Day 137 — 高度なアンサンブル② — Iterative Pseudo Labelingとconfirmation bias / selection biasの実測

2026-08-27 金 / Phase 6(Grandmaster、テーマ3群 の 2日目) コーディング Iterative Pseudo Labeling / confirmation bias / selection bias

📚 背景知識(読んでから問題へ)

🔁
Phase6テーマ3「高度なアンサンブル」の2日目です。Day136では「1回だけ」のPseudo Labelingを扱いましたが、今日はそれを拡張し、学習→予測→採用→再学習を複数ラウンド繰り返すIterative Pseudo Labelingを実装します。

Iterative Pseudo Labelingの5ステップ(繰り返し版)

1
現在の学習データ(最初はラベル付き300件のみ)でモデルを学習する
2
まだ疑似ラベルを付けていない残りのプールを予測する
3
確信度が閾値以上のサンプルを選び、疑似ラベルとして学習データに追加・プールから除外する
4
拡張された学習データで再度モデルを学習し直し、test精度を測る
5
ステップ2〜4を、採用0件になるか決めたラウンド数に達するまで繰り返す
❓
今日確かめたい2つの問い:
問い1 — ラウンドを重ねると、どこかの時点でtest精度がベースラインを下回る「境目のラウンド」が現れるか?
問い2 — confirmation biasは「疑似ラベルの正解率が低いラウンドでだけ」起きるのか? それとも疑似ラベルの正解率が100%でもtest精度が落ちることがあるのか?
キーワード直感的な説明
Iterative Pseudo Labeling学習→予測→採用→再学習、を複数ラウンド繰り返す半教師あり学習の手法
ラウンドローカル正解率そのラウンドで新たに採用したサンプルだけの疑似ラベル正解率
累積正解率これまでの全ラウンドで採用してきたサンプル全体の疑似ラベル正解率
selection bias(サンプル選択バイアス)ラベルの正しさではなく「どのサンプルが選ばれやすいか」という選び方自体に起因する偏り。今日新しく登場する落とし穴

🪄 実験デザイン(Day136と同じ2400件分割 + 反復ループ)

学習(300+累積)
→ 予測
残プールへ推論
→ 閾値0.60で採用
学習データへ追加
→ 再学習・test評価
R+1へ (最大10回)

プールから採用したサンプルは除外され、二度と重複採用されない。累積で学習データが300件→最大1500件まで増えうる。

⚠️
閾値は環境依存: 今日の実行環境ではベースラインモデルの確信度の最大値が0.89までしか届かず、Day136で「安全」とされた閾値0.90では1件も採用できなかった。乱数・sklearnバージョンなどにより確信度分布は変わるため、実行のたびにpredict_probaの分布を確認してから閾値を決める必要がある。今日は実測に基づきTHRESHOLD=0.60で反復実験を行う。

🎯 問題

Day136と同じ合成分類データ・同じ分割方法(ラベル付き300件・プール1500件・test 600件)を使います。

項目値
タスク5クラス分類(make_classification, n_samples=2400, n_features=20)
labeled / pool / test先頭300件 / 次の1500件 / 残り600件
ベースモデルRandomForestClassifier(n_estimators=300, random_state=0)
反復設定THRESHOLD = 0.60(環境に応じて調整) / N_ROUNDS = 10
評価軸採用件数 / プール残数 / ラウンドローカル正解率 / 累積正解率 / test精度 / ベースライン比
import numpy as np
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score

X, y = make_classification(
    n_samples=2400, n_features=20, n_informative=12,
    n_classes=5, n_clusters_per_class=1, random_state=42,
    flip_y=0.03,
)

LABELED_LEN = 300
POOL_LEN = 1500

X_labeled, y_labeled = X[:LABELED_LEN], y[:LABELED_LEN]
X_pool_full = X[LABELED_LEN:LABELED_LEN + POOL_LEN]
y_pool_true_full = y[LABELED_LEN:LABELED_LEN + POOL_LEN]  # 検証専用
X_test, y_test = X[LABELED_LEN + POOL_LEN:], y[LABELED_LEN + POOL_LEN:]
1
ベースライン: X_labeled・y_labeledのみで学習しX_testの精度を測る
2
Iterative Pseudo Labelingの実装: THRESHOLD=0.60・N_ROUNDS=10で、学習→未採用プールへ予測→閾値以上を採用・除外→再学習、を繰り返す
3
閾値は環境依存であることの確認: 確信度分布を確認し、0.90が使えないなら実際に採用が発生する閾値へ調整する
4
可視化(表): ラウンド・採用件数・プール残数・ラウンドローカル正解率・累積正解率・test精度・ベースライン比を1つの表にまとめる
5
「疑似ラベル100%正解でもtest精度は落ちるか」の検証: ラウンド1のみを対象に閾値[0.70,0.75,0.80,0.85]×random_state 0〜4で、pseudo_accとdeltaの平均を確認する
6
(考察): confirmation biasはどのラウンドから顕在化したか、それは疑似ラベルの誤りだけが原因と言えるかを100字程度で結論づける

💡 ヒント

ヒント1方向性

Day136は「1回」の実験でしたが、今日は「同じ実験をN_ROUNDS回、状態を持ち越しながら繰り返す」だけです。ループの中身自体はDay136とほとんど同じですが、2点だけ新しく気をつける必要があります。1つ目は「すでに採用したサンプルをプールから除外し、二度と同じサンプルを採用しないこと」。2つ目は「ラウンドごとの正解率」と「これまでの累積の正解率」を分けて記録すること(両者が乖離し始めたら、直近のラウンドで質が急落しているサインになります)。

ヒント2アプローチ
  • ループの外で「残っているプールのインデックス」をremaining_idx = np.arange(POOL_LEN)のように持ち、採用するたびにremaining_idx = remaining_idx[~mask]で更新する
  • 学習データはX_train_cur・y_train_curという変数に持たせ、採用のたびにnp.vstack・np.concatenateで追記していく
  • 累積正解率は、これまで採用してきた(y_true, y_pred)のペアをリストに貯めてnp.concatenateしてからaccuracy_scoreを呼ぶと計算しやすい
  • タスク5では「ラウンド1だけ」を切り出して、閾値とrandom_stateを二重ループで回せばよい(Iterative Pseudo Labeling全体を再実行する必要はない)
ヒント3コード骨格
THRESHOLD = 0.60
N_ROUNDS = 10

X_train_cur, y_train_cur = X_labeled.copy(), y_labeled.copy()
remaining_idx = np.arange(POOL_LEN)

rows = []
cum_true, cum_pred = [], []

for r in range(1, N_ROUNDS + 1):
    model = RandomForestClassifier(n_estimators=300, random_state=0)
    model.fit(X_train_cur, y_train_cur)

    if len(remaining_idx) == 0:
        break

    X_rem = X_pool_full[remaining_idx]
    y_rem_true = y_pool_true_full[remaining_idx]
    proba = model.predict_proba(X_rem)
    conf, pred = proba.max(axis=1), proba.argmax(axis=1)

    mask = conf >= THRESHOLD
    if mask.sum() == 0:
        break

    round_pseudo_acc = accuracy_score(y_rem_true[mask], pred[mask])
    cum_true.append(y_rem_true[mask]); cum_pred.append(pred[mask])
    cum_pseudo_acc = accuracy_score(np.concatenate(cum_true), np.concatenate(cum_pred))

    X_train_cur = np.vstack([X_train_cur, X_rem[mask]])
    y_train_cur = np.concatenate([y_train_cur, pred[mask]])
    remaining_idx = remaining_idx[~mask]

    updated_model = RandomForestClassifier(n_estimators=300, random_state=0)
    updated_model.fit(X_train_cur, y_train_cur)
    test_acc = accuracy_score(y_test, updated_model.predict(X_test))

    rows.append((r, int(mask.sum()), len(remaining_idx), round_pseudo_acc, cum_pseudo_acc, test_acc))

✅ 模範解答

import numpy as np
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score

X, y = make_classification(
    n_samples=2400, n_features=20, n_informative=12,
    n_classes=5, n_clusters_per_class=1, random_state=42,
    flip_y=0.03,
)

LABELED_LEN = 300
POOL_LEN = 1500

X_labeled, y_labeled = X[:LABELED_LEN], y[:LABELED_LEN]
X_pool_full = X[LABELED_LEN:LABELED_LEN + POOL_LEN]
y_pool_true_full = y[LABELED_LEN:LABELED_LEN + POOL_LEN]
X_test, y_test = X[LABELED_LEN + POOL_LEN:], y[LABELED_LEN + POOL_LEN:]

# --- ステップ1: ベースライン ---
baseline_model = RandomForestClassifier(n_estimators=300, random_state=0)
baseline_model.fit(X_labeled, y_labeled)
baseline_acc = accuracy_score(y_test, baseline_model.predict(X_test))
print(f"baseline_acc = {baseline_acc:.4f}")
# 実測: baseline_acc = 0.7967

# --- ステップ3: 確信度分布の確認(閾値0.90は使えるか?) ---
pool_conf_check = baseline_model.predict_proba(X_pool_full).max(axis=1)
print(f"確信度: mean={pool_conf_check.mean():.4f} min={pool_conf_check.min():.4f} max={pool_conf_check.max():.4f}")
# 実測: mean=0.4944 min=0.2167 max=0.8900
# → 確信度の最大値が0.89までしか届かず、0.90以上のサンプルが1件も存在しない
#   → THRESHOLD=0.60に調整する

# --- ステップ2: Iterative Pseudo Labeling ---
THRESHOLD = 0.60
N_ROUNDS = 10

X_train_cur, y_train_cur = X_labeled.copy(), y_labeled.copy()
remaining_idx = np.arange(POOL_LEN)
rows = []
cum_true, cum_pred = [], []

for r in range(1, N_ROUNDS + 1):
    model = RandomForestClassifier(n_estimators=300, random_state=0)
    model.fit(X_train_cur, y_train_cur)
    if len(remaining_idx) == 0:
        break

    X_rem = X_pool_full[remaining_idx]
    y_rem_true = y_pool_true_full[remaining_idx]
    proba = model.predict_proba(X_rem)
    conf, pred = proba.max(axis=1), proba.argmax(axis=1)

    mask = conf >= THRESHOLD
    n_adopted = int(mask.sum())
    if n_adopted == 0:
        break

    round_pseudo_acc = accuracy_score(y_rem_true[mask], pred[mask])
    cum_true.append(y_rem_true[mask]); cum_pred.append(pred[mask])
    cum_pseudo_acc = accuracy_score(np.concatenate(cum_true), np.concatenate(cum_pred))

    X_train_cur = np.vstack([X_train_cur, X_rem[mask]])
    y_train_cur = np.concatenate([y_train_cur, pred[mask]])
    remaining_idx = remaining_idx[~mask]

    updated_model = RandomForestClassifier(n_estimators=300, random_state=0)
    updated_model.fit(X_train_cur, y_train_cur)
    test_acc = accuracy_score(y_test, updated_model.predict(X_test))

    rows.append((r, n_adopted, len(remaining_idx), round_pseudo_acc, cum_pseudo_acc, test_acc))

# 実測結果(THRESHOLD=0.60, random_state=0固定):
# round adopted pool_left round_acc cum_acc test_acc   delta
#     1     343      1157    0.9504  0.9504   0.7633  -0.0333
#     2     255       902    0.9020  0.9298   0.7517  -0.0450
#     3     135       767    0.8000  0.9059   0.7617  -0.0350
#     4      70       697    0.7714  0.8941   0.7600  -0.0367
#     5      50       647    0.7600  0.8863   0.7500  -0.0467
#     6      18       629    0.8333  0.8852   0.7517  -0.0450
#     7      21       608    0.4762  0.8756   0.7517  -0.0450
#     8      14       594    0.5714  0.8709   0.7550  -0.0417
#     9      13       581    0.3846  0.8640   0.7517  -0.0450
#    10       8       573    0.6250  0.8619   0.7467  -0.0500
# 学習データ件数の推移: 300件 → 1227件

# --- ステップ5: 「疑似ラベル100%正解でもtest精度は落ちるか」のミニ実験 ---
for th in [0.85, 0.80, 0.75, 0.70]:
    deltas, pseudo_accs = [], []
    for seed in range(5):
        base = RandomForestClassifier(n_estimators=300, random_state=seed)
        base.fit(X_labeled, y_labeled)
        base_acc = accuracy_score(y_test, base.predict(X_test))

        proba = base.predict_proba(X_pool_full)
        conf, pred = proba.max(axis=1), proba.argmax(axis=1)
        mask = conf >= th
        if mask.sum() == 0:
            continue

        pseudo_acc = accuracy_score(y_pool_true_full[mask], pred[mask])
        X_aug = np.vstack([X_labeled, X_pool_full[mask]])
        y_aug = np.concatenate([y_labeled, pred[mask]])
        aug = RandomForestClassifier(n_estimators=300, random_state=seed)
        aug.fit(X_aug, y_aug)
        aug_acc = accuracy_score(y_test, aug.predict(X_test))

        deltas.append(aug_acc - base_acc)
        pseudo_accs.append(pseudo_acc)

    print(f"threshold={th}: mean_pseudo_acc={np.mean(pseudo_accs):.4f}  mean_delta={np.mean(deltas):+.4f}")

# 実測結果(5 random_state平均):
# threshold=0.85: mean_pseudo_acc=1.0000  mean_delta=-0.0100
# threshold=0.80: mean_pseudo_acc=1.0000  mean_delta=-0.0120
# threshold=0.75: mean_pseudo_acc=0.9948  mean_delta=-0.0120
# threshold=0.70: mean_pseudo_acc=0.9750  mean_delta=-0.0187
🧭
タスク6: 考察 — 閾値0.60でのIterative Pseudo Labelingでは、ラウンド1の時点で早くもtest精度がベースラインを下回った(0.7967→0.7633、-0.0333)。10ラウンド繰り返しても一度もベースラインを上回らなかった。さらに閾値0.85(疑似ラベル正解率100%)でも平均delta -0.01と、疑似ラベルが全く間違っていなくてもtest精度が下がった。これは「間違ったラベルを学習したせいで精度が落ちる」という狭い意味のconfirmation biasだけでは説明できない。疑似ラベルとして選ばれるのは「モデルが既に自信を持てる、既存の学習データに似た区別しやすいサンプル」に偏りやすく(サンプル選択バイアス)、そうした「簡単な」サンプルばかりを大量に追加すると、決定木の分岐が簡単な領域に引っ張られ、test集合の境界付近サンプルへの対応力がむしろ落ちる可能性がある。

📊 結果の可視化(10ラウンドの推移 と 100%正解でも落ちる delta)

ラウンドごとのtest精度の推移(点線=ベースライン 0.7967) 0.7967 0.70 0.80 R1 R2 R3 R4 R5 R6 R7 R8 R9 R10 → ラウンド1から一貫してベースライン未満。反復しても回復しない

ラウンドローカル正解率(急落)vs 累積正解率(緩やかに低下・過去の高得点に引きずられて劣化が見えにくい):

R1 local
95.0%
R5 local
76.0%
R9 local
38.5%
R1 累積
95.0%
R5 累積
88.6%
R9 累積
86.4%

■ 赤/黄=ラウンドローカル(直近だけ) / 青=累積(過去含む全体)。R9ではローカル正解率が38.5%まで落ちているのに、累積で見るとまだ86.4%あるように「見えてしまう」。

閾値疑似ラベル正解率(平均)test精度delta(平均)
0.85100.0%-0.0100
0.80100.0%-0.0120
0.7599.5%-0.0120
0.7097.5%-0.0187
🚨
閾値0.85・0.80では疑似ラベル正解率が100.0%(間違いゼロ)にもかかわらず、test精度のdeltaは負(-0.01前後)。ラベルの誤りを完全に排除しても精度低下が起きており、selection biasはラベルノイズ対策(閾値を上げる)では防げないことを示している。
ラウンド採用件数プール残数ローカル正解率累積正解率test精度delta
1343115795.0%95.0%0.7633-0.0333
225590290.2%93.0%0.7517-0.0450
313576780.0%90.6%0.7617-0.0350
47069777.1%89.4%0.7600-0.0367
55064776.0%88.6%0.7500-0.0467
61862983.3%88.5%0.7517-0.0450
72160847.6%87.6%0.7517-0.0450
81459457.1%87.1%0.7550-0.0417
91358138.5%86.4%0.7517-0.0450
10857362.5%86.2%0.7467-0.0500

🪜 Step-by-Step 解説

1なぜ「まだ採用していないプール」だけを毎回予測するのか

🎫
なぜこうするか: remaining_idxで未採用のサンプルだけに絞らないと、前のラウンドで既に学習データに取り込んだサンプルを、次のラウンドでもう一度「疑似ラベル」として重複採用してしまう可能性がある。同じサンプルを2回学習データに入れると、その影響がモデルの中で不自然に大きくなり、実験の解釈が歪む。「一度採用したら卒業」というルールを徹底することで、各ラウンドが「まだ見ぬデータへの挑戦」であり続けるようにしている。

2なぜラウンドローカル正解率と累積正解率を両方記録するのか

round_pseudo_acc = accuracy_score(y_rem_true[mask], pred[mask])          # このラウンドだけ
cum_pseudo_acc = accuracy_score(np.concatenate(cum_true), np.concatenate(cum_pred))  # これまでの全部
📉
なぜこうするか: 実測結果を見ると、ラウンドローカル正解率はラウンド1の95.0%から徐々に下がり、ラウンド9では38.5%まで落ち込んでいる。一方、累積正解率はラウンド1の95.0%からラウンド10の86.2%まで、ゆるやかにしか下がらない。これは「後半のラウンドで新たに採用されるサンプルの質が急激に悪化しているが、その悪影響は累積の中では薄まって見えにくい」ことを意味する。累積正解率だけを見ていると質の劣化に気づくのが遅れる——ラウンドローカルの数値を必ず併記する必要がある理由がここにある。

3なぜ「疑似ラベル100%正解」でもtest精度が落ちたのか(selection bias)

🔍
なぜこうするか: タスク5のミニ実験は、あえて「ラベルの誤りをほぼゼロに抑えた」条件(閾値0.85、pseudo_acc=100%)でも同じ実験をすることで、「精度低下の原因が本当にラベルの誤りなのか」を切り分けるために行った。もしラベルの誤りだけが原因であれば、pseudo_acc=100%のときはdeltaが0以上になるはずだが、実際には-0.01前後の低下が観測された。300件のRandomForestが確信度0.85以上で予測できるサンプルは「モデルが既に得意な、境界から離れた典型的なサンプル」に偏っており、そればかりを追加学習することで、逆に境界付近の判定が疎かになった可能性を示す。「正しいラベルを学習させれば安全」という直感は、サンプルの選ばれ方自体に偏りがある場合には成り立たない、というのが今日最大の学びである。

🧮 数学・統計の補足(文系向け)

📖
「疑似ラベル100%正解でも精度が落ちる」を、勉強法のたとえで理解する:

模試の過去問を解くとき、「もう完璧に解ける得意な問題(100点満点で必ず正解する問題)」ばかりを繰り返し解いても、本番のテストの点数は上がりません。むしろ、その時間を「まだ自信のない苦手な問題」に使わないと、本番で出る「境界線上の難しい問題」に対応できないままです。今日のRandomForestに起きたのも似たことです。確信度が高い=「モデルにとって簡単な問題」ばかりを追加で"復習"させても、test集合に含まれる「クラスの境目にある紛らわしいサンプル」への対応力は伸びません。むしろ簡単な問題の比重が増えすぎて、木の分岐が「簡単な領域」に最適化され、境界線付近の判断がぼやけてしまうことすらあります。
📊
「ラウンドローカル正解率が急落しても累積正解率は緩やかにしか落ちない」を、平均点のたとえで理解する:

10回のテストの平均点が90点だったとして、11回目に30点を取っても、11回分の平均点は90点から55点にはならず、まだ85点前後に留まります(過去の高得点が平均を下支えするため)。今日の累積疑似ラベル正解率も同じ仕組みで、初期ラウンドの高い正解率(95.0%)が、後期ラウンドの低い正解率(38.5%〜57.1%)の悪化を「薄めて」しまい、全体を見ているだけでは異変に気づきにくくなります。だからこそ「直近の1回だけ」を見る指標(ラウンドローカル正解率)が別途必要になります。

🏆 Kaggleでの実践的な使い方

よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☑ 自然言語処理(NLP) / ☑ 画像認識(CV) / ☐ 時系列(Time Series)

実例

🖼️ 画像コンペでのIterative Pseudo Labeling

反復は1〜3ラウンドが定石

Cassava Leaf Disease Classification など各種画像コンペの上位解法では、反復は1〜3ラウンド程度に留めるのが一般的。ラウンドを重ねすぎると今日確認したような劣化リスクが増すため、「何ラウンドで打ち切るか」自体をCVスコアの推移を見ながら決める。

応用

📝 NLPでの多様性確保の工夫

確信度上位だけに頼らない

BERT系モデルのfine-tuningでIterative Pseudo Labelingを行う際は、各ラウンドでサンプリングの多様性を意識的に確保する(確信度上位だけでなく、クラスごとの采配を均等にするなど)ことで、今日確認したselection biasを緩和する工夫がよく使われる。

実務

🔧 直近の質を間接的に監視する

累積指標だけに頼らない

実務では疑似ラベルの正解率を直接測れないことがほとんどだが、代わりに「直近ラウンドで追加したサンプルに対するCVスコアの変化」や「予測エントロピーの推移」を監視し、質の劣化を間接的に検知する手法が使われる。

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
ラウンドを重ねるほどtest精度は単調に改善すると思い込む「学習データが増える=良いこと」という直感をそのまま適用してしまう今日の実測ではラウンド1の時点で早くも精度が悪化し、10ラウンド通してベースラインを上回らなかった。反復によって改善するとは限らない
confirmation biasは「疑似ラベルの誤り」だけが原因だと考える「間違ったラベルを学習したから悪化した」という説明が最も直感的でわかりやすいため疑似ラベル正解率が100%でも精度が落ちるケースがあり、サンプルの選ばれ方の偏り(selection bias)という、ラベルの正しさとは独立した要因が存在する
累積疑似ラベル正解率だけを見て「まだ質は保たれている」と安心する一つの集計指標だけを見て判断してしまう累積正解率は過去の高い正解率に引きずられて緩やかにしか下がらないため、直近ラウンドの急激な劣化を見逃す。ラウンドローカルの指標を必ず併記する必要がある
前回(Day136)決めた閾値を、乱数や環境が変わっても同じ値のまま使い回してよいと考える「一度決めた閾値は固定して使うもの」という思い込み今日の実行環境では確信度の最大値が0.89までしか届かず、昨日「安全」とされた閾値0.90は1件も採用できなかった。閾値は実行のたびに確信度分布を確認し、必要なら調整すべき値である

🚀 次のステップ

  • 発展: 今日はRandomForestで固定の閾値・固定のラウンド数で実験しましたが、実務では「ラウンドごとに閾値を段階的に引き上げる」戦略や、「1ラウンドあたりに追加するサンプル数の上限を決める」戦略もよく使われます。これらを実装し、今日確認したselection biasが緩和されるかを検証してみましょう
  • 次回予告: Phase6テーマ3の3日目。今日学んだ「疑似ラベルの質は正解率だけでは測れない」という教訓を踏まえ、複数の予測(元データ+摂動を加えたデータ)を平均して1つの最終予測にまとめるTTA(Test Time Augmentation)に取り組みます

Phase 6 の学習マップ(全20テーマ)

1-5
SOTA手法追跡・論文実装(完了)
6-10
カスタムアーキテクチャ設計(完了)
11-15
高度なアンサンブル(本日2/5・Iterative PL完了)
16-20
コンペ戦略・Gold 3枚達成プラン

Day136で単発Pseudo Labelingを、Day137でIterative Pseudo Labelingを実装。次はTTA(Test Time Augmentation)に進む。

📝 自己評価(解いた後に記入)

自分の回答・気づき・メモ: