diff --git a/backend/app/analysis/common.py b/backend/app/analysis/common.py index 3c278c5b..b5abc89b 100644 --- a/backend/app/analysis/common.py +++ b/backend/app/analysis/common.py @@ -64,13 +64,18 @@ def today_param(env: Env) -> str: 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 补上查过的目录事实。""" + phrase: str, caveats: Sequence[Mapping[str, str]] = (), + formats: Mapping[str, str] | None = None, named: Mapping[str, str] | None = None) -> AnalysisResult: + """组装一份结论:形状严格按 contract.AnalysisResult,params 补上查过的目录事实。formats 是结果表各列的换算(占比、 + 变化率的列记 pct,比率的变化记 pp),named 是具名的数的换算(value_formats):都只留记了的,为空时整项不写。""" 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 { + out_table = dict(table) if table is not None else None + if out_table is not None and (table_formats := column_formats(formats or {})): + out_table["formats"] = table_formats + out: AnalysisResult = { "op": env.op, "mode": env.mode, "method": METHODS[(env.op, env.mode)], @@ -81,7 +86,7 @@ def build(env: Env, params: dict[str, Any], used: Sequence[str], *, status: str "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] + "table": out_table, # type: ignore[typeddict-item] "values": dict(values or {}), "verdict": dict(verdict) if verdict is not None else None, # type: ignore[typeddict-item] "criterion": criterion, @@ -89,6 +94,9 @@ def build(env: Env, params: dict[str, Any], used: Sequence[str], *, status: str "caveats": [dict(c) for c in caveats], # type: ignore[misc] "hypothesis": env.hypothesis, } + if named_formats := value_formats(named or {}): + out["value_formats"] = named_formats + return out def refuse(env: Env, params: dict[str, Any], used: Sequence[str], code: str, reason: str, *, @@ -155,9 +163,19 @@ def column_types(columns: Sequence[str], *, dims: Mapping[str, str | None] | Non return out +def column_formats(formats: Mapping[str, str]) -> dict[str, str]: + """结果表的 formats(ResultTable.formats):列 id → 换算。只留记了换算的列,都为空时整项不写(结论和以前一字不差)。""" + return {str(k): v for k, v in formats.items() if v} + + +def value_formats(values: Mapping[str, Any]) -> dict[str, str]: + """结论的 value_formats(AnalysisResult.value_formats):键 → 换算。只留记了换算的数,都为空时整项不写。""" + return {str(k): v for k, v in values.items() if v} + + 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", "column_types", "label_for", "need", "refuse", - "reserved_check", "sign_word", "today_param", "written_places"] +__all__ = ["DERIVED_PARAMS", "Env", "build", "closes", "column_formats", "column_label", "column_types", "label_for", + "need", "refuse", "reserved_check", "sign_word", "today_param", "value_formats", "written_places"] diff --git a/backend/app/analysis/contract.py b/backend/app/analysis/contract.py index d8d8c61a..8f2552ac 100644 --- a/backend/app/analysis/contract.py +++ b/backend/app/analysis/contract.py @@ -277,6 +277,10 @@ class ResultTable(TypedDict): # 列 id → 类型(写法同查询快照的 column_types;第 1 波 W1-a 加,可选):维度列照输入快照里那一列查询时记下的类型 # (文本型的码值 "1203" 才不会被渲染成「1,203」;输入没记类型的不写),数值列 number,说明、叫法这类文字列 text column_types: NotRequired[dict[str, str]] + # 列 id → 换算(第 2 波加,可选):占比、变化率、权重这类以比率存的列,渲染时按它换算,换算的写法同 [[m:]] 的后缀 + # (pct 显示成百分数、pp 显示成「+1.2 个百分点」)。引用这一列的一格、展开整表时没写换算的按它渲染,写作者自己写了换算的 + # 照他的(引用上记下实际用的换算,导出、可验证报告照它渲染) + formats: NotRequired[dict[str, str]] class AnalysisRequest(TypedDict, total=False): @@ -302,6 +306,9 @@ class AnalysisResult(TypedDict, total=False): reason: str | None table: ResultTable | None values: dict[str, Any] # 具名标量:[[a:A3.top_share]];数一律十进制字符串 + # 键 → 换算(第 2 波加,可选):占比、变化率这类以比率存的数,渲染时按它换算(写法同 ResultTable.formats)。 + # [[a:A3.top_share]] 没写换算的按它渲染,写作者自己写了换算的照他的 + value_formats: dict[str, str] verdict: Verdict | None criterion: str # 判定标准,一句,照实际阈值写 phrase: str # 判定语(app/analysis/phrases 渲染),[[a:A3]] 渲染成它 @@ -417,6 +424,16 @@ class ScopeBlock(TypedDict, total=False): #: 复算核对时不比的字段:hypothesis 是 agent 写的话,不是算出来的 NOT_RECOMPUTED = frozenset({"hypothesis"}) +#: 结论里记格式的两处:结果表的 formats(列 id → 换算)、values 的 value_formats(键 → 换算)。换算取值同 [[m:]] 的后缀, +#: 另加 pp(比率的变化按百分点写:「+1.2 个百分点」) +TABLE_FORMATS = "formats" +VALUE_FORMATS = "value_formats" +#: 比率的换算:pct 按百分数写(0.62 显示成 62.0%) +CONV_PCT = "pct" +#: 比率的变化按百分点写(0.012 显示成「+1.2 个百分点」,下降时带负号):[[m:]] 的后缀里原本没有这一种 +CONV_POINTS = "pp" +FORMAT_CONVS: tuple[str, ...] = (CONV_PCT, CONV_POINTS) + def same(a: Mapping[str, Any], b: Mapping[str, Any]) -> bool: """两份结论(封存的、复算的)是否逐字段相同(hypothesis 除外)。""" @@ -425,8 +442,10 @@ def same(a: Mapping[str, Any], b: Mapping[str, Any]) -> bool: __all__ = [ - "ARTIFACT_KIND", "CATALOG_PREFIX", "CAVEATS", "DEFAULTS", "LABELS", "LEDGER_KIND", "MARKER_KIND", "METHODS", "MODES", - "MODE_LABELS", "NODE_TYPE", "NOT_RECOMPUTED", "OPS", "OP_LABELS", "RANGES", "REASONS", "SETTINGS_KEY", "TOOL_NAMES", + "ARTIFACT_KIND", "CATALOG_PREFIX", "CAVEATS", "CONV_PCT", "CONV_POINTS", "DEFAULTS", "FORMAT_CONVS", "LABELS", + "LEDGER_KIND", "MARKER_KIND", "METHODS", "MODES", + "MODE_LABELS", "NODE_TYPE", "NOT_RECOMPUTED", "OPS", "OP_LABELS", "RANGES", "REASONS", "SETTINGS_KEY", + "TABLE_FORMATS", "TOOL_NAMES", "VALUE_FORMATS", "VERDICTS", "AnalysisInput", "AnalysisRequest", "AnalysisResult", "CallError", "Caveat", "Context", "InputSnapshot", "MetricComparability", "Probe", "Prober", "ResultTable", "ScopeBlock", "ScopeItem", "Verdict", "catalog_result", "effective_settings", "is_result", "ledger_entry", "method_label", "same", "settings_problem", diff --git a/backend/app/analysis/decompose.py b/backend/app/analysis/decompose.py index 104001b6..fe9f6649 100644 --- a/backend/app/analysis/decompose.py +++ b/backend/app/analysis/decompose.py @@ -29,7 +29,7 @@ from app.analysis import comparable as cmp from app.analysis import phrases as ph from app.analysis.common import (Env, build, closes, column_label, column_types, label_for, need, refuse, reserved_check, sign_word, today_param) -from app.analysis.contract import CallError +from app.analysis.contract import CONV_PCT, CONV_POINTS, CallError from app.analysis.inputs import Snap, as_list, hashable, same_value from app.analysis.numbers import canon, to_fraction @@ -227,6 +227,17 @@ def _parent(raw: Mapping[str, Any], env: Env, mode: str, dims: int) -> tuple[dic return {"of": name, "key": key}, dict(zip(columns, hit)), depth +#: 结果表里以比率存的列(占比、变化率、权重、比率本身):引用、展开时按百分数显示(ResultTable.formats 记 pct) +_RATE_COLUMNS = ("pct", "share") + + +def _rate_formats(columns: Sequence[str], rate: Sequence[str] = _RATE_COLUMNS, + points: Sequence[str] = ()) -> dict[str, str]: + """结果表各列记的换算:rate 里的列(占比、变化率、权重、比率本身)记 pct,points 里的列(比率的变化)记 pp。""" + return {**{c: CONV_PCT for c in rate if c in columns}, + **{c: CONV_POINTS for c in points if c in columns}} + + _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") @@ -470,7 +481,9 @@ def _additive_result(env: Env, params: dict[str, Any], used: list[str], segs: li skip = (("offset",) if code == "flat" else ()) + (("comparability",) if outcome is None else ()) phrase = ph.with_caveats(main, caveats, skip=skip) 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) + criterion=ph.criterion_additive(s, top_k_max=k_max, summed=summed), phrase=phrase, caveats=caveats, + formats=_rate_formats(columns), + named={k: CONV_PCT for k in ("pct_total", "top_share", "top_k_share")}) # -------------------------------------------------------------------------- @@ -681,7 +694,13 @@ def _ratio_result(env: Env, params: dict[str, Any], used: list[str], segs: list[ "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, skip=("comparability",) if outcome is None else ()), - caveats=caveats) + caveats=caveats, + # 权重、各段的比率按百分数显示;结构效应、自身效应、合计影响是比率的变化,按百分点 + formats=_rate_formats(columns, rate=("w_base", "w_target", "r_base", "r_target"), + points=("mix", "rate", "total")), + named={k: CONV_PCT for k in ("r_base_total", "r_target_total")} + | {k: CONV_POINTS for k in ("delta", "mix_total", "rate_total")} + | {"mix_share": CONV_PCT, "rate_share": CONV_PCT}) # -------------------------------------------------------------------------- @@ -811,7 +830,8 @@ def factor(raw: Mapping[str, Any], env: Env) -> Any: 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, skip=("comparability",) if outcome is None else ()), - caveats=caveats) + caveats=caveats, formats=_rate_formats(columns), + named={k: CONV_PCT for k in ("pct_total", "top_share")}) __all__ = ["HEADLINE_SHAPE", "PARENT_SHAPE", "SAME_QUERY", "SIMPSON_SHARE", "USED", "additive", "factor", "lmdi", diff --git a/backend/app/analysis/differ.py b/backend/app/analysis/differ.py index 668e31e5..1b959b3b 100644 --- a/backend/app/analysis/differ.py +++ b/backend/app/analysis/differ.py @@ -33,7 +33,7 @@ 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, column_types, label_for, need, refuse, sign_word -from app.analysis.contract import CallError +from app.analysis.contract import CONV_PCT, CONV_POINTS, CallError from app.analysis.inputs import Snap, as_list, hashable, same_value from app.analysis.numbers import canon, to_fraction @@ -389,8 +389,13 @@ def _decide(env: Env, params: dict[str, Any], used: list[str], pairs: list[Pair] "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(tested) > 1 else ()) + # 比率模式的组的比率按百分数显示,两组的差、差的区间是比率之差,按百分点(均值模式是量,照原样) + rate = ("a_value", "b_value") if mode == "rate" else () + points = ("diff", "ci_lo", "ci_hi") if mode == "rate" else () return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion, phrase=phrase, - caveats=caveats) + caveats=caveats, formats={**{c: CONV_PCT for c in rate}, **{c: CONV_POINTS for c in points}}, + named={**{k: CONV_PCT for k in ("a", "b")}, **{k: CONV_POINTS for k in ("diff", "ci_lo", "ci_hi")}} + if mode == "rate" else {}) def _sensitivity(pairs: list[Pair], mode: str, alpha: Fraction, min_expected: Fraction, diff --git a/backend/app/analysis/series.py b/backend/app/analysis/series.py index c9b57687..ef536014 100644 --- a/backend/app/analysis/series.py +++ b/backend/app/analysis/series.py @@ -33,7 +33,7 @@ from app.analysis import phrases as ph from app.analysis import stats from app.analysis.common import (Env, build, column_label, column_types, label_for, need, refuse, reserved_check, sign_word, today_param) -from app.analysis.contract import CallError +from app.analysis.contract import CONV_PCT, CallError from app.analysis.inputs import Snap, same_value from app.analysis.numbers import canon, to_fraction @@ -258,8 +258,13 @@ def baseline(raw: Mapping[str, Any], env: Env) -> Any: "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)} + # 按变化率比时,本期的变化率、中位数、常规波动范围、历史最小最大都是变化率,按百分数显示;按值本身比时照原样 + change = ("change",) if basis == "change" else () return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion, - phrase=ph.with_caveats(main, caveats, skip=("comparability",)), caveats=caveats) + phrase=ph.with_caveats(main, caveats, skip=("comparability",)), caveats=caveats, + formats={c: CONV_PCT for c in change}, + named={k: CONV_PCT for k in ("x", "median", "band_lo", "band_hi", "hist_min", "hist_max") + if basis == "change"}) def _compare_gap(series: Series, ordinal: int, lag: int, unit: str) -> str: @@ -360,7 +365,9 @@ def trend(raw: Mapping[str, Any], env: Env) -> Any: 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) + phrase=ph.with_caveats(main, caveats, skip=("comparability",)), caveats=caveats, + # 相对斜率是比率(每期变百分之几),按百分数显示;斜率本身、p 值、各期之比照原样 + named={"rel_slope": CONV_PCT}) def _tail_gap(series: Series, ordinal: int) -> str: