📚 背景知識(読んでから問題へ)
Early Fusion(早期融合): 指紋・証言メモ・防犯カメラの画素値をそのまま1枚の証拠用紙に貼り合わせてから分析するイメージ。生の特徴量を最初に結合してから1つのモデルに入れる。実装は単純だが、モダリティごとにスケール・次元数が大きく異なるため難しいことが多い
Late Fusion(後期融合): 3人の専門家がそれぞれ独立に結論を出してから、意見を多数決や平均で統合するイメージ。モダリティごとに完全に別モデルを学習し、最後に予測値だけを混ぜる。実装は簡単だがモダリティ間の相互作用を学習の途中で活かせない
Intermediate Fusion(中間融合): 3人の専門家がそれぞれ「要約メモ(埋め込みベクトル)」を作り、それを持ち寄って統括責任者が判断するイメージ。今日はこの方式を実装する。実務・Kaggleで最もよく使われる
modality(モダリティ): データの種類(数値・テキスト・画像・音声など)branch(ブランチ): モダリティごとに用意する専用サブネットワーク。入力を固定長の埋め込みベクトルに変換するnn.EmbeddingBag: 単語ID列を受け取り、各単語の埋め込みを引いてから平均(または合計)を1レイヤーで行うPyTorchのモジュール。Day123のAttention(重み付き平均)に対し、mean modeは「均等な重みの平均」=Attentionの特殊ケースmodality dropout: 学習時にランダムに一部モダリティをゼロ埋めして学習させ、推論時に欠損しても頑健に動くようにする正則化
🔀 3つのFusion方式 — どの段階で複数モダリティを混ぜるか
今日実装するのは②Intermediate Fusion。数値ブランチ・テキストブランチがそれぞれ同じ次元数の埋め込みベクトルを出力し、torch.catで結合してから予測ヘッドに渡す。
🗂️ データスキーマ
| 列名 | 型 | 説明 |
|---|---|---|
| price_scaled | float | 価格(標準化済み、平均0) |
| discount | float | 割引率。0〜0.5の一様分布 |
| views | float | 閲覧数。50 + 真の品質×8 + ノイズ(σ=10)。品質の弱いシグナルを含むが大きいノイズあり |
| description | str | 商品説明文。VOCAB(12語)から5語をサンプリング。品質が高いほどPREMIUM語(高品質・耐久・高級・評判)が、低いほどBUDGET語(コスパ・安価・シンプル・お手頃)が出やすい |
| rating | float | 目的変数。3.0 + 0.9×真の品質 − 0.4×price_scaled + 0.6×discount + ノイズを1.0〜5.0にクリップ |
| 変数 | 値 | 役割 |
|---|---|---|
| N | 600 | 商品件数 |
| TRAIN_LEN | 500 | 先頭500件をtrain、残り100件をvalとして評価 |
| HIDDEN | 16 | 数値ブランチ・テキストブランチ共通の埋め込み次元数 |
🎯 問題
架空のネットショップの商品600件について、数値特徴量(価格・割引率・閲覧数)と商品説明文(5単語のBag of Words)から評価スコア(rating, 1.0〜5.0)を予測します。評価スコアは実際には観測できない「商品の本当の品質」に強く依存しており、その品質の手がかりは閲覧数と説明文の単語選びの両方に、それぞれ不完全な形で滲み出ています。
import numpy as np import pandas as pd np.random.seed(42) N = 600 # 観測できない「商品の本当の品質」(-∞〜+∞、平均0) true_quality = np.random.normal(0, 1, size=N) # --- 数値特徴量 --- price_scaled = np.random.normal(0, 1, size=N) discount = np.random.uniform(0, 0.5, size=N) views_noise = np.random.normal(0, 10, size=N) views = (50 + true_quality * 8 + views_noise).clip(min=0) # --- テキスト特徴量(5単語のBag of Words) --- VOCAB = ["高品質", "耐久", "高級", "評判", "コスパ", "安価", "シンプル", "お手頃", "限定", "人気", "軽量", "新作"] PREMIUM = {"高品質", "耐久", "高級", "評判"} BUDGET = {"コスパ", "安価", "シンプル", "お手頃"} def generate_description(quality, rng): probs = [] for w in VOCAB: base = 1.0 if w in PREMIUM: base = 1.0 + max(quality, 0) * 2 elif w in BUDGET: base = 1.0 + max(-quality, 0) * 2 probs.append(base) probs = np.array(probs) probs /= probs.sum() return list(rng.choice(VOCAB, size=5, replace=True, p=probs)) rng = np.random.default_rng(42) descriptions = [generate_description(q, rng) for q in true_quality] # --- 目的変数 --- rating = 3.0 + 0.9 * true_quality - 0.4 * price_scaled + 0.6 * discount + np.random.normal(0, 0.3, size=N) rating = np.clip(rating, 1.0, 5.0) df = pd.DataFrame({ "price_scaled": price_scaled, "discount": discount, "views": views, "description": [" ".join(d) for d in descriptions], "rating": rating, }) TRAIN_LEN = 500 # 先頭500件をtrain、残り100件をvalとして評価
word2idx辞書で説明文をID列に変換し、数値特徴量はtrainだけの統計量で標準化するNumericOnlyModel・TextOnlyModel(nn.EmbeddingBag)・MultimodalModel(torch.catで結合)を同じhidden次元で作る💡 ヒント
各モダリティに専用の「ブランチ」を用意し、それぞれが同じ次元数hiddenの固定長ベクトルを出力するように設計します。3つのモデル(数値のみ・テキストのみ・両方)は、この「ブランチ→(結合)→予測ヘッド」という共通の骨格を使い回して作ると比較がしやすくなります。テキストは5語すべて揃っているため、可変長対応のパディングは今回は不要です。
word2idx = {w: i for i, w in enumerate(VOCAB)}で辞書を作り、各説明文を[word2idx[w] for w in desc.split()]でID列に変換する- 数値特徴量の標準化は train だけの
mean,stdを使う(Day121-123と同じ作法)。viewsは分布の幅が大きいので必ず標準化すること nn.EmbeddingBag(vocab_size, hidden, mode="mean")にshape (batch, 5)のLong tensorをそのまま渡すと、各サンプルについて5語の埋め込みの平均shape (batch, hidden)が一発で得られるMultimodalModelでは、数値ブランチの出力(batch, hidden)とテキストブランチの出力(batch, hidden)をtorch.cat([h_num, h_txt], dim=1)で結合し、(batch, hidden*2)からnn.Linear(hidden*2, 1)で1つの予測値を出す- コサイン類似度は
F.cosine_similarity(a, b, dim=0)または(a @ b) / (a.norm() * b.norm())で計算できる
import torch import torch.nn as nn class NumericBranch(nn.Module): def __init__(self, in_dim=3, hidden=16): super().__init__() self.net = nn.Sequential(nn.Linear(in_dim, hidden), nn.ReLU()) def forward(self, x): return self.net(x) # (batch, hidden) class TextBranch(nn.Module): def __init__(self, vocab_size, hidden=16): super().__init__() self.embed = nn.EmbeddingBag(vocab_size, hidden, mode="mean") def forward(self, text_idx): return self.embed(text_idx) # (batch, hidden) 5語の平均埋め込み class MultimodalModel(nn.Module): def __init__(self, vocab_size, hidden=16): super().__init__() self.numeric_branch = NumericBranch(hidden=hidden) self.text_branch = TextBranch(vocab_size, hidden=hidden) self.head = nn.Linear(hidden * 2, 1) def forward(self, numeric, text_idx): h_num = self.numeric_branch(numeric) h_txt = self.text_branch(text_idx) h = torch.cat([h_num, h_txt], dim=1) # ← Intermediate Fusion return self.head(h).squeeze(-1)
NumericOnlyModel・TextOnlyModelは上記のbranchを1つだけ使い、head = nn.Linear(hidden, 1)にすれば同じ骨格で作れます。
✅ 模範解答
import numpy as np import pandas as pd import torch import torch.nn as nn import torch.nn.functional as F from torch.utils.data import Dataset, DataLoader torch.manual_seed(0) # --- サンプルデータ生成(問題文と同じ) --- np.random.seed(42) N = 600 true_quality = np.random.normal(0, 1, size=N) price_scaled = np.random.normal(0, 1, size=N) discount = np.random.uniform(0, 0.5, size=N) views_noise = np.random.normal(0, 10, size=N) views = (50 + true_quality * 8 + views_noise).clip(min=0) VOCAB = ["高品質", "耐久", "高級", "評判", "コスパ", "安価", "シンプル", "お手頃", "限定", "人気", "軽量", "新作"] PREMIUM = {"高品質", "耐久", "高級", "評判"} BUDGET = {"コスパ", "安価", "シンプル", "お手頃"} def generate_description(quality, rng): probs = [] for w in VOCAB: base = 1.0 if w in PREMIUM: base = 1.0 + max(quality, 0) * 2 elif w in BUDGET: base = 1.0 + max(-quality, 0) * 2 probs.append(base) probs = np.array(probs) probs /= probs.sum() return list(rng.choice(VOCAB, size=5, replace=True, p=probs)) rng = np.random.default_rng(42) descriptions = [generate_description(q, rng) for q in true_quality] rating = 3.0 + 0.9 * true_quality - 0.4 * price_scaled + 0.6 * discount + np.random.normal(0, 0.3, size=N) rating = np.clip(rating, 1.0, 5.0) df = pd.DataFrame({ "price_scaled": price_scaled, "discount": discount, "views": views, "description": [" ".join(d) for d in descriptions], "rating": rating, }) TRAIN_LEN = 500 # --- ステップ1: 単語→IDの辞書とテキストのID化 --- word2idx = {w: i for i, w in enumerate(VOCAB)} text_idx_all = np.array([[word2idx[w] for w in desc.split()] for desc in df["description"]]) # --- ステップ2: 数値特徴量をtrainだけの統計量で標準化 --- numeric_raw = df[["price_scaled", "discount", "views"]].values num_mean = numeric_raw[:TRAIN_LEN].mean(axis=0) num_std = numeric_raw[:TRAIN_LEN].std(axis=0) numeric_scaled = (numeric_raw - num_mean) / num_std class MultimodalDataset(Dataset): def __init__(self, numeric, text_idx, rating): self.numeric = torch.tensor(numeric, dtype=torch.float32) self.text_idx = torch.tensor(text_idx, dtype=torch.long) self.rating = torch.tensor(rating, dtype=torch.float32) def __len__(self): return len(self.rating) def __getitem__(self, idx): return self.numeric[idx], self.text_idx[idx], self.rating[idx] train_ds = MultimodalDataset(numeric_scaled[:TRAIN_LEN], text_idx_all[:TRAIN_LEN], rating[:TRAIN_LEN]) val_ds = MultimodalDataset(numeric_scaled[TRAIN_LEN:], text_idx_all[TRAIN_LEN:], rating[TRAIN_LEN:]) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False) # --- ステップ3: 3種類のモデル --- HIDDEN = 16 VOCAB_SIZE = len(VOCAB) class NumericBranch(nn.Module): def __init__(self, in_dim=3, hidden=HIDDEN): super().__init__() self.net = nn.Sequential(nn.Linear(in_dim, hidden), nn.ReLU()) def forward(self, x): return self.net(x) class TextBranch(nn.Module): def __init__(self, vocab_size=VOCAB_SIZE, hidden=HIDDEN): super().__init__() self.embed = nn.EmbeddingBag(vocab_size, hidden, mode="mean") def forward(self, text_idx): return self.embed(text_idx) class NumericOnlyModel(nn.Module): def __init__(self): super().__init__() self.branch = NumericBranch() self.head = nn.Linear(HIDDEN, 1) def forward(self, numeric, text_idx): return self.head(self.branch(numeric)).squeeze(-1) class TextOnlyModel(nn.Module): def __init__(self): super().__init__() self.branch = TextBranch() self.head = nn.Linear(HIDDEN, 1) def forward(self, numeric, text_idx): return self.head(self.branch(text_idx)).squeeze(-1) class MultimodalModel(nn.Module): def __init__(self): super().__init__() self.numeric_branch = NumericBranch() self.text_branch = TextBranch() self.head = nn.Linear(HIDDEN * 2, 1) def forward(self, numeric, text_idx): h_num = self.numeric_branch(numeric) h_txt = self.text_branch(text_idx) h = torch.cat([h_num, h_txt], dim=1) return self.head(h).squeeze(-1) # --- ステップ4: 学習と評価を共通化 --- def train_and_eval(model, epochs=100, lr=0.01): optimizer = torch.optim.Adam(model.parameters(), lr=lr) for epoch in range(epochs): model.train() for numeric, text_idx, y in train_loader: optimizer.zero_grad() pred = model(numeric, text_idx) loss = F.mse_loss(pred, y) loss.backward() optimizer.step() model.eval() preds, trues = [], [] with torch.no_grad(): for numeric, text_idx, y in val_loader: preds.append(model(numeric, text_idx)) trues.append(y) preds = torch.cat(preds).numpy() trues = torch.cat(trues).numpy() return np.sqrt(np.mean((preds - trues) ** 2)) torch.manual_seed(0); numeric_rmse = train_and_eval(NumericOnlyModel()) torch.manual_seed(0); text_rmse = train_and_eval(TextOnlyModel()) torch.manual_seed(0); multi_rmse = train_and_eval(MultimodalModel()) print(f"{'NumericOnly':12s} RMSE={numeric_rmse:.3f}") print(f"{'TextOnly':12s} RMSE={text_rmse:.3f}") print(f"{'Multimodal':12s} RMSE={multi_rmse:.3f}") # 出力例(同条件のOLS線形回帰で概算した参考値。学習率やepoch数で多少変動する): # NumericOnly RMSE=0.72(例) # TextOnly RMSE=0.78(例) # Multimodal RMSE=0.58(例) # → 数値のみ・テキストのみよりも、両方を組み合わせたMultimodalModelの方が # RMSEが小さくなりやすい(=それぞれのモダリティが持つ「品質」についての # 不完全な手がかりを補い合っている)。 # --- ステップ5: テキスト埋め込みの解釈(PREMIUM語・BUDGET語のクラスタ確認) --- multi_model = MultimodalModel() train_and_eval(multi_model) # 再学習(本来は学習済みモデルを使い回す) emb_matrix = multi_model.text_branch.embed.weight.detach() # (vocab_size, HIDDEN) def avg_cos_sim(words_a, words_b, same_group=False): idx_a = [word2idx[w] for w in words_a] idx_b = [word2idx[w] for w in words_b] sims = [] for i in idx_a: for j in idx_b: if same_group and i == j: continue sims.append(F.cosine_similarity(emb_matrix[i], emb_matrix[j], dim=0).item()) return np.mean(sims) premium_list, budget_list = list(PREMIUM), list(BUDGET) print("PREMIUM内の平均類似度:", avg_cos_sim(premium_list, premium_list, same_group=True)) print("BUDGET内の平均類似度 :", avg_cos_sim(budget_list, budget_list, same_group=True)) print("PREMIUM-BUDGET間の類似度:", avg_cos_sim(premium_list, budget_list)) # 出力例(学習がうまくいっていれば): # PREMIUM内の平均類似度: 0.4x(例) # BUDGET内の平均類似度 : 0.4x(例) # PREMIUM-BUDGET間の類似度: -0.1x(例、負またはPREMIUM内より明確に低い) # → 「高品質という言葉が使われている商品は評価も高い」という統計的な # 関係だけから、モデルが「PREMIUM語同士は近く、BUDGET語とは遠い」 # というベクトル空間の構造を自動的に学習していることが分かる。
📊 評価指標の可視化(val 100件のRMSE、小さいほど良い)
🪜 Step-by-Step 解説
1nn.EmbeddingBagは「Embedding lookup + 平均」を1つのレイヤーにまとめたもの
self.embed = nn.EmbeddingBag(vocab_size, hidden, mode="mean")
nn.Embeddingは単語IDを渡すと対応するベクトルを返すだけで、その後「5語をどう1本のベクトルにまとめるか」を自分で書く必要がある(Day123のAttentionはこの「まとめ方」に重みを付ける発展形だった)。nn.EmbeddingBag(mode="mean")は「embedding lookup → 平均」までを1つのレイヤーで済ませてくれる。実務上は「Attentionを使うほどの複雑さは要らないが、Bag of Wordsより表現力が欲しい」場面で軽量な選択肢になる。2数値ブランチとテキストブランチを同じhidden次元に揃えるのは公平な比較のため
HIDDEN = 16 class NumericBranch(nn.Module): ... # 出力 (batch, 16) class TextBranch(nn.Module): ... # 出力 (batch, 16)
3torch.cat([h_num, h_txt], dim=1)が今日の主役——Intermediate Fusionの実体
h = torch.cat([h_num, h_txt], dim=1) # (batch, hidden*2) return self.head(h).squeeze(-1)
hidden次元のベクトルに圧縮した後、その2本を単純に横に並べて結合するだけで、後段のhead(1層の線形層)が「2つの要約情報をどう組み合わせれば予測に有利か」を学習データから自動で調整してくれる。Early Fusionのように生データを直接混ぜる必要も、Late Fusionのように別々に学習したモデルの予測値を後から混ぜる必要もない。4Multimodalが両方の単一モダリティモデルを上回る理由——独立ノイズの打ち消し合い
views(数値側の品質シグナル)とdescription(テキスト側の品質シグナル)は、どちらも「真の品質」を不完全にしか反映していない。しかしそのノイズの発生源は別々(views_noiseは乱数、単語選びのばらつきも別の乱数)なので、両方の情報を同時に使うモデルは、一方のノイズが大きく出たサンプルでも、もう一方の手がかりで補正できる。これは複数の模試の平均点の方が1回の模試よりブレが少ないのと同じ理屈で、次の「数学・統計の補足」で詳しく説明する。🧮 数学・統計の補足(文系向け)
views)とテキスト特徴量(description)は、まさに「品質」という同じ本質を、それぞれ別々のブレ方をする2つの模試から覗き見ているようなもの。統計的には、2つの独立な推定値 $\hat{\theta}_1$(分散 $\sigma_1^2$)と $\hat{\theta}_2$(分散 $\sigma_2^2$)を適切な重みで混ぜた推定値の分散は、必ず $\min(\sigma_1^2, \sigma_2^2)$ 以下になることが知られている。マルチモーダル学習の効果は、この「複数の不完全な観測を組み合わせるとブレが減る」という発想をニューラルネットに埋め込んだものと理解すれば十分。nn.Embedding(EmbeddingBagの内部もこれ)は、vocab_size行×hidden列の行列を1枚持っているだけ。単語IDを渡すことは「その番号のカードを1枚引く」ことと同じで、カードの中身(ベクトルの値)は学習によって少しずつ書き換えられていく。今日の実験で「PREMIUM語同士のカードが似た内容になり、BUDGET語のカードとは違う内容になった」のは、モデルが「ratingを当てる」タスクを解く過程で、単語の意味を知らないまま、統計的な共起パターンだけからカードの中身を自動調整した結果。🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☐ 表形式データ(Tabular) / ☑ 自然言語処理(NLP) / ☑ 画像認識(CV) / ☐ 時系列(Time Series)
🐾 PetFinder.my Adoption Prediction
画像+テキスト+表形式データの3モダリティ
保護動物の「引き取られやすさ」を、動物の写真・プロフィール文・年齢や種類などの属性から予測する代表的なマルチモーダルコンペ。上位解法の多くは今日と同じ「モダリティごとにブランチで埋め込みを作り、結合してGBDT or NNのヘッドに渡す」構成を取る。
🛍️ Shopee - Price Match Guarantee
画像とタイトル文から同一商品を判定
商品の画像とタイトル文の両方から「同一商品かどうか」を判定するコンペ。画像はCNN(Day114-120)、テキストはBERT系や今日のようなBag of Words/TF-IDFで埋め込みを作り、両者の埋め込みベクトルの距離を使ってマッチングする。
⚙️ 事前学習済み埋め込み+GBDT
End-to-End NNとハイブリッドの使い分け
画像・テキストのような「NNが得意なモダリティ」は事前学習済みモデル(画像ならResNet/EfficientNet、テキストならBERT)で埋め込みベクトルを作り、それを表形式データの特徴量の1つとしてLightGBM/XGBoostに渡すハイブリッド構成が非常に多い。今日のようなEnd-to-End NNは、NNで扱いやすいモダリティが支配的、またはデータ量が十分な場合に選ばれる。
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| モダリティを増やせば増やすほど必ず精度が上がると思ってしまう | 「情報は多いほど良い」という直感 | 追加するモダリティが目的変数と無関係なノイズしか含まない場合、モデルの自由度が増えるだけで過学習しやすくなり、むしろ精度が悪化することがある。必ず単一モダリティのベースラインと比較して効果を確認する習慣が必要 |
| Early Fusion(生データの単純結合)が最も素直な方法だと思い、まず試そうとする | 「混ぜるなら最初から混ぜればいい」という直感 | 数値3個・テキスト5語(可変長になりうる)・画像数万画素のように、モダリティごとにスケールと次元数が大きく異なる生データをそのまま結合すると、特定のモダリティが次元数の多さだけで支配的になりやすい。実務ではIntermediate Fusion(各モダリティを同程度の次元に要約してから結合)が扱いやすい |
nn.EmbeddingBagはnn.Embedding + mean()と完全に同じ処理を別の書き方にしただけだと思う | APIの見た目だけを見て判断してしまう | 機能的には同等だが、EmbeddingBagは可変長の単語列を1回のレイヤー呼び出しで扱える(offsets引数でバッチ内の各サンプルの単語数がバラバラでも処理できる)よう最適化されており、パディングなしで可変長テキストを扱える点が実務上のメリットになる |
| 学習済みのテキスト埋め込みが「PREMIUM語同士で似ている」のを見て、モデルが日本語の意味を理解したと錯覚する | 結果だけを見ると言語理解しているように見える | モデルは単語の意味を一切知らず、「この単語が出現した商品はratingが高い/低い傾向がある」という統計的な共起パターンのみから埋め込みを調整している。訓練データの生成過程が変われば、埋め込みの配置も逆転する |
🚀 次のステップ
- 発展: 今日のモデルに
modality dropout(学習時にランダムな確率でテキストブランチの出力をゼロベクトルに置き換える)を実装し、テキストが欠損した商品でもMultimodalModelがある程度の精度を保てるかを検証してみましょう。またtorch.catではなく要素ごとの積(h_num * h_txt)や、Day123で学んだAttentionで2つのブランチの重要度を動的に変える「Gated Fusion」を試すのもおすすめです - 次回予告: Day 125 — ソロGold戦略。Phase 5最後のテーマとして、これまで学んだGBDT(Phase 4)・ディープラーニング各分野(Phase 5)を、チームを組まずに1人でKaggleのGoldメダル圏内を狙うための現実的な立ち回り・コンペ選定・時間配分の戦略を扱います。ここでPhase 5(20テーマ)が完了し、Day126からはいよいよPhase 6: Grandmasterに突入します
Phase 5 の学習マップ(全20テーマ予定)
Day121-123で時系列ブロックが完了し、今日Day124のマルチモーダル学習でテーマ19が完了した。残るはテーマ20「ソロGold戦略」のみで、Phase 5(20テーマ)が完了する。
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: