食に対する個人の嗜好は、「珍しいものが食べたい」「調理が簡単なものが良い」など、非常に多様かつ動的である。従来の推薦システムは、栄養バランスやコストといった静的な指標に基づいて献立を提案するものが主であった。本研究では、ユーザーからのフィードバック(満足度評価)を元に、強化学習(多腕バンディットアルゴリズム)を用いて「ユーザーがどのようなタイプの献立を好むか」という提案戦略を学習し、その戦略に基づいて多目的最適化(遺伝的アルゴリズム)が献立を生成する、パーソナライズされた動的な献立推薦システムのコアエンジンを構築・検証することを目的とする。
本システムは、強化学習エージェント(バンディット)と多目的最適化エンジン(遺伝的アルゴリズム)を連携させた、クローズドループのシミュレーション環境である。全体の処理フローは以下の通り。
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を規定回数繰り返し、学習を行う。
現在のシステムは、`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`は、そのユーザーの評価(報酬)を、「どの戦略で提案したか」という情報と紐づけて、そのユーザーの学習データとして記録する。
| 扱うデータ | 用途 | ファイル名 | ファイルの場所 |
| システム制御 | シミュレーション全体の制御、各モジュールの呼び出し、結果の記録 | 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グラフを表示するメインページのHTML | graph_viewer.html | /code/templates/ |
| Webページ用テンプレート | 献立詳細と評価フォームを表示するHTML | details_template.html | /code/templates/ |
1.必要なライブラリをインストールする.
2.(任意・初回のみ)ユーザー設定ファイルを作成する.
本システムには、大きく分けて2つの実行モードがある。
【A】シミュレーション実験を実行する場合(開発者・研究者向け)
【B】献立推薦システムを実際に利用する場合(一般ユーザー向け)
本システムは、目的を達成するために、大きく分けて2つのアルゴリズムを中核技術として利用している。
- 目的と課題:活用と探索のジレンマ ユーザーの好みを学習する過程は、「多腕バンディット問題」としてモデル化できる。これは、スロットマシン(バンディット)が複数台並んでいる状況で、限られた回数しかレバーを引けない中、どの台をどの順番で引けば最終的な報酬を最大化できるか、という問題である。
この問題の核心には、「活用と探索のジレンマ」が存在する。
「活用」ばかりでは、今一番良いと思っている腕が実は最善ではなかった場合に、それ以上の成果は望めない。「探索」ばかりでは、過去の成功体験を活かせず、非効率的になる。このトレードオフを適切に管理することが、優れた学習アルゴリズムの条件となる。
- 目的と課題:トレードオフを持つ複数の目的 献立作成は、単一の目的だけでは評価できない複雑な問題である。本研究では、以下の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を規定の世代数繰り返し、解集団全体を徐々に真のパレートフロントへと進化させていく。
import pandas as pd import numpy as np import PySimpleGUI as sg from pymoo.algorithms.moo.nsga2 import NSGA2 from pymoo.optimize import minimize from pymoo.core.problem import ElementwiseProblem from pymoo.core.crossover import Crossover from pymoo.core.mutation import Mutation from pymoo.core.sampling import Sampling from bandit_logic import choose_arm import json import sys import os # 他、標準ライブラリ
SETTINGS_FILE = 'menu_creation_settings.json'
def load_settings():
# ... (実装省略) ...
def save_settings(data):
# ... (実装省略) ...is_auto_mode = '--auto' in sys.argv
else:
print("--- 手動設定モードで起動しました ---")
settings = load_settings()
sg.theme('DarkAmber')
# 人数入力ウィンドウ
layout1 = [[...]]
window = sg.Window('入力画面', layout1)
# ... (window.read()によるイベントループ) ...
window.close()
# ユーザーごとの情報入力ループ
for i in range(ninzu):
# 個人情報入力ウィンドウ
layout2 = [[...]]
window_person = sg.Window(...)
# ... (イベントループ) ...
window_person.close()
# アレルギー入力ウィンドウ
layout3 = [[...]]
window_allergy = sg.Window(...)
# ... (イベントループ) ...
window_allergy.close()
# 病気入力ウィンドウ
layout4 = [[...]]
window_disease = sg.Window(...)
# ... (イベントループ) ...
window_disease.close()
# 日数入力ウィンドウ
layout5 = [[...]]
window = sg.Window(...)
# ... (イベントループ) ...
window.close()
save_settings(settings_to_save)print("レシピデータを作成します")
# ... (空のリストを初期化) ...
for j in range(R_orig):
try:
df = pd.read_csv(f"./data/hyouka/recipe_no{j+1}.csv", encoding="cp932")
# ... (dfから必要な情報を抽出し、各リストに格納) ...
q1_scores[j] = float(df.iloc[1, 14])
# ... (q2, q3, q4も同様) ...
except Exception:
continue
df_recipe = pd.DataFrame(recipe_details_list)
# ... (アレルギー・病気情報に基づき、df_recipeから不要なレシピを削除) ...print("\n--- 強化学習エージェントによる戦略決定を開始 ---")
chosen_arm = choose_arm()
try:
with open('last_chosen_arm.txt', 'w', encoding='utf-8') as f:
f.write(str(chosen_arm))
print(f"選択した腕の情報 ({chosen_arm}) を last_chosen_arm.txt に保存しました。")
except IOError as e:
print(f"エラー: last_chosen_arm.txt への書き込みに失敗しました - {e}")class SubsetProblem(ElementwiseProblem):
def __init__(self, cost, time, q1, q2, q3, q4, chosen_arm, n_max, cal, f0, f1, f2, day, eer, tanpakumin, sisitumin, tansuimin, **kwargs):
super().__init__(n_var=len(cost), n_obj=3, n_constr=5, **kwargs)
# ...
def _evaluate(self, x, out, *args, **kwargs):
# ...n_max = 7 * day
problem = SubsetProblem(...)
algorithm = NSGA2(...)
res = minimize(problem, algorithm, ('n_gen', 100), seed=1, verbose=False)
# ... (resオブジェクトからパレート解を抽出し、JSONファイルに整形・保存する処理) ...
all_candidates_details = []
for idx, p_indices in enumerate(parate):
# ...
meal_recipes.append({
"original_number": original_idx + 1,
# ...
"q1_score": q1_scores[recipe_index],
"q2_score": q2_scores[recipe_index],
"q3_score": q3_scores[recipe_index],
"q4_score": q4_scores[recipe_index],
# ...
})
# ...
with open('static/all_details.json', 'w', encoding='utf-8') as f:
json.dump(all_candidates_details, f, ensure_ascii=False, indent=4, cls=CustomJSONEncoder)from flask import Flask, render_template, request, json, session import pandas as pd import os import csv from datetime import datetime app = Flask(__name__) app.secret_key = 'your_secret_key' # セッション管理のための秘密鍵
@app.route('/')
def index():
return render_template('graph_viewer.html')@app.route('/details')
def details():
candidate_id = request.args.get('id', type=int)
details_path = os.path.join('static', 'all_details.json')
try:
with open(details_path, 'r', encoding='utf-8') as f:
all_details = json.load(f)
except FileNotFoundError:
return "詳細データファイル(all_details.json)が見つかりません。", 404
candidate = next((item for item in all_details if item["id"] == candidate_id), None)
if candidate:
meal_names = ['朝食', '昼食1', '昼食2', '昼食3', '夕食1', '夕食2', '夕食3']
meals = {meal_names[i]: recipe for i, recipe in enumerate(candidate['day_recipes']) if i < len(meal_names)}
return render_template('details_template.html', candidate=candidate, meals=meals)
else:
return "指定されたIDの献立が見つかりません。", 404@app.route('/save_survey', methods=['POST'])
def save_survey():
try:
form_data = request.form
# 1. フォームから総合満足度(報酬)を取得
reward = form_data.get('overall_satisfaction')
# 2. どの戦略(腕)が使われたかをファイルから取得
try:
with open('last_chosen_arm.txt', 'r', encoding='utf-8') as f:
chosen_arm = f.read().strip()
except FileNotFoundError:
chosen_arm = -1
print("警告: last_chosen_arm.txt が見つかりませんでした。")
# 3. 報酬と腕の情報を mab_feedback.csv に追記
if reward and chosen_arm != -1:
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
mab_feedback_file = 'mab_feedback.csv'
file_exists = os.path.exists(mab_feedback_file)
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"MABフィードバックを保存しました: arm={chosen_arm}, reward={reward}") # 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
if __name__ == '__main__':
app.run(debug=True, port=5000)import subprocess import os import json import random import csv from datetime import datetime from virtual_user import get_satisfaction import pandas as pd # --- 実験設定 --- NUM_TRIALS = 200 # 実験の繰り返し回数 MAB_FEEDBACK_FILE = 'mab_feedback.csv'
def run_single_trial():
"""1回分の実験(献立作成→評価→学習データ追記)を実行する"""
# 1. 献立作成スクリプトを実行
print("\n--- 献立作成エンジンを実行中... ---")
try:
subprocess.run(['python', '2献立作成(GraphicalRecipes).py', '--auto'], check=True)
except subprocess.CalledProcessError as e:
print(f"エラー: 2献立作成(GraphicalRecipes).py の実行に失敗しました。 - {e}")
return False # 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
if __name__ == '__main__':
if os.path.exists(MAB_FEEDBACK_FILE):
os.remove(MAB_FEEDBACK_FILE)
print(f"古い {MAB_FEEDBACK_FILE} を削除し、実験を初期化しました。")
print(f"\n===== {NUM_TRIALS}回のシミュレーション実験を開始します =====")
for i in range(NUM_TRIALS):
print(f"\n---【 試行 {i + 1}/{NUM_TRIALS} 】---")
success = run_single_trial()
if not success:
print("\n重大なエラーが発生したため、実験を中止します。")
break
print(f"\n===== 実験終了 =====")
# (最終結果の集計処理) ...import pandas as pd import numpy as np import os MAB_FEEDBACK_FILE = 'mab_feedback.csv' N_ARMS = 4
def choose_arm():
if not os.path.exists(MAB_FEEDBACK_FILE):
return np.random.randint(N_ARMS)
df = pd.read_csv(MAB_FEEDBACK_FILE)
if df.empty:
return np.random.randint(N_ARMS) 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)
def get_satisfaction(chosen_arm, selected_menu_details):
# この仮想ユーザーの「真の好み」の重み
true_weights = {
0: 0.15, # q1: 入手のしやすさ
1: 0.05, # q2: 意外性
2: 0.10, # q3: 時間帯
3: 0.70 # q4: 調理しやすさ
} 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} です。")
{
"ninzu": "1",
"name_0": "るるる",
"age_0": "21",
"height_0": "170.0",
"weight_0": "60.0",
"gender_0": "男",
"actlevel_0": "高い",
"normal_0": false,
"egg_0": false,
"milk_0": true,
"rakkasei_0": false,
"ebi_0": false,
"komugi_0": false,
"kani_0": false,
"soba_0": false,
"syokuzai_0": "",
"normal2_0": true,
"tounyou_0": false,
"jinzou_0": false,
"sisituijou_0": false,
"kouketu_0": false,
"day": "5",
"timea": "30",
"timeb": "60",
"timec": "60"
}{
"nodes": [
{
"id": 0,
"label": "候補1",
"recipes": [ "ひまわりご飯", "エビと豆腐の煮物(かんたん)", ... ],
"values": { "cost": 10933.0, "time": 880.0, "ux_score_q1": 26.0391 }
},
{
"id": 1,
"label": "候補2",
"recipes": [ "ひまわりご飯", "かやくそば", ... ],
"values": { "cost": 9757.0, "time": 765.0, "ux_score_q1": 23.0723 }
}
],
"links": [
{ "from": 0, "to": 1, "value": 0.2962... },
{ "from": 0, "to": 2, "value": 0.3207... }
]
}<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>献立候補 3D関連グラフ</title>
<style>
/* ... (CSSによる見た目の定義) ... */
</style>
</head>
<body>
<div id="graph-container"></div>
<div class="info-panel top-left">
</div>
<div class="info-panel top-right" id="node-info-panel" style="display: none;">
</div>
<script src="//unpkg.com/3d-force-graph"></script>
<script src="https://d3js.org/d3.v6.min.js"></script>
<script>
// ... (JavaScriptによるグラフ描画・操作のロジック) ...
</script>
</body>
</html><!DOCTYPE html>
<html lang="ja">
<head>
</head>
<body>
<h1>{{ candidate.label }} の詳細</h1>
<form action="/save_survey" method="post">
{% for day in candidate.day_recipes %}
<h2>{{ day.day }}日目</h2>
{% for meal in day.meals %}
<h3>{{ meal.type }}</h3>
{% for recipe in meal.recipes %}
<div class="recipe-card">
<h4>{{ recipe.title }}</h4>
<div class="survey-section">
<h5>アンケート</h5>
<div class="question">
<p>1. 食材は入手しやすいものか</p>
<label><input type="radio" name="q1_{{ recipe.original_number }}" value="1" checked> Yes</label>
<label><input type="radio" name="q1_{{ recipe.original_number }}" value="0"> No</label>
</div>
</div>
</div>
{% endfor %}
{% endfor %}
{% endfor %}
<div>
<h4>この献立セットへの総合満足度 (1〜5)</h4>
<input type="number" name="overall_satisfaction" min="1" max="5" value="3" required>
</div>
<button type="submit">アンケートを保存</button>
</form>
</body>
</html>構築した献立推薦システムの有効性を検証するため、特定の嗜好を持つ仮想ユーザーを対象としたシミュレーション実験を行った。
本実験の目的は、構築したシステムが、特定の好みを持つ仮想ユーザーの嗜好を正しく学習し、最適な提案戦略に収束するかを検証することである。
- 仮想ユーザーの嗜好設定 評価者として、「調理しやすさ(腕3)」を特に重視する仮想ユーザー(`virtual_user.py`)を設定した。各UX指標に対する内部的な好みの重み付けは、以下の通りである。
- 学習アルゴリズム 提案戦略を学習するエージェントとして、UCB1バンディットアルゴリズム(`bandit_logic.py`)を用いた。
- 実行環境 シミュレーションは`run_experiment.py`によって制御され、総試行回数は200回および500回でそれぞれ実施した。
500回のシミュレーションを実行した最終的な各腕の選択回数と割合を以下に示す。
- 集計データ (500回)
| 腕(戦略) | 選択回数 | 割合 |
| :--- | :--- | :--- |
| 腕0 (q1: 入手のしやすさ) | 97回 | 19.4% |
| 腕1 (q2: 意外性) | 74回 | 14.8% |
| 腕2 (q3: 時間帯) | 66回 | 13.2% |
| 腕3 (q4: 調理しやすさ) | 263回 | 52.6% |
- 学習の成功 まず、200回のシミュレーション結果を以下に示す。
この時点でも、システムは仮想ユーザーの最も重要な好みである「調理しやすさ(腕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が最善である」という確信度を高め、不必要な「探索」の割合を減らして、より効率的に「活用」へとシフトしていることを示している。
- 結論 以上の比較から、構築したシステムは、ユーザーの嗜好を正しく学習する能力を持つだけでなく、**十分な試行回数(フィードバック)を与えることで、最適な戦略へとより強く収束していく**ことが実証された。
本システムの開発においては、いくつかの問題の発見と、それに対する修正・分析を経て、最終的な完成に至った。主要な質疑応答の過程を以下に記録する。