📚 背景知識(読んでから問題へ)
Pseudo Labelingの基本アイデアは「少量のラベル付きデータでモデルを学習し、そのモデルでラベルなしデータを予測し、確信度が高い予測だけを疑似ラベルとして学習データに追加して再学習する」という半教師あり学習の手法である。Day136-137では表形式データで確信度閾値を適切に設定すれば精度改善につながることを確認済みである。
| 用語 | 意味 |
|---|---|
| Pseudo Labeling(自己学習) | ラベル付きモデルの予測を、ラベルなしデータの「疑似的な正解」として学習データに追加する半教師あり学習の手法 |
| 確信度閾値 | predict_probaの値がこの閾値を超えた(または下回った)予測だけを疑似ラベルとして採用する基準 |
| 選択バイアス | 「確信度が高い」という基準でデータを選ぶこと自体が、元の分布から偏ったサブセットを切り出してしまう現象。ラベルが全部正解でも生じうる |
| ラベルノイズ | 疑似ラベルが実際の正解と異なってしまうこと(モデルの予測ミスによる誤ラベル) |
🗂️ データ・分割のスキーマ
| 変数名 | 型 | 内容 |
|---|---|---|
| df (n=450) | pd.DataFrame | make_text_dataset()で生成した合成テキスト全体。Day150と同じ語彙・ロジック |
| test_df (110件) | pd.DataFrame | 最終評価専用のホールドアウト。全設定で共通に使う |
| labeled_df (40件) | pd.DataFrame | 「ラベル付きデータが少ない」状況を模したベースライン学習セット |
| unlabeled_df (約300件) | pd.DataFrame | ラベルを隠した疑似ラベル候補プール(label列は分析専用でモデルには使わせない) |
STANDARD_THRESHOLD = 0.90 # Day136-137(表形式データ)の定番閾値をそのまま流用 MODERATE_THRESHOLD = 0.60 # 実測したproba分布(0.22〜0.72程度)に合わせて調整 PERMISSIVE_THRESHOLD = 0.55 # ほぼ無選別に近い、意図的に緩すぎる閾値
🎯 問題
タスクA(理論)
以下2点をそれぞれ2〜3文で説明する。
predict_probaの性質から予想するタスクB(コーディング→分析)
make_text_dataset(n=450, seed) — Day150と同じ語彙・ロジックで合成テキストを生成する関数を実装する(n=450に拡大)split_seed(seed) — test_df(110件) / labeled_df(40件) / unlabeled_df(残り約300件)の3分割を作る関数を実装するtrain_and_eval(texts, labels, test_df) — TF-IDF+LogisticRegressionを学習しtest精度・loglossを返す共通関数を実装するselect_confident_pseudo_labels(model, vectorizer, unlabeled_df, threshold) — 予測確率が閾値条件を満たす行だけを選ぶ関数を実装するassert文で確認する💡 ヒント
タスクAの1.は、Day150で見た「TF-IDFの固定語彙による情報の分岐」とは別角度の問題である。ヒントは「ラベル付きデータがわずか40件しかない状態で学習したLogisticRegressionのpredict_probaは、どれくらい極端な値(0や1に近い値)を出せるか」を考えることである。学習データが少なく特徴量空間が疎なTF-IDFでは、モデルの自信の"天井"がそもそも低い可能性がある。
select_confident_pseudo_labelsを実装したら、まずthreshold=0.9で実際に何件選ばれるかを1シードだけ試しに確認するとよい。「0件」という結果はバグではなく重要な実測結果である- アブレーション(A)(B)(C)は、
labeled_dfに「追加する行以外」は固定し、(A)(B)は同じmask(同じ行)を使いラベルだけを疑似/正解で変える。(C)はmaskを使わずmask.sum()と同じ件数をnp.random.RandomState(seed).choiceでランダムに選ぶ - 「オラクル」は、モデルには本来見せない正解情報を検証目的だけに使う、という意味。「もし疑似ラベルが100%正しくても、確信度による選び方自体に問題はないか」を確認するために使う
- 5シードのループを
run_threshold_sweep(seed, threshold)・run_selection_bias_ablation(seed, threshold=0.6)のように関数化しpd.DataFrame([... for s in SEEDS])で集計する
def select_confident_pseudo_labels(model, vectorizer, unlabeled_df, threshold): X_un = vectorizer.transform(unlabeled_df["text"]) proba = model.predict_proba(X_un)[:, 1] mask = (proba >= threshold) | (proba <= (1 - threshold)) pseudo_labels = (proba[mask] >= 0.5).astype(int) return mask, pseudo_labels def run_selection_bias_ablation(seed, threshold=0.60): labeled_df, unlabeled_df, test_df = split_seed(seed) acc_base, ll_base, vectorizer, model = train_and_eval( labeled_df["text"].tolist(), labeled_df["label"].tolist(), test_df) mask, pseudo_labels = select_confident_pseudo_labels(model, vectorizer, unlabeled_df, threshold) true_labels_selected = unlabeled_df.loc[mask, "label"].to_numpy() # (A) 疑似ラベル自己学習 / (B) オラクル(同じ選択+正解ラベル) / (C) ランダム(同数+正解ラベル) # ... train_and_eval を3パターン呼び出して比較する ...
✅ 模範解答
import random import numpy as np import pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score, log_loss # POSITIVE_WORDS / NEGATIVE_WORDS / NEUTRAL_WORDS / make_text_dataset は Day150と同一(省略) def train_and_eval(texts, labels, test_df): vectorizer = TfidfVectorizer() X = vectorizer.fit_transform(texts) model = LogisticRegression(max_iter=1000) model.fit(X, labels) X_test = vectorizer.transform(test_df["text"]) proba = model.predict_proba(X_test)[:, 1] acc = accuracy_score(test_df["label"], (proba >= 0.5).astype(int)) ll = log_loss(test_df["label"], proba, labels=[0, 1]) return acc, ll, vectorizer, model def select_confident_pseudo_labels(model, vectorizer, unlabeled_df, threshold): X_un = vectorizer.transform(unlabeled_df["text"]) proba = model.predict_proba(X_un)[:, 1] mask = (proba >= threshold) | (proba <= (1 - threshold)) pseudo_labels = (proba[mask] >= 0.5).astype(int) return mask, pseudo_labels def split_seed(seed): df = make_text_dataset(n=450, seed=seed) rest_df, test_df = train_test_split(df, test_size=110, random_state=seed, stratify=df["label"]) labeled_df, unlabeled_df = train_test_split(rest_df, train_size=40, random_state=seed, stratify=rest_df["label"]) return labeled_df.reset_index(drop=True), unlabeled_df.reset_index(drop=True), test_df.reset_index(drop=True) def run_threshold_sweep(seed, threshold): labeled_df, unlabeled_df, test_df = split_seed(seed) acc_base, ll_base, vectorizer, model = train_and_eval( labeled_df["text"].tolist(), labeled_df["label"].tolist(), test_df) mask, pseudo_labels = select_confident_pseudo_labels(model, vectorizer, unlabeled_df, threshold) n_added = int(mask.sum()) if n_added == 0: acc_st, ll_st, pseudo_acc = acc_base, ll_base, float("nan") else: true_labels = unlabeled_df.loc[mask, "label"].to_numpy() pseudo_acc = float((pseudo_labels == true_labels).mean()) texts_st = labeled_df["text"].tolist() + unlabeled_df.loc[mask, "text"].tolist() labels_st = labeled_df["label"].tolist() + pseudo_labels.tolist() acc_st, ll_st, _, _ = train_and_eval(texts_st, labels_st, test_df) return {"seed": seed, "n_added": n_added, "pseudo_acc": pseudo_acc, "acc_base": acc_base, "acc_st": acc_st, "ll_base": ll_base, "ll_st": ll_st} def run_selection_bias_ablation(seed, threshold=0.60): labeled_df, unlabeled_df, test_df = split_seed(seed) acc_base, ll_base, vectorizer, model = train_and_eval( labeled_df["text"].tolist(), labeled_df["label"].tolist(), test_df) mask, pseudo_labels = select_confident_pseudo_labels(model, vectorizer, unlabeled_df, threshold) n_added = int(mask.sum()) true_labels_selected = unlabeled_df.loc[mask, "label"].to_numpy() # (A) 疑似ラベル自己学習: 高確信度サブセット + モデル自身の予測ラベル acc_pseudo, ll_pseudo, _, _ = train_and_eval( labeled_df["text"].tolist() + unlabeled_df.loc[mask, "text"].tolist(), labeled_df["label"].tolist() + pseudo_labels.tolist(), test_df) # (B) オラクル: 同じ高確信度サブセット + 正解ラベル(ラベルノイズだけを除去) acc_oracle, ll_oracle, _, _ = train_and_eval( labeled_df["text"].tolist() + unlabeled_df.loc[mask, "text"].tolist(), labeled_df["label"].tolist() + true_labels_selected.tolist(), test_df) # (C) ランダム: 同じ件数をランダム選択 + 正解ラベル(選択バイアスだけを除去) rng = np.random.RandomState(seed) rand_idx = rng.choice(len(unlabeled_df), size=n_added, replace=False) rand_texts = unlabeled_df.iloc[rand_idx]["text"].tolist() rand_labels = unlabeled_df.iloc[rand_idx]["label"].tolist() acc_random, ll_random, _, _ = train_and_eval( labeled_df["text"].tolist() + rand_texts, labeled_df["label"].tolist() + rand_labels, test_df) return {"seed": seed, "n_added": n_added, "acc_base": acc_base, "acc_pseudo": acc_pseudo, "acc_oracle": acc_oracle, "acc_random": acc_random} SEEDS = [0, 1, 2, 3, 4] df_std = pd.DataFrame([run_threshold_sweep(s, 0.90) for s in SEEDS]) df_mod = pd.DataFrame([run_threshold_sweep(s, 0.60) for s in SEEDS]) df_perm = pd.DataFrame([run_threshold_sweep(s, 0.55) for s in SEEDS]) df_ablation = pd.DataFrame([run_selection_bias_ablation(s, 0.60) for s in SEEDS]) assert (df_std["n_added"] == 0).all(), "STANDARD閾値0.90で疑似ラベルが1件も追加されない、という実測結果が再現しませんでした" assert df_mod["acc_st"].mean() < df_std["acc_base"].mean(), "MODERATE閾値の自己学習がbaselineを悪化させていません" assert df_perm["acc_st"].mean() < df_std["acc_base"].mean(), "PERMISSIVE閾値の自己学習がbaselineを悪化させていません" assert df_ablation["acc_random"].mean() > df_ablation["acc_oracle"].mean() + 0.05, \ "ランダム選択(正解ラベル)がオラクルを明確に上回っていません" print("[OK] STANDARDは空振り/MODERATE・PERMISSIVEはbaselineを悪化/悪化の主因は選択バイアス")
# ============ 実測結果(実際にPython 3で実行して確認済み) ============
# ---- Part 1: 閾値スイープ ----
# STANDARD threshold=0.90: 5シード全てで n_added=0(疑似ラベル追加ゼロ、完全に空振り)
# MODERATE threshold=0.60: mean n_added≈91件 mean pseudo_acc=0.8995
# mean acc_base=0.7418 → mean acc_st=0.6236(悪化)
# PERMISSIVE threshold=0.55: mean n_added≈182件 mean pseudo_acc=0.8122
# mean acc_base=0.7418 → mean acc_st=0.6527(悪化、だがMODERATEより悪化幅は小さい)
#
# ---- Part 2: 選択バイアス vs ラベルノイズのアブレーション(閾値0.60)----
# mean acc_base = 0.7418
# (A) 疑似ラベル自己学習 mean acc = 0.6236
# (B) オラクル(同じ選択+正解ラベル) mean acc = 0.7291 ← baselineとほぼ同水準
# (C) ランダム(同数+正解ラベル) mean acc = 0.8818 ← 大きく改善
#
# [OK] STANDARDは空振り/MODERATE・PERMISSIVEはbaselineを悪化/悪化の主因は選択バイアス
predict_probaの実測レンジが0.22〜0.72程度に収まり、5シード全てで疑似ラベルが1件も追加されないという完全な空振りに終わった。📊 結果の可視化
① 40件でベースライン学習
train_and_eval()でラベル付き40件のみからbaselineを学習
② 閾値スイープで疑似ラベル追加
STANDARD/MODERATE/PERMISSIVEの3閾値でselect_confident_pseudo_labels()を実行
③ 選択バイアスのアブレーション
疑似ラベル・オラクル・ランダムの3パターンでtest精度を比較
| 設定 | 平均accuracy | 解釈 |
|---|---|---|
| (C) ランダム選択 + 正解ラベル | 0.8818 | 選択バイアスもラベルノイズも無い理想形 |
| baseline(40件のみ) | 0.7418 | 追加データなし |
| (B) オラクル(確信度選択+正解ラベル) | 0.7291 | ラベルノイズを除いても改善せず→選択バイアスが主因と判明 |
| (A) 疑似ラベル自己学習(確信度選択+予測ラベル) | 0.6236 | 選択バイアス+ラベルノイズの二重苦 |
🪜 Step-by-Step 解説
1なぜ「定番の閾値0.90」がテキスト分類では機能しないのか
mask = (proba >= threshold) | (proba <= (1 - threshold)) # threshold=0.90 のとき、5シード全てで mask.sum() == 0
2なぜ疑似ラベル精度が90%近くても自己学習は悪化するのか
acc_pseudo = 0.6236 # モデル自身の疑似ラベルを使用 acc_oracle = 0.7291 # 同じサブセット + 正解ラベル acc_random = 0.8818 # 同数のランダムサブセット + 正解ラベル
3なぜPERMISSIVE閾値(0.55)がMODERATE閾値(0.60)より"マシ"だったのか
# MODERATE(0.60): mean n_added≈91件, mean pseudo_acc=0.8995, mean acc_st=0.6236
# PERMISSIVE(0.55): mean n_added≈182件, mean pseudo_acc=0.8122, mean acc_st=0.6527
🧮 数学・統計の補足(文系向け)
40件しか勉強していない受験生が、模試の問題集(ラベルなしデータ)の中から「自分が自信を持って解ける問題」だけを選んで自己採点し、その解答を新しい過去問として勉強に追加する場面を想像するとよい。自己採点自体が仮に100%正確だったとしても(今日のオラクル設定に相当)、選んだ問題は「すでに得意な、簡単な問題」に偏っている。これでは本当に苦手な範囲(境界事例)を一切補強できず、模試全体の点数(test精度)はランダムに問題を追加した場合(ランダム設定に相当)ほど伸びない。
ラベルノイズは「自己採点を間違えた(答えを勘違いしたまま覚えてしまった)」ことに相当し、選択バイアスは「そもそも解く問題の範囲が偏っている(得意分野しか復習していない)」ことに相当する。今日の実測(オラクル0.7291 vs ランダム0.8818)は、自己採点の正確さを100%にしても、出題範囲の偏りだけでこれほど点数差が生まれることを示している。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☑ 自然言語処理(NLP) / ☑ 表形式データ(半教師あり学習全般に応用可)
🎨 確信度だけでなく「多様性」を考慮する
diversity-aware pseudo labeling
確信度上位を単純に選ぶだけでは選択バイアスが生じる。クラスタリングや埋め込み距離を使って「確信度が高く、かつ既存の学習データと似すぎていない」サンプルを優先的に選ぶ手法が上位解法で使われることがある。
📐 閾値は必ずデータごとに実測してから決める
流用は危険
「他のコンペや他のデータ形式で効いた閾値」をそのまま流用すると、今日のSTANDARD閾値(0.90)のように完全に機能しないことがある。predict_probaのヒストグラムを可視化し、実際の分布範囲を確認してから設計するのが安全である。
🔬 オラクル・ランダムとの比較を検証プロセスに組み込む
原因の切り分け
「疑似ラベルを使ったら精度が下がった」で終わらせず、「同じ選択+正解ラベル」「ランダム選択+正解ラベル」との比較を行うことで、原因がラベルの質か選び方かを切り分けられる。原因が分かれば対策の方向性が変わる。
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| 「他のデータ・他のコンペで効いた閾値」をそのまま流用できると思い込む | 「Pseudo Labeling」という同じ名前の手法だから同じパラメータで動くと錯覚する | ラベル付きデータの量・特徴量空間の性質(表形式 vs TF-IDFの疎な空間)が変わればpredict_probaが出しうる値の範囲も変わる。閾値は毎回実測して設計し直す必要がある |
| 疑似ラベルの精度(正解率)が高ければ自己学習は安全だと考える | ラベルノイズが唯一の悪化要因だと無意識に仮定してしまう | 疑似ラベル精度が約90%と高くても、確信度による選択プロセス自体が学習データの多様性を損ない(選択バイアス)、精度を悪化させることがある。ラベルノイズと選択バイアスは別の問題であり、両方を切り分けて検証する必要がある |
| 閾値を上げるほど(厳しくするほど)安全だと考える | 「確信度が高い=正しい可能性が高い」という直感を、選択バイアスの影響を考慮せずに適用してしまう | 今日の実測ではPERMISSIVE(0.55)の方がMODERATE(0.60)より悪化幅が小さかった。閾値を上げることは疑似ラベルの精度を上げる一方で追加サンプルの多様性を減らし選択バイアスを強める可能性があり、単純な一方向の改善にはならない |
| 「疑似ラベルが0件だった」ことをバグだと思い込みコードを疑う | 期待した挙動(何かしら追加される)と異なる結果が出ると、まず自分の実装ミスを疑ってしまう | 正しく実装されたコードでも「データの性質上、閾値の条件を満たす予測が存在しない」という結果は普通に起こりうる。まずproba分布を確認し、閾値がそのデータに対して現実的な値かを検証する習慣が必要 |
🚀 次のステップ
- 発展: 今日は確信度上位を選ぶ1回だけの自己学習(1ラウンド)を検証したが、複数ラウンドにわたって反復的に自己学習を繰り返すと、モデルが自身の予測に対してますます自信を深め、選択バイアスがラウンドを追うごとに強化される「確証バイアスの雪だるま現象」が起こりうる。予備検証では複数ラウンドの反復自己学習で最終的に未ラベルデータのほぼ全件が学習データに取り込まれ、精度がさらに悪化する傾向が観測された。この「ラウンド数と悪化の関係」を正式に検証することが次の発展候補である
- 次回予告: 引き続きPhase6の「NLP系コンペへの応用」を深掘りし、次回はback-translationのような文単位のTTA(Day150で保留)、または今日発見した「選択バイアス」を緩和するdiversity-awareな疑似ラベル選択(クラスタリングベースの多様性確保)をテキスト分類に実装して検証することを検討する
Phase 6 の学習マップ(NLP系コンペへの応用フェーズ継続中)
Day150に続き、Day151も「表形式・画像で効いた手法をそのままテキストに持ち込むと機能不全になる」というパターンが実測で再現された。TTAは情報の中身が壊れ、Pseudo Labelingは情報の選び方が壊れる、という異なるメカニズムで同じ結論に至った。
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: