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, C002 0.65, C003 0.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%,但业务方质疑“怎么可能这么高?”。
排查过程

  1. 检查 is_renewed 逻辑:发现 next_policy_start_date 大量为空, < 比较返回NaN, mean() 跳过NaN,导致分母变小;
  2. df[df['years_insured']==5]['next_policy_start_date'].isna().mean() → 82%为空;
  3. 真正的续保率应是: 续保数 / 有续保可能性的保单数 ,而非
Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐