尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

pandas 薪酬报告自动化:情报信息分析岗数据清洗与 PDF 生成

pandas 薪酬报告自动化:情报信息分析岗数据清洗与 PDF 生成 简介《2022年黑龙江省地区情报信息分析人员职位薪酬调查报告》以PDF单文档形式呈现面向黑龙江省情报信息分析岗位的求职者、招聘负责人及薪酬研究者用于回答这类职位在当地究竟值多少钱也可为薪资谈判与人力资源定薪提供依据。报告围绕外商独资、合资、本地私营与国有企业四类雇主给出P25、P50、P75、P90等分位薪酬指标年薪区间大致覆盖10.2万元至15.9万元其中外商独资企业P90可达142,038元合资与本地私营企业居中国有企业在中高收入层次仍具竞争力。文件总数1个为PDF格式包体仅176KB轻量易取可直接查阅与检索。目前已有60人学习属于细分领域的小众资料但对关注区域薪酬行情与不同企业类型报价差异的读者仍有直接参考价值。1. 报告数字被人追问三次之后我决定用代码跑情报信息分析岗薪酬HR 或市场调研岗接到《2022年黑龙江省地区情报信息分析人员职位薪酬调查报告》这种活第一反应通常是打开 Excel 手工统计再花两天调 PPT 里的图。真正麻烦的不是算数是口径情报信息分析这类岗位名称极度分散招聘信息里既有「情报分析师」「竞争情报专员」也有「信息分析岗」「舆情研究」「开源信息分析」一个关键词抓不干净样本结构立刻偏。这份报告要回答的问题其实很具体——黑龙江省内这类岗位 2022 年的薪酬中位数落在哪一档、哈尔滨与大庆差多少、三年经验和五年经验之间的台阶有多陡。把清洗、统计、出图、排版串成一条能用 pandas 重跑的流水线比手工拉表省事也更经得起追问。2. 情报信息分析岗薪酬数据的字段设计与清洗口径薪酬报告翻车的案例里八成不是算法错了而是字段一开始就没定死。同一个「经验要求」列有人取区间下限有人取中值同一个「13 薪」有人折进年薪有人只算月薪乘以 12。清洗这一层不做规范化后面算出来的 P25、P75 谁也不敢用。2.1 先钉死 12 列字段表我给这类地区性薪酬调研固定一张宽表列名和口径在建表时就写进代码常量后续所有脚本只认这套名字避免下游到处做列名映射。字段名类型口径说明常见坑report_datedate职位发布日期跨年样本混入会污染 2022 口径title_rawstr平台原始岗位名常带编号后缀如「情报分析师(J1024)」title_normstr归一化岗位名规则要留版本号便于回溯citystr工作城市统一到地市级「哈尔滨·南岗区」需拆分industrystr所属行业各平台行业分类粒度不一致org_typestr单位性质缺失率高别当主维度edustr学历要求「不限」必须单列不能并入本科exp_yearsfloat经验年限区间取中值还是下限要固定s_min / s_maxfloat月薪下限 / 上限元单位混用 k、千、万monthsint年薪月数13 薪、14 薪需折算annual_midfloat年薪中值主分析口径必须由 s_min、s_max、months 推导字段表定下来之后逐列写校验断言比事后拍脑袋靠谱。比如assert df[s_max].ge(df[s_min]).all()一旦平台数据把上下限写反脚本当场报错而不是等到出图时才发现箱线图上下颠倒。2.2 岗位名称归一化哪些岗位算「情报信息分析」归一化不能只做关键词包含还得先剔黑名单。招聘平台上「信息分析」四个字的命中面很宽信贷信息分析、销售数据分析都会撞上直接白名单匹配会把一大堆销售岗算进薪酬样本中位数会被拉高。import re import pandas as pd # 白名单真正属于情报 / 信息分析方向的岗位称谓 INCLUDE_PAT re.compile(r情报|信息分析|竞争情报|舆情|开源信息|商业信息|战略研究) # 黑名单命中即丢弃优先于白名单判断 EXCLUDE_PAT re.compile(r销售|客服|信贷|催收|中介|主播|地推) def normalize_title(raw: str): 返回归一化岗位名不匹配或命中黑名单时返回 None if not isinstance(raw, str): return None name raw.strip().replace( , ) if EXCLUDE_PAT.search(name): return None # 黑名单优先级最高 return name if INCLUDE_PAT.search(name) else None df[title_norm] df[title_raw].map(normalize_title) print(f归一化命中率{df[title_norm].notna().mean():.2%})这段逻辑的关键在判断顺序黑名单必须先跑。命中率是个重要观察指标2022 年招聘平台的这类岗位命名混乱命中率通常在 60%80% 之间如果低于 50%说明白名单写得太窄需要补词而不是继续往下跑统计。归一化规则本身要落成rules_v1这样的版本标识写进报告附录别人拿着同一批原始数据能复现同样的样本集。2.3 薪资区间文本解析与年薪折算平台导出的薪资是一列自由文本形态五花八门「8k-12k·13薪」「1.5万-2万」「10K以上」「8000-10000元/月」。用一条正则把所有形态吃下来不现实稳妥做法是主正则匹配区间兜底正则匹配单值。import numpy as np import re def _to_yuan(v: float, unit: str) - float: 把带单位的数值统一换算成元 if unit 万: return v * 10000 if unit in (k, K, 千): return v * 1000 return v RANGE_PAT re.compile( r(\d(?:\.\d)?)\s*([kK千万]?)\s*[-~—至]\s*(\d(?:\.\d)?)\s*([kK千万]?) ) SINGLE_PAT re.compile(r(\d(?:\.\d)?)\s*([kK千万]?)) def parse_salary(text: str) - pd.Series: 解析薪资文本返回 s_min / s_max / months 三列 if not isinstance(text, str): return pd.Series({s_min: np.nan, s_max: np.nan, months: np.nan}) t text.strip() months 12 m_n re.search(r(\d{2})薪, t) if m_n: months int(m_n.group(1)) # 「13薪」→ months13 m RANGE_PAT.search(t) if m: unit_lo m.group(2) or m.group(4) unit_hi m.group(4) or m.group(2) lo _to_yuan(float(m.group(1)), unit_lo) hi _to_yuan(float(m.group(3)), unit_hi) if hi lo: # 平台偶尔把上下限写反 lo, hi hi, lo return pd.Series({s_min: lo, s_max: hi, months: months}) m2 SINGLE_PAT.search(t) # 兜底「10K以上」这类单值 if m2: v _to_yuan(float(m2.group(1)), m2.group(2)) return pd.Series({s_min: v, s_max: v, months: months}) return pd.Series({s_min: np.nan, s_max: np.nan, months: months}) df[[s_min, s_max, months]] df[salary_text].apply(parse_salary) df[annual_mid] (df[s_min] df[s_max]) / 2 * df[months]三个参数要盯住months默认取 12只有文本里明确出现「N 薪」才覆盖避免把「12 个月」误读成年薪月数单位判断优先取数字紧邻的那个单位符号1.5万-2万这种两端都带单位的写法才不会算错一位数量级单值兜底必须放在区间正则之后否则8k-12k会先被8k截胡。解析完成后统计annual_mid.isna()的比例超过 15% 就得回去看原始文本长什么样而不是简单 dropna 了事。2.4 异常值截断与样本量红线薪酬数据天然右偏平台上偶尔出现挂着「3 万-5 万」的情报总监岗或者把实习生日薪误填成月薪的脏数据。分位截断比 3σ 更稳因为 σ 本身就被极值污染了。def winsorize(s: pd.Series, lower: float 0.01, upper: float 0.99) - pd.Series: 按分位点做缩尾返回截断后的序列 lo, hi s.quantile(lower), s.quantile(upper) return s.clip(lo, hi) mask df[annual_mid].notna() df.loc[mask, annual_mid_w] winsorize(df.loc[mask, annual_mid])缩尾只改极值、不改样本量比直接删除更适合薪酬报告——删掉高薪样本会让中位数系统性偏低。lower0.01 / upper0.99是常规取值样本量不足 200 条时建议放宽到 0.02/0.98避免削掉太多真实的高端岗位。注意任何分组城市的样本量低于 30 条就不要单独出中位数。少于 30 条的样本一两条极值就能左右结果报出去等于给自己挖坑处理方式是并入邻近城市或标注「样本不足仅供参考」。3. 用 pandas 算黑龙江各地市薪酬分位数与分组差异清洗完只是拿到一张干净表真正决定报告能不能用是统计口径选得对不对。地区性薪酬调研里最常见的错误是只报均值而薪酬分布是典型右偏均值永远高于大多数人的实际收入。3.1 中位数、P25、P75 与均值的取舍同一批数据这五个数字讲的是完全不同的故事均值反映企业总人力成本中位数反映「典型从业者能拿到多少」P25 和 P75 之间的区间宽度反映市场定价的混乱程度。情报信息分析这类岗位在黑龙江省内的分布尤其分化传统单位的岗位定薪相对固定而市场化机构浮动很大P75 与 P25 的比值超过 2.0 是常态。我一般把主口径定成 P50中位数均值作为附注同时在报告里明确写出「已按 1%/99% 缩尾」。下表是脚本输出的结构示意数值为示例重点看列结构。城市样本量P25中位数P75均值P75/P25哈尔滨示例示例示例示例示例示例大庆示例示例示例示例示例示例齐齐哈尔示例示例示例示例示例示例P75/P25这一列建议保留它比标准差更直观比值接近 1说明这个城市的岗位定价集中比值超过 2说明样本里混着两类完全不同的雇主报告里得分开说。3.2 按城市、经验、学历分组透视分组统计用groupby加命名聚合一行就能出表。关键动作是先用布尔索引筛掉样本不足的分组再排序避免报告读者看到一堆没意义的行。df_ok df[df[annual_mid_w].notna() df[title_norm].notna()].copy() # 经验分箱把连续年限切成报告里好读的档位 bins [-0.1, 1, 3, 5, 8, 100] labels [1年以内, 1-3年, 3-5年, 5-8年, 8年以上] df_ok[exp_band] pd.cut(df_ok[exp_years], binsbins, labelslabels) def profile(g: pd.Series) - pd.Series: return pd.Series({ 样本量: g.size, P25: g.quantile(0.25), 中位数: g.median(), P75: g.quantile(0.75), }) city_stat df_ok.groupby(city)[annual_mid_w].apply(profile).unstack() city_stat city_stat[city_stat[样本量] 30].sort_values(中位数, ascendingFalse) exp_stat df_ok.groupby(exp_band, observedTrue)[annual_mid_w].apply(profile).unstack()pd.cut的bins里用-0.1作下界是为了把 0 年经验也收进第一档rightTrue默认左开右闭3-5年含 5 不含 3报告脚注里要把这个边界写清楚。observedTrue在 pandas 较新版本里必须加否则空分组会被算进结果输出一堆全零行。样本量 30这条过滤放在排序之前能让最终表格的长度稳定下来不会因为某个城市多了两条数据就多出一行。3.3 用 matplotlib 出图箱线图与经验-薪酬曲线PDF 报告里的图建议统一用 Agg 后端渲染服务器上没有显示环境也能出图。中文字体是第一步字体没配好整张图都是方框。import matplotlib matplotlib.use(Agg) # 无显示环境下必须指定 import matplotlib.pyplot as plt # 中文字体按优先级挑第一个系统里存在的 plt.rcParams[font.sans-serif] [Noto Sans CJK SC, Source Han Sans SC, SimHei] plt.rcParams[axes.unicode_minus] False # 负号正常显示 order city_stat.index.tolist() data [df_ok.loc[df_ok[city] c, annual_mid_w] / 10000 for c in order] fig, ax plt.subplots(figsize(7.2, 3.6), dpi200) ax.boxplot(data, labelsorder, showfliersFalse, widths0.55) ax.set_ylabel(年薪万元) ax.set_title(2022年黑龙江省情报信息分析岗年薪分布按城市) ax.grid(axisy, linestyle:, alpha0.5) fig.tight_layout() fig.savefig(out/city_box.png, bbox_inchestight)showfliersFalse是刻意的缩尾后的数据里已经没有极端值箱线图再画一圈离群点只会让报告读者误以为数据没清干净。dpi200是为了满足 PDF 里的印刷清晰度figsize按报告版心的实际宽度来定A4 正文区大约 16cm换算过来 7.2 英寸刚好嵌进去不用缩放。经验-薪酬曲线用groupby(exp_band).median()出折线X 轴保持pd.cut的顺序别让 matplotlib 按字符串排序把「8年以上」排到「1年以内」前面。4. 把统计结果排成 PDFReportLab 与 WeasyPrint 两条路统计表和 PNG 都有了最后一段是排版。这一步很多人退回 Word 手工调结果每次数据更新都要重排一遍。两条自动化路线可以选纯 Python 的 ReportLab或者 HTML CSS 交给 WeasyPrint。4.1 图表先落 PNG再谈排版不管走哪条路线图表都先由 matplotlib 存成 PNG 落到out/目录再由排版层引用文件。不要让排版库去调用绘图库两层耦合之后换字体、改配色都得同时动两个地方。文件名用固定规则比如city_box.png、exp_line.png脚本里按名字引用重跑时直接覆盖。4.2 ReportLab中文字体注册与三线表绘制ReportLab 的坑几乎全在中文上。它默认字体不含 CJK 字形不注册就整篇豆腐块。from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm from reportlab.lib import colors from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.lib.styles import ParagraphStyle from reportlab.platypus import (SimpleDocTemplate, Paragraph, Table, TableStyle, Image, Spacer) # 注册中文字体ttc 字体包必须指定 subfontIndex pdfmetrics.registerFont(TTFont( CJK, /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc, subfontIndex0, )) h1 ParagraphStyle(h1, fontNameCJK, fontSize15, leading22, spaceAfter8) body ParagraphStyle(body, fontNameCJK, fontSize9.5, leading16) # 三线表只保留顶线、表头线、底线 rows [[城市, 样本量, P25, 中位数, P75]] city_stat.reset_index().values.tolist() table Table(rows, colWidths[35*mm, 22*mm, 26*mm, 26*mm, 26*mm], hAlignLEFT) table.setStyle(TableStyle([ (FONTNAME, (0, 0), (-1, -1), CJK), (FONTSIZE, (0, 0), (-1, -1), 9), (LINEABOVE, (0, 0), (-1, 0), 0.8, colors.black), (LINEBELOW, (0, 0), (-1, 0), 0.4, colors.black), (LINEBELOW, (0, -1), (-1, -1), 0.8, colors.black), (ALIGN, (1, 0), (-1, -1), RIGHT), (VALIGN, (0, 0), (-1, -1), MIDDLE), ])) doc SimpleDocTemplate(out/report.pdf, pagesizeA4, leftMargin20*mm, rightMargin20*mm, topMargin22*mm, bottomMargin20*mm, title2022年黑龙江省地区情报信息分析人员职位薪酬调查报告) doc.build([Paragraph(一、样本与口径, h1), Paragraph(说明文字…, body), Spacer(1, 4*mm), table, Spacer(1, 6*mm), Image(out/city_box.png, width160*mm, height80*mm)])registerFont里的subfontIndex是 Noto CJK 这类.ttc字体包的必需参数不传会报解析错误如果是单个.ttf文件则不要传。表格列宽必须手动给ReportLab 不会自动按内容分配中文列名容易被压成两行。三线表的样式规则里LINEBELOW (0, -1)定位最后一行配合LINEABOVE (0, 0)就是标准的学术报告样式比满框网格线清爽得多。doc.build()的title参数会写进 PDF 元数据用pdfinfo一查就能确认方便归档检索。4.3 WeasyPrintHTML CSS 分页方案如果报告里文字段落多、要分章节页码HTML 路线写起来更顺手分页交给 CSS 的page。from weasyprint import HTML, CSS CSS_TEXT page { size: A4; margin: 20mm 18mm; bottom-center { content: 第 counter(page) 页 / 共 counter(pages) 页; font-size: 9pt; } } body { font-family: Noto Sans CJK SC, sans-serif; font-size: 10.5pt; line-height: 1.7; } h1 { font-size: 15pt; page-break-before: always; } table { width: 100%; border-collapse: collapse; } th, td { padding: 4px 8px; border-bottom: 0.5pt solid #999; text-align: right; } th:first-child, td:first-child { text-align: left; } figure { page-break-inside: avoid; } /* 图表不与题注分离 */ HTML(stringopen(tpl/report.html, encodingutf-8).read()) \ .write_pdf(out/report.pdf, stylesheets[CSS(stringCSS_TEXT)])page-break-before: always让每个一级标题新起一页page-break-inside: avoid保证图片和它的题注不被拆到两页上这两条是排版质量的分水岭。页脚的counter(page)和counter(pages)由 WeasyPrint 自动计算不需要手工维护。4.4 两种方案的取舍维度ReportLabWeasyPrint上手成本坐标式布局表格列宽要手算会 HTML/CSS 就能写中文字体需手动注册 TTFont走系统 fontconfig配好即可分页控制手动插 PageBreakCSSpage 分页属性依赖纯 Python无系统依赖依赖 pango、cairo 等系统库适合场景表格密集、版式固定的报告图文混排、章节结构复杂的长报告部署环境受限比如纯容器、不便装系统库优先 ReportLab报告以文字叙述为主、需要精细页码和目录选 WeasyPrint。5. 报告口径审计与一键重跑让薪酬数字经得起追问薪酬报告发出去之后一定会有人问「这个中位数怎么算的」「为什么大庆比哈尔滨高」。能不能当场答上来取决于有没有留审计痕迹。5.1 交叉校验三张表必须对得上最低限度要保证三处数字一致汇总表里各城市样本量之和等于总样本量分组表的中位数落在总表中位数所在的合理区间内缩尾前后的中位数差异要记录在案。把这三条写成断言跑在生成 PDF 之前。# 校验 1分组样本量之和 总样本量 assert city_stat[样本量].sum() len(df_ok), 城市分组样本量超过总样本量 # 校验 2缩尾前后中位数偏移不超过 5%超出则说明极值影响过大 shift abs(df_ok[annual_mid_w].median() - df_ok[annual_mid].median()) assert shift / df_ok[annual_mid].median() 0.05, f缩尾偏移过大{shift:.0f} 元第二条断言特别有用如果缩尾把中位数拉动了 5% 以上说明原始数据里的极值比例异常通常是薪资解析的单位搞错了——比如把「1.5万」算成了 1.5 元。这类 bug 不会报错只会安静地把整份报告的数字改掉一半。5.2 样本量阈值与异常值回溯每条进入报告的数字都要能追回原始行。做法是给宽表加一列row_id指向原始数据文件的行号报告附录里放一张「样本量构成表」写清抓取总量、归一化命中量、薪资可解析量、缩尾后进入统计量四个数字层层递减每一层的损耗原因都写一句。缩尾截掉的行单独导出一份out/winsorized_outliers.csv别人质疑时直接甩文件。5.3 一键重跑与产物校验整条流水线写成一个run.sh数据、规则、模板三样东西都进版本管理。重跑后先算产物哈希再和上一次对比哈希变了就说明中间有变量没锁死。#!/usr/bin/env bash set -euo pipefail # 任一环节失败立即中断不产出半成品 PDF python clean.py # 清洗 归一化输出 data/clean.parquet python analyze.py # 统计 出图输出 out/*.png 与 stat/*.csv python render.py # 排版输出 out/report.pdf sha256sum out/report.pdf stat/*.csv dist/checksums.txtset -euo pipefail是整条流水线里最值钱的一行中间任何一步失败都立刻停不会用上一轮遗留的旧 PNG 混着新表格拼出一份看起来正常的 PDF。这类「半新半旧」的报告最危险数字对不上还查不出是哪一步断的。真正需要长期维护的其实是rules_v1这份岗位归一化规则和bins这份经验分箱边界它们比代码更容易被改也更容易被忘记改。把它们抽成独立的 YAML 文件改动时写进变更记录明年做 2023 版时只要换数据源和这几个参数报告就能重出一版。本文还有配套的精品资源点击获取
返回列表