1. 项目概述:为什么多维聚合不是“加个groupby”就完事了?
我在银行数据平台组干了八年,从最早用SQL写几十行嵌套子查询做客户分层,到现在每天在Jupyter里敲pandas链式操作处理上亿条交易流水——最深的体会是:真正的业务分析,从来不是“算出一个数”,而是“让这个数能被业务方一眼看懂、立刻决策、还能回溯验证”。这句话我贴在工位玻璃上,三年没换过。
你手头这份《Part 20: Data Manipulation in Multi-Dimensional Aggregation》原文,表面看是讲pandas的agg()、rolling()、unstack()怎么用,但内核其实在解决一个更本质的问题:如何把冷冰冰的原始交易记录,变成风控经理能调阈值、运营总监能定策略、分行行长能拍板的“活数据”。比如原文里那个“商户类别交易金额范围(max-min)”,它真正在业务中对应的是:当某类商户单日交易波动超过300%,系统自动触发人工复核;当餐饮类商户的交易中位数突然跌到均值的60%以下,说明可能有区域性消费疲软,要提前调整营销预算。这些逻辑,全藏在聚合方式的选择里。
我带过的新人常犯一个致命错误:看到需求文档里写着“按客户+产品线+地区统计销售额”,就直接df.groupby(['cust_id','prod_line','region'])['sales'].sum(),然后把结果扔给BI工具。结果呢?业务方打开报表,第一反应是:“这表格谁看得懂?我要的是每个省下面前三大产品线的环比,不是这个密密麻麻的三重索引!”——问题不在代码,而在没理解聚合的本质是“信息降维”与“语义重构”的结合体。你用unstack()把地区转成列,不是为了好看,是为了让销售总监横向扫一眼就能发现“华东区的A产品线比华南区低了40%”;你用rolling(7).mean(),不是为了算个平均数,而是为了让风控模型能忽略周末单日异常,只捕捉持续三天以上的消费趋势变化。
所以这篇博文,我不会照搬原文的代码示例再讲一遍语法。我会带你钻进银行真实生产环境的后台:看我们怎么设计一个聚合模块,让它既能跑在本地笔记本上快速验证逻辑,又能无缝迁移到Spark集群处理TB级数据;看为什么agg({'col': ['mean', 'std']})生成的多层列名结构,在下游ETL任务里会引发一连串字段映射错误;看当rolling().mean()遇到节假日断点数据时,我们宁可多写20行代码做日期对齐,也不用min_periods=1糊弄过去——因为监管报送要求所有时间窗口计算必须严格对齐自然日。这些细节,才是决定一个分析脚本是“玩具demo”还是“生产级资产”的分水岭。
关键词里的“Towards AI - Medium”提醒我:这不是一篇纯技术文档,而是一份给一线数据工程师、分析师、甚至懂点SQL的业务BP看的实战手册。所以接下来每一部分,我都会先说清楚“业务场景是什么、为什么非得这么干、不这么干会踩什么坑”,再给你可直接抄作业的代码、参数选择依据、以及我们压测过的真实性能数据。比如滚动窗口大小选7天还是30天?不是拍脑袋,而是基于我们信用卡部的历史告警准确率曲线——7天窗口对欺诈交易的检出率比30天高22%,但误报率只高3.5%。这种数字,才是你写代码时该盯住的靶心。
2. 多维聚合的核心设计逻辑:从“算得出来”到“用得明白”
2.1 为什么必须拒绝“单列groupby + 多次merge”的野路子?
刚入行时,我见过最典型的反模式:一个需求要同时输出“客户维度的交易均值、中位数、标准差,以及手续费的最小值、最大值”,有人会这样写:
# ❌ 千万别学!这是生产环境的定时炸弹 mean_df = df.groupby('customer_id')['amount'].mean().rename('avg_amount') median_df = df.groupby('customer_id')['amount'].median().rename('med_amount') std_df = df.groupby('customer_id')['amount'].std().rename('std_amount') min_fee = df.groupby('customer_id')['fee'].min().rename('min_fee') max_fee = df.groupby('customer_id')['fee'].max().rename('max_fee') # 然后疯狂merge... result = mean_df.to_frame().join(median_df).join(std_df).join(min_fee).join(max_fee)这段代码在10万行数据上跑得飞快,但一旦数据量上到千万级,CPU使用率直接飙到95%,内存溢出报错。为什么?因为pandas每次groupby都要重新扫描整个DataFrame,做哈希分桶、排序、聚合——相当于把同一份数据读了5遍。更糟的是,当customer_id有缺失值时,不同聚合结果的索引长度可能不一致,join操作会静默丢弃数据,而你根本发现不了。去年我们有个反洗钱模型就因此漏掉了23个高风险客户,审计时差点背锅。
正确的解法,是让pandas一次扫描完成所有计算。原文提到的字典映射法,就是我们的标准答案:
# ✅ 生产环境黄金写法 result = df.groupby('customer_id').agg({ 'amount': ['mean', 'median', 'std'], 'fee': ['min', 'max'] })但这里有个关键细节原文没展开:多层列名(MultiIndex)的处理成本。上面代码输出的result.columns长这样:
MultiIndex([('amount', 'mean'), ('amount', 'median'), ('amount', 'std'), ('fee', 'min'), ('fee', 'max')])如果你直接把它喂给to_sql()或to_parquet(),下游系统大概率报错——Hive不认嵌套列名,Power BI加载时会把('amount','mean')当成字符串而不是数值。所以我们团队强制规定:所有聚合结果必须扁平化列名。有两种安全方案:
方案A:用map()函数重命名(推荐)
result.columns = result.columns.map('{0[0]}_{0[1]}'.format) # → 'amount_mean', 'amount_median'...优点:逻辑清晰,可读性强,调试时一眼看出来源;缺点:如果列名含空格或特殊字符,需要额外清洗。
方案B:用add_suffix()(适合简单场景)
# 先对每组列单独处理 amount_agg = df.groupby('customer_id')['amount'].agg(['mean','median','std']) fee_agg = df.groupby('customer_id')['fee'].agg(['min','max']) # 再合并并加后缀 result = pd.concat([amount_agg.add_suffix('_amount'), fee_agg.add_suffix('_fee')], axis=1)优点:避免MultiIndex,天然兼容所有下游系统;缺点:代码稍长,当聚合列很多时维护成本高。
提示:我们线上所有ETL任务都用方案A,并在CI/CD流程中加入列名校验——如果检测到列名含括号、空格、中文,构建直接失败。这比后期排查数据质量问题省10倍精力。
2.2 自定义聚合函数:业务逻辑的“封装容器”,不是语法糖
原文用lambda x: x.max() - x.min()演示了自定义聚合,但实际生产中,我们严禁在核心分析脚本里写lambda。原因很现实:lambda无法被序列化(pickle),会导致Dask/Spark分布式计算失败;没有docstring,半年后你自己都看不懂这行代码在算什么。我们团队的铁律是:所有业务逻辑必须封装成具名函数,且函数名要像API文档一样自解释。
比如原文的“加权平均”,我们真实的风控模型代码长这样:
def risk_weighted_avg(series, weight_col='risk_score'): """ 计算风险加权平均交易额 业务逻辑:对高风险客户(risk_score > 0.8)的近期交易赋予1.5倍权重, 对低风险客户(risk_score < 0.3)的交易赋予0.5倍权重, 中间客户保持1.0权重。权重基于客户历史行为动态计算。 参数: series: 交易金额序列 weight_col: 风控系统提供的风险评分列名(需在原始DataFrame中存在) 返回: 加权平均值(float) """ # 从全局上下文获取风险评分(实际项目中通过config注入,此处简化) if not hasattr(risk_weighted_avg, 'risk_scores'): raise ValueError("风险评分未初始化,请先调用init_risk_scores()") weights = np.ones(len(series)) # 实际项目中这里会查Redis缓存获取实时风险分 for i, cust_id in enumerate(series.index.get_level_values('customer_id')): score = risk_weighted_avg.risk_scores.get(cust_id, 0.5) if score > 0.8: weights[i] = 1.5 elif score < 0.3: weights[i] = 0.5 return np.average(series, weights=weights) # 初始化风险评分(模拟) risk_weighted_avg.risk_scores = {'C001': 0.92, 'C002': 0.25, 'C003': 0.61}看到没?这个函数里藏着三个生产级要素:
- 业务注释:明确写出“高风险客户权重1.5倍”,而不是让读者猜
np.linspace(0.5,1.5,len(series))的业务含义; - 容错机制:
hasattr检查和ValueError提示,避免因配置缺失导致整批数据计算中断; - 扩展性设计:
weight_col参数允许未来接入不同风控模型(如ML模型输出的ml_risk_score),不用改聚合逻辑。
注意:我们测试过,当数据量超500万行时,这种带循环的自定义函数比纯向量化操作慢3-5倍。所以真实项目中,我们会预先把风险评分merge到原始DataFrame,再用
np.where()向量化计算权重——但函数接口保持不变。这就是“逻辑封装”和“性能优化”的分离哲学。
2.3 多维分组的底层陷阱:索引爆炸与内存泄漏
原文的groupby(['region','product'])['revenue'].mean().unstack()看起来很优雅,但当你面对真实银行数据时,会发现两个血泪教训:
教训一:unstack()不是万能的,它会制造“稀疏矩阵”假设你有1000个地区、500个产品线,但每个地区只卖其中20个产品——unstack()后会产生1000×500=50万个单元格,其中96%是NaN。Pandas默认用float64存储NaN,每个NaN占8字节,光空值就吃掉4MB内存。当你的聚合结果有10个指标列时,内存占用直接翻10倍。我们曾因此导致Airflow任务OOM重启。
解决方案:永远用fill_value参数填充空值
# ✅ 强制填充0,避免NaN内存黑洞 result = df_sales.groupby(['region','product'])['revenue'].mean().unstack(fill_value=0) # 或者用业务语义填充(更推荐) result = df_sales.groupby(['region','product'])['revenue'].mean().unstack(fill_value=np.nan) # 保留NaN但明确语义教训二:多级索引(MultiIndex)在.loc切片时的性能灾难
# ❌ 危险操作!当索引层级>2时,.loc会触发全表扫描 result.loc[('North', 'Widget'), 'revenue'] # 如果result是3层索引,这里极慢 # ✅ 正确姿势:先reset_index,再用布尔索引 result_flat = result.reset_index() result_flat[(result_flat['region']=='North') & (result_flat['product']=='Widget')]我们做过压测:100万行的MultiIndex DataFrame,.loc切片耗时2.3秒;reset_index()后布尔索引只要0.08秒。虽然多了一步转换,但整体ETL流程快了17倍。
3. 四大核心聚合技术的实操拆解:从代码到业务落地
3.1 多指标聚合:如何让一行代码扛起整个仪表盘
我们银行的“客户健康度仪表盘”每天凌晨3点自动生成,核心数据源就是一段多指标聚合代码。它要同时输出:
- 交易维度:近30天交易笔数、总金额、平均单笔、最大单笔、最小单笔
- 费用维度:手续费总额、平均费率、费率标准差
- 行为维度:首次交易距今天数、最近交易距今天数、交易频次(笔/天)
如果按传统思路写10个groupby,代码会像意大利面条。而我们的生产级写法是:
def build_customer_metrics(df): """构建客户全维度健康指标(生产环境精简版)""" # 定义聚合规则:列名 -> [(函数名, 别名), ...] agg_rules = { 'amount': [ ('count', 'txn_count'), ('sum', 'total_amount'), ('mean', 'avg_amount'), ('max', 'max_amount'), ('min', 'min_amount') ], 'fee': [ ('sum', 'total_fee'), ('mean', 'avg_fee_rate'), ('std', 'fee_rate_std') ], 'date': [ ('min', 'first_txn_days_ago'), # 首次交易距今 ('max', 'last_txn_days_ago') # 最近交易距今 ] } # 执行聚合(注意:date列需先转为datetime并计算天数差) df_with_days = df.copy() df_with_days['date'] = pd.to_datetime(df_with_days['date']) ref_date = df_with_days['date'].max() df_with_days['days_ago'] = (ref_date - df_with_days['date']).dt.days # 应用聚合 result = df_with_days.groupby('customer_id').agg({ 'amount': [rule[0] for rule in agg_rules['amount']], 'fee': [rule[0] for rule in agg_rules['fee']], 'days_ago': [rule[0] for rule in agg_rules['date']] }) # 扁平化列名 result.columns = ['_'.join(col).strip() for col in result.columns] # 重命名列(按规则映射) rename_map = {} for col, rules in agg_rules.items(): for func_name, alias in rules: old_name = f"{col}_{func_name}" rename_map[old_name] = alias result = result.rename(columns=rename_map) # 计算衍生指标 result['txn_frequency'] = result['txn_count'] / (result['first_txn_days_ago'] + 1) # 防除零 result['fee_rate'] = (result['total_fee'] / result['total_amount'] * 100).round(2) return result # 调用 metrics_df = build_customer_metrics(df_transactions) print(metrics_df.head())这段代码的关键设计点:
- 规则驱动:把聚合逻辑抽成
agg_rules字典,新增指标只需改字典,不用动核心逻辑; - 日期预处理:
days_ago计算放在聚合前,避免在agg()里用lambda处理时间; - 防错设计:
first_txn_days_ago + 1防止分母为0,这是我们被生产事故教育出来的习惯。
实操心得:我们监控过,当
agg_rules中函数超过7个时,agg()执行时间会非线性增长。所以对于超复杂指标(如“近7天交易金额的变异系数”),我们宁愿拆成两步:先agg({'amount': ['sum','std']}),再用apply()计算变异系数。牺牲一点代码简洁性,换来可预测的性能。
3.2 滚动窗口计算:时间敏感型分析的“心跳监测器”
原文的rolling(3).mean()只是入门,真实风控系统里,滚动窗口是带“心跳”的精密仪器。比如我们反欺诈模块的“7日异常交易检测”,要求:
- 窗口必须严格对齐自然日(不能跳过周末);
- 当某日无交易时,用前一日值填充(业务要求连续性);
- 窗口内至少要有3笔交易才计算均值(避免小样本误导);
- 结果要标记“是否触发预警”(布尔值)。
实现代码如下:
def fraud_rolling_analysis(df, window_days=7, min_txns=3, alert_threshold=2.5): """ 反欺诈滚动分析(生产环境版) 参数: df: 交易数据框,必须含'date'(datetime)、'customer_id'、'amount'列 window_days: 滚动窗口天数(自然日) min_txns: 窗口内最少交易笔数 alert_threshold: 均值偏离倍数阈值(如2.5倍) """ # 1. 确保日期索引并补全缺失日(关键!) df_sorted = df.sort_values(['customer_id','date']).set_index('date') # 生成完整日期范围 date_range = pd.date_range( start=df_sorted.index.min(), end=df_sorted.index.max(), freq='D' ) # 按customer_id分组,对每个客户补全日历 full_df = [] for cust_id, group in df_sorted.groupby('customer_id'): # 创建该客户全日期索引 cust_full_idx = pd.MultiIndex.from_product( [[cust_id], date_range], names=['customer_id','date'] ) # 重采样并填充 group_full = group.reindex(cust_full_idx, fill_value=0) full_df.append(group_full) df_full = pd.concat(full_df).sort_index() # 2. 计算滚动均值(要求min_periods=min_txns) df_full['rolling_mean'] = ( df_full.groupby('customer_id')['amount'] .rolling(window=f'{window_days}D', min_periods=min_txns) .mean() .reset_index(level=0, drop=True) ) # 3. 前向填充空值(业务要求连续性) df_full['rolling_mean'] = df_full.groupby('customer_id')['rolling_mean'].fillna(method='ffill') # 4. 计算当前交易是否异常 df_full['is_alert'] = ( df_full['amount'] > df_full['rolling_mean'] * alert_threshold ) return df_full[['amount','rolling_mean','is_alert']].reset_index() # 使用示例 alert_df = fraud_rolling_analysis(df_transactions, window_days=7, min_txns=3) print(alert_df[alert_df['is_alert']].head()) # 查看触发预警的交易这段代码解决了三个生产痛点:
- 日期对齐:用
pd.date_range()生成完整日历,再reindex()补零,确保窗口不跳日; - 小样本保护:
min_periods=3保证只有足够数据才计算,避免首日就报假警; - 业务连续性:
fillna(method='ffill')让滚动均值在无交易日保持前值,符合风控逻辑。
注意:
rolling(window='7D')比rolling(window=7)更精准,因为它按日历天数而非行数计算。我们压测过,对跨月数据,后者会漏掉月末最后一天。
3.3 扩展窗口计算:累计指标的“时间锚点”设计
原文的expanding().sum()很简单,但真实场景中,“累计”必须有明确的时间锚点。比如“客户生命周期价值(CLV)”,业务定义是:从客户开户日起,到当前分析日止的所有交易总和。如果你直接用expanding().sum(),当数据按交易时间排序时,第一个值是开户日交易,最后一个值是最新交易——这没问题;但如果数据按客户ID分组后内部排序混乱,结果就全错了。
我们的标准做法是:永远显式指定时间顺序,绝不依赖数据原始顺序。
def calculate_clv(df, open_date_col='open_date', txn_date_col='date'): """ 计算客户生命周期价值(CLV) 要求:df必须含客户开户日期(open_date)和交易日期(date) """ # 1. 合并开户日期到交易数据(左连接,确保每个交易都有开户日) # (实际项目中这里会从客户主数据表join) df_with_open = df.copy() # 模拟:为每个customer_id添加开户日(真实项目查dim_customer表) open_dates = df_with_open.groupby('customer_id')[txn_date_col].min().rename('open_date') df_with_open = df_with_open.merge(open_dates, on='customer_id', how='left') # 2. 过滤:只计算开户日之后的交易(防数据质量问题) df_valid = df_with_open[ df_with_open[txn_date_col] >= df_with_open['open_date'] ].copy() # 3. 按客户+时间排序,确保expanding正确 df_sorted = df_valid.sort_values(['customer_id', txn_date_col]) # 4. 计算累计和(关键:用cumsum()替代expanding().sum(),更稳定) df_sorted['clv_cumsum'] = df_sorted.groupby('customer_id')['amount'].cumsum() # 5. 添加时间锚点标识(业务必需) df_sorted['days_since_open'] = ( df_sorted[txn_date_col] - df_sorted['open_date'] ).dt.days return df_sorted[['customer_id', txn_date_col, 'amount', 'clv_cumsum', 'days_since_open']] # 调用 clv_df = calculate_clv(df_transactions) print(clv_df[clv_df['customer_id']=='C001'].tail())为什么用cumsum()不用expanding().sum()?因为前者是纯向量化操作,后者在某些pandas版本中对MultiIndex支持不稳定。我们线上所有累计计算都用cumsum(),并配合sort_values()确保顺序。
实操心得:CLV计算必须包含
days_since_open列。去年审计时,监管要求提供“客户开户后第30天的CLV值”,如果没有这个锚点列,我们得重跑整个历史数据。现在直接clv_df[clv_df['days_since_open']==30]秒出结果。
3.4 多级分组与透视:让业务方自己“拖拽”看数据
原文的unstack()生成了漂亮的交叉表,但真实BI场景中,业务方需要的是“可交互的透视能力”。我们的解决方案是:用pivot_table()替代groupby().unstack(),因为它原生支持margins(总计行/列)和aggfunc(多函数聚合)。
def create_interactive_pivot(df, index_cols, columns_col, values_col, agg_func='sum', fill_value=0): """ 创建可直接对接BI工具的透视表 支持多索引、多聚合、总计行/列 """ # 构建pivot_table参数 pivot_params = { 'index': index_cols, 'columns': columns_col, 'values': values_col, 'aggfunc': agg_func, 'fill_value': fill_value, 'margins': True, # 自动生成总计行/列 'margins_name': 'TOTAL' # 总计行/列名称 } # 处理多函数聚合(如同时要sum和count) if isinstance(agg_func, list): # pivot_table支持list,但需指定values为list pivot_params['values'] = [values_col] * len(agg_func) pivot_params['aggfunc'] = agg_func # 重命名列(避免重复) result = pd.pivot_table(df, **pivot_params) result.columns = [f"{values_col}_{func}" for func in agg_func] return result return pd.pivot_table(df, **pivot_params) # 示例:生成“客户×产品线×地区”的三维透视(简化为二维展示) pivot_result = create_interactive_pivot( df_sales, index_cols=['region'], columns_col='product', values_col='revenue', agg_func=['sum', 'mean'], # 同时输出总和与均值 fill_value=0 ) print(pivot_result)输出效果:
revenue_sum revenue_mean product Gadget Widget Gadget Widget region North 12000.0 15500.0 12000.0 15500.0 South 13750.0 18000.0 13750.0 18000.0 TOTAL 25750.0 33500.0 12875.0 16750.0 # margins自动生成pivot_table()的优势:
- 开箱即用的总计:
margins=True直接生成TOTAL行,业务方不用自己加公式; - 多函数聚合:
agg_func=['sum','mean']一步到位,不用拼接多个unstack()结果; - BI友好:Tableau/Power BI原生支持pivot_table输出格式,导入即用。
注意:
pivot_table()比groupby().unstack()内存占用高15%-20%,所以对超大数据集(>1亿行),我们仍用groupby().unstack(),但会手动添加总计行(用pd.concat([result, result.sum().to_frame('TOTAL').T]))。
4. 生产环境避坑指南:那些让数据工程师半夜爬起来的Bug
4.1 NaN值的“七宗罪”:从隐形杀手到业务真相
在银行数据中,NaN不是缺失,而是业务信号。我们曾因忽略NaN的语义,导致重大误判:
| NaN场景 | 错误处理 | 正确处理 | 业务影响 |
|---|---|---|---|
fee列为NaN | 直接dropna() | 用fee_rate = fee/amount计算,NaN表示“免手续费” | 误删2000+银联云闪付交易(手续费为0) |
rolling_mean()首N行NaN | fillna(0) | 保留NaN,下游用isna().sum()统计窗口覆盖度 | 风控模型将“数据不足”误判为“交易平稳” |
unstack()产生的NaN | fillna(0) | fillna(np.nan)并标注is_sparse=True | 分行经理把“未开展业务”当成“零收入”做考核 |
我们的NaN处理铁律:
- 绝不无脑
fillna(0):先问业务方“这个空值代表什么?” - 用
pd.NA替代np.nan:pd.NA支持三值逻辑(True/False/Unknown),在query()中更安全; - 记录NaN比例:每个聚合任务必须输出
nan_ratio = df.isna().mean(),>5%需告警。
# ✅ 生产环境NaN审计模板 def audit_nans(df, threshold=0.05): """审计DataFrame中NaN比例""" nan_stats = df.isna().mean() high_nan_cols = nan_stats[nan_stats > threshold].index.tolist() if high_nan_cols: print(f"⚠️ 警告:以下列NaN比例>{threshold*100}%:{high_nan_cols}") print("建议:检查数据源质量或业务定义") return nan_stats audit_nans(result)4.2 性能瓶颈定位:三步揪出慢聚合的元凶
当一个聚合任务从1分钟涨到10分钟,我们按此流程排查:
第一步:确认是否I/O瓶颈
# 在Linux服务器上运行 iostat -x 1 5 # 查看%util是否>90%如果是,说明磁盘读写满载——优化方向:用parquet替代csv,开启use_threads=True。
第二步:确认是否内存瓶颈
import psutil process = psutil.Process() print(f"内存占用:{process.memory_info().rss / 1024 / 1024:.0f} MB")如果>80%物理内存,说明DataFrame太大——优化方向:用dtype压缩(category类型省70%内存),或sample(frac=0.1)先验证逻辑。
第三步:确认是否算法瓶颈用line_profiler精准定位:
pip install line_profiler kernprof -l -v your_script.py常见慢操作TOP3:
agg()中用lambda(比具名函数慢5-10倍);unstack()后立即reset_index()(触发全表复制);rolling()未设min_periods(pandas内部做大量空值判断)。
4.3 版本兼容性雷区:pandas 1.5 vs 2.0的“静默变更”
pandas 2.0移除了sort参数(groupby(sort=False)失效),但我们线上还有pandas 1.5的旧任务。为避免上线失败,我们强制统一版本并添加兼容层:
import pandas as pd from packaging import version def safe_groupby(df, by, **kwargs): """兼容pandas 1.5/2.0的groupby封装""" if version.parse(pd.__version__) >= version.parse('2.0.0'): # pandas 2.0+ 移除了sort参数 kwargs.pop('sort', None) # 安全移除 else: # pandas 1.5 保留sort参数 if 'sort' not in kwargs: kwargs['sort'] = False # 默认不排序,提升性能 return df.groupby(by, **kwargs) # 使用 result = safe_groupby(df, 'customer_id').agg({'amount': 'sum'})提示:我们CI流程中强制检查pandas版本,并用
pip check验证依赖兼容性。任何版本冲突,构建直接失败。
5. 终极实战:构建银行级客户交易分析流水线
5.1 流水线架构图(文字描述)
我们真实的生产流水线分五层:
- 数据接入层:Kafka实时接收交易流,Flink做窗口聚合(1分钟级);
- 基础清洗层:PySpark处理TB级历史数据,输出Parquet分区表(按
dt分区); - 特征工程层:本文所有聚合技术在此实现,输出
customer_features表; - 模型服务层:XGBoost模型读取特征表,实时输出风险评分;
- 应用层:BI看板、短信预警、客户经理APP。
本文聚焦第3层,即如何用pandas构建可复用的特征模块。
5.2 可复用的聚合模块代码(生产环境精简版)
import pandas as pd import numpy as np from typing import Dict, List, Union, Callable, Optional from datetime import datetime class BankAggregator: """银行级聚合器:生产环境验证的聚合模块""" def __init__(self, config: Optional[Dict] = None): self.config = config or {} # 预设业务规则 self.rules = { 'fraud_window_days': self.config.get('fraud_window_days', 7), 'clv_min_txns': self.config.get('clv_min_txns', 1), 'nan_fill_value': self.config.get('nan_fill_value', 0) } def multi_metric_agg(self, df: pd.DataFrame, group_cols: List[str], agg_specs: Dict[str, List[Union[str, Callable]]], flatten: bool = True) -> pd.DataFrame: """ 多指标聚合主入口 agg_specs示例:{ 'amount': ['sum','mean','std'], 'fee': lambda x: (x/x.sum()).mean() # 自定义 } """ # 校验输入 missing_cols = set(group_cols) - set(df.columns) if missing_cols: raise ValueError(f"分组列不存在:{missing_cols}") # 执行聚合 result = df.groupby(group_cols, dropna=False).agg(agg_specs) if flatten and isinstance(result.columns, pd.MultiIndex): result.columns = ['_'.join(col).strip() for col in result.columns] return result def rolling_fraud_score(self, df: pd.DataFrame, window_days: int = None) -> pd.DataFrame: """反欺诈滚动评分(核心业务逻辑)""" window_days = window_days or self.rules['fraud_window_days'] # 时间预处理 df_ts = df.copy() df_ts['date'] = pd.to_datetime(df_ts['date']) df_ts = df_ts.set_index('date').sort_index() # 按客户计算滚动均值和标准差 rolling_stats = df_ts.groupby('customer_id')['amount'].agg([ ('rolling_mean', lambda x: x.rolling(f'{window_days}D').mean()), ('rolling_std', lambda x: x.rolling(f'{window_days}D').std()) ]).reset_index() # 合并回原数据 df_merged = df_ts.reset_index().merge( rolling_stats, on=['customer_id', 'date'], how='left' ) # 计算Z-score(标准化偏离度) df_merged['z_score'] = ( df_merged['amount'] - df_merged['rolling_mean'] ) / (df_merged['rolling_std'] + 1e-8) # 防除零 return df_merged def build_customer_profile(self, df: pd.DataFrame) -> pd.DataFrame: """构建客户全维度画像(端到端示例)""" # 步骤1