目次 

引き継ぎ 

引き継ぎ用ページ

アプリ概要 

ホタルイカのオフシーズン中(2〜5月以外)は予報や詳細情報を表示されず「オフシーズン」と表示されます。シーズン中の表示は、デモデータを用いた以下のプレビューページで確認できます。

アプリURL
https://bakuwaki.jp/
プレビューページ(シーズン中デモ)
https://bakuwaki.jp/preview
Webサイト構築コード(GitHub)
https://github.com/yuchi1128/hotaruika-bakuwaki-forecast

機能 

身投げ量予報
先7日間の予測身投げ量を指数で確認できる。
詳細情報
日付ごとの時間帯別の天気、波の高さ、潮位、月齢などの詳細なデータを確認できる。
掲示板機能
現地の最新情報やホタルイカに関する話題を共有・交換できる。

作成動機 

使用技術 

アプリ全体で使用している技術です。

サーバーサイド 

フロントエンド 

データベース・ストレージ 

デプロイ先 

機械学習 

その他 

アプリ構成図 

以下はこのアプリの流れを示した図になります。

アプリ構成図.png

開発の流れ 

  1. データセットの構築 過去のホタルイカの身投げ量(実績値)と、それに対応する日の1時間ごとの気象データ、潮汐データ、月齢などのデータを収集し、一つのJSONファイルにまとめる。
  2. 機械学習モデルの訓練 構築したデータセットを利用して、LightGBMでホタルイカの身投げ量を予測するためのモデルを学習させる。
  3. 予測APIの開発 学習済みのモデルを組み込み、未来の天気予報や月齢などを入力として受け取り、身投げ量を予測し、予測した身投げ量をJSON形式で返すAPIを作成。
  4. Webサイトの構築 作成した予測APIを用いてWebサイトを構築。

ここからはこれらを順に解説していきます。

データセットの構築 

1. ホタルイカ身投げデータの取得 (スクレイピング) 

ホタルイカの身投げ量は公的なデータが存在しないため、掲示板サイトの口コミを利用して数値化しました。

2. 過去の気象データの取得 

気象庁公式サイト より、過去の天気、風、気温、降水量などのCSVデータをダウンロードし、JSONに変換しました。 ※身投げデータが存在する日付に対応するデータを取得。

3. 潮位データの取得 

外部API(tide736.net)を経由して、対象日の潮位、潮の種類を取得しJSON化しました。

4. 月齢データの取得 

西暦と日付から計算式を用いて算出しました。

5. データの統合 

上記で作成した複数のJSONファイルを、日付をキー(主キー)として一つのJSONデータセットに結合しました。

機械学習モデルの訓練 

私は、機械学習などの分野は全くの未経験だったのでAIに聞きながら行いました。これが正しい方法だったかは分かりません。

モデル構築のステップ 

  1. データ読み込みと前処理: JSON形式のデータを読み込み、扱いやすい形に整形
  2. 特徴量エンジニアリング: 予測に役立ちそうな新しい特徴量を作成
  3. モデル学習: LightGBMを使ってモデルを学習
  4. 評価と可視化: モデルの精度を評価し、結果を可視化して考察

1. データ読み込みと前処理 

まず、様々なデータソースから収集・統合した JSON ファイルを読み込みます。 このファイルには、日ごとのホタルイカの平均身投げ量と、その日に対応する1時間ごとの気象データ・潮位データ・月齢データが含まれています。 対象期間は2〜5月の10年間で、計1220日分のデータがあります。

2. 特徴量エンジニアリング 

使用した特徴量 (説明変数) 

予測に使用した主な特徴量は以下の通りです。

時間特徴量
年、月、日、曜日、年の第何週
月齢 (sin/cos変換)
月齢(0〜29.53日周期)を円周上の点として表現。
  • 月齢 sin: 上弦の月で最大(1)、下弦の月で最小(-1)
  • 月齢 cos: 新月で最大(1)、満月で最小(-1)
気温
平均、最大、最小、標準偏差。
  • 時間帯別(10-13時、14-17時、18-21時、22-0時、1-4時)の平均気温も算出。
降水量
合計降水量、降水の有無(バイナリ変数)。
  • 時間帯別の合計も算出。
風速・風向
平均、最大、最小、標準偏差。
潮汐データ
  • 潮型: 若潮、長潮、小潮、中潮、大潮を数値化。
  • 夜間潮位: 夜間(21時〜5時)の満潮・干潮の高さ(平均、最大、最小)。
ラグ特徴量
過去の情報を特徴量として追加。
  • 気象・潮汐変数: 1日前、2日前の値。
  • 目的変数(avg_amount): 1日前、2日前、直近3日間の平均値。

工夫した点 

「周期性」と「過去の情報」の2つの観点を工夫し特徴量を作成しました。

sin/cos変換で周期性を表現
月齢(約29.5日周期)や日付(365日周期)といった周期的なデータは、そのまま数値として扱うと周期の終わりと始まり(例: 12月31日と1月1日)の関係性をうまく表現できません。 そこでsin/cos変換を行い、周期的なデータを円周上の点として表現し、連続値として学習できるようにしました。
# 月齢をsin/cosに変換
df['moon_age_sin'] = np.sin(2 * np.pi * df['moon_age'] / 29.53)
df['moon_age_cos'] = np.cos(2 * np.pi * df['moon_age'] / 29.53)
# 1年のうちの日付をsin/cosに変換
df['day_of_year_sin'] = np.sin(2 * np.pi * df['day_of_year'] / 365.25)
df['day_of_year_cos'] = np.cos(2 * np.pi * df['day_of_year'] / 365.25)
ラグ特徴量で過去の情報を利用
ラグ特徴量も作成しました。これは、過去のデータを当日の特徴量として利用する手法です。今回は1日前と2日前の気象データなどを特徴量に加えました。
# ラグ特徴量を作成したいカラムのリスト
cols_for_lag = [
    'moon_age_sin', 'moon_age_cos', 'temperature_mean', 
    'precipitation_sum', 'wind_speed_mean', 'wind_direction_encoded'
]
# forループで1日前と2日前のデータ(ラグ)を新しい列として追加
for col in cols_for_lag:
    df[f'{col}_lag1'] = df[col].shift(1) # 1日前の値
    df[f'{col}_lag2'] = df[col].shift(2) # 2日前の値

3. モデル学習 

今回は高精度で計算も高速なLightGBMを使用しました。

時系列データの交差検証とハイパーパラメータチューニング 

時系列データを扱う上で最も重要なのは、未来のデータを使って過去を予測しないことです。 これを防ぐため、交差検証にTimeSeriesSplitを使用し、常に過去のデータで学習し、未来のデータで評価を行うようにしています。

from sklearn.model_selection import TimeSeriesSplit, GridSearchCV
import lightgbm as lgb

# 特徴量Xと目的変数yを準備
features = [col for col in df.columns if col not in ['date', 'avg_amount']]
X = df[features]
y = df['avg_amount']

# 時系列データ用の交差検証を設定
tscv = TimeSeriesSplit(n_splits=5)

# LightGBMモデルとチューニングしたいパラメータ範囲を定義
lgb_model = lgb.LGBMRegressor(random_state=42)
param_grid = {
    'n_estimators': [300, 500],
    'learning_rate': [0.01, 0.05],
    'num_leaves': [20, 31, 40],
}

# グリッドサーチで最適なパラメータを探索
gs = GridSearchCV(lgb_model, param_grid, cv=tscv, scoring='r2')
gs.fit(X, y)

# 最も性能の良かったモデルを取得
best_model = gs.best_estimator_

4. 評価と可視化 

モデルの精度を評価します。 ここでは、学習には使っていない 全データの最後の20% を「テストデータ」として使用し、予測値と実際の値を比較しました。

評価指標は以下の通りです。今回は、0から1の範囲に正規化された値で評価指標を計算します。

算出された評価指数 

このようになりました。

予測結果と実測値の比較 

学習済みモデルを使ってテストデータの身投げ量を予測し、実際の値と比較しました。

予測精度グラフ.png

青色が実際の過去の身投げ量(正解値)で、赤色が予測された身投げ量です。 グラフを見ると、身投げ量の増減はある程度捉えられています。しかし、まだ改善の余地がありそうです。

特徴量の重要度 

特徴量の重要度の上位には、temperature_std、precipitation_sum、day_of_year_sin、moon_age_cos、wind_speed_std_lag1、temperature_std_lag1 などがあり、季節、気温、月齢、降水量などが特に身投げ量に影響することがわかりました。

予測APIの開発 

作成したAPIの概要 

前回で学習させたモデルを使用して予測値を返すAPIを開発しました。フレームワークはFastAPI、GCP(Cloud Run)上にデプロイしています。

APIの構造 

APIは以下の流れで動作します。リクエストを受けると、外部のAPIから最新の予報を取得し、学習済みの機械学習モデルを使って予測値を算出し、予測値を返します。

API概要.png

使用した外部API 

非営利なら無料で使用できるこれらのAPIを使用しました。

APIエンドポイント 

1週間分(当日を含む7日間)のホタルイの予測身投げ量データと、その他の主要な関連データ返します。

レスポンスボディの例 

(7日分のデータが配列で返されますが、ここでは1日分の例を示します)

[
  {
    "date": "2025-4-10",
    "predicted_amount": 1.5,
    "moon_age": 18.2,
    "weather_code": 3,
    "temperature_max": 18.5,
    "temperature_min": 12.3,
    "precipitation_probability_max": 20,
    "dominant_wind_direction": 270
  },
]

時間の基準 

ホタルイカの身投げは主に 22:00〜翌4:00ごろ に発生します。 そのため、このサイトでは 1日の切り替え時刻を 5:00 に設定しています。 たとえば「4/10 の予報」は、実際には「4/10の22:00 〜 4/11の4:00ごろ」までの身投げ量を指しています。この設定は機械学習モデルの学習段階から一貫して適用しています。

Webサイトの開発のセクションで後述しますが、このAPIには、2:00, 5:00, 8:00, 11:00, 14:00, 17:00, 20:00, 23:00にリクエストが送信されます。 そこで問題なのが2:00のアクセスです。例えば「4/11の2:00」にリクエストが送られた場合、サイト上では「4/10」が当日扱いになるはずですが、実際には「4/11から一週間分のデータ」が返ってきてしまいます。 この問題を解決するために、API 側のタイムゾーンを日本時間より 4 時間遅め に設定しています。これにより「2:00 のアクセス」は内部的に「前日の 22:00」として扱われ、期待どおり「4/10 から一週間分のデータ」を取得できるようになります。

# Cloud Run にデプロイする際の設定例
gcloud run deploy [SERVICE_NAME] \
  --image [IMAGE_URL] \
  --set-env-vars "TZ=Etc/GMT-13"

大変だったこと 

「学習時と予測時でデータの前処理を完全に一致させること」がとても大変でした。 学習時に実施した欠損値補完・スケーリング・特徴量生成などの前処理を、外部APIから取得したデータに対してもAPI側のPythonスクリプト上で厳密に再現する必要がありました。 APIで予測時に使用した特徴量の値をログで出して、実際の値との違いを比べるといった作業を何回も行いました。

Webサイトの構築 

作成した予測APIを用いて、Webサイトを作成しました。 アプリケーション全体構成の中の、この赤枠の部分がこのセクションの内容になります。

アプリ構成図_web.png

アプリケーション構成 

バックエンド (Go) 

主なAPIエンドポイント 

キャッシュ 

外部API(気象情報、潮汐情報、先ほど解説した予測API)へユーザーが直接ブラウザからアクセスすると、コストと時間がかかります。 そこで、アプリの構成図のように、頻繁にアクセスされるデータをメモリ上にキャッシュするように実装しました。

backend/internal/cache/cache.go

// CacheManager は予測データと詳細データのキャッシュを管理
type CacheManager struct {
    logger        *slog.Logger
    predictionURL string
    predictionCache struct {
        sync.RWMutex
        data []byte
    }
    detailCache struct {
        sync.RWMutex
        data map[string][]byte
    }
}

// FetchAndCachePredictionData は予測データを取得しキャッシュする
func (c *CacheManager) FetchAndCachePredictionData() {
    // ... 外部APIからデータを取得 ...
    
    c.predictionCache.Lock()
    c.predictionCache.data = body
    c.predictionCache.Unlock()
}

// GetPredictionData はキャッシュされた予測データを返す
func (c *CacheManager) GetPredictionData() []byte {
    c.predictionCache.RLock()
    defer c.predictionCache.RUnlock()
    return c.predictionCache.data
}

/api/tasks/refresh-cacheにPOSTリクエストがあるとFetchAndCache... 関数群が実行されるようになっていて、キャッシュを最新の状態に更新します。本番環境では Google Cloud Scheduler を使用し2:00, 5:00, 8:00, 11:00, 14:00, 17:00, 20:00, 23:00にPOSTリクエストを送る設定にしています。 /api/prediction および /api/detail/{date} エンドポイントは、このキャッシュに保存されたデータを返します。ブラウザからはこのエンドポイントにアクセスするようにし、外部APIへのアクセスを最小限に抑え、高速なレスポンスを実現しています。

管理者機能とJWT認証 

口コミの削除など、特定の操作は管理者のみが行えるように制限する必要があります。この認証にはJWTを利用しました。

backend/internal/handler/admin.go

func (h *Handler) adminLoginHandler(w http.ResponseWriter, r *http.Request) {
    // ... パスワード検証 ...

    // JWT Claimsを設定
    expirationTime := time.Now().Add(24 * time.Hour)
    claims := &model.Claims{
        Role: "admin",
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(expirationTime),
        },
    }

    // トークンを生成し、署名
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    tokenString, err := token.SignedString(h.jwtKey)

    // ...

    // Cookieにトークンをセット
    http.SetCookie(w, &http.Cookie{
        Name:      "admin_token",
        Value:     tokenString,
        Expires:   expirationTime,
        HttpOnly:  true,
        Path:      "/",
        SameSite:  http.SameSiteNoneMode,
        Secure:    true,
    })
}

ログインに成功すると、Role: "admin" という情報を含んだJWTを生成し、HttpOnly Secure SameSite=None 属性を付けたCookieとしてクライアントに送信します。

データベース (PostgreSQL & Supabase) 

テーブル設計 

このサイトではログイン機能を実装していないためUserテーブルはありません。掲示板機能のテーブルのみになっています。

DB構成図.png

フロントエンド (Next.js) 

ページ構成とデータ取得 

湧きレベル判定 

このサイトでは、予測身投げ量を「湧きなし」「プチ湧き」「チョイ湧き」「湧き」「大湧き」「爆湧き」の6段階のレベルで予報します。 APIから予測身投げ量の数値が返ってくるのですが、その値は0~4の値をとります。現時点ではそのAPIから返ってくる値をxとした時、

範囲 (x)評価
x < 0.25湧きなし
0.25 ≦ x < 0.5プチ湧き
0.5 ≦ x < 0.75チョイ湧き
0.75 ≦ x < 1湧き
1 ≦ x < 1.25大湧き
1.25 ≦ x爆湧き

としています。機械学習セクションでの予測結果と実測値の比較グラフを見てこの基準にしました。

複雑な状態を管理するコンポーネント 

CommentSection は、口コミの表示、投稿、フィルタリング、検索、並び替え、ページネーションといった多くの機能を持ちます。

frontend/components/Community/CommentSection.tsx

const CommentSection = ({ ... }: CommentSectionProps) => {
  // 投稿内容、画像、ラベルなどの状態
  const [newComment, setNewComment] = useState('');
  const [selectedImages, setSelectedImages] = useState<File[]>([]);
  const [selectedLabel, setSelectedLabel] = useState<string>('現地情報');

  // フィルタリング、検索、ソート、ページネーションの状態
  const [selectedFilterLabel, setSelectedFilterLabel] = useState<string | null>(null);
  const [searchQuery, setSearchQuery] = useState<string>('');
  const [sortOrder, setSortOrder] = useState<'newest' | 'oldest' | 'good' | 'bad'>('newest');
  const [currentPage, setCurrentPage] = useState<number>(1);

  // フィルタリングとソートを適用したメモ化済みのコメントリスト
  const sortedComments = useMemo(() => {
    // ... フィルタリングとソートのロジック ...
  }, [comments, searchQuery, sortOrder]);

  // ページネーションを適用した最終的な表示用コメントリスト
  const paginatedComments = useMemo(() => sortedComments.slice(startIndex, endIndex), [sortedComments, startIndex, endIndex]);

  // ...
}

ログイン機能無しでのリアクション管理 

本アプリケーションでは、ユーザーの利用開始時の負担を最小限に抑えるため、あえてログイン機能を実装していません。これにより、誰でも気軽に口コミの閲覧や投稿ができるようになっています。 「good」「bad」のリアクション機能については、ユーザーが行ったリアクションを記憶するために、ブラウザの localStorage を利用しています。ブラウザやデバイスを変えると何回もリアクションを押せるようになっていますが仕方ないです。

UI 

今回のアプリではUIも自分なりにこだわってみました。 全体的にモダンな雰囲気を目指し、ホタルイカの神秘的な世界観に合うよう意識しました。 また、深夜の時間帯に利用されることが多いと思うので、暗い環境でも目が疲れにくいように暗めのデザインにしました。また、スマートフォンからのアクセスがメインと思い、レスポンシブ対応にも気をつけて開発を進めました。 デザイン面が苦手なので、最初に bolt.new に自分が想像しているデザインを指示し、Next.jsのプロジェクトコードを生成してもらい、それをダウンロードして開発を開始しました。

開発環境と本番環境の構成 

ローカル開発環境 (Docker Compose) 

開発環境では、docker-compose.yml を用いて frontend, backend, db の3つのサービスを定義し、連携させています。

docker-compose.yml

version: '3.8'
services:
  frontend:
    build:
      context: ./frontend
    ports:
      - "3001:3000"
    environment:
      # コンテナ内からバックエンドサービスにアクセスするためのURL
      NEXT_PUBLIC_API_BASE_URL: http://backend:8080
    depends_on:
      - backend
  backend:
    build:
      context: ./backend
    ports:
      - "8080:8080"
    env_file:
      - ./backend/.env # DB接続情報などをファイルから読み込む
    depends_on:
      db:
        condition: service_healthy # DBが準備完了してから起動
  db:
    image: postgres:14-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d hotaruika_db"]
      interval: 5s
      timeout: 5s
      retries: 5
    # ...

本番環境 (Cloud Run, Vercel, Supabase) 

本番環境では、以下のクラウドサービスを組み合わせて利用しています。

課題点や懸念点 

海岸ごとの予測ができていない 

現在のモデルでは、富山湾全体としてのホタルイカの湧き量(身投げ量)を予測しています。そのため、特定の海岸ごとの違いまでは反映できていません。実際には、同じ日でも場所によって湧き量に差が出ることがあり、予測精度の低下やサイトの信頼性の低下つながることが心配です。

身投げ量の予測精度が外部APIの精度に影響される 

このサイトでは、自作の機械学習モデルに外部APIから取得したデータを入力し、身投げ量を予測しています。なので、そもそも外部APIの予報精度が低い場合には、予測精度が落ちてしまいます。今後は、より精度の高い気象予報APIの使用を検討する必要があると感じています。

サイトを訪れるユーザーの母体が少ない 

サイトの主な利用者は最低でもホタルイカ掬いに関心のある人に限られます。そのためユーザー母数が少なく、また利用が集中するのは2〜5月のシーズン期間のみです。オフシーズンにはアクセスが減少しやすく、ユーザーが離れてしまいます。季節外でも使ってもらえる工夫が必要だと感じています。

終わりに 

なんとかアプリを形にすることができてよかったです。


トップ   新規 一覧 検索 最終更新   ヘルプ   最終更新のRSS