ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

爬虫+机器学习:BOSS直聘数据分析师薪资预测实战

爬虫+机器学习:BOSS直聘数据分析师薪资预测实战 简介基于BOSS直聘“数据分析师”职位信息构建的完整数据项目涵盖爬虫实现、数据清洗、可视化分析与机器学习预测四大环节适合数据相关专业学生用于期末大作业、课程设计或毕业设计。资源包共35个文件包含3个Python脚本、3个Jupyter Notebook分析文档、1份CSV职位数据集、26张可视化结果图及README说明压缩包仅1.31MB模块划分清晰便于按目录针对性学习。目前已有732人学习下载代码带有详细注释新手也能读懂爬虫、分析与建模思路。简单部署后即可复现从职位信息采集、城市分布统计到薪资回归预测的完整流程具有较强的完整性可作为课程设计和综合项目的高分参考范例。1. 从一条JD反推BOSS直聘爬虫与预测任务的边界第一次用requests去抓BOSS直聘的职位列表时我得到的HTML里只有几个固定的div职位数据一条都没有。这不是你写错了而是列表页由前端框架异步渲染真正的内容在浏览器Network面板里的XHR请求中。这个项目把“数据分析师”作为目标职位走通从接口采集、字段清洗、可视化到机器学习预测平均薪资的完整链路。你会看到一份JD里的“20-30K·14薪”如何变成模型里的数值特征也会看到最后的预测结果为什么不能直接当薪资谈判依据。整个方案基于公开接口做低频访问适合期末项目、数据分析作品集以及想研究招聘市场技能要求的在职工程师。2. 爬虫实现找到数据分析师职位列表的真实接口2.1 为什么直接抓页面源码是错的BOSS直聘职位列表页的HTML在首次加载时只有页面骨架职位数据由JavaScript向服务端异步请求后动态填充。直接在Python里用requests请求列表页URL得到的response里找不到任何“数据分析师”职位最多能看到一些初始化变量。这也是很多新手把爬虫写成Selenium的根本原因——不是requests不行而是你请求错了资源。常见做法是把页面当作一个前端应用来调试打开开发者工具切到Network面板筛选XHR请求然后刷新或滚动列表页观察哪几个请求返回了包含jobList或jobId的JSON。复制这个请求的URL和参数再用requests改写。这个方法不依赖浏览器响应快、机器开销低也更容易控制采集频率。代价是接口的字段名是内部约定可能随版本调整代码里要预留容错。我一般会把抓到的接口参数先记下来重点看这几个query、city、page、pageSize。query是搜索关键词city是国家行政区划代码或城市编号page从1开始递增pageSize控制每页返回数量。确定这些参数后一个最小采集函数就能工作。2.2 用requests写一个最小采集函数先准备一组User-Agent和请求头避免固定UA被服务端识别import random import time import requests UA_POOL [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36, ] def fetch_job_page(query: str, page: int) - list: url https://www.zhipin.com/wapi/zpgeek/search/joblist.json params { query: query, city: 100010000, page: page, pageSize: 30, } headers { User-Agent: random.choice(UA_POOL), Referer: https://www.zhipin.com/web/geek/job, Accept: application/json, } resp requests.get(url, paramsparams, headersheaders, timeout10) resp.raise_for_status() data resp.json() return data.get(zpData, {}).get(jobList, [])这段代码里params中的query是“数据分析师”city是一个城市编号实际接口路径以你本地抓到的为准上面这个路径是这类接口常见的形态字段名也可能不同。timeout设置成10秒避免某个请求把脚本挂死。resp.raise_for_status()会在响应码为4xx或5xx时抛出异常让后续重试逻辑接管。得到jobList后定义一个解析函数把嵌套字段拍平def parse_job(job: dict) - dict: return { job_id: job.get(jobId), job_name: job.get(jobName), salary: job.get(salary), city: job.get(cityName), experience: job.get(jobExperience), education: job.get(jobDegree), company: job.get(brandName), industry: job.get(brandIndustry), skills: |.join(job.get(jobLabels, []) or []), update_time: job.get(updateTime), }skills字段在原始JSON里可能是列表比如[“Python”, “SQL”]写入CSV时直接存列表会变成字符串表示所以这里用竖线拼接。后续分析时按竖线拆分就能还原。2.3 请求频率控制的三个必调参数BOSS直聘这类站点对高频访问比较敏感最简单的保护墙就是请求频率。很多人一上来就写for page in range(1, 100)结果跑了十几页就被中断。常见做法是把三个参数写成一个配置参数推荐设置作用interval1-3秒随机让请求间隔不规律避免机械节奏retry3次指数退避处理超时、连接重置和429响应total_pages先跑3页验证验证字段和入库逻辑后再放量配合一个带重试的请求函数def request_with_retry(url: str, params: dict, headers: dict, retries: int 3) - dict: for attempt in range(retries): try: resp requests.get(url, paramsparams, headersheaders, timeout10) if resp.status_code 429: time.sleep(5 * (2 ** attempt)) continue resp.raise_for_status() return resp.json() except requests.RequestException: time.sleep(5 * (2 ** attempt)) return {}指数退避的逻辑是每次失败后等待时间翻倍第1次5秒第2次10秒第3次20秒。这样既不会在服务端拒绝时继续猛冲也不会因为一次网络抖动就丢数据。提示只采集登录后也能看到的公开列表页数据不要带上登录态去抓权限范围外的内容也不要对页面做高频压测。用于学习的数据项目低频请求足够。3. 数据分析与特征工程薪资文本如何变成可建模字段3.1 正则解析薪资区间从接口拿到的salary字段是文本常见格式有“15-20K”、“20-30K·14薪”、“面议”、“1-1.5万”。建模前要先把这些字符串变成数字。一个可靠的做法是保留前两个数字作为上下限计算他们的平均值为区间中点。import re def parse_salary(s: str): if not s or 面议 in s: return None, None, None s s.replace(K, ).replace(k, ) scale 10 if 万 in s else 1 nums [float(x) for x in re.findall(r\d(?:\.\d)?, s)] if len(nums) 2: low, high nums[0] * scale, nums[1] * scale return low, high, (low high) / 2 return None, None, None这里先replace掉K再找数字是为了让“20-30K”变成“20-30”避免正则把K后面的内容也带进来。字符串里如果有“·14薪”findall也会抓到14但由于我们只取前两个数字14会被忽略。另一个细节是“万”要乘10而不是乘1000“1-1.5万”的意思是1万到1.5万转换成K单位就是10-15K。解析完可以加一列校验如果salary_low大于salary_high说明源数据格式异常直接丢弃。确认解析逻辑正确后再批量应用到整个DataFrame。3.2 经验学历的离散映射经验字段是文本比如“1-3年”、“3-5年”、“经验不限”。学历字段是“大专”、“本科”、“硕士”。树模型可以直接吃标签编码但标签编码的顺序最好有业务含义。经验映射成年数学历按等级映射让单调顺序成立。原始字段原始值映射值含义experience经验不限 / 在校/应届00年experience1-3年2取中位数experience3-5年4取中位数education大专1学历等级education本科2学历等级education硕士3学历等级EXP_MAP { 经验不限: 0, 在校/应届: 0, 1年以内: 1, 1-3年: 2, 3-5年: 4, 5-10年: 7, 10年以上: 10, } EDU_MAP { 学历不限: 0, 大专: 1, 本科: 2, 硕士: 3, 博士: 4, } df[exp_num] df[experience].map(EXP_MAP) df[edu_num] df[education].map(EDU_MAP)用map映射的优点是未命中值变成NaN后续用缺失值统计一眼就能看出哪些原始字段写法没覆盖到。比如有的JD写“1年经验”而不是“1年以内”映射后是NaN这时需要去查原始数据把新值补进字典。3.3 去重、缺失与异常值检查采集到的数据要先过一次质量检查再做特征。最少要做三步按job_id去重、丢弃薪资为空的记录、过滤薪资上下限反转的异常行。import pandas as pd df pd.read_csv(jobs.csv) print(原始记录数:, len(df)) df df.drop_duplicates(subset[job_id]) df df.dropna(subset[salary_low, salary_high]) df df[df[salary_low] df[salary_high]] df[skill_num] df[skills].fillna().apply( lambda x: len(x.split(|)) if x else 0 )skill_num表示JD里写了几个技能标签。这个特征虽然简单但在招聘数据里往往比“是否要求Python”更能反映岗位的专业深度因为JD列出的技能数量通常和薪资有正相关。这里有一个容易忽略的点drop_duplicates默认保留第一条如果爬虫分多天跑同一职位在不同天出现应该保留最近一次update_time的记录。操作上要先按update_time排序再drop_duplicates否则可能把最新的薪资信息丢掉。4. 数据可视化用pyecharts展示职位分布与薪资结构4.1 为什么要选交互式图表这一步的目标是让结论能被非技术同事直接看。matplotlib的静态图适合写报告但筛选交互弱pyecharts生成HTML基于ECharts打开浏览器就能缩放、悬停、点击做数据可视化大屏也很方便。安装只需要pip install pyecharts图表导出是独立的HTML文件不依赖Jupyter环境。4.2 不同学历的平均薪资条形图先做最稳的一张图学历等级对应的薪资平均值。这里要用之前解析出的salary_mid按education分组。from pyecharts import options as opts from pyecharts.charts import Bar edu_order [学历不限, 大专, 本科, 硕士, 博士] avg_salary df.groupby(education)[salary_mid].mean().reindex(edu_order).round(1) bar ( Bar() .add_xaxis(edu_order) .add_yaxis(平均薪资(K), avg_salary.fillna(0).tolist()) .set_global_opts( title_optsopts.TitleOpts(title不同学历的数据分析师平均薪资), xaxis_optsopts.AxisOpts(name学历), yaxis_optsopts.AxisOpts(name平均薪资(K)), ) ) bar.render(edu_salary.html)groupby之后用reindex是为了固定顺序否则pyecharts会按字母排序图表顺序就会变成“本科、大专、硕士”这种没有业务含义的排列。fillna(0)是为了防止某个学历类别没有数据时图表报错。render函数会把图表写入一个独立的HTML文件。4.3 四张图表覆盖分析主线一个完整的可视化页面不需要堆十几张图能回答四个问题就够了。图表使用字段回答的问题薪资分布直方图salary_mid数据分析师的薪资集中在哪个区间城市平均薪资条形图city, salary_mid哪些城市薪资明显更高经验要求占比饼图experience市场更接受有几年经验的人技能标签词云skills招聘JD里出现最多的技能把单张图表组合成看板用pyecharts的Pagefrom pyecharts.charts import Page page Page(layoutPage.SimplePageLayout) page.add(bar, bar_city, pie_exp, wordcloud_skills) page.render(analysis_dashboard.html)Page.SimplePageLayout会把多个图表按行列自动排列免去手动设置grid坐标。做数据可视化大屏时这个组合比单图更接近交付形态。注意图表的作用是暴露问题不是装饰。比如城市平均薪资图如果显示“北京远高于其他城市”下一步就要回数据里看北京样本量是不是太少避免用小样本画大结论。5. 机器学习预测与结果分析从薪资建模到特征重要性解读5.1 先建一个基线再谈模型预测“薪资区间中点”是一个回归问题。不少项目一上来就跑梯度提升最后R2只有0.3却说模型不行。问题往往不在模型而在于没有基线。DummyRegressor用训练集均值做预测代表“不看任何特征”的下限。from sklearn.dummy import DummyRegressor from sklearn.metrics import mean_absolute_error, r2_score from sklearn.model_selection import train_test_split X df[[exp_num, edu_num, skill_num]] y df[salary_mid] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 ) dummy DummyRegressor(strategymean) dummy.fit(X_train, y_train) y_base dummy.predict(X_test) print(Baseline MAE:, mean_absolute_error(y_test, y_base))Mean策略下MAE大约等于薪资中点的标准差。如果后续随机森林的MAE只比基线低5%说明特征的预测能力很弱应该先做特征分析而不是继续调参。5.2 城市编码与随机森林训练城市是字符串“北京”、“上海”、“杭州”这样几十个类别直接进模型不行。树模型可以用LabelEncoder因为决策边界不受编码顺序影响线性回归则要用OneHot编码。先把城市转成数值from sklearn.ensemble import RandomForestRegressor from sklearn.preprocessing import LabelEncoder city_enc LabelEncoder() df[city_code] city_enc.fit_transform(df[city].astype(str)) X df[[exp_num, edu_num, city_code, skill_num]] y df[salary_mid] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 ) model RandomForestRegressor( n_estimators200, max_depth10, min_samples_leaf3, random_state42, ) model.fit(X_train, y_train) y_pred model.predict(X_test) print(RandomForest MAE:, mean_absolute_error(y_test, y_pred)) print(RandomForest R2:, r2_score(y_test, y_pred))n_estimators决定树的数量200棵足够捕捉数据规律再大收益递减max_depth限制树的深度防止单棵记住噪声min_samples_leaf3要求叶子节点至少3个样本减小方差。对于几千条职位数据这几个参数不需要激进调优。如果还要加行业、公司规模等特征记得先把缺失值填掉。RandomForestRegressor不接受NaN常见做法是用“未知”或众数填充而不是删行。5.3 特征重要性与结果分析训练完之后结果分析不是只看MAE和R2还要看特征重要性。importance sorted( zip(X.columns, model.feature_importances_), keylambda x: x[1], reverseTrue, ) for feat, imp in importance: print(f{feat}: {imp:.4f})输出结果大致能回答这些问题经验年数是否最影响薪资城市贡献是否大于学历技能数量是否几乎不重要。下面是一张常见现象对照表现象可能的解释下一步exp_num重要性最高数据分析师薪资随经验线性增长可做分经验段的均值对比city_code重要性高城市间薪资差异大分城市建模或把城市换成levelskill_num重要性很低技能种类比数量更重要改用是否包含特定技能R2低于0.2JD薪资噪声大特征覆盖不足检查缺失率补充字段这里的核心结论是模型预测值是JD上写的薪资区间中点不是真实offer。企业写JD时会结合预算和岗位定级这个数天然存在大量噪声。所以预测结果的价值在于分析相对排序比如“5年经验且在一线的数据分析师JD薪资普遍在30K以上”而不是把它当成谈薪的精确答案。如果R2实在低检查两个地方一是特征矩阵里是否混进了太多缺失值填充的占位二是薪资分布是否严重右偏如果是对salary_mid取log后再训练MAE的报告口径也要改回原单位。6. 并发采集与增量更新把爬虫脚本接到最后一个环节6.1 用ThreadPoolExecutor做可控并发单线程跑300页职位列表会很慢但并发设计不是num_workers越大越好。对BOSS直聘这类接口合理做法是4个线程、每页间隔1-2秒。用ThreadPoolExecutor包一层from concurrent.futures import ThreadPoolExecutor, as_completed import random import time def safe_fetch(page: int): try: return fetch_job_page(query数据分析师, pagepage) except Exception: return [] with ThreadPoolExecutor(max_workers4) as pool: futures [pool.submit(safe_fetch, page) for page in range(1, 11)] for f in as_completed(futures): jobs f.result() for job in jobs: save_row(parse_job(job)) time.sleep(random.uniform(1, 2))safe_fetch把异常吞掉并返回空列表避免单个页面失败导致整个线程池退出。as_completed谁先完成先处理谁不用等待前一个页面完成。sleep放在处理完每个future后让请求节奏有一个随机波动。6.2 增量更新用job_id去重爬虫如果只跑一次价值有限。增量更新的思路很简单把已采集的job_id放进一个set新请求返回的job_id如果已经在set里就跳过否则写入并加入set。import os csv_path jobs.csv if os.path.exists(csv_path): seen set(pd.read_csv(csv_path)[job_id].astype(str)) else: seen set() # 在save_row里检查 def save_row(job_dict): if str(job_dict[job_id]) in seen: return pd.DataFrame([job_dict]).to_csv(csv_path, modea, headernot os.path.exists(csv_path), indexFalse) seen.add(str(job_dict[job_id]))这里用追加写入而不是反复重写整个CSV大数据量下省很多时间。header参数只在文件不存在时写入。6.3 跑完后的三个数据校验增量任务跑到一半最怕静默失败。我会在采集结束后做这样一个检查check { 总记录数: len(df), 缺失率: df.isna().mean().round(4), 薪资字段数量: df[salary_mid].notna().sum(), } print(check)如果总记录数没增加去看接口返回是否为空如果薪资字段缺失率超过5%先查parse_salary的正则而不是继续堆数据。跑完检查脚本确认缺失率和重复率都正常模型才可以直接拿去做下一轮训练。同时把seen集合和CSV里的job_id做一次全量比对发现不一致就说明增量逻辑里漏了去重需要单独修正后再继续采集。本文还有配套的精品资源点击获取
返回列表