0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

150年分の気象データをFastAPI + React + 統計検定で可視化するアプリをAIで開発

0
Last updated at Posted at 2026-05-31

150年分の気象データをFastAPI + React + 統計検定で可視化するアプリをAIで開発

はじめに

「今日の東京は本当に暑いのか?それとも気のせいか?」

この疑問を客観的に検証するWebアプリ Notoshttps://notos-77b.pages.dev/)を個人で開発しました。今日の気温が観測史上の同日データと比べてどの位置にあるかを、バイオリンプロット+スワームプロットと統計検定で可視化します。

Notosのスクリーンショット

私はもともと脊椎動物の進化を研究する生物学者で、計算生物学・バイオインフォマティクスを経てソフトウェアエンジニアになりました。データ解析の視点から気象データを眺めると、「今日の気温は統計的に有意に高いのか」という問いが自然と出てきます。本記事はその実装の記録です。


Claude Codeで開発した話

本プロジェクトはほぼ全工程をClaude Code(Anthropicの CLIエージェント)と対話しながら開発しました。普段はPythonがメインで、TypeScriptはAWS CDKで触る程度。フロントエンド(React)は慣れていない状態でスタートしています。

何をやってもらったか

  • FastAPIのルーター・サービス層の雛形生成
  • JMAのHTMLテーブルのパース処理(列インデックスがトリッキーで、手で書くと地味に面倒)
  • 統計検定のコード(Laplace平滑化やBootstrap CIは「こういう検定がしたい」と説明したら実装してくれた)
  • Cloud Run + GCS連携の設計と実装
  • CORS設定・セキュリティレビュー(「管理エンドポイントが無防備では?」と指摘してもらった)
  • デプロイスクリプト(gcloudコマンドの引数周りのエスケープ問題など)

特に助かった場面

データ取得の戦略決定。Open-MeteoのERA5は1940年以前のデータがない、NOAAのCDO APIなら1860年代まで遡れる、でも取得済みデータを消したくない——という複合的な条件を伝えたら「INSERT OR IGNOREで既存データを保護しつつNOAAで補完する」という設計を提案してくれました。自分では思いつくのに時間がかかったと思います。

セキュリティの見落とし発見。「フロントエンドのセキュリティを見てほしい」と聞いたら、dangerouslySetInnerHTMLでバックエンドから受け取ったテキストをそのままHTMLとして描画していた箇所を指摘されました。バックエンドが汚染されれば任意のスクリプトを実行できるリスクがあるため、ReactMarkdownに差し替えました。

人間がやったこと

  • 何を作るかの決断:バイオリンプロットで見せたい、50年区切りで時代比較したい、という可視化の方針
  • 統計手法の選定:正規性を仮定しない経験的検定を使う、Laplace平滑化でp=0を回避する、という判断(研究者時代の知識が活きた)
  • デバッグの方向性:「LA(ロサンゼルス)の1940年以前のデータが入ってこない」など、データの問題に気づくこと
  • 最終的なUI・UXの判断:レイアウト、色、文言

感想

「自分で書けばもっと速かった」場面はほぼありませんでした。一方で、「何を作るか」「データのどこがおかしいか」という判断は自分でしないといけません。AIは実装の加速装置であって、設計の代替ではない、というのが正直な感想です。


技術スタック

レイヤー 技術
バックエンド FastAPI (Python 3.11) + aiosqlite
統計処理 scipy, numpy
データ取得 httpx + BeautifulSoup4 (JMA), httpx (Open-Meteo / NOAA CDO)
フロントエンド React + TypeScript + Vite
グラフ Plotly.js (react-plotly.js)
ストレージ SQLite(開発)→ Cloud Run + GCS(本番)
ホスティング Cloud Run(バックエンド)、Cloudflare Pages(フロントエンド)

データ取得:気象庁サイトをスクレイピング

日本の気温データは気象庁の過去データ閲覧サービスから取得します。

https://www.data.jma.go.jp/stats/etrn/view/daily_s1.php
  ?prec_no={prec_no}&block_no={block_no}&year={year}&month={month}

月ごとにHTMLを取得し、BeautifulSoupでパースします。

JMA_URL = (
    "https://www.data.jma.go.jp/stats/etrn/view/daily_s1.php"
    "?prec_no={prec_no}&block_no={block_no}&year={year}&month={month}&day=&view="
)

def _parse_monthly_html(html: str, year: int, month: int) -> list[dict]:
    soup = BeautifulSoup(html, "lxml")
    table = soup.find("table", class_="data2_s")
    if table is None:
        return []

    records = []
    for row in table.find_all("tr"):
        cols = row.find_all("td")
        if len(cols) < 5:
            continue
        try:
            day = int(cols[0].get_text(strip=True))
        except ValueError:
            continue
        # 列順:日付, 降水量..., 平均気温(6), 最高気温(7), 最低気温(8)
        temp_avg = _parse_temp(cols[6].get_text(strip=True))
        temp_max = _parse_temp(cols[7].get_text(strip=True))
        temp_min = _parse_temp(cols[8].get_text(strip=True))
        records.append({"date": date(year, month, day).isoformat(),
                         "temp_avg": temp_avg, "temp_max": temp_max, "temp_min": temp_min})
    return records

東京なら1872年から約1800ヶ月分。サーバー負荷を考慮して0.5秒間隔でリクエストし、取得済みの月はfetch_progressテーブルで管理して二重取得を防ぎます。

INSERT OR IGNOREにより、Open-Meteoで取得済みの1940年以降データを上書きせず、NOAA CDOでは不足している年だけを埋めます。


統計処理:「今日の気温は本当に異常か」を検定する

統計の核心部分です。正規性を仮定せず、**経験的片側検定(Empirical one-sided test)**を使います。

なぜ経験的検定か

気温データはしばしば正規分布から逸脱します(都市化やヒートアイランドによる非定常性、外れ値など)。Shapiro-Wilkで正規性を確認した上で、非正規の場合でも適用できる経験的手法を採用しました。

p値の計算(Laplace平滑化)

direction = "high" if today >= mean else "low"

if direction == "high":
    extreme_count = int(np.sum(arr >= today))
else:
    extreme_count = int(np.sum(arr <= today))

# Laplace平滑化:p=0(観測史上最高)を回避
# 「n年中0回」ではなく「n+1年中1回」として扱う
p_empirical = (extreme_count + 1) / (n + 1)

Laplace平滑化により、観測史上最高・最低の値でもp=0にならず、「約n年に1度」という解釈が可能になります。

Bootstrap Monte CarloによるCI

p値の信頼区間もBootstrapで推定します。

def _bootstrap_p_ci(arr, today, direction, rng, n_boot=5000):
    n = len(arr)
    if direction == "high":
        boot_p = np.array([
            np.mean(rng.choice(arr, size=n, replace=True) >= today)
            for _ in range(n_boot)
        ])
    else:
        boot_p = np.array([
            np.mean(rng.choice(arr, size=n, replace=True) <= today)
            for _ in range(n_boot)
        ])
    return float(np.percentile(boot_p, 2.5)), float(np.percentile(boot_p, 97.5))

予測区間

「来年の同日は何℃くらいか」の予測区間も計算します。将来の1観測値には標本誤差も加わるため、sqrt(1 + 1/n)項を含むt分布の予測区間を使います(信頼区間より広い)。

t95 = stats.t.ppf(0.975, df=n - 1)
pi95_high = mean + t95 * std * math.sqrt(1 + 1 / n)
pi95_low  = mean - t95 * std * math.sqrt(1 + 1 / n)

判定ロジック

p値 パーセンタイル 判定
p < 0.01 ≥ 99% 記録的な高温(約100年に1度)
p < 0.05 ≥ 95% 歴史的に高温(約20年に1度)
≥ 90% かなり高め
≥ 75% やや高め
それ以外 平年並み

Cloud Run + SQLiteをGCSで永続化するパターン

Cloud Runはエフェメラルコンテナです。SQLiteをそのまま使うとコンテナ再起動のたびにデータが消えます。そこでGCS(Google Cloud Storage)をSQLiteのバックアップストレージとして使います。

# 起動時: GCSからDBをダウンロード
async def download_db() -> bool:
    if not GCS_BUCKET:
        return False
    blob = storage.Client().bucket(GCS_BUCKET).blob("weather.db")
    if not blob.exists():
        return False
    blob.download_to_filename(str(DB_PATH))
    return True

# 都市データ取得完了後・シャットダウン時: GCSにアップロード
async def upload_db() -> None:
    if not GCS_BUCKET:
        return
    async with _upload_lock:   # 同時アップロードを防ぐ
        blob = storage.Client().bucket(GCS_BUCKET).blob("weather.db")
        blob.upload_from_filename(str(DB_PATH))

FastAPIのlifespanで組み合わせます。

@asynccontextmanager
async def lifespan(app: FastAPI):
    await download_db()   # 起動時にGCSから取得
    await init_db()
    downloader.start_if_idle()
    yield
    await upload_db()     # シャットダウン時に保存

このパターンにより、Cloud Runの無料枠(月216万vCPU秒)を使いながら、79MBのSQLiteを永続化できます。RDBMSを立ち上げる必要がないため、運用コストがほぼゼロです。


バックグラウンドダウンロードとSSE

初回起動時に全都市の過去データをバックグラウンドで自動取得します。フロントエンドはポーリングで進捗を受け取り、取得完了した都市を即座に使えるようにします。

# バックエンド:都市ごとに取得し、完了したらGCSに保存
async def _run() -> None:
    for city in cities_pending:
        async for progress in _scraper(city).download_city(city):
            state.month_done = progress.get("done", 0)
        state.newly_completed.append(city.id)
        await upload_db()   # 都市完了ごとにGCSに永続化
// フロントエンド:3秒ごとにポーリング
useEffect(() => {
  fetch(`${BASE}/api/download/all/start`, { method: "POST" });
  timerRef.current = setInterval(async () => {
    const s = await fetch(`${BASE}/api/download/all/status`).then(r => r.json());
    s.newly_completed.forEach(onCityComplete);  // 完了した都市でデータ再取得
  }, 3000);
}, []);

気象業務法について

気象業務法では予報業務許可のない者が独自の予報を提供することを規制しています。本アプリは気象庁・Open-Meteo・NOAAの公開データをそのまま可視化・転載するものであり、独自の気象予報を行うものではありません。また、「参考情報のみ」である旨を明示しています。


まとめ

ポイント 採用した解決策
150年分のデータ取得 JMAスクレイピング + NOAA CDO API
「異常かどうか」の判定 経験的片側検定 + Laplace平滑化 + Bootstrap CI
Cloud RunでSQLite永続化 起動時GCSダウンロード・終了時アップロード
時代による気温変化の可視化 Plotly violin + swarm(50年区切り)
コスト ほぼ無料(Cloud Run無料枠 + Cloudflare Pages無料枠)

生物学のバックグラウンドがあると、「分布を見る・検定する・外れ値に意味を見出す」という視点が自然と出てきます。気象データはそのまま集団遺伝学や生態学のデータ解析と似た構造を持っており、ドメインを超えた応用が面白いと感じました。

ソースコードは近日公開予定です。質問・フィードバックはkoh.onimaru@gmail.comまで。


参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?