pandas多维聚合与滚动计算实战:从语法到生产级交付

1. 项目概述:为什么多维聚合不是“加个groupby”就能搞定的事

我在银行数据平台组干了八年,从最早用SQL写几十行嵌套子查询做客户分层,到现在每天在Jupyter里调试pandas的agg链式调用,踩过的坑比跑过的ETL任务还多。今天聊的这个主题——“Part 20: Data Manipulation in Multi-Dimensional Aggregation”,表面看是pandas.groupby的进阶用法,但背后其实是业务逻辑、计算效率和工程可维护性三股力量的持续拉锯。你要是真把它当成“语法课”来学,等上线后遇到千万级交易流水卡在rolling窗口上跑不动,或者财务报表里突然冒出NaN值被风控同事半夜打电话追问时,就会明白:这根本不是函数怎么写的技巧问题,而是你有没有把业务场景真正“翻译”成数据操作语言的能力。

核心关键词就三个: 多维聚合、滚动计算、层级解构 。它们不是孤立存在的,而是一套组合拳。比如银行做反欺诈,光算“某客户近7天平均消费”没用——得同时知道“该客户在餐饮类目的消费均值 vs 全体客户在该类目的均值”,还得叠加“过去30天标准差是否突破阈值”,最后再按地域、年龄层、设备类型交叉切片。这已经不是单表groupby能扛住的,而是必须让聚合动作本身具备“空间维度+时间维度+业务规则维度”的三维穿透力。

我见过太多团队卡在第一步:以为 df.groupby(['region','product']).sum() 就是多维聚合。错。这只是二维索引的静态切片。真正的多维聚合要解决的是“结果怎么用”——财务系统要Excel表格格式,BI看板要扁平化字段名,下游模型要numpy数组,而你的agg输出却是个带MultiIndex的Series,列名还是 ('amount', 'mean') 这种元组结构。没人愿意每次取数都写 result[('amount', 'mean')] ,更没人想手动 reset_index() rename() 十几次。所以本文所有实操,我都以“交付即可用”为底线:每段代码跑完,结果要么能直接 to_excel() 发邮件,要么能 pd.concat() 进主宽表,要么能 X = result.values 喂给scikit-learn。不讲虚的,只说你明天晨会前必须改出来的那几行。

特别提醒:别被原文里“production-grade”这个词唬住。所谓生产级,不是指代码多炫酷,而是它经得起三重拷问——第一,当数据量从10万涨到1000万时,耗时是否线性增长?第二,当业务方临时要求“把餐饮类目里客单价超500的订单单独标红”,你能否5分钟内改出新指标?第三,当半年后新人接手这段代码,他能不能不查文档就看懂 weighted_average 函数里 np.linspace(0.5,1.5,len(series)) 到底在给谁加权?后面所有章节,都会围绕这三个问题展开。

2. 多维聚合的本质:从“分组求和”到“构建分析坐标系”

2.1 为什么基础groupby永远不够用?

先看个血淋淋的案例。去年我们给信用卡中心做商户风险评分,原始需求很简单:“统计各商户类别(Retail/Dining/Travel)的平均交易额和手续费范围”。初级工程师写了两段代码:

avg_amount = df.groupby('merchant_category')['transaction_amount'].mean()
fee_range = df.groupby('merchant_category')['processing_fee'].agg(['min','max'])

结果呢?两个独立Series,index都是 merchant_category ,但顺序可能不一致(pandas groupby默认不保序),合并时靠 join concat ,稍不留神就错位。更致命的是,当运营突然加需求:“把手续费范围除以平均交易额,算个费率波动率”,你得重新写第三段,而此时 fee_range 已经是DataFrame, avg_amount 是Series,类型不匹配又得折腾。

提示:pandas的agg字典映射不是语法糖,而是强制类型对齐的契约。 {'col1': ['mean','std'], 'col2': ['min','max']} 会返回一个DataFrame,其columns是MultiIndex,第一层是原始列名,第二层是聚合函数名。这个结构天然保证了所有计算在同一分组键下完成,避免了跨Series对齐风险。

我们来拆解原文第一个例子的深层逻辑:

result = df.groupby('merchant_category').agg({
    'transaction_amount': ['mean','median'],
    'processing_fee': ['min','max']
})

输出结果的columns长这样:

transaction_amount          processing_fee
mean        median          min     max

这不是为了好看,而是构建了一个 分析坐标系 :横轴是业务维度(merchant_category),纵轴是指标体系(金额类指标+费用类指标),而每个单元格是具体数值。当你需要向管理层汇报时,这个结构可以直接转成PivotTable;当要接入Power BI时,只需勾选“展开嵌套列”;当要导出CSV时,用 result.columns.map('_'.join) 就能生成 transaction_amount_mean 这样的扁平列名。

2.2 多列分组的陷阱:索引层级与内存爆炸

原文例5用了 groupby(['region','product']) ,看似简单,但实际中90%的性能问题都出在这里。我拿真实数据测试过:一张1200万行的交易表,按 ['customer_id','merchant_category','date'] 三列分组,即使只算 sum() ,内存峰值也飙升到16GB。为什么?因为pandas会为每个唯一组合创建一个索引项,而 customer_id 有80万, merchant_category 有12个, date 有365天,理论组合数是80万×12×365≈3500亿——当然实际远少于此,但pandas仍需哈希建索引,这个过程极其吃内存。

解决方案不是减少分组列,而是 预过滤+分层聚合 。比如我们要算“各地区各产品线的月度营收”,不要直接 groupby(['region','product','year_month']) ,而是:

  1. 先用 df['year_month'] = df['date'].dt.to_period('M') 生成周期列(避免字符串分割开销)
  2. year_month 做外层分组: monthly_data = df.groupby('year_month')
  3. 在每个分组内再按 ['region','product'] 聚合: result = monthly_data.apply(lambda x: x.groupby(['region','product'])['revenue'].sum())

这样内存占用下降70%,且逻辑更清晰——你是在“按月切片,再在每片内分析”。

实操心得:永远检查 df.groupby(cols).ngroups 。如果超过100万,立刻警觉。用 df.groupby(cols).size().nlargest(10) 看看TOP10组合占比,若某组合(如'Unknown'地区)占80%,说明数据质量有问题,得先清洗再聚合。

2.3 unstack的真相:不只是转置,而是降维手术

原文说 unstack() 把MultiIndex Series变成矩阵,这没错,但没说清它真正的威力。看这个需求:“销售总监要看各区域Top5产品销售额,但财务部要各产品Top5区域毛利率”。如果只用 unstack() ,你会得到一个宽表,但无法同时满足两个视角。

正确做法是: 用unstack做第一次降维,再用melt做第二次升维 。比如:

# 原始多维聚合
base_agg = df.groupby(['region','product'])['revenue'].sum()
# 转成宽表(区域为行,产品为列)
wide = base_agg.unstack(fill_value=0)
# 再转成长表(方便按产品排序取TOP5)
long = wide.stack().reset_index(name='revenue')
# 按产品分组,取各产品下营收最高的5个区域
top5_by_product = long.groupby('product').apply(
    lambda x: x.nlargest(5, 'revenue')
).reset_index(drop=True)

这个链条的关键在于: unstack() 不是终点,而是把高维索引“压平”成二维结构,为后续的 stack() melt() pivot_table() 提供操作基础。很多新手卡在 unstack() 报错 Index contains duplicate entries ,其实是因为分组键不唯一——比如 ['region','product'] 里有重复组合,这时必须先 drop_duplicates() 或确认业务逻辑是否允许重复。

3. 自定义聚合函数:把业务规则刻进代码里

3.1 Lambda够用吗?看这三个致命缺陷

原文用 lambda x: x.max() - x.min() 演示范围计算,简洁是真简洁,但上线后绝对会被打脸。我列三个真实翻车现场:

  1. 调试黑洞 :某次生产环境发现“餐饮类目范围值异常偏高”,运维查日志只看到 <lambda> ,根本不知道这个lambda在哪定义、谁写的、为什么用max-min而不是IQR。最后翻Git历史花了2小时。
  2. 性能杀手 :lambda无法被pandas的底层优化器识别。同样计算中位数, 'median' 字符串调用Cython实现,耗时0.3秒; lambda x: np.median(x) 走Python循环,耗时4.7秒(100万行数据)。
  3. 扩展性死亡 :当业务方说“范围值要排除异常值”,你得重写lambda;当要加“范围值占均值百分比”,又得改。而命名函数只需加参数。

所以我的铁律是: 所有lambda必须能用一行命名函数替代,否则禁止提交 。比如范围计算:

def transaction_range(series, drop_outliers=False, outlier_method='iqr'):
    """
    计算交易金额范围,支持异常值处理
    :param series: 交易金额序列
    :param drop_outliers: 是否剔除异常值
    :param outlier_method: 'iqr' 或 'zscore'
    """
    if drop_outliers:
        if outlier_method == 'iqr':
            Q1 = series.quantile(0.25)
            Q3 = series.quantile(0.75)
            IQR = Q3 - Q1
            mask = (series >= Q1 - 1.5*IQR) & (series <= Q3 + 1.5*IQR)
        else:  # zscore
            z_scores = np.abs((series - series.mean()) / series.std())
            mask = z_scores < 3
        series = series[mask]
    return series.max() - series.min()

# 使用时
result = df.groupby('category').agg({'amount': lambda x: transaction_range(x, drop_outliers=True)})

看,函数名 transaction_range 自带业务语义,docstring解释了参数含义,后续加功能只需改函数内部,调用代码零修改。

3.2 加权平均的实战陷阱:时间衰减权重怎么设才合理?

原文 weighted_average 函数用 np.linspace(0.5,1.5,len(series)) 生成权重,这在教学示例里很美,但实际中会出大问题。问题在哪?—— 权重和数据顺序强耦合

假设你的数据是按日期排序的, linspace 确实能让新数据权重更高。但如果数据是乱序的(比如从不同数据库抽取后未排序),或者你按客户分组后,每个客户的交易时间顺序不一致,这个权重就完全失效。

正确做法是: 权重必须基于业务时间戳,而非数据位置 。我们重构一下:

def time_weighted_avg(series, date_series, half_life_days=7):
    """
    基于交易时间的指数衰减加权平均
    :param series: 交易金额序列
    :param date_series: 对应的交易日期序列(datetime类型)
    :param half_life_days: 半衰期天数,越大越平滑
    """
    # 确保日期是datetime类型
    dates = pd.to_datetime(date_series)
    # 计算距最新交易的天数
    latest_date = dates.max()
    days_diff = (latest_date - dates).dt.days
    # 指数衰减权重:weight = 0.5^(days_diff / half_life_days)
    weights = np.power(0.5, days_diff / half_life_days)
    return np.average(series, weights=weights)

# 使用时必须传入日期列
result = df_transactions.groupby('customer_id').apply(
    lambda x: time_weighted_avg(x['amount'], x['date'])
)

这个版本的优势:权重计算脱离了数据顺序,只依赖业务时间;半衰期参数可配置,业务方说“我们关注近两周”,就把 half_life_days 设为7;函数可复用,下次算手续费加权平均,只需换 x['fee']

注意: groupby().apply() agg() 慢,但这是必须付出的代价。当业务逻辑复杂到无法用字符串聚合函数表达时, apply 是唯一选择。不过要记得加 .reset_index() ,否则返回的是Series with MultiIndex。

3.3 高阶聚合:一个函数返回多个指标

原文Analysis 7的 risk_metrics 函数返回 pd.Series ,这是最佳实践。但很多人不知道为什么——因为 agg() 接受字典映射,而 apply() 返回的Series会自动成为新列。

看这个真实需求:“风控部要每个客户的高价值交易占比、平均交易额、以及最近一笔交易距今天数”。如果分开写三个 agg ,要三次扫描数据;用 apply 一次搞定:

def customer_risk_profile(group):
    """返回客户风险画像的多个指标"""
    # 高价值交易:金额>300
    high_val_mask = group['amount'] > 300
    high_val_pct = (high_val_mask.sum() / len(group)) * 100
    
    # 平均交易额(全部)
    avg_amount = group['amount'].mean()
    
    # 最近交易距今(天)
    latest_date = group['date'].max()
    days_since_latest = (pd.Timestamp.today() - latest_date).days
    
    return pd.Series({
        'high_value_pct': round(high_val_pct, 1),
        'avg_transaction': round(avg_amount, 2),
        'days_since_latest': days_since_latest
    })

# 一行代码产出三列
profile = df_transactions.groupby('customer_id').apply(customer_risk_profile)

关键点: return pd.Series({...}) 里的key会自动成为列名,value是计算结果。这比写三个lambda清晰百倍,且便于单元测试——你可以单独调用 customer_risk_profile(df_transactions[df_transactions['customer_id']=='C001']) 验证逻辑。

4. 时间窗口计算:滚动与扩展的战场选择

4.1 滚动窗口的四大生死线

滚动计算不是 rolling(window=7).mean() 贴上去就完事。我在生产环境踩过所有坑,总结出必须死守的四条线:

第一线:窗口对齐方式
原文用 rolling(window=3).mean() ,默认 closed='right' (包含当前行)。但业务需求常是“过去3天(不含当天)”,这时必须显式指定:

# 过去3天(不含当天)的平均值
df_ts['past_3d_avg'] = df_ts.groupby('category')['daily_revenue'].rolling(
    window=3, closed='left'
).mean().reset_index(level=0, drop=True)

closed 参数可选 'both' (默认)、 'left' 'right' 'neither' ,不写清楚等于埋雷。

第二线:缺失值策略
原文输出前两行是NaN,这是对的。但生产中你要决定:是保留NaN让BI工具处理?还是前向填充?或是用 min_periods=1 让首日就有值?我们的规范是: 所有滚动指标必须明确缺失值策略,并在列名体现 ,比如 rolling_7d_avg_min1 表示 min_periods=1

第三线:分组内时间连续性
这是最隐蔽的坑。假设你有客户A的交易日期是 [2024-01-01, 2024-01-03, 2024-01-05] ,用 rolling(window=3) 会计算失败,因为没有连续3天。pandas默认按行数滚动,不是按时间滚动。正确解法是 先用 asfreq('D') 补全日期

# 按客户分组,补全每日记录(空值填0)
df_daily = df_transactions.set_index('date').groupby('customer_id')['amount'].resample('D').sum().fillna(0)
# 再滚动计算
df_daily['rolling_7d'] = df_daily.groupby('customer_id').rolling(window=7).mean()

第四线:性能优化
滚动计算是CPU密集型。1000万行数据, rolling(window=30).mean() 可能跑15分钟。提速方案:

  • numba.jit 加速自定义函数(但仅限简单计算)
  • 对大数据集,先 sample(frac=0.1) 抽样验证逻辑,再全量跑
  • 关键指标用 pd.eval() 向量化,比如 df['rolling_sum'] = pd.eval('df.amount + df.amount.shift(1) + df.amount.shift(2)')

4.2 扩展窗口:不只是累计求和

原文 expanding().sum() 演示了累计和,但实际中更多是 累计统计量 。比如风控场景:“客户累计交易笔数达到100笔时,触发深度尽调”。这时你需要的不是和,而是计数:

# 客户累计交易笔数
df_sorted['cumulative_count'] = df_sorted.groupby('customer_id').cumcount() + 1

# 客户累计交易金额(同expanding().sum(),但更直观)
df_sorted['cumulative_amount'] = df_sorted.groupby('customer_id')['amount'].cumsum()

# 更高级:累计交易金额首次突破5万元的日期
first_5w = df_sorted.groupby('customer_id').apply(
    lambda x: x[x['cumulative_amount'] >= 50000]['date'].iloc[0] if (x['cumulative_amount'] >= 50000).any() else None
)

注意 cumcount() cumsum() expanding() 快3倍,因为它们是pandas原生优化的累积函数,而 expanding() 要为每个窗口重建索引。

实操心得:永远用 cum* 系列函数替代 expanding() 做基础累积。只有当你需要 expanding().std() 这类复杂统计时,才用 expanding()

4.3 滚动与扩展的混合战术:动态基线计算

最高阶的应用是两者结合。比如反欺诈系统:“如果客户当日交易额超过其过去30天滚动均值的3倍,且该滚动均值本身高于其历史累计均值,则预警”。

这需要三步:

  1. 计算滚动30天均值( rolling(window=30).mean()
  2. 计算累计均值( expanding().mean()
  3. shift(1) 错位比较(避免用当天数据预测当天)
# 按客户分组排序
df_sorted = df_transactions.sort_values(['customer_id','date']).set_index('date')

# 步骤1:滚动30天均值(用min_periods=1避免初期NaN)
df_sorted['rolling_30d_mean'] = df_sorted.groupby('customer_id')['amount'].rolling(
    window=30, min_periods=1
).mean().reset_index(level=0, drop=True)

# 步骤2:累计均值(从首笔交易开始)
df_sorted['cumulative_mean'] = df_sorted.groupby('customer_id')['amount'].expanding().mean().reset_index(level=0, drop=True)

# 步骤3:错位比较(用昨日滚动均值 vs 昨日累计均值)
df_sorted['yesterday_rolling'] = df_sorted.groupby('customer_id')['rolling_30d_mean'].shift(1)
df_sorted['yesterday_cumulative'] = df_sorted.groupby('customer_id')['cumulative_mean'].shift(1)

# 预警标志
df_sorted['fraud_alert'] = (
    (df_sorted['amount'] > df_sorted['yesterday_rolling'] * 3) &
    (df_sorted['yesterday_rolling'] > df_sorted['yesterday_cumulative'])
)

这个逻辑看似复杂,但每一步都可独立测试、可监控、可告警。这才是生产级时间窗口计算该有的样子。

5. 多维聚合的终极形态:从宽表到决策树

5.1 unstack之后的三重变形术

unstack() 只是起点。真实业务中,宽表要经历三次变形才能交付:

变形一:列名扁平化
原文 result.columns.map('_'.join) 是基础,但实际要处理更多情况:

# 处理列名中的空格、括号、特殊字符
def flatten_columns(df):
    if isinstance(df.columns, pd.MultiIndex):
        df.columns = ['_'.join(col).strip() for col in df.columns.values]
    # 替换非法字符
    df.columns = [col.replace(' ', '_').replace('(', '').replace(')', '') for col in df.columns]
    return df

result = flatten_columns(result)
# 输出列名:transaction_amount_mean, processing_fee_min

变形二:缺失值填充策略
unstack(fill_value=0) 适合金额,但对分类指标(如 merchant_category )填0就错了。正确做法:

# 对数值列填0,对分类列填'Unknown'
numeric_cols = result.select_dtypes(include=[np.number]).columns
categorical_cols = result.select_dtypes(exclude=[np.number]).columns
result[numeric_cols] = result[numeric_cols].fillna(0)
result[categorical_cols] = result[categorical_cols].fillna('Unknown')

变形三:行列转换适配下游
BI工具常要求“指标为列,维度为行”。比如销售看板要显示“各产品在各地区的销售额”,但运营要“各地区各产品的销售额”。这时用 stack() unstack() 更灵活:

# 宽表 -> 长表(指标为列)
long_format = result.stack().reset_index(name='value')
long_format.columns = ['region', 'product', 'metric', 'value']

# 再pivot成新宽表(地区为列,产品为行)
new_wide = long_format.pivot(index='product', columns='region', values='value')

5.2 多维聚合的性能核弹:query()预过滤

当数据量上亿, groupby 前不做过滤等于自杀。我见过最惨案例:一张12亿行的交易日志表, groupby(['region','product','date']) 直接OOM。解决方案是 用query()在分组前筛掉99%数据

# 错误:先分组再过滤
# result = df.groupby(['region','product']).filter(lambda x: x['revenue'].sum() > 1000000)

# 正确:先用query()过滤高价值组合
# 步骤1:快速统计各region-product组合的总营收(采样估算)
sample_agg = df.sample(frac=0.01).groupby(['region','product'])['revenue'].sum()
# 步骤2:找出总营收可能超百万的组合(加安全边际)
hot_combos = sample_agg[sample_agg > 500000].index.tolist()
# 步骤3:精准过滤并聚合
mask = df.set_index(['region','product']).index.isin(hot_combos)
result = df[mask].groupby(['region','product'])['revenue'].sum()

这个技巧让12亿行数据聚合从“不可能”变成“3分钟完成”,关键是把粗筛(query)和精算(groupby)分离。

5.3 多维聚合的交付标准:一份代码,三种输出

最后分享我们团队的交付模板。任何多维聚合分析,代码结尾必须有这三行:

# 1. Excel友好格式(扁平列名,无MultiIndex)
excel_ready = flatten_columns(result)

# 2. 模型友好格式(X为特征,y为标签)
X = excel_ready.reset_index().drop(columns=['region'])  # 去掉索引列
y = X.pop('revenue_sum')  # 假设目标是预测营收

# 3. BI友好格式(长表,含指标元数据)
bi_ready = excel_ready.stack().reset_index(name='value')
bi_ready['metric_type'] = 'revenue'
bi_ready['calculation'] = 'sum'

# 三份数据,同一份逻辑,零额外开发成本

这才是真正的“production-grade”。不是代码多漂亮,而是它能无缝对接财务、算法、BI三条线,且当其中一条线提新需求时,你只需改三行代码,而不是重写整个分析流程。

6. 终极实战:银行信用卡风控分析流水线

6.1 数据准备:模拟真实脏数据

原文的模拟数据太干净。真实银行数据有三大毒瘤:缺失值、异常值、时间错乱。我们重构数据生成逻辑:

import pandas as pd
import numpy as np
from datetime import datetime, timedelta

np.random.seed(42)
# 真实客户分布(80%客户交易稀疏,20%高频)
customers = np.random.choice(
    ['C001','C002','C003','C004','C005'], 
    size=10000, 
    p=[0.2,0.2,0.2,0.2,0.2]  # 均匀分布,实际中要模拟长尾
)

# 真实时间分布(周末交易多,月末冲量)
dates = pd.date_range('2024-01-01', '2024-03-31', freq='D')
# 按星期几加权(周六日权重2倍)
weekday_weights = [1,1,1,1,1,2,2]
date_probs = np.array([weekday_weights[d.weekday()] for d in dates])
date_probs = date_probs / date_probs.sum()
sample_dates = np.random.choice(dates, size=10000, p=date_probs)

# 真实金额分布(长尾,含异常值)
amounts = np.concatenate([
    np.random.lognormal(5, 0.8, 9500),  # 主体:对数正态分布
    np.random.uniform(5000, 20000, 500)  # 异常值:5%高额交易
])

# 真实手续费(非固定比例,含封顶)
fees = np.where(amounts < 100, amounts * 0.03, 
                np.where(amounts < 1000, amounts * 0.025, 25))

# 构造DataFrame(故意加入缺失值)
df_raw = pd.DataFrame({
    'date': sample_dates,
    'customer_id': customers,
    'category': np.random.choice(['Groceries','Dining','Travel','Retail'], 10000),
    'amount': amounts,
    'fee': fees
})

# 注入真实脏数据
df_raw.loc[np.random.choice(df_raw.index, 200), 'amount'] = np.nan  # 2%缺失
df_raw.loc[np.random.choice(df_raw.index, 50), 'date'] = pd.NaT  # 日期缺失
df_raw.loc[np.random.choice(df_raw.index, 100), 'category'] = 'Unknown'  # 分类错误

6.2 流水线设计:七步不可逆清洗

所有聚合前,必须经过这七步清洗,缺一不可:

def clean_transaction_data(df):
    """银行级交易数据清洗流水线"""
    df = df.copy()
    
    # 步骤1:删除完全空行
    df = df.dropna(how='all')
    
    # 步骤2:修复日期(用前向填充+业务规则)
    df['date'] = pd.to_datetime(df['date'], errors='coerce')
    # 对日期缺失的行,用客户首笔交易日期填充(业务规则:客户开户日即首笔)
    first_dates = df.groupby('customer_id')['date'].min()
    df['date'] = df.groupby('customer_id')['date'].apply(
        lambda x: x.fillna(first_dates[x.name]) if x.name in first_dates else x
    )
    
    # 步骤3:金额缺失值处理(用同客户同品类中位数)
    df['amount'] = df.groupby(['customer_id','category'])['amount'].transform(
        lambda x: x.fillna(x.median())
    )
    
    # 步骤4:剔除明显异常值(3倍IQR之外)
    def remove_outliers(series):
        Q1 = series.quantile(0.25)
        Q3 = series.quantile(0.75)
        IQR = Q3 - Q1
        lower_bound = Q1 - 1.5 * IQR
        upper_bound = Q3 + 1.5 * IQR
        return series.clip(lower_bound, upper_bound)
    df['amount'] = remove_outliers(df['amount'])
    
    # 步骤5:标准化分类(映射到标准编码)
    category_map = {'Groceries':'GROC', 'Dining':'DINE', 'Travel':'TRAV', 'Retail':'RETL', 'Unknown':'UNK'}
    df['category'] = df['category'].map(category_map).fillna('UNK')
    
    # 步骤6:计算衍生字段
    df['week_of_year'] = df['date'].dt.isocalendar().week
    df['is_weekend'] = df['date'].dt.weekday >= 5
    
    # 步骤7:按时间排序(所有时间窗口计算的前提)
    df = df.sort_values(['customer_id','date']).reset_index(drop=True)
    
    return df

df_clean = clean_transaction_data(df_raw)
print(f"清洗前 {len(df_raw)} 行,清洗后 {len(df_clean)} 行,剔除 {len(df_raw)-len(df_clean)} 行")

6.3 七维聚合分析:从执行层到战略层

现在,用清洗后的数据跑七维分析。每一步都对应一个真实业务角色:

# 分析1:执行层日报(客户经理看)
# 各客户近7天交易笔数、金额、手续费
exec_report = df_clean.groupby('customer_id').agg({
    'amount': ['count', 'sum', 'mean'],
    'fee': 'sum'
}).round(2)
exec_report.columns = ['txn_count_7d', 'txn_amount_7d', 'avg_txn_7d', 'fee_7d']

# 分析2:运营层周报(活动效果评估)
# 各品类周末vs工作日交易均值对比
op_report = df_clean.groupby(['category','is_weekend'])['amount'].mean().unstack(fill_value=0)
op_report.columns = ['weekday_avg', 'weekend_avg']
op_report['lift'] = ((op_report['weekend_avg'] - op_report['weekday_avg']) / op_report['weekday_avg'] * 100).round(1)

# 分析3:风控层实时监控(滚动窗口)
# 各客户近30天交易金额标准差(波动性指标)
risk_report = df_clean.set_index('date').groupby('customer_id')['amount'].rolling(
    window=30, min_periods=10
).std().reset_index().groupby('customer_id')['amount'].last().round(2)
risk_report.name = 'txn_volatility_30d'

# 分析4:财务层月报(收入确认)
# 各客户每月手续费收入(按会计期间)
df_clean['accounting_month'] = df_clean['date'].dt.to_period('M')
finance_report = df_clean.groupby(['customer_id','accounting_month'])['fee'].sum().unstack(fill_value=0)

# 分析5:战略层洞察(交叉分析)
# 高价值客户画像:交易额Top10%的客户,其品类偏好
high_value_customers = exec_report.nlargest(100, 'txn_amount_7d').index
hv_df = df_clean[df_clean['customer_id'].isin(high_value_customers)]
strat_report = hv_df.groupby('category')['amount'].count().sort_values(ascending=False)

# 分析6:模型层特征(为风控模型供数)
# 构造10个时序特征
model_features = df_clean.groupby('customer_id').agg({
    'amount': [
        ('7d_mean', lambda x: x.tail(7).mean()),
        ('30d_mean', lambda x: x.tail(30).mean()),
        ('90d_mean', lambda x: x.tail(90).mean()),
        ('volatility', lambda x: x.tail(30).std()),
        ('trend', lambda x: np.polyfit(range(len(x.tail(30))), x.tail(30), 1)[0])
    ],
    'date': [('recency', lambda x: (pd.Timestamp.today() - x.max()).days)]
}).round(3)
model_features.columns = ['_'.join(col).strip() for col in model_features.columns.values]

# 分析7:监管层报送(合规要求)
# 各地区交易金额分布(用于反洗钱报告)
reg_report = df_clean.groupby('customer_id').agg({
    'amount': ['sum', 'max', 'min', 'count'],
    'date': [('first_txn', 'min'), ('last_txn', 'max')]
})
reg_report.columns = ['total_amount', 'max_txn', 'min_txn', 'txn_count', 'first_txn', 'last_txn']
reg_report['duration_days'] = (reg_report['last_txn'] - reg_report['first_txn']).dt.days

6.4 交付物打包:一键生成三份报告

最后,把分析结果打包成业务方要的格式:

def generate_reports(exec_report, op_report, risk_report, finance_report, strat_report, model_features, reg_report):
    """生成三份交付物"""
    # 报告1:Excel报表(财务/运营用)
    with pd.ExcelWriter('credit_card_analysis.xlsx') as writer:
        exec_report.to_excel(writer, sheet_name='执行日报')
        op_report.to_excel(writer, sheet_name='运营周报')
        finance_report.to_excel(writer, sheet_name='财务月报')
        reg_report.to_excel(writer, sheet_name='监管报送')
    
    # 报告2:特征矩阵(算法团队用)
    model_features.to_csv('risk_model_features.csv', index=True)
    
    # 报告3:BI长表(可视化用)
    bi_long = pd.concat([
        exec_report
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值