
简介一份聚焦BOSS直聘数据分析师岗位的完整学术实践项目面向计算机与数据科学专业学生用于课程设计、毕业项目及实战技能提升。项目在高校考核中获98分涵盖网络爬虫构建、多维统计解析、交互式可视化与机器学习薪资预测全流程适合想系统掌握招聘数据挖掘与建模方法的学习者。压缩包共43个文件、约1.36MB以Python脚本、Jupyter Notebook、CSV数据、PNG可视化图片及zbak备份文件为主代码模块化且注释详尽解压即可运行。已有55人学习下载。内容亮点包括城市列表与岗位详情爬虫采集职位信息统计分析学历、经验与薪酬的关联动态图表展示地域分布与技能热度并以决策树、随机森林等算法构建薪资预测模型量化解读特征重要性。所有模块均通过兼容性验证为招聘数据分析提供可靠技术范本。1. 为什么把爬虫系统和机器学习预测模型接在一起一个求职定价工具的起点打开 BOSS直聘 搜「数据分析师」一页页翻能得到几千条岗位但翻完你只记得「熟悉 SQL、有三年经验、本科以上」这些单点印象回答不了「同城同年限多掌握 Python 到底值多少钱」「资深岗和普通岗的薪资分界在哪」这类结构性提问。把爬虫系统和机器学习预测模型接在一起后流程变成爬虫按固定频率采集职位数据清洗后抽出薪资、经验、学历、城市、技能标签再训练回归或分类模型输出可解释的预测结果。这篇笔记面向想把这个闭环自己搭出来的从业者默认你会 Python 和基本 pandas重点讲字段设计、特征工程、模型选型三个环节以及爬虫和建模交接处的坑。2. 爬虫系统落地的第一步字段设计、requests 采集与增量入库很多爬虫教程只会演示「拿标题 拿薪资」就结束但等到做机器学习时才发现公司规模、融资阶段、技能标签一个都没存回头补爬的成本比重新写一遍还高。我的习惯是写爬虫之前先定表结构把建模需要的字段一次想清楚再动手写请求和解析。BOSS直聘的页面是动态渲染和接口混合的直接用 requests 抓列表页 HTML 能拿到大部分卡片信息详情页再用接口或 HTML 补齐。整个过程不复杂但字段设计一旦漏了后面就是补数据的血泪史。2.1 先定字段再爬数据分析师职位要落库的 14 个字段先给出一张我常用的建表字段表每一列都对应后续建模的一个用途字段示例值建模用途job_id8b2f...唯一标识去重title数据分析师职位头衔可用于分类salary_min15薪资下限目标变量salary_max25薪资上限目标变量salary_basis月 / 日 / 年单位统一bonus_months14薪年薪修正experience3-5年核心数值特征education本科等级特征city上海城市特征company_name某科技公司文本特征注意泄漏company_size1000-9999人企业规模financingD轮及以上企业发展阶段industry互联网行业差异skill_tagsSQL,Python,Tableau技能特征job_desc长文本TF-IDF 文本特征published_date2025-01-10时间衰减「公司规模」和「融资阶段」这两个字段经常被初学者忽略但它们和薪资的相关性很强B 轮之前的公司给高薪的意愿通常低于 D 轮和上市公司。我当时第一版没建 financing 字段后面做特征时才发现库里根本没有融资数据只能重新跑一轮爬虫后悔药都来不及吃。2.2 请求与解析用 requests 抓列表页和详情页Cookie 与随机延时把 Cookie 和请求头放进一个 requests.Session后面所有请求都复用它避免每次请求被要求重新登录import requests import time import random from bs4 import BeautifulSoup def build_session(cookie_str): session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), Referer: https://www.zhipin.com/, Cookie: cookie_str, }) return session def fetch_list_page(session, page1, query数据分析师, city_code101020100): params { query: query, city: city_code, page: page, pageSize: 15, } resp session.get( https://www.zhipin.com/web/geek/job, paramsparams, timeout10, ) return resp.text这里的逻辑是BOSS直聘 的城市参数用的是内部编码比如城市编码对应上海直接传「上海」两个字不一定被接受。Cookie 是登录态会话凭证第一次从浏览器复制过来用一两周没问题过期后重新手动登录一次再更新即可。list 页面返回的是服务端渲染 HTML解析时用 BeautifulSoup 选择卡片节点def parse_card_list(html): soup BeautifulSoup(html, html.parser) cards soup.select(.job-card-wrapper) for card in cards: item { job_id: card.get(data-jid), title: card.select_one(.job-name).get_text(stripTrue), salary: card.select_one(.salary).get_text(stripTrue), company_name: card.select_one(.company-name).get_text(stripTrue), } yield item def fetch_detail(session, job_id): url fhttps://www.zhipin.com/job_detail/{job_id}.html resp session.get(url, timeout10) return resp.text列表页的卡片只暴露了职位名称、薪资和公司名完整的 job_desc 和 skill_tags 要进详情页才能拿到。每抓一个详情页sleep 一个随机间隔for item in parse_card_list(html): detail_html fetch_detail(session, item[job_id]) time.sleep(random.uniform(3, 6))提示连续出现图形验证码说明访问速度超过了站点容忍度此时停下来等一小时再继续不要刚验证完就提速。抓取公开职位信息用于个人学习控制频率、不对站点造成压力是底线。2.3 增量更新与去重同一职位反复发布怎么入库同一家公司同一个岗位会反复刷新发布如果每次抓取都覆盖数据就丢了如果全量插入分析时就分不清哪一条是最新的。我用的方案是job_id fetch_time组成联合唯一键INSERT INTO job_post (job_id, title, salary_min, salary_max, experience, education, city, company_name, company_size, financing, industry, job_desc, skill_tags, published_date, fetch_time) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE title VALUES(title), salary_min VALUES(salary_min), salary_max VALUES(salary_max), company_size VALUES(company_size), financing VALUES(financing)这个 SQL 的语义是第一次抓到的职位插入新行同一次任务里重复抓到的同一条记录更新会变的字段薪资、规模、融资阶段不会把 published_date 这种业务字段覆盖掉。分析时用窗口函数按 job_id 取最新一条就能保证「一个职位一行样本」。3. 数据清洗与特征工程把职位描述转成机器学习能用的样本爬虫落库只是开始库里的薪资写法五花八门有「15-25K·14薪」、有「日薪 300」、有「面议」职位描述里还混着「薪资待遇优厚」这种和预测目标直接相关的噪声文本。机器学习算法本身不会帮你处理这些必须在这一步把脏数据转成数值矩阵。3.1 薪资字段规范化面议、日薪、13薪怎么统一到月薪先写一个解析函数把薪资字符串拆成下限和上限import re MONTH_DAYS 21.75 def parse_salary(salary_str): if not salary_str or 面议 in salary_str: return None pair re.search(r(\d(?:\.\d)?)\s*[-~到]\s*(\d(?:\.\d)?), salary_str) if not pair: single re.search(r(\d(?:\.\d)?), salary_str) if not single: return None v float(single.group(1)) low high v else: low, high float(pair.group(1)), float(pair.group(2)) if 日 in salary_str: low, high low * MONTH_DAYS, high * MONTH_DAYS return low, high逻辑说明正则里[-~到]覆盖了10-20K、10~20K、10到20K三种常见写法「面议」返回 None建模时直接剔除或单独生成一列is_negotiable日薪按 21.75 个工作日换算成月薪否则日薪 300 的数据点会让模型以为它是 300 块一个月。再把「13薪」「14薪」单独拆出来它会直接改变年薪水平def parse_bonus(salary_str): m re.search(r(\d{1,2})薪, salary_str) return int(m.group(1)) if m else 12这里有个选择如果预测目标是月薪bonus_months就作为一个普通特征如果预测年薪就把中位数月薪乘以 bonus 再取 log。我建议预测月薪因为绝大多数 JD 的薪资区间是按月写的年薪误差会被 bonus 的写法放大。3.2 文本特征构造去掉框架词之后的分词与 TF-IDF职位描述的长文本是特征金矿但必须先做两步分词和去停用词。职位描述里有一些每篇必出现的框架词它们不区分岗位差异必须停用import jieba from sklearn.feature_extraction.text import TfidfVectorizer STOP {岗位职责, 任职要求, 职位描述, 工作内容, 负责, 具备, 熟悉, 优先, 薪资, 待遇} def cut_job_desc(text): words [w for w in jieba.cut(text) if w.strip() and w not in STOP] return .join(words) vec TfidfVectorizer( max_features3000, min_df5, ngram_range(1, 2), sublinear_tfTrue, ) X_text vec.fit_transform(df[job_desc].map(cut_job_desc))参数说明max_features3000把文本维度压到可管理范围min_df5表示一个词至少出现在 5 条 JD 里才保留这是过滤低频噪声词最直接的手段ngram_range(1,2)让「机器学习」「数据分析」这种双词短语也能成为特征。特别注意「薪资」「待遇」这两个词必须停用否则文本特征直接泄漏了目标信息这个坑在第 5 章会专门展开。3.3 类别与数值特征经验等级、学历等级、城市编码经验要求和学历要求本身是字符串但它们有天然顺序适合映射成有序数值exp_map {应届: 0, 1年以下: 1, 1-3年: 2, 3-5年: 4, 5-10年: 7, 10年以上: 12} edu_map {高中: 1, 大专: 2, 本科: 3, 硕士: 4, 博士: 5} df[exp_years] df[experience].map(exp_map).fillna(2) df[edu_level] df[education].map(edu_map).fillna(3) df[city] df[city].astype(category)经验取区间中值而不是上限写上「1-3年」的岗位真实期望通常接近 2 年取 3 会高估。「10年以上」这种开区间按 12 年处理虽然粗糙但对模型影响不大。城市不要直接映射成数值编码因为城市之间没有大小关系留着让后面的 ColumnTransformer 做 one-hot 或目标编码。样本量小的城市少于 30 条直接合并到「其他」避免稀疏 one-hot 带来的噪声。3.4 样本筛选该留哪些岗位不该留哪些这一步很多人会跳过但它是数据质量的分水岭剔除薪资解析失败或「面议」的样本这类样本没有可靠的目标值。剔除 title 含「实习」「兼职」的岗位它们的薪酬结构全职完全不同混进去会把模型带偏。剔除异常薪资区间比如salary_min 10K且salary_max 100K这种明显是外包或猎头乱写的。剔除有效样本量太少且无法合并的城市保证每个类别下有足够数据支撑统计意义。保留 fetch_time 最新的记录一个职位只有一条样本。做完这五步库里的几千条数据才真正变成建模可用的样本集合。职位数据里的噪声比教科书数据集严重得多这一阶段多花一小时模型训练阶段就能少踩三个坑。4. 机器学习预测模型训练线性回归基线与随机森林的对比与调参机器学习应用流程不是从模型开始的而是从数据形状开始的。我先把你带到一个能跑通的最小流程上用同一条 Pipeline 串起特征处理、模型训练和评估再逐步换模型调参数。如果你是机器学习入门阶段照这个顺序做比直接上深度学习要靠谱得多。4.1 训练集划分与评估指标为什么盯 MAE 而不是 R²薪资预测的误差用绝对值衡量更直观预测差 3000 块和差 5000 块业务方一听就懂。因为薪资分布右偏——少数 50K 以上的岗位会拉高 MSE所以目标值要做 log 变换from sklearn.model_selection import train_test_split import numpy as np feature_cols [exp_years, edu_level, company_size_level, bonus_months, text_vec, city] X df[feature_cols] y np.log1p((df[salary_min] df[salary_max]) / 2) X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 )这里的company_size_level要把「100-499人」这种字符串映射成 3 这样的数值映射表规则和 exp_map 一致。text_vec是第 3 章 TF-IDF 转换后的稀疏矩阵它和数值特征一起进入同一个 Pipeline。如果文本矩阵和数值矩阵分开喂模型很容易出现维度对不上的低级错误。4.2 线性回归基线先知道下限在哪任何模型项目都值得先跑一个最朴素的线性回归它的意义不是用来交付而是给出一个分数下限from sklearn.linear_model import Ridge from sklearn.metrics import mean_absolute_error model Ridge(alpha1.0) model.fit(X_train, y_train) pred model.predict(X_test) mae_log mean_absolute_error(y_test, pred) mae_raw np.expm1(mae_log) print(MAE(raw):, round(mae_raw, 2))逻辑说明Ridge 的 alpha1.0 是默认起点作用是对系数做 L2 正则防止少数高薪样本把权重拉偏。我把 y 取 log 后再算 MAE展示时再 expm1 还原成「千元 / 月」的单位业务方更好理解。如果这一步得到的 raw MAE 在 3.5K 到 5K 之间说明基础特征有效如果超过 8K先别调模型回头检查薪资解析和样本筛选是不是出了问题。线性回归的另一个价值是系数可解释city_上海的系数是正的、edu_level系数递增说明特征方向正确如果系数符号和市场常识相反就是特征构造阶段出了问题直接 debug 数据而不是 debug 模型。4.3 随机森林与参数初调为什么树模型更合适职位数据和薪资的关系不是线性关系比如「硕士 五年经验」的涨幅远大于「本科 一年经验」的涨幅这种交互效应线性模型很难表达。随机森林不用标准化、能处理缺失值、对噪声相对钝感是这个场景下最稳的起点from sklearn.ensemble import RandomForestRegressor rf RandomForestRegressor( n_estimators300, max_depth12, min_samples_leaf5, max_featuressqrt, n_jobs-1, random_state42, ) rf.fit(X_train, y_train) pred rf.predict(X_test) print(RF MAE(raw):, round(np.expm1(mean_absolute_error(y_test, pred)), 2))参数说明n_estimators300够用再大收益递减max_depth12是经验起点太深会在噪声数据上过拟合min_samples_leaf5强制每片叶子至少 5 个样本防止模型记住单条职位max_featuressqrt让每棵树只随机看一部分特征降低树与树之间的相关性。随机森林的可解释性是黑匣子级别的只能靠特征重要性近似理解importance pd.Series(rf.feature_importances_, indexfeature_cols) print(importance.sort_values(ascendingFalse).head(10))如果 top 特征全是exp_years和edu_level说明文本特征没有提供增量信息需要检查 TF-IDF 的参数或换关键词命中方式。如果city占比极低说明样本集中在少数城市可以考虑按城市分组建模。如果你按《机器学习实战基于 Scikit-Learn、Keras 和 TensorFlow》里回归案例的最小流程走把数据源换成职位表代码骨架可以原样保留——这个场景下的核心工作已经在第 3 章做完了。5. 爬虫与建模的常见排查与避坑记录现象、原因与处理把爬虫和机器学习接在一起的项目最容易出问题的不是模型而是爬虫产出和数据清洗之间的接缝。以下五条全部来自实际会遇到的场景按「现象 → 原因 → 解决」来记。5.1 列表页能抓到卡片详情页大量字段为空现象列表页能解析出 job_id 和 title但进详情页后 job_desc、skill_tags 等字段抓回来是空的或者只有「暂无信息」。原因BOSS直聘 的详情页是异步渲染的HTML 骨架先返回正文内容由前端脚本发起二次请求后填充用 requests 直接拿到的 HTML 里根本没有这些节点。解决在浏览器开发者工具的 Network 面板里找到详情页实际调用的数据接口把 URL、请求头和参数复制下来用同一个 session 请求这个接口拿 JSON。字段名会和 HTML 不一致需要重新写映射逻辑。同时给详情页请求加同样的随机延时不要因为换了接口就放松频率控制。5.2 面议和 13 薪样本处理不一致模型 MAE 虚高现象模型训练完MAE 看起来很不错但把预测值和真实值画成散点图后发现误差集中在「面议」被错误解析的样本和「13薪」高薪岗位上。原因薪资解析函数把「面议」当成了缺失值处理但「15K-25K·14薪」这类字符串被正则拆成了 15 和 25模型按 12 薪理解高薪岗位被系统性低估。解决面议样本要么剔除要么单独打标签is_negotiable做二分类带 bonus 的岗位先把 bonus_months 解析出来再决定目标是月薪还是年薪。统一口径后重新训练MAE 的下降幅度会直接反映标签噪声的占比。5.3 资深岗位样本太少预测值集体偏低现象对「数据分析师」预测还行对「资深数据分析专家」预测普遍比真实薪资低 30% 以上。原因样本分布严重不平衡库里普通分析师占了八成树模型在切分特征时会把叶子尽量分配给高频率类别资深岗位被当成噪声。解决先把头衔做归并资深/专家/高级统一成一个 high_level 标记变成一个二分类问题而不是在多分类里硬学或者按头衔分层抽样保证训练集里资深岗位比例不低于 15%再用class_weight或者样本重采样补偿不平衡。5.4 训练集 MAE 漂亮交叉验证翻车职位描述里的薪资段泄漏现象训练集 MAE 只有 1.8K交叉验证直接涨到 5K差距大到离谱。原因职位描述末尾通常有「薪资待遇15K-25K」「优秀者可给到 XX」这类句子它们被当作文本特征进了 TF-IDF模型直接从文本里学走了答案。测试集上这份信息存在但没有对齐分数立刻崩塌。解决在切分文本之前先做一次截断把 job_desc 里包含「薪资」「待遇」「福利」「年薪」的段落整体删除或者在建表时就把 job_desc 和 salary_text 分两列存储。这个泄漏非常隐蔽我第一次遇到时花了整整一个晚上才定位到是文本里的薪资段在作祟。5.5 线上批量预测值向均值收缩文本特征方差不足现象模型线下评估没问题跑一批新收集的岗位时预测薪资全在 14K 到 16K 之间区分度很差。原因随机森林对低方差特征会趋于保守当文本特征被 max_features 压得只剩下高频通用词时样本之间的差异主要由 exp_years 和 edu_level 决定而这两个字段在城市和职级维度上分布集中预测自然会向均值收缩。解决排查特征重要性看 top 特征是否几乎只有经验、学历这类低区分度字段。常见的做法是增加「城市 × 职级」交互特征或者把文本特征从 TF-IDF 换成技能关键词命中矩阵让模型能看到更多离散的、可区分的信号。6. 从训练到交付模型保存、批量预测与业务校验模型训练完不是终点还要能对下一批新抓取的职位做批量预测。我用 joblib 把模型、特征列表、TF-IDF 向量器一起打包import joblib joblib.dump({ model: rf, features: feature_cols, vec: vec, cut: cut_job_desc, }, analyst_salary_model.pkl) loaded joblib.load(analyst_salary_model.pkl) new_df pd.read_csv(new_jobs.csv) new_X build_feature_matrix(new_df, loaded[vec]) pred_log loaded[model].predict(new_X) new_df[pred_salary_k] np.round(np.expm1(pred_log), 1)这里的build_feature_matrix是把第 3 章所有映射函数串成一个入口的函数新数据进来走同一条清洗路径不能手工复制粘贴处理逻辑。批量预测之外我会加一层业务校验用城市维度的薪资分位数做合理性检查而不是把模型的输出直接当答案city_quantile { 上海: (12, 28), 杭州: (10, 25), 成都: (8, 20), } def check_pred(row): lo, hi city_quantile.get(row[city], (5, 15)) if row[pred_salary_k] lo: return 低于城市下四分位需要复核 if row[pred_salary_k] hi: return 高于城市上四分位需要复核 return 正常区间先用经验值写好上线后积累真实数据再替换成分位数统计。这层校验能挡住模型输出漂移的问题——比如新抓到的数据集整体偏向高薪城市模型输出整体偏高没有校验你这批结果交出去会被当成错误数据。我最早一版直接拿全量数据训练忘了把“面议”样本单独处理模型给到业务方后所有「老大难岗位」都被预测成均值附近的低值被打回来重做。后来凡是碰预测模型我第一件事是看目标分布再决定用回归还是分类。BOSS直聘 职位数据本身噪声大扎实做特征比堆模型更值钱。希望帮到你。本文还有配套的精品资源点击获取