BOSS直聘数据分析师职位:Scrapy爬虫到机器学习薪资预测全链路实践
2026/9/13 16:00:09 网站建设 项目流程

简介:面向高校期末大作业与毕业设计场景的完整项目包,围绕BOSS直聘“数据分析师”岗位,覆盖爬虫采集、数据清洗与分析、可视化看板、机器学习建模预测及结果解读等全流程。代码注释详细,按爬虫、分析与可视化、机器学习划分模块,新手也能循着Notebook循序渐进,适合课程设计、综合实训参考或直接二次开发。资源内含城市岗位数据的清洗与可视化,机器学习部分提供回归建模与预测结果分析;完整输出图表分布在pics目录,便于对照理解。包体共35个文件,压缩包约1.31MB,包含3个Python脚本、3个Jupyter Notebook分析文件、1个CSV岗位数据集、26张可视化结果图及README说明,目录清晰便于按模块检索。目前已有732人浏览学习,资源内既有分模块分析与机器学习代码,也有完整输出图表,可辅助撰写实验报告或答辩展示。

1. 为什么建议用这个“BOSS直聘数据分析师职位”项目打通全链路

把爬虫、清洗、可视化、机器学习串成一条流水线,是很多人的期末大作业或毕业设计的共同需求,但真正跑通的人不多。这个项目以BOSS直聘“数据分析师”职位为对象,先写Scrapy爬虫抓岗位信息,再用pandas做清洗和聚合,之后用matplotlib输出城市、经验、学历维度的图表,最后把薪资作为预测目标交给回归模型。整套代码里有注释、有Notebook分步记录,所以即便第一次接触Scrapy或sklearn,也能按着单元格跑完。对正在找参考实现的从业者来说,价值在于它展示了一个“非理想数据”从采集到建模的完整路径,而不是只给一个调好的模型。下面的五章按复现顺序展开,每一章都留了可以直接修改的参数位置。

2. Scrapy双爬虫架构:先拉城市列表,再抓职位详情

2.1 从citylist_spider到jobs_spider的级联请求

项目中爬虫部分有两个核心文件:citylist_spider.pyjobs_spider.py。前者负责从BOSS直聘的公共接口获取城市列表,后者负责按城市抓取“数据分析师”的搜索页。这里用到了Scrapy的级联请求方式,即第一个爬虫不直接产出业务数据,而是把城市信息作为参数交给第二个爬虫。

# citylist_spider.py(思路与项目实现一致) import scrapy import json class CityListSpider(scrapy.Spider): name = "citylist" start_urls = ["https://www.zhipin.com/wapi/zpgeek/common/job/city.json"] def parse(self, response): payload = json.loads(response.text) city_list = payload.get("zpData", {}).get("cityList", []) for province in city_list: for city in province.get("subLevelModelList", []): yield { "name": city["name"], "code": city["code"], }

逻辑说明:parse方法里先把接口返回的JSON字符串转成字典,再逐层取到城市名称和城市代码。yield出来的数据会交给Scrapy的Pipeline,最终落到CSV或JSON文件。如果你本地的接口结构有变化,需要先打印payload.keys()确认层级。

jobs_spider.py的思路是接收城市代码,拼接搜索url并发送请求。

# jobs_spider.py(简化版,保留了关键逻辑) import scrapy class JobsSpider(scrapy.Spider): name = "jobs" def start_requests(self): cities = load_city_codes() # 从city.csv读取城市代码 for city in cities: url = f"https://www.zhipin.com/web/geek/job?query=数据分析师&city={city['code']}" yield scrapy.Request(url, callback=self.parse_job_list, meta={"city": city["name"]}, dont_filter=True) def parse_job_list(self, response): job_cards = response.xpath('//li[contains(@class, "job-card-wrapper")]') for card in job_cards: yield { "job_name": card.xpath('.//span[@class="job-name"]/text()').get(), "salary": card.xpath('.//span[@class="salary"]/text()').get(), "company": card.xpath('.//h3[@class="company-name"]/text()').get(), "city": response.meta["city"], }

这里要注意的是dont_filter=True,因为不同城市的目标url不同,不开启去重可以避免Scrapy把相似请求过滤掉。同时,因为搜索页的DOM结构会随站点改版而变化,真实运行前最好先用scrapy shell url检查一下.job-card-wrapper这个选择器是否还能命中。

2.2 字段设计与JSON输出管线

这个项目的目标不仅是抓下来,还要给后续Notebook做输入,所以字段设计和输出格式比爬虫本身更重要。建议字段包含岗位名称、薪资文本、城市、经验要求、学历要求、公司名称、融资阶段、技能标签。经验与学历在搜索页卡片上通常以li标签形式出现,需要做过滤处理。

# items.py class BossJobItem(scrapy.Item): job_name = scrapy.Field() salary = scrapy.Field() city = scrapy.Field() experience = scrapy.Field() education = scrapy.Field() company = scrapy.Field() financing = scrapy.Field() skills = scrapy.Field()

Pipeline输出部分,比较稳妥的做法是写入CSV而不是JSON,因为后面pandas读取更方便。Scrapy的CsvItemPipeline需要自己实现,核心就三行:打开文件、写入header、逐条写入。

# pipelines.py import csv class CsvPipeline: def open_spider(self, spider): self.file = open("jobs.csv", "w", newline="", encoding="utf-8-sig") self.writer = csv.DictWriter(self.file, fieldnames=["job_name", "salary", "city", "experience", "education", "company", "financing", "skills"]) self.writer.writeheader() def process_item(self, item, spider): self.writer.writerow(dict(item)) return item

参数说明:encoding="utf-8-sig"是为了让Excel直接打开CSV不乱码,如果只用pandas读取,用utf-8即可。写入字段顺序由fieldnames决定,所以即使item字段顺序变化也不影响表结构。

2.3 反爬策略:随机UA与请求延迟

BOSS直聘对爬虫的检测不算宽松,见者可能需要处理验证码。常见做法是在settings.py里做三件事:设置ROBOTSTXT_OBEY = False、开启下载延迟、配置随机User-Agent中间件。手动维护UA列表比每次调用第三方库更可控。

# settings.py 关键配置 BOT_NAME = "boss_jobs" ROBOTSTXT_OBEY = False DOWNLOAD_DELAY = 2.5 RANDOM_UA_TYPE = "random" DOWNLOADER_MIDDLEWARES = { "boss_jobs.middlewares.RandomUserAgentMiddleware": 543, "scrapy.downloadermiddlewares.useragent.UserAgentMiddleware": None, }

参数说明:DOWNLOAD_DELAY的单位是秒,控制同一个域名下两个请求之间的间隔,设置为2.5意味着每秒最多发出0.4个请求,这能显著降低被封概率,但爬完几个城市的数据耗时会长一些——对应这个项目的数据量,是值得的。RANDOM_UA_TYPE = "random"不是Scrapy内置选项,它来自fake-useragent库的配置,如果你用自己写的UA列表,这个字段可以去掉。

# middlewares.py 随机UA中间件 import random class RandomUserAgentMiddleware: def __init__(self): self.user_agents = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", "Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/115.0", ] @classmethod def from_crawler(cls, crawler): return cls() def process_request(self, request, spider): request.headers["User-Agent"] = random.choice(self.user_agents)

这里把UA放在请求头而不是cookie里,是因为BOSS直聘的身份校验主要不依赖cookie。遇到验证码页面时,不要硬扛,把response.status和页面标题打出来,人工确认是要输入验证码还是单纯延迟过短。该项目的价值在于整体流程,而不是对抗反爬的策略本身,所以把数据量控制在一两万条、集中在少数几个城市,比大规模抓取合理得多。

3. pandas清洗城市薪资数据与matplotlib可视化

3.1 薪资字段解析与区间提取

从BOSS直聘拿到的薪资文本通常是"20-40K""15-20K·14薪""40-70K·15薪"这类带后缀的字符串。清洗的第一步是用正则提取数字区间,同时把“14薪”这种年终月份信息拆出来,因为年终薪月份对年薪影响很大。

import re import pandas as pd df = pd.read_csv("jobs.csv") def parse_salary(s): if not isinstance(s, str): return (None, None, None) numbers = re.findall(r"\d+", s) if len(numbers) < 2: return (None, None, None) low, high = int(numbers[0]), int(numbers[1]) months_match = re.search(r"(\d+)薪", s) months = int(months_match.group(1)) if months_match else 12 return (low, high, months) 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_months"] = df["salary"].apply(lambda x: parse_salary(x)[2]) df["salary_mid"] = (df["salary_low"] + df["salary_high"]) / 2 df["annual_salary"] = df["salary_mid"] * df["salary_months"]

逻辑说明:parse_salary"20-40K"转成(20, 40, 12),把"15-20K·14薪"转成(15, 20, 14)salary_mid是月薪区间中值,annual_salary是中值乘以发薪月数,作为后面机器学习的标签。这里的一个常见坑是re.findall(r"\d+", s)会把“14薪”的14也提取出来,导致len(numbers)大于2,所以上面的实现强制只取前两个数字作为薪资区间,后面对月份单独用正则匹配。

3.2 城市-薪资分布的可视化代码

city.csv里保存的是城市数据,包括城市名、城市代码、平均薪资等字段。为了直观比较城市间的薪资差异,通常的做法是按城市分组,计算平均月薪中值和平均年薪,然后绘制横向条形图或箱线图。

import matplotlib.pyplot as plt import seaborn as sns city_salary = df.groupby("city")["salary_mid"].mean().sort_values(ascending=False).head(15) plt.figure(figsize=(10, 8)) sns.barplot(x=city_salary.values, y=city_salary.index, palette="Blues_d") plt.title("数据分析师月薪中值 Top15 城市") plt.xlabel("月薪中值(K)") plt.tight_layout() plt.savefig("top15_city_salary.png", dpi=200)

参数说明:palette="Blues_d"只影响颜色映射,对数据结果无影响;dpi=200保证图片插入论文或大作业时不会模糊。tail(20)可以换成head(30)或直接去掉head画出全量分布,但城市过多时会挤在一起,所以一般建议Top15~20即可。图中如果出现明显的离群城市,比如上海远超其他城市,可以在报告里说明一线城市的数据分析岗薪资溢价情况。

3.3 工作经验与学历的交叉分析

除了城市维度,工作经验与学历是数据分析师岗位的两个核心硬性门槛。这里用crosstab做交叉统计,再用热力图展示,比单纯画两组柱状图更有信息量。

edu_order = ["大专", "本科", "硕士", "博士"] exp_order = ["1-3年", "3-5年", "5-10年", "经验不限"] df["experience"] = df["experience"].astype(str) df["education"] = df["education"].astype(str) cross = pd.crosstab(df["experience"], df["education"]) cross = cross.reindex(index=exp_order, columns=edu_order, fill_value=0) plt.figure(figsize=(9, 6)) sns.heatmap(cross, annot=True, fmt="d", cmap="YlOrRd") plt.title("经验与学历交叉数量热力图") plt.tight_layout() plt.savefig("exp_edu_heatmap.png", dpi=200)

逻辑说明:reindex在这里承担两个职责——重新排列行列顺序到预设的经验和学历顺序,同时用fill_value=0处理缺失组合。如果数据中有“学历不限”这类值,edu_order里要单独加上,否则会被reindex丢弃。热力图里的数值来自crosstab默认的计数统计,fmt="d"表示用整数格式显示,避免科学计数法。

这一章的产出以图片为主,建议在Notebook里把每张图都配上2~3句解读,比如“硕士学历的岗位数量在3-5年经验段达到峰值,说明硕士毕业2-3年进入高阶岗位是主流路径”。图表本身不解释自己,分析结论才是大作业的加分项。

4. 机器学习薪资预测:从特征工程到模型评估

4.1 特征编码与标签构造

机器学习部分的核心文件是jobs_ml_reg.ipynb,目标是基于岗位属性预测薪资。能作为特征的字段有:城市、学历、经验、公司规模、融资阶段;不适合作为特征的是岗位名称(不同标题含义差异大)和技能标签(非结构化文本)。

标签选用年薪annual_salary,因为月薪中值受月份数影响,而年薪更接近实际收入。特征编码方面,城市、学历这类无序类别用OneHotEncoder,经验年限可以有序映射成数值。

import pandas as pd from sklearn.preprocessing import OneHotEncoder, StandardScaler from sklearn.compose import ColumnTransformer df_model = df.dropna(subset=["salary_mid", "city", "education", "experience"]) # 经验字段映射为有序数值 exp_map = {"经验不限": 0, "在校/应届": 0, "1年以内": 1, "1-3年": 2, "3-5年": 3, "5-10年": 4, "10年以上": 5} df_model["exp_year"] = df_model["experience"].map(exp_map) categorical_cols = ["city", "education", "financing"] preprocessor = ColumnTransformer([ ("ohe", OneHotEncoder(handle_unknown="ignore"), categorical_cols), ("scaler", StandardScaler(), ["exp_year"]), ])

参数说明:handle_unknown="ignore"非常关键——测试集里如果出现训练集没见过的城市,编码器不会报错,而是全0向量,这在实际爬虫数据里很常见。StandardScaler只作用于数值列,因为树模型对数值scale不敏感,但后面要对比线性回归,标准化是必须的。

标签构造时,把annual_salary里的缺失值直接删除,而不是填充,因为目标是回归,缺失标签没有训练意义。同时,把年薪的单位统一为“千元”或“万元”会减少数值量级过大带来的梯度问题,Scikit-learn的HistGradientBoostingRegressor对此不敏感,但线性模型需要。

4.2 线性回归与集成模型对比

薪资预测问题里,线性模型适合作为baseline,随机森林和梯度提升是主力。在Notebook里通常的做法是写一个对比函数,统一输出MAE、RMSE、R²三个指标。

from sklearn.linear_model import LinearRegression from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor from sklearn.pipeline import Pipeline from sklearn.model_selection import train_test_split from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score import numpy as np X = df_model[categorical_cols + ["exp_year"]] y = df_model["annual_salary"] X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) models = { "LinearRegression": LinearRegression(), "RandomForest": RandomForestRegressor(n_estimators=200, max_depth=10, random_state=42), "GradientBoosting": GradientBoostingRegressor(n_estimators=200, max_depth=4, random_state=42), } results = [] for name, model in models.items(): pipe = Pipeline([("pre", preprocessor), ("model", model)]) pipe.fit(X_train, y_train) y_pred = pipe.predict(X_test) results.append({ "model": name, "MAE": mean_absolute_error(y_test, y_pred), "RMSE": np.sqrt(mean_squared_error(y_test, y_pred)), "R2": r2_score(y_test, y_pred), }) result_df = pd.DataFrame(results).set_index("model") print(result_df.round(2))

逻辑说明:Pipeline把预处理和模型串成一个对象,fit时先跑preprocessor再跑model,测试集预测时同样自动走预处理。这样避免了数据泄露——比如必须先fit编码器才能transform测试集,Pipeline保证了这个顺序。random_state=42固定随机种子,保证论文里每次运行结果一致。

表格输出类似下面这样:

模型MAERMSE
LinearRegression11.2315.870.42
RandomForest9.5813.760.56
GradientBoosting8.9412.910.61

其中R² 0.61意味着模型解释了61%的薪资方差,这个水平在职位数据上属正常——薪资还受公司具体业务和候选人背景影响,岗位描述里无法完全体现。如果你的R²低于0.4,优先检查是否把“年终薪”字段正确用在标签里,很多低分模型是把月薪中值当标签、又混入了月份数导致的。

4.3 结果解读:特征重要性排序

树模型可以输出特征重要性,但OneHotEncoder后的特征名需要还原才能看懂。这里有个技巧:把列名从preprocessor里取出来,再对齐feature_importances_

feature_names = preprocessor.named_transformers_["ohe"].get_feature_names_out(categorical_cols).tolist() feature_names.append("exp_year") rf = models["RandomForest"] pipe_rf = Pipeline([("pre", preprocessor), ("model", rf)]) pipe_rf.fit(X_train, y_train) importance = pipe_rf.named_steps["model"].feature_importances_ feat_imp = pd.DataFrame({"feature": feature_names, "importance": importance}) feat_imp = feat_imp.sort_values("importance", ascending=False).head(10) print(feat_imp.to_string(index=False))

逻辑说明:get_feature_names_out返回的列名形如city_上海education_本科,直接能看到是哪个城市或学历的作用。排序后一般会发现“上海”“北京”“5-10年经验”排在前列,这和业务常识一致。如果发现某个城市的重要性异常高,可以回去检查city.csv里是不是该城市的样本量特别大,样本量不均衡会放大特征重要性。

特征重要性只能说明预测贡献度,不能解释因果。比如“融资阶段B轮”重要性高,不代表B轮公司薪资高,更可能是这个类别的样本在训练集里比较集中。要解释因果关系,需要进一步做分组统计或SHAP分析。在这个Notebook里,特征重要性表已经足以支撑大作业的分析章节。

5. 从作业到项目:参数调整与爬虫扩展技巧

5.1 爬虫端可以改的三个参数

第一个是DOWNLOAD_DELAY和并发数的平衡。DOWNLOAD_DELAY=2.5配合默认并发16,实测爬3个城市约1200条数据要6~10分钟。如果城市数量增加,可以把延迟降到1.5,但CONCURRENT_REQUESTS_PER_DOMAIN建议控制在8以内,否则很容易触发风控。第二个是每个城市抓取的页数,在jobs_spider.pyparse_job_list末尾往下翻页时,通常有3页到10页不等,直接限制meta里的页数计数器是简单有效的做法。第三个是查询关键词,把query=数据分析师改成query=数据挖掘query=商业分析,就变成了另一个细分职位的完整数据,整个爬虫和分析流程不需要改动。

项目里city_data目录和city.csv已经保存了城市爬虫的产出,如果不想重跑爬虫,直接对CSV做分析和建模也完全可行。这也体现了这个项目的可复用性:爬虫、分析、建模三个环节解耦,每个环节都能单独替换或扩展。

5.2 模型技巧:把回归预测转成薪资区间

直接回归给出具体年薪数字,在业务上不如给区间实用。可以用分位数切分把连续预测变成区间预测,比如将预测结果按三分位切成“低于市场价”“市场价区间”“高于市场价”三档,然后和真实值做交叉验证。这样做的价值在于把模型输出的数字变成决策建议。

y_pred_series = pd.Series(pipe.predict(X_test), index=X_test.index) # 按预测值三分位切分 pred_bins = pd.qcut(y_pred_series, 3, labels=["低于中位", "中位区间", "高于中位"]) test_df = X_test.copy() test_df["实际年薪"] = y_test.values test_df["预测区间"] = pred_bins grouped = test_df.groupby("预测区间", observed=True)["实际年薪"].agg(["mean", "count"]) print(grouped.round(1))

参数说明:pd.qcut按分位数切分,第三个参数labels给区间命名。注意observed=True是pandas 2.x的写法,避免未来版本对分类变量聚合时报错。如果分位切分后各组的实际年薪均值有明显梯度(比如低于中位组均值15万、中位组均值23万、高于中位组均值31万),说明模型预测方向和真实趋势基本一致,这个结论写进分析报告比单个R²更有说服力。

最后说一个排查模型的通用技巧:把测试集的预测值和真实值按城市分组画散点图,如果某个城市的点全部偏高或偏低,大概率是这个城市在训练集中的样本太少。处理方式是回到jobs_spider.py里这个城市的搜索页翻页逻辑,或者直接在df_model里对样本量小于30的城市做合并,归类为“其他城市”。这个细化整理后的CSV和数据图,是最值得写进文档和放进压缩包的产物。

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

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

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

立即咨询