Python groupby 为什么给出重复键:连续片段与全局聚合是两回事
输入是A、B、A,itertools.groupby竟然给出三组。它没有漏合并,而是完成了自己的合同:把连续相同键的片段划成一组,键变化就开始下一组。
把它想成SQL的GROUP BY,往往会在日志统计里得到重复键。另一个更隐蔽的错误,是把每一组的迭代器存进列表,等主循环结束后再读取;这时很多组已经空了,因为它们与外层共享输入迭代过程。
这两个行为要一起理解。连续分组让groupby可以边读边处理,却也意味着后续推进会改变旧组还能读取的内容。下面用Python 3.12把连续片段、全局聚合和组迭代器生命周期拆开验证。
from collections import defaultdict
from itertools import groupby
from operator import itemgetter
rows = [('A', 2), ('B', 3), ('A', 5), ('A', 7), ('B', 11)]
segments = [(key, sum(value for _, value in group))
for key, group in groupby(rows, key=itemgetter(0))]
print('segments:', segments)
assert segments == [('A', 2), ('B', 3), ('A', 12), ('B', 11)]
ordered = sorted(rows, key=itemgetter(0))
merged = [(key, sum(value for _, value in group))
for key, group in groupby(ordered, key=itemgetter(0))]
print('sorted groups:', merged)
assert merged == [('A', 14), ('B', 14)]
stored = list(groupby('AAABB'))
print('late group contents:', [(key, list(group)) for key, group in stored])
assert all(list(group) == [] for _, group in stored)
materialized = [(key, list(group)) for key, group in groupby('AAABB')]
print('copied now:', materialized)
assert materialized == [('A', ['A', 'A', 'A']), ('B', ['B', 'B'])]
def aggregate(source):
totals = defaultdict(int)
for key, value in source:
totals[key] += value
return dict(totals)
print('dictionary aggregation:', aggregate(iter(rows)))
assert aggregate(iter(rows)) == {'A': 14, 'B': 14}
def completed_runs(source):
for key, group in groupby(source):
yield key, sum(1 for _ in group)
print('state runs:', list(completed_runs(['up', 'up', 'down', 'up'])))
存为group_runs.py执行。segments里A出现两次,是因为B把两个A片段隔开;sorted groups则先重排输入,得到全局每键总量。late group contents在外层已经耗尽后读取,组内容为空;copied now在每组仍有效时立刻物化,保留下真实字符。
先决定是否允许改变原始顺序
日志状态的连续运行段,通常不应该先排序。up、up、down、up表达两段不同的up;排序后把它们接起来,会丢掉中间发生过down的信息。此时groupby的“只管连续”正好符合问题。
若需求是按用户汇总总量,输入可能交错,则应使用全局聚合,或者先确保按同一分组键排序。排序不是无害准备步骤,它会改变顺序并通常需要容纳数据;字典聚合则保留每个键的累计状态,内存需求与不同键数量相关。
本例sorted使用的键与groupby一致。若按完整元组排序,恰好也可能让首字段相邻,但不能把偶然结果当成普遍规则。按时间排序后再按用户分组,就无法保证相同用户连续。两个操作各写了key参数,不代表它们表达同一种顺序。
处理已由数据库ORDER BY或外部排序生成的流时,可以在连续分组里即时求和,避免再次把整批记录装进内存。前提是输入来源确实提供相应顺序合同;仅仅说“最近看起来排好了”不足以支持这项优化。
组迭代器不是独立的小列表
外层groupby要知道下一组从哪里开始,就必须推进同一个底层输入。进入新组时,上一组未消费的内容会被越过,旧迭代器不能重新回到先前位置。因此代码应在当前循环内完成聚合,或明确复制需要保留的部分。
如果每组都转list,结果当然可在稍后读取,但内存成本也随组大小增加。不能一边宣称“流式、常量内存”,一边把所有组全部物化保存。只需要计数或和时,像程序一样在当前组内累计即可。
对无限输入还要谨慎:一个键永远不变化,当前组就永远不结束。sum要等待这一组耗尽才能返回,输出不会凭空定期刷新。实时监控若需要每秒统计,应采用时间窗口或事件边界,而不是期待groupby自己发现合适的截止点。
如果每组只关心第一条,可以读出那条后继续推进外层;剩余同键记录会为了定位下一组而被消费过去。这种行为适合只取连续片段代表值,却不适合把剩余明细交给另一个独立消费者。底层生成器若带计数或读取动作,这些越过的记录也确实发生过消费。
外层与组迭代器也不适合分给两个线程同时推进。它们不是对同一数据创建的独立游标,随意交错读取会破坏调用方对组边界的理解。需要并发处理时,应在拥有输入的单一流程里完成必要物化或聚合,再把独立结果交出去,并给待处理结果设置容量。
分组键应当稳定,异常也属于消费过程
key函数是在消费输入时调用的。它可能抛异常,也可能读到可变外部状态;如果同一条记录的分组含义会随调用时点变化,连续关系就很难解释。最好先明确规范化规则,再用稳定字段分组。
例如大小写是否合并、空白是否裁剪、空值是否归入特殊组,都应写成业务决定。把原字段原样展示,却用另一份未声明的规范化键计算,可能让读者看见两个不同名字被悄悄归成一组。
生成器中途失败时,已经消费的输入不会自动复原。若结果要写入外部系统,需要决定按组提交还是全批提交;groupby只提供迭代组织,不保证事务或重试。重复运行不可重放的数据源,还可能得到完全不同内容。
字典聚合也有自己的限制:键数量无上限时,累计表可以持续增长;值需要保留明细时,单个键又可能持有大量记录。选择工具时应同时知道总记录量、键基数和是否需要原始顺序,不能只比较哪种写法短。
回到最初的A、B、A,可以先在纸上标出键每次变化的位置,再运行第一条segments断言。要全局合并就补足排序或聚合合同;要保留运行段就保留原顺序;要延后读取就显式复制。三种需求各有答案,函数名字本身不会替我们选。对每份输入都能说明它是否已排序、是否可重放、结果何时被消费,才有条件判断一段简短迭代代码能否安全替换原来的聚合逻辑。