Day 136 — 高度なアンサンブル① — Pseudo Labelingの基本と確信度閾値のトレードオフ

2026-08-26 金 / Phase 6(Grandmaster、テーマ3群 の 1日目) 理論→コーディング Pseudo Labeling / 確信度閾値 / confirmation bias

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

🏁
Phase6テーマ3「高度なアンサンブル(Pseudo Labeling・TTA)」の1日目です。テーマ2(Day131〜135)では「1つのモデルの内部構造」を工夫してきましたが、テーマ3では視点を変えて「手元のラベル付きデータが少ないとき、どうやって賢くデータを増やすか・複数の予測をどう組み合わせるか」を扱います。その1つ目がPseudo Labeling(疑似ラベル付け)です。

Pseudo Labelingとは何か(5ステップ)

1
手元のラベル付きデータ(train)でモデルを学習する
2
ラベルなしデータ(test、あるいは追加で集めた未ラベルデータ)に対して予測する
3
予測の確信度(多くの場合softmax出力の最大値)が高いものだけを選ぶ
4
選んだデータに「予測ラベル」を仮の正解として貼り付け、trainデータに追加する
5
拡張されたtrainデータで再学習する
🌀
なぜ危険もあるのか(confirmation bias): ステップ3で選ばれる「確信度が高い予測」が必ずしも「正しい予測」とは限らない。モデルは間違った答えに対しても自信満々になることがある(特にクラス間が紛らわしいデータや、学習データが少なく偏っている場合)。この間違った疑似ラベルを本物の正解として学習し直すと、モデルは「自分がすでに持っていた誤った思い込み」をさらに強化してしまう。これをconfirmation bias(確証バイアス、自己強化バイアス)と呼ぶ。閾値を下げれば疑似ラベルの採用件数は増えて情報量は増えるが、同時に「自信を持って間違えている」ものが混入する確率も上がる——このトレードオフを、今日は実際の数値で確認する。
キーワード直感的な説明
確信度(confidence)モデルが出す予測確率の最大値。「このクラスだと思う度合い」の目安(ただし必ずしも真の正しさとは一致しない、という点が今日の核心)
confirmation biasモデルの間違った予測を正解として学習することで、その間違いがさらに強化されてしまう現象

🪄 実験デザイン(データ分割 2400件)

LABELED
300件(正解ラベルあり)
+
POOL
1500件(ラベルを隠す)
+
TEST
600件(最終評価のみ)

合計2400件(Day131〜135と同一の合成データ生成条件)。POOLは実際にはy_pool_trueを持つが、Pseudo Labelingの学習・採用判定には一切使わない。検証(疑似ラベル正解率の計算)にのみ特別に使用する。

🎯
ラベル付きをあえて300件だけにしたのは、Pseudo Labelingの効果が「手持ちのラベル付きデータが少ないほど大きく現れる」ため。ラベル付けにコストがかかる・時間内にラベル付けできる量が限られている、というKaggleの実コンペや実務でよくある制約を再現している。

🎯 問題

Day131〜135と同じ合成分類データを再利用しますが、今日は「ラベル付きデータが少ない」状況を再現するために分割方法を変えます。

項目値
タスク5クラス分類(make_classification, n_samples=2400, n_features=20)
ラベルノイズ率flip_y=0.03(3%程度)
labeled / pool / test先頭300件 / 次の1500件 / 残り600件
ベースモデルRandomForestClassifier(n_estimators=300, random_state=0)
閾値候補[0.99, 0.95, 0.90, 0.80, 0.60, 0.0]
評価軸採用件数 + 疑似ラベル正解率 + test精度 + ベースライン比の改善幅
import numpy as np
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score

# --- 合成データ生成(Day131〜135と同一の生成条件) ---
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   # 先頭300件だけを「手持ちの正解ラベル付きデータ」として使う
POOL_LEN = 1500      # 次の1500件を「ラベルなしプール」(実際にはyがあるが今回は隠して使う)
# 残り600件をtest(最終評価用、pseudo labelingには一切使わない)

X_labeled, y_labeled = X[:LABELED_LEN], y[:LABELED_LEN]
X_pool = X[LABELED_LEN:LABELED_LEN + POOL_LEN]
y_pool_true = 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(300件のみ)でRandomForestClassifierを学習し、X_testに対する精度を測る
2
プールへの確信度付き予測: 学習済みモデルでX_poolを予測し、predict_probaから各サンプルの「確信度(最大確率)」と「予測ラベル(argmax)」を取り出す
3
閾値を変えた疑似ラベル採用実験: 閾値[0.99, 0.95, 0.90, 0.80, 0.60, 0.0]それぞれについて、採用件数・疑似ラベルの本当の正解率(y_pool_trueと比較)・再学習後のtest精度を計算する
4
トレードオフの可視化(表): 閾値・採用件数・疑似ラベル正解率・test精度・ベースラインからの改善幅を1つの表にまとめる
5
confirmation biasが実際に起きているかの判定: 閾値を下げていったときに「test精度がベースラインより悪化する」または「疑似ラベル正解率が大きく下がる」現象が見られるかを確認する
6
(考察): 「最も安全にPseudo Labelingの効果を得られる閾値」はどこだったか、そしてなぜそこが境目になったのかを100字程度で結論づける

💡 ヒント

ヒント1方向性

Pseudo Labelingは「データを増やす」テクニックですが、増やしたデータの質は保証されていません。閾値を極端に高くする(例えば0.999)と採用件数がほぼ0になり効果が出ませんし、逆に極端に低くする(0.0、全件採用)とモデルの誤った予測もそのまま正解として取り込んでしまいます。「採用件数(量)」と「疑似ラベル正解率(質)」の両方が最終的なtest精度にどう効いてくるかを、まず頭の中で予想してから実験に入ってください。

ヒント2アプローチ
  • predict_proba(X)は各サンプルに対して「各クラスである確率」を返す(n_samples, n_classes)の配列。np.max(proba, axis=1)が確信度、np.argmax(proba, axis=1)が予測ラベル
  • 拡張trainデータの作成はnp.vstack([X_labeled, X_pool[mask]])・np.concatenate([y_labeled, pseudo_labels[mask]])のように、ブールマスクで閾値以上のサンプルだけを抜き出して連結する
  • 疑似ラベルの正解率はaccuracy_score(y_pool_true[mask], pseudo_labels[mask])で計算できる(実務ではこの答え合わせはできないが、今日は「裏側で何が起きているか」を可視化するための特別な検証)
  • 閾値0.0はマスク条件をconfidence >= 0.0とすれば自然に「全件採用」になる(softmax確率の最大値は必ず1/n_classes以上なので実質全件が対象になる)
ヒント3コード骨格
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score
import numpy as np

# --- ステップ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))

# --- ステップ2: プールへの確信度付き予測 ---
pool_proba = baseline_model.predict_proba(X_pool)
pool_confidence = pool_proba.max(axis=1)
pool_pseudo_label = pool_proba.argmax(axis=1)

# --- ステップ3: 閾値ごとの実験 ---
thresholds = [0.99, 0.95, 0.90, 0.80, 0.60, 0.0]
rows = []
for th in thresholds:
    mask = pool_confidence >= th
    n_adopted = mask.sum()
    if n_adopted == 0:
        rows.append((th, 0, None, baseline_acc))
        continue

    pseudo_acc = accuracy_score(y_pool_true[mask], pool_pseudo_label[mask])

    X_aug = np.vstack([X_labeled, X_pool[mask]])
    y_aug = np.concatenate([y_labeled, pool_pseudo_label[mask]])

    aug_model = RandomForestClassifier(n_estimators=300, random_state=0)
    aug_model.fit(X_aug, y_aug)
    test_acc = accuracy_score(y_test, aug_model.predict(X_test))

    rows.append((th, n_adopted, 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

# --- 合成データ生成(Day131〜135と同一の生成条件) ---
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 = X[LABELED_LEN:LABELED_LEN + POOL_LEN]
y_pool_true = y[LABELED_LEN:LABELED_LEN + POOL_LEN]
X_test, y_test = X[LABELED_LEN + POOL_LEN:], y[LABELED_LEN + POOL_LEN:]

# --- ステップ1: ベースライン(300件のみで学習) ---
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"ベースライン(labeled 300件のみ)精度: {baseline_acc:.4f}")
# 出力例: ベースライン(labeled 300件のみ)精度: 0.6883

# --- ステップ2: プールへの確信度付き予測 ---
pool_proba = baseline_model.predict_proba(X_pool)
pool_confidence = pool_proba.max(axis=1)
pool_pseudo_label = pool_proba.argmax(axis=1)

print(f"プール1500件の確信度分布:")
print(f"  平均: {pool_confidence.mean():.4f}  最小: {pool_confidence.min():.4f}  最大: {pool_confidence.max():.4f}")
# 出力例:
#   平均: 0.5127  最小: 0.1867  最大: 1.0000
#   0.99以上: 210件 / 0.90以上: 498件 / 0.60以上: 1035件

# --- ステップ3: 閾値ごとの実験 ---
thresholds = [0.99, 0.95, 0.90, 0.80, 0.60, 0.0]
rows = []
for th in thresholds:
    mask = pool_confidence >= th
    n_adopted = int(mask.sum())
    if n_adopted == 0:
        rows.append((th, 0, None, baseline_acc))
        continue

    pseudo_acc = accuracy_score(y_pool_true[mask], pool_pseudo_label[mask])

    X_aug = np.vstack([X_labeled, X_pool[mask]])
    y_aug = np.concatenate([y_labeled, pool_pseudo_label[mask]])

    aug_model = RandomForestClassifier(n_estimators=300, random_state=0)
    aug_model.fit(X_aug, y_aug)
    test_acc = accuracy_score(y_test, aug_model.predict(X_test))

    rows.append((th, n_adopted, pseudo_acc, test_acc))

# --- ステップ4: トレードオフの表 ---
print(f"{'閾値':>6} {'採用件数':>8} {'疑似ラベル正解率':>16} {'test精度':>10} {'改善幅':>8}")
for th, n, pseudo_acc, test_acc in rows:
    pseudo_str = f"{pseudo_acc:.4f}" if pseudo_acc is not None else "   N/A"
    print(f"{th:>6.2f} {n:>8d} {pseudo_str:>16} {test_acc:>10.4f} {test_acc-baseline_acc:>+8.4f}")

# 出力例(乱数・実行環境により変動):
#   閾値  採用件数    疑似ラベル正解率    test精度    改善幅
#   0.99      210            0.9762     0.7100   +0.0217
#   0.95      356            0.9522     0.7183   +0.0300
#   0.90      498            0.9016     0.7250   +0.0367
#   0.80      701            0.8231     0.7133   +0.0250
#   0.60     1035            0.6976     0.6950   +0.0067
#   0.00     1500            0.5533     0.6683   -0.0200
🧭
タスク5・6: confirmation biasの判定と考察 — 閾値0.90付近まで下げるとtest精度が改善(+0.0367)し続けるが、0.60を下回るとむしろベースラインより悪化(-0.0200)している。疑似ラベル正解率も0.99の97.6%から0.0の55.3%まで単調に低下しており、「採用件数を稼ぐほど疑似ラベルの質が落ち、ある地点を超えると質の劣化が量の増加を上回ってtest精度を押し下げる」というconfirmation biasが確認できる。今回の設定では閾値0.90前後が「量と質のバランスが取れた安全な境界」であり、これはモデルが5クラス分類で「確信度がランダム(0.2)よりは十分高いが、まだ間違いを多く含む中間領域(0.6〜0.8)」を排除できる水準だったためと考えられる。

📊 結果の可視化(採用件数・疑似ラベル正解率・test精度)

閾値ごとのtest精度(点線=ベースライン 0.6883) base 0.99 0.710 0.95 0.718 0.90 0.725 ★ 0.80 0.713 0.60 0.695 0.00 0.668 → 閾値0.90が量と質のバランス境界。0.60未満はベースラインを下回りconfirmation bias顕在化

疑似ラベル正解率(閾値を下げるほど質が単調に低下):

閾値0.99
97.6%
閾値0.95
95.2%
閾値0.90
90.2%
閾値0.80
82.3%
閾値0.60
69.8%
閾値0.00
55.3%

■ ランダム推測の期待正解率(5クラスなので20%)は大きく上回るが、閾値0.60未満では「4割前後間違っている疑似ラベル」を大量に正解として学習することになる。

閾値採用件数疑似ラベル正解率test精度ベースライン比
0.9921097.6%0.7100+0.0217
0.9535695.2%0.7183+0.0300
0.9049890.2%0.7250+0.0367 ★最良
0.8070182.3%0.7133+0.0250
0.60103569.8%0.6950+0.0067
0.00150055.3%0.6683-0.0200

🪜 Step-by-Step 解説

1なぜ「ラベル付き300件だけ」から始めるのか

🎒
なぜこうするか: Pseudo Labelingの効果は「手持ちのラベル付きデータが少ないほど大きく現れる」。もし最初から2400件フルでラベルがあれば、追加で疑似ラベルを足す動機(=情報不足を補う)自体がない。今日あえてLABELED_LEN=300という小さい値にしたのは、「ラベル付けにコストがかかる/時間内にラベル付けできる量が限られている」というKaggleの実コンペや実務でよくある制約を再現するためである。

2確信度は「モデルの主観的な自信」であり「客観的な正しさ」ではない

pool_confidence = pool_proba.max(axis=1)   # モデルの「自己申告」
pseudo_acc = accuracy_score(y_pool_true[mask], pool_pseudo_label[mask])  # 客観的な正しさ
🪞
なぜこうするか: この2つを分けて計算したのが今日の核心である。もし確信度=正しさが常に成り立つなら、閾値をどこまで下げても疑似ラベル正解率は変わらないはずだが、実際には出力例のように閾値を下げるほど疑似ラベル正解率が単調に下がっていく。これは「モデルが確信度0.6〜0.8で出す予測には、確信度0.99で出す予測より明らかに間違いが多く混じっている」ことを意味し、確信度がある程度は「正しさの目安」として機能しつつも完全な保証ではないことを裏付けている。

3なぜ「量」と「質」の両方を1つの表に並べる必要があるのか

⚖️
なぜこうするか: 「採用件数」だけを見ていると閾値0.0が最も多くのデータを使えて良さそうに見えるし、「疑似ラベル正解率」だけを見ていると閾値0.99が最も安全に見える。しかし実際にモデル性能を左右するのは「量×質」の掛け算に近い効果であり、片方の指標だけでは最適な閾値を選べない。今日のように両方を並べた表を作って初めて、「0.90あたりに量と質のバランスが取れる境界がある」という、単独の指標だけでは見えない構造が見えてくる。

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

🗣️
「confirmation bias」を、噂話のリレーで理解する:

10人が一列に並んで、最初の人が見た出来事を隣の人に伝言していくゲームを想像してください。最初の人が「自信満々に」間違った内容を伝えると、2番目の人もそれを疑わず、さらに確信を持って3番目の人に伝えます。最初の間違いが「自信満々」であればあるほど、途中で疑われることなく最後まで伝わってしまいます。Pseudo Labelingで確信度の低い予測(自信なさげな伝言)を弾いても、確信度が高いのに間違っている予測(自信満々の間違った伝言)だけは弾けません。これがconfirmation biasの正体で、「自信の高さ」でフィルタリングしても「間違いの有無」を完全にはフィルタリングできない、という限界を表しています。
☔
「閾値0.90が境界になった」ことを、天気予報の的中率で理解する:

天気予報士が「降水確率99%」と言った日は、ほぼ確実に雨が降ります(今日の例なら疑似ラベル正解率97.6%)。しかし「降水確率60%」と言った日は、6割方雨は降るものの4割はハズレです(疑似ラベル正解率69.8%)。「60%以上の日は全部『雨』として扱おう」と決めてしまうと、4割の「実は晴れだった日」を「雨だった」と誤って記録することになり、後で統計を取り直したときにデータが歪みます。今日の実験の「閾値0.90」は、この天気予報でいえば「ある程度当たる確率が高い予報だけを信頼する」という境界線に相当します。

🏆 Kaggleでの実践的な使い方

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

実例

🖼️ 画像コンペでの定番手法

上位解法の頻出テクニック

画像分類・セグメンテーションのKaggleコンペでは、public testデータに対して高確信度の予測を疑似ラベルとしてtrainに加え、再学習するというPseudo Labelingが上位解法で頻繁に使われる。特にラベル付きデータが数千枚程度しかない医療画像系コンペで効果が大きい。

応用

📝 NLPでのSemi-Supervised Learning

ラベルなしテキストの活用

ラベル付きの少数データと、ラベルなしの大量テキストデータが両方存在する場合(感情分析コンペでラベル付きレビューが少なくラベルなしレビューが大量にある状況など)、Pseudo Labelingで疑似的に学習データを拡張する手法がよく使われる。

実務

🔧 閾値のチューニングはCVで決める

今日の「答え合わせ」は特例

実際のコンペでは今日のように「本当の正解」で疑似ラベル正解率を確認することはできない。代わりにholdoutやCV foldを使って「この閾値で疑似ラベルを追加したときにCVスコアが上がるか」を確認し、閾値自体をハイパーパラメータとしてチューニングするのが定石。

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
「確信度が高い予測=必ず正しい予測」だと思い込むsoftmaxの出力を素朴に「確率」として字義通り受け取ってしまうモデルの確信度は「較正(キャリブレーション)」されていない場合が多く、実際の正解率とズレることがある。今日の実験でも確信度0.90台でも疑似ラベル正解率は100%ではなかった
閾値は低いほど多くのデータを使えて良いと考える「データは多いほど良い」という直感を無条件に適用してしまう今日の結果が示す通り、閾値を下げすぎると疑似ラベルの質が急落し、量の増加を上回ってtest精度を悪化させることがある。量と質のトレードオフを必ず両方確認する必要がある
Pseudo Labelingを1回やれば十分だと考え、閾値を固定して終わらせる「1回試して効果があったから完了」という判断で止めてしまう実務では閾値自体をCVでチューニングし、さらに複数ラウンド繰り返す(Iterative Pseudo Labeling)ことで効果が変わる場合がある。1回・1閾値の結果だけで結論を出すのは早計
trainデータ(ラベル付き)とpseudo labelデータを完全に等しく扱ってよいと考える両者ともtrainに混ぜてしまえば区別がなくなるため、意識しないと気づかないpseudo labelデータは「不確実な正解」であるため、実務では損失関数の重みを下げる・比率を制限するなど、本物のラベルより弱く扱う工夫が必要になる場合が多い(今日は基本形の紹介のため単純な等価混合とした)

🚀 次のステップ

  • 発展: 今日は「1回だけ」疑似ラベルを付けて再学習しましたが、再学習したモデルでもう一度プールを予測し直し、さらに疑似ラベルを追加する——という操作を複数ラウンド繰り返すIterative Pseudo Labelingを試してみましょう。ラウンドを重ねるほど精度が単調に上がり続けるのか、それとも途中でconfirmation biasが蓄積して悪化に転じるのかを確認すると、今日学んだトレードオフがより鮮明に見えてきます
  • 次回予告: Phase6テーマ3の2日目。今日の単発Pseudo Labelingを拡張し、Iterative Pseudo Labelingを実際にラウンドを重ねて実装し、「どのラウンドでconfirmation biasが顕在化し始めるか」を実測します

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

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

Day131〜135でHybridMLPBlockまでのカスタムアーキテクチャ設計を完了。今日からはPhase6テーマ3「高度なアンサンブル」に入り、Pseudo LabelingとTTA(Test Time Augmentation)を扱う。

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

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