XGBoostで食材のペアリング推定をする (2)

# 少ないデータで戦う

今回扱うのは、**フードペアリングの分類問題**です。
 具体的には、「この食材ペアは一般的な組み合わせなのか？それとも珍しいのか？」
 を分類モデルで判定したいと考えています。
たとえば、**ビーフ × ガーリック**のように、
 レシピによく登場する組み合わせは「一般的」と判断しやすいです。
ですがその逆、あまり見かけないペアはどうでしょう？
 それが「 **美味しくないから登場しない**」のか、
 「**まだ発見されていない良い組み合わせ**」なのかを見極めるのは簡単ではありません。
そこで、今回は、香りのペアリング情報（アロマ）やグラフ特徴量など、
周辺情報を説明変数として使いながら、分類モデルを構築してみます。

今回はプロトタイピングという前提で、
 「**不十分なデータでもどうやって戦うか**」というテーマに挑戦してみます。
今回は、食材が980件、レシピが1200件という、モデルを作るには明らかに不十分な状態ではじめます。

そこで今回は、香りのペアリング情報（アロマ）やグラフ特徴量など、
周辺情報を説明変数として使いながら、分類モデルを構築してみます。

![Screenshot 2025-03-29 at 14.24.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89997/edb46886-832d-48e4-81d7-2dd58896f05e.png)

## 分類に使う説明変数たち
|特徴量名|内容|例|タイプ|
|-----------------------------------------------|----------------------------------------|-----------------------|-------|
|frequency|そのペアが登場するレシピの回数|ビーフ × ガーリック → 27回|数値(int)|
|shared_aromas|共通して含む香りの数|バジルとミント → 2つ|数値(int)|
|food1_popularity/food2_popularity |各食材がどれくらい他の食材とペアになっているか|ガーリック：120件 |数値(int)|
|food1_aroma_page_rank/food2_aroma_page_rank  |香りのネットワークにおける PageRank|高いほど「香りとして目立つ食材|数値(float)|
|food1_recipe_page_rank/food2_recipe_page_rank|レシピネットワークにおける PageRank|高いほど「レシピの中で目立つ食材」|数値(float)|
|word_similarity|食材名の意味の近さ|キャベツと白菜 → 高|数値(float)|
|flavor_similarity|香りプロファイルのオーバーラップ率|コリアンダーとレモングラス → 中程度    |数値(float)|

## データの問題点
![Screenshot 2025-03-29 at 14.32.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89997/d27afcab-f119-4ecc-a0dc-4f8d1850aae8.png)

### A. 似たような意味の説明変数しかない情報不足問題
1. food1_popularity と food1_recipe_page_rank の相関は 0.96 と非常に強く、
 → これは ほぼ同じ情報を含んでいると見なして、一方だけ使えば十分です。
2. shared_aromas と food2_aroma_page_rank の相関も 0.63
 → 香りのPageRankが高い食材ほど、他の食材と香りを共有する傾向があるのかもしれません。
 → こちらも 説明変数の冗長性に注意が必要です。

### B. 相関弱い問題
1. 目的変数 frequency と説明変数の相関は 全体的に0.2以下
2. 特に注目される food1_popularity や recipe_page_rank でもせいぜい0.2〜0.24程度
 → 直線的な関係では説明しづらいことがわかります。

### C. データが少ない偏り問題
1. データの粒度が粗く、1食材あたりの出現頻度が非常に少ない
2. 現頻度（frequency）の分布は極端に偏っており、1回も登場しない組み合わせが大多数です。
3. 正解ラベル（frequency）も、本当に一般的でないのか or 単に未登場なだけか区別が難しい
レシピの件数が少ない（1200件）ため、正解ラベルとしての信頼性が低くなる

## 課題と対策！
**A. 似たような意味の説明変数しか作れない情報不足問題**
**B. 相関弱い問題**

こちらの問題解決には、木構造のアルゴリズムを採用して、非線形に変化するデータを扱う事ができる、**XGBoost**を利用します。

1. 相関が高い冗長な特徴が含まれていても大きな問題になりにくい
2. 相関が低くても非線形な関係を拾えるのがXGBoostの強み
3. 情報量が少ない特徴は自動的にスプリットから除外される
4. 正則化（L1, L2）により、過学習も抑制される

**C. データが少ない問題**
こちらの問題解決には、不均衡な分類問題において、少数派クラスのサンプルを人工的に生成することで、クラスバランスを取る事ができる、**SMOTE**を利用します。

1. 少数派クラスの既存データを単に複製するのではなく、近傍のデータ間を補間して新しいデータを作る
2. クラスの偏りを減らし、モデルが少数派クラスを正しく学習できるようになる
3. 少数派のデータに対して、近くのk個のサンプル（k-NN）を使って補間することで、自然な分布の合成データを作ることができます。

# 実装していきましょう！
### 下準備
必要なライブラリをインポートしておきます

```python
import numpy as np
import pandas as pd
import xgboost as xgb
from sklearn.model_selection import train_test_split
from imblearn.over_sampling import SMOTE
from sklearn.preprocessing import MinMaxScaler
from sklearn.metrics import confusion_matrix

```

### 実装 (1) 下処理 - モデル作成
とりあえず、平均二乗誤差(MSE)を指定した回帰モデルを作成します。

```python
# Split the data into features and target label
Xcolumns = target_column.copy()
Xcolumns.remove("frequency")

# Use MinMaxScaler to scale the data
scaler_X = MinMaxScaler()
scaler_y = MinMaxScaler()
X = scaler_X.fit_transform(df[Xcolumns])
y = scaler_y.fit_transform(df[["frequency"]]).flatten()

# Split the data into training and testing sets
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# Train the model
model = xgb.XGBRegressor(objective="reg:squarederror", eval_metric="rmse")
model.fit(X_train, y_train)
```


### 実装 (2) 特徴量自動検出
 (1) で作成したモデルの特徴量をグラフ化して確認します。

```python
feature_names = {f"f{i}": Xcolumns[i] for i in range(len(Xcolumns))}

# Get importance and map feature names
importance = model.get_booster().get_score(importance_type="weight")
importance_named = {feature_names[k]: v for k, v in importance.items()}

# Convert to DataFrame
importance_df = pd.DataFrame(list(importance_named.items()), columns=["Feature", "Importance"])
importance_df = importance_df.sort_values(by="Importance", ascending=False)

# Visualization
plt.figure(figsize=(6, 6))
plt.barh(importance_df["Feature"], importance_df["Importance"], color="skyblue")
plt.gca().invert_yaxis()  # Bring important features to the top
plt.show()
```

### 実装 (3) 重要な特徴量の確認
こちらは、説明変数の重要度などを確認するために行います。

![Screenshot 2025-03-29 at 14.41.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89997/895a6e98-1fe2-4352-b22d-19f5d078b72d.png)

:::note info
pagerankやsimilarityはneo4jで計算したあとにモデルの説明変数として利用してます。詳しくは、下記をご覧ください。
[neo4jが楽しすぎるので勉強してみた (1)](https://qiita.com/MariMurotani/items/8b1c4f4e1b9b4dbb793f)
[neo4jが楽しすぎるので勉強してみた (2)](https://qiita.com/MariMurotani/items/e3485a8a796796215ea7)
:::

### 実装 (4) データの粒度を均一にする (log変換)
データの偏りが大きいため、目的変数となる **frequency**の大きさを揃えたいと思います！
`log1p`を使って変換する事で、BEFORE -> AFTERがグラフのようになります！

```python
# Apply log1p
df["frequency_log"] = np.log1p(df["frequency"])
# Define the threshold to 5 (そのペアが登場するレシピの回数が5以上)
df["class"] = (df["frequency_log"] > 5).astype(int)
# Set class as the target(Y) column
y = df["class"]
```
![Screenshot 2025-03-29 at 14.46.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89997/b10fe633-3417-4805-a0c2-7823a9b638dc.png)

### 実装 (5) SMOTEでサンプルデータを増やす 
少ないデータの方のクラスを増やす手法をオーバーサンプリングといいます。あらかじめデータの偏りをなくす事で精度の高いモデルを構築するのに役立てます。

```python
# クラスの分布を確認 (SMOTE 前)
print("Before SMOTE:", Counter(y))

# SMOTE で少数派クラスを増やす
smote = SMOTE(sampling_strategy=0.5, k_neighbors=3, random_state=42)
X_train_resampled, y_train_resampled = smote.fit_resample(X_train, y_train)

# クラスの分布を確認 (SMOTE 後)
print("After SMOTE:", Counter(y_train_resampled))
```

![Screenshot 2025-03-29 at 14.50.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89997/d74112d4-ff8e-4e52-b3d9-eb05da894237.png)
図のように、実際に存在するデータ、この場合にはk_neighboersに指定された3点をとって、データを内挿します。

### 実装 (6) XGBoostの分類モデルを作成
[XGBoostで食材のペアリング推定をする (1)](https://qiita.com/MariMurotani/items/82b994ed97eb0db006de)で学んだので割愛します。
`objective="binary:logistic"`なので、分類モデルを作成しています。
`scale_pos_weight = 負例（class=0） / 正例（class=1）`となっている部分は、正例の重みを重くしています。

```python
# XGBoost の分類モデルを作成
model = xgb.XGBClassifier(
    objective="binary:logistic",
    eval_metric="logloss",
    max_depth=5,
    n_estimators=100,
    learning_rate=0.1,
    scale_pos_weight=len(y_train_resampled) / (2 * sum(y_train_resampled == 1))
)

# モデルの学習
model.fit(X_train_resampled, y_train_resampled)

# 予測
y_pred = model.predict(X_test)
```

### 実装 (7) SHAPで補正後のデータを確認
補正後に特徴量がどれくらいモデルに貢献したかを確認します！

```python
explainer = shap.Explainer(model)
shap_values = explainer(X_test)
shap.summary_plot(shap_values, X_test, feature_names=Xcolumns)
```
![Screenshot 2025-03-29 at 14.58.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89997/ec2f44c7-3e46-4db6-91b8-06d89513fda1.png)

### 実装 (8) 分類モデルの精度確認
ビルドしたモデルの精度を確認していきます！

```python
# 混同行列の作成
cm = confusion_matrix(y_test, y_pred_new)
# 混同行列をヒートマップで可視化
# Create a heatmap
plt.figure(figsize=(6, 4))
# ラベルを文字列で埋め込んだアノテーション配列を作る
labels = ["Unusual (0)", "Usual (1)"]
annot_labels = np.array([
    [f'TN\n{cm[0,0]}', f'FP\n{cm[0,1]}'],
    [f'FN\n{cm[1,0]}', f'TP\n{cm[1,1]}']
])
plt.figure(figsize=(6, 4))
sns.heatmap(cm, annot=annot_labels, fmt='', cmap="Blues", xticklabels=labels, yticklabels=labels)
plt.xlabel("Predicted Label")
plt.ylabel("True Label")
plt.title("Confusion Matrix (Evaluated on SMOTE Data)")
plt.show()
```
![Screenshot 2025-04-02 at 19.37.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89997/edf291cd-bdb2-4f74-a752-964df0bc1c91.png)

SMOTEでサンプルデータを増やして補正した後では
モデルは最適化されて、かなりの精度で判別ができるようです。
レシピのデータも食材のデータも不十分ですが、データが十分に増えた暁に新しいペアリングを探すのに活用できる未来が垣間見えました。

# 終わりに
わずか レシピ1200件、食材980種 という、明らかに不十分なデータ量にも関わらず、
香りのネットワーク や PageRank、SMOTE、XGBoost を活用して、
「一般的な食材ペア」と「珍しいペア」を それなりの精度で分類できる ことがわかりました。
「美味しくないから登場しない」のか、「まだ発見されていないから登場しないのか」という事までは
当然分かりませんでしたが、判別できる可能性を余白として残した形になったと思います。

そして、グラフDBであるneo4jや決定木のアルゴリズム活用を学ぶ事ができました。
具体的なデータでテーマを持って学ぶとより、深く理解できるものだと改めて痛感しています。
他の事例にも、どんどん活用できそうです！一旦、今回のフードペアリング関連の記事を一覧にまとめます。

[neo4jが楽しすぎるので勉強してみた (1)](https://qiita.com/MariMurotani/items/8b1c4f4e1b9b4dbb793f)
[neo4jが楽しすぎるので勉強してみた (2)](https://qiita.com/MariMurotani/items/e3485a8a796796215ea7)
[XGBoostで食材のペアリング推定をする (1)](https://qiita.com/MariMurotani/items/82b994ed97eb0db006de)

## 参考リンク
[SMOTEのいい説明](https://qiita.com/eigs/items/8ae0970afe188a1124d1)
[SMOTE公式](https://imbalanced-learn.org/stable/references/over_sampling.html)
[SHAP公式](Shttps://shap.readthedocs.io/en/latest/index.html)
[XGBoost公式](https://xgboost.readthedocs.io/en/release_3.0.0/index.html)