fxtensor-bayesnet 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ """設定ファイル駆動の離散ベイジアンネットワーク推論(fxtensor-salmon 使用)。"""
2
+ __version__ = "0.9.0"
3
+
4
+ from .config import ConfigError, config_hash, dump_config, load_config, topo_order, validate # noqa: E402
5
+ from .inference import Engine, run # noqa: E402
6
+ from .network import Net # noqa: E402
7
+
8
+ __all__ = ["ConfigError", "Engine", "Net", "config_hash", "dump_config", "load_config",
9
+ "run", "topo_order", "validate", "__version__"]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,148 @@
1
+ """fxtensor-bayesnet — 設定ファイル駆動の離散ベイジアンネットワーク推論(fxtensor-salmon 使用)
2
+
3
+ 使い方:
4
+ fxtensor-bayesnet [--config PATH] [options]
5
+ --config PATH 設定ファイル(既定: カレントディレクトリの config.json)
6
+ --out-dir DIR results.json / results.md / model.mmd の出力先(既定: 設定ファイルと同じフォルダ)
7
+ --history-dir DIR スナップショットの保存先(既定: 設定ファイルと同じフォルダの history/)
8
+ --no-write results.json / results.md / model.mmd を書かず、標準出力だけにする
9
+ --md 標準出力を Markdown にする(既定は JSON)
10
+ --check 設定の検証だけ行う(エラーなら終了コード 2)
11
+ --snapshot 設定と結果を <history-dir>/<as_of>/ に保存(既存なら --force が必要)
12
+ --force --snapshot の上書きを許可
13
+ --diff [PATH] 設定(と結果)の前回スナップショットとの差分を表示
14
+ PATH 省略時は <history-dir> の as_of より前で最新のもの
15
+ --format-config 設定ファイルを読みやすい形(核の1行=1行)に整形して書き戻す
16
+ --plot 図を <out-dir> に出力(network / marginals / updates。--diff と併用で diff)
17
+ matplotlib が必要: pip install 'fxtensor-bayesnet[plot]'
18
+ --plot-format F.. 図の形式 png / svg(複数可。既定: png)
19
+ --plot-dpi N PNG の解像度(既定: 150)
20
+ --lang ja|en Markdown と図の見出し・凡例の言語(既定: ja。results.json のキーは固定)
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import sys
28
+
29
+ from . import __version__
30
+ from .config import ConfigError, config_hash, dump_config, load_config
31
+ from .history import diff_configs, diff_results, previous_snapshot, snapshot
32
+ from .inference import run
33
+ from .report import render_markdown, render_mermaid
34
+
35
+
36
+ def build_parser():
37
+ ap = argparse.ArgumentParser(prog="fxtensor-bayesnet", description=__doc__,
38
+ formatter_class=argparse.RawDescriptionHelpFormatter)
39
+ ap.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
40
+ ap.add_argument("--config", default="config.json")
41
+ ap.add_argument("--out-dir", default=None)
42
+ ap.add_argument("--history-dir", default=None)
43
+ ap.add_argument("--no-write", action="store_true")
44
+ ap.add_argument("--md", action="store_true")
45
+ ap.add_argument("--check", action="store_true")
46
+ ap.add_argument("--snapshot", action="store_true")
47
+ ap.add_argument("--force", action="store_true")
48
+ ap.add_argument("--diff", nargs="?", const="__prev__")
49
+ ap.add_argument("--format-config", action="store_true")
50
+ ap.add_argument("--plot", action="store_true")
51
+ ap.add_argument("--plot-format", nargs="+", choices=["png", "svg"], default=["png"])
52
+ ap.add_argument("--plot-dpi", type=int, default=150)
53
+ ap.add_argument("--lang", choices=["ja", "en"], default="ja")
54
+ return ap
55
+
56
+
57
+ def _plot_module():
58
+ """図のモジュールを読み込み、matplotlib の有無を先に確かめる。"""
59
+ from . import plot
60
+ plot._mpl()
61
+ return plot
62
+
63
+
64
+ def main(argv=None):
65
+ a = build_parser().parse_args(argv)
66
+ base = os.path.dirname(os.path.abspath(a.config))
67
+ out_dir = a.out_dir or base
68
+ history_dir = a.history_dir or os.path.join(base, "history")
69
+
70
+ try:
71
+ cfg = load_config(a.config)
72
+ except FileNotFoundError:
73
+ print(f"設定ファイルがありません: {a.config}", file=sys.stderr)
74
+ return 2
75
+ except json.JSONDecodeError as e:
76
+ print(f"JSON の構文エラー: {a.config}: {e}", file=sys.stderr)
77
+ return 2
78
+ except ConfigError as e:
79
+ print(str(e), file=sys.stderr)
80
+ return 2
81
+
82
+ plot = None
83
+ if a.plot and not a.check:
84
+ try:
85
+ plot = _plot_module()
86
+ except Exception as e: # PlotError
87
+ print(str(e), file=sys.stderr)
88
+ return 3
89
+
90
+ if a.format_config:
91
+ with open(a.config, "w", encoding="utf-8") as f:
92
+ f.write(dump_config(cfg))
93
+ print(f"整形しました: {a.config}", file=sys.stderr)
94
+ if a.check:
95
+ print(f"設定OK: {a.config}(変数 {len(cfg['variables'])}、核 {len(cfg['kernels'])}、ハッシュ {config_hash(cfg)})")
96
+ return 0
97
+ if a.diff:
98
+ path = previous_snapshot(history_dir, cfg["as_of"]) if a.diff == "__prev__" else a.diff
99
+ if not path:
100
+ print(f"比較対象のスナップショットがありません({history_dir}/<日付>/config.json)")
101
+ return 0
102
+ with open(path, encoding="utf-8") as f:
103
+ old = json.load(f)
104
+ print(f"# 設定の差分: {path} → {a.config}")
105
+ print("\n".join(diff_configs(old, cfg)))
106
+ rpath = os.path.join(os.path.dirname(path), "results.json")
107
+ if os.path.exists(rpath):
108
+ with open(rpath, encoding="utf-8") as f:
109
+ old_res = json.load(f)
110
+ new_res = run(cfg)
111
+ print(f"\n# 結果の差分(0.1pt 以上): {rpath} → 現在の設定で再計算")
112
+ print("\n".join(diff_results(old_res, new_res)))
113
+ if plot:
114
+ for fmt in a.plot_format:
115
+ out = plot.plot_diff(old_res, new_res, cfg, os.path.join(out_dir, f"diff.{fmt}"), a.plot_dpi,
116
+ old_label=old_res.get("基準日"), new_label=cfg["as_of"], lang=a.lang)
117
+ if out:
118
+ print(f"図を保存: {out}", file=sys.stderr)
119
+ elif plot:
120
+ print("前回の results.json がないため変化量の図は描けません", file=sys.stderr)
121
+ return 0
122
+
123
+ res = run(cfg)
124
+ md = render_markdown(res, cfg, a.lang)
125
+ if not a.no_write:
126
+ os.makedirs(out_dir, exist_ok=True)
127
+ with open(os.path.join(out_dir, "results.json"), "w", encoding="utf-8") as f:
128
+ json.dump(res, f, ensure_ascii=False, indent=2)
129
+ with open(os.path.join(out_dir, "results.md"), "w", encoding="utf-8") as f:
130
+ f.write(md)
131
+ with open(os.path.join(out_dir, "model.mmd"), "w", encoding="utf-8") as f:
132
+ f.write(render_mermaid(cfg))
133
+ if plot:
134
+ for out in plot.plot_all(res, cfg, out_dir, a.plot_format, a.plot_dpi, lang=a.lang):
135
+ print(f"図を保存: {out}", file=sys.stderr)
136
+ if a.snapshot:
137
+ try:
138
+ d = snapshot(cfg, res, history_dir, a.force)
139
+ except FileExistsError as e:
140
+ print(str(e), file=sys.stderr)
141
+ return 1
142
+ print(f"スナップショット保存: {d}", file=sys.stderr)
143
+ print(md if a.md else json.dumps(res, ensure_ascii=False, indent=2))
144
+ return 0
145
+
146
+
147
+ if __name__ == "__main__":
148
+ sys.exit(main())
@@ -0,0 +1,136 @@
1
+ """設定ファイルの読み込み・検証・整形・ハッシュ。"""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import re
7
+ from itertools import product
8
+
9
+ import numpy as np
10
+
11
+ TOL = 1e-6
12
+
13
+
14
+ class ConfigError(ValueError):
15
+ """設定ファイルの内容が不正なときに送出される。"""
16
+
17
+
18
+ def load_config(path):
19
+ """JSON 設定を読み込み、検証してから返す。"""
20
+ with open(path, encoding="utf-8") as f:
21
+ cfg = json.load(f)
22
+ validate(cfg)
23
+ return cfg
24
+
25
+
26
+ def topo_order(cfg):
27
+ """変数のトポロジカル順(親が先)。循環があれば ConfigError。"""
28
+ vars_ = list(cfg["variables"])
29
+ kernels = cfg.get("kernels", {})
30
+ parents = {v: kernels[v]["parents"] if v in kernels else [] for v in vars_}
31
+ order, seen, temp = [], set(), set()
32
+
33
+ def visit(v):
34
+ if v in seen:
35
+ return
36
+ if v in temp:
37
+ raise ConfigError(f"循環があります: {v}")
38
+ temp.add(v)
39
+ for p in parents[v]:
40
+ visit(p)
41
+ temp.discard(v)
42
+ seen.add(v)
43
+ order.append(v)
44
+
45
+ for v in vars_:
46
+ visit(v)
47
+ return order
48
+
49
+
50
+ def validate(cfg):
51
+ """設定を検査する。問題があればすべてまとめて ConfigError で報告する。"""
52
+ errs = []
53
+ if "as_of" not in cfg:
54
+ errs.append("as_of(基準日 YYYY-MM-DD)がありません")
55
+ V = cfg.get("variables", {})
56
+ P = cfg.get("priors", {})
57
+ K = cfg.get("kernels", {})
58
+ if not V:
59
+ errs.append("variables が空です")
60
+ for v, spec in V.items():
61
+ labs = spec.get("labels", [])
62
+ if len(labs) < 2 or len(set(labs)) != len(labs):
63
+ errs.append(f"{v}: labels が不正 {labs}")
64
+ if (v in P) == (v in K):
65
+ errs.append(f"{v}: priors(根)か kernels(子)のどちらか一方にだけ定義してください")
66
+ for v in list(P) + list(K):
67
+ if v not in V:
68
+ errs.append(f"{v}: variables に未定義")
69
+
70
+ def check_dist(where, p, n):
71
+ if not isinstance(p, list) or len(p) != n:
72
+ errs.append(f"{where}: 長さ {n} のリストが必要({p})")
73
+ return
74
+ a = np.asarray(p, dtype=float)
75
+ if (a < -TOL).any():
76
+ errs.append(f"{where}: 負の確率 {p}")
77
+ if abs(a.sum() - 1) > TOL:
78
+ errs.append(f"{where}: 和が1でない(和={a.sum():.6f}){p}")
79
+
80
+ for v, spec in P.items():
81
+ if v in V:
82
+ check_dist(f"priors.{v}", spec.get("p"), len(V[v]["labels"]))
83
+ for v, spec in K.items():
84
+ if v not in V:
85
+ continue
86
+ pars = spec.get("parents", [])
87
+ bad = [p for p in pars if p not in V]
88
+ if bad:
89
+ errs.append(f"kernels.{v}: 未定義の親 {bad}")
90
+ continue
91
+ cpt = spec.get("cpt", {})
92
+ want = {"|".join(c) for c in product(*[V[p]["labels"] for p in pars])}
93
+ have = set(cpt)
94
+ for k in sorted(want - have):
95
+ errs.append(f"kernels.{v}: 行が足りない「{k}」")
96
+ for k in sorted(have - want):
97
+ errs.append(f"kernels.{v}: 余分な行「{k}」(親のラベルと一致しない)")
98
+ for k in sorted(want & have):
99
+ check_dist(f"kernels.{v}[{k}]", cpt[k], len(V[v]["labels"]))
100
+ for name, ov in cfg.get("scenarios", {}).items():
101
+ for v, p in ov.items():
102
+ if v not in P:
103
+ errs.append(f"scenarios.{name}: {v} は根の変数ではない")
104
+ else:
105
+ check_dist(f"scenarios.{name}.{v}", p, len(V[v]["labels"]))
106
+ for ob in cfg.get("observations", []):
107
+ if ob.get("var") not in V or ob.get("label") not in V.get(ob.get("var"), {}).get("labels", []):
108
+ errs.append(f"observations: 不正な観測 {ob}")
109
+ for f in ob.get("focus", []):
110
+ if f not in V:
111
+ errs.append(f"observations: focus {f} が未定義")
112
+ for o in cfg.get("outputs", []):
113
+ if o not in V:
114
+ errs.append(f"outputs: {o} が未定義")
115
+ if not errs:
116
+ try:
117
+ topo_order(cfg)
118
+ except ConfigError as e:
119
+ errs.append(str(e))
120
+ if errs:
121
+ raise ConfigError("設定エラー:\n - " + "\n - ".join(errs))
122
+
123
+
124
+ def config_hash(cfg):
125
+ """設定内容の短いハッシュ(キー順に依存しない)。"""
126
+ return hashlib.sha256(json.dumps(cfg, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:12]
127
+
128
+
129
+ def dump_config(cfg):
130
+ """核の1行・ラベル列を1行にまとめた読みやすい JSON(差分が行単位で読める)。"""
131
+ s = json.dumps(cfg, ensure_ascii=False, indent=2)
132
+ s = re.sub(r"\[\s*((?:-?[0-9.eE+-]+\s*,\s*)*-?[0-9.eE+-]+)\s*\]",
133
+ lambda m: "[" + ", ".join(x.strip() for x in m.group(1).split(",")) + "]", s)
134
+ s = re.sub(r'\[\s*((?:"[^"\n]*"\s*,\s*)*"[^"\n]*")\s*\]',
135
+ lambda m: "[" + ", ".join(x.strip() for x in re.findall(r'"[^"\n]*"', m.group(1))) + "]", s)
136
+ return s + "\n"
@@ -0,0 +1,90 @@
1
+ """日次スナップショットと差分。"""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+
8
+ from .config import dump_config
9
+
10
+
11
+ def snapshot(cfg, res, history_dir, force=False):
12
+ d = os.path.join(history_dir, cfg["as_of"])
13
+ if os.path.exists(d) and not force:
14
+ raise FileExistsError(f"{d} は既にあります(上書きするなら --force)")
15
+ os.makedirs(d, exist_ok=True)
16
+ with open(os.path.join(d, "config.json"), "w", encoding="utf-8") as f:
17
+ f.write(dump_config(cfg))
18
+ with open(os.path.join(d, "results.json"), "w", encoding="utf-8") as f:
19
+ json.dump(res, f, ensure_ascii=False, indent=2)
20
+ return d
21
+
22
+
23
+ def previous_snapshot(history_dir, as_of):
24
+ """history_dir のうち as_of より前で最新の日付の config.json(なければ None)。"""
25
+ if not os.path.isdir(history_dir):
26
+ return None
27
+ dates = sorted(d for d in os.listdir(history_dir)
28
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", d) and d < as_of
29
+ and os.path.exists(os.path.join(history_dir, d, "config.json")))
30
+ return os.path.join(history_dir, dates[-1], "config.json") if dates else None
31
+
32
+
33
+ def diff_configs(old, new):
34
+ out = []
35
+ ov, nv = old.get("variables", {}), new.get("variables", {})
36
+ for v in sorted(set(ov) | set(nv)):
37
+ if v not in ov:
38
+ out.append(f"+ 変数追加 {v}: {nv[v]['labels']}")
39
+ elif v not in nv:
40
+ out.append(f"- 変数削除 {v}")
41
+ elif ov[v]["labels"] != nv[v]["labels"]:
42
+ out.append(f"~ ラベル変更 {v}: {ov[v]['labels']} → {nv[v]['labels']}")
43
+ for v in sorted(set(old.get("priors", {})) | set(new.get("priors", {}))):
44
+ a = old.get("priors", {}).get(v, {}).get("p")
45
+ b = new.get("priors", {}).get(v, {}).get("p")
46
+ if a != b:
47
+ out.append(f"~ 事前分布 {v}: {a} → {b}")
48
+ ok, nk = old.get("kernels", {}), new.get("kernels", {})
49
+ for v in sorted(set(ok) | set(nk)):
50
+ if v not in ok:
51
+ out.append(f"+ 核追加 {v} | {nk[v]['parents']}")
52
+ continue
53
+ if v not in nk:
54
+ out.append(f"- 核削除 {v}")
55
+ continue
56
+ if ok[v]["parents"] != nk[v]["parents"]:
57
+ out.append(f"~ 経路変更 {v}: 親 {ok[v]['parents']} → {nk[v]['parents']}")
58
+ continue
59
+ for row in nk[v]["cpt"]:
60
+ if ok[v]["cpt"].get(row) != nk[v]["cpt"][row]:
61
+ out.append(f"~ 核 {v}[{row}]: {ok[v]['cpt'].get(row)} → {nk[v]['cpt'][row]}")
62
+ if ok[v].get("reason") != nk[v].get("reason"):
63
+ out.append(f"~ 理由文 {v} を更新")
64
+ os_, ns = old.get("scenarios", {}), new.get("scenarios", {})
65
+ for s in sorted(set(os_) | set(ns)):
66
+ if os_.get(s) != ns.get(s):
67
+ out.append(f"~ シナリオ {s}: {os_.get(s)} → {ns.get(s)}")
68
+ if old.get("observations") != new.get("observations"):
69
+ out.append("~ observations を変更")
70
+ if old.get("outputs") != new.get("outputs"):
71
+ out.append(f"~ outputs: {old.get('outputs')} → {new.get('outputs')}")
72
+ return out or ["(設定の変更なし)"]
73
+
74
+
75
+ def diff_results(old, new, threshold_pt=0.1):
76
+ out = []
77
+ for sc, r in new.get("シナリオ", {}).items():
78
+ o = old.get("シナリオ", {}).get(sc)
79
+ if not o:
80
+ out.append(f"+ シナリオ {sc}(前回なし)")
81
+ continue
82
+ for v, d in r["周辺"].items():
83
+ od = o["周辺"].get(v)
84
+ if od is None:
85
+ out.append(f"+ {sc} / {v}(前回なし)")
86
+ continue
87
+ delta = {lab: round((d[lab] - od.get(lab, 0)) * 100, 1) for lab in d}
88
+ if any(abs(x) >= threshold_pt for x in delta.values()):
89
+ out.append(f"~ {sc} / {v}: " + ", ".join(f"{lab} {x:+.1f}pt" for lab, x in delta.items()))
90
+ return out or ["(結果の変化なし)"]
@@ -0,0 +1,74 @@
1
+ """表示用の文言(Markdown と図)。results.json のキーは言語によらず固定。"""
2
+ from __future__ import annotations
3
+
4
+ LANGS = ("ja", "en")
5
+
6
+ TEXT = {
7
+ "ja": {
8
+ "result_title": "# 結果(基準日 {as_of}{horizon})",
9
+ "horizon_sep": "、",
10
+ "scenario": "シナリオ",
11
+ "base": "基本",
12
+ "bayes_header": "## ベイズ更新(基本)",
13
+ "obs_header": "### {obs} を観測(観測前の確率 {p:.1f}%)",
14
+ "ranking": "動きの大きさ順(TV)",
15
+ "delta": "差",
16
+ "checks": "検算",
17
+ "network_title": "ネットワーク構造",
18
+ "kind_root": "根(事前分布)",
19
+ "kind_middle": "中間変数",
20
+ "kind_output": "出力変数",
21
+ "marginals_title": "周辺分布のシナリオ比較(基準日 {as_of})",
22
+ "prior": "事前",
23
+ "posterior": "事後",
24
+ "obs_annot": "観測: {obs}(観測前の確率 {p:.1f}%)",
25
+ "updates_title": "ベイズ更新:注目変数の事前・事後",
26
+ "change_pt": "変化(pt)",
27
+ "no_change": "変化なし",
28
+ "diff_title": "周辺分布の変化:{old} → {new}",
29
+ "previous": "前回",
30
+ "current": "今回",
31
+ },
32
+ "en": {
33
+ "result_title": "# Results (as of {as_of}{horizon})",
34
+ "horizon_sep": ", ",
35
+ "scenario": "Scenario",
36
+ "base": "Base",
37
+ "bayes_header": "## Bayesian updates (base)",
38
+ "obs_header": "### Observed {obs} (prior probability {p:.1f}%)",
39
+ "ranking": "Ranking by movement (TV)",
40
+ "delta": "change",
41
+ "checks": "Checks",
42
+ "network_title": "Network structure",
43
+ "kind_root": "Root (prior)",
44
+ "kind_middle": "Intermediate",
45
+ "kind_output": "Output",
46
+ "marginals_title": "Marginals by scenario (as of {as_of})",
47
+ "prior": "Prior",
48
+ "posterior": "Posterior",
49
+ "obs_annot": "Observed: {obs} (prior probability {p:.1f}%)",
50
+ "updates_title": "Bayesian update: prior vs posterior of focus variables",
51
+ "change_pt": "Change (pt)",
52
+ "no_change": "No change",
53
+ "diff_title": "Change in marginals: {old} → {new}",
54
+ "previous": "previous",
55
+ "current": "current",
56
+ },
57
+ }
58
+
59
+
60
+ def t(lang, key, **kw):
61
+ if lang not in TEXT:
62
+ raise ValueError(f"unsupported language: {lang} (choose from {', '.join(LANGS)})")
63
+ s = TEXT[lang][key]
64
+ return s.format(**kw) if kw else s
65
+
66
+
67
+ def scenario_name(lang, name):
68
+ """結果内部のシナリオ名「基本」を表示言語に合わせる。"""
69
+ return t(lang, "base") if name == "基本" else name
70
+
71
+
72
+ def obs_name(key):
73
+ """結果キー「var=label を観測」から「var=label」を取り出す。"""
74
+ return key.replace(" を観測", "")
@@ -0,0 +1,144 @@
1
+ """推論:シナリオ別の周辺分布、観測によるベイズ更新、要因の逆算、einsum による検算。"""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ from fxtensor_salmon import FXTensor
6
+
7
+ from .config import config_hash
8
+ from .network import Net, self_test_graph_extend, wiring
9
+
10
+ MAX_JOINT = 2_000_000 # 同時分布の要素数がこれを超えたら祖先集合ごとの計算に切り替え
11
+
12
+
13
+ def as_dict(labels, arr, nd=4):
14
+ return {lab: round(float(x), nd) for lab, x in zip(labels, arr)}
15
+
16
+
17
+ def movement(prior, post, labels):
18
+ """事前→事後の動き(差・全変動距離・KL)。"""
19
+ p = np.asarray(prior, float)
20
+ q = np.array([post[lab] for lab in labels])
21
+ nz = q > 0
22
+ return {
23
+ "差(pt)": {lab: round(float((q[i] - p[i]) * 100), 1) for i, lab in enumerate(labels)},
24
+ "全変動距離TV": round(float(0.5 * np.abs(q - p).sum()), 4),
25
+ "KL(事後||事前)[nat]": round(float(np.sum(q[nz] * np.log(q[nz] / np.maximum(p[nz], 1e-300)))), 4),
26
+ }
27
+
28
+
29
+ class Engine:
30
+ def __init__(self, net, priors, max_joint=MAX_JOINT):
31
+ self.net, self.priors = net, priors
32
+ self.mode = "joint" if net.joint_size() <= max_joint else "ancestral"
33
+ self.check_err = 0.0
34
+ if self.mode == "joint":
35
+ self.joint, self.names = net.build_joint(priors)
36
+ self.labs = [net.labels[v] for v in self.names]
37
+ perm = [net.order.index(v) for v in self.names]
38
+ self.check_err = float(np.max(np.abs(
39
+ np.transpose(self.joint.data, perm) - net.brute_force_joint(priors).transpose(perm)
40
+ )))
41
+
42
+ def marginal(self, v):
43
+ """周辺分布(joint: ライブラリの配線核で discard / ancestral: 祖先集合の einsum)。"""
44
+ if self.mode == "joint":
45
+ return self.joint.composition(wiring(self.labs, [self.names.index(v)])).data
46
+ return self.net.einsum_query(self.priors, [v])
47
+
48
+ def pair(self, a, b):
49
+ """I → a ⊗ b の同時状態(FXTensor)。"""
50
+ net = self.net
51
+ if self.mode == "joint":
52
+ return self.joint.composition(wiring(self.labs, [self.names.index(a), self.names.index(b)]))
53
+ return FXTensor([[], [net.labels[a], net.labels[b]]], data=net.einsum_query(self.priors, [a, b]))
54
+
55
+ def condition(self, obs, label):
56
+ """観測 obs=label の下での各変数の事後((obs, v) の2変数状態を conditionalization(2))。"""
57
+ net = self.net
58
+ ev = float(self.marginal(obs)[net.labels[obs].index(label)])
59
+ if ev <= 0:
60
+ raise ValueError(f"観測 {obs}={label} の確率が 0 のため条件付けできません")
61
+ post = {}
62
+ for v in net.order:
63
+ if v == obs:
64
+ continue
65
+ cond = self.pair(obs, v).conditionalization(2) # obs → v
66
+ post[v] = as_dict(net.labels[v], cond.data[cond.get_label_index(0, label)])
67
+ return ev, post
68
+
69
+ def inversion_roots(self, obs, label):
70
+ """核 (根の全体)→obs を einsum で作り FXTensor.bayesian_inversion で根の事後を出す(要因の逆算)。"""
71
+ net = self.net
72
+ kdata = net.einsum_query(self.priors, [obs], free_roots=True)
73
+ k = FXTensor([[net.labels[r] for r in net.roots], [net.labels[obs]]], data=kdata)
74
+ assert k.is_markov()
75
+ inv = k.bayesian_inversion(net.prior_state(self.priors))
76
+ row = inv.data[inv.get_label_index(0, label)]
77
+ out = {}
78
+ for i, r in enumerate(net.roots):
79
+ axes = tuple(j for j in range(len(net.roots)) if j != i)
80
+ out[r] = as_dict(net.labels[r], row.sum(axis=axes) if axes else row)
81
+ return out
82
+
83
+
84
+ def run(cfg, max_joint=MAX_JOINT):
85
+ """設定を計算して結果の辞書を返す。検算で食い違いがあれば AssertionError。"""
86
+ self_test_graph_extend()
87
+ net = Net(cfg)
88
+ base_pri = {v: cfg["priors"][v]["p"] for v in net.roots}
89
+ res = {
90
+ "基準日": cfg["as_of"], "ホライズン": cfg.get("horizon"), "注意": cfg.get("note"),
91
+ "設定ハッシュ": config_hash(cfg),
92
+ "事前分布(基本)": {v: as_dict(net.labels[v], base_pri[v]) for v in net.roots},
93
+ "シナリオ": {}, "ベイズ更新(基本)": {}, "検算": {},
94
+ }
95
+ checks = {"計算モード": None, "同時分布の要素数": net.joint_size(),
96
+ "einsum(全同時)との最大差": 0.0, "einsum(祖先集合・周辺)との最大差": 0.0,
97
+ "einsum(条件付け)との最大差": 0.0, "bayesian_inversionとconditionalizationの最大差": 0.0}
98
+ outputs = cfg.get("outputs") or [v for v in net.order if v not in net.roots]
99
+ for name, ov in {"基本": {}, **cfg.get("scenarios", {})}.items():
100
+ pri = {**base_pri, **ov}
101
+ eng = Engine(net, pri, max_joint)
102
+ checks["計算モード"] = eng.mode
103
+ checks["einsum(全同時)との最大差"] = max(checks["einsum(全同時)との最大差"], eng.check_err)
104
+ marg = {}
105
+ for v in outputs:
106
+ m = eng.marginal(v)
107
+ e = net.einsum_query(pri, [v])
108
+ checks["einsum(祖先集合・周辺)との最大差"] = max(checks["einsum(祖先集合・周辺)との最大差"],
109
+ float(np.max(np.abs(m - e))))
110
+ marg[v] = as_dict(net.labels[v], m)
111
+ res["シナリオ"][name] = {"上書き": ov, "周辺": marg}
112
+
113
+ eng = Engine(net, base_pri, max_joint)
114
+ for ob in cfg.get("observations", []):
115
+ v, lab = ob["var"], ob["label"]
116
+ ev, post = eng.condition(v, lab)
117
+ for w, d in post.items(): # einsum 検算(祖先集合で (obs, w) を直接)
118
+ pr = net.einsum_query(base_pri, [v, w])
119
+ ref = pr[net.labels[v].index(lab)] / pr[net.labels[v].index(lab)].sum()
120
+ checks["einsum(条件付け)との最大差"] = max(checks["einsum(条件付け)との最大差"],
121
+ float(np.max(np.abs(np.array(list(d.values())) - ref))))
122
+ inv = eng.inversion_roots(v, lab)
123
+ for r, d in inv.items():
124
+ if r != v:
125
+ checks["bayesian_inversionとconditionalizationの最大差"] = max(
126
+ checks["bayesian_inversionとconditionalizationの最大差"],
127
+ max(abs(d[lab_] - post[r][lab_]) for lab_ in d))
128
+ focus = ob.get("focus") or net.roots
129
+ prior_marg = {w: eng.marginal(w) for w in focus}
130
+ mv = {w: movement(prior_marg[w], post[w], net.labels[w]) for w in focus if w != v}
131
+ ranking = sorted(mv, key=lambda w: -mv[w]["全変動距離TV"])
132
+ res["ベイズ更新(基本)"][f"{v}={lab} を観測"] = {
133
+ "観測前の確率": round(ev, 4), "事後": post,
134
+ "注目変数の事前": {w: as_dict(net.labels[w], prior_marg[w]) for w in focus},
135
+ "注目変数の動き": mv, "動きの大きさ順(TV)": ranking,
136
+ }
137
+ for k in list(checks):
138
+ if isinstance(checks[k], float):
139
+ checks[k] = float(f"{checks[k]:.3g}")
140
+ checks["備考"] = "事後は小数4桁に丸めて出力するため、条件付けの差は丸め誤差(<5e-5)程度"
141
+ res["検算"] = checks
142
+ assert checks["einsum(全同時)との最大差"] < 1e-12 and checks["einsum(祖先集合・周辺)との最大差"] < 1e-12
143
+ assert checks["einsum(条件付け)との最大差"] < 1e-4 and checks["bayesian_inversionとconditionalizationの最大差"] < 1e-3
144
+ return res