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.
- fxtensor_bayesnet/__init__.py +9 -0
- fxtensor_bayesnet/__main__.py +5 -0
- fxtensor_bayesnet/cli.py +148 -0
- fxtensor_bayesnet/config.py +136 -0
- fxtensor_bayesnet/history.py +90 -0
- fxtensor_bayesnet/i18n.py +74 -0
- fxtensor_bayesnet/inference.py +144 -0
- fxtensor_bayesnet/network.py +133 -0
- fxtensor_bayesnet/plot.py +407 -0
- fxtensor_bayesnet/report.py +66 -0
- fxtensor_bayesnet-0.9.0.dist-info/METADATA +346 -0
- fxtensor_bayesnet-0.9.0.dist-info/RECORD +16 -0
- fxtensor_bayesnet-0.9.0.dist-info/WHEEL +5 -0
- fxtensor_bayesnet-0.9.0.dist-info/entry_points.txt +2 -0
- fxtensor_bayesnet-0.9.0.dist-info/licenses/LICENSE +21 -0
- fxtensor_bayesnet-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -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__"]
|
fxtensor_bayesnet/cli.py
ADDED
|
@@ -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
|