Day 138 — 高度なアンサンブル③ — TTAはノイズ摂動でtest精度を改善できるか

2026-08-28 金 / Phase 6(Grandmaster、テーマ3群 の 3日目) コーディング TTA / Test Time Augmentation / 予測の分散

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

🔀
Phase6テーマ3「高度なアンサンブル」の3日目です。Day136-137では「学習データを増やす」Pseudo Labelingを扱いましたが、今日は再学習をせず、予測のやり方だけを工夫するTTA(Test Time Augmentation、推論時データ拡張)を実装します。

TTAの基本発想(画像コンペでの定番・4ステップ)

1
学習済みモデルを1つ用意する(再学習はしない)
2
テストデータ1件に対して、少しだけ加工したバージョンを複数作る(画像なら反転・回転・明るさ変更など)
3
オリジナル+加工版のすべてに対してモデルで予測する
4
得られた複数の予測確率を平均し、それを最終予測とする
🧪
表形式データへの翻訳: 画像の「反転」「回転」は表形式データには存在しない。そこで今日は各特徴量に小さなガウスノイズを加えることを「摂動」の代わりに使う。「本質的に同じサンプルのはずなのに測定誤差でわずかに違う値が観測された」という状況を人工的に作り、その複数バージョンへの予測を平均することで、1回だけの予測のブレを打ち消せるかを確かめる。
❓
今日確かめたい2つの問い:
問い1 — ノイズTTAは単一予測よりtest精度を改善するか? 閾値と同じく「強すぎ・弱すぎ」のトレードオフがあるか?
問い2 — RandomForestは300本の決定木の多数決=内部にすでに平均化を内蔵している。外側からさらにTTAを重ねる意味はあるか? それとも単にn_estimatorsを増やす方が安上がりか?
キーワード直感的な説明
TTA(Test Time Augmentation)学習は1回のまま、予測時にだけ入力を複数バージョンに摂動させ、平均して最終予測とする手法
摂動(perturbation)元データの意味を変えない範囲でわずかに加える変化。今日はガウスノイズを使う
予測の分散(variance)同じ「真の」入力に対し、測定誤差やノイズでモデルの予測がどれだけブレるか。TTAはこのブレを平均化で抑えようとする

🪄 実験デザイン(Day136-137と同じ分割・同じベースモデル)

学習(300件・1回のみ)
→ X_testを複製
+ ガウスノイズ ×N回
→ 各バージョンをpredict_proba
確率を平均
→ argmaxで最終予測

再学習は一切行わない。ベースモデルはDay136-137と全く同じ(labeled 300件・RandomForestClassifier(n_estimators=300, random_state=0))なので単一予測の精度も同じ0.7967になる。

📏
ノイズの強さは絶対値ではなく特徴量の標準偏差の平均に対する倍率(例: 0.2倍)で指定する。特徴量ごとにスケールがバラバラでも「元データのばらつきの何割揺らすか」という統一的な単位で扱えるため。

🎯 問題

Day136-137と同じ合成分類データ・同じ分割方法(ラベル付き300件・プール1500件・test 600件)、同じベースモデルを使います。

項目値
タスク5クラス分類(make_classification, n_samples=2400, n_features=20)
ベースモデルRandomForestClassifier(n_estimators=300, random_state=0) を labeled 300件で学習
摂動方法ガウスノイズ: X_test + N(0, scale_ratio × feat_std.mean())
スイープ対象noise scale比率 [0.05〜1.20] / n_aug [1〜50] / 乱数シード [0〜4]
比較対象n_estimators [50, 100, 300, 600, 1000] を単一予測のみで測定
import numpy as np
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score

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_test, y_test = X[LABELED_LEN + POOL_LEN:], y[LABELED_LEN + POOL_LEN:]

baseline_model = RandomForestClassifier(n_estimators=300, random_state=0)
baseline_model.fit(X_labeled, y_labeled)
1
単一予測のベースライン: baseline_model.predict(X_test)の精度を測る
2
TTA関数の実装: run_tta(model, X_test, n_aug, noise_scale, rng, include_original=True) — オリジナル+n_aug回分のノイズ付きX_testの確率を平均してargmax
3
ノイズ強度のスイープ: 倍率[0.05,0.10,0.20,0.30,0.50,0.80,1.20]・n_aug=10固定でTTA精度とdeltaを記録
4
n_augのスイープ: 倍率0.20固定で[1,3,5,10,20,50]を振る
5
乱数シードによる安定性チェック: 倍率[0.10,0.20,0.30,0.50]それぞれで5シードの平均・標準偏差を計算し、deltaの大きさとブレの大きさを比較する
6
食い違い分析: 単一予測とTTA予測が異なるサンプル数、fixed(正解化)とbroken(不正解化)の件数を数える
7
n_estimatorsを増やすだけとの比較: [50,100,300,600,1000]で単一予測精度を測る
8
(考察): ノイズ摂動TTAは表形式RandomForestに対して実用的な改善と言えるかを100字程度で結論づける

💡 ヒント

ヒント1方向性

TTAは「モデルを再学習しない」という点でPseudo Labelingとは根本的に違います。やることは「同じモデルに、同じ意味のデータを複数の見え方で見せて、その予測を平均する」だけです。今日の核心は「効果があるかないか」を確認することそのものよりも、「効果の大きさ」を「乱数によるブレの大きさ」と比べて、本当に意味のある改善と言えるかを判断するところにあります。Day136-137の閾値実験と同じく、1回の実行結果だけを見て即断しないことが重要です。

ヒント2アプローチ
  • ノイズはrng.normal(loc=0.0, scale=noise_scale, size=X_test.shape)で生成し、X_test + noiseを作る
  • predict_probaは(n_samples, n_classes)の確率配列を返すので、include_original込みで集めた確率配列を単純に+=で足し合わせ、最後に集めた個数で割ればよい
  • 標準偏差の基準値はX_labeled.std(axis=0).mean()で計算し、それに倍率を掛けてnoise_scaleとする
  • fixed/brokenの集計は3つのブールマスクを組み合わせる: ((single_pred != y_test) & (pred_tta == y_test)).sum()
ヒント3コード骨格
def run_tta(model, X_test, n_aug, noise_scale, rng, include_original=True):
    proba_sum = np.zeros((X_test.shape[0], model.n_classes_))
    n_terms = 0
    if include_original:
        proba_sum += model.predict_proba(X_test)
        n_terms += 1
    for i in range(n_aug):
        noise = rng.normal(loc=0.0, scale=noise_scale, size=X_test.shape)
        X_aug = X_test + noise
        proba_sum += model.predict_proba(X_aug)
        n_terms += 1
    proba_avg = proba_sum / n_terms
    pred = proba_avg.argmax(axis=1)
    return pred, proba_avg

feat_std = X_labeled.std(axis=0)
for scale_ratio in [0.05, 0.10, 0.20, 0.30, 0.50, 0.80, 1.20]:
    scale = scale_ratio * feat_std.mean()
    rng = np.random.default_rng(0)
    pred, _ = run_tta(baseline_model, X_test, n_aug=10, noise_scale=scale, rng=rng)
    acc = accuracy_score(y_test, pred)
    print(scale_ratio, acc, acc - single_acc)

✅ 模範解答

import numpy as np
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score

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_test, y_test = X[LABELED_LEN + POOL_LEN:], y[LABELED_LEN + POOL_LEN:]

# --- ステップ1: 単一予測のベースライン ---
baseline_model = RandomForestClassifier(n_estimators=300, random_state=0)
baseline_model.fit(X_labeled, y_labeled)
single_pred = baseline_model.predict(X_test)
single_acc = accuracy_score(y_test, single_pred)
print(f"single_acc = {single_acc:.4f}")
# 実測: single_acc = 0.7967(Day136-137と同一条件なので同じ値)

feat_std = X_labeled.std(axis=0)
# 実測: feature std mean = 2.0302

# --- ステップ2: TTA関数 ---
def run_tta(model, X_test, n_aug, noise_scale, rng, include_original=True):
    proba_sum = np.zeros((X_test.shape[0], model.n_classes_))
    n_terms = 0
    if include_original:
        proba_sum += model.predict_proba(X_test)
        n_terms += 1
    for i in range(n_aug):
        noise = rng.normal(loc=0.0, scale=noise_scale, size=X_test.shape)
        X_aug = X_test + noise
        proba_sum += model.predict_proba(X_aug)
        n_terms += 1
    proba_avg = proba_sum / n_terms
    pred = proba_avg.argmax(axis=1)
    return pred, proba_avg

# --- ステップ3: ノイズ強度のスイープ(n_aug=10固定) ---
for scale_ratio in [0.05, 0.10, 0.20, 0.30, 0.50, 0.80, 1.20]:
    scale = scale_ratio * feat_std.mean()
    rng = np.random.default_rng(0)
    pred, _ = run_tta(baseline_model, X_test, n_aug=10, noise_scale=scale, rng=rng)
    acc = accuracy_score(y_test, pred)
    print(f"scale_ratio={scale_ratio:.2f}: tta_acc={acc:.4f} delta={acc-single_acc:+.4f}")

# 実測結果:
# scale_ratio=0.05: tta_acc=0.7983 delta=+0.0017
# scale_ratio=0.10: tta_acc=0.8033 delta=+0.0067
# scale_ratio=0.20: tta_acc=0.7967 delta=+0.0000
# scale_ratio=0.30: tta_acc=0.8000 delta=+0.0033
# scale_ratio=0.50: tta_acc=0.8017 delta=+0.0050
# scale_ratio=0.80: tta_acc=0.7833 delta=-0.0133
# scale_ratio=1.20: tta_acc=0.7683 delta=-0.0283

# --- ステップ5: 乱数シードによる安定性チェック ---
for scale_ratio in [0.10, 0.20, 0.30, 0.50]:
    scale = scale_ratio * feat_std.mean()
    accs = []
    for seed in range(5):
        rng = np.random.default_rng(seed)
        pred, _ = run_tta(baseline_model, X_test, n_aug=10, noise_scale=scale, rng=rng)
        accs.append(accuracy_score(y_test, pred))
    print(f"scale_ratio={scale_ratio:.2f}: mean={np.mean(accs):.4f} std={np.std(accs):.4f} mean_delta={np.mean(accs)-single_acc:+.4f}")

# 実測結果:
# scale_ratio=0.10: mean_tta_acc=0.8000 std=0.0037 mean_delta=+0.0033
# scale_ratio=0.20: mean_tta_acc=0.8003 std=0.0045 mean_delta=+0.0037
# scale_ratio=0.30: mean_tta_acc=0.8000 std=0.0024 mean_delta=+0.0033
# scale_ratio=0.50: mean_tta_acc=0.7990 std=0.0045 mean_delta=+0.0023
# → deltaの大きさ(+0.002〜+0.004)が、シード間の標準偏差(0.002〜0.005)と同程度かそれ以下
#
# 参考: 高ノイズ域は劣化がシード間ブレより明確に大きい
# scale_ratio=0.80: mean_tta_acc=0.7883 std=0.0061 mean_delta=-0.0083
# scale_ratio=1.20: mean_tta_acc=0.7683 std=0.0091 mean_delta=-0.0283

# --- ステップ6: 単一予測とTTA予測の食い違い分析 ---
rng = np.random.default_rng(0)
pred_tta, _ = run_tta(baseline_model, X_test, n_aug=10, noise_scale=0.20 * feat_std.mean(), rng=rng)
disagree = (pred_tta != single_pred)
fixed = ((single_pred != y_test) & (pred_tta == y_test)).sum()
broken = ((single_pred == y_test) & (pred_tta != y_test)).sum()
print(f"disagreement: {disagree.sum()} / {len(single_pred)}  fixed={fixed}  broken={broken}")
# 実測: disagreement: 26 / 600  fixed=10  broken=10

# --- ステップ7: n_estimatorsを増やすだけとの比較 ---
for n_est in [50, 100, 300, 600, 1000]:
    m = RandomForestClassifier(n_estimators=n_est, random_state=0)
    m.fit(X_labeled, y_labeled)
    acc = accuracy_score(y_test, m.predict(X_test))
    print(f"n_estimators={n_est}: acc={acc:.4f}")

# 実測結果:
# n_estimators=  50: acc=0.7683
# n_estimators= 100: acc=0.7900
# n_estimators= 300: acc=0.7967 (今日のbaseline)
# n_estimators= 600: acc=0.8000
# n_estimators=1000: acc=0.8067
# → 300→1000本に増やすだけで+0.0100。TTA最良設定の平均改善(+0.0037前後)より大きい
🧭
タスク8: 考察 — ノイズ摂動TTAは倍率0.10〜0.30・n_aug=10付近で平均+0.002〜+0.004の改善を示したが、この幅は乱数シードを変えたときのブレ(標準偏差0.002〜0.005)とほぼ同じ大きさであり、「本当に効果がある」と言い切るには根拠が弱い。単一予測とTTA予測の食い違いを見ても、正解化(fixed=10)と不正解化(broken=10)が同数で、TTAは「ランダムに予測を揺らしているだけ」に近い。一方、n_estimatorsを300→1000に増やすだけで得られる改善(+0.0100)の方が明確に大きく安定している。RandomForestはすでに300本の決定木の多数決という形で「複数バージョンの平均化」を内蔵しているため、外側から入力側にノイズを加えて平均化を重ねても、得られる追加情報がほとんど無い(すでに飽和している)と考えられる。

📊 結果の可視化(deltaの大きさ vs 乱数シードのブレ / n_estimators比較)

ノイズ倍率ごとのdelta(単一予測比・n_aug=10・seed=0) 0 0.05+.0017 0.10+.0067 0.200.0000 0.30+.0033 0.50+.0050 0.80-.0133 1.20-.0283 → 弱ノイズ域(0.05〜0.50)のプラスは僅少・不安定。0.80以上は明確なマイナスへ反転

delta(平均改善幅)vs シード間の標準偏差(弱ノイズ域では両者がほぼ同じ大きさ = 誤差に埋もれる):

0.10 mean_delta
+0.0033
0.10 std(シード)
0.0037
0.20 mean_delta
+0.0037
0.20 std(シード)
0.0045

■ 緑=平均delta / 灰=乱数シード間の標準偏差。バーの長さがほぼ同じ=改善幅が誤差の範囲内で「効果あり」と断定できない状態。

設定test精度単一予測比
単一予測(n_estimators=300)0.7967baseline
TTA最良(scale=0.10, n_aug=10, seed=0)0.8033+0.0067(誤差の範囲内)
TTA平均(5シード, scale=0.20)0.8003+0.0037(std=0.0045)
n_estimators=1000(TTAなし)0.8067+0.0100 ★最も安定した改善
🚨
TTAの平均的な改善幅(+0.003〜+0.004)は乱数シードのブレ(std 0.002〜0.005)と同程度で統計的に頼りない。一方n_estimatorsを単純に増やすだけの改善(+0.0100)はTTAの平均改善より大きく、追加の実装(摂動関数・複数回推論)も不要。
n_augTTA精度(scale=0.20)delta
10.7950-0.0017
30.7950-0.0017
50.79670.0000
100.79670.0000
200.7917-0.0050
500.7983+0.0017

n_augを増やしても改善幅は単調に伸びず、±0.005程度の範囲で揺れ続ける。「回数を増やせば増やすほど良い」という単純な関係にはなっていない。

🪜 Step-by-Step 解説

1なぜノイズの強さを「絶対値」ではなく「特徴量の標準偏差に対する倍率」で指定するのか

feat_std = X_labeled.std(axis=0)
scale = scale_ratio * feat_std.mean()
📐
なぜこうするか: make_classificationが生成する各特徴量は単位もスケールもバラバラである。ノイズの強さを絶対値で固定すると、値の範囲が広い特徴量にはほとんど影響を与えず、値の範囲が狭い特徴量には過大な影響を与えてしまい、「摂動の強さ」が特徴量ごとにバラバラになる。feat_std.mean()を基準にした倍率にすることで、「元のデータのばらつきの何割くらい揺らすか」という直感的で移植性の高い単位に統一できる。

2なぜ「乱数シードを変えたときのブレ」と比較する必要があるのか

🎲
なぜこうするか: ステップ3の1回の実行結果だけを見ると、scale_ratio=0.10でdelta=+0.0067という「効果がありそうな」数値が出る。しかしこれはノイズ生成に使った乱数シード(seed=0)1つだけの結果であり、別のシードを使えば別の数値が出る可能性がある。実際に5つのシードを試すと、平均delta(+0.002〜+0.004)に対して標準偏差も0.002〜0.005とほぼ同じ大きさになっており、「効果がプラスに出た」という1回の観測結果を、そのまま「TTAには効果がある」という結論に飛躍させてはいけないことがわかる。

3なぜ「fixed」と「broken」を分けて数える必要があるのか

fixed = ((single_pred != y_test) & (pred_tta == y_test)).sum()   # 10件
broken = ((single_pred == y_test) & (pred_tta != y_test)).sum()  # 10件
🔍
なぜこうするか: 「TTA前後で精度が同じだった」という結果だけを見ると、「TTAは予測を全く変えなかった」と誤解しがちだが、実際には26件のサンプルで予測が変化しており、そのうち10件が正解化・10件が不正解化している。全体の精度という1つの数字は、内部で起きている「入れ替わり」を隠してしまう。今日のケースではfixed=brokenとほぼ均衡しており、体系的な改善ではなく「ノイズによるランダムな入れ替わり」に近いと判断できる。

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

⚖️
「TTAの効果が乱数のブレに埋もれる」を、体重計の測定誤差で理解する:

体重計に1回だけ乗って「62.3kg」と表示されたとします。次の日、同じ体重計に3回連続で乗って平均を取ったら「62.1kg」になりました。この「-0.2kg」という差は、本当に体重が減ったからでしょうか? それとも体重計自体の測定誤差の範囲内に収まる、ただのブレでしょうか? これを判断するには、同じ条件で何度も測り直して「差の大きさ」と「測るたびのブレの大きさ」を比べる必要があります。今日のTTAの実験もこれと同じで、「delta=+0.0037」という数字だけを見て「効果があった」と判断するのではなく、乱数シードを変えたときの標準偏差(0.0045)と比較して初めて、「この差は測定誤差の範囲内かもしれない」と気づくことができます。
🗳️
「RandomForestがすでに平均化を内蔵している」を、多数決を取ったあとにもう一度多数決を取るたとえで理解する:

クラス40人に「今日の天気は晴れか雨か」を聞いて多数決を取ったとします。すでに40人分の意見を集約した「多数決の結果」に対して、さらに「その40人にもう一度、少し違う聞き方で聞き直して多数決を取り直す」としても、得られる情報はほとんど増えません。すでに集団の意見のばらつきは1回目の多数決でほぼ均されているからです。RandomForestの300本の決定木による多数決も同じで、すでに「複数の視点を平均する」という仕事を内部でやり終えているため、外側から入力にノイズを加えてもう一段階平均化を重ねても、得られる追加の情報はわずかしかありません。

🏆 Kaggleでの実践的な使い方

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

実例

🖼️ 画像コンペでのTTAは逆に大きく効く

今日の結果とは対照的

画像分類・セグメンテーションコンペでは、CNNやVision Transformerに水平反転・複数スケールクロップ・色調変換などのTTAを組み合わせることで、public LBスコアが着実に0.1〜0.5%改善するケースが多い。単一の決定的な推論経路しか持たない深層学習モデルは、今日のRandomForestと違い予測のブレを内部で吸収する仕組みを持たないため、外側からのTTAが素直に効いてくる。

応用

📝 NLPでのTTA

Attentionの内部平均化との兼ね合い

同義語への置き換え・語順のわずかな入れ替え・トークナイズ方法を変えるなどの摂動を使い、複数の予測を平均する手法が使われる。ただしBERT系モデルも内部にAttentionによる一定の平均化機構を持つため、画像ほど劇的な効果は出ないことが多いと報告されている。

実務

🔧 TTAの費用対効果を事前に見積もる

「内部で何本平均しているか」を先に見る

今日の実験のように、まず「そのモデルは予測時に内部でどれだけ平均化を行っているか」(決定木の本数、アンサンブルの有無など)を考えることで、TTAに計算時間を投資する価値があるかを事前に見積もることができる。木ベースモデルではn_estimatorsを増やす方が同じ計算コストでより安定した改善を得やすい。

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
TTAは「複数回予測して平均する」だけなので、常に精度が上がると思い込む「多く測るほど正確になる」という直感をそのまま適用してしまう今日の実測では改善幅(+0.002〜+0.004)が乱数シードによるブレ(標準偏差0.002〜0.005)と同程度で、効果があると言い切れなかった。TTAは万能の改善策ではない
1回の実行結果でdeltaがプラスだったら、そのまま「効果がある」と結論づける複数シードで再現性を確認する手間を省いてしまう乱数シードを変えて複数回測定し、効果の大きさとブレの大きさを比較して初めて、その効果が偶然なのか体系的なものなのかを判断できる
ノイズは強ければ強いほど、多様な視点が増えて良いと考えるPseudo Labelingの閾値と違い「量を増やす」話ではないため直感が働きにくい今日の実測では倍率0.80以上でtest精度が明確に悪化した(delta -0.0083〜-0.0283)。摂動が強すぎると元のサンプルとは意味が変わってしまい、逆効果になる
どんなモデルに対してもTTAは同じように効くと考えるTTAを画像コンペの文脈だけで学び、モデルの内部構造の違いを意識しないためRandomForestのように内部で既に多数決(平均化)を行っているモデルでは、外側からのTTAが重複した平均化になり効果が薄い。単一の決定的な推論経路しか持たないモデルほどTTAの効果が出やすい傾向がある

🚀 次のステップ

  • 発展: 今日はガウスノイズによる摂動のみを試しましたが、表形式データには他にも「一部の特徴量をランダムに欠損値にして予測する(feature masking)」「特徴量の値を近傍のサンプルの値と一部入れ替える」といった摂動方法が考えられます。これらの摂動方法がノイズ摂動よりも体系的な改善につながるか、今日と同じ「乱数シードによるブレとの比較」の枠組みで検証してみましょう
  • 次回予告: Phase6テーマ3の4日目。今日試したノイズ摂動の代わりに、特徴量ドロップアウト(feature masking)によるTTAを実装し、なぜ・どのような摂動方法であればノイズ摂動より安定した改善につながるのか(あるいはつながらないのか)を比較検証します

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

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

Day136で単発Pseudo Labelingを、Day137でIterative Pseudo Labelingを、Day138でノイズ摂動TTAを実装。次はfeature maskingによるTTAの比較に進む。

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

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