Newer
Older
TIASJudgeModel / tests / training / test_cross_validation_imbalance.py
"""交差検証でのクラス不均衡の対策のテスト(SPEC_02「クラス不均衡」→「対策の設定」).

- クラスの枚数 n_c は各 fold の**学習用データ(`data.severities` 適用後)**から数える
  (検証用・held_out は使わない).「ややあり除外」なら mild を除いた枚数で重みが決まる
- バランス・サンプリングは各画像を 1 / n_c に比例する確率で重複ありで抜き出し,1 回の抜き出しで
  各クラスがほぼ等確率.1 エポックの枚数は学習画像数と同じ.同じ seed で同じ抜き出し順
- 既定(`random`)はシャッフルで,各画像を 1 エポックに 1 回ずつ使う
"""

from __future__ import annotations

import importlib.util
import sys
from collections import Counter
from pathlib import Path
from types import ModuleType

import numpy as np
import pytest
import torch

from tias_judge_model.data.class_balance import balanced_sample_weights, compute_class_weights
from tias_judge_model.data.classes import CLASS_SLUGS, NUM_CLASSES
from tias_judge_model.data.manifest import read_manifest
from tias_judge_model.schema import ExperimentConfig, TrainConfig
from tias_judge_model.training.cross_validation import (
    CrossValidationResult,
    _make_loader,
    run_cross_validation,
)

SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "make_synthetic_dataset.py"
N_FOLDS = 5
CPU = torch.device("cpu")

# 不均衡な学習データ(クラス ID 順)
COUNTS = np.array([4, 24, 12, 8, 6])  # N = 54


def _labels() -> np.ndarray:
    labels = np.repeat(np.arange(NUM_CLASSES), COUNTS)
    np.random.default_rng(0).shuffle(labels)
    return labels


def _drawn_labels(loader) -> list[int]:
    return [int(y) for _, ys in loader for y in ys]


def _balanced_loader(seed: int, labels: np.ndarray):

    images = np.zeros((len(labels), 16, 16, 3), dtype=np.uint8)
    config = TrainConfig.model_validate(
        {"batch_size": 128, "num_workers": 0, "sampler": "balanced", "image_size": 16}
    )
    return _make_loader(
        images,
        labels,
        config,
        CPU,
        train=True,
        seed=seed,
        sample_weights=balanced_sample_weights(labels),
    )


class TestBalancedLoader:
    def test_epoch_size_equals_number_of_training_images(self) -> None:
        labels = _labels()
        drawn = _drawn_labels(_balanced_loader(0, labels))
        assert len(drawn) == len(labels)

    def test_draws_with_replacement(self) -> None:
        # 4 枚しか無い pale も約 54 / 5 ≒ 11 回抜き出される → 重複が必要
        labels = _labels()
        loader = _balanced_loader(0, labels)
        counts = Counter()
        for _ in range(5):
            counts.update(_drawn_labels(loader))
        assert counts[0] > 5 * COUNTS[0]

    def test_each_class_drawn_about_equally(self) -> None:
        labels = _labels()
        loader = _balanced_loader(0, labels)
        drawn: list[int] = []
        for _ in range(40):
            drawn.extend(_drawn_labels(loader))
        freq = np.bincount(drawn, minlength=NUM_CLASSES) / len(drawn)
        # 抜き出し 2160 回.一様(0.2)から ±0.04 以内(二項分布の標準偏差 ≒ 0.0086)
        np.testing.assert_allclose(freq, 1 / NUM_CLASSES, atol=0.04)
        # 元の比率(light_red 0.44・pale 0.07)とは明確に違う
        assert freq[1] < 0.3
        assert freq[0] > 0.12

    def test_same_seed_gives_same_draw_order(self) -> None:
        labels = _labels()
        a = [_drawn_labels(loader) for loader in [_balanced_loader(5, labels)] for _ in range(3)]
        b = [_drawn_labels(loader) for loader in [_balanced_loader(5, labels)] for _ in range(3)]
        assert a == b

    def test_different_seed_gives_different_draw_order(self) -> None:
        labels = _labels()
        assert _drawn_labels(_balanced_loader(1, labels)) != _drawn_labels(
            _balanced_loader(2, labels)
        )


def test_random_sampler_uses_each_image_once_per_epoch() -> None:
    labels = _labels()
    images = np.zeros((len(labels), 16, 16, 3), dtype=np.uint8)
    config = TrainConfig.model_validate({"batch_size": 128, "num_workers": 0, "image_size": 16})
    loader = _make_loader(images, labels, config, CPU, train=True, seed=0)
    drawn = _drawn_labels(loader)
    assert sorted(drawn) == sorted(labels.tolist())


# --- 交差検証を実際に回す(slow) ---


def _load_script() -> ModuleType:
    spec = importlib.util.spec_from_file_location("make_synthetic_dataset_cv_imb", SCRIPT_PATH)
    assert spec is not None and spec.loader is not None
    module = importlib.util.module_from_spec(spec)
    sys.modules[spec.name] = module
    spec.loader.exec_module(module)
    return module


def _config(root: Path, severities: list[str], **train: str) -> ExperimentConfig:
    return ExperimentConfig.model_validate(
        {
            "data": {"root": str(root), "severities": severities},
            "split": {"seed": 7, "n_folds": N_FOLDS},
            "model": {"name": "resnet18", "pretrained": False},
            "train": {
                "image_size": 32,
                "batch_size": 8,
                "max_epochs": 1,
                "warmup_epochs": 0,
                "inner_val_ratio": 0.2,
                "num_workers": 0,
                "amp": False,
                **train,
            },
        }
    )


@pytest.fixture(scope="module")
def imbalance_runs(
    tmp_path_factory: pytest.TempPathFactory,
) -> tuple[CrossValidationResult, CrossValidationResult]:
    root = tmp_path_factory.mktemp("cv_imb") / "dataset"
    _load_script().generate(root, per_dir=6, seed=0)
    records = read_manifest(root / "manifest.csv")
    weighted_without_mild = run_cross_validation(
        _config(root, ["strong", "normal"], class_weighting="inverse"),
        records,
        root.parent / "ck_w",
    )
    balanced_with_mild = run_cross_validation(
        _config(root, ["strong", "mild", "normal"], sampler="balanced"),
        records,
        root.parent / "ck_b",
    )
    return weighted_without_mild, balanced_with_mild


def _train_counts_by_class(group_counts: dict[str, int]) -> list[int]:
    out = [0] * NUM_CLASSES
    for group, n in group_counts.items():
        label = next(s for s in sorted(CLASS_SLUGS, key=len, reverse=True) if group.startswith(s))
        out[CLASS_SLUGS.index(label)] += n
    return out


@pytest.mark.slow
def test_class_counts_come_from_training_data_after_severities(imbalance_runs) -> None:
    weighted, balanced = imbalance_runs
    for result, mild_expected in ((weighted, False), (balanced, True)):
        for f in result.folds:
            b = f.class_balance
            assert b is not None
            train_groups = f.group_counts["train"]
            assert list(b.class_counts) == _train_counts_by_class(train_groups)
            has_mild = sum(n for g, n in train_groups.items() if g.endswith("_mild")) > 0
            assert has_mild is mild_expected
            # 検証用・held_out を含めた枚数ではない
            with_val = [
                a + c
                for a, c in zip(
                    _train_counts_by_class(train_groups),
                    _train_counts_by_class(f.group_counts["val"]),
                    strict=True,
                )
            ]
            assert list(b.class_counts) != with_val


@pytest.mark.slow
def test_weights_follow_formula_on_fold_training_counts(imbalance_runs) -> None:
    weighted, _ = imbalance_runs
    for f in weighted.folds:
        b = f.class_balance
        assert b.class_weighting == "inverse"
        assert b.sampler == "random"
        assert b.class_draw_probabilities is None
        labels = np.repeat(np.arange(NUM_CLASSES), b.class_counts)
        np.testing.assert_allclose(
            b.class_weights, compute_class_weights(labels, "inverse"), rtol=1e-9
        )
        n = np.array(b.class_counts, dtype=float)
        assert float(np.sum(n / n.sum() * np.array(b.class_weights))) == pytest.approx(1.0)


@pytest.mark.slow
def test_without_mild_weights_use_counts_excluding_mild(imbalance_runs) -> None:
    # 分割は data.severities に関わらず同じなので,ややあり込みの run の同じ fold の学習用枚数から
    # mild を引いたものが,ややあり除外の学習用枚数になる
    weighted, balanced = imbalance_runs
    for fw, fb in zip(weighted.folds, balanced.folds, strict=True):
        inclusive = _train_counts_by_class(fb.group_counts["train"])
        mild = _train_counts_by_class(
            {g: n for g, n in fb.group_counts["train"].items() if g.endswith("_mild")}
        )
        excluding = [a - m for a, m in zip(inclusive, mild, strict=True)]
        assert list(fw.class_balance.class_counts) == excluding
        assert list(fb.class_balance.class_counts) == inclusive
        # 合成データは各ディレクトリ同数なので,mild 込みなら淡紅(normal のみ)の重みが最大
        # になるが,
        # mild を除くと淡紅も他と同程度になる.重みは mild を除いた枚数の式に従う(上のテスト)
        w_inclusive = compute_class_weights(np.repeat(np.arange(NUM_CLASSES), inclusive), "inverse")
        assert int(np.argmax(w_inclusive)) == CLASS_SLUGS.index("light_red")
        assert not np.allclose(fw.class_balance.class_weights, w_inclusive)


@pytest.mark.slow
def test_balanced_sampler_records_equal_draw_probabilities(imbalance_runs) -> None:
    _, balanced = imbalance_runs
    for f in balanced.folds:
        b = f.class_balance
        assert b.sampler == "balanced"
        assert b.class_weighting == "none"
        assert b.class_weights is None
        np.testing.assert_allclose(b.class_draw_probabilities, 1 / NUM_CLASSES, rtol=1e-9)


@pytest.mark.slow
def test_training_runs_with_countermeasures(imbalance_runs) -> None:
    for result in imbalance_runs:
        assert len(result.folds) == N_FOLDS
        for f in result.folds:
            assert np.isfinite(f.history[0].train_loss)
            assert np.isfinite(f.history[0].val_loss)