☰
薪酬合理性分析实战:用多元线性回归诊断倒挂与调薪
2026/10/12 1:30:10 网站建设 项目流程

简介:一份基于多元线性回归的高校教师薪酬合理性分析文档,源自第六届大学生数学建模竞赛优秀作品,适合数模参赛者、统计学初学者及人事管理研究者参考。文档完整记录了从问题重述、模型假设、虚拟变量引入到逐步回归、单/双因素方差分析的建模全过程,包含SPSS输出表格的解读、交互变量建模思路及绩效工资分配方案设计,能够帮助读者理解如何处理定性变量并构建回归模型。资源为单份doc文档,约469KB,内容结构清晰,涵盖摘要、模型建立与求解、误差分析等完整章节,可直接用于论文写作思路借鉴。该资料已有155人学习下载,可作为数学建模竞赛中回归类题目的范例,也可用于薪酬公平性量化分析的入门参考。

1. 为什么薪酬合理性分析值得用多元线性回归:一个离职率 30% 的场景

去年我接过一次让所有人头疼的薪酬盘点:某技术团队一年内离职 30%,走的还大多是司龄三年上下、绩效拿 A 的那批人。拉出薪酬表一对比,他们到手月薪比当年校招新人还低,倒挂已经摆到了台面上。问题是光喊“不公平”没用,预算委员会要的是能说清楚“每个人工资为什么是这个数、差在哪、该补多少”的量化依据。多元线性回归就是把这件事工程化的标准工具:把年总现金当作因变量,把职级、司龄、绩效、学历、城市系数当作自变量,拟合出一条这家公司当下真实执行的薪酬基准线。它不负责预测跳槽概率,但在这个场景里它的产出足够实用——系数解释薪酬差异的来源,残差定位倒挂和偏高人群,最终形成一份能指导调薪和预算的回归分析报告。这篇按数据准备、建模、检验、避坑、落地五步展开,适合企业里做薪酬分析、人力数据分析或 HR 数字化的人照着重跑一遍。

2. 薪酬回归的数据准备:变量怎么选、脏数据怎么洗、哑变量怎么设

2.1 样本量与目标变量:用“年总现金 / 12”当因变量

做薪酬回归第一件事是定因变量,千万别直接用“月基本工资”。月基本工资不含绩效奖金、项目奖和加班费,而这三块恰恰是新老员工差距最大的地方。我一般用“年总现金 / 12”:年总现金等于 12 个月基本工资加实发年终奖、半年奖、绩效奖金、项目奖金以及各类固定补贴,直接从 HR 系统取实发数据,不要用理论上限或预算额度。

原始薪酬数据高度右偏,这是必须取对数的最直接原因。核心管理层加少数资深专家,年总现金可能到一百多万,而普通员工集中在二十到四十万。直接拿原始值跑 OLS,那三五个高杠杆点会把整体系数带偏,职级系数、司龄系数全部失真。取自然对数之后残差接近正态,回归系数也能解释成“自变量每变化一个单位,薪酬近似变化百分之几”。这正是薪酬分析最想要的语言:司龄多一年涨 3% 还是 5%,职级跨一级溢价 30%,HR 和预算部门都能直接听懂,不需要任何统计背景。

样本量方面,我的经验阈值是每个自变量至少对应 20 个观测。薪酬回归能接受的变量个数上限大约在 8 到 10 个,也就是 6 个连续变量加 4 组哑变量,模型对样本量的下限大约在 150 到 200 人。低于 150 人的公司回归也能跑,但置信区间会很宽,我会把分析重心从“系数推断”切换到“残差排名”,先锁定异常名单再说。另外要提醒一句:样本里不要混入试用期员工,试用期工资通常打八折,混进去会生成一条虚假的“低司龄低薪酬”曲线,把司龄系数整体拉低。

数据清洗是这道工序里最容易翻车的地方,我习惯先按这张表过一遍:

字段取什么值清洗要点
工号唯一标识剔除离职、实习生、劳务外包
职级内部职级数字统一口径;无职级的公司用岗位序列加管理幅度
司龄入职月数 / 12按司龄而非工龄;海外经历多的公司可考虑工龄
绩效近 12 个月等级 S/A/B/C缺绩效先按 B 填充并打标记
学历大专/本科/硕士/博士在职学历单独标记,不与全日制混用
城市一线/新一线/二三线按办公地,不用户籍地
年总现金实发合计剔除一次性签字费、留任奖等偶然项

这份清单能兜住九成常见脏数据。两个最容易忽视的细节:一是离职员工数据要单独留一份,别直接删掉,离职员工的薪酬平均水平往往是倒挂问题的最早证据;二是分析口径里明确“本期内实发”还是“本期应发”,我见过团队因为用应发数、把补发历史工资一起算进当年现金,导致一批老员工残差被严重低估。

2.2 核心自变量:从薪酬制度里筛出五类变量

自变量选型的原则一句话:选“薪酬制度里应该决定工资的变量”,不选“结果变量或行为变量”。大多数公司的薪酬制度明确写着职级、岗位序列、司龄或任职年限、绩效、学历是调薪依据,那就以这五类为自变量主体。城市系数在跨地域团队里也常见,同职级在不同城市设了不同带宽,应该进模型。管理岗与非管理岗的差距用“是否管理岗”哑变量吸收。

我把常用的变量形态整理成表,方便直接对照取数:

自变量类型进入模型的形式预期业务含义
职级有序连续性数字,先验证近似线性每升一级薪酬上升一个百分比
司龄连续原值或 ln(司龄+1)司龄每增加一年薪酬微增
绩效有序数字转换 S=4/A=3/B=2/C=1绩效每高一档薪酬有一定溢价
学历无序分类哑变量,专科/本科为基准组硕士、博士相对本科的溢价
城市无序分类哑变量,分一线/新一线/二线不同城市的薪酬差异
是否管理岗二值0/1管理岗相对非管理岗的溢价

特别提醒一个我踩过的坑:不要把“当前薪级”或者“当前带宽位置”放进去。我见过有同事把这个变量加进模型,R 方直接飙升到 0.98,看起来拟合得完美,但这是循环论证——你的薪酬制度本身定义了薪级,回归只是把制度又念了一遍,对“合理性”没有任何解释增量。合理性分析要回答的是抛开既定体系不谈,结构性因素能不能解释薪酬差异,以及差异主要来自哪里。

职级这个变量到底当连续数字还是哑变量,我的习惯是先看样本分布。职级有十级以上且每级都有人,就先跑一个把职级转成哑变量的版本,看相邻级别系数是否近似等距递增。如果间隔均匀、规律明显,再当连续数字处理以节省自由度;如果间隔忽大忽小,保留哑变量,因为“从 P6 到 P7”和“从 P7 到 P8”的薪酬跳跃本来就不一样。

2.3 分类变量转哑变量:基准组决定截距的含义

学历、城市这种无序分类变量不能直接放进 OLS,必须转成哑变量。代码很简单,但基准组的选择会影响截距和所有哑变量系数的解读,这里容易被忽略。先给一段标准的 pandas 预处理:

import numpy as np import pandas as pd df = pd.read_csv("salary_raw.csv", encoding="utf-8-sig") # 只保留正式员工,剔除入职不足一年的保护期样本 df = df[df["emp_status"] == "正式"].copy() df = df[df["work_months"] >= 12].copy() # 因变量:年总现金取自然对数 df["ln_cash"] = np.log(df["annual_cash_total"]) # 明确分类变量 for col in ["edu", "city_grp", "is_mgr"]: df[col] = df[col].astype("category") # 生成哑变量,drop_first 表示把第一类视为基准组 df = pd.get_dummies( df, columns=["city_grp", "edu"], drop_first=True, prefix={"city_grp": "cg", "edu": "edu"}, ) # 建模用列 x_cols = [ "level_no", "seniority", "perf_score", "is_mgr", "cg_新一线", "cg_一线", "edu_硕士", "edu_博士", ] # 若分类顺序不对,先重排再生成哑变量 # df["city_grp"] = df["city_grp"].cat.reorder_categories( # ["二线", "新一线", "一线"], ordered=True # ) df = df[x_cols + ["ln_cash", "emp_id"]] print(df.head())

这段代码做了三件事:按口径过滤样本、构造对数因变量、把城市和学历两个分类变量转成哑变量。drop_first=True表示把分类顺序排在最前的那组作为基准,其他分组各生成一列 0/1;截距的含义因此被定义为“基准组成员、连续变量全为 0”的理论薪酬对数。prefix参数控制生成列的前缀,方便后面识别系数,比如cg_一线和edu_硕士。

基准组的选择有实际讲究:把人数最多或者能代表绝大多数员工的类别放到 category 的第一位,截距才落在一个有业务意义的组上。如果默认排序把“博士”放在学历第一位,那截距就变成“博士且基准城市”的理论薪酬,后面所有学历系数都会匪夷所思。这里还隐含另一个注意点:哑变量生成前要先检查分类是否有未预期的空值或“未知”类别,空值不会报错,但会被当作一类单独生成列,分析结果里会多出一个无法解释的系数。

3. 跑通多元线性回归模型:最小二乘拟合、系数解读与检验三步

3.1 用 statsmodels 完成 OLS 拟合的最小代码

数据准备好了就进入建模。我习惯用 statsmodels 的 OLS,因为它的 summary 自带 t 检验、F 检验和 R 方,比手动写公式一家一家算稳定,也方便把结果表直接贴进分析报告。核心代码只有十几行:

import statsmodels.api as sm # X 去掉因变量与标识列,手动加上截距 y = df["ln_cash"] X = df[x_cols].astype(float) X = sm.add_constant(X) # 普通最小二乘拟合 ols_model = sm.OLS(y, X).fit() print(ols_model.summary()) # 导出系数、P 值与置信区间,便于后续整理 coef_out = pd.DataFrame({ "coef": ols_model.params, "pvalue": ols_model.pvalues, "ci_low": ols_model.conf_int()[0], "ci_high": ols_model.conf_int()[1], }) coef_out.to_csv("ols_coef.csv", encoding="utf-8-sig")

这里最关键的一步是sm.add_constant(X),statsmodels 不会自动加截距列,忘了这一步所有系数都会被强制穿过原点,薪酬数据这种截距明显不为零的场景会直接得出荒谬结果。第 7 行的fit()默认用普通最小二乘,不处理异方差,而薪酬回归里异方差几乎必然存在——高薪人群的残差方差明显更大。第一步诊断阶段先不过度处理,等看到结果再决定是否需要稳健标准误或加权最小二乘。

跑完先别急着解读单个系数,先看整体摘要里的三项:F 检验 p 值、R 方、样本量。F 检验 p 值大于 0.05 说明模型整体是噪声,这时候检查 X 的列是否有多重共线性或数据没对齐;R 方用于判断模型解释力,跨公司的薪酬回归能到 0.7 到 0.8 已经算很好,低于 0.5 说明还有结构性变量没抓到,可能是岗位技能等级、稀缺性认证或者外部市场标签。把这两项确认了,再进入逐系数解读。

3.2 回归系数怎么读:一个系数就是一段“薪酬溢价”的估值

因为因变量是 ln(年总现金),OLS 系数乘以 100 就近似等于该变量每变化一个单位的薪酬溢价百分比。注意是近似,更精确的写法是exp(系数)-1,两者差异在系数小于 0.3 时很小,但报告里我一般用精确值,避免较真的财务同事拿计算器当场验证出偏差。

我拿一个实际跑过的结果举例:职级level_no的系数是 0.29,p 值小于 0.001,含义是职级每升一级,年总现金平均上升约 33.6%(exp(0.29)-1)。这个数字对扁平化技术团队偏高,对传统等级制公司则合理。如果系数远低于市场常见的职级薪酬跳幅,说明这家公司的晋升涨薪不足,晋升本身没有带来足够的薪酬兑现。

司龄seniority系数 0.03,p 值小于 0.01,含义是司龄每多一年,年总现金上升约 3.0%。这个系数同时是“忠诚度溢价”的量化。如果市场上跳槽涨薪普遍有 10% 到 15%,公司内部司龄溢价只有 3%,那五年以上老员工的相对购买力实际被稀释,倒挂的根源在这个系数上就已经暴露了。

绩效perf_score系数 0.12,p 值小于 0.01,表示绩效每提升一档,薪酬上升约 12.7%。用 0/1/2/3 数字编码时,模型默认绩效各档是等距影响,这通常不符合实际——S 到 A 的薪酬差距往往比 A 到 B 大得多。如果你看到绩效系数不显著,别急着下结论说绩效不影响工资,很可能是在职级被控制后绩效的边际效应确实不大,也可能是绩效数据只有当年等级、没有回溯历史等级。城市哑变量cg_一线系数 0.21 表示一线城市相对基准城市溢价约 23%,这个数可以拿出来和公司各城市带宽设置对照,偏差过大说明带宽基准需要修正。

把所有系数连同置信区间整理成一张表,配合一句业务解释,这就是回归模型最核心的交付物。置信区间别丢,它比 p 值更能说明问题——区间太宽的系数,说明该变量在样本里的信息量不足,报告里应明确写“该系数方向可信、幅度不可信”。

3.3 显著性、R 方与 F 检验:判断模型能不能用于调薪决策

模型能不能用于决策,我按四个顺序看。第一看 F 检验 p 值,小于 0.05 说明这些自变量和薪酬之间至少存在线性关系,模型不是纯噪声。第二看 R 方,判断解释力度。用不同子公司、不同序列混跑的薪酬回归 R 方一般在 0.6 到 0.8,如果低于 0.5 说明大量薪酬差异来自模型外因素,可以补充岗位技能等级或市场稀缺性标签,也可以接受模型仅用于异常值排名。第三看单变量 p 值,但这里要克制:薪酬回归的目的是解释和定位,不是做特征筛选,单变量不显著时先看置信区间宽度,区间在业务上不可容忍再讨论要不要删变量。

第四也是我最看重的一点:系数符号是否符合制度直觉。制度里写着高绩效该拿更多,结果perf_score系数为负,那基本不是模型问题,是数据时间口径没对齐——绩效等级是 2024 年的,薪酬是 2023 年的,或者说绩效等级对应的是发薪周期而非评估周期。这种问题在回归摘要里不会报错,只有对照业务知识才能发现。另外一个常见陷阱是样本里混入不同序列的人,技术序列和管理序列的薪酬带宽差异很大,混在一起拟合会让职级系数被高序列样本主导,我的做法是先按序列分组跑模型,再对比组间系数,而不是一上来就塞进同一个 OLS。

4. 薪酬回归避坑实录:倒挂、共线性与样本量不足的三个重灾区

4.1 现象:回归跑出的“倒挂名单”,拿到业务里谁也核不出来

我第一次做薪酬合理性分析时,交付的倒挂名单是用“负残差最大的前 10%”圈出来的,HR 拿回去逐个人工核对,回来说这些人里好几个是去年高薪挖来的,根本不存在倒挂。当时我一度怀疑模型是不是有问题,后来才想明白:残差负得大只表示“按公司现行结构性因素的预测,这个人拿低了”,并不等于“他低于市场水平”。

被高薪挖来的员工,其月薪基数里已经含有外部市场溢价,而我的模型里没有吸收这部分信息的变量。他们通常还拿着签字费、留任奖或股票,一次性项目在清洗时被我剔除了,剔除后基本盘看起来就更“偏低”。解决方法是分人群建模。把“社招引入且司龄不足 2 年”的人打一个标记ex_hire_flag放进模型,这个系数会吸收外部市场价格的影响,其他系数不再被这批人污染。最终倒挂名单的筛选条件加严为“负残差大”且“司龄≥2 年”且“绩效 B 以上”。名单立即从“人人可疑”变成“个个可核”。

这条血泪经验的核心在于:回归残差是“内部不公平”的代理指标,不是“外部不公平”的代理指标。要同时回答内部合理性和外部竞争力两个问题,就要在变量上同时放入内部结构变量和外部市场变量,否则残差会被系统性的市场溢价占据。

4.2 现象:司龄系数不显著、职级系数突然变大——多重共线性

有次跑完模型,司龄系数的 p 值是 0.4,职级系数从之前版本的 0.3 变成 0.45,业务上完全不合理。老员工职级普遍高,司龄与职级高度相关,OLS 会把共同解释力尽量分给其中一个变量,司龄的贡献被职级吃掉,表现为不显著、系数被压低;职级则显得格外强势。

处理时我一般先算方差膨胀因子(VIF),代码很短:

from statsmodels.stats.outliers_influence import variance_inflation_factor # X 里不要包含 constant 列 Xv = X.drop(columns=["const"]) vif = pd.DataFrame({ "var": Xv.columns, "vif": [ variance_inflation_factor(Xv.values, i) for i in range(Xv.shape[1]) ], }) print(vif.sort_values("vif", ascending=False))

VIF 超过 5 我开始警惕,超过 10 基本确认共线性。但处理方式不是机械地删变量,而是要回到业务问题:这次分析想解释的是什么?如果重点看职级溢价,就把司龄替换成“当前职级内任职年限”,这个变量与职级的纠缠小得多,业务含义也直接对应“任职多久该升下一级”。如果重点看司龄忠诚度溢价,就把职级替换成“岗位序列内职级段”,粗分三档,减少与司龄的冗余。

另一种常见共线性来源于哑变量与截距:分类变量类别过多导致“虚拟变量陷阱”,也就是类别全生成后与截距完全共线。只要用了drop_first=True就不会触发,但手动用pd.get_dummies时如果忘了加这个参数,OLS 会自动丢弃一列,往往丢的是业务上很重要的一类,等你解读系数时才发现基准组被悄悄换掉了。

4.3 现象:R 方很高但调薪建议完全没法落地——连续模型与薪酬带宽的冲突

有一版模型拟合非常好,R 方 0.85,系数全部显著,看起来可以直接用于调薪。落地时发现预算完全不够:模型建议给某团队 20 人加薪 15% 以上,而年度调薪预算只够全员覆盖 3%,最终建议被预算委员会直接打回。

问题出在“合理性”和“可行性”的错位。回归模型给的是理想校准值,而企业实际用的是职级带宽,带宽通常只有 ±20% 到 30%,年度调薪还受总额约束。把回归建议的连续金额直接当调薪指令,必然跟预算打架。我的落地做法是将模型输出从“精确金额”降级为“优先级排序”,核心放在识别超出带宽的人:用职级带宽上下限生成一个带内合理区间,把回归预测值和实际薪酬都映射进去,实际值低于带宽下限且残差为负的人进偏低名单,实际值高于带宽上限且残差为正的人进偏高名单。这样既不和既有职级体系冲突,又给出了优先级依据。

这里还牵扯出一个治理问题:调薪建议应该给出“范围”而不是“点值”。你在报告里写“A 应涨 12%”会被当成承诺,写“A 在 8% 到 14% 区间、建议按绩效取中上沿”就给薪酬委员会留了决策空间。多元线性回归输出的置信区间天然适合干这个,把回归预测值的置信区间和带宽上下限比较,比用单一残差值更抗争论。

4.4 现象:整体拟合很好,但换几个样本结果就翻车——极端值与样本敏感

还有一次模型整体指标全部正常,R 方、F 检验、单变量显著性都好看,但做敏感性测试时抽掉薪酬最高的 10 个人,职级系数从 0.26 跳到 0.17,司龄系数直接反向。原因是高薪人群数量很少,在 OLS 里占据的杠杆却极大,少数几个人的数据就能拉动整条回归线,而这几个人的薪酬又往往由特殊历史背景决定,不能代表结构性因素。

解决分三步。第一步画残差与拟合值图,看是否存在“喇叭口”形状,即拟合值越大残差越分散,这是异方差和极端值的共同信号。第二步算 Cook's distance,把距离最大的前 2% 样本打出来逐个核对:数据录入错误的删除,真实高潜但薪酬特殊的单列一个high_potential_flag变量吸收影响。第三步用留一法交叉验证,每次去掉一个样本重新拟合,观察各变量回归系数的波动范围,波动超过 20% 的变量在报告里明确标注“该变量结论不稳定”。

这三步做完,模型得出的结论才敢写进正式薪酬报告。薪酬分析是要给人调薪的,一个极端样本的拉动效应可能让某个职级的人集体被误判为倒挂或偏高,这种错误比模型拟合不足更难察觉,因为它披着“高 R 方”的外衣。所以我在每个模型结果页顶部都会固定放一行样本量与极端值剔除数,提醒自己也提醒读者,这份分析建立在多少人、哪些人之上。

5. 从回归系数到调薪动作:残差名单怎么圈、预算怎么卡、模板怎么落

5.1 负残差前 5% 名单要配合两条业务过滤

模型调通后,最终交付物实际是两张表。第一张是“负残差前 5% 名单”,但它只能作为候选池,不能直接当调薪名单。我会在这个池子上叠加两条业务过滤:司龄不低于两年,绩效不低于 B。前一条过滤掉保护期和市场溢价吸收期的新人,后一条确保调薪资源优先给被低估且干得好的人。两者同时满足的人,才是真正值得逐个复核的倒挂候选。每季度我会用最新数据重跑一遍模型,把上一季度的名单和本季度实际离职名单做对比,重合度能稳定在 60% 以上时,这套模型就算有了预测动作上的验证。

5.2 预算怎么卡:系数校准加总额的双层控制

第二张表是“系数校准表”:把本年度回归系数与上年度的放在一起,观察司龄溢价、绩效溢价、职级溢价的变化方向。系数逐年下降的项就是薪酬竞争力流失最快的项。调薪预算按两层卡:一层是个体不能突破带宽上下限,另一层是全员调增总额不能超过年度预算的 3% 到 5%。回归模型负责把有限预算分配到残差负向最大、绩效最好的人身上,而不是平均分给所有人。这个过程我会固化成一套模板,每年盘点时直接换最新数据跑一遍,输出三样东西:系数表、候选名单、带宽对比表。做这个方向值不值得投入,我的判断是五十人以上、有职级体系的公司完全值得,它把薪酬从“感觉不公平”变成了“可计算、可追溯、可复核”的结论。

5.3 一个让我长记性的习惯

最后说一个自己踩出来的习惯:任何回归结果在发出去之前,先把原始数据里的极端样本单独抽出来人工看一眼。有一版模型我只看统计指标,没注意到薪酬最高的三个人里有两个是事业部负责人特批薪酬,结果模型整体被他们带偏,好在发出去之前组长指着残差图问了一句“这三个点是谁”,才避免了整个盘点结论翻车。从那以后,我的回归结果页第一行永远是样本量、极端值剔除数、保留的标记变量数这三项。数据里的特殊背景永远比统计模型更懂业务,模型只是把大部分规律找出来,剩下那些没解释的残差,才是值得一个人一个人去聊的部分。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询