#!/usr/bin/env python3 """sim/results/*.json → report/results.md と sim/results/predictions_check.json。 数字は全部 JSON から読む(手で書かない)。予測(sim/predictions.json)は回す前に書いたもので、 ここでは当たり/外れを機械的に判定するだけ。 """ from __future__ import annotations import argparse import json import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) # 公開名(結果表の見出しに使う) PUBLIC_NAME = "ひとつ変えてみる No.01 / sim-lab ep01" # AI開示の文(当社の標準文言・逐語)。このリポジトリの全 .md の先頭に置く。 DISCLOSURE = "株式会社DIGITのAIエージェントが運営しています。" LABELS = { "t030": "基準 t=0.30", "t033": "基準 t=0.33", "indiff010": "無頓着な住民 10%", "indiff030": "無頓着な住民 30%", "moveprob050": "引越し確率 0.5", "moveprob020": "引越し確率 0.2", "colors2_torus": "2色・ドーナツ型", "colors3_bounded": "3色・長方形", "colors3_torus": "3色・ドーナツ型", } GROUP_TITLES = { "baseline": "基準(2色・長方形・全員 t)", "sweep_t": "介入 (a) 許容 t の掃引 0.10〜0.60", "sweep_t_ext": "追加 許容 t 0.65〜0.80(仕様の外側)", "indifferent": "介入 (b) 無頓着な住民(t=0)を混ぜる・他の住民は t=0.30", "move_prob": "介入 (c) 引越し費用=不満でも動く確率・t=0.30", "colors_shape": "介入 (d) 色の数と街の形・t=0.30", } def label(key: str) -> str: if key in LABELS: return LABELS[key] if key.startswith("t") and key[1:].isdigit(): return f"t={int(key[1:]) / 100:.2f}" return key def load(results_dir: str) -> dict: with open(os.path.join(results_dir, "summary.json")) as f: return json.load(f) def find(summary: dict, key: str) -> dict: for g in summary["groups"].values(): if key in g: return g[key] raise KeyError(key) def fmt_range(a: dict, pct=False, nd=1) -> str: if pct: return f"{a['mean']*100:.{nd}f}%({a['min']*100:.{nd}f}〜{a['max']*100:.{nd}f})" return f"{a['mean']:.{nd}f}({a['min']:.{nd}f}〜{a['max']:.{nd}f})" def metric_value(summary: dict, cond: str, metric: str): s = find(summary, cond) base = find(summary, "t030") if metric == "identical_to_t030": return summary["checks"]["t030_equals_t033_all_seeds"] if metric == "converged_share": return s["converged_share"] if metric == "steps": return s["steps"]["mean"] if metric == "steps_ratio_to_baseline": return s["steps"]["mean"] / base["steps"]["mean"] if metric.endswith("_ratio_to_baseline"): m = metric[: -len("_ratio_to_baseline")] return s[m]["mean"] / base[m]["mean"] return s[metric]["mean"] def check_predictions(summary: dict, preds: dict) -> list: out = [] for p in preds["predictions"]: # 予測ファイルは条件のキーが確定する前に書いた。予測ファイルは書き換えず、名前の対応だけをここで取る。 alias = {"sweep_t010": "t010", "sweep_t060": "t060", "baseline_t030": "t030", "baseline_t033": "t033", "indiff_010": "indiff010", "indiff_030": "indiff030", "moveprob_020": "moveprob020"} cond = alias.get(p["condition"], p["condition"]) v = metric_value(summary, cond, p["metric"]) if p["kind"] == "bool": hit = bool(v) == bool(p["value"]) else: hit = p["lo"] - 1e-12 <= v <= p["hi"] + 1e-12 out.append({**p, "observed": v, "hit": hit}) return out def equivalence_classes(summary: dict, group: str) -> list: """同じグループ内で、全シードの最終格子が一致する条件の組。""" g = summary["groups"][group] keys = list(g.keys()) seen, classes = set(), [] for i, a in enumerate(keys): if a in seen: continue cls = [a] for b in keys[i + 1:]: if g[a]["final_grid_sha256"] == g[b]["final_grid_sha256"]: cls.append(b) seen.add(b) if len(cls) > 1: classes.append(cls) return classes def build_markdown(summary: dict, checks: list) -> str: c = summary["config"] L = [] L.append(f"> {DISCLOSURE}") L.append("") L.append(f"# {PUBLIC_NAME} シェリング分居モデル — 結果表") L.append("") L.append(f"生成: `sim/make_report.py` が `sim/results/summary.json`({c['generated_at']})から自動生成。**手で編集しない。**") L.append("") L.append(f"- モデル: {c['model']}") L.append(f"- 格子 {c['size']}×{c['size']}({c['cells']}区画)・空き {c['empty_cells']}区画・住民 {c['agents']}人・近傍 {c['neighbourhood']}") L.append(f"- 分離度 D: {c['district']}×{c['district']}区画の地区 {c['districts']}個で計算する多群の相違指数(Reardon & Firebaugh 2002。2色では Duncan & Duncan 1955 の D)") L.append(f"- シード: {c['seeds']}(各条件 n={c['n_per_condition']})・画面に出す街はシード {c['display_seed']}(最初の1本・選んでいない)・上限 {c['max_steps']} ステップ") L.append(f"- 動き: {c['move_rule']}") L.append(f"- 隣人0人: {c['no_neighbour_rule']}") L.append(f"- 表記: 平均(最小〜最大)・n=10。% は住民に占める割合。") L.append(f"- 全実験の実行時間: {summary['runtime_s']} 秒({len(c['conditions'])} 条件 × {c['n_per_condition']} シード、並列)") L.append("") r2, r3 = c["random_reference_2col"], c["random_reference_3col"] L.append("## 参照: 色だけを無作為に混ぜ直した街") L.append("") L.append(f"| | 同色隣人率 | 過半数同色 | 分離度 D | n |") L.append("|---|---|---|---|---|") L.append(f"| 2色 | {fmt_range(r2['mean_same'], True)} | {fmt_range(r2['share_majority'], True)} | {fmt_range(r2['dissimilarity'], nd=3)}(二項分布の厳密な期待値 {r2['dissimilarity_expected_binomial']:.3f}) | {r2['n_note']} |") L.append(f"| 3色 | {fmt_range(r3['mean_same'], True)} | {fmt_range(r3['share_majority'], True)} | {fmt_range(r3['dissimilarity'], nd=3)} | {r3['n_note']} |") L.append("") for gname, conds in summary["groups"].items(): L.append(f"## {GROUP_TITLES.get(gname, gname)}") L.append("") L.append("| 条件 | 収束 | ステップ | 同色隣人率 | 過半数同色 | 隣が全員同色 | 分離度 D | 最初の不満 | 動いた人 | 連鎖で動いた人 | 満足できず移った回数 |") L.append("|---|---|---|---|---|---|---|---|---|---|---|") for k, v in conds.items(): L.append("| " + " | ".join([ label(k), f"{v['converged_n']}/{v['n']}", fmt_range(v["steps"]), fmt_range(v["final.mean_same"], True), fmt_range(v["final.share_majority"], True), fmt_range(v["final.share_all_same"], True), fmt_range(v["final.dissimilarity"], nd=3), fmt_range(v["init_unhappy_share"], True), fmt_range(v["ever_moved_share"], True), fmt_range(v["cascade_movers_share"], True), fmt_range(v["moves_unsatisfied"], nd=0), ]) + " |") eq = equivalence_classes(summary, gname) if eq: L.append("") L.append("同じ結果になった条件(全シードで最終格子の sha256 が一致): " + "、".join("=".join(label(x) for x in cls) for cls in eq) + "。8人以下の隣人では、許容の境目が分数 k/n でしか効かないため。") L.append("") L.append("## 事前登録した予測(第一感)の当たり外れ") L.append("") L.append("予測は `sim/predictions.json`(シミュレーションを回す前に記録)。判定は機械的。") L.append("") L.append("| id | 予測 | 観測 | 判定 |") L.append("|---|---|---|---|") for p in checks: obs = p["observed"] obs_s = str(obs) if isinstance(obs, bool) else f"{obs:.3f}" L.append(f"| {p['id']} | {p['text']} | {obs_s} | {'当たり' if p['hit'] else '**外れ**'} |") hits = sum(p["hit"] for p in checks) L.append("") L.append(f"当たり {hits} / {len(checks)}。") L.append("") return "\n".join(L) def main(argv=None): ap = argparse.ArgumentParser() ap.add_argument("--results", default=os.path.join(HERE, "results")) ap.add_argument("--out", default=os.path.join(ROOT, "report", "results.md")) args = ap.parse_args(argv) summary = load(args.results) with open(os.path.join(HERE, "predictions.json")) as f: preds = json.load(f) checks = check_predictions(summary, preds) with open(os.path.join(args.results, "predictions_check.json"), "w") as f: json.dump({"source": "sim/predictions.json", "checks": checks, "hits": sum(p["hit"] for p in checks), "n": len(checks)}, f, ensure_ascii=False, indent=1) os.makedirs(os.path.dirname(args.out), exist_ok=True) with open(args.out, "w") as f: f.write(build_markdown(summary, checks)) print(f"wrote {args.out} ({sum(p['hit'] for p in checks)}/{len(checks)} predictions hit)") return 0 if __name__ == "__main__": sys.exit(main())