跳转到内容

聚合、groupby 与 transform

计算机基础 · pandas

前置知识:字符串、日期与分类数据

本章目录

本章学习目标

  • 说明拆分、计算和组合过程
  • 区分 agg 与 transform 的结果粒度
  • 核对缺失分组和计数口径

先定义组和指标

groupby 把相同键的记录放进同一组,在各组内计算,再把结果组合起来。按用户汇总订单时,输入每行是一张订单,输出每行是一位有记录的用户;没有订单的用户不会被 groupby 凭空生成,应再连接完整用户表。计算前先声明纳入哪些状态,paid 和 refunded 都是已经支付过的订单,净金额为 amount 减 refund_amount。

一个指标同时需要对象、时间范围、状态和分母。平均订单净金额是净金额总和除以订单数,平均用户净金额则除以用户数,两者不能用同一个 mean 偷换。输出使用命名聚合,让 order_count、net 等列直接表达含义。金额采用 Decimal 或整数分,避免浮点求和的误差被误解为退款差异。

小数据:size 和 count

小表中组 a 有两行,值为二和缺失;组 b 有一行,值为六;另外有一行键缺失、值为八。a 的 size 为二、count 为一、mean 为二,因为均值只使用有效数值。若用 size 作均值的分母,便会得到一,那等于把未知强行当成零。通常应同时提供有效数值数和总行数。

groupby 默认可能排除空分组键,所以示例显式写 dropna=False,保留匿名用户组并断言各组订单数总和等于输入。SQL GROUP BY 会形成 NULL 分组,做结果对照时必须统一这一点。若使用分类列,指定 observed=True 决定只输出实际类别;日期跨度中完全没有记录的日子,也不会自动生成,需要重建日历索引。

聚合与变换的形状

agg 把每组压缩成一行,transform 为原来的每行返回所属组的统计量。例如用户一有三张订单,transform('size') 在这三行都返回三,而 agg 的结果只有一行写三。用户内金额占比可以用每笔净金额除以所属用户净额,但总额为零时需明确避免除零。

当指标只是求和、均值、排名或累计时,优先使用内置方法,它们有清晰的形状和高效实现。apply 能处理更复杂的组逻辑,却可能带来额外索引、类型推断与性能成本。必须说明返回结果如何与原表对应,不能把按组输出的数组直接赋给原表期待自动猜对行位置。

过滤、排序和加权平均

filter 是保留满足条件的整组,普通布尔筛选是保留满足条件的行。若先删除低金额订单再选高收入用户,与先算用户总额再按总额筛选,问题已经不同。HAVING 对应聚合后筛选,WHERE 对应聚合前筛选,pandas 中应保持同样的操作顺序。

加权平均用 sum(value*weight)/sum(weight),不能先算各组平均值再直接平均,除非各组权重相同。例如一天两笔交易,另一天一百笔交易,日均价格的平均不等于全体交易均价。输出排序应使用明确的指标和次级键,确保并列结果稳定;groupby 的默认键排序不是业务排名,不能替代 sort_values。

验证组统计的守恒关系

示例核对订单总数与净金额总和,能够发现空键被默认丢弃或过滤条件错误。输出按用户的统计以及附回订单的组内计数,是同一数据在不同粒度下的表达。检查 transform 的长度等于原订单长度,再检查同一用户内值相同。

边界情形包括空输入、全缺失数值、只有匿名用户及零净额组。空输入时结果应保留预期列而不抛出任意位置访问异常;全缺失数值应使用 min_count 保留未知;零净额组的比例可留缺失并报告原因。练习要求你用自然语言写出每个指标的分母,再用独立的小表手算,不能只接受运行成功的汇总表。

版本与复现约定

本课程 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)
from decimal import Decimal
orders=load("orders").copy()
paid=orders.loc[orders["status"].isin(["paid","refunded"])].copy()
paid["net"]= [Decimal(str(a))-Decimal(str(r)) for a,r in zip(paid["amount"],paid["refund_amount"])]
summary=paid.groupby("user_id",dropna=False).agg(order_count=("order_id","size"),known_amounts=("amount","count"),net=("net","sum"))
assert int(summary["order_count"].sum())==len(paid)
assert sum(summary["net"],Decimal("0"))==sum(paid["net"],Decimal("0"))
paid["user_order_count"]=paid.groupby("user_id",dropna=False)["order_id"].transform("size")
assert len(paid["user_order_count"])==len(paid)
small=pd.DataFrame({"g":["a","a","b",None],"v":[2,None,6,8]})
check=small.groupby("g",dropna=False)["v"].agg(["size","count","mean"])
assert check.loc["a","size"]==2 and check.loc["a","count"]==1 and check.loc["a","mean"]==2
assert check["size"].sum()==4
print(check.to_string())
print(summary.to_string())
summary.reset_index().to_csv(OUT / "pd_group_summary.csv",index=False)

实际运行通过CPU Python execution.

size  count  mean
g                     
a       2      1   2.0
b       1      1   6.0
NaN     1      1   8.0
         order_count  known_amounts    net
user_id                                   
1.0                3              3  144.0
2.0                3              3  720.0
3.0                2              2   25.0
4.0                3              3  144.0
5.0                3              3  720.0
6.0                2              2   25.0
7.0                3              3  120.0
8.0                3              3  720.0
9.0                2              2  18.75
10.0               3              3  144.0
11.0               3              3  600.0
12.0               1              1   12.5
13.0               2              2   96.0
14.0               2              2  480.0
15.0               2              2   25.0
16.0               2              2   96.0
17.0               2              2  480.0
18.0               1              1   12.5
19.0               2              2   96.0
20.0               2              2  360.0
21.0               1              1   12.5
22.0               2              2   72.0
23.0               2              2  480.0
24.0               2              2   25.0
NaN                1              1   12.5

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

01 · agg 后表的粒度是什么?

查看提示

每组是否还保留所有行?

查看参考答案

一般每个分组一行;与原输入行粒度不同。

02 · transform('sum') 有多少行?

查看提示

统计量要附回原记录。

查看参考答案

与输入同长度,每行对应所属组总和。

03 · 空分组键如何保留?

查看提示

dropna 参数。

查看参考答案

groupby(...,dropna=False),并核对计数守恒。

04 · 平均两个组的均值何时正确?

查看提示

组大小。

查看参考答案

当目标明确给各组相同权重时正确;全体样本均值需要按有效样本数加权。

05 · size=2,count=1 表示什么?

查看提示

一个值缺失。

查看参考答案

组有两条记录,但被统计列只有一个有效值。

两项动手任务

任务 1 · 汇总支付订单并核对订单数及净额守恒。

提示

保留匿名用户组,金额用 Decimal。

完整参考解答与检查方式
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)
from decimal import Decimal
orders=load("orders").copy()
paid=orders.loc[orders["status"].isin(["paid","refunded"])].copy()
paid["net"]= [Decimal(str(a))-Decimal(str(r)) for a,r in zip(paid["amount"],paid["refund_amount"])]
summary=paid.groupby("user_id",dropna=False).agg(order_count=("order_id","size"),known_amounts=("amount","count"),net=("net","sum"))
assert int(summary["order_count"].sum())==len(paid)
assert sum(summary["net"],Decimal("0"))==sum(paid["net"],Decimal("0"))
paid["user_order_count"]=paid.groupby("user_id",dropna=False)["order_id"].transform("size")
assert len(paid["user_order_count"])==len(paid)
small=pd.DataFrame({"g":["a","a","b",None],"v":[2,None,6,8]})
check=small.groupby("g",dropna=False)["v"].agg(["size","count","mean"])
assert check.loc["a","size"]==2 and check.loc["a","count"]==1 and check.loc["a","mean"]==2
assert check["size"].sum()==4
print(check.to_string())
print(summary.to_string())
summary.reset_index().to_csv(OUT / "pd_group_summary.csv",index=False)

任务 2 · 计算两组加权平均并验证不能直接平均组均值。

提示

直接累计 value*weight。

完整参考解答与检查方式
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({"g":["a","a","b"],"v":[10,20,100],"w":[1,1,8]})
weighted=(d["v"]*d["w"]).sum()/d["w"].sum()
assert weighted==83
plain=d.groupby("g")["v"].mean().mean()
assert plain!=weighted
print("加权平均:",weighted,"组均值再平均:",plain)

官方资料与掌握状态

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

动手任务记录

掌握状态与复习

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

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