diff --git a/CLAUDE.md b/CLAUDE.md index 7673e23..b408e9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ ## 開発進捗 -最新: PLAN_02 Step 4〜5(学習・5-fold 交差検証)完了.4 モデル × 2 条件 × 3 seed を実行(TEST_03).次は Step 7(実験反復) +最新: PLAN_02 Step 7 の一部としてクラス不均衡対策(重み・バランス・サンプリング)を実装・実験(TEST_04).次は学習率の公平な選び直し ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) @@ -101,3 +101,4 @@ - データセット構成調査: docs/06_TEST/TEST_01_データセット構成調査.md - データセット集計結果: docs/06_TEST/TEST_02_データセット集計結果.md - 交差検証結果: docs/06_TEST/TEST_03_交差検証結果.md +- クラス不均衡対策の実験: docs/06_TEST/TEST_04_クラス不均衡対策の実験.md diff --git a/configs/imbalance/cv_resnet50_with_mild_lr1e-3_balanced.yaml b/configs/imbalance/cv_resnet50_with_mild_lr1e-3_balanced.yaml new file mode 100644 index 0000000..1880851 --- /dev/null +++ b/configs/imbalance/cv_resnet50_with_mild_lr1e-3_balanced.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ResNet-50.モデル名以外は cv_with_mild.yaml と同一).学習率 1e-3 の再実験(lr 以外は configs/cv_resnet50_with_mild.yaml と同一.lr 1e-4 では学習不足だったため).クラス不均衡の対策: バランス・サンプリング(sampler 以外は configs/lr1e-3/cv_resnet50_with_mild_lr1e-3.yaml と同一.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: 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 + sampler: balanced diff --git a/configs/imbalance/cv_resnet50_with_mild_lr1e-3_cwinv.yaml b/configs/imbalance/cv_resnet50_with_mild_lr1e-3_cwinv.yaml new file mode 100644 index 0000000..0c6bd49 --- /dev/null +++ b/configs/imbalance/cv_resnet50_with_mild_lr1e-3_cwinv.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ResNet-50.モデル名以外は cv_with_mild.yaml と同一).学習率 1e-3 の再実験(lr 以外は configs/cv_resnet50_with_mild.yaml と同一.lr 1e-4 では学習不足だったため).クラス不均衡の対策: 損失のクラス重み inverse(class_weighting 以外は configs/lr1e-3/cv_resnet50_with_mild_lr1e-3.yaml と同一.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: 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 + class_weighting: inverse diff --git a/configs/imbalance/cv_resnet50_with_mild_lr1e-3_cwsqrt.yaml b/configs/imbalance/cv_resnet50_with_mild_lr1e-3_cwsqrt.yaml new file mode 100644 index 0000000..145364b --- /dev/null +++ b/configs/imbalance/cv_resnet50_with_mild_lr1e-3_cwsqrt.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ResNet-50.モデル名以外は cv_with_mild.yaml と同一).学習率 1e-3 の再実験(lr 以外は configs/cv_resnet50_with_mild.yaml と同一.lr 1e-4 では学習不足だったため).クラス不均衡の対策: 損失のクラス重み sqrt_inverse(class_weighting 以外は configs/lr1e-3/cv_resnet50_with_mild_lr1e-3.yaml と同一.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: 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 + class_weighting: sqrt_inverse diff --git a/configs/imbalance/cv_resnet50_with_mild_lr1e-3_s43_balanced.yaml b/configs/imbalance/cv_resnet50_with_mild_lr1e-3_s43_balanced.yaml new file mode 100644 index 0000000..858a77c --- /dev/null +++ b/configs/imbalance/cv_resnet50_with_mild_lr1e-3_s43_balanced.yaml @@ -0,0 +1,28 @@ +# 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 では学習不足だったため).クラス不均衡の対策: バランス・サンプリング(sampler 以外は configs/lr1e-3/cv_resnet50_with_mild_lr1e-3_s43.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + sampler: balanced diff --git a/configs/imbalance/cv_resnet50_with_mild_lr1e-3_s43_cwinv.yaml b/configs/imbalance/cv_resnet50_with_mild_lr1e-3_s43_cwinv.yaml new file mode 100644 index 0000000..9b8c98c --- /dev/null +++ b/configs/imbalance/cv_resnet50_with_mild_lr1e-3_s43_cwinv.yaml @@ -0,0 +1,28 @@ +# 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 では学習不足だったため).クラス不均衡の対策: 損失のクラス重み inverse(class_weighting 以外は configs/lr1e-3/cv_resnet50_with_mild_lr1e-3_s43.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + class_weighting: inverse diff --git a/configs/imbalance/cv_resnet50_with_mild_lr1e-3_s43_cwsqrt.yaml b/configs/imbalance/cv_resnet50_with_mild_lr1e-3_s43_cwsqrt.yaml new file mode 100644 index 0000000..c622f08 --- /dev/null +++ b/configs/imbalance/cv_resnet50_with_mild_lr1e-3_s43_cwsqrt.yaml @@ -0,0 +1,28 @@ +# 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 では学習不足だったため).クラス不均衡の対策: 損失のクラス重み sqrt_inverse(class_weighting 以外は configs/lr1e-3/cv_resnet50_with_mild_lr1e-3_s43.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + class_weighting: sqrt_inverse diff --git a/configs/imbalance/cv_resnet50_with_mild_lr1e-3_s44_balanced.yaml b/configs/imbalance/cv_resnet50_with_mild_lr1e-3_s44_balanced.yaml new file mode 100644 index 0000000..504955f --- /dev/null +++ b/configs/imbalance/cv_resnet50_with_mild_lr1e-3_s44_balanced.yaml @@ -0,0 +1,28 @@ +# 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 では学習不足だったため).クラス不均衡の対策: バランス・サンプリング(sampler 以外は configs/lr1e-3/cv_resnet50_with_mild_lr1e-3_s44.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + sampler: balanced diff --git a/configs/imbalance/cv_resnet50_with_mild_lr1e-3_s44_cwinv.yaml b/configs/imbalance/cv_resnet50_with_mild_lr1e-3_s44_cwinv.yaml new file mode 100644 index 0000000..7274fc1 --- /dev/null +++ b/configs/imbalance/cv_resnet50_with_mild_lr1e-3_s44_cwinv.yaml @@ -0,0 +1,28 @@ +# 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 では学習不足だったため).クラス不均衡の対策: 損失のクラス重み inverse(class_weighting 以外は configs/lr1e-3/cv_resnet50_with_mild_lr1e-3_s44.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + class_weighting: inverse diff --git a/configs/imbalance/cv_resnet50_with_mild_lr1e-3_s44_cwsqrt.yaml b/configs/imbalance/cv_resnet50_with_mild_lr1e-3_s44_cwsqrt.yaml new file mode 100644 index 0000000..ea43342 --- /dev/null +++ b/configs/imbalance/cv_resnet50_with_mild_lr1e-3_s44_cwsqrt.yaml @@ -0,0 +1,28 @@ +# 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 では学習不足だったため).クラス不均衡の対策: 損失のクラス重み sqrt_inverse(class_weighting 以外は configs/lr1e-3/cv_resnet50_with_mild_lr1e-3_s44.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + class_weighting: sqrt_inverse diff --git a/configs/imbalance/cv_resnet50_without_mild_lr1e-3_balanced.yaml b/configs/imbalance/cv_resnet50_without_mild_lr1e-3_balanced.yaml new file mode 100644 index 0000000..126d992 --- /dev/null +++ b/configs/imbalance/cv_resnet50_without_mild_lr1e-3_balanced.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ResNet-50.モデル名以外は cv_without_mild.yaml と同一).学習率 1e-3 の再実験(lr 以外は configs/cv_resnet50_without_mild.yaml と同一.lr 1e-4 では学習不足だったため).クラス不均衡の対策: バランス・サンプリング(sampler 以外は configs/lr1e-3/cv_resnet50_without_mild_lr1e-3.yaml と同一.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: 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 + sampler: balanced diff --git a/configs/imbalance/cv_resnet50_without_mild_lr1e-3_cwinv.yaml b/configs/imbalance/cv_resnet50_without_mild_lr1e-3_cwinv.yaml new file mode 100644 index 0000000..c56666e --- /dev/null +++ b/configs/imbalance/cv_resnet50_without_mild_lr1e-3_cwinv.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ResNet-50.モデル名以外は cv_without_mild.yaml と同一).学習率 1e-3 の再実験(lr 以外は configs/cv_resnet50_without_mild.yaml と同一.lr 1e-4 では学習不足だったため).クラス不均衡の対策: 損失のクラス重み inverse(class_weighting 以外は configs/lr1e-3/cv_resnet50_without_mild_lr1e-3.yaml と同一.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: 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 + class_weighting: inverse diff --git a/configs/imbalance/cv_resnet50_without_mild_lr1e-3_cwsqrt.yaml b/configs/imbalance/cv_resnet50_without_mild_lr1e-3_cwsqrt.yaml new file mode 100644 index 0000000..83d0d8f --- /dev/null +++ b/configs/imbalance/cv_resnet50_without_mild_lr1e-3_cwsqrt.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).モデル比較用(ResNet-50.モデル名以外は cv_without_mild.yaml と同一).学習率 1e-3 の再実験(lr 以外は configs/cv_resnet50_without_mild.yaml と同一.lr 1e-4 では学習不足だったため).クラス不均衡の対策: 損失のクラス重み sqrt_inverse(class_weighting 以外は configs/lr1e-3/cv_resnet50_without_mild_lr1e-3.yaml と同一.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: 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 + class_weighting: sqrt_inverse diff --git a/configs/imbalance/cv_resnet50_without_mild_lr1e-3_s43_balanced.yaml b/configs/imbalance/cv_resnet50_without_mild_lr1e-3_s43_balanced.yaml new file mode 100644 index 0000000..a4001a5 --- /dev/null +++ b/configs/imbalance/cv_resnet50_without_mild_lr1e-3_s43_balanced.yaml @@ -0,0 +1,28 @@ +# 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 では学習不足だったため).クラス不均衡の対策: バランス・サンプリング(sampler 以外は configs/lr1e-3/cv_resnet50_without_mild_lr1e-3_s43.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + sampler: balanced diff --git a/configs/imbalance/cv_resnet50_without_mild_lr1e-3_s43_cwinv.yaml b/configs/imbalance/cv_resnet50_without_mild_lr1e-3_s43_cwinv.yaml new file mode 100644 index 0000000..da1e345 --- /dev/null +++ b/configs/imbalance/cv_resnet50_without_mild_lr1e-3_s43_cwinv.yaml @@ -0,0 +1,28 @@ +# 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 では学習不足だったため).クラス不均衡の対策: 損失のクラス重み inverse(class_weighting 以外は configs/lr1e-3/cv_resnet50_without_mild_lr1e-3_s43.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + class_weighting: inverse diff --git a/configs/imbalance/cv_resnet50_without_mild_lr1e-3_s43_cwsqrt.yaml b/configs/imbalance/cv_resnet50_without_mild_lr1e-3_s43_cwsqrt.yaml new file mode 100644 index 0000000..7a5f93b --- /dev/null +++ b/configs/imbalance/cv_resnet50_without_mild_lr1e-3_s43_cwsqrt.yaml @@ -0,0 +1,28 @@ +# 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 では学習不足だったため).クラス不均衡の対策: 損失のクラス重み sqrt_inverse(class_weighting 以外は configs/lr1e-3/cv_resnet50_without_mild_lr1e-3_s43.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + class_weighting: sqrt_inverse diff --git a/configs/imbalance/cv_resnet50_without_mild_lr1e-3_s44_balanced.yaml b/configs/imbalance/cv_resnet50_without_mild_lr1e-3_s44_balanced.yaml new file mode 100644 index 0000000..7fbcf9a --- /dev/null +++ b/configs/imbalance/cv_resnet50_without_mild_lr1e-3_s44_balanced.yaml @@ -0,0 +1,28 @@ +# 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 では学習不足だったため).クラス不均衡の対策: バランス・サンプリング(sampler 以外は configs/lr1e-3/cv_resnet50_without_mild_lr1e-3_s44.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + sampler: balanced diff --git a/configs/imbalance/cv_resnet50_without_mild_lr1e-3_s44_cwinv.yaml b/configs/imbalance/cv_resnet50_without_mild_lr1e-3_s44_cwinv.yaml new file mode 100644 index 0000000..4cdcc56 --- /dev/null +++ b/configs/imbalance/cv_resnet50_without_mild_lr1e-3_s44_cwinv.yaml @@ -0,0 +1,28 @@ +# 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 では学習不足だったため).クラス不均衡の対策: 損失のクラス重み inverse(class_weighting 以外は configs/lr1e-3/cv_resnet50_without_mild_lr1e-3_s44.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + class_weighting: inverse diff --git a/configs/imbalance/cv_resnet50_without_mild_lr1e-3_s44_cwsqrt.yaml b/configs/imbalance/cv_resnet50_without_mild_lr1e-3_s44_cwsqrt.yaml new file mode 100644 index 0000000..e40618a --- /dev/null +++ b/configs/imbalance/cv_resnet50_without_mild_lr1e-3_s44_cwsqrt.yaml @@ -0,0 +1,28 @@ +# 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 では学習不足だったため).クラス不均衡の対策: 損失のクラス重み sqrt_inverse(class_weighting 以外は configs/lr1e-3/cv_resnet50_without_mild_lr1e-3_s44.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + class_weighting: sqrt_inverse diff --git a/configs/imbalance/cv_with_mild_balanced.yaml b/configs/imbalance/cv_with_mild_balanced.yaml new file mode 100644 index 0000000..76386f7 --- /dev/null +++ b/configs/imbalance/cv_with_mild_balanced.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).クラス不均衡の対策: バランス・サンプリング(sampler 以外は configs/cv_with_mild.yaml と同一.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 + sampler: balanced diff --git a/configs/imbalance/cv_with_mild_cwinv.yaml b/configs/imbalance/cv_with_mild_cwinv.yaml new file mode 100644 index 0000000..02aced0 --- /dev/null +++ b/configs/imbalance/cv_with_mild_cwinv.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).クラス不均衡の対策: 損失のクラス重み inverse(class_weighting 以外は configs/cv_with_mild.yaml と同一.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 + class_weighting: inverse diff --git a/configs/imbalance/cv_with_mild_cwsqrt.yaml b/configs/imbalance/cv_with_mild_cwsqrt.yaml new file mode 100644 index 0000000..8cd6d09 --- /dev/null +++ b/configs/imbalance/cv_with_mild_cwsqrt.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).クラス不均衡の対策: 損失のクラス重み sqrt_inverse(class_weighting 以外は configs/cv_with_mild.yaml と同一.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 + class_weighting: sqrt_inverse diff --git a/configs/imbalance/cv_with_mild_s43_balanced.yaml b/configs/imbalance/cv_with_mild_s43_balanced.yaml new file mode 100644 index 0000000..6aa127d --- /dev/null +++ b/configs/imbalance/cv_with_mild_s43_balanced.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).分割 seed 43 の繰り返し(seed 以外は configs/cv_with_mild.yaml と同一).クラス不均衡の対策: バランス・サンプリング(sampler 以外は configs/repeat/cv_with_mild_s43.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + sampler: balanced diff --git a/configs/imbalance/cv_with_mild_s43_cwinv.yaml b/configs/imbalance/cv_with_mild_s43_cwinv.yaml new file mode 100644 index 0000000..9c0af20 --- /dev/null +++ b/configs/imbalance/cv_with_mild_s43_cwinv.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).分割 seed 43 の繰り返し(seed 以外は configs/cv_with_mild.yaml と同一).クラス不均衡の対策: 損失のクラス重み inverse(class_weighting 以外は configs/repeat/cv_with_mild_s43.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + class_weighting: inverse diff --git a/configs/imbalance/cv_with_mild_s43_cwsqrt.yaml b/configs/imbalance/cv_with_mild_s43_cwsqrt.yaml new file mode 100644 index 0000000..5a09aac --- /dev/null +++ b/configs/imbalance/cv_with_mild_s43_cwsqrt.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).分割 seed 43 の繰り返し(seed 以外は configs/cv_with_mild.yaml と同一).クラス不均衡の対策: 損失のクラス重み sqrt_inverse(class_weighting 以外は configs/repeat/cv_with_mild_s43.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + class_weighting: sqrt_inverse diff --git a/configs/imbalance/cv_with_mild_s44_balanced.yaml b/configs/imbalance/cv_with_mild_s44_balanced.yaml new file mode 100644 index 0000000..03bb837 --- /dev/null +++ b/configs/imbalance/cv_with_mild_s44_balanced.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).分割 seed 44 の繰り返し(seed 以外は configs/cv_with_mild.yaml と同一).クラス不均衡の対策: バランス・サンプリング(sampler 以外は configs/repeat/cv_with_mild_s44.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + sampler: balanced diff --git a/configs/imbalance/cv_with_mild_s44_cwinv.yaml b/configs/imbalance/cv_with_mild_s44_cwinv.yaml new file mode 100644 index 0000000..3ad7379 --- /dev/null +++ b/configs/imbalance/cv_with_mild_s44_cwinv.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).分割 seed 44 の繰り返し(seed 以外は configs/cv_with_mild.yaml と同一).クラス不均衡の対策: 損失のクラス重み inverse(class_weighting 以外は configs/repeat/cv_with_mild_s44.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + class_weighting: inverse diff --git a/configs/imbalance/cv_with_mild_s44_cwsqrt.yaml b/configs/imbalance/cv_with_mild_s44_cwsqrt.yaml new file mode 100644 index 0000000..54800bb --- /dev/null +++ b/configs/imbalance/cv_with_mild_s44_cwsqrt.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり込み): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).分割 seed 44 の繰り返し(seed 以外は configs/cv_with_mild.yaml と同一).クラス不均衡の対策: 損失のクラス重み sqrt_inverse(class_weighting 以外は configs/repeat/cv_with_mild_s44.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + class_weighting: sqrt_inverse diff --git a/configs/imbalance/cv_without_mild_balanced.yaml b/configs/imbalance/cv_without_mild_balanced.yaml new file mode 100644 index 0000000..7060769 --- /dev/null +++ b/configs/imbalance/cv_without_mild_balanced.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).クラス不均衡の対策: バランス・サンプリング(sampler 以外は configs/cv_without_mild.yaml と同一.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 + sampler: balanced diff --git a/configs/imbalance/cv_without_mild_cwinv.yaml b/configs/imbalance/cv_without_mild_cwinv.yaml new file mode 100644 index 0000000..992f755 --- /dev/null +++ b/configs/imbalance/cv_without_mild_cwinv.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).クラス不均衡の対策: 損失のクラス重み inverse(class_weighting 以外は configs/cv_without_mild.yaml と同一.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 + class_weighting: inverse diff --git a/configs/imbalance/cv_without_mild_cwsqrt.yaml b/configs/imbalance/cv_without_mild_cwsqrt.yaml new file mode 100644 index 0000000..694b23a --- /dev/null +++ b/configs/imbalance/cv_without_mild_cwsqrt.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).クラス不均衡の対策: 損失のクラス重み sqrt_inverse(class_weighting 以外は configs/cv_without_mild.yaml と同一.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 + class_weighting: sqrt_inverse diff --git a/configs/imbalance/cv_without_mild_s43_balanced.yaml b/configs/imbalance/cv_without_mild_s43_balanced.yaml new file mode 100644 index 0000000..303449e --- /dev/null +++ b/configs/imbalance/cv_without_mild_s43_balanced.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).分割 seed 43 の繰り返し(seed 以外は configs/cv_without_mild.yaml と同一).クラス不均衡の対策: バランス・サンプリング(sampler 以外は configs/repeat/cv_without_mild_s43.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + sampler: balanced diff --git a/configs/imbalance/cv_without_mild_s43_cwinv.yaml b/configs/imbalance/cv_without_mild_s43_cwinv.yaml new file mode 100644 index 0000000..bb579b8 --- /dev/null +++ b/configs/imbalance/cv_without_mild_s43_cwinv.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).分割 seed 43 の繰り返し(seed 以外は configs/cv_without_mild.yaml と同一).クラス不均衡の対策: 損失のクラス重み inverse(class_weighting 以外は configs/repeat/cv_without_mild_s43.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + class_weighting: inverse diff --git a/configs/imbalance/cv_without_mild_s43_cwsqrt.yaml b/configs/imbalance/cv_without_mild_s43_cwsqrt.yaml new file mode 100644 index 0000000..7966dec --- /dev/null +++ b/configs/imbalance/cv_without_mild_s43_cwsqrt.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).分割 seed 43 の繰り返し(seed 以外は configs/cv_without_mild.yaml と同一).クラス不均衡の対策: 損失のクラス重み sqrt_inverse(class_weighting 以外は configs/repeat/cv_without_mild_s43.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + class_weighting: sqrt_inverse diff --git a/configs/imbalance/cv_without_mild_s44_balanced.yaml b/configs/imbalance/cv_without_mild_s44_balanced.yaml new file mode 100644 index 0000000..847b776 --- /dev/null +++ b/configs/imbalance/cv_without_mild_s44_balanced.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).分割 seed 44 の繰り返し(seed 以外は configs/cv_without_mild.yaml と同一).クラス不均衡の対策: バランス・サンプリング(sampler 以外は configs/repeat/cv_without_mild_s44.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + sampler: balanced diff --git a/configs/imbalance/cv_without_mild_s44_cwinv.yaml b/configs/imbalance/cv_without_mild_s44_cwinv.yaml new file mode 100644 index 0000000..a1f5e7b --- /dev/null +++ b/configs/imbalance/cv_without_mild_s44_cwinv.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).分割 seed 44 の繰り返し(seed 以外は configs/cv_without_mild.yaml と同一).クラス不均衡の対策: 損失のクラス重み inverse(class_weighting 以外は configs/repeat/cv_without_mild_s44.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + class_weighting: inverse diff --git a/configs/imbalance/cv_without_mild_s44_cwsqrt.yaml b/configs/imbalance/cv_without_mild_s44_cwsqrt.yaml new file mode 100644 index 0000000..bb9ab8c --- /dev/null +++ b/configs/imbalance/cv_without_mild_s44_cwsqrt.yaml @@ -0,0 +1,28 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 計算サーバ上の実データで学習・評価する(SPEC_02「交差検証の実行」).分割 seed 44 の繰り返し(seed 以外は configs/cv_without_mild.yaml と同一).クラス不均衡の対策: 損失のクラス重み sqrt_inverse(class_weighting 以外は configs/repeat/cv_without_mild_s44.yaml と同一.SPEC_02「クラス不均衡」→「対策の設定」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない. +# 環境変数 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 + class_weighting: sqrt_inverse 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 ec2fbce..c82b325 100644 --- "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" +++ "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" @@ -66,7 +66,7 @@ | --- | --- | --- | | 5-fold 交差検証 | `uv run tias-train cv --config <設定>` | 学習・評価して `runs/<実験ID>/` に書き出す.最後に実験 ID を出力する([SPEC_02](../04_SPEC/SPEC_02_学習・評価仕様.md)「交差検証の実行」) | | 手元での試運転 | `uv run tias-train cv --config configs/cv_synthetic.yaml` | 合成データ・小さなモデル・事前学習なし.CPU で十数秒 | -| 回収した結果の集計 | `uv run python scripts/summarize_cv.py runs` | `runs/*_cv_*` を読み,モデル × 学習率 × 条件ごとの表と「除外 − 込み」の対応比較を Markdown で出す([TEST_03](../06_TEST/TEST_03_交差検証結果.md) の表) | +| 回収した結果の集計 | `uv run python scripts/summarize_cv.py runs` | `runs/*_cv_*` を読み,モデル × 学習率 × 不均衡対策 × 条件ごとの表と,「除外 − 込み」「対策あり − 対策なし」の対応比較を Markdown で出す([TEST_03](../06_TEST/TEST_03_交差検証結果.md) の表) | 設定ファイル: @@ -76,6 +76,7 @@ | `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) | +| `configs/imbalance/*.yaml` | クラス不均衡の対策(`_cwinv`: 重み inverse・`_cwsqrt`: 重み sqrt_inverse・`_balanced`: バランス・サンプリング).元の設定(ConvNeXt-Tiny・ResNet-50 lr 1e-3 の各 6 本)から対策のキーだけを変えたもの | **サーバでの実行例**: 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 ee36225..d130a72 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" @@ -12,7 +12,7 @@ | 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 | +| 7 | 実験反復(不均衡対策・モデル比較・解像度検討)※ 一部実施(モデル比較: [TEST_03](../06_TEST/TEST_03_交差検証結果.md)・不均衡対策: [TEST_04](../06_TEST/TEST_04_クラス不均衡対策の実験.md)) | Step 6 | | 8 | 結論(5-fold 交差検証・評価レポート・配布形式の判断) | Step 7 | Step 1・2 はデータのコピー待ちの間に進められる. 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 87573b1..0f72afe 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" @@ -48,6 +48,8 @@ | `train.inner_val_ratio` | 0.15 | 交差検証の各 fold で,学習側から早期終了用の検証データを取る割合(下記) | | `train.num_workers` | 4 | DataLoader | | `train.amp` | `true` | CUDA のときだけ混合精度 | +| `train.class_weighting` | `none` | 損失のクラス重み(下記「クラス不均衡」→「対策の設定」) | +| `train.sampler` | `random` | 学習データの抜き出し方(同上) | - データ拡張(学習時のみ): 左右反転(確率 0.5)・回転 ±10°・拡大縮小 0.9〜1.1 倍・平行移動わずか.はみ出した部分は黒で埋める.**色を変える拡張は行わない**(上記「データ拡張」) - 正規化: ImageNet の mean / std @@ -167,6 +169,26 @@ 程度ラベル(`severity`)を含めた 9 クラス分類は採らない(最小クラス 51 枚・不均衡 6.6 倍となり学習が不安定になる.目的は TIASjudge の 5 分類の置き換えであるため). +### 対策の設定 (Countermeasures) + +素の結果([TEST_03](../06_TEST/TEST_03_交差検証結果.md))で紫・淡白の再現率が低かったため,次の 2 つを設定で選べるようにする.既定はどちらも「対策なし」で,既定のままなら従来と同じ学習になる. + +| キー | 値 | 内容 | +| --- | --- | --- | +| `train.class_weighting` | `none`(既定)・`inverse`・`sqrt_inverse` | 損失のクラス重み.クラス c の学習画像数を n_c,全体を N,クラス数を K として,`inverse` は w_c ∝ N / (K・n_c),`sqrt_inverse` はその平方根.**重みは学習データでの平均が 1 になるよう正規化する**(Σ_c (n_c / N)・w_c = 1.損失の大きさを対策なしと揃え,学習率の意味を変えないため) | +| `train.sampler` | `random`(既定)・`balanced` | `balanced` は各画像を 1 / n_c の確率で重複ありで抜き出し,どのクラスもほぼ同じ頻度で学習に出す.1 エポックに抜き出す枚数は学習画像数と同じ | + +- クラスの画像数 n_c は,**各 fold の学習用データ(`data.severities` を適用した後)**から数える.検証用・held_out の枚数は使わない +- 学習用データに 1 枚も無いクラスがある場合は停止する(重みが定義できないため).対策を指定しないとき(既定)は従来どおり停止しない +- **重み付きの損失はバッチの件数で割って平均する**(Σ_i w_{y_i}・CE_i / B).PyTorch の `CrossEntropyLoss(weight=w)` の既定は重みの和で割るため,上の正規化が効かなくなる.正規化で揃うのは学習データ全体での平均であり,1 つのミニバッチの損失はクラス比の偏りに応じて対策なしとずれうる +- `sampler: balanced` でも,学習時に端数のバッチを捨てる規則(「学習の設定」)は従来どおりかかる +- 重みは学習時の損失にだけかける.**検証の損失は重みなし**で計算する(対策の有無で早期終了の比較が変わらないようにするため.早期終了の基準は従来どおり検証 macro F1) +- `class_weighting` と `sampler: balanced` は同時に指定できるが,偏りを二重に補正することになるため実験では組み合わせない +- `metrics.json` に,使った対策(トップレベルの `imbalance`)と,fold ごとのクラス別枚数・クラス重み・1 回の抜き出しで各クラスが選ばれる確率(各 fold の `class_balance`.使わない側は `null`)を記録する +- 重みを使うと `history.csv` の学習損失は重み付きの値になる(検証損失は重みなし).対策なしの run と学習損失を直接比べない + +効果は,対策なしの結果と**同じ seed・同じ fold どうしの対応比較**で評価する(common の macro F1 と,クラス別の再現率・適合率・混同行列).少ないクラスの再現率が上がっても,他クラスの適合率が下がる入れ替わりが起きうるため,全体の数字だけで判断しない. + ## 評価指標 (Metrics) | 指標 | 役割 | diff --git "a/docs/06_TEST/TEST_04_\343\202\257\343\203\251\343\202\271\344\270\215\345\235\207\350\241\241\345\257\276\347\255\226\343\201\256\345\256\237\351\250\223.md" "b/docs/06_TEST/TEST_04_\343\202\257\343\203\251\343\202\271\344\270\215\345\235\207\350\241\241\345\257\276\347\255\226\343\201\256\345\256\237\351\250\223.md" new file mode 100644 index 0000000..cdb6071 --- /dev/null +++ "b/docs/06_TEST/TEST_04_\343\202\257\343\203\251\343\202\271\344\270\215\345\235\207\350\241\241\345\257\276\347\255\226\343\201\256\345\256\237\351\250\223.md" @@ -0,0 +1,85 @@ +# クラス不均衡対策の実験 (Class Imbalance Experiments) + +[TEST_03](TEST_03_交差検証結果.md) で紫・淡白の再現率が低かったため,クラス不均衡への対策([SPEC_02「クラス不均衡」→「対策の設定」](../04_SPEC/SPEC_02_学習・評価仕様.md))を 2 モデル × 3 通り × 2 条件 × 3 seed で試した結果.対策なしの結果は TEST_03 のものを使い,**同じ seed・同じ fold どうしで対応比較**した.表は `uv run python scripts/summarize_cv.py runs` で再生成できる. + +| 項目 | 内容 | +| --- | --- | +| 実行日時 | 2026-09-28 08:50〜10:14 | +| 実行場所 | Archimedes の RTX A6000(GPU 1 の 1 枚.未使用の GPU のみを使用) | +| 本数 | 36 本(ConvNeXt-Tiny・ResNet-50 × 対策 3 通り × ややあり込み/除外 × seed 42・43・44) | +| モデルと学習率 | ConvNeXt-Tiny(1e-4)・ResNet-50(1e-3).TEST_03 の設定から対策のキーだけを変えた | +| 対策 | 重み `inverse`・重み `sqrt_inverse`・バランス・サンプリング `balanced` | +| コード | `f9d9ebd-dirty`(コミット `8387ebd` の内容を実行時点で未コミットのまま使用.既定のままなら変更前と同じ学習になることはテストで確認済み) | + +## 結果 (Results) + +common(「あり」と淡紅)の macro F1・正解率と,all(ややあり込みの全体)の macro F1.15 fold の平均 ± 標準偏差. + +| モデル | 対策 | 条件 | common macro F1 | common 正解率 | all macro F1 | +| --- | --- | --- | --- | --- | --- | +| ConvNeXt-Tiny | なし | 込み | 0.541 ± 0.066 | 0.668 ± 0.035 | 0.510 ± 0.056 | +| ConvNeXt-Tiny | 重み inverse | 込み | **0.585 ± 0.057** | 0.669 ± 0.063 | **0.546 ± 0.054** | +| ConvNeXt-Tiny | 重み sqrt | 込み | 0.563 ± 0.045 | 0.645 ± 0.057 | 0.524 ± 0.032 | +| ConvNeXt-Tiny | balanced | 込み | 0.566 ± 0.071 | 0.663 ± 0.081 | 0.528 ± 0.061 | +| ConvNeXt-Tiny | なし | 除外 | 0.592 ± 0.068 | 0.744 ± 0.035 | 0.480 ± 0.056 | +| ConvNeXt-Tiny | 重み inverse | 除外 | **0.632 ± 0.060** | 0.745 ± 0.030 | 0.528 ± 0.043 | +| ConvNeXt-Tiny | 重み sqrt | 除外 | 0.595 ± 0.060 | 0.750 ± 0.030 | 0.490 ± 0.049 | +| ConvNeXt-Tiny | balanced | 除外 | 0.611 ± 0.052 | 0.734 ± 0.041 | 0.509 ± 0.046 | +| ResNet-50 | なし | 込み | 0.546 ± 0.057 | 0.649 ± 0.066 | 0.520 ± 0.050 | +| ResNet-50 | 重み inverse | 込み | 0.558 ± 0.045 | 0.635 ± 0.055 | 0.535 ± 0.035 | +| ResNet-50 | 重み sqrt | 込み | **0.590 ± 0.057** | 0.666 ± 0.070 | **0.554 ± 0.043** | +| ResNet-50 | balanced | 込み | 0.562 ± 0.038 | 0.627 ± 0.045 | 0.533 ± 0.043 | +| ResNet-50 | なし | 除外 | **0.637 ± 0.062** | 0.748 ± 0.045 | 0.519 ± 0.056 | +| ResNet-50 | 重み inverse | 除外 | **0.637 ± 0.058** | 0.720 ± 0.045 | 0.542 ± 0.047 | +| ResNet-50 | 重み sqrt | 除外 | 0.633 ± 0.056 | 0.733 ± 0.045 | 0.534 ± 0.044 | +| ResNet-50 | balanced | 除外 | 0.616 ± 0.050 | 0.712 ± 0.062 | 0.523 ± 0.038 | + +## 対策なしとの対応比較 (Paired Comparison) + +対策あり − 対策なし(同じモデル・条件・seed・fold どうし.15 fold).recall は common でのクラス別再現率の差. + +| モデル | 対策 | 条件 | common F1 の差 | 上回った fold | 淡白 | 紫 | 淡紅 | 暗赤紅 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| ConvNeXt-Tiny | 重み inverse | 込み | **+0.044 ± 0.064** | **11 / 15** | +0.19 | +0.15 | −0.03 | −0.05 | +| ConvNeXt-Tiny | 重み inverse | 除外 | **+0.040 ± 0.063** | **12 / 15** | +0.19 | +0.09 | −0.07 | +0.02 | +| ConvNeXt-Tiny | 重み sqrt | 込み | +0.021 ± 0.052 | 10 / 15 | +0.12 | +0.23 | −0.06 | −0.07 | +| ConvNeXt-Tiny | 重み sqrt | 除外 | +0.003 ± 0.074 | 8 / 15 | −0.01 | −0.04 | −0.02 | +0.06 | +| ConvNeXt-Tiny | balanced | 込み | +0.025 ± 0.060 | 9 / 15 | +0.07 | +0.19 | −0.00 | −0.09 | +| ConvNeXt-Tiny | balanced | 除外 | +0.018 ± 0.067 | 10 / 15 | +0.13 | +0.05 | −0.08 | +0.03 | +| ResNet-50 | 重み inverse | 込み | +0.011 ± 0.060 | 7 / 15 | +0.16 | +0.10 | −0.04 | −0.05 | +| ResNet-50 | 重み inverse | 除外 | −0.000 ± 0.053 | 5 / 15 | +0.11 | +0.08 | −0.04 | −0.12 | +| ResNet-50 | 重み sqrt | 込み | **+0.043 ± 0.065** | **11 / 15** | +0.12 | +0.10 | +0.01 | −0.03 | +| ResNet-50 | 重み sqrt | 除外 | −0.004 ± 0.065 | 8 / 15 | −0.01 | +0.10 | −0.01 | −0.07 | +| ResNet-50 | balanced | 込み | +0.016 ± 0.068 | 8 / 15 | +0.13 | +0.15 | −0.05 | −0.08 | +| ResNet-50 | balanced | 除外 | −0.021 ± 0.071 | 4 / 15 | +0.06 | +0.07 | −0.04 | −0.09 | + +クラス別再現率の差の標準偏差は 0.1〜0.3 と大きい(各 fold の held_out に淡白・紫は 11〜12 人しかいないため). + +## 分かったこと (Findings) + +1. **ConvNeXt-Tiny では重み `inverse` が安定して効く**.込み・除外とも common macro F1 が約 +0.04 上がり(11〜12/15 fold),除外では 0.632 となって ResNet-50 の最良(0.637)と並んだ +2. **ResNet-50(学習率 1e-3)では効果がほとんど無い**.除外ではどの対策も ±0.02 の範囲で,上回った fold も半分以下.込みで `sqrt_inverse` だけが +0.043(11/15) +3. **狙いどおり淡白・紫の再現率は上がる**が(多くの組み合わせで +0.1〜0.2),**淡紅・暗赤紅の再現率が下がる入れ替わり**が起きる(−0.03〜−0.12).macro F1 の改善が小さいのはこのため +4. **all(ややあり込みの全体)の macro F1 も上がる**(ConvNeXt・inverse で 0.510 → 0.546,ResNet-50・sqrt で 0.520 → 0.554) +5. 3 通りの対策の中では,**重み `inverse` が最も安定**.`balanced` は同じ画像を何度も学習するためか効果が不安定で,ResNet-50 の除外では下がった +6. 紫の再現率は対策後も 0.3〜0.55 に留まる.紫は枚数の少なさに加えて暗赤紅と色が近く([TEST_02](TEST_02_データセット集計結果.md).ΔE 4.6),不均衡対策だけでは解決しない + +## 注意 (Caveats) + +- 差の多くは fold 間のばらつき(標準偏差 0.05〜0.08)と同程度で,確定的に言えるのは「ConvNeXt・`inverse` はおおむね改善する」程度 +- ResNet-50 の学習率 1e-3 は TEST_03 で結果を見て選んだもので,その上に重ねた比較である(TEST_03「学習率について」) +- 対策の種類と強さも結果を見て選ぶことになるため,最終的な構成は入れ子の交差検証など評価とは別のデータで選ぶ必要がある + +## 次の一手 (Next Steps) + +- 今後の基準の構成を「ConvNeXt-Tiny・重み `inverse`」または「ResNet-50(1e-3)」とし,学習率の公平な選び直し(入れ子の交差検証)と組み合わせる +- 紫と暗赤紅の取り違えを混同行列で詳しく見る(紫の多くがどのクラスに流れているか) +- 入力解像度を上げる(224 → 320 等).色むらの細部が紫の判別に効く可能性がある + +## 実験 ID (Run IDs) + +手元の `runs/` に回収済み(`.gitignore` 対象).名前の末尾 `_cwinv`・`_cwsqrt`・`_balanced` が対策を表す.実験 ID の日時は 2026-09-28 08:50〜10:12. + +```bash +ls -d runs/*_f9d9ebd-dirty +``` diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 54401e9..cc563fe 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -100,3 +100,13 @@ - 止めた判断: 残り時間はあったが,学習率の調整をこれ以上重ねると評価データへの合わせ込みが強まるため,追加実験はせず Step 7 の計画(入れ子の交差検証で全モデル同じ候補から選ぶ)に回した - 未実施: ベースライン比較(Step 6) - 対応済み: 36 本の重み(約 12 GB)が Archimedes の共用ディスク(残り 40 GB・98%)を圧迫していたため,ユーザーの判断で削除した(空き 52 GB に回復).重みは同じ設定で数分で作り直せる(ビット単位の一致はしない).今後は保存するかを設定で選べるようにする + +## Step 7(一部): クラス不均衡への対策 + +- [x] **損失のクラス重み(`inverse`・`sqrt_inverse`)とバランス・サンプリングを追加し,2 モデル × 3 通り × 2 条件 × 3 seed で実験**: TEST_03 で紫・淡白の再現率が低かったため.結果は [TEST_04](06_TEST/TEST_04_クラス不均衡対策の実験.md). + - 設計判断 ①: クラス重みは学習データでの平均が 1 になるよう正規化し,損失はバッチの件数で割る(PyTorch の `CrossEntropyLoss(weight=w)` の既定は重みの和で割るため正規化が効かない.tester の指摘で仕様に明記).損失の大きさを対策なしと揃え,学習率の意味を変えない + - 設計判断 ②: 既定のままなら変更前と同じ学習になるようにし,対策なしの TEST_03 の結果と同じ分割で対応比較した + - 結果: ConvNeXt-Tiny では重み `inverse` が安定して効く(込み・除外とも common macro F1 +0.04,11〜12/15 fold.除外で 0.632).ResNet-50(1e-3)ではほぼ効かない.淡白・紫の再現率は +0.1〜0.2 上がるが淡紅・暗赤紅が下がる入れ替わりがあり,macro F1 の改善は小さい.紫は対策後も 0.3〜0.55 + - 失敗パターン: キュー完了後の自動回収が,長くなったジョブログの末尾しか読めず 36 本中 27 本しか拾えなかった → サーバ上の実験ディレクトリの一覧から回収し直した.以後,回収はログではなくディレクトリの一覧を正とする + - 速度の調査: 学習中の GPU 使用率は平均 86% で計算律速(データ準備は余裕あり).性能を変えずに速くするなら channels_last・bf16(1〜3 割の見込み)や GPU の並列利用が候補(未実施) + diff --git a/scripts/summarize_cv.py b/scripts/summarize_cv.py index 12ca498..b2cbdfe 100644 --- a/scripts/summarize_cv.py +++ b/scripts/summarize_cv.py @@ -27,6 +27,15 @@ } MODEL_ORDER = list(MODEL_LABEL) CONDITIONS = ("with_mild", "without_mild") +MEASURES = ("none", "inverse", "sqrt_inverse", "balanced") +MEASURE_LABEL = { + "none": "", + "inverse": "・重み inverse", + "sqrt_inverse": "・重み sqrt", + "balanced": "・balanced", +} +# 不均衡対策の対応比較で recall の差を示すクラス(少数クラス → 多数クラスの順) +COMPARED_RECALL_CLASSES = ("pale", "purple", "light_red", "dark_red") CONDITION_LABEL = {"with_mild": "込み", "without_mild": "除外"} MeanStd = tuple[float, float, int] @@ -48,10 +57,24 @@ 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 label(key: tuple[str, float, str]) -> str: + """(モデル名, 学習率, 不均衡対策)の表示名.""" + name, lr, measure = key + return f"{MODEL_LABEL[name]}(lr {lr:g}{MEASURE_LABEL[measure]})" + + +def imbalance_measure(metrics: dict) -> str: + """不均衡対策の種類(対策の記録が無い古い run は対策なし).""" + imb = metrics.get("imbalance") or {} + if imb.get("sampler") == "balanced": + return "balanced" + weighting = imb.get("class_weighting", "none") + return weighting if weighting in MEASURES else "none" + + +def sort_key(key: tuple[str, float, str]) -> tuple[int, float, int]: + """表の並び順(モデル → 学習率 → 対策).""" + return (MODEL_ORDER.index(key[0]), key[1], MEASURES.index(key[2])) def load_runs(root: Path) -> list[dict]: @@ -68,7 +91,7 @@ runs.append( { "run_id": metrics["run_id"], - "model": (model, float(conf["train"]["lr"])), + "model": (model, float(conf["train"]["lr"]), imbalance_measure(metrics)), "cond": cond, "seed": conf["split"]["seed"], "m": metrics, @@ -102,13 +125,59 @@ return mean_std([v for r in runs for v in fold_values(r, subset, key)]) +def measure_comparison(keys: list, groups: dict) -> list[str]: + """不均衡対策あり − 対策なし の対応比較(同じモデル・学習率・条件・seed・fold).""" + recall_headers = [f"{JP[c]} recall の差" for c in COMPARED_RECALL_CLASSES] + headers = ["モデル", "条件", "fold 数", "common F1 の差", "上回った fold", *recall_headers] + lines = [ + "| " + " | ".join(headers) + " |", + "|" + " --- |" * len(headers), + ] + found = False + for model in keys: + if model[2] == "none": + continue + base_key = (model[0], model[1], "none") + for cond in CONDITIONS: + treated = {r["seed"]: r for r in groups.get((model, cond), [])} + base = {r["seed"]: r for r in groups.get((base_key, cond), [])} + seeds = sorted(set(treated) & set(base)) + if not seeds: + continue + found = True + f1_diffs: list[float] = [] + recall_diffs: dict[str, list[float]] = {c: [] for c in COMPARED_RECALL_CLASSES} + for seed in seeds: + a = fold_values(base[seed], "common", "macro_f1") + b = fold_values(treated[seed], "common", "macro_f1") + f1_diffs += [y - x for x, y in zip(a, b, strict=True)] + for c in COMPARED_RECALL_CLASSES: + ra = fold_class_recall(base[seed], "common", c) + rb = fold_class_recall(treated[seed], "common", c) + if len(ra) == len(rb): + recall_diffs[c] += [y - x for x, y in zip(ra, rb, strict=True)] + wins = sum(1 for d in f1_diffs if d > 0) + n = len(f1_diffs) + cells = [label(model), CONDITION_LABEL[cond], str(n), fmt(mean_std(f1_diffs))] + cells.append(f"{wins} / {n}") + cells += [fmt(mean_std(recall_diffs[c]), 2) for c in COMPARED_RECALL_CLASSES] + lines.append("| " + " | ".join(cells) + " |") + if not found: + return [] + lines.append( + "\n※ 不均衡対策あり − 対策なし(同じモデル・学習率・条件・seed・fold どうし)." + "recall は common でのクラス別再現率.\n" + ) + return lines + + 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])) + keys = sorted({r["model"] for r in runs}, key=sort_key) seeds = ", ".join(sorted({str(r["seed"]) for r in runs})) lines = [f"対象: {len(runs)} 本(seed {seeds})\n"] @@ -141,7 +210,7 @@ fmt(row["seconds"], 0), ] lines.append("| " + " | ".join(cells) + " |") - summary[f"{model[0]}/lr{model[1]:g}/{cond}"] = row + summary[f"{model[0]}/lr{model[1]:g}/{model[2]}/{cond}"] = row lines.append( "\n※ macro F1・正解率は全 seed × 5 fold の fold ごとの値の平均 ± 標準偏差." "mild→淡紅 は run ごとの(全 fold をまとめた)割合の平均 ± 標準偏差.\n" @@ -182,7 +251,7 @@ 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}"] = { + paired[f"{model[0]}/lr{model[1]:g}/{model[2]}"] = { "diff": mean_std(diffs), "wins": wins, "n": n, @@ -192,6 +261,8 @@ "fold ごとの差を取っている.\n" ) + lines += measure_comparison(keys, groups) + print("\n".join(lines)) out = {"summary": summary, "paired": paired, "runs": [r["run_id"] for r in runs]} (root / "_cv_summary.json").write_text( diff --git a/src/tias_judge_model/cli/train.py b/src/tias_judge_model/cli/train.py index 203802d..ed740d2 100644 --- a/src/tias_judge_model/cli/train.py +++ b/src/tias_judge_model/cli/train.py @@ -55,6 +55,7 @@ ) from tias_judge_model.schema import ExperimentConfig, load_config from tias_judge_model.training.cross_validation import ( + ClassBalance, CrossValidationResult, FoldResult, fold_seed, @@ -269,6 +270,7 @@ "mean_epoch_seconds": round(float(np.mean(seconds)), 3) if seconds else None, }, "subsets": subset_metrics(y_true, y_pred, sev), + "class_balance": class_balance_entry(fold.class_balance), } ) @@ -297,6 +299,10 @@ "git_commit": git_commit, "classes": list(CLASS_SLUGS), "severities_trained": list(config.data.severities), + "imbalance": { + "class_weighting": config.train.class_weighting, + "sampler": config.train.sampler, + }, "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]), @@ -321,6 +327,33 @@ } +def class_balance_entry(balance: ClassBalance | None) -> dict[str, Any] | None: + """fold の `class_balance`(学習のクラス別枚数・クラス重み・抜き出し確率)を作る. + + Args: + balance: fold の学習に使ったクラス不均衡の対策(None なら記録なし). + + Returns: + クラス slug をキーとする dict を含む dict.`class_weights` は `class_weighting` が + `none` のとき,`class_draw_probabilities` は `sampler` が `random` のとき None. + """ + if balance is None: + return None + + def by_class(values: Sequence[float] | None) -> dict[str, float] | None: + if values is None: + return None + return {slug: round(float(v), 6) for slug, v in zip(CLASS_SLUGS, values, strict=True)} + + return { + "class_weighting": balance.class_weighting, + "sampler": balance.sampler, + "class_counts": dict(zip(CLASS_SLUGS, balance.class_counts, strict=True)), + "class_weights": by_class(balance.class_weights), + "class_draw_probabilities": by_class(balance.class_draw_probabilities), + } + + def _write_split(path: Path, config: ExperimentConfig, result: CrossValidationResult) -> None: # 患者 ID を含む(回収しない). _write_json( diff --git a/src/tias_judge_model/data/class_balance.py b/src/tias_judge_model/data/class_balance.py new file mode 100644 index 0000000..18f72d5 --- /dev/null +++ b/src/tias_judge_model/data/class_balance.py @@ -0,0 +1,116 @@ +"""クラス不均衡への対策の計算(SPEC_02「クラス不均衡」→「対策の設定」). + +学習ラベルから,損失のクラス重みとバランス・サンプリングの抜き出し重みを求める純関数. +クラスの画像数 n_c は呼び出し側が渡す**各 fold の学習用データ(`data.severities` 適用後)**の +ラベルから数える(検証用・held_out は使わない). +""" + +from __future__ import annotations + +import numpy as np + +from tias_judge_model.data.classes import CLASS_SLUGS, NUM_CLASSES + +# 損失のクラス重みの方式(`train.class_weighting` のうち `none` 以外). +CLASS_WEIGHTING_METHODS: tuple[str, ...] = ("inverse", "sqrt_inverse") + + +def count_classes(labels: np.ndarray) -> np.ndarray: + """クラス ID ごとの画像数を数える. + + Args: + labels: `(N,)` の整数配列(クラス ID.`classes.py` の固定 ID). + + Returns: + `(NUM_CLASSES,)` の int64 配列(クラス ID 順の画像数). + + Raises: + ValueError: 範囲外のクラス ID を含む場合. + """ + labels = np.asarray(labels, dtype=np.int64) + if labels.size and (labels.min() < 0 or labels.max() >= NUM_CLASSES): + raise ValueError(f"クラス ID が範囲外である(0〜{NUM_CLASSES - 1})") + return np.bincount(labels, minlength=NUM_CLASSES).astype(np.int64) + + +def _require_all_classes(counts: np.ndarray) -> None: + missing = [CLASS_SLUGS[i] for i, n in enumerate(counts) if n == 0] + if missing: + raise ValueError( + f"学習用データに 1 枚も無いクラスがあるため,クラス不均衡の対策を計算できない: " + f"{', '.join(missing)}" + ) + + +def compute_class_weights(labels: np.ndarray, method: str) -> np.ndarray: + """学習ラベルから損失のクラス重みを求める. + + クラス c の画像数を n_c,全体を N,クラス数を K として,`inverse` は w_c ∝ N / (K・n_c), + `sqrt_inverse` はその平方根に比例する.学習データでの平均が 1 になるよう + Σ_c (n_c / N)・w_c = 1 に正規化する(損失の大きさを対策なしと揃えるため). + + Args: + labels: `(N,)` の整数配列(学習用データのクラス ID). + method: `inverse` または `sqrt_inverse`. + + Returns: + `(NUM_CLASSES,)` の float64 配列(クラス ID 順の重み). + + Raises: + ValueError: 未知の方式,または学習用データに 1 枚も無いクラスがある場合. + """ + if method not in CLASS_WEIGHTING_METHODS: + valid = ", ".join(CLASS_WEIGHTING_METHODS) + raise ValueError(f"未知のクラス重みの方式である: {method!r}(有効値: {valid})") + counts = count_classes(labels) + _require_all_classes(counts) + n = counts.astype(np.float64) + total = n.sum() + raw = total / (NUM_CLASSES * n) + if method == "sqrt_inverse": + raw = np.sqrt(raw) + return raw / float(np.sum(n / total * raw)) + + +def balanced_sample_weights(labels: np.ndarray) -> np.ndarray: + """バランス・サンプリングの画像ごとの抜き出し重み(1 / n_c)を求める. + + 重みの合計は K(クラス数)になり,どのクラスも 1 回の抜き出しで 1 / K の確率で選ばれる. + + Args: + labels: `(N,)` の整数配列(学習用データのクラス ID). + + Returns: + `(N,)` の float64 配列(各画像の抜き出し重み.並びは `labels` の順). + + Raises: + ValueError: 学習用データに 1 枚も無いクラスがある場合. + """ + labels = np.asarray(labels, dtype=np.int64) + counts = count_classes(labels) + _require_all_classes(counts) + return 1.0 / counts[labels].astype(np.float64) + + +def class_draw_probabilities(labels: np.ndarray, sample_weights: np.ndarray) -> np.ndarray: + """画像ごとの抜き出し重みから,1 回の抜き出しで各クラスが選ばれる確率を求める. + + Args: + labels: `(N,)` の整数配列(学習用データのクラス ID). + sample_weights: `(N,)` の非負の抜き出し重み. + + Returns: + `(NUM_CLASSES,)` の float64 配列(クラス ID 順.合計 1). + + Raises: + ValueError: 件数が一致しない,または重みの合計が 0 以下の場合. + """ + labels = np.asarray(labels, dtype=np.int64) + weights = np.asarray(sample_weights, dtype=np.float64) + if labels.shape != weights.shape: + raise ValueError("ラベルと抜き出し重みの件数が一致しない") + total = weights.sum() + if total <= 0: + raise ValueError("抜き出し重みの合計が 0 以下である") + per_class = np.bincount(labels, weights=weights, minlength=NUM_CLASSES) + return per_class / total diff --git a/src/tias_judge_model/schema.py b/src/tias_judge_model/schema.py index 313c9ea..2d8dfa5 100644 --- a/src/tias_judge_model/schema.py +++ b/src/tias_judge_model/schema.py @@ -9,7 +9,7 @@ import os from collections.abc import Mapping from pathlib import Path -from typing import Any +from typing import Any, Literal import yaml from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -129,6 +129,10 @@ inner_val_ratio: 交差検証の各 fold で,学習側から早期終了用の検証に回す患者の比率. num_workers: DataLoader のワーカー数. amp: CUDA のときに混合精度を使うか(CPU では無視する). + class_weighting: 損失のクラス重み(`none`・`inverse`・`sqrt_inverse`.SPEC_02 + 「クラス不均衡」→「対策の設定」).`none` なら重みなし(従来どおり). + sampler: 学習データの抜き出し方(`random`・`balanced`.同上).`random` なら + 従来どおりのシャッフル. """ image_size: int = Field(default=224, ge=16) @@ -141,6 +145,8 @@ 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_weighting: Literal["none", "inverse", "sqrt_inverse"] = "none" + sampler: Literal["random", "balanced"] = "random" class ExperimentConfig(_StrictModel): diff --git a/src/tias_judge_model/training/cross_validation.py b/src/tias_judge_model/training/cross_validation.py index e03dc6c..0e810d9 100644 --- a/src/tias_judge_model/training/cross_validation.py +++ b/src/tias_judge_model/training/cross_validation.py @@ -4,8 +4,10 @@ 2. 各 fold で 1. 学習側から `train.inner_val_ratio` の患者を検証用に取る(患者単位・9 グループ層化) 2. `data.severities` を学習用と検証用にだけ適用する - 3. 検証 macro F1 が最良のエポックの重みを採用する(早期終了) - 4. held_out(**全程度**)で 1 回だけ評価する + 3. クラス不均衡の対策(`train.class_weighting`・`train.sampler`)を学習用データのクラス別枚数 + から求める(SPEC_02「クラス不均衡」→「対策の設定」) + 4. 検証 macro F1 が最良のエポックの重みを採用する(早期終了) + 5. held_out(**全程度**)で 1 回だけ評価する 3. 全 fold の held_out の予測を呼び出し側でまとめて集計する ログには件数・グループ別件数・損失・指標・時間だけを出し,患者 ID・ファイル名は出さない. @@ -21,9 +23,15 @@ import numpy as np import torch -from torch.utils.data import DataLoader +from torch.utils.data import DataLoader, WeightedRandomSampler -from tias_judge_model.data.classes import label_to_id +from tias_judge_model.data.class_balance import ( + balanced_sample_weights, + class_draw_probabilities, + compute_class_weights, + count_classes, +) +from tias_judge_model.data.classes import CLASS_SLUGS, 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 @@ -49,6 +57,26 @@ @dataclass +class ClassBalance: + """1 fold の学習に使ったクラス不均衡の対策(SPEC_02「クラス不均衡」→「対策の設定」). + + Attributes: + class_weighting: 損失のクラス重みの方式(`none`・`inverse`・`sqrt_inverse`). + sampler: 学習データの抜き出し方(`random`・`balanced`). + class_counts: 学習用データ(`data.severities` 適用後)のクラス別枚数(クラス ID 順). + class_weights: 損失のクラス重み(クラス ID 順).`class_weighting` が `none` なら None. + class_draw_probabilities: 1 回の抜き出しで各クラスが選ばれる確率(クラス ID 順). + `sampler` が `random` なら None. + """ + + class_weighting: str + sampler: str + class_counts: tuple[int, ...] + class_weights: tuple[float, ...] | None = None + class_draw_probabilities: tuple[float, ...] | None = None + + +@dataclass class FoldResult: """1 fold の学習・評価の結果. @@ -70,6 +98,7 @@ eval_seconds: held_out の評価の所要秒数. held_out_indices: `(M,)` の held_out 画像の manifest 上の位置(0 始まり). held_out_probs: `(M, C)` の held_out の予測確率(softmax). + class_balance: この fold の学習に使ったクラス不均衡の対策. """ index: int @@ -85,6 +114,7 @@ eval_seconds: float held_out_indices: np.ndarray held_out_probs: np.ndarray + class_balance: ClassBalance | None = None @property def held_out_pred(self) -> np.ndarray: @@ -136,7 +166,8 @@ 交差検証の結果. Raises: - ValueError: 分割できない,画像を読めない,学習用・検証用が空になる等の入力の問題. + ValueError: 分割できない,画像を読めない,学習用・検証用が空になる,対策を指定したのに + 学習用データに 1 枚も無いクラスがある等の入力の問題. """ records = list(records) device = select_device() @@ -204,11 +235,20 @@ val_idx = _positions(val_records, position) held_idx = _positions(held_records, position) + balance, class_weights, sample_weights = make_class_balance(labels[train_idx], config.train) + _log_class_balance(prefix, balance) + 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 + images[train_idx], + labels[train_idx], + config.train, + device, + train=True, + seed=seed, + sample_weights=sample_weights, ) val_loader = _make_loader( images[val_idx], labels[val_idx], config.train, device, train=False, seed=seed @@ -218,7 +258,13 @@ ) trained = train_model( - model, train_loader, val_loader, config.train, device, log_prefix=prefix + model, + train_loader, + val_loader, + config.train, + device, + log_prefix=prefix, + class_weights=None if class_weights is None else torch.from_numpy(class_weights), ) torch.save( { @@ -262,6 +308,7 @@ eval_seconds=eval_seconds, held_out_indices=held_idx, held_out_probs=probs, + class_balance=balance, ) ) del model, trained @@ -270,6 +317,66 @@ return result +def make_class_balance( + train_labels: np.ndarray, config: TrainConfig +) -> tuple[ClassBalance, np.ndarray | None, np.ndarray | None]: + """学習用データのラベルからクラス不均衡の対策(損失のクラス重み・抜き出し重み)を求める. + + Args: + train_labels: `(N,)` の整数配列(この fold の学習用データのクラス ID. + `data.severities` 適用後). + config: 学習の設定(`class_weighting`・`sampler` を使う). + + Returns: + (記録用の対策, `(NUM_CLASSES,)` の float64 の損失のクラス重み(`none` なら None), + `(N,)` の float64 の画像ごとの抜き出し重み(`random` なら None)). + + Raises: + ValueError: 対策を指定したのに学習用データに 1 枚も無いクラスがある場合. + """ + counts = count_classes(train_labels) + class_weights: np.ndarray | None = None + sample_weights: np.ndarray | None = None + draw_probs: np.ndarray | None = None + if config.class_weighting != "none": + class_weights = compute_class_weights(train_labels, config.class_weighting) + if config.sampler == "balanced": + sample_weights = balanced_sample_weights(train_labels) + draw_probs = class_draw_probabilities(train_labels, sample_weights) + balance = ClassBalance( + class_weighting=config.class_weighting, + sampler=config.sampler, + class_counts=tuple(int(n) for n in counts), + class_weights=None if class_weights is None else tuple(float(w) for w in class_weights), + class_draw_probabilities=( + None if draw_probs is None else tuple(float(p) for p in draw_probs) + ), + ) + return balance, class_weights, sample_weights + + +def _log_class_balance(prefix: str, balance: ClassBalance) -> None: + def by_class(values: Sequence[float]) -> dict[str, float]: + return {slug: round(v, 4) for slug, v in zip(CLASS_SLUGS, values, strict=True)} + + counts = dict(zip(CLASS_SLUGS, balance.class_counts, strict=True)) + logger.info( + "%s: 対策 class_weighting=%s・sampler=%s(学習のクラス別枚数 %s)", + prefix, + balance.class_weighting, + balance.sampler, + counts, + ) + if balance.class_weights is not None: + logger.info("%s: 損失のクラス重み %s", prefix, by_class(balance.class_weights)) + if balance.class_draw_probabilities is not None: + logger.info( + "%s: 1 回の抜き出しでクラスが選ばれる確率 %s", + prefix, + by_class(balance.class_draw_probabilities), + ) + + 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) @@ -283,14 +390,26 @@ *, train: bool, seed: int, + sample_weights: np.ndarray | None = None, ) -> DataLoader: dataset = TongueImageDataset(images, labels, augment=train) generator = torch.Generator() generator.manual_seed(seed) + sampler: WeightedRandomSampler | None = None + if sample_weights is not None: + # バランス・サンプリング: 重複ありで,1 エポックに学習画像数と同じ枚数を抜き出す. + # 抜き出しも seed から再現できるよう,DataLoader と同じ generator を渡す. + sampler = WeightedRandomSampler( + torch.as_tensor(sample_weights, dtype=torch.float64), + num_samples=len(dataset), + replacement=True, + generator=generator, + ) return DataLoader( dataset, batch_size=config.batch_size, - shuffle=train, + shuffle=train and sampler is None, + sampler=sampler, # BatchNorm を含むモデルで最後のバッチが 1 件になると学習が止まるため,学習時は端数を捨てる # (件数がバッチサイズ以下のときは捨てない). drop_last=train and len(dataset) > config.batch_size, diff --git a/src/tias_judge_model/training/loop.py b/src/tias_judge_model/training/loop.py index 0074708..ec9a7bb 100644 --- a/src/tias_judge_model/training/loop.py +++ b/src/tias_judge_model/training/loop.py @@ -122,6 +122,7 @@ *, log_prefix: str = "", on_epoch_end: Callable[[EpochRecord], None] | None = None, + class_weights: torch.Tensor | None = None, ) -> TrainResult: """モデルを学習し,検証 macro F1 が最良のエポックの重みを返す(早期終了あり). @@ -133,6 +134,9 @@ device: デバイス. log_prefix: ログの先頭に付ける文字列(例: `fold 1/5`). on_epoch_end: エポックごとに呼ぶ関数(任意). + class_weights: `(C,)` の float の損失のクラス重み(クラス ID 順.学習データでの平均が + 1 になるよう正規化済みのもの).None なら重みなし.**学習の損失にだけかけ**, + 検証の損失は重みなしで計算する(SPEC_02「クラス不均衡」→「対策の設定」). Returns: 学習の結果.`model` の重みは最良エポックのものに戻しておく. @@ -150,6 +154,7 @@ ) scaler = torch.amp.GradScaler("cuda", enabled=use_amp) criterion = nn.CrossEntropyLoss() + train_criterion = _make_train_criterion(class_weights, device) best_state: dict[str, torch.Tensor] | None = None best_epoch = 0 @@ -165,7 +170,7 @@ 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) + loss = train_criterion(model(inputs), targets) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() @@ -227,6 +232,24 @@ ) +def _make_train_criterion( + class_weights: torch.Tensor | None, device: torch.device +) -> Callable[[torch.Tensor, torch.Tensor], torch.Tensor]: + # 重みなしは従来と同じ損失(結果を変えないため,同じ nn.CrossEntropyLoss を使う). + if class_weights is None: + return nn.CrossEntropyLoss() + weighted = nn.CrossEntropyLoss( + weight=class_weights.to(device=device, dtype=torch.float32), reduction="none" + ) + + def criterion(logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor: + # nn.CrossEntropyLoss(weight=...) の既定の mean はバッチ内の重みの和で割るため,重みの + # 正規化(Σ (n_c / N)・w_c = 1)が効かない.件数で割り,損失の大きさを対策なしと揃える. + return weighted(logits, targets).mean() + + return criterion + + @torch.no_grad() def predict_logits( model: nn.Module, loader: DataLoader, device: torch.device, *, amp: bool diff --git a/tests/cli/test_train_imbalance.py b/tests/cli/test_train_imbalance.py new file mode 100644 index 0000000..a519d8c --- /dev/null +++ b/tests/cli/test_train_imbalance.py @@ -0,0 +1,284 @@ +"""tias-train cv のクラス不均衡の対策の結合テスト(SPEC_02「クラス不均衡」→「対策の設定」). + +- `metrics.json` に使った対策(トップレベル)と fold ごとのクラス重み(または抜き出し確率)が入る +- 既存のキー名・構造は変わらない(`scripts/summarize_cv.py` 等が依存する) +- 既定のままなら従来と同じ学習: 対策のキーを書かない設定と,既定値を明示した設定で指標が一致する +- 回収対象の出力(ログを含む)に患者 ID・ファイル名が出ない + +合成データ(scripts/make_synthetic_dataset.py)・resnet18・pretrained なし・CPU・1 エポックで回す. +""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import json +import logging +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.class_balance import compute_class_weights +from tias_judge_model.data.classes import CLASS_SLUGS, NUM_CLASSES +from tias_judge_model.data.manifest import build_manifest_records, write_manifest + +pytestmark = pytest.mark.slow + +SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "make_synthetic_dataset.py" +SECRET = "SECRETPT" +N_FOLDS = 5 +PER_DIR = 6 + +# 変更前(対策の導入前)の metrics.json のキー +LEGACY_TOP_KEYS = { + "run_id", + "git_commit", + "classes", + "severities_trained", + "subsets", + "n_folds", + "best_epoch", + "mean_std", + "pooled", + "confusion_matrix_sum", + "folds", + "timing", + "environment", +} +LEGACY_FOLD_KEYS = { + "fold", + "seed", + "best_epoch", + "best_val_macro_f1", + "counts", + "timing", + "subsets", +} + + +def _load_script() -> ModuleType: + spec = importlib.util.spec_from_file_location("make_synthetic_dataset_train_imb", 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 _train(**extra: str) -> dict: + return { + "image_size": 32, + "batch_size": 8, + "max_epochs": 2, + "warmup_epochs": 1, + "inner_val_ratio": 0.2, + "num_workers": 0, + "amp": False, + **extra, + } + + +# 設定名 → (severities, train の追加キー) +RUNS = { + "cv_default": (["strong", "mild", "normal"], {}), + "cv_explicit_default": ( + ["strong", "mild", "normal"], + {"class_weighting": "none", "sampler": "random"}, + ), + "cv_cwsqrt_nomild": (["strong", "normal"], {"class_weighting": "sqrt_inverse"}), + "cv_cwinv": (["strong", "mild", "normal"], {"class_weighting": "inverse"}), + "cv_balanced": (["strong", "mild", "normal"], {"sampler": "balanced"}), +} + + +@pytest.fixture(scope="module") +def runs(tmp_path_factory: pytest.TempPathFactory) -> dict[str, dict]: + tmp = tmp_path_factory.mktemp("train_cli_imb") + 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") + + out: dict[str, dict] = {} + mp = pytest.MonkeyPatch() + mp.delenv("TIAS_DATA_ROOT", raising=False) + mp.setenv("TIAS_GIT_COMMIT", "abc1234") + root_logger = logging.getLogger() + old_level = root_logger.level + root_logger.setLevel(logging.INFO) + try: + for name, (severities, extra) in RUNS.items(): + config = tmp / f"{name}.yaml" + config.write_text( + yaml.safe_dump( + { + "data": {"root": data_root.as_posix(), "severities": severities}, + "split": {"seed": 3, "n_folds": N_FOLDS}, + "model": {"name": "resnet18", "pretrained": False}, + "train": _train(**extra), + } + ), + encoding="utf-8", + ) + out_root = tmp / f"runs_{name}" + stdout, stderr = io.StringIO(), io.StringIO() + handler = logging.StreamHandler(stderr) + root_logger.addHandler(handler) + try: + with contextlib.redirect_stdout(stdout): + rc = cli.main(["cv", "--config", str(config), "--out-root", str(out_root)]) + finally: + root_logger.removeHandler(handler) + run_dir = next(p for p in out_root.iterdir() if p.is_dir()) + out[name] = { + "rc": rc, + "dir": run_dir, + "stdout": stdout.getvalue(), + "stderr": stderr.getvalue(), + "metrics": json.loads((run_dir / "metrics.json").read_text(encoding="utf-8")), + } + finally: + root_logger.setLevel(old_level) + mp.undo() + return out + + +def test_all_runs_succeed(runs: dict) -> None: + assert all(r["rc"] == 0 for r in runs.values()) + + +@pytest.mark.parametrize("name", list(RUNS)) +def test_legacy_keys_are_kept(runs: dict, name: str) -> None: + m = runs[name]["metrics"] + assert set(m) == LEGACY_TOP_KEYS | {"imbalance"} + for fold in m["folds"]: + assert set(fold) == LEGACY_FOLD_KEYS | {"class_balance"} + # summarize_cv.py が読むキー + assert m["environment"]["model_name"] == "resnet18" + assert isinstance(m["severities_trained"], list) + assert "light_red" in m["pooled"]["mild"]["prediction_breakdown"]["overall"] + assert m["timing"]["total_seconds"] > 0 + for fold in m["folds"]: + for subset in ("all", "common", "mild"): + assert {"macro_f1", "accuracy", "per_class"} <= set(fold["subsets"][subset]) + + +@pytest.mark.parametrize( + ("name", "weighting", "sampler"), + [ + ("cv_default", "none", "random"), + ("cv_explicit_default", "none", "random"), + ("cv_cwsqrt_nomild", "sqrt_inverse", "random"), + ("cv_cwinv", "inverse", "random"), + ("cv_balanced", "none", "balanced"), + ], +) +def test_top_level_records_countermeasure( + runs: dict, name: str, weighting: str, sampler: str +) -> None: + imb = runs[name]["metrics"]["imbalance"] + assert imb["class_weighting"] == weighting + assert imb["sampler"] == sampler + + +def _counts(entry: dict) -> list[int]: + return [entry["class_counts"][s] for s in CLASS_SLUGS] + + +@pytest.mark.parametrize( + ("name", "method"), [("cv_cwsqrt_nomild", "sqrt_inverse"), ("cv_cwinv", "inverse")] +) +def test_fold_class_weights_recorded(runs: dict, name: str, method: str) -> None: + for fold in runs[name]["metrics"]["folds"]: + cb = fold["class_balance"] + assert cb["class_weighting"] == method + assert list(cb["class_weights"]) == list(CLASS_SLUGS) + assert cb["class_draw_probabilities"] is None + counts = _counts(cb) + expected = compute_class_weights(np.repeat(np.arange(NUM_CLASSES), counts), method) + got = np.array([cb["class_weights"][s] for s in CLASS_SLUGS]) + np.testing.assert_allclose(got, expected, atol=1e-5) + # 学習用データ(data.severities 適用後)のクラス別枚数 + groups = fold["counts"]["images_by_group"]["train"] + for slug in CLASS_SLUGS: + assert cb["class_counts"][slug] == sum( + n for g, n in groups.items() if g.rsplit("_", 1)[0] == slug + ) + + +def test_without_mild_counts_exclude_mild(runs: dict) -> None: + for fold in runs["cv_cwsqrt_nomild"]["metrics"]["folds"]: + groups = fold["counts"]["images_by_group"]["train"] + assert sum(n for g, n in groups.items() if g.endswith("_mild")) == 0 + assert sum(fold["class_balance"]["class_counts"].values()) == sum(groups.values()) + + +def test_fold_draw_probabilities_recorded(runs: dict) -> None: + for fold in runs["cv_balanced"]["metrics"]["folds"]: + cb = fold["class_balance"] + assert cb["sampler"] == "balanced" + assert cb["class_weights"] is None + probs = np.array([cb["class_draw_probabilities"][s] for s in CLASS_SLUGS]) + np.testing.assert_allclose(probs, 1 / NUM_CLASSES, atol=1e-5) + + +def test_default_run_records_no_weights(runs: dict) -> None: + for fold in runs["cv_default"]["metrics"]["folds"]: + cb = fold["class_balance"] + # 記録が無い(None)か,対策なしであることが分かる形 + if cb is None: + continue + assert cb["class_weighting"] == "none" + assert cb["sampler"] == "random" + assert cb["class_weights"] is None + assert cb["class_draw_probabilities"] is None + + +def _strip_volatile(m: dict) -> dict: + m = json.loads(json.dumps(m)) + for key in ("run_id", "timing"): + m.pop(key) + for fold in m["folds"]: + fold.pop("timing") + return m + + +def test_explicit_defaults_give_same_results_as_omitted_keys(runs: dict) -> None: + a = _strip_volatile(runs["cv_default"]["metrics"]) + b = _strip_volatile(runs["cv_explicit_default"]["metrics"]) + assert a == b + ha = (runs["cv_default"]["dir"] / "history.csv").read_text(encoding="utf-8").splitlines() + hb = (runs["cv_explicit_default"]["dir"] / "history.csv").read_text(encoding="utf-8") + # 所要秒数の列を除けば学習の経過も一致する + header = ha[0].split(",") + keep = [i for i, c in enumerate(header) if "second" not in c] + rows_a = [[r.split(",")[i] for i in keep] for r in ha] + rows_b = [[r.split(",")[i] for i in keep] for r in hb.splitlines()] + assert rows_a == rows_b + + +def test_countermeasure_changes_training(runs: dict) -> None: + # 対策を入れると学習損失の経過が変わる(対策が実際に効いていること) + def train_losses(name: str) -> list[str]: + text = (runs[name]["dir"] / "history.csv").read_text(encoding="utf-8").splitlines() + col = text[0].split(",").index("train_loss") + return [r.split(",")[col] for r in text[1:]] + + assert train_losses("cv_cwinv") != train_losses("cv_default") + assert train_losses("cv_balanced") != train_losses("cv_default") + + +@pytest.mark.parametrize("name", list(RUNS)) +def test_outputs_have_no_patient_ids(runs: dict, name: str) -> None: + r = runs[name] + for fname in ("metrics.json", "log.txt", "history.csv", "config.yaml"): + assert SECRET not in (r["dir"] / fname).read_text(encoding="utf-8"), fname + assert SECRET not in r["stdout"] + assert SECRET not in r["stderr"] diff --git a/tests/data/test_class_balance.py b/tests/data/test_class_balance.py new file mode 100644 index 0000000..7fa17d9 --- /dev/null +++ b/tests/data/test_class_balance.py @@ -0,0 +1,226 @@ +"""クラス不均衡の対策の計算のテスト(SPEC_02「クラス不均衡」→「対策の設定」). + +- `inverse` は w_c ∝ N / (K・n_c),`sqrt_inverse` はその平方根に比例する +- 重みは Σ_c (n_c / N)・w_c = 1 に正規化される(学習データでの平均が 1) +- クラス ID 順(淡白・淡紅・紅・暗赤紅・紫)で,少ないクラスほど重みが大きい +- 学習データに 1 枚も無いクラスがあれば停止する(メッセージに個人情報を出さない) +- バランス・サンプリングの抜き出し重みは 1 / n_c に比例し,各クラスがほぼ等確率で選ばれる +- 対策の組み立て(`make_class_balance`)は渡された学習ラベルだけから枚数を数える +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from tias_judge_model.data.class_balance import ( + CLASS_WEIGHTING_METHODS, + balanced_sample_weights, + class_draw_probabilities, + compute_class_weights, + count_classes, +) +from tias_judge_model.data.classes import CLASS_SLUGS, NUM_CLASSES +from tias_judge_model.schema import TrainConfig +from tias_judge_model.training.cross_validation import make_class_balance + +# 実データに近い不均衡(最大 / 最小 ≒ 3.7 倍).クラス ID 順. +COUNTS = np.array([104, 380, 250, 200, 151]) + + +def _labels(counts: np.ndarray, *, shuffle_seed: int | None = 0) -> np.ndarray: + labels = np.repeat(np.arange(NUM_CLASSES), counts) + if shuffle_seed is not None: + np.random.default_rng(shuffle_seed).shuffle(labels) + return labels + + +def _expected_weights(counts: np.ndarray, method: str) -> np.ndarray: + n = counts.astype(np.float64) + total = n.sum() + raw = total / (NUM_CLASSES * n) + if method == "sqrt_inverse": + raw = np.sqrt(raw) + return raw / np.sum(n / total * raw) + + +def test_class_order_is_fixed() -> None: + assert CLASS_SLUGS == ("pale", "light_red", "red", "dark_red", "purple") + + +def test_weighting_methods_are_the_spec_values() -> None: + assert set(CLASS_WEIGHTING_METHODS) == {"inverse", "sqrt_inverse"} + + +def test_count_classes_in_class_id_order() -> None: + counts = count_classes(_labels(COUNTS)) + assert counts.tolist() == COUNTS.tolist() + + +class TestComputeClassWeights: + @pytest.mark.parametrize("method", ["inverse", "sqrt_inverse"]) + def test_matches_formula(self, method: str) -> None: + w = compute_class_weights(_labels(COUNTS), method) + assert w.shape == (NUM_CLASSES,) + np.testing.assert_allclose(w, _expected_weights(COUNTS, method), rtol=1e-12) + + def test_inverse_proportional_to_inverse_count(self) -> None: + w = compute_class_weights(_labels(COUNTS), "inverse") + # w_c・n_c が全クラスで一定(w_c ∝ 1 / n_c) + prod = w * COUNTS + np.testing.assert_allclose(prod, prod[0], rtol=1e-12) + + def test_sqrt_inverse_is_proportional_to_sqrt_of_inverse(self) -> None: + inv = compute_class_weights(_labels(COUNTS), "inverse") + sq = compute_class_weights(_labels(COUNTS), "sqrt_inverse") + ratio = sq / np.sqrt(inv) + np.testing.assert_allclose(ratio, ratio[0], rtol=1e-12) + + @pytest.mark.parametrize("method", ["inverse", "sqrt_inverse"]) + def test_normalized_so_training_mean_is_one(self, method: str) -> None: + w = compute_class_weights(_labels(COUNTS), method) + n = COUNTS.astype(np.float64) + assert float(np.sum(n / n.sum() * w)) == pytest.approx(1.0, abs=1e-12) + # 画像ごとの重みの平均としても 1 + labels = _labels(COUNTS) + assert float(w[labels].mean()) == pytest.approx(1.0, abs=1e-12) + + def test_inverse_known_values(self) -> None: + # n = [1, 3] ではなく 5 クラスで手計算: n = [10, 20, 20, 25, 25](N = 100) + counts = np.array([10, 20, 20, 25, 25]) + w = compute_class_weights(_labels(counts), "inverse") + # 正規化後は w_c = 1 / (K・p_c)(Σ p_c・w_c = K・(1/K) = 1 がそのまま成り立つ) + expected = 100 / (5 * counts) + np.testing.assert_allclose(w, expected, rtol=1e-12) + assert w.tolist() == pytest.approx([2.0, 1.0, 1.0, 0.8, 0.8]) + + @pytest.mark.parametrize("method", ["inverse", "sqrt_inverse"]) + def test_rarer_class_gets_larger_weight(self, method: str) -> None: + w = compute_class_weights(_labels(COUNTS), method) + order_by_count = np.argsort(COUNTS) # 少ない順 + assert np.all(np.diff(w[order_by_count]) < 0) + assert int(np.argmax(w)) == 0 # pale(104 枚)が最小 + assert int(np.argmin(w)) == 1 # light_red(380 枚)が最大 + + @pytest.mark.parametrize("method", ["inverse", "sqrt_inverse"]) + def test_equal_counts_give_unit_weights(self, method: str) -> None: + w = compute_class_weights(_labels(np.array([7] * NUM_CLASSES)), method) + np.testing.assert_allclose(w, 1.0, rtol=1e-12) + + def test_sqrt_inverse_is_milder_than_inverse(self) -> None: + inv = compute_class_weights(_labels(COUNTS), "inverse") + sq = compute_class_weights(_labels(COUNTS), "sqrt_inverse") + assert sq.max() / sq.min() < inv.max() / inv.min() + assert sq.max() / sq.min() == pytest.approx(np.sqrt(inv.max() / inv.min())) + + @pytest.mark.parametrize("method", ["inverse", "sqrt_inverse"]) + def test_does_not_depend_on_label_order(self, method: str) -> None: + a = compute_class_weights(_labels(COUNTS, shuffle_seed=None), method) + b = compute_class_weights(_labels(COUNTS, shuffle_seed=1), method) + np.testing.assert_allclose(a, b, rtol=1e-12) + + @pytest.mark.parametrize("method", ["inverse", "sqrt_inverse"]) + @pytest.mark.parametrize("missing", range(NUM_CLASSES)) + def test_missing_class_raises(self, method: str, missing: int) -> None: + counts = COUNTS.copy() + counts[missing] = 0 + with pytest.raises(ValueError): + compute_class_weights(_labels(counts), method) + + @pytest.mark.parametrize("method", ["none", "balanced", "INVERSE", ""]) + def test_unknown_method_raises(self, method: str) -> None: + with pytest.raises(ValueError): + compute_class_weights(_labels(COUNTS), method) + + +class TestBalancedSampling: + def test_sample_weight_proportional_to_inverse_class_count(self) -> None: + labels = _labels(COUNTS) + sw = balanced_sample_weights(labels) + assert sw.shape == labels.shape + # 各画像の重み × その画像のクラスの枚数 が一定(∝ 1 / n_c) + prod = sw * COUNTS[labels] + np.testing.assert_allclose(prod, prod[0], rtol=1e-12) + + def test_each_class_equally_likely_per_draw(self) -> None: + labels = _labels(COUNTS) + probs = class_draw_probabilities(labels, balanced_sample_weights(labels)) + assert probs.shape == (NUM_CLASSES,) + np.testing.assert_allclose(probs, 1.0 / NUM_CLASSES, rtol=1e-12) + + def test_uniform_weights_give_class_frequencies(self) -> None: + labels = _labels(COUNTS) + probs = class_draw_probabilities(labels, np.ones(len(labels))) + np.testing.assert_allclose(probs, COUNTS / COUNTS.sum(), rtol=1e-12) + + @pytest.mark.parametrize("missing", range(NUM_CLASSES)) + def test_missing_class_raises(self, missing: int) -> None: + counts = COUNTS.copy() + counts[missing] = 0 + with pytest.raises(ValueError): + balanced_sample_weights(_labels(counts)) + + +def test_missing_class_message_has_no_personal_info() -> None: + # メッセージにファイル名・患者 ID が入らないこと + counts = COUNTS.copy() + counts[4] = 0 + with pytest.raises(ValueError) as exc: + compute_class_weights(_labels(counts), "inverse") + msg = str(exc.value) + assert msg + assert ".png" not in msg + assert "patient" not in msg.lower() + + +class TestMakeClassBalance: + def _config(self, **train: str) -> TrainConfig: + return TrainConfig.model_validate(train) + + def test_defaults_have_no_countermeasure(self) -> None: + labels = _labels(COUNTS) + balance, class_weights, sample_weights = make_class_balance(labels, self._config()) + assert class_weights is None + assert sample_weights is None + assert balance.class_weighting == "none" + assert balance.sampler == "random" + assert list(balance.class_counts) == COUNTS.tolist() + assert balance.class_weights is None + assert balance.class_draw_probabilities is None + + def test_defaults_do_not_stop_on_missing_class(self) -> None: + # 対策なしは従来どおりの学習(重みが要らないため停止しない) + counts = COUNTS.copy() + counts[4] = 0 + balance, cw, sw = make_class_balance(_labels(counts), self._config()) + assert cw is None and sw is None + assert balance.class_counts[4] == 0 + + @pytest.mark.parametrize("method", ["inverse", "sqrt_inverse"]) + def test_class_weighting(self, method: str) -> None: + labels = _labels(COUNTS) + balance, cw, sw = make_class_balance(labels, self._config(class_weighting=method)) + assert sw is None + assert cw is not None + np.testing.assert_allclose(cw, _expected_weights(COUNTS, method), rtol=1e-12) + assert balance.class_weighting == method + np.testing.assert_allclose(balance.class_weights, cw, rtol=1e-12) + assert balance.class_draw_probabilities is None + + def test_balanced_sampler(self) -> None: + labels = _labels(COUNTS) + balance, cw, sw = make_class_balance(labels, self._config(sampler="balanced")) + assert cw is None + assert sw is not None and sw.shape == labels.shape + assert balance.sampler == "balanced" + assert balance.class_weights is None + np.testing.assert_allclose(balance.class_draw_probabilities, 1 / NUM_CLASSES, rtol=1e-12) + + @pytest.mark.parametrize( + "train", [{"class_weighting": "inverse"}, {"sampler": "balanced"}], ids=str + ) + def test_missing_class_with_countermeasure_raises(self, train: dict) -> None: + counts = COUNTS.copy() + counts[0] = 0 + with pytest.raises(ValueError): + make_class_balance(_labels(counts), self._config(**train)) diff --git a/tests/test_training_config.py b/tests/test_training_config.py index 7ffc451..9ed8585 100644 --- a/tests/test_training_config.py +++ b/tests/test_training_config.py @@ -8,9 +8,11 @@ from __future__ import annotations +import re from pathlib import Path import pytest +import yaml from pydantic import ValidationError from tias_judge_model.schema import ExperimentConfig, load_config @@ -124,3 +126,109 @@ def test_model_comparison_configs_are_present() -> None: assert len(_model_comparison_pairs()) >= 2 + + +# --- クラス不均衡の対策(SPEC_02「クラス不均衡」→「対策の設定」) --- + +IMBALANCE_CONFIGS = sorted((CONFIGS_DIR / "imbalance").glob("*.yaml")) +REPO_ROOT = CONFIGS_DIR.parent +# 設定ファイル名の末尾と,対策のキー・値の対応 +IMBALANCE_SUFFIXES = { + "_cwinv": ("class_weighting", "inverse"), + "_cwsqrt": ("class_weighting", "sqrt_inverse"), + "_balanced": ("sampler", "balanced"), +} + + +def test_imbalance_defaults_are_no_countermeasure() -> None: + t = ExperimentConfig.model_validate(_minimal()).train + assert t.class_weighting == "none" + assert t.sampler == "random" + + +@pytest.mark.parametrize( + "train", + [ + {"class_weighting": "none"}, + {"class_weighting": "inverse"}, + {"class_weighting": "sqrt_inverse"}, + {"sampler": "random"}, + {"sampler": "balanced"}, + {"class_weighting": "inverse", "sampler": "balanced"}, + ], + ids=str, +) +def test_valid_imbalance_settings_are_accepted(train: dict) -> None: + t = ExperimentConfig.model_validate({**_minimal(), "train": train}).train + for key, value in train.items(): + assert getattr(t, key) == value + + +@pytest.mark.parametrize( + "train", + [ + {"class_weighting": "balanced"}, + {"class_weighting": "Inverse"}, + {"class_weighting": "focal"}, + {"class_weighting": ""}, + {"class_weighting": None}, + {"class_weighting": True}, + {"sampler": "weighted"}, + {"sampler": "inverse"}, + {"sampler": "Balanced"}, + {"sampler": None}, + {"class_weights": "inverse"}, + ], + ids=str, +) +def test_invalid_imbalance_settings_are_rejected(train: dict) -> None: + with pytest.raises(ValidationError): + ExperimentConfig.model_validate({**_minimal(), "train": train}) + + +def _imbalance_base(path: Path) -> tuple[str, Path]: + # 先頭行のコメントの最後の「(<キー> 以外は <元設定> と同一」が,この設定の元 + first = path.read_text(encoding="utf-8").splitlines()[0] + found = re.findall(r"((\w+) 以外は (configs/[\w./-]+\.yaml) と同一", first) + assert found, f"元設定の記載が無い: {path.name}" + key, base = found[-1] + return key, REPO_ROOT / base + + +def test_imbalance_configs_count() -> None: + assert len(IMBALANCE_CONFIGS) == 36 + + +@pytest.mark.parametrize("path", IMBALANCE_CONFIGS, ids=lambda p: p.name) +def test_imbalance_config_differs_from_base_only_in_countermeasure(path: Path) -> None: + suffix = next(s for s in IMBALANCE_SUFFIXES if path.stem.endswith(s)) + key, value = IMBALANCE_SUFFIXES[suffix] + comment_key, base = _imbalance_base(path) + assert comment_key == key + assert base.is_file(), base + v = load_config(path, environ=NO_ENV).model_dump() + b = load_config(base, environ=NO_ENV).model_dump() + assert v["train"][key] == value + # 元設定は対策なし + assert b["train"]["class_weighting"] == "none" + assert b["train"]["sampler"] == "random" + v["train"].pop(key) + b["train"].pop(key) + assert v == b + # 生の YAML でも,元設定との違いは対策のキー 1 つだけ + raw_v = yaml.safe_load(path.read_text(encoding="utf-8")) + raw_b = yaml.safe_load(base.read_text(encoding="utf-8")) + assert raw_v["train"].pop(key) == value + assert raw_v == raw_b + + +def test_imbalance_configs_cover_each_base_with_three_countermeasures() -> None: + by_base: dict[Path, set[str]] = {} + for path in IMBALANCE_CONFIGS: + suffix = next(s for s in IMBALANCE_SUFFIXES if path.stem.endswith(s)) + _, base = _imbalance_base(path) + by_base.setdefault(base.resolve(), set()).add(suffix) + # 設定名は「元設定名 + 対策の接尾辞」 + assert path.stem == base.stem + suffix + assert len(by_base) == 12 + assert all(s == set(IMBALANCE_SUFFIXES) for s in by_base.values()) diff --git a/tests/training/test_cross_validation_imbalance.py b/tests/training/test_cross_validation_imbalance.py new file mode 100644 index 0000000..05724c5 --- /dev/null +++ b/tests/training/test_cross_validation_imbalance.py @@ -0,0 +1,254 @@ +"""交差検証でのクラス不均衡の対策のテスト(SPEC_02「クラス不均衡」→「対策の設定」). + +- クラスの枚数 n_c は各 fold の**学習用データ(`data.severities` 適用後)**から数える + (検証用・held_out は使わない).「ややあり除外」なら mild を除いた枚数で重みが決まる +- バランス・サンプリングは各画像を 1 / n_c に比例する確率で重複ありで抜き出し,1 回の抜き出しで + 各クラスがほぼ等確率.1 エポックの枚数は学習画像数と同じ.同じ seed で同じ抜き出し順 +- 既定(`random`)はシャッフルで,各画像を 1 エポックに 1 回ずつ使う +""" + +from __future__ import annotations + +import importlib.util +import sys +from collections import Counter +from pathlib import Path +from types import ModuleType + +import numpy as np +import pytest +import torch + +from tias_judge_model.data.class_balance import balanced_sample_weights, compute_class_weights +from tias_judge_model.data.classes import CLASS_SLUGS, NUM_CLASSES +from tias_judge_model.data.manifest import read_manifest +from tias_judge_model.schema import ExperimentConfig, TrainConfig +from tias_judge_model.training.cross_validation import ( + CrossValidationResult, + _make_loader, + run_cross_validation, +) + +SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "make_synthetic_dataset.py" +N_FOLDS = 5 +CPU = torch.device("cpu") + +# 不均衡な学習データ(クラス ID 順) +COUNTS = np.array([4, 24, 12, 8, 6]) # N = 54 + + +def _labels() -> np.ndarray: + labels = np.repeat(np.arange(NUM_CLASSES), COUNTS) + np.random.default_rng(0).shuffle(labels) + return labels + + +def _drawn_labels(loader) -> list[int]: + return [int(y) for _, ys in loader for y in ys] + + +def _balanced_loader(seed: int, labels: np.ndarray): + + images = np.zeros((len(labels), 16, 16, 3), dtype=np.uint8) + config = TrainConfig.model_validate( + {"batch_size": 128, "num_workers": 0, "sampler": "balanced", "image_size": 16} + ) + return _make_loader( + images, + labels, + config, + CPU, + train=True, + seed=seed, + sample_weights=balanced_sample_weights(labels), + ) + + +class TestBalancedLoader: + def test_epoch_size_equals_number_of_training_images(self) -> None: + labels = _labels() + drawn = _drawn_labels(_balanced_loader(0, labels)) + assert len(drawn) == len(labels) + + def test_draws_with_replacement(self) -> None: + # 4 枚しか無い pale も約 54 / 5 ≒ 11 回抜き出される → 重複が必要 + labels = _labels() + loader = _balanced_loader(0, labels) + counts = Counter() + for _ in range(5): + counts.update(_drawn_labels(loader)) + assert counts[0] > 5 * COUNTS[0] + + def test_each_class_drawn_about_equally(self) -> None: + labels = _labels() + loader = _balanced_loader(0, labels) + drawn: list[int] = [] + for _ in range(40): + drawn.extend(_drawn_labels(loader)) + freq = np.bincount(drawn, minlength=NUM_CLASSES) / len(drawn) + # 抜き出し 2160 回.一様(0.2)から ±0.04 以内(二項分布の標準偏差 ≒ 0.0086) + np.testing.assert_allclose(freq, 1 / NUM_CLASSES, atol=0.04) + # 元の比率(light_red 0.44・pale 0.07)とは明確に違う + assert freq[1] < 0.3 + assert freq[0] > 0.12 + + def test_same_seed_gives_same_draw_order(self) -> None: + labels = _labels() + a = [_drawn_labels(loader) for loader in [_balanced_loader(5, labels)] for _ in range(3)] + b = [_drawn_labels(loader) for loader in [_balanced_loader(5, labels)] for _ in range(3)] + assert a == b + + def test_different_seed_gives_different_draw_order(self) -> None: + labels = _labels() + assert _drawn_labels(_balanced_loader(1, labels)) != _drawn_labels( + _balanced_loader(2, labels) + ) + + +def test_random_sampler_uses_each_image_once_per_epoch() -> None: + labels = _labels() + images = np.zeros((len(labels), 16, 16, 3), dtype=np.uint8) + config = TrainConfig.model_validate({"batch_size": 128, "num_workers": 0, "image_size": 16}) + loader = _make_loader(images, labels, config, CPU, train=True, seed=0) + drawn = _drawn_labels(loader) + assert sorted(drawn) == sorted(labels.tolist()) + + +# --- 交差検証を実際に回す(slow) --- + + +def _load_script() -> ModuleType: + spec = importlib.util.spec_from_file_location("make_synthetic_dataset_cv_imb", 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], **train: 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, + **train, + }, + } + ) + + +@pytest.fixture(scope="module") +def imbalance_runs( + tmp_path_factory: pytest.TempPathFactory, +) -> tuple[CrossValidationResult, CrossValidationResult]: + root = tmp_path_factory.mktemp("cv_imb") / "dataset" + _load_script().generate(root, per_dir=6, seed=0) + records = read_manifest(root / "manifest.csv") + weighted_without_mild = run_cross_validation( + _config(root, ["strong", "normal"], class_weighting="inverse"), + records, + root.parent / "ck_w", + ) + balanced_with_mild = run_cross_validation( + _config(root, ["strong", "mild", "normal"], sampler="balanced"), + records, + root.parent / "ck_b", + ) + return weighted_without_mild, balanced_with_mild + + +def _train_counts_by_class(group_counts: dict[str, int]) -> list[int]: + out = [0] * NUM_CLASSES + for group, n in group_counts.items(): + label = next(s for s in sorted(CLASS_SLUGS, key=len, reverse=True) if group.startswith(s)) + out[CLASS_SLUGS.index(label)] += n + return out + + +@pytest.mark.slow +def test_class_counts_come_from_training_data_after_severities(imbalance_runs) -> None: + weighted, balanced = imbalance_runs + for result, mild_expected in ((weighted, False), (balanced, True)): + for f in result.folds: + b = f.class_balance + assert b is not None + train_groups = f.group_counts["train"] + assert list(b.class_counts) == _train_counts_by_class(train_groups) + has_mild = sum(n for g, n in train_groups.items() if g.endswith("_mild")) > 0 + assert has_mild is mild_expected + # 検証用・held_out を含めた枚数ではない + with_val = [ + a + c + for a, c in zip( + _train_counts_by_class(train_groups), + _train_counts_by_class(f.group_counts["val"]), + strict=True, + ) + ] + assert list(b.class_counts) != with_val + + +@pytest.mark.slow +def test_weights_follow_formula_on_fold_training_counts(imbalance_runs) -> None: + weighted, _ = imbalance_runs + for f in weighted.folds: + b = f.class_balance + assert b.class_weighting == "inverse" + assert b.sampler == "random" + assert b.class_draw_probabilities is None + labels = np.repeat(np.arange(NUM_CLASSES), b.class_counts) + np.testing.assert_allclose( + b.class_weights, compute_class_weights(labels, "inverse"), rtol=1e-9 + ) + n = np.array(b.class_counts, dtype=float) + assert float(np.sum(n / n.sum() * np.array(b.class_weights))) == pytest.approx(1.0) + + +@pytest.mark.slow +def test_without_mild_weights_use_counts_excluding_mild(imbalance_runs) -> None: + # 分割は data.severities に関わらず同じなので,ややあり込みの run の同じ fold の学習用枚数から + # mild を引いたものが,ややあり除外の学習用枚数になる + weighted, balanced = imbalance_runs + for fw, fb in zip(weighted.folds, balanced.folds, strict=True): + inclusive = _train_counts_by_class(fb.group_counts["train"]) + mild = _train_counts_by_class( + {g: n for g, n in fb.group_counts["train"].items() if g.endswith("_mild")} + ) + excluding = [a - m for a, m in zip(inclusive, mild, strict=True)] + assert list(fw.class_balance.class_counts) == excluding + assert list(fb.class_balance.class_counts) == inclusive + # 合成データは各ディレクトリ同数なので,mild 込みなら淡紅(normal のみ)の重みが最大 + # になるが, + # mild を除くと淡紅も他と同程度になる.重みは mild を除いた枚数の式に従う(上のテスト) + w_inclusive = compute_class_weights(np.repeat(np.arange(NUM_CLASSES), inclusive), "inverse") + assert int(np.argmax(w_inclusive)) == CLASS_SLUGS.index("light_red") + assert not np.allclose(fw.class_balance.class_weights, w_inclusive) + + +@pytest.mark.slow +def test_balanced_sampler_records_equal_draw_probabilities(imbalance_runs) -> None: + _, balanced = imbalance_runs + for f in balanced.folds: + b = f.class_balance + assert b.sampler == "balanced" + assert b.class_weighting == "none" + assert b.class_weights is None + np.testing.assert_allclose(b.class_draw_probabilities, 1 / NUM_CLASSES, rtol=1e-9) + + +@pytest.mark.slow +def test_training_runs_with_countermeasures(imbalance_runs) -> None: + for result in imbalance_runs: + assert len(result.folds) == N_FOLDS + for f in result.folds: + assert np.isfinite(f.history[0].train_loss) + assert np.isfinite(f.history[0].val_loss) diff --git a/tests/training/test_loop_class_weights.py b/tests/training/test_loop_class_weights.py new file mode 100644 index 0000000..fe00fb6 --- /dev/null +++ b/tests/training/test_loop_class_weights.py @@ -0,0 +1,151 @@ +"""損失のクラス重みのテスト(SPEC_02「クラス不均衡」→「対策の設定」). + +- 重みは**学習の損失にだけ**かかり,検証の損失は重みなし +- 正規化(Σ_c (n_c / N)・w_c = 1)により,重み付きでも損失の大きさの尺度が対策なしと揃う + (全クラス同数なら重み付きと重みなしの損失が一致する) +- 重み付きの学習損失は Σ_i w_{y_i}・CE_i / B(件数で割る).重みの和で割ると正規化の意味が無くなる + +出力が入力に依らない固定ロジットのモデルで,損失の値そのものを手計算と比べる +(重みの勾配が 0 なので学習しても値が変わらない). +""" + +from __future__ import annotations + +import sys + +import numpy as np +import pytest +import torch +from torch import nn +from torch.utils.data import DataLoader, TensorDataset + +from tias_judge_model.data.class_balance import compute_class_weights +from tias_judge_model.data.classes import NUM_CLASSES +from tias_judge_model.schema import TrainConfig +from tias_judge_model.training.loop import train_model + +CPU = torch.device("cpu") +# 固定ロジット(クラスごとに CE が異なるように非一様にする) +LOGITS = torch.tensor([2.0, 0.5, -1.0, 0.0, 1.0]) + + +class _ConstLogits(nn.Module): + def __init__(self) -> None: + super().__init__() + self.dummy = nn.Parameter(torch.zeros(NUM_CLASSES)) + self.register_buffer("logits", LOGITS.clone()) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.logits.expand(x.shape[0], -1) + 0.0 * self.dummy + + +def _loader(labels: np.ndarray) -> DataLoader: + images = torch.zeros((len(labels), 3, 8, 8), dtype=torch.uint8) + ds = TensorDataset(images, torch.as_tensor(labels, dtype=torch.int64)) + # 1 バッチに全件を入れる(バッチ平均 = 学習データ全体の平均) + return DataLoader(ds, batch_size=len(labels), shuffle=False) + + +def _config() -> TrainConfig: + return TrainConfig.model_validate( + { + "max_epochs": 1, + "warmup_epochs": 0, + "weight_decay": 0.0, + "num_workers": 0, + "amp": False, + "batch_size": 1024, + } + ) + + +def _ce_per_class() -> np.ndarray: + return (-torch.log_softmax(LOGITS, dim=0)).numpy().astype(np.float64) + + +def _run(labels: np.ndarray, class_weights: torch.Tensor | None) -> tuple[float, float]: + torch.manual_seed(0) + result = train_model( + _ConstLogits(), + _loader(labels), + _loader(labels), + _config(), + CPU, + class_weights=class_weights, + ) + rec = result.history[0] + return rec.train_loss, rec.val_loss + + +def _labels(counts: list[int]) -> np.ndarray: + return np.repeat(np.arange(NUM_CLASSES), counts) + + +IMBALANCED = [2, 12, 6, 4, 3] + + +def test_unweighted_losses_are_plain_cross_entropy() -> None: + labels = _labels(IMBALANCED) + train_loss, val_loss = _run(labels, None) + expected = float(_ce_per_class()[labels].mean()) + assert train_loss == pytest.approx(expected, rel=1e-5) + assert val_loss == pytest.approx(expected, rel=1e-5) + + +@pytest.mark.parametrize("method", ["inverse", "sqrt_inverse"]) +def test_weighted_train_loss_on_full_training_data(method: str) -> None: + labels = _labels(IMBALANCED) + w = compute_class_weights(labels, method) + train_loss, _ = _run(labels, torch.from_numpy(w)) + ce = _ce_per_class()[labels] + expected = float(np.sum(w[labels] * ce) / len(labels)) + assert train_loss == pytest.approx(expected, rel=1e-5) + # 少ないクラスが重く効くので,重みなしとは値が変わる + assert train_loss != pytest.approx(float(ce.mean()), rel=1e-3) + + +def test_weighted_train_loss_divides_by_batch_count_not_weight_sum() -> None: + # バッチ内の重みの和が件数と異なる場合(ミニバッチのクラス比が学習データ全体と違う場合)でも, + # 損失は Σ_i w_{y_i}・CE_i / B.重みの和で割ると重みの正規化が意味を持たなくなる + labels = _labels(IMBALANCED) + w = np.array([3.0, 0.5, 1.0, 1.5, 2.0]) + train_loss, _ = _run(labels, torch.from_numpy(w)) + ce = _ce_per_class()[labels] + by_count = float(np.sum(w[labels] * ce) / len(labels)) + by_weight_sum = float(np.sum(w[labels] * ce) / np.sum(w[labels])) + assert by_count != pytest.approx(by_weight_sum, rel=1e-3) + assert train_loss == pytest.approx(by_count, rel=1e-5) + + +@pytest.mark.parametrize("method", ["inverse", "sqrt_inverse"]) +def test_validation_loss_is_unweighted(method: str) -> None: + labels = _labels(IMBALANCED) + w = compute_class_weights(labels, method) + _, val_weighted = _run(labels, torch.from_numpy(w)) + _, val_plain = _run(labels, None) + assert val_weighted == pytest.approx(val_plain, rel=1e-6) + assert val_weighted == pytest.approx(float(_ce_per_class()[labels].mean()), rel=1e-5) + + +@pytest.mark.parametrize("method", ["inverse", "sqrt_inverse"]) +def test_equal_class_counts_give_same_loss_as_unweighted(method: str) -> None: + labels = _labels([5] * NUM_CLASSES) + w = compute_class_weights(labels, method) + weighted = _run(labels, torch.from_numpy(w)) + plain = _run(labels, None) + assert weighted[0] == pytest.approx(plain[0], rel=1e-6) + assert weighted[1] == pytest.approx(plain[1], rel=1e-6) + + +@pytest.mark.parametrize("method", ["inverse", "sqrt_inverse"]) +def test_loss_scale_matches_unweighted_when_per_sample_loss_is_constant( + method: str, monkeypatch: pytest.MonkeyPatch +) -> None: + # 全画像の CE が等しい(一様ロジット)とき,正規化により重み付きの学習損失は重みなしと一致する + monkeypatch.setattr(sys.modules[__name__], "LOGITS", torch.zeros(NUM_CLASSES), raising=True) + labels = _labels(IMBALANCED) + w = compute_class_weights(labels, method) + weighted, _ = _run(labels, torch.from_numpy(w)) + plain, _ = _run(labels, None) + assert plain == pytest.approx(float(np.log(NUM_CLASSES)), rel=1e-6) + assert weighted == pytest.approx(plain, rel=1e-6)