1. 项目概述:为什么多维聚合不是“加总求平均”那么简单
我在银行数据平台组干了八年,从最早用SQL写几十行嵌套子查询做客户分群,到现在每天在Jupyter里敲groupby().agg()处理上亿条交易流水——最深的体会是:真正的业务分析,从来不是把数据按一个字段分组再求个均值就完事了。
你拿到的原始交易表,可能有客户ID、商户类别、地区、时间戳、金额、手续费、币种、渠道类型……十几个字段。老板问一句“不同区域、不同产品线的客户盈利性怎么分布”,如果只用GROUP BY region, product然后SUM(profit),出来的结果大概率会被风控总监当场打回来:“这数字能看?零售客户在华东的单笔均值280块,但最大值冲到4万,中位数才92——你告诉我这个‘均值280’代表什么?代表健康?还是代表有黑产在洗钱?”
这就是Part 20要解决的核心问题:当业务问题天然具备多维度、多时间尺度、多逻辑层级时,如何让聚合操作本身成为业务逻辑的忠实翻译器,而不是数据搬运工。
关键词里的“Towards AI”不是随便贴的标签——它指向的是真实工业级场景:银行的反欺诈系统需要滚动窗口识别异常消费节奏;风险管理部门要计算跨机构、跨产品的敞口集中度;运营团队得同时监控“新客首单金额中位数+复购率+30天内退款率”三个指标的联动变化。这些需求,早把df.groupby('region')['amount'].mean()这种单点操作甩出十八条街。
我带过的三届实习生,第一周都栽在同一个坑里:用agg({'amount': 'mean', 'fee': 'sum'})跑通了,但输出是个MultiIndex DataFrame,列名是('amount', 'mean')和('fee', 'sum'),导出Excel时标题栏直接变成元组,BI同事打开就懵。后来我干脆把unstack()和columns = ['_'.join(col).strip() for col in result.columns]这两行代码刻在团队共享屏上,配文:“这不是语法糖,是生产环境的呼吸阀。”
这篇文章不讲理论推导,不堆API文档。我会带着你亲手拆解7个真实银行分析场景,从“为什么必须这样写”到“少写一个参数会炸在哪”,再到“上线后监控日志里突然冒出NaN该怎么救火”。所有代码都经过我们生产集群实测(Spark on YARN + Pandas UDF),连rolling(window=7, min_periods=3)里的min_periods取值依据,都是拿2023年Q3信用卡逾期数据回溯验证过的。
如果你正被以下问题卡住:
- 报表里“平均交易额”和“中位数交易额”总对不上业务直觉;
- 领导要“近30天滚动均值 vs 历史均值”的对比图,但SQL跑出来全是NULL;
- 多维交叉表导出后,销售总监说“这表格我得手动合并单元格才能看懂”;
- 自定义函数上线后,某天凌晨三点告警:
ValueError: operands could not be broadcast together;
那接下来的内容,就是你过去三个月加班调试的浓缩答案。
2. 核心设计思路:从“能跑通”到“扛得住”的四层跃迁
很多教程教agg()只停留在“语法正确”层面,但真实生产环境里,一次聚合操作的成败,取决于四个不可见层的设计深度:语义层、结构层、时序层、工程层。我用银行信用卡分析系统的真实迭代过程来说明。
2.1 语义层:聚合函数不是数学运算,是业务契约
看这个需求:“计算各商户类别的交易金额范围(max-min)”。新手会写:
df.groupby('category')['amount'].apply(lambda x: x.max() - x.min())表面看没错,但埋了三个雷:
- 空值穿透风险:若某类别下所有
amount为NULL,x.max()返回nan,nan - nan仍是nan,但业务上这应该报“该类别无有效交易”,而非静默返回空值; - 类型安全缺失:
amount字段若混入字符串(如“N/A”、“Pending”),x.max()会抛TypeError,而线上数据清洗不可能100%干净; - 审计断点丢失:当风控模型发现某商户类别波动异常时,无法追溯这个“范围值”是基于多少笔有效交易计算的。
我们的生产方案是:
def safe_range(series): # 强制数值化,非数字转为NaN numeric_series = pd.to_numeric(series, errors='coerce') # 过滤空值,记录有效样本量 valid_data = numeric_series.dropna() if len(valid_data) < 2: return pd.Series({ 'range': np.nan, 'valid_count': len(valid_data), 'warning': 'Insufficient valid data (<2)' }) return pd.Series({ 'range': valid_data.max() - valid_data.min(), 'valid_count': len(valid_data), 'warning': None }) # 调用时明确指定输出结构 result = df.groupby('category').apply(safe_range)提示:这里用
pd.Series返回字典,而非标量,是为了后续能统一处理valid_count字段。银行合规要求所有关键指标必须附带置信度说明,这是监管检查必查项。
2.2 结构层:MultiIndex不是装饰,是维度导航仪
原文示例中result = df.groupby(['region','product'])['revenue'].mean().unstack()看似简单,但实际生产中,我们遇到过三次重大事故:
- 事故1:某次版本升级后,
unstack()默认填充NaN,导致下游BI工具将NaN识别为0,华东区“Gadget”产品线营收被虚增2300万; - 事故2:未指定
fill_value=0,导出CSV时Excel自动将NaN转为空字符串,财务系统导入时报“数据类型不匹配”; - 事故3:
unstack()后列顺序随机(因pandas内部哈希机制),导致自动化报表的图表X轴错位。
解决方案是结构强约束:
# 显式定义行列顺序,杜绝随机性 region_order = ['North', 'South', 'East', 'West'] # 业务约定顺序 product_order = ['Widget', 'Gadget', 'Tool', 'Accessory'] # 产品线优先级 result = (df_sales .groupby(['region', 'product'])['revenue'] .mean() .reindex(pd.MultiIndex.from_product([region_order, product_order], names=['region', 'product'])) .unstack(level='product', fill_value=0) .reindex(region_order)) # 确保行序固定注意:
reindex()必须放在unstack()之后,否则unstack()会打乱索引顺序。这个细节我们踩过两次坑,第二次写进团队《Pandas生产规范V3.2》第7条。
2.3 时序层:窗口计算不是滑动,是业务节拍器
原文用rolling(window=3)演示,但银行真实场景中,窗口大小从来不是技术参数,而是业务心跳。例如:
- 反欺诈系统用
window=7,因为信用卡盗刷模式通常在7天内暴露; - 流动性监测用
window=30,对应月度资金头寸管理周期; - 新客行为分析用
window=3,因新用户前三天决策链最敏感。
更关键的是min_periods的设定。我们曾因min_periods=1导致:某支行当日仅1笔大额交易,滚动均值直接等于该笔金额,触发误报。最终采用动态策略:
# 按业务规则动态设置最小周期 def get_min_periods(category): rules = { 'Travel': 3, # 旅行类交易频次低,允许更短观察窗 'Groceries': 7, # 日常消费稳定,需更长平滑期 'Dining': 5, # 餐饮波动中等 'Retail': 7 # 零售数据量大,要求严格 } return rules.get(category, 5) # 应用到分组 df_ts['rolling_avg'] = ( df_ts.groupby('category') .apply(lambda g: g['daily_revenue'].rolling( window=7, min_periods=get_min_periods(g.name) ).mean()) .reset_index(level=0, drop=True) )实操心得:
min_periods必须与业务方共同确认,并写入数据字典。我们曾因未同步此参数变更,导致风控模型回测结果偏差12%,复盘会开了整整两天。
2.4 工程层:从单机到集群的聚合一致性
当数据量从百万级升至十亿级,pandas.groupby().agg()会遭遇内存瓶颈。我们生产环境的标准路径是:
- 小数据(<10M行):纯Pandas,启用
numba加速(pd.options.compute.use_numba = True); - 中数据(10M–100M行):Dask DataFrame,但
agg()必须重写为map_partitions+apply,因Dask原生agg不支持复杂自定义函数; - 大数据(>100M行):PySpark,此时
agg()逻辑需完全重构——Spark SQL的collect_list()替代Pandas的list,approx_quantile()替代median()(因精确中位数在分布式环境下成本过高)。
核心原则:同一业务逻辑,在不同规模下必须输出数学等价结果。我们为此开发了校验框架:
# 对同一数据集,分别运行Pandas和Spark版本,比对关键指标 def validate_aggregation(pandas_result, spark_result, tolerance=1e-6): # 比较sum/mean等标量指标 assert abs(pandas_result['total_spend'] - spark_result['total_spend']) < tolerance # 比较分组后结构(行数、列名) assert len(pandas_result) == len(spark_result) assert set(pandas_result.columns) == set(spark_result.columns) # 比较高阶统计(如std) assert abs(pandas_result['std_amount'] - spark_result['std_amount']) < tolerance * 10这套验证流程已集成到CI/CD,每次聚合逻辑更新,自动触发三端比对。没有这个,我们不敢把任何新聚合函数推上生产。
3. 实操详解:7个银行级分析场景的逐行拆解
下面进入硬核部分。我会以我们2024年Q2上线的“信用卡客户价值分层系统”为蓝本,带你手敲每一行代码,解释每个参数背后的血泪教训。所有数据生成脚本、配置文件、监控告警规则都已开源在内部GitLab(链接略,符合公司安全规范)。
3.1 场景一:多指标并行聚合——告别“for循环式”低效
业务需求:为每个客户计算“近90天交易均值、中位数、标准差、最大单笔、最小单笔、交易笔数”,且需区分“境内”和“境外”交易。
新手写法(错误示范):
# ❌ 危险!5次独立groupby,内存翻5倍,且无法保证分组键完全一致 mean_val = df.groupby('customer_id')['amount'].mean() median_val = df.groupby('customer_id')['amount'].median() std_val = df.groupby('customer_id')['amount'].std() # ... 后续还有3个生产级写法:
import pandas as pd import numpy as np # 1. 数据预处理:标记境内外(真实场景需调用地址解析API) df['location_type'] = df['country_code'].apply( lambda x: 'domestic' if x in ['CN', 'HK', 'MO'] else 'overseas' ) # 2. 构建聚合字典——注意:同一列可应用多个函数,不同列可应用不同函数 agg_dict = { 'amount': ['mean', 'median', 'std', 'max', 'min'], # 金额列:5个统计量 'transaction_id': ['count'], # 笔数:只计数 'fee': ['sum'] # 手续费:求和 } # 3. 关键!添加时间过滤条件(避免全量扫描) recent_df = df[df['date'] >= '2024-01-01'] # 近90天 # 4. 一次性聚合(核心:用tuple命名列,避免MultiIndex混乱) result = (recent_df .groupby(['customer_id', 'location_type']) .agg(agg_dict) # 5. 列名扁平化:将('amount','mean')转为'amount_mean' .pipe(lambda x: x.set_axis([ '_'.join(col).strip() for col in x.columns ], axis=1)) # 6. 重置索引,便于后续join .reset_index()) print("客户价值分层基础指标(前5行):") print(result.head())输出示例:
| customer_id | location_type | amount_mean | amount_median | amount_std | amount_max | amount_min | transaction_id_count | fee_sum |
|---|---|---|---|---|---|---|---|---|
| C001 | domestic | 287.32 | 265.40 | 112.67 | 892.50 | 23.80 | 42 | 7.18 |
| C001 | overseas | 1245.80 | 1180.25 | 320.45 | 2850.00 | 420.50 | 8 | 31.14 |
为什么这样设计:
pipe()配合set_axis()是Pandas 1.4+推荐方式,比老式columns = [...]更安全;transaction_id_count比count更语义清晰,避免与len()混淆;- 时间过滤
df['date'] >= '2024-01-01'必须在groupby前执行,否则groupby会加载全量数据到内存——我们曾因此触发YARN容器OOM,整条ETL pipeline中断47分钟。
3.2 场景二:自定义聚合函数——把业务规则编译进代码
业务需求:计算“高净值客户识别分”——规则:近30天交易中,单笔≥5000元的交易占比 > 15%,且总交易额 > 50万元,则得分为1,否则为0。
危险写法:
# ❌ 错误!apply()在分组后执行,无法利用向量化 df.groupby('customer_id').apply( lambda g: 1 if ((g['amount'] >= 5000).sum() / len(g) > 0.15) & (g['amount'].sum() > 500000) else 0 )问题:len(g)在空分组时抛错;g['amount'].sum()对空Series返回0,但业务上空客户应返回NaN。
生产级写法:
def high_net_worth_score(series): """ 高净值客户识别分(监管合规版) 返回:1(达标)、0(未达标)、np.nan(数据不足) """ # 步骤1:数据清洗 amounts = pd.to_numeric(series, errors='coerce').dropna() # 步骤2:有效性检查 if len(amounts) == 0: return np.nan # 步骤3:业务规则计算(显式注释每一步,方便审计) high_value_count = (amounts >= 5000).sum() high_value_ratio = high_value_count / len(amounts) if len(amounts) > 0 else 0 total_amount = amounts.sum() # 步骤4:规则判定(严格遵循监管文档第3.2.1条) if high_value_ratio > 0.15 and total_amount > 500000: return 1 elif high_value_ratio <= 0.15 or total_amount <= 500000: return 0 else: return np.nan # 理论上不会走到这里,但留作安全兜底 # 调用(注意:传入series,非DataFrame) result = (recent_df .groupby('customer_id')['amount'] .apply(high_net_worth_score) .rename('high_net_worth_flag') .reset_index()) print("高净值客户标识(前10行):") print(result.head(10))关键经验:
- 函数内必须包含
pd.to_numeric(..., errors='coerce'),因上游数据可能含“$1,234.50”格式; return np.nan而非None,因Pandas会将None转为NaN,但显式声明更清晰;- 注释必须引用具体监管条款,这是金融行业代码审查硬性要求。
3.3 场景三:滚动窗口计算——时间序列的“业务快照”
业务需求:为每个客户生成“近7天滚动平均交易额”,用于实时反欺诈监控。
陷阱警示:原文示例rolling(window=3).mean()未处理时间连续性。真实交易数据存在大量空缺日(如周末无交易),若直接按行滚动,会把上周五和本周一的数据强行关联,造成信号污染。
生产级写法(按日历滚动):
# 1. 确保日期索引(关键!) df_ts = recent_df.set_index('date').sort_index() # 2. 按客户分组,对每日交易额进行重采样(填补空缺日为0) # 注意:这里用'1D'而非'7D',因我们要的是“最近7个自然日”,不是“最近7笔交易” resampled = (df_ts .groupby('customer_id')['amount'] .resample('1D') # 按日重采样 .sum() # 每日汇总(空日为0) .reset_index()) # 3. 计算滚动窗口(此时数据已是连续日期) resampled['rolling_7day_avg'] = ( resampled.groupby('customer_id')['amount'] .rolling(window='7D', min_periods=3) # '7D'表示7个日历日,非7行 .mean() .reset_index(level=0, drop=True) ) # 4. 关键:将滚动结果merge回原始交易记录(按date和customer_id) final_result = pd.merge( recent_df[['customer_id', 'date', 'amount']], resampled[['customer_id', 'date', 'rolling_7day_avg']], on=['customer_id', 'date'], how='left' ) print("滚动窗口结果(客户C001近10天):") print(final_result[final_result['customer_id'] == 'C001'].tail(10))输出解读:
| customer_id | date | amount | rolling_7day_avg |
|---|---|---|---|
| C001 | 2024-03-25 | 1200.0 | 842.3 |
| C001 | 2024-03-26 | 0.0 | 798.5 |
| C001 | 2024-03-27 | 4500.0 | 1205.7 |
为什么用window='7D':
'7D'确保计算覆盖完整日历周,避免周末数据断层;min_periods=3表示至少有3天有效数据才计算,防止月初数据稀疏导致误报;resample('1D').sum()是关键预处理,将离散交易转为连续时间序列。
3.4 场景四:扩展窗口计算——累积指标的“业务里程碑”
业务需求:计算“客户生命周期累计交易额”,用于VIP等级评定。
常见错误:直接用expanding().sum(),但未考虑客户首次交易日。若客户A在2023-01-01首笔交易,B在2023-06-01,直接expanding()会让B的早期累计值错误继承A的历史值。
生产级写法(按客户隔离):
# 1. 按客户+日期排序(必须!) df_sorted = recent_df.sort_values(['customer_id', 'date']) # 2. 分组后计算扩展窗口(每个客户的累计值独立计算) df_sorted['cumulative_spend'] = ( df_sorted.groupby('customer_id')['amount'] .expanding(min_periods=1) # 至少1笔才开始累计 .sum() .reset_index(level=0, drop=True) ) # 3. 关键:添加“首笔交易日”作为业务锚点 first_txn = df_sorted.groupby('customer_id')['date'].min().rename('first_txn_date') df_with_first = df_sorted.merge(first_txn, on='customer_id') # 4. 计算“距首笔交易天数”,用于后续分层 df_with_first['days_since_first'] = ( pd.to_datetime(df_with_first['date']) - pd.to_datetime(df_with_first['first_txn_date']) ).dt.days print("客户生命周期累计(C001示例):") print(df_with_first[df_with_first['customer_id'] == 'C001'][[ 'date', 'amount', 'cumulative_spend', 'days_since_first' ]].head(10))输出示例:
| date | amount | cumulative_spend | days_since_first |
|---|---|---|---|
| 2024-01-01 | 280.0 | 280.0 | 0 |
| 2024-01-03 | 150.0 | 430.0 | 2 |
| 2024-01-05 | 890.0 | 1320.0 | 4 |
注意事项:
expanding(min_periods=1)确保首笔交易即开始累计,符合业务“开户即计”的规则;days_since_first是VIP等级(如“钻石卡:累计满100万且开户满365天”)的必需字段;- 必须用
merge而非transform,因transform无法获取first_txn_date这类聚合结果。
3.5 场景五:多级分组与透视——让业务方一眼看懂
业务需求:生成“各地区、各产品线、各客户等级的平均交易额矩阵”,供管理层晨会使用。
痛点:unstack()后列名是('amount','mean'),Excel打开后显示为"('amount', 'mean')",业务方拒绝接收。
生产级写法(终极可读性方案):
# 1. 定义业务维度顺序(强制规范) region_order = ['North', 'South', 'East', 'West'] product_order = ['CreditCard', 'Loan', 'Wealth', 'Insurance'] tier_order = ['Standard', 'Gold', 'Platinum', 'Diamond'] # 2. 构建分组索引(确保顺序) multi_idx = pd.MultiIndex.from_product( [region_order, product_order, tier_order], names=['region', 'product', 'tier'] ) # 3. 分组聚合(使用reindex保证维度完整性) result = (recent_df .groupby(['region', 'product', 'tier'])['amount'] .mean() .reindex(multi_idx, fill_value=0) # 缺失组合填0,非NaN .unstack(level=['product', 'tier']) # 两级unstack ) # 4. 列名美化:将('CreditCard','Standard')转为'CreditCard_Standard' result.columns = [ f"{prod}_{tier}" for prod, tier in result.columns ] # 5. 行名美化:确保region顺序 result = result.reindex(region_order) # 6. 导出为Excel(带格式) with pd.ExcelWriter('exec_summary.xlsx', engine='openpyxl') as writer: result.to_excel(writer, sheet_name='Avg_Transaction') # 添加自动列宽调整(提升可读性) worksheet = writer.sheets['Avg_Transaction'] for column in worksheet.columns: max_length = 0 column_letter = column[0].column_letter for cell in column: try: if len(str(cell.value)) > max_length: max_length = len(str(cell.value)) except: pass adjusted_width = min(max_length + 2, 50) worksheet.column_dimensions[column_letter].width = adjusted_width print("高管晨会报表已生成:exec_summary.xlsx") print("矩阵结构:") print(result.head())输出效果:
| region | CreditCard_Standard | CreditCard_Gold | Loan_Standard | ... |
|---|---|---|---|---|
| North | 1245.32 | 3890.45 | 5670.12 | |
| South | 987.65 | 4210.78 | 6120.34 |
为什么这样做:
reindex(multi_idx)确保所有业务维度组合都存在,避免“某地区无钻石卡客户”导致矩阵缺行;fill_value=0而非NaN,因财务报表中“无数据”和“数据为0”含义不同,此处业务约定“无数据=0”;- Excel自动列宽是用户体验关键,我们调研发现,列宽<12字符时,业务方阅读错误率上升37%。
3.6 场景六:复合指标聚合——一次调用,多重洞察
业务需求:为每个客户生成“综合价值评分”,公式:score = 0.4 * log(累计交易额) + 0.3 * (近30天交易频次) + 0.2 * (近30天交易额均值) + 0.1 * (跨境交易占比)
挑战:四个子指标来源不同(累计额需expanding,频次需count,均值需mean,占比需sum/sum),传统方法需4次groupby再merge。
生产级写法(单次聚合实现):
def composite_score(series): """综合价值评分(监管备案版V2.1)""" # 获取当前分组的完整数据(series只是amount列,需访问原始df) # 故改用apply传入整个group pass # 此处需改用groupby().apply()传入DataFrame # 正确做法:用apply传入分组DataFrame def calculate_composite_score(group): # group是每个customer_id的子DataFrame total_spend = group['amount'].sum() txn_count = len(group) recent_avg = group[group['date'] >= '2024-02-01']['amount'].mean() cross_border_ratio = ( group[group['location_type'] == 'overseas']['amount'].sum() / total_spend if total_spend > 0 else 0 ) # 计算得分(加log防0) score = ( 0.4 * np.log1p(total_spend) + 0.3 * txn_count + 0.2 * (recent_avg if not np.isnan(recent_avg) else 0) + 0.1 * cross_border_ratio ) return pd.Series({ 'composite_score': round(score, 2), 'total_spend': total_spend, 'txn_count': txn_count, 'recent_avg': round(recent_avg, 2) if not np.isnan(recent_avg) else 0, 'cross_border_ratio': round(cross_border_ratio, 3) }) # 调用 composite_result = recent_df.groupby('customer_id').apply(calculate_composite_score) print("综合价值评分(前10客户):") print(composite_result.head(10))关键设计:
np.log1p()替代np.log(),避免log(0)报错;round(score, 2)确保分数精度可控,符合监管报告要求;- 返回
pd.Series包含所有中间指标,方便后续审计追踪。
3.7 场景七:异常检测聚合——让算法读懂业务语言
业务需求:识别“交易模式突变客户”——规则:近7天滚动均值较历史均值偏离>3σ,且突变发生在最近24小时内。
生产难点:需同时计算“长期均值”和“短期滚动均值”,且要定位突变时间点。
生产级写法(双时间尺度聚合):
def detect_abnormal_pattern(group): """交易模式突变检测(风控模型V3.5)""" # 按时间排序 sorted_group = group.sort_values('date') # 计算历史均值(排除最近7天) history_window = sorted_group[sorted_group['date'] < sorted_group['date'].max() - pd.Timedelta(days=7)] historical_mean = history_window['amount'].mean() if len(history_window) > 0 else np.nan historical_std = history_window['amount'].std() if len(history_window) > 0 else np.nan # 计算近7天滚动均值(按日) daily_agg = sorted_group.set_index('date').resample('1D')['amount'].sum() rolling_7d = daily_agg.rolling(window=7, min_periods=3).mean() # 找到最近24小时的滚动均值(即最后1个值) recent_rolling = rolling_7d.iloc[-1] if len(rolling_7d) > 0 else np.nan # 判定突变(3σ规则) is_abnormal = ( not np.isnan(recent_rolling) and not np.isnan(historical_mean) and not np.isnan(historical_std) and historical_std > 0 and abs(recent_rolling - historical_mean) > 3 * historical_std ) return pd.Series({ 'abnormal_flag': 1 if is_abnormal else 0, 'historical_mean': round(historical_mean, 2), 'historical_std': round(historical_std, 2), 'recent_rolling_mean': round(recent_rolling, 2), 'deviation_sigma': round(abs(recent_rolling - historical_mean) / historical_std, 2) if historical_std > 0 else np.nan }) # 执行检测 abnormal_result = recent_df.groupby('customer_id').apply(detect_abnormal_pattern) print("突变客户检测(前10):") print(abnormal_result.head(10))输出解读:
| customer_id | abnormal_flag | historical_mean | historical_std | recent_rolling_mean | deviation_sigma |
|---|---|---|---|---|---|
| C001 | 1 | 287.32 | 112.67 | 892.50 | 5.36 |
风控实践:
deviation_sigma字段直接输入风控引擎,作为模型特征;abnormal_flag同步至实时告警系统,延迟<2秒;- 所有计算在客户粒度隔离,避免A客户异常影响B客户基线。
4. 常见问题与实战排障:那些凌晨三点的告警真相
再完美的代码,上线后也会遇到意想不到的问题。我把过去三年处理过的27个高频故障,按发生频率排序,给出根因和速查方案。这些内容,是任何官方文档都不会写的“血泪笔记”。
4.1 NaN爆炸:为什么你的agg()结果全是空值?
现象:df.groupby('col')['val'].agg('mean')返回全NaN,但df['val'].describe()显示有有效数据。
根因TOP3:
- 分组键含NaN:
df.groupby('region')时,若region列有NaN值,pandas默认将其归为单独一组,且agg()对NaN组返回NaN; - 数据类型错误:
val列为object类型(含字符串),mean()无法计算; - 空分组:
groupby后某组无数据(如region='Antarctica'),agg()返回NaN。
速查命令:
# 检查分组键空值 print("region空值数量:", df['region'].isna().sum()) # 检查数据类型 print("val列类型:", df['val'].dtype) # 检查各组数据量 print("各region数据量:\n", df.groupby('region').size())修复方案:
# 方案1:删除分组键空值 df_clean = df.dropna(subset=['region']) # 方案2:强制转换类型 df['val'] = pd.to_numeric(df['val'],