"""クラス不均衡への対策の計算(SPEC_02「クラス不均衡」→「対策の設定」).
学習ラベルから,損失のクラス重みとバランス・サンプリングの抜き出し重みを求める純関数.
クラスの画像数 n_c は呼び出し側が渡す**各 fold の学習用データ(`data.severities` 適用後)**の
ラベルから数える(検証用・held_out は使わない).
"""
from __future__ import annotations
import numpy as np
from tias_judge_model.data.classes import CLASS_SLUGS, NUM_CLASSES
# 損失のクラス重みの方式(`train.class_weighting` のうち `none` 以外).
CLASS_WEIGHTING_METHODS: tuple[str, ...] = ("inverse", "sqrt_inverse")
def count_classes(labels: np.ndarray) -> np.ndarray:
"""クラス ID ごとの画像数を数える.
Args:
labels: `(N,)` の整数配列(クラス ID.`classes.py` の固定 ID).
Returns:
`(NUM_CLASSES,)` の int64 配列(クラス ID 順の画像数).
Raises:
ValueError: 範囲外のクラス ID を含む場合.
"""
labels = np.asarray(labels, dtype=np.int64)
if labels.size and (labels.min() < 0 or labels.max() >= NUM_CLASSES):
raise ValueError(f"クラス ID が範囲外である(0〜{NUM_CLASSES - 1})")
return np.bincount(labels, minlength=NUM_CLASSES).astype(np.int64)
def _require_all_classes(counts: np.ndarray) -> None:
missing = [CLASS_SLUGS[i] for i, n in enumerate(counts) if n == 0]
if missing:
raise ValueError(
f"学習用データに 1 枚も無いクラスがあるため,クラス不均衡の対策を計算できない: "
f"{', '.join(missing)}"
)
def compute_class_weights(labels: np.ndarray, method: str) -> np.ndarray:
"""学習ラベルから損失のクラス重みを求める.
クラス c の画像数を n_c,全体を N,クラス数を K として,`inverse` は w_c ∝ N / (K・n_c),
`sqrt_inverse` はその平方根に比例する.学習データでの平均が 1 になるよう
Σ_c (n_c / N)・w_c = 1 に正規化する(損失の大きさを対策なしと揃えるため).
Args:
labels: `(N,)` の整数配列(学習用データのクラス ID).
method: `inverse` または `sqrt_inverse`.
Returns:
`(NUM_CLASSES,)` の float64 配列(クラス ID 順の重み).
Raises:
ValueError: 未知の方式,または学習用データに 1 枚も無いクラスがある場合.
"""
if method not in CLASS_WEIGHTING_METHODS:
valid = ", ".join(CLASS_WEIGHTING_METHODS)
raise ValueError(f"未知のクラス重みの方式である: {method!r}(有効値: {valid})")
counts = count_classes(labels)
_require_all_classes(counts)
n = counts.astype(np.float64)
total = n.sum()
raw = total / (NUM_CLASSES * n)
if method == "sqrt_inverse":
raw = np.sqrt(raw)
return raw / float(np.sum(n / total * raw))
def balanced_sample_weights(labels: np.ndarray) -> np.ndarray:
"""バランス・サンプリングの画像ごとの抜き出し重み(1 / n_c)を求める.
重みの合計は K(クラス数)になり,どのクラスも 1 回の抜き出しで 1 / K の確率で選ばれる.
Args:
labels: `(N,)` の整数配列(学習用データのクラス ID).
Returns:
`(N,)` の float64 配列(各画像の抜き出し重み.並びは `labels` の順).
Raises:
ValueError: 学習用データに 1 枚も無いクラスがある場合.
"""
labels = np.asarray(labels, dtype=np.int64)
counts = count_classes(labels)
_require_all_classes(counts)
return 1.0 / counts[labels].astype(np.float64)
def class_draw_probabilities(labels: np.ndarray, sample_weights: np.ndarray) -> np.ndarray:
"""画像ごとの抜き出し重みから,1 回の抜き出しで各クラスが選ばれる確率を求める.
Args:
labels: `(N,)` の整数配列(学習用データのクラス ID).
sample_weights: `(N,)` の非負の抜き出し重み.
Returns:
`(NUM_CLASSES,)` の float64 配列(クラス ID 順.合計 1).
Raises:
ValueError: 件数が一致しない,または重みの合計が 0 以下の場合.
"""
labels = np.asarray(labels, dtype=np.int64)
weights = np.asarray(sample_weights, dtype=np.float64)
if labels.shape != weights.shape:
raise ValueError("ラベルと抜き出し重みの件数が一致しない")
total = weights.sum()
if total <= 0:
raise ValueError("抜き出し重みの合計が 0 以下である")
per_class = np.bincount(labels, weights=weights, minlength=NUM_CLASSES)
return per_class / total