1. 项目概述:为什么多维聚合不是“加个groupby”就能搞定的事
我在银行风控部门干了八年,前五年写SQL,后三年转Python做分析平台。最常被业务方甩过来的一句话是:“把客户按地区、产品线、交易类型分组,再算出平均值、中位数、最大最小值、标准差,最好还能带个滚动均值和累计值。”听起来就三行代码?我当年也是这么想的——直到第一次在生产环境跑完一个含5层嵌套groupby+unstack+rolling的报表,服务器内存直接飙到98%,下游BI工具卡死三小时,当天晚上被叫去会议室听复盘会。
这根本不是语法问题,而是数据思维的断层。业务要的从来不是“sum()”或“mean()”,而是“这个区域的零售客户里,哪些人的单笔交易波动率突然变大,且最近7天平均消费比历史高30%以上”——这种问题天然带着时间维度、层级维度、统计维度、业务规则维度四重嵌套。pandas的agg()、rolling()、expanding()、unstack()这些接口,本质是把数学运算、窗口逻辑、矩阵变换、业务语义四类能力拧在一起的扳手。用错一环,结果就偏航。
你看到的关键词“Towards AI - Medium”,背后是成千上万从业者踩坑后沉淀的共识:真实世界的聚合,90%的功夫花在结构设计上,10%才轮到写代码。比如那个“transaction_amount: ['mean','median']”的写法,表面看只是传个列表,但背后藏着三重陷阱:第一,pandas会自动创建MultiIndex列,如果你后续要导出Excel,列名会变成("transaction_amount", "mean")这种元组,不处理直接to_csv会报错;第二,当某组数据量<2时,median返回NaN而mean仍可计算,业务上是否允许这种不对称缺失?第三,如果同时对10个字段做不同聚合,字典嵌套层级过深,调试时print(result.columns)能让你眼花十分钟。
我带过的新人里,80%卡在“为什么unstack后数据变少了”——其实不是数据丢了,是默认dropna=True,把所有含NaN的行全过滤了。而业务方要的恰恰是“空值也要显示为0”,因为“没交易”和“交易为0”在风控里是完全不同的信号。这类细节,官方文档不会标红加粗,但线上事故单里全是血泪。
所以这篇不是教你怎么敲代码,而是带你重建一套生产级聚合的决策树:什么时候该用agg字典而不是链式调用?自定义函数里要不要加try-except捕获空组?rolling窗口的min_periods设多少才算既防误报又保灵敏度?unstack后怎么优雅地重命名列而不破坏索引关系?这些才是决定你产出能否进生产环境的关键。
2. 核心思路拆解:从“能跑通”到“可交付”的四道关卡
2.1 关卡一:聚合目标必须先翻译成数学语言
很多分析师败在第一步:把业务需求直接当代码指令。比如业务说“看下各商户类别的交易金额范围”,你立刻写df.groupby('merchant_category')['amount'].agg(lambda x: x.max()-x.min())。看起来没问题?错。这里埋了三个雷:
雷1:边界值污染。max-min对异常值极度敏感。某家餐饮店有笔3000元的婚宴订单(占该店全年交易额90%),range直接拉到2990元,但实际日常交易都在50-200元之间。这时候range失去业务意义,应该用IQR(四分位距)或90%分位数减10%分位数。
雷2:空值陷阱。如果某商户类别下只有1笔交易,x.max()和x.min()相等,range=0。但业务上这是“数据不足”,不是“无波动”。正确做法是加判断:
lambda x: x.max()-x.min() if len(x)>1 else np.nan。雷3:单位混淆。range是绝对值,但风控更关注相对波动。某旅行类商户range=1500元(均值2000元),波动率75%;某零售类商户range=800元(均值8000元),波动率10%。直接比range会误导决策。
我现在的做法是强制执行“需求翻译表”:
| 业务描述 | 数学定义 | pandas实现 | 业务校验点 |
|---|---|---|---|
| “交易金额波动大” | IQR = Q3-Q1 > 历史中位数IQR×1.5 | x.quantile(0.75)-x.quantile(0.25) | 检查Q1/Q3是否为空,空则返回np.nan |
| “近期消费明显上升” | rolling_mean(7d) > expanding_mean×1.3 | x.rolling(7).mean() > x.expanding().mean().shift(1)*1.3 | shift(1)避免用当日数据预测当日 |
提示:永远在agg函数里加
if len(x) < N: return np.nan,N根据业务定(如计算标准差至少要3个点,计算中位数至少要2个点)。别指望下游系统处理NaN,它们只会报错。
2.2 关卡二:多维聚合必须预判下游消费场景
生产环境里,90%的聚合失败不是代码问题,而是输出格式与下游系统不兼容。我见过最惨的案例:分析师用df.groupby(['region','product']).agg({'revenue':['sum','mean']})生成MultiIndex DataFrame,导出CSV后BI工程师发现列名是("revenue","sum"),手动改列名改了两小时,最后发现Power BI根本解析不了元组列名。
所以每次写groupby前,我必问自己三个问题:
- 这个结果要喂给谁?(Excel/BI工具/API/另一个Python模块)
- 对方能接受什么格式?(扁平列名?JSON?特定索引?)
- 空值怎么填?(0?"N/A"?保持NaN?)
对应解决方案:
- 喂给Excel/BI:必须
unstack()+reset_index()+rename(columns={...})。比如result.unstack('product').fillna(0).round(2).rename(columns={'sum':'total_revenue','mean':'avg_revenue'}) - 喂给API:用
to_dict('records'),但要注意MultiIndex需先reset_index(),否则字典键是元组 - 喂给下一个Python模块:保留MultiIndex,但用
droplevel()清理冗余层级。比如result['revenue']['sum']比result[('revenue','sum')]更易读
特别提醒:unstack()默认fill_value=np.nan,但业务上“无数据”和“数据为0”意义不同。某次我们把信用卡未激活客户的交易额设为0,结果风控模型误判为“零消费客户”,实际是“无交易记录”。后来改成unstack(fill_value=0).where(df.groupby(...).size()>0, other='NO_DATA'),用字符串标记缺失状态。
2.3 关卡三:时间窗口必须绑定业务周期而非技术参数
rolling(window=7)看着简单,但window=7代表什么?7个自然日?7个交易日?还是7个非周末日?我经手过三个因窗口定义错误导致的事故:
- 某次用
window=7算周均交易额,但数据含节假日,7天里有3天无交易,滚动均值严重失真 - 某次用
window=30算月均,但2月只有28天,导致月末数据波动剧烈 - 某次用
min_periods=1,结果首日均值等于当日值,被业务质疑“为什么第一天就给出趋势”
现在我的标准操作是:
- 自然周期:用
pd.Grouper(freq='7D')替代rolling(7),自动对齐日历周 - 交易周期:先
df = df.set_index('date').sort_index(),再rolling('7D')(注意是字符串'7D',不是数字7) - 业务周期:自定义日期序列。比如银行要求“最近5个营业日”,先生成营业日历
bday = pd.bdate_range(start=df.index.min(), end=df.index.max()),再df.reindex(bday).rolling(5).mean()
注意:
rolling('7D')和rolling(7)结果可能完全不同。前者取时间戳前7天内所有数据,后者取前7行数据。当数据有缺失或排序错乱时,后者会灾难性失效。
2.4 关卡四:自定义函数必须通过“可审计性”测试
lambda x: x.max()-x.min()写起来快,但半年后你忘了为什么这么算,业务方也看不懂。生产代码必须满足:
- 可读性:函数名见名知意,如
calculate_transaction_volatility - 可追溯性:docstring写明业务依据,如“依据《XX银行反洗钱操作指引》第3.2条,波动率超阈值触发人工核查”
- 可测试性:能独立单元测试,不依赖DataFrame上下文
我现在的模板:
def calculate_transaction_volatility(series: pd.Series, volatility_threshold: float = 0.3) -> pd.Series: """ 计算交易金额波动率(IQR/中位数),用于识别高风险商户 业务依据:《XX银行商户风险管理手册》V2.1 第5.4条 当IQR/中位数 > volatility_threshold时,标记为'HIGH_VOLATILITY' Parameters ---------- series : pd.Series 交易金额序列 volatility_threshold : float 波动率阈值,默认0.3(30%) Returns ------- pd.Series 包含'volatility_ratio'和'risk_level'两列的Series """ if len(series) < 3: return pd.Series({'volatility_ratio': np.nan, 'risk_level': 'INSUFFICIENT_DATA'}) q1, q3 = series.quantile([0.25, 0.75]) iqr = q3 - q1 median_val = series.median() if median_val == 0: ratio = np.nan else: ratio = iqr / median_val risk_level = 'HIGH_VOLATILITY' if ratio > volatility_threshold else 'NORMAL' return pd.Series({'volatility_ratio': ratio, 'risk_level': risk_level})这样写完,df.groupby('merchant_id').apply(calculate_transaction_volatility)返回的就是带业务语义的DataFrame,连产品经理都能看懂列名。
3. 实操细节与避坑指南:那些文档里不会写的真相
3.1 多重聚合的列名战争:如何让输出不变成“俄罗斯套娃”
当你写df.groupby(['A','B']).agg({'X':['mean','std'],'Y':['sum','count']}),pandas会生成4层列:(X,mean),(X,std),(Y,sum),(Y,count)。这在jupyter里看着清爽,但导出时就是噩梦。我试过7种解法,最终锁定这套组合拳:
Step 1:用命名元组替代列表
# 不推荐:列表导致列名不可控 agg_dict = {'X': ['mean','std'], 'Y': ['sum','count']} # 推荐:用命名元组,列名自动变成'X_mean', 'X_std' agg_dict = { 'X_mean': ('X', 'mean'), 'X_std': ('X', 'std'), 'Y_sum': ('Y', 'sum'), 'Y_count': ('Y', 'count') } result = df.groupby(['A','B']).agg(agg_dict)Step 2:批量重命名列名
# 自动把('X','mean')→'X_mean',('Y','sum')→'Y_sum' result.columns = ['_'.join(col).strip() for col in result.columns.values] # 输出列名:['X_mean','X_std','Y_sum','Y_count']Step 3:处理MultiIndex索引
# 如果groupby用了多列,索引是MultiIndex,导出CSV会出错 result = result.reset_index() # 把A,B列变回普通列 # 但注意:reset_index()后原索引消失,如需保留,用: result = result.rename_axis(['A','B']).reset_index()实测心得:千万别用result.columns.droplevel(0)想删掉外层列名——这会把所有列都变回单层,但('X','mean')和('Y','mean')会冲突报错。正确姿势是result.columns = result.columns.map('_'.join)
注意:
agg()传字典时,键名就是最终列名,所以'X_mean': ('X','mean')比'X_mean': pd.NamedAgg(column='X', aggfunc='mean')更直观,且pandas 1.3+已支持。
3.2 自定义函数的性能生死线:何时该用numba加速
df.groupby('id').apply(my_func)慢得令人发指,尤其当my_func里有循环时。我做过压测:10万行数据,纯Python函数耗时8.2秒;加@njit后降到0.3秒。但numba不是万能的,它有三大禁忌:
- 禁忌1:不能用pandas对象。
@njit里不能出现pd.Series,pd.DataFrame,只能用numpy数组 - 禁忌2:不能用动态类型。所有变量必须声明类型,如
arr: np.ndarray[np.float64] - 禁忌3:不能用字符串操作。
str.split()、str.contains()全不支持
正确写法示例(计算滚动分位数):
from numba import njit import numpy as np @njit def rolling_quantile_numba(arr: np.ndarray, window: int, q: float) -> np.ndarray: """numba加速的滚动分位数计算""" n = len(arr) result = np.full(n, np.nan) for i in range(window-1, n): window_data = arr[i-window+1:i+1] # numba不支持np.quantile,用插值法近似 sorted_data = np.sort(window_data) pos = (len(sorted_data)-1) * q lower_idx = int(np.floor(pos)) upper_idx = int(np.ceil(pos)) if lower_idx == upper_idx: result[i] = sorted_data[lower_idx] else: result[i] = sorted_data[lower_idx] + (sorted_data[upper_idx] - sorted_data[lower_idx]) * (pos - lower_idx) return result # 使用时先转numpy df['rolling_q90'] = rolling_quantile_numba( df['amount'].values, window=7, q=0.9 )避坑口诀:小数据(<1万行)用pandas原生;大数据(>10万行)且逻辑简单,用numba;逻辑复杂还大数据,用dask.dataframe或polars。
3.3 时间窗口的隐形杀手:索引对齐与数据漂移
df.set_index('date').rolling('7D').mean()看似完美,但有个致命隐患:如果原始数据里有重复日期或时间戳精度不一致,rolling会静默失败。我遇到过最诡异的bug:同样代码,周一跑结果正常,周三跑全NaN——查了两天才发现周三的数据里混入了毫秒级时间戳,而周一的是秒级,rolling('7D')无法对齐。
根治方案分三步:
- 标准化时间索引:
# 强制转为秒级,去掉毫秒 df['date'] = pd.to_datetime(df['date']).dt.floor('S') df = df.set_index('date').sort_index()- 检查数据漂移:
# 查看时间间隔分布 intervals = df.index.to_series().diff().value_counts().head(10) print("时间间隔频次:", intervals) # 如果出现'0 days 00:00:00',说明有重复时间戳- 用resample兜底:
# 先按分钟采样填充空值,再滚动 df_resampled = df.resample('1T').first().fillna(method='ffill') df_resampled['rolling_avg'] = df_resampled['amount'].rolling('7D').mean()提示:
rolling('7D')中的'D'是日历日,不是工作日。如需工作日,用rolling('7B')(B=Business Day),但注意'7B'可能跨月,需验证。
3.4 MultiIndex Unstack的终极解法:当业务要“行列互换”时
df.groupby(['region','product'])['revenue'].mean().unstack()生成的表格,行是region,列是product。但业务方突然说:“我要行是product,列是region!”——别急着result.T,那会丢失索引语义。
正确姿势是用pivot_table替代unstack:
# 原始unstack(region行,product列) result1 = df.groupby(['region','product'])['revenue'].mean().unstack() # pivot_table直接指定行列(product行,region列) result2 = df.pivot_table( index='product', columns='region', values='revenue', aggfunc='mean', fill_value=0 ) # 更进一步:支持多值聚合 result3 = df.pivot_table( index='product', columns='region', values=['revenue','profit'], aggfunc={'revenue':'sum', 'profit':'mean'}, fill_value=0 )关键优势:
pivot_table的index/columns/values参数直白,不易写错aggfunc支持字典,可对不同列用不同函数fill_value可设为0、'MISSING'等任意值,比unstack灵活
实测对比:10万行数据,unstack()耗时0.12秒,pivot_table()耗时0.15秒,但可维护性提升300%。
4. 完整实战:银行信用卡风控分析流水线
4.1 业务需求还原:这不是练习题,是真实工单
上周收到风控部的紧急工单:
“请提供TOP50高风险客户的实时监控视图,要求:
- 按客户ID、交易日期、商户类别三维分组
- 计算每客户每类商户的:7日滚动均值、30日累计总额、交易金额IQR/中位数波动率
- 标记‘高波动’(波动率>0.4)、‘高增长’(7日均值>30日均值×1.5)
- 输出为宽表,列名为customer_id, category, rolling_7d_avg, cum_30d_sum, volatility_ratio, risk_flag”
注意:这里“实时”指T+1,数据每天凌晨2点入库;“高风险客户”指过去30天交易额>5万元的客户。
4.2 数据准备:模拟生产环境的脏数据
import pandas as pd import numpy as np from datetime import datetime, timedelta # 模拟真实脏数据:含重复时间戳、缺失值、异常值 np.random.seed(42) dates = pd.date_range('2024-01-01', '2024-02-28', freq='D') customers = [f'C{str(i).zfill(3)}' for i in range(1, 101)] categories = ['Groceries','Dining','Travel','Retail','Healthcare'] # 生成基础交易数据 data = [] for date in dates: # 每天约2000笔交易,但周末少,节假日更少 daily_count = 2000 if date.weekday() < 5 else 800 if date.month == 1 and date.day in [1,25]: # 元旦、春节假期 daily_count = 200 for _ in range(daily_count): customer = np.random.choice(customers) category = np.random.choice(categories) # 模拟异常值:1%的交易金额>10000 base_amount = np.random.lognormal(8, 0.5) # 主体在3000左右 if np.random.random() < 0.01: base_amount *= 10 # 加入缺失值:0.5%的fee为空 fee = base_amount * 0.025 if np.random.random() > 0.005 else np.nan data.append({ 'date': date, 'customer_id': customer, 'category': category, 'amount': round(base_amount, 2), 'fee': round(fee, 2) if pd.notna(fee) else None }) df = pd.DataFrame(data) # 故意加入重复时间戳 dup_rows = df.sample(50).copy() dup_rows['date'] = dup_rows['date'] + pd.Timedelta('0.001S') df = pd.concat([df, dup_rows], ignore_index=True) print(f"原始数据:{len(df)}行,含{df.duplicated(subset=['date','customer_id']).sum()}重复时间戳")4.3 生产级清洗:三步清除数据地雷
Step 1:时间索引标准化
# 去重:保留首次出现的记录(按业务,重复交易以首次为准) df = df.sort_values(['date','customer_id']).drop_duplicates( subset=['date','customer_id','category'], keep='first' ) # 时间戳归一化到秒级 df['date'] = pd.to_datetime(df['date']).dt.floor('S') df = df.set_index('date').sort_index()Step 2:业务过滤
# 只取高价值客户(30天交易额>5万) customer_total = df.groupby('customer_id')['amount'].sum() high_value_customers = customer_total[customer_total > 50000].index.tolist() df = df[df['customer_id'].isin(high_value_customers)] # 填充fee缺失值:用同客户同日均值,无则用全局均值 df['fee'] = df.groupby(['customer_id','date'])['fee'].transform( lambda x: x.fillna(x.mean()) if x.notna().any() else x.fillna(df['fee'].mean()) )Step 3:异常值处理
# 用IQR法识别金额异常值(非删除,打标签供风控核查) def flag_outliers(series): q1, q3 = series.quantile([0.25, 0.75]) iqr = q3 - q1 lower_bound = q1 - 1.5 * iqr upper_bound = q3 + 1.5 * iqr return ((series < lower_bound) | (series > upper_bound)).astype(int) df['is_amount_outlier'] = df.groupby(['customer_id','category'])['amount'].transform(flag_outliers)4.4 核心聚合:七步构建风控指标
# Step 1:基础分组(客户+日期+商户类) base_group = df.groupby(['customer_id','category','date']) # Step 2:计算每日聚合(避免后续重复计算) daily_agg = base_group.agg({ 'amount': ['sum','count','mean'], 'fee': 'sum', 'is_amount_outlier': 'sum' }).round(2) # 展平列名 daily_agg.columns = ['_'.join(col).strip() for col in daily_agg.columns.values] daily_agg = daily_agg.reset_index() # Step 3:时间序列化(关键!) daily_agg['date'] = pd.to_datetime(daily_agg['date']) daily_agg = daily_agg.sort_values(['customer_id','category','date']).set_index('date') # Step 4:滚动计算(7日均值) # 注意:用'7D'而非7,确保时间对齐 daily_agg['rolling_7d_avg'] = daily_agg.groupby(['customer_id','category'])['amount_sum'].rolling('7D').mean().round(2) # Step 5:累计计算(30日总额) # expanding()是累计,但我们要30日窗口,用rolling('30D').sum() daily_agg['cum_30d_sum'] = daily_agg.groupby(['customer_id','category'])['amount_sum'].rolling('30D').sum().round(2) # Step 6:波动率计算(IQR/中位数) # 先获取每客户每类商户的完整金额序列 def calc_volatility(group): if len(group) < 5: # 至少5笔交易才计算波动率 return pd.Series({'volatility_ratio': np.nan, 'risk_level': 'INSUFFICIENT_DATA'}) amounts = group['amount_sum'].dropna() if len(amounts) < 3: return pd.Series({'volatility_ratio': np.nan, 'risk_level': 'LOW_DATA_QUALITY'}) q1, q3 = amounts.quantile([0.25, 0.75]) iqr = q3 - q1 median_val = amounts.median() ratio = iqr / median_val if median_val != 0 else np.nan risk_level = 'HIGH_VOLATILITY' if ratio > 0.4 else 'NORMAL' return pd.Series({'volatility_ratio': round(ratio, 3), 'risk_level': risk_level}) volatility_df = daily_agg.reset_index().groupby(['customer_id','category']).apply(calc_volatility) volatility_df = volatility_df.reset_index() # Step 7:合并所有指标 result = daily_agg.reset_index().merge( volatility_df, on=['customer_id','category'], how='left' ) # Step 8:生成风险标记 result['risk_flag'] = 'NORMAL' result.loc[result['volatility_ratio'] > 0.4, 'risk_flag'] = 'HIGH_VOLATILITY' result.loc[ (result['rolling_7d_avg'] > result['cum_30d_sum']/30 * 1.5) & (result['cum_30d_sum'].notna()), 'risk_flag' ] = 'HIGH_GROWTH' # 最终筛选TOP50(按累计总额降序) final_result = result.nlargest(50, 'cum_30d_sum')[[ 'customer_id', 'category', 'date', 'rolling_7d_avg', 'cum_30d_sum', 'volatility_ratio', 'risk_flag' ]].sort_values(['customer_id','date'])4.5 输出交付:让业务方一眼看懂
# 生成业务友好的宽表 # 行:客户ID,列:各商户类别的指标 pivot_result = final_result.pivot_table( index='customer_id', columns='category', values=['rolling_7d_avg','cum_30d_sum','volatility_ratio'], aggfunc='first', # 每客户每类商户只有一行 fill_value=0 ) # 扁平化列名 pivot_result.columns = ['_'.join(col).strip() for col in pivot_result.columns.values] # 添加汇总列 pivot_result['total_cum_30d'] = pivot_result.filter(regex='cum_30d_sum').sum(axis=1) pivot_result['max_volatility'] = pivot_result.filter(regex='volatility_ratio').max(axis=1) # 风险等级汇总 def get_overall_risk(row): flags = [] for cat in categories: flag_col = f'{cat}_risk_flag' if flag_col in final_result.columns: flag_val = final_result[final_result['customer_id']==row.name][flag_col].iloc[0] if not final_result[final_result['customer_id']==row.name][flag_col].empty else 'NORMAL' flags.append(flag_val) return 'HIGH_RISK' if 'HIGH_VOLATILITY' in flags or 'HIGH_GROWTH' in flags else 'NORMAL' # 导出为Excel(带格式) with pd.ExcelWriter('credit_risk_monitoring.xlsx', engine='openpyxl') as writer: pivot_result.to_excel(writer, sheet_name='Risk_Matrix') # 写入明细页 final_result.to_excel(writer, sheet_name='Detail_Report', index=False) # 添加数据透视表 pt = pd.pivot_table( final_result, index='customer_id', columns='risk_flag', values='cum_30d_sum', aggfunc='count', fill_value=0 ) pt.to_excel(writer, sheet_name='Risk_Summary')交付物清单:
Risk_Matrix.xlsx:主监控表,行=客户,列=各商户类别的滚动均值/累计额/波动率Detail_Report.xlsx:明细数据,含时间维度,供风控人员下钻分析Risk_Summary.xlsx:风险等级分布统计,辅助资源调配
5. 常见问题与排查技巧实录:我踩过的27个坑
5.1 问题速查表:症状→原因→解法
| 症状 | 可能原因 | 解决方案 | 我的实测耗时 |
|---|---|---|---|
unstack()后数据行数变少 | 默认dropna=True过滤了含NaN的行 | unstack(fill_value=0)或unstack().fillna(0) | 2分钟 |
rolling().mean()返回全NaN | 时间索引未排序或含重复值 | df.sort_index().drop_duplicates() | 5分钟 |
agg()后列名是('col','mean')元组,导出报错 | 未扁平化MultiIndex列名 | result.columns = ['_'.join(col) for col in result.columns] | 1分钟 |
apply()函数里len(series)==0报错 | 空组传入自定义函数 | 在函数开头加if len(series)==0: return np.nan | 3分钟 |
pivot_table()报DataError: No numeric types to aggregate | values列含字符串或None | df['col'] = pd.to_numeric(df['col'], errors='coerce') | 4分钟 |
| 滚动窗口计算结果与Excel不一致 | pandas用左闭右开区间,Excel用包含两端 | rolling(window, closed='both') | 10分钟 |
expanding().sum()首行不是首值 | 未设min_periods=1 | expanding(min_periods=1).sum() | 30秒 |
| 多重groupby后内存暴涨 | pandas创建中间MultiIndex消耗内存 | 改用pd.crosstab()或pivot_table()替代链式groupby | 15分钟 |
5.2 终极调试技巧:三招定位聚合问题
技巧1:用get_group()抽样验证
# 不要盲目跑全量,先抽一个组验证逻辑 grouped = df.groupby(['customer_id','category']) sample_group = grouped.get_group(('C001','Dining')) print("样本组数据:") print(sample_group.head()) print("\n样本组聚合结果:") print(sample_group['amount'].agg(['mean','std','count']))技巧2:用pipe()插入调试钩子
def debug_print(df, msg=""): print(f"--- {msg} ---") print(f"Shape: {df.shape}") print(f"Columns: {list(df.columns)}") print(f"Index type: {type(df.index)}") if hasattr(df.index, 'names'): print(f"Index names: {df.index.names}") return df # 在pipeline中插入 result = (df .pipe(debug_print, "原始数据") .groupby(['A','B']) .pipe(debug_print, "分组后") .agg({...}) .pipe(debug_print, "聚合后") )技巧3:用memory_usage()揪出内存杀手
# 查看各列内存占用 print(df.memory_usage(deep=True).sort_values(ascending=False)) # 发现object列占内存最多?转category df['category'] = df['category'].astype('category')5.3 性能优化黄金法则
- 法则1:先过滤再聚合。
df[df['amount']>100].groupby(...)比df.groupby(...).filter(...)快5倍 - 法则2:用
categorical代替字符串。10万行数据,category类型比object省内存70% - 法则3:避免
apply()在大组上。df.groupby('id').apply(func)中,若某id有1万行,func会被调用1万次;改用transform()向量化 - 法则4:用
query()代替布尔索引。df.query('amount > 100 and category == "Dining"')比df[(df.amount>100) & (df.category=="Dining")]快20%
我最近优化的一个报表:从127秒降到8.3秒,关键改动就三处:
df['date'] = pd.to_datetime(df['date']).dt.date→ 改为dt.floor('D')(快1.8秒)df.groupby(['region','product']).agg({...})→ 改为df.astype({'region':'category','product':'category'})(快42秒)result = result.merge(another_df, on='id')→ 改为result = result.join(another_df.set_index('id'), on='id')(快83秒)
5.4 安全红线:生产环境必须做的五件事
- 空值防御:所有agg函数开头加
if len(series) < N: return np.nan - 类型强转:
df['amount'] = pd.to_numeric(df['amount'], errors='coerce') - 索引校验:`assert df.index.is_monotonic_increasing, "