跳转到内容

长宽表转换

计算机基础 · pandas

前置知识:聚合、groupby 与 transform · merge、concat 与连接检查

本章目录

本章学习目标

  • 判断长表与宽表的用途
  • 在唯一键前提下 pivot 并用 melt 还原
  • 显式声明透视聚合方式

同一数据的两种布局

长表把类别值放在列中,例如日期、事件类型、事件次数,每行是一个日期和类型组合;宽表把事件类型展开为列,每行是一个日期。长表容易追加新的类别,适合分组、筛选和绘图库的数据接口;宽表容易比较同一天多个指标,适合报告或矩阵计算。布局改变不应该改变业务总量,但是否增添缺失组合和是否聚合会影响行数。

选布局时先说明一行的粒度和唯一键。用户事件表原本每行一条事件,一个日期同类事件可能出现多次,不能直接认为日期和类型是唯一键。需要先聚合到日和类型,再展开;如果想保留每条事件,就要有 event_id 而不是把多行强行挤进一个单元格。

pivot 的前提

pivot 用 index 指定行键、columns 指定要展开的类别、values 指定填入单元格的数值。每个行键和类别组合只能出现一次,否则应报错。这个错误提示输入粒度不符合转换合同,不能简单忽略。先用 duplicated 检查组合,再决定是否该聚合。

pivot_table 可处理重复组合,因为它先按组合聚合。默认通常是均值,而业务事件次数需要求和,因此必须写 aggfunc='sum'。如果值代表用户编号,不能求和;可能要 nunique,表示不同用户数。不同用户数也不能跨类型直接相加当作总用户数,因为同一用户可能属于多个类型。方法名称相近并不意味着业务定义相同。

小数据手工透视

小表在第一天有两条 view,次数分别一和二;有一条 cart,次数四。直接 pivot 会拒绝两条同键 view;pivot_table 求和以后第一天 view 为三、cart 为四。若没有声明聚合而采用均值,view 会是一点五,明显偏离事件数定义。

事件脚本先按日和类型数行,得到唯一组合,然后 pivot。某日没有 cart 事件,宽表对应位置为空;在本课程完整事件日志的前提下可填零,表示观察期内确实没有该事件。如果整天日志丢失,则填零会把采集故障写成真实没有活动,必须先检查日志完整性。缺失组合的意义必须由数据采集合同决定。

melt 与往返验证

melt 用 id_vars 保留日期这样的标识列,把指标列堆叠成 variable 和 value 两列。本例命名为 event_type 和 event_count,转换回长表后,包括零事件组合也有记录,因此行数可能大于最初的已观测长表。正确的往返检查是比较键上的数值和事件总和,而不是要求行数完全相同。

宽表列是类别,若类别顺序重要,应显式 reindex(columns=...)。输出文件把日期索引保存为命名列或有意义的索引,读取时再次指定解析。多个数值列透视可能产生多级列,应在交付报告前明确展开规则,避免出现难以理解的嵌套列标签。

重塑时保持统计含义

stack 和 unstack 处理索引层级,melt 和 pivot 处理普通列,初学可从后者入手再理解前者。把宽表转 NumPy 数组后列名消失,必须同时保存列顺序,否则模型的特征含义会被打乱。重复列名也会使选择结果出乎意料,输出之前检查 columns.is_unique。

常见错误包括默认平均事件次数、把未观测值任意填零、将各类别独立用户数相加,以及把长表多出的零行认作重复。练习从三个小数值手算透视再运行脚本,最后检查全体计数守恒与单个格子一致;这一检查比仅看表格形状更能验证数据语义。

版本与复现约定

本课程 pandas 示例以 2.3.3 为验证基线,页面运行记录给出实际版本。pandas 3.x 默认使用 Copy-on-Write:从表中取出的对象发生修改时,不据此假定原表同步变化。跨版本都应使用一次完整的 loc 赋值表达式;需要独立工作表时显式 copy。阅读网上的旧示例时,先核对版本、输入类型和索引,再判断结果能否复现。

完整示例与实际输出

from pathlib import Path
import pandas as pd
import numpy as np
from _shared import load
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "outputs"
OUT.mkdir(exist_ok=True)
events=load("events").copy()
events["day"]=pd.to_datetime(events["event_time"]).dt.normalize()
daily=events.groupby(["day","event_type"],dropna=False).size().rename("event_count").reset_index()
assert not daily.duplicated(["day","event_type"]).any()
wide=daily.pivot(index="day",columns="event_type",values="event_count").fillna(0).astype("int64")
long=wide.reset_index().melt(id_vars="day",var_name="event_type",value_name="event_count")
assert int(long["event_count"].sum())==len(events)
assert int(wide.to_numpy().sum())==len(events)
small=pd.DataFrame({"day":["d1","d1","d1"],"type":["view","view","cart"],"n":[1,2,4]})
try:
    small.pivot(index="day",columns="type",values="n")
except ValueError:
    print("pivot 正确拒绝重复键")
else:
    raise AssertionError("pivot 应拒绝重复组合")
table=small.pivot_table(index="day",columns="type",values="n",aggfunc="sum",fill_value=0)
assert table.loc["d1","view"]==3
print(wide.head().to_string())
wide.to_csv(OUT / "pd_events_wide.csv")

实际运行通过CPU Python execution.

pivot 正确拒绝重复键
event_type  cart  purchase  view
day                             
2026-01-01     2         1     2
2026-01-02     3         1     4
2026-01-03     2         1     4
2026-01-04     4         3     6
2026-01-05     3         1     6

五道自测:先回答,再核对

01 · pivot 为什么拒绝重复组合?

查看提示

一个格子只能容纳一个数。

查看参考答案

行键与列类别不能唯一确定数值,必须先明确聚合或保留更细粒度。

02 · pivot_table 默认聚合适合事件计数吗?

查看提示

默认均值。

查看参考答案

通常不适合,事件计数应显式 aggfunc='sum' 或先 groupby.size。

03 · 宽表缺失总能填零吗?

查看提示

是否有完整日志。

查看参考答案

不能,只有确认完整观察且没有事件时才可解释为零。

04 · melt 为什么可能增加长表行数?

查看提示

零组合。

查看参考答案

宽表填零后,原未观测组合也转成了记录。

05 · 不同类型的独立用户数能直接相加吗?

查看提示

用户可能重叠。

查看参考答案

不能;要在目标范围直接 nunique 用户键。

两项动手任务

任务 1 · 生成每日事件宽表并做总量核对。

提示

先聚合再透视,显式填零依据。

完整参考解答与检查方式
from pathlib import Path
import pandas as pd
import numpy as np
from _shared import load
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "outputs"
OUT.mkdir(exist_ok=True)
events=load("events").copy()
events["day"]=pd.to_datetime(events["event_time"]).dt.normalize()
daily=events.groupby(["day","event_type"],dropna=False).size().rename("event_count").reset_index()
assert not daily.duplicated(["day","event_type"]).any()
wide=daily.pivot(index="day",columns="event_type",values="event_count").fillna(0).astype("int64")
long=wide.reset_index().melt(id_vars="day",var_name="event_type",value_name="event_count")
assert int(long["event_count"].sum())==len(events)
assert int(wide.to_numpy().sum())==len(events)
small=pd.DataFrame({"day":["d1","d1","d1"],"type":["view","view","cart"],"n":[1,2,4]})
try:
    small.pivot(index="day",columns="type",values="n")
except ValueError:
    print("pivot 正确拒绝重复键")
else:
    raise AssertionError("pivot 应拒绝重复组合")
table=small.pivot_table(index="day",columns="type",values="n",aggfunc="sum",fill_value=0)
assert table.loc["d1","view"]==3
print(wide.head().to_string())
wide.to_csv(OUT / "pd_events_wide.csv")

任务 2 · 将两个指标宽表转成长表,验证值和键。

提示

id_vars 保留对象编号。

完整参考解答与检查方式
from pathlib import Path
import pandas as pd
import numpy as np
from _shared import load
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "outputs"
OUT.mkdir(exist_ok=True)
d=pd.DataFrame({"user_id":[1,2],"views":[3,4],"carts":[1,0]})
long=d.melt(id_vars="user_id",var_name="metric",value_name="count")
assert len(long)==4 and long["count"].sum()==8
back=long.pivot(index="user_id",columns="metric",values="count")
assert back.loc[1,"views"]==3 and back.loc[2,"carts"]==0
print(long.to_string(index=False))

官方资料与掌握状态

完成两项任务后,按独立完成程度记录。状态会尽量保存在当前浏览器,可导出备份。

动手任务记录

掌握状态与复习

关掉资料独立完成后记 A;提示后完成记 B;需要重学记 C。

记录尽量保存在当前浏览器,建议定期导出备份。