五个分析算子与 run / recompute(细案 01 §1–§8):decompose(additive 按维度算贡献、headline 与下钻闭合、抵消、新出现 / 消失;ratio 中点对称拆结构与自身效应、辛普森、分母小;factor LMDI-I;两份快照先做可比性,口径结构不同不拆)、baseline(中位数与 MAD 的稳健 z、σ̂ = 0、日数据 lag 7、新高新低)、trend(Theil–Sen + Mann–Kendall、末尾连续期数)、differ(rate 的 z 检验 / Fisher、mean 与 rows 的 Welch、Holm、rows 的敏感性)、comparable(同口径、窗口长度、整期、星期构成、数据问题,现查记进 probes);run 先登记现查、查完再算,运行当天和目录事实记进 params,recompute 只用记下的、不现查;判定码、原因码、附带说明码都在契约里;输入记下方言和遮罩列名,复算逐字段相同;测试用虚构零售库,每个用例都核对复算

This commit is contained in:
ThearchyHelios
2026-10-21 13:04:58 +08:00
parent 7e6d52004c
commit 7d31922d00
15 changed files with 3589 additions and 6 deletions
+144 -6
View File
@@ -8,14 +8,24 @@
- recompute:按结论里记下的 params、settings、probes 和输入快照(按 sha256 取回)重算一遍,不现查;和封存的用
contract.same 比。
契约(名字、形状、缺省阈值)在 contract.py。
契约(名字、形状、缺省阈值)在 contract.py。各算子在 decompose.py(变化拆解)、series.py(波动基线、趋势)、differ.py
(差异检验)、comparable.py(可比性检查);统计在 stats.py,判定语和数的写法在 phrases.py,现查在 probe.py。
**一份结论只由 inputs(快照里的格)、probes、params、settings、method 决定**:run 时从 context 读到的东西(运行当天、目录
里的度量类型和叫法、现查的范围)都记进结论——运行当天、目录事实在 params(today、catalog),现查结果在 probes——复算只读
记下的。同样的输入两次 run 逐字节相同(现查结果相同时);recompute 和 run 逐字段相同。
**参数**(AnalysisRequest.params;列名不分大小写,按快照认)见各算子文件头;params 里的 catalog、today、depth 由系统填,
调用方写了也不收。
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from datetime import date, datetime
from typing import Any
from app.analysis.contract import AnalysisRequest, AnalysisResult, Context, InputSnapshot
from app.analysis.contract import (MODES, OPS, AnalysisRequest, AnalysisResult, CallError, Context, InputSnapshot,
effective_settings)
#: 按调用里写的名字取回输入快照;没有这个名字返回 None
Resolver = Callable[[str], InputSnapshot | None]
@@ -23,16 +33,144 @@ Resolver = Callable[[str], InputSnapshot | None]
ParentResolver = Callable[[str], AnalysisResult | None]
def _op_mode(request: Mapping[str, Any]) -> tuple[str, str | None]:
from app.analysis.phrases import OP_NAMES
op = request.get("op")
if op not in OPS:
raise CallError("unknown_op", f"不认识的分析:{op}。能做的有{'、'.join(OP_NAMES[o] for o in OPS)}")
modes = MODES.get(op)
mode = request.get("mode")
if modes is None:
return op, None
if mode not in modes:
raise CallError("unknown_mode", f"{OP_NAMES[op]}要写模式:{' / '.join(modes)}")
return op, mode
def _compute(op: str, mode: str | None, raw: Mapping[str, Any], env: Any) -> AnalysisResult:
from app.analysis import comparable, decompose, differ, series
if op == "decompose":
return {"additive": decompose.additive, "ratio": decompose.ratio, "factor": decompose.factor}[mode](raw, env) # type: ignore[index]
if op == "baseline":
return series.baseline(raw, env)
if op == "trend":
return series.trend(raw, env)
if op == "differ":
return differ.differ(raw, env)
return comparable.comparable(raw, env)
def _env(op: str, mode: str | None, settings: Mapping[str, Any], inputs: Any, facts: Any, book: Any,
today: date | None, parents: ParentResolver | None, hypothesis: Any) -> Any:
from app.analysis.common import Env
return Env(op=op, mode=mode, settings=settings, inputs=inputs, facts=facts, book=book, today=today,
parents=parents, hypothesis=hypothesis if isinstance(hypothesis, str) else None)
async def run(request: AnalysisRequest, resolve: Resolver, *, settings: Mapping[str, Any], context: Context,
parents: ParentResolver | None = None) -> AnalysisResult:
"""算一份分析结论。settings 是 contract.effective_settings 算好的阈值。"""
raise NotImplementedError("analysis.run:第 1 波 W1-a 实现")
from app.analysis.common import DERIVED_PARAMS
from app.analysis.facts import Facts
from app.analysis.inputs import Inputs
from app.analysis.probe import MAX_ROUNDS, Book, fetch
op, mode = _op_mode(request)
raw = {k: v for k, v in (request.get("params") or {}).items() if k not in DERIVED_PARAMS}
full = effective_settings(settings)
prober = context.get("probe")
today = context.get("today") or date.today()
if isinstance(today, datetime):
today = today.date()
def dialect(name: str, snap: Mapping[str, Any]) -> str | None:
given = snap.get("dialect")
if isinstance(given, str) and given:
return given
lookup = getattr(prober, "dialect", None)
return lookup(str(snap.get("source") or "")) if lookup is not None else None
known: dict[tuple[str, ...], Any] = {}
for rounds in range(MAX_ROUNDS + 1):
book = Book(known)
env = _env(op, mode, full, Inputs(resolve, dialect), Facts(catalogs=context.get("catalogs")), book, today,
parents, request.get("hypothesis"))
result = _compute(op, mode, raw, env)
missing = book.missing
if not missing or rounds == MAX_ROUNDS:
return result
known.update(await fetch(book, context))
return result # pragma: no cover - 循环里一定返回
def recompute(result: AnalysisResult, load: Callable[[str], InputSnapshot | None], *,
load_result: Callable[[str], AnalysisResult | None] | None = None) -> AnalysisResult:
"""复算:load 按 sha256 取回输入快照,load_result 按 sha256 取回 parent 那份结论。"""
raise NotImplementedError("analysis.recompute:第 1 波 W1-a 实现")
"""复算:load 按 sha256 取回输入快照,load_result 按 sha256 取回 parent 那份结论。用结论里记下的 params(含运行当天、
目录事实)、settings、probes,不现查、不读目录。取不回输入时抛 CallError(code input_missing);方法升版后调用不再成立
时同样抛 CallError(照 run 的说法)。和封存的那份用 contract.same 比。"""
from app.analysis.facts import Facts
from app.analysis.inputs import _CELL, Inputs
from app.analysis.probe import Book
op, mode = _op_mode(result)
params = dict(result.get("params") or {})
settings = effective_settings(result.get("settings"))
names: dict[str, tuple[str | None, str | None, list[str]]] = {}
parents_at: dict[str, str] = {}
for item in result.get("inputs") or []:
ref = str(item.get("ref") or "")
if item.get("role") == "parent":
parents_at[ref] = str(item.get("artifact") or "")
continue
cell = _CELL.match(ref) if "row" in item else None
name = cell.group("name") if cell else ref
names.setdefault(name, (item.get("artifact"), item.get("dialect"), list(item.get("masked") or [])))
def resolve(name: str) -> InputSnapshot | None:
artifact, _, masked = names.get(name, (None, None, []))
found = load(artifact) if artifact else None
if not isinstance(found, Mapping):
raise CallError("input_missing", f"复算取不回 {name} 的查询快照")
return {**found, "artifact": artifact, "masked": masked} # type: ignore[typeddict-item]
def dialect(name: str, snap: Mapping[str, Any]) -> str | None:
return names.get(name, (None, None, []))[1]
def parents(name: str) -> AnalysisResult | None:
artifact = parents_at.get(name)
found = load_result(artifact) if artifact and load_result is not None else None
if not isinstance(found, Mapping):
raise CallError("input_missing", f"复算取不回上一层的分析结论 {name}")
return found # type: ignore[return-value]
today = date.fromisoformat(params["today"]) if isinstance(params.get("today"), str) else None
env = _env(op, mode, settings, Inputs(resolve, dialect), Facts(recorded=params.get("catalog") or {}),
Book(result.get("probes") or []), today, parents, result.get("hypothesis"))
raw = {k: v for k, v in params.items() if k not in ("catalog", "today", "depth")}
return _compute(op, mode, raw, env)
__all__ = ["ParentResolver", "Resolver", "recompute", "run"]
def exhausted(settings: Mapping[str, Any]) -> CallError:
"""本运行分析次数用完(max_calls)的调用错误:入口数着次数,到了上限交回它,不再算。"""
limit = int(effective_settings(settings)["max_calls"])
return CallError("max_calls", f"这次运行的分析次数已经用完(最多 {limit} 次):用已有的分析结论写,不再新做分析")
def make_prober(*args: Any, **kwargs: Any) -> Any:
"""见 app/analysis/probe.make_prober。"""
from app.analysis.probe import make_prober as make
return make(*args, **kwargs)
def comparable_reasons(result: Mapping[str, Any]) -> list[str]:
"""可比性结论里 fail、warn 的原因,一句一个(口径卡指标的 comparability.reasons)。"""
from app.analysis.comparable import reasons
return reasons(result)
__all__ = ["ParentResolver", "Resolver", "comparable_reasons", "exhausted", "make_prober", "recompute", "run"]
+136
View File
@@ -0,0 +1,136 @@
"""算子共用的:一次计算的环境(输入、目录事实、现查簿、阈值)、结论的组装、几样参数的规范化。
每个算子是 (raw params, Env) → AnalysisResult 的纯函数:只经 Env 取输入快照(Inputs)、目录事实(Facts)、现查结果
(Book),阈值只来自 Env.settings。run 和 recompute 只是装 Env 的方式不同(见 app/analysis/__init__.py)。
"""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import date
from fractions import Fraction
from typing import Any
from app.analysis import phrases as ph
from app.analysis.contract import METHODS, AnalysisResult, CallError
from app.analysis.facts import Facts
from app.analysis.inputs import Inputs
from app.analysis.numbers import to_fraction
from app.analysis.probe import Book
#: 结论的 params 里由系统填、不收调用方的几项:目录事实、运行当天、下钻的层数
DERIVED_PARAMS = frozenset({"catalog", "today", "depth"})
@dataclass
class Env:
op: str
mode: str | None
settings: Mapping[str, Any]
inputs: Inputs
facts: Facts
book: Book
today: date | None
parents: Callable[[str], Mapping[str, Any] | None] | None
hypothesis: str | None
def threshold(self, key: str) -> Fraction:
value = to_fraction(self.settings[key])
assert value is not None, key
return value
def label_for(env: Env, raw: Any, snap: Any, column: str | None, *, default: str | None = None) -> str:
"""判定语里的度量叫法:agent 给的 label → 目录里的中文名 → default → 列名,依次取第一个消毒后不为空的(agent 给了能用的
就不去查目录)。"""
if ph.clean_label(raw):
return ph.clean_label(raw)
catalog = env.facts.label(snap, column) if column else None
return ph.pick_label(catalog, default, column)
def column_label(env: Env, snap: Any, column: str) -> str:
"""维度、因子这类列的叫法:目录里的中文名,没有就是列名(都消毒)。"""
return ph.pick_label(env.facts.label(snap, column), column)
def today_param(env: Env) -> str:
"""要判本期完不完整的算子把运行当天记进 params(复算用记下的那一天)。"""
day = env.today or date.today()
env.today = day
return day.isoformat()
def build(env: Env, params: dict[str, Any], used: Sequence[str], *, status: str = "ok",
reason_code: str | None = None, reason: str | None = None, table: Mapping[str, Any] | None = None,
values: Mapping[str, Any] | None = None, verdict: Mapping[str, Any] | None = None, criterion: str,
phrase: str, caveats: Sequence[Mapping[str, str]] = ()) -> AnalysisResult:
"""组装一份结论:形状严格按 contract.AnalysisResult,params 补上查过的目录事实。"""
record = env.facts.record()
out_params = {k: v for k, v in params.items() if v is not None}
if record:
out_params["catalog"] = record
return {
"op": env.op,
"mode": env.mode,
"method": METHODS[(env.op, env.mode)],
"params": out_params,
"settings": {k: env.settings[k] for k in used},
"inputs": list(env.inputs.records),
"probes": env.book.used,
"status": status, # type: ignore[typeddict-item]
"reason_code": reason_code,
"reason": reason,
"table": dict(table) if table is not None else None, # type: ignore[typeddict-item]
"values": dict(values or {}),
"verdict": dict(verdict) if verdict is not None else None, # type: ignore[typeddict-item]
"criterion": criterion,
"phrase": phrase,
"caveats": [dict(c) for c in caveats], # type: ignore[misc]
"hypothesis": env.hypothesis,
}
def refuse(env: Env, params: dict[str, Any], used: Sequence[str], code: str, reason: str, *,
criterion: str, caveats: Sequence[Mapping[str, str]] = ()) -> AnalysisResult:
"""算不了(status refused):判定语是「{算子的叫法}没有做成:{原因}」。"""
return build(env, params, used, status="refused", reason_code=code, reason=reason, criterion=criterion,
phrase=ph.refused(env.op, reason), caveats=caveats)
def need(raw: Mapping[str, Any], key: str, what: str) -> Any:
"""必填的参数;没写是调用错误。"""
value = raw.get(key)
if value is None or (isinstance(value, str) and not value.strip()) or value == []:
raise CallError("missing_param", f"没写{what}")
return value
def written_places(raw: Any) -> int:
"""快照里一格写到第几位小数(headline 是取整过的,按它的书写精度容差):数字文本按字面,浮点按最短写法,整数为 0。"""
if isinstance(raw, bool) or raw is None:
return 0
text = repr(raw) if isinstance(raw, float) else str(raw).strip()
if "e" in text.lower():
return 0
return len(text.split(".", 1)[1]) if "." in text else 0
def closes(total: Fraction, cell: Fraction, raw: Any) -> bool:
"""各段之和(或合起来的比率)和总数那一格对得上:十进制逐位相等;总数取整过的,差在它书写精度的半个末位以内也算;浮点
算出来的差在最后几位的也算(相对误差 1e-9 以内,同 engine/compare._equal)。"""
if total == cell:
return True
gap = abs(total - cell)
if gap <= Fraction(1, 2) / Fraction(10) ** written_places(raw):
return True
scale = max(abs(total), abs(cell))
return scale != 0 and gap <= scale * Fraction(1, 10 ** 9)
def sign_word(value: Fraction | float | int) -> str | None:
return "up" if value > 0 else "down" if value < 0 else None
__all__ = ["DERIVED_PARAMS", "Env", "build", "closes", "column_label", "label_for", "need", "refuse", "sign_word",
"today_param", "written_places"]
+540
View File
@@ -0,0 +1,540 @@
"""可比性检查(细案 01 §6):比两期之前先确认能比。三处用到:agent 单独调用(analysis_comparable);变化拆解给了两份快照时
先做;口径卡跨两份查询算环比、同比时自动做(入口经 run 调)。波动基线、趋势判本期完整用这里的 span_coverage。
五项检查,每项 pass / warn / fail / unknown(unknown 只写「核对不了」,不判不可比):
- 同口径:两份 SQL 一字不差 → pass;规范化(data/sql_semantics.normalize_sql)后只差字面量:把时间条件的界(data/coverage
认出的时间谓词的界,整个表达式)和像日期的字面量换成同一个占位再比,一样就是只差日期 → pass,不一样就是比的两段不是
两期 → fail「两边除了日期,筛选的还不一样」;规范形式不同交给口径账本的 explain_sql_diff,等价 → pass,只差时间范围、排序
→ pass,否则 fail,写出差在哪些方面(只写方面,不写 SQL 原文里的值)。两份来自不同的数据源 fail。同一份快照里按期次分组
的天然同口径。
- 窗口长度:用 data/coverage 的 filters 取业务日期列(目录的 business_date;没写就取唯一那一列时间谓词)上的区间,界是字面
量的直接解析,按运行当天现算的在库里算(Book 的 bound,记进 probes);算两边天数。整月对整月、整季对整季算一样长。
不相等:度量是流量 fail,比率、存量、没标的 warn。按期次分组的:两期粒度相同,一样长。
- 整期:窗口(或这一期)的终点和表里这一列的实际最大值(Book 的 range:现查,查不成退回剖析事实)、运行当天比;数据从
窗口起点之后才开始的也算不完整。不完整:流量 fail(「本期数据只到 2026-10-15,还差 3 天」),其余 warn;表的范围查不到时
只能按运行当天判,判不出的 unknown。
- 星期构成:按天的窗口、天数不是 7 的倍数时数两边的周六、周日天数,不同 warn(「两段的周末天数不同:4 天对 6 天」)。整月、
整季、按周的不看。
- 数据问题:两边查询结果上「查询区间里这张表没有数据」(快照的 no_data)fail,SQL 检查的错误级结果(快照的 checks,level
error)warn。
判定:有 fail → incomparable;只有 warn → conditional;其余 comparable(有核对不了的项照实写在判定语后面)。
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import Any
from app.analysis import periods as pd
from app.analysis import phrases as ph
from app.analysis.facts import Facts, date_source
from app.analysis.inputs import Snap
from app.analysis.probe import Book
from app.data.names import name_key
CHECKS = ("same_query", "window", "complete", "weekdays", "data")
CHECK_NAMES = {"same_query": "同口径", "window": "窗口长度", "complete": "整期", "weekdays": "星期构成",
"data": "数据问题"}
STATUS_NAMES = {"pass": "通过", "warn": "提醒", "fail": "不通过", "unknown": "核对不了"}
#: explain_sql_diff 的方面 → 叫法
_ASPECTS = {"time": "时间列", "time_range": "时间范围", "filter": "筛选条件", "aggregate": "聚合方式",
"expression": "选出来的列", "join": "关联", "tables": "查的表", "set_op": "合并方式", "group_by": "分组",
"having": "分组后的筛选", "limit": "取哪几行", "order": "排序", "parse": "写法", "other": "写法"}
#: 只差这几个方面的两份 SQL 仍是同一个量的两期
_SAME_ASPECTS = frozenset({"time_range", "order"})
#: 一项 SQL 检查的说明最多引多少字
_CHECK_TEXT_MAX = 80
@dataclass
class Check:
id: str
status: str = "unknown"
reasons: list[str] = field(default_factory=list)
base: str | None = None
target: str | None = None
@dataclass
class Span:
"""一边的时间范围:start / end 是窗口(或这一期)的头尾(含);table / column 是这段时间按的那一列(探查表的范围用);
sql_window 是 SQL 时间条件在这一列上的区间(按期次分组时看它有没有截掉这一期)。"""
label: str
snap: Snap
start: date | None = None
end: date | None = None
table: str | None = None
column: str | None = None
sql_window: Any = None
unit: str | None = None # 这一段是不是整月、整季、整年(calendar_unit),按期次时是粒度
@dataclass
class Outcome:
checks: list[Check]
verdict: str
reasons: list[str]
unknown: list[str]
phrase: str
structure: str | None # 同口径不通过的原因(变化拆解据此不拆)
base: Span | None = None
target: Span | None = None
def check(self, cid: str) -> Check:
return next(c for c in self.checks if c.id == cid)
# --------------------------------------------------------------------------
# 同口径
# --------------------------------------------------------------------------
def _undated(sql: str, read: str | None) -> tuple[str, tuple[str, ...]] | None:
"""时间条件的界、像日期的字面量换成同一个占位之后的规范形式(文本 + 剩下的字面量)。解析不了为 None。"""
from sqlglot import exp
from app.data import coverage
from app.data.sql_semantics import normalize_sql
tree = coverage.parse_sql(sql, read)
if tree is None:
return None
for flt in coverage.filters(tree):
for _, node, _ in flt.conds:
if node.parent is not None:
node.replace(exp.Literal.string("__day__"))
for lit in list(tree.find_all(exp.Literal)):
if coverage.literal_bound(lit)[1] is not None:
lit.replace(exp.Literal.string("__day__"))
normalized = normalize_sql(tree.sql(dialect=read), read)
if normalized.parse_error:
return None
return normalized.text, normalized.literals
def same_query(base: Snap, target: Snap) -> Check:
from app.data.sql_semantics import explain_sql_diff, normalize_sql
check = Check("same_query")
if base.source != target.source:
check.status, check.reasons = "fail", ["两份查询来自不同的数据源"]
return check
a, b = base.sql.strip(), target.sql.strip()
if a == b:
check.status = "pass"
return check
read = base.dialect or target.dialect
na, nb = normalize_sql(a, read), normalize_sql(b, read)
if na.parse_error or nb.parse_error:
check.reasons = ["SQL 解析不了"]
return check
if na.text == nb.text:
ua, ub = _undated(a, read), _undated(b, read)
if ua is None or ub is None:
return check
if ua == ub:
check.status = "pass"
else:
check.status, check.reasons = "fail", ["两边除了日期,筛选的还不一样"]
return check
try:
diff = explain_sql_diff(a, b, read)
except Exception: # noqa: BLE001 - 比不了就当核对不了
return check
aspects = [d.aspect for d in diff.items]
if diff.equivalent or (aspects and set(aspects) <= _SAME_ASPECTS):
check.status = "pass"
return check
names = list(dict.fromkeys(_ASPECTS.get(x, "写法") for x in aspects if x not in _SAME_ASPECTS)) or ["写法"]
check.status, check.reasons = "fail", [f"两份查询的口径结构不同(差在{'、'.join(names)})"]
return check
# --------------------------------------------------------------------------
# 窗口
# --------------------------------------------------------------------------
def _bound_days(snap: Snap, conds: Sequence[tuple[str, Any, bool]], book: Book) -> dict[int, Any]:
from app.data import coverage
days: dict[int, Any] = {}
for _, node, _ in conds:
literal, day = coverage.literal_bound(node)
if literal:
days[id(node)] = day
continue
try:
text = node.sql(dialect=snap.dialect)
except Exception: # noqa: BLE001
days[id(node)] = None
continue
probe = book.bound(snap.source, text)
days[id(node)] = coverage.as_day(probe.get("value")) if probe else None
return days
def _filters_on(snap: Snap, measure: str | None, facts: Facts,
column: tuple[str, str] | None = None) -> tuple[Any, str | None]:
"""SQL(收窄到度量那一项)里按业务日期列(或唯一那一列时间谓词,或给定的那一列)的时间条件:(那段条件, 认不出的原因)。"""
from app.data import coverage
tree = coverage.parse_sql(snap.sql, snap.dialect)
if tree is None:
return None, "SQL 解析不了"
narrowed = coverage.narrowed(tree, measure, snap.dialect) if measure else tree
flts = coverage.filters(narrowed)
groups: dict[tuple[str, str], list[Any]] = {}
for flt in flts:
groups.setdefault((name_key(flt.table_name), name_key(flt.column_name)), []).append(flt)
if column is not None:
wanted = (name_key(column[0]), name_key(column[1]))
wanted_short = (name_key(column[0].rsplit(".", 1)[-1]), wanted[1])
chosen = groups.get(wanted) or next((v for k, v in groups.items()
if (k[0].rsplit(".", 1)[-1], k[1]) == wanted_short), None)
if chosen is None:
return None, None
else:
if not groups:
return None, "SQL 里没有时间条件"
dated = [v for v in groups.values()
if (bd := facts.business_date(snap.source, v[0].table_name)) and name_key(bd) == name_key(v[0].column_name)]
if len(dated) == 1:
chosen = dated[0]
elif len(groups) == 1:
chosen = next(iter(groups.values()))
else:
return None, "时间条件不止一列,认不出按哪一列算"
if len({f.key for f in chosen}) > 1:
return None, "这一列上有好几段时间条件"
return chosen[0], None
def sql_span(label: str, snap: Snap, measure: str | None, facts: Facts, book: Book) -> tuple[Span, str | None]:
"""一份查询的时间窗口(两份快照比两期时)。"""
from app.data import coverage
span = Span(label, snap)
flt, why = _filters_on(snap, measure, facts)
if flt is None:
return span, why
if flt.column_name.lower() in snap.masked:
return span, "时间列是遮罩列" # 遮罩列不探表的范围
span.table, span.column = flt.table_name, flt.column_name
window = coverage.window_of(flt.conds, _bound_days(snap, flt.conds, book))
if window is None or window.start is None or window.end is None:
return span, "时间窗口没有两头"
span.start, span.end = window.start, window.end
span.unit = pd.calendar_unit(window.start, window.end)
return span, None
def period_span(label: str, snap: Snap, period: Any, item: pd.Period, grain: str, measure: str | None,
facts: Facts, book: Book) -> Span:
"""按期次分组的一份快照里的一期:这一期覆盖的日子,加上 SQL 在期次那一列上的时间条件。"""
from app.data import coverage
span = Span(label, snap, item.start, item.end, unit=grain)
found = date_source(snap.sql, period)
if found is None:
return span
span.table, span.column = found
if span.column.lower() in snap.masked:
span.table = span.column = None
return span
flt, _ = _filters_on(snap, measure, facts, column=found)
if flt is not None:
span.sql_window = coverage.window_of(flt.conds, _bound_days(snap, flt.conds, book))
return span
def _range(span: Span, book: Book) -> tuple[bool, date | None, date | None, bool]:
"""(查得到, 最早, 最晚, 一个值都没有):表里这一列的实际范围。"""
from app.data import coverage
if not (span.table and span.column):
return False, None, None, False
probe = book.range(span.snap.source, span.table, span.column)
if probe is None:
return False, None, None, False
lo = coverage.as_day(probe.get("min")) if probe.get("min") else None
hi = coverage.as_day(probe.get("max")) if probe.get("max") else None
empty = probe.get("basis") == "live" and probe.get("min") is None and probe.get("max") is None
return True, lo.day if lo else None, hi.day if hi else None, empty
def span_coverage(span: Span, today: date, book: Book) -> tuple[str, str | None]:
"""这一段(窗口或一期)的数据齐不齐:(pass / short / unknown, 不齐的那一句)。没过完(终点不早于运行当天:当天的数据还没
到齐)、SQL 的时间条件截掉了一部分、表里的数据没到(最晚早于终点)或来得晚(最早晚于起点),都算不齐。"""
if span.start is None or span.end is None:
return "unknown", None
lo, hi = span.start, span.end
window = span.sql_window
if window is not None:
if window.start is not None:
lo = max(lo, window.start)
if window.end is not None:
hi = min(hi, window.end)
if span.end >= today:
hi = min(hi, today - timedelta(days=1))
known, dmin, dmax, empty = _range(span, book)
if empty:
return "short", f"{span.label}在 {span.start.isoformat()} 到 {span.end.isoformat()} 没有数据"
if dmin is not None:
lo = max(lo, dmin)
if dmax is not None:
hi = min(hi, dmax)
if hi < lo:
return "short", f"{span.label}在 {span.start.isoformat()} 到 {span.end.isoformat()} 没有数据"
parts = []
if hi < span.end:
parts.append(f"{span.label}数据只到 {hi.isoformat()},还差 {(span.end - hi).days} 天")
if lo > span.start:
parts.append(f"{span.label}数据从 {lo.isoformat()} 才开始,缺前 {(lo - span.start).days} 天")
if parts:
return "short", ",".join(parts)
return ("pass", None) if known and dmax is not None else ("unknown", None)
def _days(span: Span) -> int | None:
return (span.end - span.start).days + 1 if span.start and span.end else None
def _shown(span: Span) -> str | None:
if span.start is None or span.end is None:
return None
if span.start == span.end:
return f"{span.start.isoformat()}(1 天)"
return f"{span.start.isoformat()} 到 {span.end.isoformat()}({_days(span)} 天)"
# --------------------------------------------------------------------------
# 五项检查和判定
# --------------------------------------------------------------------------
def _window_check(base: Span, target: Span, flow: bool, *, grouped: bool) -> Check:
check = Check("window", base=_shown(base), target=_shown(target))
a, b = _days(base), _days(target)
if a is None or b is None:
return check
if a == b or grouped or (base.unit and base.unit == target.unit):
check.status = "pass"
return check
check.status = "fail" if flow else "warn"
check.reasons = [f"两段的天数不同:{a} 天对 {b} 天"]
return check
def _complete_check(sides: Sequence[Span], today: date, book: Book, flow: bool) -> Check:
check = Check("complete")
shorts, unknown = [], False
for span in sides:
status, reason = span_coverage(span, today, book)
if status == "short":
shorts.append(reason)
elif status == "unknown":
unknown = True
if span.label == "基期":
check.base = reason or STATUS_NAMES[status if status != "short" else "fail"]
else:
check.target = reason or STATUS_NAMES[status if status != "short" else "fail"]
if shorts:
check.status, check.reasons = ("fail" if flow else "warn"), [r for r in shorts if r]
elif not unknown:
check.status = "pass"
return check
def _weekday_check(base: Span, target: Span, *, grain: str | None) -> Check:
check = Check("weekdays")
a, b = _days(base), _days(target)
if a is None or b is None:
return check
if grain in ("week", "month", "quarter", "seq") or (a % 7 == 0 and b % 7 == 0) or \
(base.unit and base.unit == target.unit and base.unit in ("month", "quarter", "year")):
check.status = "pass"
return check
wa, wb = pd.weekend_days(base.start, base.end), pd.weekend_days(target.start, target.end) # type: ignore[arg-type]
check.base, check.target = f"周末 {wa} 天", f"周末 {wb} 天"
if wa == wb:
check.status = "pass"
else:
check.status, check.reasons = "warn", [f"两段的周末天数不同:{wa} 天对 {wb} 天"]
return check
def _data_check(sides: Sequence[tuple[str, Snap]], measure: str | None) -> Check:
from app.data import coverage
check = Check("data", status="pass")
fails, warns = [], []
for label, snap in sides:
found = coverage.no_data_of(snap.data, measure) if measure else \
next(iter(snap.data.get("no_data") or []), None)
if found:
fails.append(f"{label}查询的区间里这张表没有数据")
errors = [c for c in snap.data.get("checks") or [] if isinstance(c, Mapping) and c.get("level") == "error"]
if errors:
text = str(errors[0].get("message") or "").strip().replace(";", ",")
if len(text) > _CHECK_TEXT_MAX:
text = text[:_CHECK_TEXT_MAX - 1] + "…"
warns.append(f"{label}查询的 SQL 检查有错误:{text}" if text else f"{label}查询的 SQL 检查有错误")
if fails:
check.status = "fail"
elif warns:
check.status = "warn"
check.reasons = fails + warns
return check
def _decide(checks: list[Check], base: Span | None, target: Span | None) -> Outcome:
fails = [r for c in checks if c.status == "fail" for r in c.reasons]
warns = [r for c in checks if c.status == "warn" for r in c.reasons]
unknown = [CHECK_NAMES[c.id] for c in checks if c.status == "unknown"]
verdict = "incomparable" if fails else "conditional" if warns else "comparable"
reasons = fails + warns
structure = next((c.reasons[0] for c in checks if c.id == "same_query" and c.status == "fail" and c.reasons), None)
return Outcome(checks, verdict, reasons, unknown, ph.comparable_phrase(verdict, reasons, unknown), structure,
base, target)
def compare_queries(base: Snap, target: Snap, measure: str | None, facts: Facts, book: Book, today: date) -> Outcome:
"""两份快照比两期(本周一份、上周一份)。measure 是两边的度量列(收窄 SQL 找时间条件、认度量类型);没有为 None。"""
same = same_query(base, target)
b_span, _ = sql_span("基期", base, measure, facts, book)
t_span, _ = sql_span("本期", target, measure, facts, book)
flow = bool(measure) and facts.measure(target, measure)[0] == "flow" # type: ignore[arg-type]
checks = [same, _window_check(b_span, t_span, flow, grouped=False),
_complete_check([b_span, t_span], today, book, flow), _weekday_check(b_span, t_span, grain=None),
_data_check([("基期", base), ("本期", target)], measure)]
return _decide(checks, b_span, t_span)
def compare_periods(snap: Snap, period: str, base: pd.Period, target: pd.Period, grain: str, measure: str | None,
facts: Facts, book: Book, today: date) -> Outcome:
"""同一份快照里按期次分组的两期:天然同口径;窗口按期次的粒度;整期看这两期各自齐不齐。"""
same = Check("same_query", status="pass")
b_span = period_span("基期", snap, period, base, grain, measure, facts, book)
t_span = period_span("本期", snap, period, target, grain, measure, facts, book)
flow = bool(measure) and facts.measure(snap, measure)[0] == "flow" # type: ignore[arg-type]
if grain == "seq":
checks = [same, Check("window"), Check("complete"), Check("weekdays"), _data_check([("", snap)], measure)]
else:
checks = [same, _window_check(b_span, t_span, flow, grouped=True),
_complete_check([b_span, t_span], today, book, flow),
_weekday_check(b_span, t_span, grain=grain), _data_check([("", snap)], measure)]
return _decide(checks, b_span, t_span)
def table_of(outcome: Outcome) -> dict[str, Any]:
"""检查表(结论的 table):检查项、结果、基期、本期、说明。"""
rows = [[CHECK_NAMES[c.id], STATUS_NAMES[c.status], c.base, c.target, ";".join(c.reasons) or None]
for c in outcome.checks]
return {"columns": ["check", "status", "base", "target", "note"],
"labels": {"check": "检查项", "status": "结果", "base": "基期", "target": "本期", "note": "说明"},
"rows": rows}
def values_of(outcome: Outcome) -> dict[str, Any]:
out: dict[str, Any] = {c.id: c.status for c in outcome.checks}
for side, span in (("base", outcome.base), ("target", outcome.target)):
if span is not None and span.start and span.end:
out[f"{side}_start"] = span.start.isoformat()
out[f"{side}_end"] = span.end.isoformat()
out[f"{side}_days"] = str(_days(span))
out[f"{side}_weekend"] = str(pd.weekend_days(span.start, span.end))
return out
def reasons(result: Mapping[str, Any]) -> list[str]:
"""一份可比性结论里 fail、warn 的原因,一句一个(口径卡指标的 comparability.reasons 用)。按检查表的说明拆开。"""
out: list[str] = []
table = result.get("table") or {}
for row in table.get("rows") or []:
if len(row) >= 5 and row[1] in (STATUS_NAMES["fail"], STATUS_NAMES["warn"]) and row[4]:
out.extend(str(row[4]).split(";"))
return out
# --------------------------------------------------------------------------
# 算子:analysis_comparable
# --------------------------------------------------------------------------
def comparable(raw: Mapping[str, Any], env: Any) -> Any:
"""可比性检查这个算子。参数:两份快照 base_query、target_query;或一份快照 query + period(期次列)+ base、target(期次
列里的两个值)、可选 grain。都可以写 measure(度量列:收窄 SQL 找时间条件、按目录认它是不是流量;不写时只有一列数值列
就用它)。"""
from app.analysis.common import build, need, today_param
from app.analysis.contract import CallError
from app.analysis.inputs import same_value
params: dict[str, Any] = {}
two = raw.get("base_query") is not None or raw.get("target_query") is not None
if two:
base = env.inputs.snap(need(raw, "base_query", "基期的查询编号"), "基期的查询编号")
target = env.inputs.snap(need(raw, "target_query", "本期的查询编号"), "本期的查询编号")
if base is target:
from app.analysis.decompose import SAME_QUERY
raise CallError("same_query", SAME_QUERY)
params.update(base_query=base.ref, target_query=target.ref)
else:
base = target = env.inputs.snap(need(raw, "query", "查询编号"), "查询编号")
params["query"] = base.ref
measure = target.column(raw["measure"], "度量列") if raw.get("measure") is not None else _only_number(target)
if measure is not None and two:
base.column(measure, "度量列")
params["measure"] = measure
params["today"] = today_param(env)
if two:
env.inputs.use(base, "base", [measure] if measure else [])
env.inputs.use(target, "target", [measure] if measure else [])
outcome = compare_queries(base, target, measure, env.facts, env.book, env.today)
else:
period = target.column(need(raw, "period", "期次列"), "期次列")
grain = raw.get("grain")
if grain is not None and grain not in (*pd.GRAINS, "seq"):
raise CallError("bad_grain", "粒度只能写 day、week、month、quarter,或者不写(按期次值推断)")
params.update(period=period, base=need(raw, "base", "基期(期次列里的值)"),
target=need(raw, "target", "本期(期次列里的值)"))
i = target.index(period)
values = [row[i] if i < len(row) else None for row in target.rows]
rows = {side: [r for r, v in enumerate(values) if same_value(params[side], v)] for side in ("base", "target")}
for side, what in (("base", "基期"), ("target", "本期")):
if not rows[side]:
raise CallError("period_not_found", f"期次列「{period}」里没有「{params[side]}」:{what}要写 {target.ref} "
"里有的期次值")
env.inputs.use(target, "data", [period, *([measure] if measure else [])], rows["base"] + rows["target"])
parsed = pd.parse(values, grain)
if parsed is None:
item_b = item_t = pd.Period(None, 0, None, None)
grain = "seq"
else:
items, grain = parsed
item_b, item_t = items[rows["base"][0]], items[rows["target"][0]]
params["grain"] = grain
outcome = compare_periods(target, period, item_b, item_t, grain, measure, env.facts, env.book, env.today)
caveats = []
window = outcome.check("window")
if measure and window.status == "warn" and env.facts.measure(target, measure)[0] is None:
caveats.append(ph.caveat("measure_unlabeled", ph.unlabeled_text(measure, "窗口不等只作提醒")))
verdict = {"code": outcome.verdict, "subjects": [], "direction": None}
return build(env, params, [], table=table_of(outcome), values=values_of(outcome), verdict=verdict,
criterion=ph.CRITERION_COMPARABLE, phrase=outcome.phrase, caveats=caveats)
def _only_number(snap: Snap) -> str | None:
"""没写度量列时:快照里唯一一列(没遮罩的)数值列;没有或不止一列为 None。"""
found = [c for c in snap.columns if c.lower() not in snap.masked and snap.kind(c) == "number"]
return found[0] if len(found) == 1 else None
__all__ = ["CHECKS", "CHECK_NAMES", "Check", "Outcome", "STATUS_NAMES", "Span", "comparable", "compare_periods",
"compare_queries", "period_span", "reasons", "same_query", "span_coverage", "sql_span", "table_of",
"values_of"]
+1
View File
@@ -208,6 +208,7 @@ class AnalysisInput(TypedDict, total=False):
column: str
value: str | None
dialect: str # 读这份快照的 SQL 用的方言(第 1 波 W1-a 加,可选):复算按它读,界的写法才一样
masked: list[str] # 这份快照当时不能当维度、度量的列(小写,只记列名;第 1 波 W1-a 加,可选):复算按它认
class Probe(TypedDict, total=False):
+732
View File
@@ -0,0 +1,732 @@
"""变化拆解(细案 01 §2):变化从哪来。三种模式,agent 按指标的性质选,算子核对选得对不对。
**参数**(AnalysisRequest.params;列名不分大小写,按快照认):
- 一份快照:query(编号)、period(期次列)、base / target(期次列里的两个值);或两份快照:base_query、target_query(各自
只有维度列和度量列,先做可比性检查:口径结构不同的不拆,算不了 incomparable_sides;其余不可比的照样算,判定后附上
可比性结论)。
- additive:by(维度列,1–2 列)、measure(度量列);可选 headline {base, target}(基期、本期总数各一格,如 Q1.r1.gmv,用来
核对闭合)、parent {of: A3, key: [华东]}(下钻:这一层各段之和要等于 A3 里华东那一段)。
- ratio:by、numerator(分子列)、denominator(分母列);可选 headline {base, target}(整体比率的两格)、parent(上一层的比率
拆解)。
- factor:product(乘积列)、factors(因子列,2–4 个);一份快照按 period / base / target 取两行,两份快照各一行。
- 都可以写 label(度量的叫法,消毒后进判定语)。
**计算**全程 Fraction(快照里的数按字面换成精确的有理数):各段之和逐位等于总变化;比率拆解的结构效应、自身效应按中点
对称拆,Σmix + Σrate 逐位等于比率的变化;乘积拆解是 LMDI-I(对数),浮点,各因子贡献之和在浮点误差内等于总变化。判定、
阈值、边界照细案 §2.1–§2.4。
"""
from __future__ import annotations
import math
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from fractions import Fraction
from typing import Any
from app.analysis import comparable as cmp
from app.analysis import phrases as ph
from app.analysis.common import Env, build, closes, column_label, label_for, need, refuse, sign_word, today_param
from app.analysis.contract import CallError
from app.analysis.inputs import Snap, as_list, hashable, same_value
from app.analysis.numbers import canon, to_fraction
#: 辛普森:分母够大的段里,按平均权重算有这么多的段比率和整体反着变
SIMPSON_SHARE = Fraction(4, 5)
#: 下钻、总数的写法(调用错误里教 agent 怎么写)
PARENT_SHAPE = "下钻要写成 {of: A3, key: [华东]} 这样:of 是上一层变化拆解的编号,key 是那一段的维度值"
HEADLINE_SHAPE = "总数要写成 {base: Q1.r1.gmv, target: Q1.r0.gmv} 这样:base 是基期总数那一格,target 是本期总数那一格"
SAME_QUERY = "基期和本期是同一份查询:两期各给一份查询,或者给一份按期次分组的查询和期次列"
#: 乘积闭合的最小相对误差
_FACTOR_REL = Fraction(1, 10 ** 6)
_NOT_ADDITIVE = {"ratio": "比率", "identifier": "标识", "status": "状态", "attribute": "属性"}
@dataclass
class Side:
"""一期的各段:键 → 值(ratio 模式是 (分子, 分母));nulls 是这一期度量格为空的段;rows 是用到的行。"""
snap: Snap
values: dict[tuple[Any, ...], Any] = field(default_factory=dict)
nulls: list[tuple[Any, ...]] = field(default_factory=list)
rows: list[int] = field(default_factory=list)
@dataclass
class Segment:
key: tuple[Any, ...]
base: Any
target: Any
flags: list[str] = field(default_factory=list)
def _key_strings(key: Sequence[Any]) -> list[str]:
return [ph.key_text([k]) for k in key]
def _sides(raw: Mapping[str, Any], env: Env, roles: Sequence[tuple[str, str]], *, by_needed: bool = True
) -> tuple[dict[str, Any], Snap, Snap, dict[str, str], list[str]]:
"""一份或两份快照:(规范化的参数, 基期快照, 本期快照, {角色: 列名}, 维度列)。roles 是 [(参数名, 角色的叫法)]。"""
params: dict[str, Any] = {}
two = raw.get("base_query") is not None or raw.get("target_query") is not None
if two:
base = env.inputs.snap(need(raw, "base_query", "基期的查询编号"), "基期的查询编号")
target = env.inputs.snap(need(raw, "target_query", "本期的查询编号"), "本期的查询编号")
if base is target:
raise CallError("same_query", SAME_QUERY)
params.update(base_query=base.ref, target_query=target.ref)
else:
base = target = env.inputs.snap(need(raw, "query", "查询编号"), "查询编号")
params["query"] = base.ref
by: list[str] = []
if by_needed:
wanted = as_list(raw.get("by"))
if not 1 <= len(wanted) <= 2:
raise CallError("bad_by", "维度列要写 1 到 2 列")
by = [target.column(c, "维度列") for c in wanted]
if two:
for c in by:
base.column(c, "维度列")
params["by"] = by
cols: dict[str, str] = {}
for key, what in roles:
if key == "factors":
continue
col = target.column(need(raw, key, what), what)
if two:
base.column(col, what)
cols[key] = col
params[key] = col
if not two:
params["period"] = target.column(need(raw, "period", "期次列"), "期次列")
params["base"] = need(raw, "base", "基期(期次列里的值)")
params["target"] = need(raw, "target", "本期(期次列里的值)")
if same_value(params["base"], params["target"]):
raise CallError("same_period", "基期和本期是同一期:写期次列里两个不同的值")
used = [*cols.values(), *([params["period"]] if "period" in params else [])]
if len(set(by)) != len(by) or set(by) & set(used) or len(set(used)) != len(used):
raise CallError("bad_by", "维度列、期次列、度量列要是不同的列")
return params, base, target, cols, by
def _period_rows(snap: Snap, period: str, wanted: Any, what: str) -> list[int]:
i = snap.index(period)
rows = [r for r, row in enumerate(snap.rows) if i < len(row) and same_value(wanted, row[i])]
if not rows:
values = list(dict.fromkeys(ph.key_text([row[i]]) for row in snap.rows if i < len(row)))
raise CallError("period_not_found", f"期次列「{period}」里没有「{wanted}」:{what}要写 {snap.ref} 里有的期次值,"
f"比如 {'、'.join(values[:6])}")
return rows
def _rows_of(raw: Mapping[str, Any], params: dict[str, Any], base: Snap, target: Snap) -> tuple[list[int], list[int]]:
if "period" in params:
return (_period_rows(base, params["period"], params["base"], "基期"),
_period_rows(target, params["period"], params["target"], "本期"))
return list(range(len(base.rows))), list(range(len(target.rows)))
def _collect(snap: Snap, rows: Sequence[int], by: Sequence[str], read: Any, labels: Sequence[str],
period_label: str | None) -> Side:
"""一期的各段:按维度键,同一键只许一行(多行是 SQL 没按这些维度汇总)。read(row) 读出这一行的值(None 是空)。"""
side = Side(snap)
seen: set[tuple[Any, ...]] = set()
idx = [snap.index(c) for c in by]
for r in rows:
row = snap.rows[r]
key = tuple(hashable(row[i] if i < len(row) else None) for i in idx)
if key in seen:
keys = "、".join([*labels, *([period_label] if period_label else [])])
raise CallError("duplicate_rows", f"{snap.ref} 里同一个{'、'.join(labels)}在同一期有不止一行:在 SQL 里按 "
f"{keys} 汇总,每段每期一行")
seen.add(key)
value = read(r)
side.rows.append(r)
if value is None:
side.nulls.append(key)
side.values[key] = value
return side
def _segments(base: Side, target: Side, zero: Any) -> list[Segment]:
"""两期的各段,按先出现的顺序:缺的一边记 0,标「新出现 / 消失」;度量格为空的记 0,标「为空」。"""
out: list[Segment] = []
for key in dict.fromkeys([*base.values, *target.values]):
flags = []
if key not in base.values:
flags.append("新出现")
if key not in target.values:
flags.append("消失")
if key in base.nulls or key in target.nulls:
flags.append("为空")
b, t = base.values.get(key), target.values.get(key)
out.append(Segment(key, zero if b is None else b, zero if t is None else t, flags))
return out
def _parent(raw: Mapping[str, Any], env: Env, mode: str, dims: int) -> tuple[dict[str, Any], list[Any], int]:
"""下钻:上一层的结论和那一段。返回 (params 里的 parent, 那一段在上一层结果表里的那一行, 这一层的层数)。"""
from app.core.artifact_store import canonical_json, content_hash
spec = raw.get("parent")
name = spec.get("of") if isinstance(spec, Mapping) else None
key = as_list(spec.get("key")) if isinstance(spec, Mapping) else []
if not isinstance(name, str) or not name.strip() or not key:
raise CallError("bad_parent", PARENT_SHAPE)
name = name.strip()
found = env.parents(name) if env.parents is not None else None
if not isinstance(found, Mapping):
raise CallError("parent_missing", f"没有叫「{name}」的分析结论:下钻写这次运行里变化拆解的编号(如 A3)")
if found.get("op") != "decompose" or found.get("mode") != mode:
raise CallError("parent_not_decompose", f"{name} 不是{'按维度' if mode == 'additive' else '比率'}的变化拆解,"
"下钻要接在同一种拆解后面")
if found.get("status") != "ok" or not isinstance(found.get("table"), Mapping):
raise CallError("parent_refused", f"{name} 没有做成,不能在它下面下钻")
depth = int((found.get("params") or {}).get("depth") or 0) + 1
limit = int(env.settings["max_drill_depth"])
if depth > limit:
raise CallError("too_deep", f"已经下钻了 {depth - 1} 层,最多 {limit} 层:换一个维度从头拆")
table = found["table"]
fixed = _FIXED_ADDITIVE if mode == "additive" else _FIXED_RATIO
columns = list(table.get("columns") or [])
dim_count = len([c for c in columns if c not in fixed])
if len(key) != dim_count:
raise CallError("bad_parent", f"{name} 按 {dim_count} 列拆,下钻的那一段要写 {dim_count} 个维度值")
hit = next((row for row in table.get("rows") or []
if all(same_value(k, v) for k, v in zip(key, row[:dim_count]))), None)
if hit is None:
raise CallError("parent_key_missing", f"{name} 里没有「{ph.key_text(key)}」这一段:写 {name} 结果表里的段")
env.inputs.parent(name, content_hash(canonical_json(found)))
return {"of": name, "key": key}, dict(zip(columns, hit)), depth
_FIXED_ADDITIVE = ("base", "target", "delta", "pct", "share", "flag")
_FIXED_RATIO = ("n_base", "n_target", "d_base", "d_target", "w_base", "w_target", "r_base", "r_target", "mix", "rate",
"total", "flag")
def _headline(raw: Mapping[str, Any], env: Env) -> tuple[dict[str, str] | None, dict[str, tuple[Fraction, Any]]]:
"""headline:基期、本期总数各一格 → ({base, target} 的写法, {base / target: (值, 原样)})。"""
spec = raw.get("headline")
if spec is None:
return None, {}
if isinstance(spec, (list, tuple)) and len(spec) == 2:
spec = {"base": spec[0], "target": spec[1]}
if not isinstance(spec, Mapping) or not spec.get("base") or not spec.get("target"):
raise CallError("bad_headline", HEADLINE_SHAPE)
out: dict[str, tuple[Fraction, Any]] = {}
refs: dict[str, str] = {}
for side, what in (("base", "基期总数"), ("target", "本期总数")):
snap, row, col, value = env.inputs.cell(spec[side], f"headline_{side}", what)
if value is None:
raise CallError("headline_empty", f"{what}那一格({snap.ref}.r{row}.{col})是空的:换一格有值的")
out[side] = (value, snap.cell(row, col))
refs[side] = f"{snap.ref}.r{row}.{col}"
return refs, out
def _measure_guard(env: Env, snap: Snap, column: str, caveats: list[dict[str, str]], tail: str) -> None:
"""additive 的度量列:目录标成(确认过的)比率、标识、状态、属性是调用错误;没标的、只是推断的照算,写附带说明。"""
kind, confirmed = env.facts.measure(snap, column)
if kind in _NOT_ADDITIVE and confirmed:
if kind == "ratio":
raise CallError("ratio_as_additive", f"「{column}」是比率(或平均值),不能按段相加:改用比率拆解,分子、分母各给一列")
raise CallError("not_a_measure", f"「{column}」在目录里是{_NOT_ADDITIVE[kind]},不是能相加的量:度量列要选金额、数量这类列")
if kind is None:
caveats.append(ph.caveat("measure_unlabeled", ph.unlabeled_text(column, tail)))
elif kind in _NOT_ADDITIVE:
caveats.append(ph.caveat("measure_unlabeled", ph.unconfirmed_text(column, kind, tail)))
def _comparability(env: Env, params: dict[str, Any], base: Snap, target: Snap, measure: str | None
) -> tuple[cmp.Outcome | None, str | None]:
"""两份快照时先做可比性:(结论, 口径结构不同的原因)。"""
if "base_query" not in params:
return None, None
params["today"] = today_param(env)
outcome = cmp.compare_queries(base, target, measure, env.facts, env.book, env.today) # type: ignore[arg-type]
return outcome, outcome.structure
def _share_rank(segments: Sequence[Any], share: Any) -> list[Any]:
"""按 share 从大到小(一样大的按先出现的)。"""
return sorted(segments, key=lambda s: -share(s))
# --------------------------------------------------------------------------
# additive
# --------------------------------------------------------------------------
def additive(raw: Mapping[str, Any], env: Env) -> Any:
used = ["main_share", "concentration", "offset_ratio", "flat_pct", "table_top_k"]
params, bs, ts, cols, by = _sides(raw, env, [("measure", "度量列")])
measure = cols["measure"]
label = label_for(env, raw.get("label"), ts, measure)
if raw.get("label") is not None:
params["label"] = ph.clean_label(raw.get("label")) or None
dims = [column_label(env, ts, c) for c in by]
caveats: list[dict[str, str]] = []
_measure_guard(env, ts, measure, caveats, "按可以按段相加处理")
refs, heads = _headline(raw, env)
if refs:
params["headline"] = refs
parent_row = None
if raw.get("parent") is not None:
params["parent"], parent_row, params["depth"] = _parent(raw, env, "additive", len(by))
used.append("max_drill_depth")
summed = not heads and parent_row is None
criterion_args = {"summed": summed}
for snap in dict.fromkeys([bs, ts]):
if snap.truncated:
env.inputs.use(snap, "data" if bs is ts else ("base" if snap is bs else "target"), [*by, measure])
return refuse(env, params, used, "truncated",
f"{snap.ref} 只取回了前 {len(snap.rows)} 行,各段之和不是完整的和",
criterion=ph.criterion_additive(env.settings, top_k_max=None, **criterion_args))
rows_b, rows_t = _rows_of(raw, params, bs, ts)
period_label = column_label(env, ts, params["period"]) if "period" in params else None
side_b = _collect(bs, rows_b, by, lambda r: bs.number(r, measure, "度量列"), dims, period_label)
side_t = _collect(ts, rows_t, by, lambda r: ts.number(r, measure, "度量列"), dims, period_label)
if bs is ts:
env.inputs.use(ts, "data", [*by, params["period"], measure], side_b.rows + side_t.rows)
else:
env.inputs.use(bs, "base", [*by, measure], side_b.rows)
env.inputs.use(ts, "target", [*by, measure], side_t.rows)
outcome, structure = _comparability(env, params, bs, ts, measure)
if structure is not None:
return refuse(env, params, used, "incomparable_sides", f"{structure},不是同一个量的两期,不拆",
criterion=ph.criterion_additive(env.settings, top_k_max=None, **criterion_args), caveats=caveats)
segs = _segments(side_b, side_t, Fraction(0))
if len(segs) < 2:
return refuse(env, params, used, "too_few_segments", f"按{ph.dims_text(dims)}只有 {len(segs)} 段,至少要 2 段才拆得开",
criterion=ph.criterion_additive(env.settings, top_k_max=None, **criterion_args), caveats=caveats)
return _additive_result(env, params, used, segs, by, dims, label, heads, parent_row, outcome, caveats, summed,
side_b, side_t)
def _additive_result(env: Env, params: dict[str, Any], used: list[str], segs: list[Segment], by: list[str],
dims: list[str], label: str, heads: Mapping[str, tuple[Fraction, Any]],
parent_row: Mapping[str, Any] | None, outcome: cmp.Outcome | None,
caveats: list[dict[str, str]], summed: bool, side_b: Side, side_t: Side) -> Any:
s = env.settings
main_share, concentration = env.threshold("main_share"), env.threshold("concentration")
offset_ratio, flat_pct = env.threshold("offset_ratio"), env.threshold("flat_pct")
B = sum((x.base for x in segs), Fraction(0))
T = sum((x.target for x in segs), Fraction(0))
delta = T - B
gaps: dict[str, Fraction] = {}
for side, total in (("base", B), ("target", T)):
if side in heads and not closes(total, heads[side][0], heads[side][1]):
gaps[side] = heads[side][0] - total
if parent_row is not None:
expect = to_fraction(parent_row.get(side))
if expect is None or not closes(total, expect, parent_row.get(side)):
gaps.setdefault(side, (expect or Fraction(0)) - total)
closed = not gaps
deltas = {x.key: x.target - x.base for x in segs}
shares = {k: d / delta for k, d in deltas.items()} if closed and delta != 0 else {}
gross_up = sum((d for d in deltas.values() if d > 0), Fraction(0))
gross_down = sum((d for d in deltas.values() if d < 0), Fraction(0))
opposite = abs(gross_down) if delta > 0 else gross_up if delta < 0 else gross_up
n = len(segs)
k_max = max(2, math.ceil(n / 5))
flat = delta == 0 or (B != 0 and abs(delta) / abs(B) < flat_pct)
ranked = _share_rank(segs, lambda x: shares[x.key]) if shares else []
top_k = top_k_share = None
if ranked:
running = Fraction(0)
for k, seg in enumerate(ranked, start=1):
running += shares[seg.key]
if running >= concentration:
top_k, top_k_share = k, running
break
if not closed:
code, subjects = "unclosed", []
elif flat:
code, subjects = "flat", []
elif shares[ranked[0].key] >= main_share:
code, subjects = "driver", [ranked[0]]
elif top_k is not None and 2 <= top_k <= k_max:
code, subjects = "concentrated", ranked[:top_k]
else:
code, subjects = "dispersed", []
offset = opposite > 0 and opposite >= offset_ratio * abs(delta)
if offset:
caveats.append(ph.caveat("offset", ph.offset_text(delta, gross_up, gross_down)))
nulls = [x for x in segs if "为空" in x.flags]
if nulls:
caveats.append(ph.caveat("null_values", ph.null_segments_text([ph.key_text(x.key) for x in nulls], label)))
if summed:
caveats.append(ph.caveat("headline_basis", ph.HEADLINE_BASIS))
if outcome is not None and outcome.verdict != "comparable":
caveats.append(ph.caveat("comparability", outcome.phrase))
top = ranked[0] if ranked else max(segs, key=lambda x: abs(deltas[x.key]))
total_text = ph.change(delta, B)
top_n = int(s["table_top_k"])
if code == "driver":
main = ph.decompose_driver(label, total_text, ph.key_text(top.key), ph.amount(deltas[top.key], signed=True),
shares[top.key])
elif code == "concentrated":
main = ph.decompose_concentrated(label, total_text, ph.names([ph.key_text(x.key) for x in subjects], top_n),
top_k_share)
elif code == "dispersed":
main = ph.decompose_dispersed(label, total_text, ph.dims_text(dims), main_share)
elif code == "flat":
ups = [x for x in segs if deltas[x.key] > 0]
downs = [x for x in segs if deltas[x.key] < 0]
pair = None
if offset and ups and downs:
up = max(ups, key=lambda x: deltas[x.key])
down = min(downs, key=lambda x: deltas[x.key])
pair = (ph.key_text(up.key), ph.amount(deltas[up.key], signed=True), ph.key_text(down.key),
ph.amount(deltas[down.key], signed=True))
main = ph.decompose_flat(label, delta / abs(B) if B != 0 else None, pair)
else:
gap = max(gaps.values(), key=abs)
main = ph.decompose_unclosed(ph.dims_text(dims), gap, ratio=False)
order = sorted(segs, key=lambda x: -abs(deltas[x.key]))
rows = []
for x in order:
d = deltas[x.key]
rows.append([*x.key, canon(x.base), canon(x.target), canon(d), canon(d / x.base) if x.base != 0 else None,
canon(shares[x.key]) if shares else None, "、".join(x.flags) or None])
table = {"columns": [*by, *_FIXED_ADDITIVE],
"labels": {**dict(zip(by, dims)), "base": "基期", "target": "本期", "delta": "变化", "pct": "变化率",
"share": "占变化的比重", "flag": "说明"},
"rows": rows}
values: dict[str, Any] = {
"base_total": canon(B), "target_total": canon(T), "delta_total": canon(delta),
"pct_total": canon(delta / abs(B)) if B != 0 else None, "top_key": ph.key_text(top.key),
"top_share": canon(shares[top.key]) if shares else None, "top_k": str(top_k) if top_k is not None else None,
"top_k_share": canon(top_k_share) if top_k_share is not None else None, "gross_up": canon(gross_up),
"gross_down": canon(gross_down), "segments": str(n),
}
if gaps:
values["gap"] = canon(max(gaps.values(), key=abs))
verdict = {"code": code, "subjects": [_key_strings(x.key) for x in subjects],
"direction": sign_word(delta) if code in ("driver", "concentrated", "dispersed") else None}
phrase = ph.with_caveats(main, caveats, skip=("offset",) if code == "flat" else ())
return build(env, params, used, table=table, values=values, verdict=verdict,
criterion=ph.criterion_additive(s, top_k_max=k_max, summed=summed), phrase=phrase, caveats=caveats)
# --------------------------------------------------------------------------
# ratio
# --------------------------------------------------------------------------
def ratio(raw: Mapping[str, Any], env: Env) -> Any:
used = ["main_share", "flat_pct", "min_denominator", "table_top_k"]
params, bs, ts, cols, by = _sides(raw, env, [("numerator", "分子列"), ("denominator", "分母列")])
num, den = cols["numerator"], cols["denominator"]
num_label, den_label = column_label(env, ts, num), column_label(env, ts, den)
label = ph.pick_label(raw.get("label"), ph.say(num_label, "与", den_label, "之比"))
if raw.get("label") is not None:
params["label"] = ph.clean_label(raw.get("label")) or None
dims = [column_label(env, ts, c) for c in by]
refs, heads = _headline(raw, env)
if refs:
params["headline"] = refs
parent_row = None
if raw.get("parent") is not None:
params["parent"], parent_row, params["depth"] = _parent(raw, env, "ratio", len(by))
used.append("max_drill_depth")
criterion = ph.criterion_ratio(env.settings)
for snap in dict.fromkeys([bs, ts]):
if snap.truncated:
env.inputs.use(snap, "data" if bs is ts else ("base" if snap is bs else "target"), [*by, num, den])
return refuse(env, params, used, "truncated", f"{snap.ref} 只取回了前 {len(snap.rows)} 行,各段合起来不是完整的数",
criterion=criterion)
rows_b, rows_t = _rows_of(raw, params, bs, ts)
period_label = column_label(env, ts, params["period"]) if "period" in params else None
def reader(snap: Snap) -> Any:
return lambda r: (snap.number(r, num, "分子列"), snap.number(r, den, "分母列"))
side_b = _collect(bs, rows_b, by, reader(bs), dims, period_label)
side_t = _collect(ts, rows_t, by, reader(ts), dims, period_label)
for side in (side_b, side_t):
side.nulls = [k for k, v in side.values.items() if v[0] is None or v[1] is None]
side.values = {k: (v[0] or Fraction(0), v[1] or Fraction(0)) for k, v in side.values.items()}
if bs is ts:
env.inputs.use(ts, "data", [*by, params["period"], num, den], side_b.rows + side_t.rows)
else:
env.inputs.use(bs, "base", [*by, num, den], side_b.rows)
env.inputs.use(ts, "target", [*by, num, den], side_t.rows)
outcome, structure = _comparability(env, params, bs, ts, den)
if structure is not None:
return refuse(env, params, used, "incomparable_sides", f"{structure},不是同一个量的两期,不拆", criterion=criterion)
segs = _segments(side_b, side_t, (Fraction(0), Fraction(0)))
live = [x for x in segs if x.base[1] != 0 or x.target[1] != 0]
if len(live) < 2:
return refuse(env, params, used, "too_few_segments", f"按{ph.dims_text(dims)}只有 {len(live)} 段,至少要 2 段才拆得开",
criterion=criterion)
for when, i in (("基期", 0), ("本期", 1)):
if sum(((x.base, x.target)[i][1] for x in live), Fraction(0)) == 0:
return refuse(env, params, used, "zero_base", ph.say(f"{when}的", den_label, "合计为 0,算不了比率"),
criterion=criterion)
for x in live:
for (n_, d_), when in ((x.base, "基期"), (x.target, "本期")):
if d_ == 0 and n_ != 0:
return refuse(env, params, used, "zero_base",
ph.say(ph.key_text(x.key), f"{when}的", den_label, "为 0 而", num_label,
"不为 0,比率拆不了"), criterion=criterion)
return _ratio_result(env, params, used, segs, by, dims, label, num_label, den_label, heads, parent_row, outcome,
criterion)
def ratio_effects(segs: Sequence[tuple[Fraction, Fraction, Fraction, Fraction]]
) -> tuple[Fraction, Fraction, list[tuple[Fraction, ...]]]:
"""比率拆解的计算(细案 §2.2,中点对称拆法)。segs 是各段 (分子_基期, 分母_基期, 分子_本期, 分母_本期),两期分母都为 0
的段先剔掉。交回 (R, R', 各段 (w, w', r, r', mix, rate)):Σmix + Σrate = R' − R 逐位成立(Σ(w' − w) = 0)。一边分母为 0
的段:那一边权重 0、比率取另一边的(自身效应为 0,变化全记在结构上)。"""
db = sum((s[1] for s in segs), Fraction(0))
dt = sum((s[3] for s in segs), Fraction(0))
R = sum((s[0] for s in segs), Fraction(0)) / db
R2 = sum((s[2] for s in segs), Fraction(0)) / dt
mid = (R + R2) / 2
out = []
for nb, d_b, nt, d_t in segs:
w, w2 = d_b / db, d_t / dt
r = nb / d_b if d_b != 0 else None
r2 = nt / d_t if d_t != 0 else None
r = r2 if r is None else r
r2 = r if r2 is None else r2
mix = (w2 - w) * ((r + r2) / 2 - mid) # type: ignore[operator]
rate = (r2 - r) * (w + w2) / 2 # type: ignore[operator]
out.append((w, w2, r, r2, mix, rate))
return R, R2, out
def _ratio_result(env: Env, params: dict[str, Any], used: list[str], segs: list[Segment], by: list[str],
dims: list[str], label: str, num_label: str, den_label: str,
heads: Mapping[str, tuple[Fraction, Any]], parent_row: Mapping[str, Any] | None,
outcome: cmp.Outcome | None, criterion: str) -> Any:
main_share, flat_pct = env.threshold("main_share"), env.threshold("flat_pct")
min_den = env.threshold("min_denominator")
live = [x for x in segs if x.base[1] != 0 or x.target[1] != 0]
dropped = [x for x in segs if x not in live]
R, R2, effects = ratio_effects([(x.base[0], x.base[1], x.target[0], x.target[1]) for x in live])
delta = R2 - R
by_key = {x.key: e for x, e in zip(live, effects)}
gaps: dict[str, Fraction] = {}
for side, total in (("base", R), ("target", R2)):
if side in heads and not closes(total, heads[side][0], heads[side][1]):
gaps[side] = heads[side][0] - total
if parent_row is not None:
sums = {"n_base": sum((x.base[0] for x in live), Fraction(0)), "d_base": sum((x.base[1] for x in live), Fraction(0)),
"n_target": sum((x.target[0] for x in live), Fraction(0)),
"d_target": sum((x.target[1] for x in live), Fraction(0))}
for col, total in sums.items():
expect = to_fraction(parent_row.get(col))
if expect is None or not closes(total, expect, parent_row.get(col)):
gaps.setdefault("base" if col.endswith("base") else "target", (expect or Fraction(0)) - total)
closed = not gaps
mix_total = sum((e[4] for e in effects), Fraction(0))
rate_total = sum((e[5] for e in effects), Fraction(0))
flat = delta == 0 or (R != 0 and abs(delta) / abs(R) < flat_pct)
mix_share = mix_total / delta if delta != 0 else None
rate_share = rate_total / delta if delta != 0 else None
sign = 1 if delta >= 0 else -1
if not closed:
code, subjects = "unclosed", []
elif flat:
code, subjects = "flat", []
elif mix_share >= main_share and rate_share < main_share: # type: ignore[operator]
code = "mix_driven"
subjects = [max(live, key=lambda x: sign * by_key[x.key][4])]
elif rate_share >= main_share and mix_share < main_share: # type: ignore[operator]
code = "rate_driven"
subjects = [max(live, key=lambda x: sign * by_key[x.key][5])]
else:
code, subjects = "both", []
proportion = all(0 <= v[0] <= v[1] for x in live for v in (x.base, x.target))
small = [x for x in live if x.base[1] < min_den or x.target[1] < min_den]
caveats: list[dict[str, str]] = []
eligible = [x for x in live if x not in small]
if delta != 0 and eligible:
weight = sum(((by_key[x.key][0] + by_key[x.key][1]) / 2 for x in eligible), Fraction(0))
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:
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])))
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"]))))
nulls = [x for x in segs if "为空" in x.flags]
if nulls:
caveats.append(ph.caveat("null_values", ph.null_segments_text([ph.key_text(x.key) for x in nulls],
ph.say(num_label, "或", den_label))))
if outcome is not None and outcome.verdict != "comparable":
caveats.append(ph.caveat("comparability", outcome.phrase))
total_text = ph.ratio_change(R, R2, proportion=proportion)
if code == "mix_driven":
star = subjects[0]
main = ph.ratio_mix(label, total_text, ph.key_text(star.key), by_key[star.key][0], by_key[star.key][1])
elif code == "rate_driven":
main = ph.ratio_rate(label, total_text, ph.key_text(subjects[0].key))
elif code == "both":
main = ph.ratio_both(label, total_text, mix_share, rate_share)
elif code == "flat":
main = ph.decompose_flat(label, delta / abs(R) if R != 0 else None, None)
else:
main = ph.decompose_unclosed(ph.dims_text(dims), max(gaps.values(), key=abs), ratio=True)
rows = []
for x in sorted(live, key=lambda x: -abs(by_key[x.key][4] + by_key[x.key][5])):
w, w2, r, r2, mix, rate = by_key[x.key]
flags = list(x.flags)
if x.base[1] == 0:
flags.append("基期分母为 0")
if x.target[1] == 0:
flags.append("本期分母为 0")
if x in small:
flags.append("分母小")
rows.append([*x.key, canon(x.base[0]), canon(x.target[0]), canon(x.base[1]), canon(x.target[1]), canon(w),
canon(w2), canon(r), canon(r2), canon(mix), canon(rate), canon(mix + rate),
"、".join(dict.fromkeys(flags)) or None])
for x in dropped:
rows.append([*x.key, canon(x.base[0]), canon(x.target[0]), "0", "0", None, None, None, None, None, None, None,
"两期分母都为 0,不计入"])
table = {"columns": [*by, *_FIXED_RATIO],
"labels": {**dict(zip(by, dims)), "n_base": ph.say("基期", num_label), "n_target": ph.say("本期", num_label),
"d_base": ph.say("基期", den_label), "d_target": ph.say("本期", den_label),
"w_base": "基期权重", "w_target": "本期权重", "r_base": "基期比率", "r_target": "本期比率",
"mix": "结构效应", "rate": "自身效应", "total": "合计影响", "flag": "说明"},
"rows": rows}
values: dict[str, Any] = {
"r_base_total": canon(R), "r_target_total": canon(R2), "delta": canon(delta), "mix_total": canon(mix_total),
"rate_total": canon(rate_total), "mix_share": canon(mix_share), "rate_share": canon(rate_share),
"segments": str(len(live)),
}
if gaps:
values["gap"] = canon(max(gaps.values(), key=abs))
verdict = {"code": code, "subjects": [_key_strings(x.key) for x in subjects],
"direction": sign_word(delta) if code in ("mix_driven", "rate_driven", "both") else None}
return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion,
phrase=ph.with_caveats(main, caveats), caveats=caveats)
# --------------------------------------------------------------------------
# factor
# --------------------------------------------------------------------------
def _half_ulp_ratio(raw: Any, value: Fraction) -> Fraction:
from app.analysis.common import written_places
return Fraction(1, 2) / Fraction(10) ** written_places(raw) / abs(value) if value != 0 else Fraction(0)
def lmdi(base: Sequence[float], target: Sequence[float], m_base: float, m_target: float) -> list[float]:
"""LMDI-I:contrib_k = L(M', M) · ln(F_k' / F_k),L(a, b) = (a − b) / (ln a − ln b),L(a, a) = a。因子按书写精度取过整、
乘积和乘积列差一点时,各因子按同一比例缩放到乘积恰好等于乘积列(每个因子的对数比各补上 (1/K)·差的对数),Σcontrib 在
浮点误差内等于 M' − M。"""
k = len(base)
fix = (math.log(m_target / math.prod(target)) - math.log(m_base / math.prod(base))) / k
weight = m_base if m_base == m_target else (m_target - m_base) / (math.log(m_target) - math.log(m_base))
return [weight * (math.log(t / b) + fix) for b, t in zip(base, target)]
def factor(raw: Mapping[str, Any], env: Env) -> Any:
used = ["main_share", "flat_pct"]
params, bs, ts, cols, _ = _sides(raw, env, [("product", "乘积列")], by_needed=False)
product = cols["product"]
wanted = as_list(raw.get("factors"))
if not 2 <= len(wanted) <= 4:
raise CallError("bad_factors", "因子列要写 2 到 4 列")
factors = [ts.column(c, "因子列") for c in wanted]
if bs is not ts:
for c in factors:
bs.column(c, "因子列")
if len(set(factors)) != len(factors) or product in factors:
raise CallError("bad_factors", "因子列不能重复,也不能是乘积列本身")
params["factors"] = factors
label = label_for(env, raw.get("label"), ts, product)
if raw.get("label") is not None:
params["label"] = ph.clean_label(raw.get("label")) or None
names = [column_label(env, ts, c) for c in factors]
criterion = ph.criterion_factor(env.settings)
for snap in dict.fromkeys([bs, ts]):
if snap.truncated:
env.inputs.use(snap, "data" if bs is ts else ("base" if snap is bs else "target"), [product, *factors])
return refuse(env, params, used, "truncated", f"{snap.ref} 只取回了前 {len(snap.rows)} 行,结果不完整",
criterion=criterion)
if "period" in params:
rows_b = _period_rows(bs, params["period"], params["base"], "基期")
rows_t = _period_rows(ts, params["period"], params["target"], "本期")
else:
rows_b, rows_t = list(range(len(bs.rows))), list(range(len(ts.rows)))
for rows, snap, when in ((rows_b, bs, "基期"), (rows_t, ts, "本期")):
if len(rows) != 1:
raise CallError("duplicate_rows", f"{snap.ref} 里{when}有 {len(rows)} 行:乘积拆解每期要正好一行(在 SQL 里汇总成一行)")
cells: dict[str, list[tuple[Fraction, Any]]] = {}
for when, snap, r in (("base", bs, rows_b[0]), ("target", ts, rows_t[0])):
row = []
for col, what in ((product, "乘积列"), *((c, "因子列") for c in factors)):
value = snap.number(r, col, what)
if value is None:
raise CallError("empty_cell", f"{snap.ref} 第 {r} 行的「{col}」是空的:乘积、因子都要有值")
row.append((value, snap.cell(r, col)))
cells[when] = row
if bs is ts:
env.inputs.use(ts, "data", [params["period"], product, *factors], rows_b + rows_t)
else:
env.inputs.use(bs, "base", [product, *factors], rows_b)
env.inputs.use(ts, "target", [product, *factors], rows_t)
outcome, structure = _comparability(env, params, bs, ts, product)
if structure is not None:
return refuse(env, params, used, "incomparable_sides", f"{structure},不是同一个量的两期,不拆", criterion=criterion)
if any(v <= 0 for row in cells.values() for v, _ in row):
return refuse(env, params, used, "non_positive", "乘积或因子里有不大于 0 的值,对数拆解要求全为正", criterion=criterion)
for when, row in cells.items():
m = row[0][0]
prod = math.prod((v for v, _ in row[1:]), start=Fraction(1))
rel = abs(m - prod) / abs(m)
allowed = max(_FACTOR_REL, sum((_half_ulp_ratio(raw_v, v) for v, raw_v in row[1:]), Fraction(0)))
if rel > allowed:
raise CallError("not_product", ph.say("这几列相乘不等于", column_label(env, ts, product),
f"({'基期' if when == 'base' else '本期'}差 {ph.pct(rel, signed=False)}),"
"不是乘积关系:换成乘起来正好等于它的几列"))
M, M2 = cells["base"][0][0], cells["target"][0][0]
fb = [float(v) for v, _ in cells["base"][1:]]
ft = [float(v) for v, _ in cells["target"][1:]]
contrib = lmdi(fb, ft, float(M), float(M2))
delta = M2 - M
flat = delta == 0 or abs(delta) / abs(M) < env.threshold("flat_pct")
shares = [c / float(delta) for c in contrib] if delta != 0 else [None] * len(contrib)
main_share = float(env.threshold("main_share"))
order = sorted(range(len(factors)), key=lambda i: -abs(contrib[i]))
caveats: list[dict[str, str]] = []
if outcome is not None and outcome.verdict != "comparable":
caveats.append(ph.caveat("comparability", outcome.phrase))
total_text = ph.change(delta, M)
top = max(range(len(factors)), key=lambda i: shares[i] if shares[i] is not None else -math.inf)
if flat:
code, subjects = "flat", []
main = ph.decompose_flat(label, delta / abs(M), None)
elif shares[top] >= main_share: # type: ignore[operator]
code, subjects = "driver", [[names[top]]]
main = ph.factor_driver(label, total_text, names[top], contrib[top], shares[top])
else:
code, subjects = "both", []
main = ph.factor_both(label, total_text, [(names[i], shares[i]) for i in order])
rows = []
for i in order:
b, t = cells["base"][i + 1][0], cells["target"][i + 1][0]
rows.append([names[i], canon(b), canon(t), canon((t - b) / b), canon(contrib[i]),
canon(shares[i]) if shares[i] is not None else None])
table = {"columns": ["factor", "base", "target", "pct", "contrib", "share"],
"labels": {"factor": "因子", "base": "基期", "target": "本期", "pct": "变化率", "contrib": "贡献",
"share": "占变化的比重"},
"rows": rows}
values = {"base_total": canon(M), "target_total": canon(M2), "delta_total": canon(delta),
"pct_total": canon(delta / abs(M)), "top_key": names[top],
"top_share": canon(shares[top]) if shares[top] is not None else None}
verdict = {"code": code, "subjects": subjects, "direction": sign_word(delta) if code != "flat" else None}
return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion,
phrase=ph.with_caveats(main, caveats), caveats=caveats)
__all__ = ["HEADLINE_SHAPE", "PARENT_SHAPE", "SAME_QUERY", "SIMPSON_SHARE", "additive", "factor", "lmdi", "ratio", "ratio_effects"]
+372
View File
@@ -0,0 +1,372 @@
"""差异检验(细案 01 §5):这两组(或两期)真的不一样,还是样本波动。
**参数**:query(一份快照);按组比时 by(分组列,一列);按期比同一组时 period、base、target(各一行,SQL 里先筛好那一组)。
比哪几对:pairs([[A, B], …],A、B 是分组列里的值)、reference(对照组:其余各组各和它比);都不写时两组就比这两组,三组
以上每组和「其余各组合计」比(和含它自己的整体比不独立)。可选 label。
- rate:numerator(成功数,如转化单数)、denominator(试验数,如访客数),每组一行,都要是不小于 0 的整数、成功数不超过
试验数。每组 Wilson 区间,两组之差 Newcombe 区间;最小期望频数不小于 min_expected 用合并比例的 z 检验,否则用 Fisher
精确检验。
- mean:每组一行,mean、sd、n 三列(均值、标准差、样本数),或者 sum、sumsq、n 三列(合计、平方和、样本数:SQLite 没有标准差
函数,SUM(x)、SUM(x*x)、COUNT(*) 都写得出)。Welch t 检验,自由度按 Welch–Satterthwaite。
- rows:value(逐行的值)+ 分组列(或期次列),快照不超过 1000 行、没截断;算子自己算均值、方差,另做敏感性:去掉离均值
最远的那一个值后重算,判定变了加 outlier_sensitive。
比了几对时按 Holm 校正,表里同时给原始 p 和校正后的 p,附 multiple_comparisons。校正后 p < differ_alpha → differs(direction
按 A − B 的正负);否则 no_evidence。用了 Fisher(比率)或有一组样本数小于 min_n(均值)→ small_sample。判定语不出现「p 值」
「显著性」:differs 说「差异站得住」,no_evidence 说「差异可能只是样本波动」,p 值、区间在表和证据面板里。
subjects 是判定说的那一对 [[A], [B]](只比一对时就是那一对,比了几对时是校正后最站得住的那一对;几对都不站得住时为空),
direction 是 A 相对 B。
"""
from __future__ import annotations
import math
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from fractions import Fraction
from typing import Any
from app.analysis import phrases as ph
from app.analysis import stats
from app.analysis.common import Env, build, column_label, label_for, need, refuse, sign_word
from app.analysis.contract import CallError
from app.analysis.inputs import Snap, as_list, hashable, same_value
from app.analysis.numbers import canon, to_fraction
TESTS = {"z": "z 检验", "fisher": "Fisher 精确检验", "welch": "Welch t 检验"}
@dataclass(eq=False)
class Group:
key: Any
name: str
rows: list[int] = field(default_factory=list)
x: int = 0 # rate:成功数
n: int = 0 # rate:试验数;mean / rows:样本数
mean: Fraction | None = None
var: Fraction | None = None
values: list[Fraction] = field(default_factory=list)
@dataclass
class Pair:
a: Group
b: Group
test: str | None = None
p: float | None = None
p_adj: float | None = None
diff: Fraction | None = None
ci: tuple[float, float] | None = None
a_value: Fraction | None = None
b_value: Fraction | None = None
def _int(value: Fraction | None, snap: Snap, r: int, column: str) -> int:
if value is None or value.denominator != 1 or value < 0:
raise CallError("not_count", f"{snap.ref} 第 {r} 行的「{column}」不是不小于 0 的整数:比率比较的分子、分母要是计数")
return int(value)
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])
if mode == "rate":
out.x, out.n = sum(g.x for g in groups), sum(g.n for g in groups)
return out
if mode == "rows":
out.values = [v for g in groups for v in g.values]
return _moments(out)
out.n = sum(g.n for g in groups)
if out.n == 0:
return out
out.mean = sum((g.mean * g.n for g in groups if g.mean is not None), Fraction(0)) / out.n
if out.n > 1:
within = sum(((g.n - 1) * g.var for g in groups if g.var is not None and g.n > 1), Fraction(0))
between = sum((g.n * (g.mean - out.mean) ** 2 for g in groups if g.mean is not None), Fraction(0))
out.var = (within + between) / (out.n - 1)
return out
def _moments(group: Group) -> Group:
group.n = len(group.values)
if group.n:
group.mean = sum(group.values, Fraction(0)) / group.n
if group.n > 1:
group.var = sum(((v - group.mean) ** 2 for v in group.values), Fraction(0)) / (group.n - 1) # type: ignore[operator]
return group
def _test(pair: Pair, mode: str, min_expected: Fraction) -> None:
a, b = pair.a, pair.b
if mode == "rate":
if a.n == 0 or b.n == 0:
return
pair.a_value, pair.b_value = Fraction(a.x, a.n), Fraction(b.x, b.n)
pair.diff = pair.a_value - pair.b_value
pair.ci = stats.newcombe(a.x, a.n, b.x, b.n)
if stats.min_expected(a.x, a.n, b.x, b.n) >= min_expected:
pair.test = "z"
pair.p = stats.pooled_z(a.x, a.n, b.x, b.n)[1] # type: ignore[index]
else:
pair.test = "fisher"
pair.p = stats.fisher_exact(a.x, a.n - a.x, b.x, b.n - b.x)
return
if a.mean is None or b.mean is None:
return
pair.a_value, pair.b_value = a.mean, b.mean
pair.diff = a.mean - b.mean
if a.var is None or b.var is None:
return
test = stats.welch(float(a.mean), float(a.var), a.n, float(b.mean), float(b.var), b.n)
if test is None:
return
pair.test, pair.p = "welch", test.p
pair.ci = stats.welch_interval(float(a.mean), float(a.var), a.n, float(b.mean), float(b.var), b.n)
def _verdict(pairs: Sequence[Pair], alpha: Fraction) -> list[Pair]:
"""Holm 校正后站得住的那几对(按校正后的 p 从小到大,一样的按先后)。"""
adjusted = stats.holm([p.p for p in pairs])
for pair, adj in zip(pairs, adjusted):
pair.p_adj = adj
hits = [p for p in pairs if p.p_adj is not None and to_fraction(p.p_adj) < alpha] # type: ignore[operator]
return sorted(hits, key=lambda p: p.p_adj) # type: ignore[arg-type, return-value]
def differ(raw: Mapping[str, Any], env: Env) -> Any:
mode = env.mode
used = ["differ_alpha", "min_expected"] if mode == "rate" else ["differ_alpha", "min_n"]
snap = env.inputs.snap(need(raw, "query", "查询编号"), "查询编号")
params: dict[str, Any] = {"query": snap.ref}
by_period = raw.get("period") is not None
if by_period:
group_col = snap.column(raw.get("period"), "期次列")
params.update(period=group_col, base=need(raw, "base", "基期(期次列里的值)"),
target=need(raw, "target", "本期(期次列里的值)"))
else:
wanted = as_list(raw.get("by"))
if len(wanted) != 1:
raise CallError("bad_by", "差异检验按一列分组:分组列写一列;比同一组的两期就写期次列、基期、本期")
group_col = snap.column(wanted[0], "分组列")
params["by"] = [group_col]
if mode == "rate":
cols = {"numerator": snap.column(need(raw, "numerator", "成功数列"), "成功数列"),
"denominator": snap.column(need(raw, "denominator", "试验数列"), "试验数列")}
elif mode == "mean":
if raw.get("mean") is not None:
cols = {"mean": snap.column(raw.get("mean"), "均值列"), "sd": snap.column(need(raw, "sd", "标准差列"), "标准差列"),
"n": snap.column(need(raw, "n", "样本数列"), "样本数列")}
else:
cols = {"sum": snap.column(need(raw, "sum", "合计列(或均值列)"), "合计列"),
"sumsq": snap.column(need(raw, "sumsq", "平方和列"), "平方和列"),
"n": snap.column(need(raw, "n", "样本数列"), "样本数列")}
else:
cols = {"value": snap.column(need(raw, "value", "值列"), "值列")}
params.update(cols)
measure_col = cols.get("numerator") or cols.get("mean") or cols.get("sum") or cols.get("value")
if mode == "rate":
label = ph.pick_label(raw.get("label"), ph.say(column_label(env, snap, cols["numerator"]), "占",
column_label(env, snap, cols["denominator"]), "的比率"))
else:
label = label_for(env, raw.get("label"), snap, measure_col)
if raw.get("label") is not None:
params["label"] = ph.clean_label(raw.get("label")) or None
dim_label = column_label(env, snap, group_col)
criterion = ph.criterion_differ(env.settings, mode) # type: ignore[arg-type]
if snap.truncated:
env.inputs.use(snap, "data", [group_col, *cols.values()])
return refuse(env, params, used, "truncated", f"{snap.ref} 只取回了前 {len(snap.rows)} 行,各组不完整",
criterion=criterion)
groups = _groups(snap, group_col, cols, mode, by_period, params) # type: ignore[arg-type]
pairs = _pairs(raw, params, groups, mode, dim_label, by_period, group_col) # type: ignore[arg-type]
env.inputs.use(snap, "data", [group_col, *cols.values()],
sorted({r for p in pairs for g in (p.a, p.b) for r in g.rows}))
return _decide(env, params, used, pairs, mode, label, criterion) # type: ignore[arg-type]
def _groups(snap: Snap, group_col: str, cols: Mapping[str, str], mode: str, by_period: bool,
params: Mapping[str, Any]) -> dict[Any, Group]:
gi = snap.index(group_col)
groups: dict[Any, Group] = {}
wanted = [params["target"], params["base"]] if by_period else None
for r, row in enumerate(snap.rows):
value = row[gi] if gi < len(row) else None
if wanted is not None:
if not any(same_value(w, value) for w in wanted):
continue
key = hashable(value)
group = groups.setdefault(key, Group(key, ph.key_text([value])))
if mode != "rows" and group.rows:
raise CallError("duplicate_rows", f"{snap.ref} 里「{ph.key_text([value])}」有不止一行:在 SQL 里按 {group_col} "
"汇总,每组一行")
group.rows.append(r)
if mode == "rate":
group.x = _int(snap.number(r, cols["numerator"], "成功数列"), snap, r, cols["numerator"])
group.n = _int(snap.number(r, cols["denominator"], "试验数列"), snap, r, cols["denominator"])
if group.x > group.n:
raise CallError("not_count", f"{snap.ref} 第 {r} 行的成功数大于试验数:分子、分母要是同一批里的「成功几个、"
"一共几个」")
elif mode == "mean":
n = _int(snap.number(r, cols["n"], "样本数列"), snap, r, cols["n"])
group.n = n
if "mean" in cols:
group.mean = snap.number(r, cols["mean"], "均值列")
sd = snap.number(r, cols["sd"], "标准差列")
group.var = sd * sd if sd is not None and n > 1 else None
else:
total = snap.number(r, cols["sum"], "合计列")
sq = snap.number(r, cols["sumsq"], "平方和列")
if total is not None and n > 0:
group.mean = total / n
if sq is not None and n > 1:
group.var = max(Fraction(0), (sq - total * total / n) / (n - 1))
else:
v = snap.number(r, cols["value"], "值列")
if v is not None:
group.values.append(v)
if mode == "rows":
for g in groups.values():
_moments(g)
if by_period:
for w, what in ((params["target"], "本期"), (params["base"], "基期")):
if not any(same_value(w, g.key) for g in groups.values()):
raise CallError("period_not_found", f"期次列「{group_col}」里没有「{w}」:{what}要写 {snap.ref} 里有的期次值")
return groups
def _find(groups: Mapping[Any, Group], value: Any, column: str) -> Group:
found = next((g for g in groups.values() if same_value(value, g.key)), None)
if found is None:
shown = "、".join(g.name for g in list(groups.values())[:8])
raise CallError("unknown_group", f"「{value}」不是「{column}」里的值:要比的组写 {shown} 里的")
return found
def _pairs(raw: Mapping[str, Any], params: dict[str, Any], groups: dict[Any, Group], mode: str, dim_label: str,
by_period: bool, group_col: str) -> list[Pair]:
if by_period:
target = _find(groups, params["target"], group_col)
base = _find(groups, params["base"], group_col)
return [Pair(target, base)]
if raw.get("pairs") is not None:
spec = raw.get("pairs")
if not isinstance(spec, list) or not spec or not all(isinstance(p, (list, tuple)) and len(p) == 2 for p in spec):
raise CallError("bad_pairs", "要比的几对写成 [[A, B], …],A、B 是分组列里的值")
out = []
for a, b in spec:
ga, gb = _find(groups, a, group_col), _find(groups, b, group_col)
if ga is gb:
raise CallError("bad_pairs", f"「{ga.name}」不能和自己比")
out.append(Pair(ga, gb))
params["pairs"] = [[p.a.key, p.b.key] for p in out]
return out
if len(groups) < 2:
raise CallError("too_few_groups", f"「{group_col}」里只有 {len(groups)} 组,至少要两组才比得了")
if raw.get("reference") is not None:
ref = _find(groups, raw.get("reference"), group_col)
params["reference"] = ref.key
return [Pair(g, ref) for g in groups.values() if g is not ref]
items = list(groups.values())
if len(items) == 2:
return [Pair(items[0], items[1])]
rest_name = ph.say("其余各", dim_label)
return [Pair(g, _pool(rest_name, [o for o in items if o is not g], mode)) for g in items]
def _decide(env: Env, params: dict[str, Any], used: list[str], pairs: list[Pair], mode: str, label: str,
criterion: str) -> Any:
alpha = env.threshold("differ_alpha")
min_expected = env.threshold("min_expected") if mode == "rate" else Fraction(0)
for pair in pairs:
_test(pair, mode, min_expected)
hits = _verdict(pairs, alpha)
caveats: list[dict[str, str]] = []
if mode == "rows":
flipped = _sensitivity(pairs, mode, alpha, min_expected, bool(hits))
if flipped is not None:
caveats.append(ph.caveat("outlier_sensitive", ph.outlier_text(*flipped)))
hits = _verdict(pairs, alpha)
if mode == "rate" and any(p.test == "fisher" for p in pairs):
caveats.append(ph.caveat("small_sample", ph.small_sample_rate_text(int(env.settings["min_expected"]))))
if mode != "rate":
min_n = int(env.settings["min_n"])
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))))
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))
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
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))
subjects = [[head.a.name], [head.b.name]] if head is not None else []
direction = None
value_name = "比率" if mode == "rate" else "均值"
rows = []
for p in pairs:
flags = []
if p.p is None:
flags.append("样本不够,算不了")
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),
canon(p.ci[0]) if p.ci else None, canon(p.ci[1]) if p.ci else None, str(p.a.n), str(p.b.n),
TESTS.get(p.test or "") or None, canon(p.p), canon(p.p_adj), "、".join(flags) or None])
table = {"columns": ["a", "b", "a_value", "b_value", "diff", "ci_lo", "ci_hi", "a_n", "b_n", "test", "p", "p_adj",
"flag"],
"labels": {"a": "组", "b": "对照组", "a_value": f"组的{value_name}", "b_value": f"对照组的{value_name}",
"diff": "差", "ci_lo": "差的区间下限", "ci_hi": "差的区间上限", "a_n": "组的样本量",
"b_n": "对照组的样本量", "test": "检验", "p": "p 值", "p_adj": "校正后的 p 值", "flag": "说明"},
"rows": rows}
shown = head if head is not None else pairs[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 ())
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)]
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)))
for p in trimmed:
_test(p, mode, min_expected)
now = bool(_verdict(trimmed, alpha))
_verdict(pairs, alpha)
return (value > mean, now) if now != differs else None
__all__ = ["TESTS", "differ"]
+2
View File
@@ -198,6 +198,8 @@ class Inputs:
out["artifact"] = snap.artifact
if snap.dialect:
out["dialect"] = snap.dialect
if snap.masked:
out["masked"] = sorted(snap.masked) # 只记列名(复算按它认遮罩列,快照工件里不一定都有)
out.update({k: v for k, v in entry.items() if k != "ref"})
if out not in self.records:
self.records.append(out) # type: ignore[arg-type]
+304
View File
@@ -0,0 +1,304 @@
"""放回历史看(细案 01 §3、§4):波动基线(这个变化大不大、算不算异常)和趋势(是持续在变,还是一次波动)。
**参数**:query(一份快照:一条序列;要看某一段就在 SQL 里筛好)、period(期次列)、measure(度量列);可选 grain(day /
week / month / quarter,缺省按期次值推断,见 analysis/periods)、label。波动基线另有 target(本期,缺省最后一期)、basis
(change 缺省:和比较期比的变化率;level:值本身,给存量、比率用)、lag(和往前第几期比,缺省日数据 7、其余 1;同比写 52 /
12 / 4,历史要相应够长)。
**前提**(不满足就算不了,原因照实写):期次认得出(period_unreadable)、本期完整(同可比性检查的整期:没过完、数据没到、
SQL 截掉一部分 → partial_target;表的日期范围查不到时只按运行当天判,附一句核对不了)、本期和比较期之间不缺期(target_gap)、
比较期不为 0(zero_base)、历史够长(history_short);趋势要最后几期连着(中间缺期 series_gap)。度量格为空的那一期跳过。
**波动基线**:历史的中位数 m、中位绝对偏差 MAD,σ̂ = 1.4826·MAD,稳健 z = (x − m)/σ̂;常规波动范围 m ± k_warn·σ̂;全程
精确的有理数。σ̂ = 0(历史每期都一样)时本期一样判常规、不一样判明显超出。本期的值(不论 basis)是历史各期里最高 / 最低的
加 record_high / record_low:「历史新高」说的是值本身。为什么不用均值和标准差:促销周这类极端值会把它们拉宽。
**趋势**:最后 n 期(期次连着的那一段,trend_min_points ≤ n ≤ max_history + 1)的 Theil–Sen 斜率、Mann–Kendall 双侧 p,
相对斜率 = 斜率 / |序列中位数|。p < trend_alpha 时按斜率的正负判上升、下降。末尾连续同向的期数从最后一期往前数(变化为 0
断开)。已知局限:Mann–Kendall 假设各期独立,业务序列常有自相关,p 会偏小;所以缺省 n 至少 6、α 取 0.05,判定语只说
「呈上升趋势」,不说「显著上升」。日数据附一句星期效应。
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from fractions import Fraction
from typing import Any
from app.analysis import comparable as cmp
from app.analysis import periods as pd
from app.analysis import phrases as ph
from app.analysis import stats
from app.analysis.common import Env, build, column_label, label_for, need, refuse, sign_word, today_param
from app.analysis.contract import CallError
from app.analysis.inputs import Snap, same_value
from app.analysis.numbers import canon, to_fraction
#: 稳健标准差 = 1.4826 × MAD(正态时等于标准差)
MAD_SCALE = Fraction(14826, 10000)
#: lag 的上限(周数据同比是 52)
_LAG_MAX = 260
@dataclass
class Series:
snap: Snap
period: str
measure: str
grain: str
label: str
points: list[tuple[pd.Period, Fraction]] # 按期次排好、度量不为空的
nulls: int
params: dict[str, Any]
def _load(raw: Mapping[str, Any], env: Env, used: list[str]) -> Series | Any:
"""读出一条序列;算不了时直接交回 refused 的结论。"""
snap = env.inputs.snap(need(raw, "query", "查询编号"), "查询编号")
period = snap.column(need(raw, "period", "期次列"), "期次列")
measure = snap.column(need(raw, "measure", "度量列"), "度量列")
grain = raw.get("grain")
if grain is not None and grain not in (*pd.GRAINS, "seq"):
raise CallError("bad_grain", "粒度只能写 day、week、month、quarter,或者不写(按期次值推断)")
params: dict[str, Any] = {"query": snap.ref, "period": period, "measure": measure}
label = label_for(env, raw.get("label"), snap, measure)
if raw.get("label") is not None:
params["label"] = ph.clean_label(raw.get("label")) or None
criterion = _criterion(env, None, "期")
env.inputs.use(snap, "data", [period, measure])
if snap.truncated:
return refuse(env, params, used, "truncated", f"{snap.ref} 只取回了前 {len(snap.rows)} 行,序列不完整",
criterion=criterion)
i_period = snap.index(period)
values = [row[i_period] if i_period < len(row) else None for row in snap.rows]
parsed = pd.parse(values, grain)
if parsed is None:
sample = "、".join(dict.fromkeys(ph.key_text([v]) for v in values[:3]))
return refuse(env, params, used, "period_unreadable",
f"期次列「{period}」的值认不出是哪一期({sample}):要日期、YYYY-MM、YYYY-Www、YYYY-Qn 或整数序号",
criterion=criterion)
items, grain = parsed
params["grain"] = grain
seen: set[int] = set()
points: list[tuple[pd.Period, Fraction]] = []
nulls = 0
for r, item in enumerate(items):
if item.ordinal in seen:
raise CallError("duplicate_rows", ph.say(f"{snap.ref} 里同一期有不止一行:在 SQL 里按 ",
column_label(env, snap, period), " 汇总,每期一行"))
seen.add(item.ordinal)
value = snap.number(r, measure, "度量列")
if value is None:
nulls += 1
continue
points.append((item, value))
points.sort(key=lambda p: p[0].ordinal)
return Series(snap, period, measure, grain, label, points, nulls, params)
def _criterion(env: Env, n: int | None, unit: str) -> str:
if env.op == "baseline":
return ph.criterion_baseline(env.settings, n, unit)
return ph.criterion_trend(env.settings, n, unit)
def _completeness(env: Env, series: Series, item: pd.Period) -> tuple[str, str | None]:
"""本期齐不齐(同可比性检查的整期):(pass / short / unknown, 不齐的那一句)。"""
series.params["today"] = today_param(env)
if item.start is None:
return "unknown", None
span = cmp.period_span("本期", series.snap, series.period, item, series.grain, series.measure, env.facts, env.book)
return cmp.span_coverage(span, env.today, env.book) # type: ignore[arg-type]
def _shown_period(item: pd.Period) -> Any:
return item.raw
# --------------------------------------------------------------------------
# 波动基线
# --------------------------------------------------------------------------
def baseline(raw: Mapping[str, Any], env: Env) -> Any:
used = ["k_warn", "k_alert", "min_history", "max_history"]
loaded = _load(raw, env, used)
if not isinstance(loaded, Series):
return loaded
series, params = loaded, loaded.params
basis = raw.get("basis") or "change"
if basis not in ("change", "level"):
raise CallError("bad_basis", "比法只能写 change(和比较期比的变化率)或 level(值本身)")
lag = raw.get("lag", pd.DEFAULT_LAG[series.grain])
if isinstance(lag, bool) or not isinstance(lag, int) or not 1 <= lag <= _LAG_MAX:
raise CallError("bad_lag", f"往前比几期要写 1 到 {_LAG_MAX} 的整数(同比:周数据 52、月数据 12、季度 4)")
params.update(basis=basis, lag=lag)
unit, per = pd.COUNT_WORD[series.grain], pd.PER_WORD[series.grain]
criterion = _criterion(env, None, unit)
if not series.points:
return refuse(env, params, used, "history_short", f"只有 0 期历史,至少要 {int(env.settings['min_history'])} 期",
criterion=criterion)
if raw.get("target") is not None:
found = next((p for p in series.points if same_value(raw["target"], p[0].raw)), None)
if found is None:
raise CallError("period_not_found", f"期次列「{series.period}」里没有「{raw['target']}」(或那一期为空):"
"本期要写序列里有值的一期,不写就是最后一期")
else:
found = series.points[-1]
target, x_level = found
params["target"] = target.raw
caveats: list[dict[str, str]] = []
status, why = _completeness(env, series, target)
if status == "short":
return refuse(env, params, used, "partial_target", f"{why},和完整的{per}比没有意义", criterion=criterion)
if status == "unknown" and target.start is not None:
caveats.append(ph.caveat("comparability", ph.UNVERIFIED_COMPLETE))
by_ordinal = {p.ordinal: v for p, v in series.points}
before = [(p, v) for p, v in series.points if p.ordinal < target.ordinal]
if basis == "change":
prev = by_ordinal.get(target.ordinal - lag)
if prev is None:
return refuse(env, params, used, "target_gap",
f"本期和它的比较期(往前第 {lag} {'期' if series.grain == 'seq' else unit})之间缺期,没有比较期的值",
criterion=criterion)
if prev == 0:
return refuse(env, params, used, "zero_base", ph.say("比较期的", series.label, "为 0,算不了变化率"),
criterion=criterion)
x = (x_level - prev) / abs(prev)
history = [(p, (v - by_ordinal[p.ordinal - lag]) / abs(by_ordinal[p.ordinal - lag]), v) for p, v in before
if by_ordinal.get(p.ordinal - lag) not in (None, 0)]
else:
x = x_level
history = [(p, v, v) for p, v in before]
history = history[-int(env.settings["max_history"]):]
n = len(history)
criterion = _criterion(env, n, unit)
if n < int(env.settings["min_history"]):
return refuse(env, params, used, "history_short", f"只有 {n} 期历史,至少要 {int(env.settings['min_history'])} 期",
criterion=criterion)
hv = [h for _, h, _ in history]
m = stats.median(hv)
sigma = MAD_SCALE * stats.mad(hv, m)
k_warn, k_alert = env.threshold("k_warn"), env.threshold("k_alert")
if sigma == 0:
z = None
code = "within_band" if x == m else "far_outside_band"
else:
z = (x - m) / sigma
code = "within_band" if abs(z) < k_warn else "outside_band" if abs(z) < k_alert else "far_outside_band"
lo, hi = m - k_warn * sigma, m + k_warn * sigma
levels = [lv for _, _, lv in history]
if x_level > max(levels):
caveats.append(ph.caveat("record_high", ph.record_text(True, series.label, n, unit)))
elif x_level < min(levels):
caveats.append(ph.caveat("record_low", ph.record_text(False, series.label, n, unit)))
if series.nulls:
caveats.append(ph.caveat("null_values", ph.null_periods_text(series.nulls, series.label)))
current = ph.current_text(x, basis=basis, lag=lag, grain=series.grain)
if sigma == 0:
shown = ph.pct(m) if basis == "change" else ph.amount(m)
main = ph.baseline_flat_phrase(code, series.label, current, n, unit, per, shown)
else:
main = ph.baseline_phrase(code, series.label, current, n, unit, ph.band_text(lo, hi, basis=basis))
rows = [[p.raw, canon(lv), canon(h) if basis == "change" else None, "历史"] for p, h, lv in history]
rows.append([target.raw, canon(x_level), canon(x) if basis == "change" else None, "本期"])
table = {"columns": [series.period, "value", "change", "role"],
"labels": {series.period: column_label(env, series.snap, series.period), "value": series.label,
"change": "变化率", "role": "说明"},
"rows": rows}
values = {"x": canon(x), "value": canon(x_level), "median": canon(m), "band_lo": canon(lo), "band_hi": canon(hi),
"z": canon(z) if z is not None else None, "rank": str(stats.rank_desc(x, hv)), "n_history": str(n),
"hist_min": canon(min(hv)), "hist_max": canon(max(hv))}
verdict = {"code": code, "subjects": [], "direction": sign_word(x - m)}
return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion,
phrase=ph.with_caveats(main, caveats, skip=("comparability",)), caveats=caveats)
# --------------------------------------------------------------------------
# 趋势
# --------------------------------------------------------------------------
def run_length(values: list[Fraction]) -> tuple[int, str | None]:
"""末尾连续同向:从最后一期往前数,相邻变化同号的有几个(变化为 0 断开)。"""
changes = [b - a for a, b in zip(values, values[1:])]
if not changes or changes[-1] == 0:
return 0, None
sign = changes[-1] > 0
count = 0
for c in reversed(changes):
if c == 0 or (c > 0) != sign:
break
count += 1
return count, "up" if sign else "down"
def trend(raw: Mapping[str, Any], env: Env) -> Any:
used = ["trend_min_points", "trend_alpha", "max_history"]
loaded = _load(raw, env, used)
if not isinstance(loaded, Series):
return loaded
series, params = loaded, loaded.params
unit, per = pd.COUNT_WORD[series.grain], pd.PER_WORD[series.grain]
minimum = int(env.settings["trend_min_points"])
criterion = _criterion(env, None, unit)
if not series.points:
return refuse(env, params, used, "history_short", f"只有 0 期,至少要 {minimum} 期", criterion=criterion)
last = series.points[-1][0]
caveats: list[dict[str, str]] = []
status, why = _completeness(env, series, last)
if status == "short":
return refuse(env, params, used, "partial_target", f"{why},和完整的{per}比没有意义", criterion=criterion)
if status == "unknown" and last.start is not None:
caveats.append(ph.caveat("comparability", ph.UNVERIFIED_COMPLETE))
cap = int(env.settings["max_history"]) + 1
tail = [series.points[-1]]
for point in reversed(series.points[:-1]):
if len(tail) >= cap or point[0].ordinal != tail[-1][0].ordinal - 1:
break
tail.append(point)
tail.reverse()
n = len(tail)
criterion = _criterion(env, n, unit)
if n < minimum:
gap = len(series.points) > n and len(tail) < cap
if gap:
return refuse(env, params, used, "series_gap",
f"最后连着的只有 {n} 期,再往前缺期(至少要连着的 {minimum} 期)", criterion=criterion)
return refuse(env, params, used, "history_short", f"只有 {n} 期,至少要 {minimum} 期", criterion=criterion)
ys = [v for _, v in tail]
xs = [Fraction(i) for i in range(n)]
slope = stats.theil_sen(xs, ys)
med = stats.median(ys)
rel = slope / abs(med) if med != 0 else None
mk = stats.mann_kendall(ys)
alpha = env.threshold("trend_alpha")
p = mk.p if isinstance(mk.p, Fraction) else to_fraction(mk.p)
if p is not None and p < alpha and slope > 0:
code = "rising"
elif p is not None and p < alpha and slope < 0:
code = "falling"
else:
code = "no_trend"
run, direction = run_length(ys)
if series.grain == "day":
caveats.append(ph.caveat("weekday_effect", ph.WEEKDAY_EFFECT))
if series.nulls:
caveats.append(ph.caveat("null_values", ph.null_periods_text(series.nulls, series.label)))
slope_text = ph.pct(rel) if rel is not None else ph.amount(slope, signed=True)
main = ph.trend_phrase(code, series.label, n, unit, per, slope_text,
(run, direction) if run >= 3 and direction else None) # type: ignore[arg-type]
rows = [[p_.raw, canon(v), canon(v - tail[i - 1][1]) if i else None] for i, (p_, v) in enumerate(tail)]
table = {"columns": [series.period, "value", "change"],
"labels": {series.period: column_label(env, series.snap, series.period), "value": series.label,
"change": "比上一期"},
"rows": rows}
values = {"slope": canon(slope), "rel_slope": canon(rel) if rel is not None else None, "p": canon(mk.p),
"s": str(mk.s), "n": str(n), "run_length": str(run), "run_direction": direction}
verdict = {"code": code, "subjects": [], "direction": "up" if code == "rising" else "down" if code == "falling"
else None}
return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion,
phrase=ph.with_caveats(main, caveats, skip=("comparability",)), caveats=caveats)
__all__ = ["MAD_SCALE", "baseline", "run_length", "trend"]
+127
View File
@@ -0,0 +1,127 @@
"""分析算子测试用的样例:虚构零售库 shop(地区华东、华南、华北、西南,渠道线上、线下,销售额、订单数、退款),表名、列名、
数字都是编的。analyze 跑一次 analysis.run,顺手用 recompute 复算一遍、核对逐字段相同(contract.same),所以每个用例都同时
钉住「复算和运行逐字段相同」。"""
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from datetime import date
from typing import Any
from app import analysis
from app.analysis.contract import CallError, effective_settings, same
from app.core.artifact_store import canonical_json, content_hash
#: 测试里的「运行当天」
TODAY = date(2026, 10, 21)
def snap(ref: str, sql: str, columns: Sequence[str], rows: Sequence[Sequence[Any]], *,
types: Mapping[str, str] | None = None, source: str = "shop", truncated: bool = False,
masked: Sequence[str] = (), **extra: Any) -> dict[str, Any]:
"""一份输入快照(InputSnapshot 的形状)。artifact 按内容算,复算按它取回。"""
body = {"source": source, "sql": sql, "columns": list(columns), "rows": [list(r) for r in rows],
"truncated": truncated, **({"column_types": dict(types)} if types else {}), **extra}
return {"ref": ref, "artifact": content_hash(canonical_json(body)), "node_id": "q1", "masked": list(masked), **body}
def item(value: Any, status: str = "confirmed", source: str = "human") -> dict[str, Any]:
return {"value": value, "source": source, "status": status}
def notes(*, columns: Mapping[str, Mapping[str, Any]] | None = None, business_date: str | None = None) -> dict[str, Any]:
"""一张表的目录 notes:columns 是 {列: {label / measure: 值或 item(...)}}。"""
out: dict[str, Any] = {}
if columns:
out["columns"] = {c: {k: v if isinstance(v, Mapping) else item(v) for k, v in spec.items()}
for c, spec in columns.items()}
if business_date:
out["business_date"] = item({"column": business_date, "rule": "下单日", "timezone": "Asia/Shanghai"})
return out
#: orders 表的目录:地区、渠道、实收金额(流量)、退款单数
ORDERS = notes(columns={"region": {"label": "地区"}, "channel": {"label": "渠道"},
"amount": {"label": "销售额", "measure": "flow"},
"refund_flag": {"label": "退款单数", "measure": "flow"},
"order_date": {"label": "下单日"}},
business_date="order_date")
CATALOGS = {"shop": {"orders": ORDERS}}
async def analyze(op: str, mode: str | None = None, *, snaps: Sequence[Mapping[str, Any]], today: date = TODAY,
catalogs: Mapping[str, Any] | None = None, probe: Any = None, facts: Any = None,
parents: Mapping[str, Mapping[str, Any]] | None = None, settings: Mapping[str, Any] | None = None,
hypothesis: str | None = None, check: bool = True, **params: Any) -> dict[str, Any]:
"""跑一次 analysis.run;check 时再复算一遍,核对和封存的逐字段相同(hypothesis 除外)。"""
by_ref = {s["ref"]: s for s in snaps}
request: dict[str, Any] = {"op": op, "mode": mode, "params": params}
if hypothesis is not None:
request["hypothesis"] = hypothesis
context: dict[str, Any] = {"today": today, "catalogs": CATALOGS if catalogs is None else catalogs}
if probe is not None:
context["probe"] = probe
if facts is not None:
context["facts"] = facts
result = await analysis.run(request, by_ref.get, settings=effective_settings(settings), context=context,
parents=(parents or {}).get)
if check:
again = recomputed(result, snaps, parents)
assert same(result, again), _diff(result, again)
assert again["hypothesis"] == result["hypothesis"]
return dict(result)
def recomputed(result: Mapping[str, Any], snaps: Sequence[Mapping[str, Any]],
parents: Mapping[str, Mapping[str, Any]] | None = None) -> dict[str, Any]:
"""按结论里记下的复算:快照按 artifact 取回(只交快照内容本身,不交入口加的出处),上一层结论按内容哈希取回。"""
by_artifact = {s["artifact"]: {k: v for k, v in s.items() if k not in ("ref", "artifact", "node_id", "masked")}
for s in snaps}
by_hash = {content_hash(canonical_json(r)): r for r in (parents or {}).values()}
return dict(analysis.recompute(result, by_artifact.get, load_result=by_hash.get)) # type: ignore[arg-type]
def _diff(a: Mapping[str, Any], b: Mapping[str, Any]) -> str:
keys = sorted(k for k in set(a) | set(b) if a.get(k) != b.get(k))
return ";".join(f"{k}: {json.dumps(a.get(k), ensure_ascii=False)} ≠ {json.dumps(b.get(k), ensure_ascii=False)}"
for k in keys)
async def call_error(op: str, mode: str | None = None, **kwargs: Any) -> CallError:
"""跑一次,期望是调用错误。"""
try:
await analyze(op, mode, check=False, **kwargs)
except CallError as e:
return e
raise AssertionError("应当是调用错误")
def fixed_prober(ranges: Mapping[tuple[str, str], tuple[str | None, str | None]] | None = None, *,
bounds: Mapping[str, str] | None = None, at: str = "2026-10-21T09:00:00") -> Any:
"""测试用的探查器:ranges 是 {(表, 列): (最早, 最晚)},bounds 是 {界的 SQL: 日期};没写的查不到。"""
class Prober:
def __init__(self) -> None:
self.calls: list[tuple[str, ...]] = []
async def __call__(self, source: str, table: str, column: str) -> dict[str, Any] | None:
self.calls.append((source, table, column))
found = (ranges or {}).get((table, column))
if found is None:
return None
return {"source": source, "table": table, "column": column, "min": found[0], "max": found[1],
"basis": "live", "at": at}
async def bounds(self, source: str, expr: str) -> dict[str, Any] | None:
self.calls.append((source, expr))
value = (bounds or {}).get(expr)
return None if value is None else {"source": source, "expr": expr, "value": value, "basis": "live", "at": at}
def dialect(self, source: str) -> str | None:
return "sqlite"
return Prober()
__all__ = ["CATALOGS", "ORDERS", "TODAY", "analyze", "call_error", "fixed_prober", "item", "notes", "recomputed",
"snap"]
+182
View File
@@ -0,0 +1,182 @@
"""可比性检查(app/analysis/comparable.py,细案 01 §6):只差日期 / 差了别的筛选 / 结构不同、窗口不等(流量不可比、比率只提醒)、
本期不完整、周末天数不同、探查失败退回剖析事实、都没有就核对不了。每个用例都顺带核对复算和运行逐字段相同(复算用记下的
probes,不现查)。"""
from __future__ import annotations
from analysis_samples import analyze, fixed_prober, snap
from app.analysis import comparable_reasons
WINDOW = "SELECT SUM(amount) AS gmv FROM orders WHERE order_date >= '{a}' AND order_date < '{b}'"
RANGE = {("orders", "order_date"): ("2025-01-01", "2026-10-20")}
def period(ref, a, b, sql=WINDOW, **kw):
return snap(ref, sql.format(a=a, b=b), ["gmv"], [[100]], types={"gmv": "number"}, **kw)
async def compare(base, target, *, probe=None, **params):
params = {"base_query": base["ref"], "target_query": target["ref"], **params}
return await analyze("comparable", snaps=[base, target], probe=probe, **params)
def checks(result):
return {r[0]: r[1] for r in result["table"]["rows"]}
async def test_two_weeks_that_differ_only_in_dates_are_comparable():
result = await compare(period("Q1", "2026-10-05", "2026-10-12"), period("Q2", "2026-10-12", "2026-10-19"),
probe=fixed_prober(RANGE), measure="gmv")
assert result["method"] == "comparable@1" and result["mode"] is None
assert result["verdict"] == {"code": "comparable", "subjects": [], "direction": None}
assert result["phrase"] == "两期可比"
assert checks(result) == {"同口径": "通过", "窗口长度": "通过", "整期": "通过", "星期构成": "通过", "数据问题": "通过"}
assert result["values"]["base_days"] == "7" and result["values"]["target_start"] == "2026-10-12"
assert result["values"]["window"] == "pass" and result["settings"] == {}
assert result["params"]["today"] == "2026-10-21" and result["params"]["measure"] == "gmv"
assert [p["column"] for p in result["probes"]] == ["order_date"]
assert result["table"]["columns"] == ["check", "status", "base", "target", "note"]
async def test_other_literals_differing_mean_two_segments_not_two_periods():
sql = WINDOW + " AND region = '{r}'"
base = snap("Q1", sql.format(a="2026-10-05", b="2026-10-12", r="华南"), ["gmv"], [[100]])
target = snap("Q2", sql.format(a="2026-10-12", b="2026-10-19", r="华东"), ["gmv"], [[100]])
result = await compare(base, target, probe=fixed_prober(RANGE), measure="gmv")
assert result["verdict"]["code"] == "incomparable"
assert result["phrase"] == "两期不可比:两边除了日期,筛选的还不一样"
assert comparable_reasons(result) == ["两边除了日期,筛选的还不一样"]
async def test_a_different_structure_says_where_it_differs():
base = period("Q1", "2026-10-05", "2026-10-12")
target = period("Q2", "2026-10-12", "2026-10-19", sql=WINDOW + " AND status = 'paid'")
result = await compare(base, target, probe=fixed_prober(RANGE), measure="gmv")
assert result["verdict"]["code"] == "incomparable"
assert result["phrase"] == "两期不可比:两份查询的口径结构不同(差在筛选条件)"
other = await compare(base, period("Q2", "2026-10-12", "2026-10-19", source="crm"), measure="gmv")
assert "两份查询来自不同的数据源" in other["phrase"]
async def test_unequal_windows_fail_for_flows_and_only_warn_otherwise():
base, target = period("Q1", "2026-10-05", "2026-10-12"), period("Q2", "2026-10-12", "2026-10-15")
flow = await compare(base, target, probe=fixed_prober(RANGE), measure="gmv") # SUM(amount):目录标的流量
assert flow["verdict"]["code"] == "incomparable" and checks(flow)["窗口长度"] == "不通过"
assert "两段的天数不同:7 天对 3 天" in flow["phrase"]
avg_sql = "SELECT AVG(amount) AS aov FROM orders WHERE order_date >= '{a}' AND order_date < '{b}'"
ratio = await compare(period("Q1", "2026-10-05", "2026-10-12", sql=avg_sql),
period("Q2", "2026-10-12", "2026-10-15", sql=avg_sql), probe=fixed_prober(RANGE),
measure="gmv")
assert ratio["verdict"]["code"] == "conditional" and checks(ratio)["窗口长度"] == "提醒"
unlabeled = await compare(base, target, probe=fixed_prober(RANGE), measure="gmv", catalogs={})
assert unlabeled["verdict"]["code"] == "conditional"
assert any(c["code"] == "measure_unlabeled" for c in unlabeled["caveats"])
async def test_whole_months_count_as_the_same_length():
result = await compare(period("Q1", "2026-08-01", "2026-09-01"), period("Q2", "2026-09-01", "2026-10-01"),
probe=fixed_prober(RANGE), measure="gmv")
assert result["verdict"]["code"] == "comparable" and result["values"]["base_days"] == "31"
async def test_an_unfinished_period_is_not_a_whole_period():
result = await compare(period("Q1", "2026-10-12", "2026-10-19"), period("Q2", "2026-10-19", "2026-10-26"),
probe=fixed_prober(RANGE), measure="gmv")
assert result["verdict"]["code"] == "incomparable"
assert result["phrase"] == "两期不可比:本期数据只到 2026-10-20,还差 5 天"
late = fixed_prober({("orders", "order_date"): ("2025-01-01", "2026-10-16")})
behind = await compare(period("Q1", "2026-10-05", "2026-10-12"), period("Q2", "2026-10-12", "2026-10-19"),
probe=late, measure="gmv")
assert behind["phrase"] == "两期不可比:本期数据只到 2026-10-16,还差 2 天"
async def test_weekend_composition_of_short_windows():
# 周一到周三 对 周五到周日
result = await compare(period("Q1", "2026-10-05", "2026-10-08"), period("Q2", "2026-10-16", "2026-10-19"),
probe=fixed_prober(RANGE), measure="gmv")
assert result["verdict"]["code"] == "conditional"
assert result["phrase"] == "两期有条件可比:两段的周末天数不同:0 天对 2 天"
async def test_a_failed_probe_falls_back_to_profile_facts_then_says_it_cannot_check():
facts = {"shop": {"orders": {"at": "2026-10-20T03:00:00", "date_ranges": [
{"column": "order_date", "min": "2025-01-01", "max": "2026-10-19"}]}}}
base, target = period("Q1", "2026-10-05", "2026-10-12"), period("Q2", "2026-10-12", "2026-10-19")
fallback = await compare(base, target, probe=fixed_prober({}), facts=facts, measure="gmv")
assert fallback["verdict"]["code"] == "comparable" and fallback["phrase"] == "两期可比"
assert fallback["probes"] == [{"source": "shop", "table": "orders", "column": "order_date", "min": "2025-01-01",
"max": "2026-10-19", "basis": "profile", "at": "2026-10-20"}]
nothing = await compare(base, target, measure="gmv")
assert nothing["verdict"]["code"] == "comparable"
assert nothing["phrase"] == "两期可比,整期核对不了" and nothing["probes"] == []
async def test_bounds_computed_from_the_run_day_are_evaluated_in_the_database_and_recorded():
sql = "SELECT SUM(amount) AS gmv FROM orders WHERE order_date >= date('now', '{a}') AND order_date < date('now', '{b}')"
base = snap("Q1", sql.format(a="-16 days", b="-9 days"), ["gmv"], [[100]])
target = snap("Q2", sql.format(a="-9 days", b="-2 days"), ["gmv"], [[90]])
bounds = {"DATE('now', '-16 days')": "2026-10-05", "DATE('now', '-9 days')": "2026-10-12",
"DATE('now', '-2 days')": "2026-10-19"}
probe = fixed_prober(RANGE, bounds=bounds)
result = await compare(base, target, probe=probe, measure="gmv")
assert result["verdict"]["code"] == "comparable"
assert checks(result)["同口径"] == "通过" # 只差按运行当天现算的界
assert result["values"]["base_start"] == "2026-10-05" and result["values"]["target_end"] == "2026-10-18"
exprs = [p["expr"] for p in result["probes"] if "expr" in p]
assert exprs == ["DATE('now', '-16 days')", "DATE('now', '-9 days')", "DATE('now', '-2 days')"]
assert all(p["basis"] == "live" for p in result["probes"])
# 界算不出来:窗口核对不了,不判不可比
blind = await compare(base, target, probe=fixed_prober(RANGE), measure="gmv")
assert blind["verdict"]["code"] == "comparable" and "窗口长度" in blind["phrase"]
async def test_data_problems_on_the_snapshots():
base = period("Q1", "2026-10-05", "2026-10-12")
empty = period("Q2", "2026-10-12", "2026-10-19", no_data=[{"columns": ["gmv"], "table": "orders",
"column": "order_date", "message": "没有数据"}])
result = await compare(base, empty, probe=fixed_prober(RANGE), measure="gmv")
assert result["verdict"]["code"] == "incomparable" and "本期查询的区间里这张表没有数据" in result["phrase"]
checked = period("Q2", "2026-10-12", "2026-10-19", checks=[{"code": "fanout_sum", "level": "error",
"message": "关联订单明细后对订单金额求和,会重复计算"}])
warned = await compare(base, checked, probe=fixed_prober(RANGE), measure="gmv")
assert warned["verdict"]["code"] == "conditional"
assert "本期查询的 SQL 检查有错误:关联订单明细后对订单金额求和,会重复计算" in warned["phrase"]
async def test_one_snapshot_grouped_by_period_is_naturally_the_same_query():
sql = "SELECT order_date AS day, SUM(amount) AS gmv FROM orders WHERE order_date >= '2026-10-01' GROUP BY 1"
rows = [[f"2026-10-{d:02d}", 100] for d in range(1, 20)]
s = snap("Q1", sql, ["day", "gmv"], rows, types={"day": "text", "gmv": "number"})
probe = fixed_prober(RANGE)
same_weekday = await analyze("comparable", snaps=[s], probe=probe, query="Q1", period="day", base="2026-10-05",
target="2026-10-12")
assert same_weekday["verdict"]["code"] == "comparable" and same_weekday["params"]["measure"] == "gmv"
assert same_weekday["params"]["grain"] == "day"
weekend = await analyze("comparable", snaps=[s], probe=probe, query="Q1", period="day", base="2026-10-09",
target="2026-10-10")
assert weekend["phrase"] == "两期有条件可比:两段的周末天数不同:0 天对 1 天"
# 期次有一截落在 SQL 的时间条件之外:基期被截掉了;本期(10 月)还没过完
monthly_rows = [["2026-09-01", 100], ["2026-10-01", 90]]
month = snap("Q1", "SELECT DATE(order_date, 'start of month') AS month, SUM(amount) AS gmv FROM orders "
"WHERE order_date >= '2026-09-05' GROUP BY 1", ["month", "gmv"], monthly_rows)
partial = await analyze("comparable", snaps=[month], probe=probe, query="Q1", period="month", base="2026-09-01",
target="2026-10-01", measure="gmv")
assert partial["verdict"]["code"] == "incomparable"
assert "基期数据从 2026-09-05 才开始,缺前 4 天" in partial["phrase"]
assert "本期数据只到 2026-10-20,还差 11 天" in partial["phrase"]
async def test_reasons_are_split_one_sentence_each():
result = await compare(period("Q1", "2026-10-05", "2026-10-08"), period("Q2", "2026-10-16", "2026-10-21"),
probe=fixed_prober(RANGE), measure="gmv")
reasons = comparable_reasons(result)
assert "两段的天数不同:3 天对 5 天" in reasons and "本期数据只到 2026-10-20,还差 0 天" not in reasons
assert all(";" not in r for r in reasons)
assert comparable_reasons({"table": None}) == []
async def test_a_masked_date_column_is_neither_used_nor_probed():
probe = fixed_prober(RANGE)
result = await compare(period("Q1", "2026-10-05", "2026-10-12", masked=["order_date"]),
period("Q2", "2026-10-12", "2026-10-19", masked=["order_date"]), probe=probe, measure="gmv")
assert probe.calls == [] and result["probes"] == []
assert result["verdict"]["code"] == "comparable" and "窗口长度" in result["phrase"]
+420
View File
@@ -0,0 +1,420 @@
"""变化拆解(app/analysis/decompose.py,细案 01 §2):三种恒等式逐位成立、辛普森、抵消、不闭合、下钻闭合与超深、新出现 /
消失的段、比率当可加的调用错误……每个用例都顺带核对复算和运行逐字段相同(tests/analysis_samples.analyze)。"""
from __future__ import annotations
import math
import random
from decimal import Decimal
from fractions import Fraction
import pytest
from analysis_samples import analyze, call_error, fixed_prober, notes, snap
from app.analysis.decompose import lmdi, ratio_effects
SQL = "SELECT region, week, SUM(amount) AS gmv FROM orders GROUP BY region, week"
TYPES = {"region": "text", "week": "text", "gmv": "number"}
WEEKLY = [["华东", "2026-W41", 820], ["华东", "2026-W42", 700], ["华北", "2026-W41", 600], ["华北", "2026-W42", 560],
["华南", "2026-W41", 700], ["华南", "2026-W42", 666], ["西南", "2026-W41", 100], ["西南", "2026-W42", 100]]
def weekly(rows=WEEKLY, **kw):
return snap("Q2", SQL, ["region", "week", "gmv"], rows, types=TYPES, **kw)
async def additive(rows=WEEKLY, *, snaps=None, **params):
params = {"query": "Q2", "by": ["region"], "period": "week", "base": "2026-W41", "target": "2026-W42",
"measure": "gmv", **params}
return await analyze("decompose", "additive", snaps=snaps or [weekly(rows)], **params)
def _rows(base: dict, target: dict) -> list[list]:
return [[k, "2026-W41", v] for k, v in base.items()] + [[k, "2026-W42", v] for k, v in target.items()]
# --------------------------------------------------------------------------
# additive
# --------------------------------------------------------------------------
async def test_a_driver_is_named_with_its_share_and_the_whole_shape_is_filled():
result = await additive(label="销售额", hypothesis="华东拖累")
assert result["method"] == "decompose.additive@1" and result["status"] == "ok"
assert result["verdict"] == {"code": "driver", "subjects": [["华东"]], "direction": "down"}
assert result["phrase"] == "销售额下降 194(-8.7%),主要来自华东(-120,占 61.9%)"
assert result["values"]["delta_total"] == "-194" and result["values"]["top_key"] == "华东"
assert result["values"]["top_share"] == "0.618556701031" and result["values"]["segments"] == "4"
table = result["table"]
assert table["columns"] == ["region", "base", "target", "delta", "pct", "share", "flag"]
assert table["labels"]["region"] == "地区" and table["labels"]["share"] == "占变化的比重"
assert [r[0] for r in table["rows"]] == ["华东", "华北", "华南", "西南"] # 按变化的绝对值从大到小
assert result["settings"] == {"main_share": 0.5, "concentration": 0.8, "offset_ratio": 0.25, "flat_pct": 0.005,
"table_top_k": 10}
assert result["inputs"] == [{"ref": "Q2", "artifact": weekly()["artifact"], "role": "data",
"columns": ["region", "week", "gmv"], "rows": "all"}]
assert result["hypothesis"] == "华东拖累" and result["probes"] == []
assert result["criterion"].endswith("以各段之和为总数")
assert {c["code"] for c in result["caveats"]} == {"headline_basis"}
assert result["params"]["catalog"] == {"labels": {"region": "地区"}, "measures": {"gmv": "flow"}}
async def test_the_segments_add_up_to_the_total_digit_for_digit():
rng = random.Random(7)
regions = ["华东", "华南", "华北", "西南", "东北", "西北"]
for _ in range(20):
base = {r: round(rng.uniform(0, 5000), rng.choice([0, 2, 3])) for r in regions}
target = {r: round(rng.uniform(0, 5000), rng.choice([0, 2, 3])) for r in regions}
result = await additive(_rows(base, target))
rows = result["table"]["rows"]
assert sum(Decimal(r[3]) for r in rows) == Decimal(result["values"]["delta_total"])
assert sum(Decimal(r[1]) for r in rows) == Decimal(result["values"]["base_total"])
assert sum(Decimal(r[2]) for r in rows) == Decimal(result["values"]["target_total"])
assert Decimal(result["values"]["target_total"]) - Decimal(result["values"]["base_total"]) == \
Decimal(result["values"]["delta_total"])
assert result["values"]["gross_up"] and result["values"]["gross_down"]
async def test_concentrated_and_dispersed():
base = {f"店{i}": 1000 for i in range(10)}
target = {**base, "店0": 1450, "店1": 1400, "店2": 1050, "店3": 1050, "店4": 1050}
result = await additive(_rows(base, target), by="region")
assert result["verdict"]["code"] == "concentrated"
assert result["verdict"]["subjects"] == [["店0"], ["店1"]]
assert result["values"]["top_k"] == "2" and result["values"]["top_k_share"] == "0.85"
assert "集中在店0、店1(合计占 85.0%)" in result["phrase"]
spread = await additive(_rows({r: 1000 for r in "甲乙丙丁戊"}, {r: 1100 for r in "甲乙丙丁戊"}))
assert spread["verdict"] == {"code": "dispersed", "subjects": [], "direction": "up"}
assert "分散在各地区,没有哪一段占到一半" in spread["phrase"]
async def test_offsetting_segments_are_said_and_a_share_can_pass_one_hundred_percent():
result = await additive(_rows({"华东": 1000, "华北": 500, "华南": 500}, {"华东": 850, "华北": 540, "华南": 490}))
assert result["values"]["delta_total"] == "-120"
assert result["verdict"]["code"] == "driver" and result["values"]["top_share"] == "1.25"
offset = next(c for c in result["caveats"] if c["code"] == "offset")
assert offset["text"] == "下降的段合计 -160,上升的段合计 +40,互相抵消了一部分"
assert result["phrase"] == "销售额下降 120(-6.0%),主要来自华东(-150,占 125.0%);下降的段合计 -160,上升的段合计 +40,互相抵消了一部分"
async def test_flat_with_offsets_says_what_moved_inside():
result = await additive(_rows({"华东": 1000, "华北": 1000, "华南": 220}, {"华东": 1120, "华北": 882, "华南": 220}))
assert result["verdict"] == {"code": "flat", "subjects": [], "direction": None}
assert result["phrase"] == "销售额基本持平(+0.1%),内部此消彼长:华东 +120、华北 -118"
async def test_a_headline_that_does_not_close_stops_the_concentration_verdict():
head = snap("Q1", "SELECT week, SUM(amount) AS gmv FROM orders GROUP BY week", ["week", "gmv"],
[["2026-W42", 2050], ["2026-W41", 2220]], types={"week": "text", "gmv": "number"})
result = await additive(snaps=[weekly(), head], headline={"base": "Q1.r1.gmv", "target": "Q1.r0.gmv"})
assert result["verdict"] == {"code": "unclosed", "subjects": [], "direction": None}
assert result["values"]["gap"] == "24" and result["values"]["top_share"] is None
assert all(r[5] is None for r in result["table"]["rows"]) # 不给占比
assert result["phrase"] == "按地区拆开后各段之和和总数对不上(差 24),不判断变化来自哪里"
assert [i["role"] for i in result["inputs"]] == ["headline_base", "headline_target", "data"]
assert result["inputs"][0] == {"ref": "Q1.r1.gmv", "artifact": head["artifact"], "role": "headline_base",
"row": 1, "column": "gmv", "value": "2220"}
assert "各段之和要和总数对得上" in result["criterion"]
async def test_a_rounded_headline_closes_within_its_written_precision():
rows = [["华东", "2026-W41", "820.25"], ["华东", "2026-W42", "700.12"], ["华北", "2026-W41", "1399.9"],
["华北", "2026-W42", "1325.84"]]
for written, closes in (("2220.2", True), ("2220", True), ("2221", False)):
head = snap("Q1", "SELECT SUM(amount) AS gmv FROM orders", ["gmv_base", "gmv"], [[written, "2025.96"]])
result = await additive(rows, snaps=[weekly(rows), head], headline=["Q1.r0.gmv_base", "Q1.r0.gmv"])
assert (result["verdict"]["code"] != "unclosed") is closes, written
async def test_drilling_down_checks_closure_against_the_parent_segment():
top = await additive()
province = snap("Q4", "SELECT province, week, SUM(amount) AS gmv FROM orders WHERE region = '华东' GROUP BY 1, 2",
["province", "week", "gmv"],
[["沪", "2026-W41", 500], ["沪", "2026-W42", 410], ["苏", "2026-W41", 320], ["苏", "2026-W42", 290]])
params = {"query": "Q4", "by": ["province"], "period": "week", "base": "2026-W41", "target": "2026-W42",
"measure": "gmv", "parent": {"of": "A3", "key": ["华东"]}}
child = await analyze("decompose", "additive", snaps=[province], parents={"A3": top}, **params)
assert child["verdict"]["code"] == "driver" and child["verdict"]["subjects"] == [["沪"]]
assert child["params"]["depth"] == 1 and child["params"]["parent"] == {"of": "A3", "key": ["华东"]}
assert child["inputs"][0]["role"] == "parent" and child["inputs"][0]["ref"] == "A3"
assert "max_drill_depth" in child["settings"]
assert {c["code"] for c in child["caveats"]} == set() # 有 parent 就不是「以各段之和为总数」
short = snap("Q4", province["sql"], ["province", "week", "gmv"],
[["沪", "2026-W41", 500], ["沪", "2026-W42", 410], ["苏", "2026-W41", 300], ["苏", "2026-W42", 290]])
unclosed = await analyze("decompose", "additive", snaps=[short], parents={"A3": top}, **params)
assert unclosed["verdict"]["code"] == "unclosed" and unclosed["values"]["gap"] == "20"
# 超深:上一层已经是第 1 层,最多 1 层
error = await call_error("decompose", "additive", snaps=[province], parents={"A3": child},
settings={"max_drill_depth": 1}, **{**params, "parent": {"of": "A3", "key": ["沪"]}})
assert error.code == "too_deep" and "最多 1 层" in error.message
missing = await call_error("decompose", "additive", snaps=[province], parents={"A3": top},
**{**params, "parent": {"of": "A3", "key": ["东北"]}})
assert missing.code == "parent_key_missing"
absent = await call_error("decompose", "additive", snaps=[province], **params)
assert absent.code == "parent_missing"
async def test_new_and_vanished_segments_and_empty_cells_are_flagged():
rows = [["华东", "2026-W41", 800], ["华东", "2026-W42", 700], ["西南", "2026-W42", 90],
["东北", "2026-W41", 40], ["华北", "2026-W41", None], ["华北", "2026-W42", 30]]
result = await additive(rows)
flags = {r[0]: r[6] for r in result["table"]["rows"]}
assert flags == {"华东": None, "西南": "新出现", "东北": "消失", "华北": "为空"}
null = next(c for c in result["caveats"] if c["code"] == "null_values")
assert null["text"] == "华北的销售额为空,按 0 算"
assert next(r for r in result["table"]["rows"] if r[0] == "西南")[4] is None # 基期为 0 不给变化率
async def test_two_dimension_keys():
sql = "SELECT region, channel, week, SUM(amount) AS gmv FROM orders GROUP BY 1, 2, 3"
rows = [["华东", "线上", "W1", 500], ["华东", "线上", "W2", 300], ["华东", "线下", "W1", 300], ["华东", "线下", "W2", 310],
["华北", "线上", "W1", 200], ["华北", "线上", "W2", 205]]
s = snap("Q2", sql, ["region", "channel", "week", "gmv"], rows)
result = await analyze("decompose", "additive", snaps=[s], query="Q2", by=["region", "channel"], period="week",
base="W1", target="W2", measure="gmv")
assert result["verdict"]["subjects"] == [["华东", "线上"]]
assert "主要来自华东·线上" in result["phrase"]
async def test_ratio_and_non_additive_measures_are_call_errors():
avg = snap("Q2", "SELECT region, week, AVG(amount) AS aov FROM orders GROUP BY 1, 2", ["region", "week", "aov"], WEEKLY)
error = await call_error("decompose", "additive", snaps=[avg], query="Q2", by="region", period="week",
base="2026-W41", target="2026-W42", measure="aov")
assert error.code == "ratio_as_additive" and "改用比率拆解" in error.message
rate_sql = "SELECT region, week, SUM(refund_rate) AS r FROM orders GROUP BY 1, 2"
labeled = {"shop": {"orders": notes(columns={"refund_rate": {"measure": "ratio"}})}}
error = await call_error("decompose", "additive", snaps=[snap("Q2", rate_sql, ["region", "week", "r"], WEEKLY)],
catalogs=labeled, query="Q2", by="region", period="week", base="2026-W41",
target="2026-W42", measure="r")
assert error.code == "ratio_as_additive"
# 只是推断(没人确认)的照算,写附带说明
guessed = {"shop": {"orders": notes(columns={"refund_rate": {"measure": {"value": "ratio", "source": "llm",
"status": "proposed"}}})}}
result = await analyze("decompose", "additive", snaps=[snap("Q2", rate_sql, ["region", "week", "r"], WEEKLY)],
catalogs=guessed, query="Q2", by="region", period="week", base="2026-W41",
target="2026-W42", measure="r")
note = next(c for c in result["caveats"] if c["code"] == "measure_unlabeled")
assert note["text"] == "目录里「r」是比率只是推断,没人确认,按可以按段相加处理"
unlabeled = await additive(snaps=[weekly()], catalogs={})
assert any(c["code"] == "measure_unlabeled" and "目录没标「gmv」的度量类型" in c["text"] for c in unlabeled["caveats"])
async def test_call_errors_tell_the_agent_how_to_fix_the_call():
dup = WEEKLY + [["华东", "2026-W42", 5]]
error = await call_error("decompose", "additive", snaps=[weekly(dup)], query="Q2", by="region", period="week",
base="2026-W41", target="2026-W42", measure="gmv")
assert error.code == "duplicate_rows" and "在 SQL 里按 地区、week 汇总" in error.message
masked = await call_error("decompose", "additive", snaps=[weekly(masked=["Region"])], query="Q2", by="region",
period="week", base="2026-W41", target="2026-W42", measure="gmv")
assert masked.code == "masked_column"
missing = await call_error("decompose", "additive", snaps=[weekly()], query="Q2", by="region", period="week",
base="2026-W40", target="2026-W42", measure="gmv")
assert missing.code == "period_not_found" and "2026-W41" in missing.message
column = await call_error("decompose", "additive", snaps=[weekly()], query="Q2", by="area", period="week",
base="2026-W41", target="2026-W42", measure="gmv")
assert column.code == "unknown_column" and "region、week、gmv" in column.message
text = await call_error("decompose", "additive", snaps=[weekly()], query="Q2", by="gmv", period="week",
base="2026-W41", target="2026-W42", measure="region")
assert text.code == "not_numeric"
unknown = await call_error("decompose", "additive", snaps=[weekly()], query="Q9", by="region", period="week",
base="2026-W41", target="2026-W42", measure="gmv")
assert unknown.code == "unknown_query"
same_column = await call_error("decompose", "additive", snaps=[weekly()], query="Q2", by="week", period="week",
base="2026-W41", target="2026-W42", measure="gmv")
assert same_column.code == "bad_by"
async def test_truncated_snapshots_and_single_segments_are_refused():
result = await additive(snaps=[weekly(truncated=True)])
assert result["status"] == "refused" and result["reason_code"] == "truncated"
assert result["reason"] == "Q2 只取回了前 8 行,各段之和不是完整的和"
assert result["phrase"] == "变化拆解没有做成:Q2 只取回了前 8 行,各段之和不是完整的和"
assert result["verdict"] is None and result["table"] is None and result["values"] == {}
one = await additive([["华东", "2026-W41", 1], ["华东", "2026-W42", 2]])
assert one["reason_code"] == "too_few_segments"
async def test_two_snapshots_check_comparability_first():
base = snap("Q1", "SELECT region, SUM(amount) AS gmv FROM orders WHERE order_date >= '2026-10-05' AND "
"order_date < '2026-10-12' GROUP BY region", ["region", "gmv"],
[["华东", 820], ["华北", 600], ["华南", 700]])
target = snap("Q2", "SELECT region, SUM(amount) AS gmv FROM orders WHERE order_date >= '2026-10-12' AND "
"order_date < '2026-10-19' GROUP BY region", ["region", "gmv"],
[["华东", 700], ["华北", 560], ["华南", 666]])
probe = fixed_prober({("orders", "order_date"): ("2025-01-01", "2026-10-20")})
result = await analyze("decompose", "additive", snaps=[base, target], probe=probe, base_query="Q1",
target_query="Q2", by="region", measure="gmv")
assert result["verdict"]["code"] == "driver" and result["params"]["today"] == "2026-10-21"
assert not any(c["code"] == "comparability" for c in result["caveats"])
assert [i["role"] for i in result["inputs"]] == ["base", "target"]
assert result["probes"] == [{"source": "shop", "table": "orders", "column": "order_date", "min": "2025-01-01",
"max": "2026-10-20", "basis": "live", "at": "2026-10-21T09:00:00"}]
# 本期没过完:照样拆,判定后附上可比性结论
partial = snap("Q2", target["sql"].replace("'2026-10-19'", "'2026-10-25'"), ["region", "gmv"], target["rows"])
result = await analyze("decompose", "additive", snaps=[base, partial], probe=probe, base_query="Q1",
target_query="Q2", by="region", measure="gmv")
note = next(c for c in result["caveats"] if c["code"] == "comparability")
assert note["text"].startswith("两期不可比:") and "本期数据只到 2026-10-20,还差 4 天" in note["text"]
assert result["verdict"]["code"] == "driver" and note["text"] in result["phrase"]
# 口径结构不同:不拆
other = snap("Q2", "SELECT region, SUM(amount) AS gmv FROM orders JOIN refunds USING (order_id) WHERE order_date "
">= '2026-10-12' GROUP BY region", ["region", "gmv"], target["rows"])
refused = await analyze("decompose", "additive", snaps=[base, other], probe=probe, base_query="Q1",
target_query="Q2", by="region", measure="gmv")
assert refused["reason_code"] == "incomparable_sides" and refused["reason"].startswith("两份查询的口径结构不同")
# --------------------------------------------------------------------------
# ratio
# --------------------------------------------------------------------------
RATIO_SQL = "SELECT channel, week, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders GROUP BY 1, 2"
def _ratio_snap(rows):
return snap("Q1", RATIO_SQL, ["channel", "week", "refunds", "orders"], rows)
async def ratio(rows, **params):
params = {"query": "Q1", "by": "channel", "period": "week", "base": "W41", "target": "W42",
"numerator": "refunds", "denominator": "orders", **params}
return await analyze("decompose", "ratio", snaps=[_ratio_snap(rows)], **params)
def test_mix_plus_rate_effects_equal_the_ratio_change_exactly():
rng = random.Random(11)
for _ in range(200):
segs = []
for _ in range(rng.randint(2, 7)):
d_b, d_t = rng.choice([0, rng.randint(1, 5000)]), rng.randint(1, 5000)
segs.append((Fraction(rng.randint(0, d_b)) if d_b else Fraction(0), Fraction(d_b),
Fraction(rng.randint(0, d_t)), Fraction(d_t)))
if sum(s[1] for s in segs) == 0:
continue
R, R2, effects = ratio_effects(segs)
assert sum((e[4] for e in effects), Fraction(0)) + sum((e[5] for e in effects), Fraction(0)) == R2 - R
assert sum((e[0] for e in effects), Fraction(0)) == 1 == sum((e[1] for e in effects), Fraction(0))
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%")
simpson = next(c for c in result["caveats"] if c["code"] == "simpson")
assert simpson["text"] == "各段的退款率大多在下降,整体却上升:变化来自结构,线上占比从 33.3% 升到 66.7%"
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"]
async def test_rate_driven_both_and_small_denominators():
rows = [["线上", "W41", 50, 1000], ["线上", "W42", 80, 1000], ["线下", "W41", 20, 1000], ["线下", "W42", 26, 1000]]
result = await ratio(rows, label="退款率")
assert result["verdict"]["code"] == "rate_driven" and result["verdict"]["subjects"] == [["线上"]]
assert result["phrase"] == "退款率上升 1.8 个百分点(3.5% 到 5.3%),主要来自各段自身的变化,线上影响最大"
tiny = [["线上", "W41", 2, 20], ["线上", "W42", 3, 25], ["线下", "W41", 20, 1000], ["线下", "W42", 30, 1000]]
small = await ratio(tiny, label="退款率")
note = next(c for c in small["caveats"] if c["code"] == "small_denominator")
assert note["text"] == "线上的 orders 不到 30,比率波动大,不参与多数判断"
assert "分母小" in next(r for r in small["table"]["rows"] if r[0] == "线上")[-1]
async def test_ratio_refusals():
zero = [["线上", "W41", 0, 0], ["线上", "W42", 3, 100], ["线下", "W41", 0, 0], ["线下", "W42", 2, 100]]
result = await ratio(zero)
assert result["reason_code"] == "zero_base" and result["reason"] == "基期的 orders 合计为 0,算不了比率"
orphan = [["线上", "W41", 5, 0], ["线上", "W42", 3, 100], ["线下", "W41", 1, 100], ["线下", "W42", 2, 100]]
assert (await ratio(orphan))["reason_code"] == "zero_base"
# --------------------------------------------------------------------------
# factor
# --------------------------------------------------------------------------
def _factor_snap(rows):
return snap("Q1", "SELECT week, SUM(amount) AS gmv, COUNT(*) AS orders, ROUND(AVG(amount), 2) AS aov FROM orders "
"GROUP BY week", ["week", "gmv", "orders", "aov"], rows)
async def factor(rows, **params):
params = {"query": "Q1", "period": "week", "base": "W41", "target": "W42", "product": "gmv",
"factors": ["orders", "aov"], "label": "销售额", **params}
return await analyze("decompose", "factor", snaps=[_factor_snap(rows)], **params)
def test_lmdi_contributions_add_up_to_the_change_within_float_error():
rng = random.Random(3)
for _ in range(200):
k = rng.randint(2, 4)
base = [rng.uniform(0.5, 500) for _ in range(k)]
target = [b * rng.uniform(0.5, 1.8) for b in base]
m_b = math.prod(base) * (1 + rng.uniform(-1e-6, 1e-6)) # 取整过的因子:乘积差一点点
m_t = math.prod(target) * (1 + rng.uniform(-1e-6, 1e-6))
contrib = lmdi(base, target, m_b, m_t)
assert math.fsum(contrib) == pytest.approx(m_t - m_b, rel=1e-9, abs=1e-9 * max(m_b, m_t))
same = lmdi([2.0, 3.0], [2.0, 3.0], 6.0, 6.0)
assert same == [0.0, 0.0]
async def test_factor_driver_with_rounded_factors():
rows = [["W41", 1000, 137, "7.30"], ["W42", 1320, 160, "8.25"]]
result = await factor(rows)
assert result["verdict"]["code"] in ("driver", "both")
contrib = [Fraction(r[4]) for r in result["table"]["rows"]]
assert float(sum(contrib)) == pytest.approx(320, rel=1e-9)
clean = await factor([["W41", "1000", 100, "10"], ["W42", "1320", 120, "11"]])
assert clean["verdict"] == {"code": "driver", "subjects": [["orders"]], "direction": "up"}
assert clean["phrase"].startswith("销售额上升 320(+32.0%),主要来自 orders 的变化(贡献 +")
assert clean["table"]["columns"] == ["factor", "base", "target", "pct", "contrib", "share"]
async def test_factor_call_errors_and_refusals():
# 客单价写到两位小数:容差是它的半个末位(加上订单数的),差 1.5% 不是取整造成的
error = await call_error("decompose", "factor", snaps=[_factor_snap([["W41", 1000, 100, "10.00"],
["W42", 1300, 120, "11.00"]])],
query="Q1", period="week", base="W41", target="W42", product="gmv",
factors=["orders", "aov"])
assert error.code == "not_product" and "本期差" in error.message
negative = await factor([["W41", 0, 0, 10], ["W42", 1320, 120, 11]])
assert negative["reason_code"] == "non_positive"
flat = await factor([["W41", 1000, 100, 10], ["W42", 1000, 125, 8]])
assert flat["verdict"]["code"] == "flat"
few = await call_error("decompose", "factor", snaps=[_factor_snap([["W41", 1, 1, 1], ["W42", 1, 1, 1]])],
query="Q1", period="week", base="W41", target="W42", product="gmv", factors=["orders"])
assert few.code == "bad_factors"
async def test_ratio_headline_closure_and_drilling_into_a_channel():
rows = [["线上", "W41", 50, 1000], ["线上", "W42", 80, 1000], ["线下", "W41", 20, 1000], ["线下", "W42", 26, 1000]]
head = snap("Q9", "SELECT week, SUM(refund_flag) * 1.0 / COUNT(*) AS rate FROM orders GROUP BY week",
["week", "rate"], [["W41", "0.035"], ["W42", "0.053"]])
params = {"query": "Q1", "by": "channel", "period": "week", "base": "W41", "target": "W42",
"numerator": "refunds", "denominator": "orders", "label": "退款率"}
closed = await analyze("decompose", "ratio", snaps=[_ratio_snap(rows), head],
headline={"base": "Q9.r0.rate", "target": "Q9.r1.rate"}, **params)
assert closed["verdict"]["code"] == "rate_driven"
off = snap("Q9", head["sql"], ["week", "rate"], [["W41", "0.035"], ["W42", "0.06"]])
unclosed = await analyze("decompose", "ratio", snaps=[_ratio_snap(rows), off],
headline={"base": "Q9.r0.rate", "target": "Q9.r1.rate"}, **params)
assert unclosed["verdict"]["code"] == "unclosed"
assert unclosed["phrase"].startswith("按渠道拆开后各段合起来的比率和总数对不上")
# 线上按地区下钻:各地区的分子、分母之和要等于上一层线上那一段的
region = snap("Q2", "SELECT region, week, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders "
"WHERE channel = '线上' GROUP BY 1, 2", ["region", "week", "refunds", "orders"],
[["华东", "W41", 30, 600], ["华东", "W42", 55, 600], ["华北", "W41", 20, 400], ["华北", "W42", 25, 400]])
child = await analyze("decompose", "ratio", snaps=[region], parents={"A1": closed},
**{**params, "query": "Q2", "by": "region", "parent": {"of": "A1", "key": ["线上"]}})
assert child["verdict"]["code"] != "unclosed" and child["params"]["depth"] == 1
wrong = await call_error("decompose", "additive", snaps=[weekly()], parents={"A1": closed}, query="Q2",
by="region", period="week", base="2026-W41", target="2026-W42", measure="gmv",
parent={"of": "A1", "key": ["线上"]})
assert wrong.code == "parent_not_decompose"
async def test_the_same_query_cannot_be_both_periods():
error = await call_error("decompose", "additive", snaps=[weekly()], base_query="Q2", target_query="Q2",
by="region", measure="gmv")
assert error.code == "same_query"
error = await call_error("comparable", snaps=[weekly()], base_query="Q2", target_query="Q2")
assert error.code == "same_query"
+138
View File
@@ -0,0 +1,138 @@
"""差异检验(app/analysis/differ.py,细案 01 §5):比率小样本走 Fisher、均值 Welch、Holm、rows 敏感性翻转。每个用例都顺带核对
复算和运行逐字段相同。"""
from __future__ import annotations
import math
from fractions import Fraction
import pytest
from analysis_samples import analyze, call_error, snap
from app.analysis import stats
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] == "其余各地区"
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"]))
+129
View File
@@ -0,0 +1,129 @@
"""可比性检查的现查(app/analysis/probe.py):make_prober 在真的数据源上探表的日期范围、在库里算按运行当天现算的界,遮罩列
不探、超时限不探、现查不成退回剖析事实;Book 先登记、查完再算,结论只记最后真用到的。库是现造的 SQLite 文件,表名、列名、
数字都是虚构的。"""
from __future__ import annotations
import sqlite3
import uuid
from datetime import date, datetime, timedelta, timezone
import pytest
from analysis_samples import CATALOGS, recomputed, snap
from app import analysis
from app.analysis.contract import effective_settings, same
from app.analysis.probe import Book, make_prober
from app.data import introspect
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource
CLOCK = "2026-10-21T09:30:00"
#: SQLite 的 date('now') 按 UTC 算:表里的数据造到 UTC 的昨天
UTC_TODAY = datetime.now(timezone.utc).date()
LAST_DAY = UTC_TODAY - timedelta(days=1)
@pytest.fixture
async def shop(tmp_path):
"""orders:order_date 从 2026-01-01 到昨天(UTC),每天一单;customers 的 signup_date、phone 是遮罩列。"""
path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE orders (order_date TEXT, region TEXT, amount INTEGER);"
"CREATE TABLE customers (signup_date TEXT, phone TEXT);"
"INSERT INTO customers VALUES ('2026-01-05', '13900000000');")
day = date(2026, 1, 1)
while day <= LAST_DAY:
db.execute("INSERT INTO orders VALUES (?, '华东', 100)", (day.isoformat(),))
day += timedelta(days=1)
db.commit()
db.close()
name = f"shop_{uuid.uuid4().hex[:8]}"
async with SessionLocal() as session:
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True,
options={"mask_columns": ["phone", "signup_date"]})
session.add(row)
await session.flush()
row.schema_cache = await introspect.introspect(row)
await session.commit()
await session.refresh(row)
yield row
await engines.invalidate(row.id)
async def test_the_live_range_of_a_column(shop):
prober = make_prober({shop.name: shop}, clock=lambda: CLOCK)
probe = await prober(shop.name, "orders", "order_date")
assert probe == {"source": shop.name, "table": "orders", "column": "order_date", "min": "2026-01-01",
"max": LAST_DAY.isoformat(), "basis": "live", "at": CLOCK}
assert prober.dialect(shop.name) == "sqlite" and prober.dialect("nope") is None
assert await prober("nope", "orders", "order_date") is None
async def test_masked_columns_are_never_probed(shop):
prober = make_prober({shop.name: shop})
assert await prober(shop.name, "customers", "signup_date") is None # 数据源设的遮罩列
explicit = make_prober({shop.name: shop}, masked={shop.name: ["ORDER_DATE"]})
assert await explicit(shop.name, "orders", "order_date") is None
async def test_a_failed_probe_falls_back_to_profile_facts(shop):
async def facts(table):
return {"at": "2026-10-20T02:00:00", "date_ranges": [{"column": "paid_on", "min": "2026-02-01",
"max": "2026-10-19"}]}
prober = make_prober({shop.name: shop}, facts={shop.name: facts}, clock=lambda: CLOCK)
probe = await prober(shop.name, "payments", "paid_on") # 没有这张表:现查不成
assert probe == {"source": shop.name, "table": "payments", "column": "paid_on", "min": "2026-02-01",
"max": "2026-10-19", "basis": "profile", "at": "2026-10-20"}
async def test_the_budget_stops_further_probes(shop):
prober = make_prober({shop.name: shop}, budget=0)
await prober(shop.name, "orders", "order_date")
assert await prober(shop.name, "orders", "order_date") is None
async def test_bounds_are_computed_in_the_database(shop):
prober = make_prober({shop.name: shop}, clock=lambda: CLOCK)
probe = await prober.bounds(shop.name, "DATE('now', '-7 days')")
assert probe["expr"] == "DATE('now', '-7 days')" and probe["basis"] == "live" and probe["at"] == CLOCK
assert date.fromisoformat(probe["value"]) == UTC_TODAY - timedelta(days=7)
assert await prober.bounds(shop.name, "no_such_function(1)") is None
def test_the_book_registers_first_and_records_only_what_was_used():
book = Book({})
assert book.range("shop", "orders", "order_date") is None and book.bound("shop", "DATE('now')") is None
assert book.missing == [("range", "shop", "orders", "order_date"), ("bound", "shop", "DATE('now')")]
found = {"source": "shop", "table": "orders", "column": "order_date", "min": "2026-01-01", "max": "2026-10-18",
"basis": "live", "at": CLOCK}
filled = Book({("range", "shop", "orders", "order_date"): found, ("bound", "shop", "DATE('now')"): None})
assert filled.range("shop", "ORDERS", "Order_Date") is found # 表名、列名不分大小写
filled.bound("shop", "DATE('now')")
assert filled.missing == [] and filled.used == [found]
replay = Book([found]) # 复算:记下的就是全部
assert replay.bound("shop", "DATE('now', '-1 day')") is None and replay.missing == []
async def test_a_full_comparability_check_against_the_database_records_its_probes(shop):
sql = ("SELECT SUM(amount) AS gmv FROM orders WHERE order_date >= date('now', '{a}') "
"AND order_date < date('now', '{b}')")
base = snap("Q1", sql.format(a="-14 days", b="-7 days"), ["gmv"], [[700]], source=shop.name)
target = snap("Q2", sql.format(a="-7 days", b="0 days"), ["gmv"], [[300]], source=shop.name)
prober = make_prober({shop.name: shop}, clock=lambda: CLOCK)
result = await analysis.run({"op": "comparable", "params": {"base_query": "Q1", "target_query": "Q2",
"measure": "gmv"}},
{"Q1": base, "Q2": target}.get, settings=effective_settings(None),
context={"today": UTC_TODAY, "probe": prober,
"catalogs": {shop.name: CATALOGS["shop"]}})
exprs = [p["expr"] for p in result["probes"] if "expr" in p]
assert exprs == ["DATE('now', '-14 days')", "DATE('now', '-7 days')", "DATE('now', '0 days')"]
ranges = [p for p in result["probes"] if "table" in p]
assert [(p["table"], p["column"], p["max"], p["basis"]) for p in ranges] == \
[("orders", "order_date", LAST_DAY.isoformat(), "live")]
assert result["values"]["base_days"] == "7" and result["values"]["target_days"] == "7"
assert result["values"]["target_end"] == LAST_DAY.isoformat()
assert all(i["dialect"] == "sqlite" for i in result["inputs"])
assert result["verdict"]["code"] == "comparable" and result["phrase"] == "两期可比"
assert same(result, recomputed(result, [base, target]))
+152
View File
@@ -0,0 +1,152 @@
"""analysis.run / recompute(app/analysis/__init__.py):结论的形状严格按契约、同输入两次 run 逐字节相同、复算和运行逐字段相同且
只用记下的 probes(不现查、不读目录)、调用方写的系统字段不收、复算取不回输入时的说法。"""
from __future__ import annotations
import json
from datetime import date, timedelta
import pytest
from analysis_samples import CATALOGS, analyze, call_error, fixed_prober, recomputed, snap
from app import analysis
from app.analysis import contract as c
from app.analysis.contract import CallError, effective_settings, same
WEEKLY_SQL = "SELECT region, week, SUM(amount) AS gmv FROM orders GROUP BY region, week"
WEEKLY = snap("Q2", WEEKLY_SQL, ["region", "week", "gmv"],
[["华东", "2026-W41", 820], ["华东", "2026-W42", 700], ["华北", "2026-W41", 600],
["华北", "2026-W42", 560], ["华南", "2026-W41", 700], ["华南", "2026-W42", 666]])
SERIES = snap("Q1", "SELECT DATE(order_date, 'weekday 0', '-6 days') AS week, SUM(amount) AS gmv FROM orders GROUP BY 1",
["week", "gmv"], [[(date(2026, 6, 1) + timedelta(days=7 * i)).isoformat(), 100 + (i % 4) * 3 + i]
for i in range(20)])
RATES = snap("Q3", "SELECT region, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders GROUP BY region",
["region", "refunds", "orders"], [["西南", 80, 1000], ["华东", 40, 1000], ["华北", 41, 1000]])
BASE = snap("Q4", "SELECT region, SUM(amount) AS gmv FROM orders WHERE order_date >= '2026-10-05' AND "
"order_date < '2026-10-12' GROUP BY region", ["region", "gmv"], [["华东", 820], ["华北", 600]])
TARGET = snap("Q5", "SELECT region, SUM(amount) AS gmv FROM orders WHERE order_date >= '2026-10-12' AND "
"order_date < '2026-10-19' GROUP BY region", ["region", "gmv"], [["华东", 700], ["华北", 590]])
SNAPS = [WEEKLY, SERIES, RATES, BASE, TARGET]
PROBE = {("orders", "order_date"): ("2025-01-01", "2026-10-20")}
CALLS = [
("decompose", "additive", {"query": "Q2", "by": "region", "period": "week", "base": "2026-W41",
"target": "2026-W42", "measure": "gmv", "label": "销售额"}),
("decompose", "additive", {"base_query": "Q4", "target_query": "Q5", "by": "region", "measure": "gmv"}),
("baseline", None, {"query": "Q1", "period": "week", "measure": "gmv"}),
("trend", None, {"query": "Q1", "period": "week", "measure": "gmv"}),
("differ", "rate", {"query": "Q3", "by": "region", "numerator": "refunds", "denominator": "orders"}),
("comparable", None, {"base_query": "Q4", "target_query": "Q5", "measure": "gmv"}),
]
async def _run(op, mode, params, *, probe=None, **kw):
return await analyze(op, mode, snaps=SNAPS, probe=probe or fixed_prober(PROBE), check=False, **params, **kw)
def _assert_shape(result):
"""结论的字段、取值严格按契约。"""
assert set(result) == set(c.AnalysisResult.__annotations__)
assert result["op"] in c.OPS and result["method"] == c.METHODS[(result["op"], result["mode"])]
assert result["status"] in ("ok", "refused")
if result["status"] == "refused":
assert result["reason_code"] in c.REASONS and result["reason"] and result["verdict"] is None
else:
assert result["reason_code"] is None and result["reason"] is None
assert result["verdict"]["code"] in c.VERDICTS[result["op"]]
assert set(result["verdict"]) == {"code", "subjects", "direction"}
assert all(isinstance(s, list) and all(isinstance(x, str) for x in s) for s in result["verdict"]["subjects"])
assert result["verdict"]["direction"] in (None, "up", "down")
table = result["table"]
assert set(table) == {"columns", "labels", "rows"} and set(table["labels"]) == set(table["columns"])
assert all(len(r) == len(table["columns"]) for r in table["rows"])
assert all(v is None or isinstance(v, str) for v in result["values"].values())
assert all(set(cv) == {"code", "text"} and cv["code"] in c.CAVEATS for cv in result["caveats"])
assert set(result["settings"]) <= set(c.DEFAULTS)
assert all(i.get("artifact") for i in result["inputs"])
assert result["criterion"] and result["phrase"]
json.dumps(result, ensure_ascii=False) # 写得进 JSON
@pytest.mark.parametrize("op,mode,params", CALLS)
async def test_every_result_has_the_contract_shape_and_recomputes_the_same(op, mode, params):
result = await _run(op, mode, params)
_assert_shape(result)
again = recomputed(result, SNAPS)
assert same(result, again)
assert json.dumps(again, sort_keys=True, ensure_ascii=False) == json.dumps(result, sort_keys=True, ensure_ascii=False)
@pytest.mark.parametrize("op,mode,params", CALLS)
async def test_the_same_inputs_give_byte_identical_results(op, mode, params):
one = await _run(op, mode, params)
two = await _run(op, mode, params)
assert json.dumps(one, sort_keys=True, ensure_ascii=False) == json.dumps(two, sort_keys=True, ensure_ascii=False)
async def test_recompute_uses_the_recorded_probes_and_facts_not_live_ones():
result = await _run("comparable", None, {"base_query": "Q4", "target_query": "Q5", "measure": "gmv"})
assert result["verdict"]["code"] == "comparable" and len(result["probes"]) == 1
# 记下的范围换一个(表里数据只到 10-16):复算按记下的算,结论跟着变——它没有现查
edited = {**result, "probes": [{**result["probes"][0], "max": "2026-10-16"}]}
assert recomputed(edited, SNAPS)["verdict"]["code"] == "incomparable"
# 目录事后改了也不影响复算:目录事实记在 params 里
decompose = await _run(*CALLS[0])
assert decompose["params"]["catalog"]["labels"]["region"] == "地区"
CATALOGS["shop"]["orders"]["columns"]["region"]["label"]["value"] = "大区"
try:
assert same(decompose, recomputed(decompose, SNAPS))
finally:
CATALOGS["shop"]["orders"]["columns"]["region"]["label"]["value"] = "地区"
async def test_system_params_written_by_the_caller_are_ignored():
params = {"query": "Q1", "period": "week", "measure": "gmv", "today": "2020-01-01",
"catalog": {"labels": {"gmv": "假的叫法"}}, "depth": 9}
result = await analysis.run({"op": "baseline", "params": params}, {s["ref"]: s for s in SNAPS}.get,
settings=effective_settings(None),
context={"today": date(2026, 10, 21), "catalogs": CATALOGS})
assert result["params"]["today"] == "2026-10-21"
assert "假的叫法" not in result["phrase"] and "depth" not in result["params"]
async def test_analysis_card_overrides_reach_the_criterion_and_settings():
params = CALLS[0][2]
result = await _run("decompose", "additive", params, settings={"main_share": 0.6})
assert result["settings"]["main_share"] == 0.6 and "占总变化的 60% 以上" in result["criterion"]
async def test_call_errors_for_unknown_ops_and_modes():
with pytest.raises(CallError) as e:
await analysis.run({"op": "forecast", "params": {}}, {}.get, settings=effective_settings(None), context={})
assert e.value.code == "unknown_op" and "变化拆解" in e.value.message
with pytest.raises(CallError) as e:
await analysis.run({"op": "decompose", "mode": "share", "params": {}}, {}.get,
settings=effective_settings(None), context={})
assert e.value.code == "unknown_mode" and "additive / ratio / factor" in e.value.message
missing = await call_error("baseline", snaps=SNAPS, query="Q1", period="week")
assert missing.code == "missing_param" and missing.message == "没写度量列"
async def test_recompute_says_when_an_input_cannot_be_loaded():
result = await _run(*CALLS[0])
with pytest.raises(CallError) as e:
analysis.recompute(result, lambda artifact: None)
assert e.value.code == "input_missing" and e.value.message == "复算取不回 Q2 的查询快照"
async def test_the_hypothesis_is_recorded_but_never_rendered():
result = await _run(*CALLS[0], hypothesis="可能是华东促销结束 30% 的影响")
assert result["hypothesis"] == "可能是华东促销结束 30% 的影响"
assert "促销" not in result["phrase"] and "30" not in result["phrase"]
assert same(result, {**recomputed(result, SNAPS), "hypothesis": None})
async def test_ledger_entries_name_the_real_input_snapshots():
result = await _run(*CALLS[1])
entry = c.ledger_entry(result, node_id="agent", exec_no=1, artifact="f" * 64, call_id="call-1")
assert entry["inputs"] == [BASE["artifact"], TARGET["artifact"]] and entry["op"] == "decompose"
def test_the_call_limit_error_is_spoken_with_the_limit():
error = analysis.exhausted({"max_calls": 5})
assert error.code == "max_calls" and error.message == "这次运行的分析次数已经用完(最多 5 次):用已有的分析结论写,不再新做分析"
+210
View File
@@ -0,0 +1,210 @@
"""波动基线、趋势(app/analysis/series.py,细案 01 §3、§4):常规 / 偏离 / 明显偏离、σ̂ = 0、历史不够、本期不完整、缺期、日数据
lag 7、新高新低;上升 / 下降 / 无趋势、连续期数、缺期。每个用例都顺带核对复算和运行逐字段相同。"""
from __future__ import annotations
from datetime import date, timedelta
from fractions import Fraction
import pytest
from analysis_samples import analyze, call_error, fixed_prober, snap
from app.analysis.series import MAD_SCALE, run_length
SQL = "SELECT DATE(order_date, 'weekday 0', '-6 days') AS week, COUNT(*) AS orders FROM orders GROUP BY 1"
#: 每周一的日期:最后一周是 2026-10-12(到 10-18,运行当天 10-21 之前已经过完)
LAST_MONDAY = date(2026, 10, 12)
def weeks(values, *, last=LAST_MONDAY):
start = last - timedelta(days=7 * (len(values) - 1))
return [[(start + timedelta(days=7 * i)).isoformat(), v] for i, v in enumerate(values)]
def series(rows, sql=SQL, cols=("week", "orders")):
return snap("Q1", sql, list(cols), rows, types={cols[0]: "text", cols[1]: "number"})
async def baseline(rows, **params):
params = {"query": "Q1", "period": "week", "measure": "orders", "label": "订单数", **params}
return await analyze("baseline", snaps=[series(rows)], **params)
async def trend(rows, **params):
params = {"query": "Q1", "period": "week", "measure": "orders", "label": "线上销售额", **params}
return await analyze("trend", snaps=[series(rows)], **params)
NOISE = [100, 102, 98, 101, 99, 103, 100, 97, 102, 101, 99, 100, 98, 103, 101, 100, 99, 102, 100]
# --------------------------------------------------------------------------
# 波动基线
# --------------------------------------------------------------------------
async def test_level_basis_with_hand_computed_median_and_mad():
rows = [[i + 1, v] for i, v in enumerate([10, 12, 11, 13, 9, 10, 12, 11, 16])]
result = await baseline(rows, basis="level")
# 历史中位数 11,MAD 1,σ̂ = 1.4826,z = (16 − 11) / 1.4826
values = result["values"]
assert values["median"] == "11" and values["n_history"] == "8" and values["rank"] == "1"
assert Fraction(values["z"]) == pytest.approx(Fraction(5) / MAD_SCALE)
assert values["band_lo"] == "8.0348" and values["band_hi"] == "13.9652"
assert result["verdict"] == {"code": "far_outside_band", "subjects": [], "direction": "up"}
assert result["phrase"].startswith("订单数本期为 16,明显超出过去 8 期的常规波动范围(8.03 至 13.97)")
assert any(c["code"] == "record_high" for c in result["caveats"])
assert result["params"]["grain"] == "seq" and result["params"]["lag"] == 1
assert result["settings"] == {"k_warn": 2.0, "k_alert": 3.0, "min_history": 8, "max_history": 52}
assert result["table"]["rows"][-1][-1] == "本期"
async def test_change_basis_within_outside_and_far_outside():
probe = fixed_prober({("orders", "order_date"): ("2025-01-01", "2026-10-20")})
within = await analyze("baseline", snaps=[series(weeks(NOISE + [101]))], probe=probe, query="Q1", period="week",
measure="orders", label="订单数")
assert within["verdict"]["code"] == "within_band"
assert within["phrase"].startswith("订单数本期变化 +1.0%,在过去 18 周的常规波动范围内(")
assert within["params"]["today"] == "2026-10-21" and within["params"]["grain"] == "week"
assert within["probes"][0]["table"] == "orders" and within["probes"][0]["column"] == "order_date"
assert not any(c["code"] == "comparability" for c in within["caveats"])
outside = await analyze("baseline", snaps=[series(weeks(NOISE + [107]))], probe=probe, query="Q1",
period="week", measure="orders")
assert outside["verdict"]["code"] == "outside_band" and "超出过去 18 周" in outside["phrase"]
far = await analyze("baseline", snaps=[series(weeks(NOISE + [70]))], probe=probe, query="Q1", period="week",
measure="orders", label="订单数")
assert far["verdict"] == {"code": "far_outside_band", "subjects": [], "direction": "down"}
assert far["phrase"].startswith("订单数本期变化 -30.0%,明显超出过去 18 周的常规波动范围")
assert any(c["code"] == "record_low" and c["text"] == "本期的订单数是过去 18 周里最低的" for c in far["caveats"])
async def test_without_a_table_range_completeness_is_said_to_be_unverified_but_not_in_the_phrase():
result = await baseline(weeks(NOISE + [101]))
note = next(c for c in result["caveats"] if c["code"] == "comparability")
assert note["text"] == "没能核对本期的数据是否到齐(查不到表里这一列的日期范围)"
assert note["text"] not in result["phrase"]
async def test_flat_history_has_no_spread():
rows = [[i + 1, 50] for i in range(10)]
same = await baseline(rows, basis="level")
assert same["verdict"]["code"] == "within_band" and same["values"]["z"] is None
assert same["phrase"] == "订单数本期为 50,和过去 9 期一样(每期都是 50)"
jump = await baseline(rows[:-1] + [[10, 51]], basis="level")
assert jump["verdict"] == {"code": "far_outside_band", "subjects": [], "direction": "up"}
assert jump["phrase"].startswith("订单数本期为 51,明显超出过去 9 期的常规波动范围(每期都是 50)")
async def test_short_history_partial_target_gaps_and_zero_bases_are_refused():
short = await baseline(weeks([100, 101, 99, 102, 98, 100]))
# 6 周:第一周没有上一周可比,本期不算历史,历史的变化只有 4 个
assert short["reason_code"] == "history_short" and short["reason"] == "只有 4 期历史,至少要 8 期"
assert short["phrase"] == "波动基线没有做成:只有 4 期历史,至少要 8 期"
# 最后一周是 10-19 到 10-25:运行当天 10-21 还没过完
partial = await baseline(weeks(NOISE + [60], last=date(2026, 10, 19)))
assert partial["reason_code"] == "partial_target"
assert partial["reason"] == "本期数据只到 2026-10-20,还差 5 天,和完整的周比没有意义"
# 周过完了,但表里的数据只到 10-17
probe = fixed_prober({("orders", "order_date"): ("2025-01-01", "2026-10-17")})
late = await analyze("baseline", snaps=[series(weeks(NOISE + [60]))], probe=probe, query="Q1", period="week",
measure="orders")
assert late["reason"] == "本期数据只到 2026-10-17,还差 1 天,和完整的周比没有意义"
gap_rows = weeks(NOISE + [100, 100])
del gap_rows[-2] # 本期的上一周没有
gap = await baseline(gap_rows)
assert gap["reason_code"] == "target_gap"
zero = await baseline(weeks(NOISE + [0, 10]))
assert zero["reason_code"] == "zero_base"
unreadable = await baseline([["上周", 1], ["本周", 2]])
assert unreadable["reason_code"] == "period_unreadable" and "上周" in unreadable["reason"]
async def test_daily_series_compare_with_the_same_weekday_last_week():
start = date(2026, 7, 1)
rows = [[(start + timedelta(days=i)).isoformat(), 100 + (i % 7) * 10] for i in range(100)]
result = await analyze("baseline", snaps=[series(rows, cols=("day", "orders"))], query="Q1", period="day",
measure="orders", label="订单数", check=True)
assert result["params"]["grain"] == "day" and result["params"]["lag"] == 7
# 星期效应:和上周同一天比,每天的变化都是 0
assert result["values"]["median"] == "0" and result["verdict"]["code"] == "within_band"
assert result["phrase"].startswith("订单数比上周同一天变化 0.0%,和过去 52 天一样")
yoy = await baseline(weeks(NOISE * 3), lag=52) # 57 周:能和 52 周前比的历史只有 4 期
assert yoy["reason_code"] == "history_short" and yoy["reason"] == "只有 4 期历史,至少要 8 期"
assert (await baseline(weeks(NOISE * 4), lag=52))["status"] == "ok"
assert (await call_error("baseline", snaps=[series(weeks(NOISE))], query="Q1", period="week", measure="orders",
lag=0)).code == "bad_lag"
async def test_duplicate_periods_and_given_targets():
rows = weeks(NOISE + [101])
error = await call_error("baseline", snaps=[series(rows + [rows[-1]])], query="Q1", period="week",
measure="orders")
assert error.code == "duplicate_rows" and "在 SQL 里按 week 汇总" in error.message
earlier = await baseline(rows, target=rows[-3][0])
assert earlier["params"]["target"] == rows[-3][0] and earlier["values"]["n_history"] == "16"
missing = await call_error("baseline", snaps=[series(rows)], query="Q1", period="week", measure="orders",
target="2020-01-06")
assert missing.code == "period_not_found"
async def test_iso_week_labels_and_months():
labels = [[f"2026-W{w:02d}", v] for w, v in zip(range(22, 42), NOISE + [101])]
result = await baseline(labels)
assert result["params"]["grain"] == "week" and result["verdict"]["code"] == "within_band"
months = [[f"2025-{m:02d}", 100 + m] for m in range(1, 13)] + [[f"2026-{m:02d}", 112 + m] for m in range(1, 10)]
monthly = await baseline(months)
assert monthly["params"]["grain"] == "month" and "过去 19 个月" in monthly["phrase"]
# --------------------------------------------------------------------------
# 趋势
# --------------------------------------------------------------------------
async def test_rising_and_falling_trends_with_run_length():
rising = await trend(weeks([100 + 3 * i + (i % 3) for i in range(12)]))
assert rising["verdict"] == {"code": "rising", "subjects": [], "direction": "up"}
assert rising["values"]["n"] == "12" and rising["values"]["run_length"] == "11"
assert rising["phrase"].startswith("过去 12 周线上销售额呈上升趋势(每周约 +")
assert ",已连续 11 周上升" in rising["phrase"]
assert rising["settings"] == {"trend_min_points": 6, "trend_alpha": 0.05, "max_history": 52}
falling = await trend(weeks([200, 196, 197, 190, 185, 186, 180, 181, 175, 170]))
assert falling["verdict"]["code"] == "falling"
assert falling["values"]["run_length"] == "2" and "已连续" not in falling["phrase"]
async def test_no_trend_and_exact_small_sample_p():
flat = await trend(weeks([100, 104, 98, 103, 97, 101, 99, 102]))
assert flat["verdict"] == {"code": "no_trend", "subjects": [], "direction": None}
assert flat["phrase"] == "过去 8 周线上销售额没有明显的上升或下降趋势"
six = await trend(weeks([1, 2, 3, 4, 5, 6]))
# 没有并列、n ≤ 10:精确分布,单调的 6 期双侧 p = 2 / 6!
assert Fraction(six["values"]["p"]) == pytest.approx(Fraction(2, 720))
assert six["verdict"]["code"] == "rising" and six["values"]["slope"] == "1"
def test_run_length_counts_consecutive_changes_from_the_end():
assert run_length([Fraction(v) for v in (1, 2, 3, 2, 3, 4, 5)]) == (3, "up")
assert run_length([Fraction(v) for v in (5, 4, 3)]) == (2, "down")
assert run_length([Fraction(v) for v in (1, 2, 2)]) == (0, None)
async def test_trend_needs_enough_consecutive_points():
rows = weeks([100 + i for i in range(10)])
del rows[-4] # 最后连着的只有 3 期
gap = await trend(rows)
assert gap["reason_code"] == "series_gap" and gap["reason"] == "最后连着的只有 3 期,再往前缺期(至少要连着的 6 期)"
short = await trend(weeks([1, 2, 3, 4]))
assert short["reason_code"] == "history_short" and short["reason"] == "只有 4 期,至少要 6 期"
partial = await trend(weeks([100 + i for i in range(10)], last=date(2026, 10, 19)))
assert partial["reason_code"] == "partial_target"
async def test_daily_trends_warn_about_weekday_effects():
start = date(2026, 8, 1)
rows = [[(start + timedelta(days=i)).isoformat(), 100 + i] for i in range(30)]
result = await analyze("trend", snaps=[series(rows, cols=("day", "orders"))], query="Q1", period="day",
measure="orders")
assert result["verdict"]["code"] == "rising"
assert any(c["code"] == "weekday_effect" and c["text"] == "日数据有星期效应,看趋势建议按周汇总"
for c in result["caveats"])
assert "日数据有星期效应" in result["phrase"]