1. 项目概述:为什么多维聚合不是“高级技巧”,而是日常分析的呼吸本身
你有没有过这种经历:凌晨两点,报表系统告警,风控模型突然飘红,业务方在群里@你问“上个月南区高净值客户交易额同比为什么跌了12%?”——你手忙脚乱打开Jupyter,groupby('region')、groupby('customer_tier')、groupby('month')……试了七八种组合,结果要么报错KeyError: 'region',要么输出一个嵌套三层的MultiIndex Series,连自己都看不懂。最后硬着头皮写SQL,再把结果导回Python,折腾两小时,交上去的还是个没加注释的df.groupby(['a','b']).sum()。
这不是你能力的问题。这是绝大多数人对pandas聚合的理解,还卡在“能算平均值”的初级阶段。而真实世界的数据分析,从来不是单维度的切片游戏。银行要同时看“客户等级 × 地区 × 产品线 × 时间窗口”的四维盈利结构;电商要对比“新客/老客在大促期间对不同品类的7日滚动复购率”;供应链系统得实时计算“供应商A在华东仓的近30天缺货率 + 历史均值偏差 + 季节性调整系数”。这些需求,用df.groupby().mean()连门都摸不到。
我做金融数据平台开发十年,亲手重构过6套核心报表引擎。最深的体会是:90%的性能瓶颈和85%的逻辑错误,根源不在算法,而在聚合层的设计失当。一个没处理好的unstack()会让下游BI工具卡死;一个没设min_periods=3的滚动窗口,会让风控信号延迟三天;一个没加文档的lambda函数,会在交接后三个月被同事当成bug删掉——然后全链路告警。
这篇文章不讲“pandas有多强大”,只讲你在工位上真正会用到的七种聚合模式。它来自我们给某全国性股份制银行做的信用卡反欺诈系统上线实录:所有代码都在生产环境跑过百万级日活数据,所有参数都是从37次AB测试中挑出来的最优解。没有玩具数据集,没有“假设我们有数据”,只有你明天晨会就要交的那张表、那个指标、那个让老板皱眉的异常点。
关键词里提到的“Towards AI”,不是指某个平台,而是指一种务实态度:AI落地的第一公里,永远是把原始交易流水变成可决策的维度指标。而这件事,99%靠的是扎实的聚合功底,不是炫酷的模型。
2. 核心设计思路:为什么这七种模式构成现代分析的“最小可行集合”
很多人学聚合,习惯按“函数列表”来记:sum()、mean()、std()……这就像学开车只背仪表盘图标。真正决定你能不能开上高速的,是对数据时空结构的理解深度。我把这七种模式重新归类为三个底层思维框架,这才是你该刻进肌肉记忆的东西。
2.1 维度解耦思维:把“交叉分析”从手工拼接变成原子操作
传统做法:先groupby('region')算一次,再groupby('product')算一次,最后用merge()强行拼。问题在哪?
- 时间成本爆炸:10个维度两两组合,要执行45次独立groupby;
- 内存灾难:每次groupby都生成新DataFrame,10G数据瞬间吃光32G内存;
- 逻辑断裂:合并时索引对不齐,
fillna(0)掩盖真实缺失,导致南区“零售”和“Retail”被算成两个品类。
正确解法:用agg()字典一次声明所有需求。关键不是语法,而是理解pandas如何调度计算资源。当你写:
df.groupby(['region','product']).agg({ 'revenue': ['sum','mean'], 'cost': ['sum','std'] })pandas实际执行的是:
- 先按
['region','product']做一次哈希分组(O(n)时间); - 对每个分组桶,并行计算revenue的sum/mean和cost的sum/std(CPU多核利用);
- 最终用MultiIndex组织结果,避免中间DataFrame拷贝。
我实测过:对1200万行交易数据,单维度分组+合并耗时47秒;多维agg()字典仅需8.3秒,且内存占用降低62%。这不是语法糖,是计算范式的升级。
2.2 业务逻辑内嵌思维:让代码成为可审计的业务说明书
财务总监不会关心lambda x: x.max()-x.min(),但他需要知道:“商户类别交易额波动率 = (单日最高交易额 - 单日最低交易额)/ 当月均值,用于校准反欺诈阈值”。这就是为什么必须用命名函数而非lambda:
def merchant_volatility(series): """ 【业务规则】商户交易波动率 计算逻辑:(max-min)/mean,规避极端值干扰 应用场景:当波动率>1.8时,触发人工复核流程(见风控SOP v3.2) """ if len(series) < 5: return np.nan # 数据不足,不参与计算 return (series.max() - series.min()) / series.mean()这个函数的价值远超计算本身:
- 审计友好:六个月后查问题,看到函数名立刻定位到SOP文档;
- 协作高效:业务方提需求“增加波动率指标”,你直接复用此函数;
- 风险可控:
if len(series)<5这行防御式编程,避免了某新商户首日数据导致整张报表NaN蔓延。
我在某城商行项目吃过亏:早期用lambda写了一堆指标,半年后业务规则变更,团队花三天逐行grep替换,结果漏掉一处,导致季度财报差错。现在所有自定义聚合函数,必须带docstring和版本号注释。
2.3 时空上下文思维:时间不是坐标轴,而是分析的氧气
新手常犯的致命错误:把时间序列当静态数据处理。比如计算“7日滚动均值”,直接写:
# ❌ 错误示范:忽略时间连续性 df['rolling_7day'] = df.groupby('customer_id')['amount'].rolling(7).mean()问题在哪?
- 如果某客户3月1日、3月5日、3月10日有交易,
rolling(7)会把这三天当连续7天算,实际跨度10天; - 银行合规要求“滚动周期必须严格按日历日”,否则监管检查通不过。
正确姿势必须绑定时间索引:
# ✅ 正确:用date作为索引,rolling基于时间窗口 df_ts = df.set_index('date').sort_index() df_ts['rolling_7day'] = df_ts.groupby('customer_id')['amount'].rolling('7D').mean()注意'7D'(7天)而非7(7行)。这才是生产环境的底线思维。
这三种思维,构成了我们设计所有聚合方案的底层罗盘。接下来每一部分,我都会紧扣这个框架,告诉你为什么这样设计,不这样设计会踩什么坑,以及在银行/电商/制造等不同场景下,参数怎么调才不翻车。
3. 多维聚合实战:从基础语法到生产级陷阱排查
3.1 多列多函数聚合:告别merge,拥抱原子化计算
先看最常被低估的基础操作。很多人以为agg()字典只是语法糖,其实它藏着三个关键生产细节:
▶️ 维度对齐的隐形杀手:空值处理策略
业务数据永远有缺失。假设你分析信用卡分期业务,installment_term字段有20%为空(未选择分期),此时:
# ❌ 危险写法:默认跳过空值 result = df.groupby('product').agg({'amount': 'sum', 'installment_term': 'mean'})'mean'会自动忽略NaN,但'sum'不会——如果某产品所有amount都是NaN,sum返回0,而mean返回NaN。下游看到“产品A总金额0元,平均期数NaN”,会误判为数据异常。
生产级写法必须显式声明空值策略:
result = df.groupby('product').agg({ 'amount': lambda x: x.sum(min_count=1), # min_count=1:至少1个非空才计算,否则返回NaN 'installment_term': 'mean' }).fillna(0) # 统一补0,但要在注释里写明“0=无数据,非真实值”提示:
min_count参数是pandas 1.1+新增,旧版本需用x.dropna().sum(),但会丢失索引信息。务必检查你的pandas版本!
▶️ 列名冲突的终极解法:用tuple重命名
当多个列用相同函数时,pandas会自动生成('amount', 'sum')这样的tuple列名,但下游系统(如Tableau)常无法识别。更糟的是,如果你写:
df.groupby('region').agg({'revenue': 'sum', 'cost': 'sum'})输出列名是revenue和cost,看似正常。但一旦加入第三个列:
df.groupby('region').agg({'revenue': 'sum', 'cost': 'sum', 'profit': 'sum'})列名还是revenue/cost/profit——这没问题。但当你混用函数时:
df.groupby('region').agg({'revenue': ['sum','mean'], 'cost': 'sum'})输出列名变成('revenue', 'sum')、('revenue', 'mean')、('cost', 'sum'),而('cost', 'sum')在pandas内部存储为('cost', 'sum'),但显示时可能被截断。
生产环境强制规范:所有agg结果必须用tuple重命名,确保下游兼容:
result = df.groupby('region').agg({ 'revenue': [('total_revenue', 'sum'), ('avg_revenue', 'mean')], 'cost': [('total_cost', 'sum')] }) # 展平列名 result.columns = ['_'.join(col).strip() for col in result.columns.values]这样输出列名永远是total_revenue_sum、avg_revenue_mean、total_cost_sum,Excel、BI工具、数据库导入全部零故障。
▶️ 性能核弹:预过滤比后过滤快10倍
分析高价值客户时,你可能想:
# ❌ 慢:先分组再过滤 high_value = df.groupby('customer_id').agg({'amount': 'sum'}).query('amount > 10000')这会让pandas对全部千万级客户做sum计算,再filter。正确姿势是在groupby前用布尔索引缩小数据集:
# ✅ 快:先过滤再分组 high_value_candidates = df[df['amount'] > 1000].copy() # 先筛出单笔超1k的交易 high_value = high_value_candidates.groupby('customer_id')['amount'].sum() high_value = high_value[high_value > 10000] # 再筛总金额实测:某银行信用卡数据(800万行),前者耗时23秒,后者仅2.1秒。因为df[df['amount']>1000]是向量化操作,而query()在groupby后执行。
3.2 自定义聚合函数:把业务规则编译成可执行代码
Lambda函数适合临时调试,但生产环境必须用命名函数。这里分享三个血泪教训换来的经验:
▶️ 教训一:永远检查输入Series的dtype
某次给保险客户做保费分析,我写了:
def premium_growth(series): return (series.iloc[-1] - series.iloc[0]) / series.iloc[0] * 100上线后发现大量NaN。排查三天才发现:某些客户只有1笔保单,series.iloc[-1] == series.iloc[0],但series.iloc[0]是字符串(保单号)!因为原始数据中premium列混入了文本"NA",pandas自动转为object类型。
生产级防御:
def premium_growth(series): # 强制转数值,错误值转NaN numeric_series = pd.to_numeric(series, errors='coerce') if numeric_series.isna().all(): return np.nan if len(numeric_series.dropna()) < 2: return np.nan return (numeric_series.iloc[-1] - numeric_series.iloc[0]) / numeric_series.iloc[0] * 100▶️ 教训二:避免在函数内做IO操作
曾有同事在自定义函数里加了日志:
def risky_log_func(series): logger.info(f"Processing {len(series)} rows") # ❌ 千万行数据会打爆日志 return series.mean()结果日志文件一天涨到12GB,磁盘告警。正确做法:聚合外记录,函数内只计算
# 聚合前记录 logger.info(f"Starting aggregation on {len(df)} rows") result = df.groupby('category').agg({'amount': premium_growth}) logger.info(f"Aggregation completed, {len(result)} groups")▶️ 教训三:复杂逻辑必须拆解验证
风控要求计算“动态风险评分”:
- 基础分 = 交易额标准差
- 若近7天有境外交易,+20分
- 若单笔超5万,+30分
新手常写成一个函数:
def dynamic_risk(series): # ❌ 不可维护 base = series.std() bonus = 0 if any(df_recent['country'] != 'CN'): bonus += 20 if any(df_recent['amount'] > 50000): bonus += 30 return base + bonus问题:df_recent作用域错误,且无法单独测试各模块。
生产级写法:分层函数+单元测试
def base_risk_score(series): """基础风险分:交易额标准差""" return series.std() def overseas_bonus(df_group): """境外交易加分""" return 20 if (df_group['country'] != 'CN').any() else 0 def high_value_bonus(df_group): """大额交易加分""" return 30 if (df_group['amount'] > 50000).any() else 0 # 在agg中组合(注意:需用apply,因涉及多列) def full_risk_score(df_group): base = base_risk_score(df_group['amount']) bonus = overseas_bonus(df_group) + high_value_bonus(df_group) return base + bonus result = df.groupby('customer_id').apply(full_risk_score)这样每个函数都能单独unittest,上线前用100条样本数据验证逻辑,杜绝“改一处崩全局”。
3.3 滚动窗口聚合:时间不是数字,是业务心跳
滚动窗口最易被误解。记住黄金法则:滚动窗口的本质,是为每个时间点构建一个“局部历史视图”。所有参数都服务于这个目的。
▶️ 窗口类型选择:'7D'vs7的生死线
| 参数类型 | 适用场景 | 银行案例 | 风险 |
|---|---|---|---|
window=7 | 数据严格等间隔(如每小时采集) | 服务器监控指标 | 若某小时数据缺失,窗口会跳过,导致趋势失真 |
window='7D' | 日历日业务(如交易、还款) | 信用卡7日滚动逾期率 | 精确匹配监管要求,但需确保date列是datetime类型 |
生产检查清单:
df['date'] = pd.to_datetime(df['date'])—— 强制转换,避免字符串日期;df = df.set_index('date').sort_index()—— 排序是rolling前提;df['rolling_7d'] = df.groupby('customer_id')['amount'].rolling('7D').mean()—— 用'7D';- 关键!检查结果是否含NaN:
df['rolling_7d'].isna().sum(),若>0,需确认是否应fillna(method='ffill')或dropna()。
▶️min_periods:不是可选项,是业务契约
风控系统要求:滚动逾期率必须基于至少3天数据才有效。否则某客户刚开户,1天数据就计算“100%逾期”,引发误告警。
# ✅ 生产写法:min_periods=3 明确业务规则 df['rolling_overdue_rate'] = df.groupby('customer_id')['is_overdue'].rolling( '30D', min_periods=3 # 至少3天数据才计算 ).mean()注意:
min_periods不能大于窗口大小。rolling('30D', min_periods=35)会报错。
▶️ 性能优化:用resample替代rolling处理高频数据
对每秒采集的支付网关日志(10亿行/天),rolling('1H')会慢到崩溃。此时用resample预降采样:
# 先按小时聚合原始数据 hourly_df = df.set_index('timestamp').resample('1H').agg({ 'success_count': 'sum', 'fail_count': 'sum', 'latency_ms': 'mean' }) # 再对小时数据做滚动 hourly_df['rolling_24h_success_rate'] = ( hourly_df['success_count'].rolling('24H').sum() / (hourly_df['success_count'] + hourly_df['fail_count']).rolling('24H').sum() )速度提升50倍,且结果完全等价。
3.4 扩展窗口聚合:累计不是求和,是业务进程的刻度
扩展窗口(expanding())常被误用为“简单累加”。但在金融场景,它承载着更重的业务含义:累计值是客户生命周期的进度条。
▶️ 累计指标的三大禁忌
禁忌一:不排序就expanding
# ❌ 危险:未按时间排序,累计顺序错乱 df['cumulative_spend'] = df.groupby('customer_id')['amount'].expanding().sum()正确:
df_sorted = df.sort_values(['customer_id','date']).set_index('date') df_sorted['cumulative_spend'] = df_sorted.groupby('customer_id')['amount'].expanding().sum()禁忌二:忽略首次交易的基准
某理财APP要求“累计收益率=(当前总资产-首次投入本金)/首次投入本金”。但expanding().sum()只给总额,不记录首次值。
生产解法:用transform抓首次值# 获取每个客户的首次投入 first_invest = df.groupby('customer_id')['amount'].transform('first') # 计算累计收益率 df['cumulative_return'] = (df.groupby('customer_id')['amount'].expanding().sum() - first_invest) / first_invest禁忌三:不处理跨年累计
年度报告需“YTD累计”,但expanding()会跨年。正确姿势:# 按年分组再expanding df['year'] = df['date'].dt.year df['ytd_cumulative'] = df.groupby(['customer_id','year'])['amount'].expanding().sum()
▶️ 扩展统计:用expanding().std()做动态风控
静态标准差会掩盖风险演变。某基金公司用扩展标准差监测基金经理风格漂移:
# 计算基金经理管理产品的月度收益扩展标准差 df['expanding_volatility'] = df.groupby('fund_manager')['monthly_return'].expanding().std() # 当扩展波动率突破历史均值+2σ,触发预警 volatility_history = df['expanding_volatility'].dropna() alert_threshold = volatility_history.mean() + 2 * volatility_history.std() df['volatility_alert'] = df['expanding_volatility'] > alert_threshold这比固定窗口灵敏得多——新基金经理初期波动率天然高,扩展窗口会自适应学习。
3.5 多级分组与unstack:让老板一眼看懂的矩阵艺术
unstack()不是格式美化工具,而是将分析逻辑映射到业务认知框架的翻译器。销售总监脑中天然有“区域×产品”矩阵,你硬给他MultiIndex Series,等于用摩斯电码汇报。
▶️ unstack的三大雷区
| 雷区 | 现象 | 解决方案 |
|---|---|---|
| 索引层级错位 | unstack()后出现KeyError: 'level' | 用reset_index()确认当前索引层级,unstack(level=1)明确指定层级 |
| 缺失值爆炸 | 矩阵中大量NaN(某区域无某产品) | unstack(fill_value=0)填0,或unstack().fillna(0),但必须注释“0=无交易,非零值” |
| 列名混乱 | 输出列名是('revenue','sum'),BI工具不识别 | unstack().pipe(lambda x: x.set_axis([f'{c[0]}_{c[1]}' for c in x.columns], axis=1)) |
▶️ 生产级矩阵构建模板
def build_business_matrix(df, row_dim, col_dim, value_col, agg_func='sum', fill_value=0): """ 构建业务矩阵的标准化函数 :param row_dim: 行维度(如'province') :param col_dim: 列维度(如'product_line') :param value_col: 数值列(如'revenue') :param agg_func: 聚合函数(支持str或dict,如{'revenue':'sum','cost':'mean'}) :param fill_value: 缺失值填充 """ # 多级分组 grouped = df.groupby([row_dim, col_dim])[value_col].agg(agg_func) # unstack并处理列名 matrix = grouped.unstack(level=col_dim, fill_value=fill_value) # 标准化列名 if isinstance(matrix.columns, pd.MultiIndex): matrix.columns = ['_'.join(map(str, col)).strip() for col in matrix.columns.values] else: matrix.columns = [f"{col}_{agg_func}" for col in matrix.columns] return matrix # 使用示例 sales_matrix = build_business_matrix( df_sales, row_dim='region', col_dim='product', value_col='revenue', agg_func='sum' )这个函数已在5个项目中复用,确保所有业务矩阵格式统一。
▶️ 高级技巧:用pivot_table替代groupby+unstack
当需要多值列或复杂填充时,pivot_table更鲁棒:
# 同时展示收入和成本矩阵 matrix = df.pivot_table( values=['revenue','cost'], index='region', columns='product', aggfunc={'revenue':'sum','cost':'sum'}, fill_value=0 ) # 输出列名自动为('revenue','North'),('cost','South')等4. 端到端实战:银行信用卡风控分析流水线
现在把所有技术点串起来,还原一个真实项目:为某银行信用卡中心构建实时风控看板。需求来自风控总监邮件:“需每小时更新,监控各客群在重点商户类别的交易波动、滚动逾期率、累计风险分。”
4.1 数据准备:模拟生产环境的脏数据
import pandas as pd import numpy as np from datetime import datetime, timedelta # 模拟100万行信用卡交易(生产环境最小数据量) np.random.seed(42) dates = pd.date_range('2024-01-01', '2024-03-31', freq='T') # 分钟级 customers = [f'C{str(i).zfill(4)}' for i in np.random.randint(1000, 9999, 1000000)] merchants = ['Amazon', 'Walmart', 'Starbucks', 'Airline', 'Hotel', 'Gas_Station'] categories = ['Retail', 'Dining', 'Travel', 'Fuel'] df = pd.DataFrame({ 'transaction_time': np.random.choice(dates, 1000000), 'customer_id': np.random.choice(customers, 1000000), 'merchant_name': np.random.choice(merchants, 1000000), 'merchant_category': np.random.choice(categories, 1000000), 'amount': np.random.lognormal(8, 0.8, 1000000).round(2), # 模拟长尾分布 'is_overdue': np.random.choice([0,1], 1000000, p=[0.98,0.02]), # 2%逾期 'is_international': np.random.choice([0,1], 1000000, p=[0.95,0.05]) }) # 注入典型脏数据 df.loc[np.random.choice(df.index, 5000), 'amount'] = np.nan # 0.5%空值 df.loc[np.random.choice(df.index, 2000), 'merchant_category'] = '' # 空类别 df = df.dropna(subset=['transaction_time']) # 删除关键时间空值4.2 流水线设计:七个分析模块的协同逻辑
整个流水线不是七个独立脚本,而是一个有依赖关系的DAG(有向无环图):
原始数据 → [清洗] → [基础聚合] → [滚动计算] → [扩展计算] → [矩阵生成] → [风险评分] ↓ ↓ ↓ ↓ ↓ ↓ 客户分层 类别波动率 7日滚动逾期率 YTD累计逾期 区域-品类矩阵 动态风险分▶️ 模块1:数据清洗(5分钟内必须完成)
def clean_transaction_data(df): """生产级清洗:100%覆盖银行ETL规范""" df_clean = df.copy() # 时间处理 df_clean['transaction_date'] = pd.to_datetime(df_clean['transaction_time']).dt.date df_clean['hour'] = pd.to_datetime(df_clean['transaction_time']).dt.hour # 类别填充 df_clean['merchant_category'] = df_clean['merchant_category'].replace('', 'Unknown') # 金额异常值:用IQR法(非3σ,因交易额非正态) Q1 = df_clean['amount'].quantile(0.25) Q3 = df_clean['amount'].quantile(0.75) IQR = Q3 - Q1 lower_bound = Q1 - 1.5 * IQR upper_bound = Q3 + 1.5 * IQR df_clean = df_clean[(df_clean['amount'] >= lower_bound) & (df_clean['amount'] <= upper_bound)] # 关键字段非空检查 assert not df_clean['customer_id'].isna().any(), "customer_id存在空值" assert not df_clean['transaction_date'].isna().any(), "transaction_date存在空值" return df_clean df_clean = clean_transaction_data(df) print(f"清洗后数据量:{len(df_clean):,} 行(原{len(df):,}行,丢弃{len(df)-len(df_clean):,}行)")▶️ 模块2:基础多维聚合(核心指标)
def calculate_core_metrics(df): """计算风控核心指标:必须原子化,不可拆分""" # 多维分组:客户等级 × 商户类别 × 时间粒度 # 先标记客户等级(简化版:按历史总交易额) customer_total = df.groupby('customer_id')['amount'].sum() df['customer_tier'] = pd.qcut(customer_total, q=4, labels=['Tier1','Tier2','Tier3','Tier4']) # 核心聚合:一次完成所有指标 core_metrics = df.groupby(['customer_tier', 'merchant_category', 'transaction_date']).agg({ 'amount': [('total_amount', 'sum'), ('avg_amount', 'mean'), ('tx_count', 'count')], 'is_overdue': [('overdue_rate', 'mean'), ('overdue_count', 'sum')], 'is_international': [('int_rate', 'mean')] }) # 展平列名 core_metrics.columns = ['_'.join(col).strip() for col in core_metrics.columns.values] return core_metrics.reset_index() core_df = calculate_core_metrics(df_clean)▶️ 模块3:滚动窗口计算(实时风控)
def calculate_rolling_metrics(df): """滚动指标:必须基于时间索引,且处理边界""" # 按客户+日期排序 df_sorted = df.sort_values(['customer_id','transaction_date']).set_index('transaction_date') # 7日滚动逾期率(关键风控指标) rolling_overdue = df_sorted.groupby('customer_id')['is_overdue'].rolling( '7D', min_periods=3 # 至少3天数据才有效 ).mean().rename('rolling_7d_overdue_rate') # 7日滚动交易频次 rolling_tx = df_sorted.groupby('customer_id')['amount'].rolling( '7D', min_periods=3 ).count().rename('rolling_7d_tx_count') # 合并结果 rolling_df = pd.concat([rolling_overdue, rolling_tx], axis=1).reset_index() # 添加客户等级(用于后续分组) rolling_df = rolling_df.merge( df[['customer_id','customer_tier']].drop_duplicates(), on='customer_id', how='left' ) return rolling_df rolling_df = calculate_rolling_metrics(df_clean)▶️ 模块4:扩展窗口计算(生命周期追踪)
def calculate_expanding_metrics(df): """扩展指标:YTD累计,需按年分组""" df_sorted = df.sort_values(['customer_id','transaction_date']).set_index('transaction_date') # 按年分组计算YTD df_sorted['year'] = df_sorted.index.year expanding_df = df_sorted.groupby(['customer_id','year']).agg({ 'amount': [('ytd_total_amount', 'sum'), ('ytd_avg_amount', 'mean')], 'is_overdue': [('ytd_overdue_count', 'sum')] }) # 展平 expanding_df.columns = ['_'.join(col).strip() for col in expanding_df.columns.values] return expanding_df.reset_index() expanding_df = calculate_expanding_metrics(df_clean)▶️ 模块5:业务矩阵生成(老板看板)
def build_risk_matrix(df): """构建风控矩阵:区域×商户类别×客户等级""" # 模拟区域字段(生产中来自客户地址表) df['region'] = np.random.choice(['North','South','East','West'], len(df)) # 构建矩阵:行=区域,列=商户类别,值=逾期率 matrix = df.pivot_table( values='is_overdue', index='region', columns='merchant_category', aggfunc='mean', fill_value=0 ).round(4) * 100 # 转百分比 # 添加总计行/列 matrix.loc['TOTAL'] = matrix.mean() # 行总计 matrix['TOTAL'] = matrix.mean(axis=1) # 列总计 return matrix risk_matrix = build_risk_matrix(df_clean) print("风控矩阵(逾期率%):") print(risk_matrix)▶️ 模块6:动态风险评分(最终输出)
def calculate_dynamic_risk_score(df): """综合风险评分:0-100分,分数越高风险越大""" # 基础分:7日滚动逾期率 × 50 base_score = df['rolling_7d_overdue_rate'] * 50 # 加分项 bonus = 0 # 国际交易加分 bonus += df['int_rate'] * 20 # 大额交易加分(单笔>5000) high_value_flag = (df['amount'] > 5000).astype(int) bonus += high_value_flag * 15 # 最终分(截断0-100) final_score = np.clip(base_score + bonus, 0, 100) return final_score.round(1) # 合并所有数据用于评分 score_df = df_clean.merge( rolling_df, on=['customer_id','transaction_date'], how='left' ).merge( df_clean.groupby('customer_id')[['is_international','amount']].agg({ 'is_international': 'mean', 'amount': 'max' }).rename(columns={'is_international':'int_rate','amount':'max_amount'}), on='customer_id', how='left' ) score_df['risk_score'] = calculate_dynamic_risk_score(score_df)4.3 性能压测与优化:百万行数据的毫秒级响应
在生产环境,这个流水线需在5分钟内完成。我们做了三轮优化:
| 优化阶段 | 耗时 | 关键动作 | 效果 |
|---|---|---|---|
| 初始版 | 182s | 全量数据直接计算 | 基线 |
| 第一轮 | 47s | 用query()预过滤:df.query('amount > 100') | 降74% |
| 第二轮 | 12. |