☰
员工离职预测实战:数据清洗、特征工程与LR/XGBoost调参全解析
2026/10/22 10:07:11 网站建设 项目流程

简介:面向数据挖掘与机器学习初学者的员工离职预测训练赛资源,包含完整赛题数据与可运行代码,旨在帮助读者练习二分类建模、特征编码与模型调优流程。压缩包共8个文件,由4个Python脚本和4个CSV数据集组成,包体仅83KB;脚本覆盖逻辑回归、XGBoost、特征合并与字符串处理等环节,数据文件提供训练集、测试集及衍生特征表,结构紧凑,便于直接复现和扩展实验。目前已有191人学习浏览。作者基于参赛实测整理,采用OneHot编码配合逻辑回归即可达到0.89+的基准得分,进一步组合基础特征后刷新至0.90857的当前最优成绩,并附有相应实现脚本,对希望快速上手员工流失预测或参加同类练习赛的读者具有直接参考价值。

1. 员工离职预测:一个练手资源,却把表格型模型的底子摸了个遍

员工离职预测是 HR 数据分析里最常拿来练手的一类任务,训练集、测试集、绩效明细表全部备齐,评测指标固定,非常适合检验特征工程和模型调参的基本功。这份资源给出的完整路径是:先合并绩效表,再对基础特征做 OneHot,用 LR 跑到 0.89+,靠简单组合特征和 XGB 逼近 0.90857。很多人看到“员工离职预测”第一反应是找预训练模型,但这个练习赛资源恰恰证明表格型任务里传统模型依然能打。适合刚入门分类任务的从业者完整复现一遍,也适合老手看看 0.89 到 0.91 之间到底差在哪。

2. 数据摸底:train.csv 与 pfm_train.csv 的结构和坑

2.1 主表和明细表:为什么训练数据要拆成两份

把压缩包解压后,文件结构比较清晰:四个 CSV 和四个脚本。train.csv 是主表,一行一个员工,包含员工 ID、部门、薪资等级、工作年限、满意度评分、最近一次考核评分、项目数量、平均每月工时等字段,最后一列 left 是离职标签(1 离职,0 未离职)。test.csv 的结构和 train.csv 一致,只是没有 left 列。pfm_train.csv 和 pfm_test.csv 是绩效明细表,按考核记录一条一行,员工 ID 会重复出现。

为什么要拆成两份而不是直接合并成一张大宽表?因为绩效明细和员工主表是一对多关系——一个员工一年内可以有多次考核记录。如果直接合成一张表,每个员工会对应多行数据,离职标签也跟着重复出来,模型会把同一个员工的重复样本当成独立样本来学习,相当于变相提高这个员工的权重。拆成主表和明细表让参与者自己处理合并,这本身就是竞赛想考察的第一个点。

两个表的关键字段差异可以先用一张对照表固定住,后面做特征工程时反复回看:

文件数据粒度关键字段与另一文件的关联
train.csv员工级employee_id, department, salary, satisfaction_level, last_evaluation, number_project, left主表
pfm_train.csv考核记录级employee_id, 考核周期, 绩效评分, 晋升记录, 奖惩标记按 employee_id 多对一关联主表
test.csv员工级同 train 但无 left主表
pfm_test.csv考核记录级同 pfm_train按 employee_id 关联 test

train 和 test 的样本量比例一般维持在 5:1 左右,具体行数以你解压后的实际文件为准。这里要特别注意 pfm_train 的行数几乎一定不等于 train 的行数,这个信息决定了 merge 时是用聚合还是直接 join。

2.2 用 pandas 做第一轮探查

拿到数据后先做探查,不要急着写模型。我一般是把一个通用探查脚本跑一遍,一次性输出每个文件的字段类型、非空数量、唯一值数量和首条样本,这样能快速定位到空值和重复 ID 问题。

import pandas as pd train = pd.read_csv('train.csv') pfm_train = pd.read_csv('pfm_train.csv') def quick_view(df, name): info = [] for col in df.columns: info.append({ 'file': name, 'col': col, 'dtype': str(df[col].dtype), 'non_null': df[col].notna().sum(), 'unique': df[col].nunique(), 'sample': df[col].dropna().iloc[0] if df[col].notna().any() else None, }) return pd.DataFrame(info) view = pd.concat([ quick_view(train, 'train'), quick_view(pfm_train, 'pfm_train') ], ignore_index=True) print(view.to_string())

这段代码把两个表的所有列汇总成一张清单,dtype 告诉你哪些列是数值、哪些是字符串,non_null 和 unique 能帮你判断某列到底是 ID、类别还是连续值。比如 employee_id 的 unique 数应该等于 train 行数,如果明显小于行数,说明主表里有重复员工,这个必须在 merge 前处理掉。satisfaction_level 这类列 unique 值有限但又不是纯类别,就要决定是切桶还是直接当连续值进模型。

探查时重点盯三件事:空值分布、ID 列唯一性、pfm 表每个员工平均记录条数。前两件决定清洗策略,最后一件决定 merge 策略,这三件事也是后面翻车最多的地方。

数值列检查时,satisfaction_level 和 last_evaluation 通常是一个 0 到 1 之间的浮点数。如果发现取值集中出现在几个固定小数上,说明原始问卷是离散量表,这种情况下按 0.2 间隔切桶再 OneHot,往往比直接当连续值进模型效果更好。要不要切、切几桶,用 LR 的验证集回测来定,不要凭空拍脑袋。

对于缺失值,常见做法是数值列用中位数填充、类别列用众数填充,但更讲究一点的做法是保留“缺失”这个状态本身。比如 merge 之后绩效相关的列全是空值,就新增一列 is_pfm_missing,让模型自己去学“没有考核记录的人”这个群体。这类员工往往是入职时间短或者考核数据缺失,在离职预测里可能是强特征。直接用 0 填充会把缺失和真实低绩效混淆,丢掉这个信号。

2.3 code_string.py 到底干了什么

压缩包里的 code_string.py 从文件名看容易误以为是编码转换脚本,实际上在这个项目里它负责的是字符串类特征归一化。比如部门名在不同文件里写法可能不一致,有的叫 “Sales”,有的叫 “sales ”,薪资等级有 “High” 和 “high”,不统一的话 merge 时匹配会大量失败,留下一堆 NaN。

我见过不少人直接跳过这个脚本,结果 merge 之后 join 键匹配率只有 60%,一直以为自己写错了 merge 逻辑。字符串归一化必须在 merge 之前做,而且得同时作用在 train 和 test 两侧,否则训练和预测用的特征分布不一致。

# code_string.py 的典型处理思路:文本列归一化后再合并 import pandas as pd norm_rules = { 'department': { 'sales': 'sales', 'Sales': 'sales', 'sales ': 'sales', }, 'salary': { 'low': 'low', 'Low': 'low', 'medium': 'medium', 'high': 'high', } } for col, mapping in norm_rules.items(): if col in train.columns: train[col] = train[col].map(mapping).fillna(train[col]) if col in pfm_train.columns: pfm_train[col] = pfm_train[col].map(mapping).fillna(pfm_train[col])

这里的 map(mapping) 命中字典里的键后替换成统一写法,fillna(train[col]) 兜住字典里没有的原始值,防止整列被清空。参数上要注意键必须是精确匹配,Excel 导出的数据经常带不可见空格,如果发现 map 之后大量变成 NaN,先用 df[col].str.strip() 去两端空格再归一化。分词类的字符串统一转成小写再入字典,也是这个脚本里常做的事。

ID 合法性检查同样值得加进流程。train 内部 ID 重复会导致 merge 和交叉验证错乱,train 和 test 的 ID 交叉则意味着信息泄露。我一般会在 merge 前加三行断言:

assert train['employee_id'].is_unique, 'train ID 有重复' assert test['employee_id'].is_unique, 'test ID 有重复' assert not train['employee_id'].isin(test['employee_id']).any(), 'train/test ID 交叉'

这三行如果有一个触发,数据源就有问题,后续任何模型结果都不能信。数据清洗阶段的这类硬校验能省下大量排错时间,是我每次复现这类练习赛都会保留的习惯。

3. 用 LR 快速出基线:OneHot 之后直接跑也能 0.89

3.1 为什么先选 LR 而不是直接上 XGB

摘要里把答案写得很直白:基础特征 OneHot 后直接 LR 就能 0.89+。选 LR 开路的理由有两点。第一,LR 对特征质量极其敏感,做了什么特征分数立刻反映在验证集上,不会像树模型那样用复杂度掩盖特征缺陷。第二,LR 训练快、参数少,适合做特征工程的迭代探针,几秒钟出一轮结果,而 XGB 一轮就要几十秒到几分钟,调参周期完全不是一个量级。

有人会觉得这类任务怎么也得套个预训练模型才跟得上时代,但这个练习赛资源恰好给出了反例:数据量在万级、特征以表格为主时,模型复杂度不是瓶颈,特征表达才是。先用 LR 把数据链路跑通,确认合并、清洗、编码、训练、预测、提交每一步都正常,再考虑换更强的模型。

3.2 训练脚本里的关键组成

logistic_regression_model.py 的核心流程是:读取 train.csv,对类别特征 OneHot,数值特征保留原值,训练 LR,预测 test.csv 并输出提交文件。复现时要注意 OneHotEncoder 的几个关键参数:

import pandas as pd import numpy as np from sklearn.preprocessing import OneHotEncoder from sklearn.linear_model import LogisticRegression from sklearn.model_selection import StratifiedKFold from sklearn.metrics import roc_auc_score train = pd.read_csv('train.csv') test = pd.read_csv('test.csv') y = train['left'] train = train.drop(columns=['left', 'employee_id']) test = test.drop(columns=['employee_id']) cat_cols = ['department', 'salary', 'work_year_level'] num_cols = ['age', 'satisfaction_level', 'last_evaluation', 'number_project', 'average_monthly_hours'] ohe = OneHotEncoder(handle_unknown='ignore', sparse_output=False) X_cat_train = ohe.fit_transform(train[cat_cols]) X_cat_test = ohe.transform(test[cat_cols]) X_train_all = pd.DataFrame(X_cat_train, columns=ohe.get_feature_names_out(cat_cols)) X_test_all = pd.DataFrame(X_cat_test, columns=ohe.get_feature_names_out(cat_cols)) X_train_all[num_cols] = train[num_cols].values X_test_all[num_cols] = test[num_cols].values model = LogisticRegression(C=0.1, max_iter=500, solver='liblinear') model.fit(X_train_all, y)

handle_unknown='ignore' 是必写的,作用是测试集里出现训练集没见过的类别时,对应编码列全部置 0 而不是报错。sparse_output=False 把稀疏矩阵转成稠密 DataFrame,方便和数值列拼接。num_cols 用 .values 再赋值,是为了避免索引错位——pandas 在拼接不同来源的 DataFrame 时索引不齐会引入 NaN,这行代码翻车率极高,本地验证没报错但提交分数不对,多半就是这个位置出了问题。

正则强度 C=0.1 的设定是有讲究的。OneHot 之后特征维度膨胀到几十维,必须加正则化。C 是正则化强度的倒数,C 越小正则越强,默认 C=1.0 在特征维度升高后明显不够。solver 这里用 liblinear,它适合中小规模数据且收敛快;如果特征维度再大一两个量级,可以换 lbfgs。

训练部分用 StratifiedKFold 做五折分层验证,保证每一折里离职和未离职样本比例接近原始分布。离职预测的正负样本比例通常在 1:4 到 1:9 之间,简单的 train_test_split 有概率把少数类全部抽进某一折,分层抽样能从机制上避免这个问题。

skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) lr = LogisticRegression(C=0.1, max_iter=500, solver='liblinear') val_scores = [] for fold, (tr_idx, va_idx) in enumerate(skf.split(X_train_all, y)): X_tr, X_va = X_train_all.iloc[tr_idx], X_train_all.iloc[va_idx] y_tr, y_va = y.iloc[tr_idx], y.iloc[va_idx] lr.fit(X_tr, y_tr) val_auc = roc_auc_score(y_va, lr.predict_proba(X_va)[:, 1]) val_scores.append(val_auc) print(f'fold {fold} auc: {val_auc:.5f}') print(f'mean val auc: {np.mean(val_scores):.5f}')

预测时只取 predict_proba 的第二列(离职概率)。如果直接提交 predict 出的 0/1,就丢掉了概率的排序信息,而 AUC 评估的恰恰是排序质量而不是分类准确率。提交文件要保证行顺序和 test.csv 的原始顺序一致,列名和 ID 都按评测要求来,多一层保险就多一份心安。

test_prob = lr.predict_proba(X_test_all)[:, 1] sub = pd.DataFrame({ 'employee_id': pd.read_csv('test.csv')['employee_id'], 'left_prob': test_prob }) sub.to_csv('sub_lr.csv', index=False)

这个流程下来的分数在 0.89 附近。它的意义在于:告诉你仅靠原始字段的 OneHot,模型已经能区分绝大多数离职员工。后续的提分空间全部来自特征工程,而不是换更复杂的模型。也有人会给 LogisticRegression 传 class_weight='balanced' 让它自动调整类别权重,这在部分数据集上能涨点,但要注意加了类别权重后输出的概率不再是自然概率,会影响后面做融合时的权重设定。

3.3 LR 的边界在哪里

0.89 是 LR 在基础特征上的上限附近,但不是它能力的天花板。LR 是线性模型,只能捕捉特征和标签之间的线性关系。满意度低、工作年限短的人离职概率高,这类单特征关系 LR 学得很好;但“满意度低且考核成绩高”这类交叉信号,LR 必须依赖手工构造的组合特征才能表达,XGB 却能自动学出这些交互。

正因如此,LR 在特征工程阶段最好的用法是当“探针”。每构造一个新特征,先用 LR 跑一遍验证集 AUC,涨了保留,不涨就扔掉。XGB 调一轮参数的时间够 LR 跑几十轮特征实验,这个迭代速度差异在调模型时体会会更深。

4. 特征组合与 XGBoost:从 0.89 到 0.90857

4.1 merge.py:把 pfm 绩效明细合并进主表

pfm 表是绩效明细,每个员工可能有多条考核记录。merge.py 的核心任务就是把多行记录压缩成员工级的一行,再按 employee_id 合并回主表。聚合方式选择看数据语义:如果每条记录没有时间次序,直接用统计聚合最稳妥;如果带考核周期,可以考虑取最近一次或做时间加权。

常见的聚合代码是这个模式:

import pandas as pd train = pd.read_csv('train.csv') pfm = pd.read_csv('pfm_train.csv') # 先确认一对多关系 cnt = pfm.groupby('employee_id').size() print('pfm 记录数分布:') print(cnt.value_counts().head(10)) # 聚合到每个员工一行 pfm_agg = pfm.groupby('employee_id').agg({ 'performance_score': ['mean', 'max', 'min', 'count'], 'promotion_count': 'max', 'penalty_count': 'sum', }).reset_index() pfm_agg.columns = ['employee_id', 'pfm_score_mean', 'pfm_score_max', 'pfm_score_min', 'pfm_record_count', 'promotion_max', 'penalty_total'] train = train.merge(pfm_agg, on='employee_id', how='left') print(train.shape)

groupby 之后用 agg 一次算出多个统计量,是处理明细表的标准姿势。用 mean/max/min/count 而不是只取最新一条,是因为绩效的横向统计比单点值更稳定,而且能保留“这员工被考核过多少次”这个信息。pfm_record_count 本身就是一个有价值的特征:入职时间短的员工考核次数少,离职倾向往往更高。

merge 时必须用 how='left',以主表为准。如果用默认的 inner join,pfm 表里没有记录的员工会被整个删掉,训练样本直接少一批。更要命的是离职员工里没有绩效记录的比例可能更高,inner join 会引入选择偏差,让模型学到的规律整体偏移。merge 之后务必打印 shape 对比行数:如果 train.shape[0] 不等于原始行数,说明 join 键重复,需要回到聚合步骤检查。行数变化不会报错,但会让本地 AUC 异常高,影响对模型的判断。

4.2 xgboost_model.py:从 LR 切换到树模型

表格型任务里,XGB 几乎是默认的升级路径。把 merge 之后的新特征喂给 XGB,默认参数就能接近 0.90。xgboost_model.py 的核心流程与 LR 版本一致,区别在模型部分。我复现时习惯直接用 xgb.train 而不是 sklearn 包装接口,因为在 DMatrix 格式下可以同时拿到训练集和验证集的评估曲线,方便判断早停时机。

import xgboost as xgb from sklearn.model_selection import StratifiedKFold params = { 'max_depth': 4, 'learning_rate': 0.03, 'subsample': 0.8, 'colsample_bytree': 0.8, 'min_child_weight': 5, 'objective': 'binary:logistic', 'eval_metric': 'auc', 'seed': 42 } dtrain = xgb.DMatrix(X_train_all, label=y_train) dvalid = xgb.DMatrix(X_va, label=y_va) bst = xgb.train( params, dtrain, num_boost_round=1000, evals=[(dtrain, 'train'), (dvalid, 'valid')], early_stopping_rounds=50, verbose_eval=50 )

max_depth 设 4 而不是默认的 6,是防过拟合考虑。这个练习赛的样本量在万级,树太深会很快记住训练集的噪声,验证集 AUC 在某个轮次后会掉头向下。learning_rate=0.03 配 num_boost_round=1000 是“小步慢跑”标准配置:每棵树贡献小一点,用轮数换取精度。early_stopping_rounds=50 兜住过拟合:验证集 AUC 连续 50 轮没提升就停,xgb.train 会自动保留历史最优轮次的模型。

colsample_bytree=0.8 让每棵树只随机采样 80% 的特征列,降低树与树之间的相关性。min_child_weight=5 控制叶子节点最小样本权重和,值越大模型越保守。这几个参数组合在实际运行中基本不需要再大动,可以在 0.908 附近稳定收敛。

如果跑完发现验证集 AUC 明显高于线上提交分数,优先怀疑 early_stopping 没生效。比如直接用了 num_boost_round 固定值而没有配合验证集评估,模型在测试集上已经过拟合了。

4.3 “简单组合基础特征”到底组合了什么

摘要里写“简单组合了一下基础特征,得到目前最好结果”,这句话说起来轻巧,但组合的方向是有讲究的。常见组合分三类:比值、差值、乘积。平均每月工时除以项目数量,得到“每个项目投入的工时”;最近一次考核评分减去满意度评分,得到“工作表现与个人感受的落差”;工作年限和薪资等级做叉积,得到“老员工拿低薪”这类复合信号。分组统计特征也属于这一类。

# 比值 / 差值 / 交叉特征 train['workload_per_project'] = train['average_monthly_hours'] / (train['number_project'] + 1) train['satisfaction_gap'] = train['last_evaluation'] - train['satisfaction_level'] train['tenure_salary_interact'] = train['work_year_level'] * \ train['salary'].map({'low': 1, 'medium': 2, 'high': 3}) # 分组均值特征 train['dept_satisfaction_mean'] = train.groupby('department')['satisfaction_level'].transform('mean')

number_project 加 1 再除是为了防除零。tenure_salary_interact 先把薪资等级映射成有序数值再和工作年限相乘,它表达的是“工作年限长但薪资低”这类组合状态,对离职预测经常是稳定信号。dept_satisfaction_mean 这类分组统计特征,XGB 自己是学不出来的,因为它只看得到单行特征,看不到整个部门的分布。这类特征加进模型后,对线上分数的提升通常比单纯调 XGB 参数更明显。

但要强调一点:组合特征不是加得越多越好。每加一组都要在固定交叉验证下回测,只保留验证集 AUC 上涨的特征。如果一次加十来个特征,你根本分不清哪个在起作用。从 0.89 到 0.90857 的过程,本质上就是一组一组特征加进去、一遍一遍验证集回测、最后把 LR 换成 XGB 的过程。

5. 高频踩坑:提交格式、特征泄露与过拟合

这个练习赛复现起来整体不算难,但有几类问题几乎每个照着跑一遍的人都会遇到。挑四个我实际踩过的写出来,现象、原因、解决一条线说清。

5.1 本地 AUC 很高,提交分数却更低

现象:本地五折交叉验证 AUC 0.92,提交到评测页面上只有 0.85,差距大到不合理。

原因:最常见的有两种。第一种是提交文件的 ID 顺序和 test.csv 原始顺序不一致,AUC 按行逐一对应,错位一行分数就崩了。第二种是特征预处理时用了全量数据的信息,比如用整个 train 集做标准化、用全量分位数切桶,验证集不再是独立评估,本地分数虚高。

解决:提交前用 pandas 做一次顺序校验,断言 sub 的 employee_id 和 test.csv 的 employee_id 完全相等。特征预处理统一固定为标准写法:fit 只在训练折上执行,验证折和测试折只做 transform。这条规则我后来在每轮实验里都会强制执行,凡是涉及 fit 的操作全部放在交叉验证循环内部。

5.2 merge 之后行数翻倍

现象:merge.py 跑完,train.shape 从 15000 行变成了 30000 多行,脚本没有报任何错误。

原因:pfm 表和主表是一对多关系,一个员工在绩效明细表里有不止一条记录,直接 merge 做了笛卡尔展开。更迷惑的是本地 AUC 反而异常升高——重复样本相当于给这个员工加了权重,模型把它学进去了,但测试集是单行预测,线上分数自然回落。

解决:merge 前先 groupby 统计每个员工的记录数分布,确认是否存在多行;存在就聚合到一行再 join。我现在都会在 merge 之后加一行硬校验,train.shape[0] 必须等于原始行数,否则立即中断。这个习惯帮我拦下了很多次无声的数据错误。

5.3 OneHot 编码列数爆炸

现象:OneHot 之后特征列从十几列变成三百多列,训练时间变长,验证集 AUC 反而下降。

原因:类别列里有一个高基数列,部门或者岗位这类字段有几十上百个不同取值,OneHot 会为每个取值生成一列。LR 在高维稀疏输入下容易对低频类别的权重学偏,XGB 的分裂收益判断也会被高基数列干扰。

解决:对高基数类别列做频数合并,出现次数低于 50 的取值统一归到 other 类,再进 OneHot。频数阈值必须基于训练集统计,测试集用同一套映射,否则测试集的冷门类别会因为找不到映射而丢失。OneHotEncoder 的 handle_unknown='ignore' 能兜住未知值,但兜不住高基数带来的维度膨胀。

5.4 测试集和训练集分布不一致

现象:数据探查时发现 test.csv 里某个部门的员工占比与 train.csv 差异很大,提交后分数稳定性明显变差。

原因:训练集和测试集往往来自不同时间段或不同抽样方式。员工离职预测场景里,测试集可能比训练集晚几个月,部门结构调整、入职节奏变化都会造成分布偏移。模型在训练集上学会的规律到测试集上部分失效了。

解决:先逐列对比两个数据集的均值、缺失率、类别分布,差异明显的特征要谨慎使用。一个可用思路是直接去掉分布差异最大的列,或者把高偏移类别单独做标签编码而不是 OneHot,降低模型对不稳定分布的依赖。这类问题没有统一参数解,靠的是每次实验前先做分布对比的习惯。

6. 继续往上走:从 0.90857 再榨出半个点的三个方向

6.1 时间型特征的再挖掘

如果 pfm 表里带考核周期或日期字段,可以进一步构造时间类特征:最近一次考核距统计截止日期的天数、最近两次考核的间隔、考核评分的环比变化。离职预测里时间间隔是强信号,很久没被考核的员工和刚被考核过的员工,离职倾向完全不同。用 pandas 的 to_datetime 转成时间戳后做差,就能得到标准的天数特征。

6.2 模型融合:LR 概率和 XGB 概率的加权平均

LR 擅长捕捉线性信号,XGB 擅长捕捉非线性交互,两者错误重叠度相对低。常见做法是把 LR 和 XGB 在验证集上的 OOF 预测做网格搜索,找到最优加权系数,一般落在 0.3 到 0.5 之间。如果嫌网格搜索麻烦,对两份概率分别做 rank 归一化再取平均,对 AUC 往往也很稳。

final_prob = 0.4 * lr_oof + 0.6 * xgb_oof

6.3 这会变成一种肌肉记忆

从复现这份资源到跑出 0.90857,我最大的收获不是那几个模型参数,而是整套流程的固定动作:merge 后校验行数、交叉验证内部做预处理、提交前核对 ID 顺序、每组特征先回测再保留。从那以后,我每次拿到新的表格型练习赛资源,都会强制走一遍这套流程再开始建模。先跑通 LR 基线,再做特征工程,最后上 XGB,每个阶段有明确的验证指标兜底,模型结果再离谱也能快速定位是数据问题还是模型问题。这套习惯帮我把很多项目的调试时间压到了原来的三分之一,希望也能帮到你。

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

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

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

立即咨询