CSVをアップロードするだけで売上予測できるWebアプリをProphetで作った【FastAPI + React】
はじめに
「来月の売上、どうなるんだろう」——そんな疑問に手軽に答えてくれるツールを個人開発しました。
SalesCast(https://salescast.app)は、CSVをアップロードするだけで売上・需要予測ができるWebアプリです。
この記事では、Prophetをバックエンド(FastAPI)に組み込み、フロントエンド(React + Vite)からCSVを送って予測結果を返すまでの実装ポイントと詰まった箇所を解説します。
対象読者
- PythonでProphetを使ってみたい方
- FastAPIでMLモデルをAPIとして公開したい方
- React + FastAPIの構成で何か作ってみたい方
技術スタック
| レイヤー | 技術 |
|---|---|
| フロントエンド | React + Vite, TypeScript, Recharts |
| バックエンド | FastAPI, Prophet, pandas |
| デプロイ | Vercel(フロント), Render(バック) |
Prophetとは
ProphetはMeta(旧Facebook)が開発・OSS公開している時系列予測ライブラリです。
pip install prophet
特徴:
- トレンド・季節性・祝日効果を自動でモデル化
- データが少なくても(数十件〜)動く
- パラメータチューニングがほぼ不要で使いやすい
公式ドキュメント:https://facebook.github.io/prophet/
アーキテクチャ全体像
[ユーザー]
│
│ CSVアップロード
▼
[React (Vercel)]
│
│ POST /forecast (multipart/form-data)
▼
[FastAPI (Render)]
│
│ Prophet で予測
▼
[JSON レスポンス]
│
▼
[Recharts でグラフ表示]
バックエンド実装(FastAPI + Prophet)
ディレクトリ構成
backend/
├── main.py
├── routers/
│ └── forecast.py
├── services/
│ └── prophet_service.py
└── requirements.txt
CSVを受け取るAPIエンドポイント
# routers/forecast.py
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from services.prophet_service import run_forecast
import pandas as pd
import io
router = APIRouter()
@router.post("/forecast")
async def forecast(
file: UploadFile = File(...),
periods: int = Form(30),
date_col: str = Form("date"),
value_col: str = Form("sales"),
):
contents = await file.read()
try:
df = pd.read_csv(io.StringIO(contents.decode("utf-8")))
except Exception:
raise HTTPException(status_code=400, detail="CSVの読み込みに失敗しました")
result = run_forecast(df, date_col=date_col, value_col=value_col, periods=periods)
return result
Prophet予測ロジック
# services/prophet_service.py
from prophet import Prophet
import pandas as pd
import json
def run_forecast(df: pd.DataFrame, date_col: str, value_col: str, periods: int) -> dict:
# Prophetが要求するカラム名に変換
df_prophet = df[[date_col, value_col]].rename(
columns={date_col: "ds", value_col: "y"}
)
df_prophet["ds"] = pd.to_datetime(df_prophet["ds"])
df_prophet["y"] = pd.to_numeric(df_prophet["y"], errors="coerce")
df_prophet = df_prophet.dropna()
# データ件数に応じて季節性を調整(ここがハマりポイント!)
n = len(df_prophet)
model = Prophet(
yearly_seasonality=n >= 365, # 1年以上のデータがないとOFFに
weekly_seasonality=n >= 14, # 2週間以上でON
daily_seasonality=False,
)
model.fit(df_prophet)
future = model.make_future_dataframe(periods=periods)
forecast = model.predict(future)
# JSON シリアライズのためTimestampをstrに変換
result_df = forecast[["ds", "yhat", "yhat_lower", "yhat_upper"]].copy()
result_df["ds"] = result_df["ds"].dt.strftime("%Y-%m-%d")
return {
"forecast": result_df.to_dict(orient="records"),
"periods": periods,
}
詰まったポイント①:yearly_seasonality の罠
最初、モデルを何も考えずに以下のように書いていました。
# NG: データ件数を考慮しないデフォルト設定
model = Prophet()
Prophetのデフォルトは yearly_seasonality="auto" で、データが少ない(1年未満)場合でも内部的に年次季節性を推定しようとして過学習・異常な予測値が出ることがあります。
対策: データ件数でON/OFFを明示的に制御する。
model = Prophet(
yearly_seasonality=len(df_prophet) >= 365,
weekly_seasonality=len(df_prophet) >= 14,
daily_seasonality=False,
)
詰まったポイント②:JSONシリアライズエラー
Prophetの出力する ds カラムは pandas.Timestamp 型で、そのまま FastAPI のレスポンスに渡すと Object of type Timestamp is not JSON serializable エラーが発生します。
# NG
return forecast[["ds", "yhat"]].to_dict(orient="records")
# OK: 文字列に変換してから返す
result_df["ds"] = result_df["ds"].dt.strftime("%Y-%m-%d")
return result_df.to_dict(orient="records")
フロントエンド実装(React + Recharts)
CSVアップロードとAPI呼び出し
// ForecastPage.tsx(抜粋)
const handleUpload = async (file: File) => {
const formData = new FormData();
formData.append("file", file);
formData.append("periods", "30");
formData.append("date_col", "date");
formData.append("value_col", "sales");
const res = await fetch(`${import.meta.env.VITE_API_URL}/forecast`, {
method: "POST",
body: formData,
});
if (!res.ok) throw new Error("予測に失敗しました");
const data = await res.json();
setForecastData(data.forecast);
};
Rechartsで予測グラフを表示
import {
LineChart, Line, XAxis, YAxis,
CartesianGrid, Tooltip, ResponsiveContainer, Legend
} from "recharts";
<ResponsiveContainer width="100%" height={400}>
<LineChart data={forecastData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="ds" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="yhat" name="予測値" stroke="#2563eb" dot={false} />
<Line type="monotone" dataKey="yhat_upper" name="上限" stroke="#93c5fd" strokeDasharray="4 2" dot={false} />
<Line type="monotone" dataKey="yhat_lower" name="下限" stroke="#93c5fd" strokeDasharray="4 2" dot={false} />
</LineChart>
</ResponsiveContainer>
レート制限(SQLiteで実装)
無制限にAPIを叩かれると Render の無料枠が死ぬので、SQLiteでシンプルなレート制限を実装しました。
# rate_limiter.py(抜粋)
import sqlite3
from datetime import datetime, timedelta
DB_PATH = "rate_limit.db"
def is_rate_limited(ip: str, max_requests: int = 5, window_minutes: int = 60) -> bool:
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""
CREATE TABLE IF NOT EXISTS requests
(ip TEXT, timestamp TEXT)
""")
window_start = (datetime.utcnow() - timedelta(minutes=window_minutes)).isoformat()
c.execute("DELETE FROM requests WHERE timestamp < ?", (window_start,))
c.execute("SELECT COUNT(*) FROM requests WHERE ip = ?", (ip,))
count = c.fetchone()[0]
if count >= max_requests:
conn.close()
return True
c.execute("INSERT INTO requests VALUES (?, ?)", (ip, datetime.utcnow().isoformat()))
conn.commit()
conn.close()
return False
デプロイ構成
フロントエンド(Vercel)
// vercel.json
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
バックエンド(Render)
Prophetは依存が重いので requirements.txt に明示しておく必要があります。
fastapi
uvicorn
prophet
pandas
pystan
Renderの無料プランはコールドスタートが遅い(初回30秒程度)ため、フロントにローディング表示を出すと体験が改善されます。
実際の予測精度
以下は月次売上データ(36ヶ月分)を入れたときのサンプル結果です:
- MAPE(平均絶対パーセント誤差):約8〜12% (季節性が強いデータの場合)
- トレンド転換点の検出はおおむね良好
- データが少ない(12件未満)場合は精度が落ちる
まとめ
| ポイント | 内容 |
|---|---|
| Prophetの季節性設定 | データ件数に応じてON/OFF制御が必須 |
| JSONシリアライズ | Timestampは .dt.strftime() で文字列変換 |
| レート制限 | SQLiteで手軽に実装可能 |
| デプロイ | Vercel + Renderの組み合わせで無料運用可能 |
SalesCastはまだ開発中ですが、無料で使えます。ぜひ試してみてください。
SalesCast:
Discussion