BOSS直聘数据分析师职位:Scrapy爬虫到机器学习薪资预测全链路实践
简介面向高校期末大作业与毕业设计场景的完整项目包围绕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.py和jobs_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 fhttps://www.zhipin.com/web/geek/job?query数据分析师city{city[code]} yield scrapy.Request(url, callbackself.parse_job_list, meta{city: city[name]}, dont_filterTrue) 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[classjob-name]/text()).get(), salary: card.xpath(.//span[classsalary]/text()).get(), company: card.xpath(.//h3[classcompany-name]/text()).get(), city: response.meta[city], }这里要注意的是dont_filterTrue因为不同城市的目标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, encodingutf-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参数说明encodingutf-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(ascendingFalse).head(15) plt.figure(figsize(10, 8)) sns.barplot(xcity_salary.values, ycity_salary.index, paletteBlues_d) plt.title(数据分析师月薪中值 Top15 城市) plt.xlabel(月薪中值K) plt.tight_layout() plt.savefig(top15_city_salary.png, dpi200)参数说明paletteBlues_d只影响颜色映射对数据结果无影响dpi200保证图片插入论文或大作业时不会模糊。tail(20)可以换成head(30)或直接去掉head画出全量分布但城市过多时会挤在一起所以一般建议Top1520即可。图中如果出现明显的离群城市比如上海远超其他城市可以在报告里说明一线城市的数据分析岗薪资溢价情况。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(indexexp_order, columnsedu_order, fill_value0) plt.figure(figsize(9, 6)) sns.heatmap(cross, annotTrue, fmtd, cmapYlOrRd) plt.title(经验与学历交叉数量热力图) plt.tight_layout() plt.savefig(exp_edu_heatmap.png, dpi200)逻辑说明reindex在这里承担两个职责——重新排列行列顺序到预设的经验和学历顺序同时用fill_value0处理缺失组合。如果数据中有“学历不限”这类值edu_order里要单独加上否则会被reindex丢弃。热力图里的数值来自crosstab默认的计数统计fmtd表示用整数格式显示避免科学计数法。这一章的产出以图片为主建议在Notebook里把每张图都配上23句解读比如“硕士学历的岗位数量在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_unknownignore), categorical_cols), (scaler, StandardScaler(), [exp_year]), ])参数说明handle_unknownignore非常关键——测试集里如果出现训练集没见过的城市编码器不会报错而是全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_size0.2, random_state42) models { LinearRegression: LinearRegression(), RandomForest: RandomForestRegressor(n_estimators200, max_depth10, random_state42), GradientBoosting: GradientBoostingRegressor(n_estimators200, max_depth4, random_state42), } 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_state42固定随机种子保证论文里每次运行结果一致。表格输出类似下面这样模型MAERMSER²LinearRegression11.2315.870.42RandomForest9.5813.760.56GradientBoosting8.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, ascendingFalse).head(10) print(feat_imp.to_string(indexFalse))逻辑说明get_feature_names_out返回的列名形如city_上海、education_本科直接能看到是哪个城市或学历的作用。排序后一般会发现“上海”“北京”“5-10年经验”排在前列这和业务常识一致。如果发现某个城市的重要性异常高可以回去检查city.csv里是不是该城市的样本量特别大样本量不均衡会放大特征重要性。特征重要性只能说明预测贡献度不能解释因果。比如“融资阶段B轮”重要性高不代表B轮公司薪资高更可能是这个类别的样本在训练集里比较集中。要解释因果关系需要进一步做分组统计或SHAP分析。在这个Notebook里特征重要性表已经足以支撑大作业的分析章节。5. 从作业到项目参数调整与爬虫扩展技巧5.1 爬虫端可以改的三个参数第一个是DOWNLOAD_DELAY和并发数的平衡。DOWNLOAD_DELAY2.5配合默认并发16实测爬3个城市约1200条数据要610分钟。如果城市数量增加可以把延迟降到1.5但CONCURRENT_REQUESTS_PER_DOMAIN建议控制在8以内否则很容易触发风控。第二个是每个城市抓取的页数在jobs_spider.py的parse_job_list末尾往下翻页时通常有3页到10页不等直接限制meta里的页数计数器是简单有效的做法。第三个是查询关键词把query数据分析师改成query数据挖掘或query商业分析就变成了另一个细分职位的完整数据整个爬虫和分析流程不需要改动。项目里city_data目录和city.csv已经保存了城市爬虫的产出如果不想重跑爬虫直接对CSV做分析和建模也完全可行。这也体现了这个项目的可复用性爬虫、分析、建模三个环节解耦每个环节都能单独替换或扩展。5.2 模型技巧把回归预测转成薪资区间直接回归给出具体年薪数字在业务上不如给区间实用。可以用分位数切分把连续预测变成区间预测比如将预测结果按三分位切成“低于市场价”“市场价区间”“高于市场价”三档然后和真实值做交叉验证。这样做的价值在于把模型输出的数字变成决策建议。y_pred_series pd.Series(pipe.predict(X_test), indexX_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(预测区间, observedTrue)[实际年薪].agg([mean, count]) print(grouped.round(1))参数说明pd.qcut按分位数切分第三个参数labels给区间命名。注意observedTrue是pandas 2.x的写法避免未来版本对分类变量聚合时报错。如果分位切分后各组的实际年薪均值有明显梯度比如低于中位组均值15万、中位组均值23万、高于中位组均值31万说明模型预测方向和真实趋势基本一致这个结论写进分析报告比单个R²更有说服力。最后说一个排查模型的通用技巧把测试集的预测值和真实值按城市分组画散点图如果某个城市的点全部偏高或偏低大概率是这个城市在训练集中的样本太少。处理方式是回到jobs_spider.py里这个城市的搜索页翻页逻辑或者直接在df_model里对样本量小于30的城市做合并归类为“其他城市”。这个细化整理后的CSV和数据图是最值得写进文档和放进压缩包的产物。本文还有配套的精品资源点击获取