Files
AgentLab/backend/tests/test_analysis_differ.py
T

142 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""差异检验(app/analysis/differ.py,细案 01 §5):比率小样本走 Fisher、均值 Welch、Holm、rows 敏感性翻转。每个用例都顺带核对
复算和运行逐字段相同。"""
from __future__ import annotations
import math
from fractions import Fraction
import pytest
from app.analysis import stats
from tests.analysis_samples import analyze, call_error, snap
RATE_SQL = "SELECT region, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders GROUP BY region"
def rates(rows):
return snap("Q1", RATE_SQL, ["region", "refunds", "orders"], rows)
async def rate(rows, **params):
params = {"query": "Q1", "by": "region", "numerator": "refunds", "denominator": "orders", "label": "退款率", **params}
return await analyze("differ", "rate", snaps=[rates(rows)], **params)
async def test_small_samples_use_fisher_and_say_it_may_be_noise():
result = await rate([["西南", 2, 40], ["华东", 5, 230]])
assert result["method"] == "differ.rate@1"
assert result["verdict"] == {"code": "no_evidence", "subjects": [["西南"], ["华东"]], "direction": None}
assert result["values"]["test"] == "fisher" and result["values"]["n_a"] == "40" and result["values"]["n_b"] == "230"
assert Fraction(result["values"]["p"]) == pytest.approx(stats.fisher_exact(2, 38, 5, 225), rel=1e-9)
assert result["phrase"] == ("西南和华东的退款率差异可能只是样本波动(样本量 40、230);样本少(有格子的期望频数不到 5),"
"用了精确检验")
assert "p 值" not in result["phrase"] and "显著" not in result["phrase"]
assert result["settings"] == {"differ_alpha": 0.05, "min_expected": 5}
async def test_large_samples_use_the_pooled_z_test():
result = await rate([["西南", 80, 1000], ["华东", 40, 1000]])
assert result["verdict"] == {"code": "differs", "subjects": [["西南"], ["华东"]], "direction": "up"}
z, p = stats.pooled_z(80, 1000, 40, 1000)
assert Fraction(result["values"]["p"]) == pytest.approx(p, rel=1e-9) and result["values"]["test"] == "z"
low, high = stats.newcombe(80, 1000, 40, 1000)
assert Fraction(result["values"]["ci_lo"]) == pytest.approx(low, rel=1e-9)
assert result["values"]["a"] == "0.08" and result["values"]["diff"] == "0.04"
assert result["phrase"] == "西南的退款率(8.0%)高于华东(4.0%),差异站得住"
lower = await rate([["西南", 80, 1000], ["华东", 40, 1000]], pairs=[["华东", "西南"]])
assert lower["verdict"]["direction"] == "down" and lower["phrase"].startswith("华东的退款率(4.0%)低于西南(8.0%)")
async def test_several_groups_each_against_the_rest_with_holm():
rows = [["西南", 80, 1000], ["华东", 40, 1000], ["华南", 42, 1000], ["华北", 38, 1000]]
result = await rate(rows)
table = result["table"]
assert [r[0] for r in table["rows"]] == ["西南", "华东", "华南", "华北"]
assert table["rows"][0][1] == "其余各地区"
# 对照组那一列有「其余各地区」这样的叫法:写 text;没记类型的分组列(组那一列)不写
assert table["column_types"]["b"] == "text" and "a" not in table["column_types"]
assert table["column_types"]["p"] == "number" and table["column_types"]["test"] == "text"
raw = [Fraction(r[10]) for r in table["rows"]]
adjusted = [Fraction(r[11]) for r in table["rows"]]
assert all(a >= p for a, p in zip(adjusted, raw))
assert adjusted == pytest.approx(stats.holm([float(p) for p in raw]), rel=1e-9)
assert result["verdict"]["code"] == "differs" and result["verdict"]["subjects"] == [["西南"], ["其余各地区"]]
assert any(c["code"] == "multiple_comparisons" and c["text"] == "比了 4 对,已按次数校正" for c in result["caveats"])
assert result["phrase"] == "比了 4 对,1 对差异站得住:西南的退款率(8.0%)高于其余各地区(4.0%)"
ref = await rate(rows, reference="华东")
assert [r[0] for r in ref["table"]["rows"]] == ["西南", "华南", "华北"] and ref["params"]["reference"] == "华东"
async def test_rate_call_errors():
assert (await call_error("differ", "rate", snaps=[rates([["西南", 5, 4], ["华东", 1, 9]])], query="Q1", by="region",
numerator="refunds", denominator="orders")).code == "not_count"
assert (await call_error("differ", "rate", snaps=[rates([["西南", 1.5, 4], ["华东", 1, 9]])], query="Q1",
by="region", numerator="refunds", denominator="orders")).code == "not_count"
unknown = await call_error("differ", "rate", snaps=[rates([["西南", 1, 4], ["华东", 1, 9]])], query="Q1",
by="region", numerator="refunds", denominator="orders", pairs=[["西南", "东北"]])
assert unknown.code == "unknown_group" and "西南、华东" in unknown.message
dup = await call_error("differ", "rate", snaps=[rates([["西南", 1, 4], ["西南", 1, 9]])], query="Q1", by="region",
numerator="refunds", denominator="orders")
assert dup.code == "duplicate_rows"
async def test_means_from_sums_and_squares_use_welch():
sql = "SELECT channel, SUM(amount) AS s, SUM(amount * amount) AS ss, COUNT(*) AS n FROM orders GROUP BY channel"
rows = [["线上", 5000, 300000, 100], ["线下", 4000, 200000, 100]]
result = await analyze("differ", "mean", snaps=[snap("Q1", sql, ["channel", "s", "ss", "n"], rows)], query="Q1",
by="channel", sum="s", sumsq="ss", n="n", label="客单价")
va = (300000 - 5000 ** 2 / 100) / 99
vb = (200000 - 4000 ** 2 / 100) / 99
expected = stats.welch(50, va, 100, 40, vb, 100)
assert Fraction(result["values"]["p"]) == pytest.approx(expected.p, rel=1e-9)
assert result["verdict"]["code"] == "differs" and result["values"]["test"] == "welch"
assert result["phrase"] == "线上的客单价(50)高于线下(40),差异站得住"
assert result["method"] == "differ.welch@1" and result["settings"] == {"differ_alpha": 0.05, "min_n": 30}
async def test_means_from_mean_sd_and_n_and_small_samples():
sql = "SELECT channel, avg_amount, sd_amount, n FROM channel_stats"
rows = [["线上", 52, 12, 12], ["线下", 50, 11, 400]]
result = await analyze("differ", "mean", snaps=[snap("Q1", sql, ["channel", "avg_amount", "sd_amount", "n"], rows)],
query="Q1", by="channel", mean="avg_amount", sd="sd_amount", n="n", label="客单价")
assert result["verdict"]["code"] == "no_evidence"
note = next(c for c in result["caveats"] if c["code"] == "small_sample")
assert note["text"] == "有一组的样本数不到 30(线上 12),结论不稳"
assert result["phrase"].startswith("线上和线下的客单价差异可能只是样本波动(样本量 12、400)")
async def test_rows_mode_flags_a_single_value_that_flips_the_verdict():
base = [["线下", v] for v in [40, 60] * 200]
online = [["线上", f"{v:.1f}"] for v in [41.3, 61.3] * 200]
online[-1] = ["线上", "231.3"]
s = snap("Q1", "SELECT channel, amount FROM orders", ["channel", "amount"], online + base)
result = await analyze("differ", "rows", snaps=[s], query="Q1", by="channel", value="amount", label="客单价")
assert result["method"] == "differ.welch_rows@1"
assert result["verdict"]["code"] == "differs"
note = next(c for c in result["caveats"] if c["code"] == "outlier_sensitive")
assert note["text"] == "去掉最大的一笔后,差异不再站得住"
assert note["text"] in result["phrase"]
hidden = [["线上", 50 + (i % 7)] for i in range(30)] + [["线下", 48 + (i % 5)] for i in range(30)] + [["线上", 400]]
s = snap("Q1", "SELECT channel, amount FROM orders", ["channel", "amount"], hidden)
flipped = await analyze("differ", "rows", snaps=[s], query="Q1", by="channel", value="amount", label="客单价")
assert flipped["verdict"]["code"] == "no_evidence"
assert any(c["text"] == "去掉最大的一笔后,差异变得站得住" for c in flipped["caveats"])
async def test_two_periods_of_the_same_group():
sql = "SELECT week, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders WHERE region = '西南' GROUP BY week"
s = snap("Q1", sql, ["week", "refunds", "orders"], [["W41", 30, 1000], ["W42", 60, 1000], ["W40", 31, 1000]])
result = await analyze("differ", "rate", snaps=[s], query="Q1", period="week", base="W41", target="W42",
numerator="refunds", denominator="orders", label="退款率")
assert result["verdict"] == {"code": "differs", "subjects": [["W42"], ["W41"]], "direction": "up"}
assert result["inputs"][0]["rows"] == [0, 1]
assert result["phrase"] == "W42 的退款率(6.0%)高于 W41(3.0%),差异站得住"
async def test_a_group_with_one_value_cannot_be_tested():
s = snap("Q1", "SELECT channel, amount FROM orders", ["channel", "amount"], [["线上", 5], ["线下", 4], ["线下", 6]])
result = await analyze("differ", "rows", snaps=[s], query="Q1", by="channel", value="amount")
assert result["verdict"]["code"] == "no_evidence" and result["values"]["p"] is None
assert result["table"]["rows"][0][-1] == "样本不够,算不了"
assert math.isfinite(float(result["values"]["diff"]))