diff --git a/CLAUDE.md b/CLAUDE.md index 1a09ab9..7673e23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ ## 開発進捗 -最新: PLAN_02 Step 3(データセット集計・ややあり込み/除外の切り替え)完了.次は Step 4(学習パイプライン最小版) +最新: PLAN_02 Step 4〜5(学習・5-fold 交差検証)完了.4 モデル × 2 条件 × 3 seed を実行(TEST_03).次は Step 7(実験反復) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) @@ -100,3 +100,4 @@ - データセット構成調査: docs/06_TEST/TEST_01_データセット構成調査.md - データセット集計結果: docs/06_TEST/TEST_02_データセット集計結果.md +- 交差検証結果: docs/06_TEST/TEST_03_交差検証結果.md diff --git "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" index d1d052d..ec2fbce 100644 --- "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" +++ "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" @@ -66,6 +66,7 @@ | --- | --- | --- | | 5-fold 交差検証 | `uv run tias-train cv --config <設定>` | 学習・評価して `runs/<実験ID>/` に書き出す.最後に実験 ID を出力する([SPEC_02](../04_SPEC/SPEC_02_学習・評価仕様.md)「交差検証の実行」) | | 手元での試運転 | `uv run tias-train cv --config configs/cv_synthetic.yaml` | 合成データ・小さなモデル・事前学習なし.CPU で十数秒 | +| 回収した結果の集計 | `uv run python scripts/summarize_cv.py runs` | `runs/*_cv_*` を読み,モデル × 学習率 × 条件ごとの表と「除外 − 込み」の対応比較を Markdown で出す([TEST_03](../06_TEST/TEST_03_交差検証結果.md) の表) | 設定ファイル: @@ -74,6 +75,7 @@ | `configs/cv_with_mild.yaml`・`configs/cv_without_mild.yaml` | ConvNeXt-Tiny・ややあり込み/除外(サーバの実データ) | | `configs/cv__.yaml` | モデル比較用(モデル名以外は上と同一) | | `configs/repeat/*_s43.yaml`・`*_s44.yaml` | 分割 seed を変えた繰り返し(seed 以外は元の設定と同一) | +| `configs/lr1e-3/*.yaml` | ResNet-50・EfficientNet-B0 を学習率 1e-3 で回す設定(1e-4 では学習不足だったため.TEST_03) | **サーバでの実行例**: diff --git "a/docs/06_TEST/TEST_03_\344\272\244\345\267\256\346\244\234\350\250\274\347\265\220\346\236\234.md" "b/docs/06_TEST/TEST_03_\344\272\244\345\267\256\346\244\234\350\250\274\347\265\220\346\236\234.md" new file mode 100644 index 0000000..b55b784 --- /dev/null +++ "b/docs/06_TEST/TEST_03_\344\272\244\345\267\256\346\244\234\350\250\274\347\265\220\346\236\234.md" @@ -0,0 +1,167 @@ +# 交差検証結果 (Cross-Validation Results) + +`tias-train cv`([SPEC_02「交差検証の実行」](../04_SPEC/SPEC_02_学習・評価仕様.md))で,実データ(1085 枚)の 5-fold 交差検証を 4 モデル × 2 条件 × 3 seed で回した結果.学習は研究室サーバで行い,手元には集計値・図・ログだけを回収した.表は `uv run python scripts/summarize_cv.py runs` で再生成できる. + +| 項目 | 内容 | +| --- | --- | +| 実行日時 | 2026-09-28 00:38〜01:34(ユーザー就寝中の自律実行) | +| 実行場所 | Archimedes の RTX A6000 × 2(GPU 0・1 を並行.ユーザーの許可による) | +| 本数 | 36 本(5-fold × 36 = 180 回の学習).所要時間の合計 88 分 | +| コード | 最初の 2 本は `9445436`,次の 22 本は `b17fcc9`,学習率 1e-3 の 12 本は `3983c2a` | +| 分割 seed | 42・43・44(**同じ seed なら全モデル・両条件で分割が完全に同じ**) | + +※ `b17fcc9-dirty` の実験 ID は,実行時にテストファイル(`tests/`)が未コミットだったことによる.学習に使うコード(`src/`)はコミット `b17fcc9` と同一. + +## 評価の読み方 (How to Read) + +- **common**: held_out のうち「あり」(`strong`)と淡紅(`normal`).両条件とも学習に含まれる部分で,**2 条件の比較はここで行う** +- **all**: held_out 全体(「ややあり」を含む) +- **mild→淡紅**: held_out の「ややあり」の画像を淡紅と予測した割合 +- macro F1・正解率は,3 seed × 5 fold = 15 fold の fold ごとの値の平均 ± 標準偏差 +- 学習の設定は SPEC_02「学習の設定」の既定値(AdamW・学習率 1e-4・最大 40 エポック・patience 8・224×224・色を変えない拡張).ResNet-50 と EfficientNet-B0 は学習率 1e-3 でも回した(下記「学習率について」) + +## 結果 (Results) + +| モデル | 学習率 | 条件 | common macro F1 | common 正解率 | all macro F1 | mild→淡紅 | +| --- | --- | --- | --- | --- | --- | --- | +| ConvNeXt-Tiny | 1e-4 | 込み | 0.541 ± 0.066 | 0.668 ± 0.035 | 0.510 ± 0.056 | 0.29 | +| ConvNeXt-Tiny | 1e-4 | 除外 | 0.592 ± 0.068 | 0.744 ± 0.035 | 0.480 ± 0.056 | 0.55 | +| EfficientNet-B0 | 1e-4 | 込み | 0.494 ± 0.047 | 0.593 ± 0.046 | 0.475 ± 0.028 | 0.20 | +| EfficientNet-B0 | 1e-4 | 除外 | 0.520 ± 0.066 | 0.664 ± 0.039 | 0.447 ± 0.048 | 0.40 | +| EfficientNet-B0 | 1e-3 | 込み | 0.529 ± 0.043 | 0.629 ± 0.027 | 0.501 ± 0.032 | 0.26 | +| EfficientNet-B0 | 1e-3 | 除外 | 0.577 ± 0.050 | 0.707 ± 0.037 | 0.486 ± 0.036 | 0.48 | +| ResNet-50 | 1e-4 | 込み | 0.574 ± 0.046 | 0.702 ± 0.037 | 0.516 ± 0.049 | 0.29 | +| ResNet-50 | 1e-4 | 除外 | 0.362 ± 0.073 | 0.714 ± 0.029 | 0.296 ± 0.052 | 0.62 | +| ResNet-50 | 1e-3 | 込み | 0.546 ± 0.057 | 0.649 ± 0.066 | 0.520 ± 0.050 | 0.24 | +| **ResNet-50** | **1e-3** | **除外** | **0.637 ± 0.062** | **0.748 ± 0.045** | 0.519 ± 0.056 | 0.48 | +| ViT-Small | 1e-4 | 込み | 0.579 ± 0.060 | 0.683 ± 0.053 | 0.525 ± 0.048 | 0.34 | +| ViT-Small | 1e-4 | 除外 | 0.581 ± 0.063 | 0.743 ± 0.032 | 0.464 ± 0.052 | 0.57 | + +### クラス別の再現率(common) (Per-Class Recall) + +| モデル | 学習率 | 条件 | 淡白 | 淡紅 | 紅 | 暗赤紅 | 紫 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| ConvNeXt-Tiny | 1e-4 | 込み | 0.45 | 0.66 | 0.60 | 0.82 | 0.29 | +| ConvNeXt-Tiny | 1e-4 | 除外 | 0.45 | 0.88 | 0.59 | 0.77 | 0.26 | +| EfficientNet-B0 | 1e-3 | 込み | 0.49 | 0.61 | 0.66 | 0.74 | 0.34 | +| EfficientNet-B0 | 1e-3 | 除外 | 0.47 | 0.82 | 0.64 | 0.73 | 0.24 | +| ResNet-50 | 1e-4 | 除外 | **0.04** | 0.92 | **0.11** | 0.89 | **0.00** | +| ResNet-50 | 1e-3 | 込み | 0.48 | 0.62 | 0.68 | 0.77 | 0.40 | +| ResNet-50 | 1e-3 | 除外 | 0.59 | 0.83 | 0.61 | 0.79 | 0.39 | +| ViT-Small | 1e-4 | 込み | 0.46 | 0.70 | 0.62 | 0.79 | 0.42 | +| ViT-Small | 1e-4 | 除外 | 0.37 | 0.88 | 0.48 | 0.81 | 0.26 | + +fold 間の標準偏差は淡白・紅・紫で 0.1〜0.28 と大きい(各 fold の held_out にこれらのクラスは 11〜12 人しかいないため). + +## 「ややあり」を学習から外すと (Effect of Excluding Mild) + +同じ seed・同じ fold どうしで common macro F1 の差(除外 − 込み)を取った対応比較. + +| モデル | 学習率 | 差の平均 ± 標準偏差 | 除外が上回った fold | +| --- | --- | --- | --- | +| ConvNeXt-Tiny | 1e-4 | +0.051 ± 0.070 | 11 / 15 | +| EfficientNet-B0 | 1e-3 | +0.049 ± 0.060 | **14 / 15** | +| ResNet-50 | 1e-3 | +0.091 ± 0.082 | **13 / 15** | +| ViT-Small | 1e-4 | +0.002 ± 0.048 | 8 / 15 | + +- 学習がうまくいった 4 つの組み合わせのうち 3 つで,**「ややあり」を学習から外すと共通部分(あり・淡紅)の性能が上がる**.ViT-Small は差が無い +- 上がる主な理由は**淡紅の再現率**(0.6〜0.7 → 0.8〜0.9).「ややあり」は淡紅に近い色で他クラスのラベルが付いているため([TEST_02](TEST_02_データセット集計結果.md)),これを学習に入れると淡紅と他クラスの境界がぼやける +- 外して学習したモデルは,「ややあり」の画像の **48〜57% を淡紅と予測する**(込みのモデルでは 24〜34%).臨床上「ややあり」は淡紅と判断されることが多いという知見と整合する +- 一方,all(ややありを含む全体)の macro F1 は外すと下がるか同程度.「ややあり」を元のクラスとして当てる力は,入れて学習したほうが高い + +## モデルの比較 (Model Comparison) + +ConvNeXt-Tiny(学習率 1e-4)を基準にした,同じ seed・同じ fold どうしの common macro F1 の差. + +| 条件 | EfficientNet-B0(1e-3) | ResNet-50(1e-3) | ViT-Small(1e-4) | +| --- | --- | --- | --- | +| 除外 | −0.015 ± 0.070(5/15) | **+0.045 ± 0.059(12/15)** | −0.012 ± 0.060(6/15) | +| 込み | −0.013 ± 0.063(5/15) | +0.005 ± 0.102(8/15) | +0.038 ± 0.067(9/15) | + +(括弧内は ConvNeXt-Tiny を上回った fold の数) + +- 「除外」では ResNet-50(1e-3)が最良(common macro F1 0.637)で,ConvNeXt-Tiny を 15 fold 中 12 で上回った.ただし下記「学習率について」の偏りがあるため,**確定的な順位とは言えない** +- それ以外のモデル間の差は fold 間のばらつき(約 0.06)の範囲に収まる + +## 学習率について (Learning Rate) + +- 全モデル共通の学習率 1e-4 では,**ResNet-50 が学習不足になった**(学習損失の下がりが遅く,最良エポックが上限の 40 近くまで遅れる).学習データが少ない「除外」では多数派クラス(淡紅・暗赤紅)しか予測しない状態で止まり,紫の再現率が 0.00 になった.timm の ResNet-50 の既定の重みは微調整に大きめの学習率が要ることが知られている +- そこで BatchNorm を持つ CNN の 2 モデル(ResNet-50・EfficientNet-B0)を学習率 1e-3 でも回し,どちらも改善した +- **注意**: この学習率は交差検証の結果を見てから選んでいるため,1e-3 の結果はやや楽観的になりうる(評価データへの合わせ込み).また ConvNeXt-Tiny・ViT-Small は 1e-4 の 1 点しか試しておらず,モデルごとに調整の手間が揃っていない.公平なモデル比較には,全モデルで同じ候補から,評価とは別の検証データで学習率を選ぶ必要がある + +## 所要時間 (Timing) + +| モデル | 学習率 | 条件 | 1 エポック(秒) | 回ったエポック数 | 1 本(5 fold)の所要(秒) | パラメータ数 | +| --- | --- | --- | --- | --- | --- | --- | +| ConvNeXt-Tiny | 1e-4 | 込み | 1.44 | 19.7 | 170 | 27.8M | +| ConvNeXt-Tiny | 1e-4 | 除外 | 1.09 | 22.9 | 147 | 27.8M | +| EfficientNet-B0 | 1e-3 | 込み | 1.53 | 19.3 | 163 | 4.0M | +| EfficientNet-B0 | 1e-3 | 除外 | 1.11 | 23.1 | 142 | 4.0M | +| ResNet-50 | 1e-4 | 込み | 1.16 | 34.6 | 223 | 23.5M | +| ResNet-50 | 1e-3 | 込み | 1.16 | 18.3 | 133 | 23.5M | +| ResNet-50 | 1e-3 | 除外 | 0.84 | 24.4 | 119 | 23.5M | +| ViT-Small | 1e-4 | 込み | 0.99 | 19.1 | 114 | 21.7M | +| ViT-Small | 1e-4 | 除外 | 0.71 | 22.7 | 100 | 21.7M | + +- どのモデルも 5-fold 交差検証 1 本が 2〜4 分で終わる(RTX A6000・画像をメモリに載せた状態・混合精度).画像の読み込み(レターボックス化)は 1 本あたり約 8 秒 +- 計算時間はボトルネックではない.実験の数を増やす(seed・学習率・対策の組み合わせ)余地が大きい + +## 分かったこと (Findings) + +1. **現状の到達点**: 共通部分(あり・淡紅)で macro F1 0.58〜0.64・正解率 0.71〜0.75(ややあり除外で学習した場合).all(ややあり込み全体)では macro F1 0.50 前後 +2. **「ややあり」を学習から外すと,共通部分の性能が上がる**(4 組中 3 組で 11〜14/15 fold).外したモデルは「ややあり」の約半数を淡紅と予測し,臨床の知見と整合する +3. **紫と淡白が弱い**(再現率 0.24〜0.59).どちらも枚数が少なく,平均色も他グループと重なる(TEST_02) +4. モデル間の差は小さく,ばらつきの範囲に収まるものが多い.ResNet-50(学習率 1e-3)が「除外」で最良だが,学習率の選び方に偏りがある + +## 次の一手 (Next Steps) + +PLAN_02 の Step 7(実験反復)で扱う. + +- **学習率を公平に選ぶ**: 全モデルで同じ候補(例: 3e-5・1e-4・3e-4・1e-3)を,各 fold の内側の検証データで選ぶ(入れ子の交差検証) +- **クラス不均衡への対策**: 紫・淡白の再現率を上げるため,クラス重み付き損失やサンプリングを独立した実験として試す(SPEC_02「クラス不均衡」) +- **入力解像度**: 224 → 320・384 で細かな色むらが効くかを見る +- **「ややあり」の扱いの結論**: 今回の結果からは「学習から外す」方が共通部分の性能は高い.ただし運用時に来る境界例をどう出力すべきか(淡紅と出してよいか)は臨床側の判断が要る +- **ベースライン比較**(Step 6): 同じ held_out で TIASjudge のルールベースと並べる + +## 実験 ID (Run IDs) + +手元の `runs/` に回収済み(`.gitignore` 対象).サーバ上にも同名で残っており,`checkpoints/` の重みは `uv run tias-remote pull <実験ID> --with-checkpoints` で回収できる. + +```text +20260928-003803_cv_with_mild_9445436 +20260928-003817_cv_without_mild_9445436 +20260928-004230_cv_effb0_with_mild_b17fcc9-dirty +20260928-004245_cv_effb0_without_mild_b17fcc9-dirty +20260928-004437_cv_resnet50_without_mild_b17fcc9-dirty +20260928-004551_cv_resnet50_with_mild_b17fcc9-dirty +20260928-004658_cv_vits_without_mild_b17fcc9-dirty +20260928-004834_cv_without_mild_s43_b17fcc9-dirty +20260928-004944_cv_vits_with_mild_b17fcc9-dirty +20260928-005106_cv_without_mild_s44_b17fcc9-dirty +20260928-005133_cv_with_mild_s43_b17fcc9-dirty +20260928-005332_cv_effb0_without_mild_s43_b17fcc9-dirty +20260928-005451_cv_with_mild_s44_b17fcc9-dirty +20260928-005555_cv_effb0_without_mild_s44_b17fcc9-dirty +20260928-005731_cv_effb0_with_mild_s43_b17fcc9-dirty +20260928-005825_cv_resnet50_without_mild_s43_b17fcc9-dirty +20260928-010038_cv_effb0_with_mild_s44_b17fcc9-dirty +20260928-010055_cv_resnet50_without_mild_s44_b17fcc9-dirty +20260928-010255_cv_vits_without_mild_s43_b17fcc9-dirty +20260928-010353_cv_resnet50_with_mild_s43_b17fcc9-dirty +20260928-010443_cv_vits_without_mild_s44_b17fcc9-dirty +20260928-010735_cv_resnet50_with_mild_s44_b17fcc9-dirty +20260928-011122_cv_vits_with_mild_s43_b17fcc9-dirty +20260928-011330_cv_vits_with_mild_s44_b17fcc9-dirty +20260928-011834_cv_resnet50_with_mild_lr1e-3_3983c2a +20260928-011847_cv_resnet50_without_mild_lr1e-3_3983c2a +20260928-012047_cv_resnet50_with_mild_lr1e-3_s43_3983c2a +20260928-012049_cv_resnet50_without_mild_lr1e-3_s43_3983c2a +20260928-012253_cv_resnet50_without_mild_lr1e-3_s44_3983c2a +20260928-012300_cv_resnet50_with_mild_lr1e-3_s44_3983c2a +20260928-012457_cv_effb0_without_mild_lr1e-3_3983c2a +20260928-012525_cv_effb0_with_mild_lr1e-3_3983c2a +20260928-012716_cv_effb0_without_mild_lr1e-3_s43_3983c2a +20260928-012822_cv_effb0_with_mild_lr1e-3_s43_3983c2a +20260928-012950_cv_effb0_without_mild_lr1e-3_s44_3983c2a +20260928-013114_cv_effb0_with_mild_lr1e-3_s44_3983c2a +``` diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 03dff75..ae7fdcd 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -88,3 +88,15 @@ - 結果: 読めない画像 0.**ややありの 4 グループ中 3 つは色(ΔE)の上でも淡紅に近く**,臨床の知見と整合した(紅だけは等距離).一方でグループ間の平均色の差(ΔE 4〜13)に比べてグループ内のばらつきが大きく,平均色だけでは分布が大きく重なる → 精度の見込みは控えめに置く - 仕組み: 実験 ID の git ハッシュは,サーバに `.git` を送らないため `tias-remote run` が手元で求めて `TIAS_GIT_COMMIT` で渡す +## Step 4〜5: 学習と 5-fold 交差検証(夜間の自律作業) + +- [x] **`tias-train cv` を実装し,実データで 4 モデル × 2 条件 × 3 seed の交差検証を実行**: ユーザー就寝中(2026-09-28 00:23〜)に,確認を省略して実装→テスト→リファクタ→実行まで進めた(ユーザーの許可による.作業ブランチへのコミットも許可済み.GPU は Archimedes の 2 枚を使う許可).結果は [TEST_03](06_TEST/TEST_03_交差検証結果.md). + - 設計判断 ①: 1 ジョブで 5 fold を順に回し 1 つの実験ディレクトリにまとめる.held_out は `data.severities` に関わらず全程度で評価し,部分集合(all・common・mild)ごとに指標を出す.これで「ややあり込み/除外」を同じ土俵で比べられる + - 設計判断 ②: macro F1 は部分集合の正解に現れるクラスだけで平均する(mild には淡紅の正解が無いため) + - 進め方の工夫: 本番の交差検証を先に走らせ,その裏でテスト・リファクタを行った(リファクタは挙動を変えず,実行中のサーバのコードを上書きしないよう push を止めた).比較用の設定を先にすべて作って 1 回の push で済ませた + - 所要時間の記録: fold ごとの学習・評価秒数・エポック数・1 エポック平均・全体・デバイス・パラメータ数を `metrics.json` と `history.csv` に残す(ユーザー要望).1 本(5 fold)2〜4 分,36 本で合計 88 分 + - 結果: 「ややあり」を学習から外すと共通部分(あり・淡紅)の macro F1 が上がる(4 組中 3 組で 11〜14/15 fold).外したモデルは「ややあり」の約半数を淡紅と予測する.最良は ResNet-50(学習率 1e-3)・除外で common macro F1 0.637.紫・淡白の再現率が低い + - 失敗パターン: 全モデル共通の学習率 1e-4 では ResNet-50 が学習不足になり,「除外」では多数派クラスしか予測しない状態(紫の再現率 0)で止まった → 学習損失の推移から学習不足と判断し,BatchNorm を持つ CNN 2 モデルを 1e-3 で回し直して解消.**この学習率は結果を見て選んだので楽観的になりうる**点を TEST_03 に明記した + - 止めた判断: 残り時間はあったが,学習率の調整をこれ以上重ねると評価データへの合わせ込みが強まるため,追加実験はせず Step 7 の計画(入れ子の交差検証で全モデル同じ候補から選ぶ)に回した + - 未実施: ベースライン比較(Step 6) + diff --git a/scripts/summarize_cv.py b/scripts/summarize_cv.py new file mode 100644 index 0000000..12ca498 --- /dev/null +++ b/scripts/summarize_cv.py @@ -0,0 +1,203 @@ +"""回収した交差検証の結果(runs/*_cv_*/metrics.json)を集計して Markdown の表にする. + +`tias-remote pull` で手元に回収した実験ディレクトリを読む(metrics.json と config.yaml は +集計値のみで個人情報を含まない).モデル × 学習率 × 条件(ややあり込み/除外)ごとに, +全 seed × 5 fold の fold ごとの値の平均 ± 標準偏差と,同じ seed・同じ fold どうしの +対応比較(除外 − 込み)を出す.結果は標準出力(Markdown)と runs/_cv_summary.json に書く. + +使い方: + uv run python scripts/summarize_cv.py [runs] +""" + +import json +import math +import sys +from collections import defaultdict +from pathlib import Path + +import yaml + +CLASSES = ["pale", "light_red", "red", "dark_red", "purple"] +JP = {"pale": "淡白", "light_red": "淡紅", "red": "紅", "dark_red": "暗赤紅", "purple": "紫"} +MODEL_LABEL = { + "convnext_tiny": "ConvNeXt-Tiny", + "efficientnet_b0": "EfficientNet-B0", + "resnet50": "ResNet-50", + "vit_small_patch16_224": "ViT-Small", +} +MODEL_ORDER = list(MODEL_LABEL) +CONDITIONS = ("with_mild", "without_mild") +CONDITION_LABEL = {"with_mild": "込み", "without_mild": "除外"} + +MeanStd = tuple[float, float, int] + + +def mean_std(xs: list[float]) -> MeanStd: + """平均・不偏標準偏差・件数を返す.""" + xs = [x for x in xs if x is not None and not math.isnan(x)] + if not xs: + return (float("nan"), float("nan"), 0) + m = sum(xs) / len(xs) + s = math.sqrt(sum((x - m) ** 2 for x in xs) / (len(xs) - 1)) if len(xs) > 1 else 0.0 + return (m, s, len(xs)) + + +def fmt(ms: MeanStd, digits: int = 3) -> str: + """平均 ± 標準偏差を文字列にする.""" + m, s, n = ms + return "—" if n == 0 else f"{m:.{digits}f} ± {s:.{digits}f}" + + +def label(key: tuple[str, float]) -> str: + """(モデル名, 学習率)の表示名.""" + name, lr = key + return f"{MODEL_LABEL[name]}(lr {lr:g})" + + +def load_runs(root: Path) -> list[dict]: + """実験ディレクトリを読む(対象外のモデルは飛ばす).""" + runs = [] + for mpath in sorted(root.glob("*_cv_*/metrics.json")): + metrics = json.loads(mpath.read_text(encoding="utf-8")) + doc = yaml.safe_load((mpath.parent / "config.yaml").read_text(encoding="utf-8")) + conf = doc.get("config", doc) + model = metrics["environment"]["model_name"] + if model not in MODEL_LABEL: + continue + cond = "with_mild" if "mild" in metrics["severities_trained"] else "without_mild" + runs.append( + { + "run_id": metrics["run_id"], + "model": (model, float(conf["train"]["lr"])), + "cond": cond, + "seed": conf["split"]["seed"], + "m": metrics, + } + ) + return runs + + +def fold_values(run: dict, subset: str, key: str) -> list[float]: + """fold ごとの指標の値.""" + return [f["subsets"][subset][key] for f in run["m"]["folds"]] + + +def fold_class_recall(run: dict, subset: str, cls: str) -> list[float]: + """fold ごとのクラス別再現率(正解に現れた fold のみ).""" + out = [] + for f in run["m"]["folds"]: + per_class = f["subsets"][subset].get("per_class", {}) + if cls in per_class: + out.append(per_class[cls]["recall"]) + return out + + +def mild_to_normal(run: dict) -> float: + """mild の画像を淡紅と予測した割合(全 fold をまとめた値).""" + return run["m"]["pooled"]["mild"]["prediction_breakdown"]["overall"]["light_red"] + + +def pooled_folds(runs: list[dict], subset: str, key: str) -> MeanStd: + """複数 run の fold ごとの値をまとめて平均 ± 標準偏差にする.""" + return mean_std([v for r in runs for v in fold_values(r, subset, key)]) + + +def main(root: Path) -> None: + """集計して表を出力する.""" + runs = load_runs(root) + groups: dict = defaultdict(list) + for r in runs: + groups[(r["model"], r["cond"])].append(r) + keys = sorted({r["model"] for r in runs}, key=lambda k: (MODEL_ORDER.index(k[0]), k[1])) + seeds = ", ".join(sorted({str(r["seed"]) for r in runs})) + + lines = [f"対象: {len(runs)} 本(seed {seeds})\n"] + lines.append( + "| モデル | 条件 | seed 数 | common macro F1 | common 正解率 | all macro F1" + " | mild→淡紅 | 1 本の所要(秒) |" + ) + lines.append("| --- | --- | --- | --- | --- | --- | --- | --- |") + summary = {} + for model in keys: + for cond in CONDITIONS: + rs = groups.get((model, cond), []) + if not rs: + continue + row = { + "common_f1": pooled_folds(rs, "common", "macro_f1"), + "common_acc": pooled_folds(rs, "common", "accuracy"), + "all_f1": pooled_folds(rs, "all", "macro_f1"), + "mild_to_normal": mean_std([mild_to_normal(r) for r in rs]), + "seconds": mean_std([r["m"]["timing"]["total_seconds"] for r in rs]), + } + cells = [ + label(model), + CONDITION_LABEL[cond], + str(len(rs)), + fmt(row["common_f1"]), + fmt(row["common_acc"]), + fmt(row["all_f1"]), + fmt(row["mild_to_normal"], 2), + fmt(row["seconds"], 0), + ] + lines.append("| " + " | ".join(cells) + " |") + summary[f"{model[0]}/lr{model[1]:g}/{cond}"] = row + lines.append( + "\n※ macro F1・正解率は全 seed × 5 fold の fold ごとの値の平均 ± 標準偏差." + "mild→淡紅 は run ごとの(全 fold をまとめた)割合の平均 ± 標準偏差.\n" + ) + + lines.append("| モデル | 条件 | " + " | ".join(JP[c] for c in CLASSES) + " |") + lines.append("| --- | --- | " + " | ".join("---" for _ in CLASSES) + " |") + for model in keys: + for cond in CONDITIONS: + rs = groups.get((model, cond), []) + if not rs: + continue + cells = [ + fmt(mean_std([v for r in rs for v in fold_class_recall(r, "common", c)]), 2) + for c in CLASSES + ] + lines.append( + f"| {label(model)} | {CONDITION_LABEL[cond]} | " + " | ".join(cells) + " |" + ) + lines.append( + "\n※ common(「あり」と淡紅)での各クラスの再現率(fold ごとの値の平均 ± 標準偏差).\n" + ) + + lines.append( + "| モデル | 対応のある fold 数 | common F1 の差(除外 − 込み) | 除外が上回った fold |" + ) + lines.append("| --- | --- | --- | --- |") + paired = {} + for model in keys: + with_mild = {r["seed"]: r for r in groups.get((model, "with_mild"), [])} + without = {r["seed"]: r for r in groups.get((model, "without_mild"), [])} + diffs = [] + for seed in sorted(set(with_mild) & set(without)): + a = fold_values(with_mild[seed], "common", "macro_f1") + b = fold_values(without[seed], "common", "macro_f1") + diffs += [y - x for x, y in zip(a, b, strict=True)] + if diffs: + wins = sum(1 for d in diffs if d > 0) + n = len(diffs) + lines.append(f"| {label(model)} | {n} | {fmt(mean_std(diffs))} | {wins} / {n} |") + paired[f"{model[0]}/lr{model[1]:g}"] = { + "diff": mean_std(diffs), + "wins": wins, + "n": n, + } + lines.append( + "\n※ 同じ seed なら「込み」と「除外」の分割は完全に同じ(SPEC_02)なので," + "fold ごとの差を取っている.\n" + ) + + print("\n".join(lines)) + out = {"summary": summary, "paired": paired, "runs": [r["run_id"] for r in runs]} + (root / "_cv_summary.json").write_text( + json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + +if __name__ == "__main__": + main(Path(sys.argv[1] if len(sys.argv) > 1 else "runs"))