diff --git a/CLAUDE.md b/CLAUDE.md index b408e9c..2b8b5e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ ## 開発進捗 -最新: PLAN_02 Step 7 の一部としてクラス不均衡対策(重み・バランス・サンプリング)を実装・実験(TEST_04).次は学習率の公平な選び直し +最新: 色特徴+ロジスティック回帰が CNN と同等以上(込みで +0.04)と判明(TEST_05).次は CNN と色特徴の併用・効いている特徴の分析 ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) @@ -102,3 +102,4 @@ - データセット集計結果: docs/06_TEST/TEST_02_データセット集計結果.md - 交差検証結果: docs/06_TEST/TEST_03_交差検証結果.md - クラス不均衡対策の実験: docs/06_TEST/TEST_04_クラス不均衡対策の実験.md +- 色特徴分類器の実験: docs/06_TEST/TEST_05_色特徴分類器の実験.md diff --git a/configs/color/cv_color_hist_gb_with_mild.yaml b/configs/color/cv_color_hist_gb_with_mild.yaml new file mode 100644 index 0000000..b3654e1 --- /dev/null +++ b/configs/color/cv_color_hist_gb_with_mild.yaml @@ -0,0 +1,26 @@ +# 5-fold 交差検証(ややあり込み): 色特徴+勾配ブースティング(tias-train color-cv)を計算サーバ上の実データで学習・評価する.分割・評価は tias-train cv と同じ(SPEC_02「交差検証の実行」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 42 + n_folds: 5 + +train: + inner_val_ratio: 0.15 # ハイパーパラメータ選び用の内側の検証(CNN の早期終了用と同じ分け方) + class_weighting: none + +color: + classifier: hist_gb + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + hist_gb: + learning_rates: [0.05, 0.1] # learning_rates × max_depths の組み合わせを内側の検証 macro F1 で選ぶ + max_depths: [3, null] # null は深さの上限なし(葉の数は max_leaf_nodes で制限) + max_iter: 200 + max_leaf_nodes: 31 + min_samples_leaf: 20 + l2_regularization: 0.0 diff --git a/configs/color/cv_color_hist_gb_with_mild_s43.yaml b/configs/color/cv_color_hist_gb_with_mild_s43.yaml new file mode 100644 index 0000000..46f09fd --- /dev/null +++ b/configs/color/cv_color_hist_gb_with_mild_s43.yaml @@ -0,0 +1,26 @@ +# 5-fold 交差検証(ややあり込み): 色特徴+勾配ブースティング(tias-train color-cv)を計算サーバ上の実データで学習・評価する.分割・評価は tias-train cv と同じ(SPEC_02「交差検証の実行」).分割 seed 43 の繰り返し(seed 以外は configs/color/cv_color_hist_gb_with_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 43 + n_folds: 5 + +train: + inner_val_ratio: 0.15 # ハイパーパラメータ選び用の内側の検証(CNN の早期終了用と同じ分け方) + class_weighting: none + +color: + classifier: hist_gb + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + hist_gb: + learning_rates: [0.05, 0.1] # learning_rates × max_depths の組み合わせを内側の検証 macro F1 で選ぶ + max_depths: [3, null] # null は深さの上限なし(葉の数は max_leaf_nodes で制限) + max_iter: 200 + max_leaf_nodes: 31 + min_samples_leaf: 20 + l2_regularization: 0.0 diff --git a/configs/color/cv_color_hist_gb_with_mild_s44.yaml b/configs/color/cv_color_hist_gb_with_mild_s44.yaml new file mode 100644 index 0000000..bb26d62 --- /dev/null +++ b/configs/color/cv_color_hist_gb_with_mild_s44.yaml @@ -0,0 +1,26 @@ +# 5-fold 交差検証(ややあり込み): 色特徴+勾配ブースティング(tias-train color-cv)を計算サーバ上の実データで学習・評価する.分割・評価は tias-train cv と同じ(SPEC_02「交差検証の実行」).分割 seed 44 の繰り返し(seed 以外は configs/color/cv_color_hist_gb_with_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 44 + n_folds: 5 + +train: + inner_val_ratio: 0.15 # ハイパーパラメータ選び用の内側の検証(CNN の早期終了用と同じ分け方) + class_weighting: none + +color: + classifier: hist_gb + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + hist_gb: + learning_rates: [0.05, 0.1] # learning_rates × max_depths の組み合わせを内側の検証 macro F1 で選ぶ + max_depths: [3, null] # null は深さの上限なし(葉の数は max_leaf_nodes で制限) + max_iter: 200 + max_leaf_nodes: 31 + min_samples_leaf: 20 + l2_regularization: 0.0 diff --git a/configs/color/cv_color_hist_gb_without_mild.yaml b/configs/color/cv_color_hist_gb_without_mild.yaml new file mode 100644 index 0000000..d8f79b4 --- /dev/null +++ b/configs/color/cv_color_hist_gb_without_mild.yaml @@ -0,0 +1,26 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 色特徴+勾配ブースティング(tias-train color-cv)を計算サーバ上の実データで学習・評価する.分割・評価は tias-train cv と同じ(SPEC_02「交差検証の実行」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 42 + n_folds: 5 + +train: + inner_val_ratio: 0.15 # ハイパーパラメータ選び用の内側の検証(CNN の早期終了用と同じ分け方) + class_weighting: none + +color: + classifier: hist_gb + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + hist_gb: + learning_rates: [0.05, 0.1] # learning_rates × max_depths の組み合わせを内側の検証 macro F1 で選ぶ + max_depths: [3, null] # null は深さの上限なし(葉の数は max_leaf_nodes で制限) + max_iter: 200 + max_leaf_nodes: 31 + min_samples_leaf: 20 + l2_regularization: 0.0 diff --git a/configs/color/cv_color_hist_gb_without_mild_s43.yaml b/configs/color/cv_color_hist_gb_without_mild_s43.yaml new file mode 100644 index 0000000..5f5d17e --- /dev/null +++ b/configs/color/cv_color_hist_gb_without_mild_s43.yaml @@ -0,0 +1,26 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 色特徴+勾配ブースティング(tias-train color-cv)を計算サーバ上の実データで学習・評価する.分割・評価は tias-train cv と同じ(SPEC_02「交差検証の実行」).分割 seed 43 の繰り返し(seed 以外は configs/color/cv_color_hist_gb_without_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 43 + n_folds: 5 + +train: + inner_val_ratio: 0.15 # ハイパーパラメータ選び用の内側の検証(CNN の早期終了用と同じ分け方) + class_weighting: none + +color: + classifier: hist_gb + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + hist_gb: + learning_rates: [0.05, 0.1] # learning_rates × max_depths の組み合わせを内側の検証 macro F1 で選ぶ + max_depths: [3, null] # null は深さの上限なし(葉の数は max_leaf_nodes で制限) + max_iter: 200 + max_leaf_nodes: 31 + min_samples_leaf: 20 + l2_regularization: 0.0 diff --git a/configs/color/cv_color_hist_gb_without_mild_s44.yaml b/configs/color/cv_color_hist_gb_without_mild_s44.yaml new file mode 100644 index 0000000..d677a16 --- /dev/null +++ b/configs/color/cv_color_hist_gb_without_mild_s44.yaml @@ -0,0 +1,26 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 色特徴+勾配ブースティング(tias-train color-cv)を計算サーバ上の実データで学習・評価する.分割・評価は tias-train cv と同じ(SPEC_02「交差検証の実行」).分割 seed 44 の繰り返し(seed 以外は configs/color/cv_color_hist_gb_without_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 44 + n_folds: 5 + +train: + inner_val_ratio: 0.15 # ハイパーパラメータ選び用の内側の検証(CNN の早期終了用と同じ分け方) + class_weighting: none + +color: + classifier: hist_gb + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + hist_gb: + learning_rates: [0.05, 0.1] # learning_rates × max_depths の組み合わせを内側の検証 macro F1 で選ぶ + max_depths: [3, null] # null は深さの上限なし(葉の数は max_leaf_nodes で制限) + max_iter: 200 + max_leaf_nodes: 31 + min_samples_leaf: 20 + l2_regularization: 0.0 diff --git a/configs/color/cv_color_logistic_with_mild.yaml b/configs/color/cv_color_logistic_with_mild.yaml new file mode 100644 index 0000000..7e02dc6 --- /dev/null +++ b/configs/color/cv_color_logistic_with_mild.yaml @@ -0,0 +1,22 @@ +# 5-fold 交差検証(ややあり込み): 色特徴+ロジスティック回帰(tias-train color-cv)を計算サーバ上の実データで学習・評価する.分割・評価は tias-train cv と同じ(SPEC_02「交差検証の実行」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 42 + n_folds: 5 + +train: + inner_val_ratio: 0.15 # ハイパーパラメータ選び用の内側の検証(CNN の早期終了用と同じ分け方) + class_weighting: none + +color: + classifier: logistic + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + logistic: + c_values: [0.01, 0.1, 1.0, 10.0, 100.0] # 各 fold の内側の検証 macro F1 で 1 つ選ぶ + max_iter: 5000 diff --git a/configs/color/cv_color_logistic_with_mild_cwinv.yaml b/configs/color/cv_color_logistic_with_mild_cwinv.yaml new file mode 100644 index 0000000..1fa56b8 --- /dev/null +++ b/configs/color/cv_color_logistic_with_mild_cwinv.yaml @@ -0,0 +1,22 @@ +# 5-fold 交差検証(ややあり込み): 色特徴+ロジスティック回帰(tias-train color-cv)を計算サーバ上の実データで学習・評価する.分割・評価は tias-train cv と同じ(SPEC_02「交差検証の実行」).クラス不均衡の対策: 重み inverse を sample_weight として使う(class_weighting 以外は configs/color/cv_color_logistic_with_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 42 + n_folds: 5 + +train: + inner_val_ratio: 0.15 # ハイパーパラメータ選び用の内側の検証(CNN の早期終了用と同じ分け方) + class_weighting: inverse + +color: + classifier: logistic + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + logistic: + c_values: [0.01, 0.1, 1.0, 10.0, 100.0] # 各 fold の内側の検証 macro F1 で 1 つ選ぶ + max_iter: 5000 diff --git a/configs/color/cv_color_logistic_with_mild_s43.yaml b/configs/color/cv_color_logistic_with_mild_s43.yaml new file mode 100644 index 0000000..a345cf6 --- /dev/null +++ b/configs/color/cv_color_logistic_with_mild_s43.yaml @@ -0,0 +1,22 @@ +# 5-fold 交差検証(ややあり込み): 色特徴+ロジスティック回帰(tias-train color-cv)を計算サーバ上の実データで学習・評価する.分割・評価は tias-train cv と同じ(SPEC_02「交差検証の実行」).分割 seed 43 の繰り返し(seed 以外は configs/color/cv_color_logistic_with_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 43 + n_folds: 5 + +train: + inner_val_ratio: 0.15 # ハイパーパラメータ選び用の内側の検証(CNN の早期終了用と同じ分け方) + class_weighting: none + +color: + classifier: logistic + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + logistic: + c_values: [0.01, 0.1, 1.0, 10.0, 100.0] # 各 fold の内側の検証 macro F1 で 1 つ選ぶ + max_iter: 5000 diff --git a/configs/color/cv_color_logistic_with_mild_s43_cwinv.yaml b/configs/color/cv_color_logistic_with_mild_s43_cwinv.yaml new file mode 100644 index 0000000..25dc777 --- /dev/null +++ b/configs/color/cv_color_logistic_with_mild_s43_cwinv.yaml @@ -0,0 +1,22 @@ +# 5-fold 交差検証(ややあり込み): 色特徴+ロジスティック回帰(tias-train color-cv)を計算サーバ上の実データで学習・評価する.分割・評価は tias-train cv と同じ(SPEC_02「交差検証の実行」).分割 seed 43 の繰り返し(seed 以外は configs/color/cv_color_logistic_with_mild.yaml と同一).クラス不均衡の対策: 重み inverse を sample_weight として使う(class_weighting 以外は configs/color/cv_color_logistic_with_mild_s43.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 43 + n_folds: 5 + +train: + inner_val_ratio: 0.15 # ハイパーパラメータ選び用の内側の検証(CNN の早期終了用と同じ分け方) + class_weighting: inverse + +color: + classifier: logistic + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + logistic: + c_values: [0.01, 0.1, 1.0, 10.0, 100.0] # 各 fold の内側の検証 macro F1 で 1 つ選ぶ + max_iter: 5000 diff --git a/configs/color/cv_color_logistic_with_mild_s44.yaml b/configs/color/cv_color_logistic_with_mild_s44.yaml new file mode 100644 index 0000000..4c8c6aa --- /dev/null +++ b/configs/color/cv_color_logistic_with_mild_s44.yaml @@ -0,0 +1,22 @@ +# 5-fold 交差検証(ややあり込み): 色特徴+ロジスティック回帰(tias-train color-cv)を計算サーバ上の実データで学習・評価する.分割・評価は tias-train cv と同じ(SPEC_02「交差検証の実行」).分割 seed 44 の繰り返し(seed 以外は configs/color/cv_color_logistic_with_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 44 + n_folds: 5 + +train: + inner_val_ratio: 0.15 # ハイパーパラメータ選び用の内側の検証(CNN の早期終了用と同じ分け方) + class_weighting: none + +color: + classifier: logistic + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + logistic: + c_values: [0.01, 0.1, 1.0, 10.0, 100.0] # 各 fold の内側の検証 macro F1 で 1 つ選ぶ + max_iter: 5000 diff --git a/configs/color/cv_color_logistic_with_mild_s44_cwinv.yaml b/configs/color/cv_color_logistic_with_mild_s44_cwinv.yaml new file mode 100644 index 0000000..289de26 --- /dev/null +++ b/configs/color/cv_color_logistic_with_mild_s44_cwinv.yaml @@ -0,0 +1,22 @@ +# 5-fold 交差検証(ややあり込み): 色特徴+ロジスティック回帰(tias-train color-cv)を計算サーバ上の実データで学習・評価する.分割・評価は tias-train cv と同じ(SPEC_02「交差検証の実行」).分割 seed 44 の繰り返し(seed 以外は configs/color/cv_color_logistic_with_mild.yaml と同一).クラス不均衡の対策: 重み inverse を sample_weight として使う(class_weighting 以外は configs/color/cv_color_logistic_with_mild_s44.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 44 + n_folds: 5 + +train: + inner_val_ratio: 0.15 # ハイパーパラメータ選び用の内側の検証(CNN の早期終了用と同じ分け方) + class_weighting: inverse + +color: + classifier: logistic + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + logistic: + c_values: [0.01, 0.1, 1.0, 10.0, 100.0] # 各 fold の内側の検証 macro F1 で 1 つ選ぶ + max_iter: 5000 diff --git a/configs/color/cv_color_logistic_without_mild.yaml b/configs/color/cv_color_logistic_without_mild.yaml new file mode 100644 index 0000000..91dfb59 --- /dev/null +++ b/configs/color/cv_color_logistic_without_mild.yaml @@ -0,0 +1,22 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 色特徴+ロジスティック回帰(tias-train color-cv)を計算サーバ上の実データで学習・評価する.分割・評価は tias-train cv と同じ(SPEC_02「交差検証の実行」) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 42 + n_folds: 5 + +train: + inner_val_ratio: 0.15 # ハイパーパラメータ選び用の内側の検証(CNN の早期終了用と同じ分け方) + class_weighting: none + +color: + classifier: logistic + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + logistic: + c_values: [0.01, 0.1, 1.0, 10.0, 100.0] # 各 fold の内側の検証 macro F1 で 1 つ選ぶ + max_iter: 5000 diff --git a/configs/color/cv_color_logistic_without_mild_cwinv.yaml b/configs/color/cv_color_logistic_without_mild_cwinv.yaml new file mode 100644 index 0000000..50b1bd8 --- /dev/null +++ b/configs/color/cv_color_logistic_without_mild_cwinv.yaml @@ -0,0 +1,22 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 色特徴+ロジスティック回帰(tias-train color-cv)を計算サーバ上の実データで学習・評価する.分割・評価は tias-train cv と同じ(SPEC_02「交差検証の実行」).クラス不均衡の対策: 重み inverse を sample_weight として使う(class_weighting 以外は configs/color/cv_color_logistic_without_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 42 + n_folds: 5 + +train: + inner_val_ratio: 0.15 # ハイパーパラメータ選び用の内側の検証(CNN の早期終了用と同じ分け方) + class_weighting: inverse + +color: + classifier: logistic + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + logistic: + c_values: [0.01, 0.1, 1.0, 10.0, 100.0] # 各 fold の内側の検証 macro F1 で 1 つ選ぶ + max_iter: 5000 diff --git a/configs/color/cv_color_logistic_without_mild_s43.yaml b/configs/color/cv_color_logistic_without_mild_s43.yaml new file mode 100644 index 0000000..1f0fcc4 --- /dev/null +++ b/configs/color/cv_color_logistic_without_mild_s43.yaml @@ -0,0 +1,22 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 色特徴+ロジスティック回帰(tias-train color-cv)を計算サーバ上の実データで学習・評価する.分割・評価は tias-train cv と同じ(SPEC_02「交差検証の実行」).分割 seed 43 の繰り返し(seed 以外は configs/color/cv_color_logistic_without_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 43 + n_folds: 5 + +train: + inner_val_ratio: 0.15 # ハイパーパラメータ選び用の内側の検証(CNN の早期終了用と同じ分け方) + class_weighting: none + +color: + classifier: logistic + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + logistic: + c_values: [0.01, 0.1, 1.0, 10.0, 100.0] # 各 fold の内側の検証 macro F1 で 1 つ選ぶ + max_iter: 5000 diff --git a/configs/color/cv_color_logistic_without_mild_s43_cwinv.yaml b/configs/color/cv_color_logistic_without_mild_s43_cwinv.yaml new file mode 100644 index 0000000..370c7ba --- /dev/null +++ b/configs/color/cv_color_logistic_without_mild_s43_cwinv.yaml @@ -0,0 +1,22 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 色特徴+ロジスティック回帰(tias-train color-cv)を計算サーバ上の実データで学習・評価する.分割・評価は tias-train cv と同じ(SPEC_02「交差検証の実行」).分割 seed 43 の繰り返し(seed 以外は configs/color/cv_color_logistic_without_mild.yaml と同一).クラス不均衡の対策: 重み inverse を sample_weight として使う(class_weighting 以外は configs/color/cv_color_logistic_without_mild_s43.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 43 + n_folds: 5 + +train: + inner_val_ratio: 0.15 # ハイパーパラメータ選び用の内側の検証(CNN の早期終了用と同じ分け方) + class_weighting: inverse + +color: + classifier: logistic + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + logistic: + c_values: [0.01, 0.1, 1.0, 10.0, 100.0] # 各 fold の内側の検証 macro F1 で 1 つ選ぶ + max_iter: 5000 diff --git a/configs/color/cv_color_logistic_without_mild_s44.yaml b/configs/color/cv_color_logistic_without_mild_s44.yaml new file mode 100644 index 0000000..9820e85 --- /dev/null +++ b/configs/color/cv_color_logistic_without_mild_s44.yaml @@ -0,0 +1,22 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 色特徴+ロジスティック回帰(tias-train color-cv)を計算サーバ上の実データで学習・評価する.分割・評価は tias-train cv と同じ(SPEC_02「交差検証の実行」).分割 seed 44 の繰り返し(seed 以外は configs/color/cv_color_logistic_without_mild.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 44 + n_folds: 5 + +train: + inner_val_ratio: 0.15 # ハイパーパラメータ選び用の内側の検証(CNN の早期終了用と同じ分け方) + class_weighting: none + +color: + classifier: logistic + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + logistic: + c_values: [0.01, 0.1, 1.0, 10.0, 100.0] # 各 fold の内側の検証 macro F1 で 1 つ選ぶ + max_iter: 5000 diff --git a/configs/color/cv_color_logistic_without_mild_s44_cwinv.yaml b/configs/color/cv_color_logistic_without_mild_s44_cwinv.yaml new file mode 100644 index 0000000..23a59f0 --- /dev/null +++ b/configs/color/cv_color_logistic_without_mild_s44_cwinv.yaml @@ -0,0 +1,22 @@ +# 5-fold 交差検証(ややあり除外.held_out は全程度で評価する): 色特徴+ロジスティック回帰(tias-train color-cv)を計算サーバ上の実データで学習・評価する.分割・評価は tias-train cv と同じ(SPEC_02「交差検証の実行」).分割 seed 44 の繰り返し(seed 以外は configs/color/cv_color_logistic_without_mild.yaml と同一).クラス不均衡の対策: 重み inverse を sample_weight として使う(class_weighting 以外は configs/color/cv_color_logistic_without_mild_s44.yaml と同一) +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 44 + n_folds: 5 + +train: + inner_val_ratio: 0.15 # ハイパーパラメータ選び用の内側の検証(CNN の早期終了用と同じ分け方) + class_weighting: inverse + +color: + classifier: logistic + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + logistic: + c_values: [0.01, 0.1, 1.0, 10.0, 100.0] # 各 fold の内側の検証 macro F1 で 1 つ選ぶ + max_iter: 5000 diff --git a/configs/color_synthetic.yaml b/configs/color_synthetic.yaml new file mode 100644 index 0000000..e530137 --- /dev/null +++ b/configs/color_synthetic.yaml @@ -0,0 +1,22 @@ +# 手元確認用: 合成データ(scripts/make_synthetic_dataset.py の出力)で色特徴分類器の交差検証を通す設定 +# tias-train color-cv で実行する(GPU 不要.CPU で数秒〜十数秒).分割・評価は configs/cv_synthetic.yaml と同じ. +# data.root の相対パスはカレントディレクトリ(リポジトリのルート)基準. +data: + root: data/synthetic + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 42 + n_folds: 5 + +train: + inner_val_ratio: 0.15 + class_weighting: none + +color: + classifier: logistic + max_side: 512 + logistic: + c_values: [0.01, 0.1, 1.0, 10.0, 100.0] + max_iter: 5000 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 c82b325..de96f1c 100644 --- "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" +++ "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" @@ -66,6 +66,7 @@ | --- | --- | --- | | 5-fold 交差検証 | `uv run tias-train cv --config <設定>` | 学習・評価して `runs/<実験ID>/` に書き出す.最後に実験 ID を出力する([SPEC_02](../04_SPEC/SPEC_02_学習・評価仕様.md)「交差検証の実行」) | | 手元での試運転 | `uv run tias-train cv --config configs/cv_synthetic.yaml` | 合成データ・小さなモデル・事前学習なし.CPU で十数秒 | +| 色特徴分類器の交差検証 | `uv run tias-train color-cv --config <設定>` | CNN と同じ分割で色特徴+ロジスティック回帰/勾配ブースティングを評価する([SPEC_02](../04_SPEC/SPEC_02_学習・評価仕様.md)「色特徴分類器」).GPU 不要.手元の試運転は `configs/color_synthetic.yaml` | | 回収した結果の集計 | `uv run python scripts/summarize_cv.py runs` | `runs/*_cv_*` を読み,モデル × 学習率 × 不均衡対策 × 条件ごとの表と,「除外 − 込み」「対策あり − 対策なし」の対応比較を Markdown で出す([TEST_03](../06_TEST/TEST_03_交差検証結果.md) の表) | 設定ファイル: @@ -77,6 +78,7 @@ | `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 本)から対策のキーだけを変えたもの | +| `configs/color/*.yaml` | 色特徴分類器(`cv_color__[_s43 \| _s44][_cwinv]`).分割・程度の設定は同名の CNN の設定と同一 | **サーバでの実行例**: @@ -91,6 +93,7 @@ uv run tias-remote pull <実験ID> # 指標・図・ログだけが手元の runs/ に届く ``` +- 色特徴分類器は GPU を使わないので `--cpu` で回す(例: `uv run tias-remote run --name color1 --cpu --server Archimedes -- bash -c "tias-train color-cv --config configs/color/cv_color_logistic_with_mild.yaml;"`).1 本は Archimedes で約 1 分・Newton で約 2 分(大半が画像の読み込みと特徴抽出.2026-09-28 実測) - ConvNeXt-Tiny の 5-fold 交差検証 1 本は Archimedes の RTX A6000 で約 2.5 分(2026-09-28 実測.画像の読み込み約 8 秒を含む) - 事前学習の重みは初回にサーバが Hugging Face からダウンロードする(`~/.cache/huggingface`) - ログに出る `lr_scheduler.step() before optimizer.step()` の警告は,混合精度で最初の数ステップの勾配がオーバーフローして捨てられたときに出るもので無害 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 d130a72..b43008a 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" @@ -60,6 +60,7 @@ - **成果物**: 実験群(クラス不均衡対策・モデル比較・入力解像度・マスク外領域の埋め方)とその記録 - **完了条件**: 各要因の効果が**独立した実験として**切り分けて記録されている - **注意**: 共用サーバのため使用する GPU を明示的に指定する +- **進捗**: クラス不均衡対策([TEST_04](../06_TEST/TEST_04_クラス不均衡対策の実験.md))・色特徴分類器との比較([TEST_05](../06_TEST/TEST_05_色特徴分類器の実験.md))を実施 ## Step 8: 結論 (Conclusion) diff --git "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" index d46768f..d782fe5 100644 --- "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" +++ "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" @@ -149,6 +149,7 @@ | --- | --- | --- | | `tias-dataset build-manifest` | `images/` を走査して `manifest.csv` を書き出す(既存があれば `--overwrite` が必要) | サーバ | | `tias-train cv --config PATH` | 5-fold 交差検証で学習・評価し,`runs/<実験ID>/` に書き出す([SPEC_02「交差検証の実行」](SPEC_02_学習・評価仕様.md)).最後に実験 ID を出力する | サーバ(GPU) | +| `tias-train color-cv --config PATH` | 色特徴分類器([SPEC_02「色特徴分類器」](SPEC_02_学習・評価仕様.md))を同じ分割で交差検証し,同じ形式で書き出す(学習曲線・重みは出さない).最後に実験 ID を出力する | サーバ(CPU) | | `tias-dataset survey` | データセットを集計して `runs/<実験ID>/` に集計値だけを書き出す(下記) | サーバ(結果の要約のみ手元へ) | | 学習 | 設定 YAML を受け取って学習する | サーバ | | 評価 | 学習済み run の評価・レポート生成 | サーバ | diff --git "a/docs/04_SPEC/SPEC_02_\345\255\246\347\277\222\343\203\273\350\251\225\344\276\241\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_02_\345\255\246\347\277\222\343\203\273\350\251\225\344\276\241\344\273\225\346\247\230.md" index 0f72afe..21952d1 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" @@ -189,6 +189,41 @@ 効果は,対策なしの結果と**同じ seed・同じ fold どうしの対応比較**で評価する(common の macro F1 と,クラス別の再現率・適合率・混同行列).少ないクラスの再現率が上がっても,他クラスの適合率が下がる入れ替わりが起きうるため,全体の数字だけで判断しない. +## 色特徴分類器 (Color Feature Classifier) + +ラベルが舌の色そのものであり,データが少ないため,舌領域の色の統計量を特徴にした古典的な分類器を CNN と**同じ分割・同じ評価**で比べる(`tias-train color-cv`.結果は [TEST_05](../06_TEST/TEST_05_色特徴分類器の実験.md)).GPU は使わない. + +### 特徴量 (Features) + +元解像度の画像の**舌領域の画素だけ**から取る(背景は RGB がすべて 0 の画素.レターボックス縮小は補間で背景の黒が縁に混ざるため使わない).長辺が `color.max_side`(既定 512.`null` で縮小しない)を超える画像は最近傍法で縮小する(新しい色を作らないため).画素を CIE L\*a\*b\*(D65)に変換し,次の 107 次元を作る. + +| 特徴 | 次元 | +| --- | --- | +| L\*・a\*・b\*・C\*(彩度 √(a\*² + b\*²))・h(色相角 atan2(b\*, a\*).度,(−180, 180])それぞれの平均・標準偏差(母標準偏差.ddof=0)・10/25/50/75/90 パーセンタイル | 5 × 7 = 35 | +| a\*-b\* の 2 次元ヒストグラム(a\* [0, 50]・b\* [−10, 30] を 8 × 8 に等分.舌領域の画素数に対する割合) | 64 | +| L\* のヒストグラム([20, 100] を 8 等分.割合) | 8 | + +- ヒストグラムの範囲外の画素は端のビンに入れる(割合の合計を 1 に保つ).範囲は全データの平均色([TEST_02](../06_TEST/TEST_02_データセット集計結果.md))を根拠に固定する +- 色相角は 0° をまたがないよう (−180, 180] で表す(舌の色は赤〜紫で a\* > 0 に集まるため) +- 舌領域の画素が無い画像は停止する(manifest の何件目かで示し,ファイル名は出さない) + +### 分類器と候補の選び方 (Classifier & Selection) + +設定の `color` 節で指定する(CNN の設定には不要.`color` 節が無い設定で `color-cv` を実行すると停止する). + +| キー | 値 | 内容 | +| --- | --- | --- | +| `color.classifier` | `logistic`・`hist_gb` | 標準化+多クラスのロジスティック回帰,または scikit-learn の HistGradientBoostingClassifier | +| `color.logistic.c_values` | 正の数のリスト(既定 0.01・0.1・1・10・100) | 正則化の強さ C の候補 | +| `color.hist_gb.learning_rates` × `max_depths` | 正の数・正の整数または `null` のリスト | 学習率と木の深さの候補(組み合わせ) | +| `color.max_side` | 正の整数または `null`(既定 512) | 特徴抽出の前に縮小する長辺の上限 | + +- 分割は CNN と同じ(「交差検証の実行」の 1〜2.2.同じ seed なら `split.json` が完全に一致する) +- 各 fold で,候補ごとに学習用データで学習し,**内側の検証の macro F1 が最良の候補**を採用する(厳密な改善で更新し,同点なら先の候補.CNN の最良エポックの選び方に対応).採用したモデル(学習用データだけで学習したもの)で held_out を 1 回だけ予測する +- `train.class_weighting` のクラス重み(「クラス不均衡」)を sample_weight として使う.`train.sampler: balanced` は未対応で,指定すると停止する +- 学習用データに無いクラスの予測確率は 0 とし,5 クラスの形を保つ +- 出力は CNN と同じ名前・同じ形式(「出力」).ただし `history.csv`・学習曲線・重み(`checkpoints/`)は出さない.`metrics.json` の `environment.model_name` は `color_logistic`・`color_hist_gb`,最良エポックは `null` で,fold ごとの採用した候補(`selected_params`)と候補ごとの検証 macro F1(`candidates`),特徴名(`color_classifier`),特徴抽出の所要秒数(`timing.feature_extraction_seconds`)を加える + ## 評価指標 (Metrics) | 指標 | 役割 | @@ -218,5 +253,5 @@ - 入力解像度 224 で足りるか(元画像は平均 675×614 で,約 1/3 に縮小することになる) - 黒背景が面積の大半を占めるため,正規化・統計を画像全体で取るか舌領域内のみで取るか - 「ややあり」を含めるか否かの結論(両条件の実験結果を見て決める) -- ルールベース診断の取り込み方 +- ルールベース診断の取り込み方(色特徴分類器の「平均色だけ」の版を作れば,TIASjudge に近い条件の比較の土台になる.TEST_05) - 医師ラベルの評価者間一致率(分かれば性能の上限の目安になる) diff --git "a/docs/06_TEST/TEST_05_\350\211\262\347\211\271\345\276\264\345\210\206\351\241\236\345\231\250\343\201\256\345\256\237\351\250\223.md" "b/docs/06_TEST/TEST_05_\350\211\262\347\211\271\345\276\264\345\210\206\351\241\236\345\231\250\343\201\256\345\256\237\351\250\223.md" new file mode 100644 index 0000000..fde3189 --- /dev/null +++ "b/docs/06_TEST/TEST_05_\350\211\262\347\211\271\345\276\264\345\210\206\351\241\236\345\231\250\343\201\256\345\256\237\351\250\223.md" @@ -0,0 +1,85 @@ +# 色特徴分類器の実験 (Color Feature Classifier Experiments) + +舌領域の画素から取った色の特徴量(107 次元.[SPEC_02「色特徴分類器」](../04_SPEC/SPEC_02_学習・評価仕様.md))と古典的な分類器で舌色 5 分類を行い,CNN([TEST_03](TEST_03_交差検証結果.md)・[TEST_04](TEST_04_クラス不均衡対策の実験.md))と**同じ分割・同じ評価**で比べた結果.ラベルが舌の色そのものであり,データが 1085 枚と少ないため,手作りの色特徴のほうが強い可能性を確かめる目的で行った.表は `uv run python scripts/summarize_cv.py runs` で再生成できる. + +| 項目 | 内容 | +| --- | --- | +| 実行日時 | 2026-09-28 10:54〜11:30 頃 | +| 実行場所 | Newton・Archimedes の CPU(GPU は使わない.`tias-remote run --cpu`.2 台に 9 本ずつ) | +| 本数 | 18 本(ロジスティック回帰・勾配ブースティング × ややあり込み/除外 × seed 42・43・44 = 12 本,ロジスティック回帰+重み `inverse` × 2 条件 × 3 seed = 6 本) | +| 分類器 | `logistic`(標準化+多クラスのロジスティック回帰.C を 0.01〜100 の 5 候補から選ぶ)・`hist_gb`(HistGradientBoostingClassifier.学習率 2 × 深さ 2 の 4 候補から選ぶ) | +| 候補の選び方 | fold ごとに,学習用データで各候補を学習し,内側の検証の macro F1 が最良の候補を採用する(CNN の最良エポックの選び方に対応) | +| コード | `c66b8bd-dirty`(本ブランチの実装を実行時点で未コミットのまま使用.同じ seed で CNN と `split.json` が完全に一致することはテストで確認済み) | + +## 結果 (Results) + +common(「あり」と淡紅)の macro F1・正解率と,all(ややあり込みの全体)の macro F1.15 fold の平均 ± 標準偏差.CNN は各モデルの最良の構成だけを抜粋した(全体は TEST_03・TEST_04). + +| モデル | 条件 | common macro F1 | common 正解率 | all macro F1 | mild→淡紅 | +| --- | --- | --- | --- | --- | --- | +| **色特徴+ロジスティック回帰** | 込み | **0.630 ± 0.052** | 0.721 ± 0.035 | **0.556 ± 0.043** | 0.33 | +| **色特徴+ロジスティック回帰** | 除外 | **0.644 ± 0.040** | **0.754 ± 0.031** | 0.520 ± 0.034 | 0.55 | +| 色特徴+ロジスティック回帰(重み inverse) | 込み | 0.568 ± 0.038 | 0.618 ± 0.036 | 0.541 ± 0.034 | 0.21 | +| 色特徴+ロジスティック回帰(重み inverse) | 除外 | 0.599 ± 0.040 | 0.672 ± 0.029 | 0.533 ± 0.036 | 0.38 | +| 色特徴+勾配ブースティング | 込み | 0.626 ± 0.049 | 0.712 ± 0.034 | 0.555 ± 0.044 | 0.33 | +| 色特徴+勾配ブースティング | 除外 | 0.627 ± 0.064 | 0.747 ± 0.037 | 0.512 ± 0.042 | 0.54 | +| ResNet-50(lr 1e-3・重み sqrt) | 込み | 0.590 ± 0.057 | 0.666 ± 0.070 | 0.554 ± 0.043 | 0.25 | +| ResNet-50(lr 1e-3) | 除外 | 0.637 ± 0.062 | 0.748 ± 0.045 | 0.519 ± 0.056 | 0.48 | +| ConvNeXt-Tiny(重み inverse) | 除外 | 0.632 ± 0.060 | 0.745 ± 0.030 | 0.528 ± 0.043 | 0.47 | + +common でのクラス別再現率(15 fold の平均): + +| モデル | 条件 | 淡白 | 淡紅 | 紅 | 暗赤紅 | 紫 | +| --- | --- | --- | --- | --- | --- | --- | +| 色特徴+ロジスティック回帰 | 込み | 0.56 | 0.73 | 0.65 | 0.82 | 0.41 | +| 色特徴+ロジスティック回帰 | 除外 | 0.48 | 0.88 | 0.59 | 0.77 | 0.36 | +| 色特徴+ロジスティック回帰(重み inverse) | 込み | **0.81** | 0.56 | **0.79** | 0.63 | **0.60** | +| 色特徴+ロジスティック回帰(重み inverse) | 除外 | **0.78** | 0.70 | 0.71 | 0.63 | 0.52 | +| ResNet-50(lr 1e-3) | 除外 | 0.59 | 0.83 | 0.61 | 0.79 | 0.39 | + +## CNN との対応比較 (Paired Comparison) + +色特徴+ロジスティック回帰 − CNN の common macro F1(同じ条件・seed・fold どうし.15 fold).正の値は色特徴のほうが良いことを表す. + +| 比べた CNN | 込み | 色特徴が上回った fold | 除外 | 色特徴が上回った fold | +| --- | --- | --- | --- | --- | +| ConvNeXt-Tiny(対策なし) | +0.089 ± 0.065 | 14 / 15 | +0.052 ± 0.063 | 11 / 15 | +| ConvNeXt-Tiny(重み inverse) | +0.045 ± 0.069 | 11 / 15 | +0.012 ± 0.063 | 8 / 15 | +| ResNet-50(lr 1e-3) | +0.084 ± 0.093 | 13 / 15 | +0.008 ± 0.063 | 8 / 15 | +| ResNet-50(lr 1e-3・重み sqrt) | +0.041 ± 0.076 | 12 / 15 | +0.011 ± 0.069 | 9 / 15 | +| ViT-Small | +0.051 ± 0.082 | 11 / 15 | +0.064 ± 0.074 | 13 / 15 | +| EfficientNet-B0(lr 1e-3) | +0.102 ± 0.062 | 14 / 15 | +0.067 ± 0.061 | 13 / 15 | +| 色特徴+勾配ブースティング | +0.005 ± 0.045 | 7 / 15 | +0.018 ± 0.057 | 10 / 15 | + +重み `inverse` の効果(色特徴+ロジスティック回帰.対策あり − なし): common macro F1 は込み −0.062(0/15 fold),除外 −0.046(2/15 fold).淡白 +0.25〜0.30・紫 +0.16〜0.18 に対して淡紅 −0.17〜0.18・暗赤紅 −0.14〜0.20. + +## 分かったこと (Findings) + +1. **色特徴+ロジスティック回帰は,これまでのどの CNN と比べても同等以上だった**.除外では common macro F1 0.644 で CNN の最良(ResNet-50 lr 1e-3 の 0.637・ConvNeXt+inverse の 0.632)と並び(対応比較で +0.01 前後.8/15 fold),**込みでは 0.630 で CNN の最良(0.590)を約 +0.04 上回った**(11〜14/15 fold) +2. **fold 間のばらつきが CNN より小さい**(標準偏差 0.040〜0.052.CNN は 0.045〜0.070).seed による揺れもほぼ無い(mild→淡紅 の割合の標準偏差 0.01) +3. **「ややあり」の込み/除外による差が小さい**(+0.014.9/15 fold).CNN は除外で大きく上がった(ResNet-50 で +0.09).CNN は境界例を入れると学習が乱れるが,単純な線形モデルは影響を受けにくいと考えられる +4. 勾配ブースティングはロジスティック回帰と同程度か少し下(除外で −0.018).非線形な分類器にしても伸びない +5. ロジスティック回帰で選ばれた正則化は強め(C = 0.1 が 30 fold 中 17,0.01 が 10)で,**単純なモデルで十分**なことを示す +6. **重み `inverse` は macro F1 を下げた**が,淡白 0.81・紫 0.60 と,**少数クラスの再現率はこれまでで最も高い**.淡紅・暗赤紅との入れ替わりが大きすぎて macro F1 では損になる.用途(見逃したくないクラスがあるか)によっては選択肢になる +7. 計算コストは極めて小さい.1 本(5 fold)の所要の大半は画像の読み込みと特徴抽出(Archimedes で約 62 秒,Newton で約 115 秒)で,ロジスティック回帰の学習は 5 fold で約 1 秒.GPU が要らない + +## 注意 (Caveats) + +- 差の多くは fold 間のばらつき(標準偏差 0.04〜0.07)と同程度で,確定的に言えるのは「色特徴+ロジスティック回帰は CNN に劣らない(込みでは上回る)」まで +- 比較相手の CNN の学習率・対策は結果を見て選んだもの(TEST_03・TEST_04)で,CNN 側に有利な比較になっている.一方,色特徴側の候補(C など)は内側の検証だけで選んでおり,held_out は見ていない +- 特徴の定義(ヒストグラムの範囲・ビン数)は TEST_02 の集計値(全データの平均色)を根拠に固定した.範囲を決めるのに全データの平均だけを使っており,ラベルごとの分布は使っていない +- TIASjudge のルールベース(平均色による判定)との比較ではない(Step 6 は保留中).ただし「色の統計だけで CNN 並み」という結果は,ルールベースとの比較の土台になる + +## 次の一手 (Next Steps) + +- **CNN と色特徴の併用**(CNN の予測確率と色特徴の分類器の予測確率の平均,または CNN の特徴と色特徴の連結).両者の誤り方が違えば上乗せが期待できる +- どの特徴が効いているか(ロジスティック回帰の係数・特徴の絞り込み)を調べる.平均色だけで足りるのか,分布の形(分位点・ヒストグラム)が効いているのかが分かれば,TIASjudge のルールの改良にも使える +- ルールベース(Step 6)との比較: 同じ色空間で「平均色だけ」の分類器を作れば,TIASjudge に近い条件で比べられる + +## 実験 ID (Run IDs) + +手元の `runs/` に回収済み(`.gitignore` 対象).名前の `color_logistic`・`color_hist_gb` が分類器,末尾 `_cwinv` が重み `inverse` を表す.実験 ID の日時は 2026-09-28 10:54〜11:22. + +```bash +ls -d runs/*_cv_color_* +``` diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index cc563fe..4c00449 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -110,3 +110,12 @@ - 失敗パターン: キュー完了後の自動回収が,長くなったジョブログの末尾しか読めず 36 本中 27 本しか拾えなかった → サーバ上の実験ディレクトリの一覧から回収し直した.以後,回収はログではなくディレクトリの一覧を正とする - 速度の調査: 学習中の GPU 使用率は平均 86% で計算律速(データ準備は余裕あり).性能を変えずに速くするなら channels_last・bf16(1〜3 割の見込み)や GPU の並列利用が候補(未実施) +## Step 7(一部): 色特徴分類器との比較 + +- [x] **舌領域の色特徴(107 次元)+ロジスティック回帰/勾配ブースティングの交差検証 `tias-train color-cv` を追加し,CNN と同じ分割で 18 本を実験**: ラベルが色そのもので,データが少ないため,手作りの色特徴のほうが強い可能性を確かめた(ユーザーの選択).結果は [TEST_05](06_TEST/TEST_05_色特徴分類器の実験.md). + - 設計判断 ①: 分割は CNN と完全に同じにした(fold の準備処理を CNN と共有する形に切り出した.同じ seed で `split.json` が一致することをテストで確認).これで CNN の結果と fold どうしの対応比較ができる + - 設計判断 ②: 分類器の候補(C 等)は内側の検証の macro F1 で選び,held_out は見ない(CNN の最良エポックの選び方に対応) + - 設計判断 ③: 特徴は元解像度の舌領域の画素だけから取る(レターボックス縮小は補間で背景の黒が縁に混ざるため) + - 進め方: ユーザーの指示で Phase 1 の人間の確認を省き,テスト作成と並行してサーバで実験した.GPU は使わず 2 台の CPU で 9 本ずつ回した(計約 30 分.1 本 1〜2 分) + - 結果: **色特徴+ロジスティック回帰が CNN と同等以上**.除外で common macro F1 0.644(CNN の最良 0.637 と並ぶ),込みで 0.630(CNN の最良 0.590 を上回り,対応比較で 11〜14/15 fold).fold 間のばらつきも小さく,込み/除外の差も小さい.重み `inverse` は macro F1 を下げたが,淡白 0.81・紫 0.60 と少数クラスの再現率はこれまでで最高 + - 示唆: 現状のデータ量では CNN が色の統計以上のものを学べていない.次は CNN と色特徴の併用と,どの特徴が効いているかの分析(TIASjudge のルール改良にもつながる) diff --git a/scripts/summarize_cv.py b/scripts/summarize_cv.py index b2cbdfe..30ac066 100644 --- a/scripts/summarize_cv.py +++ b/scripts/summarize_cv.py @@ -4,6 +4,8 @@ 集計値のみで個人情報を含まない).モデル × 学習率 × 条件(ややあり込み/除外)ごとに, 全 seed × 5 fold の fold ごとの値の平均 ± 標準偏差と,同じ seed・同じ fold どうしの 対応比較(除外 − 込み)を出す.結果は標準出力(Markdown)と runs/_cv_summary.json に書く. +色特徴分類器(tias-train color-cv.モデル名 color_*)の run も同じ表に並べる(学習率を持たない +ため,学習率は表示せず集計のキーでは 0 とする). 使い方: uv run python scripts/summarize_cv.py [runs] @@ -24,7 +26,11 @@ "efficientnet_b0": "EfficientNet-B0", "resnet50": "ResNet-50", "vit_small_patch16_224": "ViT-Small", + "color_logistic": "色特徴+ロジスティック回帰", + "color_hist_gb": "色特徴+勾配ブースティング", } +# 学習率を持たないモデル(色特徴分類器.config.yaml の train.lr は使われない既定値) +MODELS_WITHOUT_LR = frozenset({"color_logistic", "color_hist_gb"}) MODEL_ORDER = list(MODEL_LABEL) CONDITIONS = ("with_mild", "without_mild") MEASURES = ("none", "inverse", "sqrt_inverse", "balanced") @@ -60,9 +66,18 @@ def label(key: tuple[str, float, str]) -> str: """(モデル名, 学習率, 不均衡対策)の表示名.""" name, lr, measure = key + if name in MODELS_WITHOUT_LR: + extra = MEASURE_LABEL[measure].removeprefix("・") + return MODEL_LABEL[name] + (f"({extra})" if extra else "") return f"{MODEL_LABEL[name]}(lr {lr:g}{MEASURE_LABEL[measure]})" +def key_name(key: tuple[str, float, str]) -> str: + """_cv_summary.json のキー(モデル名/lr/対策.学習率を持たないモデルは lr を省く).""" + name, lr, measure = key + return f"{name}/{measure}" if name in MODELS_WITHOUT_LR else f"{name}/lr{lr:g}/{measure}" + + def imbalance_measure(metrics: dict) -> str: """不均衡対策の種類(対策の記録が無い古い run は対策なし).""" imb = metrics.get("imbalance") or {} @@ -77,6 +92,11 @@ return (MODEL_ORDER.index(key[0]), key[1], MEASURES.index(key[2])) +def run_lr(model: str, conf: dict) -> float: + """集計のキーに使う学習率(学習率を持たないモデルは 0).""" + return 0.0 if model in MODELS_WITHOUT_LR else float(conf["train"]["lr"]) + + def load_runs(root: Path) -> list[dict]: """実験ディレクトリを読む(対象外のモデルは飛ばす).""" runs = [] @@ -91,7 +111,7 @@ runs.append( { "run_id": metrics["run_id"], - "model": (model, float(conf["train"]["lr"]), imbalance_measure(metrics)), + "model": (model, run_lr(model, conf), imbalance_measure(metrics)), "cond": cond, "seed": conf["split"]["seed"], "m": metrics, @@ -210,7 +230,7 @@ fmt(row["seconds"], 0), ] lines.append("| " + " | ".join(cells) + " |") - summary[f"{model[0]}/lr{model[1]:g}/{model[2]}/{cond}"] = row + summary[f"{key_name(model)}/{cond}"] = row lines.append( "\n※ macro F1・正解率は全 seed × 5 fold の fold ごとの値の平均 ± 標準偏差." "mild→淡紅 は run ごとの(全 fold をまとめた)割合の平均 ± 標準偏差.\n" @@ -251,7 +271,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}/{model[2]}"] = { + paired[key_name(model)] = { "diff": mean_std(diffs), "wins": wins, "n": n, diff --git a/src/tias_judge_model/cli/train.py b/src/tias_judge_model/cli/train.py index ed740d2..374b7e1 100644 --- a/src/tias_judge_model/cli/train.py +++ b/src/tias_judge_model/cli/train.py @@ -4,8 +4,12 @@ - `cv --config PATH [--out-root runs]`: 5-fold 交差検証で学習・評価し,`runs/<実験ID>/` に書き出す (SPEC_02「交差検証の実行」).最後に実験 ID を標準出力へ出す(`tias-remote pull <実験ID>` 用) +- `color-cv --config PATH [--out-root runs]`: 舌領域の色特徴量+古典的分類器(設定の `color` 節)で + `cv` と同じ分割・同じ評価の交差検証を行う.出力は `cv` と同じ規則(ただし学習曲線・ + `history.csv`・重みは出さない).GPU は使わない -実データを読むためサーバで実行する(手元では合成データの設定 `configs/cv_synthetic.yaml`). +実データを読むためサーバで実行する(手元では合成データの設定 `configs/cv_synthetic.yaml`・ +`configs/color_synthetic.yaml`). 回収対象(`config.yaml`・`metrics.json`・`history.csv`・`confusion_matrix.csv`・`log.txt`・ `figures/*.png`)には患者 ID・ファイル名・画像単位の値を出さない.個票(`split.json`・ `predictions.csv`)はサーバ上にだけ置く. @@ -20,12 +24,13 @@ import os import sys import time -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from datetime import datetime from pathlib import Path from typing import Any import numpy as np +import sklearn import torch import yaml from pydantic import ValidationError @@ -54,6 +59,12 @@ sum_confusion_matrices, ) from tias_judge_model.schema import ExperimentConfig, load_config +from tias_judge_model.training.color_cross_validation import ( + ColorCrossValidationResult, + candidate_params, + check_color_config, + run_color_cross_validation, +) from tias_judge_model.training.cross_validation import ( ClassBalance, CrossValidationResult, @@ -79,6 +90,8 @@ 使用例(サーバ上で実行する.手元では configs/cv_synthetic.yaml で合成データを使う): uv run tias-train cv --config configs/cv_with_mild.yaml uv run tias-remote run --name cv-with-mild -- tias-train cv --config configs/cv_with_mild.yaml + uv run tias-train color-cv --config configs/color_synthetic.yaml + uv run tias-remote run --name color1 --cpu -- tias-train color-cv --config 環境変数: TIAS_DATA_ROOT data.root を上書き(tias-remote run が各サーバの data_dir を渡す) @@ -87,13 +100,23 @@ # --------------------------------------------------------------------------- -# cv +# cv・color-cv # --------------------------------------------------------------------------- +# サブコマンドの本体(設定・実験ディレクトリと,キーワード引数 run_id・git_commit・started). +_Runner = Callable[..., None] -def _cmd_cv(args: argparse.Namespace) -> int: + +def _cmd_run( + args: argparse.Namespace, + runner: _Runner, + check: Callable[[ExperimentConfig], object] | None = None, +) -> int: started = time.monotonic() config = load_config(args.config) + if check is not None: + # 設定の不備は実験ディレクトリを作る前に止める(空の実験ディレクトリを残さないため). + check(config) git_commit = resolve_git_commit(os.environ) run_id = make_run_id(datetime.now(), args.config.stem, git_commit) run_dir = args.out_root / run_id @@ -107,7 +130,7 @@ root = logging.getLogger() root.addHandler(file_handler) try: - _run_cv(config, run_dir, run_id=run_id, git_commit=git_commit, started=started) + runner(config, run_dir, run_id=run_id, git_commit=git_commit, started=started) except (ValueError, OSError) as exc: # log.txt にも失敗の理由を残すため,ハンドラを外す前にここで記録する. logger.error("%s", exc) @@ -124,27 +147,66 @@ config: ExperimentConfig, run_dir: Path, *, run_id: str, git_commit: str, started: float ) -> None: logger.info("交差検証の実験 ID: %s", run_id) + _write_yaml( + run_dir / CONFIG_FILENAME, + _cv_config_document(config, run_id=run_id, git_commit=git_commit, command="train cv"), + ) + + records = read_manifest(config.data.manifest_path) + result = run_cross_validation(config, records, run_dir / CHECKPOINTS_DIRNAME) + + history = history_rows(result.folds) + _write_csv(run_dir / HISTORY_FILENAME, history) + plot_learning_curves(history, run_dir / FIGURES_DIRNAME / LEARNING_CURVES_FILENAME) + + metrics = build_metrics(config, records, result, run_id=run_id, git_commit=git_commit) + _write_evaluation_outputs(run_dir, config, records, result, metrics, started=started) + + +def _run_color_cv( + config: ExperimentConfig, run_dir: Path, *, run_id: str, git_commit: str, started: float +) -> None: + logger.info("色特徴分類器の交差検証の実験 ID: %s", run_id) + _write_yaml( + run_dir / CONFIG_FILENAME, + _cv_config_document(config, run_id=run_id, git_commit=git_commit, command="train color-cv"), + ) + + records = read_manifest(config.data.manifest_path) + result = run_color_cross_validation(config, records) + + # エポックが無いため history.csv と学習曲線は出さない.重みも保存しない. + metrics = build_color_metrics(config, records, result, run_id=run_id, git_commit=git_commit) + _write_evaluation_outputs(run_dir, config, records, result, metrics, started=started) + + +def _cv_config_document( + config: ExperimentConfig, *, run_id: str, git_commit: str, command: str +) -> dict[str, Any]: document = resolved_config_document( - config, run_id=run_id, git_commit=git_commit, command="train cv" + config, run_id=run_id, git_commit=git_commit, command=command ) document["seeds"] = { "split": config.split.seed, "folds": [fold_seed(config.split.seed, i) for i in range(config.split.n_folds)], } - _write_yaml(run_dir / CONFIG_FILENAME, document) + return document - records = read_manifest(config.data.manifest_path) - result = run_cross_validation(config, records, run_dir / CHECKPOINTS_DIRNAME) +def _write_evaluation_outputs( + run_dir: Path, + config: ExperimentConfig, + records: Sequence[ManifestRecord], + result: CrossValidationResult, + metrics: dict[str, Any], + *, + started: float, +) -> None: + # cv・color-cv に共通の書き出し(分割・個票・混同行列・図・metrics.json)とログの要約. _write_split(run_dir / SPLIT_FILENAME, config, result) _write_predictions(run_dir / PREDICTIONS_FILENAME, records, result) - history = history_rows(result.folds) - _write_csv(run_dir / HISTORY_FILENAME, history) - - metrics = build_metrics(config, records, result, run_id=run_id, git_commit=git_commit) _write_confusion_csv(run_dir / CONFUSION_MATRIX_FILENAME, metrics["confusion_matrix_sum"]) figures_dir = run_dir / FIGURES_DIRNAME - plot_learning_curves(history, figures_dir / LEARNING_CURVES_FILENAME) for subset in CONFUSION_SUBSETS: matrix = metrics["confusion_matrix_sum"].get(subset) if matrix is not None: @@ -327,6 +389,74 @@ } +def build_color_metrics( + config: ExperimentConfig, + records: Sequence[ManifestRecord], + result: ColorCrossValidationResult, + *, + run_id: str, + git_commit: str, +) -> dict[str, Any]: + """色特徴分類器の `metrics.json` の内容を作る(`build_metrics` + 選んだハイパーパラメータ). + + `build_metrics` と同じ指標・集計に次の変更を加える(集計値だけを含み,患者 ID・ファイル名・ + 画像単位の値は含まない). + + - `environment`: モデル名を `color_` にし,特徴量の次元数・縮小の上限・ + scikit-learn の版を記録する(CNN 固有のパラメータ数・入力サイズ・事前学習は持たない) + - `best_epoch`(トップレベル・fold ごと): エポックが無いため None + - fold ごとに `selected_params`(選んだハイパーパラメータ)と `candidates`(候補ごとの + 検証 macro F1・学習秒数)を加える + - `color_classifier`: 分類器・候補の一覧・特徴量の名前 + - `timing.feature_extraction_seconds`: 画像の読み込みと特徴抽出の秒数 + (`data_loading_seconds` と同じ値) + + Args: + config: 実験設定(`color` 節を含む). + records: manifest の全レコード. + result: 色特徴分類器の交差検証の結果. + run_id: 実験 ID. + git_commit: git の短縮ハッシュ(または `nogit`). + + Returns: + JSON に書き出せる dict(`timing.total_seconds` は呼び出し側で埋める). + + Raises: + ValueError: 設定に `color` 節が無い場合. + """ + color = check_color_config(config) + metrics = build_metrics(config, records, result, run_id=run_id, git_commit=git_commit) + metrics["best_epoch"] = None + selections = {s.fold: s for s in result.selections} + for entry in metrics["folds"]: + selection = selections[entry["fold"]] + entry["best_epoch"] = None + entry["selected_params"] = selection.selected_params + entry["candidates"] = [ + { + "params": c.params, + "val_macro_f1": c.val_macro_f1, + "fit_seconds": round(c.fit_seconds, 3), + } + for c in selection.candidates + ] + metrics["color_classifier"] = { + "classifier": color.classifier, + "candidates": candidate_params(color), + "num_features": len(result.feature_names), + "feature_names": list(result.feature_names), + } + metrics["timing"]["feature_extraction_seconds"] = round(result.data_loading_seconds, 2) + metrics["environment"] = { + "device": result.device, + "model_name": result.model_name, + "num_features": len(result.feature_names), + "max_side": color.max_side, + "sklearn_version": sklearn.__version__, + } + return metrics + + def class_balance_entry(balance: ClassBalance | None) -> dict[str, Any] | None: """fold の `class_balance`(学習のクラス別枚数・クラス重み・抜き出し確率)を作る. @@ -456,10 +586,23 @@ "held_out(全程度)で評価する.最後に実験 ID を標準出力へ出す." ), ) - p_cv.add_argument("--config", type=Path, required=True, help="実験設定 YAML.") - p_cv.add_argument( - "--out-root", type=Path, default=Path("runs"), help="実験ディレクトリの親(既定: runs)." + p_color = sub.add_parser( + "color-cv", + help="色特徴量+古典的分類器で cv と同じ分割・評価の交差検証をする", + description=( + "舌領域の色特徴量(L*a*b* 系の統計量・ヒストグラム)と設定の color 節の分類器で," + "cv と同じ分割・同じ評価の交差検証を行う.各 fold でハイパーパラメータの候補を内側の" + "検証 macro F1 で選ぶ.GPU は使わない.最後に実験 ID を標準出力へ出す." + ), ) + for p in (p_cv, p_color): + p.add_argument("--config", type=Path, required=True, help="実験設定 YAML.") + p.add_argument( + "--out-root", + type=Path, + default=Path("runs"), + help="実験ディレクトリの親(既定: runs).", + ) return parser.parse_args(argv) @@ -482,7 +625,9 @@ args = _parse_args(argv) _configure_logging() try: - return _cmd_cv(args) + if args.subcommand == "color-cv": + return _cmd_run(args, _run_color_cv, check=check_color_config) + return _cmd_run(args, _run_cv) except ValidationError as exc: logger.error("設定が不正である:\n%s", exc) return 1 diff --git a/src/tias_judge_model/data/color_features.py b/src/tias_judge_model/data/color_features.py new file mode 100644 index 0000000..e76e4c2 --- /dev/null +++ b/src/tias_judge_model/data/color_features.py @@ -0,0 +1,138 @@ +"""舌領域の色特徴量(L*a*b* 系の統計量・ヒストグラム)の抽出. + +色特徴+古典的分類器(`training/color_cross_validation.py`)の入力を作る純関数. +舌領域(`color.tongue_mask`.RGB がすべて 0 でない画素)の画素だけを使う.レターボックス縮小は +補間で背景の黒が舌の縁に混ざるため使わず,元の解像度(または `downscale_nearest` による +最近傍縮小.画素の色を変えない)の画像から計算する. + +特徴量(1 画像あたり `NUM_COLOR_FEATURES` 次元.並びは `color_feature_names()`): + +1. チャンネル L*・a*・b*・C*(彩度 = √(a*² + b*²))・h(色相角 = atan2(b*, a*).度)ごとの + 平均・標準偏差(母標準偏差.ddof=0)・分位点(10/25/50/75/90): 5 × 7 = 35 +2. a*-b* の 2 次元ヒストグラム(`AB_HIST_BINS` × `AB_HIST_BINS`.割合に正規化): 64 +3. L* の 1 次元ヒストグラム(`L_HIST_BINS` ビン.割合に正規化): 8 + +ヒストグラムの範囲は固定値とし(画像ごと・fold ごとに変えると特徴の意味がずれるため),範囲外の +画素は端のビンに入れる(割合の合計を 1 に保つため). +""" + +from __future__ import annotations + +import numpy as np +from PIL import Image + +from tias_judge_model.data.color import rgb8_to_lab, tongue_mask + +# 統計量を取るチャンネル(L*・a*・b*・C*・h). +COLOR_CHANNELS: tuple[str, ...] = ("L", "a", "b", "C", "h") +# 分位点(パーセント).分布の形(裾・偏り)を平均と標準偏差より細かく捉えるため. +PERCENTILES: tuple[int, ...] = (10, 25, 50, 75, 90) +# チャンネルごとの統計量の名前(並びは特徴ベクトルの並びと一致させる). +CHANNEL_STATS: tuple[str, ...] = ("mean", "std", *(f"p{q}" for q in PERCENTILES)) + +# a*-b* の 2 次元ヒストグラム.実データの舌領域の画像平均は a* 24〜32・b* 5〜10(TEST_02)で, +# 画素単位ではその周りに ±15 程度広がる(苔・光沢・影).a* [0, 50]・b* [-10, 30] は画像平均の +# 分布を中央に置きつつ画素の広がりの大半を覆い,8 ビンで 1 ビン幅 a* 6.25・b* 5(ΔE の数単位. +# グループ平均どうしの差 ΔE 4〜13 と同程度)になる. +AB_HIST_BINS = 8 +A_RANGE: tuple[float, float] = (0.0, 50.0) +B_RANGE: tuple[float, float] = (-10.0, 30.0) +# L* の 1 次元ヒストグラム.画像平均は L* 46〜57(TEST_02).暗い影(< 20)は端のビンにまとめ, +# 光沢(高い L*)は上のビンに分かれて入るよう上限を 100 にする(1 ビン幅 10). +L_HIST_BINS = 8 +L_RANGE: tuple[float, float] = (20.0, 100.0) + +NUM_COLOR_FEATURES = len(COLOR_CHANNELS) * len(CHANNEL_STATS) + AB_HIST_BINS**2 + L_HIST_BINS + + +def color_feature_names() -> tuple[str, ...]: + """特徴ベクトルの各次元の名前を返す(ログ・重要度の記録用). + + Returns: + 長さ `NUM_COLOR_FEATURES` の名前(例: `L_mean`・`h_p90`・`ab_hist_3_5`・`L_hist_7`). + `ab_hist__` の i は a* のビン,j は b* のビン(0 始まり.値の小さい順). + """ + names = [f"{ch}_{stat}" for ch in COLOR_CHANNELS for stat in CHANNEL_STATS] + names += [f"ab_hist_{i}_{j}" for i in range(AB_HIST_BINS) for j in range(AB_HIST_BINS)] + names += [f"L_hist_{k}" for k in range(L_HIST_BINS)] + return tuple(names) + + +def downscale_nearest(pixels: np.ndarray, max_side: int) -> np.ndarray: + """長辺が `max_side` を超える画像を,アスペクト比を保って最近傍法で縮小する. + + 最近傍法は画素の色を混ぜないため,背景(黒)と舌の画素が縁で混ざらず,舌領域の画素の色は + 元の画像のどれかの画素の色のまま残る.長辺が `max_side` 以下ならそのまま返す. + + Args: + pixels: RGB 画像(`(H, W, 3)`, uint8). + max_side: 縮小後の長辺の上限(ピクセル.1 以上). + + Returns: + RGB 画像(`(H', W', 3)`, uint8.max(H', W') ≤ max_side). + + Raises: + ValueError: `max_side` が 1 未満の場合. + """ + if max_side < 1: + raise ValueError(f"max_side は 1 以上でなければならない(現在 {max_side})") + arr = np.asarray(pixels, dtype=np.uint8) + height, width = arr.shape[:2] + if max(height, width) <= max_side: + return arr + scale = max_side / max(height, width) + new_w = max(1, round(width * scale)) + new_h = max(1, round(height * scale)) + resized = Image.fromarray(arr).resize((new_w, new_h), Image.Resampling.NEAREST) + return np.asarray(resized, dtype=np.uint8) + + +def extract_color_features(pixels: np.ndarray) -> np.ndarray: + """舌領域の画素から色特徴量を求める. + + Args: + pixels: RGB 画像(`(H, W, 3)`, uint8).背景(マスク外)は RGB がすべて 0 の画素. + + Returns: + `(NUM_COLOR_FEATURES,)` の float64 の特徴ベクトル(並びは `color_feature_names()`). + 標準偏差(`*_std`)は母標準偏差(ddof=0). + + Raises: + ValueError: 形状・dtype が不正,または舌領域の画素が 0 の場合. + """ + arr = np.asarray(pixels) + if arr.ndim != 3 or arr.shape[-1] != 3: + raise ValueError(f"画像の形状は (H, W, 3) でなければならない(形状 {arr.shape})") + mask = tongue_mask(arr) + n_pixels = int(mask.sum()) + if n_pixels == 0: + raise ValueError("舌領域の画素が 0 である(全画素が背景の黒)") + + lab = rgb8_to_lab(arr[mask]) # (P, 3) + lightness, a, b = lab[:, 0], lab[:, 1], lab[:, 2] + chroma = np.hypot(a, b) + # 色相角は (-180, 180] の度で表す.舌の色は a* > 0 の赤〜紫の範囲にあり,0° を跨いでも + # 連続になる.不連続点(±180°.緑〜青緑)は舌の色では現れない. + hue = np.degrees(np.arctan2(b, a)) + channels = np.stack([lightness, a, b, chroma, hue], axis=1) # (P, 5) + + means = channels.mean(axis=0) + stds = channels.std(axis=0) # 母標準偏差(ddof=0.舌領域の全画素を母集団とみなす) + quantiles = np.percentile(channels, PERCENTILES, axis=0) # (len(PERCENTILES), 5) + stats = np.concatenate([means[None, :], stds[None, :], quantiles], axis=0) # (7, 5) + channel_features = stats.T.reshape(-1) # チャンネルごとに統計量を並べる + + a_clipped = np.clip(a, *A_RANGE) + b_clipped = np.clip(b, *B_RANGE) + ab_hist, _, _ = np.histogram2d( + a_clipped, b_clipped, bins=AB_HIST_BINS, range=[A_RANGE, B_RANGE] + ) + l_hist, _ = np.histogram(np.clip(lightness, *L_RANGE), bins=L_HIST_BINS, range=L_RANGE) + + return np.concatenate( + [ + channel_features, + ab_hist.reshape(-1) / n_pixels, + l_hist.astype(np.float64) / n_pixels, + ] + ).astype(np.float64) diff --git a/src/tias_judge_model/data/images.py b/src/tias_judge_model/data/images.py index 0e88835..19fb5f4 100644 --- a/src/tias_judge_model/data/images.py +++ b/src/tias_judge_model/data/images.py @@ -41,6 +41,36 @@ return np.asarray(canvas, dtype=np.uint8) +def load_rgb_image(images_dir: Path, record: ManifestRecord, index: int) -> Image.Image: + """manifest の 1 件の画像を元の解像度のまま RGB で読む. + + 読めない画像は学習を歪めるため止める(fail fast).例外メッセージには画像のパスを出さず, + manifest のデータ行の番号(`index + 1`)で示す. + + Args: + images_dir: 画像ディレクトリ(`/images`). + record: manifest レコード. + index: manifest 上の位置(0 始まり.例外メッセージにだけ使う). + + Returns: + RGB の PIL 画像(元の解像度). + + Raises: + ValueError: 画像が無い,または読めない場合. + """ + path = resolve_image_path(images_dir, record) + if not path.is_file(): + raise ValueError(f"manifest のデータ {index + 1} 件目: 画像ファイルが無い") + try: + with Image.open(path) as img: + img.load() + return img.convert("RGB") + except (OSError, ValueError, Image.DecompressionBombError) as exc: + raise ValueError( + f"manifest のデータ {index + 1} 件目: 画像を読めない({type(exc).__name__})" + ) from None + + def load_letterboxed_images( records: Sequence[ManifestRecord], images_dir: Path, size: int ) -> np.ndarray: @@ -62,17 +92,6 @@ """ out = np.zeros((len(records), size, size, 3), dtype=np.uint8) for i, record in enumerate(records): - path = resolve_image_path(images_dir, record) - if not path.is_file(): - raise ValueError(f"manifest のデータ {i + 1} 件目: 画像ファイルが無い") - try: - with Image.open(path) as img: - img.load() - rgb = img.convert("RGB") - except (OSError, ValueError, Image.DecompressionBombError) as exc: - raise ValueError( - f"manifest のデータ {i + 1} 件目: 画像を読めない({type(exc).__name__})" - ) from None - out[i] = letterbox(rgb, size) + out[i] = letterbox(load_rgb_image(images_dir, record, i), size) logger.info("画像 %d 件をレターボックス化した(%d×%d)", len(records), size, size) return out diff --git a/src/tias_judge_model/schema.py b/src/tias_judge_model/schema.py index 2d8dfa5..2e8475e 100644 --- a/src/tias_judge_model/schema.py +++ b/src/tias_judge_model/schema.py @@ -149,6 +149,93 @@ sampler: Literal["random", "balanced"] = "random" +def _check_candidates(values: tuple[Any, ...], key: str) -> tuple[Any, ...]: + # 候補が空だとハイパーパラメータを選べず,重複は同じ設定を 2 度学習するだけなので拒否する. + if not values: + raise ValueError(f"{key} が空である(候補を 1 つ以上)") + if len(set(values)) != len(values): + raise ValueError(f"{key} に重複がある") + return values + + +class LogisticConfig(_StrictModel): + """色特徴+ロジスティック回帰(StandardScaler → 多クラス LogisticRegression)の設定. + + Attributes: + c_values: 正則化の強さの逆数 C の候補(各 fold の内側の検証 macro F1 で 1 つ選ぶ). + 既定は 1e-2〜1e2 を 10 倍刻み(特徴は標準化するので,この幅で弱〜強の正則化を覆う). + max_iter: lbfgs の最大反復回数. + """ + + c_values: tuple[float, ...] = (0.01, 0.1, 1.0, 10.0, 100.0) + max_iter: int = Field(default=5000, ge=1) + + @field_validator("c_values", mode="after") + @classmethod + def _check_c_values(cls, value: tuple[float, ...]) -> tuple[float, ...]: + if any(c <= 0 for c in value): + raise ValueError("color.logistic.c_values は正の値でなければならない") + return _check_candidates(value, "color.logistic.c_values") + + +class HistGradientBoostingConfig(_StrictModel): + """色特徴+勾配ブースティング(HistGradientBoostingClassifier)の設定. + + 候補は `learning_rates` × `max_depths` の全組み合わせ(各 fold の内側の検証 macro F1 で + 1 つ選ぶ).学習データが 1000 枚未満と少ないため,候補は小さく保つ. + + Attributes: + learning_rates: 学習率の候補. + max_depths: 木の深さの上限の候補(`null` は上限なし.葉の数は `max_leaf_nodes` で制限). + max_iter: ブースティングの反復回数(早期終了は使わない.内側の検証は候補選びに使うため). + max_leaf_nodes: 1 本の木の葉の数の上限. + min_samples_leaf: 葉に必要な最小サンプル数(少数クラスへの過学習を抑える). + l2_regularization: L2 正則化の強さ. + """ + + learning_rates: tuple[float, ...] = (0.05, 0.1) + max_depths: tuple[int | None, ...] = (3, None) + max_iter: int = Field(default=200, ge=1) + max_leaf_nodes: int = Field(default=31, ge=2) + min_samples_leaf: int = Field(default=20, ge=1) + l2_regularization: float = Field(default=0.0, ge=0.0) + + @field_validator("learning_rates", mode="after") + @classmethod + def _check_learning_rates(cls, value: tuple[float, ...]) -> tuple[float, ...]: + if any(lr <= 0 for lr in value): + raise ValueError("color.hist_gb.learning_rates は正の値でなければならない") + return _check_candidates(value, "color.hist_gb.learning_rates") + + @field_validator("max_depths", mode="after") + @classmethod + def _check_max_depths(cls, value: tuple[int | None, ...]) -> tuple[int | None, ...]: + if any(d is not None and d < 1 for d in value): + raise ValueError("color.hist_gb.max_depths は 1 以上(または null)でなければならない") + return _check_candidates(value, "color.hist_gb.max_depths") + + +class ColorClassifierConfig(_StrictModel): + """色特徴量による分類器の設定(`tias-train color-cv`). + + 分割・内側の検証の比率・クラス重み(`train.inner_val_ratio`・`train.class_weighting`)は + CNN の交差検証と共通の設定を使う. + + Attributes: + classifier: 分類器(`logistic`: StandardScaler + 多クラス LogisticRegression, + `hist_gb`: HistGradientBoostingClassifier). + max_side: 特徴抽出の前に,長辺がこれを超える画像を最近傍法で縮小する(処理時間の短縮. + 最近傍法は画素の色を混ぜない).`null` なら縮小しない. + logistic: `logistic` のハイパーパラメータ. + hist_gb: `hist_gb` のハイパーパラメータ. + """ + + classifier: Literal["logistic", "hist_gb"] = "logistic" + max_side: int | None = Field(default=512, ge=16) + logistic: LogisticConfig = Field(default_factory=LogisticConfig) + hist_gb: HistGradientBoostingConfig = Field(default_factory=HistGradientBoostingConfig) + + class ExperimentConfig(_StrictModel): """実験設定のルート. @@ -157,12 +244,14 @@ split: 分割の設定. model: モデルの設定(省略時は既定値). train: 学習の設定(省略時は既定値). + color: 色特徴量による分類器の設定(`tias-train color-cv` で使う.CNN の設定では省略). """ data: DataConfig split: SplitConfig = Field(default_factory=SplitConfig) model: ModelConfig = Field(default_factory=ModelConfig) train: TrainConfig = Field(default_factory=TrainConfig) + color: ColorClassifierConfig | None = None def load_config(path: Path, environ: Mapping[str, str] | None = None) -> ExperimentConfig: diff --git a/src/tias_judge_model/training/color_cross_validation.py b/src/tias_judge_model/training/color_cross_validation.py new file mode 100644 index 0000000..4e1258b --- /dev/null +++ b/src/tias_judge_model/training/color_cross_validation.py @@ -0,0 +1,437 @@ +"""色特徴量+古典的分類器の患者単位 K-fold 交差検証(`tias-train color-cv`). + +分割は CNN の交差検証(`cross_validation.py`)と完全に同じ手順・同じ関数で作る: + +1. manifest 全体で患者単位・9 グループ層化の K-fold 分割を作る(`make_kfold_splits`) +2. 各 fold で + 1. 学習側から `train.inner_val_ratio` の患者を検証用に取る(`split_inner_val`.seed は + `fold_seed`) + 2. `data.severities` を学習用と検証用にだけ適用する(`filter_by_severity`) + 3. ハイパーパラメータの候補ごとに学習用データで学習し,検証用の macro F1 が最良の候補を選ぶ + (CNN の「検証 macro F1 が最良のエポックを選ぶ」に対応する.同点なら先の候補) + 4. 選んだ候補のモデル(学習用データのみで学習したもの)で held_out(**全程度**)を 1 回だけ + 予測する + +特徴量は全画像ぶん 1 回だけ抽出して fold 間で使い回す.クラス不均衡の対策は +`train.class_weighting` を学習時の sample_weight として使う(`train.sampler: balanced` は未対応). +ログには件数・ハイパーパラメータ・指標・時間だけを出し,患者 ID・ファイル名は出さない. +""" + +from __future__ import annotations + +import itertools +import logging +import time +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import numpy as np +from sklearn.ensemble import HistGradientBoostingClassifier +from sklearn.linear_model import LogisticRegression +from sklearn.pipeline import Pipeline, make_pipeline +from sklearn.preprocessing import StandardScaler + +from tias_judge_model.data.classes import NUM_CLASSES, label_to_id +from tias_judge_model.data.color_features import ( + NUM_COLOR_FEATURES, + color_feature_names, + downscale_nearest, + extract_color_features, +) +from tias_judge_model.data.images import load_rgb_image +from tias_judge_model.data.manifest import ManifestRecord +from tias_judge_model.data.split import make_kfold_splits +from tias_judge_model.evaluation.metrics import macro_f1 +from tias_judge_model.schema import ColorClassifierConfig, ExperimentConfig +from tias_judge_model.training.cross_validation import ( + CrossValidationResult, + FoldResult, + fold_seed, + log_class_balance, + make_class_balance, + prepare_fold, +) + +logger = logging.getLogger(__name__) + +# metrics.json の environment.model_name に入れる名前の接頭辞(CNN の timm モデル名と区別する). +COLOR_MODEL_PREFIX = "color_" + + +@dataclass +class CandidateScore: + """1 つのハイパーパラメータ候補の内側の検証の結果. + + Attributes: + params: ハイパーパラメータ(JSON に書ける値のみ). + val_macro_f1: 検証用データの macro F1. + fit_seconds: 学習の所要秒数. + """ + + params: dict[str, Any] + val_macro_f1: float + fit_seconds: float + + +@dataclass +class ColorFoldSelection: + """1 fold のハイパーパラメータ選択の記録. + + Attributes: + fold: fold 番号(0 始まり). + selected_params: 選んだハイパーパラメータ. + candidates: 候補ごとの検証の結果(候補の順). + """ + + fold: int + selected_params: dict[str, Any] + candidates: list[CandidateScore] + + +@dataclass +class ColorCrossValidationResult(CrossValidationResult): + """色特徴分類器の交差検証の結果(CNN と同じ形 + 色特徴分類器に固有の記録). + + `folds` の各 `FoldResult` は `history` が空・`best_epoch` が 0 で,`best_val_macro_f1` は選んだ + 候補の検証 macro F1,`train_seconds` は全候補の学習と検証の合計秒数. + `data_loading_seconds` は画像の読み込みと特徴抽出の合計秒数. + + Attributes: + model_name: `color_`(例: `color_logistic`). + feature_names: 特徴量の名前(特徴ベクトルの並び). + selections: fold ごとのハイパーパラメータ選択の記録. + """ + + model_name: str = "" + feature_names: tuple[str, ...] = () + selections: list[ColorFoldSelection] = field(default_factory=list) + + +def color_model_name(config: ColorClassifierConfig) -> str: + """metrics.json に記録するモデル名(`color_logistic`・`color_hist_gb`)を返す. + + Args: + config: 色特徴分類器の設定. + + Returns: + モデル名. + """ + return f"{COLOR_MODEL_PREFIX}{config.classifier}" + + +def candidate_params(config: ColorClassifierConfig) -> list[dict[str, Any]]: + """設定からハイパーパラメータの候補の一覧を作る. + + Args: + config: 色特徴分類器の設定. + + Returns: + 候補の一覧(`logistic`: `{"C"}`,`hist_gb`: `{"learning_rate", "max_depth"}` の + 全組み合わせ).並びは設定の記載順(同点のときは先の候補を選ぶ). + """ + if config.classifier == "logistic": + return [{"C": float(c)} for c in config.logistic.c_values] + return [ + {"learning_rate": float(lr), "max_depth": depth} + for lr, depth in itertools.product(config.hist_gb.learning_rates, config.hist_gb.max_depths) + ] + + +def make_classifier( + config: ColorClassifierConfig, params: Mapping[str, Any], seed: int +) -> Pipeline | HistGradientBoostingClassifier: + """ハイパーパラメータの候補 1 つから未学習の分類器を作る. + + Args: + config: 色特徴分類器の設定. + params: `candidate_params` の要素. + seed: 乱数 seed(fold の seed). + + Returns: + `logistic` なら StandardScaler → LogisticRegression のパイプライン,`hist_gb` なら + HistGradientBoostingClassifier. + """ + if config.classifier == "logistic": + return make_pipeline( + StandardScaler(), + LogisticRegression(C=params["C"], max_iter=config.logistic.max_iter, random_state=seed), + ) + gb = config.hist_gb + return HistGradientBoostingClassifier( + learning_rate=params["learning_rate"], + max_depth=params["max_depth"], + max_iter=gb.max_iter, + max_leaf_nodes=gb.max_leaf_nodes, + min_samples_leaf=gb.min_samples_leaf, + l2_regularization=gb.l2_regularization, + # 内側の検証はハイパーパラメータ選びに使うため,学習用データから更に早期終了用を取らない. + early_stopping=False, + random_state=seed, + ) + + +def fit_classifier( + model: Pipeline | HistGradientBoostingClassifier, + features: np.ndarray, + labels: np.ndarray, + sample_weight: np.ndarray | None, +) -> None: + """分類器を学習する(sample_weight はパイプラインでは最後の分類器にだけ渡す). + + Args: + model: `make_classifier` の戻り値. + features: `(N, F)` の float64 の特徴量. + labels: `(N,)` の整数配列(クラス ID). + sample_weight: `(N,)` の float64 の画像ごとの重み(None なら重みなし). + """ + if sample_weight is None: + model.fit(features, labels) + elif isinstance(model, Pipeline): + model.fit(features, labels, **{f"{model.steps[-1][0]}__sample_weight": sample_weight}) + else: + model.fit(features, labels, sample_weight=sample_weight) + + +def predict_proba_all_classes( + model: Pipeline | HistGradientBoostingClassifier, features: np.ndarray +) -> np.ndarray: + """全クラス(クラス ID 順)の予測確率を返す. + + 学習用データに現れなかったクラスは分類器の `classes_` に無いため,その列を 0 で埋める. + + Args: + model: 学習済みの分類器. + features: `(M, F)` の float64 の特徴量. + + Returns: + `(M, NUM_CLASSES)` の float64 の予測確率(行の和は 1). + """ + probs = model.predict_proba(features) + out = np.zeros((len(features), NUM_CLASSES), dtype=np.float64) + out[:, np.asarray(model.classes_, dtype=np.int64)] = probs + return out + + +def extract_features_for_records( + records: Sequence[ManifestRecord], images_dir: Path, max_side: int | None +) -> np.ndarray: + """manifest の全画像を元の解像度で読み,色特徴量を抽出する. + + Args: + records: manifest レコード. + images_dir: 画像ディレクトリ(`/images`). + max_side: 長辺の上限(最近傍法で縮小する.None なら縮小しない). + + Returns: + `(N, NUM_COLOR_FEATURES)` の float64 の特徴量(並びは `records` と同じ). + + Raises: + ValueError: 画像が無い・読めない,または舌領域の画素が 0 の画像がある場合(位置は manifest + のデータの件数目で示す). + """ + out = np.zeros((len(records), NUM_COLOR_FEATURES), dtype=np.float64) + for i, record in enumerate(records): + pixels = np.asarray(load_rgb_image(images_dir, record, i), dtype=np.uint8) + if max_side is not None: + pixels = downscale_nearest(pixels, max_side) + try: + out[i] = extract_color_features(pixels) + except ValueError as exc: + raise ValueError(f"manifest のデータ {i + 1} 件目: {exc}") from None + logger.info("画像 %d 件から色特徴量(%d 次元)を抽出した", len(records), NUM_COLOR_FEATURES) + return out + + +def check_color_config(config: ExperimentConfig) -> ColorClassifierConfig: + """設定が色特徴分類器の交差検証に使えるか確かめ,`color` 節を返す. + + Args: + config: 実験設定. + + Returns: + `color` 節. + + Raises: + ValueError: `color` 節が無い,または `train.sampler` が `random` でない場合 + (バランス・サンプリングは色特徴分類器では未対応.対策は `train.class_weighting`). + """ + if config.color is None: + raise ValueError("設定に color 節が無い(tias-train color-cv には color 節が必要)") + if config.train.sampler != "random": + raise ValueError( + f"色特徴分類器は train.sampler: {config.train.sampler} に対応していない" + "(random のみ.クラス不均衡の対策は train.class_weighting を使う)" + ) + return config.color + + +def _select_candidate( + color: ColorClassifierConfig, + candidates: Sequence[Mapping[str, Any]], + train_features: np.ndarray, + train_labels: np.ndarray, + sample_weight: np.ndarray | None, + val_features: np.ndarray, + val_labels: np.ndarray, + *, + seed: int, + log_prefix: str, +) -> tuple[Pipeline | HistGradientBoostingClassifier, list[CandidateScore], int]: + """候補ごとに学習用データで学習し,検証 macro F1 が最良の候補を選ぶ. + + Args: + color: 色特徴分類器の設定. + candidates: `candidate_params` の戻り値. + train_features: `(N_train, F)` の float64 の学習用特徴量. + train_labels: `(N_train,)` の int64 のクラス ID. + sample_weight: `(N_train,)` の float64 の画像ごとの重み(None なら重みなし). + val_features: `(N_val, F)` の float64 の検証用特徴量. + val_labels: `(N_val,)` の int64 のクラス ID. + seed: 乱数 seed(fold の seed). + log_prefix: ログの接頭辞(fold の表示). + + Returns: + (選んだ候補の学習済み分類器, 候補ごとの検証の結果(候補の順), 選んだ候補の位置). + + Raises: + RuntimeError: 候補が 0 個の場合(設定の検証で弾かれるため通常は起きない). + """ + scores: list[CandidateScore] = [] + best_model: Pipeline | HistGradientBoostingClassifier | None = None + best_index = -1 + for i, params in enumerate(candidates): + fit_started = time.monotonic() + model = make_classifier(color, params, seed) + fit_classifier(model, train_features, train_labels, sample_weight) + val_pred = predict_proba_all_classes(model, val_features).argmax(axis=1) + score = macro_f1(val_labels, val_pred) + scores.append( + CandidateScore( + params=dict(params), + val_macro_f1=score, + fit_seconds=time.monotonic() - fit_started, + ) + ) + logger.info("%s: 候補 %s の val_macro_f1 %.4f", log_prefix, params, score) + # 厳密な改善(>)でだけ更新し,同点なら先の候補を残す(CNN の早期終了と同じ規則). + if best_model is None or score > scores[best_index].val_macro_f1: + best_model = model + best_index = i + if best_model is None: + raise RuntimeError("ハイパーパラメータの候補が 0 個である") + return best_model, scores, best_index + + +def run_color_cross_validation( + config: ExperimentConfig, records: Sequence[ManifestRecord] +) -> ColorCrossValidationResult: + """色特徴量+古典的分類器で K-fold 交差検証する(重みファイルは保存しない). + + Args: + config: 実験設定(`color` 節が必要). + records: manifest の全レコード(検証済み.除外前). + + Returns: + 交差検証の結果(CNN と同じ `FoldResult` の形 + ハイパーパラメータ選択の記録). + + Raises: + ValueError: `color` 節が無い,`train.sampler` が `balanced`,分割できない,画像を読めない, + 舌領域の無い画像がある,学習用・検証用が空になる,クラス重みを指定したのに学習用データに + 1 枚も無いクラスがある等の入力の問題. + """ + color = check_color_config(config) + records = list(records) + candidates = candidate_params(color) + result = ColorCrossValidationResult( + device="cpu", + model_name=color_model_name(color), + feature_names=color_feature_names(), + ) + logger.info( + "色特徴分類器の交差検証を開始する: 画像 %d 件・%d fold・分類器 %s・候補 %d 個・程度 %s・" + "class_weighting=%s・長辺の上限 %s", + len(records), + config.split.n_folds, + color.classifier, + len(candidates), + list(config.data.severities), + config.train.class_weighting, + color.max_side, + ) + + folds = make_kfold_splits(records, config.split.n_folds, config.split.seed) + + started = time.monotonic() + features = extract_features_for_records(records, config.data.images_dir, color.max_side) + result.data_loading_seconds = time.monotonic() - started + logger.info("画像の読み込みと特徴抽出に %.1f 秒かかった", result.data_loading_seconds) + labels = np.array([label_to_id(r.label) for r in records], dtype=np.int64) + position = {r.image_path: i for i, r in enumerate(records)} + + n_folds = len(folds) + for fold in folds: + prefix = f"fold{fold.index}({fold.index + 1}/{n_folds})" + seed = fold_seed(config.split.seed, fold.index) + # 分割は CNN の交差検証と同じ関数で作る(同じ seed なら同じ学習用・検証用・held_out). + prepared = prepare_fold(fold, records, config, position, seed=seed, log_prefix=prefix) + train_idx, val_idx, held_idx = prepared.train_idx, prepared.val_idx, prepared.held_idx + train_labels = labels[train_idx] + + balance, class_weights, _ = make_class_balance(train_labels, config.train) + log_class_balance(prefix, balance) + sample_weight = None if class_weights is None else class_weights[train_labels] + + train_started = time.monotonic() + best_model, scores, best_index = _select_candidate( + color, + candidates, + features[train_idx], + train_labels, + sample_weight, + features[val_idx], + labels[val_idx], + seed=seed, + log_prefix=prefix, + ) + train_seconds = time.monotonic() - train_started + + eval_started = time.monotonic() + probs = predict_proba_all_classes(best_model, features[held_idx]) + eval_seconds = time.monotonic() - eval_started + best = scores[best_index] + held_acc = float(np.mean(probs.argmax(axis=1) == labels[held_idx])) + logger.info( + "%s: 選んだ候補 %s(val_macro_f1 %.4f)・held_out accuracy %.4f" + "(学習 %.1f 秒・評価 %.2f 秒)", + prefix, + best.params, + best.val_macro_f1, + held_acc, + train_seconds, + eval_seconds, + ) + + result.selections.append( + ColorFoldSelection(fold=fold.index, selected_params=best.params, candidates=scores) + ) + result.folds.append( + FoldResult( + index=fold.index, + seed=seed, + train_patients=prepared.train_patients, + val_patients=prepared.val_patients, + held_out_patients=fold.held_out, + group_counts=prepared.group_counts, + history=[], + best_epoch=0, + best_val_macro_f1=best.val_macro_f1, + train_seconds=train_seconds, + eval_seconds=eval_seconds, + held_out_indices=held_idx, + held_out_probs=probs, + class_balance=balance, + ) + ) + return result diff --git a/src/tias_judge_model/training/cross_validation.py b/src/tias_judge_model/training/cross_validation.py index 0e810d9..b03e148 100644 --- a/src/tias_judge_model/training/cross_validation.py +++ b/src/tias_judge_model/training/cross_validation.py @@ -36,6 +36,7 @@ from tias_judge_model.data.images import load_letterboxed_images from tias_judge_model.data.manifest import ManifestRecord from tias_judge_model.data.split import ( + Fold, count_by_group, filter_by_severity, make_kfold_splits, @@ -196,47 +197,11 @@ for fold in folds: prefix = f"fold{fold.index}({fold.index + 1}/{n_folds})" seed = fold_seed(config.split.seed, fold.index) - train_side = select_records(records, fold.train) - train_patients, val_patients = split_inner_val( - train_side, config.train.inner_val_ratio, seed - ) - train_records = filter_by_severity( - select_records(train_side, train_patients), config.data.severities - ) - val_records = filter_by_severity( - select_records(train_side, val_patients), config.data.severities - ) - # held_out には data.severities を適用しない(全程度で評価する). - held_records = select_records(records, fold.held_out) - if not train_records or not val_records or not held_records: - raise ValueError( - f"{prefix}: 学習用 {len(train_records)} 件・検証用 {len(val_records)} 件・" - f"held_out {len(held_records)} 件のいずれかが 0 件である" - ) - group_counts = { - "train": count_by_group(train_records), - "val": count_by_group(val_records), - "held_out": count_by_group(held_records), - } - logger.info( - "%s: 画像 学習 %d・検証 %d・held_out %d(患者 学習 %d・検証 %d・held_out %d)", - prefix, - len(train_records), - len(val_records), - len(held_records), - len(train_patients), - len(val_patients), - len(fold.held_out), - ) - for name, counts in group_counts.items(): - logger.info("%s: %s のグループ別枚数 %s", prefix, name, counts) - - train_idx = _positions(train_records, position) - val_idx = _positions(val_records, position) - held_idx = _positions(held_records, position) + prepared = prepare_fold(fold, records, config, position, seed=seed, log_prefix=prefix) + train_idx, val_idx, held_idx = prepared.train_idx, prepared.val_idx, prepared.held_idx balance, class_weights, sample_weights = make_class_balance(labels[train_idx], config.train) - _log_class_balance(prefix, balance) + log_class_balance(prefix, balance) seed_everything(seed) model = create_model(config.model.name, pretrained=config.model.pretrained).to(device) @@ -297,10 +262,10 @@ FoldResult( index=fold.index, seed=seed, - train_patients=train_patients, - val_patients=val_patients, + train_patients=prepared.train_patients, + val_patients=prepared.val_patients, held_out_patients=fold.held_out, - group_counts=group_counts, + group_counts=prepared.group_counts, history=trained.history, best_epoch=trained.best_epoch, best_val_macro_f1=trained.best_val_macro_f1, @@ -317,6 +282,99 @@ return result +@dataclass +class PreparedFold: + """1 fold の学習用・検証用・held_out の分け方(CNN と色特徴分類器で共通). + + Attributes: + train_patients: 学習用の患者 ID(除外前.昇順). + val_patients: 検証用の患者 ID(除外前.昇順). + group_counts: 分割名 → 層化グループ → 画像枚数(`train`・`val` は `data.severities` + 適用後,`held_out` は全程度). + train_idx: `(N_train,)` の int64.学習用画像の manifest 上の位置. + val_idx: `(N_val,)` の int64.検証用画像の manifest 上の位置. + held_idx: `(M,)` の int64.held_out 画像の manifest 上の位置. + """ + + train_patients: tuple[str, ...] + val_patients: tuple[str, ...] + group_counts: dict[str, dict[str, int]] + train_idx: np.ndarray + val_idx: np.ndarray + held_idx: np.ndarray + + +def prepare_fold( + fold: Fold, + records: Sequence[ManifestRecord], + config: ExperimentConfig, + position: Mapping[str, int], + *, + seed: int, + log_prefix: str, +) -> PreparedFold: + """fold の学習側から内側の検証用を取り,`data.severities` を学習用と検証用にだけ適用する. + + SPEC_02「交差検証の実行」の手順 2.1・2.2.held_out には `data.severities` を適用しない + (全程度で評価する).件数とグループ別枚数をログに出す(患者 ID は出さない). + + Args: + fold: `make_kfold_splits` の fold. + records: manifest の全レコード(除外前). + config: 実験設定(`train.inner_val_ratio`・`data.severities` を使う). + position: 画像パス → manifest 上の位置(0 始まり). + seed: 内側の検証分割の seed(`fold_seed`). + log_prefix: ログの接頭辞(fold の表示). + + Returns: + 学習用・検証用・held_out の分け方. + + Raises: + ValueError: 内側の検証分割ができない,または学習用・検証用・held_out のいずれかが + 0 件になる場合. + """ + train_side = select_records(records, fold.train) + train_patients, val_patients = split_inner_val(train_side, config.train.inner_val_ratio, seed) + train_records = filter_by_severity( + select_records(train_side, train_patients), config.data.severities + ) + val_records = filter_by_severity( + select_records(train_side, val_patients), config.data.severities + ) + # held_out には data.severities を適用しない(全程度で評価する). + held_records = select_records(records, fold.held_out) + if not train_records or not val_records or not held_records: + raise ValueError( + f"{log_prefix}: 学習用 {len(train_records)} 件・検証用 {len(val_records)} 件・" + f"held_out {len(held_records)} 件のいずれかが 0 件である" + ) + group_counts = { + "train": count_by_group(train_records), + "val": count_by_group(val_records), + "held_out": count_by_group(held_records), + } + logger.info( + "%s: 画像 学習 %d・検証 %d・held_out %d(患者 学習 %d・検証 %d・held_out %d)", + log_prefix, + len(train_records), + len(val_records), + len(held_records), + len(train_patients), + len(val_patients), + len(fold.held_out), + ) + for name, counts in group_counts.items(): + logger.info("%s: %s のグループ別枚数 %s", log_prefix, name, counts) + return PreparedFold( + train_patients=train_patients, + val_patients=val_patients, + group_counts=group_counts, + train_idx=_positions(train_records, position), + val_idx=_positions(val_records, position), + held_idx=_positions(held_records, position), + ) + + def make_class_balance( train_labels: np.ndarray, config: TrainConfig ) -> tuple[ClassBalance, np.ndarray | None, np.ndarray | None]: @@ -355,7 +413,14 @@ return balance, class_weights, sample_weights -def _log_class_balance(prefix: str, balance: ClassBalance) -> None: +def log_class_balance(prefix: str, balance: ClassBalance) -> None: + """fold の学習に使うクラス不均衡の対策(クラス別枚数・重み・抜き出し確率)をログに出す. + + Args: + prefix: ログの接頭辞(fold の表示). + balance: クラス不均衡の対策. + """ + def by_class(values: Sequence[float]) -> dict[str, float]: return {slug: round(v, 4) for slug, v in zip(CLASS_SLUGS, values, strict=True)} diff --git a/tests/cli/test_train_color.py b/tests/cli/test_train_color.py new file mode 100644 index 0000000..9002644 --- /dev/null +++ b/tests/cli/test_train_color.py @@ -0,0 +1,311 @@ +"""tias-train color-cv の結合テスト. + +仕様(実装サマリーの設計を仕様として扱う.出力の規則は SPEC_01「実験の出力」): + +- 出力: config.yaml・log.txt・metrics.json・confusion_matrix.csv・ + figures/confusion_matrix_{all,common}.png・split.json・predictions.csv. + history.csv・learning_curves.png・checkpoints は出さない +- metrics.json: environment.model_name が color_logistic / color_hist_gb,fold ごとに + selected_params と candidates,best_epoch は None,color_classifier(特徴名を含む), + timing.feature_extraction_seconds +- 同じ seed なら `cv` と split.json の分割が一致する +- 回収対象(ログ・標準出力を含む)に患者 ID・ファイル名が出ない +- 最後に実験 ID を標準出力へ出す +- `sampler: balanced`・`color` 節の欠落は,実験ディレクトリを作る前に終了コード 1 で止まる +""" + +from __future__ import annotations + +import contextlib +import csv +import importlib.util +import io +import json +import logging +import re +import sys +from pathlib import Path +from types import ModuleType + +import numpy as np +import pytest +import yaml + +import tias_judge_model.cli.train as cli +from tias_judge_model.data.color_features import NUM_COLOR_FEATURES, color_feature_names +from tias_judge_model.data.manifest import build_manifest_records, write_manifest +from tias_judge_model.remote.whitelist import is_pull_allowed + +pytestmark = pytest.mark.slow + +SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "make_synthetic_dataset.py" +SECRET = "SECRETPT" +GIT = "abc1234" +N_FOLDS = 5 +PER_DIR = 6 +SPLIT_SEED = 3 +COLOR_NAME = "cv_color_tiny" +CNN_NAME = "cv_cnn_tiny" +RECOVERABLE = ["config.yaml", "metrics.json", "confusion_matrix.csv", "log.txt"] +C_VALUES = [0.1, 1.0, 10.0] + + +def _load_script() -> ModuleType: + spec = importlib.util.spec_from_file_location("make_synthetic_dataset_color_cli", 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 _base_config(data_root: Path) -> dict: + return { + "data": {"root": data_root.as_posix(), "severities": ["strong", "normal"]}, + "split": {"seed": SPLIT_SEED, "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, + }, + } + + +def _invoke(argv: list[str]) -> tuple[int, str, str]: + root_logger = logging.getLogger() + old_level = root_logger.level + root_logger.setLevel(logging.INFO) + out, err = io.StringIO(), io.StringIO() + handler = logging.StreamHandler(err) + root_logger.addHandler(handler) + try: + with contextlib.redirect_stdout(out): + rc = cli.main(argv) + finally: + root_logger.removeHandler(handler) + root_logger.setLevel(old_level) + return rc, out.getvalue(), err.getvalue() + + +@pytest.fixture(scope="module") +def run(tmp_path_factory: pytest.TempPathFactory) -> dict: + tmp = tmp_path_factory.mktemp("train_color_cli") + data_root = tmp / "dataset" + _load_script().generate(data_root, per_dir=PER_DIR, seed=0) + for n, png in enumerate(sorted((data_root / "images").rglob("*.png"))): + png.rename(png.with_name(f"{SECRET}{n:04d}.png")) + write_manifest(build_manifest_records(data_root / "images"), data_root / "manifest.csv") + + color_config = tmp / f"{COLOR_NAME}.yaml" + color_config.write_text( + yaml.safe_dump( + { + **_base_config(data_root), + "color": { + "classifier": "logistic", + "max_side": 64, + "logistic": {"c_values": C_VALUES, "max_iter": 2000}, + }, + } + ), + encoding="utf-8", + ) + cnn_config = tmp / f"{CNN_NAME}.yaml" + cnn_config.write_text(yaml.safe_dump(_base_config(data_root)), encoding="utf-8") + + color_root, cnn_root = tmp / "runs_color", tmp / "runs_cnn" + mp = pytest.MonkeyPatch() + mp.delenv("TIAS_DATA_ROOT", raising=False) + mp.setenv("TIAS_GIT_COMMIT", GIT) + try: + rc, stdout, stderr = _invoke( + ["color-cv", "--config", str(color_config), "--out-root", str(color_root)] + ) + cnn_rc, _, _ = _invoke(["cv", "--config", str(cnn_config), "--out-root", str(cnn_root)]) + finally: + mp.undo() + run_dirs = [p for p in color_root.iterdir() if p.is_dir()] + cnn_dirs = [p for p in cnn_root.iterdir() if p.is_dir()] + return { + "tmp": tmp, + "data_root": data_root, + "rc": rc, + "stdout": stdout, + "stderr": stderr, + "run_dirs": run_dirs, + "run_dir": run_dirs[0] if run_dirs else None, + "cnn_rc": cnn_rc, + "cnn_dir": cnn_dirs[0] if cnn_dirs else None, + "n_images": 9 * PER_DIR, + } + + +def _metrics(run: dict) -> dict: + return json.loads((run["run_dir"] / "metrics.json").read_text(encoding="utf-8")) + + +def test_exit_code_and_single_run_dir(run: dict) -> None: + assert run["rc"] == 0 + assert len(run["run_dirs"]) == 1 + + +def test_run_id_is_last_stdout_line(run: dict) -> None: + run_id = run["run_dir"].name + assert re.fullmatch(rf"\d{{8}}-\d{{6}}_{COLOR_NAME}_{GIT}", run_id) + lines = [ln for ln in run["stdout"].splitlines() if ln.strip()] + assert lines[-1] == run_id + + +def test_expected_outputs_exist(run: dict) -> None: + d = run["run_dir"] + for name in [*RECOVERABLE, "split.json", "predictions.csv"]: + assert (d / name).is_file(), name + for name in ("confusion_matrix_all.png", "confusion_matrix_common.png"): + assert (d / "figures" / name).is_file(), name + assert (d / "log.txt").stat().st_size > 0 + + +def test_cnn_only_outputs_are_absent(run: dict) -> None: + d = run["run_dir"] + assert not (d / "history.csv").exists() + assert not (d / "figures" / "learning_curves.png").exists() + assert not (d / "checkpoints").exists() + assert not list(d.rglob("*.pt")) + + +def test_config_yaml_records_command_and_seed(run: dict) -> None: + doc = yaml.safe_load((run["run_dir"] / "config.yaml").read_text(encoding="utf-8")) + assert GIT in json.dumps(doc, ensure_ascii=False) + assert doc["config"]["split"]["seed"] == SPLIT_SEED + assert doc["config"]["color"]["classifier"] == "logistic" + assert doc["seeds"]["split"] == SPLIT_SEED + assert "color-cv" in json.dumps(doc, ensure_ascii=False) + + +def test_metrics_environment_and_color_section(run: dict) -> None: + m = _metrics(run) + assert m["environment"]["model_name"] == "color_logistic" + assert m["best_epoch"] is None + cc = m["color_classifier"] + assert cc["classifier"] == "logistic" + assert cc["feature_names"] == list(color_feature_names()) + assert len(cc["feature_names"]) == NUM_COLOR_FEATURES + assert cc["candidates"] == [{"C": c} for c in C_VALUES] + t = m["timing"] + assert t["feature_extraction_seconds"] >= 0 + assert t["total_seconds"] >= t["feature_extraction_seconds"] + assert m["imbalance"] == {"class_weighting": "none", "sampler": "random"} + + +def test_metrics_folds_have_selection(run: dict) -> None: + m = _metrics(run) + assert len(m["folds"]) == N_FOLDS + for fold in m["folds"]: + assert fold["best_epoch"] is None + cands = fold["candidates"] + assert [c["params"] for c in cands] == [{"C": c} for c in C_VALUES] + assert fold["selected_params"] in [c["params"] for c in cands] + best = max(c["val_macro_f1"] for c in cands) + first_best = next(c for c in cands if c["val_macro_f1"] == best) + assert fold["selected_params"] == first_best["params"] + for subset in ("all", "common", "mild"): + assert {"macro_f1", "accuracy", "per_class", "confusion_matrix"} <= set( + fold["subsets"][subset] + ) + + +def test_held_out_evaluated_on_all_severities(run: dict) -> None: + m = _metrics(run) + assert sum(f["subsets"]["all"]["n"] for f in m["folds"]) == run["n_images"] + assert sum(f["subsets"]["mild"]["n"] for f in m["folds"]) == 4 * PER_DIR + cm = np.array(m["confusion_matrix_sum"]["all"]) + assert cm.shape == (5, 5) + assert cm.sum() == run["n_images"] + + +def test_predictions_cover_manifest_with_five_probabilities(run: dict) -> None: + with (run["run_dir"] / "predictions.csv").open(encoding="utf-8") as f: + rows = list(csv.DictReader(f)) + assert len(rows) == run["n_images"] + assert {r["severity"] for r in rows} == {"strong", "mild", "normal"} + + +def test_split_json_matches_cnn_cv_with_same_seed(run: dict) -> None: + assert run["cnn_rc"] == 0 + color_split = json.loads((run["run_dir"] / "split.json").read_text(encoding="utf-8")) + cnn_split = json.loads((run["cnn_dir"] / "split.json").read_text(encoding="utf-8")) + assert color_split == cnn_split + assert len(color_split["folds"]) == N_FOLDS + + +def test_recoverable_outputs_have_no_patient_ids(run: dict) -> None: + d = run["run_dir"] + for name in RECOVERABLE: + assert SECRET not in (d / name).read_text(encoding="utf-8"), name + for png in (d / "figures").glob("*.png"): + assert SECRET.encode() not in png.read_bytes(), png.name + assert SECRET not in run["stdout"] + assert SECRET not in run["stderr"] + + +def test_every_output_follows_pull_whitelist(run: dict) -> None: + d = run["run_dir"] + for path in d.rglob("*"): + if not path.is_file(): + continue + rel = path.relative_to(d).as_posix() + if rel in RECOVERABLE or (rel.startswith("figures/") and rel.endswith(".png")): + assert is_pull_allowed(rel, with_checkpoints=False), rel + else: + assert rel in {"split.json", "predictions.csv"}, f"想定外の出力: {rel}" + assert not is_pull_allowed(rel, with_checkpoints=True), rel + + +def _write(path: Path, doc: dict) -> Path: + path.write_text(yaml.safe_dump(doc), encoding="utf-8") + return path + + +@pytest.mark.parametrize("case", ["balanced", "no_color"]) +def test_invalid_color_config_stops_before_creating_run_dir(run: dict, case: str) -> None: + tmp, data_root = run["tmp"], run["data_root"] + doc = _base_config(data_root) + if case == "balanced": + doc["train"]["sampler"] = "balanced" + doc["color"] = {"classifier": "logistic"} + config = _write(tmp / f"cv_bad_{case}.yaml", doc) + out_root = tmp / f"runs_bad_{case}" + rc, _, stderr = _invoke(["color-cv", "--config", str(config), "--out-root", str(out_root)]) + assert rc == 1 + assert not out_root.exists() or not any(out_root.iterdir()) + assert SECRET not in stderr + + +def test_hist_gb_run_records_model_name(run: dict) -> None: + tmp, data_root = run["tmp"], run["data_root"] + doc = { + **_base_config(data_root), + "color": { + "classifier": "hist_gb", + "max_side": None, + "hist_gb": {"learning_rates": [0.1], "max_depths": [2, None], "max_iter": 10}, + }, + } + config = _write(tmp / "cv_color_gb_tiny.yaml", doc) + out_root = tmp / "runs_gb" + rc, _, _ = _invoke(["color-cv", "--config", str(config), "--out-root", str(out_root)]) + assert rc == 0 + (run_dir,) = [p for p in out_root.iterdir() if p.is_dir()] + m = json.loads((run_dir / "metrics.json").read_text(encoding="utf-8")) + assert m["environment"]["model_name"] == "color_hist_gb" + assert m["color_classifier"]["candidates"] == [ + {"learning_rate": 0.1, "max_depth": 2}, + {"learning_rate": 0.1, "max_depth": None}, + ] + for fold in m["folds"]: + assert len(fold["candidates"]) == 2 diff --git a/tests/data/test_color_features.py b/tests/data/test_color_features.py new file mode 100644 index 0000000..002afe5 --- /dev/null +++ b/tests/data/test_color_features.py @@ -0,0 +1,317 @@ +"""色特徴量の抽出(`data/color_features.py`)のテスト. + +仕様(実装サマリーの設計を仕様として扱う): + +- `extract_color_features` は 107 次元(L*・a*・b*・C*・h ごとに平均・標準偏差・10/25/50/75/90 + 分位点 = 35,a*-b* 8×8 ヒストグラム = 64,L* 8 ビン = 8).並びは `color_feature_names` +- h は atan2(b*, a*) の度((-180, 180]) +- ヒストグラムは割合(合計 1).範囲(a* [0, 50]・b* [-10, 30]・L* [20, 100])外は端のビン +- 舌領域(RGB がすべて 0 でない画素)だけを使う(背景の量・位置で特徴が変わらない) +- 舌領域が 0 画素なら ValueError(ファイル名は出さない) +- `downscale_nearest` は長辺が max_side を超えるときだけ最近傍法で縮小する(色が変わらない) +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from tias_judge_model.data.color import rgb8_to_lab +from tias_judge_model.data.color_features import ( + NUM_COLOR_FEATURES, + color_feature_names, + downscale_nearest, + extract_color_features, +) + +NAMES = color_feature_names() +AB_BINS = 8 +L_BINS = 8 +A_RANGE = (0.0, 50.0) +B_RANGE = (-10.0, 30.0) +L_RANGE = (20.0, 100.0) + + +def _idx(name: str) -> int: + return NAMES.index(name) + + +def _ab_hist(features: np.ndarray) -> np.ndarray: + return np.array( + [features[_idx(f"ab_hist_{i}_{j}")] for i in range(AB_BINS) for j in range(AB_BINS)] + ).reshape(AB_BINS, AB_BINS) + + +def _l_hist(features: np.ndarray) -> np.ndarray: + return np.array([features[_idx(f"L_hist_{k}")] for k in range(L_BINS)]) + + +def _uniform(rgb: tuple[int, int, int], shape: tuple[int, int] = (6, 5)) -> np.ndarray: + img = np.zeros((*shape, 3), dtype=np.uint8) + img[:] = rgb + return img + + +def _lab(rgb: tuple[int, int, int]) -> np.ndarray: + return rgb8_to_lab(np.array([rgb], dtype=np.uint8))[0] + + +def _random_tongue(rng: np.random.Generator, shape: tuple[int, int]) -> np.ndarray: + # 1〜255 の値だけにして,すべての画素を舌領域にする + return rng.integers(1, 256, size=(*shape, 3), dtype=np.uint8) + + +# --- 次元・名前 --- + + +def test_dimension_is_107() -> None: + assert NUM_COLOR_FEATURES == 107 + assert len(NAMES) == 107 + rng = np.random.default_rng(0) + f = extract_color_features(_random_tongue(rng, (8, 9))) + assert f.shape == (107,) + assert f.dtype == np.float64 + assert np.all(np.isfinite(f)) + + +def test_feature_names_are_unique_and_follow_layout() -> None: + assert len(set(NAMES)) == len(NAMES) + stats = ["mean", "std", "p10", "p25", "p50", "p75", "p90"] + expected_channel = [f"{ch}_{s}" for ch in ("L", "a", "b", "C", "h") for s in stats] + assert list(NAMES[:35]) == expected_channel + assert list(NAMES[35:99]) == [f"ab_hist_{i}_{j}" for i in range(8) for j in range(8)] + assert list(NAMES[99:]) == [f"L_hist_{k}" for k in range(8)] + + +# --- 統計量 --- + + +def test_uniform_color_statistics_match_lab() -> None: + rgb = (225, 140, 140) + f = extract_color_features(_uniform(rgb)) + lightness, a, b = _lab(rgb) + chroma = np.hypot(a, b) + hue = np.degrees(np.arctan2(b, a)) + for ch, value in zip("LabCh", (lightness, a, b, chroma, hue), strict=True): + assert f[_idx(f"{ch}_mean")] == pytest.approx(value, abs=1e-6) + assert f[_idx(f"{ch}_std")] == pytest.approx(0.0, abs=1e-6) + for q in (10, 25, 50, 75, 90): + assert f[_idx(f"{ch}_p{q}")] == pytest.approx(value, abs=1e-6) + + +def test_mean_and_median_over_mixed_pixels() -> None: + # 9 画素が色 B,1 画素が色 A: 平均は重み付き,中央値・p90 は B + img = np.zeros((1, 10, 3), dtype=np.uint8) + color_a, color_b = (120, 20, 30), (225, 140, 140) + img[0, 0] = color_a + img[0, 1:] = color_b + f = extract_color_features(img) + la, lb = _lab(color_a)[0], _lab(color_b)[0] + assert f[_idx("L_mean")] == pytest.approx((la + 9 * lb) / 10, abs=1e-6) + assert f[_idx("L_p50")] == pytest.approx(lb, abs=1e-6) + assert f[_idx("L_p90")] == pytest.approx(lb, abs=1e-6) + assert f[_idx("L_std")] > 0 + # 分位点は単調非減少 + ps = [f[_idx(f"L_p{q}")] for q in (10, 25, 50, 75, 90)] + assert ps == sorted(ps) + + +@pytest.mark.parametrize( + "rgb", + [(225, 140, 140), (110, 50, 140), (0, 255, 0), (0, 0, 255), (40, 200, 220)], + ids=str, +) +def test_hue_is_atan2_degrees_in_range(rgb: tuple[int, int, int]) -> None: + f = extract_color_features(_uniform(rgb)) + _, a, b = _lab(rgb) + h = f[_idx("h_mean")] + assert -180.0 < h <= 180.0 + assert h == pytest.approx(np.degrees(np.arctan2(b, a)), abs=1e-6) + + +def test_hue_sign_follows_b() -> None: + # 紫(a* > 0, b* < 0)は負の色相,淡紅(a* > 0, b* > 0)は 0〜90° + purple = extract_color_features(_uniform((110, 50, 140)))[_idx("h_mean")] + pink = extract_color_features(_uniform((225, 140, 140)))[_idx("h_mean")] + assert purple < 0 + assert 0 < pink < 90 + + +# --- ヒストグラム --- + + +def test_histograms_sum_to_one_and_are_proportions() -> None: + rng = np.random.default_rng(1) + img = _random_tongue(rng, (13, 17)) + img[:3] = 0 # 背景 + f = extract_color_features(img) + ab = _ab_hist(f) + lh = _l_hist(f) + assert ab.sum() == pytest.approx(1.0) + assert lh.sum() == pytest.approx(1.0) + assert np.all(ab >= 0) and np.all(lh >= 0) + # 割合は舌領域の画素数の倍数 + n = 10 * 17 + assert np.allclose(ab * n, np.round(ab * n)) + assert np.allclose(lh * n, np.round(lh * n)) + + +def test_histogram_proportions_three_to_one() -> None: + img = np.zeros((2, 3, 3), dtype=np.uint8) # 2 画素は背景 + img[0, :3] = (225, 140, 140) + img[1, 0] = (255, 255, 255) + f = extract_color_features(img) + lh = _l_hist(f) + assert lh[7] == pytest.approx(0.25) # 白(L* = 100)は最上位ビン + assert sorted(lh[lh > 0].tolist()) == pytest.approx([0.25, 0.75]) + + +def test_in_range_color_goes_to_expected_ab_bin() -> None: + rgb = (225, 140, 140) + lightness, a, b = _lab(rgb) + assert A_RANGE[0] < a < A_RANGE[1] and B_RANGE[0] < b < B_RANGE[1] + i = int((a - A_RANGE[0]) // ((A_RANGE[1] - A_RANGE[0]) / AB_BINS)) + j = int((b - B_RANGE[0]) // ((B_RANGE[1] - B_RANGE[0]) / AB_BINS)) + k = int((lightness - L_RANGE[0]) // ((L_RANGE[1] - L_RANGE[0]) / L_BINS)) + f = extract_color_features(_uniform(rgb)) + ab = _ab_hist(f) + assert ab[i, j] == pytest.approx(1.0) + assert _l_hist(f)[k] == pytest.approx(1.0) + + +@pytest.mark.parametrize( + ("rgb", "ab_bin"), + [ + ((255, 0, 0), (7, 7)), # a* ≈ 80・b* ≈ 67(両方上限超え) + ((0, 255, 0), (0, 7)), # a* ≈ -86(下限未満)・b* ≈ 83(上限超え) + ((0, 0, 255), (7, 0)), # a* ≈ 79(上限超え)・b* ≈ -108(下限未満) + ], + ids=["red", "green", "blue"], +) +def test_out_of_range_ab_goes_to_edge_bin( + rgb: tuple[int, int, int], ab_bin: tuple[int, int] +) -> None: + f = extract_color_features(_uniform(rgb)) + ab = _ab_hist(f) + assert ab[ab_bin] == pytest.approx(1.0) + assert ab.sum() == pytest.approx(1.0) + + +def test_out_of_range_lightness_goes_to_edge_bin() -> None: + img = np.zeros((1, 4, 3), dtype=np.uint8) + img[0, 0] = (5, 5, 5) # L* ≈ 1.4(下限 20 未満)→ 最下位ビン + img[0, 1] = (3, 3, 3) + img[0, 2] = (255, 255, 255) # L* = 100(上限ちょうど)→ 最上位ビン + img[0, 3] = (255, 255, 255) + lh = _l_hist(extract_color_features(img)) + assert lh[0] == pytest.approx(0.5) + assert lh[7] == pytest.approx(0.5) + assert lh.sum() == pytest.approx(1.0) + + +# --- 背景の扱い --- + + +def test_background_amount_and_position_do_not_change_features() -> None: + rng = np.random.default_rng(2) + tongue = _random_tongue(rng, (10, 12)) + small = tongue.copy() + large = np.zeros((40, 55, 3), dtype=np.uint8) + large[17:27, 30:42] = tongue + np.testing.assert_allclose( + extract_color_features(small), extract_color_features(large), rtol=0, atol=1e-9 + ) + + +def test_black_holes_inside_tongue_are_ignored() -> None: + rng = np.random.default_rng(3) + tongue = _random_tongue(rng, (1, 20)) + with_holes = np.zeros((3, 20, 3), dtype=np.uint8) + with_holes[1] = tongue + # 舌の画素の順序を変えても(並べ替え不変),黒を挟んでも同じ + shuffled = np.zeros((20, 2, 3), dtype=np.uint8) + shuffled[:, 0] = tongue[0][::-1] + np.testing.assert_allclose( + extract_color_features(tongue), extract_color_features(with_holes), atol=1e-9 + ) + np.testing.assert_allclose( + extract_color_features(tongue), extract_color_features(shuffled), atol=1e-9 + ) + + +def test_nearly_black_pixel_counts_as_tongue() -> None: + # RGB がすべて 0 でなければ舌領域(1 チャンネルだけ 1 でも数える) + img = np.zeros((3, 3, 3), dtype=np.uint8) + img[1, 1] = (0, 0, 1) + f = extract_color_features(img) + assert _l_hist(f).sum() == pytest.approx(1.0) + assert f[_idx("L_std")] == pytest.approx(0.0, abs=1e-9) + + +def test_no_tongue_pixels_raises_without_file_name() -> None: + with pytest.raises(ValueError) as excinfo: + extract_color_features(np.zeros((5, 5, 3), dtype=np.uint8)) + msg = str(excinfo.value) + assert ".png" not in msg and ".jpg" not in msg + + +@pytest.mark.parametrize("shape", [(5, 5), (5, 5, 4), (5, 5, 1)], ids=str) +def test_invalid_shape_raises(shape: tuple[int, ...]) -> None: + with pytest.raises(ValueError): + extract_color_features(np.full(shape, 100, dtype=np.uint8)) + + +# --- 最近傍縮小 --- + + +def test_downscale_keeps_small_images_unchanged() -> None: + rng = np.random.default_rng(4) + img = _random_tongue(rng, (30, 50)) + np.testing.assert_array_equal(downscale_nearest(img, 50), img) + np.testing.assert_array_equal(downscale_nearest(img, 100), img) + + +@pytest.mark.parametrize( + ("shape", "max_side", "expected"), + [((40, 100), 50, (20, 50)), ((100, 30), 50, (50, 15)), ((64, 64), 16, (16, 16))], +) +def test_downscale_limits_long_side_keeping_aspect( + shape: tuple[int, int], max_side: int, expected: tuple[int, int] +) -> None: + rng = np.random.default_rng(5) + out = downscale_nearest(_random_tongue(rng, shape), max_side) + assert out.shape == (*expected, 3) + assert out.dtype == np.uint8 + assert max(out.shape[:2]) <= max_side + + +def test_downscale_does_not_create_new_colors() -> None: + rng = np.random.default_rng(6) + img = np.zeros((90, 70, 3), dtype=np.uint8) + img[10:80, 10:60] = _random_tongue(rng, (70, 50)) + out = downscale_nearest(img, 23) + src_colors = {tuple(p) for p in img.reshape(-1, 3)} + out_colors = {tuple(p) for p in out.reshape(-1, 3)} + assert out_colors <= src_colors + + +def test_downscale_of_block_image_keeps_features() -> None: + # 2×2 の一様なブロックからなる画像を 1/2 に縮小しても,色の割合は変わらない + rng = np.random.default_rng(7) + small = _random_tongue(rng, (16, 24)) + small[:4, :4] = 0 # 背景も含める + big = np.repeat(np.repeat(small, 2, axis=0), 2, axis=1) # (32, 48, 3) + out = downscale_nearest(big, 24) + assert out.shape == (16, 24, 3) + # 縮小後は各ブロックの色がちょうど 1 画素ずつ残る(色が混ざらず,割合も保たれる) + np.testing.assert_array_equal(out, small) + # 分位点は線形補間のため重複度で僅かに変わりうるので,平均・ヒストグラムで比べる + keep = [i for i, n in enumerate(NAMES) if not n.split("_")[-1].startswith("p")] + f_out, f_big = extract_color_features(out), extract_color_features(big) + np.testing.assert_allclose(f_out[keep], f_big[keep], atol=1e-9) + + +@pytest.mark.parametrize("max_side", [0, -1]) +def test_downscale_rejects_non_positive_max_side(max_side: int) -> None: + with pytest.raises(ValueError): + downscale_nearest(np.ones((4, 4, 3), dtype=np.uint8), max_side) diff --git a/tests/scripts/test_summarize_cv.py b/tests/scripts/test_summarize_cv.py new file mode 100644 index 0000000..5f464e1 --- /dev/null +++ b/tests/scripts/test_summarize_cv.py @@ -0,0 +1,168 @@ +"""scripts/summarize_cv.py のテスト(色特徴分類器の run の集計). + +仕様(実装サマリーの設計を仕様として扱う): + +- color run(environment.model_name が color_logistic / color_hist_gb)を + 「色特徴+ロジスティック回帰」「色特徴+勾配ブースティング」として集計する +- 色特徴の run には学習率を表示しない(_cv_summary.json のキーにも lr を含めない) +- 既存(CNN)の run の出力(表示名・キー)は変わらない + +集計値だけを持つ最小の metrics.json・config.yaml を tmp_path に作って読む. +""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import json +import sys +from pathlib import Path +from types import ModuleType + +import yaml + +SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "summarize_cv.py" +CLASSES = ["pale", "light_red", "red", "dark_red", "purple"] + + +def _load_script() -> ModuleType: + spec = importlib.util.spec_from_file_location("summarize_cv_under_test", 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 _subset(f1: float) -> dict: + return { + "macro_f1": f1, + "accuracy": f1, + "per_class": {c: {"recall": f1} for c in CLASSES}, + } + + +def _write_run( + root: Path, + run_id: str, + model: str, + *, + severities: list[str], + seed: int = 42, + lr: float = 1e-4, + weighting: str = "none", + f1: float = 0.5, +) -> None: + d = root / run_id + d.mkdir(parents=True) + metrics = { + "run_id": run_id, + "severities_trained": severities, + "imbalance": {"class_weighting": weighting, "sampler": "random"}, + "folds": [{"subsets": {"common": _subset(f1), "all": _subset(f1)}} for _ in range(5)], + "pooled": {"mild": {"prediction_breakdown": {"overall": {"light_red": 0.3}}}}, + "timing": {"total_seconds": 10.0}, + "environment": {"model_name": model}, + } + (d / "metrics.json").write_text(json.dumps(metrics), encoding="utf-8") + config = {"config": {"split": {"seed": seed}, "train": {"lr": lr}}} + (d / "config.yaml").write_text(yaml.safe_dump(config), encoding="utf-8") + + +def _summarize(root: Path) -> tuple[str, dict]: + module = _load_script() + out = io.StringIO() + with contextlib.redirect_stdout(out): + module.main(root) + summary = json.loads((root / "_cv_summary.json").read_text(encoding="utf-8")) + return out.getvalue(), summary + + +WITH = ["strong", "mild", "normal"] +WITHOUT = ["strong", "normal"] + + +def test_color_runs_are_labeled_without_lr(tmp_path: Path) -> None: + _write_run( + tmp_path, "20260101-000000_cv_color_logistic_with_mild_x", "color_logistic", severities=WITH + ) + _write_run( + tmp_path, + "20260101-000001_cv_color_logistic_without_mild_x", + "color_logistic", + severities=WITHOUT, + f1=0.6, + ) + _write_run( + tmp_path, "20260101-000002_cv_color_hist_gb_with_mild_x", "color_hist_gb", severities=WITH + ) + text, summary = _summarize(tmp_path) + assert "色特徴+ロジスティック回帰" in text + assert "色特徴+勾配ブースティング" in text + color_lines = [ln for ln in text.splitlines() if "色特徴" in ln] + assert color_lines + assert all("lr" not in ln for ln in color_lines) + assert set(summary["summary"]) == { + "color_logistic/none/with_mild", + "color_logistic/none/without_mild", + "color_hist_gb/none/with_mild", + } + # 込み/除外の対応比較にも lr の無いキーで入る + assert set(summary["paired"]) == {"color_logistic/none"} + assert summary["paired"]["color_logistic/none"]["n"] == 5 + + +def test_color_run_with_class_weighting_label(tmp_path: Path) -> None: + _write_run( + tmp_path, "20260101-000000_cv_color_logistic_with_mild_x", "color_logistic", severities=WITH + ) + _write_run( + tmp_path, + "20260101-000001_cv_color_logistic_with_mild_cwinv_x", + "color_logistic", + severities=WITH, + weighting="inverse", + f1=0.7, + ) + text, summary = _summarize(tmp_path) + assert "色特徴+ロジスティック回帰(重み inverse)" in text + assert "color_logistic/inverse/with_mild" in summary["summary"] + assert all("lr" not in k for k in summary["summary"]) + + +def test_cnn_run_output_is_unchanged(tmp_path: Path) -> None: + _write_run( + tmp_path, "20260101-000000_cv_resnet50_with_mild_x", "resnet50", severities=WITH, lr=1e-3 + ) + _write_run( + tmp_path, + "20260101-000001_cv_resnet50_with_mild_cwinv_x", + "resnet50", + severities=WITH, + lr=1e-3, + weighting="inverse", + ) + text, summary = _summarize(tmp_path) + assert "ResNet-50(lr 0.001)" in text + assert "ResNet-50(lr 0.001・重み inverse)" in text + assert set(summary["summary"]) == { + "resnet50/lr0.001/none/with_mild", + "resnet50/lr0.001/inverse/with_mild", + } + + +def test_cnn_and_color_runs_side_by_side(tmp_path: Path) -> None: + _write_run(tmp_path, "20260101-000000_cv_with_mild_x", "convnext_tiny", severities=WITH) + _write_run( + tmp_path, "20260101-000001_cv_color_logistic_with_mild_x", "color_logistic", severities=WITH + ) + _write_run(tmp_path, "20260101-000002_cv_unknown_x", "resnet18", severities=WITH) + text, summary = _summarize(tmp_path) + assert set(summary["summary"]) == { + "convnext_tiny/lr0.0001/none/with_mild", + "color_logistic/none/with_mild", + } + assert len(summary["runs"]) == 2 # 対象外のモデル(resnet18)は飛ばす + # 並びは CNN → 色特徴 + assert text.index("ConvNeXt-Tiny") < text.index("色特徴+ロジスティック回帰") diff --git a/tests/test_color_config.py b/tests/test_color_config.py new file mode 100644 index 0000000..41ff20a --- /dev/null +++ b/tests/test_color_config.py @@ -0,0 +1,169 @@ +"""色特徴分類器の設定(`color` 節)のテスト. + +仕様(実装サマリーの設計を仕様として扱う): + +- `ExperimentConfig.color` は省略可(既存の CNN 設定はそのまま読め,`color` は None) +- classifier は `logistic` / `hist_gb`.`max_side` は既定 512,`null` で縮小なし +- 候補(`c_values`・`learning_rates`・`max_depths`)の空・重複・非正は拒否.`max_depths` は null 可 +- 未知のキーは拒否 +- `configs/color/*.yaml`(18 本)と `configs/color_synthetic.yaml` がすべて読める +- 色特徴の設定は,対応する CNN の設定と分割(data・split・inner_val_ratio)が同一 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from tias_judge_model.schema import ExperimentConfig, load_config + +CONFIGS_DIR = Path(__file__).resolve().parents[1] / "configs" +COLOR_CONFIGS = sorted((CONFIGS_DIR / "color").glob("*.yaml")) +ALL_CONFIGS = sorted(p for p in CONFIGS_DIR.rglob("*.yaml") if p.name != "remote.yaml") +NO_ENV: dict[str, str] = {} + + +def _with_color(color: dict | None = None, **extra) -> dict: + raw: dict = {"data": {"root": "somewhere"}, **extra} + if color is not None: + raw["color"] = color + return raw + + +# --- 既定値・省略 --- + + +def test_color_is_optional_and_none_by_default() -> None: + assert ExperimentConfig.model_validate(_with_color()).color is None + + +def test_color_defaults() -> None: + c = ExperimentConfig.model_validate(_with_color({})).color + assert c is not None + assert c.classifier == "logistic" + assert c.max_side == 512 + assert len(c.logistic.c_values) >= 1 + assert c.logistic.max_iter >= 1 + assert len(c.hist_gb.learning_rates) >= 1 + assert len(c.hist_gb.max_depths) >= 1 + + +def test_max_side_null_disables_downscale() -> None: + c = ExperimentConfig.model_validate(_with_color({"max_side": None})).color + assert c is not None and c.max_side is None + + +def test_hist_gb_accepts_null_max_depth() -> None: + c = ExperimentConfig.model_validate( + _with_color( + { + "classifier": "hist_gb", + "hist_gb": {"learning_rates": [0.1], "max_depths": [2, None]}, + } + ) + ).color + assert c is not None + assert c.classifier == "hist_gb" + assert tuple(c.hist_gb.max_depths) == (2, None) + + +@pytest.mark.parametrize( + "color", + [ + {"classifier": "svm"}, + {"classifier": ""}, + {"max_side": 0}, + {"max_side": -1}, + {"logistic": {"c_values": []}}, + {"logistic": {"c_values": [1.0, 1.0]}}, + {"logistic": {"c_values": [0.0]}}, + {"logistic": {"c_values": [-1.0, 1.0]}}, + {"logistic": {"max_iter": 0}}, + {"hist_gb": {"learning_rates": []}}, + {"hist_gb": {"learning_rates": [0.1, 0.1]}}, + {"hist_gb": {"learning_rates": [0.0]}}, + {"hist_gb": {"learning_rates": [-0.1]}}, + {"hist_gb": {"max_depths": []}}, + {"hist_gb": {"max_depths": [3, 3]}}, + {"hist_gb": {"max_depths": [None, None]}}, + {"hist_gb": {"max_depths": [0]}}, + {"hist_gb": {"max_depths": [-2]}}, + {"hist_gb": {"max_iter": 0}}, + {"unknown": 1}, + {"logistic": {"C": [1.0]}}, + {"hist_gb": {"lr": [0.1]}}, + ], + ids=str, +) +def test_invalid_color_settings_are_rejected(color: dict) -> None: + with pytest.raises(ValidationError): + ExperimentConfig.model_validate(_with_color(color)) + + +def test_existing_cnn_configs_still_load_without_color() -> None: + cnn = [p for p in ALL_CONFIGS if "color" not in p.as_posix()] + assert cnn + for path in cnn: + assert load_config(path, environ=NO_ENV).color is None, path.name + + +# --- リポジトリの設定ファイル --- + + +def test_color_configs_count() -> None: + assert len(COLOR_CONFIGS) == 18 + + +@pytest.mark.parametrize( + "path", [*COLOR_CONFIGS, CONFIGS_DIR / "color_synthetic.yaml"], ids=lambda p: p.name +) +def test_every_color_config_loads_with_color_section(path: Path) -> None: + c = load_config(path, environ=NO_ENV) + assert c.color is not None + # バランス・サンプリングは色特徴分類器では未対応 + assert c.train.sampler == "random" + + +@pytest.mark.parametrize("path", COLOR_CONFIGS, ids=lambda p: p.name) +def test_color_config_classifier_matches_file_name(path: Path) -> None: + c = load_config(path, environ=NO_ENV) + assert c.color is not None + expected = "hist_gb" if "_hist_gb_" in path.stem else "logistic" + assert c.color.classifier == expected + expected_weighting = "inverse" if path.stem.endswith("_cwinv") else "none" + assert c.train.class_weighting == expected_weighting + + +def _cnn_counterpart(path: Path) -> Path: + # cv_color_<分類器>_<条件>[_sNN][_cwinv] → CNN の cv_<条件>.yaml・repeat/cv_<条件>_sNN.yaml + m = re.fullmatch( + r"cv_color_(?:logistic|hist_gb)_(with_mild|without_mild)(_s\d+)?(_cwinv)?", path.stem + ) + assert m, path.name + cond, seed_suffix = m.group(1), m.group(2) + if seed_suffix: + return CONFIGS_DIR / "repeat" / f"cv_{cond}{seed_suffix}.yaml" + return CONFIGS_DIR / f"cv_{cond}.yaml" + + +@pytest.mark.parametrize("path", COLOR_CONFIGS, ids=lambda p: p.name) +def test_color_config_split_matches_cnn_config(path: Path) -> None: + # 同じ seed の CNN と分割が一致するには,data・split・inner_val_ratio が同一である必要がある + base = _cnn_counterpart(path) + assert base.is_file(), base + c = load_config(path, environ=NO_ENV) + b = load_config(base, environ=NO_ENV) + assert c.data == b.data + assert c.split == b.split + assert c.train.inner_val_ratio == b.train.inner_val_ratio + + +def test_color_synthetic_split_matches_cv_synthetic() -> None: + c = load_config(CONFIGS_DIR / "color_synthetic.yaml", environ=NO_ENV) + b = load_config(CONFIGS_DIR / "cv_synthetic.yaml", environ=NO_ENV) + assert c.data == b.data + assert c.split == b.split + assert c.train.inner_val_ratio == b.train.inner_val_ratio diff --git a/tests/training/test_color_cross_validation.py b/tests/training/test_color_cross_validation.py new file mode 100644 index 0000000..23f3456 --- /dev/null +++ b/tests/training/test_color_cross_validation.py @@ -0,0 +1,415 @@ +"""色特徴分類器の交差検証(`training/color_cross_validation.py`)のテスト. + +仕様(実装サマリーの設計を仕様として扱う): + +- 分割は CNN の交差検証と同一(同じ seed なら学習用・検証用・held_out の患者と画像が一致する). + held_out は全程度,学習用・検証用にだけ `data.severities` を適用する +- 候補ごとに学習用で学習し,内側の検証の macro F1 が**厳密に**上回るときだけ最良を更新する + (同点は先の候補).選んだモデルで held_out を 1 回だけ予測する +- 学習用に無いクラスは確率 0 の列にして 5 クラスを保つ(`predict_proba_all_classes`) +- `train.class_weighting` のクラス重みを sample_weight に使う +- `sampler: balanced` と `color` 節の欠落は `check_color_config` が ValueError +- 舌領域 0 画素の画像は ValueError(ファイル名を出さない) + +合成データ(scripts/make_synthetic_dataset.py)で動かす.交差検証を回すテストは slow. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +import numpy as np +import pytest +from PIL import Image + +import tias_judge_model.training.color_cross_validation as ccv +from tias_judge_model.data.classes import NUM_CLASSES, label_to_id +from tias_judge_model.data.color_features import NUM_COLOR_FEATURES +from tias_judge_model.data.manifest import build_manifest_records, read_manifest, write_manifest +from tias_judge_model.schema import ExperimentConfig +from tias_judge_model.training.color_cross_validation import ( + candidate_params, + check_color_config, + color_model_name, + make_classifier, + predict_proba_all_classes, + run_color_cross_validation, +) + +SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "make_synthetic_dataset.py" +N_FOLDS = 5 +PER_DIR = 6 +SEED = 7 +SECRET = "SECRETPT" + + +def _load_script() -> ModuleType: + spec = importlib.util.spec_from_file_location("make_synthetic_dataset_color_cv", SCRIPT_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _config( + root: Path, + severities: list[str] | None = None, + color: dict | None = None, + train: dict | None = None, + with_color: bool = True, +) -> ExperimentConfig: + raw: dict = { + "data": {"root": str(root), "severities": severities or ["strong", "mild", "normal"]}, + "split": {"seed": SEED, "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 or {}), + }, + } + if with_color: + raw["color"] = color or {"classifier": "logistic", "logistic": {"c_values": [0.1, 1.0]}} + return ExperimentConfig.model_validate(raw) + + +@pytest.fixture(scope="module") +def dataset(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, list]: + root = tmp_path_factory.mktemp("color_cv") / "dataset" + _load_script().generate(root, per_dir=PER_DIR, seed=0) + return root, read_manifest(root / "manifest.csv") + + +# --- 設定の検査・候補(学習しない) --- + + +def test_check_color_config_requires_color_section(tmp_path: Path) -> None: + with pytest.raises(ValueError): + check_color_config(_config(tmp_path, with_color=False)) + + +def test_check_color_config_rejects_balanced_sampler(tmp_path: Path) -> None: + with pytest.raises(ValueError): + check_color_config(_config(tmp_path, train={"sampler": "balanced"})) + + +@pytest.mark.parametrize("weighting", ["none", "inverse", "sqrt_inverse"]) +def test_check_color_config_accepts_class_weighting(tmp_path: Path, weighting: str) -> None: + config = _config(tmp_path, train={"class_weighting": weighting}) + assert check_color_config(config) is config.color + + +def test_run_rejects_balanced_sampler_and_missing_color(dataset) -> None: + root, records = dataset + with pytest.raises(ValueError): + run_color_cross_validation(_config(root, train={"sampler": "balanced"}), records) + with pytest.raises(ValueError): + run_color_cross_validation(_config(root, with_color=False), records) + + +def test_candidate_params_follow_config_order(tmp_path: Path) -> None: + logistic = _config(tmp_path, color={"logistic": {"c_values": [10.0, 0.1, 1.0]}}).color + assert candidate_params(logistic) == [{"C": 10.0}, {"C": 0.1}, {"C": 1.0}] + gb = _config( + tmp_path, + color={ + "classifier": "hist_gb", + "hist_gb": {"learning_rates": [0.1, 0.05], "max_depths": [3, None]}, + }, + ).color + assert candidate_params(gb) == [ + {"learning_rate": 0.1, "max_depth": 3}, + {"learning_rate": 0.1, "max_depth": None}, + {"learning_rate": 0.05, "max_depth": 3}, + {"learning_rate": 0.05, "max_depth": None}, + ] + + +def test_color_model_name(tmp_path: Path) -> None: + assert color_model_name(_config(tmp_path).color) == "color_logistic" + gb = _config(tmp_path, color={"classifier": "hist_gb"}).color + assert color_model_name(gb) == "color_hist_gb" + + +# --- 欠けたクラスの 0 列 --- + + +@pytest.mark.parametrize("classifier", ["logistic", "hist_gb"]) +def test_predict_proba_fills_missing_classes_with_zero(tmp_path: Path, classifier: str) -> None: + rng = np.random.default_rng(0) + present = [0, 2, 4] # 1・3 は学習用に無い + x = np.concatenate([rng.normal(loc=3 * k, size=(30, 4)) for k in present]) + y = np.repeat(present, 30) + color = _config( + tmp_path, + color={ + "classifier": classifier, + "hist_gb": {"max_iter": 10, "min_samples_leaf": 5}, + }, + ).color + model = make_classifier(color, candidate_params(color)[0], seed=0) + model.fit(x, y) + probs = predict_proba_all_classes(model, x[:7]) + assert probs.shape == (7, NUM_CLASSES) + assert np.all(probs[:, [1, 3]] == 0.0) + np.testing.assert_allclose(probs.sum(axis=1), 1.0) + # 存在するクラスの列は分類器の確率と一致する(列の位置がクラス ID に対応する) + np.testing.assert_allclose(probs[:, present], model.predict_proba(x[:7])) + + +def test_predict_proba_keeps_all_five_when_all_present(tmp_path: Path) -> None: + rng = np.random.default_rng(1) + x = np.concatenate([rng.normal(loc=3 * k, size=(20, 3)) for k in range(5)]) + y = np.repeat(np.arange(5), 20) + color = _config(tmp_path).color + model = make_classifier(color, {"C": 1.0}, seed=0) + model.fit(x, y) + probs = predict_proba_all_classes(model, x) + assert probs.shape == (100, 5) + assert (probs.argmax(axis=1) == y).mean() > 0.9 + + +# --- 交差検証(slow) --- + + +@pytest.mark.slow +def test_split_matches_cnn_cross_validation(dataset) -> None: + from tias_judge_model.training.cross_validation import run_cross_validation + + root, records = dataset + for severities in (["strong", "mild", "normal"], ["strong", "normal"]): + config = _config(root, severities) + color = run_color_cross_validation(config, records) + cnn = run_cross_validation(config, records, root.parent / f"ck_{len(severities)}") + assert len(color.folds) == len(cnn.folds) == N_FOLDS + for fc, fn in zip(color.folds, cnn.folds, strict=True): + assert fc.index == fn.index + assert fc.seed == fn.seed + assert fc.train_patients == fn.train_patients + assert fc.val_patients == fn.val_patients + assert fc.held_out_patients == fn.held_out_patients + assert fc.group_counts == fn.group_counts + np.testing.assert_array_equal(fc.held_out_indices, fn.held_out_indices) + + +@pytest.mark.slow +def test_patients_disjoint_and_held_out_covers_manifest(dataset) -> None: + root, records = dataset + result = run_color_cross_validation(_config(root), records) + all_held: list[int] = [] + for fold in result.folds: + train, val, held = ( + set(fold.train_patients), + set(fold.val_patients), + set(fold.held_out_patients), + ) + assert not (train & val) and not (train & held) and not (val & held) + all_held += [int(i) for i in fold.held_out_indices] + # held_out の画像は held_out の患者のもの + assert {records[int(i)].patient_id for i in fold.held_out_indices} == held + assert sorted(all_held) == list(range(len(records))) + + +@pytest.mark.slow +def test_held_out_uses_all_severities_but_training_excludes_mild(dataset) -> None: + root, records = dataset + result = run_color_cross_validation(_config(root, ["strong", "normal"]), records) + n_mild_held = 0 + for fold in result.folds: + for split in ("train", "val"): + counts = fold.group_counts[split] + assert all(n == 0 for g, n in counts.items() if g.endswith("_mild")), split + assert sum(counts.values()) > 0, split + n_mild_held += sum(records[int(i)].severity == "mild" for i in fold.held_out_indices) + assert n_mild_held == 4 * PER_DIR + + +@pytest.mark.slow +def test_result_shapes_and_metadata(dataset) -> None: + root, records = dataset + result = run_color_cross_validation(_config(root), records) + assert result.model_name == "color_logistic" + assert len(result.feature_names) == NUM_COLOR_FEATURES + assert len(result.selections) == N_FOLDS + for fold, sel in zip(result.folds, result.selections, strict=True): + assert sel.fold == fold.index + assert fold.history == [] + assert fold.held_out_probs.shape == (len(fold.held_out_indices), NUM_CLASSES) + np.testing.assert_allclose(fold.held_out_probs.sum(axis=1), 1.0) + assert [c.params for c in sel.candidates] == [{"C": 0.1}, {"C": 1.0}] + assert sel.selected_params in [c.params for c in sel.candidates] + # 選んだ候補の検証 macro F1 は候補の最大 + assert fold.best_val_macro_f1 == max(c.val_macro_f1 for c in sel.candidates) + + +@pytest.mark.slow +def test_results_are_reproducible(dataset) -> None: + root, records = dataset + config = _config(root, color={"classifier": "hist_gb", "hist_gb": {"max_iter": 10}}) + a = run_color_cross_validation(config, records) + b = run_color_cross_validation(config, records) + for fa, fb in zip(a.folds, b.folds, strict=True): + np.testing.assert_allclose(fa.held_out_probs, fb.held_out_probs) + assert [s.selected_params for s in a.selections] == [s.selected_params for s in b.selections] + + +@pytest.mark.slow +def test_synthetic_colors_are_learnable(dataset) -> None: + # 合成データはクラスごとに色が大きく異なるため,強い程度の held_out はほぼ当たる + root, records = dataset + result = run_color_cross_validation(_config(root), records) + correct = total = 0 + for fold in result.folds: + for i, p in zip(fold.held_out_indices, fold.held_out_probs, strict=True): + r = records[int(i)] + if r.severity != "mild": + total += 1 + correct += int(p.argmax() == label_to_id(r.label)) + assert correct / total > 0.8 + + +@pytest.mark.slow +def test_missing_training_class_gets_zero_probability(dataset) -> None: + # severities=[strong] では淡紅(normal のみ)が学習用に無い.held_out には淡紅がある + root, records = dataset + result = run_color_cross_validation(_config(root, ["strong"]), records) + light_red = label_to_id("light_red") + for fold in result.folds: + assert fold.held_out_probs.shape[1] == NUM_CLASSES + assert np.all(fold.held_out_probs[:, light_red] == 0.0) + np.testing.assert_allclose(fold.held_out_probs.sum(axis=1), 1.0) + held_labels = {records[int(i)].label for i in fold.held_out_indices} + assert "light_red" in held_labels + + +# 候補の検証スコアの筋書き(fold ごと)と,期待する選択(候補の位置) +SCORE_SCRIPTS = [ + ([0.5, 0.7, 0.7, 0.6], 1), # 同点は先の候補 + ([0.3, 0.3, 0.3, 0.3], 0), # 全部同点なら最初 + ([0.2, 0.4, 0.6, 0.8], 3), # 単調増加なら最後 + ([0.9, 0.1, 0.9, 0.95], 3), # 厳密に上回れば更新 + ([0.0, 0.0, 0.1, 0.1], 2), +] + + +@pytest.mark.slow +def test_selection_rule_strict_improvement_and_held_out_uses_selected_model( + dataset, monkeypatch: pytest.MonkeyPatch +) -> None: + root, records = dataset + c_values = [0.01, 0.1, 1.0, 10.0] + config = _config(root, color={"logistic": {"c_values": c_values}}) + scores = iter([s for script, _ in SCORE_SCRIPTS for s in script]) + monkeypatch.setattr(ccv, "macro_f1", lambda y_true, y_pred: next(scores)) + + created: list = [] + real_make = ccv.make_classifier + + def spy_make(color, params, seed): + model = real_make(color, params, seed) + created.append(model) + return model + + predicted: list = [] + real_predict = ccv.predict_proba_all_classes + + def spy_predict(model, features): + out = real_predict(model, features) + predicted.append((model, out)) + return out + + monkeypatch.setattr(ccv, "make_classifier", spy_make) + monkeypatch.setattr(ccv, "predict_proba_all_classes", spy_predict) + + result = run_color_cross_validation(config, records) + n_cand = len(c_values) + assert len(created) == N_FOLDS * n_cand + # 1 fold あたり 検証の予測が候補数 + held_out の予測が 1 回 + assert len(predicted) == N_FOLDS * (n_cand + 1) + for k, ((script, expected), fold, sel) in enumerate( + zip(SCORE_SCRIPTS, result.folds, result.selections, strict=True) + ): + assert sel.selected_params == {"C": c_values[expected]}, k + assert [c.val_macro_f1 for c in sel.candidates] == script + assert fold.best_val_macro_f1 == script[expected] + held_model, held_probs = predicted[k * (n_cand + 1) + n_cand] + assert held_model is created[k * n_cand + expected] + np.testing.assert_array_equal(held_probs, fold.held_out_probs) + + +@pytest.mark.slow +@pytest.mark.parametrize("weighting", ["none", "inverse"]) +def test_class_weighting_is_used_as_sample_weight( + dataset, monkeypatch: pytest.MonkeyPatch, weighting: str +) -> None: + root, records = dataset + config = _config(root, ["strong", "mild", "normal"], train={"class_weighting": weighting}) + calls: list = [] + real_fit = ccv.fit_classifier + + def spy_fit(model, features, labels, sample_weight): + calls.append((labels.copy(), None if sample_weight is None else sample_weight.copy())) + return real_fit(model, features, labels, sample_weight) + + monkeypatch.setattr(ccv, "fit_classifier", spy_fit) + result = run_color_cross_validation(config, records) + assert calls + for labels, weights in calls: + if weighting == "none": + assert weights is None + continue + assert weights is not None and weights.shape == labels.shape + counts = np.bincount(labels, minlength=NUM_CLASSES) + for c in np.unique(labels): + w = weights[labels == c] + assert np.all(w == w[0]) # 同じクラスは同じ重み + # inverse: 重み × 枚数 はクラスによらず一定 + per_class = {int(c): weights[labels == c][0] * counts[c] for c in np.unique(labels)} + values = list(per_class.values()) + np.testing.assert_allclose(values, values[0]) + for fold in result.folds: + assert fold.class_balance is not None + + +def _write_single_black(root: Path) -> Path: + # 合成データのうち 1 枚を全画素黒(舌領域 0 画素)にし,ファイル名を目立つ文字列にする + _load_script().generate(root, per_dir=PER_DIR, seed=1) + pngs = sorted((root / "images").rglob("*.png")) + for n, png in enumerate(pngs): + png.rename(png.with_name(f"{SECRET}{n:04d}.png")) + target = sorted((root / "images").rglob("*.png"))[3] + Image.fromarray(np.zeros((20, 20, 3), dtype=np.uint8)).save(target) + write_manifest(build_manifest_records(root / "images"), root / "manifest.csv") + return target + + +@pytest.mark.slow +def test_image_without_tongue_raises_without_file_name(tmp_path: Path) -> None: + root = tmp_path / "dataset" + target = _write_single_black(root) + records = read_manifest(root / "manifest.csv") + with pytest.raises(ValueError) as excinfo: + run_color_cross_validation(_config(root), records) + msg = str(excinfo.value) + assert SECRET not in msg + assert target.name not in msg + assert "件目" in msg # 位置は件数で示す + + +@pytest.mark.slow +def test_max_side_downscale_and_none_both_run(dataset) -> None: + root, records = dataset + small = run_color_cross_validation(_config(root, color={"max_side": 16}), records) + full = run_color_cross_validation(_config(root, color={"max_side": None}), records) + for fs, ff in zip(small.folds, full.folds, strict=True): + assert fs.held_out_probs.shape == ff.held_out_probs.shape + assert fs.held_out_patients == ff.held_out_patients