技術資料:強化学習を用いたパーソナライズ献立推薦システム 

目次 

1. 目的 

食に対する個人の嗜好は、「珍しいものが食べたい」「調理が簡単なものが良い」など、非常に多様かつ動的である。従来の推薦システムは、栄養バランスやコストといった静的な指標に基づいて献立を提案するものが主であった。本研究では、ユーザーからのフィードバック(満足度評価)を元に、強化学習(多腕バンディットアルゴリズム)を用いて「ユーザーがどのようなタイプの献立を好むか」という提案戦略を学習し、その戦略に基づいて多目的最適化(遺伝的アルゴリズム)が献立を生成する、パーソナライズされた動的な献立推薦システムのコアエンジンを構築・検証することを目的とする。

2. システム概要 

本システムは、強化学習エージェント(バンディット)と多目的最適化エンジン(遺伝的アルゴリズム)を連携させた、クローズドループのシミュレーション環境である。全体の処理フローは以下の通り。 1. 戦略決定: `bandit_logic.py` が過去の学習記録 `mab_feedback.csv` を参照し、今回最適化すべきUX戦略(腕)を決定する。 2. 献立生成: `2献立作成(GraphicalRecipes).py` が、決定された戦略に基づき多目的最適化を実行し、献立候補群(パレート解)を生成する。 3. 評価: `run_experiment.py` が生成された献立の一つをランダムに選択し、`virtual_user.py` に渡して評価させる。 4. フィードバック: `virtual_user.py` は自身の隠れた好みに基づいて満足度(報酬)を計算し、返す。 5. 学習記録: `run_experiment.py` は、(1)で選択された戦略と、(4)で得られた報酬のペアを `mab_feedback.csv` に追記する。 6. 上記1~5を規定回数繰り返し、学習を行う。

3. 実際の人間が利用する場合の想定フロー 

現在のシステムは、`virtual_user.py` を用いた自動シミュレーション環境だが、これを実際のサービスとして人間が利用する場合、以下のようなフローが想定される。 1. ユーザーのログイン: ユーザーがシステムにログインする。ユーザーごとに過去の評価履歴が管理される。 2. 戦略決定: `bandit_logic.py` が、そのユーザーの過去の評価履歴(`mab_feedback.csv`に相当)を読み込み、「今日のあなたへのおすすめ方針」として最適な腕(例:腕3「調理しやすさ重視」)を選択する。 3. 献立生成: `2献立作成(GraphicalRecipes).py` が、選択された戦略に基づいて、複数の優れた献立候補(パレート解)を生成する。 4. 献立の提示: `server1(GraphicalRecipes).py`が起動したWebアプリケーションが、生成された複数の献立候補をユーザーに提示する。ユーザーは気分や状況に合わせて、その中から一つを選ぶ。 5. 調理と食事: ユーザーは選んだ献立を実際に調理し、食事をする。 6. 満足度の評価: 後日、ユーザーはWebアプリケーション上で、前回の献立に対する総合的な満足度を1~5の星などで評価する。 7. フィードバックの記録: `server1(GraphicalRecipes).py`は、そのユーザーの評価(報酬)を、「どの戦略で提案したか」という情報と紐づけて、そのユーザーの学習データとして記録する。

4. 使用するファイル全部 

扱うデータ用途ファイル名ファイルの場所
システム制御シミュレーション全体の制御、各モジュールの呼び出し、結果の記録run_experiment.py/code
システム制御多目的最適化による献立生成、GUIによる手動設定2献立作成(GraphicalRecipes).py/code
Webサーバー生成された献立をブラウザで表示するためのWebサーバー機能server1(GraphicalRecipes).py/code
システム制御UCB1バンディットアルゴリズムによる戦略決定bandit_logic.py/code
システム制御仮想ユーザーによる献立評価と報酬計算virtual_user.py/code
入力データ各レシピの栄養素・コスト・UXスコアの格納recipe_noX.csv/code/data/hyouka/
設定データ手動実行時のユーザー情報やアレルギー設定を保存menu_creation_settings.json/code
出力データ(中間)生成した献立の日数をWebサーバーに渡すための中間ファイルparams.json/code/static/
出力データ(学習ログ)強化学習の試行ごとの結果(腕、報酬)を記録mab_feedback.csv/code
出力データ(献立)生成された献立候補群の詳細情報をJSON形式で保存all_details.json/code/static/
出力データ(グラフ用)3Dグラフ描画用のノード・リンク情報graph_data.json/code/static/
出力データ(グラフ)遺伝的アルゴリズムのパレート解の分布を可視化palate.png/code
Webページ用テンプレート3Dグラフを表示するメインページのHTMLgraph_viewer.html/code/templates/
Webページ用テンプレート献立詳細と評価フォームを表示するHTMLdetails_template.html/code/templates/


5. システムの実行方法 

5.1. 事前準備 

1.必要なライブラリをインストールする.

2.(任意・初回のみ)ユーザー設定ファイルを作成する.

5.2. 目的別の実行フロー 

本システムには、大きく分けて2つの実行モードがある。

【A】シミュレーション実験を実行する場合(開発者・研究者向け)

【B】献立推薦システムを実際に利用する場合(一般ユーザー向け)

6. 使用アルゴリズムの理論的背景 

本システムは、目的を達成するために、大きく分けて2つのアルゴリズムを中核技術として利用している。

6.1. 強化学習:多腕バンディット問題とUCB1アルゴリズム 

- 目的と課題:活用と探索のジレンマ ユーザーの好みを学習する過程は、「多腕バンディット問題」としてモデル化できる。これは、スロットマシン(バンディット)が複数台並んでいる状況で、限られた回数しかレバーを引けない中、どの台をどの順番で引けば最終的な報酬を最大化できるか、という問題である。

この問題の核心には、「活用と探索のジレンマ」が存在する。

「活用」ばかりでは、今一番良いと思っている腕が実は最善ではなかった場合に、それ以上の成果は望めない。「探索」ばかりでは、過去の成功体験を活かせず、非効率的になる。このトレードオフを適切に管理することが、優れた学習アルゴリズムの条件となる。

スクリーンショット 2025-07-01 124546.png

6.2. 多目的最適化:NSGA-IIとパレート最適解 

- 目的と課題:トレードオフを持つ複数の目的 献立作成は、単一の目的だけでは評価できない複雑な問題である。本研究では、以下の3つの目的を同時に最適化する必要がある。

これらの目的は互いに「トレードオフ」の関係にある。例えば、コストを極端に下げようとすると、使える食材が限られUXスコアが下がる可能性がある。このような問題では、全ての目的で最良となる「唯一の完璧な解」は通常存在しない。

- 解の概念:パレート最適 このような多目的最適化問題の解として、「パレート最適」という概念を用いる。 ある解Aが別の解Bに対して、全ての目的において同等以上、かつ、少なくとも一つの目的で明確に優れている場合、「AはBを支配(dominate)する」と定義される。 そして、「どの解にも支配されていない、優秀な解」の集合が**「パレート最適解(またはパレートフロント)」**となる。

これは、「これ以上どれかの目的を改善しようとすると、他の目的が必ず悪化してしまう」という、トレードオフの限界線上にある、甲乙つけがたい優れた解の集まりである。この解集合を求めることが、多目的最適化のゴールとなる。

- 解法:NSGA-II (Non-dominated Sorting Genetic Algorithm II) NSGA-IIは、このパレート最適解を効率的に探索するための、遺伝的アルゴリズムの一種である。生物の進化を模倣したアルゴリズムであり、以下の手順で解を探索する。 1. 初期集団生成: ランダムな解(献立の組み合わせ)を多数生成する。 2. 評価・ソート: 各解を目的関数で評価し、パレート最適の概念に基づいて「支配されていないランク」を決定する。また、解の密集度も計算する。 3. 選択: 評価の高い解(より優れたランクに属し、かつ、周りに他の解が少ない多様な解)が、次の世代に生き残りやすいように選択する。 4. 交叉: 生き残った優秀な解(親)を2つ選び、それらの特徴を組み合わせて新しい解(子)を作る。 5. 突然変異: 新しく作られた解の一部をランダムに変更し、解の多様性を維持する。 6. 上記の2~5を規定の世代数繰り返し、解集団全体を徐々に真のパレートフロントへと進化させていく。

7. 主要プログラムの詳細解説 

7.1. `2献立作成(GraphicalRecipes).py` 

7.2. `server1(GraphicalRecipes).py` 

        # 4. レシピごとの詳細なアンケート結果を別途保存
        responses = {}
        for key, value in form_data.items():
            if key.startswith('q'):
                # ... (キーを 'qX' と 'レシピ番号' に分割) ...
                responses[recipe_num][q_key] = value
        
        # ... (中略) ...
        
        # 最終的に整形されたデータを別ファイルに保存
        df_final_data.to_csv('cdijnklmn_extracted_with_headers.csv', mode='a', header=False, index=False, encoding='cp932')

        return "<h3>アンケートへのご協力、ありがとうございました!</h3>"
    
    except Exception as e:
        return f"サーバー内部でエラーが発生しました: {e}", 500

7.3. `run_experiment.py` 

    # 2. 生成された献立候補から1つをランダムに選択
    details_path = os.path.join('static', 'all_details.json')
    try:
        with open(details_path, 'r', encoding='utf-8') as f:
            all_candidates = json.load(f)
        
        if not all_candidates:
            print("警告: 献立候補が生成されませんでした。この試行をスキップします。")
            return True
            
        selected_candidate = random.choice(all_candidates)
        
        all_recipes_in_menu = []
        for day in selected_candidate.get('day_recipes', []):
            for meal in day.get('meals', []):
                all_recipes_in_menu.extend(meal.get('recipes', []))

    except (FileNotFoundError, json.JSONDecodeError, IndexError) as e:
        print(f"エラー: 生成された詳細ファイルの読み込みに失敗しました。 - {e}")
        return False
    # 3. 仮想ユーザーが評価し、報酬を計算
    try:
        with open('last_chosen_arm.txt', 'r', encoding='utf-8') as f:
            chosen_arm = int(f.read().strip())
    except (FileNotFoundError, ValueError) as e:
        print(f"エラー: last_chosen_arm.txt の読み込みに失敗しました。 - {e}")
        return False
        
    reward = get_satisfaction(chosen_arm, all_recipes_in_menu)
    # 4. 報酬データをmab_feedback.csvに追記
    timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    file_exists = os.path.exists(MAB_FEEDBACK_FILE)

    try:
        with open(MAB_FEEDBACK_FILE, 'a', newline='', encoding='utf-8') as f:
            writer = csv.writer(f)
            if not file_exists:
                writer.writerow(['timestamp', 'chosen_arm', 'reward'])
            writer.writerow([timestamp, chosen_arm, reward])
        print(f"フィードバックを記録しました: arm={chosen_arm}, reward={reward}")
    except IOError as e:
        print(f"エラー: {MAB_FEEDBACK_FILE} への書き込みに失敗しました。 - {e}")
        return False
        
    return True

7.4. `bandit_logic.py` 

    total_plays = len(df)
    arm_stats = []
    for i in range(N_ARMS):
        arm_df = df[df['chosen_arm'] == i]
        plays = len(arm_df)
        if plays == 0:
            return i
        normalized_rewards = (arm_df['reward'] - 1) / 4.0
        avg_reward = normalized_rewards.mean()
        arm_stats.append({'plays': plays, 'avg_reward': avg_reward})
    ucb_scores = []
    for i in range(N_ARMS):
        exploration_bonus = np.sqrt((2 * np.log(total_plays)) / arm_stats[i]['plays'])
        score = arm_stats[i]['avg_reward'] + exploration_bonus
        ucb_scores.append(score)

    return np.argmax(ucb_scores)

7.5. `virtual_user.py` 

    total_score = 0
    num_recipes = len(selected_menu_details)

    if num_recipes == 0:
        return 1 # レシピがなければ最低評価

    # 献立に含まれる全レシピのUXスコアを、真の好みで重み付けして合計
    for recipe in selected_menu_details:
        total_score += true_weights[0] * recipe.get('q1_score', 0)
        total_score += true_weights[1] * recipe.get('q2_score', 0)
        total_score += true_weights[2] * recipe.get('q3_score', 0)
        total_score += true_weights[3] * recipe.get('q4_score', 0)
    avg_score = total_score / num_recipes
    
    reward = round(avg_score * 4) + 1
    reward = max(1, min(5, int(reward)))
    
    print(f"仮想ユーザー評価: 平均スコア={avg_score:.3f} -> 報酬={reward}")
    return reward

# --- 設定項目 --- MAB_FEEDBACK_FILE = 'mab_feedback.csv' N_ARMS = 4 # 腕の数(UX指標の数 q1, q2, q3, q4)

def choose_arm():

   """
   多腕バンディット問題(UCB1アルゴリズム)に基づき、次に選択すべき腕を決定する関数。
   :return: 選択された腕のインデックス (0, 1, 2, or 3)
   """
   # --- 1. 学習データの読み込み ---
   if not os.path.exists(MAB_FEEDBACK_FILE):
       # まだフィードバックが一件もない場合、ランダムに腕を選択
       print("MABフィードバックファイルが存在しないため、ランダムに腕を選択します。")
       return np.random.randint(N_ARMS)
       
   df = pd.read_csv(MAB_FEEDBACK_FILE)
   if df.empty:
       # ファイルは存在するが中身が空の場合も、ランダムに腕を選択
       print("MABフィードバックが空のため、ランダムに腕を選択します。")
       return np.random.randint(N_ARMS)
   # --- 2. 各腕の成績を計算 ---
   total_plays = len(df)
   arm_stats = []
   for i in range(N_ARMS):
       arm_df = df[df['chosen_arm'] == i]
       plays = len(arm_df)
       
       if plays == 0:
           # まだ一度も選ばれたことがない腕があれば、それを最優先で選択(探索)
           print(f"腕{i}が未選択のため、優先的に選択します。")
           return i
       
       # 報酬を0-1の範囲に正規化 (元の報酬は1-5なので、(x-1)/4で計算)
       normalized_rewards = (arm_df['reward'] - 1) / 4.0
       avg_reward = normalized_rewards.mean()
       
       arm_stats.append({'plays': plays, 'avg_reward': avg_reward})
   # --- 3. UCB1スコアの計算と腕の選択 ---
   ucb_scores = []
   for i in range(N_ARMS):
       # UCB1スコア = 平均報酬 + √(2 * log(全試行回数) / この腕の試行回数)
       exploration_bonus = np.sqrt((2 * np.log(total_plays)) / arm_stats[i]['plays'])
       score = arm_stats[i]['avg_reward'] + exploration_bonus
       ucb_scores.append(score)
       print(f"腕{i}: 平均報酬={arm_stats[i]['avg_reward']:.3f}, UCBスコア={score:.3f}")
   # 最もスコアが高い腕を選択
   chosen_arm = np.argmax(ucb_scores)
   print(f"UCBスコアが最も高い腕 {chosen_arm} を選択しました。")
   
   return chosen_arm

# このファイルが直接実行された場合のテスト用コード if __name__ == '__main__':

   # mab_feedback.csv が存在すれば、それに基づいて腕を選択
   # 存在しなければ、ランダムに選択される
   print("\n--- バンディットアルゴリズムのテスト実行 ---")
   selected_arm = choose_arm()
   print(f"\nテスト結果: 次に選択すべき腕は {selected_arm} です。")

7.6. ユーザー設定ファイル (`menu_creation_settings.json`) 

7.7. Web UI関連ファイル 

8. 実験設定と結果 

構築した献立推薦システムの有効性を検証するため、特定の嗜好を持つ仮想ユーザーを対象としたシミュレーション実験を行った。

8.1. 実験設定 

本実験の目的は、構築したシステムが、特定の好みを持つ仮想ユーザーの嗜好を正しく学習し、最適な提案戦略に収束するかを検証することである。

- 仮想ユーザーの嗜好設定 評価者として、「調理しやすさ(腕3)」を特に重視する仮想ユーザー(`virtual_user.py`)を設定した。各UX指標に対する内部的な好みの重み付けは、以下の通りである。

- 学習アルゴリズム 提案戦略を学習するエージェントとして、UCB1バンディットアルゴリズム(`bandit_logic.py`)を用いた。

- 実行環境 シミュレーションは`run_experiment.py`によって制御され、総試行回数は200回および500回でそれぞれ実施した。

8.2. 実験結果 (総試行回数: 500回) 

500回のシミュレーションを実行した最終的な各腕の選択回数と割合を以下に示す。

final_arm_selection_table500.png
final_arm_selection_chart500.png

- 集計データ (500回)

腕(戦略)選択回数割合
:---:---:---
腕0 (q1: 入手のしやすさ)97回19.4%
腕1 (q2: 意外性)74回14.8%
腕2 (q3: 時間帯)66回13.2%
腕3 (q4: 調理しやすさ)263回52.6%

8.3. 考察と比較 

- 学習の成功 まず、200回のシミュレーション結果を以下に示す。

final_arm_selection_table200.png
final_arm_selection_chart200.png

この時点でも、システムは仮想ユーザーの最も重要な好みである「調理しやすさ(腕3)」を最適戦略として学習し、最も多く選択(42.0%)している。このことから、システムの基本的な学習能力は200回の試行で十分に確認できる。

- 試行回数の増加による学習の収束 次に、試行回数を500回に増やした結果と比較する。

腕(戦略)200回時点の選択率500回時点の選択率変化
:---:---:---:---
腕0 (q1)22.5%19.4%-3.1%
腕1 (q2)19.0%14.8%-4.2%
腕2 (q3)16.5%13.2%-3.3%
腕3 (q4)42.0%52.6%+10.6%

試行回数の増加に伴い、最適でない腕(0, 1, 2)の選択率は満遍なく減少し、最適である腕3の選択率が42.0%から52.6%へと大幅に上昇した。これは、アルゴリズムが学習を重ねることで「腕3が最善である」という確信度を高め、不必要な「探索」の割合を減らして、より効率的に「活用」へとシフトしていることを示している。

- 結論 以上の比較から、構築したシステムは、ユーザーの嗜好を正しく学習する能力を持つだけでなく、**十分な試行回数(フィードバック)を与えることで、最適な戦略へとより強く収束していく**ことが実証された。

9. 質疑応答とデバッグの過程 

本システムの開発においては、いくつかの問題の発見と、それに対する修正・分析を経て、最終的な完成に至った。主要な質疑応答の過程を以下に記録する。

Q1. シミュレーションの自動実行が、GUI表示によって停止してしまう。 

Q2. 学習が進まない。平均報酬が常に0.000のまま変動しない。 

Q3. 腕が0, 1, 2, 3,...と順番に選ばれているように見えるが、正常か? 

Q4. 平均報酬の値が途中で変動しなくなったり、UCBスコアが全体的に下がっていくのはなぜか? 

Q5. システムは本当にパーソナライズされているのか?UXスコアが事前に入っている前提で良いのか? 


トップ   新規 一覧 検索 最終更新   ヘルプ   最終更新のRSS