1. 先搞清楚"重排和旋转"到底在做什么
Pandas里的Dataframe重排和旋转,是数据处理中容易卡壳、但也最有价值的一环。我经常看到有人处理数据时陷入一个怪圈:拿到一份订单明细,想要按月看各品类销售对比,得来回写循环;拿到一份宽表的统计报表,想做分组聚合,发现一堆列名根本没法直接groupby。折腾半天,其实问题都出在对"长表"和"宽表"这两个形态理解不够,以及不熟悉Dataframe在不同形态之间切换的那几个核心方法。
先说结论:所谓"重排和旋转",本质上就是改变数据的"形状"。我们把每一行代表一次独立观测、每一列代表一个字段的格式称为长表(Long Format),把同一主体、不同属性的值摊开成多列的格式称为宽表(Wide Format)。长表适合存储、筛选、聚合,宽表适合阅读、对比、特征工程。而pivot、pivot_table、melt、stack、unstack就是在这两种形态之间来回切换的五把钥匙。这篇文章就把这几个方法从原理到实操拆透,适合已经学过Pandas基础、但在实际项目中遇到"表结构不对"这类问题的读者。
1.1 长表和宽表:先建立坐标系
很多人上来就写代码,结果被reshape成MultiIndex的Dataframe搞晕。我建议先建立一个坐标系,把问题想清楚再动笔。
- 长表的长处:数据库中导出的原始记录基本都是长表,每行是独立的样本点,比如一个用户在一个时间点的购买行为。它天然适合SQL、适合groupby、适合大部分统计检验。
- 宽表的长处:宽表把同一主体的多个属性并排列出来,直观、利于横向比较,也适合直接喂给很多机器学习模型做特征矩阵。
但宽表不是免费的午餐——同一个主体多行记录在转宽时,很容易产生"重复索引"问题。这就是为什么pivot、pivot_table、melt这几个方法总是配套使用:你先把明细用pivot做成矩阵看一眼,再用melt把结果还原长表继续算,整个过程就像把数据在两种坐标系之间来回投影。
1.2 四个核心方法怎么分工
先用一句话建立整体认知框架:
- pivot / pivot_table:长表转宽表。把某一列的唯一值变成新列,把另一列的值变成新列的内容。
- melt:宽表转长表。把多列内容"溶"回两列:一列存原来的列名,一列存原来的数值。
- stack / unstack:在行和列之间平移索引层级。unstack把行的某个层级"推到"列上,stack反方向操作,把列"压回"行。
这四个方法不是孤立的。我用一个比喻:pivot是"立起一堵墙",melt是"拆墙做平地",stack和unstack则是"把楼层上下移动"。理解了各自的位置,实际项目里看到奇怪的报表结构,就不会束手无策了。
2. 用pivot把长表变宽:从明细到矩阵
2.1 基础场景:一列变多列
pivot是最直观的旋转方法。它的语法其实就三个核心参数:index、columns、values。意思分别是——哪些列作为新表的行索引,哪些列的唯一值作为新表的列,哪些列的值填进新表。
我一个具体例子。假设手上是一份某电商平台三天的销售明细,每条记录是某个类目在某天的销售额:
import pandas as pd df = pd.DataFrame({ '日期': ['2024-01-01', '2024-01-01', '2024-01-02', '2024-01-02', '2024-01-03', '2024-01-03'], '类目': ['数码', '服饰', '数码', '服饰', '数码', '服饰'], '销售额': [1200, 800, 1500, 900, 1100, 1300] }) print(df)现在想看每一天、每个类目的销售额矩阵,直接一行代码搞定:
pivot_df = df.pivot(index='日期', columns='类目', values='销售额') print(pivot_df)输出会变成这样的宽表:
类目 数码 服饰 日期 2024-01-01 1200 800 2024-01-02 1500 900 2024-01-03 1100 1300对比原始数据,你发现pivot做了三件事:把日期作为新的行索引,把类目的唯一值(数码、服饰)变成两个新列,把销售额填到了对应的行列交叉处。这就是"旋转"的直观感觉——原本在"类目"这一列里的分类值,旋转90度变成了列标题。
这里有个容易忽略的细节:pivot之后的Dataframe列名就是原字符串,而且默认会保留index的排序。如果你希望新的索引按日期排序,用sort_index();如果你希望列按特定顺序,直接用布尔索引调整列顺序即可。用的时候想清楚:pivot返回的不是一个"视图",而是一个全新的Dataframe,修改它不会影响原表。
2.2 pivot的坑:遇到重复怎么办
pivot看起来很美,但它有一个极其严格的前提:在index和columns组成的组合下,values必须唯一。换句话说,pivot默认假设你不会有两行相同日期、相同类目的记录。如果出现了重复记录,它就会抛出一个ValueError,提示包含重复值。
举例来说,把刚才的数据复制一份:
df_dup = pd.concat([df, df.iloc[[0]]], ignore_index=True) # 此时2024-01-01、数码这条记录出现了两次 try: df_dup.pivot(index='日期', columns='类目', values='销售额') except ValueError as e: print(type(e).__name__, ':', e)你会遇到经典的报错:Index contains duplicate entries, cannot reshape。
这个报错在实际项目中几乎每天都能遇到。原因很现实:原始明细里不乏同一天、同一个用户、同一种商品的多次交易;甚至只是因为上游数据做了union,导致某些行重复。遇到这种情况你有两条路:
- 先消除重复:用drop_duplicates(subset=['日期', '类目'])把重复行去掉,再pivot。
- 换用pivot_table做聚合:pivot_table会自动按需聚合,绕开"唯一性"限制,后面我会专门讲。
我个人的习惯是:先查清楚为什么重复。如果到导入环节就出现重复,往往说明上游ETL有bug,直接丢弃可能掩盖数据质量问题。建议先df[df_dup.duplicated(subset=['日期', '类目'])]看一遍哪些重复、重复了几次,再决定用聚合还是去重。
2.3 pivot_table:带聚合的升级版
pivot_table可以看作pivot的"放宽版"。当你的数据在index和columns的组合下有多个值时,pivot_table不会报错,而是用aggfunc参数指定的聚合函数把它们合并。这个参数默认是np.mean,你可以传入'sum'、'max'、'min'、'count',也可以传一个函数列表。
沿用重复数据的例子:
pivot_table_df = df_dup.pivot_table( index='日期', columns='类目', values='销售额', aggfunc='sum' ) print(pivot_table_df)现在不会报错了,2024-01-01的数码类目会显示2400,因为那条记录被复制后求和了。你再传一个参数fill_value=0:
pivot_table_df = df_dup.pivot_table( index='日期', columns='类目', values='销售额', aggfunc='sum', fill_value=0 )这样所有NaN都会被填成0。这在业务报表里很常用——没有销售的类目不是缺失,而是0。
pivot_table还有一个隐藏好用的参数叫margins,设成True会在末尾追加一行"总计"或一列总计:
pivot_table_df = df_dup.pivot_table( index='日期', columns='类目', values='销售额', aggfunc='sum', fill_value=0, margins=True, margins_name='合计' )这样输出的宽表自带边际汇总,适合做报表直接展示。如果你做透视表时希望同时看总销量和总数,aggfunc可以传列表,比如aggfunc=['sum', 'count'],结果列会变成多层索引,但信息量会大很多。
注意:pivot_table可以传多个values列,比如values=['销售额', '订单数'],结果会生成MultiIndex列。这时如果想继续做重塑,需要先levels处理或者把列名拼平,切忌直接按两列名拿数值。
3. 用stack和unstack在行列之间平移
pivot和melt解决的是"普通两维表"的形态转换,而stack和unstack则是专门处理MultiIndex(多层索引)的工具。在实际场景里,这两种方法常被用在处理带复合分组、宽表统计结果、透视表结果的后续加工中。
3.1 unstack:收起行、展开列
unstack的意思是:把行索引的某一层"抬起来"变到列索引上去。它的方向是行转列。
还是用数据说话。假设我们现在有一份按"季度"和"地区"统计销售额的数据,但存成了两层行索引:
df_multi = pd.DataFrame({ '销售额': [100, 200, 300, 400, 500, 600] }, index=pd.MultiIndex.from_product([ ['Q1', 'Q2', 'Q3'], ['华东', '华北'] ], names=['季度', '地区'])) print(df_multi)这个Dataframe的行索引有两层(季度、地区)。现在我们想把它转成每行是一个季度、每列是一个地区的形状:
unstacked = df_multi.unstack() print(unstacked)结果是:
销售额 地区 华东 华北 季度 Q1 100 200 Q2 300 400 Q3 500 600其实结果里列索引还是两层,只不过原来在行上的"地区"这一层被搬到了列上。如果你觉得列索引里的"销售额"这层多余,还可以链式调用unstack(level=...)或者用droplevel去清理:
unstacked2 = df_multi.unstack(level='地区') # 甚至可以直接指定要平移的层,更直观stack和unstack最常用的场景就是:当你用groupby得到了多个分组维度的统计结果时,结果往往有MultiIndex的行,不方便直接用列表或标准模型读取。你只需要用unstack把其中一个维度展开成列,数据就变得非常规整。
3.2 stack:把列压回行
stack和unstack方向相反:它把列索引"压"回行索引,生成更长的表。通俗讲,就是把宽表变回长表,但它不像melt那样基于普通列名操作,而是专门处理MultiIndex列。
上面的unstacked就是一个多层列索引的宽表。现在把它恢复成原来的形状:
stacked = unstacked.stack() print(stacked)你会看到之前被摊开的地区列又回到了行索引,整个表恢复成了多层索引的长表。这就是stack和unstack互相逆操作的关系。
不过这里要特别提醒一个细节:unstack之后如果你做一次stack,可能会发现数据的顺序或者索引名字和你预想的不同,因为stack默认会把剩下的列索引层级都压回去。当列索引还有多余层级时,最好用level参数指定。
3.3 level参数:多层索引里指定往哪一层转
level是stack和unstack里面最容易被忽略、但最关键的参数。默认为-1,也就是最内层。实际项目中,多层索引可能有三层甚至更多,你经常只希望移动其中特定的一层。
举个例子:上面数据如果季度、地区、销售员三层索引,你想只把地区这层移动到列上,就要写unstack(level='地区'),而不是直接unstack()。写错level,得到的表结构完全不是你想要的。
我处理实际问题时有一个原则:只要出现了MultiIndex,就必须明确level是哪个名字或顺序位置。不要赌默认值。尤其是当索引层数增加时,默认的-1经常会把最内层的粒度项转成列,你本以为会得到一个汇总矩阵,结果列数暴增,数据完全变形。
提示:stack和unstack都不会改变数据的"总量",只是重新排列维度。你可以通过检查转换前后df.shape的乘积变化快速判断操作是否符合预期——比如宽表列数为K,转换成长表后行数增加、列数减少,但总单元格数保持不变。
4. 用melt把宽表变长:从矩阵回归明细
4.1 基础场景:把多列溶成两列
如果说pivot是"立墙",melt就是"拆墙"。melt能把宽表的多个数据列"熔"成一个"变量名"列和一个"数值"列,变成我们最爱处理的长表。
假设你拿到的是一份门店每个月的销量统计,每个门店一行,每个月是一列:
df_wide = pd.DataFrame({ '门店': ['A店', 'B店', 'C店'], '一月': [120, 200, 150], '二月': [180, 230, 170], '三月': [210, 260, 190] }) print(df_wide)现在的需求是:把每个月的销量变成一行,方便后续用Matplotlib画趋势图,也方便直接拖进统计模型做分组。melt代码如下:
df_long = df_wide.melt( id_vars='门店', var_name='月份', value_name='销量' ) print(df_long)输出为:
门店 月份 销量 0 A店 一月 120 1 B店 一月 200 2 C店 一月 150 3 A店 二月 180 4 B店 二月 230 5 C店 二月 170 6 A店 三月 210 7 B店 三月 260 8 C店 三月 190你会发现melt把一月、二月、三月三列"溶"成了两列:一列是原来的列名(月份),一列是对应的数值(销量)。id_vars指定保留哪些列不变,var_name是新生成的"列名"列的名称,value_name是新生成的"数值"列的名称。
melt是我们做数据清洗时最常用的方法之一,尤其是上游报表为了展示方便把时间维度摊成多列时,必须先用melt还原成明细,才能继续聚合、筛选、画图。
4.2 与pivot互为逆操作
pivot和melt正好是逆过程:melt把宽表变长,pivot把长表变宽。你用melt生成的df_long,如果再用pivot:
df_recovered = df_long.pivot(index='门店', columns='月份', values='销量') print(df_recovered)基本能恢复成原来的宽表,只是一些顺序和索引细节需要调整。这种"长宽互转"的灵活性是Pandas重构数据最核心的思想之一,我把这套操作称为"数据形态互转三连":先搞清楚你要的最终形状,再选对应的主方法,必要时用一个辅助方法做二次加工。
4.3 其他"变长"工具:wide_to_long
melt能解决90%的宽转长需求,但有一种场景不太方便:宽表的列名带有共同前缀,比如'成绩_语文'、'成绩_数学'、'身高_cm'、'体重_kg',melt会把所有列混在一起,丢失分组关系。这时候更好的选择是pandas.wide_to_long。
举个例子:
df_stu = pd.DataFrame({ '学号': [1, 2, 3], '成绩_期中': [88, 92, 80], '成绩_期末': [90, 89, 85], '考勤_期中': [10, 9, 10], '考勤_期末': [9, 10, 8] })现在想变成每行一个"学号-阶段"组合,包含成绩和考勤两个指标:
df_stu_long = pd.wide_to_long( df_stu, stubnames=['成绩', '考勤'], i='学号', j='阶段', sep='_', suffix='.*' ) print(df_stu_long)wide_to_long的设计思想挺好理解:stubnames是公共前缀,i是标识主键,j是后缀那列生成的新列名,sep是分隔符,suffix是后缀的正则表达式。它不像melt那样"无脑熔",而是按前缀分组再变长,适合列名结构规整的数据。但要注意,它的默认后缀匹配是'\d+',即只匹配数字,非数字后缀比如"期中""期末"必须指定suffix='.*',否则会报错或者匹配为空。这也是我踩过的坑之一。
5. 综合实战:一次完整的数据重塑流程
方法单独看都好懂,但真实项目里往往要把它们串起来用。下面我用一个虚构的业务场景完整走一遍流程。
5.1 需求与初始数据
某公司运营部门拿到一份各区域各品类每月销售金额的宽表,部分列还有合计、空值等问题。需求是:先把数据清洗成长表,再去掉明显异常的合计行,最后转成按"日期-区域"的透视表,并做一个对比图的数据源。
初始表大致长这样:
df_raw = pd.DataFrame({ '区域': ['北京', '上海', '广州'], '品类1_1月': [100, 150, 120], '品类1_2月': [88, 140, 110], '品类2_1月': [210, 240, 220], '品类2_2月': [200, 260, 250], '合计': [598, 790, 700] })这种表就是典型的"为了好看而设计的展示结构",直接分析寸步难行:列名里既有品类又有月份,最后还混着合计,根本没有统一规范字段。
5.2 数据清洗与标准化
第一步:先把列名里的"品类"和"月份"拆成两个维度。这里适合用melt,但要先处理列名。我的做法是先把列名改造成标准的"品类_月份"形式,然后melt。
df_raw = df_raw.rename(columns={'合计': '总计'}) # 人为构造更规范的数据实际项目里你可能从Excel读进来会有各种奇怪列名,规范化的第一步永远是:把所有非业务字段(如总计、小计)识别出来并从分析范围剔除。我用的是"显式指定要保留的维度列"的方式,这也是最安全的。
df_melt = df_raw.melt( id_vars='区域', var_name='指标', value_name='销售额' ) # 把指标拆成品类和月份 df_melt['品类'] = df_melt['指标'].str.extract(r'品类(\d)_(\d+)月')[0] df_melt['月份'] = df_melt['指标'].str.extract(r'品类(\d)_(\d+)月')[1] df_melt['月份'] = df_melt['月份'].astype(int)这里坑点不少:正则表达式如果写错会导致品类或月份变成NaN,而NaN混在维度列里,后面pivot或groupby时会生成奇怪的"nan"分组。我在实操中的对策是:每做完一步提取,立刻检查df_melt['品类'].isna().sum()和月份数量分布,确认没有丢失数据再往下走。
5.3 多步重塑:长→宽→长
清洗完的长表一般长这样:区域、指标、销售额、品类、月份。现在做最终的聚合透视:
pivot_result = df_melt.pivot_table( index='区域', columns='月份', values='销售额', aggfunc='sum', fill_value=0 ) print(pivot_result)如果后续还要继续给统计模型喂数据,把它变回长表,同时保留每行完整维度信息:
final_long = pivot_result.reset_index().melt( id_vars='区域', var_name='月份', value_name='销售额' ) print(final_long)整个链路里有一个细节值得提:pivot_table之后再melt,列名和顺序会发生微妙变化,比如月份会变成数字类型,区域作为普通列而非索引。如果不做reset_index直接melt,id_vars拿不到"区域"。这个小坑,只有自己写过一遍才会记得。
提示:每次重排生成新表后,我都建议立即检查df.shape和df.columns,并对照原始数据总记录数来判断是否发生了丢失或膨胀。比例不对就立刻停手,别等画图才发现数据错了。
6. 常见问题与排查经验
6.1 问题速查表
把我在实际项目中遇到最多的问题整理成一个速查表,供大家直接对号入座:
| 问题现象 | 常见原因 | 解决方案 |
|---|---|---|
| pivot报ValueError: Index contains duplicate entries | index与columns组合有重复 | 先用drop_duplicates,或换pivot_table聚合 |
| pivot后大量NaN | 部分组合没有值 | 用pivot_table的fill_value=0,或对NaN做业务区分 |
| melt后列名变成数字后缀 | 原列名本身是数字 | 先转字符串astype(str),或替换为可读名 |
| stack/unstack后索引层级不对 | level参数用错,或者索引有重名 | 打印df.index或df.columns检查levels,显式指定level |
| wide_to_long结果为空 | sep或suffix写错 | 确认分隔符、后缀正则是否匹配真实列名 |
| groupby结果想转宽表出错 | 结果行索引是MultiIndex | 用reset_index把分组列变成普通列再pivot |
| 表转来转去之后数据类型变了 | 数值列被当成了索引列 | 检查dtypes,必要时用astype重置类型 |
6.2 我的三条实战建议
第一,永远有一个"标准长表"作为主数据形态。我不管原始数据是什么形状,清洗阶段第一件事就是melt或stack成长表,然后用标准列名统一管理。长表对聚合、筛选、画图最友好,展示形态等最后需要可见时再转宽。这样做的结果是:所有项目里代码风格可以高度统一,遇到新数据只要套同一个模板。
第二,结构转换前先备份原始数据。重排和旋转一旦选错方法,数据量大的时候非常容易"灾难性变形"——几千行数据变出几百万行,或者彻底丢失分组信息。我在项目里始终保留一份raw_df副本,转换之前还会打印df.shape,转完再打印一次,两相对比,立刻能发现异常。
第三,善用duplicated和dtypes做校验。reshape之后我最常做的两件事:df[df.duplicated(subset=[...])]去看有没有不该出现的重复行,以及df.dtypes确认月份、ID这类字段没有被转成object类型。数据类型的隐性变化在melt、stack、pivot操作中非常常见,也是最容易导致下游报错的原因。
做数据分析这几年,我对比过很多处理重塑方案,坦白讲Pandas这几个核心方法已经覆盖了90%以上的场景。少数复杂场景可能会引入MultiIndex的碰撞,但只要心里始终装着"长表、宽表、索引层"这三个坐标系,再配上对shape和数据类型的主动检查,基本不会走偏。希望这篇经验总结能让你少走一些我走过的弯路。