pandas多维聚合生产实战:银行场景下的滚动窗口与自定义函数优化

发布时间:2026/7/15 3:20:17
pandas多维聚合生产实战:银行场景下的滚动窗口与自定义函数优化 1. 项目概述为什么多维聚合不是“加个groupby”就能搞定的事我在银行数据平台组干了八年从最早用SQL写几十行嵌套子查询做客户分层到后来带团队重构整个风险指标计算引擎踩过的坑比别人走的路还多。今天聊的这个主题——“多维聚合中的数据操作”听起来像教科书里的一个章节标题但实际在生产环境里它直接决定着风控模型能不能按时上线、月度经营分析报告能不能准时发出、甚至监管报送数据有没有逻辑性错误。我见过太多人把df.groupby().agg()当成万能胶水结果在测试环境跑通一上生产就报内存溢出也见过分析师花三天调通一个滚动均值却因为没处理好时间索引对齐导致下游BI图表全错位。这不是代码能力问题而是对pandas聚合机制底层逻辑的理解断层。核心关键词是多维聚合、生产级聚合策略、银行分析场景、滚动窗口、自定义聚合函数。这几个词串起来就是真实世界的数据分析日常你面对的从来不是一张干净的CSV而是跨时间、跨客户、跨产品、跨渠道的混合维度数据流你要输出的也不是单个数字而是供风控系统实时调用的指标、给高管看的交叉矩阵、嵌入报表的动态趋势线。比如某次我们为信用卡反欺诈模块升级交易波动率指标需求文档里只写了“计算每个商户类别的30天滚动标准差”但落地时发现原始交易日志里存在大量重复记录、部分商户无连续30天数据、夜间批量任务必须在15分钟内完成全量计算——这些细节任何教程都不会告诉你但它们才是决定项目成败的关键。这篇文章不讲概念定义也不堆砌API参数。我会带你回到一个真实银行数据分析现场从一张模拟的60条信用卡交易数据出发手把手复现7个典型分析任务——不是演示“怎么写”而是解释“为什么这么写”、“不这么写会怎样”、“线上出问题怎么快速定位”。所有代码都经过我本地实测Python 3.10 pandas 2.2.2参数选择有明确业务依据比如为什么滚动窗口设为7天而不是5天错误处理有具体方案比如NaN值在不同场景下的处置逻辑。如果你正在做金融、电商、SaaS等强分析驱动的业务或者刚接手一个遗留的数据管道需要优化这篇就是为你写的实战手册。2. 多维聚合的核心设计思路从“算得出来”到“算得稳、算得快、算得懂”2.1 为什么基础groupby在生产环境必然失效先说个血泪教训去年我们迁移一个零售银行的客户价值评分模型原SQL脚本用GROUP BY customer_id, product_line, region配合多个CASE WHEN计算不同产品的加权贡献度跑批耗时47分钟。迁移到pandas后第一版代码直接用df.groupby([customer_id,product_line,region]).agg({...})结果在测试集群上跑了2小时18分钟OOM Killed三次。问题出在哪不是数据量大而是pandas默认的groupby实现机制与生产环境的数据分布特征严重错配。关键点在于pandas的groupby操作本质是内存中构建哈希表进行分组。当分组键group keys的组合数极高时比如百万级客户×千级产品×百级区域千亿级可能组合哈希表会疯狂膨胀。更致命的是原始数据中存在大量稀疏组合——90%的客户只买过1-2类产品但pandas仍会为所有可能组合预留空间。这就像租仓库明明只存10箱货却按1000箱的规格付租金。解决方案不是换工具而是重构分组逻辑预过滤在groupby前用df.query(transaction_amount 10)剔除无效交易减少分组基数键压缩将region字段从字符串如North-East-District-05映射为整数IDregion_id哈希计算开销直降60%分块处理对超大数据集用pd.read_csv(..., chunksize50000)分批聚合再用pd.concat()合并结果内存占用稳定在1.2GB以内实测数据。提示永远不要在未评估分组键基数的情况下直接执行多维groupby。用df.groupby([a,b,c]).ngroups快速探查组合数超过10万就要警惕。2.2 生产级聚合的三大设计原则基于八年银行数据平台经验我总结出三条铁律每一条都对应着线上事故的惨痛教训第一聚合即契约Aggregation as Contract每个agg操作必须明确回答三个问题输入数据的业务含义是什么输出指标的业务口径是否与监管要求一致中间计算过程是否可审计比如计算“客户月均交易额”业务方要求“仅统计成功交易”但原始数据中status字段有completed、success、settled三种等效值。如果agg函数里没做标准化映射产出的指标就会被风控部门打回重算。我们在代码里强制要求所有聚合前必加df df[df[status].isin([completed,success,settled])]并在函数docstring里注明依据《XX银行交易状态编码规范V3.2》。第二性能即功能Performance as Feature在银行系统里“跑得慢”等于“功能缺陷”。一个T1的日报任务如果从凌晨2点拖到5点会阻塞后续所有ETL作业。我们规定所有聚合操作必须满足“三秒定律”——单次groupby耗时≤3秒数据量≤100万行。达标手段包括用CategoricalDtype替代字符串列df[category] df[category].astype(category)内存降低70%groupby提速2.3倍预排序对时间序列聚合先df.sort_values([customer_id,date])再groupby(customer_id).rolling(30D)比未排序快4.8倍pandas 2.2优化向量化替代applydf.groupby(id)[value].apply(lambda x: x.max()-x.min())比df.groupby(id)[value].agg(lambda x: x.max()-x.min())慢17倍。第三结构即接口Structure as Interface生产系统中聚合结果要喂给下游的BI工具、风控引擎、监管报送系统。这些系统对DataFrame结构有严格要求。比如监管报送系统只接受扁平化列名customer_id,avg_amount,std_amount而pandas默认的MultiIndex列(amount,mean)会导致解析失败。因此所有聚合后必须执行result.columns [_.join(col).strip() for col in result.columns.values]并用result.reset_index()确保索引干净。这个步骤不是锦上添花而是上线必备。2.3 银行场景下的聚合模式选型决策树不同业务问题需要不同的聚合范式选错模式会导致事倍功半。我画了一张决策树这是我们在内部培训中反复验证的开始 │ ├─ 问题是否涉及时间维度 → 是 → 进入时间序列分支 │ │ │ ├─ 需要对比当前与历史 → 是 → 选滚动窗口rolling │ │ │ │ │ └─ 需要累计效果 → 是 → 选扩展窗口expanding │ │ │ └─ 无需时间对比 → 否 → 回到主干 │ ├─ 问题是否需多维度交叉分析 → 是 → 选多级groupby unstack │ │ │ └─ 结果需供业务人员直接阅读 → 是 → unstack后fill_value0 │ ├─ 标准聚合函数能否满足 → 是 → 直接用内置函数sum/mean/std │ │ │ └─ 是否需业务定制逻辑 → 是 → 进入自定义函数分支 │ │ │ ├─ 逻辑简单单表达式 → 是 → 用lambda但需注释业务依据 │ │ │ └─ 逻辑复杂多步骤/条件分支 → 是 → 写命名函数完整docstring │ └─ 所有维度是否需同时计算不同指标 → 是 → 用字典映射{col1:[mean,std], col2:[min,max]}举个实例某次为贷后管理部门开发“客户还款波动率”指标需求是“计算每个客户近90天还款金额的标准差但剔除单笔5万元的异常还款”。这题要同时触发三个分支时间维度90天→选rolling业务定制剔除5万→选命名函数多指标std需基于清洗后数据→用字典映射。最终代码结构必须是def clean_std(series): 计算剔除大额还款后的标准差依据《贷后风险管理指引》第4.2条 cleaned series[series 50000] return cleaned.std() if len(cleaned) 1 else 0 result df.sort_values(date).groupby(customer_id)[repayment_amt].rolling(90D).apply(clean_std)3. 核心细节解析与实操要点那些文档里不会写的魔鬼细节3.1 多列多函数聚合为什么你的列名总是乱套看原文示例中这段代码result df.groupby(merchant_category).agg({ transaction_amount: [mean,median], processing_fee: [min,max] })输出是带MultiIndex的DataFrame列名为(transaction_amount,mean)这种元组。很多新手直接print(result)觉得没问题但一到导出Excel或对接BI就崩溃——Tableau不认识元组列名Power BI导入时报错“invalid column name”。真正生产环境的写法# 步骤1执行聚合保持原样 result df.groupby(merchant_category).agg({ transaction_amount: [mean,median], processing_fee: [min,max] }) # 步骤2扁平化列名关键 result.columns [_.join(col).strip() for col in result.columns.values] # 输出列名变为transaction_amount_mean, transaction_amount_median, ... # 步骤3重置索引避免下游系统无法识别索引 result result.reset_index() # 步骤4类型优化节省内存 result result.astype({ transaction_amount_mean: float32, transaction_amount_median: float32, processing_fee_min: float32, processing_fee_max: float32 })注意_.join(col).strip()中strip()必不可少pandas有时会在元组元素间插入空格导致列名变成transaction_amount _ mean后续用result[transaction_amount_mean]会报KeyError。这个细节我调试了两天才定位到。实操心得在银行系统中我们强制要求所有聚合结果必须通过validate_aggregation_output()函数校验def validate_aggregation_output(df, required_cols): 校验聚合结果是否符合下游系统要求 missing set(required_cols) - set(df.columns) if missing: raise ValueError(f缺失必需列{missing}) # 检查数值列是否为浮点型 for col in required_cols: if df[col].dtype not in [float32,float64]: raise TypeError(f列{col}类型错误应为float当前为{df[col].dtype}) return True这个函数在CI流水线中自动执行杜绝“本地跑通线上报错”。3.2 自定义聚合函数lambda的陷阱与命名函数的正确姿势原文用lambda演示范围计算df.groupby(merchant_category).agg({transaction_amount: lambda x: x.max() - x.min()})这在小数据量下没问题但一旦数据量超过10万行lambda会成为性能黑洞。原因在于pandas对lambda函数无法进行向量化优化每次调用都要启动Python解释器而内置函数如np.mean是C语言实现。性能对比实测100万行数据方法耗时内存峰值lambda x: x.max()-x.min()8.2秒1.8GBnp.ptppeak-to-peak0.3秒0.4GB命名函数def range_func(x): return x.max()-x.min()0.4秒0.4GB所以lambda只适用于临时调试或逻辑极简场景。生产代码必须用命名函数且要遵循三项规范规范一函数必须有业务语义化名称错def calc_diff(x): return x.max()-x.min()对def transaction_range(x): return x.max()-x.min()理由六个月后你或同事看到transaction_range立刻明白这是计算交易额区间而calc_diff需要重新读代码。规范二docstring必须包含业务依据def transaction_range(series): 计算交易金额区间最大值-最小值 依据《反欺诈规则引擎V2.1》第3.4条高波动商户需启用增强版监控。 注返回0.0表示该商户无有效交易避免NaN传播 if series.empty: return 0.0 return series.max() - series.min()规范三必须处理边界情况空序列返回0.0或np.nan根据业务定但必须显式声明单值序列max()min()结果为0无需特殊处理全NaN序列series.max()返回nan需用series.dropna().max()。实操心得我们团队有个硬性规定——所有自定义聚合函数必须通过单元测试覆盖empty_series、single_value、all_nan、normal_case四种场景。测试用例直接写在函数下方用doctest格式CI自动执行。3.3 滚动窗口聚合时间对齐的生死线原文中滚动平均的代码df_ts[rolling_avg] df_ts.groupby(category)[daily_revenue].rolling(window3).mean().reset_index(level0, dropTrue)这段代码在时间序列分析中是危险的问题在于rolling(window3)默认按行序而非时间序计算。如果数据未按日期排序结果完全错误。正确做法分三步强制按时间排序df_ts df_ts.sort_values(date).set_index(date)使用时间窗口rolling(3D)而非rolling(3)这样即使某天无数据窗口仍按日历计算处理缺失值min_periods2保证至少2天数据才计算避免首日NaN污染。# 安全的滚动平均实现 df_ts df_ts.sort_values(date).set_index(date) df_ts[rolling_avg] ( df_ts.groupby(category)[daily_revenue] .rolling(3D, min_periods2) # 3日滚动最少2天数据 .mean() .reset_index(level0, dropTrue) ) # 对NaN做业务处理用前向填充ffill还是用当日值 df_ts[rolling_avg] df_ts[rolling_avg].fillna(methodffill) # 银行常用窗口大小选择的业务逻辑为什么选3天、7天、30天不是拍脑袋。在信用卡场景中3天窗口用于实时欺诈监控检测突发性大额消费7天窗口用于周度经营分析平滑周末效应30天窗口用于月度风险评估匹配账单周期。我们有个内部文档《窗口参数业务对照表》明确规定各场景的窗口选择依据新成员入职必考。3.4 扩展窗口聚合cumsum的隐藏风险原文用expanding().sum()计算累计和看似简单但在分布式环境或分块处理时极易出错。问题在于expanding()依赖数据的物理顺序如果数据被分区如Spark DataFrame分片各分片独立计算的cumsum会断裂。生产环境安全方案# 方案1确保全局有序推荐 df_sorted df.sort_values([customer_id,date]) df_sorted[cumulative_spend] ( df_sorted.groupby(customer_id)[amount] .apply(lambda x: x.cumsum()) # 在每个客户组内cumsum ) # 方案2用rank避免排序副作用当date有重复时 df[date_rank] df.groupby(customer_id)[date].rank(methodfirst) df_sorted df.sort_values([customer_id,date_rank]) # 后续同方案1关键细节cumsum()结果必须与原始数据对齐。原文中reset_index(level0, dropTrue)会丢失原始索引导致df_ts[cumulative_sum]与df_ts[daily_revenue]行序错位。正确做法是# 保留原始索引对齐 cum_series df_ts.groupby(category)[daily_revenue].expanding().sum() df_ts[cumulative_sum] cum_series.droplevel(0) # 直接drop掉多余的groupby level4. 实操过程与核心环节实现从模拟数据到生产就绪的7步闭环4.1 构建高保真模拟数据拒绝随机数生成器原文用np.random.uniform(20,500,60)生成交易额这在教学中可以但生产环境调试必须用业务真实分布。我们内部有套数据生成规范def generate_transaction_data(n_samples60): 生成符合银行交易分布的模拟数据 # 1. 客户分层依据巴塞尔协议III客户分类 customers np.random.choice( [C001,C002,C003], sizen_samples, p[0.4, 0.35, 0.25] # 高净值客户占比更高 ) # 2. 商户类别依据银联商户编码标准 categories np.random.choice( [Groceries,Dining,Travel,Retail], sizen_samples, p[0.3, 0.25, 0.2, 0.25] # 日常消费占比高 ) # 3. 交易额对数正态分布模拟真实偏态 # 参数依据2023年信用卡年报均值¥280标准差¥150 mu, sigma 5.4, 0.5 amounts np.random.lognormal(mu, sigma, n_samples) # 截断至合理范围剔除¥10或¥5000的异常值 amounts np.clip(amounts, 10, 5000) # 4. 时间戳按工作日分布周末交易少30% dates pd.date_range(2024-01-01, periodsn_samples, freqD) # 周末概率降低 weekend_mask (dates.weekday 5) weekend_prob np.where(weekend_mask, 0.7, 1.0) # 用概率重采样 weights weekend_prob / weekend_prob.sum() dates np.random.choice(dates, sizen_samples, pweights) return pd.DataFrame({ date: pd.to_datetime(dates), customer_id: customers, category: categories, amount: np.round(amounts, 2), fee: np.round(amounts * 0.025, 2) # 固定费率2.5% }) df generate_transaction_data(60)这个生成器确保数据具备客户分层合理性高净值客户交易频次低但金额高时间分布真实性周末交易量下降金额分布偏态符合长尾特征费率计算一致性避免amount*0.025产生浮点误差。4.2 分析1多维统计聚合客户×商户类别的全指标原文代码multi_agg df_transactions.groupby([customer_id,category]).agg({ amount: [mean,median,count], fee: [min,max] })生产环境增强版# 步骤1预处理业务规则过滤 df_clean df.copy() df_clean df_clean[df_clean[amount] 10] # 剔除测试交易 df_clean df_clean[df_clean[fee] 0] # 剔除手续费为0的异常 # 步骤2执行聚合添加业务特有指标 multi_agg df_clean.groupby([customer_id,category]).agg({ amount: [ mean, median, count, pd.NamedAgg(columnamount, aggfuncsum), # 总金额 pd.NamedAgg(columnamount, aggfunclambda x: (x 300).sum()) # 高额交易数 ], fee: [min, max, sum] }) # 步骤3扁平化与类型优化 multi_agg.columns [_.join(col).strip() for col in multi_agg.columns.values] multi_agg multi_agg.reset_index() multi_agg multi_agg.astype({ amount_mean: float32, amount_median: float32, amount_count: uint32, amount_sum: float32, amount_lambda: uint32, # 高额交易数 fee_min: float32, fee_max: float32, fee_sum: float32 }) # 步骤4业务校验 validate_aggregation_output(multi_agg, [ customer_id, category, amount_mean, amount_count, fee_sum ])输出解读amount_lambda列名虽丑但明确标识这是lambda计算的业务指标。我们约定所有lambda指标必须在列名后加_业务描述如amount_high_value_count。4.3 分析2自定义范围计算Transaction Range原文用简单lambda生产环境必须支持配置化阈值def transaction_range(series, thresholdNone): 计算交易额区间支持动态阈值过滤 :param series: 交易金额序列 :param threshold: 过滤阈值如300None则不过滤 :return: 区间值max-min if threshold is not None: series series[series threshold] # 剔除超大额 if len(series) 2: return 0.0 return series.max() - series.min() # 应用按商户类别计算阈值设为300 range_analysis df.groupby(category).agg({ amount: lambda x: transaction_range(x, threshold300) }).rename(columns{amount: transaction_range_300})为什么需要阈值因为银行风控中“交易区间”指标若包含单笔500万购房款会完全扭曲餐饮类商户的风险画像。这个阈值必须可配置方便不同业务线调整。4.4 分析3滚动窗口7日平均——时间对齐终极方案原文代码存在两个致命缺陷未排序、未处理时间间隙。生产环境黄金模板def safe_rolling_mean(df, group_col, value_col, window_days7, min_periods3): 安全的滚动平均计算处理时间间隙、排序、缺失值 # 1. 强制按时间排序 df_sorted df.sort_values([date]).copy() # 2. 设置日期索引关键 df_sorted df_sorted.set_index(date) # 3. 按分组列分组应用时间窗口 rolling_result ( df_sorted.groupby(group_col)[value_col] .rolling(f{window_days}D, min_periodsmin_periods) .mean() .droplevel(0) # 移除多余的groupby level ) # 4. 与原始数据对齐用reindex确保行序一致 df_sorted[rolling_avg] rolling_result.reindex(df_sorted.index) # 5. 缺失值处理前向填充业务要求 df_sorted[rolling_avg] df_sorted[rolling_avg].fillna(methodffill) return df_sorted.reset_index() # 使用 df_with_rolling safe_rolling_mean( df, group_colcustomer_id, value_colamount, window_days7, min_periods3 )参数说明window_days7严格按日历日非交易日也计入窗口min_periods3至少3天有数据才计算避免首周数据失真fillna(methodffill)用最近有效值填充符合银行“数据连续性”要求。4.5 分析4扩展窗口累计消费——防断裂设计原文expanding().sum()在分块处理时会断裂。生产环境防断裂方案def safe_cumulative_sum(df, group_col, value_col): 安全的累计求和确保分组内严格有序 # 按分组列和日期排序确保物理顺序 df_sorted df.sort_values([group_col, date]).copy() # 在每个分组内计算cumsum df_sorted[fcumulative_{value_col}] ( df_sorted.groupby(group_col)[value_col] .cumsum() ) return df_sorted df_with_cumsum safe_cumulative_sum(df, customer_id, amount)为什么不用expanding()因为expanding()在pandas 2.2中仍存在分组内索引错乱bug已提交issue #54213。cumsum()是原子操作100%可靠。4.6 分析5多级透视客户vs商户——unstack的工业级用法原文unstack()后直接打印生产环境必须处理稀疏性# 原始分组 crosstab_raw df.groupby([customer_id,category])[amount].mean() # unstack并填充缺失值关键 crosstab crosstab_raw.unstack(fill_value0.0) # 列名标准化移除层级 crosstab.columns [favg_amount_{col} for col in crosstab.columns] # 行索引重置 crosstab crosstab.reset_index() # 类型优化 crosstab crosstab.astype({ customer_id: category, avg_amount_Groceries: float32, avg_amount_Dining: float32, avg_amount_Travel: float32, avg_amount_Retail: float32 })fill_value0.0的意义在银行报表中“客户未在某商户消费”必须显示0而非NaNNaN会被BI工具解释为“数据缺失”影响汇总统计。这是监管报送的硬性要求。4.7 分析6高管摘要Executive Summary——指标工程化原文的summary计算较随意生产环境必须指标化def build_executive_summary(df): 构建高管摘要指标工程化版本 summary df.groupby(customer_id).agg({ amount: [ (total_spend, sum), (avg_transaction, mean), (transaction_count, count) ], fee: [(total_fees, sum)] }) # 扁平化列名 summary.columns [_.join(col).strip() for col in summary.columns.values] # 计算衍生指标必须用astype避免float64精度问题 summary[avg_fee_percent] ( (summary[total_fees] / summary[total_spend]) * 100 ).round(2).astype(float32) # 添加业务标签高价值客户识别 summary[customer_segment] pd.cut( summary[total_spend], bins[0, 1000, 5000, float(inf)], labels[Basic, Premium, VIP] ) return summary.reset_index() summary_df build_executive_summary(df)指标工程化要点所有衍生指标如avg_fee_percent必须在agg后计算避免在agg字典中嵌套表达式易出错pd.cut添加客户分层这是风控模型的基础输入astype(float32)节省内存64位浮点在银行场景中纯属浪费。4.8 分析7风险分层High-Value Segmentation——生产级自定义函数原文的risk_metrics函数有严重缺陷未处理空序列、未指定返回类型。生产环境加固版def risk_metrics(series, high_value_threshold300): 风险分层指标计算生产加固版 返回pd.Series确保下游可直接concat # 边界处理 if series.empty: return pd.Series({ high_value_count: 0, high_value_pct: 0.0, regular_avg: 0.0 }, dtypeobject) # 计算 high_value_mask series high_value_threshold high_value_count high_value_mask.sum() high_value_pct (high_value_count / len(series) * 100) if len(series) 0 else 0.0 # regular_avg仅计算非高价值交易避免除零 regular_series series[~high_value_mask] regular_avg regular_series.mean() if len(regular_series) 0 else 0.0 return pd.Series({ high_value_count: int(high_value_count), high_value_pct: round(float(high_value_pct), 1), regular_avg: round(float(regular_avg), 2) }) # 应用注意用apply而非agg因返回Series risk_analysis df.groupby(customer_id)[amount].apply(risk_metrics) risk_analysis risk_analysis.reset_index() # 展开为DataFrame加固点显式int()和float()转换避免numpy类型在Pandas中引发隐式转换错误round()确保小数位数可控银行报表要求统一2位小数reset_index()将结果转为标准DataFrame可直接写入数据库。5. 常见问题与排查技巧实录那些让你半夜爬起来改代码的坑5.1 常见问题速查表问题现象根本原因快速定位方法解决方案发生频率MemoryError在groupby时爆发分组键组合数爆炸如100万客户×1000产品df.groupby([a,b]).ngroups 10万预过滤键压缩分块处理★★★★★滚动平均结果全是NaN数据未按时间排序或rolling(window3)在稀疏数据中无足够点df.head().sort_values(date)检查顺序改用rolling(3D)min_periods2★★★★☆unstack后列名含(col,agg)元组未执行扁平化列名操作print(result.columns)查看类型result.columns [_.join(col) for col in result.columns]★★★★☆自定义函数返回NaN而非0空序列或单值序列未处理df.groupby(id)[val].apply(lambda x: print(len(x)))函数开头加if len(series)0: return 0.0★★★☆☆累计求和结果跳跃式增长数据未按时间排序cumsum按物理行序计算df.sort_values(date)[cumsum].diff().min()0df.sort_values([id,date]).groupby(id)[val].cumsum()★★★☆☆导出Excel列名乱码列名含中文或特殊字符df.columns.tolist()检查用英文下划线命名如avg_amount★★☆☆☆指标值与SQL结果不一致pandas默认skipnaTrueSQL中AVG()跳过NULLdf[col].mean(skipnaFalse)对比统一设置skipnaTrue银行标准★★☆☆☆5.2 实战排障案例一次线上事故的完整复盘事故背景某日早8点信用卡风险监控大屏中“商户交易波动率”指标突降为0触发一级告警。值班工程师重启服务无效。排查过程确认数据源检查Kafka Topic确认交易数据正常流入定位计算节点发现指标计算服务CPU 100%日志中大量SettingWithCopyWarning代码审查找到问题代码# 错误写法链式赋值 df[clean_amount]