简介:面向求职市场数据分析与期末作业参考的实战案例包,以 Boss 直聘招聘数据为对象,完整覆盖数据获取、预处理、探索性分析与机器学习建模等环节。压缩包约 12.51MB,包含 Data-Analysis-Project-master 项目文件夹,内附数据集、分析报告及 Python 代码等资料,方便学习者对照实践。已有 586 人学习下载,适合高校学生、转行求职者及数据分析初学者用于课程设计或自我提升。案例中不仅演示了缺失值清洗、异常值处理、直方图与箱线图绘制等 EDA 方法,还提供了线性回归、决策树、随机森林等预测模型构建、训练集与测试集划分及交叉验证评估的思路,并强调业务理解与可视化仪表板展示,帮助读者建立从数据到决策的完整分析框架。通过亲手操作,可深入理解招聘市场薪资差异、经验与薪资关系等实际结论,提升数据敏感度和商业洞察力。
1. 一份Boss招聘数据项目能完整跑通的关键前提
以Boss直聘抓取数据为对象的分析项目,看起来只是读CSV、画图、跑模型三步,但真正卡住大多数人的不在模型,而在字段语义。薪资写成"20-40K·14薪",经验写成"3-5年",公司规模混着"不需要融资"和"已上市",这些文本不先解析成结构化字段,后续聚合和建模都会失真。这篇文章按一份完整项目作业的顺序拆解:字段清洗、探索性分析、预测建模三个主环节,最后给出报告和答辩的验证技巧。无论你是拿这个案例直接交期末作业,还是想弄懂这类招聘数据项目的通用流程,都能照着做。
2. 字段梳理与薪资区间解析:清洗环节的决定性一步
2.1 招聘数据的字段结构与不一致性来源
Boss直聘相关的招聘抓取数据,字段构成因采集脚本不同而存在差异,但通常不会跳出三大类:职位基本信息(job_name、salary、experience、education、city)、公司信息(company_name、industry、company_size、financing_stage)以及行为结果信息(apply_count、deliver_count、interview_count)。压缩包里的Data-Analysis-Project-master文件夹一般会放一个CSV或Excel数据集,部分还带有分析报告和代码。
我在拿到一份新数据集时,第一步不是急着写清洗函数,而是先回答三个问题:每列有多少非空值、字符串字段的真实写法是什么、是否有明显来自同一公司同一岗位的重复记录。听起来简单,但绝大多数项目翻车都源自这一步没做好。比如salary字段可能同时存在"面议""15-20K""20-40K·14薪"和"200-300元/天"四种写法,如果不通过value_counts先看清全貌,解析函数必然漏掉某个分支。
另一个值得注意的点:采集脚本抓下来的字段顺序和列名不可控。有的数据表列名是英文,比如job_title、salary_range,有的直接是中文;有的把薪资和福利拼在一个字段里,有的单独拆开。我在拿到csv后会先做一次统一的列名映射,把不同来源的字段对齐到同一个分析口径。这个动作不产生任何可视化效果,但它决定了后续流程能不能复用。
表2-1 常见字段和处理思路
| 字段类型 | 示例字段 | 典型问题 | 处理方式 |
|---|---|---|---|
| 职位基本信息 | job_name, salary, experience, city | 薪资单位混乱、经验区间文本化 | 正则解析、映射编码 |
| 公司信息 | industry, company_size, financing_stage | 行业粒度过细、规模区间重叠 | 一级分类合并、频数或标签编码 |
| 行为数据 | apply_count, interview_count | 缺失率高、口径不一致 | 缺失多直接丢弃或单独标记 |
经验字段的写法通常是"在校/应届""1-3年""3-5年"这类区间文本,不能直接排序。城市字段偶尔会有"北京·朝阳区"这样把区和市混在一个字符串里的写法,需要split后取第一位。这些内容不处理,进模型前一定会报类型错误或产生错误的分组。
2.2 用pandas完成缺失值、重复值与类型清洗
清洗的第一步先做缺失值盘点。我会用isnull().mean()看重灾字段,缺失率超过30%的字段默认丢弃;低于5%的字段用众数或中位数填充;介于中间的字段根据业务含义决定。
import pandas as pd import numpy as np import re df = pd.read_csv('boss_jobs.csv', encoding='utf-8') # 查看整体缺失情况 print(df.shape) print(df.isnull().mean().sort_values(ascending=False)) # 删除关键列缺失的记录 df = df.dropna(subset=['job_name', 'salary', 'company_name']) # 按关键业务键去重 df = df.drop_duplicates(subset=['job_name', 'company_name', 'city']) # 确认薪资字段的取值形式 print(df['salary'].value_counts().head(20))这段代码做了四件事。dropna(subset=['job_name','salary','company_name'])把无法解析的关键缺失记录剔除;drop_duplicates只按job_name、company_name、city去重,避免把不同城市的同名职位误删,注意这里没有把experience放进subset,因为同一城市内同公司同岗位的不同经验等级,可以认为是不同的JD,但如果拿到的是招聘快照数据且采集时间接近,保留多条会重复计算岗位数量。value_counts().head(20)是为了确认薪资字符串到底有哪几种写法,这一步决定了后面的解析函数要覆盖多少分支。
如果发现缺失集中在apply_count这类行为字段,直接删除会造成数据量骤减。常见做法是保留记录并新增一列apply_missing标记,用0填充apply_count,让模型自己学习缺失模式。这个技巧在处理真实抓取数据时很常用,也适合在报告中写出来。
2.3 薪资文本解析与经验字段数值化
薪资解析是这个数据集里最核心的清洗环节。"20-40K·14薪"需要拆成salary_low、salary_high和salary_month三个字段,计算月均薪资时再把年终月数折算进去。
def parse_salary(value): if pd.isnull(value) or value == '面议': return np.nan, np.nan, np.nan # 取出字符串中所有数字 nums = re.findall(r'\d+', value) if len(nums) == 0: return np.nan, np.nan, np.nan low = int(nums[0]) # 覆盖 "15-20K" 和 "8K" 两种情况 if len(nums) >= 2: high = int(nums[1]) else: high = low month = 12 # 覆盖 "14薪" 这类写法,注意不是所有含薪字的都带数字 if '薪' in value and len(nums) >= 3: month = int(nums[-1]) return low, high, month df['salary_low'] = df['salary'].apply(lambda x: parse_salary(x)[0]) df['salary_high'] = df['salary'].apply(lambda x: parse_salary(x)[1]) df['salary_month'] = df['salary'].apply(lambda x: parse_salary(x)[2]) df['salary_avg'] = (df['salary_low'] + df['salary_high']) / 2 * df['salary_month'] / 12parse_salary的边界条件值得逐行看。re.findall(r'\d+', value)会把"20-40K·14薪"里的20、40、14全部提取,如果只有一个数字说明是类似"8K"的固定薪资;month字段只有在包含'薪'字时才取最后一个数字,避免把年份等无关数字误判成月数。salary_avg的计算先把区间中点求出来,再乘以薪资月数除以12,这样年终奖的差异能被正确折算进月度口径。真实数据里如果出现"200-300元/天"这类日薪岗位,正则一样能解析出200和300,但单位不一致会污染统计,我在使用时会先把不包含'K'的薪资记录过滤到单独一个子集,保证后面所有分析都在K年薪口径下进行。
经验字段的数值化更直接:
exp_map = { '在校/应届': 0, '1年以内': 0, '1-3年': 1, '3-5年': 3, '5-10年': 5, '10年以上': 10, } df['exp_year_low'] = df['experience'].map(exp_map)exp_map的关键是取区间下限作为代表值。"3-5年"映射成3而不是4,下限值既能保证分组顺序稳定,又便于解释成"至少3年经验"。如果不做映射直接用字符串分组,'1-3年'和'10年以上'按字典序排会前后错乱,画出来的图完全没法看。
清洗到这里,数据已经具备做分析的基本形态:薪资有连续值、经验有排序数值、公司规模可以用标签编码、城市可以拆分出市一级。下一步就可以进入探索性分析环节,先看市场结构,再做建模。
3. 探索性分析:用Python画出就业市场里的结构差异
3.1 用直方图和箱线图定位薪资分布问题
数据清洗完,不要直接去跑模型。第一件事是看薪资全貌分布,目的有两个:验证解析函数的正确性,以及判断数据里是否存在量纲不一致的记录。如果直方图最左侧出现一个孤零零的小峰,大概率是日薪岗位没过滤干净;如果右侧出现一条延伸很长的尾巴,说明金融、算法这类高薪岗位真实存在,不能当异常值删除。
import matplotlib.pyplot as plt import seaborn as sns plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = False fig, axes = plt.subplots(1, 2, figsize=(14, 5)) # 左侧直方图:看整体分布形态 sns.histplot(df['salary_avg'], bins=60, kde=True, ax=axes[0]) axes[0].axvline(df['salary_avg'].median(), color='red', linestyle='--') axes[0].set_title('平均月薪分布') # 右侧箱线图:按行业观察离散程度 top_industries = df['industry'].value_counts().head(10).index df_top = df[df['industry'].isin(top_industries)] sns.boxplot(data=df_top, x='salary_avg', y='industry', ax=axes[1]) axes[1].set_title('Top10行业薪资箱线图') plt.tight_layout() plt.show()bins=60控制直方图的柱数,柱数太少会抹掉分布细节,太多又会出现大量空槽,针对几千行的招聘数据60是个稳妥值。axvline在中位数位置画一条参考线,用来对比均值和中位数的偏离程度,如果红线明显落在均值左侧,说明分布右偏,存在一批超高薪岗位把平均值拉高,后面所有对比统一改用中位数即可。箱线图能同时看出中位数、四分位距和离群点,金融行业箱体上沿很高、互联网行业箱体又宽又扁的形态非常常见,前者反映头部高薪岗位集中,后者说明岗位层级跨度大。
有个细节容易被忽略:直方图柱子数量会直接影响视觉结论。同样一份数据,bins=20时看起来是单峰,bins=100时会看到明显的多峰。报告里如果贴了直方图,一定要在说明性文字里写上参数,否则答辩时被问到"为什么你的分布和别人不一样"会很被动。
3.2 行业、城市与经验的三维交叉透视
单维分布只能回答"是什么",交叉表能回答"谁和谁有关系"。用中位数聚合而不是均值,这是招聘数据分析流程里很关键的一点。
# 生成城市x行业的中位数透视表 pivot_table = pd.pivot_table( df, values='salary_avg', index='city', columns='industry', aggfunc='median' ) # 只保留岗位量最多的8个城市,避免稀疏行干扰 city_rank = df.groupby('city')['salary_avg'].median().sort_values(ascending=False) top_cities = city_rank.head(8).index pivot_table = pivot_table.loc[top_cities] plt.figure(figsize=(12, 7)) sns.heatmap(pivot_table, annot=True, fmt='.0f', cmap='RdYlGn') plt.title('主要城市与行业薪资中位数热力图') plt.show()pd.pivot_table的aggfunc='median'和mean的区别,在招聘数据里直接决定结论方向。均值被高薪岗位拉升后,容易得出"金融行业全面高于互联网"的结论,而用中位数会发现两个行业的中位水平其实接近,差异主要体现在上四分位区间。annot=True在每个格子里打印数值,fmt='.0f'限制整数,避免表格过宽。颜色从红到绿表示低薪到高薪,整体配色对色弱人群也相对友好。
热力图只能看到水平,看不到每个格子背后的样本量。一个只有5条数据的格子如果恰好对应某外企高管岗,中位数会被拉得异常高。我的处理是:统计每个格子的样本量,小于30的格子置为空或者用NaN标记。放在期末作业里,这一步可以写成"排除了样本量小于30的城市行业组合,避免小样本噪声干扰"。
表3-1 主要行业薪资统计对比(示例)
| 行业 | 岗位数 | 薪资中位数(K) | 薪资均值(K) | 均值与中位数差 |
|---|---|---|---|---|
| 企业服务 | 415 | 21.5 | 22.8 | 1.3 |
| 电子商务 | 328 | 16.0 | 18.2 | 2.2 |
| 金融 | 186 | 26.0 | 33.5 | 7.5 |
| 生活服务 | 117 | 14.5 | 15.0 | 0.5 |
这张表示例展示了为什么不能只看均值。金融行业均值比中位数高7.5K,说明顶部岗位的拉动效应非常明显;生活服务行业差值只有0.5K,说明薪资分布相对均匀。报告中放这样一张表,比放十张堆叠柱状图更有说服力。
3.3 行业对比中的两个统计陷阱
第一个陷阱是城市产业结构差异。直接拿北京和成都的整体中位数对比,成都一定大幅落后,但这不是城市机会差距,而是两个城市的行业构成不同。成都的互联网岗位薪资中位数其实挺接近北京,整体被拉低是因为制造业和生活服务类岗位占比高。要得出"哪些城市机会多"的结论,必须固定行业维度再对比。
第二个陷阱是样本覆盖偏差。抓取数据里往往混着大量外包岗位、实习岗位和重复刊登的JD,外包岗位薪资通常比正编低一档,混在行业里会直接拉低中位数。我一般用经验字段过滤掉"在校/应届"和"1年以内",后面做行业对比时结论更接近真实求职场景。而重复刊登问题靠drop_duplicates解决,这一步做不做,行业岗位数统计会差出20%以上。
4. 从特征到模型:投递量回归与交叉验证
4.1 问题定性:回归任务还是分类任务
建模环节要做的第一个决定是问题类型。如果数据集里有apply_count这类投递次数字段,任务就定义成回归:用职位、公司、城市特征预测一个岗位能收到多少份投递。如果没有行为字段,就把任务改成分类:构造"薪资是否高于所在城市同行业中位数"的二分类标签,模型输出的概率可以直接解读成"岗位薪酬竞争力"。
这两个方案里,我建议优先做回归。原因是回归模型的残差和交叉验证指标更容易解释,随机森林的feature_importances_输出天然适合讲业务故事;分类任务一旦标签分布不均衡,比如90%都是正样本,F1分数就很不好看,还要额外交代采样策略。期末作业答辩时,评审老师通常会问"为什么选这个目标变量",回归场景下的回答是"投递量代表岗位热度,和薪资、经验要求存在可验证的量级关系",比分类逻辑更站得住脚。
4.2 特征编码、划分与基准模型训练
进入训练前,类别特征需要编码。城市有几十个取值,直接独热会生成大量稀疏列,随机森林遇到这种特征收益不高。我在这里用频数编码,用每个城市的岗位总量替换城市名,这个值天然包含了城市招聘市场规模的语义。行业字段用pd.Categorical转成整数编码,不引入维度膨胀。
from sklearn.model_selection import train_test_split, cross_val_score, KFold from sklearn.ensemble import RandomForestRegressor # 城市频数编码:城市出现次数作为一列数值特征 df['city_freq'] = df['city'].map(df['city'].value_counts()) # 行业整数编码 df['industry_code'] = pd.Categorical(df['industry']).codes features = ['salary_low', 'salary_high', 'exp_year_low', 'city_freq', 'industry_code'] X = df[features].fillna(-1) y = df['apply_count'] # 7:3 划分 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42 ) # 随机森林基准模型 rf = RandomForestRegressor( n_estimators=300, max_depth=14, min_samples_leaf=4, n_jobs=-1, random_state=42 ) rf.fit(X_train, y_train) # 5折交叉验证 kfold = KFold(n_splits=5, shuffle=True, random_state=42) cv_scores = cross_val_score(rf, X_train, y_train, cv=kfold, scoring='r2') print('5折交叉验证 R2: %.3f (+/- %.3f)' % (cv_scores.mean(), cv_scores.std())) # 测试集最终评估 test_r2 = rf.score(X_test, y_test) print('测试集 R2: %.3f' % test_r2)几个参数值得在答辩时展开讲。max_depth=14限制每棵树的最大深度,防止单棵树把训练集的噪声学到;min_samples_leaf=4要求叶子节点至少4个样本,效果是按样本量做平滑,和高薪资岗位的极端分布形成对抗。n_jobs=-1启用全部CPU核并行训练,数据量在万级时能明显缩短运行时间。random_state=42保证随机种子固定,这是可复现性的基础,所有随机过程都依赖这一行约束。
test_size=0.3把三成数据留在训练外做最终评估。但如果数据里有同公司同岗位的相似记录,随机切分会让测试集和训练集高度相似,导致R2虚高。遇到这种情况,更严谨的做法是按city分组切分,保证同一城市的样本要么全部在训练集、要么全部在测试集。虽然R2会下降,但结论更可信,答辩时加分。
再补充一个招聘数据里常见的处理:apply_count这类计数型目标通常严重右偏,直接拟合R2很难看。常见做法是对y做log1p变换,让极端投递量的岗位不再主导误差,训练完成后用expm1还原预测值。这一技巧对工资预测同样有效,尤其是薪资带有明显长尾的场景。
4.3 特征重要性与单变量验证的对照解读
训练完成后,特征重要性可以直接排序:
importance_df = pd.DataFrame({ 'feature': features, 'importance': rf.feature_importances_ }).sort_values('importance', ascending=False) print(importance_df) plt.figure(figsize=(8, 4)) sns.barplot(data=importance_df, x='importance', y='feature') plt.title('随机森林特征重要性') plt.show()这里要提醒一个解释误区:特征重要性低,不代表业务上不重要。薪资和工作经验往往高度相关,随机森林会把重要性分散到两个字段上,单独看哪个都不高。为了交叉验证,我会对每个特征做单变量线性回归,把R2和随机森林重要性并列展示。
表4-1 特征重要性对照表(示例)
| 特征 | 随机森林重要性 | 单变量R2 | 解读 |
|---|---|---|---|
| salary_high | 0.31 | 0.05 | 在树模型中贡献大,受与经验的特征交互影响 |
| salary_low | 0.25 | 0.04 | 与salary_high共线,重要性被分散 |
| exp_year_low | 0.19 | 0.01 | 单变量预测力弱,但与薪资交互后价值凸显 |
| city_freq | 0.16 | 0.01 | 城市供给量具备一定预测力 |
| industry_code | 0.09 | 0.00 | 线性关系弱,蕴含信息需要树模型挖掘 |
表中金融行业的例子是随机森林和非线性模型才能捕捉到的结构,单变量线性回归找不到。能讲清楚"两种方法的结论为什么不同、为什么更信任交叉验证结果",比堆任何一个单一指标都更能体现分析深度。
如果交叉验证R2是负的,不用慌。R2为负说明模型比直接预测均值还差,最常见的原因是目标变量是投递量这类重尾分布,RMSE被极端值主导。处理办法是对y做log1p变换,让分布接近正态后再训练,同时评估指标换成mean_absolute_error更直观。这一招在招聘数据里基本都能用上。
5. 期末报告收尾与答辩检查清单:验证与交付技巧
5.1 报告里放三个验证步骤
第一个是解析函数边界测试。拿薪资里"面议""8K""20-40K·14薪"三种典型值分别调用parse_salary,把输出打在报告里;评分老师看到这一步,就知道你对数据做了认真的边界处理。第二个是数据口径说明。报告里明确写出"薪资单位统一为K,未含日薪岗位,所有数值保留两位小数",避免阅读者产生疑问。第三个是模型的可复现环境。requirements.txt里固定pandas、numpy、matplotlib、seaborn和scikit-learn的具体版本,把复制和粘贴的成本降到最低。
5.2 把核心结论做成断言式检查
在notebook最后加一个验证cell,把分析里的核心结果变成可执行断言。
# 核心结论验证 assert df['salary_avg'].median() > 10, '薪资中位数异常' assert abs(cv_scores.mean() - 0.5) < 0.3, '模型R2偏离预期,检查特征与清洗' assert (df['salary_avg'] > 100).mean() < 0.01, '高薪岗位占比异常' print('核心结论检查通过')三个断言分别监控薪资量级、模型表现和极端值占比。这部分代码放在报告末尾,能直接向答辩老师证明:结论不是跑完就贴上去的,而是有自动检查兜底的。
5.3 答辩高频问题与应答要点
问一:薪资解析为什么不用pd.to_numeric?答:to_numeric只能处理纯数字,Boss直聘的薪资字段是区间文本加单位混合,必须先正则拆分再计算,否则会把"14薪"当成薪资的一部分。
问二:交叉验证的标准差0.12可以接受吗?答:可以接受。R2标准差小于0.1说明模型在不同子集上稳定;超过0.15就需要检查是否有单条异常样本主导训练,常见处理是删除Y值超过99分位数的样本后重新训练。
问三:投递量预测结果对求职者有什么用?答:预测值代表岗位竞争强度。求职者看到投递量高、薪水中等的岗位,会预期得到反馈的概率更低;平台侧则可以用预测值做推荐排序,控制流量分配。
提示:答辩时主动补充一句"我用分组切分验证过备选模型,训练集和测试集里的样本来自不同公司",属于最有区分度的一句话,普通学生不会注意这一点。
本文还有配套的精品资源,点击获取