📚 背景知識(読んでから問題へ)
今日はDay151の次回予告で挙がっていた対策候補の1つ、diversity-aware pseudo labeling(多様性を意識した疑似ラベル選択)を実装する。アイデアは、確信度が高い予測の中から闇雲に上位N件を取るのではなく、まずTF-IDFベクトルをKMeansでクラスタに分け、各クラスタに均等な割当数(quota)を与えたうえでクラスタ内の確信度上位から選ぶことで、「モデルにとって判断しやすい似た文ばかり」を選ぶ偏りを緩和しよう、というものである。
| 用語 | 意味 |
|---|---|
| 候補プール | 疑似ラベル候補として「確信度がある閾値以上」という条件を満たす、選択前の未ラベルデータの集合 |
| クラスタ割当(quota) | KMeansで分けた各クラスタに対し、選びたい総数を均等に割り振った上限数 |
| diversity-aware選択 | 確信度だけでなく、特徴量空間上の分布(クラスタ)も考慮してサンプルを選ぶ手法 |
| フェアな比較 | 「候補プールの広さ」と「選ぶ件数」を揃え、選び方(top-N vs diversity)だけを変数にして比較すること |
🗂️ データ・分割のスキーマ
| 変数名 | 型 | 内容 |
|---|---|---|
| df (n=450) | pd.DataFrame | Day151と同じmake_text_dataset()で生成した合成テキスト全体 |
| test_df (110件) | pd.DataFrame | 最終評価専用のホールドアウト。全設定で共通に使う |
| labeled_df (40件) | pd.DataFrame | 「ラベル付きデータが少ない」状況を模したベースライン学習セット |
| unlabeled_df (約300件) | pd.DataFrame | ラベルを隠した疑似ラベル候補プール(label列は分析専用でモデルには使わせない) |
threshold = 0.60 # Day151と同一。件数n_addedを決めるための基準 candidate_threshold = 0.55 # top-N・diversity両方が選ぶ「同じ候補プール」を作る閾値 n_clusters = 8 # KMeansのクラスタ数
🎯 問題
タスクA(理論)
以下2点をそれぞれ2〜3文で説明する。
タスクB(コーディング→分析)
make_text_dataset・split_seed・train_and_evalはDay151と同じ実装を再利用する(n=450、test_df110件/labeled_df40件/unlabeled_df残り約300件)select_confident_pseudo_labels(model, vectorizer, unlabeled_df, threshold) — Day151と同じ実装を再利用するselect_topn_from_pool(model, vectorizer, unlabeled_df, candidate_threshold, n_target) — 広めの閾値(0.55)で候補プールを作り、確信度上位n_target件を単純に選ぶ関数を実装する(「プールを広げただけ」の対照群)select_diverse_pseudo_labels(model, vectorizer, unlabeled_df, candidate_threshold, n_target, n_clusters, seed) — 候補プールのTF-IDFベクトルをKMeansでクラスタリングし、n_target件を各クラスタに均等割当(余りは平均確信度が高いクラスタから+1)し、クラスタ内は確信度が高い順に選ぶ関数を実装するunlabeled_df.loc[mask, "text"](常に元の行順=昇順)と結合する前に必ず昇順に戻してからmaskを作り、pseudo_labelsもproba[mask]のようにmask経由で取り出すことthreshold=0.60・candidate_threshold=0.55・n_clusters=8で、5シードにわたり (A) select_confident_pseudo_labels(0.60固定), (B) select_topn_from_pool(0.55プール・同数top-N), (C) select_diverse_pseudo_labels(0.55プール・同数diversity) の3パターンを比較する0.05未満であることをassert文で確認する💡 ヒント
タスクAの2.は、「候補プール自体がすでにモデルの確信度でフィルタされたサブセットである」という点を考えるとよい。Day151のランダム選択は「ラベルなしデータ全体」からランダムに選んでいたのに対し、今日のdiversity-aware選択は「確信度である閾値を満たすサブセット」の中だけでクラスタリングしている。このサブセット自体がすでにモデルの決定境界寄りに偏っている可能性があり、その中でどうクラスタ分けしても、Day151のランダム選択が持っていた「本当に多様なサンプル」までは再現できない可能性がある。
select_diverse_pseudo_labelsのKMeans.fit_predictは候補プールのベクトル(X_un[cand_idx])に対してのみ行う。unlabeled_df全体に対してクラスタリングすると候補プール外のクラスタ構造まで混ざるので注意- クラスタごとのquotaは
n_target // n_clustersを基本にし、割り切れない余りは「平均確信度が高いクラスタ」から優先的に+1する - 順序整合性のバグは非常に踏みやすい落とし穴である。
pseudo_labelsを作る直前に、必ずmask(boolean配列)経由でprobaにアクセスし直すことで防げる - (A)は
threshold=0.60固定のDay151と同じ選び方(対照点として残す)、(B)(C)はcandidate_threshold=0.55・同数n_addedで条件を完全に揃えることが、diversityそのものの効果を切り分けるポイント
def select_topn_from_pool(model, vectorizer, unlabeled_df, candidate_threshold, n_target): X_un = vectorizer.transform(unlabeled_df["text"]) proba = model.predict_proba(X_un)[:, 1] cand_idx = np.where((proba >= candidate_threshold) | (proba <= (1 - candidate_threshold)))[0] confidence = np.abs(proba[cand_idx] - 0.5) order = np.argsort(-confidence) n_target = min(n_target, len(cand_idx)) top_idx = np.sort(cand_idx[order[:n_target]]) # 昇順に戻す mask = np.zeros(len(unlabeled_df), dtype=bool) mask[top_idx] = True pseudo_labels = (proba[mask] >= 0.5).astype(int) # mask経由で取得し順序を一致させる return mask, pseudo_labels def select_diverse_pseudo_labels(model, vectorizer, unlabeled_df, candidate_threshold, n_target, n_clusters, seed): # ... cand_idxを作り、X_un[cand_idx]をKMeans(n_clusters, random_state=seed)でクラスタリング # ... クラスタごとにquotaを割当て、クラスタ内は確信度が高い順に選ぶ # ... 最後に選んだインデックスをnp.sort()で昇順に戻してからmaskを作る ...
✅ 模範解答
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 from sklearn.cluster import KMeans # POSITIVE_WORDS / NEGATIVE_WORDS / NEUTRAL_WORDS / make_text_dataset / train_and_eval / # split_seed / select_confident_pseudo_labels は Day151と同一(省略) def select_topn_from_pool(model, vectorizer, unlabeled_df, candidate_threshold, n_target): X_un = vectorizer.transform(unlabeled_df["text"]) proba = model.predict_proba(X_un)[:, 1] cand_idx = np.where((proba >= candidate_threshold) | (proba <= (1 - candidate_threshold)))[0] confidence = np.abs(proba[cand_idx] - 0.5) order = np.argsort(-confidence) n_target = min(n_target, len(cand_idx)) top_idx = np.sort(cand_idx[order[:n_target]]) mask = np.zeros(len(unlabeled_df), dtype=bool) mask[top_idx] = True pseudo_labels = (proba[mask] >= 0.5).astype(int) return mask, pseudo_labels def select_diverse_pseudo_labels(model, vectorizer, unlabeled_df, candidate_threshold, n_target, n_clusters, seed): X_un = vectorizer.transform(unlabeled_df["text"]) proba = model.predict_proba(X_un)[:, 1] cand_idx = np.where((proba >= candidate_threshold) | (proba <= (1 - candidate_threshold)))[0] if len(cand_idx) == 0: return np.zeros(len(unlabeled_df), dtype=bool), np.array([], dtype=int) n_target = min(n_target, len(cand_idx)) X_cand = X_un[cand_idx] k = min(n_clusters, len(cand_idx)) km = KMeans(n_clusters=k, random_state=seed, n_init=10) cluster_ids = km.fit_predict(X_cand) confidence = np.abs(proba[cand_idx] - 0.5) order = np.argsort(-confidence) quota = n_target // k remainder = n_target - quota * k cluster_quota = {c: quota for c in range(k)} cluster_conf_rank = pd.Series(confidence, index=cluster_ids).groupby(level=0).mean().sort_values(ascending=False) for c in list(cluster_conf_rank.index)[:remainder]: cluster_quota[c] += 1 taken = {c: 0 for c in range(k)} selected_local = [] for i in order: c = cluster_ids[i] if taken[c] < cluster_quota[c]: selected_local.append(i) taken[c] += 1 if len(selected_local) >= n_target: break selected_global_idx = np.sort(cand_idx[selected_local]) mask = np.zeros(len(unlabeled_df), dtype=bool) mask[selected_global_idx] = True pseudo_labels = (proba[mask] >= 0.5).astype(int) return mask, pseudo_labels def run_comparison(seed, threshold=0.60, candidate_threshold=0.55, n_clusters=8): 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) # (A) Day151のプレーン確信度選択(threshold=0.60)そのもの mask_conf, pseudo_conf = select_confident_pseudo_labels(model, vectorizer, unlabeled_df, threshold) n_added = int(mask_conf.sum()) acc_conf, ll_conf, _, _ = train_and_eval( labeled_df["text"].tolist() + unlabeled_df.loc[mask_conf, "text"].tolist(), labeled_df["label"].tolist() + pseudo_conf.tolist(), test_df) # (B) 同じ候補プール(0.55)からtop-Nを確信度だけで選ぶ mask_topn, pseudo_topn = select_topn_from_pool(model, vectorizer, unlabeled_df, candidate_threshold, n_added) acc_topn, ll_topn, _, _ = train_and_eval( labeled_df["text"].tolist() + unlabeled_df.loc[mask_topn, "text"].tolist(), labeled_df["label"].tolist() + pseudo_topn.tolist(), test_df) # (C) 同じ候補プール(0.55)・同じ件数(n_added)からdiversity-awareで選ぶ mask_div, pseudo_div = select_diverse_pseudo_labels( model, vectorizer, unlabeled_df, candidate_threshold, n_added, n_clusters, seed) acc_div, ll_div, _, _ = train_and_eval( labeled_df["text"].tolist() + unlabeled_df.loc[mask_div, "text"].tolist(), labeled_df["label"].tolist() + pseudo_div.tolist(), test_df) return {"seed": seed, "n_added": n_added, "acc_base": acc_base, "acc_conf": acc_conf, "acc_topn": acc_topn, "acc_div": acc_div} SEEDS = [0, 1, 2, 3, 4] df = pd.DataFrame([run_comparison(s) for s in SEEDS]) mean_acc_base = df["acc_base"].mean() mean_acc_topn = df["acc_topn"].mean() mean_acc_div = df["acc_div"].mean() assert mean_acc_topn < mean_acc_base, "同一プールtop-N選択がbaselineを悪化させていません" assert mean_acc_div < mean_acc_base, "diversity-aware選択がbaselineを悪化させていません" assert abs(mean_acc_topn - mean_acc_div) < 0.05, \ "top-N選択とdiversity-aware選択のtest精度差が0.05以上あります" print("[OK] 同一プール条件ではdiversity-aware選択が明確に優位とは言えない")
# ============ 実測結果(実際にPython 3で実行して確認済み) ============
# seed n_added acc_base acc_conf acc_topn acc_div pseudo_acc_conf pseudo_acc_topn pseudo_acc_div
# 0 94 0.763636 0.581818 0.581818 0.590909 0.946809 0.946809 0.893617
# 1 125 0.727273 0.590909 0.590909 0.618182 0.888000 0.888000 0.856000
# 2 81 0.781818 0.645455 0.645455 0.645455 0.938272 0.938272 0.962963
# 3 75 0.736364 0.672727 0.672727 0.663636 0.840000 0.840000 0.813333
# 4 78 0.700000 0.627273 0.627273 0.627273 0.884615 0.884615 0.871795
#
# [まとめ]
# mean acc_base = 0.7418
# mean acc_conf(0.60固定, Day151と同一) = 0.6236 (pseudo_acc=0.8995)
# mean acc_topn(同一プール0.55, top-N) = 0.6236 (pseudo_acc=0.8995) ※acc_confと完全一致(sanity check成立)
# mean acc_div (同一プール0.55, diversity)= 0.6291 (pseudo_acc=0.8795)
# |acc_topn - acc_div| = 0.0055(0.05未満)
#
# [OK] 同一プール条件ではdiversity-aware選択が明確に優位とは言えない
pseudo_labelsを作っていた。一方unlabeled_df.loc[mask]は常に元の行順(昇順)を返すため、この2つの順序がズレたまま結合すると、正しい疑似ラベルが別の行に付け替わる。「acc_confとacc_topnは理論上一致するはず」というsanity checkが最初は成立せず、それが発覚のきっかけになった。修正は「選んだインデックスをnp.sort()で昇順に戻し、pseudo_labelsは必ずmask経由で取得する」という単純なもの。📊 結果の可視化
① 同じ候補プールを作る
candidate_threshold=0.55で(B)(C)が選ぶ母集団を揃える
② top-N vs diversity-aware
同じ件数n_added件を「確信度順」「クラスタquota」の2通りで選ぶ
③ フェアな比較で判定
選び方だけを変数にして、diversityが本当に効くかを5シードで確認
| 設定 | 平均accuracy | 解釈 |
|---|---|---|
| Day151参考: ランダム選択+正解ラベル(未ラベル全体から) | 0.8818 | 母集団自体が偏っていない理想形(参考値) |
| baseline(40件のみ) | 0.7418 | 追加データなし |
| (C) diversity-aware選択(候補プール内クラスタquota) | 0.6291 | top-Nよりわずかに高いが誤差の範囲 |
| (B) プレーンtop-N選択(同一候補プール) | 0.6236 | 母集団の偏りは解消されないまま |
🪜 Step-by-Step 解説
1なぜacc_confとacc_topnが完全一致することが「正しさの検証」になるのか
# mean acc_conf(0.60固定) = 0.6236
# mean acc_topn(同一プール0.55, top-N) = 0.6236 # 完全一致
0.55という広いプールから確信度上位n_added件(=0.60のプールのサイズ)を取れば、選ばれる集合は理論上0.60のプールと完全に一致するはずである。この一致が5シード全てで成立したことは、「候補プールを広げる」操作自体は選択結果に影響しない(プールの広さそのものはバイアスの原因ではない)ことを裏付けると同時に、実装が意図通り動いていることのsanity checkとしても機能する。逆にこの一致が崩れていれば、それは実装バグの証拠になる——実際に開発過程ではこの一致が最初は崩れており、バグ発見の糸口になった。2なぜfancy indexingの順序とboolean maskの順序のズレが危険なのか
# 危険な書き方(初期バージョンで実際に踏んだバグ): top_idx = cand_idx[order[:n_target]] # 確信度でソートされた順 pseudo_labels = (proba[top_idx] >= 0.5).astype(int) # top_idxの順(確信度順) # 一方 unlabeled_df.loc[mask, "text"] は元の行順(昇順)で返す → 順序が一致しない! # 安全な書き方(修正後): top_idx = np.sort(cand_idx[order[:n_target]]) # 昇順に戻す mask = np.zeros(len(unlabeled_df), dtype=bool) mask[top_idx] = True pseudo_labels = (proba[mask] >= 0.5).astype(int) # mask経由で取得 → 昇順で一致
np.argsortなどで確信度順に並べ替えたインデックス配列と、.loc[boolean_mask]が返す行順(常に元の行順=昇順)は別物である。この2つを別々に使って「テキストのリスト」と「ラベルのリスト」を作ると、要素数は合っていても対応関係がズレるという、気づきにくいバグになる。このバグは疑似ラベルの精度を落とすのではなく、正しい疑似ラベルを別のテキストに付け替えてしまうため、pseudo_accの異常な低下という形で症状が出る。実測でpseudo_acc_topnが0.76前後に不自然に低下した(修正前)ことが、原因調査のきっかけになった。3なぜdiversity-aware選択はDay151のランダム選択に及ばなかったのか
mean_acc_div = 0.6291 # 候補プール内クラスタリング mean_acc_random = 0.8818 # Day151: 未ラベルデータ全体からのランダム選択(参考値)
🧮 数学・統計の補足(文系向け)
クラス全員(未ラベルデータ全体)からランダムに班を作れば、成績上位者も下位者も混ざった多様な班ができる(Day151のランダム選択に相当)。しかし「テストで80点以上を取った生徒だけ」を集めてから班分けした場合(今日のdiversity-aware選択に相当)、班ごとに得意科目は多少ばらけても、そもそも「80点以上」という母集団自体が偏っているため、クラス全体の多様性(苦手科目を持つ生徒の視点)は再現できない。母集団の選び方(誰を対象にクラスタリングするか)が、多様性確保の効果の上限を決めてしまう、という直感である。
確信度でソートした配列は、いわば「成績順に並べ替えた出席簿」である。一方
.loc[mask]は「元の出席番号順の出席簿からmaskに該当する生徒だけ抜き出したもの」である。この2つの出席簿の並び順を混同して「成績順の名前リスト」と「出席番号順の点数リスト」を組み合わせると、生徒と点数の対応がズレる。今日のバグはまさにこれに相当し、要素数が合っているだけに見た目では気づきにくい。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☑ 自然言語処理(NLP) / ☑ 表形式データ(半教師あり学習全般に応用可)
🎯 多様性を測る母集団まで検証する
母集団の選び方が上限を決める
「確信度の高いサブセット内でクラスタリングする」だけでは選択バイアスの根本原因(母集団そのものの偏り)は解消できない。確信度フィルタを適用する前の全データに対してクラスタリングを行う設計の方が有望である。
🔍 「一致するはず」のsanity checkを仕込む
acc_conf ≡ acc_topn
理論上一致するはずの2つの計算結果をあえて両方計算して突き合わせることで、実装バグを早期に発見できる。疑似ラベル選択・TTA・スタッキングなど「複数の配列を後から結合する」処理で特に有効である。
⚙️ fancy indexingとmaskの混在を避ける規約
順序整合性バグの予防
「複数の配列を対応付けて使う場合は必ず同じ方法(mask経由 or 同じソート済みインデックス経由)でアクセスする」というルールをコーディング規約にしておくと、静かに指標を壊すバグを未然に防げる。
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| 「多様性を確保する」対策を入れれば選択バイアスは必ず緩和されると思い込む | 「diversity」という言葉の響きから、対策を入れさえすれば効果が出ると期待してしまう | 多様性を測る母集団自体が既に偏っている(確信度フィルタ後のプール内でのクラスタリング)場合、対策を入れても効果はほぼ出ない。多様性確保の効果は「どの母集団に対して多様性を測るか」に強く依存する |
確信度でソートした配列のインデックスと.loc[mask]の行順が同じだと思い込む | どちらも「同じ行を指している」という点だけに注目し、配列の並び順(ソート済みか元の順か)を意識しない | np.argsortなどで並べ替えたインデックス配列は確信度順、.loc[boolean_mask]は常に元の行順(昇順)を返す。この2つを混在させると要素数は合っていても対応関係がズレる |
| 疑似ラベル関連の指標が不自然に低い場合、真っ先に「閾値やモデルの問題」を疑う | Day151で「閾値の実測が大事」という教訓を得たばかりだと、同種の原因を優先的に疑ってしまう | 指標の異常低下は閾値の問題ではなく「配列の順序ズレ」のような実装バグが原因のこともある。まず「理論上一致するはずの値同士を突き合わせる」sanity checkで、閾値設計の問題か実装バグかを切り分けるとよい |
KMeansのクラスタ数n_clustersを大きくすれば多様性はいくらでも改善すると考える | クラスタ数を増やすほど「きめ細かく分ける」ので多様性が増しそうだと直感的に考えてしまう | クラスタ数を増やしても、クラスタリングの対象となる母集団(今日で言えば候補プール)自体の偏りは解消されない。母集団を広げない限り、クラスタ数の調整だけで得られる改善には上限がある |
🚀 次のステップ
- 発展: 今日の考察を踏まえると、真に選択バイアスを緩和する設計は「確信度フィルタを適用する前の未ラベルデータ全体」に対してクラスタリングを行い、各クラスタから確信度に関係なく一定数を選ぶ(クラスタ代表点+確信度は補助情報として使う)方式である可能性が高い。この「フィルタ前 vs フィルタ後」のクラスタリング順序の違いが実際に結果を変えるかを検証することが次の発展候補である
- 次回予告: 引き続きPhase6の「NLP系コンペへの応用」を深掘りし、次回はDay150で保留したback-translationのような文単位のTTA、または「フィルタ前クラスタリング」による多様性確保の再検証を検討する
Phase 6 の学習マップ(NLP系コンペへの応用フェーズ継続中)
Day150→151→152と、「表形式・画像で効いた手法・素朴な対策をそのままテキストに持ち込むと期待通りには機能しない」というパターンが3日連続で実測された。今日は対策そのもの(diversity-aware選択)まで検証範囲に含め、その対策自身も母集団の選び方次第で効果が限定的になることが分かった。
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: