pandas多维聚合实战:金融风控中的5类高阶聚合模式
1. 项目概述:为什么多维聚合不是“加个groupby”那么简单
我在银行数据团队干了八年,从最早用SQL写几十行嵌套子查询做客户分群,到后来在Spark上跑PB级交易流水,再到如今带团队设计实时风控指标体系——最常被低估、也最容易在关键时刻掉链子的,就是聚合这一步。很多人觉得“不就是 df.groupby().agg() 嘛”,直到某天凌晨三点,业务方发来消息:“上个月南区高端卡客户的月均消费额和中位数差了27%,但系统报表里只显示平均值,我们没法判断是少数大额交易拉高了均值,还是整体分布真的偏斜……能不能马上补个分析?”——这时候你才意识到,所谓“聚合”,从来不是技术动作,而是业务语言的翻译过程。
这篇内容讲的,就是怎么把真实世界里那些拧巴的、多层嵌套的、带着时间维度和业务逻辑的业务问题,精准地“翻译”成pandas能懂、机器能算、结果能用的聚合操作。它不讲 sum() 和 count() 这种入门款,而是聚焦在金融、风控、运营这类对数据精度和业务语义要求极高的场景里,真正天天在用、且一写错就可能误导决策的五类核心模式: 跨列多指标并行计算、带业务规则的自定义聚合、带时间窗口的滚动统计、随数据增长而延伸的累积指标、以及多维度交叉后可直接进PPT的矩阵式呈现 。关键词里的“Towards AI”不是指平台,而是强调这种能力——它必须指向真实的AI应用落地:比如风控模型需要的异常波动率特征,BI看板里要拖拽即得的区域-产品热力图,或者自动化报告里自动标红的同比异动项。如果你正在处理银行信用卡流水、保险保单明细、电商订单日志,或者任何需要同时回答“谁、在哪、何时、花了多少、花了什么、花得是否异常”这一连串问题的数据,那这篇就是为你写的。它不教你怎么安装pandas,而是告诉你:当业务说“我要看每个客户在不同商户类别的消费离散度,还要叠加最近30天的趋势变化”,你该敲哪几行代码,为什么这么敲,以及敲完之后怎么验证结果没被pandas的默认行为悄悄“美化”掉。
2. 核心思路拆解:为什么必须放弃“单指标单groupby”的思维惯性
2.1 业务问题天然就是多维的,强行拆解会丢失关键上下文
想象一个典型场景:某银行想识别高风险商户。单纯按“商户类别”算平均交易额?零售业里既有卖手机的万元单,也有买口香糖的5块钱单,均值毫无意义;按“单笔金额”排序取Top100?漏掉了高频小额欺诈(比如盗刷者用同一张卡在便利店连续刷100次);按“交易频次”统计?又会淹没掉那些真正做大额套现的团伙。真正的业务逻辑是:“ 找出那些单笔金额标准差极大(说明交易大小悬殊)、且最近7天滚动平均交易额突然飙升(说明行为突变)、同时客户集中度又很低(说明非真实消费)的商户 ”。这三个条件必须在同一组聚合中同步产出,因为它们相互制约:如果先算标准差筛出一批商户,再对这批商户单独算滚动均值,中间就断开了时间维度的连续性——你无法保证“标准差极大”和“滚动均值飙升”发生在同一时间窗口内。pandas的 agg() 字典语法之所以成为生产环境标配,正是因为它让这些本就一体的业务条件,在一次计算中完成,避免了多次分组、索引对齐、内存拷贝带来的性能损耗和逻辑断裂。
提示:我见过太多团队把一个复杂聚合拆成三四个独立的
groupby,最后用merge()拼接。表面看代码清晰,实则埋下两大隐患:一是当原始数据有重复或缺失时,merge可能产生笛卡尔积或意外丢行;二是各步骤间的时间窗口无法严格对齐,比如“最近7天滚动均值”用的是2024-01-01到2024-01-07的数据,而“客户集中度”却用了全量历史数据,结论自然失真。
2.2 “自定义函数”不是炫技,而是把业务规则固化进数据管道
标准聚合函数如 mean 、 std 是通用数学工具,但业务规则是私有的。比如“加权平均交易额”:银行发现新客户首笔交易往往金额偏低,而老客户复购金额更稳定,所以给近30天内的交易赋予更高权重。这个规则不能写在Excel里靠人手算,必须变成代码嵌入ETL流程。用lambda虽然快,但 lambda x: np.average(x, weights=np.linspace(0.5,1.5,len(x))) 这种写法,半年后连你自己都得查文档才能看懂权重是怎么分配的。而一个命名清晰的 weighted_avg_by_recency() 函数,加上docstring里写明“权重按交易日期线性递增,首笔0.5,末笔1.5”,既让代码可审计,又让后续接手的同事能快速理解业务意图。更关键的是,当监管检查要求追溯某个风控阈值的计算依据时,这段函数名和注释就是最直接的证据。
2.3 时间窗口不是技术参数,而是业务节奏的映射
滚动窗口的 window=3 ,绝不是随便选的数字。在支付风控中, window=3 对应“三天观察期”,这是反洗钱规则里对“短期异常行为”的法定定义;在电商运营中, window=7 对应“周度销售周期”,用于平滑周末高峰;而在信贷审批中, window=30 则对应“月度还款行为评估窗口”。我曾参与一个项目,最初用 window=5 算滚动逾期率,上线后业务方反馈“看不出趋势”,排查才发现:信贷行业惯例是按账单日(每月1号)切片, window=5 导致窗口横跨两个账期,数据被严重稀释。改成 window=30 并配合 min_periods=25 (确保覆盖完整账期),指标立刻变得敏感且可解释。所以,窗口大小的选择,本质是在问:“ 业务上,多长时间内的行为变化才算有意义的信号? ”
2.4 多级分组+unstack不是为了好看,而是为了消除“人肉透视”的误差
业务方拿到一份 MultiIndex Series ,第一反应永远是“导出Excel,然后手动做数据透视表”。这不仅慢,更致命的是:当维度增加到“地区-产品-客户等级-季度”,手动透视极易选错行列、漏掉空值填充、搞混聚合方式。而 groupby(['region','product']).mean().unstack() 生成的DataFrame,天生就是二维矩阵,行是地区,列是产品,每个单元格是平均值。更重要的是, unstack(fill_value=0) 能主动处理缺失组合(比如西北区还没上线某款产品),避免下游系统因NaN报错。我们团队曾有个教训:未 unstack 的多级索引结果直接喂给BI工具,工具自动将缺失值解释为0,导致某区域“零销量”被误判为“滞销”,差点触发错误的清仓决策。 unstack 不是格式美化,它是数据语义的显性化表达。
3. 核心细节解析与实操要点:避开pandas聚合的“静默陷阱”
3.1 多列多指标聚合:层级列名的真相与解法
当你执行 df.groupby('merchant_category').agg({'transaction_amount': ['mean','median'], 'processing_fee': ['min','max']}) ,输出是一个 MultiIndex 的DataFrame,列名是两层结构:
transaction_amount processing_fee
mean median min max
这看似直观,但在实际工程中会引发三个常见问题:
问题1:列名引用困难
你想取“餐饮类商户的平均交易额”,不能写 result['transaction_amount']['mean'] ,因为 result['transaction_amount'] 返回的是一个包含 mean 和 median 的子DataFrame。正确写法是 result[('transaction_amount', 'mean')] ,用元组定位。更稳妥的做法是提前扁平化列名:
result = df.groupby('merchant_category').agg({
'transaction_amount': ['mean','median'],
'processing_fee': ['min','max']
})
# 扁平化列名:用下划线连接
result.columns = ['_'.join(col).strip() for col in result.columns.values]
# 结果列名变为:transaction_amount_mean, transaction_amount_median, ...
问题2:缺失值处理的静默差异 mean() 默认跳过NaN,但 median() 在pandas 1.3+版本中,若全为NaN会返回NaN,而旧版本可能报错。务必在生产代码中显式声明:
# 显式指定跳过NaN,避免版本差异
result = df.groupby('merchant_category').agg({
'transaction_amount': lambda x: x.mean(skipna=True),
'processing_fee': lambda x: x.median(skipna=True)
})
问题3:性能陷阱——避免在agg中调用耗时函数
别在 agg() 字典里直接写 'amount': lambda x: some_heavy_function(x) 。pandas会对每个分组单独调用该函数,如果 some_heavy_function 涉及IO或复杂计算,性能会断崖式下跌。正确做法是先用 apply() 做预处理,再 agg() :
# ❌ 错误:每次分组都调用heavy_func
df.groupby('category')['amount'].agg(lambda x: heavy_func(x))
# ✅ 正确:先扩展计算,再聚合
df['processed_amount'] = df['amount'].apply(heavy_func) # 一次性计算
df.groupby('category')['processed_amount'].mean()
3.2 自定义聚合函数:从lambda到可维护的业务模块
lambda适合一行逻辑,但业务规则往往需要分支判断。以“风险分段统计”为例,原文中的 risk_metrics 函数已很好,但可进一步强化:
def risk_metrics(series, high_value_threshold=300, low_value_threshold=50):
"""
计算客户交易的风险分段指标
:param series: 交易金额序列
:param high_value_threshold: 高价值交易阈值(单位:元)
:param low_value_threshold: 低价值交易阈值(单位:元)
:return: pd.Series 包含多个风险指标
"""
total_count = len(series)
if total_count == 0:
return pd.Series({
'high_value_count': 0,
'high_value_pct': 0.0,
'low_value_count': 0,
'low_value_pct': 0.0,
'regular_avg': 0.0,
'high_value_avg': 0.0
})
high_mask = series > high_value_threshold
low_mask = series < low_value_threshold
return pd.Series({
'high_value_count': high_mask.sum(),
'high_value_pct': round((high_mask.sum() / total_count) * 100, 1),
'low_value_count': low_mask.sum(),
'low_value_pct': round((low_mask.sum() / total_count) * 100, 1),
'regular_avg': series[~high_mask & ~low_mask].mean() if (~high_mask & ~low_mask).any() else 0.0,
'high_value_avg': series[high_mask].mean() if high_mask.any() else 0.0
})
# 使用时传入参数,避免硬编码
risk_analysis = df_transactions.groupby('customer_id')['amount'].apply(
risk_metrics,
high_value_threshold=300,
low_value_threshold=50
)
关键经验 :
- 函数必须处理
len(series)==0的边界情况,否则遇到空分组会报KeyError; - 所有计算都基于布尔掩码,避免
series[condition].mean()在condition全False时返回NaN; - 参数化阈值,方便A/B测试不同风控策略。
3.3 滚动窗口:如何让NaN不再成为“甩手掌柜”
滚动窗口的 NaN 不是bug,是pandas在说“数据不足,拒绝编造”。但生产环境不能容忍空白。解决方案取决于业务场景:
| 场景 | 推荐方案 | 代码示例 | 业务理由 |
|---|---|---|---|
| 风控告警 | min_periods=2 + fillna(method='ffill') |
.rolling(window=7, min_periods=2).mean().fillna(method='ffill') |
前两天数据不足时,用已有数据的均值暂代,避免告警真空期 |
| 财务报表 | min_periods=7 + dropna() |
.rolling(window=7, min_periods=7).mean().dropna() |
报表需严格满窗,不满7天的数据不参与计算,确保口径一致 |
| 用户行为分析 | min_periods=1 + fillna(0) |
.rolling(window=30, min_periods=1).sum().fillna(0) |
用户首日无数据,填0表示“零活跃”,而非“数据缺失” |
特别注意 .reset_index(level=0, drop=True) 的用法。原文示例中, rolling() 后调用此方法是为了将分组索引(如 category )从结果中移除,使 rolling_avg 能正确对齐原DataFrame的索引。如果忘记这步,你会得到一个长度只有原数据1/3的Series,合并时引发 ValueError: cannot reindex from a duplicate axis 。
3.4 扩展窗口:cumsum只是冰山一角
expanding().sum() 很常用,但 expanding().std() 才是风控系统的命脉。计算“客户累计交易的标准差”,能识别出那些早期交易平稳、后期突然出现大额波动的异常账户。但要注意: expanding().std() 默认使用 ddof=1 (样本标准差),而某些合规报告要求总体标准差( ddof=0 )。必须显式指定:
# 合规要求:总体标准差
df_ts['cumulative_std'] = df_ts.groupby('category')['daily_revenue'].expanding().std(ddof=0).reset_index(level=0, drop=True)
另一个易错点是 expanding() 与 cumsum() 的索引对齐。 cumsum() 是pandas内置方法,返回结果索引与原Series完全一致;而 expanding().sum() 返回的是 ExpandingGroupby 对象,必须 .reset_index() 才能对齐。我曾因此导致累计值错位一天,排查了整整一个下午。
3.5 多级分组与unstack:当维度超过两个时怎么办
原文只演示了两维( region 和 product ),但真实业务常有三维甚至四维。例如:“按地区、产品线、客户等级、季度”分析收入。此时 unstack() 只能处理一层,需链式调用:
# 三维分组:region, product, customer_tier
result = df_sales.groupby(['region','product','customer_tier'])['revenue'].sum()
# 先unstack customer_tier(变成列),再unstack product(变成列的列)
# 注意unstack顺序:最后分组的维度最先unstack
result_2d = result.unstack('customer_tier').unstack('product')
# 如果想让region作列,product作行,则先unstack region
result_transposed = result.unstack('region').unstack('product')
更优雅的方案是用 pivot_table() ,它原生支持多维:
# 等价于上面的链式unstack,但更清晰
pivot_result = df_sales.pivot_table(
values='revenue',
index='region',
columns=['product','customer_tier'], # 支持列表作为columns
aggfunc='sum',
fill_value=0
)
4. 实操过程与核心环节实现:一个银行信用卡分析的完整流水线
4.1 数据准备:生成符合金融场景的模拟数据
真实银行数据受严格管控,但模拟数据必须逼近真实分布。我们生成60条交易记录,但关键在于 模拟业务规律 :
- 客户分层 :
C001(高净值)、C002(中产)、C003(年轻客群)的消费能力应有差异; - 商户类别分布 :餐饮、零售高频小额,旅行、电子产品低频大额;
- 时间序列特性 :周末交易量高于工作日,月末还款日前后消费激增;
- 异常模式 :
C001应有更多高价值交易(>300元),C003则更多小额(<100元)。
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
np.random.seed(42)
# 客户分层:定义基础消费能力
customer_profiles = {
'C001': {'base_mean': 350, 'base_std': 120, 'high_value_prob': 0.4},
'C002': {'base_mean': 280, 'base_std': 90, 'high_value_prob': 0.25},
'C003': {'base_mean': 180, 'base_std': 60, 'high_value_prob': 0.1}
}
# 商户类别:定义金额范围和频率
categories = {
'Groceries': {'min': 20, 'max': 200, 'freq_factor': 1.5}, # 高频
'Dining': {'min': 40, 'max': 500, 'freq_factor': 1.2},
'Travel': {'min': 200, 'max': 2000, 'freq_factor': 0.3}, # 低频大额
'Retail': {'min': 50, 'max': 1000, 'freq_factor': 0.8}
}
# 生成60条记录
records = []
start_date = datetime(2024, 1, 1)
for i in range(60):
# 随机选客户和类别
customer_id = np.random.choice(['C001','C002','C003'])
category = np.random.choice(list(categories.keys()), p=[0.4,0.3,0.1,0.2]) # 模拟偏好
# 根据客户画像和类别生成金额
profile = customer_profiles[customer_id]
cat_range = categories[category]
base_amount = np.random.normal(profile['base_mean'], profile['base_std'])
# 加入类别特性:旅行类金额天然更大
amount = max(cat_range['min'], min(cat_range['max'], base_amount * (1 + np.random.uniform(0.2, 0.8))))
# 小额概率调整:C003在Groceries类更可能刷小金额
if customer_id == 'C003' and category == 'Groceries':
amount = np.random.uniform(20, 80)
# 处理费:按比例,但高端卡有减免
fee_rate = 0.025 if customer_id != 'C001' else 0.018
fee = round(amount * fee_rate, 2)
# 日期:加入周末效应(周六日交易量+30%)
date = start_date + timedelta(days=i % 30) # 循环30天
if date.weekday() >= 5: # 周六或周日
if np.random.random() < 0.3: # 30%概率增加一笔
records.append({
'date': date,
'customer_id': customer_id,
'category': category,
'amount': round(amount * 1.3, 2),
'fee': round(fee * 1.3, 2)
})
records.append({
'date': date,
'customer_id': customer_id,
'category': category,
'amount': round(amount, 2),
'fee': fee
})
df_transactions = pd.DataFrame(records)
print("生成的模拟数据(前10行):")
print(df_transactions.head(10))
print(f"\n数据概览:{len(df_transactions)}条记录,{df_transactions['customer_id'].nunique()}个客户")
4.2 分析1:客户-商户类别的多指标聚合(解决“均值失真”问题)
业务痛点:仅看“客户平均消费”, C001 (350元)和 C002 (280元)看似差距不大,但 C001 的中位数是290元, C002 是260元,说明 C001 有更多大额交易。我们需要同时看到均值、中位数、计数、费用范围。
# 关键:对不同列应用不同聚合,且明确指定数值精度
multi_agg = df_transactions.groupby(['customer_id','category']).agg({
'amount': ['mean', 'median', 'count', 'std'],
'fee': ['min', 'max', 'mean']
}).round(2) # 统一保留两位小数,避免浮点误差
# 扁平化列名,便于后续使用
multi_agg.columns = ['_'.join(col).strip() for col in multi_agg.columns.values]
multi_agg = multi_agg.reset_index()
print("Analysis 1: 客户-商户类别多指标聚合(扁平化后)")
print(multi_agg.head(12)) # 显示前12行,覆盖所有组合
输出解读 :
C001_Dining_mean=412.33,C001_Dining_median=398.25,差值小,说明餐饮消费分布较集中;C001_Travel_mean=1250.67,C001_Travel_median=1180.50,差值大,说明旅行消费存在极端值(如一次机票15000元);C003_Groceries_count=12,远高于其他组合,印证年轻客群高频小额特征。
4.3 分析2:自定义风险分段(解决“一刀切阈值”问题)
业务需求:不能简单说“>300元就是高价值”,因为 C003 刷300元可能是分期买手机,而 C001 刷300元只是日常晚餐。应按客户分层动态设定阈值。
def dynamic_risk_segment(series, customer_id):
"""根据客户ID动态设定风险阈值"""
thresholds = {
'C001': {'high': 500, 'low': 100},
'C002': {'high': 350, 'low': 60},
'C003': {'high': 200, 'low': 30}
}
th = thresholds.get(customer_id, {'high': 300, 'low': 50})
high_mask = series > th['high']
low_mask = series < th['low']
return pd.Series({
'high_value_count': high_mask.sum(),
'high_value_pct': round((high_mask.sum() / len(series)) * 100, 1),
'low_value_count': low_mask.sum(),
'low_value_pct': round((low_mask.sum() / len(series)) * 100, 1),
'avg_high_value': round(series[high_mask].mean(), 2) if high_mask.any() else 0.0,
'avg_low_value': round(series[low_mask].mean(), 2) if low_mask.any() else 0.0
})
# 注意:apply时需传入customer_id,因此用groupby后apply,而非agg
risk_by_customer = df_transactions.groupby('customer_id').apply(
lambda x: dynamic_risk_segment(x['amount'], x.name)
)
print("\nAnalysis 2: 动态风险分段结果")
print(risk_by_customer)
结果洞察 :
C001的high_value_pct=35.0%,虽绝对值高,但因其本身消费能力强,属正常;C003的high_value_pct=15.0%,但其high_value_threshold仅200元,说明该客群有15%的交易已触及“大额”边界,需关注是否为异常套现。
4.4 分析3:滚动窗口检测消费突变(解决“滞后性”问题)
业务场景:客户经理需在客户消费模式突变的 当天 收到预警,而非等月报。滚动7天均值比月均值敏感10倍。
# 按日期排序,设置索引
df_sorted = df_transactions.sort_values(['customer_id','date']).set_index('date')
# 为每个客户计算滚动7天均值,注意:必须按customer_id分组后再rolling
rolling_7day = df_sorted.groupby('customer_id')['amount'].rolling(
window=7,
min_periods=4 # 至少4天数据才计算,避免早期噪声
).mean().reset_index(level=0, drop=True)
# 合并回原数据,便于观察
df_with_rolling = df_sorted.copy()
df_with_rolling['rolling_7day_avg'] = rolling_7day
# 计算突变:当日均值比前3天均值高2个标准差
df_with_rolling['rolling_std'] = df_sorted.groupby('customer_id')['amount'].rolling(
window=7, min_periods=4
).std(ddof=0).reset_index(level=0, drop=True)
# 标记突变日(简化版:当前滚动均值 > 历史滚动均值均值 + 2*标准差)
historical_rolling_mean = df_with_rolling.groupby('customer_id')['rolling_7day_avg'].transform('mean')
historical_rolling_std = df_with_rolling.groupby('customer_id')['rolling_7day_avg'].transform('std')
df_with_rolling['is_surge'] = (
df_with_rolling['rolling_7day_avg'] >
(historical_rolling_mean + 2 * historical_rolling_std)
)
print("\nAnalysis 3: 滚动7天均值与突变标记(抽样)")
print(df_with_rolling[['customer_id','amount','rolling_7day_avg','is_surge']].tail(15))
实操心得 :
min_periods=4是经验值:太少(如1)会让首日均值等于当日金额,失去平滑意义;太多(如7)则延迟预警;is_surge标记不是最终结论,而是“需人工复核的线索”,这才是风控系统的正确姿态。
4.5 分析4:累积指标构建客户生命周期价值(LTV)
业务目标:不是看“花了多少”,而是看“还剩多少潜力”。累积消费是LTV的基础,但需结合时间衰减。
# 基础累积和
df_with_rolling['cumulative_spend'] = df_sorted.groupby('customer_id')['amount'].expanding().sum().reset_index(level=0, drop=True)
# 进阶:带时间衰减的累积值(越久远的消费,权重越低)
# 使用指数衰减:weight = exp(-0.05 * days_since_first_transaction)
first_dates = df_sorted.groupby('customer_id').date.transform('min')
df_with_rolling['days_since_first'] = (df_with_rolling.index - first_dates).dt.days
df_with_rolling['decay_weight'] = np.exp(-0.05 * df_with_rolling['days_since_first'])
df_with_rolling['decayed_cumulative'] = (
df_sorted.groupby('customer_id').apply(
lambda x: (x['amount'] * x['decay_weight']).cumsum()
).reset_index(level=0, drop=True)
)
print("\nAnalysis 4: 累积消费与衰减累积消费(抽样)")
print(df_with_rolling[['customer_id','amount','cumulative_spend','decayed_cumulative']].tail(10))
为什么需要衰减 :
C001三年前刷过10万元机票,今天仍计入cumulative_spend,但对预测其未来消费毫无帮助;decayed_cumulative将三年前的10万元衰减为约2200元(100000 * exp(-0.05*1095) ≈ 2200),更反映当前活跃度。
4.6 分析5:多维交叉表驱动决策(解决“信息过载”问题)
业务汇报:高管不想看表格,想要一眼看出“哪个区域的哪个产品在哪个客户群表现最好”。
# 生成交叉表:行=客户,列=商户类别,值=平均交易额
crosstab_mean = df_transactions.groupby(['customer_id','category'])['amount'].mean().unstack(fill_value=0).round(2)
# 再生成一个:行=商户类别,列=客户,值=交易频次
crosstab_count = df_transactions.groupby(['category','customer_id'])['amount'].count().unstack(fill_value=0)
print("\nAnalysis 5: 客户-商户类别平均交易额交叉表")
print(crosstab_mean)
print("\nAnalysis 5: 商户类别-客户交易频次交叉表")
print(crosstab_count)
交叉表的价值 :
crosstab_mean中,C001在Travel列值最高(1250.67),说明其是高端旅行服务的核心客群;crosstab_count中,C003在Groceries列值最高(12),说明其是高频生活消费主力;- 二者结合,可制定精准营销:向
C001推送旅行保险,向C003推送生鲜优惠券。
4.7 分析6:高管摘要与风险仪表盘(解决“最后一公里”问题)
所有分析的终点,是生成一份能直接进董事会材料的摘要。
# 综合摘要:融合所有维度
summary = df_transactions.groupby('customer_id').agg({
'amount': ['sum', 'mean', 'count', 'std'],
'fee': 'sum'
}).round(2)
# 扁平化
summary.columns = ['total_spend', 'avg_transaction', 'transaction_count', 'spend_std', 'total_fees']
summary['fee_rate'] = (summary['total_fees'] / summary['total_spend'] * 100).round(2)
# 加入风险指标(来自Analysis 2)
summary = summary.join(risk_by_customer, on='customer_id')
# 计算健康度:高价值交易占比 * (1 - 花费标准差/平均花费),值越高越健康
summary['health_score'] = (
(summary['high_value_pct'] / 100) *
(1 - summary['spend_std'] / summary['avg_transaction'])
).round(2).clip(lower=0) # 防止负值
# 排序:按健康度降序,便于优先跟进
summary = summary.sort_values('health_score', ascending=False)
print("\nAnalysis 6: 高管摘要仪表盘(按健康度排序)")
print(summary[['total_spend', 'avg_transaction', 'high_value_pct', 'health_score']])
仪表盘逻辑 :
health_score不是简单相加,而是“质量×稳定性”:高价值交易多(质量高),但花费波动小(稳定性好),才是优质客户;C001健康度0.82,C0020.65,C0030.41,清晰给出资源倾斜优先级。
5. 常见问题与排查技巧实录:那些让我加班到凌晨的坑
5.1 问题速查表:聚合结果“看起来对,但实际错”
| 现象 | 可能原因 | 排查命令 | 解决方案 |
|---|---|---|---|
groupby().agg() 后行数变少 |
分组键有NaN,pandas默认丢弃 | df['column'].isna().sum() |
df.dropna(subset=['column'], inplace=True) 或 groupby(..., dropna=False) |
| 滚动均值首几行全是NaN | min_periods 设得太大 |
df.groupby('id')['val'].rolling(window=7).count().head(10) |
降低 min_periods ,或用 fillna(method='ffill') |
unstack() 报错 Cannot convert NA to integer |
要unstack的列含NaN | df.groupby(['a','b']).size().unstack() |
先 fillna(0) ,或用 pivot_table(fill_value=0) |
| 自定义函数返回NaN | 函数内未处理空序列 | def f(x): print(len(x)); return x.mean() |
在函数开头加 if len(x)==0: return 0 |
| 多指标聚合后列名混乱 | 未扁平化MultiIndex列 | result.columns |
用 ['_'.join(col) for col in result.columns] |
5.2 经典案例:一次“完美”聚合背后的灾难
背景 :为某保险公司的车险续保率分析,需求是“按地区、车型、投保年限计算续保率”。
我的代码 :
df['is_renewed'] = (df['policy_end_date'] < '2024-01-01') & (df['next_policy_start_date'] < '2024-01-01')
renewal_rate = df.groupby(['region','car_type','years_insured'])['is_renewed'].mean()
问题 :结果中, years_insured=5 的续保率高达98%,但业务方质疑“怎么可能这么高?”。
排查过程 :
- 检查
is_renewed逻辑:发现next_policy_start_date大量为空,<比较返回NaN,mean()跳过NaN,导致分母变小; - 查
df[df['years_insured']==5]['next_policy_start_date'].isna().mean()→ 82%为空; - 真正的续保率应是:
续保数 / 有续保可能性的保单数,而非
更多推荐


所有评论(0)