简介:面向数据分析与机器学习学习者的完整实战项目包,基于BOSS直聘“数据分析师”职位信息,完整覆盖爬虫采集、数据清洗、数据可视化、机器学习预测与结果分析全流程。压缩包共35个文件,包含Python爬虫脚本、3个可执行的Jupyter Notebook、1个CSV数据集、26张可视化图表及说明文档等,整体约1.29MB,目录结构清晰,便于按采集、分析与建模阶段查阅。已有206人学习使用,适合希望掌握真实招聘数据获取与分析的入门及进阶学习者。通过项目可学习requests与BeautifulSoup解析职位信息、Pandas清洗缺失值与薪资转换、Matplotlib和Seaborn绘制薪资分布与城市需求图表,并使用scikit-learn构建回归或树模型,结合交叉验证、MSE和R²等指标评估预测效果,最终分析城市、经验等因素对薪资的影响,体会AI与机器学习在职场数据中的实际应用。
1. BOSS直聘“数据分析师”职位:一个能跑通全流程的数据分析项目
如果你正愁缺一个拿得出手的实战项目,BOSS直聘“数据分析师”职位信息的爬虫、数据分析、数据可视化与机器学习预测这条链路,几乎把数据分析岗的核心技能全串起来了:requests 和 Selenium 抓动态页面、SQLAlchemy 入库、Pandas 清洗、Pyecharts 出图、XGBoost 建模,最后还要回头解释模型结果。这个项目最大的价值不是“会爬了”,而是每一层都会逼你处理真实数据里的脏乱差——薪资写成“15-25K·13薪”、经验字段混入“学历不限”、同一个职位在不同城市重复出现。本文按我实际做这类职位的顺序,把字段设计、反爬对抗、清洗入库、可视化、建模预测和踩坑记录完整拆开。适合学过 Python 基础、想用一场完整的数据分析项目来证明自己能力的人。
2. 爬虫实现:用 requests + Selenium 拿全 BOSS直聘职位字段
2.1 字段设计:先想清楚要拿什么
很多新手写爬虫上来就对着页面点右键查看源码,结果发现页面全是空壳。BOSS直聘的职位列表是典型的 JavaScript 动态渲染页面,HTML 源码里搜不到职位信息,真实数据藏在浏览器 Network 面板的 XHR 接口里。所以动手写代码前,第一步不是找接口,而是先把字段清单列出来——你要回答什么问题,就采集什么字段。
做“数据分析师”这个职业方向,至少要覆盖四个层面的分析需求:岗位本身(职位名称、薪资、经验要求、学历要求)、公司信息(公司名称、融资阶段、公司规模)、地域信息(城市、行政区域、商圈),以及描述文本(职位描述,后面做 TF-IDF 特征要靠它)。我一般把字段分成三层:列表页能拿到的、详情页才能拿到的、以及自己加工出来的派生字段。列表页通常会返回 jobId、jobName、salaryDesc、jobExperience、jobDegree、brandName、brandStageName、brandSizeName、cityName、areaDistrict 这些字段,jobId 是去重和增量更新的关键主键。
提示:把 jobId 列为唯一键,比用“公司+职位名”拼接可靠得多。同一个岗位在不同时间抓两次,jobId 不变,但薪资和招聘状态可能变。
2.2 列表页抓取:Selenium 渲染 + Cookie 复用
明确要抓哪些字段后,接下来要解决的是“怎么拿到接口返回的 JSON”。常见做法有两种:一是直接用 Selenium 打开浏览器,等渲染完成后从页面里抠数据;二是用 Selenium 打开一次页面,从 performance log 里找到 XHR 请求的完整 URL 和参数,再换成 requests 去请求这个 JSON 接口。我通常走第二种——Selenium 只负责“过验证码”和“发现接口”,日常抓取交给 requests,速度差十倍以上。
第一步是用 Selenium 手动登录一次,把登录后的 Cookie 存成文件,之后 requests 带上 Cookie 就能维持登录态。这一步对反爬的意义在于:BOSS直聘的职位详情页和翻页接口都对未登录状态做了限制,不带登录态直接请求大概率会拿到一串错误码。
import time import json from selenium import webdriver # 打开搜索页,手动完成登录/滑块验证后回车 driver = webdriver.Chrome() driver.get("https://www.zhipin.com/web/geek/job?query=数据分析师&city=100010000&page=1") input("请在页面里完成登录和验证,然后回到终端按回车……") # 把登录后的 cookies 落盘,后续 requests 请求直接复用 cookies = driver.get_cookies() with open("zhipin_cookies.json", "w", encoding="utf-8") as f: json.dump(cookies, f, ensure_ascii=False) driver.quit()这段脚本的核心不是“打开网页”,而是“拿到登录态”。注释里手动回车这步是故意的:登录和滑块验证交给真人完成一次,后面机器请求才有通行证。Cookie 里除了常见的 sessionid,还有和风控相关的标记位,全部原样保存即可。
接着用 requests 建立会话,把 Cookie 灌进去,然后去请求列表页背后的 JSON 接口。接口的路径、参数名会随网站改版变化,以你在浏览器 Network 面板里抓到的实际请求为准,下面代码写的是这个场景最常见的结构。
import requests import json import time import random from fake_useragent import UserAgent # 从本地文件恢复登录态 cookies = json.load(open("zhipin_cookies.json", encoding="utf-8")) ua = UserAgent() session = requests.Session() for c in cookies: session.cookies.set(c["name"], c["value"], domain=c.get("domain", "")) def fetch_job_list(page, city="100010000", query="数据分析师"): # 以浏览器 Network 里实际抓到的请求为准,这里是常见结构 url = "https://www.zhipin.com/wapi/zpgeek/search/joblist.json" params = { "query": query, # 搜索关键词 "city": city, # 城市编码,100010000 是全国 "page": page, # 页码,从 1 开始 "pageSize": 30, # 每页数量,BOSS直聘通常一页 30 条 } headers = { "User-Agent": ua.random, "Referer": "https://www.zhipin.com/web/geek/job", } resp = session.get(url, params=params, headers=headers, timeout=10) data = resp.json() # 接口返回的关键路径,列表在 zpData.jobList 里 job_list = data.get("zpData", {}).get("jobList", []) return job_list这里有一个必须解释的点:为什么直接用 requests 去请求 XHR 接口,而不是继续用 Selenium 渲染?因为接口返回的是结构化 JSON,省去了解析 HTML 的步骤,字段边界清晰,出错时一眼能看出是接口变更还是参数问题。但风险也在这里——接口对请求头更敏感,Referer 不能丢,User-Agent 每次随机更换是常规操作,固定 UA 的请求量一大就会被标记。代码里 fake_useragent 库就是干这个的。
2.3 详情页二次请求:补全职位描述
列表页接口返回的 JSON 里通常会带一段职位描述,但这段描述时常被截断,或者只保留了前几十个字符。而后面做机器学习时,职位描述是重要的文本特征来源——技能词、工具词、行业词全在里面。所以只依赖列表页数据是不够的,要对每条职位做一次详情页请求。
详情页的请求方式同样是在浏览器 Network 面板里找到 jobDetail 相关的 XHR 接口,参数就是列表页返回的 jobId。这里需要控制频率,因为每条职位都要单独请求一次,量级从“一个页面”变成了“几百个页面”,请求太快很容易触发风控。
def fetch_job_detail(job_id): # 详情接口同样以实际抓包为准,这里给出常见结构 url = f"https://www.zhipin.com/wapi/zpgeek/frontend/position/detail.json" params = {"jobId": job_id} headers = { "User-Agent": ua.random, "Referer": f"https://www.zhipin.com/job_detail/{job_id}.html", } resp = session.get(url, params=params, headers=headers, timeout=10) data = resp.json().get("zpData", {}) return data.get("jobDetail", {}).get("jobDesc", "")写到这里必须提醒:详情页接口的返回字段比列表页丰富得多,除了职位描述,往往还带商圈经纬度、职位标签、招聘者信息。如果你后续想分析“数据分析师在哪个商圈最集中”,经纬度字段是列表页给不了的。所以每次抓完列表页,紧接着批量请求详情页,两个步骤在同一会话里完成。
2.4 翻页与限速:把参数吃透,不做“请求轰炸”
列表页的翻页逻辑不复杂,但有两个细节直接影响数据质量。第一,BOSS直聘的搜索页最多能翻到几十页,越往后职位重复率越高,而且很多是“推荐职位”和“搜索命中职位”混排,同一家公司的同一条职位可能出现在不同页。第二,页码参数 page 从 1 开始,但如果抓取速度太快,即使你的 Cookie 是有效的,也会在翻到三四页时忽然遇到验证码页面。
我的常规做法是:单城市只抓前 10 页,每页间隔 2 到 5 秒随机延时,每 50 条职位详情请求后休息 10 秒。对分析任务来说,10 页 300 条职位已经能支撑后续的统计建模,贪多反而容易把 IP 或账号拖进风控名单。
result = [] for page in range(1, 11): try: jobs = fetch_job_list(page) if not jobs: print(f"page {page} 返回空列表,可能触发风控或已到底") time.sleep(20) continue for job in jobs: job["jobDesc"] = fetch_job_detail(job.get("jobId", "")) result.append(job) time.sleep(random.uniform(1, 3)) except Exception as e: print(f"page {page} 抓取失败:{e}") time.sleep(15) time.sleep(random.uniform(2, 5))代码里失败后睡 15 秒再继续,是很有必要的“后悔药”:接口因为风控突然拒绝时,立刻重试大概率还是失败,等十几秒让风控状态缓一缓反而能续上。空列表和报错要分开处理——空列表可能是自然到底了,也可能是数据返回了但解析路径变了;报错则有可能是网络原因,也有可能是 Cookie 过期。我建议你每抓一页就把这页的职位数量打印出来,看到数量骤降就该去检查接口了。
3. 数据清洗与存储:SQLAlchemy 入库前必须处理的脏数据
3.1 薪资文本解析:把“15-25K·13薪”拆成数字
爬虫抓下来的字段大多是给人看的文本,而不是给机器用的数字。最典型的例子就是薪资:BOSS直聘返回的 salaryDesc 可能是“15-25K·13薪”“8-12K”“20-30K·15薪”,甚至还有“面议”。做可视化和机器学习之前,必须把它解析成数值。
常见做法是先统一字符串格式,再去提取数字。注意“15-25K·13薪”里既包含薪资区间,也包含发薪月数,“·”和“薪”字都是干扰项。我的解析逻辑是:提取字符串里所有数字,如果数字多于两个,只取前两个作为薪资下限和上限;单个数字就认为上下限相同;“面议”直接返回空值。
import re import pandas as pd def parse_salary(s): if not isinstance(s, str): return None, None # 去掉 K 和 k,保留数字与小数点,防止“15-25K”里的 K 干扰 cleaned = s.replace("K", "").replace("k", "").replace("K", "") nums = re.findall(r"\d+(?:\.\d+)?", cleaned) if len(nums) >= 2: low = float(nums[0]) high = float(nums[1]) elif len(nums) == 1: low = high = float(nums[0]) else: return None, None # BOSS直聘上的数字单位是千,转换成元 return low * 1000, high * 1000 df["salary_low"], df["salary_high"] = zip(*df["salaryDesc"].map(parse_salary)) df["salary_mid"] = (df["salary_low"] + df["salary_high"]) / 2解析结果会多出三个新字段:salary_low、salary_high、salary_mid。后续无论是画箱线图还是训练回归模型,都用 salary_mid 做目标值。有一点要提前说明:不同职位对“15K”的写法不统一,有的写“15-20K”,有的写“1.5-2万”,本方案只处理千元单位,遇到“万”的文本要先替换成“K”再进同一个函数。
3.2 经验、学历、融资阶段:字符串枚举的标准化映射
爬下来的字段里,jobExperience 的取值有“在校/应届”“1年以内”“1-3年”“3-5年”“5-10年”“10年以上”“经验不限”;jobDegree 有“大专”“本科”“硕士”“博士”“学历不限”;brandStageName 有“未融资”“天使轮”“A轮”“B轮”“C轮”“D轮及以上”“已上市”“不需要融资”。这些字段的共同点是:看起来是文本,实际上是有序或无序的类别。
处理策略取决于你要用什么模型。决策树和 XGBoost 对整数编码不敏感,所以把“经验不限”映射成 0、“1-3年”映射成 2、“3-5年”映射成 4,让文本变成有单调含义的数字,模型就能学到“经验年限越长薪资越高”这种关系。但如果换线性回归,这种编码方式会强行假设“2 和 4 的差距等于 4 和 6 的差距”,这时需要用 OneHot 编码。考虑到这个项目的预测模型基本是树模型,有序映射够用。
exp_map = { "经验不限": 0, "在校/应届": 0, "1年以内": 1, "1-3年": 2, "3-5年": 4, "5-10年": 7, "10年以上": 10 } df["exp_year"] = df["jobExperience"].map(exp_map) degree_map = { "初中及以下": 0, "高中": 1, "中专/中技": 1, "大专": 2, "本科": 3, "硕士": 4, "博士": 5 } df["degree_level"] = df["jobDegree"].map(degree_map) stage_map = { "不需要融资": 0, "未融资": 1, "天使轮": 2, "A轮": 3, "B轮": 4, "C轮": 5, "D轮及以上": 6, "已上市": 7 } df["stage_level"] = df["brandStageName"].map(stage_map)映射之后一定要做一次校验:用 value_counts() 检查每个标准字段里是否出现了不在映射表里的值。BOSS直聘的字段枚举会随版本调整,比如“D轮及以上”有时候写作“D轮及以后”,一不留神就全变成 NaN。校验这一步花一分钟,却能避免后面模型训练时特征全部为空的大坑。
3.3 去重与增量更新:job_id 做主键,用 upsert 避免重复爬
同一个岗位在一周内可能被爬多次:第一次抓的时候职位还在,第二次抓的时候招聘者更新了薪资。如果不做去重,数据表里会出现同一个 job_id 对应多条记录,统计的时候同一职位被重复计数,分析结论直接失真。
我的处理方式是建表时把 job_id 设为唯一键,入库时使用 “存在则更新、不存在则插入” 的 upsert 语义。SQLAlchemy 在 SQLite 下用 on_conflict_do_update,在 MySQL 下用 on_duplicate_key_update,写法略有差异。
from sqlalchemy import create_engine, Column, Integer, String, Float, Date, Text from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from sqlalchemy.dialects.sqlite import insert as sqlite_insert Base = declarative_base() class Job(Base): __tablename__ = "jobs" id = Column(Integer, primary_key=True, autoincrement=True) job_id = Column(String(32), unique=True, nullable=False) job_name = Column(String(128)) salary_desc = Column(String(64)) salary_low = Column(Float) salary_high = Column(Float) salary_mid = Column(Float) exp_year = Column(Integer) degree_level = Column(Integer) stage_level = Column(Integer) brand_name = Column(String(128)) brand_size_name = Column(String(64)) city_name = Column(String(32)) area_district = Column(String(64)) job_desc = Column(Text) crawl_date = Column(Date) engine = create_engine("sqlite:///jobs.db") Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) session = Session() def upsert_job(job_dict): stmt = sqlite_insert(Job).values(**job_dict) update_cols = {c.name: stmt.excluded[c.name] for c in stmt.excluded if c.name != "job_id"} stmt = stmt.on_conflict_do_update(index_elements=[Job.job_id], set_=update_cols) session.execute(stmt) session.commit()这里 on_conflict_do_update 的意思是:如果 job_id 冲突,就用新抓到的值覆盖已有记录里除 job_id 以外的所有字段。这样同一职位再次抓取时,薪资更新了会体现在新记录里,而不会生成重复行。
提示:增量更新的时间字段 crawl_date 不要加到冲突更新列里,否则每次重复抓都会刷新爬取时间,你就分不清这条职位是哪一轮抓到的了。
3.4 存储设计:表结构与索引
数据量在几千条级别时,SQLite 足够用,文件即库,拷贝方便。如果打算长期积累多城市、多职位方向的数据,建议直接上 MySQL。无论哪个库,表结构的设计原则是一样的:爬虫原始字段和新清洗字段分开存。
原始字段保留 salaryDesc 这种给人看的文本,是为了排查清洗逻辑时能对照原始值;清洗后的 salary_low、salary_high 是分析用的数值,清洗脚本跑完后再入库。直接把原始字段丢掉是个常见的错误——后面发现解析规则写错了,想回退重跑都找不到原始数据。
CREATE TABLE jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, job_id VARCHAR(32) NOT NULL UNIQUE, job_name VARCHAR(128), salary_desc VARCHAR(64), salary_low REAL, salary_high REAL, salary_mid REAL, exp_year INT, degree_level INT, stage_level INT, brand_name VARCHAR(128), brand_size_name VARCHAR(64), city_name VARCHAR(32), area_district VARCHAR(64), job_desc TEXT, crawl_date DATE ); CREATE INDEX idx_jobs_city ON jobs(city_name); CREATE INDEX idx_jobs_salary ON jobs(salary_mid);索引的取舍很简单:WHERE 条件里频繁出现的字段建索引。这个项目里城市和薪资中位数是两个最常用来分组聚合的字段,建立索引后,后面做“城市维度薪资排行”这类查询会快很多。职位描述字段是 TEXT 类型,不要给它建普通索引,MySQL 里如果要做全文搜索,应该用 FULLTEXT 索引,但这超出这个小项目的需要了。
4. 避坑指南:BOSS直聘爬虫与数据预处理中的 5 个翻车点
4.1 列表接口返回空 zpData:登录态失效还是被风控
现象:requests 请求列表接口返回的 JSON 里没有 zpData 字段,或者 zpData 为 None,代码在解析 jobList 时报 TypeError。这是整个项目里最常见的翻车点。
原因:两种情况表现一样,但处理方式完全不同。第一种是 Cookie 过期,登录态失效后接口不会明确报 401,而是返回一个空壳结构;第二种是请求频率太高触发了风控,接口返回空数据但 HTTP 状态码依然是 200。
解决:先区分再处理。如果在终端打印 resp.status_code 和 resp.text 的前 200 个字符,出现“验证码”相关字样就是风控,此时停手 10 到 15 分钟,或者回退到 Selenium 手动过一次验证码再重新存 Cookie;如果返回的是正常 JSON 但 zpData 为空,大概率是 Cookie 过期,重新走一遍“Selenium 登录 + 存 Cookie”的流程即可。我习惯在 fetch_job_list 函数里加一个判断:zpData 为空的次数连续超过 3 次,就立刻停止抓取并把错误写进日志,而不是让脚本傻空转。
4.2 无头模式被识别:headless 特征太明显
现象:用 Selenium 打开浏览器一切正常,换成 headless 模式后,访问职位列表页被重定向到安全验证页,或者滑块验证码出现的概率大幅上升。
原因:无头浏览器缺少真实浏览器的一些渲染特征,比如 Canvas 指纹、WebGL 渲染器、字体列表,这些特征可以被前端脚本探测到。BOSS直聘对无头模式的识别并不算激进,但一旦被识别,轻则偶尔出验证码,重则整个会话被标记。
解决:最省事的办法是不用无头模式,让浏览器窗口在后台最小化运行,或用 Xvfb 这类虚拟显示器工具跑。如果一定要用无头,可以加 chrome_options 参数:禁用自动化控制标志、隐藏 webdriver 属性、设置真实的 User-Agent。但说句实话,这些手段属于“玄学”,今天能用不代表明天能用,项目求稳的话不要在这一层上花太多精力。
4.3 薪资解析把“面议”变成错误数字
现象:清洗后的 salary_mid 出现几百元的极小值,或者出现 0,画箱线图时看到一堆离群点。
原因:salaryDesc 里有“面议”“薪资面议”“4-6K·13薪”等多种文本。如果正则写得过于宽松,“面议”里的数字万一有(比如“薪资面议,优秀者可谈 20K”),就会把 20K 当成薪资;如果正则写得太严格,“面议”返回 None 后如果直接用 fillna(0),就会把薪资中位数变成 0,污染整个数据集。
解决:parse_salary 函数里先判断文本是否包含“面议”,包含则直接返回 (None, None),不要走数字提取逻辑。入库后做一轮范围校验:salary_mid 小于 2000 的样本单独拿出来看原始 salaryDesc,确认是数据问题还是真实存在的实习岗位。数据分析师岗位的实习薪资确实可能低,但全日制岗位低于 3000 的样本要打问号。
4.4 学历字段混入经验列:页面结构调整导致字段错位
现象:jobExperience 的 value_counts() 结果里出现“本科”“硕士”等学历值,经验分布的统计完全乱了。
原因:页面改版时把学历字段的 key 换了,或者列表接口里把 jobExperience 和 jobDegree 放进了同一个嵌套结构,解析时按旧字段名取值,取到的是嵌套字典本身,再强转成字符串就“串位”了。这种情况在 web 爬虫里非常常见,网站前端一改,整个解析逻辑失效。
解决:在入库前对枚举字段做白名单校验。先定义合法的经验值集合,然后检查 jobExperience 列里的每个值是否都在集合内,不在的打印出来看实际值长什么样。字段错位的解决办法不是“删掉错的”,而是去接口返回的原始 JSON 里找新的字段路径,更新解析函数。这个坑的教训是:清洗脚本要保留原始字段,字段串位后至少能回退重跑,否则数据直接报废。
4.5 高薪样本稀疏:机器学习模型预测值整体偏低
现象:训练完回归模型后,测试集上的 MAE 看着不高,但画出预测值和真实值的散点图,发现预测值全部集中在 8K 到 20K,30K 以上的样本基本没预测中。
原因:数据分析师这个岗位的薪资分布是右偏的,15K 到 25K 是密集区,30K 以上的样本占总量可能不到 10%。回归模型优化的目标是最小化平均误差,它发现了“预测到 15K 附近不会错太多”,所以把高薪样本全部往均值方向拉。
解决:两条路。一是把回归目标换成薪资格档分类,比如分成“10K 以下”“10-20K”“20-30K”“30K 以上”四档,用 XGBoost 分类器,对高薪档调高样本权重;二是在训练集构造时按薪资分层采样,保证高薪档在训练数据里有足够的比例。从结果分析的角度,我更推荐分类方案——招聘者给出的薪资本身就是一个区间,预测“落在哪个档”比预测精确金额更有业务解释力。
5. 数据可视化与机器学习预测:先看分布,再建薪资模型
5.1 城市薪资地图:用 Pyecharts 定位高薪聚集地
数据清洗入库后,第一件事永远是“看分布”,而不是直接建模。我的习惯是先按城市画薪资中位数地图,用 Pyecharts 输出一个 HTML 文件,双击就能在浏览器里交互查看。这一步能回答最基础的问题:数据分析师在北京、上海、深圳、杭州的薪资中位数到底差多少?哪些城市职位量多但薪资偏低?
from pyecharts.charts import Map from pyecharts import options as opts city_salary = df.groupby("city_name")["salary_mid"].median().sort_values(ascending=False) data_pair = [(city, round(float(sal), 0)) for city, sal in city_salary.items()] map_chart = Map(init_opts=opts.InitOpts(width="900px", height="600px")) map_chart.add( "薪资中位数", data_pair, maptype="china", ) map_chart.set_global_opts( title_opts=opts.TitleOpts(title="数据分析师城市薪资中位数分布"), visualmap_opts=opts.VisualMapOpts(max=30000), ) map_chart.render("city_salary_map.html")Pyecharts 的地图匹配逻辑是“省/城市名称必须和地图内置名称一致”,职位数据里 cityName 是“北京”“上海”这种市级名称,直接能匹配上。但如果你抓到了“朝阳区”这种区级字段,别直接拿去配中国地图,要先拼成“北京市”。地图上的颜色深浅能直观看出薪资梯队,不过地图适合看分布,要精确排名还是得配一个 Bar 图,把城市薪资中位数按降序排列。
5.2 学历经验矩阵:热力图看门槛
城市之后要看的是“学历和经验怎么组合出高薪”。用透视表把 degree_level 和 exp_year 作为行列索引,salary_mid 作为聚合值,生成一个二维矩阵,再用热力图可视化。这个图回答的问题是:一个“本科 + 3-5 年经验”的分析师,和“硕士 + 1-3 年经验”的分析师,谁的中位数薪资更高?
pivot = df.pivot_table( index="degree_level", columns="exp_year", values="salary_mid", aggfunc="median", ) from pyecharts.charts import HeatMap from pyecharts import options as opts heat_data = [] for row_idx, degree in enumerate(pivot.index): for col_idx, exp in enumerate(pivot.columns): value = pivot.loc[degree, exp] heat_data.append([col_idx, row_idx, round(float(value), 0)])热力图的 x 轴是经验年限,y 轴是学历等级,颜色越深代表薪资中位数越高。实际跑完这步往往会看到一个反直觉的结论:在 1-3 年经验段,本科和大专的薪资差距不明显,到了 5-10 年经验段差距才拉开。这说明数据分析岗位“经验积累比学历起点更值钱”,这个结论写到项目报告里,比单纯堆图表有说服力得多。
5.3 特征工程:从清洗后的字段构造模型输入
可视化确认了变量间的关系后,开始构造机器学习模型的输入。特征分成两批:结构化特征和文本特征。结构化特征用清洗阶段生成的城市、经验年限、学历等级、融资阶段、公司规模;文本特征从 job_desc 里提取。
城市名称是无序类别,理论上该用 OneHot 或目标编码,但 XGBoost 这类树模型对 LabelEncoder 后的整数编码并不敏感,它自己能找到分裂点。所以这里直接用 LabelEncoder 给城市编码,减少特征维度。职位描述做 TF-IDF,取 100 个特征词,ngram_range 设为 (1,2),把“python”“excel”“机器学习”这类关键词变成向量。
from sklearn.preprocessing import LabelEncoder from sklearn.feature_extraction.text import TfidfVectorizer # 有序类别已经清洗成整数,无序类别用 LabelEncoder 转成整数编码 le_city = LabelEncoder() df["city_code"] = le_city.fit_transform(df["city_name"]) # job_desc 做 TF-IDF,max_features 限制在 100,避免维度爆炸 tfidf = TfidfVectorizer(max_features=100, ngram_range=(1, 2)) desc_tfidf = tfidf.fit_transform(df["job_desc"].fillna("")) # 组装最终训练集 X = pd.concat([ df[["city_code", "exp_year", "degree_level", "stage_level"]].reset_index(drop=True), pd.DataFrame(desc_tfidf.toarray(), columns=tfidf.get_feature_names_out()), ], axis=1) y = df["salary_mid"].fillna(df["salary_mid"].median())一个容易被忽略的细节:中文文本的 TF-IDF 停用词。sklearn 默认的 stop_words 是英文停用词,对中文没有过滤效果。职位描述里大量出现的“岗位职责”“任职要求”“工作职责”这类词,如果不加进停用词表,它们会因为频率高而占据 TF-IDF 的头部特征,挤掉真正有区分度的技能词。我一般会从 TF-IDF 输出的词表里手动把出现频次最高的 10 个通用词加入停用词后重新拟合。
5.4 模型训练与结果分析:XGBoost 预测薪资区间
特征准备好后进入建模环节。目标变量用 salary_mid 做回归,也可以切成薪资档位做分类,这里先展示回归版本,因为它最容易检查模型是否学到了合理规律。
import xgboost as xgb from sklearn.model_selection import train_test_split from sklearn.metrics import mean_absolute_error X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) model = xgb.XGBRegressor( n_estimators=300, max_depth=5, learning_rate=0.08, subsample=0.8, colsample_bytree=0.8, random_state=42, ) model.fit(X_train, y_train, eval_set=[(X_test, y_test)], verbose=False) y_pred = model.predict(X_test) mae = mean_absolute_error(y_test, y_pred) print(f"MAE: {mae:.0f} 元")几个参数的解释:n_estimators=300 是树的数量,设太多会过拟合,设太少欠拟合;max_depth=5 控制每棵树的深度,深度越大模型越能捕捉非线性关系,但也越容易吃噪声;learning_rate=0.08 是学习率,调低它就需要更多棵树来补偿,这里 300 棵树配合 0.08 是一个相对稳妥的组合;subsample=0.8 和 colsample_bytree=0.8 分别控制样本采样和特征采样的比例,是 XGBoost 内置的正则化手段,对稀疏的职位描述特征尤其重要。MAE 在 4000 到 5000 元之间算是正常水平,毕竟薪资本身是个区间而不是精确数字。
模型训练完不能只看指标,还要看它学到了什么。用 feature_importances_ 输出特征重要性,你会立刻发现两个模式:特征重要性排名靠前的基本是 exp_year 和 degree_level,然后是城市编码,最后才是职位描述里的技能词。这说明数据分析师的薪资主要由经验和学历驱动,技能词在一定范围内有增量信息。跑一遍交叉验证比单次 train_test_split 更稳,建议用 5 折交叉验证看 MAE 的波动范围,波动太大说明模型在不同数据子集上的表现不稳定,需要考虑减少特征。
6. 进阶:增量爬取与模型可解释性验证
当模型和可视化都跑通后,下一步是把项目从“一次性分析”变成“可持续观察的系统”。增量爬取是第一个要做的改造:把已经抓过的 job_id 存到一张独立的抓取记录表里,每次爬虫启动时先读这个集合,列表页返回的 job_id 如果已存在,就跳过详情请求。这样每天只抓新增职位和薪资变动的职位,数据表不会无限膨胀,也更符合生产环境里定时任务的运维习惯。
def load_seen_job_ids(): with open("seen_job_ids.txt", "r", encoding="utf-8") as f: return set(line.strip() for line in f if line.strip()) seen = load_seen_job_ids() for page in range(1, 11): jobs = fetch_job_list(page) for job in jobs: if job.get("jobId") in seen: continue job["jobDesc"] = fetch_job_detail(job["jobId"]) result.append(job) time.sleep(random.uniform(2, 5))第二个值得做的进阶是模型可解释性。XGBoost 的 feature_importances_ 只能告诉你“哪个特征整体上重要”,但解释不了“这条预测为什么给了 18K”。用 SHAP 库对单条样本做解释,能输出每个特征对预测值的贡献方向:北京+3 贡献了 +2000,经验 1-3 年贡献了 -1500,最终基准值加上所有贡献得到 18K。
import shap explainer = shap.TreeExplainer(model) shap_values = explainer.shap_values(X_test) shap.summary_plot(shap_values, X_test, max_display=15)SHAP 的 summary_plot 会画出所有特征对预测的影响方向,横向是 SHAP 值,颜色是特征值大小。这张图能帮你验证模型有没有学到常识:经验年限的 SHAP 值应该随年限增加而升高,如果出现“5-10 年经验反而压低薪资”这种结论,要回去检查是不是高薪样本里混入了兼职或外包职位。我的习惯是模型上线前,拿 20 条已知薪资的真实职位去预测,把预测值和真实薪资区间做对比,偏差超过 30% 的样本逐个看 SHAP 解释,确认是模型问题还是特征缺失。这套流程走完,项目就不仅是一个爬虫脚本,而是一套能持续积累数据、定期更新结论的分析基础设施了。希望帮到你。
本文还有配套的精品资源,点击获取