From 427a6df23c108e8324b3123ed03cc7bad2f0fef5 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 15:56:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=AE=E5=BC=82=E6=A3=80=E9=AA=8C=E6=8B=BF?= =?UTF-8?q?=E4=B8=8D=E5=88=B0=20p=20=E5=80=BC=E7=85=A7=E5=AE=9E=E8=AF=B4?= =?UTF-8?q?=E3=80=81=E6=95=8F=E6=84=9F=E6=80=A7=E5=8F=AA=E5=9C=A8=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E7=9A=84=E5=90=84=E7=BB=84=E9=87=8C=E6=89=BE=E7=A6=BB?= =?UTF-8?q?=E7=BE=A4=E5=80=BC=EF=BC=88=E5=A4=8D=E6=A0=B8=20R8=E3=80=81R9?= =?UTF-8?q?=EF=BC=89=EF=BC=9A=E4=B8=80=E5=AF=B9=E9=83=BD=E6=8B=BF=E4=B8=8D?= =?UTF-8?q?=E5=88=B0=20p=20=E5=80=BC=EF=BC=88=E6=AF=94=E7=8E=87=E6=9C=89?= =?UTF-8?q?=E4=B8=80=E7=BB=84=E6=A0=B7=E6=9C=AC=E9=87=8F=E4=B8=BA=200?= =?UTF-8?q?=EF=BC=9B=E5=9D=87=E5=80=BC=E3=80=81=E9=80=90=E8=A1=8C=E6=9C=89?= =?UTF-8?q?=E4=B8=80=E7=BB=84=E4=B8=8D=E5=88=B0=202=20=E4=B8=AA=E6=A0=B7?= =?UTF-8?q?=E6=9C=AC=EF=BC=8C=E6=88=96=E5=9D=87=E5=80=BC=E3=80=81=E6=A0=87?= =?UTF-8?q?=E5=87=86=E5=B7=AE=E4=B8=BA=E7=A9=BA=EF=BC=89=E6=97=B6=E7=AE=97?= =?UTF-8?q?=E4=B8=8D=E4=BA=86=EF=BC=8C=E5=A5=91=E7=BA=A6=20REASONS=20?= =?UTF-8?q?=E5=8A=A0=20untestable=EF=BC=8C=E5=8E=9F=E5=9B=A0=E5=86=99?= =?UTF-8?q?=E5=93=AA=E4=B8=80=E7=BB=84=E6=80=8E=E4=B9=88=E4=BA=86=EF=BC=88?= =?UTF-8?q?=E3=80=8C=E5=8D=8E=E4=B8=9C=E5=8F=AA=E6=9C=89=201=20=E4=B8=AA?= =?UTF-8?q?=E6=A0=B7=E6=9C=AC=E3=80=81=E5=8D=8E=E5=8D=97=E5=8F=AA=E6=9C=89?= =?UTF-8?q?=201=20=E4=B8=AA=E6=A0=B7=E6=9C=AC=EF=BC=8C=E7=AE=97=E4=B8=8D?= =?UTF-8?q?=E4=BA=86=E5=B7=AE=E5=BC=82=E3=80=8D=EF=BC=89=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=88=A4=20ok=20+=20=E5=8F=AF=E8=83=BD=E5=8F=AA?= =?UTF-8?q?=E6=98=AF=E6=A0=B7=E6=9C=AC=E6=B3=A2=E5=8A=A8=EF=BC=9B=E5=8F=AA?= =?UTF-8?q?=E6=9C=89=E4=B8=80=E9=83=A8=E5=88=86=E6=8B=BF=E4=B8=8D=E5=88=B0?= =?UTF-8?q?=E7=9A=84=EF=BC=8C=E9=82=A3=E5=87=A0=E5=AF=B9=E4=B8=8D=E7=AE=97?= =?UTF-8?q?=E8=BF=9B=E6=AF=94=E4=BA=86=E5=87=A0=E5=AF=B9=E3=80=81=E4=B8=8D?= =?UTF-8?q?=E5=8F=82=E4=B8=8E=E6=A0=A1=E6=AD=A3=EF=BC=8C=E8=A1=A8=E9=87=8C?= =?UTF-8?q?=E5=86=99=E6=B8=85=E5=93=AA=E4=B8=80=E7=BB=84=E6=80=8E=E4=B9=88?= =?UTF-8?q?=E4=BA=86=EF=BC=8C=E5=88=A4=E5=AE=9A=E8=AF=AD=E5=90=8E=E6=8E=A5?= =?UTF-8?q?=E3=80=8C=E5=8F=A6=E6=9C=89=20n=20=E5=AF=B9=E7=AE=97=E4=B8=8D?= =?UTF-8?q?=E4=BA=86=EF=BC=88=E2=80=A6=EF=BC=89=E3=80=8D=EF=BC=88=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=20CAVEATS=20=E5=8A=A0=20untested=EF=BC=8C=E6=8E=92?= =?UTF-8?q?=E5=9C=A8=E5=88=A4=E5=AE=9A=E8=AF=AD=E9=99=84=E5=B8=A6=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E7=9A=84=E5=89=8D=E9=9D=A2=EF=BC=89=E3=80=82=E9=80=90?= =?UTF-8?q?=E8=A1=8C=E6=A8=A1=E5=BC=8F=E6=AF=94=E4=B8=89=E7=BB=84=E4=BB=A5?= =?UTF-8?q?=E4=B8=8A=E6=97=B6=EF=BC=8C=E7=A6=BB=E5=9D=87=E5=80=BC=E6=9C=80?= =?UTF-8?q?=E8=BF=9C=E7=9A=84=E9=82=A3=E4=B8=80=E7=AC=94=E5=8F=AA=E5=9C=A8?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E7=9A=84=E5=90=84=E7=BB=84=E9=87=8C=E6=89=BE?= =?UTF-8?q?=EF=BC=88=E5=8E=9F=E6=9D=A5=E6=B1=A0=E5=AD=90=E9=87=8C=E6=B7=B7?= =?UTF-8?q?=E7=9D=80=E5=90=84=E5=AF=B9=E7=9A=84=E3=80=8C=E5=85=B6=E4=BD=99?= =?UTF-8?q?=E5=90=84=E7=BB=84=E5=90=88=E8=AE=A1=E3=80=8D=EF=BC=8C=E7=AC=AC?= =?UTF-8?q?=E4=B8=80=E7=BB=84=E4=BB=A5=E5=A4=96=E7=9A=84=E7=A6=BB=E7=BE=A4?= =?UTF-8?q?=E5=80=BC=E5=88=A0=E4=B8=8D=E5=88=B0=E5=AE=83=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E9=82=A3=E4=B8=80=E7=BB=84=EF=BC=8C=E6=BC=8F=E6=8A=A5=20outlie?= =?UTF-8?q?r=5Fsensitive=EF=BC=89=EF=BC=8C=E5=8E=BB=E6=8E=89=E4=BB=A5?= =?UTF-8?q?=E5=90=8E=E7=94=B1=E5=90=84=E7=BB=84=E9=87=8D=E5=BB=BA=E6=AF=8F?= =?UTF-8?q?=E4=B8=80=E5=AF=B9=E7=9A=84=E3=80=8C=E5=85=B6=E4=BD=99=E3=80=8D?= =?UTF-8?q?=E5=90=88=E8=AE=A1=E5=86=8D=E6=A3=80=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/analysis/contract.py | 6 ++- backend/app/analysis/differ.py | 75 ++++++++++++++++++--------- backend/app/analysis/phrases.py | 11 +++- backend/tests/test_analysis_differ.py | 42 ++++++++++++++- 4 files changed, 104 insertions(+), 30 deletions(-) diff --git a/backend/app/analysis/contract.py b/backend/app/analysis/contract.py index 965e1bbb..9159875c 100644 --- a/backend/app/analysis/contract.py +++ b/backend/app/analysis/contract.py @@ -85,16 +85,18 @@ VERDICTS: dict[str, tuple[str, ...]] = { } #: 算不了的原因(细案 01 §8.2)。算不了也记成结论:写作者可以照实写「历史只有 5 周,判断不了是否异常」。最后两种是分析卡 #: 里随数据变化的调用错误记成的(analysis.run 的 refuse_data_errors,见 app/analysis 的 DATA_ERRORS):parent_refused 上一层 -#: 没做成;input_unavailable 这次的数据里没有要用的那一项(下钻的段、期次、总数那一格、要比的组……,reason 写是哪一项、哪个值) +#: 没做成;input_unavailable 这次的数据里没有要用的那一项(下钻的段、期次、总数那一格、要比的组……,reason 写是哪一项、哪个值)。 +#: untestable:差异检验一对都拿不到 p 值(两组各 1 个样本、样本量为 0……) REASONS: tuple[str, ...] = ( "truncated", "too_few_segments", "history_short", "partial_target", "target_gap", "series_gap", "period_unreadable", "zero_base", "non_positive", "incomparable_sides", "parent_refused", "input_unavailable", + "untestable", ) #: 附带说明的种类(caveats[].code) CAVEATS: tuple[str, ...] = ( "offset", "simpson", "small_denominator", "record_high", "record_low", "weekday_effect", "multiple_comparisons", "outlier_sensitive", "small_sample", "measure_unlabeled", "null_values", - "headline_basis", "comparability", "partial_periods", + "headline_basis", "comparability", "partial_periods", "untested", ) # -------------------------------------------------------------------------- diff --git a/backend/app/analysis/differ.py b/backend/app/analysis/differ.py index 12e6816b..668e31e5 100644 --- a/backend/app/analysis/differ.py +++ b/backend/app/analysis/differ.py @@ -16,6 +16,10 @@ 按 A − B 的正负);否则 no_evidence。用了 Fisher(比率)或有一组样本数小于 min_n(均值)→ small_sample。判定语不出现「p 值」 「显著性」:differs 说「差异站得住」,no_evidence 说「差异可能只是样本波动」,p 值、区间在表和证据面板里。 +拿不到 p 值的一对(比率有一组样本量为 0;均值、逐行有一组不到 2 个样本,或均值、标准差为空)不算进比了几对、不参与校正,表里 +照实写哪一组怎么了;一对都拿不到时算不了(untestable),不硬判「可能只是样本波动」;只有一部分拿不到的,判定语后接一句 +「另有 n 对算不了」(untested)。 + subjects 是判定说的那一对 [[A], [B]](只比一对时就是那一对,比了几对时是校正后最站得住的那一对;几对都不站得住时为空), direction 是 A 相对 B。 """ @@ -80,9 +84,13 @@ def _int(value: Fraction | None, snap: Snap, r: int, column: str) -> int: return int(value) +#: 「其余各组合计」的键(不是分组列里的值) +REST_KEY = ("__rest__",) + + def _pool(name: str, groups: Sequence[Group], mode: str) -> Group: """「其余各组合计」:比率把成功数、试验数加起来;均值按各组的均值、方差、样本数合并;逐行的值并在一起。""" - out = Group(("__rest__",), name, rows=[r for g in groups for r in g.rows]) + out = Group(REST_KEY, name, rows=[r for g in groups for r in g.rows]) if mode == "rate": out.x, out.n = sum(g.x for g in groups), sum(g.n for g in groups) return out @@ -137,6 +145,22 @@ def _test(pair: Pair, mode: str, min_expected: Fraction) -> None: pair.ci = stats.welch_interval(float(a.mean), float(a.var), a.n, float(b.mean), float(b.var), b.n) +def _gap(group: Group, mode: str) -> str | None: + """一组为什么算不了检验;算得了为 None。""" + if mode == "rate": + return ph.say(group.name, "的样本量为 0") if group.n == 0 else None + if group.n < 2: + return ph.say(group.name, "没有样本" if group.n == 0 else "只有 1 个样本") + if group.mean is None or group.var is None: + return ph.say(group.name, "的均值或标准差为空") + return None + + +def untested_text(pair: Pair, mode: str) -> str: + """这一对拿不到 p 值的原因:哪一组、怎么了(两组都有就都写)。""" + return "、".join(dict.fromkeys(why for g in (pair.a, pair.b) if (why := _gap(g, mode)))) or "样本不够" + + def _verdict(pairs: Sequence[Pair], alpha: Fraction) -> list[Pair]: """Holm 校正后站得住的那几对(按校正后的 p 从小到大,一样的按先后)。""" adjusted = stats.holm([p.p for p in pairs]) @@ -295,6 +319,12 @@ def _decide(env: Env, params: dict[str, Any], used: list[str], pairs: list[Pair] min_expected = env.threshold("min_expected") if mode == "rate" else Fraction(0) for pair in pairs: _test(pair, mode, min_expected) + tested = [p for p in pairs if p.p is not None] + untested = [p for p in pairs if p.p is None] + if not tested: + shown = list(dict.fromkeys(untested_text(p, mode) for p in pairs)) + return refuse(env, params, used, "untestable", + ",".join(shown[:3]) + ("等" if len(shown) > 3 else "") + ",算不了差异", criterion=criterion) hits = _verdict(pairs, alpha) caveats: list[dict[str, str]] = [] if mode == "rows": @@ -309,24 +339,26 @@ def _decide(env: Env, params: dict[str, Any], used: list[str], pairs: list[Pair] small = sorted((g for p in pairs for g in (p.a, p.b) if g.n < min_n), key=lambda g: g.n) if small: caveats.append(ph.caveat("small_sample", ph.small_sample_mean_text(small[0].name, small[0].n, min_n))) - tested = [p for p in pairs if p.p is not None] if len(tested) > 1: caveats.append(ph.caveat("multiple_comparisons", ph.multiple_text(len(tested)))) + if untested: + caveats.append(ph.caveat("untested", ph.untested_pairs_text(len(untested), + [untested_text(p, mode) for p in untested]))) fmt = (lambda v: ph.pct(v, signed=False)) if mode == "rate" else (lambda v: ph.amount(v)) if hits: code = "differs" head = hits[0] main = ph.differ_phrase([ph.differ_pair(p.a.name, p.b.name, label, fmt(p.a_value), fmt(p.b_value), p.diff > 0) for p in hits], # type: ignore[operator] - len(pairs)) + len(tested)) subjects = [[head.a.name], [head.b.name]] direction = sign_word(head.diff) # type: ignore[arg-type] else: code = "no_evidence" - head = pairs[0] if len(pairs) == 1 else None + head = tested[0] if len(tested) == 1 else None sizes = (ph.amount(head.a.n), ph.amount(head.b.n)) if head is not None else None main = ph.no_evidence_phrase(head.a.name if head else None, head.b.name if head else None, label, sizes, - len(pairs)) + len(tested)) subjects = [[head.a.name], [head.b.name]] if head is not None else [] direction = None value_name = "比率" if mode == "rate" else "均值" @@ -334,7 +366,7 @@ def _decide(env: Env, params: dict[str, Any], used: list[str], pairs: list[Pair] for p in pairs: flags = [] if p.p is None: - flags.append("样本不够,算不了") + flags.append(f"{untested_text(p, mode)},算不了") if p.test == "fisher": flags.append("样本少,用了精确检验") rows.append([p.a.name, p.b.name, canon(p.a_value), canon(p.b_value), canon(p.diff), @@ -342,7 +374,7 @@ def _decide(env: Env, params: dict[str, Any], used: list[str], pairs: list[Pair] TESTS.get(p.test or "") or None, canon(p.p), canon(p.p_adj), "、".join(flags) or None]) columns = ["a", "b", "a_value", "b_value", "diff", "ci_lo", "ci_hi", "a_n", "b_n", "test", "p", "p_adj", "flag"] # 组、对照组照分组列(期次列)在快照里的类型;和「其余各组合计」比时对照组那一列有一格是叫法,写 text - rest = any(p.b.key == ("__rest__",) for p in pairs) + rest = any(p.b.key == REST_KEY for p in pairs) table = {"columns": columns, "labels": {"a": "组", "b": "对照组", "a_value": f"组的{value_name}", "b_value": f"对照组的{value_name}", "diff": "差", "ci_lo": "差的区间下限", "ci_hi": "差的区间上限", "a_n": "组的样本量", @@ -350,37 +382,32 @@ def _decide(env: Env, params: dict[str, Any], used: list[str], pairs: list[Pair] "rows": rows, "column_types": column_types(columns, dims={"a": kind, "b": "text" if rest else kind}, texts=("test", "flag"))} - shown = head if head is not None else pairs[0] + shown = head if head is not None else tested[0] values = {"a": canon(shown.a_value), "b": canon(shown.b_value), "diff": canon(shown.diff), "ci_lo": canon(shown.ci[0]) if shown.ci else None, "ci_hi": canon(shown.ci[1]) if shown.ci else None, "p": canon(shown.p), "p_adj": canon(shown.p_adj), "n_a": str(shown.a.n), "n_b": str(shown.b.n), "test": shown.test, "pairs": str(len(pairs)), "differs": str(len(hits))} verdict = {"code": code, "subjects": subjects, "direction": direction} - phrase = ph.with_caveats(main, caveats, skip=("multiple_comparisons",) if len(pairs) > 1 else ()) + phrase = ph.with_caveats(main, caveats, skip=("multiple_comparisons",) if len(tested) > 1 else ()) return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion, phrase=phrase, caveats=caveats) def _sensitivity(pairs: list[Pair], mode: str, alpha: Fraction, min_expected: Fraction, differs: bool) -> tuple[bool, bool] | None: - """rows 模式的敏感性:去掉比较的各组里离均值最远的那一个值重算,判定变了交回 (去掉的是不是偏大的那一笔, 现在站不站得住)。""" - everything = [(g, i, v) for g in dict.fromkeys(g for p in pairs for g in (p.a, p.b)) for i, v in enumerate(g.values)] + """rows 模式的敏感性:去掉比较的各组里离均值最远的那一个值重算,判定变了交回 (去掉的是不是偏大的那一笔, 现在站不站得住)。 + 只在真实的各组里找(「其余各组合计」是它们的值并在一起,不另算一份),去掉以后由各组重建每一对的「其余」合计。""" + groups = list(dict.fromkeys(g for p in pairs for g in (p.a, p.b) if g.key != REST_KEY)) + everything = [(g, i, v) for g in groups for i, v in enumerate(g.values)] if len(everything) < 3: return None mean = sum((v for _, _, v in everything), Fraction(0)) / len(everything) group, index, value = max(everything, key=lambda t: abs(t[2] - mean)) - trimmed = [] - for p in pairs: - def cut(g: Group) -> Group: - if g is not group and not (g.key == ("__rest__",) and any(v is value for v in g.values)): - return g - values = list(g.values) - for k, v in enumerate(values): - if v is value: - del values[k] - break - return _moments(Group(g.key, g.name, rows=g.rows, values=values)) - trimmed.append(Pair(cut(p.a), cut(p.b))) + cut = {g: g for g in groups} + cut[group] = _moments(Group(group.key, group.name, rows=group.rows, + values=[v for k, v in enumerate(group.values) if k != index])) + trimmed = [Pair(cut[p.a], _pool(p.b.name, [cut[g] for g in groups if g is not p.a], mode) + if p.b.key == REST_KEY else cut[p.b]) for p in pairs] for p in trimmed: _test(p, mode, min_expected) now = bool(_verdict(trimmed, alpha)) @@ -388,4 +415,4 @@ def _sensitivity(pairs: list[Pair], mode: str, alpha: Fraction, min_expected: Fr return (value > mean, now) if now != differs else None -__all__ = ["TESTS", "differ", "refusal_basis", "used_settings"] +__all__ = ["REST_KEY", "TESTS", "differ", "refusal_basis", "untested_text", "used_settings"] diff --git a/backend/app/analysis/phrases.py b/backend/app/analysis/phrases.py index c379364a..7b84de8a 100644 --- a/backend/app/analysis/phrases.py +++ b/backend/app/analysis/phrases.py @@ -32,8 +32,8 @@ from app.analysis.contract import OP_LABELS from app.analysis.numbers import fixed, to_fraction #: 附带说明进判定语的先后;不在这里的(measure_unlabeled、headline_basis、partial_periods)只在证据面板 -PHRASE_ORDER = ("comparability", "simpson", "offset", "outlier_sensitive", "small_sample", "record_high", "record_low", - "multiple_comparisons", "weekday_effect", "small_denominator", "null_values") +PHRASE_ORDER = ("comparability", "untested", "simpson", "offset", "outlier_sensitive", "small_sample", "record_high", + "record_low", "multiple_comparisons", "weekday_effect", "small_denominator", "null_values") #: 判定语里最多接几条附带说明 PHRASE_CAVEATS = 2 #: 消毒后的叫法最多几个字 @@ -343,6 +343,12 @@ def multiple_text(pairs: int) -> str: return f"比了 {pairs} 对,已按次数校正" +def untested_pairs_text(count: int, reasons: Sequence[str]) -> str: + """差异检验里拿不到 p 值的那几对(不算进比了几对):另有 1 对算不了(华北的样本量为 0)。""" + shown = list(dict.fromkeys(reasons)) + return f"另有 {count} 对算不了({','.join(shown[:3])}{'等' if len(shown) > 3 else ''})" + + def outlier_text(largest: bool, now_differs: bool) -> str: which = "最大" if largest else "最小" return f"去掉{which}的一笔后,差异{'变得站得住' if now_differs else '不再站得住'}" @@ -567,6 +573,7 @@ __all__ = [ "decompose_flat", "decompose_unclosed", "differ_pair", "differ_phrase", "dims_text", "factor_both", "factor_driver", "key_text", "multiple_text", "names", "no_evidence_phrase", "null_periods_text", "null_segments_text", "number", "offset_text", "outlier_text", "partial_periods_text", "pct", "pick_label", + "untested_pairs_text", "points", "ratio_both", "ratio_change", "ratio_mix", "ratio_rate", "ratio_value", "record_text", "refused", "say", "share_words", "simpson_text", "small_denominator_text", "small_sample_mean_text", "small_sample_rate_text", "threshold_pct", diff --git a/backend/tests/test_analysis_differ.py b/backend/tests/test_analysis_differ.py index f07d4e6c..bfb5d7d5 100644 --- a/backend/tests/test_analysis_differ.py +++ b/backend/tests/test_analysis_differ.py @@ -136,6 +136,44 @@ async def test_two_periods_of_the_same_group(): 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 result["status"] == "refused" and result["reason_code"] == "untestable" + assert result["reason"] == "线上只有 1 个样本,算不了差异" + assert result["phrase"] == "差异检验没有做成:线上只有 1 个样本,算不了差异" + + +async def test_no_pair_with_a_p_value_is_refused_not_said_to_be_noise(): + sql = "SELECT region, AVG(amount) AS m, 0 AS sd, COUNT(*) AS n FROM orders GROUP BY 1" + means = snap("Q1", sql, ["region", "m", "sd", "n"], [["华东", 120, 0, 1], ["华南", 80, 0, 1]]) + mean = await analyze("differ", "mean", snaps=[means], query="Q1", by="region", mean="m", sd="sd", n="n", + label="客单价") + assert mean["reason_code"] == "untestable" and mean["reason"] == "华东只有 1 个样本、华南只有 1 个样本,算不了差异" + assert mean["verdict"] is None and mean["settings"] == {"differ_alpha": 0.05, "min_n": 30} + rows = snap("Q2", "SELECT region, amount FROM orders", ["region", "amount"], [["华东", 120], ["华南", 80]]) + one = await analyze("differ", "rows", snaps=[rows], query="Q2", by="region", value="amount") + assert one["reason"] == "华东只有 1 个样本、华南只有 1 个样本,算不了差异" + zero = await rate([["华东", 0, 0], ["华南", 3, 10]]) + assert zero["reason_code"] == "untestable" and zero["reason"] == "华东的样本量为 0,算不了差异" + + +async def test_pairs_without_a_p_value_are_named_and_not_counted(): + result = await rate([["西南", 80, 1000], ["华东", 40, 1000], ["华北", 0, 0]]) + rows = {r[0]: r for r in result["table"]["rows"]} + assert rows["华北"][-1] == "华北的样本量为 0,算不了" and rows["华北"][10] is None + note = next(c for c in result["caveats"] if c["code"] == "untested") + assert note["text"] == "另有 1 对算不了(华北的样本量为 0)" + # 华北一单都没有:西南的其余合计就是华东,两对都站得住;华北那一对不算进「比了几对」 + assert result["phrase"] == ("比了 2 对,2 对差异站得住:西南的退款率(8.0%)高于其余各地区(4.0%);华东的退款率(4.0%)" + "低于其余各地区(8.0%);另有 1 对算不了(华北的样本量为 0)") + assert {"code": "multiple_comparisons", "text": "比了 2 对,已按次数校正"} in result["caveats"] + + +async def test_the_outlier_is_looked_for_in_the_real_groups_not_in_the_pooled_rest(): + # 三组各和其余合计比:最远的那一笔在第二组(华南)。只在真实的各组里找、由各组重建「其余」合计,去掉它以后华南高出来就站得住 + base = [10, 11, 9, 10, 12, 8, 10, 11, 10, 9, 11, 10] + rows = ([["华东", v] for v in base] + [["华南", v + 2] for v in base[:-1]] + [["华南", 100]] + + [["华北", v] for v in base]) + s = snap("Q1", "SELECT region, amount FROM orders", ["region", "amount"], rows) + result = await analyze("differ", "rows", snaps=[s], query="Q1", by="region", value="amount", label="客单价") + assert result["verdict"]["code"] == "no_evidence" + assert {"code": "outlier_sensitive", "text": "去掉最大的一笔后,差异变得站得住"} in result["caveats"] assert math.isfinite(float(result["values"]["diff"]))