☰
爬虫与机器学习实战:从职位数据采集到薪资预测模型
2026/10/10 5:02:18 网站建设 项目流程

简介:一份聚焦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, page=1, query="数据分析师", city_code="101020100"): params = { "query": query, "city": city_code, "page": page, "pageSize": 15, } resp = session.get( "https://www.zhipin.com/web/geek/job", params=params, timeout=10, ) 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(strip=True), "salary": card.select_one(".salary").get_text(strip=True), "company_name": card.select_one(".company-name").get_text(strip=True), } yield item def fetch_detail(session, job_id): url = f"https://www.zhipin.com/job_detail/{job_id}.html" resp = session.get(url, timeout=10) 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_features=3000, min_df=5, ngram_range=(1, 2), sublinear_tf=True, ) X_text = vec.fit_transform(df["job_desc"].map(cut_job_desc))

参数说明:max_features=3000把文本维度压到可管理范围;min_df=5表示一个词至少出现在 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_size=0.2, random_state=42 )

这里的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(alpha=1.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 的 alpha=1.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_estimators=300, max_depth=12, min_samples_leaf=5, max_features="sqrt", n_jobs=-1, random_state=42, ) 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_estimators=300够用,再大收益递减;max_depth=12是经验起点,太深会在噪声数据上过拟合;min_samples_leaf=5强制每片叶子至少 5 个样本,防止模型记住单条职位;max_features="sqrt"让每棵树只随机看一部分特征,降低树与树之间的相关性。随机森林的可解释性是黑匣子级别的,只能靠特征重要性近似理解:

importance = pd.Series(rf.feature_importances_, index=feature_cols) print(importance.sort_values(ascending=False).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直聘 职位数据本身噪声大,扎实做特征比堆模型更值钱。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询