React-FastAPIによる栄養素最適化Webアプリ作成
はじめに
普段の業務では使ったことが無いreactと数理最適化の勉強+筋トレをする上で最適な栄養が知りたいという願望があったため、簡単な栄養素最適化webアプリを作成してみました。
アプリの概要として、一日の内に摂取したいPFC重量(Protein, Fat, Carbon)を指定すると、PFCを含む他栄養素も考慮された食材が出力されます。
アプリの構成として、フロントエンドにはwebアプリで定番のreact、バックエンドでは数理最適化ライブラリを使用するため、pythonを使用しています。
一日で作成したため、コード整理がされていない&アルゴリズムが不十分な部分もあるのですが、フルスタックでwebアプリを作成してみたい皆様の参考になれば幸いです。
また、使ってみたい方がいれば、デプロイして使えるようにしたいと思います。
アプリの概要
一日の内に摂取したいPFC重量(Protein, Fat, Carbon)を指定すると、PFCを含む他栄養素も考慮された食材が出力されます。
-
初期画面
-
条件設定
-
条件適用
-
結果表示
健康的な和食が作れそうな食材達が出力されました。うどん摂取量は少なすぎるので、制約条件に改善の余地がありそうです。実際の数理最適化案件でも、それらしい結果を得るために制約条件を調整することはありそうですね。また、条件によっては計算時間が1分程度かかるため、摂取したい食事項目をユーザーが限定できるようにして、実行速度を上げないとUXが悪いかもしれません。
環境
必要な言語やライブラリは適宜インストールしてください。
フロントエンド
npx create-next-appで作成
バックエンド
venvで作成
使用データ
文部科学省のデータを使用させて頂きました。
第2章(データ) (Excel:1.9MB)各食品毎の栄養素がエクセルにて記載されています。
これをjsonに変換して、databaseディレクトリに保存しました。
NUTRITION_TABLE_PATH = 'xxxxxxxxxxxxxxxxx'
NUTRITION_COLUMNS = [
'食品群',
'食品番号',
'索引番号',
'食品名',
'廃棄率(%)',
'エネルギー(kJ)',
'エネルギー(kcal)',
'水分(g)',
'アミノ酸組成によるたんぱく質(g)',
'たんぱく質(g)',
'脂肪酸のトリアシルグリセロール当量(g)',
'コレステロール(mg)',
'脂質(g)',
'利用可能炭水化物(単糖当量)(g)',
'謎',
'利用可能炭水化物(質量系)(g)',
'差引き法による利用可能炭水化物(質量系)(g)',
'謎2',
'食物繊維総量(g)',
'糖アルコール(g)',
'炭水化物(g)',
'有機酸(g)',
'灰分(g)',
'無機質_ナトリウム(mg)',
'無機質_カリウム(mg)',
'無機質_カルシウム(mg)',
'無機質_マグネシウム(mg)',
'無機質_リン(mg)',
'無機質_鉄(mg)',
'無機質_亜鉛(mg)',
'無機質_銅(mg)',
'無機質_マンガン(mg)',
'謎空白',
'無機質_ヨウ素(μg)',
'無機質_セレン(μg)',
'無機質_クロム(μg)',
'無機質_モリブデン(μg)',
'ビタミンA_レチノール(μg)',
'ビタミンA_αカロテン(μg)',
'ビタミンA_βカロテン(μg)',
'ビタミンA_βクリプトキサンチン(μg)',
'ビタミンA_βカロテン当量(μg)',
'ビタミンA_レチノール活性当量(μg)',
'ビタミンD(μg)',
'ビタミンE_αトコフェロール(mg)',
'ビタミンE_βトコフェロール(mg)',
'ビタミンE_γトコフェロール(mg)',
'ビタミンE_δトコフェロール(mg)',
'ビタミンK(μg)',
'ビタミンB1(mg)',
'ビタミンB2(mg)',
'ナイアシン(mg)',
'ナイアシン当量(mg)',
'ビタミンB6(mg)',
'ビタミンB12(μg)',
'葉酸(μg)',
'パントテン酸(mg)',
'ビオチン(μg)',
'ビタミンC(mg)',
'アルコール(g)',
'食塩相当量(g)',
'備考'
]
# 余分なヘッダーを消して、カラム名を日本語変換
df_nutirition = pd.read_excel(NUTRITION_TABLE_PATH, skiprows = 11)
df_nutirition = df_nutirition[11:].copy()
df_nutirition.columns = NUTRITION_COLUMNS
# 名寄せ
def to_numeric(df, columns):
for column in columns:
# 括弧を取り除く
df[column] = df[column].astype(str).str.replace('(', '', regex=False)
df[column] = df[column].astype(str).str.replace(')', '', regex=False)
# 文字列を数値型に変換
df[column] = pd.to_numeric(df[column], errors='coerce')
# NaNに変換されたエントリを0で埋める(必要に応じて)
df[column].fillna(0, inplace=True)
return df
MUST_NUTRIENTS = [
'たんぱく質(g)',
'脂質(g)',
'炭水化物(g)',
'食塩相当量(g)',
'エネルギー(kcal)',
'食物繊維総量(g)',
'ビタミンC(mg)',
'ビタミンD(μg)',
'無機質_鉄(mg)',
'無機質_カルシウム(mg)',
'アルコール(g)',
'コレステロール(mg)'
]
df_nutirition = to_numeric(df_nutirition, MUST_NUTRIENTS)
# 必要カラムの追加
df_must_nutrition = df_nutirition[['食品群','食品名']+MUST_NUTRIENTS].copy().reset_index(drop = True)
# jsonで保存
df_must_nutrition.to_json( '../database/df_must_nutrition.json'
)
アプリのアーキテクチャ
フロントエンドはtypescrypt, react, nodeで、バックエンドはFastAPIを使用しました。
また、数理最適化ライブラリにはpulpを使用しました。
ディレクトリ構成
以下概要です。
フロントエンドディレクトリにはtypescriptのコード、バックエンドディレクトリにはpythonのコードをまとめています。
各ディレクトリのusecase層に、一連の動作を記載しmain.pyやcomponentsから呼び出していますdomain等も分けようかと思いましたが簡易的なアプリのため、やめました。
また、databaseには今回は簡易的に栄養素のjsonファイルを置いています。
root/
├ backend/
├ usecase
└main.py
├ database/
└ frontend/
├ usecase
├ pages
└ components
実装
バックエンド
栄養素最適化API
pulpを使用して、スライダーによって入力された値をもとに、以下最適化を行っています。
制約条件
- 目的関数:脂質を最小化
- 栄養素制約:各栄養素の上下限を設定します。UI上のスライダーで選択される値が入ります。
- 食品群制約:指定食品群のカロリー設定(ここでは食品群3の例)
- 選択制約:(式1)yが0であれば、xも0に制限されyが1であればxが自由に選ばれるようにするための制約、(式2)ある食品が選ばれた場合に少なすぎ、多すぎにならないように上下限を設定している。
- 食品の総数制限:選択される食品の総数を制限しています
コード
from fastapi import APIRouter
router = APIRouter()
from fastapi.responses import JSONResponse
import pandas as pd
from pulp import LpMinimize, LpMaximize, LpProblem, LpVariable, lpSum, LpStatus
from pulp import PULP_CBC_CMD
@router.get("/")
async def optimal_food(
kcal_min_num: int,
kcal_max_num: int,
protein_min_num: int,
protein_max_num: int,
fat_min_num: int,
fat_max_num: int,
carbo_min_num: int,
carbo_max_num: int,
):
# 一日に摂取する栄養素の上限、下限を整理
NUTRIENT_LIMITS = {
"たんぱく質(g)": (100, 120),
"脂質(g)": (60, 80),
"炭水化物(g)": (270, 390),
"食塩相当量(g)": (0, 6),
"エネルギー(kcal)": (2500, 3000),
"食物繊維総量(g)": (25, 30),
"ビタミンC(mg)": (95, 105),
"ビタミンD(μg)": (15, 25),
"無機質_鉄(mg)": (7, 10),
"無機質_カルシウム(mg)": (700, 1000),
"アルコール(g)": (0, 5),
"コレステロール(mg)": (0, 200),
}
# 栄養素データを読み込み
df = pd.read_json(
"C:/Users/tokimitsu.kobayashi/Documents/webapp/meal_data/backend/processed_data/df_must_nutrition.json"
)
# 問題の定義(最小化)
prob = LpProblem("MinimizeNumberOfFoods", LpMinimize)
# 変数の定義
food_items = list(df["食品名"])
food_categories = list(df["食品群"].unique())
x = [LpVariable(f"{food}_amount", 0, None) for food in food_items] # 各食品を何グラム摂るか
y = [LpVariable(f"Use_{food}", 0, 1, "Binary") for food in food_items] # 各食品を摂るかどうか
# 目的関数(脂質を最小化)
prob += lpSum(df['脂質(g)'][i] * x[i] for i in range(len(food_items))), "Lowest_fat"
# 制約条件
M = 1000 # 大きな定数
NUTRIENT_LIMITS["エネルギー(kcal)"] = (kcal_min_num, kcal_max_num)
NUTRIENT_LIMITS["たんぱく質(g)"] = (protein_min_num, protein_max_num)
NUTRIENT_LIMITS["脂質(g)"] = (fat_min_num, fat_max_num)
NUTRIENT_LIMITS["炭水化物(g)"] = (carbo_min_num, carbo_max_num)
# 栄養素の制約を動的に追加
for nutrient in NUTRIENT_LIMITS.keys():
# 辞書から栄養素に対応する最小値と最大値を取得します。
min_nutrient, max_nutrient = NUTRIENT_LIMITS.get(nutrient, (None, None))
# 制約条件追加
prob += (
lpSum(df[nutrient][i] * x[i] for i in range(len(food_items)))
>= min_nutrient,
f"Min_{nutrient}",
)
prob += (
lpSum(df[nutrient][i] * x[i] for i in range(len(food_items)))
<= max_nutrient,
f"Max_{nutrient}",
)
# 食品群が3である食品(砂糖及び甘味料)のインデックスを取得
indices_for_group_3 = df.index[df["食品群"] == 3].tolist()
# 食品群が3の時のみカロリーに制約を追加
for i in indices_for_group_3:
prob += df["エネルギー(kcal)"][i] * x[i] <= 100, f"Group_3_Limit_{i}"
# 食品群が15(菓子類)である食品のインデックスを取得
indices_for_group_15 = df.index[df["食品群"] == 15].tolist()
# 食品群が15の時のみカロリーに制約を追加
for i in indices_for_group_15:
prob += df["エネルギー(kcal)"][i] * x[i] <= 200, f"Group_15_Limit_{i}"
# 食品群が16である食品のインデックスを取得
indices_for_group_16 = df.index[df["食品群"] == 16].tolist()
# 食品群が16(し好飲料類)の時のみカロリーに制約を追加
for i in indices_for_group_16:
prob += df["エネルギー(kcal)"][i] * x[i] <= 200, f"Group_16_Limit_{i}"
# 食品群が17(調味料及び香辛料類)である食品のインデックスを取得
indices_for_group_17 = df.index[df["食品群"] == 17].tolist()
# 食品群が17の時のみカロリーに制約を追加
for i in indices_for_group_17:
prob += df["エネルギー(kcal)"][i] * x[i] <= 200, f"Group_17_kcal_Limit_{i}"
prob += x[i] <= 0.5, f"Group_17_Limit_{i}"
# 選択制約を動的に追加
for i in range(len(food_items)):
prob += x[i] <= M * y[i], f"Selection_{food_items[i]}"
prob += x[i] <= 3, f"Limit_Amount_{food_items[i]}"
prob += x[i] >= 0.05 * y[i], f"Min_Limit_When_Used_{i}"
prob += (
lpSum(y[i] for i in range(len(food_items))) >= 7,
f"Limit_Amount_Y_Min_{food_items[i]}",
)
prob += (
lpSum(y[i] for i in range(len(food_items))) <= 10,
f"Limit_Amount_Y_Max_{food_items[i]}",
)
# 問題を解く
prob.solve(PULP_CBC_CMD(msg=True))
# 結果の表示
print("Status:", LpStatus[prob.status])
if LpStatus[prob.status] == "Infeasible":
return JSONResponse(content={"message": "条件が厳しすぎます!!!"}, status_code=400)
# 結果を新しい列としてデータフレームに追加
optimal_amounts = {
v.name: v.varValue for v in prob.variables() if "amount" in v.name
}
use_foods = {v.name: v.varValue for v in prob.variables() if "Use_" in v.name}
# 摂取量(g)
df["摂取量(g)"] = df.apply(
lambda row: optimal_amounts.get(f"{row['食品名']}_amount", 0), axis=1
)
df["摂取量(g)"] = df["摂取量(g)"] * 100
df["Use_Food"] = df.apply(lambda row: use_foods.get(f"Use_{row['食品名']}", 0), axis=1)
df_answer = df.loc[lambda df: df["Use_Food"] > 0].copy()
# 桁を揃える
df_answer[list(NUTRIENT_LIMITS.keys())] = df_answer[
list(NUTRIENT_LIMITS.keys())
].round(1)
df_answer["摂取量(g)"] = df_answer["摂取量(g)"].astype(int)
df_answer = df_answer[["食品名"] + list(NUTRIENT_LIMITS.keys()) + ["摂取量(g)"]]
data = df_answer.to_dict(orient="records")
columns = df_answer.columns.tolist()
return JSONResponse(content={"data": data, "columns": columns})
バックエンドのmain.py
main.pyで先ほど作成したAPIを読みだしています。
この実装方法により、各APIのコードを分離することができ、コードがクリーンになります。
つまづきポイントとして、フロントエンド-バックエンド間の通信を行うために、フロントエンドからバックエンドへのアクセス権を付与するCORS設定を忘れないようにする必要があります。
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from usecase.optimal_food import router as optimal_food_router
app = FastAPI()
# CORS設定を追加
origins = [
"http://localhost:3000", # フロントエンドのアドレス
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(optimal_food_router, prefix="")
フロントエンド
pagesからcomponentsを呼び出し、UIを作成しています。
尚、UIのスタイリングにはmaterial-uiを使用しました。
material-uiには必要なcomponentが揃っているため、実装は楽でした。
pages
最初に読み込まれるページをindex.tsxに実装する必要があります。
// pages/index.tsx
import React, { useState } from 'react';
import axios, { AxiosError } from 'axios';
import OptimalFoodFetchButton from '../components/optimal_food_button';
import OptimalFoodTable from '../components/optimal_food_table';
import RangeSlider from '../components/slider';
import AppBar from '../components/app_bar';
import CircularProgress from '@mui/material/CircularProgress';
import { Container, Box, Grid } from '@mui/material';
import Typography from '@mui/material/Typography';
import { useOptimalFoodData } from '../usecase/api_functions';
// スタイルの設定
const commonBoxStyle = {
// border: '1px solid #ccc',
padding: 2,
// minHeight: '300px'
};
const commonContainerStyle = { maxWidth: 'lg' };
// ページ作成
const IndexPage: React.FC = () => {
const { data, columns, loading, fetchOptimalFoodData } = useOptimalFoodData();
// スライダーの値
const [kcalRangeValues, setKcalRangeValues] = useState<number[]>([0, 4000]);
const [proteinRangeValues, setProteinRangeValues] = useState<number[]>([0, 200]);
const [fatRangeValues, setFatRangeValues] = useState<number[]>([0, 100]);
const [carboRangeValues, setCarboRangeValues] = useState<number[]>([0, 400]);
// 最適化を行うためのクエリ
const queryParams = {
kcal_min_num: kcalRangeValues[0],
kcal_max_num: kcalRangeValues[1],
protein_min_num: proteinRangeValues[0],
protein_max_num: proteinRangeValues[1],
fat_min_num: fatRangeValues[0],
fat_max_num: fatRangeValues[1],
carbo_min_num: carboRangeValues[0],
carbo_max_num: carboRangeValues[1],
};
return (
<div>
<AppBar />
<Grid container spacing={3} style={{ display: 'flex', alignItems: 'stretch' }}>
{/* 共通のスタイルを変数として外出し */}
<Grid item xs={12}>
<Container style={{ ...commonContainerStyle ,marginTop: '10px'} }>
<Box sx={commonBoxStyle}>
{/* コンテンツ */}
{/* <Typography variant="h6">
h1. Heading
</Typography> */}
<div style={{ display: 'flex', justifyContent: 'space', alignItems: 'center' }}>
<div style={{ marginRight: '20px' }}><strong>カロリー(kcal)</strong></div>
<div>{kcalRangeValues[0]}kcal - {kcalRangeValues[1]}kcal</div>
</div>
<RangeSlider setRangeValues={setKcalRangeValues} min_value={0} max_value={4000} />
<div style={{ display: 'flex', justifyContent: 'space', alignItems: 'center' }}>
<div style={{ marginRight: '20px' }}><strong>たんぱく質(g)</strong></div>
<div>{proteinRangeValues[0]}g - {proteinRangeValues[1]}g</div>
</div>
<RangeSlider setRangeValues={setProteinRangeValues} min_value={0} max_value={200} />
<div style={{ display: 'flex', justifyContent: 'space', alignItems: 'center' }}>
<div style={{ marginRight: '20px' }}><strong>脂質(g)</strong></div>
<div>{fatRangeValues[0]}g - {fatRangeValues[1]}g</div>
</div>
<RangeSlider setRangeValues={setFatRangeValues} min_value={0} max_value={100} />
<div style={{ display: 'flex', justifyContent: 'space', alignItems: 'center' }}>
<div style={{ marginRight: '20px' }}><strong>糖質(g)</strong></div>
<div>{carboRangeValues[0]}g - {carboRangeValues[1]}g</div>
</div>
<RangeSlider setRangeValues={setCarboRangeValues} min_value={0} max_value={400} />
</Box>
</Container>
</Grid>
<Grid item xs={12}>
<Container style={commonContainerStyle}>
<Box sx={commonBoxStyle}>
{/* コンテンツ */}
<OptimalFoodFetchButton loading={loading} fetchData={() => fetchOptimalFoodData(queryParams)} />
</Box>
</Container>
</Grid>
{loading ? (
// ローディング中
<Grid item xs={12}>
<Box
display="flex"
justifyContent="center"
alignItems="center"
// minHeight="100vh" // これはコンテナの高さに応じて調整してください。
>
<CircularProgress />
</Box>
</Grid>
) : (
// ローディング完了
<Grid item xs={12}>
<Container style={{ ...commonContainerStyle, padding: 0 }}>
{data.length > 0 ? (
// データが存在する場合はテーブルを表示
<OptimalFoodTable data={data} columns={columns} />
) : (
// データが存在しない場合は何も表示しない、またはメッセージを表示
<Box>
<p></p>
</Box>
)}
</Container>
</Grid>
)}
</Grid>
</div>
);
};
export default IndexPage;
各種コンポーネント
AppBar
import * as React from 'react';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Link from 'next/link';
import IconButton from '@mui/material/IconButton';
import MenuIcon from '@mui/icons-material/Menu';
const ButtonAppBar: React.FC = () => {
return (
<Box sx={{ flexGrow: 1 }}>
<AppBar position="static">
<Toolbar>
<Link href="/" passHref style={{ textDecoration: 'none' }}>
<Typography variant="h6" style={{ flexGrow: 1, color: 'white', textDecoration: 'none' }}>
FitFoodsFinder
</Typography>
</Link>
<div style={{ marginLeft: 'auto' }}>
<Link href="/" passHref>
<Button color="inherit" style={{ color: 'white' }}>
FitFood
</Button>
</Link>
<Link href="/search_food" passHref>
<Button color="inherit" style={{ color: 'white' }}>
SearchFood
</Button>
</Link>
</div>
</Toolbar>
</AppBar>
</Box>
);
}
export default ButtonAppBar;
最適化実行ボタン
import React from 'react';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Box from '@mui/material/Box';
interface FetchButtonProps {
loading: boolean;
fetchData: () => void;
}
const FetchButton: React.FC<FetchButtonProps> = ({ loading, fetchData }) => (
<div>
<Button
variant="contained"
color="primary"
sx={{ fontSize: '20px', padding: '10px 30px', width: '100%' }}
disabled={loading}
onClick={fetchData}
>
{loading ? 'Loading...' : '条件適用'}
</Button>
{/* {loading && (
<Box>
<CircularProgress />
</Box>
)} */}
</div>
);
export default FetchButton;
表コンポーネント
import React from 'react';
import MUIDataTable from "mui-datatables";
import { styled } from '@mui/material';
const StyledTable = styled(MUIDataTable)({
borderCollapse: 'collapse',
fontSize: '12pt',
'& th': {
color: '#ddd',
background: '#333',
textAlign: 'left',
padding: '0.2em',
border: '0.5px solid black',
},
'& td': {
color: '#333',
background: '#f6f6f6',
padding: '0.2em',
border: '0.5px solid black',
// fontFamily: 'Consolas, "Courier New"',
},
});
interface DataTableProps {
data: any[];
columns: string[];
}
const DataTable: React.FC<DataTableProps> = ({ data, columns }) => (
<StyledTable
title=""
data={data}
columns={columns}
/>
);
export default DataTable;
スライダー
import React from 'react';
import Slider from '@mui/material/Slider';
import Box from '@mui/material/Box';
type RangeSliderProps = {
setRangeValues: React.Dispatch<React.SetStateAction<number[]>>;
min_value: number
max_value: number
};
const RangeSlider: React.FC<RangeSliderProps> = ({ setRangeValues, min_value, max_value }) => {
const [value, setValue] = React.useState<number[]>([min_value, max_value]);
const handleChange = (event: Event, newValue: number | number[]) => {
setValue(newValue as number[]);
setRangeValues(newValue as number[]);
};
return (
<Slider
value={value}
onChange={handleChange}
valueLabelDisplay="auto"
aria-labelledby="range-slider"
min={min_value}
max={max_value}
// marks={marks}
/>
);
};
export default RangeSlider;
usecase
バックエンドのAPIを読み出し、最適な食品データを取得。
import { useState } from 'react';
import axios, { AxiosError } from 'axios';
export const useOptimalFoodData = () => {
const [data, setData] = useState([]);
const [columns, setColumns] = useState([]);
const [loading, setLoading] = useState(false);
const fetchOptimalFoodData = async (queryParams: any) => {
setLoading(true);
try {
const response = await axios.get('http://localhost:8000/', { params: queryParams });
setData(response.data.data);
setColumns(response.data.columns);
} catch (error: unknown) {
console.error('Error fetching data:', error);
// エラーハンドリング
if ((error as AxiosError).response) {
const axiosError = error as AxiosError;
// この部分で any 型を使用
const responseData: any = axiosError.response?.data;
if (responseData && responseData.message) {
alert(responseData.message);
} else {
alert('何らかのエラーが発生しました。');
}
} else {
alert('予期せぬエラーが発生しました。');
}
} finally {
setLoading(false);
}
};
return { data, columns, loading, fetchOptimalFoodData };
};
アプリの実行
- ターミナルにて、npm start dev
- ターミナルにて、uvicorn main:app --port 8000 --log-level debug
おわりに
個人的にはかなり簡単にUIとAPIが作成できることがわかり良かったです。
また、和食が健康に良さそうという経験測に違うことが無い出力が得られたのも興味深かったです。
最適化アルゴリズムやその他UXにはまだ修正の余地があるものの、筋トレ等を行っている身としては欲しいアプリだと思いました。
Discussion