Day 157 — NLP系コンペへの応用⑧ — オラクル打ち切り vs 検証ベース打ち切り、Dの劣化を防げるのはどちらか

2026-09-16 金 / Phase 6(Grandmaster、総復習後の発展回) コーディング→分析 真ラベル不要のval_esが、真ラベルを使うoracle_esに勝つ

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

🔁
Day155・156のおさらい: クラスタ内選択基準を「確信度優先(D)」にすると、Iterative Pseudo Labelingでround-local疑似ラベル精度が0.867→0.153へ単調に崩壊し、test精度もround1の0.6327からown-final-roundで0.5455まで劣化する(相対-13.8%)。「Uncertainty Sampling(G)」は劣化-5.1%にとどまり、gap(G-D)はround1の+0.0473からown-final-roundで+0.1000へと2倍以上に拡大する。Day156ではこの拡大がquota配分ルールという副次的設計に依存しないことを確認した。

Day155由来の発展候補は「(D)の劣化は"採用件数急増×正解率急落"という早期警告シグナルから検知できるのでは」だった。素朴には「そのラウンドの新規採用サンプルの正解率(round_pseudo_acc)」を監視すれば良さそうに見えるが、round_pseudo_accは採用サンプルの"真のラベル"が必要であり、実際のKaggleコンペの未ラベルデータでは原理的に入手不能なオラクル情報である。一方、これまでのコードは毎ラウンドtest_df(自前のローカル検証分割)でtest_accを計算してきた——これはラベル不要で実運用でも使える正当な信号である(ただし本番のPublic Leaderboardと混同してはならない)。

🧭
今日の問い: (a) 真のラベルを要するround_pseudo_acc閾値ルール(オラクル)と、(b) test_accの増減を見るルール(現実的・ラベル不要)——「情報量が多そうなオラクル信号」の方が必ず優れているのか、それとも「情報量は少ないが本来の目的を直接測る信号」の方が強いのかを実測する。
用語意味
val_es検証ベース早期打ち切り。acc_afterが直前の採択ラウンドより下がったら打ち切る(patience=1)。真ラベル不要
oracle_esオラクル早期打ち切り。round_pseudo_acc(真ラベル必要)が0.50未満なら打ち切る。実運用では計算不能・診断専用
own-final-round各手法・各ルールが実際に打ち切られた最終ラウンドどうしの比較

🗂️ データ・分割のスキーマ

変数名型内容
df (n=450)pd.DataFrameDay151-156と同じmake_text_dataset()で生成した合成テキスト全体
test_df (110件)pd.DataFrame自前のローカル検証用ホールドアウト。毎ラウンドtest_accを計算する(本番のLBではない)
labeled_df (40件)pd.DataFrame最初の学習データ。採択されたラウンドの疑似ラベルのみ追記される
remaining_dfpd.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文で説明する。

1
Day155の「(D)のround-local疑似ラベル精度は0.867→0.153まで単調に崩壊する」という事実を踏まえ、(D)に早期打ち切りルールを導入した場合、劣化は「ほぼ完全に解消する」「一部は緩和されるが劣化は残る」「ほとんど効果がない」のどれに近いと予想されるか、理由とともに述べる
2
round_pseudo_acc(オラクル)とtest_acc(現実的)はどちらも「そのラウンドの質」を測る指標である。実運用のKaggleコンペでround_pseudo_accが使えない理由を1文で、また「オラクルの方が情報量が多いはずだから必ず優れているはず」という直感が成り立たない可能性があるとすればそれはなぜかを予想する

タスクB(コーディング→分析)

B1
make_text_dataset〜select_diverse_prefilter(_uncertainty)はDay151-156と同じ実装を再利用(quota_mode="confidence"固定)
B2
run_iterative(..., stop_mode)にstop_mode∈{None,"val","oracle"}を追加。"val"はacc_afterが直前採択ラウンドより下がったら打ち切り(真ラベル不要)、"oracle"はround_pseudo_acc<0.50で打ち切り(真ラベル必要)
B3
5通り(D_base・G_base・D_val_es・G_val_es・D_oracle_es)を5シード・最大5ラウンドで実行。DとGの両方に同じval_esルールを公平に適用するのが鍵
B4
own-final-round比較(round1 acc・final acc・使われたラウンド数)を計算する
B5
(a) D_val_es>D_base (b) G_val_es>G_base (c) gap_val_es<gap_base (d) D_val_es_final==D_round1 (e) D_oracle_es<D_val_es、をassertで確認する
B6
追加診断: (e)がなぜ起きたのか、D_oracle_esのround2の詳細(round_pseudo_acc・test_acc)を見て説明する
B7
(考察): タスクAの2つの予想が実測とどの程度一致したかを、round1が事実上の大域最適だったという発見と関連づけて述べる

💡 ヒント

ヒント1方向性

stop_mode="val"を実装する際は「ラウンドrを計算してみてから、悪化していたら捨てる」という順序を守ることが重要である。Day155〜156のコードは「計算したら必ず採用する」前提だったため、ここが今日一番の実装変更点になる。

ヒント2アプローチ
  • stop_mode="oracle"はround_pseudo_acc計算直後・学習データへの追加処理の前に判定してよい(ただし意思決定に使うこと自体が非現実的な点に注意)
  • stop_mode="val"はround_pseudo_accを一切見ず、test_accの増減だけで判定する
  • 「直前に採択したラウンドのacc」をprev_accとして保持し、round1は無条件で採択する
  • DとG両方に同じstop_mode="val"を適用することが公平な比較の鍵。片方だけに有利なルールを当てると、gap縮小が「ルールの効果」か「贔屓の結果」か区別できなくなる
ヒント3コード骨格
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すべて成立
🎯
重要な発見1(val_esが解消したのは"Dだけの問題"ではなく"反復そのものの無価値さ"だった): DとG両方に同じval_esを公平に適用すると、両方ともround1で打ち切られ(5シード全て)、gapはDay155で反復拡大する前の値(+0.0473)にきれいに収束した。これは反復を続けたことで生じていた"拡大したgap(+0.1000)"そのものが、反復自体がGにとっても正味で有害だったことの副産物だったことを示している。
⚖️
重要な発見2(真のラベルを使うoracle_esが、ラベル不要のval_esに負けた): oracle_esはround2のround_pseudo_acc=0.5758(閾値0.50は上回る)を見て採用したが、そのラウンドの採用でtest_accはさらに0.5705まで悪化した。「採用サンプル自体の正解率」という代理指標と「モデル全体の汎化性能」という目的指標がズレており、情報量の多い指標が必ず優れているとは限らない。
🔍
重要な発見3(val_esが強かった理由は巧妙さではなく"本来の目的を直接測っている"ことそのもの): oracle_esは「採用サンプルの質」という最終目的とはズレた代理指標を監視していた。目的指標(test_acc)を直接監視するほうが、目的指標と相関しているはずの代理指標を監視するより信頼できるという、実務でよく起こる問題の縮図がここに現れた。

📊 結果の可視化

① 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のown-final-round test_acc:打ち切りルール別比較(round1=0.6327が破線) 0.50 0.65 round1 = 0.6327 0.5455 D_base 0.5800 D_oracle_es 0.6327 D_val_es
指標D_base(打ち切りなし)D_oracle_es(真ラベル必要)D_val_es(ラベル不要)
own-final-round test_acc0.54550.58000.6327
round1からの劣化(delta)-0.0873-0.05270.0000
平均使用ラウンド数4.61.81.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と完全一致
📋
なぜこうするか: もしval_esをDだけに適用していたら、「Dが早期打ち切りで改善した」という結果だけが見え、「実はGも反復で損をしていた」という事実が隠れてしまう。DとG両方に同じルールを公平に適用したことで、gapの縮小が「Dだけを贔屓した結果」ではなく「反復自体がどちらにとっても正味でマイナスだった」という、より根本的な事実の発見につながった。片方だけに介入して比較するのは効果を過大評価する典型的な落とし穴であり、対照群を必ず置くべきである。

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を見ずに)即座に打ち切り
💡
なぜこうするか: round_pseudo_accは「採用したサンプルという一部分」の正解率であり、test_accは「モデル全体が既存のtest_dfに対してどれだけ汎化できるか」という別の量である。両者は関連はしているが同一ではなく、「一部分の質が閾値を超えている」ことは「全体の性能が改善する」ことを保証しない。これは実務でしばしば起こる「学習データの精度は上がっているのに検証・本番の指標は改善しない」現象の縮図であり、目的指標そのものを監視する方が代理指標を監視するより信頼できるという教訓に接続する。

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系コンペへの応用フェーズ継続中)

1-23
Phase6主要テーマ6項目・総復習(Day126-149、完了)
発展①〜⑧
NLP系コンペへの応用(Day155: 反復でgap拡大/Day156: quota配分は無関係と確認/Day157: 公平な早期打ち切りで両手法がround1に収束、真ラベル不要の検証ルールがオラクルに勝つ)

Day157は「Dだけを助ける」という当初の狙いを超え、「反復自体がDにもGにも正味で無価値だった」という、より根本的で一般性の高い発見にたどり着いた回。次はこの"round1が最適"という現象が、このデータ固有の癖なのか一般的な構造なのかを掘り下げる。

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

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