Pandas多维聚合实战:银行风控中的高效分析流水线

📅 2026/7/14 21:06:39
Pandas多维聚合实战:银行风控中的高效分析流水线
1. 项目概述为什么“多维聚合”不是Pandas进阶技巧而是业务分析的生存技能我在银行风控部门干了七年从刚毕业写SQL查数的分析师到带三个人小团队做反欺诈模型的数据架构师。这七年里我亲手重构过四套核心报表系统也给二十多个业务部门做过数据赋能培训。最常被问到的问题不是“怎么建模”而是“老师这个指标能不能按客户产品时间三个维度一起算现在跑三次groupby再merge一跑就是四十分钟领导在催。”——这句话背后藏着的是真实世界里每天都在发生的效率损耗、逻辑错位和决策延迟。“Part 20: Data Manipulation in Multi-Dimensional Aggregation”这个标题听起来像教科书里的章节编号但在我日常工作中它对应的是一个具体、高频、高价值的场景当业务问题天然具备多个观察视角时如何让数据处理逻辑与业务思考方式完全对齐而不是被迫拆解、拼接、反复试错。它不是“你会不会用unstack”而是“你能不能在五分钟内把‘华东区VIP客户过去三个月在奢侈品类目的平均单笔消费、交易频次、费用占比、以及最近七天滚动均值’这五个指标一次性干净利落地吐出来并且能直接粘贴进管理层晨会PPT”。关键词里提到的“Towards AI - Medium”其实暗示了一个更本质的事实这类内容之所以在技术社区广泛传播恰恰因为它戳中了大量从业者的痛点——我们手里的数据越来越丰富但分析路径却越来越笨重。原始资料里提到的“商业银行业务分析师”“风险经理”“运营报告流水线”这些角色我太熟悉了。他们不需要知道pandas底层Cython怎么调度内存但他们必须清楚为什么用agg({col: [mean, std]})比写两个groupby().mean()快3倍以上为什么rolling(window7).mean()返回的索引结构会让下游Excel导出报错为什么unstack()后看似整齐的表格在接入BI工具时反而要额外加一层reset_index()。这些细节决定的不是代码能不能跑通而是分析结果能不能准时、准确、可解释地交付出去。所以这篇博文我不会把它写成一篇“Pandas聚合函数语法大全”。我会以一个真实银行信用卡数据分析项目的全生命周期为线索带你从需求理解、方案设计、代码实现、性能调优一直走到上线后的监控与迭代。每一个技术点都绑定一个具体的业务后果比如没处理好rolling计算后的NaN会导致欺诈预警漏掉关键窗口期unstack()时没设fill_value0会让销售总监在看区域对比图时误判某个产品线“零销量”自定义函数里忘了加if len(series) 0: return np.nan会在某天凌晨三点因为一条空数据把整个ETL任务卡死。这些才是你在生产环境里真正会踩的坑也是我愿意花时间讲透的“为什么”。2. 核心思路拆解为什么必须放弃“单维度思维”拥抱“多维原生聚合”2.1 业务问题的本质是多维的强行降维是最大的效率陷阱先看一个我上周刚处理的真实case。某分行想分析“不同年龄段客户在周末 vs 工作日的餐饮类消费偏好变化”。表面看这是个简单的交叉分析但实际执行时业务方提的需求是“我们要看到每个年龄段分5组在周六、周日、周一至周五这三类日期上的1人均单笔消费金额剔除异常值后的中位数2总交易笔数3使用优惠券的交易占比4最近30天滚动平均单笔消费5从开户日起的累计消费总额。”如果按传统思路你会怎么做大概率是先groupby([age_group, day_type])算出基础统计再单独跑一遍rolling(30)再groupby([age_group, day_type])再单独跑一遍expanding().sum()再groupby([age_group, day_type])最后把三张表merge再手动计算占比……这个过程我实测过在1200万条交易记录上耗时18分42秒中间还因内存溢出失败两次。而用本篇要讲的多维聚合原生方案同一台机器2分17秒一次完成结果字段命名清晰无任何中间表。为什么差距这么大根本原因在于Pandas的groupby操作本身就是一个昂贵的分组键哈希计算过程。每一次groupby它都要重新扫描整个DataFrame构建新的分组索引分配内存块。你做三次groupby就等于让CPU和内存重复劳动三次。而原生多维聚合是在一次分组扫描中同时触发所有聚合逻辑。这就像去超市买东西你按品类水果、蔬菜、肉分三次逛完整个超市还是只逛一次但手里拿着三张不同品类的购物清单边走边往对应袋子里装后者显然高效得多。提示agg()接受字典参数的设计绝非为了语法炫技。它的底层机制是在groupby生成的分组迭代器上对每个分组对象并行调用你指定的所有聚合函数。这意味着amount: [mean, median]和fee: [min, max]是在同一个分组数据块上用不同的函数指针分别执行共享缓存避免重复切片。2.2 “多维”不等于“堆砌groupby”关键在聚合粒度与结果形态的精准匹配很多初学者一看到“多维”第一反应就是groupby([col1, col2, col3])。这没错但远远不够。真正的挑战在于业务需要的结果形态往往和原始分组粒度不一致。比如原始资料里那个例子result df_sales.groupby([region,product])[revenue].mean().unstack()这里groupby([region,product])产生的是一个MultiIndex Series索引是(North, Widget)这样的元组。但业务方要的是一张“行是Region、列是Product”的二维表格。unstack()的作用是把MultiIndex的某一层这里是product从索引“拉”到列上从而重塑数据结构。这个动作我称之为“维度折叠”。它解决的不是计算问题而是表达问题。没有unstack()你的结果是region product North Widget 15500.0 Gadget 12000.0 South Widget 18000.0 Gadget 13750.0这对程序员友好但对业务人员极其不友好——他们得用眼睛在一堆嵌套文本里找“North Widget”的值无法一眼看出“Widget在South比North高多少”。unstack()之后变成product Gadget Widget region North 12000.0 15500.0 South 13750.0 18000.0这才是人脑能直接处理的矩阵结构。我在给分行做培训时专门做过测试让10个业务经理看同一组数据一组给MultiIndex格式一组给unstack()后格式要求他们找出“哪个区域哪个产品组合贡献了最高收入”前者平均耗时47秒后者平均耗时8秒。数据科学的价值一半在算得准一半在看得懂。2.3 时间维度的特殊性滚动与扩展窗口是业务逻辑的时间锚点原始资料里把rolling和expanding单独列为两节这很正确但我想强调一个更深层的逻辑它们不是“另一种聚合”而是“聚合在时间轴上的动态投影”。mean()给出的是静态快照rolling(7).mean()给出的是一个滑动窗口内的趋势感知expanding().sum()给出的是一个从起点开始的累积轨迹。举个风控实例。我们监控商户欺诈风险核心指标之一是“单日交易金额标准差”。但单纯看某一天的std没意义——今天突然高可能是大促。真正有用的是“过去7天滚动std的均值” vs “历史整体std” 的比值。如果比值1.8说明近期波动剧烈需人工核查。这个计算必须用rolling(7).std()然后对结果再mean()。如果你试图用groupby(merchant_id).apply(lambda x: x[amount].rolling(7).std().mean())性能会断崖式下跌因为apply是Python级循环而rolling是Cython优化的向量化操作。同样expanding在客户价值管理中不可替代。计算“客户生命周期价值CLV”本质就是expanding().sum()。但难点在于业务上要求“从客户首笔交易日开始计算”而不是从数据集最早日期开始。这就需要先按customer_id分组对每组内部的交易按日期排序再应用expanding。原始资料的代码里df_ts.groupby(category)[daily_revenue].expanding().sum()是简化版真实场景必须是df.groupby(customer_id).apply(lambda g: g.sort_values(date)[amount].expanding().sum())否则会把不同客户的交易混在一起累加结果完全错误。注意expanding()默认从分组内第一条记录开始但前提是数据在分组内已按时间排序。pandas不会自动帮你排序我见过太多线上事故根源就是忘了sort_values()这一步导致“累计值”变成乱序累加数值毫无业务含义。3. 核心细节解析与实操要点从语法到生产环境的硬核补全3.1 多重聚合的“字典映射”不只是语法糖更是性能与可维护性的双重保障原始资料展示了agg({transaction_amount: [mean,median], processing_fee: [min,max]})的基本用法。但这只是冰山一角。在真实项目中你需要掌握以下关键细节第一列名冲突与层级扁平化。agg()返回的DataFrame列名是MultiIndex外层是原始列名内层是聚合函数名。这在后续处理中极易引发问题。比如你想取transaction_amount的mean值代码是result[(transaction_amount, mean)]而不是result[transaction_amount_mean]。这种写法不仅难读而且一旦上游列名变更下游代码全崩。我的解决方案是在agg()后立即用pipe()链式调用进行扁平化。这是我团队的标准模板def flatten_columns(df): 将MultiIndex列名扁平化为col_func格式如(amount, mean) - amount_mean if isinstance(df.columns, pd.MultiIndex): df.columns [_.join(col).strip() for col in df.columns.values] return df # 使用方式 result (df.groupby(merchant_category) .agg({transaction_amount: [mean, median], processing_fee: [min, max]}) .pipe(flatten_columns) .round(2)) print(result) # 输出 # transaction_amount_mean transaction_amount_median processing_fee_min processing_fee_max # merchant_category # Dining 55.10 52.30 1.36 2.03 # Retail 150.78 125.50 2.68 6.31 # Travel 221.78 189.60 5.69 9.60第二混合聚合类型内置函数、lambda、命名函数的协同策略。原始资料提到了lambda和命名函数但没说清何时该用哪种。我的经验是内置函数mean,sum等优先使用。它们是Cython编译的速度最快内存占用最小。简单逻辑如x.max() - x.min()用lambda。代码短意图明确无需额外定义函数。复杂逻辑含条件分支、多步骤计算、需文档说明必须用命名函数。理由有三一是便于单元测试二是函数名本身就是业务语义如calculate_fraud_risk_score三是docstring能永久记录业务规则比代码注释靠谱得多。看一个真实风控函数def calculate_transaction_volatility(series): 计算交易金额波动率(标准差 / 均值) * 100用于识别高风险商户 业务规则 - 若均值为0或空返回np.nan避免除零 - 若标准差为0返回0.0表示绝对稳定 - 结果保留1位小数 if len(series) 0 or series.mean() 0: return np.nan volatility (series.std() / series.mean()) * 100 return round(volatility, 1) # 在agg中使用 result df.groupby(merchant_id).agg({ amount: [mean, std, calculate_transaction_volatility], transaction_count: sum }).pipe(flatten_columns)第三性能陷阱避免在agg中进行昂贵的Series操作。agg()内部会对每个分组调用函数如果函数里包含series.sort_values()、series.nlargest(10)这类操作性能会急剧下降。因为sort_values()是O(n log n)而分组可能有上万个。正确做法是先在全局做一次排序/筛选再分组聚合。例如要取每个商户的“最大单笔交易”不要写amount: lambda x: x.nlargest(1).iloc[0]而应# 高效做法先全局取最大值再分组 df_max df.loc[df.groupby(merchant_id)[amount].idxmax()] # 然后直接 merge 或 set_index result df_max.set_index(merchant_id)[[amount]].rename(columns{amount: max_transaction})3.2 自定义聚合函数业务逻辑的“封装容器”而非临时补丁原始资料的weighted_average函数是个好例子但它缺少生产环境最关键的两个要素健壮性和可观测性。我来补全健壮性所有自定义函数必须处理边界情况。len(series) 2只是开始还要考虑series是否全为NaNweights数组长度是否与series匹配np.average是否会因权重和为0而报错def robust_weighted_average(series, weight_coldate): 健壮加权平均用日期作为权重越近权重越大自动处理空值和长度不足 if series.isna().all(): return np.nan if len(series) 2: return series.mean() # 构建时间权重将日期转为序数归一化到[0.5, 1.5] try: dates pd.to_datetime(series.index.get_level_values(weight_col) if hasattr(series.index, get_level_values) else series.index) weights (dates - dates.min()) / (dates.max() - dates.min() pd.Timedelta(days1)) weights weights * 1.0 0.5 # 映射到[0.5, 1.5] except: # 回退到等权重 weights np.ones(len(series)) # 安全计算加权平均 try: return np.average(series.dropna(), weightsweights[~series.isna()]) except: return series.mean() # 使用 result df.groupby(customer_id).agg({amount: robust_weighted_average})可观测性生产环境中你必须知道函数在哪些分组上失败了。我习惯在函数内加日志用warnings.warn或写入日志文件并在调用后检查结果中的np.nan比例import warnings def logged_weighted_avg(series): result robust_weighted_average(series) if pd.isna(result): warnings.warn(fWeighted avg failed for group with {len(series)} items, mean{series.mean():.2f}) return result # 调用后检查 result df.groupby(customer_id)[amount].apply(logged_weighted_avg) nan_ratio result.isna().mean() if nan_ratio 0.01: # 超过1%失败触发告警 send_alert(fWeighted avg failed for {nan_ratio:.1%} of groups)3.3 滚动窗口时间序列分析的“显微镜”但必须校准焦距原始资料的rolling(3).mean()示例过于理想化。真实金融数据充满噪声节假日无交易、数据延迟入库、批量补录。rolling窗口若不加干预会产生大量误导性NaN或错误值。关键参数详解与选型逻辑参数作用生产建议为什么window窗口大小必须用business-day计数而非calendar-day交易只在工作日发生用window7会把周末也计入导致周一的值基于“上周五周末无数据周一”三个点失真严重。应使用windowpd.offsets.BDay(7)min_periods最小有效点数设为window//2 1默认min_periods1第一天就有值但毫无统计意义。设为半窗长确保有一定数据基础center是否居中False默认居中窗口如centerTrue会使结果与原始时间点错位不利于因果分析如“周三的滚动均值”应反映周一到周三而非周二到周四closed窗口闭合方式right默认表示窗口包含右端点符合“截至今日”的业务表述习惯实战代码# 正确的滚动计算工作日窗口最小点数保障 df_ts df_ts.sort_index() # 确保时间索引有序 df_ts[rolling_7d_avg] ( df_ts.groupby(customer_id)[amount] .rolling( windowpd.offsets.BDay(7), # 7个工作日 min_periods4, # 至少4个点才计算 closedright ) .mean() .reset_index(level0, dropTrue) # 保持索引对齐 ) # 处理NaN业务上缺失值通常意味着“无活动”应前向填充ffill或设为0 df_ts[rolling_7d_avg] df_ts[rolling_7d_avg].fillna(methodffill) # 前向填充 # 或 df_ts[rolling_7d_avg] df_ts[rolling_7d_avg].fillna(0) # 设为0一个血泪教训我们曾用window30计算月度滚动均值但未设min_periods。某天数据延迟导致当月前5天无数据rolling(30)从第6天开始就返回NaN整个仪表盘显示“客户活跃度归零”引发业务部门恐慌。后来加了min_periods15并加了监控告警问题解决。3.4 扩展窗口累积计算的“时间之河”但源头必须清澈expanding()看似简单但它是生产环境中最容易出错的聚合之一。错误根源几乎都来自数据质量和分组逻辑。数据质量陷阱expanding().sum()对NaN极度敏感。如果某客户某天的交易金额是NaN那么从那天起所有后续的cumulative_sum都会是NaN。这不是bug是数学必然。但业务上NaN应被视为空值0而非中断信号。解决方案在expanding前必须对目标列进行确定性填充# 错误直接expanding # df[cumsum] df.groupby(customer_id)[amount].expanding().sum() # 正确先填充再expanding df_filled df.copy() df_filled[amount] df_filled[amount].fillna(0) # 或用前向填充fillna(methodffill) df_filled[cumulative_spend] ( df_filled.groupby(customer_id)[amount] .expanding() .sum() .reset_index(level0, dropTrue) )分组逻辑陷阱expanding()必须在每个分组内独立计算。原始资料的df_ts.groupby(category)[daily_revenue].expanding().sum()是正确的因为它按category分组了。但如果你忘了groupby直接df[daily_revenue].expanding().sum()就会把所有类别的收入混在一起累加结果完全错误。更隐蔽的陷阱索引顺序。expanding()默认按索引顺序累加。如果date是索引但数据未按日期排序比如从数据库导出时乱序expanding会按索引物理顺序累加而非时间逻辑顺序。因此expanding前必须sort_index()或sort_values(date)。# 绝对安全的写法 df_sorted df.set_index(date).sort_index() # 先设索引再按索引排序 df_sorted[cumsum] ( df_sorted.groupby(customer_id)[amount] .expanding() .sum() .reset_index(level0, dropTrue) )4. 实操过程与核心环节实现一个银行信用卡分析项目的完整复现4.1 项目背景与数据准备从模糊需求到可执行Schema我们接到的需求来自信用卡中心“请提供一份客户分群报告支持按地区、客户等级、消费类别三个维度分析其近90天的消费行为特征用于下季度营销策略制定。” 需求很模糊但作为数据工程师我们必须把它翻译成可执行的Schema。第一步明确业务维度与指标。和业务方开会确认地区region总行定义的6大区域华北、华东...客户等级tier白金、金卡、普卡由风控模型输出消费类别category银联标准12类餐饮、零售、旅游...核心指标total_spend总消费额sumavg_ticket平均单笔消费mean剔除10元和50000元的异常值freq交易频次counthigh_value_pct高价值交易3000元占比customrolling_30d_avg30天滚动平均单笔消费rollingcumulative_spend从开户日起的累计消费expanding第二步构建模拟数据集。真实数据涉及隐私我们用numpy和pandas生成高度仿真的数据import pandas as pd import numpy as np from datetime import datetime, timedelta np.random.seed(42) # 生成10万条模拟交易 n_records 100000 customers [fC{str(i).zfill(4)} for i in np.random.randint(1000, 9999, n_records)] regions np.random.choice([华北, 华东, 华南, 华中, 西南, 西北], n_records) tiers np.random.choice([白金, 金卡, 普卡], n_records, p[0.1, 0.3, 0.6]) categories np.random.choice([餐饮, 零售, 旅游, 娱乐, 教育, 医疗, 交通, 通讯, 家居, 数码, 服装, 其他], n_records) # 生成时间过去90天但加入周末和节假日分布 start_date datetime(2024, 1, 1) dates pd.date_range(startstart_date, endstart_date timedelta(days90), freqD) # 模拟交易日期工作日概率高周末低 date_probs [0.9 if d.weekday() 5 else 0.3 for d in dates] transaction_dates np.random.choice(dates, sizen_records, pdate_probs/np.sum(date_probs)) # 生成金额不同类别有不同分布 amounts [] for cat in categories: if cat in [旅游, 数码]: amounts.append(np.random.lognormal(8.5, 0.8)) # 高额 elif cat in [餐饮, 零售]: amounts.append(np.random.lognormal(6.0, 0.6)) # 中额 else: amounts.append(np.random.lognormal(4.5, 0.5)) # 低额 # 构建DataFrame df pd.DataFrame({ customer_id: customers, region: regions, tier: tiers, category: categories, date: transaction_dates, amount: np.round(amounts, 2), account_open_date: np.random.choice(pd.date_range(2020-01-01, 2023-12-31), n_records) }) # 添加客户等级映射真实场景来自另一张表 tier_mapping {C1234: 白金, C5678: 金卡, C9012: 普卡} # ... 实际中通过merge加载 print(数据集概览) print(df.info()) print(\n前5行) print(df.head())第三步定义核心聚合函数。将前面讨论的健壮函数全部实现def clean_mean(series): 清洗后求均值剔除10和50000的异常值 cleaned series[(series 10) (series 50000)] return cleaned.mean() if len(cleaned) 0 else np.nan def high_value_pct(series): 高价值交易3000占比 if len(series) 0: return np.nan return ((series 3000).sum() / len(series) * 100).round(1) def rolling_30d_avg(series): 30个工作日滚动均值 if len(series) 10: # 数据太少不计算 return np.nan return series.rolling(windowpd.offsets.BDay(30), min_periods15).mean().iloc[-1] def cumulative_spend(series, open_date_series): 按开户日起的累计消费 # 这里需要传入开户日期实际中应在groupby前merge好 pass # 后续在分组中实现4.2 核心聚合流程七步构建生产级分析流水线现在我们进入最核心的实操环节。整个流程严格遵循生产环境规范可复现、可监控、可审计、可回滚。Step 1数据预处理与质量检查# 1.1 按时间过滤只取近90天 cutoff_date df[date].max() - pd.Timedelta(days90) df_recent df[df[date] cutoff_date].copy() print(f过滤后记录数{len(df_recent)}) # 1.2 关键字段空值检查 null_report df_recent[[region, tier, category, amount]].isna().sum() if null_report.sum() 0: print(警告发现空值) print(null_report[null_report 0]) # 业务决策region/tier为空则dropamount为空则填0 df_recent df_recent.dropna(subset[region, tier]) df_recent[amount] df_recent[amount].fillna(0) # 1.3 异常金额标记为后续清洗做准备 df_recent[is_outlier] ((df_recent[amount] 10) | (df_recent[amount] 50000)) outlier_rate df_recent[is_outlier].mean() print(f异常交易占比{outlier_rate:.2%})Step 2构建多维分组索引# 2.1 创建分组键region tier category group_keys [region, tier, category] # 2.2 验证分组唯一性避免笛卡尔爆炸 n_groups df_recent.groupby(group_keys).ngroups print(f预期分组数{len(df_recent[region].unique()) * len(df_recent[tier].unique()) * len(df_recent[category].unique())}) print(f实际分组数{n_groups}) # 2.3 按日期排序为滚动和扩展计算做准备 df_sorted df_recent.sort_values([customer_id, date]).reset_index(dropTrue)Step 3执行多重聚合核心# 3.1 定义聚合字典混合内置、lambda、命名函数 agg_dict { amount: [ (total_spend, sum), (avg_ticket, clean_mean), (freq, count), (high_value_pct, high_value_pct) ], is_outlier: [(outlier_rate, mean)] # 异常率 } # 3.2 执行聚合 print(正在执行多维聚合...) start_time pd.Timestamp.now() result_base df_sorted.groupby(group_keys).agg(agg_dict).pipe(flatten_columns) end_time pd.Timestamp.now() print(f基础聚合耗时{(end_time - start_time).total_seconds():.2f}秒) # 3.3 查看结果结构 print(\n基础聚合结果前5行) print(result_base.head())Step 4添加滚动窗口计算# 4.1 为每个客户-地区-等级-类别组合计算滚动均值 # 注意rolling必须在customer_id粒度上计算再按多维分组聚合 print(正在计算30天滚动均值...) # 先按customer_id分组计算每个客户的滚动均值 df_sorted[rolling_30d_avg] ( df_sorted.groupby(customer_id)[amount] .rolling(windowpd.offsets.BDay(30), min_periods15) .mean() .reset_index(level0, dropTrue) ) # 4.2 将滚动均值关联回多维分组 # 方法对每个customer_id取其最后一条记录的rolling值代表最新状态 last_rolling df_sorted.groupby(customer_id)[rolling_30d_avg].last() # 将其merge到基础结果上 df_customers df_sorted[[customer_id, region, tier, category]].drop_duplicates() df_customers[rolling_30d_avg] df_customers[customer_id].map(last_rolling) # 4.3 按多维分组对rolling值求均值即该组合下所有客户的平均滚动均值 rolling_agg df_customers.groupby(group_keys)[rolling_30d_avg].mean().round(2) result_base result_base.join(rolling_agg, ongroup_keys)Step 5添加扩展窗口累计消费# 5.1 按customer_id和account_open_date计算每个客户的累计消费 print(正在计算累计消费...) # 先按customer_id和开户日分组确保每个客户只有一条开户记录 df_open df_sorted.groupby(customer_id)[account_open_date].min().reset_index() # 将开户日合并到主数据 df_with_open df_sorted.merge(df_open, oncustomer_id, howleft) # 按customer_id分组对amount进行expanding求和 df_with_open df_with_open.sort_values([customer_id, date]) df_with_open[cumulative_spend] ( df_with_open.groupby(customer_id)[amount] .expanding() .sum() .reset_index(level0, dropTrue) ) # 5.2 取每个客户的最终累计值 final_cumsum df_with_open.groupby(customer_id)[cumulative_spend].last() # 5.3 关联并聚合 df_customers[cumulative_spend] df_customers[customer_id].map(final_cumsum) cumsum_agg df_customers.groupby(group_keys)[cumulative_spend].mean().round(2) result_base result_base.join(cumsum_agg, ongroup_keys)Step 6维度折叠与结果整形# 6.1 将region-tie-category三维结果按业务需求reshape # 例如生成“地区 x 客户等级”的交叉表 print(正在生成交叉表...) crosstab_region_tier result_base.groupby([region, tier])[[total_spend, freq]].sum().unstack(fill_value0) crosstab_region_tier.columns [_.join(col).strip() for col in crosstab_region_tier.columns.values] print(地区-等级交叉表总消费) print(crosstab_region_tier[[total_spend_sum]]) # 6.2 生成“客户等级 x 消费类别”的热力图数据 heatmap_data result_base.reset_index().pivot_table( indextier, columnscategory, valuesavg_ticket_clean_mean, aggfuncmean, fill_value0 ).round(2) print(\n客户等级-消费类别平均单笔消费) print(heatmap_data)Step 7结果验证与业务交付# 7.1 业务逻辑验证检查关键约束 print(\n 业务