
做数据分析这些年我最常被同事问到的不是复杂模型而是这种看起来很基础的问题这张表怎么转个方向领导要看按区域横向排列的对比看板下游的同事要拉成长表才能接进配置系统画图库又强制要求一行一条记录。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里的标准做法是meltweather_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销售额, aggfuncsum, fill_value0 )这次即使华东在Q1有两条记录也没关系aggfuncsum会自动把它们加总。结果如下季度 Q1 Q2 Q3 区域 华东 120 135 150 华北 98 110 0在真实业务里aggfunc还可以传函数列表同时得到多个统计量如果希望透视表里有汇总行和汇总列加一个marginsTrue即可。fill_value0只负责把因组合缺失产生的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销售额, aggfuncsum, fill_value0 ) pivoted.stack()pivoted.stack()的结果是把Q1、Q2、Q3这三列折叠进索引形成两级索引的Series。此时如果再调用reset_index()就能得到一个规整的长表DataFramepivoted.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[北京, 上海], columnscols)只保留一层的stack()会默认把最内层的季度列堆进索引想保留指标层、把季度层堆进索引可以用stack(level1)。反过来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销售额, aggfuncsum )第二步业务要看城市 × 渠道的订单分布orders_by_city_channel raw.pivot_table( index城市, columns渠道, values订单数, aggfuncsum )5.2 把第一张透视表还原成长表喂给下一步分析看板做完数据分析同事说需要日期、城市、销售额三列的长表这样才能跑模型或画图。这时从第一张透视表反推即可sales_long sales_by_date_city.stack().reset_index(name销售额)stack()把城市列折叠成行索引reset_index(name销售额)把折叠后的Series转回DataFrame并显式命名数值列。这里还有一个隐藏问题pivot_table默认aggfuncmean如果在第一步忘了写aggfuncsum这里得到的就是同一天同一城市的线上线下销售额平均值而不是总和。这种错误在数据量大的时候极难发现我建议拿到任何透视结果先抽查几行和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_tablepivot只在给宽表列拆行、且确定无重复时才用。这个习惯省了很多报错排查的时间。6.2 unstack 之后出现整片NaN别急着填0unstack之后会自然产生缺失值因为原始数据里没有覆盖到所有组合。这时候很多人习惯立刻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或交付时被追问这张表是怎么变出来的。