Gemmaを使った日本語対応の食事プラン作成アプリ

# パーソナライズ栄養管理アプリ

個人の健康情報にもとづいて食事プランを提案するアプリです！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/facfd2fb-e380-64ea-9fe7-2a3d165bf10c.png)

![無題の動画 ‐ Clipchampで作成.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/75911000-f1b5-56d2-c110-000111f36772.gif)

開発の軸としては、 **日本語対応した Gemma のモデル**を使うことを主に置いています。

https://developers-jp.googleblog.com/2024/10/gemma-2-for-japan.html

:::note alert
栄養に関する計算方法はパッと使えるものを持ってきました。RAGなどの検索機能も使っていないです🤦‍♂️
:::

---

## 機能の概要

このアプリは、4つの機能があります。
1. **ユーザー情報の登録**  
   年齢、性別、体重、身長、活動レベル、目標を入力して、個人の健康データを管理  
   
2. **PFC バランス表示**  
   BMIや基礎代謝量から、必要なタンパク質、脂質、炭水化物の量を計算  
   
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/5cc99640-4879-2907-ef2d-3142dde48615.png)
   
3. **栄養計算**  
   入力した食材の栄養データの生成  

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/b8b1a54e-4265-f51f-3336-45d53fa4b8bf.png)
   
4. **食事プラン**  
   個人の目標と好みに応じたダイエットプランを自動生成

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/68e84dfc-b694-064f-30d0-c8700b17631d.png)


---

## 準備

プロジェクトの構成はこんな感じです。

```bash
app
│  app.py
│  helper.py
│  config.py
│  style.css
│  requirements.txt
└
```

### 必要なパッケージ
`streamlit` で実装するので以下をインストールします。
```text: requirements.txt
streamlit
hydralit-components
streamlit_option_menu
```

```python
import streamlit as st
import hydralit_components as hc
from streamlit_option_menu import option_menu
from config import *
from helper import *
import certifi
import requests
```

### Gemma モデルについて
**Hugging Face** にモデルが公開されています。日本語版は `jpn` と書かれています。

https://huggingface.co/google/gemma-2-2b-jpn-it

投稿時点ではデプロイ方法には Inference API (Inference Providers内) がないので **HF Inference Endpoints** を利用してエンドポイントを取得しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/19b4dd3f-f05d-5ee5-6aa6-38cee9da50fd.png)

:::note 
**Hugging Face と HF Inference Endpoints で API を利用する方法はこちらに書きました！**
:::

https://qiita.com/yutowac/items/ec6d3f99f86b02dc93b0

エンドポイントを取得できると以下のようになります。インスタンスは AWS を利用しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/7e6897e1-011b-4f8c-6ff0-3e8b6e349f44.png)

実装するときはシンプルに `requests` を使えばOKです🙆‍♂️

```python

import requests

API_URL = "https://YOUR_ENDPOINT.aws.endpoints.huggingface.cloud"
headers = {
	"Accept" : "application/json",
	"Authorization": "Bearer hf_XXXXX",
	"Content-Type": "application/json" 
}

def query(payload):
	response = requests.post(API_URL, headers=headers, json=payload)
	return response.json()

output = query({
	"inputs": "Can you please let us know more details about your ",
	"parameters": {
		"max_new_tokens": 150
	}
})
```

---

## 実装方法

### ユーザー情報の登録フォーム

<img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/9da1adc7-0fa3-92f3-3f36-b715bf1e9447.png">

サイドバーにフォームを配置し、年齢、性別、体重、身長、活動レベル、目標を入力・選択できるようにします。

<details><summary>ソースコード</summary>

```python
# サイドバー
with st.sidebar:
    with st.form("form"):
        st.header("ユーザー情報")
        col1, col2 = st.columns(2)
        with col1:
            st.header("年齢")
            st.header("性別")
            st.header("体重 (kg)")
            st.header("身長 (cm)")
            st.header("活動レベル")
            st.header("目標")
        with col2:
            age = st.number_input("年齢を入力してください", min_value=5, value=person_info["年齢"], step=1, label_visibility="collapsed")
            gender = option = st.selectbox("性別を選んでください", gender_list, label_visibility="collapsed")
            weight = st.number_input("体重を入力してください", value=person_info["体重"], min_value=20, step=1, label_visibility="collapsed")
            height = st.number_input("身長を入力してください", value=person_info["身長"], min_value=100, step=1, label_visibility="collapsed")
            activity_level = st.selectbox('活動レベルを選択してください', activity_level, help=activity_details, label_visibility="collapsed")
            goal = st.selectbox('目標を選択してください', goal_list, label_visibility="collapsed")

        c = st.columns((1,4,1))
        with c[1]:
            submitted = st.form_submit_button("登録", type="primary")
            person_info["年齢"] = age
            person_info["性別"] = gender
            person_info["体重"] = weight
            person_info["身長"] = height
            person_info["活動レベル"] = activity_level
            person_info["目標"] = goal
            
```

```python
activity_details = """
とても低い: 全く運動しない \n
やや低い: 週に1,2回程度の運動 \n
普通: 週に3-5回程度の運動 \n
やや高い: 週に6,7回程度の運動 \n
とても高い: ハードなエクササイズや肉体労働
"""

gender_list = ['男性', '女性', 'その他']

goal_list = ['筋肉増量', '体重減少', '維持']

activity_level = ["とても低い", "やや低い", "普通", "やや高い", "とても高い"]
```

</details>

特に工夫すべき点はありませんが、入力フォームは色々なアプリに使えるので気になる方はソースコードを見てください！

---

### BMIとPFCバランス計算
登録されたデータを元にBMIや基礎代謝量（BMR）を計算します。ここでは、[**ハリス・ベネディクトの式**](https://www.mhlw.go.jp/file/05-Shingikai-10901000-Kenkoukyoku-Soumuka/0000083871.pdf) という計算式を使っています。

```python
def calculate_bmi(person_info):
    bmi = person_info["体重"] / (person_info["身長"] / 100) ** 2
    if bmi < 18.5:
        bmi_class = "低体重"
    elif bmi < 25:
        bmi_class = "標準体重"
    elif bmi < 30:
        bmi_class = "やや肥満"
    else:
        bmi_class = "肥満"
    return bmi, bmi_class

def energy_calc(person_info):
    if person_info["性別"] == "男性":
        bmr = 88.362 + (13.397 * person_info["体重"]) + (4.799 * person_info["身長"] / 100) - (5.677 * person_info["年齢"])
    else:
        bmr = 447.593 + (9.247 * person_info["体重"]) + (3.100 * person_info["身長"] / 100) - (4.330 * person_info["年齢"])
    tdee = bmr * activity_level_multipliers[person_info["活動レベル"]]
    return bmr, tdee

def macro_perc(person_info, calories):
    if person_info["目標"].lower() == '体重減少':
        protein_percentage = 30
        fat_percentage = 25
    elif person_info["目標"].lower() == '維持':
        protein_percentage = 25
        fat_percentage = 30
    elif person_info["目標"].lower() == '筋肉増量':
        protein_percentage = 35
        fat_percentage = 20
    else:
        raise ValueError("無効な目標")

    carb_percentage = 100 - (protein_percentage + fat_percentage)

    protein = (protein_percentage / 100) * calories / 4
    fat = (fat_percentage / 100) * calories / 9
    carbs = (carb_percentage / 100) * calories / 4

    return {'タンパク質': protein, '脂質': fat, '炭水化物': carbs}
```

---

### 栄養計算
プロンプトは次の通りです。ポイントは上手くテーブルでデータを表示できるように誘導することです。

:::note warn
より本格的なものを目指す方は RAG などでデータベースを参照できると良いと思います
:::

```python
def diet(person_info):
  search = st.text_input("食べた物を入力してください！", placeholder="焼きそば")
  f = st.button("決定", type="primary")
  if f:
    with st.spinner(f"**{search}** について情報を探しています...."):
      prompt = f"""
      表形式で栄養データを生成してください。テキストではなくテーブルだけを返すようにします。
      対象は{search}についてです。
      テーブルは栄養データとして、以下の6つの特徴を必ず持ちます：
      1.量
      2.材料
      3.タンパク質
      4.脂質
      5.炭水化物
      6.カロリー
      """
      res = get_response(prompt)
      res = res[0]["generated_text"].split("\n\n ")[1]
      st.write(res)
```

`placeholder` や `spinner` を使うと見やすくなります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/21838c81-55f0-b818-afe2-aaeeec4bed0d.png)


---

### ダイエットプランの生成
こちらもプロンプトを紹介します。住んでいる国や食の好みは実験的に入れてみました。

```python
def plan(person_info):
  with st.form("食事プラン"):
    st.header("あなたの情報")
    cols = st.columns(3)
    with cols[0]:
      loc = st.selectbox('あなたの住んでいる国はどこですか？', ["日本", "日本以外の国"])
    with cols[1]:
      remarks = st.text_input("食事の好み", placeholder="例：野菜が好き、肉が嫌い")
    s = st.form_submit_button("食事プランをつくる", type="primary")
  if s:
    with st.spinner('プランを生成中です....'):
      bmi, bmi_class = calculate_bmi(person_info)
      bmr, tdee = energy_calc(person_info)

      prompt = f"""
      表形式で食事プランを生成してください。テキストではなくテーブルだけを返すようにします。
      対象者の性別は{person_info["性別"]}、{loc}に住む人で、BMRが {bmr} kcal、 BMIは{bmi}、1日の消費エネルギーは{tdee} kcalの人です。
      そして、食事プランのメニューに反映させるべき次の嗜好を持っています：{remarks}。
      目標は、{person_info["目標"]}です。
      テーブルは食事プランとして、以下の特徴を必ず持ちます：
      1.食事時間（朝食、昼食、夕食の3回）
      2.メニュー
      3.材料
      4.栄養素(g)
      5.カロリー(kcal)
      """
      res = get_response(prompt)
      res = res[0]["generated_text"].split("```")[1]
      st.write(res)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/ae9f11f2-7bb3-9ddb-e872-ae539bcab16f.png)


---

## まとめ

食事プランの提案に関してはあまり実用的ではなかったですが、Gemma モデルとしては問題なく日本語でのやり取りができました👍

**ソースコード全体**

```python: app.py

import streamlit as st
import hydralit_components as hc
from config import *
from helper import *
from streamlit_option_menu import option_menu
import warnings
import requests


headers = {
   "Accept" : "application/json",
   "Authorization": f"Bearer {HF_TOKEN}",
   "Content-Type": "application/json",
}

warnings.filterwarnings('ignore')

# ページセットアップ
st.set_page_config(
  page_title=PAGE_TITLE,
  layout='wide'
)

def query(payload):
	response = requests.post(API_URL, headers=headers, json=payload, verify=False)
	return response.json()

def get_response(prompt):
    res = query({
      "inputs": prompt,
      "parameters": {
        "max_new_tokens": 500
      }
    })
    print(res)
    return res

# CSS
local_css("style.css")

# サイドバー
with st.sidebar:
    with st.form("form"):
        st.header("ユーザー情報")
        col1, col2 = st.columns(2)
        with col1:
            st.header("年齢")
            st.header("性別")
            st.header("体重 (kg)")
            st.header("身長 (cm)")
            st.header("活動レベル")
            st.header("目標")
        with col2:
            age = st.number_input("年齢を入力してください", min_value=5, value=person_info["年齢"], step=1, label_visibility="collapsed")
            gender = option = st.selectbox("性別を選んでください", gender_list, label_visibility="collapsed")
            weight = st.number_input("体重を入力してください", value=person_info["体重"], min_value=20, step=1, label_visibility="collapsed")
            height = st.number_input("身長を入力してください", value=person_info["身長"], min_value=100, step=1, label_visibility="collapsed")
            activity_level = st.selectbox('活動レベルを選択してください', activity_level, help=activity_details, label_visibility="collapsed")
            goal = st.selectbox('目標を選択してください', goal_list, label_visibility="collapsed")

        c = st.columns((1,4,1))
        with c[1]:
            submitted = st.form_submit_button("登録", type="primary")
            person_info["年齢"] = age
            person_info["性別"] = gender
            person_info["体重"] = weight
            person_info["身長"] = height
            person_info["活動レベル"] = activity_level
            person_info["目標"] = goal

# メインコンテンツ
def home(person_info):
    bmi, bmi_class = calculate_bmi(person_info)
    bmr, tdee = energy_calc(person_info)
    macros_req = macro_perc(person_info, tdee)
    hc_theme = {'bgcolor': '#f9f9f9','title_color': 'orange','content_color': 'orange','icon_color': 'orange', 'icon': 'fa fa-question-circle'}

    cols = st.columns(3)
    with cols[0]:
      if bmi_class == "標準体重":
        hc.info_card(title='BMI', content=f'{round(bmi, 2)}' + " kcal", sentiment='good')
      else:
        hc.info_card(title='BMI', content=f'{round(bmi, 2)}', sentiment='bad')
    with cols[1]:
      hc.info_card(title='基礎代謝', content=f'{round(bmr, 2)} kcal',theme_override=hc_theme)

    st.header("PFCバランス")
    cols = st.columns(3)
    theme_neutral = {'bgcolor': '#EFF8F7','title_color': 'green','content_color': 'green','icon_color': 'green', 'icon': 'fa fa-check-circle'}
    with cols[0]:
      hc.info_card(title='タンパク質', content=f'{round(macros_req["タンパク質"], 2)} g', theme_override=theme_neutral)
    with cols[1]:
      hc.info_card(title='脂質', content=f'{round(macros_req["脂質"], 2)} g',theme_override=theme_neutral)
    with cols[2]:
      hc.info_card(title='炭水化物', content=f'{round(macros_req["炭水化物"], 2)} g',theme_override=theme_neutral)

def diet(person_info):
  search = st.text_input("食べた物を入力してください！", placeholder="焼きそば")
  f = st.button("決定", type="primary")
  if f:
    with st.spinner(f"**{search}** について情報を探しています...."):
      prompt = f"""
      表形式で栄養データを生成してください。テキストではなくテーブルだけを返すようにします。
      対象は{search}についてです。
      テーブルは栄養データとして、以下の6つの特徴を必ず持ちます：
      1.量
      2.材料
      3.タンパク質
      4.脂質
      5.炭水化物
      6.カロリー
      """
      res = get_response(prompt)
      res = res[0]["generated_text"].split("\n\n ")[1]
      st.write(res)

def plan(person_info):
  with st.form("食事プラン"):
    st.header("あなたの情報")
    cols = st.columns(3)
    with cols[0]:
      loc = st.selectbox('あなたの住んでいる国はどこですか？', ["日本", "日本以外の国"])
    with cols[1]:
      remarks = st.text_input("食事の好み", placeholder="例：野菜が好き、肉が嫌い")
    s = st.form_submit_button("食事プランをつくる", type="primary")
  if s:
    with st.spinner('プランを生成中です....'):
      bmi, bmi_class = calculate_bmi(person_info)
      bmr, tdee = energy_calc(person_info)

      prompt = f"""
      表形式で食事プランを生成してください。テキストではなくテーブルだけを返すようにします。
      対象者の性別は{person_info["性別"]}、{loc}に住む人で、BMRが {bmr} kcal、 BMIは{bmi}、1日の消費エネルギーは{tdee} kcalの人です。
      そして、食事プランのメニューに反映させるべき次の嗜好を持っています：{remarks}。
      目標は、{person_info["目標"]}です。
      テーブルは食事プランとして、以下の特徴を必ず持ちます：
      1.食事時間（朝食、昼食、夕食の3回）
      2.メニュー
      3.材料
      4.栄養素(g)
      5.カロリー(kcal)
      """
      res = get_response(prompt)
      res = res[0]["generated_text"].split("```")[1]
      st.write(res)


selected = option_menu(
    menu_title=None,
    options=["ホーム", "栄養計算", "食事プラン"],
    icons=["house", "calculator", "envelope"],
    menu_icon="cast",
    default_index=0,
    orientation="horizontal",
)

# 画面遷移
if selected == "ホーム":
  home(person_info)
elif selected == "栄養計算":
  diet(person_info)
elif selected == "食事プラン":
  plan(person_info)
else:
  st.error("あなたの情報を登録してください")

```

```python: helper.py
from config import *

# BMI判定
def calculate_bmi(person_info):
    bmi = person_info["体重"] / (person_info["身長"] / 100) ** 2

    if bmi < 18.5:
        bmi_class = "低体重"
    elif bmi < 25:
        bmi_class = "標準体重"
    elif bmi < 30:
        bmi_class = "やや肥満"
    else:
        bmi_class = "肥満"
    return bmi, bmi_class

# カロリー計算
def energy_calc(person_info):
    if person_info["性別"] == "男性":
        bmr = 88.362 + (13.397 * person_info["体重"]) + (4.799 * person_info["身長"] / 100) - (5.677 * person_info["年齢"])
    else:
        bmr = 447.593 + (9.247 * person_info["体重"]) + (3.100 * person_info["身長"] / 100) - (4.330 * person_info["年齢"])
    tdee = bmr * activity_level_multipliers[person_info["活動レベル"]]
    return bmr, tdee

# PFC計算
def macro_perc(person_info, calories):
    if person_info["目標"].lower() == '体重減少':
        protein_percentage = 30
        fat_percentage = 25
    elif person_info["目標"].lower() == '維持':
        protein_percentage = 25
        fat_percentage = 30
    elif person_info["目標"].lower() == '筋肉増量':
        protein_percentage = 35
        fat_percentage = 20
    else:
        raise ValueError("無効な目標")

    carb_percentage = 100 - (protein_percentage + fat_percentage)

    protein = (protein_percentage / 100) * calories / 4
    fat = (fat_percentage / 100) * calories / 9
    carbs = (carb_percentage / 100) * calories / 4

    return {'タンパク質': protein, '脂質': fat, '炭水化物': carbs}

# CSS読み込み
def local_css(file_name):
    with open(file_name) as f:
        st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
```

```python: config.py
PAGE_TITLE = "ダイエット支援LLMアプリ"

activity_level_multipliers = {
    "とても低い": 1.2,
    "やや低い": 1.375,
    "普通": 1.55,
    "やや高い": 1.725,
    "とても高い": 1.9,
}

activity_details = """
とても低い: 全く運動しない \n
やや低い: 週に1,2回程度の運動 \n
普通: 週に3-5回程度の運動 \n
やや高い: 週に6,7回程度の運動 \n
とても高い: ハードなエクササイズや肉体労働
"""

gender_list = ['男性', '女性', 'その他']

goal_list = ['筋肉増量', '体重減少', '維持']

activity_level = ["とても低い", "やや低い", "普通", "やや高い", "とても高い"]

# Define the macronutrient percentages
macronutrient_percentages = {
    "炭水化物": (45, 65),
    "タンパク質": (10, 35),
    "脂質": (20, 35),
}

# Set the person's information
person_info = {
    "年齢": 25,
    "性別": "Male",
    "身長": 165,
    "体重": 70,
    "活動レベル": "普通",
    "目標": "体重減少"
}

API_URL = "https://YOUR_ENDPOINT.endpoints.huggingface.cloud"
HF_TOKEN = "hf_XXXX"

```

</details>

---