ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从爬虫到机器学习:BOSS直聘数据分析师职位薪资预测全流程实战

从爬虫到机器学习:BOSS直聘数据分析师职位薪资预测全流程实战 简介面向计算机相关专业学生课程设计与期末大作业这款基于BOSS直聘“数据分析师”职位信息的完整项目覆盖爬虫采集、数据分析、可视化及机器学习预测全流程曾获98分适合用来参考架构、动手实战或直接扩展改造。压缩包共35个文件包含3个Python爬虫脚本、3个Jupyter Notebook分析文档、1个CSV职位数据集、26张结果图表及说明文档整体仅1.31MB结构清晰便于按模块学习。已有353人学习下载项目代码经过严格调试下载即可运行。读者可从中获得职位列表爬虫、城市数据爬虫、岗位数据分析与可视化、机器学习回归预测等完整实现Notebook中记录了分析思路与结论png图表直观展示薪资分布、城市需求等结果非常适合快速搭建同类数据分析项目。1. 拿到“BOSS直聘数据分析师职位挖掘”项目先把整条链路拆清楚一份标题里同时挂着爬虫、数据分析和机器学习预测的.zip项目多半不是让你看爬虫而是让你看一条完整业务链路。以 BOSS直聘上的“数据分析师”岗位为样本先抓取职位名称、薪资、城市、经验、学历和技能标签接着把“15-20K·14薪”这类字符串变成可以参与计算的数值再做分布统计和可视化最后用这些特征训练一个薪资预测模型并通过特征重要性反推招聘市场里真正影响薪资的因素。整条路线覆盖 requests、pandas、sklearn 的核心用法也逼你在清洗阶段处理大量脏数据。适合已经熟悉 Python 基础语法、想用真实业务数据练手的人。下文不是对某个现成源码包做逐段解读而是按最常被采用的工程顺序给出每一环的可复现代码和参数选择。2. 爬虫实现把 BOSS直聘 职位列表的请求、解析和落库一次打通2.1 先想清楚请求头、Cookie 和抓取边界爬职位信息最怕的不是解析代码而是请求被风控拦在门外。BOSS直聘 的职位搜索页依赖登录态未登录时返回的 HTML 中通常看不到完整职位卡片。我一般先登录一次账号从浏览器开发者工具里复制 Cookie 存成本地文件爬虫启动时读取并拼进请求头同时带上常见的用户代理和来源页。这样写出来的代码不依赖浏览器重试和调度都更容易控制。另一个要提前敲定的点是抓取频率。对职位这种更新频率不高的数据单机串行加随机延时往往比多线程更稳线程池把并发调到 4 以上短时间收到的验证响应就会明显增加。先跑一个页面看返回结构确认字段都存在再谈并发和调度。落库字段建议在写代码前就列好否则后面清洗时会反复改。这张表是常用的最小集合字段来源说明job_name职位卡片标题后续做文本分析salary薪资文本需要解析成上下限company公司名去重时使用company_size公司规模作为模型特征city职位所在城市分组统计核心维度experience经验要求分类特征education学历要求分类特征skill_tags职位下的技能标签分词后统计job_desc职位描述可选文本分析用字段里不要混入爬虫抓取时间之外的噪音比如页码和 URL 碎片它们不构成样本特征。2.2 用 requests 拉取列表页并用 BeautifulSoup 解析下面是抓列表页和解析卡片的最小实现。注意 Cookie 文件是浏览器登录后导出的文本每一行是namevalue代码会拼成请求头。import requests from bs4 import BeautifulSoup from urllib.parse import quote import pandas as pd import time USER_AGENT (Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36) COOKIE_FILE zhipin_cookies.txt def load_cookie(cookie_fileCOOKIE_FILE): 从浏览器导出的文本读取 Cookie 串 with open(cookie_file, r, encodingutf-8) as f: lines f.read().splitlines() return ; .join(line.strip() for line in lines if in line) def fetch_page(keyword, page1): query quote(keyword) url https://www.zhipin.com/web/geek/job?query{}page{}.format(query, page) headers { User-Agent: USER_AGENT, Cookie: load_cookie(), Referer: https://www.zhipin.com/, } resp requests.get(url, headersheaders, timeout10) resp.encoding utf-8 return resp.text def parse_job_cards(html): soup BeautifulSoup(html, html.parser) cards soup.select(.job-card-wrapper) records [] for card in cards: job_name card.select_one(.job-name) salary card.select_one(.salary) company card.select_one(.company-name) area card.select_one(.job-area) if job_name is None or salary is None: continue records.append({ job_name: job_name.text.strip(), salary: salary.text.strip(), company: company.text.strip() if company else , area: area.text.strip() if area else , }) return records这段代码的请求部分用requests.get而不是 Session是因为单页任务重复连接成本不高如果要连续抓几十页建议改为Session复用连接池。quote负责把中文关键词编码。resp.encoding必须设置否则页面如果未声明 charset中文很容易乱码。选择器.job-card-wrapper、.job-name基于网页版的常见结构页面改版后最直接的排查方式是把 HTML 保存成文件用浏览器打开后重新核对选择器。2.3 保存到本地并做基础去重抓回来的记录不能直接进模型至少要保证同一职位没有重复。职位列表分页时同一个职位可能在两页里重复出现所以保存前用字段组合去重。df pd.DataFrame(records) df.drop_duplicates(subset[job_name, company, salary, area], inplaceTrue) df.to_csv(jobs_raw.csv, indexFalse, encodingutf-8-sig)drop_duplicates的 subset 选择了四个字段同一家公司在同一区域发布完全相同薪资和岗位名的记录会被去掉。encodingutf-8-sig是为了让 Excel 直接打开时不出现中文乱码如果用 pandas 继续处理读回时记得用同样编码。这里没有把 job_desc 放进抓取范围因为列表页不展示完整描述需要单独点进详情页这一步留给后面做文本模型时再扩展。3. 数据分析从“15-20K·14薪”到模型能用的特征3.1 薪资字符串解析与薪资面议处理BOSS直聘 的薪资字段常见格式是15-20K·14薪有时还会出现30-50K·15薪、薪资面议。直接从字符串里正则提取数字是最快的路径但要注意 K 的大小写、小数点的情况。下面函数返回月薪中位数作为回归目标同时保留上下限。import re def parse_salary(s): 解析薪资字符串返回 (月薪中位数, 下限, 上限) if not s or 面议 in s: return None, None, None s s.upper().replace( , ) m re.search(r(\d(?:\.\d)?)K-(\d(?:\.\d)?)K, s) if not m: m re.search(r(\d(?:\.\d)?)-(\d(?:\.\d)?)K, s) if m: low, high float(m.group(1)), float(m.group(2)) return (low high) / 2, low, high return None, None, None df[salary_mid], df[salary_low], df[salary_high] zip( *df[salary].map(parse_salary) ) df df.dropna(subset[salary_mid]).copy() df[salary_mid] df[salary_mid] * 1000这个函数先把文本转大写并去掉空格再优先匹配15K-20K的写法如果匹配不到会尝试15-20K这种去掉第一个 K 的变体。返回的中位数单位是 K最后乘 1000 转成元方便和真实预期对齐。薪资面议的样本在回归任务里是缺失目标直接丢掉比强行填充更安全如果这类样本占比超过 30%说明抓取页面里混入了大量低质量职位需要回爬虫步骤检查筛选条件。3.2 经验、学历、城市字段的层级处理爬虫拿到的经验字段通常是在校/应届、1-3年、3-5年、5-10年、10年以上学历字段是大专、本科、硕士、博士。它们在业务上是有顺序的分类特征不能只当作普通字符串丢给模型。exp_order { 在校/应届: 0, 1年以内: 1, 1-3年: 2, 3-5年: 3, 5-10年: 4, 10年以上: 5, } df[exp_level] df[experience].map(exp_order) edu_order {大专: 1, 本科: 2, 硕士: 3, 博士: 4} df[edu_level] df[education].map(edu_order)映射后经验等级从 0 到 5学历等级从 1 到 4模型可以直接把它们当作有序数值使用。如果某个岗位的经验写法和表里不一致map会产生 NaN后续要么补一个默认等级要么改用 OneHotEncoder。这里选择保留有序等级因为随机森林等树模型对单调映射更友好也不容易因为分类过多造成维度膨胀。3.3 用 pandas 和 jieba 计算技能特征与相关性技能标签通常是列表内嵌字段常见做法是拼接后用 jieba 分词再统计词频。职位名称里会反复出现“数据分析师”这个词所以要在停用词里把它去掉否则它会是最高频词但没有任何区分度。import jieba from collections import Counter def extract_tags(text): words jieba.lcut(text) stopwords {数据分析师, 岗位, br, 任职, 要求} return [w for w in words if w not in stopwords and len(w) 1] df[skill_tokens] df[skill_tags].fillna().apply(extract_tags) word_count Counter(w for tokens in df[skill_tokens] for w in tokens) print(word_count.most_common(20))这段代码先分词再过滤单字词和停用词。后续可以取 top 40 词构造布尔特征例如是否包含SQL是否包含Python。在模型里技能词往往比学历更能解释薪资差异。做完这一步后可以用 pandas 看一眼特征之间的相关关系df[skill_count] df[skill_tokens].map(len) corr_df df[[salary_mid, exp_level, edu_level, skill_count]].corr() print(corr_df[salary_mid].sort_values(ascendingFalse))相关性矩阵在这里的作用是快速筛选特征而不是确定因果关系。经验等级和技能数量通常与薪资中位数呈正相关城市维度则需要分组统计。整理后的特征表如下原始字段清洗后字段类型模型用途salarysalary_mid连续回归目标experienceexp_level有序特征educationedu_level有序特征skill_tagsskill_count连续特征skill_tagsskill_tokens文本可扩展为 TF-IDF4. 数据可视化用图表回答城市、经验与技能三个问题4.1 用 pyecharts 生成城市平均薪资条形图数据清洗完成后不要直接建模先做一轮可视化确认业务直觉。城市是分析“数据分析师”岗位最核心的维度先按城市分组算中位数再排序渲染避免标签挤在一起。from pyecharts import options as opts from pyecharts.charts import Bar city_median df.groupby(city)[salary_mid].median().sort_values() bar ( Bar() .add_xaxis(city_median.index.tolist()) .add_yaxis(平均月薪(元), city_median.round(-2).astype(int).tolist()) .set_global_opts( title_optsopts.TitleOpts(title数据分析师城市薪资中位数), yaxis_optsopts.AxisOpts(name薪资), ) ) bar.render(city_salary_bar.html)用中位数而不是平均值可以避免少数几家大厂的高薪把城市整体水平抬高。round(-2)让柱子标签显示到百位更干净。bar.render()生成独立 HTML 文件不需要起服务直接浏览器打开即可。如果之后要放到数据可视化大屏里可以改成输出 JSON 数据再对接前端。4.2 用 seaborn 画经验与薪资的箱线图经验字段是有序分类适合用箱线图看分布形态。import seaborn as sns import matplotlib.pyplot as plt exp_order [1年以内, 1-3年, 3-5年, 5-10年, 10年以上] df[experience] pd.Categorical(df[experience], categoriesexp_order, orderedTrue) df_sorted df[df[experience].notna()] plt.figure(figsize(10, 6)) sns.boxplot(datadf_sorted, xexperience, ysalary_mid, showfliersFalse) plt.xticks(rotation30) plt.title(不同经验要求下的薪资分布) plt.tight_layout() plt.savefig(salary_exp_box.png, dpi200)showfliersFalse是为了绘图时剔除极端高薪数据减少长尾干扰做模型时这些离群点要保留因为招聘市场里确有高薪样本删掉它们会让预测结果系统性偏低。箱线图能清楚看到中位数、四分位距和尾部适合判断经验等级是否应该作为强特征。4.3 技能词云与可视化选型表词云适合在演示报告里展示技能词频清晰直观。下面代码基于 3.3 生成的word_count字典from wordcloud import WordCloud import matplotlib.pyplot as plt freq_dict {word: count for word, count in word_count.most_common(60)} wc WordCloud(font_pathSimHei.ttf, width800, height500, background_colorwhite).generate_from_frequencies(freq_dict) plt.imshow(wc, interpolationbilinear) plt.axis(off) plt.savefig(skill_wordcloud.png, dpi200)font_path在 Linux 服务器上要改成系统中文字体路径否则词云会显示成方框。除了技能词云技能数量和薪资之间的关系也可以用散点图验证plt.figure(figsize(8, 5)) sns.scatterplot(datadf, xskill_count, ysalary_mid, alpha0.4) plt.savefig(skill_count_salary.png, dpi200)可视化不是为了堆图而是先想清楚要回答什么问题。下面这个对应关系在项目汇报里可以直接用图表类型回答的问题对应数据字段条形图哪个城市平均薪资更高city 与 salary_mid箱线图不同经验段薪资差异和分散程度experience 与 salary_mid词云岗位高频技能词是什么skill_tokens 词频散点图技能数量和薪资的关系skill_count 与 salary_mid5. 机器学习预测用随机森林建立薪资回归模型5.1 特征工程分类变量进 ColumnTransformer预测薪资不是把清洗后的表直接塞进 sklearn要先区分分类变量和连续变量。city、experience、education这类字段适合做 one-hotskill_count则保持数值。用 ColumnTransformer 可以把两类处理放在同一个管道里避免训练集和测试集分别处理时漏掉类别。from sklearn.model_selection import train_test_split from sklearn.preprocessing import OneHotEncoder from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import mean_absolute_error, mean_squared_error import numpy as np feature_cols [city, experience, education, skill_count] X df[feature_cols].copy() y df[salary_mid] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 ) cat_cols [city, experience, education] preprocessor ColumnTransformer( transformers[(cat, OneHotEncoder(handle_unknownignore), cat_cols)], remainderpassthrough, ) model Pipeline(steps[ (pre, preprocessor), (rf, RandomForestRegressor( n_estimators300, max_depthNone, min_samples_leaf2, n_jobs-1, random_state42)) ]) model.fit(X_train, y_train)handle_unknownignore很关键它保证测试集里出现训练集没见过的城市时不会直接报错未知类别会被置零。remainderpassthrough让skill_count连续特征原样进入模型。随机森林不需要对连续特征做标准化因为它基于分裂规则不受量纲影响。5.2 评估模型MAE、RMSE 和残差分布回归任务不能只看 R2先算 MAE 和 RMSE。MAE 表示平均偏差多少元RMSE 对大误差更敏感。预测偏差是业务上最容易理解的口径。y_pred model.predict(X_test) mae mean_absolute_error(y_test, y_pred) rmse np.sqrt(mean_squared_error(y_test, y_pred)) residual y_test - y_pred print(fMAE{mae:.0f}, RMSE{rmse:.0f}) print(残差均值:, residual.mean().round(0))如果 MAE 在 3000 元以上不要急着加特征先画残差图看是否在高薪资段系统性低估。高薪样本本来就稀少回归模型很容易被中低薪大样本拉向均值。残差均值如果明显小于 0说明整体预测偏高如果高薪段全是负残差就要考虑对数目标或样本加权。5.3 用 RandomizedSearchCV 找参数并输出特征重要性随机森林需要关心的参数不是n_estimators越大越好而是max_depth和min_samples_leaf。用随机搜索代替网格搜索是因为超参空间大时网格搜索成本太高几轮迭代就能找到足够好的组合。from sklearn.model_selection import RandomizedSearchCV param_dist { rf__max_depth: [6, 10, 15, None], rf__min_samples_leaf: [1, 2, 5], rf__n_estimators: [200, 400], } search RandomizedSearchCV( model, param_dist, n_iter12, cv5, scoringneg_mean_absolute_error, n_jobs-1, random_state42 ) search.fit(X_train, y_train) best_model search.best_estimator_ print(search.best_params_)scoringneg_mean_absolute_error让调参目标直接对齐业务口径而不是默认的 R2。业务上“差了 3000 元”比“相关系数 0.9”更容易被解释。随机搜索迭代 12 次基本能覆盖重要参数组合不需要穷尽所有可能。调参完成后输出特征重要性这一步是把模型结果和业务解释挂钩的关键feature_names best_model.named_steps[pre].get_feature_names_out() importance best_model.named_steps[rf].feature_importances_ for name, imp in sorted(zip(feature_names, importance), keylambda x: x[1], reverseTrue)[:10]: print(f{name}: {imp:.4f})特征重要性的展示可以做成表格特征重要性解读experience_3-5年0.22经验是主要杠杆skill_count0.18技能数量越多薪资越高city_上海0.16城市因素显著education_本科0.10学历门槛影响中等如果特征重要性显示城市远大于经验同时城市样本分布又很不均衡说明抓取时只覆盖了少数几个城市需要回到爬虫步骤补充数据而不是直接下业务结论。这里的表格数据是示例具体数值要以你本地跑出来的为准。6. 结果分析与进阶让模型结论能反哺下一次爬虫6.1 从特征重要性和残差反推市场结论模型训练完不要只看分数。把特征重要性排序和分组统计放在一起看能反推出“哪个变量决定了数据分析师岗位的薪资”。比如经验等级、城市、技能数量排在前三说明招聘市场的核心逻辑是经验叠加技能广度而不是学历。再看残差如果绝对值集中在 15k 到 25k 的职位实际薪资往往往上限靠而 30k 以上的样本几乎都被低估这不是模型 bug而是样本不平衡导致回归模型向中位数收缩。解决办法是分层采样或者对高薪样本设置更高的权重。另一种思路是回到爬虫步骤补抓职位名称中的职级关键词例如“资深”“专家”“高级”把它们转成一个二值特征。这样结果分析就闭环回爬虫设计而不是只输出一份 PDF。6.2 增量重训技巧每天用最新数据刷新模型项目落地时常见做法是每天定时执行一次完整流程先运行已验证的爬虫脚本把新数据追加到历史 CSV去重后再做清洗和重训。这里说的增量不是对已训练模型做 partial_fit而是把新数据合并到原始数据集后全量重训。对几千条样本的数据集全量训练耗时远小于特征漂移带来的成本。用 joblib 保存模型和预处理管道方便下次直接加载。import joblib joblib.dump(best_model, salary_model.joblib)调用预测时用best_model.predict(X_new)注意X_new的列顺序和训练时一致。为了避免预测质量回退训练前先备份旧模型对比新旧模型的验证集 MAE如果新模型劣化超过 5%就不替换模型文件。保存时把日期写进文件名例如salary_model_20250531.joblib同时维护一个指向当前版本的软链回滚时改软链即可。每次回滚都记录当时的验证集 MAE比靠记忆管理模型要可靠得多。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进