Pandas 的 groupby 是数据分析里使用频率最高的操作之一,但不少人学了 groupby 之后只会调用 mean、sum 这类聚合函数,得到一个压缩后的结果表。真正麻烦的场景往往是:分组统计完了,还需要把统计结果作为新列添加回原始数据的每一行,或者在组内做逐行计算。这篇文章就来系统讲讲如何在 Group 内添加数据,以及 NumPy 在其中能发挥什么作用。

一、理解 groupby 的拆分-应用-合并机制
想要在组内添加数据,首先得明白 groupby 到底做了什么。Pandas 官方把 groupby 的流程概括为 split-apply-combine 三步:先按照键把 DataFrame 拆成若干个子块,再对每个子块应用一个函数,最后把结果合并起来返回。理解这个机制的关键在于,第二步应用的函数返回什么形态,直接决定了最终输出的形态。
如果应用的是聚合函数(比如 sum),每个组会压缩成一行,返回的是一个缩小后的 Series 或 DataFrame。而如果希望输出和原始数据行数一致、只是多了几列组内计算的结果,就需要让函数对每一行都返回一个值。这个区别是后面所有内容的基础,也是很多人踩坑的地方:明明想要按组添加一列,结果用 agg 得到了一张被压缩的表,再用 merge 拼回去,既绕远路又容易出错。
先构造一份示例数据,后面所有操作都基于它展开:
import pandas as pd
import numpy as np
df = pd.DataFrame({
'城市': ['北京', '北京', '上海', '上海', '广州', '广州'],
'产品': ['A', 'B', 'A', 'A', 'B', 'B'],
'销量': [120, 150, 90, 110, 80, 95],
'单价': [10.0, 12.0, 10.0, np.nan, 9.5, 9.8]
})
print(df)二、transform:把组级结果广播回每一行
transform 是在组内添加数据最直接的工具。它要求传入的函数对每个组返回一个与该组等长的结果,或者返回一个标量(标量会被自动广播到组内所有行)。输出结果的索引与原始 DataFrame 完全一致,因此可以直接赋值为新列,不需要任何合并操作。
举个最常见的例子,给每一行添加它所在组的平均销量:
# 添加组内平均销量列
df['组内均量'] = df.groupby('城市')['销量'].transform('mean')
# 添加组内销量占比
组总量 = df.groupby('城市')['销量'].transform('sum')
df['销量占比'] = df['销量'] / 组总量
print(df)transform 的另一个高频用途是按组填充缺失值。上面的示例数据里上海的某行单价是缺失的,直接用全表均值填充显然不合理,用按城市分组的均值填充更贴近业务含义:
# 按组用均值填充缺失值
df['单价'] = df.groupby('城市')['单价'].transform(
lambda s: s.fillna(s.mean())
)
print(df['单价'])除了 transform,还可以通过 groupby 加 transform 的变体写法实现组内累计计算,比如累计求和、组内排名,这类需求在报表场景里非常多:
# 组内累计销量
df['累计销量'] = df.groupby('城市')['销量'].cumsum()
# 组内销量排名(从高到低)
df['组内排名'] = df.groupby('城市')['销量'].rank(ascending=False)
print(df)需要注意 transform 和 agg 的本质区别:agg 返回的是每组一个值的压缩结果,transform 返回的是与原数据等长的展开结果。判断该用哪个,就看新列是每行一个值还是每组一个值。如果确实拿到了组级的 agg 结果又想拼回原表,也可以用 map 加字典的方式:
组均值 = df.groupby('城市')['销量'].mean()
df['组均值2'] = df['城市'].map(组均值)三、apply 与自定义函数:灵活但有代价
当组内的计算逻辑比较复杂、需要用到多列数据时,transform 可能不够用,这时可以借助 apply。apply 接收的函数会把每个组当作一个小 DataFrame 处理,你可以随意计算并返回带新列的 DataFrame:
# 组内添加标准化后的销量列
def 标准化(g):
g = g.copy()
g['标准化销量'] = (g['销量'] - g['销量'].mean()) / g['销量'].std()
return g
df2 = df.groupby('城市', group_keys=False).apply(标准化)
print(df2)这里有两个细节值得注意。一是 group_keys=False 可以避免结果里出现组键的多层索引,让输出保持和原表相同的结构。二是 apply 会对每个组各调用一次 Python 函数,当组数很多时开销会明显放大,性能远不如内置的向量化方法。经验法则是:能用 transform 加字符串方法名解决的,就不要写 apply。
多列组合计算也可以不借助 apply,直接对 groupby 对象传多个列,再用 transform 调用支持多列的函数。例如给每行添加组内销售额(销量乘单价的组内合计):
df['销售额'] = df['销量'] * df['单价']
df['组内销售占比'] = df['销售额'] / df.groupby('城市')['销售额'].transform('sum')四、借助 NumPy 提升分组计算效率
Pandas 底层构建在 NumPy 之上,groupby 的很多操作最终都会转化为 NumPy 的数组运算。手工利用 NumPy 的能力,可以在特定场景下绕开 Pandas 的分组开销,获得可观的性能提升,尤其是数据量大、组数多的时候。
一个典型技巧是利用组内排序加 np.repeat 的方式实现组内广播。思路是先按组键排序,统计每组的行数,然后用 NumPy 把组级统计结果按行数重复展开,直接对齐到每一行:
# 用 NumPy 手动实现组均值广播
排序df = df.sort_values('城市')
组均值_arr = 排序df.groupby('城市')['销量'].mean().to_numpy()
组大小_arr = 排序df.groupby('城市')['销量'].size().to_numpy()
# np.repeat 把每个组的均值重复对应次数,对齐每一行
排序df['组内均量_np'] = np.repeat(组均值_arr, 组大小_arr)
df = 排序df.sort_index() # 恢复原始顺序
print(df)这段代码看起来更繁琐,但它展示了 groupby 广播的底层逻辑:transform 做的事情本质上就是把组级结果按组大小重复。在大数据量下,如果能把分组的因子编码成整数,还可以用 np.bincount 极快地计算分组求和:
# 用 bincount 实现高速分组求和
组编码 = df['城市'].astype('category').cat.codes.to_numpy()
值 = df['销量'].to_numpy()
组总和 = np.bincount(组编码, weights=值)
组大小 = np.bincount(组编码)
组均 values = 组总和 / 组大小
df['bincount均值'] = 组均 values[组编码]
print(df)bincount 的时间复杂度接近线性,比反复调用 Python 层函数快一个数量级以上。其核心在于用 cat.codes 把组键映射成从零开始的整数索引,然后直接用数组下标取回每行对应的组级结果,整个流程没有任何 Python 循环。当然,日常工作中 transform 本身已经做了不错的优化,只有 profiling 显示分组操作确实是瓶颈时,才值得用这种 NumPy 手工方案替换。
五、常见坑与最佳实践
组内添加数据时有几个容易出错的地方需要提醒。第一,transform 里传自定义 lambda 时,函数必须返回与组等长的结果或标量,如果返回了不等长的 Series 会直接报错,这时应改用 apply。第二,groupby 默认会丢弃分组键为 NaN 的行,涉及缺失键时要显式设置 dropna=False。第三,分组后想保持原表顺序,可以设置 sort=False,避免分组键被隐式排序打乱行序。
最佳实践可以归纳为三条:优先用 transform 加内置方法名(mean、sum、cumsum、rank 等),这是性能与可读性的最佳平衡点;复杂逻辑用 apply 并记得 copy 组数据避免修改原表的警告;只有在性能瓶颈明确时才考虑 NumPy 的手工向量化方案。掌握这些方法后,无论是按组添加统计列、按组填充缺失值还是组内标准化,都能用简洁的代码直接落地。