ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Pandas变形全攻略:pivot、melt、stack/unstack与实战避坑

Pandas变形全攻略:pivot、melt、stack/unstack与实战避坑

你有没有遇到过这种场面:明明只是想给表格换一种摆放方式,结果在 pivot、melt、stack、unstack 之间来回试了半天,最后还是被报错和莫名其妙的列名劝退。我在学习 Pandas 的第 4 章“变形”时,就是这种状态。后来工作了,拿到一张带两级表头的月度对账表,更是直接在 MultiIndex 的层级里绕了两个小时。这篇文章我想把“变形”这件事彻底聊透:它到底在变什么、有哪些核心函数、每种函数适合什么数据形态、以及实操里最容易踩的坑。无论你是正在啃 Pandas 的新手,还是已经写了不少分析脚本的从业者,这套思路应该都能用得上。

1. 变形的本质:同一份数据,三种截然不同的摆法

1.1 一张表拆到底:索引、列标签、单元格各司其职

很多人把“变形”理解为“转置”,也就是行列互换。这个理解太窄了。Pandas 里的变形,本质上是在索引、列标签、单元格值这三类角色之间做重新分配:原来在列标签上的东西,可以移到行索引上去;原来在行索引上的东西,可以展开成列标签;单元格的值也可以被重新摆放到新的行列位置上。数据本身并没有被修改,变的只是“坐标系”。

我习惯用日常生活打比方。一份班级成绩单,一种摆法是一行一个学生、每科一列,叫宽表;另一种摆法是一行一个“学生-科目”组合,成绩单独占一列,叫长表。两张表里的信息完全相同,但查“某个学生的平均分”和查“全班英语最高分”,各自方便的程度完全不同。Pandas 的变形函数,就是在这些摆法之间搬东西的搬运工。

只有先建立这个“角色互换”的认知,后面看 API 才不会头晕。因为你手头的每个函数,做的都是同一件事:把某个位置上的标签拿去另一个位置当标签,再根据旧位置的关系把数值对应到新格子里。理解了这个,再去看官方文档里那些参数说明,就只是在确认“把谁移到哪里”而已。

1.2 为什么要折腾表格结构:分析、可视化、建模三方的口味不一样

在实际项目里,表格结构不会天然长成你刚好需要的样子。数据库导出来的明细表通常是一行一单的长表;Excel 里做的月度汇总表却是宽表;画图工具往往要求长表;机器学习模型却要求数值矩阵。需求一变,表就得变形。

举几个最常见的场景:你要做“日期-产品”的销售额矩阵,用 pivot;你要把多列月度数据压成一个“月份-销售额”对,用 melt;你以为自己只是把 Excel 两级表头读进来,结果 columns 变成了多层索引,需要 stack / unstack 拍平;建模前要把类别字段展开成 0/1 列,得走 get_dummies。

所以说,“变形”不是炫技,而是数据分析链路里绕不开的基础能力。它的本质判断只有两个问题:当前表是长是宽?目标表是长是宽?想清楚这两点,函数名你自然能对上号。这一章后面所有内容,都会围绕这两个问题展开。

2. 先分清长表与宽表:所有变形操作的前提

2.1 以销售报表为例,一眼看懂两种形态

长表和宽表的区别,我用一家连锁门店的销售数据来说明。宽表形态大概是:

日期门店A门店B门店C
1月1日12000158009900
1月2日130001420010500

这种表的好处是横向对比一目了然,Excel 透视报告基本都是这个形态。但它的缺点是:每一行的“门店”并不是一个变量,而是被拆成了三列。想按门店分组、按时间画趋势线,都要先把列收回去。

长表形态则相反,每一行是一个最小观测单位:

日期门店销售额
1月1日门店A12000
1月1日门店B15800
1月2日门店A13000

长表看起来“啰嗦”,但它是数据分析工具最喜欢的样子。当维度都变成“列”之后,任何计算都可以按列分组完成,不必在列名上写循环。比如全门店均值就是df.groupby('门店')['销售额'].mean();如果是宽表,你得手动选三列再求均值,门店越多越痛苦。大多数统计模型、画图库,以及 Pandas 的 groupby,都直接长在长表结构上。

理解这两种形态后,“变形”就有了明确方向:长表转宽表,是把某个标识列里的多个取值(门店A、门店B、门店C)变成列名;宽表转长表,是把多个列的标签收拢成一个新列(门店),把对应数值收拢成另一个新列(销售额)。

2.2 用这张“方向地图”决定用哪个函数

一旦确定了方向,函数选型就清晰了。我的经验是先画一张“方向地图”:

  • 当前是长表,目标是宽表:用 pivot(索引组合唯一时)或 pivot_table(需要聚合时);如果行索引本身是多层索引,用 unstack;如果只是想把两个类别字段交叉成统计矩阵,用 crosstab。
  • 当前是宽表,目标是长表:优先 melt;如果列名的规律是“前缀+编号”,用 wide_to_long;如果宽的是列索引的 MultiIndex 层级,用 stack。
  • 目标既不是变长也不是变宽,而是保持行数不变地对列做计算:那是 transform 的领地。
  • 目标是把类别文字展开成多个 0/1 数值列:get_dummies。

这张地图不需要背,理解内在逻辑后自然就记住了。后面几节,我会把每个方向里的工具拆开讲清楚,包括它们之间的细微差别和各自的前提条件。

3. 长表转宽表的首选:pivot 和 pivot_table 的边界与细节

3.1 pivot 的经典用法与“索引组合必须唯一”的前提条件

pivot 是长表转宽表最直接的工具。它有三个核心参数:index 决定新表的行索引,columns 决定从哪个列取值做新列名,values 决定填充进格子的数据列。

给一个最小可复现的例子:

import pandas as pd sales = pd.DataFrame({ '日期': ['2024-01-01', '2024-01-01', '2024-01-02', '2024-01-02'], '产品': ['手机', '平板', '手机', '平板'], '销售额': [100, 200, 150, 180] }) sales.pivot(index='日期', columns='产品', values='销售额')

结果会得到一张两行两列的表:行索引是日期,列是手机和平板,格子里是销售额。这个操作很直观,但 pivot 有一个硬性条件:index 和 columns 组成的主键必须唯一。也就是说,同一日期同一产品只能出现一次,否则 Pandas 不知道该放哪个值到格子里,直接抛 ValueError。

实际业务数据几乎不可能这么干净。同一个日期下同一个产品会出现多笔订单,这时你有两条路:要么先 groupby 把重复组合聚合成一个值,再用 pivot;要么干脆用 pivot_table,让它自己聚合。

3.2 pivot_table 的聚合能力和几个容易忽略的参数

pivot_table 可以理解为“先 groupby 再 pivot”。同样一份数据,只要加上 aggfunc='sum',多笔重复记录都会被求和后放进对应格子:

sales_detail = pd.DataFrame({ '日期': ['2024-01-01', '2024-01-01', '2024-01-01', '2024-01-02'], '产品': ['手机', '手机', '平板', '手机'], '销售额': [100, 120, 180, 150] }) sales_detail.pivot_table(index='日期', columns='产品', values='销售额', aggfunc='sum')

aggfunc 不只会接受 'sum',字符串、函数都可以,还能传字典,让不同数值列用不同聚合规则,比如{'销售额': 'sum', '数量': 'mean'}。

pivot_table 有几个参数容易被新人忽略。

第一,values 如果不传,会对所有数值列同时聚合,结果会出现多层列名。多数情况下这不是你要的效果,所以尽量显式指定 values。

第二,fill_value 和 dropna 控制缺失格子。组合不存在时,聚合结果会是 NaN,可以填 0(fill_value=0),也可以直接让这些行列消失(dropna=True)。

第三,margins=True 会给行列都加上“总计”,margins_name 用来改它的名字。这个做报表时非常香,一个参数就省掉自己算 totals 的功夫。

真实项目中,我几乎不用裸 pivot,一律 pivot_table。不仅因为它更稳健,还因为它天然支持多级 index 和 columns。多级 pivot_table 就能做出“日期 x 产品 x 渠道”这种三层表,这在业务报表里是刚需。

4. 宽表折叠成长表:melt 与 wide_to_long 的互补关系

4.1 melt:把多列收回“变量-取值”两列

melt 是 pivot 的逆操作。还是用门店的例子:一张宽表 df_month,列有“门店、1月、2月、3月”,你想把它压成长表“门店、月份、销售额”。melt 的参数很直白:

df_month = pd.DataFrame({ '门店': ['门店A', '门店B'], '1月': [120, 150], '2月': [130, 160], '3月': [140, 170] }) df_month.melt(id_vars='门店', var_name='月份', value_name='销售额')

id_vars 是保留下来做标识的列;剩下的列默认全部折叠。折叠以后,原列名统一放到 var_name 这个新列里(默认叫 variable),原值统一放到 value_name 这个新列里(默认叫 value)。如果你不指定 var_name 和 value_name,得到的是非常难看的“variable”“value”,所以这两个参数我每次都写。

melt 很容易犯一个错:以为会保留值列的数据类型。折叠之后,不同原列的数据会混进同一个新列,Pandas 会做类型提升。比如 1 月整列是 int,2 月整列是 float,折叠后新列可能变成 float,甚至 object。分析前记得检查列类型。

melt 和 pivot 是互逆的两个操作:melt 后的长表,再用 pivot(或 pivot_table)转回去,理论上能得到原宽表。我经常用这个方法验证自己有没有写对参数:先 melt 再 pivot,对比列名和数据是否符合预期。不过要注意填充值和 NaN 的干扰,完全还原不一定每次都能做到 100% 一致。

4.2 wide_to_long:列名带编号时的更好选择

有一类宽表非常特殊,列名是“变量名+下划线+编号”的重复模式,比如“体重_0、体重_1、体重_2”,或者“销售额_1月、销售额_2月、销售额_3月”。用 melt 处理也能做,但你会得到 variable 列里一堆“体重_0”这种带前缀的值,后续还得再拆。这种结构用 pd.wide_to_long 一次到位:

df_weight = pd.DataFrame({ 'id': [1, 2], '体重_0': [60, 70], '体重_1': [61, 71], '体重_2': [62, 72] }) pd.wide_to_long(df_weight, stubnames='体重', i='id', j='时间点', sep='_')

stubnames 是变量前缀,i 是标识列,j 是新生成的“编号列”列名,sep 说明前缀和编号之间的分隔符。上面的例子会把“体重_0、体重_1、体重_2”折叠成两列:时间点(0/1/2)和体重。

如果有多组这样的变量,比如“体重_0”和“身高_0”同时存在,stubnames 可以传列表,如['体重', '身高']。wide_to_long 在医学随访、实验测量、时间序列采集这类“同一指标多次测量”的数据里非常好用,比 melt 的语义清晰得多。

选择 melt 还是 wide_to_long,我的判断标准很简单:如果折叠后的新列希望直接是“变量名”,用 melt;如果变量名里本来就带编号/批次,希望拆出“批次”这个维度,用 wide_to_long。

5. stack / unstack:在索引层级之间挪数据的正确打开方式

5.1 从 MultiIndex 出发理解 stack 的方向感

如果说 pivot/melt 操作的对象是“列里的值”,那 stack/unstack 操作的对象就是“索引标签的层级”。很多人在这对函数上栽跟头,是因为没意识到它们的舞台是 MultiIndex。

先造一个多级列数据:

df_multi = pd.DataFrame({ ('上半年', '销售额'): [100, 120], ('上半年', '利润'): [20, 30], ('下半年', '销售额'): [150, 160], ('下半年', '利润'): [40, 50] }, index=['门店A', '门店B'])

这份表的 columns 是两层:外层是上半年/下半年,内层是销售额/利润。stack()默认把最内层列索引移到行索引,变成“门店A + 上半年 + 销售额”这种长格式;stack(level=0)则把外层索引移下去。对应的,unstack 是把行索引的指定层级送回到列索引。

判断方向有个土办法:stack 是“把列叠到行里”,unstack 是“把行拆开到列上”。带着这个直觉再看官方文档,就不会因为“level 取 0 还是 -1”而懵。level=-1 总是最内层,level=0 是最外层;想换哪一层就传哪一层的编号。

groupby 之后返回的 Series 配合 unstack 是实战里最常见的用法:

df.groupby(['门店', '产品'])['销售额'].sum().unstack()

这一步做完,结果就变成一张“门店为行、产品为列”的宽表,不需要显式用到 pivot_table。很多老代码里你会看到这种写法,它本质上也是变形。

5.2 unstack 后出现 NaN 的处理与层级交换的配合技巧

unstack 附带一个绕不开的话题:NaN。当某些行索引组合在列索引里没有对应值时,Pandas 会补出 NaN。这时候要判断 NaN 合不合理:如果原本就是“某门店没有某产品”,那是事实,保留或填 0 看业务;如果是数据清洗没做干净导致的缺失,就得回源头补数据,不要盲目 fillna 掩盖问题。

MultiIndex 的层级多了,还经常要配合 swaplevel 和 sort_index 使用。我想把行索引从“门店->产品”交换成“产品->门店”,只需df.swaplevel(),然后再sort_index()就会按产品分组排好。这几个操作经常连着出现,可以理解为“先搬位置,再排顺序”。

另外一个容易踩的坑:stack / unstack 会改变索引的顺序和层级名字。转完之后建议立刻reset_index()把索引拍平成普通列,否则后续筛选和拼接时会很别扭。我在处理带层级表头的 Excel 报表导入时,常规流程就是:读取多级表头 -> stack 把指标层拍平 -> reset_index 重命名为标准列名,三步完成“表格整形”。

6. crosstab:类别字段交叉统计的快捷工具

6.1 频数矩阵与归一化的正确姿势

crosstab 是另一个长表转宽表的专用工具,但它只干一件事:把两个(或多个)类别字段交叉起来数次数。比如用户表里有“地区”和“偏好”两个字段,你想看每个地区里各类偏好的分布:

user = pd.DataFrame({ '地区': ['A区', 'A区', 'B区', 'B区', 'C区'], '偏好': ['线上', '线下', '线上', '线下', '线上'] }) pd.crosstab(index=user['地区'], columns=user['偏好'])

输出就是一个地区为行、偏好为列的频数矩阵。它看起来像 pivot_table,但不需要 values 和 aggfunc,因为它默认做的就是 count。

normalize 参数经常被忽略,但它能让矩阵变成占比:normalize='index' 按行归一化,每行合计是 100%,适合对比不同组的结构差异;normalize='columns' 按列归一化;normalize='all' 变成全表占比。做用户画像、渠道分析时,归一化之后的交叉矩阵比原始频数更有参考价值。

6.2 values + aggfunc:从计数表升级为聚合表

很多人以为 crosstab 只能数数,其实它还可以聚合。给 values 传入数值列,再给 aggfunc 传入汇总函数,就能得到“交叉分组下的统计量”:

user2 = pd.DataFrame({ '地区': ['A区', 'A区', 'B区', 'B区'], '偏好': ['线上', '线下', '线上', '线下'], '消费额': [100, 200, 150, 180] }) pd.crosstab(index=user2['地区'], columns=user2['偏好'], values=user2['消费额'], aggfunc='mean')

得到的就是“地区 x 偏好”交叉组的平均消费额,等价于groupby(['地区', '偏好'])['消费额'].mean().unstack()。所以 crosstab 本质上是 groupby + unstack 的语法糖,区别在于它面向“两个类别交叉”这个高频需求,写起来短。

另外,crosstab 支持多类别交叉:index 和 columns 都能传列表,结果变成 MultiIndex。如果数据量不大、分组不深,这样的交叉表做数据探查非常快;一旦分组数目膨胀(比如地区 30 个、偏好 10 个),矩阵会变得稀疏,阅读成本也上来了,这时候我反而会退回 groupby 后的长表,让数据自己说话。

7. 分组语境里的变形:reduce、expand、transform 三种模式

7.1 用“函数返回值形状”区分三种变形模式

“变形”不只是表格形状变化,分组计算里也有变形。同样是 groupby 后的 apply,函数返回的形态不同,结果表的形状就不同。我把它们分成三种模式:reduce、expand、transform。

  • reduce:函数返回一个标量,每组压成一个数,行数变少。典型是groupby().agg('sum'),每组一行。
  • expand:函数返回一个长度大于 1 的序列或数据框,一行输入变多行输出。典型是 apply 返回多元素 Series,比如同时返回分位数 25%、75%。
  • transform:函数返回与输入组等长的序列,行数不变。典型是组内中心化、缺失值填充。

用一个工资表例子看:

df_salary = pd.DataFrame({ '部门': ['A部', 'A部', 'B部', 'B部', 'B部'], '工资': [8000, 9000, 10000, 11000, 12000] }) # reduce df_salary.groupby('部门')['工资'].mean() # expand:每个部门返回 Q1 和 Q2 两条 df_salary.groupby('部门')['工资'].apply( lambda x: pd.Series({'Q1': x.quantile(0.25), 'Q2': x.quantile(0.5)}) ) # transform:组内减去部门均值,行数不动 df_salary['工资_中心化'] = ( df_salary.groupby('部门')['工资'].transform(lambda x: x - x.mean()) )

7.2 实际例子:组内中心化、分位数展开、聚合压平

三种模式选哪个,取决于你要拿数据去做什么。

如果只是想看各部门工资概览,groupby+agg 的 reduce 就够了;如果要做箱线图,一个组要取多个分位数,expand 模式能一次性算完;如果要做组内比较,比如“某个人在部门里的相对位置”,就非得 transform 不可,因为它保留了所有原始行,只是在组内做了变换。

transform 特别适合两类任务:一是组内中心化/标准化,消除组间水平差异;二是按组填充缺失值,比如用组内中位数填空。第一种我在做业务指标量纲统一时几乎天天用,第二种在做脏数据清洗时比全局填充合理得多。

用 transform 有一个必需的铁律:你传入的函数,返回值必须和组的长度一致。否则 Pandas 会直接报错。比如前面例子lambda x: x - x.mean()返回和 x 等长,可以;如果写lambda x: x.mean()去 transform,就会因为“值数量不匹配”崩溃。这和 agg 的“每组返回一个值”是相反的,刚切换时特别容易写混。

agg 和 transform 还可以组合使用:agg 负责压缩出汇总行,transform 负责保留明细行又加上组内指标。一张表里同时出现“部门平均工资”和“个人相对偏差”,我通常就是先 transform 加列,再 agg 出汇总,两条链各算各的。

8. 建模前的类别变形:get_dummies 的用法与坑

8.1 哑变量展开的基本姿势

把类别字段展开成 0/1 列,是建模前最常规的“变形”。绝大多数模型不认识“金卡、银卡、铜卡”这种字符串,需要把它变成数值特征。get_dummies 是 Pandas 提供的最简单手段:

df_user = pd.DataFrame({ '会员等级': ['金卡', '银卡', '铜卡', '金卡'], '消费额': [100, 200, 150, 300] }) pd.get_dummies(df_user, columns=['会员等级'], prefix='等级', drop_first=True)

columns 指定要展开的列;prefix 给生成列加前缀;drop_first=True 会把第一类丢掉,只保留 k-1 列。为什么要丢掉第一类?因为类别变量的 k 个哑变量之间存在完全共线关系,全保留会让很多线性模型出现多重共线性问题,系数无法稳定估计。对于树模型其实无所谓,但统一用 drop_first 可以少一列,对特征冗余也比较友好。

get_dummies 默认会把所有 object 或 category 类型的列都展开。如果表里恰好有一列分类字符串是“门店编码”这类没有序关系的标识,也会被误展开,要小心。保险的做法是显式传 columns,而不是依赖默认行为。

8.2 训练集和测试集列数对不上怎么办

get_dummies 最大的坑在于:它是按“当前数据里出现的类别”生成列的。训练集里出现的类别和测试集不完全一致时,两边展开出的列就会不一样,模型训练时特征数量对不上。这是很多新手第一次跑通建模流程后,评估时才发现的问题。

控制住它的办法有几种。最简单的是把训练集、测试集先纵向拼接,整体 get_dummies,再按原索引切回。这样做能保证两边列完全一致,缺点是测试集的信息在“看到数据”阶段就被引入了,严格做离线验证时不太干净。

更规范的做法是只从训练集里拿到完整类别列表,测试集展开后用它做列对齐:

train_dummy = pd.get_dummies(train, columns=['会员等级']) test_dummy = pd.get_dummies(test, columns=['会员等级']) test_dummy = test_dummy.reindex(columns=train_dummy.columns, fill_value=0)

reindex 会把训练集有但测试集没有的列补成 0,把测试集独有的列直接丢弃。这样就保证了特征口径一致。如果你用常见的机器学习预处理工具(比如带 one-hot 编码器的那类库),它天然支持 handle_unknown 参数,未见过的类别不会被当成新列。

除了 get_dummies,序列编码 factorize 也很常见,适合有序类别,比如“低/中/高”转 0/1/2。但注意不要把无序类别硬编码成数字,那会引入虚假的排序关系。哑变量和顺序编码本身也是“变形”,只不过发生在特征空间里。

9. 选型总结:记住这五句话,从此不再选错变形函数

9.1 五句选型口诀

一路讲到这,所有核心工具都见过了。最后给你一张我用经验浓缩的判断表,当作自测清单:

需求首选方案最适合的场景
长表转宽表,且组合唯一pivot日期x产品、样本x特征 这种干净矩阵
长表转宽表,存在重复记录pivot_table明细数据多对一,需要先聚合
宽表转长表,普通多列melt多个月份/多个属性列要折叠成对
宽表转长表,列名带编号前缀wide_to_long体重_0/身高_1 这种重复测量结构
列索引或行索引是多层stack / unstackExcel 两级表头、groupby 后的层级结果
两个类别字段交叉统计crosstab频数表、占比表、分组均值表
分组计算且保持行数transform组内中心化、组内填缺失值
类别字段展开为 0/1get_dummies机器学习特征准备

对照表格,再记五句口诀:

  • 值要变成列,就找 pivot 族;列要收成对,就找 melt 族。
  • 表格结构里的“层级标签”搬位置,找 stack / unstack,它们不管值。
  • 只是看两个类别字段怎么交叉,crosstab 最省事。
  • 分组后想保留每行明细,transform 不会让你丢数据。
  • 训练和预测阶段,get_dummies 千万先统一列名。

9.2 变形完成后的自我检查

最后分享一个我每次变形后都会做的“回读验证”,能把大部分低级错误挡在提交前。第一步,看 shape:行数列数是否符合预期;第二步,看 columns 和 index 的名称:转完之后层级是否被拍平、新列名是否符合业务习惯;第三步,随机抽原始数据和变形后数据各一行,手动推算一下对应关系,确认值和标签没错位。

如果操作时报错,就先看是不是索引重复、列名重复、数据类型混杂这三类问题;如果结果有大量 NaN,看是不是组合缺失而不是 bug。这几个检查动作练成肌肉记忆之后,“变形”就真的变成你手里随手可用的工具了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进