mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:31:58 +00:00
846 lines
49 KiB
Python
846 lines
49 KiB
Python
"""变化拆解(细案 01 §2):变化从哪来。三种模式,agent 按指标的性质选,算子核对选得对不对。
|
||
|
||
**参数**(AnalysisRequest.params;列名不分大小写,按快照认):
|
||
|
||
- 一份快照:query(编号)、period(期次列)、base / target(期次列里的两个值),本期齐不齐同波动基线那一套(comparable.
|
||
period_complete:不到整期、没过完、数据没到 → partial_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, column_types, label_for, need, refuse,
|
||
reserved_check, sign_word, today_param)
|
||
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
|
||
|
||
#: 辛普森:分母够大的段里,按平均权重算有这么多的段比率和整体反着变
|
||
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": "属性"}
|
||
#: 各模式的结论里记的阈值(下钻时另加 max_drill_depth)
|
||
USED: dict[str, tuple[str, ...]] = {
|
||
"additive": ("main_share", "concentration", "offset_ratio", "flat_pct", "table_top_k"),
|
||
"ratio": ("main_share", "flat_pct", "min_denominator", "table_top_k"),
|
||
"factor": ("main_share", "flat_pct"),
|
||
}
|
||
|
||
|
||
def refusal_basis(mode: str, raw: Mapping[str, Any], settings: Mapping[str, Any]) -> tuple[list[str], str]:
|
||
"""还没算就算不了时(分析卡把随数据变化的调用错误记成算不了的结论)结论里记的阈值和判定标准:同这个模式自己算不了时
|
||
的那一份。"""
|
||
used = list(USED[mode]) + (["max_drill_depth"] if mode != "factor" and raw.get("parent") is not None else [])
|
||
if mode == "additive":
|
||
summed = raw.get("headline") is None and raw.get("parent") is None
|
||
return used, ph.criterion_additive(settings, top_k_max=None, summed=summed)
|
||
return used, ph.criterion_ratio(settings) if mode == "ratio" else ph.criterion_factor(settings)
|
||
|
||
|
||
@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,
|
||
reserved: Sequence[str] = ()) -> tuple[dict[str, Any], Snap, Snap, dict[str, str], list[str]]:
|
||
"""一份或两份快照:(规范化的参数, 基期快照, 本期快照, {角色: 列名}, 维度列)。roles 是 [(参数名, 角色的叫法)];reserved 是
|
||
结果表固定的列名,维度列不能叫这些。"""
|
||
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", "基期的查询编号"), "基期的查询编号", role="base")
|
||
target = env.inputs.snap(need(raw, "target_query", "本期的查询编号"), "本期的查询编号",
|
||
role="target")
|
||
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, "维度列")
|
||
for c in by:
|
||
reserved_check(c, reserved, "维度列")
|
||
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])}",
|
||
reason=f"期次列「{period}」里没有{what}「{ph.key_text([wanted])}」")
|
||
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 '比率'}的变化拆解,"
|
||
"下钻要接在同一种拆解后面")
|
||
# 上一层先记进输入:它没做成、这期没有那一段时(分析卡记成算不了的结论),复算也要按内容哈希取回它、走到同一处
|
||
env.inputs.parent(name, content_hash(canonical_json(found)))
|
||
if found.get("status") != "ok" or not isinstance(found.get("table"), Mapping):
|
||
why = f"({found['reason']})" if isinstance(found.get("reason"), str) and found["reason"] else ""
|
||
raise CallError("parent_refused", f"{name} 没有做成,不能在它下面下钻",
|
||
reason=f"上一层「{name}」没有做成{why},不能在它下面下钻")
|
||
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} 结果表里的段",
|
||
reason=f"上一层「{name}」里没有「{ph.key_text(key)}」这一段")
|
||
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。
|
||
比率本身(如客单价)不是占比,不进 rate——套上百分数会显示成 16,062.5%。"""
|
||
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")
|
||
|
||
|
||
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})是空的:换一格有值的",
|
||
reason=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 _period_complete(env: Env, params: dict[str, Any], snap: Snap, measure: str | None
|
||
) -> tuple[str | None, dict[str, str] | None]:
|
||
"""一份快照按期次分组时本期齐不齐(同波动基线那一套,comparable.period_complete;期次按 periods.parse_compared 认):
|
||
(不齐时 partial_target 的原因, 核对不了时的附带说明)。两份快照的在可比性检查里核过整期,不在这里核。"""
|
||
from app.analysis import periods as pd
|
||
|
||
if "base_query" in params:
|
||
return None, None
|
||
params["today"] = today_param(env)
|
||
period = params["period"]
|
||
i = snap.index(period)
|
||
values = [row[i] if i < len(row) else None for row in snap.rows]
|
||
parsed = pd.parse_compared(values, snap.sql, period, snap.dialect)
|
||
if parsed is None:
|
||
return None, ph.caveat("comparability", ph.NO_DATES)
|
||
items, grain = parsed
|
||
target = next(item for item, v in zip(items, values) if same_value(params["target"], v))
|
||
status, why, note = cmp.period_complete(snap, period, target, grain, measure, env.facts, env.book,
|
||
env.today) # type: ignore[arg-type]
|
||
if status == "short":
|
||
return f"{why},和完整的{pd.PER_WORD[grain]}比没有意义", None
|
||
return None, ph.caveat("comparability", note) if status == "unknown" and note else None
|
||
|
||
|
||
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 = list(USED["additive"])
|
||
params, bs, ts, cols, by = _sides(raw, env, [("measure", "度量列")], reserved=_FIXED_ADDITIVE)
|
||
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)
|
||
partial, note = _period_complete(env, params, ts, measure)
|
||
if partial is not None:
|
||
return refuse(env, params, used, "partial_target", partial,
|
||
criterion=ph.criterion_additive(env.settings, top_k_max=None, **criterion_args), caveats=caveats)
|
||
if note is not None:
|
||
caveats.append(note)
|
||
segs = _segments(side_b, side_t, Fraction(0))
|
||
if len(segs) < 2:
|
||
return refuse(env, params, used, "too_few_segments",
|
||
ph.say("按", ph.dims_text(dims), f"只有 {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, None, exact=True):
|
||
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])
|
||
columns = [*by, *_FIXED_ADDITIVE]
|
||
table = {"columns": columns,
|
||
"labels": {**dict(zip(by, dims)), "base": "基期", "target": "本期", "delta": "变化", "pct": "变化率",
|
||
"share": "占变化的比重", "flag": "说明"},
|
||
"rows": rows,
|
||
"column_types": column_types(columns, dims={c: side_t.snap.kind(c) for c in by}, texts=("flag",))}
|
||
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}
|
||
# 一份快照时 comparability 只会是本期核对不了的那句(同波动基线,只在证据面板);两份快照的可比性结论照旧进判定语
|
||
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,
|
||
formats=_rate_formats(columns),
|
||
named={k: CONV_PCT for k in ("pct_total", "top_share", "top_k_share")})
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# ratio
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def ratio(raw: Mapping[str, Any], env: Env) -> Any:
|
||
used = list(USED["ratio"])
|
||
params, bs, ts, cols, by = _sides(raw, env, [("numerator", "分子列"), ("denominator", "分母列")],
|
||
reserved=_FIXED_RATIO)
|
||
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)
|
||
partial, note = _period_complete(env, params, ts, den)
|
||
if partial is not None:
|
||
return refuse(env, params, used, "partial_target", partial, 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",
|
||
ph.say("按", ph.dims_text(dims), f"只有 {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, target_snap=ts, note=note)
|
||
|
||
|
||
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, *, target_snap: Snap,
|
||
note: Mapping[str, str] | None = None) -> 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, None, exact=True):
|
||
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:
|
||
# 判在结构变化上(mix_driven)时判定语正文已经说了结构变化、点了这一段,附带说明只说各段和整体反着变
|
||
star = max(live, key=lambda x: sign * by_key[x.key][4])
|
||
caveats.append(ph.caveat("simpson", ph.simpson_text(label, delta > 0, ph.key_text(star.key),
|
||
by_key[star.key][0], by_key[star.key][1],
|
||
shift=code != "mix_driven")))
|
||
if small:
|
||
caveats.append(ph.caveat("small_denominator", ph.small_denominator_text(
|
||
[ph.key_text(x.key) for x in small], den_label, int(env.settings["min_denominator"]))))
|
||
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))
|
||
if note is not None:
|
||
caveats.append(dict(note))
|
||
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,不计入"])
|
||
columns = [*by, *_FIXED_RATIO]
|
||
table = {"columns": columns,
|
||
"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,
|
||
"column_types": column_types(columns, dims={c: target_snap.kind(c) for c in by}, texts=("flag",))}
|
||
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}
|
||
# 显示格式按「这一段(这个数)是占比还是带量纲的比率」分:
|
||
# - 各段分子都不超过分母时,整体和各段的比率是个占比(退款率):比率按百分数,结构 / 自身 / 合计影响是比率的变化,
|
||
# 按百分点
|
||
# - 否则比率本身带量纲(客单价,元 / 单,可以大于 1):比率照原值;影响也是同一个量纲的变化(元 / 单),照原值,
|
||
# 不能乘一百说成「个百分点」——否则判定语写「客单价下降 9.89」、表里同一件事写「-988.7 个百分点」
|
||
ratio_rate = ("r_base", "r_target") if proportion else ()
|
||
points = ("mix", "rate", "total") if proportion else ()
|
||
named_points = ("delta", "mix_total", "rate_total") if proportion else ()
|
||
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,
|
||
formats=_rate_formats(columns, rate=("w_base", "w_target") + ratio_rate, points=points),
|
||
named={k: CONV_POINTS for k in named_points}
|
||
| ({k: CONV_PCT for k in ("r_base_total", "r_target_total")} if proportion else {})
|
||
| {"mix_share": CONV_PCT, "rate_share": CONV_PCT})
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 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 = list(USED["factor"])
|
||
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}」是空的:乘积、因子都要有值",
|
||
reason=f"{'基期' if when == 'base' else '本期'}的「{col}」那一格是空的({snap.ref} 第 {r} 行)")
|
||
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)
|
||
partial, note = _period_complete(env, params, ts, product)
|
||
if partial is not None:
|
||
return refuse(env, params, used, "partial_target", partial, 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))
|
||
if note is not None:
|
||
caveats.append(note)
|
||
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])
|
||
columns = ["factor", "base", "target", "pct", "contrib", "share"]
|
||
table = {"columns": columns,
|
||
"labels": {"factor": "因子", "base": "基期", "target": "本期", "pct": "变化率", "contrib": "贡献",
|
||
"share": "占变化的比重"},
|
||
"rows": rows,
|
||
"column_types": column_types(columns, texts=("factor",))}
|
||
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, skip=("comparability",) if outcome is None else ()),
|
||
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",
|
||
"ratio", "ratio_effects", "refusal_basis"]
|