尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

辛普森悖论详解:用Python识别分组与汇总结论相反的数据陷阱

辛普森悖论详解:用Python识别分组与汇总结论相反的数据陷阱 辛普森悖论并不冷门但每次遇到它时数据分析师都容易在最后一步下错结论。一个典型案例是两个实验组 A 和 B分别在简单任务和复杂任务上都表现得更好可是把所有数据合并后总通过率却是 B 更高。两组都显示 A 更好汇总后反而 B 赢了这种反直觉现象就是辛普森悖论Simpsons Paradox。这个问题的本质不是“统计算错了”而是“权重”在汇总时悄悄改变了结果。数据会被拆成不同组别也会按业务需要合并成整体看板。只要各组样本量差异很大分组结论和总体结论就可能互相矛盾。分析报告中如果只保留其中一个视角很容易得出与事实相反的决策。这篇文章会从数学条件讲清楚辛普森悖论为什么会出现用 Python 构造一份可以完整复现的数据集再把数据表格、可视化、自动检测函数和排查清单串起来。读完之后你应该能在报表、AB 实验、转化率分析或客服服务质量统计中快速识别出“分组获胜、汇总失败”的陷阱并知道该向决策者解释哪一个数字才是合理的。1. 先理解辛普森悖论分组结论与汇总结论冲突的本质辛普森悖论描述的现象是当数据被分成若干个子组时某个方案在每个子组中的表现都优于另一个方案但把所有子组数据合并成一个整体后优劣关系却发生了反转。它并不是某一组数据造假也不是计算过程中出现了除零或精度问题而是组间样本量差异和组内指标差异共同作用的结果。1.1 一个可以用数据复现的场景假设某客服平台有两个处理小组 A 和 B都处理两类工单简单工单和复杂工单。我们比较的是“工单解决率”哪个小组更高。先看分组数据简单工单A 解决了 9/10解决率 90%B 解决了 800/1000解决率 80%。复杂工单A 解决了 700/1000解决率 70%B 解决了 6/10解决率 60%。在两个分组内部A 都高于 B。按直觉A 的整体解决率也应该更高。可实际合并后是A 总成功数9 700 709总工单数10 1000 1010总解决率约 70.2%。B 总成功数800 6 806总工单数1000 10 1010总解决率约 79.8%。汇总结果显示 B 胜出。这个例子的关键点在于A 的优势主要展现在它处理得很少的简单工单上而简单工单又恰好被 B 大量承接复杂工单虽然 A 也比 B 好但它的复杂工单占比太高把整体比率拉了下去。B 则正好相反依靠大量通过率更高的简单工单把总比率推了上来。1.2 辛普森悖论的数学条件设一共两个分组 G1、G2某方案在 G1 的成功率为 r1在 G2 的成功率为 r2样本量分别为 n1、n2。方案 A 的总体成功率可以写成R_A (n_A1 * r_A1 n_A2 * r_A2) / (n_A1 n_A2)等价地令 w_A1 n_A1 / (n_A1 n_A2)w_A2 n_A2 / (n_A1 n_A2)则R_A w_A1 * r_A1 w_A2 * r_A2也就是说总体比率是“组内比率”按“组内样本占比”加权得到的。要发生辛普森悖论需要同时满足以下几个条件在每一个分组里A 的组内比率都不低于 B。A 在各组的样本分配比例与 B 差异很大。这种权重差异足够抵消 A 在组内比率上的优势。各分组的组内比率本身存在明显差异否则权重变化影响不大。用上面的数据验证A 总体 简单组权重 10/1010 * 90% 复杂组权重 1000/1010 * 70% ≈ 0.0099 * 0.9 0.9901 * 0.7 ≈ 0.702 B 总体 简单组权重 1000/1010 * 80% 复杂组权重 10/1010 * 60% ≈ 0.9901 * 0.8 0.0099 * 0.6 ≈ 0.798A 的简单组权重只有约 1%而 B 的简单组权重约 99%。即使 A 在每个组内都领先 10 个百分点也敌不过对方把自己大部分样本放在高通过率分组里。注意辛普森悖论不是“比率算错”而是“加权平均的加权方式”和“比较目标”没有对齐。计算本身可以完全正确但结论可能误导。1.3 容易误解的地方很多人在第一次接触辛普森悖论时会觉得“汇总指标是错的”“不能相信整体数据”。这种说法并不准确。合并后的 70.2% 和 79.8% 并没有算错它描述的是“在现有工单分配比例下两个团队的整体平均解决率”。问题在于如果 A 团队实际面临的工单构成和 B 完全不同直接用整体平均去比较两个团队的“能力”就不公平。真正想比较的往往是“如果两个团队处理同样的工单构成谁的解决率更高”。这时需要用同一套权重去标准化而不是简单合并。2. 用 Python 构造一个完整的辛普森悖论案例只讲理论不容易留下印象。下面用 Python 构造一份可运行的数据集从明细数据开始逐步复现“分组 A 赢、汇总 B 赢”的过程。2.1 环境准备在本地写一个脚本或 Jupyter Notebook 都可以。建议 Python 3.8 以上版本用到 pandas、numpy 和 matplotlib。如果还没有安装可以执行pip install pandas numpy matplotlib如果网络环境允许也可以使用 Anaconda 自带的数据分析环境通常已经包含这些库。本文示例使用 pandas 2.xnumpy 1.xmatplotlib 3.x 编写低版本也可以运行但输出格式可能略有差异。2.2 构造工单数据集我们先生成一个包含 2020 条记录的 DataFrame每一行表示一个工单字段包括team处理团队A 或 B。complexity工单复杂度simple 或 complex。resolved是否解决1 表示解决0 表示未解决。为了保留足够的控制力这里直接用聚合比例反推明细数据。代码如下import pandas as pd import numpy as np # 构造明细数据 simple_A_trials 10 simple_A_success 9 simple_B_trials 1000 simple_B_success 800 complex_A_trials 1000 complex_A_success 700 complex_B_trials 10 complex_B_success 6 # 生成 A 组明细 a_data [] a_data.extend([(A, simple, 1)] * simple_A_success) a_data.extend([(A, simple, 0)] * (simple_A_trials - simple_A_success)) a_data.extend([(A, complex, 1)] * complex_A_success) a_data.extend([(A, complex, 0)] * (complex_A_trials - complex_A_success)) # 生成 B 组明细 b_data [] b_data.extend([(B, simple, 1)] * simple_B_success) b_data.extend([(B, simple, 0)] * (simple_B_trials - simple_B_success)) b_data.extend([(B, complex, 1)] * complex_B_success) b_data.extend([(B, complex, 0)] * (complex_B_trials - complex_B_success)) df pd.DataFrame(a_data b_data, columns[team, complexity, resolved]) df df.sample(frac1, random_state42).reset_index(dropTrue) print(df.shape) print(df.head())输出示例(2020, 3) team complexity resolved 0 B simple 1 1 B simple 1 2 A complex 1 3 B simple 0 4 A complex 1这里使用了sample(frac1, random_state42)打乱行顺序避免明细数据按固定块排列影响后续查看。resolved列是 0/1 数值方便后续聚合求平均。2.3 分组统计与汇总统计先按team和complexity分组计算成功数、总数和解决率summary df.groupby([team, complexity], as_indexFalse).agg( success(resolved, sum), total(resolved, count) ) summary[rate] summary[success] / summary[total] print(summary)输出team complexity success total rate 0 A complex 700 1000 0.700000 1 A simple 9 10 0.900000 2 B complex 6 10 0.600000 3 B simple 800 1000 0.800000按team汇总时可以直接对resolved求和再求平均得到整体解决率overall df.groupby(team).agg( success(resolved, sum), total(resolved, count) ) overall[rate] overall[success] / overall[total] print(overall)输出success total rate team A 709 1010 0.702970 B 806 1010 0.798020关键点分组统计表里A 在simple和complex两个分组内的rate都高于 B。整体统计表里B 的rate反而更高。这个差异不是浮点精度问题而是分组权重不同导致的。建议在分析脚本中保留原始明细而不是只保留整体汇总表。后续一旦发现分组与整体结论不一致还能重新统计和绘图。3. 从数据表到可视化看清“权重”如何悄悄改变胜负3.1 整理一份包含样本量的汇总对照表为了避免“只看比率不看数量”的盲区可以把各个分组的成功率、样本量、样本占比放在同一张表里。数据集方案成功数总数成功率在该方案内占比简单工单A91090%10 / 1010 ≈ 1.0%简单工单B800100080%1000 / 1010 ≈ 99.0%复杂工单A700100070%1000 / 1010 ≈ 99.0%复杂工单B61060%10 / 1010 ≈ 1.0%全部工单A709101070.3%100%全部工单B806101079.8%100%从这张表可以直观看到A 之所以总体“输”是因为它有 99% 的样本落在成功率较低的复杂工单B 恰好相反有 99% 的样本落在成功率较高的简单工单。整体解决率被分组权重主导而组内优势在汇总时被冲淡。3.2 用 matplotlib 画分组和汇总对比图可视化在辛普森悖论中很有用因为人眼对条形长度差异敏感对比表格更直接。下面画一张由四个子图组成的对比图上排画两个分组的解决率对比下排画总体解决率对比。import matplotlib.pyplot as plt fig, axes plt.subplots(1, 3, figsize(12, 4)) labels [simple, complex] a_rates [0.9, 0.7] b_rates [0.8, 0.6] x np.arange(len(labels)) width 0.35 # 子图1简单工单 axes[0].bar(x - width/2, [0.9], width, labelA, color#4C72B0) axes[0].bar(x width/2, [0.8], width, labelB, color#DD8452) axes[0].set_xticks([0]) axes[0].set_xticklabels([simple]) axes[0].set_ylim(0, 1) axes[0].set_title(Simple group) axes[0].legend() # 子图2复杂工单 axes[1].bar(x - width/2, [0.7], width, labelA, color#4C72B0) axes[1].bar(x width/2, [0.6], width, labelB, color#DD8452) axes[1].set_xticks([1]) axes[1].set_xticklabels([complex]) axes[1].set_ylim(0, 1) axes[1].set_title(Complex group) axes[1].legend() # 子图3总体 axes[2].bar(x - width/2, [0.70297], width, labelA, color#4C72B0) axes[2].bar(x width/2, [0.79802], width, labelB, color#DD8452) axes[2].set_xticks([0]) axes[2].set_xticklabels([overall]) axes[2].set_ylim(0, 1) axes[2].set_title(Overall) axes[2].legend() plt.tight_layout() plt.show()运行后会看到前两个子图中 A 的柱子更高第三个子图中 B 的柱子更高。这幅图比数字更清楚地说明了悖论的存在。如果没有保存图片也可以用plt.savefig(simpson.png)把图保存到本地工作目录方便写入报告。3.3 从图形中读出“悖论”的成因看分组图时只需要比较柱子高度看总体图时还需要考虑“每个柱子背后的样本构成”。这正是辛普森悖论最容易被忽略的地方。从堆叠角度看A 的总体解决率更接近 70% 而不是 90%因为它的复杂工单权重接近 99%。B 的总体解决率更接近 80% 而不是 60%因为它的简单工单权重接近 99%。每一个分组内部A 都比 B 好但两个方案处理的工单结构完全不同导致整体结果发生逆转。绘图时建议同时标注样本量例如在柱子上方写 “n10”“n1000”否则读者很难发现权重差异。这一步虽然简单却能把一张误导性的对比图变成一张有解释力的分析图。4. 为什么不能简单说“谁赢了”分组明细与汇总指标该怎么选遇到辛普森悖论后业务方第一个问题通常是“你到底告诉我哪个赢了”正确答案取决于要回答的问题。4.1 混淆变量的角色在刚才的案例中complexity就是一个与团队选择和解决率同时相关的变量。A 团队可能专门负责难处理的复杂工单B 团队主要接简单工单。此时“工单复杂度”不是中性维度而是影响结论的混杂变量。如果直接忽略复杂度去比较总体比率等于把“团队能力”和“工单结构”混在一起比较。这不是统计软件能自动替你解决的问题需要分析人员基于业务背景判断哪个维度是真正的分组维度。4.2 分组比较与总体比较分别回答什么问题分组比较回答的是在简单工单中A 和 B 谁解决率高在复杂工单中A 和 B 谁解决率高总体比较回答的是在目前真实的工单分配比例下A 和 B 的整体平均解决率谁高如果业务目标是招聘或外包决策决策者真实想知道的是“如果让两个团队处理同样的一批工单谁做得更好”那么应该对工单构成做标准化也就是用同一套权重重新计算总体比率。例如假设两类工单各占 50%把 A 和 B 放在同样权重下比较A 标准化 0.5 * 0.9 0.5 * 0.7 0.8 B 标准化 0.5 * 0.8 0.5 * 0.6 0.7标准化后 A 胜出。但如果业务目标是“按现有工单结构评估两个团队下个月的产出”那么总体比率同样有参考价值只是它描述的是混合后的结果而不是纯粹能力对比。4.3 常见业务场景辛普森悖论在以下场景中经常出现AB 实验整体转化率 A 低但分新老用户看都是 A 高原因是 A 组新用户占比远大于 B 组。两道工序的线上异常率分节点看方案甲都优于方案乙但整体看方案乙更优。医学或公共政策统计轻症、重症分组结论与合并结论相反。招聘录取率院系层面某群体录取率更高但全校合并后另一个群体更高。这些场景有一个共同点作为分组的变量往往不只是标签而是与最终结果有因果关系的特征。4.4 两个错误解读错误解读一是“汇总数据完全没用”。在某些决策里汇总数据恰恰是决策者关心的结果。比如评估团队真实产出时团队处理的工单结构本身就是工作安排的一部分整体解决率能反映实际贡献。错误解读二是“分组数据一定比汇总更正确”。如果分组后样本量过小或者分组维度本身是人为主观划分的也可能会制造出虚妄的差异。正确做法是先明确比较目标。再决定是否按分组维度做标准化。最终同时展示分组明细和总体结果避免只给一个数字。# 标准化总体比率的简单计算 def standardized_rate(group_df, weights): rates group_df.set_index(complexity)[rate] total 0.0 for key, w in weights.items(): total rates[key] * w return total simple_complex_weight {simple: 0.5, complex: 0.5} for team in [A, B]: team_df summary[summary[team] team] print(team, round(standardized_rate(team_df, simple_complex_weight), 4))输出A 0.8 B 0.75. 识别辛普森悖论的排查清单从报表数据到结论输出经验丰富的分析师通常会发现辛普森悖论不在于“算不出来”而在于“没想起来要做分层检查”。下面这条排查路径适合在生成报表或完成 AB 实验分析后快速自查。5.1 检查顺序确认输入数据是否正确成功数是否误用了总数分类列是否包含缺失值。确认分组变量定义是否合理是业务含义明确的维度还是无关的标签。计算每个子组的分组指标和样本量。将子组结果与整体结果并列输出人工检查方向是否一致。如果方向不一致检查各方案的组内样本占比差异。用标准化权重重算一次总体指标看结论是否改变。在报告中同时保留分层明细和汇总结果。5.2 自动检查的伪代码逻辑可以用 Python 写一个函数自动判断“是否存在辛普森悖论风险”。核心逻辑如下对每个分组计算 A、B 的指标。判断 A 是否在每个分组中都优于 B或 B 在每个分组中都优于 A。计算整体指标判断整体优劣方向与分组优势方向是否相反。如果相反输出警告。详细实现放到下一节。5.3 常见坑常见坑错误现象根本原因处理建议只汇报整体汇总表分组明明是 A 赢整体却显示 B 赢组间样本量差异被平均吞掉汇报时拆分核心维度标注样本量只汇报分组明细决策者不知道整体水平缺少对真实混合分布的判断同时给出“标准化后”的总比率分组维度选得过于随意换个分组变量结论又变把无关变量当作分层变量优先使用业务逻辑明确的维度样本量极小的组也参与计算一个样本决定整个组的方向小样本波动被放大设置最小样本量阈值低于阈值不参与结论把比率与绝对量混在一起用户认为“数量多就等于比率高”混淆总量和均值图表同时标注 n 和 rate忽略趋势性影响时序数据被合并后结论相反期初和期末用户结构不同按时间分段观察再做总览这些坑在真实项目里经常同时出现排查时建议从“最容易改动”的输入数据开始再逐步深入到指标计算和结论解释。不要只验证程序能启动还要验证输入、输出、异常分支和日志是否符合预期。辛普森悖论的验证重点是“分组方向”和“整体方向”是否一致。6. 写一个最小可用的辛普森悖论检测函数日常分析中与其每次手工观察不如把检测逻辑沉淀成一个函数。下面用一个聚合表作为输入自动输出风险报告。6.1 输入输出约定函数输入是一个 DataFrame至少包含四列group分组维度例如 complex、simple。variant方案名称例如 A、B。success成功数。total该组总数。函数输出是一份文本报告包含每个分组的指标和样本量。整体指标。是否检测到方向反转。给出风险提示。6.2 Python 实现import pandas as pd def detect_simpson(df, group_colgroup, variant_colvariant, success_colsuccess, total_coltotal): 检测是否存在辛普森悖论风险。 要求每个 group 内同一 variant 只有一行。 df df.copy() df[rate] df[success_col] / df[total_col] # 每个分组内比较 variant 的 rate 大小 groups df.groupby(group_col)[[variant_col, rate, total_col]] variant_set df[variant_col].unique() if len(variant_set) ! 2: return 当前函数只支持两个 variant 的比较。 v1, v2 variant_set[0], variant_set[1] all_group_direction_same True group_direction None for g, sub in df.groupby(group_col): rate_dict dict(zip(sub[variant_col], sub[rate])) if v1 not in rate_dict or v2 not in rate_dict: all_group_direction_same False continue diff rate_dict[v1] - rate_dict[v2] current_direction 1 if diff 0 else (-1 if diff 0 else 0) if group_direction is None: group_direction current_direction elif current_direction ! group_direction: all_group_direction_same False # 整体比较 overall df.groupby(variant_col).agg( success(success_col, sum), total(total_col, sum) ) overall[rate] overall[success] / overall[total] overall_diff overall.loc[v1, rate] - overall.loc[v2, rate] overall_direction 1 if overall_diff 0 else (-1 if overall_diff 0 else 0) # 汇总报告 report_lines [] report_lines.append(分组指标) report_lines.append(df.to_string(indexFalse)) report_lines.append(\n整体指标) report_lines.append(overall.to_string()) if all_group_direction_same and group_direction ! 0 and overall_direction ! group_direction: report_lines.append(\n风险检测到辛普森悖论分组结论与整体结论方向相反。) else: report_lines.append(\n未检测到明显的方向反转。) return \n.join(report_lines)函数先计算每个子组的 rate再判断各子组内 variant 的顺序是否一致。最后计算整体 rate比较整体方向和分组方向。如果分组方向一致且整体方向相反就报告风险。6.3 使用示例继续使用前面的聚合表agg summary.copy() report detect_simpson(agg) print(report)输出分组指标 team complexity success total rate 0 A complex 700 1000 0.700000 1 A simple 9 10 0.900000 2 B complex 6 10 0.600000 3 B simple 800 1000 0.800000 整体指标 success total rate team A 709 1010 0.702970 B 806 1010 0.798020 风险检测到辛普森悖论分组结论与整体结论方向相反。这个函数已经能处理最简单的两方案、多分组场景。如果 variant 超过两个可以改为两两比较。如果分组维度有多个字段也可以先对多个字段组合成一个group再调用函数。6.4 扩展到批量检测实际数据不会只有一张表。假设result_dimensions是一个候选分组维度列表例如[complexity, week, member_level]可以循环调用检测函数for dim in candidate_dims: tmp df.groupby([team, dim], as_indexFalse).agg( success(resolved, sum), total(resolved, count) ) tmp tmp.rename(columns{dim: group, team: variant}) print( * 40) print(分组维度, dim) print(detect_simpson(tmp))循环输出的好处是能把所有可能存在悖论的维度都列出来而不是只看第一个。7. 最佳实践与扩展方向辛普森悖论不是一道脑筋急转弯而是数据工作中非常现实的“口径陷阱”。越大的报表越容易踩中。以下几点建议可以直接落到项目里。7.1 数据分析中的三条基本纪律第一任何汇总指标都要附带样本量或占比。没有样本量的比率不具备可比性。第二优先展示分层结果再展示合并结果。合并结果只是辅助结论不能作为唯一依据。第三做出业务判断前必须先明确“分组变量是否是需要控制的混淆变量”。如果需要控制就应该做标准化而不是简单看整体均值。7.2 生产报表与 AB 实验中的落地建议在生产环境中建议在指标计算层预设“分层校验规则”。当报表指标发生变化时系统自动对比核心分层的方向是否有反转。具体实现可以这样设计定义核心分层维度如渠道、用户等级、业务线、工单类型。每天生成主指标时同时生成各分层指标。如果整体指标方向与所有主要分层方向相反自动触发告警。告警通知到数据负责人由人来判断是业务真实变化还是口径问题。在 AB 实验平台中除了看总体转化率还应该看按新老用户、设备类型、省份、会员等级分层的转化率矩阵。实验结论需要同时满足“分层效果好”和“整体效果一致”才能作为发布依据。如果分层方向一致但总量不显著可以继续观察如果分层方向与总量矛盾优先排查分流是否均匀。7.3 算法与统计扩展方向如果希望进一步系统化地处理这类问题可以研究以下方向标准化率standardized rate用统一权重计算调整后的总体率适合做总体能力比较。Cochran-Mantel-Haenszel 检验在分层后检验两个变量的关联是否一致适合分类数据。分层回归 / 多水平模型把分组信息作为随机效应或固定效应纳入模型减少聚合损失。因果推断中的后门调整当混淆变量已知时用分层、倾向评分或逆概率加权调整差异。这一层已经超出单纯的数据处理进入统计建模和实验设计范畴。但在掌握辛普森悖论之后你至少应该形成这样的习惯任何汇总数字都不是“最终答案”而是“在某种权重下的答案”。把权重说清楚结论才立得住。
返回列表