
你有没有遇到过这种场面明明只是想给表格换一种摆放方式结果在 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门店C1月1日120001580099001月2日130001420010500这种表的好处是横向对比一目了然Excel 透视报告基本都是这个形态。但它的缺点是每一行的“门店”并不是一个变量而是被拆成了三列。想按门店分组、按时间画趋势线都要先把列收回去。长表形态则相反每一行是一个最小观测单位日期门店销售额1月1日门店A120001月1日门店B158001月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”。同样一份数据只要加上 aggfuncsum多笔重复记录都会被求和后放进对应格子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销售额, aggfuncsum)aggfunc 不只会接受 sum字符串、函数都可以还能传字典让不同数值列用不同聚合规则比如{销售额: sum, 数量: mean}。pivot_table 有几个参数容易被新人忽略。第一values 如果不传会对所有数值列同时聚合结果会出现多层列名。多数情况下这不是你要的效果所以尽量显式指定 values。第二fill_value 和 dropna 控制缺失格子。组合不存在时聚合结果会是 NaN可以填 0fill_value0也可以直接让这些行列消失dropnaTrue。第三marginsTrue 会给行列都加上“总计”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 月整列是 int2 月整列是 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体重, iid, 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(level0)则把外层索引移下去。对应的unstack 是把行索引的指定层级送回到列索引。判断方向有个土办法stack 是“把列叠到行里”unstack 是“把行拆开到列上”。带着这个直觉再看官方文档就不会因为“level 取 0 还是 -1”而懵。level-1 总是最内层level0 是最外层想换哪一层就传哪一层的编号。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(indexuser[地区], columnsuser[偏好])输出就是一个地区为行、偏好为列的频数矩阵。它看起来像 pivot_table但不需要 values 和 aggfunc因为它默认做的就是 count。normalize 参数经常被忽略但它能让矩阵变成占比normalizeindex 按行归一化每行合计是 100%适合对比不同组的结构差异normalizecolumns 按列归一化normalizeall 变成全表占比。做用户画像、渠道分析时归一化之后的交叉矩阵比原始频数更有参考价值。6.2 values aggfunc从计数表升级为聚合表很多人以为 crosstab 只能数数其实它还可以聚合。给 values 传入数值列再给 aggfunc 传入汇总函数就能得到“交叉分组下的统计量”user2 pd.DataFrame({ 地区: [A区, A区, B区, B区], 偏好: [线上, 线下, 线上, 线下], 消费额: [100, 200, 150, 180] }) pd.crosstab(indexuser2[地区], columnsuser2[偏好], valuesuser2[消费额], aggfuncmean)得到的就是“地区 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 实际例子组内中心化、分位数展开、聚合压平三种模式选哪个取决于你要拿数据去做什么。如果只是想看各部门工资概览groupbyagg 的 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_firstTrue)columns 指定要展开的列prefix 给生成列加前缀drop_firstTrue 会把第一类丢掉只保留 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(columnstrain_dummy.columns, fill_value0)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。这几个检查动作练成肌肉记忆之后“变形”就真的变成你手里随手可用的工具了。