生产级pandas多维聚合实战:从groupby到高管看板的七步流水线

生产级pandas多维聚合实战:从groupby到高管看板的七步流水线
1. 项目概述为什么多维聚合不是“加个groupby”就能搞定的事我在银行风控部门做过三年数据管道开发后来跳槽到一家头部支付机构做BI平台架构。这七年里我亲手写过27个核心报表的聚合逻辑重构过14套历史遗留的聚合脚本也给超过60位业务分析师做过pandas聚合专项培训。最常听到的一句话是“这个需求很简单不就是按客户产品时间分组求个sum吗”——然后我就得花三天时间解释为什么直接写df.groupby([cust,prod,date]).sum()在生产环境里会崩为什么下游系统拿到结果后要再写三段代码做列名扁平化为什么滚动均值的NaN值不能简单用fillna(0)糊弄过去。这篇内容讲的不是pandas文档里抄来的语法示例而是我在真实银行级数据流水线中踩出来的坑、压测过的阈值、和业务方吵架后妥协出的方案。核心关键词是多维聚合、生产级聚合策略、滚动窗口计算、多级分组展开、自定义聚合函数——这些词背后对应的是信用卡反欺诈模型需要的30天动态阈值、监管报送要求的跨季度累计敞口、零售银行客户经理看板里“南区高端客群在奢侈品类目的月均消费”这种带业务语义的交叉表。它适合三类人第一类是刚从学校出来、只会groupby().sum()但被业务方一句“我要看每个客户在每个商户类别的交易金额中位数和手续费极差”问懵的新手第二类是已经能写复杂SQL但发现pandas聚合结果列名嵌套得像俄罗斯套娃、导出Excel时字段全乱套的中级工程师第三类是技术负责人正为“为什么同样的聚合逻辑在测试环境跑得飞快上线后拖垮整个ETL调度”焦头烂额。你不需要懂金融术语但得愿意把“median”和“max-min”当成真实业务指标来理解——比如餐饮类目交易金额中位数偏低说明该类目存在大量小额高频消费外卖/奶茶而极差大则意味着同时存在高净值客户的大额宴请这两者对风控策略的影响截然不同。我见过太多团队把聚合当语法题做写出正确代码就交差。结果呢报表凌晨两点还在跑财务部催着要日结数据下游系统解析不了MultiIndex列名硬编码写死result[transaction_amount][mean]导致某天新增一个聚合函数就全线报错滚动窗口没处理好边界把首周数据全标成NaN业务方以为系统挂了。所以这篇文章的出发点很实在不讲虚的“数据驱动”只说怎么让聚合结果稳稳当当落到业务方的Excel里、BI看板上、甚至监管报送的XML文件中。接下来所有内容都来自我笔记本里记着的那些“第N次被叫去救火”的现场记录。2. 核心设计思路为什么必须放弃“单步groupby”的思维惯性2.1 生产环境的三个铁律决定了聚合不能照搬教学案例教学视频里永远是一个干净的CSV10行数据groupby().agg()一气呵成。但真实银行流水数据是什么样我调取过某省分行上个月的信用卡交易表1.2亿行47个字段单日增量峰值达800万条。在这种量级下“先groupby再agg”这种线性思维会直接触发三重灾难内存雪崩pandas默认在内存中构建完整的分组索引。当按customer_id merchant_category transaction_date三级分组时假设客户数500万、商户类目50个、日期30天理论分组组合高达75亿种。实际虽有稀疏性但pandas仍会尝试预分配哈希表空间轻则内存占用飙升至32GB重则直接OOM。我们曾因此导致整条ETL链路卡死监控告警邮件塞爆运维邮箱。列名地狱教学案例输出的transaction_amount列下嵌套mean/medianprocessing_fee下嵌套min/max。但业务方要的是一张平铺的Excel表字段名必须是amt_mean、amt_median、fee_min、fee_max。如果用result.columns [_.join(col).strip() for col in result.columns.values]强行扁平化遇到(transaction_amount, mean)和(transaction_amount_mean, )这种命名冲突某些旧版pandas会生成下游解析直接崩溃。更糟的是当某天业务方突然要求增加std标准差你得改三处agg字典、列名映射逻辑、下游系统字段校验规则。时序陷阱所有教学案例的滚动窗口都假设数据已按时间排序。但真实交易数据入库顺序是乱的——支付网关、手机银行、POS机三路数据异步写入同一秒内可能产生时间戳相差毫秒级的记录。如果直接df.sort_values(date).rolling(30)排序本身耗时占聚合总耗时的40%且排序错误会导致滚动均值完全失真。我们曾因未校验时间戳精度在反欺诈模型中误判了237笔正常交易为“异常突增”。所以我的设计原则第一条就是聚合不是终点而是数据流转的中间站。每一个agg操作必须回答三个问题这个结果要喂给谁BI工具监管报送系统机器学习特征工程下游如何解析接受MultiIndex要求固定列名需要NaN填充策略业务含义是否可追溯当amt_range值突增能否快速定位是哪个商户类目、哪批客户导致的2.2 四层聚合架构把“一步到位”拆解成可验证、可回滚的流水线基于上述教训我现在所有生产级聚合都采用四层架构每层解决一类问题第一层原子分组Atomic Grouping只做最基础的单维度分组例如df.groupby(merchant_category)。禁用任何多列分组或复杂agg函数。目的是快速验证分组键的基数cardinality是否合理——如果merchant_category出现“UNKNOWN”占比超15%说明上游数据清洗有问题立刻阻断后续流程。这一层执行时间必须控制在30秒内否则说明分组键设计失败。第二层聚合策略编排Aggregation Orchestration将不同业务需求的聚合逻辑解耦。例如风控需要range极差财务需要sumcount运营需要rolling_mean。我用字典明确声明每种策略的输入列、输出列名、缺失值策略aggregation_specs { risk_metrics: { input_col: transaction_amount, func: lambda x: x.max() - x.min(), output_col: amt_range, na_strategy: drop # 风控场景宁可缺数据也不填0 }, finance_summary: { input_col: transaction_amount, func: [sum, count], output_col: [amt_total, txn_count], na_strategy: zero_fill # 财务报表要求零值显性化 } }这样做的好处是当监管突然要求新增amt_std只需在字典里加一项不影响其他策略审计时可清晰追溯每个字段的计算逻辑。第三层时序安全封装Time-Series Safeguard所有滚动/扩展窗口操作必须包裹在独立函数中并强制校验时间戳。核心逻辑是检查date列是否为datetime64类型否则抛出ValueError(Time column not parsed)检查是否存在重复时间戳同一客户同秒多笔交易自动添加微秒级偏移滚动窗口前必须sort_index()而非sort_values()避免索引错乱显式声明min_periods1确保首日数据不全为NaN业务方接受首日滚动值当日值第四层结构标准化Structural Standardization输出统一为扁平DataFrame列名遵循{业务域}_{原始列}_{聚合函数}规范例如risk_amt_range、finance_amt_sum。禁用任何嵌套列名。对MultiIndex结果用reset_index()而非unstack()——后者在分组维度过多时极易内存溢出。unstack()仅用于最终交付给BI工具的交叉表且必须指定fill_value0防止空单元格。这套架构看似繁琐实则大幅降低维护成本。去年我们迁移一个运行5年的聚合模块旧代码387行新架构下仅121行且新增需求平均开发时间从8小时降至1.5小时。关键在于每一层都可单独测试、单独压测、单独回滚。当某天发现滚动窗口结果异常我能直接定位到第三层的时间校验函数而不是在上千行agg代码里大海捞针。2.3 工具选型背后的血泪史为什么不用dask或spark做聚合很多人第一反应是“数据量大就上分布式”。但我在支付机构主导过一次dask迁移结果很打脸同样1.2亿行数据pandas单机聚合耗时4分32秒dask集群8节点耗时6分18秒。原因很现实dask的序列化开销、网络传输延迟、任务调度等待在IO密集型聚合场景下反而成为瓶颈。真正需要分布式的是聚合后的关联join、复杂转换如文本分词而非单纯的分组统计。Spark也有类似问题。我们曾用PySpark重写一个日结报表结果发现df.groupBy(region,product).agg(F.sum(revenue))在Spark SQL里比pandas慢3倍。根本原因是Spark的shuffle阶段必须将所有分组键通过网络传输到reduce节点而pandas的哈希分组在本地内存完成。除非你的分组键基数低如只有10个地区、数据极度倾斜某个地区占90%数据否则单机pandas仍是王者。所以我的选型铁律是聚合优先pandas关联/转换才上分布式。但pandas不是万能的必须配合三个关键补丁内存优化用category类型替代字符串分组键。某次将merchant_category从object转为category内存占用直降65%聚合速度提升2.3倍。并行加速swifter库自动判断是否启用Dask后端。对apply类操作效果显著但对原生agg函数无效需手动用concurrent.futures分片。磁盘缓存对超大中间结果用pd.to_parquet()替代to_csv()。Parquet的列式存储压缩使10GB中间表读取速度提升4倍且支持按列读取下游只需amt_sum列时不加载fee_min列。记住工具没有优劣只有适配场景。银行核心系统的聚合90%以上场景pandas足够关键是把它的能力榨干而不是幻想用更“高级”的工具掩盖设计缺陷。3. 实操细节与避坑指南那些文档里绝不会写的真相3.1 多列聚合的列名战争如何让业务方一眼看懂字段含义教学案例里result.columns输出的(transaction_amount, mean)看着清爽但放到生产环境就是灾难。业务方反馈最多的问题是“这个(processing_fee, min)字段到底是最小手续费还是最小交易金额对应的手续费”——因为括号嵌套完全丢失了业务语义。我的解决方案是语义化列名生成器它不只是拼接字符串而是注入业务规则def generate_semantic_columns(aggregation_dict, base_name): 生成带业务语义的列名 aggregation_dict: {transaction_amount: [mean,median], processing_fee: [min,max]} base_name: 业务域前缀如 risk_ 或 finance_ semantic_cols [] for col_name, agg_funcs in aggregation_dict.items(): # 为每个原始列定义业务含义映射 business_map { transaction_amount: amt, # 金额 processing_fee: fee, # 手续费 transaction_count: txn # 笔数 } short_col business_map.get(col_name, col_name) # 为每个聚合函数定义业务含义 func_map { mean: avg, # 平均值 - avg避免与mean混淆 median: med, # 中位数 - med min: min_val, # 最小值 - min_val强调是数值 max: max_val, # 最大值 - max_val sum: total, # 求和 - total count: cnt # 计数 - cnt } for func in agg_funcs if isinstance(agg_funcs, list) else [agg_funcs]: func_short func_map.get(func, func) # 组合业务域_原始列缩写_聚合函数缩写 full_name f{base_name}{short_col}_{func_short} semantic_cols.append(full_name) return semantic_cols # 使用示例 agg_dict { transaction_amount: [mean, median], processing_fee: [min, max] } print(generate_semantic_columns(agg_dict, base_namerisk_)) # 输出: [risk_amt_avg, risk_amt_med, risk_fee_min_val, risk_fee_max_val]这个函数的关键在于业务映射表。它不是技术翻译而是业务共识财务部确认sum必须叫total风控部坚持min要加val后缀以区别于“minimum threshold”阈值。每次新需求评审第一件事就是更新这个映射表。两年下来我们团队的聚合字段命名准确率从62%提升到99.8%下游系统对接时间平均缩短70%。提示列名生成器必须作为独立模块发布到公司内部PyPI仓库禁止在每个脚本里复制粘贴。版本号与业务需求文档强绑定例如v2.3.1对应《2024年Q2反欺诈模型升级需求》。3.2 自定义聚合函数的生死线何时该用lambda何时必须写named functionLambda函数写起来爽但生产环境里它是定时炸弹。我经历过两次严重事故事故一用lambda x: x.max() - x.min()计算交易极差某天上游数据混入None值lambda直接返回NaN而业务方把NaN当0参与风险评分导致37个高风险客户被漏判。事故二用lambda x: np.average(x, weightsnp.linspace(0.5,1.5,len(x)))做加权平均当某客户只有1笔交易时np.linspace报ZeroDivisionError整个ETL任务失败。所以我的铁律是所有lambda必须满足“三无”条件——无外部依赖、无状态、无副作用。一旦涉及以下任一情况立刻升格为named function需要异常处理如极差计算中过滤None和inf需要参数化如加权平均的权重系数需从配置中心读取需要日志追踪当结果异常时记录触发该计算的客户ID和时间戳这是我们的标准transaction_range函数import logging from typing import Union, Optional logger logging.getLogger(__name__) def transaction_range(series: pd.Series, ignore_inf: bool True, threshold_percentile: float 99.5) - float: 计算交易金额范围max-min带生产级防护 Args: series: 输入序列 ignore_inf: 是否忽略inf值上游数据污染常见 threshold_percentile: 百分位阈值用于识别异常极值防恶意刷单 Returns: 范围值若无有效数据返回0.0 Raises: ValueError: 当series为空或全为NaN时 # 步骤1基础清洗 clean_series series.dropna() if ignore_inf: clean_series clean_series.replace([np.inf, -np.inf], np.nan).dropna() if len(clean_series) 0: logger.warning(fEmpty series in transaction_range, customer_id: {getattr(series, name, unknown)}) return 0.0 # 步骤2异常值防护业务要求极差不能超过99.5%分位数的3倍 upper_bound clean_series.quantile(threshold_percentile / 100.0) if clean_series.max() upper_bound * 3: logger.error(fOutlier detected: max{clean_series.max():.2f} bound{upper_bound*3:.2f}, fclipping to bound. Series name: {getattr(series, name, unknown)}) clean_series clean_series.clip(upperupper_bound * 3) # 步骤3计算并记录 result clean_series.max() - clean_series.min() logger.debug(ftransaction_range calculated: {result:.2f} for {len(clean_series)} records) return result # 在agg中使用 result df.groupby(merchant_category).agg({ transaction_amount: transaction_range })这个函数的价值不在代码本身而在可审计性当风控总监问“为什么餐饮类目极差突然从22.6涨到164.25”我能立刻查日志看到Outlier detected警告进而定位到某家连锁餐厅系统故障导致单日产生127笔inf手续费。而lambda函数连日志都没法加。注意所有named function必须有lru_cache(maxsize128)装饰器对纯计算函数避免相同分组键重复计算。我们曾因此将某报表性能提升40%。3.3 滚动窗口的边界真相为什么window30不等于“最近30天”这是最常被误解的概念。rolling(window30)的window是行数不是天数。当你的数据存在缺失如周末无交易、重复系统重发、或非均匀采样POS机交易集中在白天window30可能覆盖62天或仅15天。真实业务需求永远是“最近30个自然日”而非“最近30行”。解决方案是时间窗口time-based window但它有隐藏陷阱# 错误示范假设数据每日均匀 df.set_index(date).rolling(30D).mean() # 看似正确 # 正确但危险的写法 df.set_index(date).sort_index().rolling(30D).mean() # 必须先sort_index # 更安全的生产写法 def safe_rolling_mean(df: pd.DataFrame, time_col: str, value_col: str, window_days: int 30) - pd.Series: 安全的时间窗口滚动均值处理常见陷阱 # 陷阱1时间列非datetime64 if not np.issubdtype(df[time_col].dtype, np.datetime64): raise TypeError(fTime column {time_col} must be datetime64, got {df[time_col].dtype}) # 陷阱2存在重复时间戳同一秒多笔交易 if df.duplicated(subset[time_col]).any(): # 添加微秒级偏移保持原始顺序 duplicates df.duplicated(subset[time_col], keepFalse) offset_us np.arange(duplicates.sum()) * 1000 # 微秒偏移 df.loc[duplicates, time_col] df.loc[duplicates, time_col] pd.to_timedelta(offset_us, unitus) # 陷阱3数据未按时间排序最致命 if not df[time_col].is_monotonic_increasing: logger.warning(fTime column {time_col} not sorted, auto-sorting...) df df.sort_values(time_col).reset_index(dropTrue) # 执行时间窗口滚动 result (df.set_index(time_col)[value_col] .rolling(f{window_days}D, min_periods1) .mean() .reset_index(dropTrue)) # 保持与原df索引对齐 return result # 使用 df[rolling_30d_amt] safe_rolling_mean(df, date, transaction_amount, 30)这个函数解决了三个致命问题类型校验避免30D对字符串时间列静默失败重复时间戳支付系统重发机制导致同一秒多条记录rolling(30D)会把它们全算进窗口造成结果虚高未排序rolling()在未排序数据上行为不可预测必须显式sort_values但最大的坑在min_periods1。业务方要求“首日滚动值当日值”但min_periods1会让rolling(30D)在首日返回当日值第二日返回前两日均值...这符合业务逻辑却违背数学直觉。所以必须在函数文档里用加粗强调“此参数使首N日结果为累积均值非严格30日窗口”。3.4 多级分组展开unstack的性能核弹什么时候该说不unstack()是生成交叉表的利器但也是内存杀手。当按[region,product,channel]三级分组后unstack()假设地区10个、产品50个、渠道5个理论上生成2500列。pandas会为每一列分配内存即使某地区某产品某渠道无数据也会存NaN。100万行数据经此操作内存占用可能从2GB暴涨至18GB。我的经验法则是unstack前必须做三重评估列数评估计算理论最大列数 各分组键唯一值数量乘积。若500列立即否决改用pivot_table()并指定fill_value0稀疏度评估对分组结果执行result.notna().sum().sum() / result.size若15%说明数据极度稀疏unstack()产生大量无意义NaN下游兼容性BI工具如Tableau对列数有限制Tableau Desktop上限1000列Excel 2016列数上限16384但实际加载5000列会卡死当评估不通过时我的替代方案是分层展开条件聚合# 原始危险写法三级unstack result df.groupby([region,product,channel])[revenue].sum().unstack() # 安全替代先按region分片再对每片做双层unstack def hierarchical_unstack(df: pd.DataFrame, group_cols: list, value_col: str, max_cols_per_slice: int 300) - pd.DataFrame: 分层unstack避免内存爆炸 if len(group_cols) 2: return df.groupby(group_cols)[value_col].sum().unstack(fill_value0) # 取第一个分组键作为切片依据 first_key group_cols[0] slices [] for key_val, slice_df in df.groupby(first_key): # 对剩余分组键做unstack remaining_cols group_cols[1:] if len(remaining_cols) 1: unstacked slice_df.groupby(remaining_cols)[value_col].sum() else: unstacked slice_df.groupby(remaining_cols)[value_col].sum().unstack(fill_value0) # 重命名列加入切片标识 if isinstance(unstacked.columns, pd.MultiIndex): unstacked.columns [f{key_val}_{col} for col in unstacked.columns] else: unstacked unstacked.rename(lambda x: f{key_val}_{x}) slices.append(unstacked) return pd.concat(slices, axis1) # 使用 safe_result hierarchical_unstack(df, [region,product,channel], revenue)这个函数把三级分组拆成“region切片→双层unstack”内存峰值降低70%且结果列名自带North_revenue、South_revenue前缀业务方更容易理解。去年我们用此方案将一个监管报送报表的内存占用从24GB压到6GB调度时间从22分钟缩短至4分钟。4. 全流程实战从原始交易数据到高管决策看板4.1 数据准备模拟真实银行流水的12个关键特征教学案例的10行数据毫无参考价值。真实信用卡交易数据有12个必须模拟的特征否则聚合结果全是假象特征说明生产影响时间戳精度微秒级2024-01-01 09:30:45.123456非秒级影响滚动窗口计算精度微秒差异导致同一秒交易被分到不同窗口数据倾斜5%客户贡献60%交易量长尾效应明显groupby时小客户组计算快大客户组易OOM需分片处理字段缺失merchant_category缺失率8.3%fee字段部分为None聚合前必须清洗否则mean()结果失真重复记录系统重发导致0.7%记录完全重复rolling()会把重复值全计入结果虚高异常值0.1%交易金额100万元疑似测试数据或错误max()被拉高需百分位截断时序错乱5%记录时间戳晚于入库时间网络延迟sort_values(date)无法修复需用入库时间戳校准多币种交易含USD/EUR/JPY需统一换算agg()前必须apply汇率转换否则sum()无意义状态字段transaction_status含completed,failed,pending仅completed状态参与聚合failed需排除渠道标识channel字段含app,web,pos,atm影响手续费率不同渠道手续费计算逻辑不同需分渠道聚合客户分层customer_tier含basic,premium,vip影响风控阈值VIP客户交易极差容忍度更高需分层计算range商户分级merchant_risk_level含low,medium,high影响监控频率高风险商户需7天滚动低风险商户用30天滚动地理编码region字段含国家、省、市三级需支持任意粒度聚合unstack()时需支持动态选择展开层级下面生成符合上述特征的模拟数据代码已通过生产环境压测import pandas as pd import numpy as np from datetime import datetime, timedelta import random def generate_bank_transactions(n_rows: int 1000000): 生成符合生产特征的银行交易数据 # 基础时间范围近90天 end_date datetime(2024, 3, 31) start_date end_date - timedelta(days90) dates pd.date_range(start_date, end_date, freqS) # 秒级时间戳 # 随机采样n_rows个时间戳模拟不均匀交易 timestamps np.random.choice(dates, sizen_rows, replaceTrue) # 客户ID模拟倾斜5%客户占60%交易 customers [C str(i).zfill(4) for i in range(1, 10001)] customer_weights [0.6 if i 500 else 0.4/9500 for i in range(10000)] # 前500客户权重高 customer_ids np.random.choice(customers, sizen_rows, pcustomer_weights) # 商户类目含缺失 categories [Groceries, Dining, Travel, Retail, Electronics, Healthcare] category_missing_mask np.random.random(n_rows) 0.083 # 8.3%缺失 categories_full np.where(category_missing_mask, None, np.random.choice(categories, n_rows)) # 交易金额含异常值 amounts np.random.lognormal(mean5.5, sigma1.2, sizen_rows).round(2) # 注入0.1%异常值100万 outlier_mask np.random.random(n_rows) 0.001 amounts[outlier_mask] np.random.uniform(1000000, 5000000, outlier_mask.sum()).round(2) # 手续费部分为None fee_mask np.random.random(n_rows) 0.05 # 5%为None fees np.where(fee_mask, None, (amounts * np.random.uniform(0.01, 0.03, n_rows)).round(2)) # 渠道和状态 channels np.random.choice([app, web, pos, atm], n_rows) statuses np.random.choice([completed, failed, pending], n_rows, p[0.92, 0.05, 0.03]) # 构建DataFrame df pd.DataFrame({ transaction_id: [fTXN{str(i).zfill(8)} for i in range(n_rows)], date: timestamps, customer_id: customer_ids, merchant_category: categories_full, transaction_amount: amounts, processing_fee: fees, channel: channels, transaction_status: statuses, currency: np.random.choice([USD, EUR, CNY], n_rows), merchant_risk_level: np.random.choice([low, medium, high], n_rows, p[0.6, 0.3, 0.1]) }) # 引入重复记录0.7% dup_indices np.random.choice(df.index, sizeint(n_rows*0.007), replaceFalse) df_duplicated df.loc[dup_indices].copy() df_duplicated[transaction_id] df_duplicated[transaction_id] _dup df pd.concat([df, df_duplicated], ignore_indexTrue) return df # 生成100万行数据生产环境最小测试集 df_raw generate_bank_transactions(1000000) print(fGenerated {len(df_raw)} rows with production characteristics) print(df_raw.info())这段代码生成的数据具备全部12个特征可直接用于压力测试。注意generate_bank_transactions()函数本身就是一个生产级组件——它被封装在公司内部数据质量平台中所有新聚合需求必须先用此函数生成测试数据通过12项特征校验后才能进入开发。4.2 七步聚合流水线从原始数据到高管看板的完整路径现在我们用前面设计的四层架构走一遍真实的聚合流水线。目标是生成一份《客户交易健康度报告》供CRO首席风险官每日审阅。步骤1原子分组与数据探查耗时30秒# 仅按customer_id分组快速探查 customer_groups df_raw.groupby(customer_id) # 探查关键指标不触发全量计算 print(Customer count:, customer_groups.ngroups) print(Avg transactions per customer:, len(df_raw) / customer_groups.ngroups) print(Top 5 customers by transaction count:) print(customer_groups.size().sort_values(ascendingFalse).head()) # 检查merchant_category缺失率 missing_cat_rate df_raw[merchant_category].isnull().mean() print(fMerchant category missing rate: {missing_cat_rate:.1%}) if missing_cat_rate 0.05: raise ValueError(Missing rate too high, data quality issue!)实操心得这一步必须自动化为ETL前置检查。我们用Airflow调度此脚本若缺失率5%或客户数1000自动触发告警并暂停下游任务。去年因此拦截了3次上游数据管道故障。步骤2清洗与过滤业务规则注入# 业务规则1仅completed状态交易参与聚合 df_clean df_raw[df_raw[transaction_status] completed].copy() # 业务规则2过滤异常金额99.5%分位数的3倍 amt_upper_bound df_clean[transaction_amount].quantile(0.995) * 3 df_clean df_clean[df_clean[transaction_amount] amt_upper_bound] # 业务规则3merchant_category缺失的记录用UNKNOWN填充非删除 # 因为监管要求统计UNKNOWN类目风险敞口 df_clean[merchant_category] df_clean[merchant_category].fillna(UNKNOWN) # 业务规则4手续费为None的按渠道费率补全 channel_rates {app: 0.015, web: 0.018, pos: 0.022, atm: 0.01} df_clean[processing_fee] df_clean.apply( lambda row: row[transaction_amount] * channel_rates.get(row[channel], 0.0