ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

员工离职预测实战:数据体检、特征工程与调参避坑指南

员工离职预测实战:数据体检、特征工程与调参避坑指南 简介面向机器学习初学者与数据竞赛爱好者这份赛题资源围绕“员工离职预测”练习赛提供一套可直接运行的基线方案和配套数据。压缩包共8个文件含4个Python脚本与4个CSV文件整体仅83KB轻量易下载。脚本涵盖逻辑回归、XGBoost建模以及特征合并与字符串处理CSV文件包含训练集、测试集和预处理后的特征表便于对照脚本理解数据清洗与特征工程流程。作者给出了当前最好成绩0.90857的实现思路先将基础特征逐一做OneHot编码再用逻辑回归跑出约0.89的分数随后简单组合基础特征获得进一步提升。对想快速搭建分类预测基线、熟悉LR与XGBoost代码套路的读者这套代码提供了清晰可复用的参考骨架。目前已有191人学习尤其适合希望低成本上手员工离职预测任务、通过实际代码理解分类模型训练流程的初学者。1. 员工离职预测训练赛.zip这个压缩包到底在考什么做 HR 数据分析的人多半接过这类需求HR 丢过来一个 zip 压缩包里面有员工编号、部门、薪资、司龄、加班标记这些字段让你预测谁会在未来一段时间离职。员工离职预测训练赛.zip 就是把这个需求变成标准竞赛题的典型交付物解压后通常是一份带离职标签的训练集和一份只给特征不带标签的预测集。第一个反直觉的结论是这类题的难点根本不在模型而在类别不均衡——离职样本往往只有 10% 上下无脑预测“所有人都不离职”准确率也有 90%可这样的模型业务上一文不值。这篇笔记按我实际跑这类题的顺序走一遍从解压体检、特征工程到调参避坑你照着连续做三遍会对分类问题的完整链路有手感和判断力。2. 解压与数据体检建模前先看清字段与不均衡目标2.1 zip 解压与数据装载为什么解压这一步也有讲究收到员工离职预测训练赛.zip第一反应别是双击解压然后马上跑模型。先建一个独立目录验证压缩包完整性再考虑读入。这类训练赛的 zip 一般是几个 csv 文件的打包train.csv、test.csv、sample_submission.csv 都在里面有时还附带一份字段说明文档。压缩包在网络传输中断过、下载工具省了校验时解压到一半报 crc mismatch 的情况很常见这时候花十分钟重下比边跑边怀疑数据要值。mkdir -p attrition cd attrition unzip -t 员工离职预测训练赛.zip unzip 员工离职预测训练赛.zip -d ./ ls -lh *.csv这段命令的逻辑是unzip -t只做完整性测试、不解压测试通过再用-d指定输出目录避免把 csv 散落在当前目录。ls -lh用来核对每个文件的大小量级train 通常几十 MB、test 十几 MB 都正常如果某个 csv 只有几 KB基本可以判断是空壳或者下载损坏。csv 读入也有讲究import pandas as pd train pd.read_csv(train.csv) test pd.read_csv(test.csv) sample pd.read_csv(sample_submission.csv, nrows5) print(train:, train.shape, test:, test.shape) print(train.head(3).T)nrows5是故意只读前五行先探测列名和格式避免几十万行全量读进来才发现列对不上。中文列名或中文路径的 csv 经常踩编码坑默认 utf-8 读 gbk 会乱码我一般会指定encodinggbk试一次不行就errorsreplace。这个数据集常见字段是英文半角如果中文说明文档里说“员工编号”对应 EmployeeNumber列名对不上时先回去看说明不要急着怀疑模型。2.2 先看目标分布再看缺失离职率不均衡时准确率会骗人数据读进来第一件事看目标字段分布而不是急着画相关性热力图。目标字段通常叫 Attrition 或 leave取值 Yes/No 或 1/0。我做这类题的固定动作是先跑一组描述统计y_col Attrition print(train[y_col].value_counts(normalizeTrue)) print(train.isnull().mean().sort_values(ascendingFalse).head(10)) print(train.nunique().sort_values().head(20))value_counts(normalizeTrue)直接给出正负样本比例离职率低于 20% 就进入不均衡警戒区。isnull().mean()看每个特征的缺失比例缺失超过 50% 的列要单独决策删还是补。nunique()是找 ID 类列最快的办法EmployeeNumber、EmployeeCount、StandardHours 这类列的唯一值数量要么和行数一样多要么只有一个值它们基本是废特征。这类 HR 数据里常见字段可以按建模角色先分个类字段类型建模用法MonthlyIncome连续取对数后进入模型原始量纲太大OverTime二值转 0/1重要性通常排前三JobSatisfaction有序类别按数值处理或映射成 1-4 等级YearsAtCompany连续司龄与离职呈 U 型分箱更稳DistanceFromHome连续通勤越长离职越高和婚姻状态交互有用EmployeeNumberID直接删除存在规律性泄漏风险数值型字段要describe()看量纲MonthlyIncome 的数值是 Age 的几十倍线性模型必须做标准化树模型可以不标但 log 变换后的薪资往往比原始值更好用。类别型字段先数唯一值超过 20 个类别的得多想一步编码方案普通 one-hot 会把特征维度直接打爆。回到不均衡问题离职率只有 10% 时模型把所有人预测成“不离职”准确率也有 90%但一个正样本都没抓到。所以这类题的评估指标我从来只看 AUC 和 PR-AUC前者看排序能力后者看少数类召回代价。交叉验证也必须用分层抽取保证每一折的正负比例和总体一致不然几折的验证结果能差出 0.1。体检完把目标列 y 和特征矩阵 X 分开下一步才进入特征工程——这一步花的时间值回票价很多翻车事故其实在数据体检阶段就能提前发现。3. 特征工程与基线模型先跑通一条能提交的流水线3.1 特征工程从原生字段里拆出 6 个有效变量很多人一上来就堆几百列特征然后让模型自己选。我的习惯是先做七八列有业务依据的派生特征跑通基线再决定要不要加。员工离职预测里反复被验证有效的几个方向加班、薪资、司龄、满意度、通勤距离和它们的交叉项。import numpy as np def build_features(df): df df.copy() # 二值化加班标记线性模型只认数值 df[Overtime_Num] df[OverTime].map({Yes: 1, No: 0}) # 月薪右偏严重取对数更接近正态 df[Income_Log] np.log1p(df[MonthlyIncome]) # 司龄不是线性关系1年内和10年以上的离职动机完全不一样分箱让树模型好切 df[Tenure_Group] pd.cut(df[YearsAtCompany], bins[-1, 1, 3, 5, 10, 99], labels[0, 1, 2, 3, 4]).astype(int) # 四个满意度子项合成总满意度 df[Sat_Total] df[[EnvironmentSatisfaction, JobSatisfaction, WorkLifeBalance, RelationshipSatisfaction]].mean(axis1) # 交叉项低职级且加班的员工离职风险可能更高 df[OverTime_Junior] (df[Overtime_Num] * (df[JobLevel] 2)).astype(int) # 交叉项已婚且通勤远家庭因素会被放大 df[MarriedDistance] (df[MaritalStatus] Married).astype(int) * np.log1p(df[DistanceFromHome]) return df几个值得说的点Income_Log用np.log1p而不是np.log是为了避免月薪为 0 时产生 NaN虽然 HR 数据里月薪很少为 0但这个习惯能省去一次排错。pd.cut的 bins 是左开右闭把下界设成 -1 是为了让 0 年司龄也能落进第一个箱子分箱边界不是拍脑袋先看司龄的直方图和离职率的折线确认存在先降后升的 U 型关系再切。交叉特征每次只加一两个加完跑一次 CV 看收益变化不要一次性塞二三十列树模型勉强扛得住线性模型立刻过拟合。3.2 基线模型逻辑回归当基线LightGBM 当主力选逻辑回归当基线理由有三个有概率输出、参数收敛快、系数可以直接讲给 HR 听。选 LightGBM 当主力是因为这个量级的数据训练快对类别特征和不均衡都有原生支持。全程用 AUC 评估因为它是阈值无关的排序指标。from sklearn.model_selection import StratifiedKFold from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.metrics import roc_auc_score import numpy as np X build_features(train) feature_cols [Overtime_Num, Income_Log, Tenure_Group, Sat_Total, OverTime_Junior, MarriedDistance, Age, DailyRate, JobLevel, StockOptionLevel] y (train[Attrition] Yes).astype(int) skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) oof np.zeros(len(train)) for tr_idx, va_idx in skf.split(X, y): scaler StandardScaler() model LogisticRegression(max_iter2000, class_weightbalanced) model.fit(scaler.fit_transform(X.iloc[tr_idx][feature_cols]), y.iloc[tr_idx]) oof[va_idx] model.predict_proba( scaler.transform(X.iloc[va_idx][feature_cols]))[:, 1] print(LR OOF AUC:, roc_auc_score(y, oof))这段代码的核心是 OOFOut-of-Fold预测每一折训练时用 4 折数据留 1 折做验证验证集的预测概率被存下来最终拼成整份训练集上无泄漏的预测。这么做比单次 train_test_split 稳定得多因为每一行样本的预测都来自没看过它的模型。StratifiedKFold保证每折正负比例和总体一致class_weightbalanced让逻辑回归自动按类别比例反向加权max_iter2000是保险标准化之后一般几百次迭代就收敛了。第一次跑通这个流程OOF AUC 落在 0.7 到 0.8 这个区间都很正常不同数据集的字段质量和噪音水平差别很大。基线不是目的它的价值是让后面每一步改进都有对照加了特征分数涨没涨换了模型分数涨没涨调了参数分数涨没涨。没有基线的调参全凭感觉最后连自己怎么赢的都说不清。3.3 交叉验证与提交本地 CV 和线上分数差在哪训练赛的提交物一般是一个 csv里面是每个测试样本的离职概率列名通常和 sample_submission.csv 保持一致。提交代码长这样sub pd.read_csv(sample_submission.csv) test_feats build_features(test)[feature_cols] scaler StandardScaler() scaler.fit(X[feature_cols]) model LogisticRegression(max_iter2000, class_weightbalanced) model.fit(scaler.transform(X[feature_cols]), y) sub[probability] model.predict_proba(scaler.transform(test_feats))[:, 1] sub.to_csv(submit_lr.csv, indexFalse)两个细节最容易出错。第一预测集的特征工程必须和训练集走同一套build_features函数少改一个条件列顺序就对不上第二scaler只能 fit 训练集再 transform 测试集如果把测试集一起 fit测试集的信息就泄漏到了训练过程本地 CV 虚高提交直接翻车。本地 CV 和线上分数对不上原因就那么几类线上数据分布和本地不一致、CV 随机切分但线上按时间切分、或者特征里混进了 ID 类泄漏列。排查顺序就是先删 ID 和常量列再确认线上切分逻辑最后才怀疑模型参数。数据泄漏的问题我会在后面的避坑章节里单独展开因为它是这类比赛里最隐蔽、也最伤分数的一个坑。4. 调参再训练把 LightGBM 从玄学变成可复现4.1 LightGBM 参数先固定哪几个再搜哪几个决策树模型不需要标准化可以直接吃原始数值和有序类别字段。第一次上 LightGBM不要上来就 OpenTuner 全参数搜索先固定一套保守参数跑出对照分数再逐个放开。import lightgbm as lgb params { objective: binary, metric: auc, learning_rate: 0.05, num_leaves: 31, max_depth: -1, min_child_samples: 30, scale_pos_weight: (y 0).sum() / (y 1).sum(), feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, lambda_l2: 1.0, n_jobs: -1, verbose: -1 }逐个解释这些参数的作用learning_rate0.05配较高的迭代轮数精度比 0.1 好但训练慢一些样本量小时 0.1 也能接受num_leaves31是默认值它才是限制模型复杂度的关键比max_depth优先调min_child_samples30防止叶子节点样本太少而过拟合数据量小的时候要降到 10 以下scale_pos_weight直接按正负样本比设置这是 LightGBM 处理不均衡最直接的手段不设置它模型会倾向预测多数类feature_fraction和bagging_fraction是两个随机采样参数一个按列采样、一个按行采样能显著降低方差lambda_l2是正则项特征列多时可以加大到 3 或 5。调参顺序我一般这样走先调num_leaves和min_child_samples因为它们是复杂度核心再调feature_fraction和bagging_fraction观察 CV 方差有没有变小最后动learning_rate。每轮只改一个参数记录 OOF AUC改完再回溯这才是可复现的调参不是玄学。4.2 早停与 OOF让 CV 不再忽高忽低固定参数后用早停控制迭代轮数用多折 OOF 平滑评估结果。早停的意义在于你不知道最优树数量是多少设置 3000 轮上限让模型在验证集 AUC 连续 200 轮不提升时自动停下。fold_pred np.zeros(len(test)) oof_lgb np.zeros(len(train)) skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) for tr_idx, va_idx in skf.split(X, y): dtr lgb.Dataset(X.iloc[tr_idx], y.iloc[tr_idx]) dva lgb.Dataset(X.iloc[va_idx], y.iloc[va_idx]) model lgb.train( params, dtr, num_boost_round3000, valid_sets[dva], callbacks[lgb.early_stopping(200), lgb.log_evaluation(200)] ) oof_lgb[va_idx] model.predict(X.iloc[va_idx]) fold_pred model.predict(test_feats) / skf.n_splits print(LGB OOF AUC:, roc_auc_score(y, oof_lgb))这段代码把 5 折的测试集预测概率做了平均比单折预测稳定得多。early_stopping(200)是连续 200 轮没有提升就停止不是累计 200 轮log_evaluation(200)每 200 轮打印一次训练信息免得刷屏。注意valid_sets[dva]传的是验证集 DatasetLightGBM 会在每一轮迭代后评估它早停依据的就是这个分数。一个常见的血泪经验是不要用早停后的model.best_iteration去重新全量训练除非你确定轮数足够稳定。我自己习惯直接保存 OOF 预测用它做后续的阈值选择、模型融合和误差分析。OOF 是整个调参过程的地基它可信后面的决策才可信。4.3 阈值与代价AUC 好看不代表业务能用AUC 只是排序指标它告诉你模型能把离职的人排到前面但不告诉你阈值设多少合适。默认的 0.5 在不均衡数据上往往不是最优解业务上我们更在意的是名单发给 HR里面有多少人真的会离职。from sklearn.metrics import precision_recall_curve prec, rec, thres precision_recall_curve(y, oof_lgb) f1 2 * prec * rec / (prec rec 1e-9) best_idx np.argmax(f1) print(best threshold:, thres[best_idx], f1:, f1[best_idx])用 OOF 预测概率画 PR 曲线找出 F1 最大的阈值然后提交时用这个阈值做决策。为什么必须用 OOF 而不是 test因为你没有 test 标签在 test 上调阈值等于用提交结果反复试探这是用验证信息污染模型。阈值降下来之后召回率会明显上升代价是误报增加HR 同学可能拿着名单找错人。所以阈值不是模型参数是业务参数得和需求方一起定。这类问题没有后悔药阈值定错了上线后只能等下一轮迭代。5. 避坑员工离职预测里最常见的 5 个翻车现场以下五条都是我交过学费的按出场顺序排从 zip 解压到模型评估每一道都能让项目卡住半天。5.1 zip 解压失败与中文编码还没建模就卡在第一步现象unzip -t报 crc mismatch或者 csv 用 pandas 读进来全是乱码。 原因zip 包在下载过程中损坏或者 csv 是 gbk 编码而 read_csv 默认按 utf-8 解析。 解决先重新下载 zip不要用 rar 修复工具硬解加载 csv 时指定encodinggbk还不行就encodingutf-8-sig两种都试一次。探测编码有个土办法用记事本打开 csv看中文是否正常正常就反而说明要用兼容编码读。这个坑卡住过不少新手但只花五分钟就能绕过去。5.2 数据泄漏本地分数高、提交分数崩的元凶现象本地 OOF AUC 跑到 0.98提交分数只有 0.65断崖式下跌。 原因特征里混进了不该有的列。EmployeeNumber、EmployeeCount、StandardHours、Over18 这类列要么是 ID、要么是常量、要么和目标有同源关系模型等于先看到了答案。 解决建模前先删掉 ID 列、常量列、以及任何在预测时点拿不到的列。判断标准很简单如果真实业务里这个字段要“事后”才能统计出来它就是泄漏。处理方式drop_cols [EmployeeNumber, EmployeeCount, StandardHours, Over18, Attrition] X X.drop(columns[c for c in drop_cols if c in X.columns])删完之后重跑 CV分数会掉到正常区间。分数掉下来不要慌这恰恰说明之前的模型是假的现在的才可信。5.3 不均衡样本90% 准确率的模型上线就被骂现象模型报告准确率 90%HR 拿着名单去找人发现名单里一堆人根本没离职倾向真正走的没抓到几个。 原因离职率只有 10%模型学到的捷径就是全部预测“不离职”准确率天然九成业务价值为零。 解决评估指标换成 AUC、PR-AUC、F1不看准确率训练时用class_weightbalanced或scale_pos_weight决策时按 4.3 的方式调阈值。模型输出的是一堆概率值把它当排序工具用而不是当分类器用业务上才不会跑偏。5.4 反复调验证集过拟合的不只是模型还有你自己现象同一个验证集上调了十轮参数每轮 CV 都在涨信心满满提交后分数反而掉了。 原因验证集被反复使用模型参数和你的直觉都开始隐式记住验证集里的样本验证集从“检验”变成了“训练”。 解决留一份 final hold-out全部调参结束后只用一次选参时看多折平均而不是单折多跑几个随机种子如果随机种子一换结果就大幅波动说明模型本身不稳定先回去减特征或加正则。调参的终点不是你手里的 CV 分数而是换数据后依然稳定的泛化能力。5.5 高基数类别一个 JobRole 就能把特征表打崩现象one-hot 之后特征从 15 列变成 300 列逻辑回归训练变慢、系数爆炸树模型的分裂也变飘忽。 原因JobRole、Department 这类字段有几十个类别暴力 one-hot 让维度暴涨低频类别还成了噪音源。 解决先看nunique()类别超过 20 个的字段不要直接 one-hot。低频类别合并成 Other或者用目标编码。目标编码有泄漏风险必须在交叉验证内部对每一折单独计算不能全量算完再切分否则又是 5.2 的翻版。低频合并是最保守的做法# 把出现次数少于 5 的 JobRole 归为 Other counts df[JobRole].value_counts() df[JobRole] df[JobRole].where(df[JobRole].isin(counts[counts 5].index), Other)这段代码的逻辑是先用value_counts统计每个类别出现次数再取出现超过 5 次的类别保留原名其他全部归为 Other。where条件里的isin判断当前值是否在保留列表里不在就走 Other。低频类别合并后one-hot 列数能压掉一半以上树模型的稳定性也明显改善。6. 进阶用阈值和 SHAP 把模型输出讲成人话6.1 代价矩阵与最优阈值模型调完最后一公里是决策。HR 业务里的成本是不对称的漏掉一个高绩效员工的离职损失是重新招聘、培训和业务断档可能抵得上几十次误报的挽留成本。我习惯是把阈值选择写成一段小函数和模型预测绑在一起threshold 0.35 employees test.copy() employees[leave_prob] fold_pred employees[decision] (employees[leave_prob] threshold).astype(int) print(employees[decision].value_counts())阈值选多少取决于你的代价矩阵。如果预算只够辅导 20% 的员工就把名单按预测概率排序取前 20% 划线而不是机械地看 0.5。这个方法把模型输出从“分类概率”翻译成了“业务动作”也是领导最愿意接受的一种呈现方式。6.2 SHAP 解释让 HR 相信你的名单树模型最被人诟病的就是黑匣子。HR 拿着名单问“为什么选这些人”你不能只回一句“模型算的”。SHAP 是目前解释单个样本预测最直观的工具import shap explainer shap.TreeExplainer(model) shap_values explainer.shap_values(X[feature_cols]) shap.summary_plot(shap_values, X[feature_cols])summary_plot输出的图里每一行是一个特征点的颜色代表特征值高低横轴是 SHAP 值对预测的贡献方向。你会看到类似这样的结论加班标记为 Yes 时 SHAP 值集中在右侧推高离职风险月薪对数的 SHAP 值随薪资升高而左移拉低离职风险。把这些图截下来放进报告HR 才会把模型当成决策辅助而不是一个不透明的打分机器。注意 SHAP 对高基数 one-hot 特征会很慢先做低频合并再跑。最后说一个我的个人习惯现在每做一个离职预测任务我一定会把 OOF 预测、阈值、特征列表、模型参数打包存成一个快照文件名带上日期。早期我曾只盯着 AUC从 0.8 一路调到 0.84 就急着交差结果因为阈值没调召回率只有两成HR 拿着名单找不到人项目差点被否。后来我固定只改一个变量、记录一次对照分数波动的时候先查数据泄漏而不是继续堆特征。这套流程走熟了员工离职预测这类题对你就不再是竞赛题而是 HR 侧最常用的一个数据产品形态。希望帮到你。本文还有配套的精品资源点击获取
返回列表