📚 背景知識(読んでから問題へ)
TTAの基本アイデアは「推論時に入力へ小さな摂動を複数パターン加え、それぞれの予測を平均することでロバストな予測を得る」というもの。画像系コンペでは左右反転・明るさ変更などが定番だが、テキストでの自然な摂動候補は単語の同義語置換である。「great」を「fantastic」に置き換えても文の感情極性は変わらないはずなので、画像の明るさを少し変えるのと同じ「意味を保った摂動」になる、というのが検証前の直感的な仮説である。
| 用語 | 意味 |
|---|---|
| TF-IDF | 単語の出現頻度を「他の文書との差別化情報」で重み付けした、テキストのベクトル表現 |
| Bag-of-Words (BoW) | 文章を「単語の集合(袋)」として扱い、語順を無視する表現方法。TF-IDFもBoWの一種 |
| OOV (Out-of-Vocabulary) | 学習時にモデルが見たことのない単語。TF-IDFのような固定語彙モデルでは推論時に無視される |
🗂️ 語彙・同義語辞書のデータスキーマ
| 変数名 | 型 | 内容 |
|---|---|---|
| POSITIVE_WORDS / NEGATIVE_WORDS / NEUTRAL_WORDS | list[str] | 合成文を生成するための感情語彙・中立語彙(各10〜17語) |
| SAFE_SYNONYM_DICT | dict[str, list[str]] | 中立語・同極性の感情語のみを対象にした「安全な」同義語辞書 |
| UNSAFE_SYNONYM_DICT | dict[str, list[str]] | SAFE_SYNONYM_DICTのコピーに、中立語"quality"→学習語彙に実在する負極性語"poor"/"bad"という危険な同義語を1件だけ混入させた辞書 |
SAFE_SYNONYM_DICT = { "product": ["item", "goods"], "service": ["support", "assistance"], "quality": ["craftsmanship", "build"], "excellent": ["outstanding", "exceptional"], "bad": ["poor", "substandard"], # ... 他8語 ... } UNSAFE_SYNONYM_DICT = dict(SAFE_SYNONYM_DICT) UNSAFE_SYNONYM_DICT["quality"] = ["poor", "bad"] # 中立語のつもりが負極性語に化ける
🎯 問題
タスクA(理論)
以下2点をそれぞれ2〜3文で説明する。
タスクB(コーディング→分析)
make_text_dataset(n=300, seed=42) -> pd.DataFrame — ポジティブ語数 > ネガティブ語数 ならlabel=1、そうでなければlabel=0とする合成文データセットを生成する関数を実装するTfidfVectorizer + LogisticRegressionでベースラインの分類パイプラインを学習するsynonym_tta(text, synonym_dict, n_aug=5, seed) -> list[str] — 確率70%で各単語を同義語辞書内のランダムな語に置換したバリエーションをn_aug個生成する関数を実装するpredict_proba_with_tta(model, vectorizer, texts, synonym_dict, n_aug=5, seed) -> np.ndarray — 元テキスト+TTAバリエーションの予測確率を平均して返す関数を実装するassert文で確認する💡 ヒント
タスクAの1.は、「画像のピクセル値に加えるノイズ」と「テキストの単語を丸ごと別の単語に置き換える」という操作の粒度の違いを考えるとよい。画像のノイズは連続値への小さな摂動だが、テキストの同義語置換は離散的な「ある/なし」の置き換えであり、しかもその単語がモデルの学習語彙に存在するかどうかによって効果がまったく変わる、という点がヒントになる。
make_text_dataset: ポジティブ・ネガティブ語彙からrng.choice([0,1,2])個ずつサンプルし、中立語彙を3〜6個混ぜてシャッフルする。両方0個になるケースは判定不能になるため片方を最低1個にするsynonym_tta:text.split()で単語リストに分解し、辞書に存在し確率70%に当たったらrng.choice(synonym_dict[word])で置換するpredict_proba_with_tta:vectorizer.transform()は学習語彙に無い単語(OOV)を自動的に無視する(特徴として使われない)ことに注意。これがタスクA-1のヒントでもある- 5シードのループで
make_text_dataset(seed=seed)→train_test_split→学習→TTAあり/なし予測、を関数化しておくと見通しがよい
def synonym_tta(text, synonym_dict, n_aug=5, seed=0): rng = random.Random(seed) variants = [] words = text.split() for i in range(n_aug): new_words = [rng.choice(synonym_dict[w]) if (w in synonym_dict and rng.random() < 0.7) else w for w in words] variants.append(" ".join(new_words)) return variants def predict_proba_with_tta(model, vectorizer, texts, synonym_dict, n_aug=5, seed=0): all_probs = [] for idx, text in enumerate(texts): variants = [text] + synonym_tta(text, synonym_dict, n_aug=n_aug, seed=seed * 1000 + idx) probs = model.predict_proba(vectorizer.transform(variants))[:, 1] all_probs.append(probs.mean()) return np.array(all_probs) # ... 5シードでTTAあり/なしのaccuracy・loglossを比較し、assertで確認 ...
✅ 模範解答
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 POSITIVE_WORDS = ["excellent", "amazing", "great", "fantastic", "wonderful", "love", "perfect", "superb", "brilliant", "awesome"] NEGATIVE_WORDS = ["terrible", "awful", "bad", "horrible", "poor", "hate", "worst", "disappointing", "mediocre", "annoying"] NEUTRAL_WORDS = ["product", "service", "today", "delivery", "package", "price", "item", "order", "experience", "quality", "team", "staff", "website", "app", "screen", "battery", "design"] # 同義語辞書: 中立語 + 同極性の感情語のみを対象にした「安全な」置換 SAFE_SYNONYM_DICT = { "product": ["item", "goods"], "service": ["support", "assistance"], "delivery": ["shipping", "dispatch"], "price": ["cost", "pricing"], "quality": ["craftsmanship", "build"], "experience": ["encounter", "interaction"], "excellent": ["outstanding", "exceptional"], "great": ["terrific", "superb"], "amazing": ["astonishing", "incredible"], "terrible": ["dreadful", "atrocious"], "bad": ["poor", "substandard"], "awful": ["appalling", "dire"], } # わざと「危険な」同義語を1件混入: 中立語"quality"を学習語彙に実在する負極性語に誤ってマッピング UNSAFE_SYNONYM_DICT = dict(SAFE_SYNONYM_DICT) UNSAFE_SYNONYM_DICT["quality"] = ["poor", "bad"] def make_text_dataset(n=300, seed=42): rng = random.Random(seed) rows = [] for _ in range(n): n_pos = rng.choice([0, 1, 2]) n_neg = rng.choice([0, 1, 2]) if n_pos == 0 and n_neg == 0: n_pos = 1 sent_words = rng.sample(POSITIVE_WORDS, k=min(n_pos, len(POSITIVE_WORDS))) \ + rng.sample(NEGATIVE_WORDS, k=min(n_neg, len(NEGATIVE_WORDS))) n_neutral = rng.randint(3, 6) neutral_words = rng.sample(NEUTRAL_WORDS, k=n_neutral) words = sent_words + neutral_words rng.shuffle(words) text = " ".join(words) label = 1 if n_pos > n_neg else 0 rows.append({"text": text, "label": label}) return pd.DataFrame(rows) def synonym_tta(text, synonym_dict, n_aug=5, seed=0): rng = random.Random(seed) variants = [] words = text.split() for i in range(n_aug): new_words = [] for w in words: if w in synonym_dict and rng.random() < 0.7: new_words.append(rng.choice(synonym_dict[w])) else: new_words.append(w) variants.append(" ".join(new_words)) return variants def predict_proba_with_tta(model, vectorizer, texts, synonym_dict, n_aug=5, seed=0): all_probs = [] for idx, text in enumerate(texts): variants = [text] + synonym_tta(text, synonym_dict, n_aug=n_aug, seed=seed * 1000 + idx) X_variants = vectorizer.transform(variants) probs = model.predict_proba(X_variants)[:, 1] all_probs.append(probs.mean()) return np.array(all_probs) def run_one_seed(seed, synonym_dict, n_aug=5): df = make_text_dataset(n=300, seed=seed) train_df, test_df = train_test_split(df, test_size=0.3, random_state=seed, stratify=df["label"]) vectorizer = TfidfVectorizer() X_train = vectorizer.fit_transform(train_df["text"]) model = LogisticRegression(max_iter=1000) model.fit(X_train, train_df["label"]) X_test = vectorizer.transform(test_df["text"]) proba_no_tta = model.predict_proba(X_test)[:, 1] acc_no_tta = accuracy_score(test_df["label"], (proba_no_tta >= 0.5).astype(int)) ll_no_tta = log_loss(test_df["label"], proba_no_tta, labels=[0, 1]) proba_tta = predict_proba_with_tta(model, vectorizer, test_df["text"].tolist(), synonym_dict, n_aug=n_aug, seed=seed) acc_tta = accuracy_score(test_df["label"], (proba_tta >= 0.5).astype(int)) ll_tta = log_loss(test_df["label"], proba_tta, labels=[0, 1]) return {"seed": seed, "acc_no_tta": acc_no_tta, "acc_tta": acc_tta, "ll_no_tta": ll_no_tta, "ll_tta": ll_tta} SEEDS = [0, 1, 2, 3, 4] df_safe = pd.DataFrame([run_one_seed(s, SAFE_SYNONYM_DICT) for s in SEEDS]) df_unsafe = pd.DataFrame([run_one_seed(s, UNSAFE_SYNONYM_DICT) for s in SEEDS]) mean_ll_no_tta_safe = df_safe["ll_no_tta"].mean() mean_ll_tta_safe = df_safe["ll_tta"].mean() mean_acc_no_tta_unsafe = df_unsafe["acc_no_tta"].mean() mean_acc_tta_unsafe = df_unsafe["acc_tta"].mean() assert mean_ll_tta_safe <= mean_ll_no_tta_safe + 0.05, "SAFE辞書でTTAがloglossを大きく悪化させています" assert mean_acc_tta_unsafe <= mean_acc_no_tta_unsafe, "UNSAFE辞書でTTAが精度を悪化させていません(想定外)" print("[OK] SAFE辞書のTTAはloglossを0.05以内の悪化に収まっている / UNSAFE辞書のTTAは精度を悪化させている") # ============ 実測結果(実際にPython 3で実行して確認済み) ============ # ==== SAFE_SYNONYM_DICT(意味を保つ同義語のみ)==== # seed acc_no_tta acc_tta ll_no_tta ll_tta # 0 0 0.955556 0.866667 0.402585 0.446694 # 1 1 0.933333 0.933333 0.422922 0.439733 # 2 2 0.888889 0.888889 0.429622 0.464201 # 3 3 0.911111 0.833333 0.430728 0.477468 # 4 4 0.933333 0.866667 0.405887 0.435327 # 平均 acc_no_tta: 0.9244 平均 acc_tta: 0.8778 # 平均 ll_no_tta : 0.4183 平均 ll_tta : 0.4527 # # ==== UNSAFE_SYNONYM_DICT(quality→poor/badという意味反転リスクを1件混入)==== # seed acc_no_tta acc_tta ll_no_tta ll_tta # 0 0 0.955556 0.822222 0.402585 0.459576 # 1 1 0.933333 0.855556 0.422922 0.462696 # 2 2 0.888889 0.833333 0.429622 0.483932 # 3 3 0.911111 0.800000 0.430728 0.491426 # 4 4 0.933333 0.822222 0.405887 0.448071 # 平均 acc_no_tta: 0.9244 平均 acc_tta: 0.8267 # 平均 ll_no_tta : 0.4183 平均 ll_tta : 0.4691 # # [OK] SAFE辞書のTTAはloglossを0.05以内の悪化に収まっている / UNSAFE辞書のTTAは精度を悪化させている
📊 結果の可視化
① 合成テキスト生成
make_text_dataset() でポジ/ネガ/中立語彙から文とラベルを合成
② TF-IDF+LogRegベースライン
ベースライン学習後、TTAなしの予測精度・loglossを計測
③ 同義語TTAの適用
synonym_tta()でSAFE/UNSAFE 2辞書を使い分けてTTA予測を比較
| 設定 | 平均accuracy | 平均logloss | 判定 |
|---|---|---|---|
| TTAなし(ベースライン) | 0.9244 | 0.4183 | 最良 |
| TTAあり(SAFE辞書) | 0.8778 | 0.4527 | 悪化(許容範囲内) |
| TTAあり(UNSAFE辞書) | 0.8267 | 0.4691 | 最悪 |
🪜 Step-by-Step 解説
1なぜ「TF-IDFの固定語彙」がテキストTTAの効果を決定づけるのか
vectorizer = TfidfVectorizer() X_train = vectorizer.fit_transform(train_df["text"]) # ... 推論時 ... X_variants = vectorizer.transform(variants) # 学習語彙に無い単語は無視される
TfidfVectorizerはfit時に見た単語だけを「語彙」として固定し、transform時にその語彙に無い単語(OOV)が出てきても特徴量として使わず単純に無視する。SAFE辞書で「product→goods」のように学習語彙に無い同義語へ置換すると、その単語の情報は文からごっそり消える。画像のノイズ摂動が「情報を保ったまま揺らす」のに対し、この種の同義語置換は「情報を削る」方向にしか働かない。これがSAFE辞書でさえTTAが精度を下げた(0.9244→0.8778)主要因である。2なぜUNSAFE辞書はさらに精度を悪化させるのか
UNSAFE_SYNONYM_DICT["quality"] = ["poor", "bad"] # 学習語彙に実在する負極性語
3なぜ「TTAあり/なし」を5シードで比較する設計にしたのか
SEEDS = [0, 1, 2, 3, 4] df_safe = pd.DataFrame([run_one_seed(s, SAFE_SYNONYM_DICT) for s in SEEDS])
acc_tta < acc_no_ttaが観測されたことで、「今回のTTA設計がテキスト分類において系統的に精度を下げる」という主張の再現性を示せる。1回の実験結果を鵜呑みにせず複数回の実測で裏付ける、という設計方針を今日も踏襲している。🧮 数学・統計の補足(文系向け)
TF-IDFベクトルは、あらかじめ決まった単語のリスト(学習時に見た全単語)を「選択肢」として持つアンケート用紙のようなものだと考えるとよい。推論時に新しい文章が来ても、そのアンケート用紙に載っていない単語(OOV)は「その他」の欄すら無く、回答用紙に書く場所がないので単純に捨てられる。同義語に置き換えた単語がこの「アンケートの選択肢」に載っていなければ、その情報はまるごと消えてしまう、という直感である。
SAFE辞書のケースは、伝言ゲームで一部の単語を無言(沈黙)に変えてしまうようなもの——情報量は減るが、少なくとも嘘は伝わらない。UNSAFE辞書のケースは、伝言ゲームで「品質」という単語を誤って「粗悪」という真逆の意味の単語に言い換えて伝えてしまうようなもの——受け手(モデル)は自信を持って間違った結論に至ってしまう。今日の実測(acc 0.8778 vs 0.8267)は、後者の方が実害が大きいことを裏付けている。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☑ 自然言語処理(NLP) / ☑ 表形式データ(Tabular、TF-IDF特徴量として応用可)
🧵 辞書ベースTTAの落とし穴を先読みする
BoW/TF-IDF系モデルでは要注意
辞書ベースの同義語置換TTAはBoW/TF-IDF系のモデルではほぼ確実に情報を削るか歪めるかのどちらかになる。実際のKaggleコンペでNLP系TTAが有効に働くのは、サブワードトークナイザー+文脈埋め込みを持つTransformer系モデルが中心である。
🌐 back-translation(逆翻訳)TTAとの比較
文全体の意味を保ちやすい
上位解法でしばしば使われる「英語→他言語→英語」の逆翻訳によるTTAは、単語単位の同義語辞書よりも文全体の意味を保ちやすく、今日発見した情報欠落・誤情報注入のリスクをある程度回避できる。ただし計算コストが高い点に注意。
📓 「なぜ効かなかったか」を実験ログに残す
次のコンペでの再発防止
直感的に良さそうな手法を試して実際には悪化した場合でも、その理由(OOV・極性反転)を構造化して記録しておくことで、次にNLPコンペに参加したときに同じ轍を踏まずに済む。
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| 画像で効いたテクニックはテキストにもそのまま応用できると思い込む | 「TTA」という同じ名前がついているため、同じ効果を持つと錯覚する | データの表現形式(連続値のピクセル vs 離散的な単語トークン、かつ固定語彙)が違えば、同じ「摂動」という操作でも効果の方向性がまったく異なりうる。今日の実測のようにテキストTTAが悪化方向に働くこともある |
| 「同義語だから意味は保たれるはず」と考え、置換先の単語が学習語彙にあるかを確認しない | 人間にとっての「意味の近さ」と、モデルにとっての「学習済み語彙・学習済み重み」は別物であることを意識しない | TF-IDFのような固定語彙モデルでは、同義語がOOVなら情報が消え、逆に学習語彙内の別極性語なら誤った信号が注入される。同義語辞書を作る際は置換先の単語が学習語彙にあるか・どの極性で学習されているかを確認する必要がある |
| TTAは「複数回平均するから必ず安定する」と思い込む | アンサンブル・平均化の一般的な効能(分散低減)だけに注目してしまう | 平均化は個々のバリエーションの予測に系統的なバイアスが無いことが前提の効果である。UNSAFE辞書のように個々のバリエーションに系統的な誤りが混入していれば、平均を取ってもバイアスは相殺されず一貫して悪化する |
| 1シードの実験結果だけで「TTAは効かない」と結論づける | 実験コストを抑えたいため1回の実行で判断してしまう | 今日のように5シードで一貫した傾向を確認することで、たまたまの結果ではなく再現性のある傾向だと言える。1シードだけの結果は方向性の参考程度にとどめるべきである |
🚀 次のステップ
- 発展: 今日はTF-IDF+LogisticRegressionという最もシンプルなBoW系パイプラインでテキストTTAを検証したが、次の段階として「Transformer系(BERT等)の文脈埋め込みを使った場合、同じ同義語置換TTAの効果はどう変わるか」を比較検証することで、「TTAが効くかどうかはモデルの表現形式に依存する」という今日の仮説をさらに裏付けられる
- 次回予告: 引き続きPhase6の「NLP系コンペへの応用」を深掘りし、次回はback-translationのような文単位のTTA、またはDay136-137で数値データに実装したPseudo Labelingのテキスト分類への応用を検討する
Phase 6 の学習マップ(NLP系コンペへの応用フェーズに突入)
Day149のPhase6総復習の次回予告どおり、Day150からNLP系コンペへの応用を開始した。画像TTAの成功体験をそのままテキストに輸入すると、TF-IDFの固定語彙という制約により逆効果になりうることを実測で確認した。
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: