第2回:栄養ベクトルとコサイン類似度で「同じ栄養、違う料理」を実現する

はじめに — 「飽きた」を解決する数学

前回は、約2万件のレシピデータを3つのCSVから統合し、recipe_master.csv を構築するまでのデータパイプラインを紹介しました。

今回は、そのデータを使った最初の分析アプローチについて解説します。

献立を考えるときの悩みの一つに、**「栄養バランスは保ちたいけれど、同じ料理ばかりでは飽きてしまう」**というものがあります。たとえば、「鮭のクリーム焼き」が家族に好評だったとします。しかし毎週作るわけにはいきません。同じような栄養が摂れる、でも見た目も味も違う料理があれば…。

この「同じ栄養、違う料理」を実現するのが、コサイン類似度です。


コサイン類似度とは

コサイン類似度は、2つのベクトルの「方向の近さ」を測る尺度です。値は -1 から 1 の範囲を取り、1 に近いほど似ていることを意味します。

数式で表すと以下の通りです。

cos(θ)=ABA×B\cos(\theta) = \frac{\mathbf{A} \cdot \mathbf{B}}{|\mathbf{A}| \times |\mathbf{B}|}

ここで重要なのは、コサイン類似度がベクトルの大きさ(絶対量)ではなく方向(比率)を比較するという点です。

これは栄養素の比較にぴったりです。なぜなら、1人前と2人前では栄養素の絶対量は2倍になりますが、栄養バランスの「形」は同じだからです。タンパク質が多めでビタミンCが少なめ、という「バランスの傾向」を捉えたいとき、コサイン類似度は最適な指標になります。


レシピ単位の栄養ベクトル検索

まず、レシピ単位での検索を実装しました。

データの概要

項目
レシピ数18,174件
栄養素の次元数19次元
データ形式Shift-JIS エンコーディング

各レシピは19種類の栄養素の値を持つベクトルとして表現されます。

実装コード

from sklearn.metrics.pairwise import cosine_similarity
from sklearn.preprocessing import StandardScaler
import pandas as pd

# レシピデータの読み込み
dataset = pd.read_csv('recipe.csv', encoding="shift-jis")

# 検索対象のレシピを指定
id = 73
df_searched = dataset[dataset['id'] == id]
print(df_searched[['name', 'type']])
# 出力: 新しょうがと豚肉のオイスター炒め  主菜,お弁当,おつまみ

まず検索元となるレシピを1つ指定します。今回は id=73 の「新しょうがと豚肉のオイスター炒め」を選びました。主菜にもお弁当にもおつまみにもなる、便利なレシピです。

# 栄養素カラムのみを抽出(レシピ情報カラムを除外)
all_nutorition = dataset.drop(['id','name','type','genre','utility','site_code'], axis=1)
target_nutorition = df_searched.drop(['id','name','type','genre','utility','site_code'], axis=1)

ID、名前、カテゴリなどの非数値カラムを除外し、19次元の栄養素ベクトルだけを取り出します。

標準化 --- なぜ StandardScaler が必要か

ここで重要なステップがあります。StandardScaler による標準化です。

# 標準化(平均0、分散1にスケーリング)
scaler = StandardScaler()
scaler.fit(all_nutorition)
all_nutorition = scaler.transform(all_nutorition)
target_nutorition = scaler.transform(target_nutorition)

なぜ標準化が必要なのでしょうか。

栄養素にはカロリー(数百kcal単位)、タンパク質(数十g単位)、ビタミン(数mg単位)など、スケールが大きく異なる値が混在しています。標準化せずにコサイン類似度を計算すると、絶対値が大きいカロリーに結果が引きずられ、微量だが重要なビタミンやミネラルの違いが無視されてしまいます。

StandardScaler は各栄養素を「平均0、標準偏差1」に変換します。これにより、すべての栄養素が同じ重みで類似度計算に反映されるようになります。

類似レシピの検索

# コサイン類似度を計算
similarities = cosine_similarity(target_nutorition, all_nutorition)

# 自分自身を除いた上位30件のインデックスを取得
similarities_index = similarities.argsort()[0][-2:-32:-1]  # 上位30件

# 類似レシピの情報を取得
data_similar = dataset.loc[similarities_index]

argsort() で類似度の高い順にソートし、[-2:-32:-1] で自分自身(最も類似度が高い = 1.0)を除いた上位30件を取得しています。

「新しょうがと豚肉のオイスター炒め」で検索すると、同じような栄養プロファイルを持つ肉料理や炒め物が上位に並びます。味付けや見た目はまったく異なるのに、栄養バランスは驚くほど似ている料理が見つかるのです。


メニュー単位の栄養ベクトル検索

レシピ単位の検索がうまく機能したので、次はメニュー(献立)単位の検索に拡張しました。

データの概要

項目
メニュー数1,526件
カラム数29カラム(menu_id, name + 27栄養素)
栄養素の次元数27次元

レシピ単位では19次元でしたが、メニュー単位では27次元に拡張されています。カルシウム、炭水化物、コレステロール、各種ビタミン(A, B1, B2, B6, B12, C, D, E, K)など、より細かい栄養素まで網羅しています。

1つのメニューは複数の料理(主菜+副菜+汁物など)で構成されており、それらの栄養素が合算された値を持っています。

実装コード

# メニューデータの読み込み
dataset = pd.read_csv('menu.csv', encoding="shift-jis")
# 1,526メニュー、29カラム(menu_id, name, + 27栄養素カラム)

# 検索対象のメニューを指定
id = 1
df_searched = dataset[dataset['menu_id'] == id]
# 出力: 鮭のコーンクリーム焼きと玉ねぎサラダの献立(全3品)

検索元は menu_id=1 の「鮭のコーンクリーム焼きと玉ねぎサラダの献立(全3品)」です。主菜の鮭料理にサラダと汁物が組み合わされたバランスの良い献立です。

# 栄養素カラムのみを抽出
all_nutorition = dataset.drop(['menu_id','name'], axis=1)
target_nutorition = df_searched.drop(['menu_id','name'], axis=1)

# 標準化
scaler = StandardScaler()
scaler.fit(all_nutorition)
all_nutorition = scaler.transform(all_nutorition)
target_nutorition = scaler.transform(target_nutorition)

# コサイン類似度で上位30件を検索
similarities = cosine_similarity(target_nutorition, all_nutorition)
similarities_index = similarities.argsort()[0][-2:-32:-1]

処理の流れはレシピ単位と同じです。データを読み込み、標準化し、コサイン類似度を計算する。アルゴリズムの美しいところは、次元数やデータ量が変わっても同じコードで動くという点です。

検索結果の例

「鮭のコーンクリーム焼き」の献立で検索すると、以下のような結果が得られました。

上位にはやはり魚をメインにしたバランスの良い献立が並びます。鮭ではなく鯖やタラを使った献立、あるいはシーフードグラタンを中心とした献立など、素材は異なるものの栄養プロファイルが似通ったメニューが抽出されます。

これはまさに主婦の方や料理を担当する方が求めていたもの --- 「栄養バランスは崩さず、献立にバリエーションを持たせたい」というニーズに応える仕組みです。


レシピ検索 vs メニュー検索の比較

2つのアプローチを比較してみます。

観点レシピ検索メニュー検索
対象個別の料理(18,174件)献立セット(1,526件)
栄養素次元19次元27次元
ユースケース「この料理の代わりになる別の料理は?」「この献立と同じ栄養の別の献立は?」
実用性1品だけ差し替えたいときに便利献立まるごと提案したいときに便利

実際の利用シーンでは、メニュー検索のほうが実用的です。料理単品の栄養バランスが似ていても、献立全体で見ると偏りが生じることがあります。メニュー単位で比較すれば、主菜・副菜・汁物を含めた総合的な栄養バランスが保証されます。


限界と気づき

コサイン類似度による検索は直感的で実装も容易ですが、いくつかの限界も見えてきました。

1. 「似ている」だけでは足りない

コサイン類似度は「入力に似たもの」を見つけるのは得意ですが、「昨日は肉だったから今日は魚にしたい」「今週はカルシウムが不足気味だから補いたい」といった文脈を考慮した提案はできません。

2. 栄養素だけでは語れない

味の好み、調理時間、旬の食材、食費の制約 --- 実際の献立決定には栄養素以外の要素が多く関わります。栄養ベクトルの類似度だけでは、実用的な献立提案には不十分です。

3. 冷蔵庫の中身との連動

「冷蔵庫にある材料で作れるもの」という制約は、栄養ベクトル検索だけでは扱えません。材料テキストの解析を別途組み合わせる必要があります。

これらの限界が、次のステップ --- テキストベースのアプローチや、最終的にはLLMを活用した対話型の献立提案へとつながっていきます。


まとめ

この回では、コサイン類似度を使って「栄養バランスが似ている別の料理・献立」を検索する仕組みを実装しました。

  • コサイン類似度はベクトルの方向(バランスの形)を比較するため、栄養素の比較に適している
  • StandardScalerで標準化することで、スケールの異なる栄養素を公平に扱える
  • レシピ単位(19次元)とメニュー単位(27次元)の2つのレベルで検索を実装した
  • 「同じ栄養、違う料理」は実現できたが、文脈を考慮した提案にはさらなる工夫が必要

10年以上前の古典的な手法ですが、アルゴリズムの本質は時代を超えて変わりません。コサイン類似度は今でもレコメンデーションシステムの基盤として広く使われています。

次回は、テキスト情報を活用した別のアプローチに進みます。


前回: 第1回「毎日の献立問題をデータで解く」

次回: 第3回(近日公開)

この記事をシェア

関連記事