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/configs/cv_effb0_with_mild.yaml b/configs/cv_effb0_with_mild.yaml new file mode 100644 index 0000000..d59a714 --- /dev/null +++ b/configs/cv_effb0_with_mild.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(EfficientNet-B0.モデル名以外は cv_with_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 42 + n_folds: 5 + +model: + name: efficientnet_b0 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/cv_effb0_without_mild.yaml b/configs/cv_effb0_without_mild.yaml new file mode 100644 index 0000000..853a5bc --- /dev/null +++ b/configs/cv_effb0_without_mild.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(EfficientNet-B0.モデル名以外は cv_without_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 42 + n_folds: 5 + +model: + name: efficientnet_b0 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/cv_resnet50_with_mild.yaml b/configs/cv_resnet50_with_mild.yaml new file mode 100644 index 0000000..cdb8962 --- /dev/null +++ b/configs/cv_resnet50_with_mild.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ResNet-50.モデル名以外は cv_with_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 42 + n_folds: 5 + +model: + name: resnet50 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/cv_resnet50_without_mild.yaml b/configs/cv_resnet50_without_mild.yaml new file mode 100644 index 0000000..d696cd9 --- /dev/null +++ b/configs/cv_resnet50_without_mild.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ResNet-50.モデル名以外は cv_without_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 42 + n_folds: 5 + +model: + name: resnet50 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/cv_synthetic.yaml b/configs/cv_synthetic.yaml new file mode 100644 index 0000000..ed53684 --- /dev/null +++ b/configs/cv_synthetic.yaml @@ -0,0 +1,27 @@ +# 手元確認用: 合成データ(scripts/make_synthetic_dataset.py の出力)で交差検証を通す設定 +# CPU で数分以内に終わるよう,小さなモデル・事前学習なし・2 エポックにしている. +# data.root の相対パスはカレントディレクトリ(リポジトリのルート)基準. +data: + root: data/synthetic + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 42 + n_folds: 5 + +model: + name: resnet18 + pretrained: false + +train: + image_size: 64 + batch_size: 8 + max_epochs: 2 + patience: 8 + lr: 1.0e-3 + weight_decay: 0.05 + warmup_epochs: 1 + inner_val_ratio: 0.15 + num_workers: 0 + amp: true diff --git a/configs/cv_vits_with_mild.yaml b/configs/cv_vits_with_mild.yaml new file mode 100644 index 0000000..8e037a3 --- /dev/null +++ b/configs/cv_vits_with_mild.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ViT-Small.モデル名以外は cv_with_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 42 + n_folds: 5 + +model: + name: vit_small_patch16_224 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/cv_vits_without_mild.yaml b/configs/cv_vits_without_mild.yaml new file mode 100644 index 0000000..f841c28 --- /dev/null +++ b/configs/cv_vits_without_mild.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ViT-Small.モデル名以外は cv_without_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 42 + n_folds: 5 + +model: + name: vit_small_patch16_224 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/cv_with_mild.yaml b/configs/cv_with_mild.yaml new file mode 100644 index 0000000..4f568e2 --- /dev/null +++ b/configs/cv_with_mild.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 42 + n_folds: 5 + +model: + name: convnext_tiny + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/cv_without_mild.yaml b/configs/cv_without_mild.yaml new file mode 100644 index 0000000..bc91e04 --- /dev/null +++ b/configs/cv_without_mild.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 42 + n_folds: 5 + +model: + name: convnext_tiny + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/lr1e-3/cv_effb0_with_mild_lr1e-3.yaml b/configs/lr1e-3/cv_effb0_with_mild_lr1e-3.yaml new file mode 100644 index 0000000..5687f4b --- /dev/null +++ b/configs/lr1e-3/cv_effb0_with_mild_lr1e-3.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(EfficientNet-B0.モデル名以外は cv_with_mild.yaml と同一).学習率 1e-3 の再実験(lr 以外は configs/cv_effb0_with_mild.yaml と同一.lr 1e-4 では学習不足だったため) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 42 + n_folds: 5 + +model: + name: efficientnet_b0 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-3 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/lr1e-3/cv_effb0_with_mild_lr1e-3_s43.yaml b/configs/lr1e-3/cv_effb0_with_mild_lr1e-3_s43.yaml new file mode 100644 index 0000000..b521ce5 --- /dev/null +++ b/configs/lr1e-3/cv_effb0_with_mild_lr1e-3_s43.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(EfficientNet-B0.モデル名以外は cv_with_mild.yaml と同一).分割 seed 43 の繰り返し(seed 以外は configs/cv_effb0_with_mild.yaml と同一).学習率 1e-3 の再実験(lr 以外は configs/repeat/cv_effb0_with_mild_s43.yaml と同一.lr 1e-4 では学習不足だったため) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 43 + n_folds: 5 + +model: + name: efficientnet_b0 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-3 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/lr1e-3/cv_effb0_with_mild_lr1e-3_s44.yaml b/configs/lr1e-3/cv_effb0_with_mild_lr1e-3_s44.yaml new file mode 100644 index 0000000..8719852 --- /dev/null +++ b/configs/lr1e-3/cv_effb0_with_mild_lr1e-3_s44.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(EfficientNet-B0.モデル名以外は cv_with_mild.yaml と同一).分割 seed 44 の繰り返し(seed 以外は configs/cv_effb0_with_mild.yaml と同一).学習率 1e-3 の再実験(lr 以外は configs/repeat/cv_effb0_with_mild_s44.yaml と同一.lr 1e-4 では学習不足だったため) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 44 + n_folds: 5 + +model: + name: efficientnet_b0 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-3 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/lr1e-3/cv_effb0_without_mild_lr1e-3.yaml b/configs/lr1e-3/cv_effb0_without_mild_lr1e-3.yaml new file mode 100644 index 0000000..850c69f --- /dev/null +++ b/configs/lr1e-3/cv_effb0_without_mild_lr1e-3.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(EfficientNet-B0.モデル名以外は cv_without_mild.yaml と同一).学習率 1e-3 の再実験(lr 以外は configs/cv_effb0_without_mild.yaml と同一.lr 1e-4 では学習不足だったため) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 42 + n_folds: 5 + +model: + name: efficientnet_b0 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-3 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/lr1e-3/cv_effb0_without_mild_lr1e-3_s43.yaml b/configs/lr1e-3/cv_effb0_without_mild_lr1e-3_s43.yaml new file mode 100644 index 0000000..2b56ad2 --- /dev/null +++ b/configs/lr1e-3/cv_effb0_without_mild_lr1e-3_s43.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(EfficientNet-B0.モデル名以外は cv_without_mild.yaml と同一).分割 seed 43 の繰り返し(seed 以外は configs/cv_effb0_without_mild.yaml と同一).学習率 1e-3 の再実験(lr 以外は configs/repeat/cv_effb0_without_mild_s43.yaml と同一.lr 1e-4 では学習不足だったため) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 43 + n_folds: 5 + +model: + name: efficientnet_b0 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-3 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/lr1e-3/cv_effb0_without_mild_lr1e-3_s44.yaml b/configs/lr1e-3/cv_effb0_without_mild_lr1e-3_s44.yaml new file mode 100644 index 0000000..c84ca4e --- /dev/null +++ b/configs/lr1e-3/cv_effb0_without_mild_lr1e-3_s44.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(EfficientNet-B0.モデル名以外は cv_without_mild.yaml と同一).分割 seed 44 の繰り返し(seed 以外は configs/cv_effb0_without_mild.yaml と同一).学習率 1e-3 の再実験(lr 以外は configs/repeat/cv_effb0_without_mild_s44.yaml と同一.lr 1e-4 では学習不足だったため) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 44 + n_folds: 5 + +model: + name: efficientnet_b0 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-3 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/lr1e-3/cv_resnet50_with_mild_lr1e-3.yaml b/configs/lr1e-3/cv_resnet50_with_mild_lr1e-3.yaml new file mode 100644 index 0000000..5b2183f --- /dev/null +++ b/configs/lr1e-3/cv_resnet50_with_mild_lr1e-3.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ResNet-50.モデル名以外は cv_with_mild.yaml と同一).学習率 1e-3 の再実験(lr 以外は configs/cv_resnet50_with_mild.yaml と同一.lr 1e-4 では学習不足だったため) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 42 + n_folds: 5 + +model: + name: resnet50 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-3 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/lr1e-3/cv_resnet50_with_mild_lr1e-3_s43.yaml b/configs/lr1e-3/cv_resnet50_with_mild_lr1e-3_s43.yaml new file mode 100644 index 0000000..e514824 --- /dev/null +++ b/configs/lr1e-3/cv_resnet50_with_mild_lr1e-3_s43.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ResNet-50.モデル名以外は cv_with_mild.yaml と同一).分割 seed 43 の繰り返し(seed 以外は configs/cv_resnet50_with_mild.yaml と同一).学習率 1e-3 の再実験(lr 以外は configs/repeat/cv_resnet50_with_mild_s43.yaml と同一.lr 1e-4 では学習不足だったため) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 43 + n_folds: 5 + +model: + name: resnet50 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-3 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/lr1e-3/cv_resnet50_with_mild_lr1e-3_s44.yaml b/configs/lr1e-3/cv_resnet50_with_mild_lr1e-3_s44.yaml new file mode 100644 index 0000000..c15b77d --- /dev/null +++ b/configs/lr1e-3/cv_resnet50_with_mild_lr1e-3_s44.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ResNet-50.モデル名以外は cv_with_mild.yaml と同一).分割 seed 44 の繰り返し(seed 以外は configs/cv_resnet50_with_mild.yaml と同一).学習率 1e-3 の再実験(lr 以外は configs/repeat/cv_resnet50_with_mild_s44.yaml と同一.lr 1e-4 では学習不足だったため) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 44 + n_folds: 5 + +model: + name: resnet50 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-3 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/lr1e-3/cv_resnet50_without_mild_lr1e-3.yaml b/configs/lr1e-3/cv_resnet50_without_mild_lr1e-3.yaml new file mode 100644 index 0000000..3be1c16 --- /dev/null +++ b/configs/lr1e-3/cv_resnet50_without_mild_lr1e-3.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ResNet-50.モデル名以外は cv_without_mild.yaml と同一).学習率 1e-3 の再実験(lr 以外は configs/cv_resnet50_without_mild.yaml と同一.lr 1e-4 では学習不足だったため) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 42 + n_folds: 5 + +model: + name: resnet50 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-3 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/lr1e-3/cv_resnet50_without_mild_lr1e-3_s43.yaml b/configs/lr1e-3/cv_resnet50_without_mild_lr1e-3_s43.yaml new file mode 100644 index 0000000..5a59352 --- /dev/null +++ b/configs/lr1e-3/cv_resnet50_without_mild_lr1e-3_s43.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ResNet-50.モデル名以外は cv_without_mild.yaml と同一).分割 seed 43 の繰り返し(seed 以外は configs/cv_resnet50_without_mild.yaml と同一).学習率 1e-3 の再実験(lr 以外は configs/repeat/cv_resnet50_without_mild_s43.yaml と同一.lr 1e-4 では学習不足だったため) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 43 + n_folds: 5 + +model: + name: resnet50 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-3 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/lr1e-3/cv_resnet50_without_mild_lr1e-3_s44.yaml b/configs/lr1e-3/cv_resnet50_without_mild_lr1e-3_s44.yaml new file mode 100644 index 0000000..1b5584c --- /dev/null +++ b/configs/lr1e-3/cv_resnet50_without_mild_lr1e-3_s44.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ResNet-50.モデル名以外は cv_without_mild.yaml と同一).分割 seed 44 の繰り返し(seed 以外は configs/cv_resnet50_without_mild.yaml と同一).学習率 1e-3 の再実験(lr 以外は configs/repeat/cv_resnet50_without_mild_s44.yaml と同一.lr 1e-4 では学習不足だったため) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 44 + n_folds: 5 + +model: + name: resnet50 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-3 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/repeat/cv_effb0_with_mild_s43.yaml b/configs/repeat/cv_effb0_with_mild_s43.yaml new file mode 100644 index 0000000..2897387 --- /dev/null +++ b/configs/repeat/cv_effb0_with_mild_s43.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(EfficientNet-B0.モデル名以外は cv_with_mild.yaml と同一).分割 seed 43 の繰り返し(seed 以外は configs/cv_effb0_with_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 43 + n_folds: 5 + +model: + name: efficientnet_b0 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/repeat/cv_effb0_with_mild_s44.yaml b/configs/repeat/cv_effb0_with_mild_s44.yaml new file mode 100644 index 0000000..41c1033 --- /dev/null +++ b/configs/repeat/cv_effb0_with_mild_s44.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(EfficientNet-B0.モデル名以外は cv_with_mild.yaml と同一).分割 seed 44 の繰り返し(seed 以外は configs/cv_effb0_with_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 44 + n_folds: 5 + +model: + name: efficientnet_b0 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/repeat/cv_effb0_without_mild_s43.yaml b/configs/repeat/cv_effb0_without_mild_s43.yaml new file mode 100644 index 0000000..0730ae0 --- /dev/null +++ b/configs/repeat/cv_effb0_without_mild_s43.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(EfficientNet-B0.モデル名以外は cv_without_mild.yaml と同一).分割 seed 43 の繰り返し(seed 以外は configs/cv_effb0_without_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 43 + n_folds: 5 + +model: + name: efficientnet_b0 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/repeat/cv_effb0_without_mild_s44.yaml b/configs/repeat/cv_effb0_without_mild_s44.yaml new file mode 100644 index 0000000..1cb490c --- /dev/null +++ b/configs/repeat/cv_effb0_without_mild_s44.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(EfficientNet-B0.モデル名以外は cv_without_mild.yaml と同一).分割 seed 44 の繰り返し(seed 以外は configs/cv_effb0_without_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 44 + n_folds: 5 + +model: + name: efficientnet_b0 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/repeat/cv_resnet50_with_mild_s43.yaml b/configs/repeat/cv_resnet50_with_mild_s43.yaml new file mode 100644 index 0000000..fb19ca2 --- /dev/null +++ b/configs/repeat/cv_resnet50_with_mild_s43.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ResNet-50.モデル名以外は cv_with_mild.yaml と同一).分割 seed 43 の繰り返し(seed 以外は configs/cv_resnet50_with_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 43 + n_folds: 5 + +model: + name: resnet50 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/repeat/cv_resnet50_with_mild_s44.yaml b/configs/repeat/cv_resnet50_with_mild_s44.yaml new file mode 100644 index 0000000..4e3395e --- /dev/null +++ b/configs/repeat/cv_resnet50_with_mild_s44.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ResNet-50.モデル名以外は cv_with_mild.yaml と同一).分割 seed 44 の繰り返し(seed 以外は configs/cv_resnet50_with_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 44 + n_folds: 5 + +model: + name: resnet50 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/repeat/cv_resnet50_without_mild_s43.yaml b/configs/repeat/cv_resnet50_without_mild_s43.yaml new file mode 100644 index 0000000..0997c39 --- /dev/null +++ b/configs/repeat/cv_resnet50_without_mild_s43.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ResNet-50.モデル名以外は cv_without_mild.yaml と同一).分割 seed 43 の繰り返し(seed 以外は configs/cv_resnet50_without_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 43 + n_folds: 5 + +model: + name: resnet50 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/repeat/cv_resnet50_without_mild_s44.yaml b/configs/repeat/cv_resnet50_without_mild_s44.yaml new file mode 100644 index 0000000..74cd004 --- /dev/null +++ b/configs/repeat/cv_resnet50_without_mild_s44.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ResNet-50.モデル名以外は cv_without_mild.yaml と同一).分割 seed 44 の繰り返し(seed 以外は configs/cv_resnet50_without_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 44 + n_folds: 5 + +model: + name: resnet50 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/repeat/cv_vits_with_mild_s43.yaml b/configs/repeat/cv_vits_with_mild_s43.yaml new file mode 100644 index 0000000..3c80b5c --- /dev/null +++ b/configs/repeat/cv_vits_with_mild_s43.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ViT-Small.モデル名以外は cv_with_mild.yaml と同一).分割 seed 43 の繰り返し(seed 以外は configs/cv_vits_with_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 43 + n_folds: 5 + +model: + name: vit_small_patch16_224 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/repeat/cv_vits_with_mild_s44.yaml b/configs/repeat/cv_vits_with_mild_s44.yaml new file mode 100644 index 0000000..061377a --- /dev/null +++ b/configs/repeat/cv_vits_with_mild_s44.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ViT-Small.モデル名以外は cv_with_mild.yaml と同一).分割 seed 44 の繰り返し(seed 以外は configs/cv_vits_with_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 44 + n_folds: 5 + +model: + name: vit_small_patch16_224 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/repeat/cv_vits_without_mild_s43.yaml b/configs/repeat/cv_vits_without_mild_s43.yaml new file mode 100644 index 0000000..83c7b68 --- /dev/null +++ b/configs/repeat/cv_vits_without_mild_s43.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ViT-Small.モデル名以外は cv_without_mild.yaml と同一).分割 seed 43 の繰り返し(seed 以外は configs/cv_vits_without_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 43 + n_folds: 5 + +model: + name: vit_small_patch16_224 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/repeat/cv_vits_without_mild_s44.yaml b/configs/repeat/cv_vits_without_mild_s44.yaml new file mode 100644 index 0000000..e93e2df --- /dev/null +++ b/configs/repeat/cv_vits_without_mild_s44.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ViT-Small.モデル名以外は cv_without_mild.yaml と同一).分割 seed 44 の繰り返し(seed 以外は configs/cv_vits_without_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 44 + n_folds: 5 + +model: + name: vit_small_patch16_224 + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/repeat/cv_with_mild_s43.yaml b/configs/repeat/cv_with_mild_s43.yaml new file mode 100644 index 0000000..975b011 --- /dev/null +++ b/configs/repeat/cv_with_mild_s43.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).分割 seed 43 の繰り返し(seed 以外は configs/cv_with_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 43 + n_folds: 5 + +model: + name: convnext_tiny + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/repeat/cv_with_mild_s44.yaml b/configs/repeat/cv_with_mild_s44.yaml new file mode 100644 index 0000000..6d7bd0e --- /dev/null +++ b/configs/repeat/cv_with_mild_s44.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).分割 seed 44 の繰り返し(seed 以外は configs/cv_with_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 44 + n_folds: 5 + +model: + name: convnext_tiny + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/repeat/cv_without_mild_s43.yaml b/configs/repeat/cv_without_mild_s43.yaml new file mode 100644 index 0000000..878bab7 --- /dev/null +++ b/configs/repeat/cv_without_mild_s43.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).分割 seed 43 の繰り返し(seed 以外は configs/cv_without_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 43 + n_folds: 5 + +model: + name: convnext_tiny + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true diff --git a/configs/repeat/cv_without_mild_s44.yaml b/configs/repeat/cv_without_mild_s44.yaml new file mode 100644 index 0000000..1484d69 --- /dev/null +++ b/configs/repeat/cv_without_mild_s44.yaml @@ -0,0 +1,27 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).分割 seed 44 の繰り返し(seed 以外は configs/cv_without_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 44 + n_folds: 5 + +model: + name: convnext_tiny + pretrained: true + +train: + image_size: 224 + batch_size: 32 + max_epochs: 40 + patience: 8 + lr: 1.0e-4 + weight_decay: 0.05 + warmup_epochs: 2 + inner_val_ratio: 0.15 + num_workers: 4 + amp: true 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 3c8c94e..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" @@ -2,7 +2,6 @@ 日常の開発で使うコマンドの一覧.**コマンドを推測せず必ず本ファイルを参照する**(新しく確定・変更された時は本ファイルに反映する). -※ 学習・評価のコマンドは未実装のため本ファイルには未記載(実装時に追記する). ※ Git Bash ではログの日本語が文字化けする.先頭に `PYTHONIOENCODING=utf-8` を付けると正しく表示される. ## 環境 (Environment) @@ -61,6 +60,40 @@ - **ジョブの実行中に同じサーバへ push しない**(上書き展開なので,実行中のジョブが新旧のコードを混ぜて読むおそれがある.実行中なら警告が出る) - 各サブコマンドの詳細は `uv run tias-remote <サブコマンド> --help` +## 学習・交差検証 (Training) + +| 目的 | コマンド | 備考 | +| --- | --- | --- | +| 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) の表) | + +設定ファイル: + +| 設定 | 内容 | +| --- | --- | +| `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) | + +**サーバでの実行例**: + +```bash +# 1 本だけ(空いている GPU を自動で選ぶ) +uv run tias-remote run --name cv1 -- tias-train cv --config configs/cv_with_mild.yaml + +# 複数本を同じ GPU で順に回す(キュー).; で区切るので 1 本が失敗しても次へ進む +uv run tias-remote run --name q1 --server Archimedes --gpu 1 -- bash -c "tias-train cv --config configs/cv_with_mild.yaml; tias-train cv --config configs/cv_effb0_with_mild.yaml;" + +uv run tias-remote logs q1 # 各実験の最後の行に実験 ID が出る +uv run tias-remote pull <実験ID> # 指標・図・ログだけが手元の runs/ に届く +``` + +- ConvNeXt-Tiny の 5-fold 交差検証 1 本は Archimedes の RTX A6000 で約 2.5 分(2026-09-28 実測.画像の読み込み約 8 秒を含む) +- 事前学習の重みは初回にサーバが Hugging Face からダウンロードする(`~/.cache/huggingface`) +- ログに出る `lr_scheduler.step() before optimizer.step()` の警告は,混合精度で最初の数ステップの勾配がオーバーフローして捨てられたときに出るもので無害 + ## テスト (Test) | 目的 | コマンド | @@ -86,9 +119,3 @@ | 目的 | コマンド | 備考 | | --- | --- | --- | | フックのテスト | `bash .claude/hooks/tests/test_hooks.py` 相当を `uv run pytest .claude/hooks/tests` | テンプレート由来.`.claude/` の変更時に確認する | - -## 未実装のコマンド (Planned) - -実装した時点で本ファイルに追記する. - -- 学習・評価 diff --git "a/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" "b/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" index eba8393..ee36225 100644 --- "a/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" +++ "b/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" @@ -9,8 +9,8 @@ | 1 | 基盤: 設定スキーマ・合成データ生成・manifest 読み込み・患者単位分割 ✅ 完了 | なし(手元で完結) | | 2 | リモート実行ツール(送信・実行・回収・GPU 確認,回収ホワイトリスト)✅ 完了(Newton・Archimedes の 2 サーバ対応) | Step 1 | | 3 | データセット集計(実データの素性把握)✅ 完了(結果は [TEST_02](../06_TEST/TEST_02_データセット集計結果.md)) | **臨床データのコピー完了**・倫理確認 | -| 4 | 学習パイプライン最小版(ConvNeXt-Tiny を 1 本通す) | Step 2・3 | -| 5 | 評価・レポート生成(指標・混同行列・`runs/` 出力) | Step 4 | +| 4 | 学習パイプライン最小版(ConvNeXt-Tiny を 1 本通す)✅ 完了 | Step 2・3 | +| 5 | 評価・レポート生成(指標・混同行列・`runs/` 出力)✅ 完了(5-fold 交差検証として実装.結果は [TEST_03](../06_TEST/TEST_03_交差検証結果.md)) | Step 4 | | 6 | ベースライン比較(TIASjudge のルールベース)※ 後回し | Step 5 | | 7 | 実験反復(不均衡対策・モデル比較・解像度検討) | Step 6 | | 8 | 結論(5-fold 交差検証・評価レポート・配布形式の判断) | Step 7 | diff --git "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" index bb65820..d46768f 100644 --- "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" +++ "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" @@ -90,7 +90,7 @@ | `split.n_folds` | 交差検証の fold 数(2 以上) | 5 | - **環境変数 `TIAS_DATA_ROOT` が設定されていれば `data.root` より優先する**(サーバと手元で同じ設定ファイルを使い回せるようにするため) -- 学習・モデル関連の設定は Step 4 で追加する +- 学習・モデル関連の設定は `model`・`train` 節に置く(キーと既定値は [SPEC_02「学習の設定」](SPEC_02_学習・評価仕様.md)).学習しないコマンド(`tias-dataset`)では省略できる ## クラス定義 (Class Definition) @@ -148,6 +148,7 @@ | コマンド | 役割 | 実行場所 | | --- | --- | --- | | `tias-dataset build-manifest` | `images/` を走査して `manifest.csv` を書き出す(既存があれば `--overwrite` が必要) | サーバ | +| `tias-train cv --config PATH` | 5-fold 交差検証で学習・評価し,`runs/<実験ID>/` に書き出す([SPEC_02「交差検証の実行」](SPEC_02_学習・評価仕様.md)).最後に実験 ID を出力する | サーバ(GPU) | | `tias-dataset survey` | データセットを集計して `runs/<実験ID>/` に集計値だけを書き出す(下記) | サーバ(結果の要約のみ手元へ) | | 学習 | 設定 YAML を受け取って学習する | サーバ | | 評価 | 学習済み run の評価・レポート生成 | サーバ | diff --git "a/docs/04_SPEC/SPEC_02_\345\255\246\347\277\222\343\203\273\350\251\225\344\276\241\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_02_\345\255\246\347\277\222\343\203\273\350\251\225\344\276\241\344\273\225\346\247\230.md" index 9f1147b..87573b1 100644 --- "a/docs/04_SPEC/SPEC_02_\345\255\246\347\277\222\343\203\273\350\251\225\344\276\241\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_02_\345\255\246\347\277\222\343\203\273\350\251\225\344\276\241\344\273\225\346\247\230.md" @@ -30,6 +30,83 @@ まず 1 本を通すことを優先し,比較は timm での差し替えで後から行う. +### 学習の設定 (Training Settings) + +設定 YAML の `model`・`train` 節で与える([SPEC_01](SPEC_01_アーキテクチャ設計.md)「設定ファイル」).既定値は次のとおり. + +| 項目 | 既定値 | 備考 | +| --- | --- | --- | +| `model.name` | `convnext_tiny` | timm のモデル名 | +| `model.pretrained` | `true` | ImageNet 事前学習の重み(初回はサーバがダウンロードする).合成データの試験では `false` | +| `train.image_size` | 224 | アスペクト比を保って長辺を合わせ,余白を黒で埋める(レターボックス) | +| `train.batch_size` | 32 | — | +| `train.max_epochs` | 40 | — | +| `train.patience` | 8 | 検証の macro F1 がこのエポック数改善しなければ打ち切る | +| `train.lr` | 1e-4 | AdamW | +| `train.weight_decay` | 0.05 | AdamW | +| `train.warmup_epochs` | 2 | 線形 warmup の後 cosine で減衰 | +| `train.inner_val_ratio` | 0.15 | 交差検証の各 fold で,学習側から早期終了用の検証データを取る割合(下記) | +| `train.num_workers` | 4 | DataLoader | +| `train.amp` | `true` | CUDA のときだけ混合精度 | + +- データ拡張(学習時のみ): 左右反転(確率 0.5)・回転 ±10°・拡大縮小 0.9〜1.1 倍・平行移動わずか.はみ出した部分は黒で埋める.**色を変える拡張は行わない**(上記「データ拡張」) +- 正規化: ImageNet の mean / std +- デバイス: CUDA があれば `cuda`(`CUDA_VISIBLE_DEVICES` で割り当てられた GPU),無ければ CPU +- 高速化のため,レターボックス済みの画像(uint8)をメモリに載せてから学習してよい(1085 枚 × 224² × 3 ≒ 160 MB) +- 平行移動は ±5%.学習時は端数のバッチを捨てる(BatchNorm を持つモデルで 1 件のバッチになると止まるため) +- 学習率は step 単位の線形 warmup の後,`max_epochs` の終わりで 0 になる cosine +- 早期終了は検証 macro F1 の厳密な改善(`>`)で判定し,同点なら先のエポックを残す +- `convnext_tiny` の事前学習の重みは timm の既定タグ `convnext_tiny.in12k_ft_in1k`(ImageNet-12k で事前学習し ImageNet-1k で微調整したもの)が使われる + +## 交差検証の実行 (Cross-Validation Run) + +**1 つのジョブで 5 つの fold を順に学習・評価**し,1 つの実験ディレクトリ `runs/<実験ID>/` にまとめて書き出す.「ややあり込み」と「ややあり除外」は別のジョブ(別の実験 ID)として回す. + +1. manifest 全体で患者単位・9 グループ層化の 5-fold 分割を作る(`data.severities` に関わらず同じ分割.上記「程度(ややあり)の扱い」) +2. 各 fold について + 1. 学習側(held_out 以外)から `train.inner_val_ratio` の患者を検証用に取る(患者単位・9 グループ層化.**`data.severities` を適用する前の全程度から取る**.seed は `split.seed + fold 番号`.学習の seed も同じ) + 2. `data.severities` を**学習用と検証用にだけ**適用する(held_out には適用しない.下記) + 3. 検証の macro F1 が最良のエポックの重みを採用する(早期終了) + 4. **held_out(全程度を含む)で 1 回だけ評価する** +3. 全 fold の held_out の予測をまとめて集計する + +### 評価の出し方 (Evaluation Output) + +held_out は `data.severities` に関わらず**全程度の画像**で評価し,次の部分集合ごとに指標を出す.これで 2 つの条件のジョブを同じ土俵で比べられる. + +| 部分集合 | 内容 | 用途 | +| --- | --- | --- | +| `all` | held_out の全画像 | ややあり込みの全体性能 | +| `common` | held_out のうち `strong` と `normal` | **2 条件の比較**(両条件とも学習に含まれる部分) | +| `mild` | held_out のうち `mild` | 境界例の扱い | + +- 各部分集合で: macro F1・accuracy・クラス別 precision / recall / F1・混同行列(`common` と `all`) +- `mild` では加えて**予測の内訳**を出す: 淡紅と予測された割合・元のクラスと予測された割合・その他のクラスと予測された割合(クラス別と全体) +- fold ごとの値と,fold 間の平均 ± 標準偏差(不偏標準偏差.ddof=1)を出す.全 fold の予測をまとめた値(`pooled`)と,混同行列の全 fold の合計も出す +- **macro F1 は,その部分集合の正解に現れるクラスだけで平均する**(`mild` には淡紅の正解が無いため,5 クラスで平均すると淡紅への予測が F1 = 0 として平均を下げてしまう).クラス別の指標も正解に現れるクラスだけを出す.淡紅への流れ込みは `mild` の予測の内訳で見る +- 画像ごとの予測(個票)は `predictions.csv` に保存し,**回収しない** + +### 出力 (Run Artifacts) + +[SPEC_01「実験の出力」](SPEC_01_アーキテクチャ設計.md)の名前に従う. + +| ファイル | 内容 | 回収 | +| --- | --- | --- | +| `config.yaml` | 解決済み設定・git ハッシュ・seed | する | +| `metrics.json` | fold ごと・fold 平均の指標(上記の部分集合ごと),各 fold の最良エポック,**所要時間**(下記) | する | +| `history.csv` | fold・エポックごとの学習損失・検証損失・検証 macro F1・学習率・**そのエポックの所要秒数** | する | +| `confusion_matrix.csv` | 全 fold 合計の混同行列(`common` と `all`) | する | +| `log.txt` | ログ | する | +| `figures/*.png` | 学習曲線・混同行列 | する | +| `split.json`・`predictions.csv` | 分割と個票の予測(患者 ID を含む) | しない | +| `checkpoints/fold.pt` | 各 fold の最良の重み(サーバには常に保存する) | `--with-checkpoints` のときだけ | + +**所要時間の記録**(2026-09-28 ユーザー要望): `metrics.json` に次を含める.モデル間・条件間で計算コストも比べられるようにするため. + +- 全体の所要秒数(データの読み込みから書き出しまで)と,データの読み込み(レターボックス化)にかかった秒数 +- fold ごとの学習の所要秒数・held_out の評価の所要秒数・実際に回したエポック数・1 エポックあたりの平均秒数 +- 実行環境: デバイス(GPU 名または CPU)・モデル名・パラメータ数 + ## データ分割 (Data Split) **すべての分割は患者単位で行う.** 同一患者の画像が複数の分割に跨ってはならない. 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..472f8dc --- /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,182 @@ +# 交差検証結果 (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 のルールベースと並べる + +## サーバの容量について (Disk Usage) + +> ⚠ **Archimedes の `/home` は共用ディスクが残り 40 GB(使用率 98%.2026-09-28 01:40 時点)**.今夜の 36 本の重み(`runs/*/checkpoints/fold.pt`.5 fold × 36 本)が **約 12 GB** を占めている([ENV_03](../02_ENV/ENV_03_管理者用環境構築手順.md) の「重みを溜め込まない」に反する状態). + +- 重みの削除は取り返しがつかないため,夜間の自律作業では削除せず,人間の判断を待つ +- 残す価値があるのは最良の組み合わせ(例: ResNet-50 学習率 1e-3・除外)程度.不要な重みの削除例(Archimedes 上で実行): + + ```bash + cd ~/work/TIASJudgeModel/runs + du -sh */checkpoints | sort -h # 容量の確認 + rm -r 20260928-0*_cv_*_b17fcc9-dirty/checkpoints # 例: b17fcc9 の 22 本の重みを消す + ``` + +- 今後は重みを保存するかを設定で選べるようにする(例: `train.save_checkpoints`).Step 7 で扱う + +## 実験 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..c53d51f 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) + - 要対応: 36 本の重みが Archimedes の `/home`(共用・残り 40 GB・使用率 98%)を約 12 GB 使っている.削除は人間の判断待ち(TEST_03「サーバの容量について」) diff --git a/pyproject.toml b/pyproject.toml index 7d7d308..edbf234 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ [project.scripts] tias-dataset = "tias_judge_model.cli.dataset:main" tias-remote = "tias_judge_model.cli.remote:main" +tias-train = "tias_judge_model.cli.train:main" [dependency-groups] dev = [ 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")) diff --git a/src/tias_judge_model/cli/train.py b/src/tias_judge_model/cli/train.py new file mode 100644 index 0000000..203802d --- /dev/null +++ b/src/tias_judge_model/cli/train.py @@ -0,0 +1,462 @@ +"""`tias-train` コマンド: 患者単位 K-fold 交差検証で学習・評価する(SPEC_01「CLI」). + +サブコマンド: + +- `cv --config PATH [--out-root runs]`: 5-fold 交差検証で学習・評価し,`runs/<実験ID>/` に書き出す + (SPEC_02「交差検証の実行」).最後に実験 ID を標準出力へ出す(`tias-remote pull <実験ID>` 用) + +実データを読むためサーバで実行する(手元では合成データの設定 `configs/cv_synthetic.yaml`). +回収対象(`config.yaml`・`metrics.json`・`history.csv`・`confusion_matrix.csv`・`log.txt`・ +`figures/*.png`)には患者 ID・ファイル名・画像単位の値を出さない.個票(`split.json`・ +`predictions.csv`)はサーバ上にだけ置く. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import logging +import os +import sys +import time +from collections.abc import Mapping, Sequence +from datetime import datetime +from pathlib import Path +from typing import Any + +import numpy as np +import torch +import yaml +from pydantic import ValidationError + +from tias_judge_model.cli.dataset import ( + CONFIG_FILENAME, + FIGURES_DIRNAME, + LOG_FILENAME, + make_run_id, + resolve_git_commit, + resolved_config_document, +) +from tias_judge_model.data.classes import CLASS_SLUGS, label_to_id +from tias_judge_model.data.manifest import ManifestRecord, read_manifest +from tias_judge_model.evaluation.figures import ( + LEARNING_CURVES_FILENAME, + confusion_matrix_filename, + plot_confusion_matrix, + plot_learning_curves, +) +from tias_judge_model.evaluation.metrics import ( + SUBSETS, + aggregate_folds, + mean_std, + subset_metrics, + sum_confusion_matrices, +) +from tias_judge_model.schema import ExperimentConfig, load_config +from tias_judge_model.training.cross_validation import ( + CrossValidationResult, + FoldResult, + fold_seed, + run_cross_validation, +) + +logger = logging.getLogger(__name__) + +METRICS_FILENAME = "metrics.json" +HISTORY_FILENAME = "history.csv" +CONFUSION_MATRIX_FILENAME = "confusion_matrix.csv" +SPLIT_FILENAME = "split.json" +PREDICTIONS_FILENAME = "predictions.csv" +CHECKPOINTS_DIRNAME = "checkpoints" +# 混同行列(全 fold 合計)を書き出す部分集合(SPEC_02「評価の出し方」). +CONFUSION_SUBSETS: tuple[str, ...] = ("common", "all") + +_LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s" + +_EPILOG = """\ +使用例(サーバ上で実行する.手元では configs/cv_synthetic.yaml で合成データを使う): + uv run tias-train cv --config configs/cv_with_mild.yaml + uv run tias-remote run --name cv-with-mild -- tias-train cv --config configs/cv_with_mild.yaml + +環境変数: + TIAS_DATA_ROOT data.root を上書き(tias-remote run が各サーバの data_dir を渡す) + TIAS_GIT_COMMIT 実験 ID に入れる git の短縮ハッシュ(tias-remote run が渡す.無ければ nogit) +""" + + +# --------------------------------------------------------------------------- +# cv +# --------------------------------------------------------------------------- + + +def _cmd_cv(args: argparse.Namespace) -> int: + started = time.monotonic() + config = load_config(args.config) + git_commit = resolve_git_commit(os.environ) + run_id = make_run_id(datetime.now(), args.config.stem, git_commit) + run_dir = args.out_root / run_id + if run_dir.exists(): + logger.error("実験ディレクトリが既にある: %s(時間をおいて再実行する)", run_dir) + return 1 + run_dir.mkdir(parents=True) + + file_handler = logging.FileHandler(run_dir / LOG_FILENAME, encoding="utf-8") + file_handler.setFormatter(logging.Formatter(_LOG_FORMAT)) + root = logging.getLogger() + root.addHandler(file_handler) + try: + _run_cv(config, run_dir, run_id=run_id, git_commit=git_commit, started=started) + except (ValueError, OSError) as exc: + # log.txt にも失敗の理由を残すため,ハンドラを外す前にここで記録する. + logger.error("%s", exc) + return 1 + finally: + root.removeHandler(file_handler) + file_handler.close() + sys.stdout.write(f"{run_id}\n") + sys.stdout.flush() + return 0 + + +def _run_cv( + config: ExperimentConfig, run_dir: Path, *, run_id: str, git_commit: str, started: float +) -> None: + logger.info("交差検証の実験 ID: %s", run_id) + document = resolved_config_document( + config, run_id=run_id, git_commit=git_commit, command="train cv" + ) + document["seeds"] = { + "split": config.split.seed, + "folds": [fold_seed(config.split.seed, i) for i in range(config.split.n_folds)], + } + _write_yaml(run_dir / CONFIG_FILENAME, document) + + records = read_manifest(config.data.manifest_path) + result = run_cross_validation(config, records, run_dir / CHECKPOINTS_DIRNAME) + + _write_split(run_dir / SPLIT_FILENAME, config, result) + _write_predictions(run_dir / PREDICTIONS_FILENAME, records, result) + history = history_rows(result.folds) + _write_csv(run_dir / HISTORY_FILENAME, history) + + metrics = build_metrics(config, records, result, run_id=run_id, git_commit=git_commit) + _write_confusion_csv(run_dir / CONFUSION_MATRIX_FILENAME, metrics["confusion_matrix_sum"]) + figures_dir = run_dir / FIGURES_DIRNAME + plot_learning_curves(history, figures_dir / LEARNING_CURVES_FILENAME) + for subset in CONFUSION_SUBSETS: + matrix = metrics["confusion_matrix_sum"].get(subset) + if matrix is not None: + plot_confusion_matrix( + matrix, + f"{subset} (sum of {len(result.folds)} folds held-out)", + figures_dir / confusion_matrix_filename(subset), + ) + + total_seconds = time.monotonic() - started + metrics["timing"]["total_seconds"] = round(total_seconds, 2) + _write_json(run_dir / METRICS_FILENAME, metrics) + + for subset in SUBSETS: + summary = metrics["mean_std"].get(subset) + if summary: + logger.info( + "%s: macro F1 %.4f ± %.4f・accuracy %.4f ± %.4f", + subset, + summary["macro_f1"]["mean"], + summary["macro_f1"]["std"], + summary["accuracy"]["mean"], + summary["accuracy"]["std"], + ) + breakdown = (metrics["pooled"].get("mild") or {}).get("prediction_breakdown") + if breakdown: + overall = breakdown["overall"] + logger.info( + "mild の予測の内訳(全 fold 合計 %d 件): 淡紅 %.3f・元のクラス %.3f・その他 %.3f", + overall["n"], + overall["light_red"], + overall["original"], + overall["other"], + ) + logger.info( + "出力先: %s(全体の所要 %.1f 秒・うち画像の読み込み %.1f 秒)", + run_dir, + total_seconds, + result.data_loading_seconds, + ) + + +def history_rows(folds: Sequence[FoldResult]) -> list[dict[str, Any]]: + """`history.csv` の行(fold・エポックごとの損失・検証 macro F1・学習率・所要秒数)を作る. + + Args: + folds: fold ごとの結果. + + Returns: + 行の dict のリスト. + """ + rows: list[dict[str, Any]] = [] + for fold in folds: + for h in fold.history: + rows.append( + { + "fold": fold.index, + "epoch": h.epoch, + "train_loss": round(h.train_loss, 6), + "val_loss": round(h.val_loss, 6), + "val_macro_f1": round(h.val_macro_f1, 6), + "lr": h.lr, + "epoch_seconds": round(h.seconds, 3), + } + ) + return rows + + +def build_metrics( + config: ExperimentConfig, + records: Sequence[ManifestRecord], + result: CrossValidationResult, + *, + run_id: str, + git_commit: str, +) -> dict[str, Any]: + """`metrics.json` の内容(fold ごと・fold 平均 ± 標準偏差・全 fold 合算の指標と所要時間)を作る. + + 集計値だけを含み,患者 ID・ファイル名・画像単位の値は含まない. + + Args: + config: 実験設定. + records: manifest の全レコード. + result: 交差検証の結果. + run_id: 実験 ID. + git_commit: git の短縮ハッシュ(または `nogit`). + + Returns: + JSON に書き出せる dict(`timing.total_seconds` は呼び出し側で埋める). + """ + labels = np.array([label_to_id(r.label) for r in records], dtype=np.int64) + severities = np.array([r.severity for r in records]) + + fold_entries: list[dict[str, Any]] = [] + all_true: list[np.ndarray] = [] + all_pred: list[np.ndarray] = [] + all_sev: list[np.ndarray] = [] + for fold in result.folds: + idx = fold.held_out_indices + y_true, y_pred, sev = labels[idx], fold.held_out_pred, severities[idx] + all_true.append(y_true) + all_pred.append(y_pred) + all_sev.append(sev) + seconds = [h.seconds for h in fold.history] + fold_entries.append( + { + "fold": fold.index, + "seed": fold.seed, + "best_epoch": fold.best_epoch, + "best_val_macro_f1": fold.best_val_macro_f1, + "counts": { + "patients": { + "train": len(fold.train_patients), + "val": len(fold.val_patients), + "held_out": len(fold.held_out_patients), + }, + "images_by_group": fold.group_counts, + }, + "timing": { + "train_seconds": round(fold.train_seconds, 2), + "eval_seconds": round(fold.eval_seconds, 2), + "epochs_run": len(fold.history), + "mean_epoch_seconds": round(float(np.mean(seconds)), 3) if seconds else None, + }, + "subsets": subset_metrics(y_true, y_pred, sev), + } + ) + + pooled = subset_metrics( + np.concatenate(all_true), np.concatenate(all_pred), np.concatenate(all_sev) + ) + mean_std_by_subset = { + subset: aggregate_folds([f["subsets"][subset] for f in fold_entries]) for subset in SUBSETS + } + confusion_sum = { + subset: ( + sum_confusion_matrices( + [ + f["subsets"][subset]["confusion_matrix"] + for f in fold_entries + if f["subsets"][subset] + ] + ) + if pooled.get(subset) + else None + ) + for subset in CONFUSION_SUBSETS + } + return { + "run_id": run_id, + "git_commit": git_commit, + "classes": list(CLASS_SLUGS), + "severities_trained": list(config.data.severities), + "subsets": {name: list(sevs) for name, sevs in SUBSETS.items()}, + "n_folds": len(result.folds), + "best_epoch": mean_std([f.best_epoch for f in result.folds]), + "mean_std": mean_std_by_subset, + "pooled": pooled, + "confusion_matrix_sum": confusion_sum, + "folds": fold_entries, + "timing": { + "total_seconds": None, + "data_loading_seconds": round(result.data_loading_seconds, 2), + "train_seconds_total": round(sum(f.train_seconds for f in result.folds), 2), + "eval_seconds_total": round(sum(f.eval_seconds for f in result.folds), 2), + }, + "environment": { + "device": result.device, + "model_name": config.model.name, + "pretrained": config.model.pretrained, + "num_parameters": result.num_parameters, + "image_size": config.train.image_size, + "torch_version": torch.__version__, + }, + } + + +def _write_split(path: Path, config: ExperimentConfig, result: CrossValidationResult) -> None: + # 患者 ID を含む(回収しない). + _write_json( + path, + { + "seed": config.split.seed, + "n_folds": config.split.n_folds, + "inner_val_ratio": config.train.inner_val_ratio, + "severities_trained": list(config.data.severities), + "folds": [ + { + "fold": f.index, + "seed": f.seed, + "train": list(f.train_patients), + "val": list(f.val_patients), + "held_out": list(f.held_out_patients), + } + for f in result.folds + ], + }, + ) + + +def _write_predictions( + path: Path, records: Sequence[ManifestRecord], result: CrossValidationResult +) -> None: + # 画像ごとの予測(個票.患者 ID・ファイル名を含む.回収しない). + rows: list[dict[str, Any]] = [] + for fold in result.folds: + for i, probs in zip(fold.held_out_indices, fold.held_out_probs, strict=True): + record = records[int(i)] + row: dict[str, Any] = { + "fold": fold.index, + "patient_id": record.patient_id, + "image_path": record.image_path, + "label": record.label, + "severity": record.severity, + "pred": CLASS_SLUGS[int(np.argmax(probs))], + } + row.update( + { + f"prob_{slug}": round(float(p), 6) + for slug, p in zip(CLASS_SLUGS, probs, strict=True) + } + ) + rows.append(row) + _write_csv(path, rows) + + +def _write_confusion_csv(path: Path, matrices: Mapping[str, list[list[int]] | None]) -> None: + rows: list[dict[str, Any]] = [] + for subset, matrix in matrices.items(): + if matrix is None: + continue + for slug, counts in zip(CLASS_SLUGS, matrix, strict=True): + row: dict[str, Any] = {"subset": subset, "true": slug} + row.update({f"pred_{p}": c for p, c in zip(CLASS_SLUGS, counts, strict=True)}) + rows.append(row) + _write_csv(path, rows) + + +def _write_csv(path: Path, rows: Sequence[Mapping[str, Any]]) -> None: + with path.open("w", encoding="utf-8", newline="") as f: + if not rows: + return + writer = csv.DictWriter(f, fieldnames=list(rows[0]), lineterminator="\n") + writer.writeheader() + writer.writerows(rows) + + +def _write_json(path: Path, data: Mapping[str, Any]) -> None: + with path.open("w", encoding="utf-8", newline="\n") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + f.write("\n") + + +def _write_yaml(path: Path, data: Mapping[str, Any]) -> None: + with path.open("w", encoding="utf-8", newline="\n") as f: + yaml.safe_dump(dict(data), f, allow_unicode=True, sort_keys=False) + + +# --------------------------------------------------------------------------- +# エントリポイント +# --------------------------------------------------------------------------- + + +def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="tias-train", + description="患者単位 K-fold 交差検証で学習・評価する", + epilog=_EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + sub = parser.add_subparsers(dest="subcommand", required=True) + p_cv = sub.add_parser( + "cv", + help="K-fold 交差検証で学習・評価して runs/<実験ID>/ に書き出す", + description=( + "manifest 全体で患者単位・クラス × 程度の層化の K-fold 分割を作り,各 fold で学習し" + "held_out(全程度)で評価する.最後に実験 ID を標準出力へ出す." + ), + ) + p_cv.add_argument("--config", type=Path, required=True, help="実験設定 YAML.") + p_cv.add_argument( + "--out-root", type=Path, default=Path("runs"), help="実験ディレクトリの親(既定: runs)." + ) + return parser.parse_args(argv) + + +def _configure_logging() -> None: + # ログは標準エラーへ(標準出力は実験 ID の受け渡しに使うため汚さない). + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter(_LOG_FORMAT)) + logging.basicConfig(level=logging.INFO, handlers=[handler]) + + +def main(argv: Sequence[str] | None = None) -> int: + """tias-train コマンドのメインエントリポイント. + + Args: + argv: 引数リスト.None のとき sys.argv[1:] を使用. + + Returns: + 終了コード(成功 0・入力の問題 1). + """ + args = _parse_args(argv) + _configure_logging() + try: + return _cmd_cv(args) + except ValidationError as exc: + logger.error("設定が不正である:\n%s", exc) + return 1 + except (ValueError, OSError) as exc: + logger.error("%s", exc) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/tias_judge_model/data/dataset.py b/src/tias_judge_model/data/dataset.py new file mode 100644 index 0000000..72614c1 --- /dev/null +++ b/src/tias_judge_model/data/dataset.py @@ -0,0 +1,91 @@ +"""学習・評価用の Dataset と入力の正規化(SPEC_02「データ拡張」「前処理」). + +Dataset はレターボックス済みの uint8 画像を返し,正規化(ImageNet の mean / std)はデバイス上で +`normalize_batch` が行う(CPU のワーカーで float 化するより転送量が小さいため). + +データ拡張は学習時のみ・幾何変換のみ(左右反転・回転・拡大縮小・平行移動.はみ出しは黒). +舌の色そのものがラベルであるため,**色を変える拡張は行わない**. +""" + +from __future__ import annotations + +import numpy as np +import torch +from torch.utils.data import Dataset +from torchvision.transforms import v2 + +IMAGENET_MEAN: tuple[float, float, float] = (0.485, 0.456, 0.406) +IMAGENET_STD: tuple[float, float, float] = (0.229, 0.224, 0.225) + +# 幾何拡張の強さ(SPEC_02「学習の設定」の箇条書き). +_FLIP_PROB = 0.5 +_ROTATION_DEGREES = 10.0 +_SCALE_RANGE = (0.9, 1.1) +_TRANSLATE_RATIO = (0.05, 0.05) + + +def make_train_augmentation() -> v2.Compose: + """学習時の幾何拡張(左右反転・回転 ±10°・拡大縮小 0.9〜1.1・平行移動 ±5%)を作る. + + はみ出した部分は黒 (0) で埋める.色を変える変換は含めない. + + Returns: + `(3, H, W)` の uint8 テンソルを受け取り同じ形状で返す変換. + """ + return v2.Compose( + [ + v2.RandomHorizontalFlip(p=_FLIP_PROB), + v2.RandomAffine( + degrees=_ROTATION_DEGREES, + translate=_TRANSLATE_RATIO, + scale=_SCALE_RANGE, + interpolation=v2.InterpolationMode.BILINEAR, + fill=0, + ), + ] + ) + + +class TongueImageDataset(Dataset): + """レターボックス済み画像とクラス ID の Dataset. + + Args: + images: `(N, H, W, 3)` の uint8 配列(RGB). + labels: `(N,)` の整数配列(クラス ID.`classes.py` の固定 ID). + augment: True なら学習時の幾何拡張をかける. + """ + + def __init__(self, images: np.ndarray, labels: np.ndarray, *, augment: bool) -> None: + if len(images) != len(labels): + raise RuntimeError("画像とラベルの件数が一致しない") + self._images = images + self._labels = labels.astype(np.int64) + self._augment = make_train_augmentation() if augment else None + + def __len__(self) -> int: + return len(self._labels) + + def __getitem__(self, index: int) -> tuple[torch.Tensor, int]: + """1 件を返す. + + Returns: + (`(3, H, W)` の uint8 テンソル, クラス ID). + """ + image = torch.from_numpy(np.ascontiguousarray(self._images[index])).permute(2, 0, 1) + if self._augment is not None: + image = self._augment(image) + return image, int(self._labels[index]) + + +def normalize_batch(images: torch.Tensor) -> torch.Tensor: + """uint8 画像のバッチを float にして ImageNet の mean / std で正規化する. + + Args: + images: `(B, 3, H, W)` の uint8 テンソル(任意のデバイス). + + Returns: + `(B, 3, H, W)` の float32 テンソル(同じデバイス). + """ + mean = torch.tensor(IMAGENET_MEAN, device=images.device).view(1, 3, 1, 1) + std = torch.tensor(IMAGENET_STD, device=images.device).view(1, 3, 1, 1) + return (images.float() / 255.0 - mean) / std diff --git a/src/tias_judge_model/data/images.py b/src/tias_judge_model/data/images.py new file mode 100644 index 0000000..0e88835 --- /dev/null +++ b/src/tias_judge_model/data/images.py @@ -0,0 +1,78 @@ +"""画像の読み込みとレターボックス化(SPEC_02「前処理」). + +アスペクト比を保って長辺を `image_size` に合わせ,余白を黒 (0, 0, 0) で埋める.マスク外の +領域も黒であるため,パディングも黒で統一する.学習を速くするため,全画像をレターボックス化した +uint8 配列としてメモリに載せる(1085 枚 × 224² × 3 ≒ 160 MB). +""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from pathlib import Path + +import numpy as np +from PIL import Image + +from tias_judge_model.data.manifest import ManifestRecord, resolve_image_path + +logger = logging.getLogger(__name__) + + +def letterbox(image: Image.Image, size: int) -> np.ndarray: + """画像をアスペクト比を保って `size × size` に収め,余白を黒で埋める. + + 長辺が `size` になるよう縮小(または拡大)し,中央に配置する. + + Args: + image: RGB の PIL 画像. + size: 出力の一辺(ピクセル). + + Returns: + `(size, size, 3)` の uint8 配列(RGB). + """ + width, height = image.size + scale = size / max(width, height) + new_w = max(1, round(width * scale)) + new_h = max(1, round(height * scale)) + resized = image.resize((new_w, new_h), Image.Resampling.BILINEAR) + canvas = Image.new("RGB", (size, size), (0, 0, 0)) + canvas.paste(resized, ((size - new_w) // 2, (size - new_h) // 2)) + return np.asarray(canvas, dtype=np.uint8) + + +def load_letterboxed_images( + records: Sequence[ManifestRecord], images_dir: Path, size: int +) -> np.ndarray: + """manifest の全画像を読み,レターボックス化して 1 つの配列にまとめる. + + 読めない画像は学習を歪めるため止める(fail fast).例外メッセージには画像のパスを出さず, + manifest のデータ行の番号(1 始まり)で示す. + + Args: + records: manifest レコード. + images_dir: 画像ディレクトリ(`/images`). + size: 出力の一辺(ピクセル). + + Returns: + `(N, size, size, 3)` の uint8 配列(RGB).並びは `records` と同じ. + + Raises: + ValueError: 画像が無い,または読めない場合. + """ + out = np.zeros((len(records), size, size, 3), dtype=np.uint8) + for i, record in enumerate(records): + path = resolve_image_path(images_dir, record) + if not path.is_file(): + raise ValueError(f"manifest のデータ {i + 1} 件目: 画像ファイルが無い") + try: + with Image.open(path) as img: + img.load() + rgb = img.convert("RGB") + except (OSError, ValueError, Image.DecompressionBombError) as exc: + raise ValueError( + f"manifest のデータ {i + 1} 件目: 画像を読めない({type(exc).__name__})" + ) from None + out[i] = letterbox(rgb, size) + logger.info("画像 %d 件をレターボックス化した(%d×%d)", len(records), size, size) + return out diff --git a/src/tias_judge_model/data/split.py b/src/tias_judge_model/data/split.py index 48f656c..9560fed 100644 --- a/src/tias_judge_model/data/split.py +++ b/src/tias_judge_model/data/split.py @@ -261,6 +261,53 @@ return folds +def split_inner_val( + records: Sequence[ManifestRecord], val_ratio: float, seed: int +) -> tuple[tuple[str, ...], tuple[str, ...]]: + """交差検証の fold の学習側を,患者単位・クラス × 程度の層化で学習用と検証用に分ける. + + `split_train_val_test` と同じ方式(層化グループごとに患者 ID を昇順に並べてから seed で + 並べ替え,先頭から `round(患者数 × 比率)` 人を検証用に取る)で,test を取らない 2 分割を作る. + `data.severities` による除外はここでは行わない(分割後に適用する). + + Args: + records: fold の学習側の manifest レコード(除外前). + val_ratio: 検証用に回す患者の比率(0 より大きく 1 未満). + seed: 乱数 seed. + + Returns: + (学習用の患者 ID, 検証用の患者 ID).それぞれ昇順. + + Raises: + ValueError: 比率が不正,レコードが空,同一患者に異なる label がある, + またはある層化グループで学習用に患者が残らない場合. + """ + if not 0.0 < val_ratio < 1.0: + raise ValueError(f"検証用の比率が不正である({val_ratio}).0 より大きく 1 未満であること") + by_group = _patients_by_group(records) + rng = np.random.default_rng(seed) + train: list[str] = [] + val: list[str] = [] + for group in ALL_DIR_NAMES: + patients = by_group.get(group) + if not patients: + continue + shuffled = [patients[i] for i in rng.permutation(len(patients))] + n_val = _round_half_up(len(patients) * val_ratio) + if n_val >= len(patients): + raise ValueError( + f"グループ {group} の患者 {len(patients)} 人では学習用に患者が残らない" + f"(検証用 {n_val} 人)" + ) + if n_val == 0: + logger.warning( + "グループ %s は患者 %d 人のため検証用 0 人になった", group, len(patients) + ) + val.extend(shuffled[:n_val]) + train.extend(shuffled[n_val:]) + return tuple(sorted(train)), tuple(sorted(val)) + + def select_records( records: Iterable[ManifestRecord], patient_ids: Collection[str] ) -> list[ManifestRecord]: diff --git a/src/tias_judge_model/evaluation/__init__.py b/src/tias_judge_model/evaluation/__init__.py new file mode 100644 index 0000000..fd78ef3 --- /dev/null +++ b/src/tias_judge_model/evaluation/__init__.py @@ -0,0 +1 @@ +"""評価層: 指標・予測の内訳・fold 間の集計・図.""" diff --git a/src/tias_judge_model/evaluation/figures.py b/src/tias_judge_model/evaluation/figures.py new file mode 100644 index 0000000..4ca78f9 --- /dev/null +++ b/src/tias_judge_model/evaluation/figures.py @@ -0,0 +1,101 @@ +"""交差検証の図(学習曲線・混同行列). + +`figures/` は回収対象であるため,集計値の図だけを描き,画像そのものは描かない(SPEC_01 +「実験の出力」).`data/survey_figures.py` と同じく pyplot を使わず `Figure` + +`FigureCanvasAgg` で描く.ラベルは日本語フォントの有無に左右されないよう英字で書く. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from pathlib import Path + +import numpy as np +from matplotlib.backends.backend_agg import FigureCanvasAgg +from matplotlib.figure import Figure + +from tias_judge_model.data.classes import CLASS_SLUGS + +LEARNING_CURVES_FILENAME = "learning_curves.png" +_DPI = 120 + + +def confusion_matrix_filename(subset: str) -> str: + """部分集合の混同行列の図のファイル名を返す(例: `confusion_matrix_all.png`).""" + return f"confusion_matrix_{subset}.png" + + +def plot_learning_curves(history: Sequence[Mapping[str, float]], path: Path) -> None: + """fold ごとの学習曲線(損失・検証 macro F1)を描いて PNG に保存する. + + Args: + history: エポックごとの記録(`fold`・`epoch`・`train_loss`・`val_loss`・`val_macro_f1`). + path: 出力先の PNG パス.親ディレクトリは作成される. + """ + folds = sorted({int(h["fold"]) for h in history}) + fig = Figure(figsize=(12, 5), dpi=_DPI) + FigureCanvasAgg(fig) + ax_loss, ax_f1 = fig.subplots(1, 2) + for fold in folds: + rows = [h for h in history if int(h["fold"]) == fold] + epochs = [int(h["epoch"]) for h in rows] + line = ax_loss.plot(epochs, [h["train_loss"] for h in rows], label=f"fold{fold} train")[0] + ax_loss.plot( + epochs, + [h["val_loss"] for h in rows], + linestyle="--", + color=line.get_color(), + label=f"fold{fold} val", + ) + ax_f1.plot(epochs, [h["val_macro_f1"] for h in rows], label=f"fold{fold}") + ax_loss.set_xlabel("epoch") + ax_loss.set_ylabel("loss") + ax_loss.set_title("loss (solid: train, dashed: val)") + ax_loss.legend(fontsize=7) + ax_f1.set_xlabel("epoch") + ax_f1.set_ylabel("val macro F1") + ax_f1.set_ylim(0.0, 1.0) + ax_f1.set_title("validation macro F1") + ax_f1.legend(fontsize=7) + fig.tight_layout() + path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(path) + + +def plot_confusion_matrix(matrix: Sequence[Sequence[int]], title: str, path: Path) -> None: + """混同行列(件数と行方向の割合)を描いて PNG に保存する. + + Args: + matrix: 5×5 の混同行列(行が正解・列が予測.クラス ID 順). + title: 図のタイトル. + path: 出力先の PNG パス.親ディレクトリは作成される. + """ + cm = np.asarray(matrix, dtype=np.float64) + row_sums = cm.sum(axis=1, keepdims=True) + ratio = np.divide(cm, row_sums, out=np.zeros_like(cm), where=row_sums > 0) + fig = Figure(figsize=(7, 6), dpi=_DPI) + FigureCanvasAgg(fig) + ax = fig.subplots() + image = ax.imshow(ratio, vmin=0.0, vmax=1.0, cmap="Blues") + fig.colorbar(image, ax=ax, label="row ratio") + n = len(CLASS_SLUGS) + ax.set_xticks(range(n), CLASS_SLUGS, rotation=30) + ax.set_yticks(range(n), CLASS_SLUGS) + ax.set_xlabel("predicted") + ax.set_ylabel("true") + ax.set_title(title) + for i in range(n): + for j in range(n): + color = "white" if ratio[i, j] > 0.5 else "black" + ax.text( + j, + i, + f"{int(cm[i, j])}\n{ratio[i, j]:.2f}", + ha="center", + va="center", + color=color, + fontsize=8, + ) + fig.tight_layout() + path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(path) diff --git a/src/tias_judge_model/evaluation/metrics.py b/src/tias_judge_model/evaluation/metrics.py new file mode 100644 index 0000000..246ae42 --- /dev/null +++ b/src/tias_judge_model/evaluation/metrics.py @@ -0,0 +1,223 @@ +"""評価指標(SPEC_02「評価指標」「交差検証の実行」). + +すべて入力 → 出力の純関数.クラスの軸は常に `classes.py` の固定 ID 順(混同行列の軸をぶらさない). +出力は集計値だけであり,画像単位の値・患者 ID は含まない. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +import numpy as np +from sklearn.metrics import confusion_matrix, f1_score, precision_recall_fscore_support + +from tias_judge_model.data.classes import CLASS_SLUGS, NUM_CLASSES, label_to_id + +# 淡紅(正常)のクラス ID.mild の予測の内訳で「淡紅と予測された割合」に使う. +LIGHT_RED_ID: int = label_to_id("light_red") + +# 評価の部分集合と含める程度(SPEC_02「評価の出し方」). +SUBSETS: dict[str, tuple[str, ...]] = { + "all": ("strong", "mild", "normal"), + "common": ("strong", "normal"), + "mild": ("mild",), +} + +# fold 間の平均・標準偏差を取らないキー(件数・行列). +_NON_AVERAGED_KEYS = frozenset({"n", "support", "confusion_matrix"}) + + +def macro_f1(y_true: np.ndarray, y_pred: np.ndarray) -> float: + """macro F1 を返す(`y_true` に現れるクラスで平均する). + + `y_true` に現れないクラス(例: mild の部分集合の淡紅)を平均に含めると,そのクラスへの予測が + それだけで F1 = 0 として平均を下げるため,正解側に現れるクラスだけで平均する. + + Args: + y_true: `(N,)` の正解クラス ID. + y_pred: `(N,)` の予測クラス ID. + + Returns: + macro F1(N = 0 なら 0.0). + """ + if len(y_true) == 0: + return 0.0 + labels = np.unique(y_true) + return float(f1_score(y_true, y_pred, labels=labels, average="macro", zero_division=0)) + + +def classification_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict[str, Any]: + """分類指標(macro F1・accuracy・クラス別 precision / recall / F1・混同行列)を返す. + + Args: + y_true: `(N,)` の正解クラス ID(0〜4). + y_pred: `(N,)` の予測クラス ID(0〜4). + + Returns: + `{"n", "macro_f1", "accuracy", "per_class": {slug: {precision, recall, f1, support}}, + "confusion_matrix": [[...]]}`.混同行列は行が正解・列が予測でクラス ID 順(5×5). + クラス別は `y_true` に現れるクラスだけを含む. + """ + y_true = np.asarray(y_true, dtype=np.int64) + y_pred = np.asarray(y_pred, dtype=np.int64) + all_labels = list(range(NUM_CLASSES)) + cm = confusion_matrix(y_true, y_pred, labels=all_labels) + precision, recall, f1, support = precision_recall_fscore_support( + y_true, y_pred, labels=all_labels, zero_division=0 + ) + present = set(np.unique(y_true).tolist()) + per_class = { + CLASS_SLUGS[c]: { + "precision": float(precision[c]), + "recall": float(recall[c]), + "f1": float(f1[c]), + "support": int(support[c]), + } + for c in all_labels + if c in present + } + return { + "n": int(len(y_true)), + "macro_f1": macro_f1(y_true, y_pred), + "accuracy": float(np.mean(y_true == y_pred)) if len(y_true) else 0.0, + "per_class": per_class, + "confusion_matrix": cm.astype(int).tolist(), + } + + +def mild_prediction_breakdown(y_true: np.ndarray, y_pred: np.ndarray) -> dict[str, Any]: + """mild の画像の予測の内訳(淡紅・元のクラス・その他と予測された割合)を返す. + + 淡紅と予測することを誤りと断定せず,割合として報告する(SPEC_02「程度(ややあり)の扱い」). + 淡紅には mild が無いため,元のクラスと淡紅は重ならない. + + Args: + y_true: `(N,)` の mild の画像の正解クラス ID. + y_pred: `(N,)` の予測クラス ID. + + Returns: + `{"overall": {...}, "per_class": {slug: {...}}}`.各値は + `{"n", "light_red", "original", "other"}`(割合.N = 0 なら None). + クラス別は `y_true` に現れるクラスだけを含む. + """ + y_true = np.asarray(y_true, dtype=np.int64) + y_pred = np.asarray(y_pred, dtype=np.int64) + per_class = { + CLASS_SLUGS[c]: _breakdown(y_true[y_true == c], y_pred[y_true == c]) + for c in range(NUM_CLASSES) + if np.any(y_true == c) + } + return {"overall": _breakdown(y_true, y_pred), "per_class": per_class} + + +def _breakdown(y_true: np.ndarray, y_pred: np.ndarray) -> dict[str, Any]: + n = len(y_true) + if n == 0: + return {"n": 0, "light_red": None, "original": None, "other": None} + to_light_red = y_pred == LIGHT_RED_ID + to_original = (y_pred == y_true) & ~to_light_red + other = ~to_light_red & ~to_original + return { + "n": int(n), + "light_red": float(np.mean(to_light_red)), + "original": float(np.mean(to_original)), + "other": float(np.mean(other)), + } + + +def subset_metrics( + y_true: np.ndarray, y_pred: np.ndarray, severities: Sequence[str] +) -> dict[str, Any]: + """部分集合 `all`・`common`・`mild` ごとの指標を返す(SPEC_02「評価の出し方」). + + Args: + y_true: `(N,)` の正解クラス ID. + y_pred: `(N,)` の予測クラス ID. + severities: 長さ N の各画像の程度(`strong`・`mild`・`normal`). + + Returns: + 部分集合名 → `classification_metrics` の結果(該当 0 件なら None). + `mild` には予測の内訳 `prediction_breakdown` を加える. + """ + y_true = np.asarray(y_true, dtype=np.int64) + y_pred = np.asarray(y_pred, dtype=np.int64) + sev = np.asarray(severities) + result: dict[str, Any] = {} + for name, included in SUBSETS.items(): + mask = np.isin(sev, included) + if not np.any(mask): + result[name] = None + continue + metrics = classification_metrics(y_true[mask], y_pred[mask]) + if name == "mild": + metrics["prediction_breakdown"] = mild_prediction_breakdown(y_true[mask], y_pred[mask]) + result[name] = metrics + return result + + +def mean_std(values: Sequence[float]) -> dict[str, float | int | None]: + """値の平均と標準偏差(不偏.1 件なら 0)を返す. + + Args: + values: fold ごとの値. + + Returns: + `{"mean", "std", "n"}`(0 件なら mean・std は None). + """ + arr = np.asarray(values, dtype=np.float64) + if arr.size == 0: + return {"mean": None, "std": None, "n": 0} + std = float(np.std(arr, ddof=1)) if arr.size > 1 else 0.0 + return {"mean": float(np.mean(arr)), "std": std, "n": int(arr.size)} + + +def aggregate_folds(fold_values: Sequence[Mapping[str, Any] | None]) -> dict[str, Any] | None: + """fold ごとの入れ子の指標(同じ形の dict)から,数値の葉ごとに平均 ± 標準偏差を作る. + + 件数(`n`・`support`)と混同行列は平均しない.ある fold に無いキー(例: その fold の + mild に現れないクラス)は,そのキーを持つ fold だけで集計する(`n` に fold 数が入る). + + Args: + fold_values: fold ごとの指標(None の fold は除く). + + Returns: + 同じ形の dict で,数値の葉を `{"mean", "std", "n"}` に置き換えたもの. + 有効な fold が無ければ None. + """ + present = [v for v in fold_values if v is not None] + if not present: + return None + keys: list[str] = [] + for v in present: + keys.extend(k for k in v if k not in keys) + result: dict[str, Any] = {} + for key in keys: + if key in _NON_AVERAGED_KEYS: + continue + items = [v.get(key) for v in present] + items = [x for x in items if x is not None] + if not items: + continue + if all(isinstance(x, Mapping) for x in items): + nested = aggregate_folds(items) + if nested: + result[key] = nested + elif all(isinstance(x, int | float) and not isinstance(x, bool) for x in items): + result[key] = mean_std([float(x) for x in items]) + return result + + +def sum_confusion_matrices(matrices: Sequence[Sequence[Sequence[int]]]) -> list[list[int]]: + """混同行列(同じ形)の合計を返す. + + Args: + matrices: 混同行列のリスト(各 5×5). + + Returns: + 合計の混同行列(5×5.空なら 0 行列). + """ + total = np.zeros((NUM_CLASSES, NUM_CLASSES), dtype=np.int64) + for m in matrices: + total += np.asarray(m, dtype=np.int64) + return total.astype(int).tolist() diff --git a/src/tias_judge_model/models/__init__.py b/src/tias_judge_model/models/__init__.py new file mode 100644 index 0000000..664250e --- /dev/null +++ b/src/tias_judge_model/models/__init__.py @@ -0,0 +1 @@ +"""モデル層: timm のモデル生成(アーキテクチャの差し替え点).""" diff --git a/src/tias_judge_model/models/factory.py b/src/tias_judge_model/models/factory.py new file mode 100644 index 0000000..66904b9 --- /dev/null +++ b/src/tias_judge_model/models/factory.py @@ -0,0 +1,42 @@ +"""timm によるモデル生成(SPEC_02「モデル」). + +`model.name` に timm のモデル名を渡すだけでアーキテクチャを差し替えられるようにする. +""" + +from __future__ import annotations + +import timm +from torch import nn + +from tias_judge_model.data.classes import NUM_CLASSES + + +def create_model(name: str, *, pretrained: bool, num_classes: int = NUM_CLASSES) -> nn.Module: + """timm のモデルを出力 `num_classes` クラスで作る. + + Args: + name: timm のモデル名(例: `convnext_tiny`・`efficientnet_b0`・`vit_small_patch16_224`). + pretrained: ImageNet 事前学習の重みを使うか(初回はダウンロードする). + num_classes: 出力クラス数. + + Returns: + 入力 `(B, 3, H, W)` の float テンソルから `(B, num_classes)` のロジットを返すモデル. + + Raises: + ValueError: timm に無いモデル名の場合. + """ + if not timm.is_model(name): + raise ValueError(f"timm に無いモデル名である: {name!r}") + return timm.create_model(name, pretrained=pretrained, num_classes=num_classes) + + +def count_parameters(model: nn.Module) -> int: + """モデルのパラメータ数(学習対象外も含む)を返す. + + Args: + model: モデル. + + Returns: + パラメータの要素数の合計. + """ + return sum(p.numel() for p in model.parameters()) diff --git a/src/tias_judge_model/schema.py b/src/tias_judge_model/schema.py index f01df80..313c9ea 100644 --- a/src/tias_judge_model/schema.py +++ b/src/tias_judge_model/schema.py @@ -1,6 +1,7 @@ """pydantic による設定スキーマと設定 YAML の読み込み. -学習・モデル関連の設定は後続のステップで `ExperimentConfig` にフィールドを追加して拡張する. +学習・モデル関連の設定(`model`・`train`)は省略でき,省略時は SPEC_02「学習の設定」の +既定値で補う(学習しないコマンド `tias-dataset` の設定ファイルをそのまま読めるようにするため). """ from __future__ import annotations @@ -102,16 +103,60 @@ return self +class ModelConfig(_StrictModel): + """モデルの設定(SPEC_02「学習の設定」). + + Attributes: + name: timm のモデル名(例: `convnext_tiny`・`resnet50`・`vit_small_patch16_224`). + pretrained: ImageNet 事前学習の重みを使うか. + """ + + name: str = Field(default="convnext_tiny", min_length=1) + pretrained: bool = True + + +class TrainConfig(_StrictModel): + """学習の設定(SPEC_02「学習の設定」). + + Attributes: + image_size: 入力画像の一辺(長辺を合わせるレターボックス). + batch_size: バッチサイズ. + max_epochs: 最大エポック数. + patience: 検証 macro F1 がこのエポック数改善しなければ打ち切る. + lr: AdamW の学習率. + weight_decay: AdamW の weight decay. + warmup_epochs: 線形 warmup のエポック数(その後 cosine で減衰). + inner_val_ratio: 交差検証の各 fold で,学習側から早期終了用の検証に回す患者の比率. + num_workers: DataLoader のワーカー数. + amp: CUDA のときに混合精度を使うか(CPU では無視する). + """ + + image_size: int = Field(default=224, ge=16) + batch_size: int = Field(default=32, ge=1) + max_epochs: int = Field(default=40, ge=1) + patience: int = Field(default=8, ge=1) + lr: float = Field(default=1e-4, gt=0.0) + weight_decay: float = Field(default=0.05, ge=0.0) + warmup_epochs: int = Field(default=2, ge=0) + inner_val_ratio: float = Field(default=0.15, gt=0.0, lt=1.0) + num_workers: int = Field(default=4, ge=0) + amp: bool = True + + class ExperimentConfig(_StrictModel): """実験設定のルート. Attributes: data: データの場所. split: 分割の設定. + model: モデルの設定(省略時は既定値). + train: 学習の設定(省略時は既定値). """ data: DataConfig split: SplitConfig = Field(default_factory=SplitConfig) + model: ModelConfig = Field(default_factory=ModelConfig) + train: TrainConfig = Field(default_factory=TrainConfig) def load_config(path: Path, environ: Mapping[str, str] | None = None) -> ExperimentConfig: diff --git a/src/tias_judge_model/training/__init__.py b/src/tias_judge_model/training/__init__.py new file mode 100644 index 0000000..d96c5d4 --- /dev/null +++ b/src/tias_judge_model/training/__init__.py @@ -0,0 +1 @@ +"""学習層: 学習ループ・交差検証の実行.""" diff --git a/src/tias_judge_model/training/cross_validation.py b/src/tias_judge_model/training/cross_validation.py new file mode 100644 index 0000000..e03dc6c --- /dev/null +++ b/src/tias_judge_model/training/cross_validation.py @@ -0,0 +1,301 @@ +"""患者単位 K-fold 交差検証の実行(SPEC_02「交差検証の実行」). + +1. manifest 全体で患者単位・9 グループ層化の K-fold 分割を作る(`data.severities` に関わらず同じ) +2. 各 fold で + 1. 学習側から `train.inner_val_ratio` の患者を検証用に取る(患者単位・9 グループ層化) + 2. `data.severities` を学習用と検証用にだけ適用する + 3. 検証 macro F1 が最良のエポックの重みを採用する(早期終了) + 4. held_out(**全程度**)で 1 回だけ評価する +3. 全 fold の held_out の予測を呼び出し側でまとめて集計する + +ログには件数・グループ別件数・損失・指標・時間だけを出し,患者 ID・ファイル名は出さない. +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from pathlib import Path + +import numpy as np +import torch +from torch.utils.data import DataLoader + +from tias_judge_model.data.classes import label_to_id +from tias_judge_model.data.dataset import TongueImageDataset +from tias_judge_model.data.images import load_letterboxed_images +from tias_judge_model.data.manifest import ManifestRecord +from tias_judge_model.data.split import ( + count_by_group, + filter_by_severity, + make_kfold_splits, + select_records, + split_inner_val, +) +from tias_judge_model.models.factory import count_parameters, create_model +from tias_judge_model.schema import ExperimentConfig, TrainConfig +from tias_judge_model.training.loop import ( + EpochRecord, + device_name, + predict_logits, + seed_everything, + select_device, + train_model, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class FoldResult: + """1 fold の学習・評価の結果. + + `train_patients`・`val_patients`・`held_out_patients` と `held_out_indices` は個票に当たるため, + サーバ上の `split.json`・`predictions.csv` にだけ書き出す. + + Attributes: + index: fold 番号(0 始まり). + seed: この fold の学習に使った seed. + train_patients: 学習用の患者 ID(除外前.昇順). + val_patients: 検証用の患者 ID(除外前.昇順). + held_out_patients: held_out の患者 ID(昇順). + group_counts: 分割名(`train`・`val`・`held_out`)→ 層化グループ → 画像枚数 + (`train`・`val` は `data.severities` 適用後,`held_out` は全程度). + history: エポックごとの記録. + best_epoch: 最良エポック(1 始まり). + best_val_macro_f1: 最良エポックの検証 macro F1. + train_seconds: 学習の所要秒数. + eval_seconds: held_out の評価の所要秒数. + held_out_indices: `(M,)` の held_out 画像の manifest 上の位置(0 始まり). + held_out_probs: `(M, C)` の held_out の予測確率(softmax). + """ + + index: int + seed: int + train_patients: tuple[str, ...] + val_patients: tuple[str, ...] + held_out_patients: tuple[str, ...] + group_counts: dict[str, dict[str, int]] + history: list[EpochRecord] + best_epoch: int + best_val_macro_f1: float + train_seconds: float + eval_seconds: float + held_out_indices: np.ndarray + held_out_probs: np.ndarray + + @property + def held_out_pred(self) -> np.ndarray: + """`(M,)` の held_out の予測クラス ID.""" + return self.held_out_probs.argmax(axis=1) + + +@dataclass +class CrossValidationResult: + """交差検証全体の結果. + + Attributes: + folds: fold ごとの結果. + data_loading_seconds: 画像の読み込み(レターボックス化)の所要秒数. + device: デバイスの表示名(GPU 名または `cpu`). + num_parameters: モデルのパラメータ数. + """ + + folds: list[FoldResult] = field(default_factory=list) + data_loading_seconds: float = 0.0 + device: str = "cpu" + num_parameters: int = 0 + + +def fold_seed(base_seed: int, fold_index: int) -> int: + """fold ごとの seed(内側の検証分割と学習の乱数)を分割の seed から決める. + + Args: + base_seed: 設定の `split.seed`. + fold_index: fold 番号(0 始まり). + + Returns: + `base_seed + fold_index`. + """ + return base_seed + fold_index + + +def run_cross_validation( + config: ExperimentConfig, records: Sequence[ManifestRecord], checkpoint_dir: Path +) -> CrossValidationResult: + """K-fold 交差検証で学習・評価する(各 fold の最良の重みを `checkpoint_dir` に保存する). + + Args: + config: 実験設定. + records: manifest の全レコード(検証済み.除外前). + checkpoint_dir: 各 fold の最良の重み `fold.pt` の保存先. + + Returns: + 交差検証の結果. + + Raises: + ValueError: 分割できない,画像を読めない,学習用・検証用が空になる等の入力の問題. + """ + records = list(records) + device = select_device() + result = CrossValidationResult(device=device_name(device)) + logger.info( + "交差検証を開始する: 画像 %d 件・%d fold・モデル %s(pretrained=%s)・程度 %s・デバイス %s", + len(records), + config.split.n_folds, + config.model.name, + config.model.pretrained, + list(config.data.severities), + result.device, + ) + + folds = make_kfold_splits(records, config.split.n_folds, config.split.seed) + + started = time.monotonic() + images = load_letterboxed_images(records, config.data.images_dir, config.train.image_size) + result.data_loading_seconds = time.monotonic() - started + logger.info("画像の読み込みに %.1f 秒かかった", result.data_loading_seconds) + labels = np.array([label_to_id(r.label) for r in records], dtype=np.int64) + position = {r.image_path: i for i, r in enumerate(records)} + + checkpoint_dir.mkdir(parents=True, exist_ok=True) + n_folds = len(folds) + for fold in folds: + prefix = f"fold{fold.index}({fold.index + 1}/{n_folds})" + seed = fold_seed(config.split.seed, fold.index) + train_side = select_records(records, fold.train) + train_patients, val_patients = split_inner_val( + train_side, config.train.inner_val_ratio, seed + ) + train_records = filter_by_severity( + select_records(train_side, train_patients), config.data.severities + ) + val_records = filter_by_severity( + select_records(train_side, val_patients), config.data.severities + ) + # held_out には data.severities を適用しない(全程度で評価する). + held_records = select_records(records, fold.held_out) + if not train_records or not val_records or not held_records: + raise ValueError( + f"{prefix}: 学習用 {len(train_records)} 件・検証用 {len(val_records)} 件・" + f"held_out {len(held_records)} 件のいずれかが 0 件である" + ) + group_counts = { + "train": count_by_group(train_records), + "val": count_by_group(val_records), + "held_out": count_by_group(held_records), + } + logger.info( + "%s: 画像 学習 %d・検証 %d・held_out %d(患者 学習 %d・検証 %d・held_out %d)", + prefix, + len(train_records), + len(val_records), + len(held_records), + len(train_patients), + len(val_patients), + len(fold.held_out), + ) + for name, counts in group_counts.items(): + logger.info("%s: %s のグループ別枚数 %s", prefix, name, counts) + + train_idx = _positions(train_records, position) + val_idx = _positions(val_records, position) + held_idx = _positions(held_records, position) + + seed_everything(seed) + model = create_model(config.model.name, pretrained=config.model.pretrained).to(device) + result.num_parameters = count_parameters(model) + train_loader = _make_loader( + images[train_idx], labels[train_idx], config.train, device, train=True, seed=seed + ) + val_loader = _make_loader( + images[val_idx], labels[val_idx], config.train, device, train=False, seed=seed + ) + held_loader = _make_loader( + images[held_idx], labels[held_idx], config.train, device, train=False, seed=seed + ) + + trained = train_model( + model, train_loader, val_loader, config.train, device, log_prefix=prefix + ) + torch.save( + { + "fold": fold.index, + "model_name": config.model.name, + "best_epoch": trained.best_epoch, + "state_dict": trained.best_state, + }, + checkpoint_dir / f"fold{fold.index}.pt", + ) + + eval_started = time.monotonic() + use_amp = config.train.amp and device.type == "cuda" + logits, _ = predict_logits(model, held_loader, device, amp=use_amp) + probs = torch.softmax(torch.from_numpy(logits), dim=1).numpy() + eval_seconds = time.monotonic() - eval_started + held_acc = float(np.mean(probs.argmax(axis=1) == labels[held_idx])) + logger.info( + "%s: 最良エポック %d(val_macro_f1 %.4f)・held_out accuracy %.4f" + "(学習 %.1f 秒・評価 %.1f 秒)", + prefix, + trained.best_epoch, + trained.best_val_macro_f1, + held_acc, + trained.seconds, + eval_seconds, + ) + + result.folds.append( + FoldResult( + index=fold.index, + seed=seed, + train_patients=train_patients, + val_patients=val_patients, + held_out_patients=fold.held_out, + group_counts=group_counts, + history=trained.history, + best_epoch=trained.best_epoch, + best_val_macro_f1=trained.best_val_macro_f1, + train_seconds=trained.seconds, + eval_seconds=eval_seconds, + held_out_indices=held_idx, + held_out_probs=probs, + ) + ) + del model, trained + if device.type == "cuda": + torch.cuda.empty_cache() + return result + + +def _positions(records: Sequence[ManifestRecord], position: Mapping[str, int]) -> np.ndarray: + # `(N,)` の int64.各レコードの manifest 上の位置(0 始まり). + return np.array([position[r.image_path] for r in records], dtype=np.int64) + + +def _make_loader( + images: np.ndarray, + labels: np.ndarray, + config: TrainConfig, + device: torch.device, + *, + train: bool, + seed: int, +) -> DataLoader: + dataset = TongueImageDataset(images, labels, augment=train) + generator = torch.Generator() + generator.manual_seed(seed) + return DataLoader( + dataset, + batch_size=config.batch_size, + shuffle=train, + # BatchNorm を含むモデルで最後のバッチが 1 件になると学習が止まるため,学習時は端数を捨てる + # (件数がバッチサイズ以下のときは捨てない). + drop_last=train and len(dataset) > config.batch_size, + num_workers=config.num_workers, + pin_memory=device.type == "cuda", + persistent_workers=config.num_workers > 0, + generator=generator, + ) diff --git a/src/tias_judge_model/training/loop.py b/src/tias_judge_model/training/loop.py new file mode 100644 index 0000000..0074708 --- /dev/null +++ b/src/tias_judge_model/training/loop.py @@ -0,0 +1,257 @@ +"""1 つの学習(1 fold 分)のループと推論(SPEC_02「モデル」「学習の設定」). + +AdamW + 線形 warmup → cosine 減衰(ステップ単位).CUDA のときだけ AMP を使う. +エポックごとに検証の macro F1 を測り,最良エポックの重みを保持し,`patience` エポック改善が +無ければ打ち切る.ログには集計値(損失・F1・時間)だけを出す. +""" + +from __future__ import annotations + +import logging +import math +import random +import time +from collections.abc import Callable +from dataclasses import dataclass, field + +import numpy as np +import torch +from torch import nn +from torch.utils.data import DataLoader + +from tias_judge_model.data.dataset import normalize_batch +from tias_judge_model.evaluation.metrics import macro_f1 +from tias_judge_model.schema import TrainConfig + +logger = logging.getLogger(__name__) + + +@dataclass +class EpochRecord: + """1 エポックの記録(`history.csv` の 1 行). + + Attributes: + epoch: エポック番号(1 始まり). + train_loss: 学習損失の平均. + val_loss: 検証損失の平均. + val_macro_f1: 検証の macro F1. + lr: エポック終了時の学習率. + seconds: このエポックの所要秒数(学習 + 検証). + """ + + epoch: int + train_loss: float + val_loss: float + val_macro_f1: float + lr: float + seconds: float + + +@dataclass +class TrainResult: + """1 回の学習の結果. + + Attributes: + best_state: 最良エポックの重み(CPU 上の state_dict). + best_epoch: 最良エポック番号(1 始まり). + best_val_macro_f1: 最良エポックの検証 macro F1. + history: エポックごとの記録. + seconds: 学習全体の所要秒数. + """ + + best_state: dict[str, torch.Tensor] + best_epoch: int + best_val_macro_f1: float + history: list[EpochRecord] = field(default_factory=list) + seconds: float = 0.0 + + +def seed_everything(seed: int) -> None: + """`random`・`numpy`・`torch`(CUDA を含む)の乱数 seed を固定する. + + Args: + seed: 乱数 seed. + """ + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + torch.cuda.manual_seed_all(seed) + + +def select_device() -> torch.device: + """CUDA があれば `cuda`(`CUDA_VISIBLE_DEVICES` で割り当てられた GPU),無ければ CPU を返す.""" + return torch.device("cuda" if torch.cuda.is_available() else "cpu") + + +def device_name(device: torch.device) -> str: + """デバイスの表示名(GPU 名または `cpu`)を返す.""" + if device.type == "cuda": + return torch.cuda.get_device_name(device) + return "cpu" + + +def make_scheduler( + optimizer: torch.optim.Optimizer, *, warmup_steps: int, total_steps: int +) -> torch.optim.lr_scheduler.LambdaLR: + """線形 warmup の後に cosine で 0 へ減衰する学習率スケジューラ(ステップ単位)を作る. + + Args: + optimizer: 最適化器. + warmup_steps: warmup のステップ数. + total_steps: 全ステップ数. + + Returns: + ステップごとに `step()` を呼ぶスケジューラ. + """ + + def factor(step: int) -> float: + if warmup_steps > 0 and step < warmup_steps: + return (step + 1) / warmup_steps + progress = (step - warmup_steps) / max(1, total_steps - warmup_steps) + return 0.5 * (1.0 + math.cos(math.pi * min(1.0, progress))) + + return torch.optim.lr_scheduler.LambdaLR(optimizer, factor) + + +def train_model( + model: nn.Module, + train_loader: DataLoader, + val_loader: DataLoader, + config: TrainConfig, + device: torch.device, + *, + log_prefix: str = "", + on_epoch_end: Callable[[EpochRecord], None] | None = None, +) -> TrainResult: + """モデルを学習し,検証 macro F1 が最良のエポックの重みを返す(早期終了あり). + + Args: + model: 学習するモデル(`device` 上に置いておく). + train_loader: 学習用(`(B, 3, H, W)` の uint8 画像と `(B,)` のクラス ID を返す). + val_loader: 検証用(同上). + config: 学習の設定. + device: デバイス. + log_prefix: ログの先頭に付ける文字列(例: `fold 1/5`). + on_epoch_end: エポックごとに呼ぶ関数(任意). + + Returns: + 学習の結果.`model` の重みは最良エポックのものに戻しておく. + """ + started = time.monotonic() + use_amp = config.amp and device.type == "cuda" + optimizer = torch.optim.AdamW( + model.parameters(), lr=config.lr, weight_decay=config.weight_decay + ) + steps_per_epoch = max(1, len(train_loader)) + scheduler = make_scheduler( + optimizer, + warmup_steps=config.warmup_epochs * steps_per_epoch, + total_steps=config.max_epochs * steps_per_epoch, + ) + scaler = torch.amp.GradScaler("cuda", enabled=use_amp) + criterion = nn.CrossEntropyLoss() + + best_state: dict[str, torch.Tensor] | None = None + best_epoch = 0 + best_f1 = -1.0 + history: list[EpochRecord] = [] + for epoch in range(1, config.max_epochs + 1): + epoch_started = time.monotonic() + model.train() + loss_sum = 0.0 + count = 0 + for images, labels in train_loader: + inputs = normalize_batch(images.to(device, non_blocking=True)) + targets = labels.to(device, non_blocking=True) + optimizer.zero_grad(set_to_none=True) + with torch.autocast(device_type=device.type, dtype=torch.float16, enabled=use_amp): + loss = criterion(model(inputs), targets) + scaler.scale(loss).backward() + scaler.step(optimizer) + scaler.update() + scheduler.step() + loss_sum += float(loss.detach()) * len(targets) + count += len(targets) + + val_logits, val_labels = predict_logits(model, val_loader, device, amp=use_amp) + val_loss = float( + criterion(torch.from_numpy(val_logits), torch.from_numpy(val_labels)).item() + ) + val_f1 = macro_f1(val_labels, val_logits.argmax(axis=1)) + record = EpochRecord( + epoch=epoch, + train_loss=loss_sum / max(1, count), + val_loss=val_loss, + val_macro_f1=val_f1, + lr=float(optimizer.param_groups[0]["lr"]), + seconds=time.monotonic() - epoch_started, + ) + history.append(record) + if on_epoch_end is not None: + on_epoch_end(record) + + improved = val_f1 > best_f1 + if improved: + best_f1 = val_f1 + best_epoch = epoch + best_state = {k: v.detach().to("cpu", copy=True) for k, v in model.state_dict().items()} + logger.info( + "%s epoch %d/%d: train_loss %.4f・val_loss %.4f・val_macro_f1 %.4f・lr %.2e" + "(%.1f 秒・経過 %.1f 秒)%s", + log_prefix, + epoch, + config.max_epochs, + record.train_loss, + record.val_loss, + record.val_macro_f1, + record.lr, + record.seconds, + time.monotonic() - started, + " *best" if improved else "", + ) + if epoch - best_epoch >= config.patience: + logger.info( + "%s 検証 macro F1 が %d エポック改善しないため打ち切る", log_prefix, config.patience + ) + break + + if best_state is None: + raise RuntimeError("学習が 1 エポックも行われなかった") + model.load_state_dict(best_state) + return TrainResult( + best_state=best_state, + best_epoch=best_epoch, + best_val_macro_f1=best_f1, + history=history, + seconds=time.monotonic() - started, + ) + + +@torch.no_grad() +def predict_logits( + model: nn.Module, loader: DataLoader, device: torch.device, *, amp: bool +) -> tuple[np.ndarray, np.ndarray]: + """データ全体のロジットと正解を返す(推論モード). + + Args: + model: モデル. + loader: `(B, 3, H, W)` の uint8 画像と `(B,)` のクラス ID を返す DataLoader + (shuffle なし). + device: デバイス. + amp: 混合精度を使うか(CUDA のときだけ有効にすること). + + Returns: + (`(N, C)` の float32 ロジット, `(N,)` の int64 正解クラス ID).並びは loader の順. + """ + model.eval() + logits: list[np.ndarray] = [] + labels: list[np.ndarray] = [] + for images, targets in loader: + inputs = normalize_batch(images.to(device, non_blocking=True)) + with torch.autocast(device_type=device.type, dtype=torch.float16, enabled=amp): + out = model(inputs) + logits.append(out.float().cpu().numpy()) + labels.append(targets.numpy().astype(np.int64)) + if not logits: + return np.zeros((0, 0), dtype=np.float32), np.zeros((0,), dtype=np.int64) + return np.concatenate(logits), np.concatenate(labels) diff --git a/tests/cli/test_train.py b/tests/cli/test_train.py new file mode 100644 index 0000000..513f046 --- /dev/null +++ b/tests/cli/test_train.py @@ -0,0 +1,289 @@ +"""tias-train cv の結合テスト. + +仕様: SPEC_01「CLI」「実験の出力」「回収ホワイトリスト」・SPEC_02「出力」. + +- runs/<実験ID>/ に SPEC_02「出力」の表のファイルが揃う +- metrics.json に部分集合ごとの指標・mild の内訳・所要時間・実行環境がある +- history.csv にエポックの所要秒数の列がある +- 実験 ID は `YYYYmmdd-HHMMSS_<設定ファイル名>_`.標準出力の最終行が実験 ID +- 回収対象の出力(ログを含む)に患者 ID・ファイル名が出ない +- split.json・predictions.csv・checkpoints は回収ホワイトリストの対象外(checkpoints は明示時のみ) +- 「ややあり除外」でも held_out は全程度で評価される + +合成データ(scripts/make_synthetic_dataset.py)のファイル名を目立つ文字列に付け替えて使う. +""" + +from __future__ import annotations + +import contextlib +import csv +import importlib.util +import io +import json +import logging +import re +import sys +from pathlib import Path +from types import ModuleType + +import numpy as np +import pytest +import yaml + +import tias_judge_model.cli.train as cli +from tias_judge_model.data.manifest import build_manifest_records, write_manifest +from tias_judge_model.remote.whitelist import is_pull_allowed + +pytestmark = pytest.mark.slow + +SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "make_synthetic_dataset.py" +SECRET = "SECRETPT" +GIT = "abc1234" +N_FOLDS = 5 +PER_DIR = 6 +CONFIG_NAME = "cv_tiny_nomild" +RECOVERABLE = ["config.yaml", "metrics.json", "history.csv", "confusion_matrix.csv", "log.txt"] + + +def _load_script() -> ModuleType: + spec = importlib.util.spec_from_file_location("make_synthetic_dataset_train", SCRIPT_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def run(tmp_path_factory: pytest.TempPathFactory) -> dict: + tmp = tmp_path_factory.mktemp("train_cli") + data_root = tmp / "dataset" + _load_script().generate(data_root, per_dir=PER_DIR, seed=0) + for n, png in enumerate(sorted((data_root / "images").rglob("*.png"))): + png.rename(png.with_name(f"{SECRET}{n:04d}.png")) + write_manifest(build_manifest_records(data_root / "images"), data_root / "manifest.csv") + + config = tmp / f"{CONFIG_NAME}.yaml" + config.write_text( + yaml.safe_dump( + { + "data": {"root": data_root.as_posix(), "severities": ["strong", "normal"]}, + "split": {"seed": 3, "n_folds": N_FOLDS}, + "model": {"name": "resnet18", "pretrained": False}, + "train": { + "image_size": 32, + "batch_size": 8, + "max_epochs": 2, + "warmup_epochs": 1, + "inner_val_ratio": 0.2, + "num_workers": 0, + "amp": False, + }, + } + ), + encoding="utf-8", + ) + out_root = tmp / "runs" + + mp = pytest.MonkeyPatch() + mp.delenv("TIAS_DATA_ROOT", raising=False) + mp.setenv("TIAS_GIT_COMMIT", GIT) + root_logger = logging.getLogger() + old_level = root_logger.level + root_logger.setLevel(logging.INFO) + out, err = io.StringIO(), io.StringIO() + stderr_handler = logging.StreamHandler(err) + root_logger.addHandler(stderr_handler) + try: + with contextlib.redirect_stdout(out): + rc = cli.main(["cv", "--config", str(config), "--out-root", str(out_root)]) + finally: + root_logger.removeHandler(stderr_handler) + root_logger.setLevel(old_level) + mp.undo() + run_dirs = [p for p in out_root.iterdir() if p.is_dir()] + return { + "rc": rc, + "stdout": out.getvalue(), + "stderr": err.getvalue(), + "run_dirs": run_dirs, + "run_dir": run_dirs[0] if run_dirs else None, + "n_images": 9 * PER_DIR, + } + + +def test_exit_code_and_single_run_dir(run: dict) -> None: + assert run["rc"] == 0 + assert len(run["run_dirs"]) == 1 + + +def test_run_id_format_and_last_stdout_line(run: dict) -> None: + run_id = run["run_dir"].name + assert re.fullmatch(rf"\d{{8}}-\d{{6}}_{CONFIG_NAME}_{GIT}", run_id) + lines = [ln for ln in run["stdout"].splitlines() if ln.strip()] + assert lines[-1] == run_id + + +def test_output_files_exist(run: dict) -> None: + d = run["run_dir"] + for name in [*RECOVERABLE, "split.json", "predictions.csv"]: + assert (d / name).is_file(), name + pngs = list((d / "figures").glob("*.png")) + assert pngs + names = " ".join(p.name for p in pngs) + assert "confusion" in names # 混同行列 + assert len(pngs) >= 2 # 学習曲線 + 混同行列 + ckpts = sorted(p.name for p in (d / "checkpoints").glob("*.pt")) + assert ckpts == [f"fold{k}.pt" for k in range(N_FOLDS)] + assert (d / "log.txt").stat().st_size > 0 + + +def test_config_yaml_records_git_and_seed(run: dict) -> None: + doc = yaml.safe_load((run["run_dir"] / "config.yaml").read_text(encoding="utf-8")) + text = json.dumps(doc, ensure_ascii=False) + assert GIT in text + # 解決済み設定(model・train を含む)と seed が記録される + assert doc["config"]["split"]["seed"] == 3 + assert doc["config"]["model"]["name"] == "resnet18" + assert doc["config"]["train"]["image_size"] == 32 + assert doc["seeds"]["split"] == 3 + + +def _metrics(run: dict) -> dict: + return json.loads((run["run_dir"] / "metrics.json").read_text(encoding="utf-8")) + + +def test_metrics_has_subsets_per_fold_and_mean_std(run: dict) -> None: + m = _metrics(run) + assert len(m["folds"]) == N_FOLDS + for fold in m["folds"]: + assert set(fold["subsets"]) >= {"all", "common", "mild"} + assert fold["best_epoch"] >= 1 + for subset in ("all", "common", "mild"): + s = fold["subsets"][subset] + assert {"macro_f1", "accuracy", "per_class", "confusion_matrix"} <= set(s) + for subset in ("all", "common", "mild"): + ms = m["mean_std"][subset] + assert {"mean", "std"} <= set(ms["macro_f1"]) + assert {"mean", "std"} <= set(ms["accuracy"]) + + +def test_held_out_evaluated_on_all_severities(run: dict) -> None: + # severities=[strong, normal] でも held_out は全程度(合計 = manifest 全件) + m = _metrics(run) + n_all = sum(f["subsets"]["all"]["n"] for f in m["folds"]) + n_mild = sum(f["subsets"]["mild"]["n"] for f in m["folds"]) + assert n_all == run["n_images"] + assert n_mild == 4 * PER_DIR # mild は淡紅以外の 4 クラス + + +def test_confusion_matrix_sum(run: dict) -> None: + m = _metrics(run) + all_cm = np.array(m["confusion_matrix_sum"]["all"]) + common_cm = np.array(m["confusion_matrix_sum"]["common"]) + assert all_cm.shape == (5, 5) + assert all_cm.sum() == run["n_images"] + assert common_cm.sum() == run["n_images"] - 4 * PER_DIR + # 行(正解)の合計はクラスごとの枚数(淡紅は normal のみ,他は strong + mild) + assert all_cm.sum(axis=1).tolist() == [ + 2 * PER_DIR, + PER_DIR, + 2 * PER_DIR, + 2 * PER_DIR, + 2 * PER_DIR, + ] + + with (run["run_dir"] / "confusion_matrix.csv").open(encoding="utf-8") as f: + rows = list(csv.DictReader(f)) + subsets = {r["subset"] for r in rows} + assert subsets == {"common", "all"} + all_rows = [r for r in rows if r["subset"] == "all"] + assert [r["true"] for r in all_rows] == ["pale", "light_red", "red", "dark_red", "purple"] + csv_matrix = [[int(v) for k, v in r.items() if k.startswith("pred_")] for r in all_rows] + assert csv_matrix == all_cm.tolist() + + +def test_mild_prediction_breakdown_present(run: dict) -> None: + m = _metrics(run) + breakdown = m["pooled"]["mild"]["prediction_breakdown"] + overall = breakdown["overall"] + assert overall["n"] == 4 * PER_DIR + total = overall["light_red"] + overall["original"] + overall["other"] + assert total == pytest.approx(1.0) + assert set(breakdown["per_class"]) == {"pale", "red", "dark_red", "purple"} + for fold in m["folds"]: + assert "prediction_breakdown" in fold["subsets"]["mild"] + + +def test_metrics_has_timing_and_environment(run: dict) -> None: + m = _metrics(run) + t = m["timing"] + assert t["total_seconds"] > 0 + assert t["data_loading_seconds"] >= 0 + assert t["total_seconds"] >= t["data_loading_seconds"] + for fold in m["folds"]: + ft = fold["timing"] + assert ft["train_seconds"] >= 0 + assert ft["eval_seconds"] >= 0 + assert 1 <= ft["epochs_run"] <= 2 + assert ft["mean_epoch_seconds"] >= 0 + env = m["environment"] + assert env["device"] + assert env["model_name"] == "resnet18" + assert env["num_parameters"] > 0 + + +def test_history_has_epoch_seconds(run: dict) -> None: + with (run["run_dir"] / "history.csv").open(encoding="utf-8") as f: + rows = list(csv.DictReader(f)) + assert rows + cols = set(rows[0]) + assert {"fold", "epoch", "train_loss", "val_loss", "val_macro_f1", "lr"} <= cols + seconds_cols = [c for c in cols if "second" in c] + assert seconds_cols + assert all(float(r[seconds_cols[0]]) >= 0 for r in rows) + assert {int(r["fold"]) for r in rows} == set(range(N_FOLDS)) + + +def test_recoverable_outputs_have_no_patient_ids(run: dict) -> None: + d = run["run_dir"] + for name in RECOVERABLE: + assert SECRET not in (d / name).read_text(encoding="utf-8"), name + for png in (d / "figures").glob("*.png"): + assert SECRET.encode() not in png.read_bytes(), png.name + assert SECRET not in run["stdout"] + assert SECRET not in run["stderr"] + + +def test_split_and_predictions_contain_ids_and_are_not_pulled(run: dict) -> None: + d = run["run_dir"] + # 個票(患者 ID を含む)はサーバ上にだけ置き,回収しない + assert SECRET in (d / "split.json").read_text(encoding="utf-8") + assert SECRET in (d / "predictions.csv").read_text(encoding="utf-8") + for name in ("split.json", "predictions.csv"): + assert not is_pull_allowed(name, with_checkpoints=False) + assert not is_pull_allowed(name, with_checkpoints=True) + + +def test_predictions_cover_all_severities(run: dict) -> None: + with (run["run_dir"] / "predictions.csv").open(encoding="utf-8") as f: + rows = list(csv.DictReader(f)) + assert len(rows) == run["n_images"] + assert {r["severity"] for r in rows} == {"strong", "mild", "normal"} + + +def test_every_output_matches_pull_whitelist_as_spec(run: dict) -> None: + d = run["run_dir"] + recoverable = set(RECOVERABLE) + for path in d.rglob("*"): + if not path.is_file(): + continue + rel = path.relative_to(d).as_posix() + if rel in recoverable or (rel.startswith("figures/") and rel.endswith(".png")): + assert is_pull_allowed(rel, with_checkpoints=False), rel + elif rel.startswith("checkpoints/") and rel.endswith(".pt"): + assert not is_pull_allowed(rel, with_checkpoints=False), rel + assert is_pull_allowed(rel, with_checkpoints=True), rel + else: + assert not is_pull_allowed(rel, with_checkpoints=True), rel + assert rel in {"split.json", "predictions.csv"}, f"想定外の出力: {rel}" diff --git a/tests/data/test_augmentation.py b/tests/data/test_augmentation.py new file mode 100644 index 0000000..7784e13 --- /dev/null +++ b/tests/data/test_augmentation.py @@ -0,0 +1,128 @@ +"""データ拡張と Dataset のテスト(SPEC_02「データ拡張」「学習の設定」). + +- **色を変える拡張は行わない**: 一様な色の画像に拡張をかけても,舌領域の画素は元の色のまま + (はみ出しの黒との境界の補間を除き,黒と元の色の混合=元の色の定数倍にしかならない) +- 拡張は幾何変換(左右反転等)であり,実際に画像が変わりうる +- 評価時(augment=False)は拡張しない +- 正規化は ImageNet の mean / std +""" + +from __future__ import annotations + +import numpy as np +import pytest +import torch +from torchvision.transforms import v2 + +from tias_judge_model.data.dataset import ( + TongueImageDataset, + make_train_augmentation, + normalize_batch, +) + +COLOR = np.array([190, 110, 95], dtype=np.uint8) +N_TRIALS = 40 + + +def _uniform_tongue(size: int = 64) -> np.ndarray: + """黒背景の中央に一様な色の領域がある画像 `(H, W, 3)`.""" + img = np.zeros((size, size, 3), dtype=np.uint8) + img[8 : size - 8, 12 : size - 12] = COLOR + return img + + +def _flatten_transforms(t: object) -> list[object]: + inner = getattr(t, "transforms", None) + if inner is None: + return [t] + out: list[object] = [] + for x in inner: + out.extend(_flatten_transforms(x)) + return out + + +def test_augmentation_contains_no_color_transforms() -> None: + forbidden = ( + v2.ColorJitter, + v2.Grayscale, + v2.RandomGrayscale, + v2.RandomAutocontrast, + v2.RandomEqualize, + v2.RandomPosterize, + v2.RandomSolarize, + v2.RandomInvert, + v2.RandomAdjustSharpness, + v2.RandomPhotometricDistort, + v2.RandomChannelPermutation, + v2.AutoAugment, + v2.RandAugment, + v2.TrivialAugmentWide, + v2.AugMix, + ) + for t in _flatten_transforms(make_train_augmentation()): + assert not isinstance(t, forbidden), f"色を変える変換が含まれる: {type(t).__name__}" + + +def test_augmentation_does_not_change_pixel_colors() -> None: + torch.manual_seed(0) + ds = TongueImageDataset(_uniform_tongue()[None], np.array([2]), augment=True) + color = COLOR.astype(np.float64) + for _ in range(N_TRIALS): + image, label = ds[0] + assert label == 2 + arr = image.permute(1, 2, 0).numpy().astype(np.float64).reshape(-1, 3) + nonblack = arr[arr.any(axis=1)] + assert len(nonblack) > 0 + # 画素は黒と元の色の混合(= 元の色の t 倍)でしかない(色相・彩度が変わらない) + t = nonblack.sum(axis=1, keepdims=True) / color.sum() + assert np.abs(nonblack - t * color).max() <= 2.0 + assert t.max() <= 1.0 + 1e-2 # 明るくならない + # 舌領域の大半は元の色そのまま + exact = np.all(np.abs(nonblack - color) <= 1, axis=1) + assert exact.mean() > 0.8 + + +def test_augmentation_keeps_shape_and_dtype() -> None: + torch.manual_seed(1) + ds = TongueImageDataset(_uniform_tongue(48)[None], np.array([0]), augment=True) + image, _ = ds[0] + assert image.shape == (3, 48, 48) + assert image.dtype == torch.uint8 + + +def test_augmentation_actually_transforms_geometry() -> None: + # 左右非対称な画像で,拡張をかけると少なくとも 1 回は元と異なる + img = np.zeros((32, 32, 3), dtype=np.uint8) + img[4:28, 2:12] = COLOR + ds = TongueImageDataset(img[None], np.array([0]), augment=True) + original = torch.from_numpy(img).permute(2, 0, 1) + torch.manual_seed(2) + assert any(not torch.equal(ds[0][0], original) for _ in range(N_TRIALS)) + + +def test_eval_dataset_is_not_augmented() -> None: + img = _uniform_tongue(32) + img[0, 0] = (1, 2, 3) # 非対称にする + ds = TongueImageDataset(img[None], np.array([4]), augment=False) + original = torch.from_numpy(img).permute(2, 0, 1) + for _ in range(10): + image, label = ds[0] + assert torch.equal(image, original) + assert label == 4 + + +def test_dataset_length_mismatch_raises() -> None: + with pytest.raises(Exception): # noqa: B017 件数不一致で止まれば型は問わない + TongueImageDataset(np.zeros((2, 8, 8, 3), dtype=np.uint8), np.array([0]), augment=False) + + +def test_normalize_batch_uses_imagenet_stats() -> None: + mean = torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1) + std = torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1) + batch = torch.tensor(np.stack([_uniform_tongue(8)] * 2)).permute(0, 3, 1, 2) + out = normalize_batch(batch) + assert out.dtype == torch.float32 + expected = (batch.float() / 255.0 - mean) / std + assert torch.allclose(out, expected, atol=1e-5) + # 黒画素は -mean/std + assert torch.allclose(out[0, :, 0, 0], (-mean / std).view(3), atol=1e-5) diff --git a/tests/data/test_images.py b/tests/data/test_images.py new file mode 100644 index 0000000..b7f0ca1 --- /dev/null +++ b/tests/data/test_images.py @@ -0,0 +1,96 @@ +"""前処理(レターボックス)のテスト(SPEC_02「前処理」「学習の設定」の train.image_size). + +- アスペクト比を保ち,長辺を image_size に合わせる(縮小・拡大とも) +- 余白は黒 (0, 0, 0) +- 読めない画像は止める.例外メッセージに画像のファイル名を出さない +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest +from PIL import Image + +from tias_judge_model.data.images import letterbox, load_letterboxed_images +from tias_judge_model.data.manifest import ManifestRecord + +COLOR = (200, 120, 90) + + +def _bbox_of_content(arr: np.ndarray) -> tuple[int, int]: + """非黒画素の (高さ, 幅).""" + mask = arr.any(axis=2) + rows = np.where(mask.any(axis=1))[0] + cols = np.where(mask.any(axis=0))[0] + return int(rows[-1] - rows[0] + 1), int(cols[-1] - cols[0] + 1) + + +@pytest.mark.parametrize( + ("width", "height", "size", "expected_hw"), + [ + (200, 100, 64, (32, 64)), # 横長の縮小 + (100, 200, 64, (64, 32)), # 縦長の縮小 + (20, 10, 64, (32, 64)), # 横長の拡大 + (90, 90, 32, (32, 32)), # 正方形 + ], +) +def test_letterbox_keeps_aspect_and_fits_long_side( + width: int, height: int, size: int, expected_hw: tuple[int, int] +) -> None: + out = letterbox(Image.new("RGB", (width, height), COLOR), size) + assert out.shape == (size, size, 3) + assert out.dtype == np.uint8 + assert _bbox_of_content(out) == expected_hw + + +def test_letterbox_pads_with_black_and_keeps_color() -> None: + out = letterbox(Image.new("RGB", (200, 100), COLOR), 64) + # 上下の余白 16 行ずつが黒 + assert (out[:16] == 0).all() + assert (out[48:] == 0).all() + # 内側は元の色のまま + inner = out[20:44, 4:60].reshape(-1, 3) + assert (np.abs(inner.astype(int) - np.array(COLOR)) <= 1).all() + + +def test_letterbox_content_is_centered() -> None: + out = letterbox(Image.new("RGB", (100, 200), COLOR), 64) + mask = out.any(axis=2) + cols = np.where(mask.any(axis=0))[0] + left, right = cols[0], 64 - 1 - cols[-1] + assert abs(left - right) <= 1 + + +def test_load_letterboxed_images_order_and_shape(tmp_path: Path) -> None: + images = tmp_path / "images" + colors = [(255, 0, 0), (0, 255, 0), (0, 0, 255)] + records = [] + for i, c in enumerate(colors): + d = images / "red_strong" + d.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (40 + 10 * i, 30), c).save(d / f"img{i}.png") + records.append(ManifestRecord(f"red_strong/img{i}.png", f"p{i}", "red", "strong")) + out = load_letterboxed_images(records, images, 16) + assert out.shape == (3, 16, 16, 3) + for i, c in enumerate(colors): + assert tuple(out[i, 8, 8]) == c + + +def test_load_letterboxed_images_broken_image_hides_filename(tmp_path: Path) -> None: + images = tmp_path / "images" / "red_strong" + images.mkdir(parents=True) + (images / "SECRETNAME.png").write_bytes(b"not a png") + records = [ManifestRecord("red_strong/SECRETNAME.png", "SECRETNAME", "red", "strong")] + with pytest.raises(ValueError) as exc_info: + load_letterboxed_images(records, tmp_path / "images", 16) + assert "SECRETNAME" not in str(exc_info.value) + + +def test_load_letterboxed_images_missing_image_hides_filename(tmp_path: Path) -> None: + (tmp_path / "images").mkdir() + records = [ManifestRecord("red_strong/SECRETNAME.png", "SECRETNAME", "red", "strong")] + with pytest.raises(ValueError) as exc_info: + load_letterboxed_images(records, tmp_path / "images", 16) + assert "SECRETNAME" not in str(exc_info.value) diff --git a/tests/data/test_split_inner_val.py b/tests/data/test_split_inner_val.py new file mode 100644 index 0000000..da5d331 --- /dev/null +++ b/tests/data/test_split_inner_val.py @@ -0,0 +1,91 @@ +"""交差検証の fold 内の検証分割のテスト(SPEC_02「交差検証の実行」手順 2-1・「データ分割」). + +- 学習側から `inner_val_ratio` の患者を検証用に取る(患者単位・9 グループ層化) +- 学習用と検証用の患者が重ならず,合わせると学習側の全患者になる +- 同じ seed で同じ結果.manifest の行順に依存しない +- held_out と組み合わせても,held_out・学習・検証の患者が互いに重ならない +""" + +from __future__ import annotations + +import random +from collections import Counter + +import pytest + +from tias_judge_model.data.classes import ALL_DIR_NAMES, parse_dir_name +from tias_judge_model.data.manifest import ManifestRecord +from tias_judge_model.data.split import make_kfold_splits, select_records, split_inner_val + +SEED = 42 + + +def make_records(per_group: int = 20, images_per_patient: int = 1) -> list[ManifestRecord]: + records = [] + for group in ALL_DIR_NAMES: + label, severity = parse_dir_name(group) + for p in range(per_group): + pid = f"pt_{group}_{p:03d}" + for k in range(images_per_patient): + records.append(ManifestRecord(f"{group}/{pid}_{k}.png", pid, label, severity)) + return records + + +def _group_of(records: list[ManifestRecord]) -> dict[str, str]: + return {r.patient_id: f"{r.label}_{r.severity}" for r in records} + + +def test_train_and_val_are_disjoint_and_cover_all_patients() -> None: + records = make_records() + train, val = split_inner_val(records, 0.15, SEED) + assert set(train).isdisjoint(val) + assert set(train) | set(val) == {r.patient_id for r in records} + + +def test_val_ratio_is_stratified_by_group() -> None: + records = make_records(per_group=20) + _, val = split_inner_val(records, 0.15, SEED) + group = _group_of(records) + counts = Counter(group[p] for p in val) + # 20 × 0.15 = 3 人ずつ,9 グループすべてから + assert counts == dict.fromkeys(ALL_DIR_NAMES, 3) + + +def test_multiple_images_per_patient_stay_together() -> None: + records = make_records(per_group=10, images_per_patient=3) + train, val = split_inner_val(records, 0.2, SEED) + assert set(train).isdisjoint(val) + val_images = select_records(records, val) + assert len(val_images) == 3 * len(val) + + +def test_same_seed_same_result_and_order_independent() -> None: + records = make_records() + shuffled = records[:] + random.Random(0).shuffle(shuffled) + assert split_inner_val(records, 0.15, SEED) == split_inner_val(shuffled, 0.15, SEED) + + +def test_different_seed_changes_result() -> None: + records = make_records() + assert split_inner_val(records, 0.15, 1) != split_inner_val(records, 0.15, 2) + + +@pytest.mark.parametrize("ratio", [0.0, 1.0, -0.1, 1.5]) +def test_invalid_ratio_raises(ratio: float) -> None: + with pytest.raises(ValueError): + split_inner_val(make_records(), ratio, SEED) + + +def test_held_out_train_val_are_mutually_disjoint_in_every_fold() -> None: + records = make_records(per_group=15) + all_patients = {r.patient_id for r in records} + folds = make_kfold_splits(records, 5, SEED) + for fold in folds: + train_side = select_records(records, fold.train) + train, val = split_inner_val(train_side, 0.15, SEED + fold.index) + held = set(fold.held_out) + assert held.isdisjoint(train) + assert held.isdisjoint(val) + assert set(train).isdisjoint(val) + assert held | set(train) | set(val) == all_patients diff --git a/tests/evaluation/test_metrics.py b/tests/evaluation/test_metrics.py new file mode 100644 index 0000000..066e951 --- /dev/null +++ b/tests/evaluation/test_metrics.py @@ -0,0 +1,200 @@ +"""評価指標のテスト(SPEC_02「評価指標」「交差検証の実行」の「評価の出し方」). + +- macro F1・accuracy・クラス別 precision / recall / F1・混同行列 + (クラス ID 順固定.SPEC_01「クラス定義」) +- 部分集合 `all`(全程度)・`common`(strong と normal)・`mild` +- mild の予測の内訳(淡紅・元のクラス・その他と予測された割合.全体とクラス別) +- fold 間の平均 ± 標準偏差・混同行列の全 fold 合計 + +期待値は手計算した既知の予測・正解から求める. +""" + +from __future__ import annotations + +import math + +import numpy as np +import pytest + +from tias_judge_model.data.classes import CLASS_SLUGS, label_to_id +from tias_judge_model.evaluation.metrics import ( + aggregate_folds, + classification_metrics, + macro_f1, + mean_std, + mild_prediction_breakdown, + subset_metrics, + sum_confusion_matrices, +) + +PALE, LIGHT_RED, RED, DARK_RED, PURPLE = range(5) + +# 正解 [淡白, 淡白, 淡紅, 淡紅, 紅, 紅]・予測 [淡白, 淡紅, 淡紅, 淡紅, 紅, 淡白] +Y_TRUE = np.array([PALE, PALE, LIGHT_RED, LIGHT_RED, RED, RED]) +Y_PRED = np.array([PALE, LIGHT_RED, LIGHT_RED, LIGHT_RED, RED, PALE]) +# 手計算: 淡白 P=1/2 R=1/2 F1=1/2・淡紅 P=2/3 R=1 F1=4/5・紅 P=1 R=1/2 F1=2/3 +EXPECTED = { + "pale": (0.5, 0.5, 0.5, 2), + "light_red": (2 / 3, 1.0, 0.8, 2), + "red": (1.0, 0.5, 2 / 3, 2), +} +EXPECTED_CM = [ + [1, 1, 0, 0, 0], + [0, 2, 0, 0, 0], + [1, 0, 1, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], +] + + +class TestClassOrder: + def test_class_ids_are_fixed_as_in_spec(self) -> None: + # SPEC_01「クラス定義」: 0 淡白・1 淡紅・2 紅・3 暗赤紅・4 紫 + assert CLASS_SLUGS == ("pale", "light_red", "red", "dark_red", "purple") + assert [label_to_id(s) for s in CLASS_SLUGS] == [0, 1, 2, 3, 4] + + +class TestClassificationMetrics: + def test_accuracy(self) -> None: + m = classification_metrics(Y_TRUE, Y_PRED) + assert m["accuracy"] == pytest.approx(4 / 6) + assert m["n"] == 6 + + def test_per_class_precision_recall_f1(self) -> None: + m = classification_metrics(Y_TRUE, Y_PRED) + for slug, (p, r, f1, support) in EXPECTED.items(): + got = m["per_class"][slug] + assert got["precision"] == pytest.approx(p) + assert got["recall"] == pytest.approx(r) + assert got["f1"] == pytest.approx(f1) + assert got["support"] == support + + def test_macro_f1_averages_classes_present_in_truth(self) -> None: + expected = (0.5 + 0.8 + 2 / 3) / 3 + m = classification_metrics(Y_TRUE, Y_PRED) + assert m["macro_f1"] == pytest.approx(expected) + assert macro_f1(Y_TRUE, Y_PRED) == pytest.approx(expected) + + def test_macro_f1_perfect_prediction_is_one(self) -> None: + y = np.array([0, 1, 2, 3, 4, 4]) + assert macro_f1(y, y) == pytest.approx(1.0) + + def test_confusion_matrix_rows_true_cols_pred_in_class_id_order(self) -> None: + m = classification_metrics(Y_TRUE, Y_PRED) + assert m["confusion_matrix"] == EXPECTED_CM + + def test_confusion_matrix_is_always_5x5(self) -> None: + # 正解・予測に現れないクラスがあっても軸がぶれない + m = classification_metrics(np.array([PURPLE]), np.array([PURPLE])) + cm = np.array(m["confusion_matrix"]) + assert cm.shape == (5, 5) + assert cm[PURPLE, PURPLE] == 1 + assert cm.sum() == 1 + + def test_input_order_does_not_change_metrics(self) -> None: + rng = np.random.default_rng(0) + perm = rng.permutation(len(Y_TRUE)) + a = classification_metrics(Y_TRUE, Y_PRED) + b = classification_metrics(Y_TRUE[perm], Y_PRED[perm]) + assert a == b + + +class TestMildPredictionBreakdown: + # mild の正解 [紅, 紅, 紅, 暗赤紅, 紫, 淡白]・予測 [淡紅, 紅, 暗赤紅, 暗赤紅, 淡紅, 淡白] + TRUE = np.array([RED, RED, RED, DARK_RED, PURPLE, PALE]) + PRED = np.array([LIGHT_RED, RED, DARK_RED, DARK_RED, LIGHT_RED, PALE]) + + def test_overall_fractions(self) -> None: + b = mild_prediction_breakdown(self.TRUE, self.PRED)["overall"] + assert b["n"] == 6 + assert b["light_red"] == pytest.approx(2 / 6) + assert b["original"] == pytest.approx(3 / 6) + assert b["other"] == pytest.approx(1 / 6) + + def test_fractions_sum_to_one(self) -> None: + b = mild_prediction_breakdown(self.TRUE, self.PRED) + for entry in [b["overall"], *b["per_class"].values()]: + assert entry["light_red"] + entry["original"] + entry["other"] == pytest.approx(1.0) + + def test_per_class_fractions(self) -> None: + per_class = mild_prediction_breakdown(self.TRUE, self.PRED)["per_class"] + red = per_class["red"] + assert red["n"] == 3 + assert red["light_red"] == pytest.approx(1 / 3) + assert red["original"] == pytest.approx(1 / 3) + assert red["other"] == pytest.approx(1 / 3) + assert per_class["dark_red"]["original"] == pytest.approx(1.0) + assert per_class["purple"]["light_red"] == pytest.approx(1.0) + assert per_class["pale"]["original"] == pytest.approx(1.0) + + +class TestSubsetMetrics: + # 程度つきの held_out: strong 2・normal 2・mild 2 + TRUE = np.array([RED, RED, LIGHT_RED, LIGHT_RED, RED, PURPLE]) + PRED = np.array([RED, PALE, LIGHT_RED, LIGHT_RED, LIGHT_RED, PURPLE]) + SEV = ["strong", "strong", "normal", "normal", "mild", "mild"] + + def test_subset_sizes(self) -> None: + s = subset_metrics(self.TRUE, self.PRED, self.SEV) + assert s["all"]["n"] == 6 + assert s["common"]["n"] == 4 + assert s["mild"]["n"] == 2 + + def test_common_is_strong_and_normal_only(self) -> None: + s = subset_metrics(self.TRUE, self.PRED, self.SEV) + expected = classification_metrics(self.TRUE[:4], self.PRED[:4]) + assert s["common"]["accuracy"] == pytest.approx(expected["accuracy"]) + assert s["common"]["confusion_matrix"] == expected["confusion_matrix"] + + def test_all_uses_every_image(self) -> None: + s = subset_metrics(self.TRUE, self.PRED, self.SEV) + assert s["all"]["accuracy"] == pytest.approx(4 / 6) + assert np.array(s["all"]["confusion_matrix"]).sum() == 6 + + def test_mild_has_prediction_breakdown(self) -> None: + s = subset_metrics(self.TRUE, self.PRED, self.SEV) + overall = s["mild"]["prediction_breakdown"]["overall"] + assert overall["n"] == 2 + assert overall["light_red"] == pytest.approx(0.5) + assert overall["original"] == pytest.approx(0.5) + assert overall["other"] == pytest.approx(0.0) + assert s["mild"]["accuracy"] == pytest.approx(0.5) + + def test_empty_mild_subset_does_not_crash(self) -> None: + s = subset_metrics(self.TRUE[:4], self.PRED[:4], self.SEV[:4]) + assert s["common"]["n"] == 4 + assert s["all"]["n"] == 4 + assert not s["mild"] + + +class TestFoldAggregation: + def test_mean_std_uses_sample_std(self) -> None: + r = mean_std([0.5, 0.7, 0.9]) + assert r["mean"] == pytest.approx(0.7) + assert r["std"] == pytest.approx(0.2) # ddof=1 + + def test_mean_std_single_value(self) -> None: + r = mean_std([0.4]) + assert r["mean"] == pytest.approx(0.4) + assert r["std"] == pytest.approx(0.0) + + def test_aggregate_folds_averages_macro_f1_and_accuracy(self) -> None: + f1 = classification_metrics(np.array([0, 1]), np.array([0, 1])) # acc 1.0 + f2 = classification_metrics(np.array([0, 1]), np.array([1, 1])) # acc 0.5 + agg = aggregate_folds([f1, f2]) + assert agg["accuracy"]["mean"] == pytest.approx(0.75) + assert agg["accuracy"]["std"] == pytest.approx(math.sqrt(0.125)) + assert agg["macro_f1"]["mean"] == pytest.approx((1.0 + f2["macro_f1"]) / 2) + assert agg["per_class"]["light_red"]["recall"]["mean"] == pytest.approx(1.0) + + def test_sum_confusion_matrices(self) -> None: + a = classification_metrics(Y_TRUE, Y_PRED)["confusion_matrix"] + b = classification_metrics(np.array([PURPLE, DARK_RED]), np.array([PURPLE, RED]))[ + "confusion_matrix" + ] + total = np.array(sum_confusion_matrices([a, b])) + assert total.shape == (5, 5) + assert total.sum() == 8 + assert total[PURPLE, PURPLE] == 1 + assert total[DARK_RED, RED] == 1 + assert total[LIGHT_RED, LIGHT_RED] == 2 diff --git a/tests/models/test_factory.py b/tests/models/test_factory.py new file mode 100644 index 0000000..1e38289 --- /dev/null +++ b/tests/models/test_factory.py @@ -0,0 +1,24 @@ +"""モデル生成のテスト(SPEC_02「モデル」: timm・出力 5 クラス). + +事前学習の重みはダウンロードしない(pretrained=False のみ). +""" + +from __future__ import annotations + +import torch + +from tias_judge_model.models.factory import count_parameters, create_model + + +def test_create_model_outputs_five_classes() -> None: + torch.manual_seed(0) + model = create_model("resnet18", pretrained=False).eval() + with torch.no_grad(): + out = model(torch.zeros(2, 3, 32, 32)) + assert out.shape == (2, 5) + + +def test_count_parameters_matches_numel() -> None: + model = create_model("resnet18", pretrained=False) + assert count_parameters(model) == sum(p.numel() for p in model.parameters()) + assert count_parameters(model) > 0 diff --git a/tests/test_training_config.py b/tests/test_training_config.py new file mode 100644 index 0000000..7ffc451 --- /dev/null +++ b/tests/test_training_config.py @@ -0,0 +1,126 @@ +"""学習の設定(`model`・`train` 節)のテスト(SPEC_02「学習の設定」・SPEC_01「設定ファイル」). + +- 既定値が SPEC_02 の表どおり +- `model`・`train` を省略した既存の設定(`tias-dataset` 用)も読める +- 未知のキー・不正な値は拒否する +- リポジトリの `configs/cv_*.yaml` がすべて読め,モデル比較用の設定はモデル名以外が元の設定と同一 +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from tias_judge_model.schema import ExperimentConfig, load_config + +CONFIGS_DIR = Path(__file__).resolve().parents[1] / "configs" +CV_CONFIGS = sorted(CONFIGS_DIR.glob("cv_*.yaml")) +NO_ENV: dict[str, str] = {} + + +def _minimal() -> dict: + return {"data": {"root": "somewhere"}} + + +def test_model_defaults_follow_spec() -> None: + c = ExperimentConfig.model_validate(_minimal()) + assert c.model.name == "convnext_tiny" + assert c.model.pretrained is True + + +def test_train_defaults_follow_spec() -> None: + t = ExperimentConfig.model_validate(_minimal()).train + assert t.image_size == 224 + assert t.batch_size == 32 + assert t.max_epochs == 40 + assert t.patience == 8 + assert t.lr == pytest.approx(1e-4) + assert t.weight_decay == pytest.approx(0.05) + assert t.warmup_epochs == 2 + assert t.inner_val_ratio == pytest.approx(0.15) + assert t.num_workers == 4 + assert t.amp is True + + +@pytest.mark.parametrize("name", ["synthetic.yaml", "newton.yaml"]) +def test_existing_configs_without_model_train_still_load(name: str) -> None: + c = load_config(CONFIGS_DIR / name, environ=NO_ENV) + assert c.model.name == "convnext_tiny" + assert c.train.image_size == 224 + + +@pytest.mark.parametrize( + "raw", + [ + {"model": {"nme": "resnet18"}}, + {"train": {"epochs": 3}}, + {"train": {"batch_size": 0}}, + {"train": {"inner_val_ratio": 0.0}}, + {"train": {"inner_val_ratio": 1.0}}, + {"train": {"lr": 0}}, + {"model": {"name": ""}}, + ], +) +def test_invalid_training_settings_are_rejected(raw: dict) -> None: + with pytest.raises(ValidationError): + ExperimentConfig.model_validate({**_minimal(), **raw}) + + +def test_cv_configs_exist() -> None: + names = {p.name for p in CV_CONFIGS} + assert {"cv_with_mild.yaml", "cv_without_mild.yaml", "cv_synthetic.yaml"} <= names + + +@pytest.mark.parametrize("path", CV_CONFIGS, ids=lambda p: p.name) +def test_every_cv_config_loads(path: Path) -> None: + load_config(path, environ=NO_ENV) + + +def test_with_and_without_mild_differ_only_in_severities() -> None: + w = load_config(CONFIGS_DIR / "cv_with_mild.yaml", environ=NO_ENV) + wo = load_config(CONFIGS_DIR / "cv_without_mild.yaml", environ=NO_ENV) + assert w.data.severities == ("strong", "mild", "normal") + assert wo.data.severities == ("strong", "normal") + dw = w.model_dump() + dwo = wo.model_dump() + dw["data"].pop("severities") + dwo["data"].pop("severities") + assert dw == dwo + + +def test_production_cv_configs_use_spec_defaults() -> None: + c = load_config(CONFIGS_DIR / "cv_with_mild.yaml", environ=NO_ENV) + assert c.model == ExperimentConfig.model_validate(_minimal()).model + assert c.train == ExperimentConfig.model_validate(_minimal()).train + assert c.split.n_folds == 5 + + +def test_synthetic_cv_config_does_not_download_weights() -> None: + c = load_config(CONFIGS_DIR / "cv_synthetic.yaml", environ=NO_ENV) + assert c.model.pretrained is False + + +def _model_comparison_pairs() -> list[tuple[Path, Path]]: + pairs = [] + for p in CV_CONFIGS: + if p.name in {"cv_with_mild.yaml", "cv_without_mild.yaml", "cv_synthetic.yaml"}: + continue + suffix = "with_mild" if p.stem.endswith("_with_mild") else "without_mild" + pairs.append((p, CONFIGS_DIR / f"cv_{suffix}.yaml")) + return pairs + + +@pytest.mark.parametrize(("variant", "base"), _model_comparison_pairs(), ids=lambda p: Path(p).name) +def test_model_comparison_config_differs_only_in_model_name(variant: Path, base: Path) -> None: + v = load_config(variant, environ=NO_ENV).model_dump() + b = load_config(base, environ=NO_ENV).model_dump() + assert v["model"]["name"] != b["model"]["name"] + v["model"].pop("name") + b["model"].pop("name") + assert v == b + + +def test_model_comparison_configs_are_present() -> None: + assert len(_model_comparison_pairs()) >= 2 diff --git a/tests/training/test_cross_validation.py b/tests/training/test_cross_validation.py new file mode 100644 index 0000000..6e4a97b --- /dev/null +++ b/tests/training/test_cross_validation.py @@ -0,0 +1,152 @@ +"""交差検証の手順のテスト(SPEC_02「交差検証の実行」「程度(ややあり)の扱い」). + +最重要: +- 分割は manifest 全体で作り,`data.severities` に関わらず同一(同じ seed なら同じ分割) +- 各 fold で held_out・学習・検証の患者が互いに重ならず,held_out は fold 間で manifest を分割する +- `data.severities` は学習用と検証用にだけ適用し,held_out は全程度のまま評価する + +合成データ(scripts/make_synthetic_dataset.py)・極小設定(resnet18・pretrained なし・1 エポック)で +実際に学習を回す(`@pytest.mark.slow`). +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +import numpy as np +import pytest + +from tias_judge_model.data.manifest import read_manifest +from tias_judge_model.schema import ExperimentConfig +from tias_judge_model.training.cross_validation import CrossValidationResult, run_cross_validation + +SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "make_synthetic_dataset.py" +N_FOLDS = 5 + + +def _load_script() -> ModuleType: + spec = importlib.util.spec_from_file_location("make_synthetic_dataset_cv", SCRIPT_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _config(root: Path, severities: list[str]) -> ExperimentConfig: + return ExperimentConfig.model_validate( + { + "data": {"root": str(root), "severities": severities}, + "split": {"seed": 7, "n_folds": N_FOLDS}, + "model": {"name": "resnet18", "pretrained": False}, + "train": { + "image_size": 32, + "batch_size": 8, + "max_epochs": 1, + "warmup_epochs": 0, + "inner_val_ratio": 0.2, + "num_workers": 0, + "amp": False, + }, + } + ) + + +@pytest.fixture(scope="module") +def cv_runs( + tmp_path_factory: pytest.TempPathFactory, +) -> tuple[list, CrossValidationResult, CrossValidationResult]: + root = tmp_path_factory.mktemp("cv") / "dataset" + _load_script().generate(root, per_dir=6, seed=0) + records = read_manifest(root / "manifest.csv") + with_mild = run_cross_validation( + _config(root, ["strong", "mild", "normal"]), records, root.parent / "ck_with" + ) + without_mild = run_cross_validation( + _config(root, ["strong", "normal"]), records, root.parent / "ck_without" + ) + return records, with_mild, without_mild + + +pytestmark = pytest.mark.slow + + +def test_runs_all_folds(cv_runs) -> None: + _, with_mild, without_mild = cv_runs + assert [f.index for f in with_mild.folds] == list(range(N_FOLDS)) + assert len(without_mild.folds) == N_FOLDS + + +def test_patients_disjoint_within_each_fold(cv_runs) -> None: + records, with_mild, without_mild = cv_runs + all_patients = {r.patient_id for r in records} + for result in (with_mild, without_mild): + for f in result.folds: + train, val, held = set(f.train_patients), set(f.val_patients), set(f.held_out_patients) + assert train and val and held + assert train.isdisjoint(val) + assert train.isdisjoint(held) + assert val.isdisjoint(held) + assert train | val | held == all_patients + + +def test_held_out_partitions_manifest_across_folds(cv_runs) -> None: + records, with_mild, _ = cv_runs + held_all = [p for f in with_mild.folds for p in f.held_out_patients] + assert len(held_all) == len(set(held_all)) + assert set(held_all) == {r.patient_id for r in records} + indices = np.concatenate([f.held_out_indices for f in with_mild.folds]) + assert sorted(indices.tolist()) == list(range(len(records))) + + +def test_split_is_same_regardless_of_severities(cv_runs) -> None: + _, with_mild, without_mild = cv_runs + for a, b in zip(with_mild.folds, without_mild.folds, strict=True): + assert a.held_out_patients == b.held_out_patients + assert a.train_patients == b.train_patients + assert a.val_patients == b.val_patients + np.testing.assert_array_equal(a.held_out_indices, b.held_out_indices) + + +def test_held_out_keeps_mild_even_when_excluded_from_training(cv_runs) -> None: + records, _, without_mild = cv_runs + severities = np.array([r.severity for r in records]) + held_sev = np.concatenate([severities[f.held_out_indices] for f in without_mild.folds]) + assert (held_sev == "mild").sum() == (severities == "mild").sum() + for f in without_mild.folds: + held_ids = set(f.held_out_patients) + expected = {i for i, r in enumerate(records) if r.patient_id in held_ids} + assert set(f.held_out_indices.tolist()) == expected + + +def test_severities_applied_only_to_train_and_val(cv_runs) -> None: + _, with_mild, without_mild = cv_runs + for f in without_mild.folds: + for split in ("train", "val"): + mild_counts = {g: n for g, n in f.group_counts[split].items() if g.endswith("_mild")} + assert sum(mild_counts.values()) == 0, split + assert sum(n for g, n in f.group_counts["held_out"].items() if g.endswith("_mild")) > 0 + # ややあり込みでは学習用に mild が入る + assert any( + sum(n for g, n in f.group_counts["train"].items() if g.endswith("_mild")) > 0 + for f in with_mild.folds + ) + + +def test_held_out_probabilities_shape(cv_runs) -> None: + _, with_mild, _ = cv_runs + for f in with_mild.folds: + assert f.held_out_probs.shape == (len(f.held_out_indices), 5) + np.testing.assert_allclose(f.held_out_probs.sum(axis=1), 1.0, atol=1e-4) + assert 1 <= f.best_epoch <= len(f.history) + + +def test_checkpoints_per_fold(cv_runs, tmp_path_factory) -> None: + records, _, _ = cv_runs + # fixture と同じ tmp の下に fold.pt が書かれている + base = tmp_path_factory.getbasetemp() + found = sorted(p.name for p in base.rglob("ck_with/fold*.pt")) + assert found == [f"fold{k}.pt" for k in range(N_FOLDS)]