生产级pandas多维聚合:性能、可维护性与下游兼容性实战
1. 项目概述为什么多维聚合不是“加总求平均”那么简单你有没有遇到过这样的场景业务方发来一条需求——“把客户在不同产品线、不同区域、不同时段的交易金额、手续费、笔数按月汇总再算出均值、中位数、最大最小值、标准差还要加上滚动30天的平均值和累计到当月的总和最后按客户产品交叉展示”你盯着这行字看了三分钟手已经伸向了复制粘贴SQL的快捷键心里却清楚这段SQL写完要200行改一个字段得通读两遍上线后出问题连日志都难定位。这不是夸张。我在银行数据平台组干了七年经手过17个核心报表系统重构最常被低估的就是多维聚合这个环节。它表面看是pandas.groupby()的语法糖实则是一套完整的分析逻辑架构——它决定着下游所有BI看板的响应速度、风控模型的特征质量、甚至监管报送的合规性。很多人以为“会用agg()就算掌握了”但真正卡住90%数据工程师的从来不是语法而是聚合维度设计的合理性、计算路径的可维护性、以及结果结构对下游系统的友好度。这篇文章讲的就是我在实际交付中反复验证过的那套“生产级聚合方法论”。它不讲理论推导不堆函数列表只聚焦五个真实高频场景如何用一次groupby同时产出均值、中位数、极差怎么让自定义函数既能嵌入业务规则又方便审计滚动窗口为何不能简单设window30为什么expanding()比cumsum()更适合YTD统计以及最关键的——多级分组后unstack()到底该在哪一层执行、填空值用0还是NaN、后续如何无缝对接Power BI或Tableau。这些细节决定了你的代码是能跑通还是能扛住每天5TB数据的调度压力。关键词里提到的“Towards AI - Medium”其实是个重要信号这类内容天然面向有实战经验的从业者不是教新手怎么安装pandas而是帮老手避开那些“看似能跑、上线就崩”的坑。所以接下来的内容我会直接切入具体案例用我们团队上周刚上线的信用卡反欺诈特征工程为例拆解每一步背后的决策逻辑。你不需要记住所有代码但一定要理解为什么这里必须用named function而不是lambda为什么rolling()后要reset_index(level0, dropTrue)为什么unstack()前必须确保索引层级顺序——这些才是让你的聚合代码从“能用”升级到“好用”的分水岭。2. 核心思路拆解生产环境下的聚合设计铁律2.1 为什么拒绝“先分组再拼接”的野路子很多初学者处理多指标聚合时习惯写三段代码第一段算均值第二段算中位数第三段算标准差最后pd.merge()拼起来。这在样本数据上完全没问题但放到生产环境就是定时炸弹。我给你算笔账假设你要分析1000万条交易记录按客户ID分组约50万个客户分别计算amount的mean、median、std。方案A三次独立groupby每次groupby都要全量扫描数据、重建哈希表、排序索引。三次操作CPU时间≈3×O(n log n)内存峰值是单次的3倍且中间结果要落盘或驻留内存。方案B单次agg字典pandas底层会一次性完成分组然后对每个分组内的amount数组依次调用mean()、median()、std()。时间复杂度≈O(n log n)O(k×m)其中k是分组数50万m是每组平均长度20。内存占用只有方案A的1/3。更致命的是可维护性。去年我们有个报表因业务方临时要求增加“交易金额的90分位数”运维同事直接在原脚本末尾加了一行.quantile(0.9)结果整个ETL任务从12分钟暴涨到47分钟——因为quantile()触发了额外的排序操作而前面的mean()和std()根本不需要排序。如果一开始就是单次agg只需在字典里加一个键值对性能影响几乎为零。提示pandas的agg()字典模式本质是“分组一次计算多次”。它的底层优化在于复用分组结果避免重复切片。这是所有生产级聚合的起点不是语法技巧而是性能基石。2.2 自定义函数lambda够用吗为什么我们强制要求named function原文示例里用了lambda x: x.max() - x.min()计算极差简洁是真简洁但隐患也真大。去年某次监管检查审计老师指着我们的风险模型代码问“这个transaction_range的计算逻辑依据哪份业务规范谁审批过版本号是多少”——我们当场哑火。因为lambda是匿名的没有docstring没有git blame指向具体责任人更没法在代码审查时打批注。我们团队现在执行的铁律是所有涉及业务规则的自定义聚合必须用named function且函数名需体现业务含义docstring必须包含计算公式、阈值来源、异常处理逻辑。比如def transaction_volatility(series): 计算交易金额波动率(max - min) / mean用于识别高风险商户类别 依据《XX银行商户风险管理指引》第3.2条波动率1.5的商户需加强监控 异常处理当mean为0时返回0避免除零错误series长度2时返回np.nan if len(series) 2: return np.nan mean_val series.mean() if mean_val 0: return 0 return (series.max() - series.min()) / mean_val这个函数的价值远超计算本身。它让代码具备了“可审计性”审计时直接搜索函数名就能定位全部调用点让新人接手时不用猜“这个lambda到底在算什么”更重要的是当业务规则变更比如波动率阈值从1.5调到1.8你只需要改一行docstring里的注释所有调用处自动获得更新提示。注意lambda适合纯数学运算如x*1.05named function适合含业务语义的逻辑。混用会导致代码库变成“语法风格混乱区”后期维护成本指数级上升。2.3 滚动窗口与扩展窗口别被“window30”带进沟里看到“滚动30天平均值”第一反应是不是rolling(window30)错。这是新手最容易踩的坑。window参数默认是行数不是天数。如果你的数据不是严格按天连续比如节假日无交易、数据延迟补传window30可能跨了45天也可能只覆盖22天。我们的真实做法是永远用time-based rolling而非row-based。关键在设置index时用pd.to_datetime()并确保时序连续# 正确基于时间的滚动窗口推荐 df_ts df_ts.set_index(date) df_ts[rolling_30d_avg] df_ts.groupby(category)[daily_revenue].rolling( 30D, # 注意这里是字符串30D表示30天 min_periods15 # 至少需要15天数据才计算避免早期噪声 ).mean().reset_index(level0, dropTrue) # 错误基于行数的滚动窗口慎用 # df_ts[wrong_rolling] df_ts.groupby(category)[daily_revenue].rolling(window30).mean()min_periods参数更是灵魂。金融场景中月初往往数据稀疏若设min_periods30前29天全是NaN报表直接空白。我们通常设为window*0.5既保证统计意义又避免过度缺失。至于expanding()它和cumsum()有本质区别cumsum()只支持求和而expanding()支持任意聚合函数。比如计算“截至当日的交易金额标准差”cumsum()无能为力但expanding().std()一行搞定。这在构建控制图Control Chart时至关重要——风控团队需要实时监控交易波动是否超出历史3σ范围。2.4 多级分组与unstack为什么顺序错了整个报表就废groupby([region,product]).mean().unstack()看着简单但背后藏着两个致命陷阱索引层级顺序决定unstack结果groupby([region,product])生成的MultiIndex外层是region内层是product。调用unstack()默认unstack最内层即product结果是region为行、product为列。但如果写成groupby([product,region])unstack后就是product为行、region为列——业务方要的是“各区域下各产品的表现”你给的是“各产品在各区域的表现”逻辑完全颠倒。fill_value的选择暴露数据治理水平unstack(fill_value0)看似省事实则埋雷。0和NaN在统计意义上完全不同0表示“有数据且值为零”NaN表示“数据缺失”。若某区域某产品当月无交易填0会导致平均值被拉低填NaN则后续计算如mean()会自动忽略。我们团队的规范是业务上“不存在”填NaN“存在但为零”才填0。这要求你在unstack前必须确认源数据中“无交易”是空值还是0值。实操心得在调用unstack前务必用result.index.names检查MultiIndex层级用result.isna().sum()验证缺失值分布。宁可多写两行诊断代码也不要赌“应该没问题”。3. 实操细节解析从代码到生产的完整链路3.1 多指标聚合如何让输出结构直击业务需求回到原文第一个例子df.groupby(merchant_category).agg({transaction_amount: [mean,median], processing_fee: [min,max]})。输出是Hierarchical Columns多层列索引看起来像这样transaction_amount processing_fee mean median min max Dining 55.10 52.30 1.36 2.03这种结构对下游系统很不友好。BI工具通常要求扁平化列名比如amount_mean、fee_min。强行用result.columns [amount_mean, amount_median, fee_min, fee_max]不行——因为agg字典的键值对顺序不保证稳定下次pandas升级可能就乱了。我们的标准解法是用tuple重命名 flatten。原理是利用pandas列索引的元组特性# 步骤1获取原始多层列名 multi_cols result.columns # 步骤2生成扁平化新列名 new_cols [] for col in multi_cols: # col是元组如(transaction_amount, mean) new_name f{col[0].split(_)[0]}_{col[1]} # → amount_mean new_cols.append(new_name) # 步骤3重命名并重置索引 result_flat result.copy() result_flat.columns new_cols result_flat result_flat.reset_index() # 最终得到干净DataFrame # merchant_category amount_mean amount_median fee_min fee_max # Dining 55.10 52.30 1.36 2.03这个方案的优势在于完全可控、可预测、可复现。无论pandas版本如何迭代只要你的命名规则不变输出列名就绝对一致。我们还把它封装成工具函数flatten_agg_columns(df, sep_)所有项目统一调用杜绝了各团队自创命名规范导致的集成灾难。3.2 自定义函数深度实践从单指标到多指标返回原文的risk_metrics()函数返回pd.Series这是高级用法但新手容易忽略关键细节。看这段代码def risk_metrics(series): high_value_threshold 300 return pd.Series({ high_value_count: (series high_value_threshold).sum(), high_value_pct: ((series high_value_threshold).sum() / len(series) * 100).round(1), regular_avg: series[series high_value_threshold].mean() })表面看没问题但series[series high_value_threshold].mean()在分组后可能遇到空数组——比如某客户所有交易都300series[series 300]返回空Series.mean()结果是nan。这会导致整个risk_analysis DataFrame出现大量NaN业务方投诉“数据不准”。我们的修复方案是所有可能为空的计算必须显式处理def risk_metrics(series): high_value_threshold 300 high_mask series high_value_threshold low_mask ~high_mask # 显式处理空数组 high_count high_mask.sum() high_pct (high_count / len(series) * 100) if len(series) 0 else 0 # regular_avg仅当有低值交易时才计算否则设为0业务约定 if low_mask.any(): regular_avg series[low_mask].mean() else: regular_avg 0 return pd.Series({ high_value_count: high_count, high_value_pct: round(high_pct, 1), regular_avg: round(regular_avg, 2) })这个改动看似琐碎却是生产代码的分水岭。它让函数具备了鲁棒性Robustness无论输入数据如何极端输出结构永远稳定不会因NaN传播导致下游计算崩溃。我们团队的代码审查清单第一条就是“所有自定义聚合函数必须有空值/边界值处理逻辑”。3.3 滚动窗口实操如何应对非连续时间序列原文的滚动示例假设数据是完美连续的每日数据。但现实是银行系统周末不生成交易快照月末数据可能延迟2天入库API接口偶尔丢包。直接rolling(30D)会漏掉大量有效窗口。我们的解决方案是双阶段滚动。第一阶段用asfreq(D, fill_valuenp.nan)补齐日期第二阶段再滚动# 原始数据可能有日期缺口 df_ts pd.DataFrame({ date: pd.to_datetime([2024-01-01, 2024-01-02, 2024-01-04]), # 缺少01-03 revenue: [1000, 1200, 1100] }) # 步骤1补齐日期缺失值设为NaN不插值 df_full df_ts.set_index(date).asfreq(D, fill_valuenp.nan) # 步骤2滚动计算用min_periods2允许最多1天缺失 df_full[rolling_3d] df_full[revenue].rolling( 3D, min_periods2 # 只要有2天有效数据就计算 ).mean() # 结果 # date revenue rolling_3d # 2024-01-01 1000.0 NaN # 第1天不足2天 # 2024-01-02 1200.0 NaN # 第2天仍不足2天需向前找2天 # 2024-01-03 NaN 1100.0 # 补齐后用01-01和01-02均值 # 2024-01-04 1100.0 1150.0 # 用01-02和01-03NaN不pandas会跳过NaN实际用01-02和01-04关键点在于asfreq()只是补齐索引不改变原始值rolling()自动忽略NaN只对有效值计算。这样既保证了时间窗口的完整性又避免了错误插值。3.4 扩展窗口进阶如何计算动态基准线expanding().mean()只能算从头到尾的累积均值但业务常需要“动态基准线”。比如风控系统要对比“当前交易金额是否超过该客户过去30笔交易的均值的2倍”。这需要expanding()配合shift()# 按客户分组计算滚动30笔的均值注意是前30笔不含当前笔 df_sorted df_transactions.sort_values([customer_id, date]) df_sorted[rolling_30_avg] df_sorted.groupby(customer_id)[amount].apply( lambda x: x.shift(1).rolling(30, min_periods10).mean() ) # 解释x.shift(1)把当前行移到下一行这样计算时用的是“之前”的数据 # rolling(30)取最近30个值min_periods10保证初期也有值这个技巧让expanding()从“静态累积”升级为“动态滑窗”是构建实时风控规则的核心能力。我们所有反欺诈模型的特征工程都基于此类变形。3.5 多级分组终极形态透视表与条件聚合的融合unstack()解决的是“二维交叉”但业务常需“三维甚至四维”。比如既要按region和product交叉又要区分is_fraud是否欺诈标签。这时unstack()不够用必须上pivot_table()# 原始数据含欺诈标签 df_fraud pd.DataFrame({ region: [North]*4 [South]*4, product: [Widget,Gadget]*4, is_fraud: [0,0,1,1]*2, revenue: [15000,12000,18000,14000,16000,13500,17000,14500] }) # 用pivot_table实现三维聚合 result_3d df_fraud.pivot_table( valuesrevenue, indexregion, columns[product, is_fraud], # 列维度支持元组 aggfuncmean, fill_value0 ) # 输出列名自动变为MultiIndex(Widget, 0), (Widget, 1), (Gadget, 0), (Gadget, 1) # 再用flatten_columns()扁平化即可pivot_table()比groupby().unstack()更强大之处在于它原生支持多列columns参数、可指定aggfunc不只是mean、能处理缺失组合fill_value。这是我们构建“欺诈率热力图”的标准流程。4. 完整端到端实战信用卡客户行为分析流水线4.1 数据准备与清洗生产环境的底线思维我们复现原文的端到端示例但加入生产必需的健壮性处理。首先生成模拟数据但关键改进在于添加真实缺失值银行数据常有amount为0或空fee计算依赖amount必须模拟引入时间漂移部分交易日期晚于系统时间需校验设置业务约束fee不能为负amount不能小于1元。import pandas as pd import numpy as np from datetime import datetime, timedelta # 设置随机种子确保可复现 np.random.seed(42) # 生成基础数据 customers [C001, C002, C003] categories [Groceries, Dining, Travel, Retail] dates_base pd.date_range(2024-01-01, periods60, freqD) # 模拟不均匀交易更真实 date_list [] for cust in customers: # 每个客户交易天数不同C001活跃C003偶发 days_active np.random.choice([40, 50, 30], p[0.5, 0.3, 0.2]) dates_cust np.random.choice(dates_base, sizedays_active, replaceFalse) date_list.extend(dates_cust) # 构建DataFrame df_raw pd.DataFrame({ date: np.array(date_list), customer_id: np.repeat(customers, [len([d for d in date_list if d in dates_base[:20]]) for _ in range(3)]), # 简化示意 category: np.random.choice(categories, len(date_list)), amount: np.random.uniform(20, 500, len(date_list)).round(2) }) # 关键清洗步骤 df_clean df_raw.copy() # 步骤1处理amount异常值业务规则不能1或10000 df_clean df_clean[(df_clean[amount] 1) (df_clean[amount] 10000)] # 步骤2添加fee但模拟计算失败5%概率fee为NaN df_clean[fee] (df_clean[amount] * 0.025).round(2) mask_fee_nan np.random.random(len(df_clean)) 0.05 df_clean.loc[mask_fee_nan, fee] np.nan # 步骤3日期校验剔除未来日期 today datetime.now().date() df_clean df_clean[df_clean[date].dt.date today] print(f原始数据量{len(df_raw)} → 清洗后{len(df_clean)}剔除{len(df_raw)-len(df_clean)}条异常)这个清洗流程体现了生产思维不假设数据完美而是主动拦截所有已知异常。我们团队有条不成文规定ETL脚本开头必须有data_quality_report()函数输出缺失率、异常值比例、唯一值统计任何一项超标立即告警绝不让脏数据流入下游。4.2 分析1多指标聚合——客户-品类维度的立体画像原文的multi_agg直接计算但我们加入两个关键增强分位数补充业务方常问“80%的交易金额在什么区间”所以加quantile([0.25,0.75])空值安全聚合fee有NaNmin()/max()会返回NaN改用agg([min,max])自动跳过。# 生产级多指标聚合 multi_agg df_clean.groupby([customer_id, category]).agg({ amount: [ mean, median, count, pd.NamedAgg(columnamount, aggfunclambda x: x.quantile(0.25)), # Q1 pd.NamedAgg(columnamount, aggfunclambda x: x.quantile(0.75)) # Q3 ], fee: [min, max, mean] # fee有NaNmin/max自动忽略 }).round(2) # 扁平化列名使用我们封装的函数 def flatten_agg_columns(df, sep_): 将MultiIndex列扁平化sep为分隔符 if not isinstance(df.columns, pd.MultiIndex): return df new_cols [] for col in df.columns: if isinstance(col, tuple): # 过滤掉空字符串合并非空部分 parts [str(c) for c in col if str(c).strip()] new_cols.append(sep.join(parts)) else: new_cols.append(str(col)) df_flat df.copy() df_flat.columns new_cols return df_flat.reset_index() multi_agg_flat flatten_agg_columns(multi_agg) print(Analysis 1: 客户-品类多维指标已扁平化) print(multi_agg_flat.head(10))输出列名如amount_mean、amount_q25、fee_min业务方导入Excel时无需二次处理。这就是“以终为始”的设计——从下游使用场景倒推上游代码结构。4.3 分析2自定义波动率——风控团队的黄金指标我们重写transaction_range为transaction_volatility并加入业务校验def transaction_volatility(series): 交易金额波动率(max-min)/mean用于识别高风险商户 if len(series) 2: return np.nan mean_val series.mean() if mean_val 0 or np.isnan(mean_val): return np.nan return round((series.max() - series.min()) / mean_val, 3) # 应用聚合 range_analysis df_clean.groupby(category).agg({ amount: [min, max, mean, transaction_volatility], fee: [mean] }).round(3) range_flat flatten_agg_columns(range_analysis) print(\nAnalysis 2: 品类波动率分析含业务校验) print(range_flat)结果中transaction_volatility列直接给出数值风控团队可据此设定阈值volatility 1.2的品类如Travel需启用更严格的实时监控规则。4.4 分析3滚动窗口——实时消费趋势捕捉针对原文的滚动计算我们强化时间处理# 按日期排序并设索引 df_sorted df_clean.sort_values([customer_id, date]).set_index(date) # 补齐日期关键 df_full df_sorted.groupby(customer_id).apply( lambda x: x.asfreq(D, fill_valuenp.nan) ).reset_index(level0, dropTrue) # 计算滚动7天均值跳过NaN df_full[rolling_7d_avg] df_full.groupby(customer_id)[amount].apply( lambda x: x.rolling(7D, min_periods4).mean() ) # 提取结果去重索引避免重复日期 result_rolling df_full[[customer_id, amount, rolling_7d_avg]].dropna(subset[rolling_7d_avg]) print(\nAnalysis 3: 滚动7天均值已处理日期缺口) print(result_rolling.head(15))min_periods4意味着只要有4天有效数据就计算避免月初空白。这个参数是根据历史数据缺失率统计得出的不是拍脑袋定的。4.5 分析4扩展窗口——客户生命周期价值CLV追踪expanding()计算CLV但需注意首次交易前不应有值# 按客户分组计算累计消费从首次交易开始 df_sorted df_clean.sort_values([customer_id, date]) df_sorted[cumulative_spend] df_sorted.groupby(customer_id)[amount].expanding().sum().reset_index(level0, dropTrue) # 关键首次交易的cumulative_spend应等于amount本身不是NaN # expanding()默认首行为NaN需手动填充 df_sorted[cumulative_spend] df_sorted.groupby(customer_id)[cumulative_spend].apply( lambda x: x.fillna(methodbfill) # 向后填充确保首行有值 ) result_cumulative df_sorted[[customer_id, date, amount, cumulative_spend]] print(\nAnalysis 4: 客户累计消费CLV追踪) print(result_cumulative.head(15))这个填充逻辑确保了CLV曲线从第一天起就是连续的业务方做客户分群时不会因首日缺失而误判。4.6 分析5多级透视——销售管理者的决策视图unstack()升级为pivot_table()支持多维# 构建三维透视客户 vs 品类 vs 是否欺诈模拟字段 df_fraud df_clean.copy() df_fraud[is_fraud] np.random.choice([0,1], len(df_fraud), p[0.98,0.02]) # pivot_table实现三维交叉 crosstab_3d df_fraud.pivot_table( valuesamount, indexcustomer_id, columns[category, is_fraud], aggfuncmean, fill_value0 ).round(2) # 扁平化列名 crosstab_flat flatten_agg_columns(crosstab_3d) print(\nAnalysis 5: 客户-品类-欺诈状态三维透视已扁平化) print(crosstab_flat.head())输出列名如Groceries_0正常交易、Dining_1欺诈交易销售总监一眼就能看出“C001在Dining类别的欺诈交易均值高达XXX”决策链条极大缩短。4.7 分析6高管摘要——自动化报告的核心模块高管摘要要求绝对稳定、零错误。我们用agg()字典assign()链式操作# 高管摘要每个客户的关键指标 summary df_clean.groupby(customer_id).agg({ amount: [sum, mean, count], fee: sum }).round(2) # 扁平化 summary_flat flatten_agg_columns(summary) # 添加衍生指标链式操作避免中间变量 summary_final summary_flat.assign( avg_fee_percentlambda x: ((x[fee_sum] / x[amount_sum]) * 100).round(2), spend_per_transactionlambda x: (x[amount_sum] / x[amount_count]).round(2) )[[ customer_id, amount_sum, amount_mean, amount_count, fee_sum, avg_fee_percent, spend_per_transaction ]] print(\nAnalysis 6: 高管摘要自动化报告就绪) print(summary_final)assign()确保所有计算在一个表达式中完成避免summary_flat[new_col] ...可能引发的SettingWithCopyWarning。这是Pandas高阶用法也是我们代码审查的重点项。4.8 分析7风险分层——机器学习特征工程的起点最后的风险分层我们升级为可配置的阈值def risk_segmentation(series, high_value_threshold300, high_freq_threshold0.3): 风险分层基于高值交易占比和频次 :param high_value_threshold: 高值交易金额阈值 :param high_freq_threshold: 高值交易占比阈值 high_mask series high_value_threshold high_count high_mask.sum() high_pct (high_count / len(series)) if len(series) 0 else 0 # 分层逻辑 if high_pct high_freq_threshold: risk_level HIGH elif high_pct 0.1: risk_level MEDIUM else: risk_level LOW return pd.Series({ high_value_count: high_count, high_value_pct: round(high_pct * 100, 1), risk_level: risk_level, regular_avg: round(series[~high_mask].mean(), 2) if (~high_mask).any() else 0 }) # 应用可动态调整阈值 risk_analysis df_clean.groupby(customer_id)[amount].apply( risk_segmentation, high_value_threshold250, # 业务方新要求 high_freq_threshold0.25 ) print(\nAnalysis 7: 风险分层可配置阈值) print(risk_analysis)这个函数的high_value_threshold参数可从配置中心读取业务方调整阈值无需改代码运维一键发布。这才是真正的“生产就绪”。5. 常见问题与避坑指南血泪教训总结5.1 性能问题为什么groupby突然变慢了10倍现象某天ETL任务从5分钟涨到50分钟日志显示groupby耗时激增。排查路径检查数据量df.shape是否突增——否数据量稳定。检查分组键df[customer_id].nunique()是否暴增——是某客户ID字段混入了UUID格式如C001-abc123导致分组数从50万飙升到500万。根本原因pandas分组性能与分组数呈线性关系分组数×10耗时×10。解决方案在groupby前强制类型转换df[customer_id] df[customer_id].str.split(-).str[0]加入数据质量检查if df[customer_id].nunique() 1000000: raise ValueError(客户ID异常疑似数据污染)实操心得永远在ETL入口处加df.info()和df.nunique()快照。我们团队的SOP是任何分组操作前必须打印分组键唯一值数量/总行数比率0.8就要预警。5.2 NaN传播为什么agg()后全是NaN现象df.groupby(cat)[val].agg([mean,std])结果全NaN但df[val].mean()有值。原因val列有大量NaN而std()对全NaN序列返回NaNmean()虽可跳过NaN但agg()字典中任一函数返回NaN整行就标为NaN。破解方法用skipnaTrue默认但需确认数据源更可靠预过滤df df.dropna(subset[val])或用agg()的min_count参数df.groupby(cat)[val].agg([mean,std], min_count1)5.3 unstack()报错Index contains duplicate entries现象df.groupby([A,B]).mean().unstack()报错。原因A和B组合不唯一比如AX, BY出现两次unstack时无法确定哪个值放列