数据重塑:melt、stack 与 unstack 的使用方法
摘要
同一份业务数据,可能以适合人工阅读的宽表保存,也可能以适合程序分析的长表保存。宽表便于查看不同月份、渠道或指标的横向对比,长表则更适合分组、筛选、绘图和写入数据库。数据分析过程中,经常需要在两种结构之间转换。
本文介绍 pandas 中melt、stack和unstack的使用方法,说明长表与宽表的区别、标识列和度量列的划分方式,以及pivot、pivot_table与数据重塑之间的关系。最后通过销售数据案例完成宽转长、长转宽、MultiIndex 重塑和结果校验。
一、背景与问题
1. 宽表和长表
宽表通常把同一类指标展开到多个列中:
region channel 一月 二月 三月 华东 线上 120 150 180 华东 门店 90 110 125 华南 线上 80 95 130长表则把“月份”从列名变成一列:
region channel month sales 华东 线上 一月 120 华东 线上 二月 150 华东 线上 三月 180两种结构没有绝对的优劣,关键在于使用场景:
| 数据结构 | 更适合的场景 |
|---|---|
| 宽表 | 人工查看、报表展示、横向对比 |
| 长表 | 分组聚合、筛选、可视化、建模、入库 |
2. 数据重塑不是数据聚合
数据重塑主要改变行列的组织方式,通常不会主动计算总和、平均值或最大值。数据聚合则会把多条记录压缩成统计结果。
例如,把“一月、二月、三月”转换为month列属于重塑;按照地区计算销售总额属于聚合。混淆二者,容易在转换过程中无意丢失明细。
3. 需要先明确一行代表什么
执行重塑前,要先写清楚数据粒度:
一行 = 一个地区 + 一个渠道 + 一个月份的销售记录如果region + channel + month不能唯一确定一行,直接使用pivot就可能报错;如果强行使用pivot_table,还需要明确重复数据应该如何聚合。
二、核心概念
1.melt:把宽表转换为长表
melt的核心思想是:
保留标识列 ↓ 把多个指标列展开 ↓ 生成变量列和数值列常用参数如下:
| 参数 | 作用 |
|---|---|
id_vars | 需要原样保留的标识列 |
value_vars | 需要展开的列 |
var_name | 展开后变量列的名称 |
value_name | 展开后数值列的名称 |
ignore_index | 是否重新生成连续索引 |
例如,region和channel是标识列,一月、二月、三月是需要展开的度量列。
2.stack:把列层级移动到行索引
stack主要面向索引和列索引,作用是把列轴上的一个层级移动到行索引中。转换之后,结果通常会出现MultiIndex。
列轴:一月、二月、三月 ↓ stack 行索引:地区、渠道、月份当 DataFrame 的列是普通单层列时,stack通常返回 Series;当列本身是多层列索引时,stack可以返回仍然带有多个列的 DataFrame。
3.unstack:把行索引层级移动到列
unstack与stack方向相反,它会把行索引中的某一层移动到列轴:
行索引:地区、渠道、月份 ↓ unstack("month") 列轴:一月、二月、三月使用unstack前,通常需要先通过set_index创建多层索引,或者已经拥有一个MultiIndex。
4.pivot与pivot_table
| 方法 | 是否允许重复键 | 是否聚合 |
|---|---|---|
pivot | 不允许 | 不聚合,重复键会报错 |
pivot_table | 允许 | 会按照aggfunc聚合 |
unstack | 依赖索引组合 | 不负责业务聚合 |
如果业务上确认每个键组合只有一条记录,优先使用pivot,这样可以尽早发现重复数据。如果重复数据本来就有业务意义,则应使用pivot_table并明确聚合规则。
三、工作原理
1.melt如何计算结果行数
如果原始表有n行,选择了m个value_vars,且没有过滤缺失值,那么melt通常会生成n × m行。
例如:
原始数据:3 行 展开列:3 列 结果数据:3 × 3 = 9 行因此,展开前后行数变化是可预期的。不能把行数增加直接判断为重复数据。
2.stack与 MultiIndex
stack的关键不是简单地“把列变成行”,而是将列标签写入索引层级。一个二维表在重塑后可能变成:
region channel month 华东 线上 一月 120 二月 150 华东 门店 一月 90 二月 110如果索引或列轴中存在多个层级,就要通过层级名称或位置指定重塑对象,例如stack("month")或unstack("channel")。
3. 缺失值与零值不是一回事
重塑可能产生两类“空”:
- 原始数据中本来没有这条组合记录;
- 这条组合存在,但业务值确实为零;
两者不能直接用fillna(0)混为一谈。前者可能表示没有销售、未采集或不适用,后者表示确实发生了记录值为零的业务事件。
4. 重塑后的排序
melt会按照value_vars的顺序生成结果。stack和unstack可能改变索引顺序,因此进行结果比较时,应该明确排序字段,而不是依赖当前显示顺序。
四、实战示例
1. 准备宽表
下面的示例使用 Python 3.10+ 和 pandas 2.1+。各代码块按顺序运行,可以直接使用交互式解释器或 Jupyter Notebook。
python -m pip install "pandas>=2.1"importpandasaspd wide=pd.DataFrame({"region":["华东","华东","华南"],"channel":["线上","门店","线上"],"一月":[120,90,80],"二月":[150,110,95],"三月":[180,125,130],})print(wide)这张表中,region和channel用来识别一条业务记录,月份列是需要展开的销售指标。
2. 使用melt转成长表
long=wide.melt(id_vars=["region","channel"],value_vars=["一月","二月","三月"],var_name="month",value_name="sales",)long=long.sort_values(["region","channel","month"],ignore_index=True,)print(long)结果结构如下:
region channel month sales 0 华东 线上 一月 120 1 华东 线上 三月 180 2 华东 线上 二月 150 ...如果需要按自然月份排序,不要依赖中文月份的字符串排序,可以显式设置分类顺序:
month_order=["一月","二月","三月"]long["month"]=pd.Categorical(long["month"],categories=month_order,ordered=True,)long=long.sort_values(["region","channel","month"],ignore_index=True,)3. 从长表恢复宽表
因为region + channel + month在本例中是唯一键,所以可以使用pivot:
restored=(long.pivot(index=["region","channel"],columns="month",values="sales",).reindex(columns=month_order).reset_index())restored.columns.name=Noneprint(restored)pivot不会替你处理重复键。如果同一个地区、渠道和月份出现多条记录,可以改用:
summary=long.pivot_table(index=["region","channel"],columns="month",values="sales",aggfunc="sum",fill_value=0,observed=True,).reindex(columns=month_order).reset_index()summary.columns.name=None这里的sum是明确的业务选择,不应为了消除报错而随意添加。
4. 使用stack和unstack
下面把宽表的标识列设置为多层索引,再将月份列堆叠到索引中:
indexed=wide.set_index(["region","channel"])stacked=indexed.stack(future_stack=True)stacked.name="sales"stacked.index=stacked.index.set_names(["region","channel","month"])print(stacked)再把month这一层索引移回列轴:
unstacked=stacked.unstack("month")unstacked=unstacked.reindex(columns=month_order)print(unstacked)future_stack参数从 pandas 2.1 开始提供。这里显式使用新的堆叠实现,不再依赖旧实现的默认缺失值处理;使用它时,不要同时传入dropna和sort。如果项目仍使用更早的 pandas,请查阅对应版本文档或先升级,不要直接复制混用版本的参数。
对于本例这种单层列结构,新实现会保留原表中已有位置上的缺失值:
with_missing=indexed.copy()with_missing.loc[("华东","线上"),"二月"]=float("nan")missing_stacked=with_missing.stack(future_stack=True)assertlen(missing_stacked)==with_missing.sizeassertpd.isna(missing_stacked.loc[("华东","线上","二月")])“保留已有缺失值”不等于“自动补齐所有可能的索引组合”。对多层列索引,仍要区分原表存在的列组合和业务上根本不存在的组合。
5. 验证重塑是否保持数据
仅仅看到结果形状正确还不够,还应该验证键和数值是否一致:
expected=(wide.set_index(["region","channel"]).reindex(columns=month_order).sort_index())actual=(restored.set_index(["region","channel"]).reindex(columns=month_order).sort_index())pd.testing.assert_frame_equal(expected,actual,check_names=False,)在生产脚本中,可以额外检查:
assertlen(long)==len(wide)*len(month_order)assertnotlong.duplicated(["region","channel","month"]).any()assertlong["sales"].sum()==wide[month_order].sum().sum()这些检查分别验证了行数、键唯一性和总量是否保持。
五、常见问题与实践建议
1.pivot报重复键错误
先定位重复组合:
duplicates=(long.groupby(["region","channel","month"],dropna=False,observed=True,).size().reset_index(name="count").query("count > 1"))print(duplicates)如果重复记录是脏数据,应回到数据源处理;如果重复记录表示多笔订单,则先按照业务规则聚合,再进行透视。
2. 把melt当成聚合操作
melt只是改变数据排列方式,不会自动计算每个地区的总销售额。需要统计时,应该显式使用groupby:
region_total=(long.groupby("region",as_index=False)["sales"].sum().rename(columns={"sales":"total_sales"}))3. 月份、季度等标签被错误排序
字符串排序不一定符合业务顺序。月份、季度、等级和流程状态等字段,应使用有序分类或显式排序映射。
4. 把缺失值直接填成零
在填充之前先确认空值语义。如果空值表示“没有销售记录”,可以在明确业务规则后填零;如果表示“数据未采集”,则应保留缺失或单独增加质量标记。
5. 重塑后索引名称没有清理
pivot、stack和unstack可能保留columns.name或多层索引名称。输出报表前,可以使用:
result=unstacked.reset_index()result.columns.name=None6. 不检查数据量变化
对melt来说,行数增加通常是正常的;对pivot来说,行数减少也可能是正常的。关键是提前计算预期粒度,并在代码中加入断言。
六、进阶思考
1. 宽表和长表应服务于不同环节
一个稳定的数据处理流程,通常可以采用如下分工:
原始文件宽表 ↓ melt 标准化长表 ↓ 清洗、关联、聚合 分析结果长表 ↓ pivot / unstack 报表展示宽表不要为了让中间结果“看起来像 Excel”而过早恢复成宽表,否则可能增加后续筛选和聚合的复杂度。
2. 多层列索引适合表达多个维度
当列同时包含“指标”和“统计口径”时,可以使用MultiIndex:
columns=pd.MultiIndex.from_tuples([("sales","sum"),("sales","mean"),],names=["metric","agg"],)multi=pd.DataFrame([[450,150],[325,325/3]],index=pd.Index(["线上","门店"],name="channel"),columns=columns,)metric_rows=multi.stack("agg",future_stack=True)print(metric_rows)这里把agg从列层级移到行索引,metric仍保留在列轴,因此结果是 DataFrame,而不是 Series。
这类结构适合复杂报表,但对下游系统不够直观。导出 CSV 或写入数据库前,通常需要扁平化列名,并检查扁平化后是否出现重名。
3. 注意数据量膨胀
melt会把每个原始行复制到多个度量变量。对于百万级数据,展开前要估算内存占用,必要时只选择需要的value_vars,或分批处理文件。
4. 重塑结果要保留可追溯字段
长表中应保留能够定位原始记录的字段,例如订单号、日期、地区、渠道和产品编码。只有指标值而没有业务主键的长表,很难排查异常和回溯来源。
5. 把数据契约和校验一起封装
不要仅封装一行melt。更有价值的是把输入字段、键唯一性和输出行数约束放在同一个函数中:
defreshape_sales(frame,months):required={"region","channel",*months}missing=required-set(frame.columns)ifmissing:raiseValueError(f"缺少字段:{sorted(missing)}")ifframe.duplicated(["region","channel"]).any():raiseValueError("地区与渠道组合必须唯一")result=frame.melt(id_vars=["region","channel"],value_vars=months,var_name="month",value_name="sales",)assertlen(result)==len(frame)*len(months)returnresult checked_long=reshape_sales(wide,month_order)assertlen(checked_long)==9往返转换不保证所有元数据自动恢复:多个数值列合并到一个值列时,类型可能统一;类别顺序、轴名称也可能变化。测试除了检查总和,还应比较各业务键对应的值,并根据项目契约决定是否严格检查数据类型。
结论
melt适合把宽表转换成长表,stack适合把列轴层级移动到行索引,unstack则可以把索引层级恢复到列轴。掌握这三个操作后,可以在业务明细、统计中间表和报表展示表之间灵活转换。
实际使用时,要先明确一行的业务粒度,再检查键是否唯一、空值代表什么、重塑后数据量是否符合预期。下一步可以继续学习 pandas 的描述性统计、时间序列分析和可视化,把重塑后的标准长表用于更完整的数据分析流程。
参考资料
- pandas DataFrame.melt 官方文档
- pandas DataFrame.stack 官方文档
- pandas 数据重塑与透视表官方指南