简介:本资源为《2024年毕业生薪酬白皮书》配套演示文稿,面向即将毕业的高校学生、就业指导教师及职业规划从业者,帮助读者系统了解当前就业市场的薪酬水平、影响因素与行业趋势,为求职定位与薪资谈判提供数据参考。资源包含1个pptx文件,压缩包约1001KB,以图文幻灯片形式组织章节内容,便于课堂展示或个人快速浏览。已有294人学习下载,具备一定的参考热度。内容覆盖毕业生总体薪酬概况与城市差异、学历与专业对薪酬的影响、热门专业与人文社科专业的起薪对比、个人能力与院校背景的作用,以及2024年薪酬预期与实际薪酬的差异分析;同时对金融、科技、医疗等行业前景及数据分析、数字化、人工智能等技能带来的薪酬变化作出预测,并给出提升竞争力的建议,适合用于就业指导课件制作与个人职业方向研判。
1. 别再用截图和手敲:一份薪酬白皮书其实是结构化数据源
很多人拿到《2024年毕业生薪酬白皮书.pptx》,动作是打开、翻到第 12 页、截图、再对着 Excel 一格一格敲「平均月薪」「中位数」。四十页、十几张表、七八张柱状图,敲完一下午没了,还容易把「年包 18.6 万」抄成「月薪 18.6 千」。反直觉的地方在于:这份 PPT 里的每一个数字,本来就躺在机器可读的位置——pptx 只是一包 zip,表格在ppt/slides/slideN.xml的<a:tbl>里,图表数值在ppt/charts/chartN.xml的<c:numCache>里,图表背后的原始工作表还作为 embedded xlsx 打包在ppt/embeddings/目录下。
这篇要解决的是把它变成可复用的数据资产:用 python-pptx 遍历页与形状,抽出全部薪酬表和图表序列,落成一张长表,再处理分位数口径、城市行业归一化、同比增速这些真正决定分析对不对的问题。适合要做行业薪酬分析、搭看板、或者把白皮书数据并进自己数仓的数据与后端同学;只想确认「读 PPT 该用哪个库」的,看完第 2、3 章就能直接抄。
2. PPTX 的 OOXML 结构与 python-pptx 对象模型怎么对上
2.1 先别急着写代码:把白皮书当压缩包拆开看目录
.pptx后缀只是包装,内核是 OPC(Open Packaging Conventions)包:一个 zip,里面是若干 XML 部件加资源文件,部件之间靠_rels/*.rels互相引用。所以第一步不是装库,是unzip看一眼真实目录,这比读任何文档都快。
mkdir -p /tmp/wp && cd /tmp/wp unzip -o "2024年毕业生薪酬白皮书.pptx" -d unpacked # 看一级目录结构,确认有没有图表与嵌入工作簿 find unpacked/ppt -maxdepth 2 -type d | sort # 图表数值、嵌入表格、备注页分别在哪儿 ls unpacked/ppt/charts unpacked/ppt/embeddings unpacked/ppt/notesSlides 2>/dev/null || trueunzip -o表示覆盖同名文件,-d指定解包目录。注意ppt/charts和ppt/embeddings并非必然存在,纯文本排版的 PPT 就没有,所以用|| true兜住报错,别让脚本在这里断掉。
| 路径 | 里面是什么 | 抽取时的用途 |
|---|---|---|
ppt/slides/slideN.xml | 该页的形状树 | 标题文本框、表格<a:tbl> |
ppt/slides/_rels/slideN.xml.rels | 该页引用的外部部件 | 顺藤摸到 chart、图片、嵌入对象 |
ppt/charts/chartN.xml | 图表定义与数值缓存 | 系列名、分类轴、每个数据点 |
ppt/embeddings/*.xlsx | 图表背后的工作表 | 还原完整数据、补齐缓存缺失值 |
ppt/notesSlides/notesSlideN.xml | 演讲者备注 | 样本量、口径说明、数据来源注释 |
ppt/media/ | 图片资源 | 被贴成图片的图表只能走 OCR |
最值得记住的一点:图表数值有两份。chartN.xml里的numCache是渲染快照,embeddings下的 xlsx 是原始表。作者在 PPT 里正常编辑时两者一致;但只要做过「粘贴为图片」或者后期手改过 XML,缓存就可能过期。chart 与 embeddings 对不上时,以 embeddings 为准。
2.2 python-pptx 的 Presentation / Slide / Shape 三层映射
python-pptx 的对象模型几乎是 XML 的一对一映射:Presentation对应ppt/presentation.xml,prs.slides对应ppt/slides/下的页,slide.shapes对应页内的形状树。先跑一遍巡检脚本,把「这一页有什么」摸清楚,比直接写抽取逻辑高效得多。
# inspect_shapes.py:先摸清每页有哪些形状类型 from pptx import Presentation prs = Presentation("2024年毕业生薪酬白皮书.pptx") print("总页数:", len(prs.slides)) for page, slide in enumerate(prs.slides, start=1): for sh in slide.shapes: print( page, sh.shape_id, # 形状 ID,同一版本内唯一,跨版本会变 sh.name, # 作者命名,常是「表 3-1」「图 4-2」 sh.shape_type, # 文本框 / 表格 / 图表 / 组合 / 图片 sh.has_text_frame, # 是否有文字 sh.has_table, # 是否是可编程表格 sh.has_chart, # 是否是原生图表 )输出里最该关注三列:shape_type是GROUP的形状需要递归展开,否则里面的表格会整块漏掉;has_chart为 True 的形状,数值要从 chart 部件读;而has_table为 False 但shape_type看起来像表格的,多半是图片或嵌入对象,只能另想办法。
关于定位主键:shape_id在同一份文件里唯一,但白皮书每年重做一次,ID 会整体重排,不能当跨年比对的主键。更稳的做法是用(页码, 形状名, 表头指纹)三元组,其中表头指纹就是表头文字拼接后的哈希——只要作者没改表头,跨版本就能对上。
2.3 合并单元格、文本框标题和备注页:最容易被丢掉的三个信息源
薪酬白皮书为了排版好看,几乎必然出现跨行跨列的合并单元格,比如「一线城市」横跨三行,只在第一行有文字,后两行是空串。python-pptx 的cell.text只会返回空,于是抽取结果里那三行城市全变成 NaN。较新版本的 python-pptx 提供了cell.is_merge_origin与cell.is_spanned两个判断,旧版本可以退回读 XML 属性:
# 读 a:tc 上的合并属性,gridSpan 横向跨度,rowSpan 纵向跨度 def cell_span(cell): tc = cell._tc return { "gridSpan": tc.get("gridSpan"), # '2' 表示横向占两格 "rowSpan": tc.get("rowSpan"), # '3' 表示纵向占三格 "hMerge": tc.get("hMerge"), # '1' 表示被左侧格横向吞并 "vMerge": tc.get("vMerge"), # '1' 表示被上方格纵向吞并 }标题同理。白皮书的页标题大多不是占位符,而是一个普通文本框,位置在页面顶部。可靠的做法遍历所有带文字的形状,取sh.top最小且文字长度大于 4 的那段作为页标题——sh.top的单位是 EMU,越小越靠上。
第三个信息源是备注页。口径说明往往写在备注里,比如「平均月薪为算术平均,含年终奖摊薄」「样本量为 12,438 份有效问卷」。抽出来当作该页所有数值行的note字段,后面做口径校验时会省很多事。抽取方式是slide.has_notes_slide为真时读slide.notes_slide.notes_text_frame.text。
提示:单位经常不在单元格里,而在表头上方一行小字,内容是「单位:元/月」或「单位:万元/年」。这一行必须单独抓,否则后面做数值换算时会整列错一个数量级。
2.4 三条抽取路线的取舍
纯 Python 生态里能走的路不止一条,选错方向会在后期付出成倍的调试成本。
| 路线 | 依赖 | 适合场景 | 代价 |
|---|---|---|---|
| python-pptx | 纯 Python,pip 即装 | 结构化表格、原生图表、批量抽取 | 合并单元格、跨部件关系要自己补 |
| lxml 直读 OOXML | lxml | python-pptx 没暴露的属性与 rels 关系 | 命名空间琐碎,代码可读性差 |
| LibreOffice 无头转换 | 需安装 soffice | 整页版式还原、扫描页兜底 | 环境重、转换有损、CI 里要额外装包 |
我一般会混用前两条:主干用 python-pptx,碰到vMerge恢复、chart 到 embeddings 的 rels 跳转这类细节,再下沉一层用 lxml 补。LibreOffice 只在最后做一次人工校对用的整页 PDF 导出,不进主流程。
3. 用 python-pptx 把薪酬表和图抽成 DataFrame 的最小可运行脚本
3.1 依赖准备与项目目录
python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install python-pptx pandas openpyxl lxmlpython-pptx负责解析 PPT 结构与图表对象,openpyxl是 pandas 读嵌入 xlsx 的引擎,lxml用于兜底读 XML 属性。项目目录建议拆成extract/(抽取)、clean/(清洗)、report/(出图与写回)三层,因为清洗逻辑会随着白皮书改版不停调整,混在一个文件里两周后就没人敢改了。
3.2 递归遍历形状并把表格转成记录
先写两个基础函数:一个递归展开组合形状,一个按位置挑页标题。
# extract_tables.py from pptx import Presentation from pptx.enum.shapes import MSO_SHAPE_TYPE def iter_shapes(shapes): """递归展开组合形状,避免组合内的表格被整块漏掉""" for sh in shapes: if sh.shape_type == MSO_SHAPE_TYPE.GROUP: yield from iter_shapes(sh.shapes) else: yield sh def slide_title(slide): """标题多为普通文本框而非占位符:取最靠上、文字较长的那段首行""" best, best_top = "", None for sh in iter_shapes(slide.shapes): if not sh.has_text_frame: continue text = sh.text_frame.text.strip() if len(text) < 4: continue if best_top is None or sh.top < best_top: best, best_top = text.splitlines()[0], sh.top return bestiter_shapes用生成器递归,好处是内存占用与页数无关。slide_title里的len(text) < 4是过滤页码、单位小字这类噪声;sh.top是 EMU 单位的纵坐标,白皮书页面上方通常还有一条细装饰线,也会被当成形状,但它没有文字,会被过滤掉。
然后是表格主体与表头识别:
HEADER_HINTS = ("城市", "学历", "行业", "岗位", "分位", "月薪", "年薪", "平均", "样本") def split_header(grid): """判断首行是否为表头:命中关键词数达到阈值就当表头""" if not grid: return [], [] first = grid[0] hit = sum(1 for c in first if any(h in c for h in HEADER_HINTS)) if hit >= max(2, len(first) // 3): return first, grid[1:] return [f"col{i}" for i in range(len(first))], grid def extract_tables(pptx_path): prs = Presentation(pptx_path) for page, slide in enumerate(prs.slides, start=1): title = slide_title(slide) for sh in iter_shapes(slide.shapes): if not sh.has_table: continue tbl = sh.table # 单元格内多段落会被拼成 \n,先压平,避免污染数值解析 grid = [[c.text.strip().replace("\n", "") for c in row.cells] for row in tbl.rows] yield page, title, sh.name, gridsplit_header的阈值写成max(2, len(first) // 3)而不是固定值,是因为有些表第一列是「城市」,有些表第一列是序号列。表头识别失败时退回col0/col1/...,宁可后面人工改名,也不要在这一步猜错列语义。
3.3 图表数值:numCache 与 embedded xlsx 的双通道读取
原生图表走 python-pptx 的 chart 对象,这是最省事的一条路:
# extract_charts.py from pptx import Presentation def chart_records(pptx_path): prs = Presentation(pptx_path) for page, slide in enumerate(prs.slides, start=1): for sh in slide.shapes: if not sh.has_chart: continue chart = sh.chart for plot in chart.plots: # 散点图的分类在 ser.x_values 上,categories 会是 None cats = list(plot.categories or []) for ser in plot.series: values = list(ser.values or []) for cat, val in zip(cats, values): yield { "page": page, "shape": sh.name, "chart_type": str(chart.chart_type), "series": str(ser.name), "category": str(cat), "value": val, }chart.plots可能返回多个绘图区,比如柱状图加折线图的组合图,所以必须循环而不是取plots[0]。ser.values为 None 只有一种情况:该系列的数值没有缓存,只有公式引用,这时只能走第二通道。
第二通道是把嵌入工作簿整份读出来:
import io, zipfile import pandas as pd def embedded_sheets(pptx_path): """一个 chart 通常对应一个嵌入工作簿,里面可能有多个 sheet""" with zipfile.ZipFile(pptx_path) as z: for name in z.namelist(): if name.startswith("ppt/embeddings/") and name.lower().endswith(".xlsx"): blob = io.BytesIO(z.read(name)) # header=None 保留首行,防止表头被当成列名丢掉 yield name, pd.read_excel(blob, sheet_name=None, header=None) for name, sheets in embedded_sheets("2024年毕业生薪酬白皮书.pptx"): for sheet, df in sheets.items(): print(name, sheet, df.shape)映射关系要精确到「哪个 chart 对应哪个工作簿」,靠的是ppt/charts/_rels/chartN.xml.rels:里面 Type 以/package结尾的那条 Relationship,Target 指向../embeddings/xxx.xlsx。一个白皮书里图表很多时,这个映射必须建,否则你无法判断哪份工作表属于哪一页。
注意:嵌入工作簿里常混着辅助列和中间透视结果,不是所有 sheet 都是原始数据。sheet 名带「数据源」「Data」的优先,带「图」「chart」的通常是加工后的。
3.4 统一成长表:字段设计与参数说明
把表格记录和图表记录合并成一张长表,是最适合后续透视的形态。
import pandas as pd rows = [] for page, title, shape_name, grid in extract_tables("2024年毕业生薪酬白皮书.pptx"): header, body = split_header(grid) for r in body: rec = {"page": page, "title": title, "shape": shape_name} rec.update({h: v for h, v in zip(header, r)}) rows.append(rec) long_df = pd.json_normalize(rows) print(long_df.shape) print(long_df.head(3).to_string())json_normalize会按并集生成列,不同页表头不一致时不会丢字段,缺的自动补 NaN。落表时至少要保留这几个元字段:
| 字段 | 类型 | 作用 |
|---|---|---|
page | int | 反查定位到原页 |
title | str | 页标题,用于识别维度分组 |
shape | str | 形状名,作者命名往往是「表 3-1」 |
unit_note | str | 页级单位标记,换算的唯一依据 |
raw_* | str | 每个数值的原文,清洗后仍保留,便于追溯 |
4. 薪酬数据清洗:分位数口径对齐与维度归一化
4.1 分位数和平均月薪不能混算
白皮书里最常见的四列是 P25、P50、P75 和「平均月薪」。前三个是同一分组的三个位置,均值是第四个统计量,把 P50 和均值放进同一个mean()里求平均,结果没有任何统计意义。更要命的是「年包」和「月固定薪资」的差别:年包含年终奖、签字费、股票折算,除以 12 得到的数会明显高于月固定薪资,两者做同比就是在比两件事。
清洗的第一步是把所有数值统一到「税前月现金收入」一个口径上,换算函数要显式处理单位:
import re def to_monthly(text, page_unit=None): """把 '18.6万/年' '9.5k' '9500元/月' '15-20万' 统一成税前月薪""" if not isinstance(text, str): return None t = text.strip().replace(",", "").replace(",", "") nums = re.findall(r"\d+(?:\.\d+)?", t) if not nums: return None # 区间取中点,单值直接用 val = sum(float(n) for n in nums) / len(nums) if len(nums) > 1 else float(nums[0]) marker = (t + (page_unit or "")).lower() if "万" in marker: return round(val * 10000 / 12, 0) # 年薪折月 if "k" in marker or "千" in marker: return round(val * 1000, 0) return round(val, 0)这里的关键设计是page_unit参数:单位优先看单元格内的标记,没有就继承页级标记。区间15-20万取中点只是为了让数据可用,正确做法是同时保留raw原文列,报告里出现异常值时能一眼看出是区间中点造成的。
4.2 城市、行业、学历三层归一化字典
同一份白皮书里「北京」和「北京市」同时出现的概率很高,行业名更是五花八门:「互联网」「互联网/电商」「信息传输、软件和信息技术服务业」讲的可能是同一件事。归一化必须落成显式字典,而不是用模糊匹配——模糊匹配在某一年会悄悄把两个不同行业合并,而且没人会发现。
CITY_ALIAS = {"北京市": "北京", "北京": "北京", "上海市": "上海", "深圳市": "深圳", "广州市": "广州", "杭州市": "杭州"} TIER = {"北京": "一线", "上海": "一线", "深圳": "一线", "广州": "一线", "杭州": "新一线", "成都": "新一线", "武汉": "新一线", "西安": "新一线"} DEGREE = {"本科": "本科", "学士": "本科", "硕士": "硕士", "研究生": "硕士", "博士": "博士", "大专": "大专"} def normalize(row): city = CITY_ALIAS.get(str(row.get("城市", "")).strip(), "其他") return { "city": city, "tier": TIER.get(city, "其他"), "degree": DEGREE.get(str(row.get("学历", "")).strip(), "其他"), } norm = pd.DataFrame([normalize(r) for r in long_df.to_dict("records")]) long_df = pd.concat([long_df, norm], axis=1)落到「其他」而不是丢弃,是有意为之:数量少的类别进入「其他」后仍然计入总量,一旦发现「其他」占比超过 5%,说明字典该补了。行业维度建议单独维护一份industry_map.csv,让业务方直接改表,而不是改代码。
4.3 透视与同比增速的三种写法
清洗完的维度列应该立刻能支撑交叉透视,这是检验清洗质量最快的方式。
p50 = long_df["P50"].map(lambda s: to_monthly(s, long_df["unit_note"].iloc[0])) pivot = (long_df.assign(p50=p50) .pivot_table(index="tier", columns="degree", values="p50", aggfunc="median", observed=True)) print(pivot.round(0)) # 同比:同口径、同分组,先各自取中位数再算增速 cur = long_df[long_df["year"] == 2024].groupby("industry")["p50"].median() prev = long_df[long_df["year"] == 2023].groupby("industry")["p50"].median() yoy = ((cur - prev) / prev * 100).round(1).sort_values(ascending=False)三个细节值得单独说:aggfunc用median而不是mean,是因为同一格内可能混了不同岗位的样本,中位数抗极值;observed=True在分类列较多时能显著减少稀疏组合;增速计算必须groupby之后再算,先算行级增速再平均会得到加权错误的数。基数很小的行业增速容易失真,输出时把样本量count一起带上,低于 30 个样本的分组标记为不可靠。
4.4 三条能塞进 CI 的勾稽自检
抽取脚本最大的风险不是报错,而是悄悄少抽了几行。把下面四条规则写成断言,每次白皮书更新跑一遍,比人工核对省事得多。
| 校验 | 规则 | 失败意味着什么 |
|---|---|---|
| 分位单调 | P25 ≤ P50 ≤ P75 | 列错位,或合并单元格串行 |
| 单位一致 | 同页同列的单位标记相同 | 有人把「万/年」混进了「元/月」列 |
| 覆盖度 | 每个 city×degree 组合都有值 | 漏抽页面,或该组合白皮书确实未披露 |
| 样本勾稽 | 各分组样本数之和 = 页脚样本量 | 合并单元格被重复计数 |
# 分位单调性断言,P25/P50/P75 必须按列取到 sub = long_df.dropna(subset=["P25", "P50", "P75"]).copy() bad = sub[(sub["P25"] > sub["P50"]) | (sub["P50"] > sub["P75"])] assert bad.empty, f"分位不单调 {len(bad)} 行,检查是否列错位:\n{bad[['page','title']].head()}"5. 反查与增量:让白皮书数据能反复用
5.1 从 DataFrame 反查页面并把结果写回一页 PPT
清洗后的数据要能双向追溯:page加shape两个字段必须能定位回原页。做年度复核时,用 python-pptx 新建一页把归一化后的中位数画回去,比在 Excel 里手工拼图快得多。
from pptx import Presentation from pptx.chart.data import CategoryChartData from pptx.enum.chart import XL_CHART_TYPE from pptx.util import Inches prs = Presentation("2024年毕业生薪酬白皮书.pptx") slide = prs.slides.add_slide(prs.slide_layouts[5]) # 版式索引因模板而异,稳妥做法是按名字查 data = CategoryChartData() data.categories = list(pivot.index) # tier 作为分类轴 data.add_series("本科 P50", tuple(pivot["本科"].fillna(0)), number_format='#,##0') data.add_series("硕士 P50", tuple(pivot["硕士"].fillna(0)), number_format='#,##0') gf = slide.shapes.add_chart(XL_CHART_TYPE.COLUMN_CLUSTERED, Inches(0.6), Inches(1.4), Inches(9), Inches(4.5), data) gf.chart.has_legend = True prs.save("薪酬复核_2024.pptx")add_series的数值序列不能含 None,先用fillna(0)补,否则会抛类型错误;slide_layouts[5]在不同母版里指向的版式未必一样,正式脚本里应按layout.name遍历查找,取名字含「标题和内容」的那个。写回文件用另存,不要覆盖原白皮书,避免破坏原始证据。
5.2 用 slide XML 哈希做增量重算
白皮书一年出一版,八成页码的内容是不变的。全量重跑既慢又会让历史修正过的清洗结果被覆盖。可行的做法是对每页的slideN.xml求哈希,只重跑变化页,再按(page, shape, row)主键与旧长表 merge。
import hashlib, zipfile def slide_hashes(pptx_path): out = {} with zipfile.ZipFile(pptx_path) as z: for n in z.namelist(): if n.startswith("ppt/slides/slide") and n.endswith(".xml"): out[n] = hashlib.sha1(z.read(n)).hexdigest() return out new, old = slide_hashes("2024版.pptx"), slide_hashes("2023版.pptx") changed = [k for k, v in new.items() if old.get(k) != v] print("变化页:", len(changed), sorted(changed)[:10])哈希只覆盖页面 XML,所以有一个盲区:只改口径说明、不改数字的页,哈希不会变。补这个洞的办法是给每页加一层二级指纹——把该页所有单元格的「单位标记 + 备注页文本」拼起来再哈希,两级指纹都一致才判定为未变。另一个细节是ppt/embeddings/里的工作簿不在页面 XML 里,图表页的哈希必须把ppt/charts/chartN.xml一并算进去,否则作者只改了图表数据源而没动版式时,你会漏掉这次更新。
本文还有配套的精品资源,点击获取