Day 139 — 高度なアンサンブル④ — Feature MaskingはノイズTTAより「安定した改善」につながるか

2026-08-29 金 / Phase 6(Grandmaster、テーマ3群 の 4日目) コーディング Feature Masking / TTA / 軸並行分岐

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

🔀
Phase6テーマ3「高度なアンサンブル」の4日目です。Day138ではガウスノイズによるTTAを実装し、「効果があるとも無いとも言い切れない、乱数のブレに埋もれるレベルの微差」という結論でした。今日はもう1つの摂動方法である特徴量マスキング(feature masking)を実装し、ノイズ摂動より安定した改善につながるのか、あるいはつながらないのかを実測で決着させます。
ノイズ摂動(Day138)マスキング(今日)
やること全特徴量に小さなランダムな揺らぎを足す一部の特徴量を丸ごと列平均に置き換える
元の情報ほぼ保たれる(値が少しズレるだけ)その特徴量については完全に失われる
直感測定誤差でわずかに違う値が出たその特徴量だけ欠損していた
❓
今日確かめたい3つの問い:
問い1 — マスキングTTAはノイズTTAと同じ「微妙なレベル」にとどまるか? それとも、はっきりした方向の変化が出るか?
問い2 — 違う結果が出るならなぜか? RandomForestが特徴量の閾値で分岐を作る仕組みと関連付けられるか?
問い3 — ノイズとマスキングを同時に使うと良いとこ取りになるか、悪い方に引っ張られるか?
キーワード直感的な説明
特徴量マスキング(feature masking)一部の特徴量の値を意図的に代表値(平均など)に置き換えて「欠損」に近い状態を作る摂動方法
軸並行分岐(axis-aligned split)決定木が「特徴量Xがしきい値tより大きいか小さいか」で分岐を作る仕組み。値を大きく変えると複数の分岐のしきい値を一度にまたぐ可能性がある
効果量 vs ブレの比較Day138では「改善」を主張してよいかの判定に使った同じモノサシを、今日は「悪化」を主張してよいかの判定に使う

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

学習(300件・1回のみ)
→ X_testを複製
一部列を列平均で置換 ×N回
→ 各バージョンをpredict_proba
確率を平均
→ argmaxで最終予測

再学習は一切行わない。ベースモデルはDay136-138と全く同じなので単一予測の精度も同じ0.7967になる。

📏
マスク列はrng.choice(n_features, size=n_mask, replace=False)でサンプルごとに独立に選ぶ。n_mask = max(1, round(mask_ratio × 20))とすることで、mask_ratioが小さくても最低1列はマスクされる。置換先はX_labeled.mean(axis=0)(列平均)——「その特徴量について何も情報がないときの最も無難な代入値」。

🎯 問題

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

項目値
タスク5クラス分類(make_classification, n_samples=2400, n_features=20)
ベースモデルRandomForestClassifier(n_estimators=300, random_state=0) を labeled 300件で学習
摂動方法特徴量マスキング: 各サンプルごとにmask_ratio×20列をrng.choiceで選び、列平均で置換
スイープ対象mask_ratio [0.05〜0.50] / n_aug [1〜50] / 乱数シード [0〜4]
比較対象Day138のノイズTTA(scale_ratio=0.10) / ノイズ+マスキング併用
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
マスキングTTA関数の実装: run_tta_mask(model, X_test, n_aug, mask_ratio, fill_values, rng, include_original=True)
2
マスク率のスイープ: [0.05,0.10,0.20,0.30,0.50]・n_aug=10固定でTTA精度とdeltaを記録
3
n_augのスイープ: mask_ratio=0.20固定で[1,3,5,10,20,50]を振る
4
乱数シードによる安定性チェック: mask_ratio[0.05,0.10,0.20,0.30]それぞれで5シードの平均・標準偏差を計算し、Day138と同じ「delta vs std」の基準で今回は悪化を言い切れるか確認
5
ノイズTTAとの直接対決: 同条件でfixed/broken件数・一致率・重なりを調べる
6
併用実験: 同じ拡張サンプルにノイズとマスキングを両方適用し5シードで平均・標準偏差を測る
7
(考察): マスキングTTAはノイズTTAより安定した改善につながったか、つながらなかったならなぜかを100字程度で結論づける

💡 ヒント

ヒント1方向性

Day138では「delta(改善幅)が乱数シードによる標準偏差より十分大きいか」を、改善を主張してよいかの判定に使いました。今日はその同じモノサシを、悪化を主張してよいかの判定にも使えることに気づくのがポイントです。「効果が誤差に埋もれて分からない」ケースと「効果ははっきり分かるが、望ましくない方向だった」ケースは、統計的な意味では対称です。

ヒント2アプローチ
  • マスクする列はrng.choice(n_features, size=n_mask, replace=False)でサンプルごとに選ぶ(全サンプル共通の列ではなく、サンプルごとに独立に選ぶ)
  • n_mask = max(1, int(round(mask_ratio * n_features)))で、mask_ratioが小さくても最低1列はマスクされるようにする
  • fill_valuesはX_labeled.mean(axis=0)(学習データの列平均)を使う
  • 併用実験では、同じX_augに対して「まずノイズを足す→次にその上から一部列をマスクする」の順で両方の操作を1つの拡張サンプルに適用する
ヒント3コード骨格
def run_tta_mask(model, X_test, n_aug, mask_ratio, fill_values, 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
    n_samples, n_features = X_test.shape
    n_mask = max(1, int(round(mask_ratio * n_features)))
    for i in range(n_aug):
        X_aug = X_test.copy()
        for row in range(n_samples):
            cols = rng.choice(n_features, size=n_mask, replace=False)
            X_aug[row, cols] = fill_values[cols]
        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_mean = X_labeled.mean(axis=0)
for mask_ratio in [0.05, 0.10, 0.20, 0.30, 0.50]:
    rng = np.random.default_rng(0)
    pred, _ = run_tta_mask(baseline_model, X_test, n_aug=10, mask_ratio=mask_ratio, fill_values=feat_mean, rng=rng)
    acc = accuracy_score(y_test, pred)
    print(mask_ratio, acc, acc - single_acc)

✅ 模範解答

# --- ステップ0: ベースライン(Day136-138と同一) ---
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)
# 実測: single_acc = 0.7967

feat_std = X_labeled.std(axis=0)
feat_mean = X_labeled.mean(axis=0)

# --- ステップ1: マスキングTTA関数 ---
def run_tta_mask(model, X_test, n_aug, mask_ratio, fill_values, 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
    n_samples, n_features = X_test.shape
    n_mask = max(1, int(round(mask_ratio * n_features)))
    for i in range(n_aug):
        X_aug = X_test.copy()
        for row in range(n_samples):
            cols = rng.choice(n_features, size=n_mask, replace=False)
            X_aug[row, cols] = fill_values[cols]
        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

# --- ステップ2: マスク率のスイープ(n_aug=10固定) ---
for mask_ratio in [0.05, 0.10, 0.20, 0.30, 0.50]:
    rng = np.random.default_rng(0)
    pred, _ = run_tta_mask(baseline_model, X_test, n_aug=10, mask_ratio=mask_ratio, fill_values=feat_mean, rng=rng)
    acc = accuracy_score(y_test, pred)
    print(f"mask_ratio={mask_ratio:.2f}: tta_acc={acc:.4f} delta={acc-single_acc:+.4f}")

# 実測結果:
# mask_ratio=0.05 (n_mask=1): tta_acc=0.7933 delta=-0.0033
# mask_ratio=0.10 (n_mask=2): tta_acc=0.7767 delta=-0.0200
# mask_ratio=0.20 (n_mask=4): tta_acc=0.7600 delta=-0.0367
# mask_ratio=0.30 (n_mask=6): tta_acc=0.7450 delta=-0.0517
# mask_ratio=0.50 (n_mask=10): tta_acc=0.6867 delta=-0.1100
# → マスク率を上げるほど悪化幅が単調に拡大する「量反応関係」がはっきり出ている

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

# 実測結果:
# mask_ratio=0.05: mean_tta_acc=0.7900 std=0.0024 mean_delta=-0.0067
# mask_ratio=0.10: mean_tta_acc=0.7797 std=0.0022 mean_delta=-0.0170
# mask_ratio=0.20: mean_tta_acc=0.7673 std=0.0086 mean_delta=-0.0293
# mask_ratio=0.30: mean_tta_acc=0.7453 std=0.0062 mean_delta=-0.0513
# → mask_ratio=0.05(1列だけマスク)の時点で既にdelta(-0.0067)がstd(0.0024)の約3倍
#   「悪化している」と誤差の範囲を超えて言い切れる

# 参考: Day138のノイズTTA(同じ枠組みで再計算)
# scale_ratio=0.10: mean_tta_acc=0.8000 std=0.0037 mean_delta=+0.0033
# → ノイズTTAはdeltaとstdが同程度(言い切れない)。マスキングTTAはdeltaがstdの3〜6倍(言い切れる、ただし悪化)

# --- ステップ5: ノイズTTAとの直接対決 ---
rng_mask = np.random.default_rng(0)
pred_mask, _ = run_tta_mask(baseline_model, X_test, n_aug=10, mask_ratio=0.20, fill_values=feat_mean, rng=rng_mask)
rng_noise = np.random.default_rng(0)
pred_noise, _ = run_tta_noise(baseline_model, X_test, n_aug=10, noise_scale=0.10 * feat_std.mean(), rng=rng_noise)

fixed_mask = ((single_pred != y_test) & (pred_mask == y_test)).sum()
broken_mask = ((single_pred == y_test) & (pred_mask != y_test)).sum()
fixed_noise = ((single_pred != y_test) & (pred_noise == y_test)).sum()
broken_noise = ((single_pred == y_test) & (pred_noise != y_test)).sum()
agree_rate = (pred_mask == pred_noise).mean()

# 実測:
# mask: fixed=6 broken=28  (net -22)
# noise: fixed=7 broken=3  (net +4)
# mask-TTA と noise-TTA の予測一致率: 0.9217
# → マスキングは「壊す」方に大きく偏り、ノイズは「直す」方にわずかに偏る。方向が逆

# --- ステップ6: 併用実験 ---
combined_accs = []
for seed in range(5):
    rng = np.random.default_rng(seed)
    pred, _ = run_tta_combined(baseline_model, X_test, n_aug=10,
                                noise_scale=0.10 * feat_std.mean(), mask_ratio=0.20,
                                fill_values=feat_mean, rng=rng)
    combined_accs.append(accuracy_score(y_test, pred))

# 実測: combined(noise 0.10 + mask 0.20): mean_tta_acc=0.7690 std=0.0039 mean_delta=-0.0277
# → noise単体(+0.0037)・mask単体(-0.0293)の単純平均(-0.0128)よりmask単体に近い
#   「良いとこ取り」にはならず、マスキングの悪影響がノイズの微小な効果を押しつぶす
🧭
タスク7: 考察 — マスキングTTAは、ノイズTTAより「安定した改善」にはつながらなかった——それどころか「安定した悪化」につながった。ノイズTTA(Day138)はdelta(+0.002〜+0.004)がシード間std(0.002〜0.005)と同程度で効果の有無を言い切れなかったのに対し、マスキングTTAは最小のマスク率(1列)でもdelta(-0.0067)がstd(0.0024)の約3倍あり、「悪化している」とはっきり言い切れる。この違いは、RandomForestが特徴量の値そのものに対する閾値で分岐を作っていることに起因すると考えられる。ノイズは値を少しずらすだけなので多くの場合同じ分岐を通り続けるが、マスキングは値を「元の値と無関係などこかの点(列平均)」に一気に飛ばすため、一度に複数の分岐のしきい値をまたいでしまい、木が学習した情報を実質的に破壊する。併用実験でもノイズの微小な改善はマスキングの悪化に押しつぶされた。

📊 結果の可視化(マスク率の量反応関係 と ノイズTTAとの対比)

マスク率ごとのdelta(単一予測比・n_aug=10・seed=0) 0 0.05-.0033 0.10-.0200 0.20-.0367 0.30-.0517 0.50-.1100 → 全てのマスク率でマイナス。強くするほど単調に悪化(Day138のノイズTTAの山型カーブと対照的)

delta(平均改善/悪化幅)vs シード間の標準偏差(マスキングはdeltaがstdを明確に上回る=「言い切れる」):

mask 0.05 |delta|
0.0067
mask 0.05 std(シード)
0.0024
参考: noise 0.10 |delta|
0.0033
参考: noise 0.10 std(シード)
0.0037

■ 赤=|delta|(マスキング・悪化幅) / 緑=|delta|(ノイズ・参考) / 灰=乱数シード間の標準偏差。マスキングは最小強度でも赤バーが灰バーを大きく超え、ノイズは緑と灰がほぼ同じ長さ。

設定test精度単一予測比|delta|/std
単一予測(n_estimators=300)0.7967baseline—
ノイズTTA(scale=0.10, 5シード平均)0.8000+0.00330.9倍(言い切れない)
マスキングTTA(ratio=0.05, 5シード平均)0.7900-0.00672.8倍(明確に悪化)
マスキングTTA(ratio=0.20, 5シード平均)0.7673-0.02933.4倍(明確に悪化)
併用(noise0.10 + mask0.20, 5シード平均)0.7690-0.0277mask単体に近い
🚨
マスキングTTAは最小強度(1列マスク)でも|delta|/std ≈ 2.8倍と、「効果は誤差では説明できない」水準にすぐ到達する。一方ノイズTTAは最良設定でも0.9倍程度で判定不能。同じ「delta÷std」というモノサシで、片方は言い切れて、片方は言い切れない——効果の大きさが桁違いに違うことを示している。
mask_ratioマスク列数(/20)TTA精度(seed=0)delta
0.0510.7933-0.0033
0.1020.7767-0.0200
0.2040.7600-0.0367
0.3060.7450-0.0517
0.50100.6867-0.1100

20特徴量中10列(半分)をマスクすると精度は0.6867まで低下する。マスク列数が増えるほど、悪化幅がほぼ単調に拡大する量反応関係。

🪜 Step-by-Step 解説

1なぜマスク率を上げるほど悪化幅が「単調に」拡大するのか

# mask_ratio=0.05: delta=-0.0033 → 0.10: -0.0200 → 0.20: -0.0367 → 0.30: -0.0517 → 0.50: -0.1100
📐
なぜこうするか: マスクする特徴量の数が増えるほど、モデルが本来頼りにしていた情報が失われる量が単純に増えていく。Day138のノイズTTAは強度を上げると改善→横ばい→悪化という山型のカーブだったが、マスキングは強度を上げるほど一貫して悪化幅が拡大する単調な量反応関係になっている。「ノイズは強すぎると悪影響、弱ければ無害に近い」のに対し、「マスキングは弱くても既に有害で、強くするほど単純に有害さが増す」という、質的に異なる摂動であることを示している。

2なぜ同じ「delta vs std」のモノサシで、今回は「言い切れる」と判断できるのか

# mask_ratio=0.05: mean_delta=-0.0067  std=0.0024   → |delta|/std ≈ 2.8
# 参考(Day138) scale_ratio=0.10: mean_delta=+0.0033  std=0.0037   → |delta|/std ≈ 0.9
🎲
なぜこうするか: Day138では「delta(改善幅)がstd(乱数のブレ)と同じくらいの大きさ」だったため、「効果があるとは言い切れない」という慎重な結論になった。今日のマスキングでは、最も弱い設定でも|delta|がstdの約2.8倍あり、5シードのうちどの組み合わせで見てもほぼ確実にベースラインを下回る。同じ「delta÷std」という比較の枠組みを使っているのに、結論の確信度がまるで違う——効果が微妙で判断がつかない場合と、効果ははっきりしているが望ましくない方向の場合を、同じモノサシで検出できる好例である。

3なぜ併用実験で「良いとこ取り」にならなかったのか

# noise単体: mean_delta=+0.0037
# mask単体:  mean_delta=-0.0293
# combined:  mean_delta=-0.0277  (単純平均の-0.0128よりmask単体に近い)
🔍
なぜこうするか: 「良い効果と悪い効果を混ぜたら中間くらいになるはず」という直感は、今回の実測では成り立たなかった。同じ拡張サンプルの中でノイズとマスキングを両方適用すると、マスキングの「情報の破壊」がノイズの「わずかなブレの打ち消し」を上書きしてしまい、結果はほぼマスク単体の悪化幅に張り付いた。「情報を破壊する」操作の方が「情報を少しぼかす」操作より、モデルの予測に対して非対称に強い影響力を持つことを示唆している。

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

🗺️
「ノイズとマスキングの違い」を、道に迷った人へのアドバイスのたとえで理解する:

ある人が地図を見ながら目的地に向かっているとします。「ノイズ」は、地図の縮尺をほんの少しだけ間違って伝えるようなものです——目的地への大まかな方向はほぼ変わらないので、多くの場合ちゃんと着けます。一方「マスキング」は、地図の一部(例えば目印になる交差点の名前)を丸ごと消して「分かりません」に置き換えるようなものです。その交差点で曲がるべきかどうかの判断材料がまるごと無くなるので、間違った方向に進んでしまう危険がずっと大きくなります。RandomForestの決定木も同じで、「この特徴量がこの値を超えているかどうか」で道を選んでいくため、値そのものを丸ごと別の値(列平均という「どこか別の場所」)に置き換えられると、正しい道を選べなくなる可能性が跳ね上がります。
⚖️
「delta÷stdが3倍」を、体重計の例えの続きで理解する:

Day138では「体重が62.3kg→62.1kgに変わったが、体重計の測定誤差(±0.2kg程度)の範囲内かもしれない」という状況でした。今日のマスキングは、体重計に乗るたびに「靴を履いたまま測る」という操作を一部の測定に混ぜ込むようなものです。その影響(+1.5kgくらいの誤差)は、体重計本来の測定誤差(±0.2kg)よりずっと大きいので、「これは測定誤差では説明できない、明確な原因がある変化だ」とすぐに分かります。変化の大きさが「測定のブレ」を明確に上回っているかどうかが結論の確信度を決めるという点は、改善でも悪化でも同じです。

🏆 Kaggleでの実践的な使い方

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

実例

✂️ cutout / random erasingは「学習時」の正則化が定石

推論時に使うと今日のように逆効果になりやすい

今日試したマスキングは画像コンペのcutoutに相当するが、実務では推論時(TTA)ではなく学習時のデータ拡張として使われることが多い。学習中にランダムな領域を隠すことで頑健なモデルを育てる効果があるが、推論時に情報を破壊する形で使うと、今日確認したように逆効果になりやすい。

応用

🔌 欠損値シミュレーションとしての応用

頑健性テストへの転用

特徴量マスキングは、実運用で一部のセンサー値やAPIレスポンスが欠損する状況を模した頑健性テストとして使える。「どの特徴量が欠けたときに予測精度がどれだけ落ちるか」を今日のようなスイープで事前に把握しておくことは、本番運用のリスク管理に直結する。

実務

🧭 「効果の方向」を先に確認する

山型か単調悪化かを早期に見分ける

まず弱い強度で「改善方向か悪化方向か」を確認し、悪化方向であれば強度を上げるスイープを続ける必要はない。Day136・Day138のように弱→中→強で山型になる場合と、今日のように単調に悪化し続ける場合を早い段階で見分けることで、無駄な実験を減らせる。

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
TTAという名前がついている手法は、種類が違っても似たような(小さな)効果しか持たないと思い込むDay138のノイズTTAの「微差」という結果を、TTA全般の性質だと一般化してしまう今日の実測ではマスキングTTAが最小強度でも明確な悪化(delta -0.0067、stdの約3倍)を示した。摂動の「種類」によって効果の大きさも方向もまるで異なりうる
「効果が誤差に埋もれて判断できない」ことと「効果がない」ことを同じだと考える「有意差なし」を「差がない」と早合点してしまう典型的な誤りDay138のノイズTTAは「改善するともしないとも言い切れない」だっただけ。マスキングTTAのように、同じ枠組みでも十分な効果量があれば方向まではっきり判定できる
2つの摂動方法を混ぜれば、良い効果と悪い効果が平均化されて中間くらいの結果になると考える「ブレンドすれば中和される」という単純な直感を適用してしまう今日の併用実験ではcombined(-0.0277)がnoise単体(+0.0037)とmask単体(-0.0293)の単純平均(-0.0128)よりmask単体に近い結果になった。情報を破壊する操作は非対称に強い影響力を持ちうる
マスキング(cutout系)は画像コンペの定番技術だから、表形式データでも推論時に同じように使えば安全だと考える手法名だけを見て、適用フェーズ(学習時か推論時か)の違いを見落としてしまうcutout系の手法は本来「学習時の正則化」として設計されていることが多く、推論時に情報を破壊する形で流用すると明確な悪化を招くことがある

🚀 次のステップ

  • 発展: 今日は列平均での置き換えのみを試しましたが、「学習データからランダムに1件選んでその値で置き換える」「近傍サンプルの値で置き換える」など、他の置き換え方法でも同じように悪化するのか、それとも列平均という「情報量ゼロの代表値」を使ったことが特に悪影響だったのかを切り分けてみましょう
  • 次回予告: Phase6テーマ3の5日目(このテーマ群の最終日)。Day136-139で得たPseudo Labeling・ノイズTTA・マスキングTTAの知見を整理し、「どの手法をどんな条件で使うべきか」の判断基準をまとめたうえで、2段階アンサンブルの基礎であるStacking(メタモデルによる予測の統合)に着手します

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

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

Day136で単発Pseudo Labelingを、Day137でIterative Pseudo Labelingを、Day138でノイズ摂動TTAを、Day139でFeature Masking TTAを実装。次はテーマ群の総括とStackingに進む。

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

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