1. 项目概述当数据不再是一张“平铺直叙”的表格你有没有遇到过这样的场景销售部门要按季度、按区域、按产品线三个维度交叉统计营收财务要同时看“上月同比”和“本季环比”两个时间粒度的毛利变化而运营团队却在盯着“新客来源渠道 × 用户活跃天数分段 × 当月复购率”这个三维切片找增长瓶颈——这些都不是简单的“SUMIF”能搞定的它们共同指向一个核心能力多维聚合中的数据操纵Data Manipulation in Multi-Dimensional Aggregation。这正是本篇标题“Part 20: Data Manipulation in Multi-Dimensional Aggregation”所锚定的真实战场。它不是教你怎么写GROUP BY而是解决当你面对一个已经聚合好的、带有多重索引比如pandas的MultiIndex、或嵌套结构如JSON中的层级数组、或OLAP立方体Cube切片后的结果时如何精准地“掰开”、“揉碎”、“重组”、“嫁接”这些高维数据块让它们真正服务于业务决策的临门一脚。我做过7年BI架构和数据分析落地亲手重构过12个企业级报表系统最深的体会是80%的报表卡点不在于原始数据没取到而在于聚合后的数据“太僵硬”——它像一块刚出炉的钢板有强度但缺延展性你没法把它弯成你需要的弧度也没法在上面钻出新的孔位。本篇讲的就是怎么用最少的代码、最稳的操作把这块钢板变成可锻打、可冲压、可焊接的工业级材料。适合所有正在用pandas做分析、用SQL写宽表、用Power BI拖拽建模却总在“最后一步”被卡住的从业者。无论你是刚学完groupby的新手还是天天和DAX公式打交道的老手只要你需要把“已聚合的结果”再加工一次这篇就是为你写的。2. 核心思路拆解为什么不能只靠一层GROUP BY2.1 传统聚合的“单点快照”局限性我们先看一个典型陷阱。假设你有一张销售明细表sales字段包括order_id、product_id、region、quarter、amount。常规做法是SELECT region, quarter, SUM(amount) AS total_revenue FROM sales GROUP BY region, quarter;这条SQL输出一个二维表格行是region列是quarter值是total_revenue。看起来很完美错。它只是一张“静态快照”。你无法用它直接回答“华东区Q3的营收占全国Q3总营收的比重是多少”——因为全国Q3总营收这个标量根本不在这张表里。你得再跑一次SQL单独算SUM(amount) WHERE quarter Q3然后手动做除法。更麻烦的是“华东区Q3相比Q2的增长率”——你得把Q2的数据也拉出来再做JOIN或子查询。每一次新问题都意味着一次新的、独立的聚合计算。这就像每次想换个角度看一幅画都得重新买一张新画布、请画家重画一遍。效率低、维护难、逻辑散。提示真正的多维聚合操纵核心目标不是“生成一张表”而是“构建一个可交互、可推演、可衍生的数据空间”。它要求聚合结果本身携带维度上下文并支持在该上下文中进行动态运算。2.2 多维聚合操纵的三层能力模型基于多年实战我把这项能力拆解为三个递进层次它们共同构成了本篇的骨架第一层结构重塑Reshaping这是最基础也最常被忽视的能力。指对已聚合结果的物理形态进行变换比如把“region quarter amount”三列长表转成“region为行、quarter为列、amount为值”的宽表pivot或者反过来把宽表“熔化”melt回长表甚至把二维表“升维”成带层级索引MultiIndex的Series或DataFrame。它的价值在于统一数据形态为后续计算铺路。我见过太多团队因为没做这一步导致同一个指标在不同报表里用不同形态存储最终连口径对不上。第二层上下文计算Contextual Computation这是本篇的核心。它利用第一层构建的结构化空间进行“带坐标系的运算”。比如在region×quarter的二维空间里计算每个region的“占本季度全国总额的百分比”即按quarter轴求和再做除法或计算每个quarter的“环比增长率”即按region轴错位相减。关键在于运算的“参照系”axis是明确的、可指定的而不是全局的。这就像在Excel里用$符号锁定行列但在这里是用代码语言精确声明“我对哪一维求和”、“我对哪一维做差分”。第三层跨维嫁接Cross-Dimensional Joining这是最高阶能力也是业务复杂度的真实映射。它解决的是“不同聚合粒度、不同维度组合的结果如何安全拼接”的问题。例如你有一张“产品线×季度”的营收表另一张“区域×月份”的用户数表现在要算“各产品线在华东区的月度人均营收”。这要求你先把“区域×月份”用户数按产品线维度“广播”broadcast或“映射”map过去再与营收表对齐。这不是简单JOIN因为维度不匹配也不是盲目填充因为会引入错误。它需要理解维度间的语义关系如“华东区”是“区域”维度的一个成员“Q3”是“季度”维度的聚合而“月份”是其子粒度并据此设计安全的对齐策略。这三层不是割裂的而是一个流水线结构重塑是预处理上下文计算是主引擎跨维嫁接是终态组装。本篇Part 20聚焦的就是这个主引擎——如何在已有的多维结构上精准、高效、可复用地执行上下文计算。2.3 方案选型为什么首选pandas SQL混合范式市面上有多种技术路径纯SQL窗口函数、ROLLUP、纯Pythonpandas、BI工具内置公式DAX、MDX、甚至专用OLAP引擎ClickHouse、Doris。我的选择非常明确以pandas为核心操作层以SQL为上游聚合层形成混合范式。理由如下SQL负责“粗粒度聚合”pandas负责“细粒度操纵”SQL擅长在海量原始数据上做高效分组汇总GROUP BY AGG这是它的强项而pandas在内存中对已聚合的小数据集通常1GB做灵活变形、计算、合并速度极快且API直观。把所有逻辑塞进SQL会导致查询臃肿、调试困难、复用性差一个复杂的WITH RECURSIVE可能写200行而全用pandas读取原始大表又会因内存和I/O成为瓶颈。分工明确才是工程最优解。pandas的MultiIndex是天然的多维坐标系pandas的MultiIndex不是噱头它是经过生产环境千锤百炼的设计。你可以用df.index.get_level_values(region)精准提取某一层的值用df.unstack(quarter)一键完成维度旋转用df.groupby(levelregion).transform(sum)在region维度内做广播求和。这种“把维度当对象来操作”的范式比SQL里一堆CASE WHEN和子查询清晰十倍。规避BI工具公式的“黑盒陷阱”DAX的CALCULATE函数很强大但它的上下文转换Context Transition规则极其晦涩一个括号位置不对结果就全错。我曾帮一家零售客户排查过一个持续半年的GMV偏差根源就是DAX中FILTER函数的迭代上下文被意外覆盖。而pandas的所有操作都是显式的、可打印的、可断点调试的。print(df)就能看到当前状态df.info()立刻知道索引结构这是确定性的保障。所以本篇所有实操都将围绕pandas展开但我会明确告诉你每一步的SQL上游对应是什么确保你既能深入细节又能跳出框架看全局。3. 核心细节解析MultiIndex的构造、验证与陷阱3.1 构造MultiIndex的三种可靠路径MultiIndex是多维聚合操纵的基石。但很多人的MultiIndex是“歪的”导致后续所有计算都错。下面这三种构造方式是我从上百个项目中总结出的最稳妥路径按推荐顺序排列路径一SQL上游直接生成最推荐在数据库层就完成分组返回带明确层级的结构。例如-- PostgreSQL示例用GROUPING SETS生成多维聚合 SELECT COALESCE(region, ALL) AS region, COALESCE(quarter, ALL) AS quarter, SUM(amount) AS total_revenue, GROUPING(region) AS region_grp, GROUPING(quarter) AS quarter_grp FROM sales GROUP BY GROUPING SETS ((region, quarter), (region), (quarter), ()) ORDER BY region_grp, quarter_grp;这段SQL会返回四类行具体regionquarter、region小计、quarter小计、总计。关键在COALESCE(region, ALL)和GROUPING()函数它们确保了“ALL”这个占位符是数据库原生生成的语义清晰不会与真实数据混淆。下游pandas只需pd.read_sql()读取再用set_index([region, quarter])即可得到标准MultiIndex。优势数据源头可控无歧义性能最优。路径二pandasgroupby().agg()set_index()次推荐当必须用pandas聚合时务必避免df.groupby([region,quarter]).sum()这种写法因为它默认返回一个带MultiIndex的DataFrame但索引名可能为空或混乱。正确姿势是# 步骤1明确指定聚合列和索引名 agg_result ( sales_df .groupby([region, quarter], as_indexTrue) # as_indexTrue是关键 .agg({amount: sum}) .rename(columns{amount: total_revenue}) ) # 步骤2显式设置索引名杜绝空名 agg_result.index.names [region, quarter] # 验证打印索引结构 print(agg_result.index) # 输出MultiIndex([(East, Q1), (East, Q2), ...], names[region, quarter])as_indexTrue强制将分组列转为索引index.names显式命名这两步缺一不可。我踩过的最大坑就是忘了as_indexTrue结果得到一个普通DataFrame后面所有.unstack()都报错。路径三pd.MultiIndex.from_tuples()应急用当数据来自CSV或API且维度信息分散在多列时这是最后手段。例如你有一个CSV列是region_quarter,total_revenue其中region_quarter值为East_Q1。此时# 从字符串解析出元组 tuples [ tuple(x.split(_)) for x in df[region_quarter] ] # [(East, Q1), (East, Q2), ...] # 构造MultiIndex multi_idx pd.MultiIndex.from_tuples( tuples, names[region, quarter] ) # 赋给DataFrame df_new df.set_index(multi_idx).drop(region_quarter, axis1)注意from_tuples()要求元组长度严格一致且顺序必须与names参数匹配。一旦有East_Q1_Q2这种异常值整个构造就失败。所以这招只用于数据质量绝对可控的场景或临时调试。注意永远不要用pd.MultiIndex.from_product()去“猜”维度组合它会生成笛卡尔积把不存在的组合如West_Q4如果West根本没有Q4数据也补全污染数据。真实业务数据是稀疏的MultiIndex必须反映这种稀疏性。3.2 验证MultiIndex健康度的四个必检项一个健康的MultiIndex不是“能跑就行”而是要经得起业务逻辑的拷问。我在每个项目启动时都会运行这四条检查索引名完整性检查assert agg_result.index.names [region, quarter], \ f索引名错误期望[region,quarter]实际{agg_result.index.names}索引名是维度的“身份证”。没有名字df.unstack(quarter)就会报KeyError因为pandas不知道你要unstack哪个level。层级唯一性检查# 检查每个层级内部是否唯一避免重复region名 for level_name in agg_result.index.names: level_values agg_result.index.get_level_values(level_name) if not level_values.is_unique: print(f警告{level_name}层级存在重复值) print(level_values[level_values.duplicated()].unique())重复的region名如一个叫East另一个叫east会导致后续groupby(levelregion)聚合结果错误。必须在上游清洗干净。数据稀疏性验证# 统计每个region有多少个quarter记录 region_counts agg_result.index.get_level_values(region).value_counts() print(Region季度覆盖情况) print(region_counts.describe()) # 查看min/max/mean判断是否均匀如果华东有4个quarter而华北只有1个说明数据采集有问题不能直接算“平均季度营收”。数值合理性快照# 打印前5行、后5行、以及各维度小计 print(前5行\n, agg_result.head()) print(后5行\n, agg_result.tail()) print(Region小计\n, agg_result.groupby(levelregion).sum()) print(Quarter小计\n, agg_result.groupby(levelquarter).sum())这是最重要的一步。通过肉眼比对小计与明细能发现90%的聚合逻辑错误。比如你发现agg_result.groupby(levelquarter).sum()的Q3值和SQL里SUM(amount) WHERE quarterQ3不一致那一定是上游聚合条件写错了。这四条检查我封装成了validate_multiindex(df)函数放在每个分析脚本的开头。它不耗时但能省下你三天的排查时间。3.3 常见陷阱那些让你深夜加班的“幽灵Bug”陷阱一“ALL”占位符的类型陷阱在SQL中用COALESCE(region, ALL)如果region是INT类型比如用ID代替名称那么ALL会被强制转为INT变成NULL或0彻底破坏维度语义。正确做法是COALESCE(CAST(region AS TEXT), ALL)确保类型一致。我在一个金融项目里就因为这个把“ALL”算成了客户ID为0的某个真实客户导致全量报表偏差17%。陷阱二unstack()的fill_value默认为NaN当你df.unstack(quarter)时如果某个region没有Q4数据对应单元格就是NaN。如果你后续做df.sum(axis1)NaN会被忽略结果看似正确。但一旦你做df.mean(axis1)NaN就会拉低均值。必须显式指定fill_value0df.unstack(quarter, fill_value0)。这是新手最容易忽略的细节。陷阱三reset_index()的drop参数误用df.reset_index(dropTrue)会丢弃所有索引得到一个RangeIndexMultiIndex荡然无存。而df.reset_index(dropFalse)默认会把索引列转为普通列保留数据。但如果你写了df.reset_index(dropTrue)又没意识到后面所有df.set_index()都得重来。我的经验是永远显式写dropFalse哪怕多敲两个字符。陷阱四sort_index()的隐式排序需求pandas很多操作如groupby(level...)、unstack()要求MultiIndex是排序的。如果你的SQL返回顺序是乱的df.sort_index()是必须的。但sort_index()默认按所有level升序排如果你的quarter是Q1,Q2,Q3,Q4它会排对但如果是Q4,Q1,Q3,Q2就会错乱。解决方案先定义排序顺序quarter_order [Q1, Q2, Q3, Q4] df df.sort_index( levelquarter, keylambda x: x.map({q: i for i, q in enumerate(quarter_order)}) )这些不是理论是我在凌晨三点改完上线的血泪教训。记住MultiIndex不是装饰它是你的数据坐标系。坐标系歪了所有计算都是徒劳。4. 实操过程从基础变形到高级上下文计算4.1 结构重塑Pivot、Melt与Unstack的黄金三角结构重塑是所有高级计算的前提。我把它比作“数据折纸”——把一张平面纸折成你需要的立体形状。pandas提供了三个核心API它们构成黄金三角缺一不可。unstack()维度升维最常用这是把某一level的索引“抬升”为列的过程。例如你有region×quarter的MultiIndex Series# 假设s是Seriesindex是MultiIndex([(East,Q1), (East,Q2), ...]) # 值是total_revenue s pd.Series([100, 120, 90, 110], indexpd.MultiIndex.from_tuples([(East,Q1), (East,Q2), (West,Q1), (West,Q2)]), namerevenue) # unstack(quarter)把quarter这一层变成列 df_wide s.unstack(quarter, fill_value0) print(df_wide) # 输出 # quarter Q1 Q2 # East 100 120 # West 90 110关键参数level指定哪一层升维fill_value指定缺失值填什么必须设。unstack()返回DataFrame行是剩余索引这里是region列是被升维的levelquarter。stack()维度降维unstack()的逆操作当你需要把宽表变回长表或为后续groupby准备时用stack()# df_wide是上面的宽表 s_long df_wide.stack(quarter) # 把quarter列变回索引 print(s_long) # 输出 # East Q1 100 # Q2 120 # West Q1 90 # Q2 110 # dtype: int64stack()的level参数指定哪一列要“压”下去。它和unstack()是严格可逆的前提是fill_value一致。melt()通用长表化处理普通DataFrame当你的数据是普通DataFrame非MultiIndex且需要把多列“熔化”成一行多列时用melt()# df_normal是普通DataFrame列是region, Q1, Q2, Q3 df_normal pd.DataFrame({ region: [East, West], Q1: [100, 90], Q2: [120, 110], Q3: [130, 105] }) # melt所有季度列保留region为id_vars df_long df_normal.melt( id_vars[region], value_vars[Q1, Q2, Q3], var_namequarter, value_namerevenue ) print(df_long) # 输出 # region quarter revenue # 0 East Q1 100 # 1 West Q1 90 # 2 East Q2 120 # ...melt()不依赖索引是处理BI导出宽表的利器。但注意melt()后得到的是普通DataFrame若要进行多维计算还需set_index([region,quarter])重建MultiIndex。实操心得我从不单独使用这三个API而是组合使用。典型流程是SQL聚合 →set_index()建MultiIndex →unstack()做宽表透视 →melt()加新维度 →set_index()重建。这个闭环能应对95%的报表变形需求。记住口诀“unstack升维stack降维melt熔化set_index筑基”。4.2 上下文计算groupby(level...)与transform()的威力这才是Part 20的灵魂。当数据有了MultiIndexgroupby(level...)就变成了你的“上帝视角”让你能对任意维度做局部运算。案例1计算各region占本季度全国总额的百分比这是最经典的“按列求和再做除法”。在宽表df_wideregion行quarter列中# 方法1用div()指定axis0按行广播 national_total df_wide.sum(axis0) # 得到Series: Q1 190, Q2 230 df_pct df_wide.div(national_total, axis1) # axis1表示按列索引quarter对齐 # 方法2用groupby(level...)更通用 # 先stack回长表再按quarter分组 s_long df_wide.stack(quarter) s_pct s_long / s_long.groupby(levelquarter).transform(sum) # transform(sum)返回一个与s_long等长的Series每个值是其quarter组的总和两种方法结果一致但方法2的优势在于它不依赖宽表形态。如果数据是MultiIndex Series方法2直接可用而方法1必须先unstack。这就是groupby(level...)的普适性。案例2计算各quarter的环比增长率Q2/Q1-1, Q3/Q2-1这需要“错位相减”。pandas的diff()和pct_change()是为此而生# 在宽表df_wide上按列quarter计算环比 df_qoq df_wide.pct_change(axis1) # axis1按列索引顺序计算 print(df_qoq) # quarter Q1 Q2 Q3 # East NaN 0.2 0.083333 # West NaN 0.222222 -0.045455 # 解释Q2列的值 (Q2-Q1)/Q1Q3列的值 (Q3-Q2)/Q2pct_change()自动处理了分母为零、NaN传播等边界。但注意它依赖列的顺序。如果quarter列是[Q4,Q1,Q2,Q3]结果就全错。所以sort_index(axis1)必须前置。案例3计算每个region的“季度营收波动系数”标准差/均值这需要跨多个quarter计算统计量# 在长表s_long上操作index: region, quarter; values: revenue # 先按region分组再对每个组内的revenue计算变异系数 def cv_func(x): return x.std() / x.mean() if x.mean() ! 0 else 0 region_cv s_long.groupby(levelregion).apply(cv_func) print(region_cv) # East 0.123 # West 0.089groupby(levelregion).apply()是终极武器可以封装任何自定义逻辑。但要注意apply()比transform()慢如果只是简单聚合优先用agg()。案例4安全的“Top N”筛选按region内quarter排序取前2这是报表中高频需求。错误做法是df_wide.nlargest(2, columnsQ1)它只取全局前2。正确做法# 在长表s_long上按region分组对每个组内的revenue降序取前2 top2_per_region ( s_long .groupby(levelregion, group_keysFalse) # group_keysFalse避免索引重复 .apply(lambda x: x.nlargest(2)) ) print(top2_per_region) # East Q3 130 # Q2 120 # West Q1 90 # Q2 110group_keysFalse是关键否则结果索引会是(East,East,West,West)难以处理。实操心得所有上下文计算本质都是“指定一个维度level在这个维度上做聚合sum, mean, diff然后把结果广播broadcast回原数据”。transform()是广播的黄金函数agg()是聚合的基石apply()是兜底方案。我建议新手先死记这三者transform用于“同形输出”如标准化agg用于“降维输出”如求和apply用于“任意输出”。4.3 跨维嫁接join()、map()与reindex()的安全实践当你要把不同维度、不同粒度的数据“缝合”在一起就是跨维嫁接。这是最易出错也最体现功力的部分。场景把“产品线×季度”营收表与“区域×季度”用户数表合成“产品线×区域×季度”的人均营收Step 1明确维度关系“产品线×季度”表A维度[product_line, quarter]“区域×季度”表B维度[region, quarter]目标[product_line, region, quarter]关系product_line和region是正交维度无直接映射quarter是公共维度。Step 2广播Broadcast用户数到产品线维度表B的用户数对每个product_line都一样。所以我们要把B“复制”到每个product_line下# 假设A和B都是Seriesindex是MultiIndex # A: index[(P1,Q1), (P1,Q2), ...], valuesrevenue # B: index[(East,Q1), (East,Q2), ...], valuesuser_count # 先把B unstack(quarter)得到region×quarter宽表 b_wide B.unstack(quarter, fill_value0) # shape: (n_regions, n_quarters) # 再把A unstack(quarter)得到product_line×quarter宽表 a_wide A.unstack(quarter, fill_value0) # shape: (n_products, n_quarters) # 关键用b_wide.reindex()按product_line索引但填充region数据 # 这里用reindex的methodffill不行因为维度无关。正确做法是 # 创建一个新索引是product_line和region的笛卡尔积再map product_list a_wide.index.tolist() region_list b_wide.index.tolist() new_index pd.MultiIndex.from_product( [product_list, region_list], names[product_line, region] ) # 把b_wide广播到new_index对每个(product_line, region)取b_wide.loc[region] user_broadcast pd.Series( indexnew_index, data[b_wide.loc[r].values for p in product_list for r in region_list] ).unstack(quarter) # 变成(product_line, region) × quarter # 现在a_wide和user_broadcast都是×quarter可以安全除法 per_capita a_wide.div(user_broadcast, axis0) # axis0按行product_line,region对齐这段代码的核心是reindex()和from_product()的组合。reindex()不是为了查找而是为了“按需生成”新索引再用列表推导式填充数据。这是最安全的广播方式不依赖任何隐式规则。Step 3处理缺失与对齐实际中A和B的quarter可能不完全重合A有Q1-Q4B只有Q1-Q3。这时div()会自动对齐缺失处为NaN。但你需要决定NaN是“无数据”还是“0”我的原则是在div()前先用fillna(0)或dropna()明确处理# 保守策略缺失季度视为0用户人均营收为inf需后续处理 user_broadcast user_broadcast.fillna(0) per_capita a_wide.div(user_broadcast, axis0).replace([np.inf, -np.inf], np.nan)或者激进策略只保留双方都有的quartercommon_quarters a_wide.columns.intersection(b_wide.columns) a_common a_wide[common_quarters] b_common b_wide[common_quarters]没有银弹选择取决于业务定义。我的经验是在报表中明确标注“数据覆盖范围”比强行填充更重要。实操心得跨维嫁接没有捷径必须手工定义维度关系。我拒绝使用pd.merge()处理MultiIndex因为它的on参数在多级索引下极易出错。reindex()from_product()map()的组合虽然代码稍长但逻辑透明可审计是生产环境的唯一选择。5. 常见问题与排查技巧实录5.1 问题速查表从报错信息反推根因报错信息最可能原因排查步骤我的修复方案KeyError: regionMultiIndex的names未设置或名字拼写错误print(df.index.names)df.index.names [region, quarter]ValueError: Index has duplicate keys某一层级如region存在重复值df.index.get_level_values(region).duplicated().any()清洗上游数据或用df.groupby(levelregion).first()去重TypeError: cannot concatenate object of type class numpy.ndarrayunstack()后某列数据类型不一致如混有str和intdf_wide.dtypesdf_wide df_wide.astype(float)强制转换ValueError: operands could not be broadcast together with shapes (n,) (m,)两个Series的索引不匹配无法做或/print(s1.index.equals(s2.index))用s1.reindex(s2.index, fill_value0)对齐PerformanceWarning: DataFrame is highly fragmented频繁drop()、assign()导致内存碎片df df.copy()在关键步骤后加df df.copy()强制整理内存这张表是我贴在工位上的“急救卡”。每次报错先看表80%的问题5分钟内解决。5.2 真实排障日记一个持续3天的“负增长”之谜现象某电商客户报表显示华东区Q3的GMV环比Q2增长率为-15%但财务系统确认是8%。数据源完全一致。排查过程Day 1检查SQL聚合逻辑确认SUM(amount)无误。print(df.groupby(levelquarter).sum())显示Q21000万Q31080万正确。Day 2检查pandas计算。df_wide.pct_change(axis1)输出Q3列-0.15。打印df_wide发现Q2列值是1000Q3列值是850。矛盾Day 3print(df_wide.info())发现Q3列是object类型不是float64。print(df_wide[Q3].head())输出[850, 920, 780, ...]—— 字符串根因上游ETL脚本中一个fillna(N/A)把整列转为了object。pct_change()对字符串无效但pandas静默返回了错误结果。修复df_wide df_wide.apply(pd.to_numeric, errorscoerce)再fillna(0)。教训永远在pct_change()、diff()等数值计算前加df.dtypes检查。字符串混入数值列是隐形杀手。5.3 性能优化三板斧让百万行聚合秒出当MultiIndex规模达到百万级如10万region × 10个quartergroupby().apply()会变慢。我的三板斧板斧一用agg()替代apply()agg()是Cython优化的apply()是Python循环。能用agg()的绝不用apply()# 慢 df.groupby(levelregion).apply(lambda x: x.mean() / x.std()) # 快 df.groupby(levelregion).agg([mean, std]).apply(lambda x: x[mean]/x[std], axis