Pandas多维聚合:金融场景下的高性能、高一致性业务指标计算
1. 项目概述为什么“多维聚合”不是Pandas进阶技巧而是业务分析的生存技能我在银行风控部门干了七年从刚毕业写SQL查数的分析师到带三个人小团队做反欺诈模型的数据架构师。这七年里我亲手重构过四套核心报表系统也给二十多个业务部门做过数据赋能培训。最常被问到的问题不是“怎么建模”而是“老师这个指标能不能按客户产品时间三个维度一起算现在跑三次groupby再merge一跑就是四十分钟领导在催。”——这句话背后藏着的是真实世界里每天都在发生的效率损耗、逻辑错位和决策延迟。“Part 20: Data Manipulation in Multi-Dimensional Aggregation”这个标题听起来像教科书里的章节编号但在我日常工作中它对应的是一个具体、高频、高价值的场景用一份代码同时回答五个不同角色的问题。财务总监要看各区域各产品的毛利总和与波动率风险经理要盯住某类商户交易金额的极差max-min是否突破阈值运营总监需要滚动30天的客单价均值来判断营销活动效果客户经理则想快速拉出自己名下客户在餐饮和旅游类目的消费偏好矩阵而CEO办公室的BI看板要求所有这些结果必须在凌晨两点前自动刷新完毕。这些需求绝不是df.groupby(region).sum()能解决的。它们共同指向一个核心能力在单次计算中对同一份数据按不同维度、施加不同逻辑、产出异构结果并保证结构可读、下游可用。这就是“多维聚合”的本质——它不是语法糖而是业务复杂度在数据层的映射。你看到的agg({amount: [mean, std], fee: [min, max]})背后是财务部和风控部两个会议纪要的合并你写的rolling(window7).mean()其实是把“过去一周是否异常”这个业务判断固化成了可复用、可审计、可回溯的计算单元而unstack()之后那个整齐的表格不是为了好看是为了让销售总监不用打开Jupyter Notebook直接复制粘贴进他明天早会的PPT里。我见过太多团队因为没吃透这些模式硬生生把一个本该200行代码搞定的分析流程拆成七八个独立脚本中间靠Excel手工拼接每次数据源更新都要花半天时间校验一致性。这种低效最终都会变成业务响应慢、指标口径乱、决策依据弱。所以这篇文章不讲“pandas有多强大”只讲“在银行、保险、支付这类强监管、高时效、多角色协同的行业里你怎么用pandas把业务语言翻译成机器可执行、人可理解、系统可集成的聚合逻辑”。它面向的不是刚学完df.head()的新手也不是只写算法不碰生产环境的研究员而是每天被业务方追着要“那个带颜色的交叉表”、被运维同事提醒“你的job又把集群内存打满了”的一线数据工程师和分析师。接下来的内容全部来自我踩过的坑、压测过的参数、上线后被反复验证过的写法。没有理论推导只有实操现场。2. 核心设计思路为什么放弃“分步计算”选择“一次聚合”2.1 业务驱动的性能瓶颈从45分钟到9秒的真实代价先说一个血泪教训。2022年Q3我们为信用卡中心搭建一套实时商户风险评分看板。原始方案是典型的“分步流”第一步按merchant_id分组算sum(amount)和count(*)第二步按merchant_category分组算std(amount)和max(amount)-min(amount)第三步按datemerchant_category分组算滚动7天均值……最后用pd.merge()把七八个DataFrame拼起来。这套逻辑在测试环境10万条记录跑得飞快不到2秒。但上线首日面对生产库每小时新增的800万笔交易整个ETL pipeline卡在聚合环节平均耗时45分钟导致看板数据延迟超6小时风控策略完全失效。问题出在哪表面看是数据量大根子上是计算冗余和内存爆炸。每一次groupbypandas都要重新扫描全量数据、重建索引、分配新内存块。更致命的是merge操作本身需要对齐索引当左右表的分组键不完全一致比如A表有1000个商户B表只有950个就会触发笛卡尔积式的匹配尝试内存占用呈指数级增长。我们用memory_profiler抓取峰值发现单次merge就占用了12GB内存而服务器总内存才32GB。解决方案就是本文开篇强调的“一次聚合”。把所有需要的指标全部塞进一个agg()调用里。这不是炫技而是用空间换时间、用结构换稳定的工程权衡。pandas底层在执行复合聚合时会智能复用分组后的中间状态。它先按merchant_id分好桶然后在这个桶内同时计算sum、std、min、max——所有运算共享同一份分组索引避免了重复扫描。我们重写后同样800万条数据聚合耗时从45分钟压到9秒内存峰值稳定在3.2GB。这个数字不是凭空来的是我用cProfile逐行分析、对比agg()内部Cython实现后确认的。提示别迷信“链式操作”。df.groupby().sum().reset_index().merge(...)看着流畅但在大数据量下每一步.reset_index()都会触发一次完整的DataFrame重建这是隐形的性能杀手。真正的生产级写法是让聚合尽可能“胖”——一次吐出所有需要的列。2.2 语义一致性为什么“同一个groupby对象”比“多个groupby结果”更可靠另一个常被忽视的点是业务语义的原子性。举个例子财务部要求“各产品线的平均交易额”风控部要求“各产品线的交易额标准差”。如果分开计算avg_by_prod df.groupby(product)[amount].mean() std_by_prod df.groupby(product)[amount].std()看起来没问题。但当数据源在两次计算之间发生变更比如上游ETL延迟、补录数据avg_by_prod和std_by_prod所基于的数据快照就可能不一致。avg_by_prod算的是截止到T时刻的数据std_by_prod算的却是T5秒的数据。这种微小的时间差在月度报表里可能只是小数点后两位的差异但在实时反欺诈场景下可能导致一个本该被拦截的高风险交易因标准差计算偏小而漏过。而agg()确保了所有指标都基于完全相同的数据切片和分组逻辑。它像一个封闭的黑盒输入是确定的DataFrame输出是确定的MultiIndex DataFrame中间过程不可分割。这不仅是技术严谨更是合规底线——在金融行业任何指标的计算过程都必须可审计、可复现、可追溯。当你向监管报送“商户风险敞口”报告时审计师不会关心你用了几个groupby他只会问“请提供该指标的完整计算逻辑和所用数据版本”。一个agg()调用就是一份天然的、自包含的计算契约。2.3 工程可维护性从“脚本拼图”到“配置驱动”的演进最后是团队协作成本。我带的第一个实习生接手了一套由前任留下的“分析脚本集”里面全是类似这样的代码# step1_calc_avg.py df_avg df.groupby([region,product]).amount.mean().reset_index(nameavg_amount) # step2_calc_std.py df_std df.groupby([region,product]).amount.std().reset_index(namestd_amount) # step3_merge.py result pd.merge(df_avg, df_std, on[region,product])问题来了当业务方突然要求增加“中位数”指标时他得改三个文件还得确保on字段的拼写、大小写、空格完全一致。有一次他把step2_calc_std.py里的on[region,product]误写成on[Region,Product]导致merge后出现大量NaN整整两天没人发现直到销售总监指着看板上“华南区Gadget产品线平均额为0”来质问。而统一聚合天然支持配置化管理。我们可以把指标定义抽成字典AGG_CONFIG { region_product_metrics: { groupby: [region, product], aggs: { amount: [mean, median, std, min, max], fee: [sum, mean] } } }然后写一个通用函数def run_aggregation(config_name): config AGG_CONFIG[config_name] return df.groupby(config[groupby]).agg(config[aggs])新增指标只需在AGG_CONFIG里加一行零代码改动。这种设计让分析逻辑从“散落的脚本”变成了“中心化的配置”极大降低了交接成本和出错概率。这也是为什么我在所有团队推行“聚合即配置”原则——它让数据工作从手工艺走向了工业化。3. 核心细节解析五种聚合模式的实战精要3.1 多列多函数聚合不只是语法是业务指标的“并行计算”df.groupby(col).agg({col_a: [func1, func2], col_b: [func3]})这个语法新手常犯两个错误一是以为[func1, func2]是列表可以随便加二是忽略输出的MultiIndex结构带来的后续处理成本。先说第一个误区。[mean, median]看似简单但median在pandas里是非向量化操作。它需要对每个分组内的Series进行排序时间复杂度是O(n log n)。而mean是O(n)。当你对一个有100万个分组、每组平均100条记录的数据集执行agg({amount: [mean, median]})时median会成为性能瓶颈。实测数据在同等条件下只算mean耗时1.2秒加上median后飙升至8.7秒。这不是pandas的bug而是算法本质决定的。解决方案用业务逻辑妥协技术限制。在绝大多数金融场景“中位数”要解决的核心问题是“剔除异常值影响”。那么与其死磕median不如用更轻量的替代方案截尾均值Trimmed Mean去掉最高10%和最低10%后再求均值。pandas原生支持lambda x: x.quantile([0.1, 0.9]).mean()但更高效的是用scipy.stats.trim_mean(x, proportiontocut0.1)实测比median快3倍。加权均值如前文提到的weighted_average用最近交易赋予更高权重既反映趋势又天然抑制历史极端值。第二个关键点是MultiIndex列的扁平化。输出的列名是(amount, mean)、(amount, median)这样的元组。如果你直接to_csvExcel里会显示为amount,mean非常难看。很多教程教用result.columns [_.join(col) for col in result.columns]但这在列名含中文或特殊字符时会报错。我的生产级写法是def flatten_columns(df): 安全扁平化MultiIndex列兼容中文、空格、特殊符号 if not isinstance(df.columns, pd.MultiIndex): return df new_cols [] for col in df.columns: # 将元组转为字符串用双下划线分隔避免与业务字段名冲突 flat_col __.join(str(c) for c in col) # 替换非法字符只保留字母、数字、下划线、中文 flat_col re.sub(r[^\w\u4e00-\u9fff], _, flat_col) new_cols.append(flat_col) df.columns new_cols return df result_flat flatten_columns(result)这个函数处理过含“¥”、“/”、“”等符号的列名从未出错。记住在生产环境任何未经清洗的列名都可能成为下游系统的雷。3.2 自定义聚合函数从“写代码”到“写业务说明书”lambda x: x.max() - x.min()是入门写法但它暴露了两个严重问题不可调试、不可复用、不可解释。当半年后审计师问“这个‘range’指标的计算逻辑是什么”你总不能翻出一行lambda给他看吧真正的生产级自定义函数必须满足“三可”原则可读、可测、可文档化。看这个改进版def transaction_range(series, threshold_percentile95): 计算交易金额范围最大值-最小值但排除极端异常值 业务背景 - 银行风控要求单笔交易超过该商户历史95%分位数的视为潜在欺诈不参与范围计算 - 避免一笔1000万的虚假交易拉爆整个商户的range指标 参数 series (pd.Series): 原始交易金额序列 threshold_percentile (float): 异常值判定分位数默认95 返回 float: 清洗后的交易金额范围 if len(series) 2: return np.nan # 业务规则先过滤掉疑似欺诈的极高值 upper_bound series.quantile(threshold_percentile / 100.0) clean_series series[series upper_bound] if len(clean_series) 2: # 如果过滤后只剩1条返回原始范围降级策略 return series.max() - series.min() return clean_series.max() - clean_series.min() # 使用 result df.groupby(merchant_category).agg({ amount: transaction_range, fee: lambda x: x.sum() * 1.05 # 加5%预留金 })这个函数的价值远超计算本身。它的docstring里明确写了“业务背景”和“参数含义”这就是一份微型的业务需求说明书。当新同事接手时他不需要猜threshold_percentile95是什么意思文档里已经解释清楚。而且这个函数可以独立单元测试def test_transaction_range(): # 构造测试数据9条正常交易 1条异常值 test_data pd.Series([100, 120, 110, 130, 105, 115, 125, 108, 122, 10000]) assert abs(transaction_range(test_data) - (130 - 100)) 0.01 # 应该排除10000 assert not np.isnan(transaction_range(pd.Series([50]))) # 单值情况返回nan在数据工程领域一个写得好的自定义函数其价值等于一份可执行的SOP标准作业程序。它把模糊的业务语言“别让假交易影响指标”转化成了精确的、可验证的代码逻辑。3.3 滚动窗口聚合时间窗口不是数字是业务节奏的刻度rolling(window3).mean()看似简单但window3这个数字背后是深刻的业务决策。在我们的信用卡反欺诈系统里这个窗口值不是拍脑袋定的而是经过AB测试验证的窗口大小误报率漏报率平均响应延迟业务影响1当日12.3%8.7%1分钟频繁误报客服热线被打爆33日3.1%2.4%~2小时最佳平衡点7周0.8%5.2%~1天漏报增多高风险交易未及时拦截所以window3不是技术参数而是业务SLA服务等级协议的数字化表达。它意味着“我们承诺在异常交易发生后的48小时内必须识别并预警”。但技术实现上rolling有个巨大陷阱默认情况下它要求窗口必须填满才计算。[1200, 1350, 1180, 1420]window3前三行是[1200, 1350, 1180] - 1243.33第四行是[1350, 1180, 1420] - 1316.67。但第一、二行呢NaN。在生产报表里NaN是灾难性的——它会让下游的sum()、plot()全部失效。正确做法是显式指定min_periodsdf_ts[rolling_avg] df_ts.groupby(category)[daily_revenue].rolling( window3, min_periods1 # 至少有1个值就计算不足时用实际数量均值 ).mean().reset_index(level0, dropTrue)这样第一行1200第二行(12001350)/21275第三行(120013501180)/31243.33数据连续无断点。min_periods1是金融场景的黄金法则——宁可早期信号弱一点也不能让监控断掉。另外rolling默认是“左闭右闭”窗口即包含当前行。但有些业务需要“向前看”如预测未来3天趋势这时要用shift()# 预测未来3天的平均值基于过去3天 df_ts[forecast_3day] df_ts.groupby(category)[daily_revenue].rolling( window3, min_periods1 ).mean().shift(-2) # 向前移2位使当前行显示未来第3天的预测值3.4 扩展窗口聚合累积计算不是数学题是生命周期的度量expanding().sum()常被用于“YTD年初至今”统计但这里有个隐蔽的业务陷阱时间顺序必须绝对正确。expanding是按DataFrame的物理行序计算的而不是按时间戳。如果数据是乱序的比如日志采集延迟、补录expanding().sum()会给出完全错误的结果。正确姿势永远是先按时间排序再计算。# 错误未排序结果不可信 df_ts[ytd_sum] df_ts.groupby(category)[revenue].expanding().sum() # 正确强制按时间升序 df_sorted df_ts.sort_values([category, date]).set_index(date) df_sorted[ytd_sum] df_sorted.groupby(category)[revenue].expanding().sum()我亲眼见过一个案例某分行的“季度手续费收入”报表因为上游数据入库顺序混乱expanding把10月的收入算进了7月的YTD里导致季度末突击冲量的假象差点引发监管问询。另一个关键是expanding的起始点定义。expanding().sum()默认从第一行开始累加。但业务上“YTD”是从1月1日开始不是从数据第一条开始。所以更健壮的写法是def ytd_cumsum(series, date_index): 按自然年YTD计算累积和而非数据首行 # 确保date_index是DatetimeIndex if not isinstance(date_index, pd.DatetimeIndex): date_index pd.DatetimeIndex(date_index) # 为每行计算其所在自然年的起始日期 year_start date_index.year.map(lambda y: pd.Timestamp(f{y}-01-01)) # 创建辅助列年份序号用于分组 df_temp pd.DataFrame({value: series, year_start: year_start, date: date_index}) df_temp df_temp.sort_values([year_start, date]) # 按年份分组再在组内expanding df_temp[ytd_sum] df_temp.groupby(year_start)[value].expanding().sum().values return df_temp[ytd_sum] # 使用 df_ts[ytd_revenue] ytd_cumsum(df_ts[revenue], df_ts[date])这个函数确保了无论数据从哪天开始YTD都严格按日历年度计算。在金融数据领域“正确”比“快”重要一万倍。3.5 多级分组与unstack从“数据结构”到“业务视图”的最后一公里groupby([region,product]).mean().unstack()输出的矩阵是业务方最爱的格式。但unstack()有个致命限制它只能展开最内层索引。如果你的分组是groupby([region,product,channel])unstack()默认只把channel展开成列region和product还是行索引。而业务方想要的是“region为行product为列channel为页签”——这需要unstack(level[1,2])。但更常见的问题是缺失值处理。unstack()遇到某个region下没有某product时会填NaN。在报表里NaN会被Excel渲染为空白但业务方会质疑“华南区真的没有Gadget销量吗还是数据丢了” 所以fill_value0是必须的result df_sales.groupby([region,product])[revenue].mean().unstack(fill_value0)然而fill_value0也有风险。如果0是合法业务值比如某产品刚上市确实卖了0元那它和“数据缺失”就混淆了。终极方案是用语义化占位符result df_sales.groupby([region,product])[revenue].mean().unstack( fill_valuenp.nan # 先保持NaN ) # 再用业务规则标注缺失原因 result result.fillna({ (North, Gadget): 新品未铺货, (South, Widget): 渠道暂停合作 })最后unstack()后的DataFrame列名是(Gadget, Widget)这样的元组。要让它真正“开箱即用”必须做两件事列名扁平化同3.1节添加总计行/列这是业务报表的刚需# 添加总计列各region的总和 result[TOTAL] result.sum(axis1) # 添加总计行各product的总和 total_row result.sum(axis0) total_row.name GRAND_TOTAL result pd.concat([result, total_row.to_frame().T]) # 确保TOTAL列在最右GRAND_TOTAL行在最下 cols [c for c in result.columns if c ! TOTAL] [TOTAL] result result[cols]这个result可以直接to_excel()业务方打开就是一张带边框、有总计、列名清晰的完美报表。unstack()的终点不是技术完成而是业务交付。4. 实操全流程零售银行信用卡分析的七步炼金术4.1 数据准备与质量校验别让脏数据毁掉所有努力所有高大上的聚合都建立在干净数据的基础上。我见过太多团队把80%时间花在debug聚合结果最后发现是原始数据里混入了N/A字符串、负数交易额、或date字段是object类型而非datetime64。所以我的标准化流程第一步永远是数据体检def data_health_check(df, required_colsNone): 对DataFrame进行全方位健康检查 report {} # 1. 基础信息 report[shape] df.shape report[dtypes] df.dtypes.to_dict() # 2. 缺失值检查重点 nulls df.isnull().sum() report[nulls] nulls[nulls 0].to_dict() # 3. 业务关键字段校验 if amount in df.columns: # 金额不能为负退款除外但需单独字段标识 negative_amounts df[df[amount] 0].shape[0] report[negative_amounts] negative_amounts if negative_amounts 0: print(f⚠️ 警告发现{negative_amounts}笔负金额交易请确认是否为退款) if date in df.columns: # 日期必须是datetime类型 if not np.issubdtype(df[date].dtype, np.datetime64): report[date_dtype_error] fdate列为{df[date].dtype}应为datetime64 # 自动转换生产环境慎用此处仅演示 try: df[date] pd.to_datetime(df[date]) print(✅ 已自动转换date列为datetime) except: raise ValueError(date列无法转换为datetime请检查格式) # 4. 重复行检查 duplicates df.duplicated().sum() report[duplicates] duplicates if duplicates 0: print(f⚠️ 警告发现{duplicates}行重复数据) return report, df # 执行检查 report, df_clean data_health_check(df_transactions) print(数据健康报告, json.dumps(report, indent2, defaultstr))这个检查函数会在聚合前揪出90%的“诡异结果”根源。它不是可选步骤而是生产环境的强制准入门槛。在我的团队任何未经data_health_check通过的数据集都不允许进入groupby环节。4.2 分析1客户-品类多维统计Multiple Aggregations目标一次性产出每个客户在每个消费品类的平均额、中位数、交易笔数以及手续费的最小值和最大值。# 定义聚合配置生产环境必须配置化 MULTI_AGG_CONFIG { customer_category_stats: { groupby: [customer_id, category], aggs: { amount: [mean, median, count], fee: [min, max] } } } # 执行聚合 multi_agg df_clean.groupby([customer_id, category]).agg({ amount: [mean, median, count], fee: [min, max] }) # 扁平化列名 multi_agg_flat flatten_columns(multi_agg) # 业务增强计算手续费率fee/amount的均值 # 注意不能直接用mean因为fee和amount是不同列需先计算每行比率 df_clean[fee_rate] df_clean[fee] / df_clean[amount] fee_rate_by_cc df_clean.groupby([customer_id, category])[fee_rate].mean() multi_agg_flat multi_agg_flat.join(fee_rate_by_cc.rename(avg_fee_rate)) print(Analysis 1: Customer-Category Statistics) print(multi_agg_flat.round(2))输出解读C001__Dining__amount__mean是C001客户在餐饮类的平均交易额。C001__Dining__fee__min是其在该类别的最低手续费。avg_fee_rate是其在该类别的平均手续费率。所有指标在同一行同一时刻计算语义绝对一致。实操心得永远在聚合后立刻做round(2)。浮点数精度问题在金融场景下是红线。我曾因0.10.20.30000000000000004导致一笔0.01元的手续费差异被风控同事追着问了三天。4.3 分析2品类风险范围Custom Aggregation目标计算每个消费品类的交易金额范围max-min但要排除异常值。def category_risk_range(series, outlier_percentile95): 计算品类交易范围排除顶部outlier_percentile%的异常值 if len(series) 2: return np.nan # 业务规则只排除高值异常低值如1元测试交易保留 upper_bound series.quantile(outlier_percentile / 100.0) clean_series series[series upper_bound] if len(clean_series) 2: return series.max() - series.min() return clean_series.max() - clean_series.min() # 执行 range_analysis df_clean.groupby(category).agg({ amount: category_risk_range, fee: lambda x: x.max() - x.min() # 手续费范围无需过滤 }).round(2) print(\nAnalysis 2: Category Risk Range (Excluding Top 5% Outliers)) print(range_analysis)输出解读Dining类的amount范围是464.69意味着在排除了5%最高交易后其正常交易区间跨度仍很大属于高风险品类需加强监控。这比单纯看std更能反映业务实质。4.4 分析3客户滚动均值Rolling Window目标计算每个客户过去7天的平均交易额用于识别消费习惯突变。# 关键必须按时间排序 df_sorted df_clean.sort_values([customer_id, date]).set_index(date) # 滚动计算按customer_id分组7天窗口至少1个值 rolling_7day df_sorted.groupby(customer_id)[amount].rolling( window7D, # 推荐用字符串窗口如7D比整数窗口更鲁棒 min_periods1 ).mean().reset_index(namerolling_7day_avg) # 合并回原数据便于查看 result_rolling df_sorted.reset_index().merge( rolling_7day, on[date, customer_id], howleft ) print(\nAnalysis 3: Rolling 7-Day Average by Customer (First 15 rows)) print(result_rolling[[date, customer_id, amount, rolling_7day_avg]].head(15).round(2))注意这里用了window7D7天而非window77行。前者按真实时间间隔计算后者按行数计算。在交易不均匀如周末无交易时7D更符合业务直觉。4.5 分析4客户累计消费Expanding Window目标计算每个客户从首笔交易开始的累计消费额用于LTV客户终身价值评估。# 按customer_id和date排序确保时间顺序 df_sorted df_clean.sort_values([customer_id, date]) # 计算YTD累计按自然年 df_sorted[date_dt] pd.to_datetime(df_sorted[date]) df_sorted[year] df_sorted[date_dt].dt.year # 按年分组再expanding df_sorted[cumulative_spend_ytd] df_sorted.groupby( [customer_id, year] )[amount].expanding().sum().values # 全局累计从第一笔开始 df_sorted[cumulative_spend_all] df_sorted.groupby(customer_id)[amount].expanding().sum().values print(\nAnalysis 4: Cumulative Spend (YTD and All-Time)) print(df_sorted[[date, customer_id, amount, cumulative_spend_ytd, cumulative_spend_all]].head(15).round(2))输出中cumulative_spend_ytd在每年1月1日归零cumulative_spend_all则持续累加。两者结合能清晰看出客户是“老用户持续贡献”还是“新用户爆发式增长”。4.6 分析5客户-品类交叉表Multi-Level Unstack目标生成客户与品类的消费偏好矩阵直观展示谁在哪儿花钱最多。# 计算平均交易额 crosstab_raw df_clean.groupby([customer_id, category])[amount].mean() # unstack填充0并扁平化 crosstab crosstab_raw.unstack(fill_value0) crosstab_flat flatten_columns(crosstab) # 添加总计行/列 crosstab_flat[TOTAL_SPEND] crosstab_flat.sum(axis1) total_row crosstab_flat.sum(axis0) total_row.name GRAND_TOTAL crosstab_final pd.concat([crosstab_flat, total_row.to_frame().T]) print(\nAnalysis 5: Customer vs Category Average Spend Matrix) print(crosstab_final.round(2))这张表就是销售总监明天晨会的PPT第一页。C001在Dining和Groceries上花费接近说明是日常消费型客户C002在Groceries上远高于其他可能是家庭主妇C003在Travel上突出或是商务人士。数据洞察就藏在这张表的数字格局里。4.7 分析6高管摘要Executive Summary目标为管理层提供一页纸的关键指标包括总消费、平均单笔、交易频次、手续费总额及费率。# 一次性聚合所有基础指标 summary_base df_clean.groupby(customer_id).agg({ amount: [sum, mean, count], fee: sum }) # 扁平化 summary_flat flatten_columns(summary_base)