资讯动态

员工离职预测实战:数据清洗、特征工程与LR/XGBoost调参全解析

发布时间:2026/10/22 10:07:17 来源:尧图企业网站定制
简介面向数据挖掘与机器学习初学者的员工离职预测训练赛资源包含完整赛题数据与可运行代码旨在帮助读者练习二分类建模、特征编码与模型调优流程。压缩包共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 关联 testtrain 和 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_indexTrue) 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.893.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_unknownignore, sparse_outputFalse) 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, columnsohe.get_feature_names_out(cat_cols)) X_test_all pd.DataFrame(X_cat_test, columnsohe.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(C0.1, max_iter500, solverliblinear) model.fit(X_train_all, y)handle_unknownignore 是必写的作用是测试集里出现训练集没见过的类别时对应编码列全部置 0 而不是报错。sparse_outputFalse 把稀疏矩阵转成稠密 DataFrame方便和数值列拼接。num_cols 用 .values 再赋值是为了避免索引错位——pandas 在拼接不同来源的 DataFrame 时索引不齐会引入 NaN这行代码翻车率极高本地验证没报错但提交分数不对多半就是这个位置出了问题。正则强度 C0.1 的设定是有讲究的。OneHot 之后特征维度膨胀到几十维必须加正则化。C 是正则化强度的倒数C 越小正则越强默认 C1.0 在特征维度升高后明显不够。solver 这里用 liblinear它适合中小规模数据且收敛快如果特征维度再大一两个量级可以换 lbfgs。训练部分用 StratifiedKFold 做五折分层验证保证每一折里离职和未离职样本比例接近原始分布。离职预测的正负样本比例通常在 1:4 到 1:9 之间简单的 train_test_split 有概率把少数类全部抽进某一折分层抽样能从机制上避免这个问题。skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) lr LogisticRegression(C0.1, max_iter500, solverliblinear) 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(ffold {fold} auc: {val_auc:.5f}) print(fmean 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, indexFalse)这个流程下来的分数在 0.89 附近。它的意义在于告诉你仅靠原始字段的 OneHot模型已经能区分绝大多数离职员工。后续的提分空间全部来自特征工程而不是换更复杂的模型。也有人会给 LogisticRegression 传 class_weightbalanced 让它自动调整类别权重这在部分数据集上能涨点但要注意加了类别权重后输出的概率不再是自然概率会影响后面做融合时的权重设定。3.3 LR 的边界在哪里0.89 是 LR 在基础特征上的上限附近但不是它能力的天花板。LR 是线性模型只能捕捉特征和标签之间的线性关系。满意度低、工作年限短的人离职概率高这类单特征关系 LR 学得很好但“满意度低且考核成绩高”这类交叉信号LR 必须依赖手工构造的组合特征才能表达XGB 却能自动学出这些交互。正因如此LR 在特征工程阶段最好的用法是当“探针”。每构造一个新特征先用 LR 跑一遍验证集 AUC涨了保留不涨就扔掉。XGB 调一轮参数的时间够 LR 跑几十轮特征实验这个迭代速度差异在调模型时体会会更深。4. 特征组合与 XGBoost从 0.89 到 0.908574.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, onemployee_id, howleft) print(train.shape)groupby 之后用 agg 一次算出多个统计量是处理明细表的标准姿势。用 mean/max/min/count 而不是只取最新一条是因为绩效的横向统计比单点值更稳定而且能保留“这员工被考核过多少次”这个信息。pfm_record_count 本身就是一个有价值的特征入职时间短的员工考核次数少离职倾向往往更高。merge 时必须用 howleft以主表为准。如果用默认的 inner joinpfm 表里没有记录的员工会被整个删掉训练样本直接少一批。更要命的是离职员工里没有绩效记录的比例可能更高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, labely_train) dvalid xgb.DMatrix(X_va, labely_va) bst xgb.train( params, dtrain, num_boost_round1000, evals[(dtrain, train), (dvalid, valid)], early_stopping_rounds50, verbose_eval50 )max_depth 设 4 而不是默认的 6是防过拟合考虑。这个练习赛的样本量在万级树太深会很快记住训练集的噪声验证集 AUC 在某个轮次后会掉头向下。learning_rate0.03 配 num_boost_round1000 是“小步慢跑”标准配置每棵树贡献小一点用轮数换取精度。early_stopping_rounds50 兜住过拟合验证集 AUC 连续 50 轮没提升就停xgb.train 会自动保留历史最优轮次的模型。colsample_bytree0.8 让每棵树只随机采样 80% 的特征列降低树与树之间的相关性。min_child_weight5 控制叶子节点最小样本权重和值越大模型越保守。这几个参数组合在实际运行中基本不需要再大动可以在 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_unknownignore 能兜住未知值但兜不住高基数带来的维度膨胀。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_oof6.3 这会变成一种肌肉记忆从复现这份资源到跑出 0.90857我最大的收获不是那几个模型参数而是整套流程的固定动作merge 后校验行数、交叉验证内部做预处理、提交前核对 ID 顺序、每组特征先回测再保留。从那以后我每次拿到新的表格型练习赛资源都会强制走一遍这套流程再开始建模。先跑通 LR 基线再做特征工程最后上 XGB每个阶段有明确的验证指标兜底模型结果再离谱也能快速定位是数据问题还是模型问题。这套习惯帮我把很多项目的调试时间压到了原来的三分之一希望也能帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑