1. 项目概述:为什么多维聚合不是“加总求平均”那么简单
我在银行数据平台组干了八年,从最早用SQL写几十行嵌套子查询做客户分层,到后来带团队搭实时风控指标体系,踩过最多的坑,八成出在“聚合”这两个字上。不是不会写
GROUP BY
,而是写完发现:业务方要的不是“每个区域的平均交易额”,而是“每个区域、每个产品线、每个客户等级组合下,过去30天滚动均值 vs 年初至今累计值 vs 同比变化率”的三维快照;风控模型要的不是“单个商户的交易标准差”,而是“该商户在餐饮类目中,近7天交易金额范围(max-min)是否突破其历史95分位阈值”的动态判据。这些需求,用
df.groupby().mean()
连门都摸不到——它连“同一列同时算均值和中位数”都做不到,更别说跨时间窗口、跨业务维度、带条件逻辑的复合计算。
这篇讲的,就是我们每天在生产环境里真刀真枪跑的那套东西。标题里“Multi-Dimensional Aggregation”听着学术,其实就三件事: 怎么一次算清多个指标、怎么把业务规则塞进聚合逻辑、怎么让时间维度活起来 。它不教pandas基础语法,因为你会;它也不讲理论推导,因为业务没时间等你推完公式。它只讲:当运营半夜发消息说“快查下华东区高端客户在奢侈品类目的大额交易突增有没有异常”,你打开Jupyter,敲哪几行代码能三分钟给出带时间对比、分位线标注、异常标记的表格——而且明天还能复用、能交接、能进CI/CD流水线。
核心关键词我直接拆给你看:“Towards AI - Medium”不是随便贴的标签,是这类内容的典型产出场景——面向一线数据工程师、分析师、风控建模师的实战笔记,不是学院派论文,也不是入门教程。它要求:
零废话、有上下文、带血槽(真实报错)、留后门(可扩展点)
。比如原文里那个
transaction_range
函数,表面是算极差,但实际在银行系统里,它后面必然跟着一个
if x.max() - x.min() > threshold * x.mean(): alert()
的告警链路;那个
weighted_average
里的
np.linspace(0.5, 1.5, len(series))
权重设计,根本不是数学游戏,而是对应着“最近3笔交易权重翻倍,因为欺诈模式往往在爆发前有试探性小额交易”的反洗钱规则。这些藏在代码背后的业务心跳,才是你真正该抄走的干货。
适合谁读?如果你还在为“同一个groupby要跑五次取不同指标,merge时索引对不上”抓狂;如果你写的自定义agg函数被同事吐槽“看不懂为啥要除以2.3”;如果你的滚动窗口结果一上线就因NaN值被下游报表系统报错;或者你正被老板追问“为什么Q3南区零售GMV环比涨了15%,但客户投诉量涨了40%”却拿不出交叉归因——那你不是来学pandas的,你是来拿生产级解法的。下面所有内容,我都按银行真实数据管道的节奏展开:先告诉你 为什么必须这么干 ,再拆解 每一步在生产环境里怎么扛住压力 ,最后给你 实测有效的避坑清单 。
2. 多维聚合的核心设计逻辑:从“算得对”到“算得稳”
2.1 为什么拒绝多次groupby?不只是性能问题
新手最容易犯的错,是把一个复杂聚合拆成N个独立
groupby
:先算均值,再算中位数,再算标准差,最后用
merge
拼起来。看起来清晰,但在生产环境里,这等于给自己埋了三颗雷:
第一颗雷:索引漂移
。
df.groupby('A')['B'].mean()
返回的是
Series
,索引是
A
的唯一值;而
df.groupby('A')['C'].std()
返回的索引虽然也是
A
的唯一值,但顺序可能因pandas内部哈希机制不同而错位。我亲眼见过某次线上事故:财务报表里“华东区平均单笔交易额”和“华东区手续费标准差”被拼到了不同区域上,导致区域经理拿着错误数据开了三天整改会。根源就是两次groupby返回的索引顺序不一致,
merge
时没强制
sort=True
。
第二颗雷:内存爆炸
。假设你有1亿行交易数据,按
customer_id + merchant_category
分组。第一次
groupby
生成中间结果占内存X MB,第二次又生成X MB,第三次再X MB……最终拼接时还要额外申请Y MB做索引对齐。而
agg({'B': ['mean', 'median'], 'C': ['min', 'max']})
只做一次分组,内存峰值稳定在X+Y MB。在我们部署的8核32G容器里,前者常触发OOM Killer杀进程,后者稳如老狗。
第三颗雷:逻辑割裂
。当你需要“对高价值客户(交易额>5000)单独计算中位数,其他人算均值”,拆开写就得先
filter
再
groupby
,但过滤条件本身可能依赖聚合结果(比如“交易频次Top10%的客户”)。这时候你不得不写两遍逻辑,极易出现“过滤用的频次统计口径和最终聚合用的不一致”的低级错误。
所以,
agg()
字典映射不是语法糖,是生产环境的生存法则。它的底层原理很简单:pandas在第一次扫描数据时,对每个分组缓存所有需要的原始值(或部分统计量),然后在内存中并行计算所有指定函数。就像工厂流水线——原料(原始数据)只过一遍,但同时产出螺丝(均值)、垫片(中位数)、弹簧(标准差)三样零件,而不是让同一批原料反复进三道工序。
2.2 多维分组的层级陷阱:为什么unstack不是“美化输出”
原文示例里
df.groupby(['region','product'])['revenue'].mean().unstack()
看着只是把结果转成表格,但实际在银行系统里,这步操作决定了下游所有环节的生死。
先看不unstack的后果:
groupby(['region','product']).mean()
返回的是
MultiIndex Series
,索引是
(North, Widget)
这样的元组。这种结构对pandas友好,但对人和系统都不友好:
-
对人
:业务方要看“南区Widget和北区Gadget对比”,得在Excel里手动筛选,或者写
result.loc[('South','Widget')],而result.loc['South','Widget']会报错(因为索引是二维的); -
对系统
:BI工具(如Tableau、Power BI)导入MultiIndex时,要么报错,要么自动展平成
region_product一列,彻底丢失维度语义; -
对代码
:后续想加一列“区域占比”,得写
result / result.groupby(level=0).sum(),而level=0这种写法,三个月后你自己都忘了代表什么。
unstack()
的本质,是把维度关系显式编码进列名。
result.unstack('product')
后,列变成
Gadget
,
Widget
,行是
North
,
South
,这直接对应业务语言:“行是区域,列是产品”。但这里有个致命细节:
unstack默认填充NaN,而金融数据里NaN和0意义天壤之别
。比如某区域某产品无交易,该填0(表示无收入)还是NaN(表示数据缺失)?原文没提,但我们在生产环境强制规定:所有unstack后缺失值必须用
fill_value=0
明确声明,且在文档里注明“0=无交易,NaN=数据采集失败”。
更关键的是,unstack的层级选择。
groupby(['region','product','channel'])
有三层,你想看“区域×产品”矩阵,该
unstack('channel')
还是
unstack(['product','channel'])
?答案是:
永远unstack最细粒度的维度
。因为
unstack('channel')
得到的是
region × product
行,
channel
列;而
unstack(['product','channel'])
会把
product_channel
拼成新列名(如
Widget_Online
),破坏产品维度的独立性。我们定的铁律是:unstack后,列名必须能直接映射到业务实体(产品、渠道、客户等级),不能是组合体。
2.3 时间窗口的业务语义:滚动vs扩张,选错等于分析失效
滚动窗口(rolling)和扩张窗口(expanding)常被混用,但它们解决的是完全不同的业务问题,选错直接导致结论反转。
滚动窗口的核心是“局部稳定性”
。比如反欺诈系统监控“单客户日均交易额30日滚动均值”,目的是捕捉短期异常波动。如果某客户过去29天日均1000元,第30天突然跳到5000元,滚动均值会从1000→1138元(假设其他天不变),这个138元的增幅就是预警信号。但如果用扩张窗口,第30天的均值是
(29*1000 + 5000)/30 = 1133元
,增幅仅133元,可能淹没在噪声里。
扩张窗口的核心是“全局累积性” 。比如客户生命周期价值(LTV)计算,“截至今日的累计消费额”必须用扩张窗口。如果用滚动窗口(如365天),那么客户注册第366天时,第一天的消费就被踢出窗口,LTV会突然下降——这显然违背商业常识。
生产环境里最常踩的坑,是
窗口大小与业务周期错配
。原文用3日滚动均值,看似合理,但在银行场景下,我们绝不用3日:工作日/周末交易量差异巨大,3日窗口可能包含2个工作日+1个周末,均值失真。我们统一用
5日(覆盖完整工作周)或7日(自然周)
,且强制要求:所有滚动计算必须配套
min_periods=3
参数(保证至少3个有效点才计算),避免月初数据不足时全屏NaN。
另一个隐形雷区是
时间索引的精度陷阱
。原文
df_ts.set_index('date')
用的是
datetime64[ns]
,但实际交易数据常有毫秒级时间戳。如果直接
rolling(window='3D')
,pandas会按日历日切分,而“3D”可能包含2个营业日+1个非营业日(节假日),导致窗口内实际只有2条数据。我们的解法是:先用
df['business_date'] = df['timestamp'].dt.floor('D')
归一化到营业日,再按
business_date
分组滚动,确保窗口内全是有效交易日。
3. 核心细节解析:生产环境中的聚合函数实操要点
3.1 多指标聚合:如何让列名不再成为沟通灾难
agg({'amount': ['mean', 'median'], 'fee': ['min', 'max']})
返回的列名是
('amount', 'mean')
这样的元组,在Jupyter里显示正常,但一旦导出CSV或对接BI系统,列名就变成
amount_mean
、
amount_median
。问题来了:业务方看到
amount_mean
,会问“这是客户均值还是商户均值?”——因为你的groupby字段没体现在列名里。
我们的标准化方案是: 聚合后立即重命名,且命名规则必须携带分组维度信息 。例如:
result = (df.groupby(['region', 'product'])
.agg({'revenue': ['sum', 'mean'], 'cost': ['sum', 'mean']})
.pipe(lambda x: x.set_axis([f"{col[0]}_{col[1]}_{col[2]}" for col in x.columns], axis=1))
.rename(columns=lambda x: x.replace('_sum_region', '_region_sum').replace('_mean_product', '_product_mean')))
最终列名变成
revenue_region_sum
、
revenue_product_mean
,业务方一眼看懂。
更狠的一招是:
用
agg()
配合
named aggregation
语法(pandas 0.25+)
,直接在字典里定义带业务含义的列名:
result = df.groupby(['region', 'product']).agg(
total_revenue=('revenue', 'sum'),
avg_revenue_per_order=('revenue', 'mean'),
min_fee=('fee', 'min'),
max_fee=('fee', 'max')
)
这样列名就是
total_revenue
、
avg_revenue_per_order
,无需后期重命名,且函数意图一目了然。我们团队已强制要求所有新代码用此语法,旧代码逐步迁移。
提示:
named aggregation不支持对同一列应用多个函数(如('revenue', ['sum', 'mean'])),此时仍需元组语法。但我们的经验是:如果同一列需要多个统计量,说明该列承载了多重业务语义,应拆分为不同业务字段。比如revenue拆成gross_revenue(毛收入)和net_revenue(净收入),再分别聚合。
3.2 自定义聚合函数:业务逻辑必须可审计、可解释
lambda函数写起来快,但生产环境禁用。原因有三:
-
不可调试
:报错时栈追踪只显示
<lambda>,找不到具体哪行逻辑出问题; - 不可文档化 :无法写docstring说明“为什么用这个权重”;
- 不可复用 :下次遇到同样需求,还得重写一遍。
我们团队的规范是:
所有自定义agg函数必须是独立def函数,且满足“三有”原则:有名字、有注释、有单元测试
。比如原文的
weighted_average
,我们改写为:
def calc_recent_weighted_avg(series, weight_window=7, recent_weight=1.5):
"""
计算加权平均值,突出最近weight_window期的数据。
业务依据:根据2023年反欺诈模型验证,近7日交易行为对欺诈风险预测贡献度提升37%,
因此将最近7期权重设为1.5倍,其余期权重为1.0。
Parameters:
-----------
series : pd.Series
待计算的数值序列
weight_window : int, default 7
高权重覆盖的期数(按时间倒序)
recent_weight : float, default 1.5
近期数据权重倍数
Returns:
--------
float : 加权平均值
"""
if len(series) == 0:
return np.nan
# 确保series按时间倒序排列(最新在前)
sorted_series = series.sort_index(ascending=False)
weights = np.ones(len(sorted_series))
# 最近weight_window期权重设为recent_weight
weights[:min(weight_window, len(weights))] = recent_weight
return np.average(sorted_series, weights=weights)
这个函数在代码库里有独立文件
aggregations.py
,每次修改都需更新docstring里的业务依据,并通过
pytest
跑覆盖率测试(要求分支覆盖率达100%)。
注意:自定义函数入参必须是
pd.Series,返回标量。如果需要访问分组的其他字段(如groupby(['region','product'])时想用region值做条件判断),必须用apply()而非agg()。但apply()性能差,我们只在万不得已时用,且强制要求函数内做try/except捕获所有异常,避免单个分组失败导致整个job中断。
3.3 滚动窗口的落地细节:NaN不是bug,是设计
原文
rolling(window=3).mean()
前两行是NaN,这是正确行为,但业务方常质疑:“为什么没有值?是不是数据丢了?”——这暴露了技术与业务的认知断层。
我们的应对策略是: 所有滚动计算必须配套“填充策略说明” 。在输出结果旁,固定添加一行注释:
# 滚动窗口说明:window=3,min_periods=1,首2行NaN表示窗口未满,非数据缺失
result['rolling_avg'] = df_ts.groupby('category')['daily_revenue'].rolling(window=3, min_periods=1).mean()
min_periods=1
确保只要有一个值就计算(避免全NaN),但业务上我们更常用
min_periods=2
,因为单点均值无意义。
另一个关键点是
窗口类型的选择
。pandas支持
window='3D'
(日历日)和
window=3
(行数)。在交易数据中,我们永远用
行数窗口
,因为:
- 交易不是均匀发生的,某天可能有1000笔,另一天只有5笔;
-
window='3D'会把3天内所有交易塞进一个窗口,导致窗口内数据量波动极大,均值失真; - 行数窗口保证每个窗口有固定数量样本,统计更稳健。
我们还封装了一个生产级滚动函数:
def robust_rolling(series, window, func, min_periods=3, fill_method='ffill'):
"""
健壮滚动计算:自动处理NaN,支持多种填充方式
Parameters:
-----------
fill_method : str, {'ffill', 'bfill', 'zero', 'drop'}
ffill: 前向填充(适合趋势分析)
bfill: 后向填充(适合回溯分析)
zero: 填0(适合计数类指标)
drop: 删除NaN行(适合严格质量控制)
"""
rolled = series.rolling(window=window, min_periods=min_periods).apply(func, raw=True)
if fill_method == 'ffill':
return rolled.fillna(method='ffill')
elif fill_method == 'bfill':
return rolled.fillna(method='bfill')
elif fill_method == 'zero':
return rolled.fillna(0)
else: # drop
return rolled.dropna()
这个函数在风控日报系统里跑了两年,从未因NaN问题被投诉。
4. 实操过程详解:从原始数据到高管简报的七步流水线
4.1 数据准备:模拟真实银行交易流
我们复现原文的端到端案例,但强化生产环境约束:
-
时间戳必须带时区
:银行系统全球部署,
2024-01-01不明确是UTC还是北京时间,必须用pd.Timestamp('2024-01-01', tz='Asia/Shanghai'); -
金额字段必须用decimal避免浮点误差
:
np.random.uniform(20,500,60).round(2)会产生210.44999999999999,而银行系统要求精确到分,我们改用Decimal:
from decimal import Decimal, ROUND_HALF_UP
def gen_amount(min_val, max_val, size):
return np.array([
float(Decimal(str(np.random.uniform(min_val, max_val)))
.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP))
for _ in range(size)
])
-
客户ID必须符合银行编码规范
:原文
C001太简单,真实系统是CNBJS2024000001(地区+年份+序列号),我们生成时加入校验位。
4.2 分析1:多指标聚合——构建客户-类目健康度仪表盘
目标:输出
customer_id × category
矩阵,含
amount_mean
、
amount_median
、
fee_min
、
fee_max
。
# 关键步骤:先groupby,再agg,再unstack,最后重命名
health_metrics = (
df_transactions
.groupby(['customer_id', 'category'])
.agg({
'amount': ['mean', 'median'],
'fee': ['min', 'max']
})
# 展平列名并重命名
.pipe(lambda x: x.set_axis([
f"{col[0]}_{col[1]}" for col in x.columns
], axis=1))
.unstack('category', fill_value=0)
# 列名格式化:amount_mean_Groceries → groc_mean_amt
.pipe(lambda x: x.rename(columns=lambda c:
c.replace('amount_mean_', '').replace('amount_median_', 'med_')
.replace('fee_min_', 'fee_min_').replace('fee_max_', 'fee_max_')
.replace('_', '_amt') if 'amount' in c else c))
)
输出效果:
| customer_id | groc_mean_amt | med_groc_amt | fee_min_groc | fee_max_groc | ... |
|---|---|---|---|---|---|
| C001 | 313.38 | 280.53 | 5.26 | 11.28 | ... |
这个表直接喂给BI工具,销售总监看一眼就能说:“C001在餐饮类目均值314,但中位数280,说明有几笔大额交易拉高了均值,得查查是不是团餐预付。”——这就是多指标聚合的价值: 均值暴露总量,中位数揭示分布,二者结合才有业务洞察 。
4.3 分析2:自定义聚合——交易范围(Range)的风险标尺
原文
transaction_range
只算
max-min
,但生产环境必须加业务阈值:
def calc_transaction_risk_range(series, risk_threshold=300):
"""
计算交易范围并标注风险等级
风险规则(2024版):
- 范围 < 100:低风险(日常消费)
- 100 <= 范围 < 300:中风险(需人工复核)
- 范围 >= 300:高风险(自动冻结交易)
"""
if len(series) < 2:
return pd.Series({'range': np.nan, 'risk_level': 'insufficient_data'})
rng = series.max() - series.min()
if rng < 100:
level = 'low'
elif rng < 300:
level = 'medium'
else:
level = 'high'
return pd.Series({'range': rng, 'risk_level': level})
# 应用
risk_analysis = df_transactions.groupby('category')['amount'].apply(calc_transaction_risk_range)
输出:
| category | range | risk_level |
|---|---|---|
| Dining | 464.69 | high |
| Groceries | 477.03 | high |
注意:这里用
apply()
而非
agg()
,因为返回的是
pd.Series
(多列),而
agg()
只接受标量返回。
4.4 分析3:滚动窗口——识别客户消费模式突变
原文
rolling(window=7).mean()
只算均值,但我们加一层业务逻辑:
# 计算7日滚动均值,并标记是否突破历史均值2个标准差
base_stats = df_transactions.groupby('customer_id')['amount'].agg(['mean', 'std'])
rolling_7d = (
df_sorted
.groupby('customer_id')['amount']
.rolling(window=7, min_periods=4) # 至少4个点才计算
.mean()
.reset_index(name='rolling_7d_avg')
)
# 合并基础统计量
enriched = rolling_7d.merge(base_stats.reset_index(), on='customer_id')
enriched['is_anomaly'] = (
enriched['rolling_7d_avg'] >
(enriched['mean'] + 2 * enriched['std'])
)
这样输出不仅有数值,还有
is_anomaly
布尔列,下游系统可直接驱动告警。
4.5 分析4:扩张窗口——客户生命周期价值(LTV)追踪
关键点:
扩张窗口必须按时间排序,且不能用
expanding().sum()
直接算,要防重复计算
。
# 先按客户+时间排序,确保扩张是时间有序的
df_sorted = df_transactions.sort_values(['customer_id', 'date'])
# 按客户分组,计算时间有序的累计和
df_sorted['cumulative_spend'] = (
df_sorted.groupby('customer_id')['amount']
.expanding(min_periods=1)
.sum()
.reset_index(level=0, drop=True)
)
# 为防同一客户同一天多笔交易导致重复,加去重逻辑
df_sorted['cumulative_spend'] = df_sorted.groupby(['customer_id', 'date'])['cumulative_spend'].transform('last')
4.6 分析5:多维透视——客户-类目偏好矩阵
原文
unstack()
后是
region × product
,我们升级为
customer_id × category
,并加业务解读:
preference_matrix = (
df_transactions
.groupby(['customer_id', 'category'])['amount']
.mean()
.unstack('category', fill_value=0)
.round(2)
)
# 计算每个客户的主导类目(最大均值对应的类目)
preference_matrix['dominant_category'] = preference_matrix.idxmax(axis=1)
# 计算类目集中度(赫芬达尔指数)
preference_matrix['concentration'] = (
(preference_matrix.div(preference_matrix.sum(axis=1), axis=0) ** 2).sum(axis=1)
)
输出新增两列:
dominant_category
(如
Dining
)、
concentration
(0-1,越接近1越集中)。风控团队据此制定策略:“主导类目为Travel且集中度>0.8的客户,提高单笔限额”。
4.7 分析6:高管简报——一键生成执行摘要
原文
summary
只算基础指标,我们加三重增强:
# 1. 业务指标增强
summary = df_transactions.groupby('customer_id').agg({
'amount': ['sum', 'mean', 'count', lambda x: (x > 300).sum()], # 高价值交易笔数
'fee': 'sum',
'date': ['min', 'max'] # 首末交易日
}).round(2)
# 2. 列名标准化
summary.columns = ['total_spend', 'avg_transaction', 'transaction_count',
'high_value_count', 'total_fees', 'first_txn', 'last_txn']
# 3. 衍生指标(全部业务驱动)
summary['ltv_to_date'] = summary['total_spend']
summary['txn_frequency'] = (summary['last_txn'] - summary['first_txn']).dt.days / summary['transaction_count']
summary['fee_ratio'] = (summary['total_fees'] / summary['total_spend'] * 100).round(2)
summary['risk_score'] = (
(summary['high_value_count'] / summary['transaction_count'] * 100).round(1) * 0.4 +
(summary['txn_frequency'] * 10).round(1) * 0.6
).round(1) # 综合风险评分
# 4. 分级标签(直接给业务用)
def label_risk(score):
if score < 20:
return 'Low'
elif score < 50:
return 'Medium'
else:
return 'High'
summary['risk_label'] = summary['risk_score'].apply(label_risk)
最终输出10列,每列都有业务含义,财务总监扫一眼就知道:“C002总消费5714,但风险分52.3,属高风险,需重点核查。”
5. 常见问题与排查技巧实录:那些让凌晨三点崩溃的Bug
5.1 “明明数据有值,groupby后却全NaN”——索引类型陷阱
现象
:
df.groupby('region')['revenue'].sum()
返回全NaN,但
df['region'].unique()
能看到值。
根因
:
region
列是
object
类型,但值里混有空格或不可见字符(如
\xa0
),
'North '
和
'North'
被视为不同分组。
排查
:
# 查看前10个值的repr,暴露隐藏字符
print([repr(x) for x in df['region'].unique()[:10]])
# 检查是否有空格
print(df['region'].str.len().describe())
# 修复
df['region'] = df['region'].str.strip()
生产规范
:所有分组字段在
groupby
前必须
strip()
,且用
df[col].nunique(dropna=False)
检查NaN占比,>0.1%则报警。
5.2 “rolling计算结果和Excel手工算的不一样”——时间排序盲区
现象
:Python滚动均值和Excel里按日期排序后手工算的结果差0.01。
根因
:pandas默认按索引顺序滚动,而你的DataFrame索引是乱序的(比如从数据库读取时未排序)。
排查
:
# 检查索引是否时间有序
print(df.index.is_monotonic_increasing) # False即有问题
# 正确做法:先排序再滚动
df_sorted = df.sort_values('date').set_index('date')
df_sorted['rolling_avg'] = df_sorted['value'].rolling(3).mean()
经验
:所有含时间窗口的操作,第一步必是
sort_values(time_col).set_index(time_col)
,写成函数封装:
def ensure_time_ordered(df, time_col):
return df.sort_values(time_col).set_index(time_col)
5.3 “unstack后列名变成('revenue', 'sum'),BI工具不认”——导出格式战争
现象
:
to_csv()
导出的文件,列名是
("revenue", "sum")
,Tableau报错。
根因
:pandas默认用元组作列名,CSV不支持。
解法
:
# 方案1:导出前展平列名
result.columns = ['_'.join(col).strip() for col in result.columns]
result.to_csv('output.csv')
# 方案2:用ExcelWriter,保留多级列(Tableau可读)
with pd.ExcelWriter('output.xlsx') as writer:
result.to_excel(writer, sheet_name='metrics')
终极方案
:在ETL流程中,所有
unstack()
后立即
reset_index()
,确保输出是扁平DataFrame,这是BI工具兼容性底线。
5.4 “自定义agg函数报错'cannot convert to float'”——数据类型污染
现象
:
df.groupby('A').agg({'B': my_func})
报错,但
my_func(df['B'])
单独运行正常。
根因
:groupby后传入函数的
series
可能含混合类型(如
float
和
str
),而
my_func
假设全是数字。
排查
:
# 在函数开头加类型检查
def my_func(series):
print(f"Series dtype: {series.dtype}, sample: {series.head(3).tolist()}")
# 强制转数值,错误值转NaN
numeric_series = pd.to_numeric(series, errors='coerce')
return numeric_series.mean()
生产规范
:所有自定义agg函数第一行必须是
series = pd.to_numeric(series, errors='coerce')
,且文档注明“输入列必须为数值型,非数值转NaN处理”。
5.5 “同样的代码,本地跑通,生产环境OOM”——分组键爆炸
现象
:
groupby(['customer_id', 'merchant_id', 'terminal_id'])
在100万行数据上本地OK,生产1亿行直接内存溢出。
根因
:分组键组合数过多(如100万客户×10万商户×10万终端=10^15种组合),pandas试图缓存所有分组。
解法
:
-
降维
:用
nunique()检查各字段唯一值数,若terminal_id.nunique() > 10000,则聚合前先map到terminal_type(如POS/APP/WEB); -
采样
:对超大分组,用
sample(frac=0.1)先验证逻辑; -
分块
:
df.groupby(..., group_keys=False).apply(lambda x: process_chunk(x)),但慎用,apply慢。
我们定的红线是: 任何groupby的分组键组合数预估不能超过100万 。超限时,必须拆成多步聚合,或改用Spark。
6. 工具链与工程化实践:让聚合代码从Notebook走向生产
6.1 从Jupyter到Airflow:聚合任务的工业化封装
在Jupyter里写
df.groupby().agg()
很爽,但上线必须工程化。我们的标准流程:
-
函数化
:每个分析封装成独立函数,如
def calc_customer_health_metrics(df: pd.DataFrame) -> pd.DataFrame:; -
参数化
:所有硬编码(如
window=7、risk_threshold=300)抽成函数参数,并设合理默认值; -
类型注解
:用
pandera库校验输入输出Schema:
import pandera as pa
from pandera.typing import DataFrame
class TransactionSchema(pa.SchemaModel):
date: pa.typing.Series[pa.DateTime]
customer_id: pa.typing.Series[str]
amount: pa.typing.Series[float]
fee: pa.typing.Series[float]
class Config:
coerce = True # 自动类型转换
@pa.check_input(TransactionSchema)
@pa.check_output(TransactionSchema)
def calc_health_metrics(df):
...
-
Airflow集成
:用
PythonOperator调用函数,失败时自动邮件通知负责人+钉钉机器人。
6.2 性能压测:你的agg函数能扛住多少数据?
我们给所有聚合函数做三档压测:
- 小数据 (1万行):验证逻辑正确性;
-
中数据
(100万行):测内存峰值(用
memory_profiler),要求<500MB;

411

被折叠的 条评论
为什么被折叠?



