From fcbcfc9aefc86f929f5a60caf930ef0dc1cbf0f7 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 15:57:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=A4=E5=AE=9A=E8=AF=AD=E7=85=A7=E6=96=B9?= =?UTF-8?q?=E5=90=91=E6=8E=A5=E6=9C=AB=E5=B0=BE=E8=BF=9E=E7=BB=AD=E6=9C=9F?= =?UTF-8?q?=E6=95=B0=E3=80=81=E8=BE=9B=E6=99=AE=E6=A3=AE=E7=9A=84=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=8F=98=E5=8C=96=E5=8F=AA=E8=AF=B4=E4=B8=80=E9=81=8D?= =?UTF-8?q?=EF=BC=88=E5=A4=8D=E6=A0=B8=20R14=E3=80=81R21=EF=BC=89=EF=BC=9A?= =?UTF-8?q?=E8=B6=8B=E5=8A=BF=E6=9C=AB=E5=B0=BE=E8=BF=9E=E7=BB=AD=E5=90=8C?= =?UTF-8?q?=E5=90=91=E7=9A=84=E6=9C=9F=E6=95=B0=E5=92=8C=E8=B6=8B=E5=8A=BF?= =?UTF-8?q?=E5=90=8C=E5=90=91=E6=97=B6=E6=8E=A5=E3=80=8C=EF=BC=8C=E5=B7=B2?= =?UTF-8?q?=E8=BF=9E=E7=BB=AD=203=20=E5=91=A8=E4=B8=8B=E9=99=8D=E3=80=8D?= =?UTF-8?q?=EF=BC=8C=E5=8F=8D=E5=90=91=E6=97=B6=E6=8E=A5=E3=80=8C=EF=BC=8C?= =?UTF-8?q?=E4=BD=86=E6=9C=80=E8=BF=91=E5=B7=B2=E8=BF=9E=E7=BB=AD=203=20?= =?UTF-8?q?=E5=91=A8=E4=B8=8A=E5=8D=87=E3=80=8D=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=87=BA=E7=8E=B0=E3=80=8C=E5=91=88=E4=B8=8B=E9=99=8D=E8=B6=8B?= =?UTF-8?q?=E5=8A=BF=E2=80=A6=E2=80=A6=E5=B7=B2=E8=BF=9E=E7=BB=AD=203=20?= =?UTF-8?q?=E5=91=A8=E4=B8=8A=E5=8D=87=E3=80=8D=E3=80=82=E6=AF=94=E7=8E=87?= =?UTF-8?q?=E6=8B=86=E8=A7=A3=E5=88=A4=E5=9C=A8=E7=BB=93=E6=9E=84=E5=8F=98?= =?UTF-8?q?=E5=8C=96=E4=B8=8A=EF=BC=88mix=5Fdriven=EF=BC=89=E6=97=B6?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E5=B7=B2=E7=BB=8F=E8=AF=B4=E4=BA=86=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=8F=98=E5=8C=96=E3=80=81=E7=82=B9=E4=BA=86=E9=82=A3?= =?UTF-8?q?=E4=B8=80=E6=AE=B5=EF=BC=8C=E8=BE=9B=E6=99=AE=E6=A3=AE=E9=82=A3?= =?UTF-8?q?=E4=B8=80=E5=8F=A5=E5=8F=AA=E8=AF=B4=E3=80=8C=E5=90=84=E6=AE=B5?= =?UTF-8?q?=E7=9A=84=E9=80=80=E6=AC=BE=E7=8E=87=E5=A4=A7=E5=A4=9A=E5=9C=A8?= =?UTF-8?q?=E4=B8=8B=E9=99=8D=EF=BC=8C=E6=95=B4=E4=BD=93=E5=8D=B4=E4=B8=8A?= =?UTF-8?q?=E5=8D=87=E3=80=8D=EF=BC=9B=E5=88=AB=E7=9A=84=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E7=85=A7=E6=97=A7=E5=B8=A6=E4=B8=8A=E5=93=AA=E4=B8=80=E6=AE=B5?= =?UTF-8?q?=E5=8D=A0=E6=AF=94=E4=BB=8E=E5=A4=9A=E5=B0=91=E5=8F=98=E5=88=B0?= =?UTF-8?q?=E5=A4=9A=E5=B0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/analysis/decompose.py | 4 +++- backend/app/analysis/phrases.py | 15 ++++++++++----- backend/tests/test_analysis_decompose.py | 7 +++++-- backend/tests/test_analysis_phrases.py | 15 +++++++++++++++ backend/tests/test_analysis_series.py | 6 ++++++ 5 files changed, 39 insertions(+), 8 deletions(-) diff --git a/backend/app/analysis/decompose.py b/backend/app/analysis/decompose.py index 1d42bb88..18520067 100644 --- a/backend/app/analysis/decompose.py +++ b/backend/app/analysis/decompose.py @@ -577,9 +577,11 @@ def _ratio_result(env: Env, params: dict[str, Any], used: list[str], segs: list[ against = sum(((by_key[x.key][0] + by_key[x.key][1]) / 2 for x in eligible if (by_key[x.key][3] - by_key[x.key][2]) * sign < 0), Fraction(0)) if weight > 0 and against / weight >= SIMPSON_SHARE: + # 判在结构变化上(mix_driven)时判定语正文已经说了结构变化、点了这一段,附带说明只说各段和整体反着变 star = max(live, key=lambda x: sign * by_key[x.key][4]) caveats.append(ph.caveat("simpson", ph.simpson_text(label, delta > 0, ph.key_text(star.key), - by_key[star.key][0], by_key[star.key][1]))) + by_key[star.key][0], by_key[star.key][1], + shift=code != "mix_driven"))) if small: caveats.append(ph.caveat("small_denominator", ph.small_denominator_text( [ph.key_text(x.key) for x in small], den_label, int(env.settings["min_denominator"])))) diff --git a/backend/app/analysis/phrases.py b/backend/app/analysis/phrases.py index 7b84de8a..e359c473 100644 --- a/backend/app/analysis/phrases.py +++ b/backend/app/analysis/phrases.py @@ -301,13 +301,16 @@ def offset_text(delta_total: Any, gross_up: Any, gross_down: Any) -> str: return f"下降的段合计 {down},上升的段合计 {up},互相抵消了一部分" -def simpson_text(label: str, overall_up: bool, subject: str, w_base: Any, w_target: Any) -> str: - """辛普森:各段的比率大多和整体反着变,整体的变化来自结构。""" +def simpson_text(label: str, overall_up: bool, subject: str, w_base: Any, w_target: Any, *, shift: bool = True) -> str: + """辛普森:各段的比率大多和整体反着变,整体的变化来自结构。判定语正文已经说了结构变化、点了那一段(mix_driven)时 + shift 为假,只说各段和整体反着变,结构变化不说第二遍。""" seg = "上升" if not overall_up else "下降" whole = "上升" if overall_up else "下降" + head = say("各段的", label, f"大多在{seg},整体却{whole}") + if not shift: + return head move = "升" if _q(w_target) > _q(w_base) else "降" - return say(f"各段的", label, f"大多在{seg},整体却{whole}:变化来自结构,", subject, - f"占比从 {pct(w_base, signed=False)} {move}到 {pct(w_target, signed=False)}") + return say(head, ":变化来自结构,", subject, f"占比从 {pct(w_base, signed=False)} {move}到 {pct(w_target, signed=False)}") def small_denominator_text(segments: Sequence[str], denominator: str, minimum: int) -> str: @@ -478,13 +481,15 @@ def baseline_flat_phrase(verdict: str, label: str, current: str, n: int, unit: s def trend_phrase(verdict: str, label: str, n: int, unit: str, per: str, slope: str | None, run: tuple[int, str] | None) -> str: + """趋势。末尾连续同向的期数(run)和趋势同向时接「,已连续 3 周下降」,反向时接「,但最近已连续 3 周上升」。""" if verdict == "no_trend": return say(f"过去 {n} {unit}", label, "没有明显的上升或下降趋势") word = "上升" if verdict == "rising" else "下降" text = say(f"过去 {n} {unit}", label, f"呈{word}趋势") + (f"(每{per}约 {slope})" if slope else "") if run is not None: length, direction = run - text += f",已连续 {length} {unit}{'上升' if direction == 'up' else '下降'}" + same = (direction == "up") == (verdict == "rising") + text += f",{'' if same else '但最近'}已连续 {length} {unit}{'上升' if direction == 'up' else '下降'}" return text diff --git a/backend/tests/test_analysis_decompose.py b/backend/tests/test_analysis_decompose.py index 9710e192..f651c35d 100644 --- a/backend/tests/test_analysis_decompose.py +++ b/backend/tests/test_analysis_decompose.py @@ -301,9 +301,12 @@ async def test_simpson_each_channel_improves_but_the_mix_makes_the_whole_worse() rows = [["线上", "W41", 50, 500], ["线上", "W42", 90, 1000], ["线下", "W41", 10, 1000], ["线下", "W42", 4, 500]] result = await ratio(rows, label="退款率") assert result["verdict"] == {"code": "mix_driven", "subjects": [["线上"]], "direction": "up"} - assert result["phrase"].startswith("退款率上升 2.3 个百分点(4.0% 到 6.3%),主要来自结构变化:线上占比从 33.3% 到 66.7%") + # 结构变化只说一遍:正文说了结构变化、点了线上,辛普森那一句只说各段和整体反着变 + assert result["phrase"] == ("退款率上升 2.3 个百分点(4.0% 到 6.3%),主要来自结构变化:线上占比从 33.3% 到 66.7%;" + "各段的退款率大多在下降,整体却上升") simpson = next(c for c in result["caveats"] if c["code"] == "simpson") - assert simpson["text"] == "各段的退款率大多在下降,整体却上升:变化来自结构,线上占比从 33.3% 升到 66.7%" + assert simpson["text"] == "各段的退款率大多在下降,整体却上升" + assert result["phrase"].count("结构") == 1 values = result["values"] assert Fraction(values["mix_total"]) + Fraction(values["rate_total"]) == pytest.approx(Fraction(values["delta"])) assert result["table"]["columns"][:5] == ["channel", "n_base", "n_target", "d_base", "d_target"] diff --git a/backend/tests/test_analysis_phrases.py b/backend/tests/test_analysis_phrases.py index 108becf1..e87aa2f6 100644 --- a/backend/tests/test_analysis_phrases.py +++ b/backend/tests/test_analysis_phrases.py @@ -141,3 +141,18 @@ def test_criteria_are_written_with_the_actual_thresholds(): assert "3 倍以上算明显超出" in ph.criterion_baseline(DEFAULTS, 12, "周") assert "小于 0.05" in ph.criterion_trend(DEFAULTS, 10, "周") assert "Fisher" in ph.criterion_differ(DEFAULTS, "rate") and "Welch" in ph.criterion_differ(DEFAULTS, "mean") + + + +def test_the_run_at_the_end_of_a_trend_says_whether_it_goes_the_same_way(): + assert ph.trend_phrase("falling", "销售额", 10, "周", "周", "-2.0%", (3, "down")) == \ + "过去 10 周销售额呈下降趋势(每周约 -2.0%),已连续 3 周下降" + assert ph.trend_phrase("falling", "销售额", 10, "周", "周", "-2.0%", (3, "up")) == \ + "过去 10 周销售额呈下降趋势(每周约 -2.0%),但最近已连续 3 周上升" + assert ph.trend_phrase("rising", "订单数", 8, "个月", "月", None, (4, "down")) == \ + "过去 8 个月订单数呈上升趋势,但最近已连续 4 个月下降" + + +def test_simpson_says_the_shift_only_when_the_phrase_does_not(): + assert ph.simpson_text("退款率", True, "线上", 0.3, 0.5) == "各段的退款率大多在下降,整体却上升:变化来自结构,线上占比从 30.0% 升到 50.0%" + assert ph.simpson_text("退款率", True, "线上", 0.3, 0.5, shift=False) == "各段的退款率大多在下降,整体却上升" diff --git a/backend/tests/test_analysis_series.py b/backend/tests/test_analysis_series.py index 671598a3..f52ae0c6 100644 --- a/backend/tests/test_analysis_series.py +++ b/backend/tests/test_analysis_series.py @@ -313,3 +313,9 @@ async def test_sequence_periods_say_completeness_cannot_be_checked(): result = await baseline(rows, basis="level") assert {"code": "comparability", "text": "没能核对本期的数据是否到齐(期次不是日期,不知道是哪几天)"} \ in result["caveats"] + + +async def test_a_falling_trend_whose_last_weeks_rise_says_but(): + falling = await trend(weeks([200, 196, 190, 188, 181, 176, 170, 165, 160, 162, 165, 168])) + assert falling["verdict"]["code"] == "falling" and falling["values"]["run_direction"] == "up" + assert falling["phrase"].endswith(",但最近已连续 3 周上升")