多维聚合实战:滚动窗口与业务逻辑嵌入的生产级实现
1. 项目概述:为什么多维聚合不是“加个groupby”就能搞定的事
我在银行数据平台组干了八年,从最早用SQL写几十行嵌套子查询做客户分层,到现在每天在Jupyter里调试pandas的agg链式调用,踩过的坑比写的代码还多。今天这篇讲的“多维聚合”,绝不是教你怎么把df.groupby('col').sum()敲得更顺——那是实习生第一天就能学会的。真正卡住业务分析师、拖慢风控模型上线、让报表系统半夜报警的,永远是那些看似简单、实则暗藏玄机的聚合需求:比如“请按城市+商户类型+交易时段,统计过去30天内每类客户的平均单笔金额、中位数、标准差,同时计算该区间内最大单笔与最小单笔的差值,并对每个组合输出滚动7日均值和累计消费总额”。你试试看,这一句话里埋了多少个技术雷区?我见过太多团队把这类需求拆成七八个独立脚本,中间用CSV临时文件接力,跑一次要23分钟,出错重跑就得从头再来。
核心关键词就三个:多维聚合、滚动窗口、业务逻辑嵌入。它们共同指向一个现实:真实世界的分析场景,从来不是单维度切片,而是立体交叉;从来不是静态快照,而是带时间纵深的动态视图;更关键的是,所有计算必须承载可审计、可复现、可解释的业务规则。比如“高价值交易”的定义,在信用卡风控里可能是单笔≥300元且发生在凌晨2点至5点,而在跨境支付场景下,可能变成单笔≥等值5000美元且收款方注册地为高风险司法管辖区——这种逻辑没法靠np.mean()内置函数解决,必须亲手把它焊进聚合流程里。
这篇文章适合三类人:第一类是刚转行做数据分析的新人,正被老板一句“把客户按地区和产品线分组算几个指标”搞得无从下手;第二类是已有两年经验的工程师,发现现有聚合脚本在数据量涨到千万级后开始卡顿,但又说不清瓶颈在哪;第三类是业务部门的数据接口人,天天被风控、运营、财务轮番追问“为什么这个数和上个月对不上”,却找不到聚合逻辑的源头。别急,我们不讲虚的,直接从银行最真实的信用卡交易分析场景切入,把每一步操作背后的“为什么”掰开揉碎——比如为什么unstack()之后必须加fill_value=0,为什么滚动窗口的min_periods参数设成3而不是1,为什么自定义函数里要显式处理空序列。这些细节,才是决定你产出能否被业务方签字确认的关键。
2. 多维聚合的核心设计思路:从“能跑通”到“能扛住生产环境”
2.1 为什么拒绝“先group再merge”的老路子?
很多新手遇到多列聚合需求,第一反应是拆解:先按商户类型算一次均值,再按地区算一次标准差,最后用pd.merge()拼起来。这在100行数据里当然没问题,但放到银行的真实场景里,就是灾难的开始。我去年接手过一个清算系统报表,原始脚本就是这么写的——它要聚合全国36个分行、427个支行、18类商户的每日交易数据,共1200万行。每次执行,pandas会为每个groupby操作单独构建索引、排序、分组,三次独立操作下来,内存峰值冲到16GB,CPU占用率持续98%,跑完要47分钟。更糟的是,当某次上游数据源漏传了“教育类”商户的记录,第一个groupby返回空结果,后续merge直接报KeyError,整个任务失败,但错误日志只显示“合并失败”,没人知道是哪个维度缺失了数据。
而agg()字典映射方案,本质是单次分组、多路并行计算。pandas底层会一次性完成分组索引构建,然后将不同列的聚合函数分配到同一分组块上并行执行。就像工厂流水线:传送带(分组索引)只走一遍,但上面同时有三台机器(mean、median、std)在各自加工不同零件(不同列)。实测对比:同样1200万行数据,拆解式脚本耗时47分钟,内存峰值16GB;改用agg({'amount':['mean','median'],'fee':['min','max']})后,耗时压到6分12秒,内存峰值稳定在3.2GB。这不是语法糖,是计算范式的升级。
提示:当你看到聚合脚本执行时间超过10分钟,第一反应不该是加服务器,而是检查是否在用多个独立
groupby。生产环境里,每一次额外的groupby都是对资源的奢侈浪费。
2.2 分层列名(MultiIndex)不是bug,是你的数据资产目录
运行df.groupby(['region','product']).agg({'revenue':'mean'}).unstack()后,你看到的输出是:
product Gadget Widget region North 12000.0 15500.0 South 13750.0 18000.0很多人第一反应是:“这列名怎么带括号?太丑了,赶紧扁平化!”——停!这个看似麻烦的分层结构,恰恰是pandas给你埋下的最强扩展性伏笔。想象一下,如果未来业务方要求增加“毛利率”指标,你只需在agg()里加一项'profit_margin': 'mean',输出自动变成三层列:[('revenue','mean'), ('profit_margin','mean')]。而如果你提前用columns.map('_'.join)强行扁平成revenue_mean、profit_margin_mean,下次加新指标就得手动改所有下游代码,包括BI工具里的字段映射、邮件模板里的变量名、甚至Excel导出的列标题。
真正的生产级做法是:拥抱分层,控制展平时机。在数据处理管道内部,全程保持MultiIndex结构,直到最后一步导出给业务方时,才用result.columns = ['_'.join(col).strip() for col in result.columns.values]统一格式化。这样既保证了中间计算的灵活性,又满足了交付物的易读性。我经手的所有银行报表系统,都强制要求ETL脚本输出保留原始分层结构,BI工程师反馈说,他们配置仪表盘时,拖拽字段的准确率从73%提升到99%,因为再也不用猜“avg_revenue”到底对应的是均值还是中位数了。
2.3 “多维”不等于“堆砌维度”,维度选择是业务决策
groupby(['region','product','channel','time_period'])看起来很全面,但实际中,维度越多,结果集越稀疏。我处理过一个案例:某银行想分析“手机银行APP渠道下,华东地区各城市、各年龄段客户的月均交易频次”。他们最初设了4个维度,结果生成的DataFrame有21万行,其中83%的单元格是NaN——因为很多小城市根本没有足够样本覆盖所有年龄段。最终报表根本无法呈现,业务方抱怨“数据太多反而看不到重点”。
解决方案不是删维度,而是用业务逻辑做预过滤。我们和风控部确认:单城市月交易客户数低于500人的,不纳入分析;年龄段按监管要求必须分5段(18-25,26-35,36-45,46-55,55+),但若某段人数<50,则合并到相邻段。调整后,维度精简为['region','city_group','age_band'](city_group是按客户数聚类的三级城市分组),结果集压缩到1.2万行,有效数据占比91%。记住:多维聚合的终极目标不是穷尽所有组合,而是找到业务可行动的最小颗粒度。那个“华东-上海-26-35岁”的组合,如果能触发精准营销策略,它就是黄金维度;如果只是躺在报表里吃灰,再精细也是资源浪费。
3. 核心实操要点:从代码片段到生产级实现的七道关卡
3.1 多列聚合:如何避免“列名冲突”和“数据类型错乱”
看这段典型代码:
result = df.groupby('merchant_category').agg({ 'transaction_amount': ['mean','median'], 'processing_fee': ['min','max'] })表面看没问题,但生产环境里,transaction_amount列如果是object类型(比如混入了字符串“N/A”),mean()会直接报错TypeError: unsupported operand type(s) for +: 'float' and 'str'。而median()在遇到非数值时会静默返回NaN,导致你拿到一个“部分成功”的结果,却浑然不觉。
实操要点一:聚合前必做类型强校验
# 在agg前插入校验 def safe_numeric_cast(series, col_name): try: # 尝试转为float,强制将非数字转为NaN return pd.to_numeric(series, errors='coerce') except Exception as e: raise ValueError(f"列 '{col_name}' 类型转换失败: {e}") # 对目标列批量处理 for col in ['transaction_amount', 'processing_fee']: df[col] = safe_numeric_cast(df[col], col)实操要点二:处理分层列名的“命名冲突”当两个不同列都用了'mean'函数,比如{'amount':'mean', 'fee':'mean'},输出列名会是('amount','mean')和('fee','mean'),看着没问题。但如果某天你加了'count',而原始数据里恰好有count列,pandas会报ValueError: column count already exists。安全做法是显式指定聚合函数别名:
result = df.groupby('merchant_category').agg({ 'transaction_amount': [('amt_mean','mean'), ('amt_median','median')], 'processing_fee': [('fee_min','min'), ('fee_max','max')] }) # 输出列名变为:amt_mean, amt_median, fee_min, fee_max —— 绝对无歧义3.2 自定义聚合函数:为什么lambda只能用于调试,不能上生产
原文示例中的lambda x: x.max() - x.min()写起来痛快,但上线后会出大问题。去年我们有个实时风控模块,就因用了lambda函数,导致线上服务重启后所有聚合逻辑失效——因为lambda函数无法被pickle序列化,而我们的分布式任务调度器(Airflow)需要序列化函数到工作节点。更隐蔽的问题是:lambda没有__name__属性,当聚合报错时,日志里只显示<lambda>,你根本不知道是哪个业务规则崩了。
生产级替代方案:带完整契约的命名函数
def transaction_range(series, threshold=0): """ 计算交易金额范围(最大值-最小值) :param series: pandas.Series,交易金额序列 :param threshold: 过滤阈值,低于此值的交易视为无效(如手续费为0的测试交易) :return: float,有效交易的范围值 :raises ValueError: 当有效交易数少于2时抛出异常(range需至少2个点) """ if not isinstance(series, pd.Series): raise TypeError("输入必须是pandas.Series") valid_series = series[series > threshold] if len(valid_series) < 2: raise ValueError(f"有效交易数({len(valid_series)})不足2个,无法计算range") return valid_series.max() - valid_series.min() # 使用时 result = df.groupby('merchant_category').agg({ 'transaction_amount': [('range', transaction_range)] })这个函数解决了四个生产痛点:① 类型检查防止上游数据污染;② 业务阈值参数化,避免硬编码;③ 异常信息明确,便于监控告警;④ docstring可被Sphinx自动提取生成API文档。我们团队规定:所有上生产的自定义聚合函数,必须包含类型检查、参数校验、异常说明三要素,缺一不可。
3.3 滚动窗口:为什么window=3不是“取最近3条”,而是“取时间序最近3条”
这是新手最容易栽跟头的地方。看原文代码:
df_ts['rolling_avg'] = df_ts.groupby('category')['daily_revenue'].rolling(window=3).mean()如果df_ts的索引是date,且date是datetime64类型,那rolling(window=3)确实按时间顺序取最近3天。但如果索引是默认的RangeIndex(0,1,2...),或者date列没设为索引,rolling()会按物理行序取3行,完全无视日期先后。我亲眼见过一个案例:某分行导出的交易数据,因Excel保存时日期列被转成文本,pd.to_datetime()失败后全变NaT,rolling()就按行号滚动,结果把上周五和本周一的数据强行算在一起,风控模型误判了237笔“异常高频交易”。
实操铁律:滚动窗口前必做三件事
- 确认索引是时间类型:
assert isinstance(df.index, pd.DatetimeIndex), "索引必须是DatetimeIndex" - 检查数据是否按时间排序:
if not df.index.is_monotonic_increasing: df = df.sort_index() - 用
min_periods代替window做兜底:rolling(window=3, min_periods=2)表示:只要凑够2个有效值就计算,避免全NaN。生产环境里,我们永远用min_periods=round(window*0.7),比如window=7就设min_periods=5,确保数据轻微缺失时不中断。
3.4 扩展窗口:cumsum()不是“累加”,而是“从起点到当前点的全量聚合”
expanding().sum()看起来像cumsum(),但二者有本质区别。cumsum()是纯粹的数学累加,而expanding()是窗口函数,支持任意聚合方法。比如expanding().std()计算的是“从第一条到当前条的标准差”,这对监控非常关键——当某天标准差突然飙升,说明客户行为基线正在漂移。
但陷阱在于:expanding()默认从第1行开始计算,而第1行的std()是未定义的(单个值无标准差),会返回NaN。很多脚本直接忽略,导致首日指标为空。正确做法是用min_periods=2强制从第2行起算:
# 错误:首日NaN,且无法解释 df['cum_std'] = df['amount'].expanding().std() # 正确:首日仍为NaN,但第2行起有值,且逻辑清晰 df['cum_std'] = df['amount'].expanding(min_periods=2).std()更进一步,银行合规要求所有累计指标必须标注“计算起始日”。我们在生产脚本里强制添加元数据:
df.attrs['expanding_start_date'] = df.index.min().strftime('%Y-%m-%d')这样当审计人员查数据时,一眼就能看到“该累计值从2024-01-01开始计算”,避免了无数扯皮。
3.5 多级分组与unstack:为什么unstack()后必须加fill_value=0
unstack()把行索引的一层转为列,但默认遇到缺失组合会填NaN。在银行场景里,NaN和0有天壤之别:NaN代表“数据缺失,不可信”,0代表“该组合真实发生0次交易”。如果直接导出含NaN的报表,业务方会质疑“是不是ETL漏数了?”,而填0则明确传递“此处无交易”的业务事实。
但填0也有风险:如果原始数据里真有NaN值(比如某笔交易金额为空),unstack()后也会变成NaN,此时填0就篡改了事实。所以必须分两步走:
# 第一步:区分“缺失组合”和“真实NaN” # 先用fillna()把原始数据的空值标为特殊标记 df_clean = df.fillna({'amount': -999999}) # 用极值标记空值 # 第二步:分组聚合时过滤掉标记值 result = df_clean.groupby(['region','product'])['amount'].agg( lambda x: x[x != -999999].mean() # 只对有效值计算 ).unstack(fill_value=0) # 缺失组合填0,真实空值已在上步过滤这套组合拳,确保了报表里每一个0都是业务意义上的“零交易”,而非技术意义上的“数据丢失”。
3.6 端到端实战:银行信用卡分析流水线的七个生死关
原文的End-to-End示例很好,但生产环境要补足七个致命细节:
关卡1:数据生成的“业务真实性”原文用np.random.uniform(20,500,60)生成金额,但真实信用卡交易有强分布特征:小额高频(奶茶25元)、中额常态(超市328元)、大额低频(机票4800元)。我们用分位数采样法模拟:
# 基于真实交易分布:20%小额(<100), 65%中额(100-1000), 15%大额(>1000) np.random.seed(42) amounts = np.concatenate([ np.random.uniform(20, 99, size=int(60*0.2)), np.random.uniform(100, 999, size=int(60*0.65)), np.random.uniform(1000, 5000, size=int(60*0.15)) ])关卡2:时间排序的“绝对强制”原文df_sorted = df_transactions.sort_values('date').set_index('date'),但没处理重复日期。真实交易中,同一秒可能有上千笔并发。必须加sort_index()二次保险:
df_sorted = (df_transactions .sort_values(['date','customer_id']) # 先按日期,再按客户ID稳定排序 .set_index('date') .sort_index()) # 再按索引排序,确保单调关卡3:滚动窗口的“业务对齐”原文rolling(window=7)是自然日,但银行风控看的是交易日历(剔除节假日)。我们维护一张trading_calendar.csv,用pd.offsets.CustomBusinessDay替换:
from pandas.tseries.offsets import CustomBusinessDay calendar = pd.read_csv('trading_calendar.csv', parse_dates=['date']) cbd = CustomBusinessDay(calendar=calendar['date'].dt.date.unique()) df_sorted['rolling_7day_avg'] = df_sorted.groupby('customer_id')['amount'].rolling( window=7, min_periods=5, closed='both' ).mean().reset_index(level=0, drop=True)关卡4:自定义函数的“空值免疫”原文risk_metrics函数没处理series全为NaN的情况。生产版必须加:
def risk_metrics(series, high_value_threshold=300): if series.isna().all(): return pd.Series({'high_value_count': 0, 'high_value_pct': 0.0, 'regular_avg': 0.0}) # 后续逻辑...关卡5:列名扁平化的“业务语义”原文summary.columns = ['total_spend','avg_transaction',...]是硬编码。我们用配置驱动:
AGG_CONFIG = { 'amount': {'sum': 'total_spend', 'mean': 'avg_transaction', 'count': 'txn_count'}, 'fee': {'sum': 'total_fees'} } # 动态生成列名映射 new_cols = [] for col, funcs in AGG_CONFIG.items(): for func, alias in funcs.items(): new_cols.append((col, func, alias)) # 扁平化时按配置映射关卡6:内存优化的“分块聚合”当数据超亿行,groupby().agg()会OOM。我们用chunksize分批:
def chunked_agg(file_path, chunk_size=100000): results = [] for chunk in pd.read_csv(file_path, chunksize=chunk_size): chunk_result = chunk.groupby(['customer_id','category']).agg({...}) results.append(chunk_result) return pd.concat(results).groupby(level=[0,1]).sum() # 二次聚合关卡7:结果验证的“黄金快照”每次脚本更新,必须和旧版结果比对。我们用deepdiff库做逐字段校验:
from deepdiff import DeepDiff old_result = pd.read_parquet('gold_snapshot_v1.parquet') new_result = run_new_pipeline() diff = DeepDiff(old_result, new_result, ignore_order=True) if diff: raise RuntimeError(f"聚合逻辑变更导致结果差异: {diff}")4. 实操过程详解:从原始交易表到高管仪表盘的完整链路
4.1 数据准备:银行级交易表的“脏数据预处理”
真实银行的交易表,远比示例复杂。我们以某股份制银行的card_transaction_raw表为例,它有67列,日增量2300万行。聚合前必须过三关:
第一关:字段清洗(Field Sanitization)
transaction_amount: 存在'NULL'字符串、'-'占位符、'0.000'(实际为0但精度异常)merchant_category: 有'RETAIL '(尾部空格)、'Dining '(大小写混用)、'E-COMMERCE'(需映射为'ECOMMERCE')date: 部分记录为'20240101'(YYYYMMDD格式字符串)
标准化脚本:
def clean_transaction_data(df): # 金额列:转数值,异常值设为NaN df['transaction_amount'] = pd.to_numeric( df['transaction_amount'].replace(['NULL', '-', ''], np.nan), errors='coerce' ).round(2) # 商户类型:去空格、统一大小写、映射别名 category_map = {'E-COMMERCE': 'ECOMMERCE', 'ONLINE': 'ECOMMERCE'} df['merchant_category'] = (df['merchant_category'] .str.strip() .str.upper() .map(lambda x: category_map.get(x, x))) # 日期列:强制转datetime,无效值设为NaT df['date'] = pd.to_datetime(df['date'], format='mixed', errors='coerce') return df # 应用清洗 df_clean = clean_transaction_data(df_raw) print(f"清洗后数据量: {len(df_clean)}, 金额空值率: {df_clean['transaction_amount'].isna().mean():.2%}")第二关:业务过滤(Business Filtering)银行只分析“已清算”交易,排除:
status != 'SETTLED'(未清算、撤单、失败)transaction_type == 'REFUND'(退款,需单独建模)amount <= 0(理论上不应存在,但上游系统偶发错误)
df_filtered = df_clean[ (df_clean['status'] == 'SETTLED') & (df_clean['transaction_type'] != 'REFUND') & (df_clean['transaction_amount'] > 0) ].copy()第三关:衍生字段(Feature Engineering)
hour_of_day: 从date提取小时,用于分时段分析is_weekend: 判断是否周末(风控策略不同)amount_tier: 按监管要求分档(<100,100-1000,>1000)
df_filtered['hour_of_day'] = df_filtered['date'].dt.hour df_filtered['is_weekend'] = df_filtered['date'].dt.dayofweek >= 5 df_filtered['amount_tier'] = pd.cut( df_filtered['transaction_amount'], bins=[0, 100, 1000, float('inf')], labels=['SMALL', 'MEDIUM', 'LARGE'] )至此,我们得到df_ready,这才是聚合的真正起点。跳过这三步直接groupby,就像没洗菜就下锅——味道全毁。
4.2 多维聚合实施:七种业务场景的代码实现
现在,我们基于df_ready,实现银行真实的七类聚合需求。每段代码都附带业务背景、技术要点、避坑提示。
场景1:分行行长看板——按城市+商户类型统计(基础多维)
# 业务背景:华东分行王行长要对比上海、杭州、南京三市的餐饮、零售商户表现 # 技术要点:用query()预过滤,避免全量分组;unstack()后fill_value=0 result_city_merchant = ( df_ready.query("city in ['Shanghai','Hangzhou','Nanjing'] and merchant_category in ['DINING','RETAIL']") .groupby(['city','merchant_category']) .agg({ 'transaction_amount': [('avg_amt','mean'), ('med_amt','median'), ('std_amt','std')], 'transaction_count': [('cnt','sum')] }) .unstack(fill_value=0) ) # 避坑提示:std()对单值返回NaN,需用min_periods=2 result_city_merchant.columns = ['_'.join(col).strip() for col in result_city_merchant.columns.values]场景2:风控模型输入——滚动30日欺诈特征(时间窗口)
# 业务背景:反欺诈模型需要每个客户最近30天的交易频次波动率 # 技术要点:用resample()替代rolling(),按日重采样更稳定;波动率用变异系数(CV) df_daily = df_ready.set_index('date').groupby('customer_id')['transaction_amount'].resample('D').count().reset_index(name='daily_txn_cnt') # 计算滚动30日CV:std/mean,规避量纲影响 df_daily['cv_30d'] = ( df_daily.groupby('customer_id')['daily_txn_cnt'] .rolling(window=30, min_periods=15) .apply(lambda x: x.std() / x.mean() if x.mean() != 0 else 0, raw=True) ) # 避坑提示:raw=True提升性能;CV分母为0时返回0,避免Inf传播场景3:运营活动效果——同期群分析(Cohort Analysis)
# 业务背景:评估“新客首刷返现”活动,看首刷当月vs次月的留存交易 # 技术要点:用first()找首刷日,计算月份差;避免用date.month直接减(跨年错误) df_cohort = df_ready.copy() df_cohort['first_txn_date'] = df_cohort.groupby('customer_id')['date'].transform('min') df_cohort['cohort_month'] = df_cohort['first_txn_date'].dt.to_period('M') df_cohort['txn_month'] = df_cohort['date'].dt.to_period('M') df_cohort['month_diff'] = (df_cohort['txn_month'] - df_cohort['cohort_month']).apply(lambda x: x.n) # 聚合:各同期群在各月的平均交易额 cohort_result = ( df_cohort.groupby(['cohort_month','month_diff']) .agg({'transaction_amount': 'mean'}) .unstack(fill_value=0) )场景4:监管报送——按客户风险等级分层(业务逻辑嵌入)
# 业务背景:向银保监报送“高风险客户交易集中度”,定义:近3月交易额TOP10%且单笔>5000元占比>30% def risk_concentration(series): # series是单个客户的交易额序列 if len(series) < 10: # 样本太少,不参与评级 return 0.0 top10_threshold = np.percentile(series, 90) high_value_ratio = (series > 5000).sum() / len(series) return 1.0 if (series.max() > top10_threshold) and (high_value_ratio > 0.3) else 0.0 # 应用:先按客户聚合,再应用自定义函数 df_customer = df_ready.groupby('customer_id')['transaction_amount'].apply(risk_concentration).reset_index(name='is_high_risk') # 再按地区统计高风险客户占比 result_regulatory = df_customer.merge(df_ready[['customer_id','region']], on='customer_id').groupby('region')['is_high_risk'].mean()场景5:BI自助分析——动态维度切换(灵活聚合)
# 业务背景:BI平台允许用户拖拽任意维度组合,需通用聚合函数 def dynamic_aggregation(df, group_cols, agg_config): """ :param group_cols: list, 如['region','merchant_category'] :param agg_config: dict, 如{'transaction_amount':['mean','sum'], 'fee':['mean']} """ # 安全校验 missing_cols = set(group_cols + list(agg_config.keys())) - set(df.columns) if missing_cols: raise ValueError(f"缺失字段: {missing_cols}") # 构建agg字典,自动添加别名 agg_dict = {} for col, funcs in agg_config.items(): for func in funcs: alias = f"{col}_{func}" agg_dict[col] = agg_dict.get(col, []) + [(alias, func)] result = df.groupby(group_cols).agg(agg_dict) result.columns = ['_'.join(col) for col in result.columns] return result # 使用示例 result = dynamic_aggregation( df_ready, group_cols=['city','amount_tier'], agg_config={'transaction_amount':['mean','std'], 'transaction_count':['sum']} )场景6:实时大屏——增量聚合(Streaming Aggregation)
# 业务背景:总行大屏需实时显示“当前小时各商户类型交易额”,延迟<30秒 # 技术要点:用stateful processing,维护滚动状态 class HourlyAggregator: def __init__(self): self.hourly_state = {} # {hour_key: {merchant: amount_sum}} def update(self, row): hour_key = row['date'].floor('H').strftime('%Y-%m-%d %H:00:00') merchant = row['merchant_category'] amount = row['transaction_amount'] if hour_key not in self.hourly_state: self.hourly_state[hour_key] = {} self.hourly_state[hour_key][merchant] = ( self.hourly_state[hour_key].get(merchant, 0) + amount ) def get_current(self): # 返回最新一小时数据 latest_hour = max(self.hourly_state.keys()) return pd.DataFrame(list(self.hourly_state[latest_hour].items()), columns=['merchant_category','hourly_amount']) # 生产中,此对象部署在Flink或Spark Structured Streaming中场景7:模型训练数据——负采样平衡(ML Ready Aggregation)
# 业务背景:训练欺诈检测模型,需对正常交易做负采样,使正负样本1:1 # 技术要点:先分组统计正样本数,再按比例采样负样本 fraud_labels = df_ready['is_fraud'] # 0 or 1 positive_count = fraud_labels.sum() negative_sample_size = int(positive_count * 1.5) # 负样本1.5倍 # 分层负采样:按商户类型保持分布一致 df_negative = df_ready[fraud_labels == 0] sampled_negative = df_negative.groupby('merchant_category', group_keys=False).apply( lambda x: x.sample(n=min(len(x), int(negative_sample_size * len(x) / len(df_negative))), random_state=42) ) # 合并正负样本,供模型训练 df_ml_ready = pd.concat([ df_ready[fraud_labels == 1], sampled_negative ]).sort_values('date')4.3 性能调优:从“能跑”到“飞快”的五个关键动作
即使代码逻辑完美,数据量上来后仍可能慢如蜗牛。以下是我在银行生产环境验证有效的五大调优动作:
动作1:用categorical类型替代object商户类型、地区等枚举字段,用astype('category')可降内存70%,提速3倍:
df_ready['merchant_category'] = df_ready['merchant_category'].astype('category') df_ready['region'] = df_ready['region'].astype('category')动作2:预聚合(Pre-aggregation)对高频查询维度,提前计算宽表:
# 每日执行一次,生成customer_daily_summary customer_daily = df_ready.groupby(['customer_id','date']).agg({ 'transaction_amount': ['sum','count','mean'], 'fee': 'sum' }).round(2) customer_daily.columns = ['daily_spend','daily_txn_cnt','daily_avg_amt','daily_fee'] customer_daily.to_parquet('customer_daily_summary.parquet', index=True)后续分析直接读取此宽表,避免每次重算。
动作3:分区读取(Partitioned Reading)用pyarrow读Parquet分区表,只加载必要分区:
# 按日期分区存储 df_partitioned = pd.read_parquet( 's3://bucket/transactions/', filters=[('date', '>=', '2024-01-01'), ('date', '<=', '2024-01-31')] )动作4:Dask分布式聚合单机扛不住时,无缝切到Dask:
import dask.dataframe as dd df_dask = dd.read_parquet('s3://bucket/transactions/') result = (df_dask.groupby(['region','merchant_category']) .agg({'transaction_amount': ['mean','std']})