📈

CSVをアップロードするだけで売上予測できるWebアプリをProphetで作った【FastAPI + React】

に公開

はじめに

「来月の売上、どうなるんだろう」——そんな疑問に手軽に答えてくれるツールを個人開発しました。

SalesCasthttps://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:

https://salescast.app


参考リンク

Discussion