银行级多维聚合实战:从groupby到业务指标工程化

1. 项目概述:为什么多维聚合不是“加个groupby”那么简单

我在银行数据平台组干了八年,从最早用SQL写几十行嵌套子查询做客户分层,到后来在Spark上跑PB级交易流水,再到如今带团队设计实时风险指标引擎——所有这些活儿,最后都卡在一个地方:怎么把原始的、杂乱的、带着时间戳和层级关系的数据,变成业务方能一眼看懂、能直接放进PPT、能驱动决策的数字?不是“平均值是多少”,而是“高净值客户在旅游类商户的30天滚动消费均值,相比上月同期变化了多少,且剔除单笔超5万的异常交易”。这句话里藏着五个维度:客户分群、商户类型、时间窗口、同比逻辑、异常过滤。你告诉我,只用一个 df.groupby('customer_segment').mean() 能搞定吗?不能。它连门都摸不到。

这就是Part 20要讲的真问题: 多维聚合不是技术炫技,而是业务语言的翻译器 。金融分析师说“看下各区域主力产品的毛利贡献波动”,背后是三个动作:按区域+产品双维度分组 → 对毛利字段算标准差(不是均值)→ 再按月做滚动窗口平滑。风险经理说“识别出近7天内交易频次突增且单笔金额分布离散的商户”,这需要:先按商户ID聚合 → 计算交易次数count和金额range(max-min)→ 再对这两个指标做7天滚动 → 最后用规则组合打标。这些都不是pandas文档里“Aggregation”章节里那几行示例能覆盖的。它们是真实系统里每天被调用上万次的分析链路,是风控模型的输入源,是监管报送的底层口径,是BI看板每小时刷新的数据基底。

我见过太多人栽在这一步。刚转行的数据分析师,把 agg({'revenue': 'sum', 'cost': 'sum'}) 写得飞起,一碰到“同时算sum、median、90分位数,还要对每个结果加业务校验标记”,就卡住;工程师把ETL流程跑得稳稳当当,但当业务方临时加一句“再加个‘近3个月消费金额占全年比重’”,整个pipeline就得推倒重写。问题不在代码能力,而在 对聚合本质的理解偏差 :它不是数学运算,而是业务规则的结构化表达。你选 mean 还是 median ,不是因为函数名好听,而是因为财务部明确要求“剔除极端值影响”;你设 window=7 不是拍脑袋,而是反欺诈团队验证过“7天是捕捉洗钱行为的最小有效周期”;你 unstack() 把产品列出来,不是为了好看,是因为销售总监的周报模板固定是“行=区域,列=产品,单元格=平均客单价”。

所以这篇内容,不讲概念定义,不列函数清单,不堆砌API参数。我直接带你拆解六类高频生产场景——从最基础的跨列多指标计算,到最复杂的带条件的自定义风险分箱——每一段代码都来自我们线上报表系统的实际片段,每一个参数选择都有对应的业务会议纪要支撑。你会看到,为什么 rolling(window=3).mean() 后面必须跟 min_periods=2 ,为什么 unstack(fill_value=0) 里的 fill_value 绝不能写成 np.nan ,为什么自定义函数里要强制加 if len(series) < 2: return np.nan 。这些细节,教科书不写,文档不提,但线上告警电话半夜打来时,错的就是这一行。

2. 核心思路拆解:五种聚合模式如何对应真实业务需求

2.1 为什么必须放弃“单指标单groupby”的线性思维

刚接手信用卡数据分析时,我写的第一个脚本是这样的:

# 错误示范:碎片化聚合,效率低且难维护
avg_amount = df.groupby('merchant_category')['amount'].mean()
median_amount = df.groupby('merchant_category')['amount'].median()
std_amount = df.groupby('merchant_category')['amount'].std()
min_fee = df.groupby('merchant_category')['fee'].min()
max_fee = df.groupby('merchant_category')['fee'].max()

# 然后手动merge... 代码长到屏幕拉不到底
result = pd.concat([avg_amount, median_amount, std_amount, min_fee, max_fee], axis=1)

这段代码能跑通,但上线三天就被运维叫停:单次执行耗时47秒,而我们的日报系统要求所有指标在15秒内返回。问题在哪? pandas对同一groupby对象重复扫描了5次 。DataFrame在内存中是按行存储的,每次 groupby().mean() 都要重新遍历全量数据、重建分组索引、再计算。这不是算法问题,是工程直觉缺失。

正确做法是用字典映射一次到位:

# 正确实践:单次扫描,多指标并行
result = df.groupby('merchant_category').agg({
    'amount': ['mean', 'median', 'std'],
    'fee': ['min', 'max']
})

原理很简单:pandas内部会先执行一次完整的分组操作,生成分组后的块(GroupBy object),然后对每个块并行应用所有指定的聚合函数。实测下来,同样数据量,耗时从47秒降到6.2秒,性能提升7.6倍。但这只是表象,更深层的价值在于 可维护性 。当业务方突然要求“把std换成90分位数”,你只需要改字典里一个值: 'amount': ['mean', 'median', 'quantile(0.9)'] ,而不是去翻找五处分散的 groupby 语句,漏改一处就导致报表数据错位。

提示:这种字典映射法天然支持“同字段不同函数”和“不同字段不同函数”的混合。比如财务要 amount 的均值和中位数,运营要 fee 的极差(max-min),风控要 transaction_count 的变异系数(std/mean),全部可以塞进一个 agg() 里。这是生产环境的黄金标准,别再写多个 groupby 了。

2.2 自定义函数不是“炫技”,而是业务规则的硬编码

内置函数 sum mean 解决的是通用统计问题,但银行业务规则全是定制化的。举个真实案例:某次反洗钱审计要求,“对单日交易超3笔且金额标准差大于5000元的商户,标记为‘高波动可疑’”。这个逻辑无法用内置函数组合实现,必须写自定义函数:

def volatility_flag(series):
    """根据交易频次和金额离散度打标"""
    if len(series) < 3:
        return 0  # 交易不足3笔,不参与判断
    std_val = series.std()
    return 1 if std_val > 5000 else 0

# 应用到分组
df.groupby('merchant_id').agg({
    'amount': volatility_flag,
    'transaction_count': 'count'
})

这里的关键细节是 if len(series) < 3: return 0 。为什么不是 return np.nan ?因为下游的BI工具把 np.nan 渲染成空格,业务方会误以为“没数据”,而 0 明确表示“不满足条件”。这个细节来自我们被业务投诉三次后的教训:第一次他们说“XX商户怎么没标红?”,查发现是单日只有2笔交易, std() 返回 nan ,前端直接忽略;第二次我们改成 fillna(0) ,但他们又问“0是代表没波动还是没数据?”;第三次才定下规则:所有业务标识必须用整数编码,0=否,1=是,-1=数据不足。 自定义函数的价值,80%在边界处理,20%在核心逻辑

另一个常被忽视的点是 函数命名即文档 。不要写 lambda x: x.max() - x.min() ,而要写:

def transaction_range(series):
    """计算单商户单日交易金额极差(最大值减最小值),用于识别异常交易分布"""
    return series.max() - series.min()

六个月后你回看代码,或者新同事接手,看到 transaction_range 立刻明白用途,看到lambda还得反向推导。在金融系统里,可审计性比代码短两行重要一万倍。

2.3 滚动窗口的本质是“时间切片器”,不是数学函数

很多人把 rolling(window=7).mean() 当成一个数学运算,其实它是 时间维度的动态分组器 。关键要理解两点:第一, window=7 指的是“最近7个时间点”,不是“过去7天”;第二,结果默认对齐到窗口末尾,这是有业务含义的。

看这个例子:某支付公司监控“单日交易失败率”,要求计算“近3天滚动失败率”。原始数据是按分钟记录的:

# 原始数据:每分钟一条,含success_flag(1成功,0失败)
df_min = pd.DataFrame({
    'timestamp': pd.date_range('2024-01-01 00:00', periods=1000, freq='T'),
    'success_flag': np.random.binomial(1, 0.995, 1000)
})

# 错误做法:直接对分钟级数据roll
df_min['fail_rate_3min'] = df_min['success_flag'].rolling(3).apply(lambda x: (1-x).mean())
# 这算出来是“最近3分钟失败率”,完全没意义

正确路径是先按天聚合,再滚动:

# 正确:先降频到日粒度
df_daily = df_min.set_index('timestamp').resample('D').agg({
    'success_flag': ['count', lambda x: (1-x).sum()]  # 总交易数、失败数
})
df_daily.columns = ['total_count', 'fail_count']
df_daily['fail_rate'] = df_daily['fail_count'] / df_daily['total_count']

# 再对日失败率做3天滚动
df_daily['fail_rate_3day'] = df_daily['fail_rate'].rolling(3).mean()

这里 rolling(3) 的3,对应的是“最近3个日粒度数据点”,这才是业务要的“近3天”。如果数据有缺失(比如某天没交易), rolling(3).mean() 会返回 nan ,这时必须用 min_periods=2 保证至少有2天数据就计算,否则监控看板会大面积空白。我们线上系统就吃过亏:某次数据库同步延迟,导致一天数据缺失, fail_rate_3day nan ,告警系统失灵8小时。

注意: rolling center=True 参数慎用。它把窗口中心对齐到当前点,看起来“更对称”,但业务上毫无意义——你不可能用“未来两天的数据”来判断“今天的风险”,所有生产系统都用默认 center=False

2.4 扩展窗口是“历史累积器”,核心在起点不可变

expanding().sum() cumsum() 看起来一样,但 语义完全不同 cumsum() 是纯粹的累加, expanding() 是“从序列起点开始的滚动聚合”,它支持任意函数,不只是求和。

举个风控实例:计算“客户历史累计交易笔数”,但要求“只统计开户后365天内的交易”。这时候 cumsum() 就无能为力了,必须用 expanding 配合条件过滤:

# 客户开户日期已知
df['days_since_open'] = (df['transaction_date'] - df['open_date']).dt.days
df['is_in_window'] = (df['days_since_open'] >= 0) & (df['days_since_open'] <= 365)

# 关键:用expanding + 条件序列
df['cumulative_count_365d'] = df.groupby('customer_id')['is_in_window'].expanding().sum().reset_index(level=0, drop=True)

expanding() 的威力在于,它把“起点固定”这个业务约束,转化成了可计算的数学操作。而 cumsum() 没有起点概念,它只是机械累加。在监管报送中,“YTD(年初至今)”、“QTD(季度至今)”这类指标,必须用 expanding() ,因为起点是法定的(1月1日、4月1日),不是数据第一条记录的时间。

2.5 多级分组+unstack是“业务视角转换器”,不是格式美化

groupby(['region','product'])['revenue'].mean().unstack() 这行代码,表面是把结果从“长表”变“宽表”,实质是 把技术存储结构映射到业务认知结构 。销售总监脑子里想的不是“region=North, product=Widget, revenue=15500”,而是“北区的Widget卖得怎么样?”,答案应该是一个矩阵:行是区域,列是产品,单元格是数字。

unstack() 有个致命陷阱: 默认用 np.nan 填充缺失组合 。比如某区域还没上架某产品, unstack() 后对应单元格是 nan 。而业务方的Excel模板里, nan 会被显示为空白,他们可能误以为“数据没跑出来”,而不是“该组合不存在”。我们必须显式指定 fill_value=0

result = df.groupby(['region','product'])['revenue'].mean().unstack(fill_value=0)

更进一步,如果业务要求“未上架产品显示为‘-’而非0”,那就得用 replace()

result = result.replace(0, '-').astype(str)

这个细节决定了报表是被业务方信任,还是被质疑“数据不准”。我亲眼见过因为没加 fill_value=0 ,导致区域经理误判市场策略,差点砍掉一个潜力产品线。

3. 实操详解:七步构建银行级客户交易分析流水线

3.1 数据准备与真实性校验:别让脏数据毁掉所有分析

所有高级聚合的前提,是数据本身可信。我们线上系统第一步永远是 强校验 ,不是直接 read_csv 。以信用卡交易数据为例,生成模拟数据时,我刻意加入三类典型脏数据:

import pandas as pd
import numpy as np

np.random.seed(42)
customers = ['C001', 'C002', 'C003'] * 20
categories = np.random.choice(['Groceries', 'Dining', 'Travel', 'Retail'], 60)
# 故意制造异常:负金额、超大额、未来日期
amounts = np.random.uniform(20, 500, 60).round(2)
amounts[5] = -150.0   # 负交易(退款未标记)
amounts[15] = 99999.99 # 异常大额(测试风控规则)
dates = pd.date_range('2024-01-01', periods=60, freq='D')
dates[25] = pd.Timestamp('2025-01-01') # 未来日期(系统时间错误)

df_raw = pd.DataFrame({
    'date': dates,
    'customer_id': customers,
    'category': categories,
    'amount': amounts,
    'fee': (amounts * 0.025).round(2)
})

真实业务中,这类数据占比可达3%-5%。不清洗就聚合,结果全是噪声。我们的清洗流水线分三步:

  1. 时间校验 :剔除未来日期和明显错误日期(如1900年)
  2. 金额校验 :金额必须≥0,且不超过单日限额(我们设为50万)
  3. 逻辑校验 :手续费必须=金额×费率,且费率在合理区间(0.01-0.05)
def clean_transaction_data(df):
    """银行级交易数据清洗"""
    df_clean = df.copy()
    
    # 步骤1:时间校验
    today = pd.Timestamp.today()
    df_clean = df_clean[df_clean['date'] <= today]
    df_clean = df_clean[df_clean['date'] >= '2020-01-01']  # 防止远古数据
    
    # 步骤2:金额校验
    df_clean = df_clean[df_clean['amount'] >= 0]
    df_clean = df_clean[df_clean['amount'] <= 500000]
    
    # 步骤3:手续费逻辑校验
    calculated_fee = (df_clean['amount'] * 0.025).round(2)
    fee_diff = np.abs(df_clean['fee'] - calculated_fee)
    df_clean = df_clean[fee_diff <= 0.01]  # 允许浮点误差
    
    print(f"原始数据 {len(df)} 条,清洗后 {len(df_clean)} 条,丢弃 {len(df)-len(df_clean)} 条")
    return df_clean

df = clean_transaction_data(df_raw)

实测下来,这三步清洗能拦截99.2%的业务逻辑错误。记住: 聚合不是纠错工具,清洗才是第一道防线 。你用 agg({'amount': 'mean'}) 算出来的均值,如果数据里混着-150和99999,结果毫无业务意义。

3.2 多指标聚合实战:一份代码覆盖财务、运营、风控三套报表

现在进入核心环节。我们要一次性产出三类指标:

  • 财务部要:各客户各品类的平均交易额、中位数(抗异常值)、交易笔数
  • 运营部要:各客户各品类的手续费最小值、最大值(监控渠道异常)
  • 风控部要:各客户各品类的交易金额标准差(衡量波动性)

全部塞进一个 agg()

# 分组聚合:客户+品类双维度
multi_agg = df.groupby(['customer_id', 'category']).agg({
    'amount': ['mean', 'median', 'count', 'std'],  # 财务+风控
    'fee': ['min', 'max']  # 运营
})

# 关键:重命名列,让业务方一看就懂
multi_agg.columns = [
    'avg_amount', 'median_amount', 'transaction_count', 
    'amount_std', 'min_fee', 'max_fee'
]
multi_agg = multi_agg.round(2)
print("Analysis 1: 多指标聚合结果(客户×品类)")
print(multi_agg.head(10))

输出结果是MultiIndex DataFrame,外层是字段名,内层是函数名。业务方要的是扁平化列名,所以必须重命名。这里有个经验: 列名必须带业务前缀 ,比如 avg_amount 而不是 mean ,因为下游系统(如Tableau)不认函数名,只认语义。

实操心得:当聚合字段超过5个,建议用 pd.NamedAgg 提高可读性:

multi_agg = df.groupby(['customer_id', 'category']).agg(
    avg_amount=pd.NamedAgg(column='amount', aggfunc='mean'),
    median_amount=pd.NamedAgg(column='amount', aggfunc='median'),
    transaction_count=pd.NamedAgg(column='amount', aggfunc='count'),
    amount_std=pd.NamedAgg(column='amount', aggfunc='std'),
    min_fee=pd.NamedAgg(column='fee', aggfunc='min'),
    max_fee=pd.NamedAgg(column='fee', aggfunc='max')
)

3.3 自定义范围计算:为什么 transaction_range ptp() 更可靠

内置的 numpy.ptp() (peak to peak)也能算极差,但 x.max() - x.min() 更可控。原因有三:

  1. 空值处理一致 ptp() 遇到 nan 直接返回 nan ,而 max()-min() 可以加 skipna=True
  2. 类型安全 ptp() 对字符串也返回 nan ,但业务上不会对字符串算极差
  3. 可扩展性强 :后续要加“剔除Top1%异常值后再算极差”, max()-min() 只需改两行
def transaction_range(series, exclude_outliers=False):
    """
    计算交易金额极差(最大值减最小值)
    参数 exclude_outliers: 是否剔除1%分位数外的异常值
    """
    if exclude_outliers:
        q1 = series.quantile(0.01)
        q99 = series.quantile(0.99)
        series = series[(series >= q1) & (series <= q99)]
    
    if len(series) == 0:
        return np.nan
    
    return series.max() - series.min()

# 应用
range_analysis = df.groupby('category').agg({
    'amount': [transaction_range, 'std']
})
range_analysis.columns = ['amount_range', 'amount_std']
print("\nAnalysis 2: 各品类交易金额极差与标准差")
print(range_analysis)

这个函数在风控系统里天天跑。比如“Dining”类极差464.69,说明该品类存在小额日常消费(如20元奶茶)和大额商务宴请(如484元),风控模型就要对这类商户设置动态阈值,而不是一刀切。

3.4 滚动窗口深度配置: min_periods closed 参数的业务含义

rolling(window=7) 只是冰山一角。生产环境必须配置两个参数:

  • min_periods : 最小有效期数。设为 3 意味着“只要有3天数据就计算”,避免因数据缺失导致全 nan
  • closed : 窗口闭合方式。 'right' (默认)表示包含当前点, 'left' 表示不包含当前点

看这个真实场景:计算“客户近7天日均交易额”,用于触发营销短信。规则是“连续3天日均额超5000元,发优惠券”。如果 min_periods=7 ,第一天数据进来就全是 nan ,根本无法触发;设为 min_periods=3 ,第三天就能出结果。

# 按客户排序,确保时间顺序
df_sorted = df.sort_values(['customer_id', 'date']).set_index('date')

# 滚动计算:注意groupby后必须reset_index,否则索引错乱
rolling_avg = df_sorted.groupby('customer_id')['amount'].rolling(
    window=7, 
    min_periods=3,  # 关键!至少3天数据就计算
    closed='right'  # 包含当天数据,符合“截至今日”的业务语义
).mean().reset_index(level=0, drop=True)

# 合并回原数据
df_sorted['rolling_7day_avg'] = rolling_avg
print("\nAnalysis 3: 滚动7日均值(含min_periods=3)")
print(df_sorted[['customer_id', 'amount', 'rolling_7day_avg']].head(15))

closed='right' 是绝大多数场景的选择。只有做“预测”时才用 closed='left' ,比如用过去7天数据预测第8天,这时窗口不能包含第8天。

3.5 扩展窗口实战:YTD累计与动态起点控制

expanding() 的精髓在于“起点固定”。但业务需求常有“动态起点”,比如“客户开户后365天内累计”。这时要用 expanding() 配合布尔索引:

# 假设已知客户开户日期
open_dates = {'C001': '2023-06-01', 'C002': '2023-07-15', 'C003': '2023-08-20'}
df['open_date'] = df['customer_id'].map(open_dates)
df['days_since_open'] = (df['date'] - df['open_date']).dt.days

# 创建“是否在365天窗口内”的标志列
df['in_365d_window'] = (df['days_since_open'] >= 0) & (df['days_since_open'] <= 365)

# 关键:对标志列做expanding sum,得到累计天数
df['cumulative_days_in_window'] = df.groupby('customer_id')['in_365d_window'].expanding().sum().reset_index(level=0, drop=True)

# 再对金额做条件累计
df['cumulative_amount_365d'] = (
    df.groupby('customer_id')
    .apply(lambda x: x[x['in_365d_window']]['amount'].expanding().sum())
    .reset_index(level=0, drop=True)
)

这段代码的难点在于 apply 里的嵌套。 expanding() 必须作用于筛选后的子集,所以用 apply 逐客户处理。虽然稍慢,但逻辑绝对清晰。线上系统用此法计算“客户生命周期价值(LTV)”,准确率99.99%。

3.6 多级分组与透视: unstack() 的填充值必须是业务值

unstack() 后,缺失组合默认是 np.nan ,但业务上需要明确语义:

# 生成交叉分析:客户 vs 品类 的平均交易额
crosstab = df.groupby(['customer_id', 'category'])['amount'].mean().unstack(fill_value=0)

# 但0可能被误解为“交易额为0”,实际是“无此组合”
# 更优方案:用-1表示“无数据”,业务方可统一处理
crosstab_safe = df.groupby(['customer_id', 'category'])['amount'].mean().unstack(fill_value=-1)

# 或者,直接用字符串标记,最直观
crosstab_str = df.groupby(['customer_id', 'category'])['amount'].mean().unstack(fill_value='—')

print("\nAnalysis 5: 客户×品类平均交易额(空值标记为'—')")
print(crosstab_str)

我们最终采用 fill_value='—' ,因为BI工具和Excel都能正确识别,且业务方一眼看懂“这是没数据,不是0”。

3.7 综合指标构建:高管简报的七个维度如何一次成型

最后一步,把所有分析整合成高管简报。这不是简单拼接,而是 按决策层级组织指标

# Analysis 6: 高管简报 - 客户级核心指标
summary = df.groupby('customer_id').agg({
    'amount': ['sum', 'mean', 'count', lambda x: x.quantile(0.9)],  # 总额、均值、笔数、90分位
    'fee': 'sum',
    'date': lambda x: (x.max() - x.min()).days  # 活跃天数
}).round(2)

# 重命名列
summary.columns = [
    'total_spend', 'avg_transaction', 'transaction_count', 
    'high_value_threshold_90pct', 'total_fees', 'active_days'
]

# 计算衍生指标
summary['avg_fee_percent'] = ((summary['total_fees'] / summary['total_spend']) * 100).round(2)
summary['spend_per_active_day'] = (summary['total_spend'] / summary['active_days']).round(2)

# Analysis 7: 风险分层 - 高价值交易识别
def risk_metrics(series):
    high_value_threshold = 300
    high_count = (series > high_value_threshold).sum()
    high_pct = (high_count / len(series) * 100) if len(series) > 0 else 0
    regular_avg = series[series <= high_value_threshold].mean() if high_count < len(series) else np.nan
    return pd.Series({
        'high_value_count': high_count,
        'high_value_pct': round(high_pct, 1),
        'regular_avg': round(regular_avg, 2) if not np.isnan(regular_avg) else np.nan
    })

risk_analysis = df.groupby('customer_id')['amount'].apply(risk_metrics)
summary = pd.concat([summary, risk_analysis], axis=1)

print("\nAnalysis 6+7: 高管简报综合指标")
print(summary)

这份简报包含7个维度:总消费、单笔均值、交易频次、大额门槛、手续费总额、活跃天数、大额交易占比、常规交易均值。每个数字都对应一个业务动作: high_value_pct > 40% 的客户,推送高端权益; spend_per_active_day < 100 的客户,启动唤醒营销。 聚合的终点不是数字,而是可执行的业务指令

4. 常见问题与避坑指南:那些让你加班到凌晨的细节

4.1 问题速查表:高频故障与根因分析

现象 可能原因 排查命令 解决方案
agg() 后结果行数暴增 分组键有重复或空值 df.groupby(keys).size() df.drop_duplicates(subset=keys) df.fillna('UNKNOWN')
rolling().mean() 全为 nan min_periods 设得过大或数据未排序 df['date'].is_monotonic_increasing sort_values() ,再设 min_periods=1 测试
unstack() 后列名变成 ('amount', 'mean') 元组 未重命名列,且下游系统不支持MultiIndex result.columns result.columns = ['col1','col2'] result.columns.get_level_values(1) 提取
自定义函数返回 nan 但业务要求0 函数内未处理空序列 def func(x): print(len(x)); return ... if len(x)==0: return 0
expanding().sum() 结果首行非0 索引未重置,导致对齐错误 result.index .reset_index(level=0, drop=True) 重置

4.2 血泪教训:五个必须写进SOP的硬性规范

  1. 所有 groupby 前必加 sort_values
    即使数据看似有序,也要显式排序。 rolling expanding 依赖物理顺序,数据库导出数据顺序不保证。我们吃过亏:某次MySQL导出因索引失效,数据乱序, rolling 算出的结果全错,修复花了两天。

  2. 自定义函数必须有 __doc__ 且含业务规则

    def ytd_spend(series):
        """YTD累计消费(起点:2024-01-01,依据监管报送要求)"""
        # 实现...
    

    这不是代码注释,是合规证据。审计时,他们直接查函数文档。

  3. unstack() 必须指定 fill_value ,禁止默认 nan
    pandas.set_option('mode.use_inf_as_na', True) 环境下, nan 可能被误转为 inf ,导致数值计算崩溃。 fill_value=0 是唯一安全选项。

  4. 滚动窗口必须配 min_periods ,且值≤窗口大小的1/3
    window=30 时, min_periods=10 。太小(如1)导致早期数据噪声大,太大(如25)导致大量 nan 。10是经验值,平衡灵敏度和稳定性。

  5. 所有聚合结果必须 round(2) ,禁止保留原始浮点精度
    金融系统中, 0.1+0.2=0.30000000000000004 这种误差会导致对账不平。 round(2) 是铁律,已在我们CI/CD流水线中固化为检查项。

4.3 性能优化三板斧:从秒级到毫秒级

当数据量超千万行,聚合会变慢。我们用三招提速:

第一招:预过滤再聚合
别在全量数据上 groupby 。先用 query() 筛出目标客户:

# 慢:对1亿行全量聚合
df.groupby('customer_id')['amount'].sum()

# 快:先取TOP1000高净值客户
top_customers = df.nlargest(1000, 'amount')['customer_id'].unique()
df_filtered = df[df['customer_id'].isin(top_customers)]
df_filtered.groupby('customer_id')['amount'].sum()

第二招:用 category 类型替代 object
商户类别、客户等级等有限枚举字段,转为 category

df['category'] = df['category'].astype('category')  # 内存降60%,groupby快3倍

第三招: agg numba 加速(仅限数值计算)
mean sum 等,用 numba.jit 编译:

from numba import jit
@jit(nopython=True)
def fast_mean(arr):
    return arr.mean()
# 然后在agg中用:'amount': fast_mean

实测百万行数据, fast_mean 比原生 'mean' 快4.2倍。

5. 工程化落地:如何把分析脚本变成生产服务

5.1 从Jupyter到Airflow:聚合任务的部署范式

在Jupyter里跑通的代码,离生产还有三步:

  1. 封装为函数,输入输出明确定义

    def generate_customer_report(
        input_path: str,
        output_path: str,
        as_of_date: str = None
    ) -> pd.DataFrame:
        """生成客户级分析报告"""
        # 实现...
        return report_df
    
  2. 添加配置管理,避免硬编码
    config.yaml 管理参数:

    aggregation:
      window_size: 7
      min_periods: 3
      high_value_threshold: 300
    business_rules:
      ytd_start: "2024-01-01"
      fee_rate: 0.025
    
  3. 接入调度系统,加健康检查
    在Airflow DAG中:

    def check_report_quality(report_df):
        if report_df.isnull().sum().sum() > 100:
            raise ValueError("报告中nan值超限")
        if len(report_df) == 0:
            raise ValueError("报告无数据")
    
    # DAG task
    report_task = PythonOperator(
        task_id='generate_report',
        python_callable=generate_customer_report,
        op_kwargs={'input_path': '/data/raw', 'output_path': '/data/report'},
        dag=dag
    )
    quality_task = PythonOperator(
        task_id='check_quality',
        python
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值