做数据分析这些年,我最常被同事问到的不是复杂模型,而是这种看起来很基础的问题:"这张表怎么转个方向?"领导要看按区域横向排列的对比看板,下游的同事要拉成长表才能接进配置系统,画图库又强制要求"一行一条记录"。DataFrame的重排和旋转,本质上就是在一个二维坐标网格里重新摆放数据——行变列、列变行、索引和列之间来回装卸。这篇文章我把Pandas里涉及这些"转方向"操作的方法完整梳理一遍,重点讲清楚pivot、pivot_table、melt、stack和unstack这几个核心手段,以及它们各自适合什么场景、有哪些坑、怎么组合使用。
1. 先分清宽表和长表,再看旋转到底在转什么
1.1 宽表和长表是两种数据视角
宽表(wide format)的特点是:一行代表一个观察对象,多个列分别记录这个对象的不同属性。比如每天的天气记录,日期是一行,温度、湿度、风速各占一列。人看起来非常直观,扫一眼就知道某一天的全部信息。
长表(long format)则不同,一行只记录一个"对象-属性-数值"的三元组。还是天气数据,长表就变成三列:日期、指标名称、指标数值。温度、湿度、风速不再是独立的列,而是被折叠成了"指标"这个列的不同取值。
这两种格式没有绝对的好坏,区别在于使用场景:
| 使用场景 | 适合宽表 | 适合长表 |
|---|---|---|
| 人工查看、报表展示 | 是 | 否 |
| 按多个维度做汇总透视 | 是 | 否 |
| 输入给统计分析模型 | 否 | 是 |
| 输入给绘图库(如seaborn) | 否 | 是 |
| 多指标按时间序列对齐比较 | 是 | 否 |
所以"重排"和"旋转"这两个动作,本质上是同一个数据集合在"以变量为列"和"以变量为行"两种布局之间切换。数据内容不增不减,只是摆放方式变了,就像整理衣柜时把叠好的衣服从竖着放改成横着放,衣服还是那些衣服,但你能看见的部分不一样了。
1.2 用一组天气数据实际看两种形态
先用代码创建一份宽表:
import pandas as pd weather = pd.DataFrame({ '日期': ['周一', '周二', '周三', '周四', '周五'], '温度': [23, 25, 24, 26, 22], '湿度': [60, 65, 58, 70, 72], '风速': [3, 4, 2, 5, 3] })这份表一行是一天,一眼能看出每一天的数据。但如果要画"风速随日期的折线图",一些绘图库更希望数据是长表形态。把上面的宽表变成长表,Pandas里的标准做法是melt:
weather_long = weather.melt( id_vars=['日期'], value_vars=['温度', '湿度', '风速'], var_name='指标', value_name='数值' )这时的weather_long长这样:
日期 指标 数值 0 周一 温度 23 1 周二 温度 25 2 周三 温度 24 3 周四 温度 26 4 周五 温度 22 5 周一 湿度 60 ...可以看到,原来三列指标被"溶化"成了两列:指标名称和指标数值,日期作为标识列保留了下来。反过来,如果拿到的是一份长表,想把它变宽,pivot / pivot_table 就是干这个的。
2. pivot 和 pivot_table:把明细数据揉成透视矩阵
2.1 pivot:适合"不重复"的干净数据
先来看最直接的pivot。它的三个核心参数是index、columns、values,意思非常直白:以哪个列作为行索引,以哪个列的值作为新列名,以哪个列填充单元格。
sales = pd.DataFrame({ '区域': ['华东', '华北', '华东', '华南'], '季度': ['Q1', 'Q1', 'Q2', 'Q1'], '销售额': [120, 98, 135, 88] }) sales.pivot(index='区域', columns='季度', values='销售额')结果:
季度 Q1 Q2 区域 华东 120.0 135.0 华北 98.0 NaN 华南 88.0 NaN这段代码的前提条件是:每一组(区域, 季度)组合必须唯一。一旦某个组合出现了两次,pivot会直接报错,错误信息大概是ValueError: Index contains duplicate entries, cannot reshape。
提示:这个报错在实际工作中出现的频率非常高。遇到以后不要慌,先用
df.groupby(['区域', '季度']).size()找出重复的行,再决定是去重、加一层维度,还是改用pivot_table做聚合。
pivot没有聚合能力,它只是把数据机械地搬到新位置。能用的场景集中在数据本身已经足够干净的情况,比如宽表还原、索引拆列这类操作。
2.2 pivot_table:带聚合能力才是日常主力
pivot_table是pivot的升级版,核心区别就在于它能聚合。当同一组(index, columns)下有多条记录时,aggfunc参数决定怎么合并。参数默认是'mean',这点容易记错——很多人默认它和pivot一样直接填数值,结果发现输出结果和自己预想的不一致。
sales_with_dup = pd.DataFrame({ '区域': ['华东', '华北', '华东', '华北', '华东'], '季度': ['Q1', 'Q1', 'Q2', 'Q2', 'Q3'], '销售额': [120, 98, 135, 110, 150] }) sales_with_dup.pivot_table( index='区域', columns='季度', values='销售额', aggfunc='sum', fill_value=0 )这次即使华东在Q1有两条记录也没关系,aggfunc='sum'会自动把它们加总。结果如下:
季度 Q1 Q2 Q3 区域 华东 120 135 150 华北 98 110 0在真实业务里,aggfunc还可以传函数列表,同时得到多个统计量;如果希望透视表里有汇总行和汇总列,加一个margins=True即可。fill_value=0只负责把"因组合缺失产生的NaN"填充掉,并不会影响聚合结果本身的NaN,这一点需要留意。
pivot_table和groupby在底层逻辑上有重叠。同样一段"按区域和季度求和",df.groupby(['区域', '季度'])['销售额'].sum().unstack()和pivot_table得到的结果几乎一样。区别在于:pivot_table直接面向"行-列-值"的透视需求,写起来更直观;groupby + unstack则更灵活,适合在聚合后还要做其他链式操作的场景。
3. melt 和 wide_to_long:把宽表摊平成可计算的规整长表
3.1 melt 是宽表转长表的最通用方式
melt是从宽表得到长表的万能钥匙。前面天气数据的例子已经展示了基本用法,这里把几个参数再拆细一些:
id_vars:保留为标识的列,这些列不会被折叠,每条记录都会重复出现在长表里。value_vars:需要折叠的列。如果不传,Pandas会把除了id_vars之外的所有列都折叠进去,通常这意味着把"不该折叠的"也折叠了。var_name:折叠列名放进去的那一列,默认叫variable。value_name:折叠数值放进去的那一列,默认叫value。
一个很容易踩的坑是漏传value_vars。比如数据里有"温度""湿度""风速""备注"四列,只想要前三个折叠成指标,结果不传value_vars,备注也被一起折叠进去了,长表里凭空多出一堆无意义行。我的习惯是每次写melt都明确写出value_vars,除非确实有特殊需求。
3.2 wide_to_long:处理列名有规律的批量宽表
有些宽表到达"变态"级别,列名自带编号或后缀,比如销量2023、销量2024,或者数值A、数值B。如果每一列都手动写进melt的value_vars,手都会酸。这时候wide_to_long是更好的选择,它专门用于列名符合"前缀 + 分隔符 + 后缀"这种规律的数据。
df_wide = pd.DataFrame({ '城市': ['北京', '上海'], '销量2023': [100, 120], '销量2024': [130, 150] }) pd.wide_to_long( df_wide, stubnames=['销量'], i='城市', j='年份', sep='', suffix='\d+' )结果:
城市 年份 销量 北京 2023 100 上海 2023 120 北京 2024 130 上海 2024 150这里的stubnames是列名前缀,i是标识列,j是后缀部分的新列名,sep是前缀和后缀之间的分隔符。如果列名是销量_2023这种带下划线的,把sep='_'传进去即可。
wide_to_long比melt的优势恰恰在这份"规律感"上:一批销量2023、销量2024、销量2025的列,用melt你得把三列都列出来,少写一列就漏数据;用wide_to_long只写一个stubnames=['销量'],写成2023/2024/2025都能自动识别。缺点是要记的参数比melt多,而且它对列名格式有硬性要求——没有规律就不要硬用。
4. stack 和 unstack:在索引和列之间装卸数据
4.1 unstack:把最内层索引搬到列上
unstack做的事情是把索引的某一层"展开"成列。最典型的用法是把groupby聚合后的结果转成透视表。
result = sales_with_dup.groupby(['区域', '季度'])['销售额'].sum() result.unstack()这段代码先用groupby得到"区域-季度"两级索引的Series,再调用unstack()把最内层的季度索引拆成列。结果和pivot_table几乎一致:区域还是行索引,季度变成了四列。
unstack可以指定拆哪一层:unstack(0)拆最外层,unstack(-1)拆最内层,unstack('季度')按索引名拆。更重要的是unstack处理的是索引层级,这是它和pivot_table的根本差异——pivot_table是从普通列生成分类矩阵,而unstack是把已有的索引层级搬到列的位置。
4.2 stack:反方向,把列堆回索引
stack和unstack互为逆操作。stack会把列按层级折叠成新的行索引。
pivoted = sales_with_dup.pivot_table( index='区域', columns='季度', values='销售额', aggfunc='sum', fill_value=0 ) pivoted.stack()pivoted.stack()的结果是把Q1、Q2、Q3这三列折叠进索引,形成两级索引的Series。此时如果再调用reset_index(),就能得到一个规整的长表DataFrame:
pivoted.stack().reset_index(name='销售额')这个过程非常像"透视表的反方向"。透视表是人看的,适合汇报;而stack + reset_index是把透视结果重新变成机器能处理的长数据。恰当与否的关键在于你是否需要保留透视后的汇总形态。
4.3 MultiIndex 场景下的层级细调
遇到多级列名时,stack和unstack的level参数就显得格外有价值。假设有一份数据是"城市 × (指标, 季度)"的MultiIndex列:
cols = pd.MultiIndex.from_tuples([('温度', 'Q1'), ('温度', 'Q2'), ('湿度', 'Q1'), ('湿度', 'Q2')]) data = pd.DataFrame([[23, 25, 60, 65], [24, 26, 58, 70]], index=['北京', '上海'], columns=cols)只保留一层的stack()会默认把最内层的季度列堆进索引;想保留"指标"层、把"季度"层堆进索引,可以用stack(level=1)。反过来,unstack也同理。这一块如果只做简单透视,似乎用不上,但一旦数据进入更复杂的建模流程,多级索引处理几乎是家常便饭,level参数必须熟练。
5. 组合拳实战:从一团乱表到三张干净看板
5.1 业务背景和原始数据
假设手头有一份原始订单明细,字段是日期、城市、渠道、订单数和销售额。当中同一天同一个城市下,总有一条线上和一条线下的记录。这份数据是长表形态,但业务方要的不是它,而是三份不同的视图。
raw = pd.DataFrame({ '日期': ['2024-01-01'] * 8 + ['2024-01-02'] * 8, '城市': ['北京', '北京', '上海', '上海', '广州', '广州', '深圳', '深圳'] * 2, '渠道': ['线上', '线下'] * 8, '订单数': [112, 84, 96, 78, 66, 52, 71, 48, 128, 91, 104, 82, 73, 60, 85, 54], '销售额': [15800, 12100, 13200, 9800, 8800, 6200, 9400, 5800, 17600, 13400, 14900, 11000, 9700, 7100, 10800, 6600] })第一步,业务要"日期 × 城市"的销售额对比看板。直接透视:
sales_by_date_city = raw.pivot_table( index='日期', columns='城市', values='销售额', aggfunc='sum' )第二步,业务要看"城市 × 渠道"的订单分布:
orders_by_city_channel = raw.pivot_table( index='城市', columns='渠道', values='订单数', aggfunc='sum' )5.2 把第一张透视表还原成长表,喂给下一步分析
看板做完,数据分析同事说需要"日期、城市、销售额"三列的长表,这样才能跑模型或画图。这时从第一张透视表反推即可:
sales_long = sales_by_date_city.stack().reset_index(name='销售额')stack()把城市列折叠成行索引,reset_index(name='销售额')把折叠后的Series转回DataFrame,并显式命名数值列。这里还有一个隐藏问题:pivot_table默认aggfunc='mean',如果在第一步忘了写aggfunc='sum',这里得到的就是同一天同一城市的线上线下销售额平均值,而不是总和。这种错误在数据量大的时候极难发现,我建议拿到任何透视结果先抽查几行,和groupby的结果对一下。
5.3 列名整理和数据字典修补
透视生成的列名往往不是最终想要的展示名。比如sales_by_date_city的列名是北京、上海、广州、深圳,在后续拼接或导出Excel时需要补一个规范前缀。可以用add_prefix批量修改:
sales_by_date_city = sales_by_date_city.add_prefix('销售额_')raw里的日期列目前是字符串,透视表索引也是字符串,如果后面要根据日期做滑动窗口计算,建议先转成datetime类型:
raw['日期'] = pd.to_datetime(raw['日期'])这两个步骤单独看都不值一提,但实际项目里类型错乱带来的问题往往比算法本身更磨人。列名是字符串还是其他类型、索引是否有层级、空值是NaN还是0,这些都值得在上手时花几分钟确认。
5.4 多张透视结果合并时的索引陷阱
实际中经常出现"先分表、后合并"的操作:日期×城市的销售额、日期×城市的订单数,各自透视后拼成一张综合看板。拼接时最容易踩的坑是索引名称不一致导致join失败。pivot_table生成的结果索引默认叫日期,但如果某次操作经过了reset_index,索引名称可能就丢了。所以强烈建议在每次透视之后做一次rename_axis的确认:
sales_by_date_city = sales_by_date_city.rename_axis('日期')再配合reset_index()操作,后续任何合并都不会因为索引名对不上而卡壳。
6. 我在实战中踩过的坑和优化经验
6.1 pivot报重复索引错误时的排查思路
pivot报duplicate entries是高频事故。我自己的排查套路是这样的:先在报错的两个维度上分组统计一下,找到具体是哪些组合重复了,然后判断这些重复是真实业务数据(比如同一订单号出现在多行),还是因为上游重复抽取导致的脏数据。如果确认是脏数据,用drop_duplicates保留需要的行;如果本来就该有重复,那就说明业务需要的其实是聚合,应该改用pivot_table而非pivot。
大多数情况下业务方要的就是聚合,所以我自己写新代码时已经默认用pivot_table,pivot只在给宽表列拆行、且确定无重复时才用。这个习惯省了很多报错排查的时间。
6.2 unstack 之后出现整片NaN,别急着填0
unstack之后会自然产生缺失值,因为原始数据里没有覆盖到所有组合。这时候很多人习惯立刻fillna(0),但这不一定是对的。如果一个季度本身没有销售记录,填0可能说得通;但如果是因为某渠道的数据还没采集完,填0就是造假。我的建议是:先看缺失值比例,再结合业务判断该填什么。数据字典或上游口径里通常会有说明,别把fillna当成无脑动作。
6.3 透视后列名的"类型陷阱"
用pivot_table(columns='季度')后,列名是字符串Q1、Q2,这还好;但如果columns的值是数字或日期,透视生成的列名也可能是数字或时间戳类型。比如columns='年份',列名会是2023和2024这样的int,后续用字符串去索引就会报KeyError。规避办法很简单:透视前或透视后,显式把列名统一转换成需要的类型。
pivoted.columns = pivoted.columns.astype(str)6.4 性能和个人体会
最后说一点性能上的体会。pivot_table、melt、stack这些操作都是Pandas底层的C语言实现,效率远高于自己写循环拼接。我在处理几百万行的订单明细时,pivot_table一次聚合通常几十秒内能跑完,而换成Python循环可能要几分钟甚至更久。所以能用原生方法解决的问题,尽量别自己造轮子。
如果数据量特别大,有几个额外手段可以考虑:把用于分组的列先转成category类型,能显著减小内存占用并加速分组操作;stack和unstack之后的中间结果会临时膨胀,注意控制内存,别一次性把几十个宽表全部堆叠。
我自己做重排操作的习惯可以总结为三句话:先想清楚目标到底是宽表还是长表;能用一句pivot_table完成的就不写三四行groupby + unstack;碰到任何结果和预期不一致的情况,先用groupby(...).size()验证数据本身有没有重复或缺失。这套思路在多数场景下都能直接落地,写完也不会在Review或交付时被追问"这张表是怎么变出来的"。