第3回:LSTMで「飽きの来ない献立」を時系列予測する

コサイン類似度の限界

前回、コサイン類似度を使って「栄養バランスの似たレシピ」を提案する仕組みを作りました。栄養素のベクトル空間で類似度を計算し、バランスの良い代替レシピを見つけるアプローチです。

これは「今日のメニューに似た別のメニュー」を見つけるには有効でした。しかし、実際に毎日の献立を考えている主婦や家庭で料理を担当されている方にとって、もう一つ大きな問題があります。

「飽き」です。

月曜にカレーを作ったら、火曜にもカレーに似たシチューが出てくる。水曜もクリーム系の煮込み料理が提案される。コサイン類似度は「栄養的に似ているもの」を見つけるのは得意ですが、時間軸での変化を考慮しません。

毎日の献立で本当に求められているのは、「栄養バランスが良くて、かつ最近作ったものとは違うメニュー」です。これは単純な類似度検索では解決できない、時系列の問題でした。


テキスト生成からの着想

この問題に取り組んでいたとき、KerasのサンプルコードにNietzscheのテキスト生成の例題がありました。LSTMを使って、ニーチェの文章を1文字ずつ予測し、新しい文章を自動生成するというものです。

ふと気づきました。

「次の文字を予測する」と「次の献立を予測する」は、構造的に同じ問題ではないか。

テキスト生成では、直近の文字列(コンテキスト)を見て次に来る文字を予測します。献立予測でも、直近の食事履歴(コンテキスト)を見て次に作るべきメニューを予測できるはずです。

テキスト生成:  "Nietzsch" → 次の文字は "e"
献立予測:     [月:カレー, 火:焼魚, 水:肉じゃが, ...] → 次のメニューは?

この発想の転換が、LSTMによる献立予測の出発点でした。


データの構造

使用したデータは menu_day.csv で、318行、6カラムの献立記録データです。毎日の食事をIDで記録したもので、数年分の実際の家庭の献立記録にもとづいています。

まず、メニューIDと配列インデックスの対応辞書を作成します。

import pandas as pd
import numpy as np

dataset = pd.read_csv('menu_day.csv', encoding="shift-jis")
d_master = dataset.drop_duplicates('id').sort_values('id')

dict_id_index = {}
dict_index_id = {}
index = 0
for v in d_master.drop(['target_date'], axis=1).to_dict('records'):
    dict_id_index[v['id']] = index
    dict_index_id[index] = v['id']
    index += 1

master_len = len(dict_id_index)  # 318 unique menus

318種類のユニークなメニューがあり、それぞれにインデックス番号を割り当てます。テキスト生成における「文字の辞書」に相当する部分です。英語のアルファベットなら26文字ですが、この献立辞書は318「文字」ということになります。


スライディングウィンドウで学習データを構成する

テキスト生成と同じく、7日間のスライディングウィンドウで学習データを作ります。「直近7日間に何を食べたか」を入力として、「8日目に何を食べるか」を予測するという構成です。

maxlen = 7  # 7日間の履歴
step = 1
current_menus = []
next_menus = []

for i in range(0, dataset.shape[0] - maxlen, step):
    arr = dataset[i:i + maxlen]['id'].values
    current_menus.append([value for value in arr])
    next_menus.append(dataset.iloc[i + maxlen]['id'])

次に、ワンホットエンコーディングで数値化します。

# Vectorization: 311 samples x 7 timesteps x 318 menu types
x = np.zeros((len(current_menus), maxlen, master_len), dtype=np.bool)
y = np.zeros((len(current_menus), master_len), dtype=np.bool)

for i, current_menu in enumerate(current_menus):
    for t, menu_id in enumerate(current_menu):
        x[i, t, dict_id_index[menu_id]] = 1
    y[i, dict_id_index[next_menus[i]]] = 1

結果として、311個のサンプルが生成されました。それぞれが「7日間の履歴 → 翌日のメニュー」というペアです。

テキスト生成の場合は「7文字の履歴 → 次の1文字」ですが、ここでは「7日間の献立 → 翌日の1品」という対応になります。ワンホットベクトルの次元が318と大きいのは、辞書サイズ(メニュー数)がそのまま反映されているためです。


LSTMモデルの構築

モデルの構造は非常にシンプルです。LSTM層1つと全結合層1つ。Nietzscheテキスト生成のサンプルとほぼ同じアーキテクチャです。

from keras.models import Sequential
from keras.layers import Dense, LSTM
from keras.optimizers import RMSprop

model = Sequential()
model.add(LSTM(128, input_shape=(maxlen, master_len)))
model.add(Dense(master_len, activation='softmax'))
model.compile(loss='categorical_crossentropy', optimizer=RMSprop(lr=0.01))
  • LSTM(128): 128ユニットのLSTM層。7日間の時系列パターンを学習します
  • Dense(318, softmax): 318種類のメニューに対する確率分布を出力します
  • categorical_crossentropy: 多クラス分類の標準的な損失関数です

このモデルが学習するのは、「この7日間の食事パターンの後には、どのメニューが来やすいか」という確率分布です。


Temperature:予測の「冒険度」を制御する

ここが、このアプローチの核心です。

softmax層の出力は318種類のメニューの確率分布ですが、この確率をそのまま使うと、最も確率の高いメニューばかりが選ばれてしまいます。それでは結局マンネリになります。

テキスト生成で使われる temperature(温度)パラメータ をそのまま転用しました。

def sample(preds, temperature=1.0):
    preds = np.asarray(preds).astype('float64')
    preds = np.log(preds) / temperature
    exp_preds = np.exp(preds)
    preds = exp_preds / np.sum(exp_preds)
    probas = np.random.multinomial(1, preds, 1)
    return np.argmax(probas)

temperatureの値によって、予測の「冒険度」が変わります。

temperature振る舞い献立での意味
0.2保守的。最も確率の高い選択肢に集中する定番メニューが中心。安心感はあるが変化が少ない
0.5ややバランス型定番を軸にしつつ、たまに変わったメニューも
1.0モデルの生の確率分布に従う自然な変化のある献立
1.2冒険的。低確率の選択肢も出やすくなる意外な組み合わせが増える。マンネリ打破に効果的

この仕組みのおかげで、「今週は定番で(temperature=0.2)」「ちょっと冒険したい(temperature=1.2)」という使い分けが可能になります。

直感的に説明すると、temperatureが低いときは「みんなが選ぶ人気メニュー」ばかりが提案され、temperatureが高いときは「たまにしか作らないけど意外と美味しいメニュー」も候補に入ってくるイメージです。


学習と予測の実行

100エポックで学習を行い、各エポックの終了時にシード(直近7日分の献立)を起点として10日分の献立を生成します。

学習ループの中では、temperatureを0.2、0.5、1.0、1.2の4段階で同時に生成し、比較できるようにしました。

for epoch in range(1, 101):
    print(f'Epoch {epoch}')
    model.fit(x, y, batch_size=128, epochs=1)

    # シードとなる7日間を選択
    start_index = random.randint(0, len(dataset) - maxlen - 1)
    generated = dataset[start_index:start_index + maxlen]['id'].values.tolist()

    for diversity in [0.2, 0.5, 1.0, 1.2]:
        sentence = generated[:]
        print(f'--- diversity: {diversity}')

        for i in range(10):
            x_pred = np.zeros((1, maxlen, master_len))
            for t, menu_id in enumerate(sentence[-maxlen:]):
                x_pred[0, t, dict_id_index[menu_id]] = 1.0

            preds = model.predict(x_pred, verbose=0)[0]
            next_index = sample(preds, diversity)
            next_id = dict_index_id[next_index]

            sentence.append(next_id)
            print(f'  Day {i+1}: Menu ID {next_id}')

学習が進むにつれて、生成される献立の傾向が変化していくのが観察できます。初期のエポックではランダムに近い提案ですが、後半になると食事パターンの「文脈」を捉えた提案になっていきます。


この手法で得られたこと

LSTMによる時系列予測は、コサイン類似度では扱えなかった「時間の流れ」を捉えることに成功しました。

  1. パターンの学習: 「肉料理の翌日は魚料理が来やすい」「週末はやや手の込んだ料理になる」といった傾向を学習します
  2. 多様性の制御: temperatureパラメータにより、保守的な提案から冒険的な提案まで、ユーザーの気分に合わせた調整ができます
  3. 連続的な予測: 1日分だけでなく、1週間分の献立を一気に生成できます

一方で、課題もありました。311サンプルというデータ量はLSTMにとって決して多くはなく、学習の安定性には限界がありました。また、レシピの詳細(材料、調理手順など)は考慮されておらず、あくまでメニューIDレベルでの予測です。

それでも、「テキスト生成の技術を献立予測に転用する」という発想自体は有効であり、日々の「何を作ろう」という悩みに対してAIがアプローチできることを示せた実験でした。


次回予告

ここまでPart 1のコサイン類似度、Part 2の栄養バランス分析、そして今回のLSTMと、当時の機械学習技術で献立の問題に取り組んできました。

次回は時代が一気に進みます。ChatGPT(LLM)の登場により、同じプロジェクトを全く異なるアプローチで再訪します。2万件のレシピデータをChatGPT APIで一括変換し、「忙しい日でも作れる時短レシピ」に自動アレンジした話です。


第2回:栄養バランスをベクトルで解く 第4回:ChatGPTで2万件のレシピを「時短版」に自動変換する

この記事をシェア

関連記事