📚 背景知識(読んでから問題へ)
Day155由来の発展候補は「(D)の劣化は"採用件数急増×正解率急落"という早期警告シグナルから検知できるのでは」だった。素朴には「そのラウンドの新規採用サンプルの正解率(round_pseudo_acc)」を監視すれば良さそうに見えるが、round_pseudo_accは採用サンプルの"真のラベル"が必要であり、実際のKaggleコンペの未ラベルデータでは原理的に入手不能なオラクル情報である。一方、これまでのコードは毎ラウンドtest_df(自前のローカル検証分割)でtest_accを計算してきた——これはラベル不要で実運用でも使える正当な信号である(ただし本番のPublic Leaderboardと混同してはならない)。
| 用語 | 意味 |
|---|---|
| val_es | 検証ベース早期打ち切り。acc_afterが直前の採択ラウンドより下がったら打ち切る(patience=1)。真ラベル不要 |
| oracle_es | オラクル早期打ち切り。round_pseudo_acc(真ラベル必要)が0.50未満なら打ち切る。実運用では計算不能・診断専用 |
| own-final-round | 各手法・各ルールが実際に打ち切られた最終ラウンドどうしの比較 |
🗂️ データ・分割のスキーマ
| 変数名 | 型 | 内容 |
|---|---|---|
| df (n=450) | pd.DataFrame | Day151-156と同じmake_text_dataset()で生成した合成テキスト全体 |
| test_df (110件) | pd.DataFrame | 自前のローカル検証用ホールドアウト。毎ラウンドtest_accを計算する(本番のLBではない) |
| labeled_df (40件) | pd.DataFrame | 最初の学習データ。採択されたラウンドの疑似ラベルのみ追記される |
| remaining_df | pd.DataFrame | 未採用の未ラベルプール。打ち切りが発動したラウンドの分は反映されない |
threshold = 0.60 # ラウンドのn_addedを決める基準(Day151-156と同一) n_clusters = 8 # KMeansのクラスタ数 quota_mode = "confidence" # Day156の実測により固定(質的結論を変えないため) stop_mode = None | "val" | "oracle" # ★Day157で追加した早期打ち切り切替 oracle_acc_threshold = 0.50 # round_pseudo_accのコイントス水準しきい値
🎯 問題
タスクA(理論)
以下2点をそれぞれ2〜3文で説明する。
タスクB(コーディング→分析)
make_text_dataset〜select_diverse_prefilter(_uncertainty)はDay151-156と同じ実装を再利用(quota_mode="confidence"固定)run_iterative(..., stop_mode)にstop_mode∈{None,"val","oracle"}を追加。"val"はacc_afterが直前採択ラウンドより下がったら打ち切り(真ラベル不要)、"oracle"はround_pseudo_acc<0.50で打ち切り(真ラベル必要)assertで確認する💡 ヒント
stop_mode="val"を実装する際は「ラウンドrを計算してみてから、悪化していたら捨てる」という順序を守ることが重要である。Day155〜156のコードは「計算したら必ず採用する」前提だったため、ここが今日一番の実装変更点になる。
stop_mode="oracle"はround_pseudo_acc計算直後・学習データへの追加処理の前に判定してよい(ただし意思決定に使うこと自体が非現実的な点に注意)stop_mode="val"はround_pseudo_accを一切見ず、test_accの増減だけで判定する- 「直前に採択したラウンドのacc」を
prev_accとして保持し、round1は無条件で採択する - DとG両方に同じ
stop_mode="val"を適用することが公平な比較の鍵。片方だけに有利なルールを当てると、gap縮小が「ルールの効果」か「贔屓の結果」か区別できなくなる
def run_iterative(seed, select_fn, quota_mode="confidence", threshold=0.60, n_clusters=8, n_rounds=5, stop_mode=None, oracle_acc_threshold=0.50): labeled_df, unlabeled_df, test_df = split_seed(seed) cur_texts = labeled_df["text"].tolist(); cur_labels = labeled_df["label"].tolist() remaining_df = unlabeled_df.copy().reset_index(drop=True) rows = []; prev_acc = None for r in range(1, n_rounds + 1): acc, vectorizer, model = train_and_eval(cur_texts, cur_labels, test_df) if len(remaining_df) == 0: break mask_conf, _ = select_confident_pseudo_labels(model, vectorizer, remaining_df, threshold) n_added = int(mask_conf.sum()) if n_added == 0: break mask_sel, pseudo_sel = select_fn(model, vectorizer, remaining_df, n_added, n_clusters, seed, quota_mode) true_sel = remaining_df.loc[mask_sel, "label"].to_numpy() round_pseudo_acc = float((pseudo_sel == true_sel).mean()) if stop_mode == "oracle" and round_pseudo_acc < oracle_acc_threshold: break # ★真のラベルが必要な非現実的ルール new_texts = cur_texts + remaining_df.loc[mask_sel, "text"].tolist() new_labels = cur_labels + pseudo_sel.tolist() new_remaining_df = remaining_df.loc[~mask_sel].reset_index(drop=True) acc_after, _, _ = train_and_eval(new_texts, new_labels, test_df) if stop_mode == "val" and prev_acc is not None and acc_after < prev_acc: break # ★ラベル不要、現実的なルール cur_texts, cur_labels, remaining_df = new_texts, new_labels, new_remaining_df rows.append({"round": r, "round_pseudo_acc": round_pseudo_acc, "test_acc": acc_after}) prev_acc = acc_after return pd.DataFrame(rows)
✅ 模範解答
# make_text_dataset / train_and_eval / split_seed / select_confident_pseudo_labels / # _cluster_and_quota / select_diverse_prefilter(D) / select_diverse_prefilter_uncertainty(G) は # Day151-156と同一(省略、quota_mode="confidence"固定で使用) def run_iterative(seed, select_fn, quota_mode="confidence", threshold=0.60, n_clusters=8, n_rounds=5, stop_mode=None, oracle_acc_threshold=0.50): """stop_mode: None(打ち切りなし) / "val"(現実的・検証accベース) / "oracle"(非現実的・真ラベル必要)""" labeled_df, unlabeled_df, test_df = split_seed(seed) cur_texts = labeled_df["text"].tolist(); cur_labels = labeled_df["label"].tolist() remaining_df = unlabeled_df.copy().reset_index(drop=True) rows = []; prev_acc = None for r in range(1, n_rounds + 1): acc, vectorizer, model = train_and_eval(cur_texts, cur_labels, test_df) if len(remaining_df) == 0: break mask_conf, _ = select_confident_pseudo_labels(model, vectorizer, remaining_df, threshold) n_added = int(mask_conf.sum()) if n_added == 0: break mask_sel, pseudo_sel = select_fn(model, vectorizer, remaining_df, n_added, n_clusters, seed, quota_mode) true_sel = remaining_df.loc[mask_sel, "label"].to_numpy() round_pseudo_acc = float((pseudo_sel == true_sel).mean()) if stop_mode == "oracle" and round_pseudo_acc < oracle_acc_threshold: break new_texts = cur_texts + remaining_df.loc[mask_sel, "text"].tolist() new_labels = cur_labels + pseudo_sel.tolist() new_remaining_df = remaining_df.loc[~mask_sel].reset_index(drop=True) acc_after, _, _ = train_and_eval(new_texts, new_labels, test_df) if stop_mode == "val" and prev_acc is not None and acc_after < prev_acc: break cur_texts, cur_labels, remaining_df = new_texts, new_labels, new_remaining_df rows.append({"round": r, "n_added": int(mask_sel.sum()), "pool_left": len(remaining_df), "round_pseudo_acc": round_pseudo_acc, "test_acc": acc_after}) prev_acc = acc_after return pd.DataFrame(rows) SEEDS = [0,1,2,3,4]; N_ROUNDS = 5; QUOTA_MODE = "confidence" configs = { "D_base": (select_diverse_prefilter, None), "G_base": (select_diverse_prefilter_uncertainty, None), "D_val_es": (select_diverse_prefilter, "val"), "G_val_es": (select_diverse_prefilter_uncertainty, "val"), "D_oracle_es": (select_diverse_prefilter, "oracle"), } # 各configを5シードで実行 -> own-final-round(round1 acc・final acc)を集計 -> summary[name] に格納(詳細は省略) assert d_val_es_final > d_base_final assert g_val_es_final > g_base_final assert gap_val_es < gap_base assert abs(d_val_es_final - d_round1) < 1e-9 assert d_oracle_es_final < d_val_es_final # ★意外な発見の確認 print("[OK] 5個のassertすべて成立")
# ============ 実測結果(実際にPython 3で実行して確認済み) ============
# [ラウンド別 平均]
# D_base round1: n_added=90.6 round_pseudo_acc=0.8674 test_acc=0.6327
# round2: n_added=140.8 round_pseudo_acc=0.5519 test_acc=0.5727
# round3〜5: test_acc 0.5491 -> 0.5473 -> 0.5273(単調悪化)
# G_base round1: test_acc=0.6800 / round2〜5: 0.6491 -> 0.6455 -> 0.6436 -> 0.5909
# D_val_es 5シード全て n_rounds_used=1(round2でacc_afterがround1を下回り即打ち切り)
# G_val_es 5シード全て n_rounds_used=1(同上)
# D_oracle_es round1: round_pseudo_acc=0.8674 test_acc=0.6327
# round2: round_pseudo_acc=0.5758(閾値0.50は上回る) test_acc=0.5705(採用したが悪化)
#
# [own-final-round 平均]
# mean_acc_round1 mean_acc_final mean_n_rounds_used
# D_base 0.6327 0.5455 4.6
# G_base 0.6800 0.6455 4.2
# D_val_es 0.6327 0.6327 1.0 ← round1で完全収束
# G_val_es 0.6800 0.6800 1.0 ← round1で完全収束
# D_oracle_es 0.6327 0.5800 1.8
#
# gap_base = 0.6455 - 0.5455 = +0.1000(Day155/156と同一)
# gap_val_es = 0.6800 - 0.6327 = +0.0473(Day155のgap_round1と完全一致)
#
# D_base: round1=0.6327 -> final=0.5455 (delta=-0.0873)
# D_val_es: round1=0.6327 -> final=0.6327 (delta= 0.0000) ← 劣化を完全に解消
# D_oracle_es: round1=0.6327 -> final=0.5800 (delta=-0.0527) ← 一部しか解消できない
#
# n_rounds_used: D_base[4,4,5,5,5] / G_base[4,4,4,5,4] / D_val_es[1,1,1,1,1] /
# G_val_es[1,1,1,1,1] / D_oracle_es[2,1,2,2,2]
#
# [OK] 5個のassertすべて成立
📊 結果の可視化
① Day155-156の発見
Dは反復でconfirmation biasにより劣化(-13.8%)。gapはround1の+0.047から反復後+0.100へ拡大
② 2つの打ち切り信号
(a)真ラベル必要なround_pseudo_acc(oracle) vs (b)ラベル不要のtest_acc増減(val)
③ 公平な適用
val_esをDとG両方に適用し5シードで実行
④ 結果
val_esは両方をround1に収束させgapを+0.047に戻した。oracle_esはval_esに負けた
| 指標 | D_base(打ち切りなし) | D_oracle_es(真ラベル必要) | D_val_es(ラベル不要) |
|---|---|---|---|
| own-final-round test_acc | 0.5455 | 0.5800 | 0.6327 |
| round1からの劣化(delta) | -0.0873 | -0.0527 | 0.0000 |
| 平均使用ラウンド数 | 4.6 | 1.8 | 1.0 |
| gap(G-D, 同条件) | +0.1000 | +0.0655(片側適用の参考値) | +0.0473 |
🪜 Step-by-Step 解説
1val_esが「Dだけを助けるルール」ではなく「反復そのものが無価値だった」ことを暴いた
D_val_es: 5シード全て n_rounds_used=1 (round1で打ち切り) G_val_es: 5シード全て n_rounds_used=1 (round1で打ち切り) gap_val_es = G_val_es(0.6800) - D_val_es(0.6327) = +0.0473 = Day155のgap_round1と完全一致
2「真のラベルが使えるなら万能」という直感が崩れた理由
D_oracle_es round2: round_pseudo_acc=0.5758(閾値0.50は上回る) だが test_acc は 0.6327 -> 0.5705 に悪化 D_val_es round2: test_acc が round1 を下回った時点で(round_pseudo_accを見ずに)即座に打ち切り
3早期打ち切りルールの実装で、判定の「順序」が結果を左右する
# 正しい順序: 計算 -> 判定 -> (採用 or 破棄) acc_after, _, _ = train_and_eval(new_texts, new_labels, test_df) if stop_mode == "val" and prev_acc is not None and acc_after < prev_acc: break # new_texts/new_labels/new_remaining_df を確定させない cur_texts, cur_labels, remaining_df = new_texts, new_labels, new_remaining_df # ここで初めて確定
cur_textsなどを先に上書きしてから判定すると「悪化したラウンドのデータが既に確定してしまっている」というバグになり、打ち切っても手遅れになる。「新しい状態を仮に計算する」→「良し悪しを判定する」→「良ければ確定、悪ければ破棄して打ち切る」という3段階の分離が、早期打ち切りロジックの実装で最も間違えやすいポイントである。🧮 数学・統計の補足(文系向け)
ある生徒が模試を受けて、「今回新しく解いた問題」の正答率が58%だったとする。「前回よりは良い」と思って気を良くしたが、実は模試全体の総合得点は前回より下がっていた——新しく解いた問題の一部だけを見て「今回は調子が良い」と判断すると、全体の成績という本来知りたい量を見誤ることがある。今日の
round_pseudo_acc(新しく採用したサンプルだけの正解率)とtest_acc(モデル全体の実力を測る総合得点)の関係も同じ構造で、部分の指標が良く見えても、全体の指標が同じ方向に動くとは限らない。
詳細な情報が書き込まれた紙の地図(oracle: 個々のサンプルの正解率という詳細な情報)を持っていても、それが「今、目的地に近づいているかどうか」という本当に知りたい情報(test_acc: 実際の到達度)を直接教えてくれるとは限らない。一方、情報量は少なくても「今の進行方向で目的地までの距離が縮まっているか」だけを教えてくれるシンプルなカーナビ(val: test_accの増減だけを見る)の方が、道に迷わずに済むことがある。判断材料は「詳しいかどうか」ではなく「本当に知りたいことを直接測れているかどうか」で選ぶべきである。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☑ 自然言語処理(NLP) / ☑ 表形式データ(半教師あり学習全般に応用可)
🛑 反復回数をearly stoppingするハイパーパラメータとして扱う
決め打ちで固定しない
ニューラルネットの学習でのearly stoppingと同じ発想で、ローカル検証スコアの増減を見て動的に打ち切る仕組みを組み込むと、反復が正味でマイナスになるケースを自動的に回避できる。
🎯 「採用サンプルの見かけの質」だけで停止判断しない
代理指標は診断用にとどめる
round_pseudo_accのような代理指標は可視化には有用だが、それ単体で「このラウンドを採用すべきか」を決める材料にするのは危険。モデル全体の検証スコアの動きを都度確認する方が信頼できる。
⚖️ 改善策は比較対象の全手法に公平に適用する
片方だけの改善は効果を過大評価する
D_val_es vs G_baseのような不公平な比較は改善効果を過大評価するリスクがある。改善策を評価するときは、比較対象の全手法に同じ条件で適用したうえで差を見る習慣が必要。
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| 真のラベルを使う指標(oracle)は、ラベル不要の指標(val)より必ず優れていると考える | 「情報量が多い方が良い判断ができるはず」という直感が強く働く | 代理指標(round_pseudo_acc)が目的指標(test_acc)とズレている場合、情報量の多い代理指標を使うルールが、目的指標を直接見るだけのシンプルなルールに負けることがある |
| 早期打ち切りルールを片方の手法(D)だけに適用して、もう片方(G)は変えずに比較する | 「劣化しているのはDなのだから、Dだけ直せばよい」という発想が自然に思える | 同じルールをG側にも公平に適用しないと、gapの縮小が「ルールの本当の効果」か「Dだけを贔屓したこと」か区別できない。今日はGにも適用したことでより本質的な発見につながった |
| 反復ラウンドを増やせば増やすほど、疑似ラベリングの効果も積み上がっていくと考える | 「データが増える=良くなる」という一般的な直感に引きずられる | 今日の実測ではD・Gともにround1が事実上の大域最適であり、round2以降の反復は両方にとって正味でマイナスだった。反復回数は都度検証して判断すべきハイパーパラメータの1つである |
| 「新しく採用したサンプルの正解率が閾値を超えている」ことを、モデル全体の性能改善の証拠として扱う | 部分の指標が良ければ全体も良くなるはず、という単純な足し算的発想 | D_oracle_esのround2(round_pseudo_acc=0.5758で採用したがtest_accは悪化)が示す通り、部分の指標と全体の指標は別物であり、全体の指標を直接監視する方が信頼できる |
🚀 次のステップ
- 発展: 今日はoracle_esの閾値を0.50(コイントス水準)に固定したが、より厳しい閾値(例: 0.70)にすればoracle_esがval_esに追いつけるかは未検証。追いついたとしても「適切な閾値をどう事前に決めるか」という別の困難(実運用では真のラベルなしにチューニングできない)が残る
- 次回予告: Day158では、今日発見した「round1が事実上の大域最適」という現象が、この合成データセット固有の性質なのか、それとも疑似ラベリング全般に共通する構造(初期ラウンドほど確信度の高い"簡単な"サンプルが残っており、後のラウンドほど"難しい"サンプルしか残らない)によるものかを、ラウンドごとの残プール(pool_left)の難易度分布を分析して検証する
Phase 6 の学習マップ(NLP系コンペへの応用フェーズ継続中)
Day157は「Dだけを助ける」という当初の狙いを超え、「反復自体がDにもGにも正味で無価値だった」という、より根本的で一般性の高い発見にたどり着いた回。次はこの"round1が最適"という現象が、このデータ固有の癖なのか一般的な構造なのかを掘り下げる。
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: