目次
1. はじめに
2. アプリ概要
3. 使用技術
4. アプリケーション構成図
5. 開発の流れ
6. 機械学習モデルの訓練
7. 予測APIの開発
8. Webサイトの構築
9. 課題点や懸念点
10. 終わりに
はじめに
ホタルイカの身投げについて
初めまして!ホタルイカの身投げについてご存知でしょうか? 身投げとは、ホタルイカが産卵のために富山県の海岸近くまで押し寄せる現象を指します。この現象は、特定の自然条件が揃ったときに発生し、身投げ量が多い時には以下のように海岸が青白い光で埋め尽くされる幻想的な光景を見ることができます。毎年春になると、富山県の海岸に県内、県外関わらず多くの人が押し寄せホタルイカ掬いを楽しみます。
身投げが起きやすい条件
身投げは主に、以下の条件が重なった時に起こりやすいと言われています。
- 月齢: 新月の前後、月明かりが少ない夜。
- 潮汐: 満潮に近い時間帯。
- 天候: 晴れて波が穏やかな日。
- 風向: 南風が吹き、沖合のホタルイカが岸に寄せられるとき。
しかし、これらの条件が揃っても、1匹も湧かないこともあり、予測するのは非常に困難です。
この身投げ量を予測するWebサイトを作ってみました。
アプリ概要
アプリURL
ホタルイカのオフシーズン中は予報や詳細情報を表示されません。シーズン中の表示は、デモデータを用いた以下のプレビューページで確認できます。
機能
- 身投げ量予報: 先7日間の予測身投げ量を指数で確認できる。
- 詳細情報: 日付ごとの時間帯別の天気、波の高さ、潮位、月齢などの詳細なデータを確認できる。
- 掲示板機能: 現地の最新情報やホタルイカに関する話題を共有・交換できる。
作成動機
- 富山出身でホタルイカ掬いにたまに行く。
- 現地に行ってもホタルイカが湧かないことも多く、予測できたら便利だと思った。
- 既存で一番使われているホタルイカ掬い用の掲示板サイトが使いづらく、自分で作りたくなった。
使用技術
アプリ全体で使用している技術です。
サーバーサイド
- Go(サイト)
- FastAPI(予測API)
フロントエンド
- TypeScript (Next.js)
- Tailwind CSS
- shadcn/ui
データベース・ストレージ
- Supabase
デプロイ先
- Vercel
- GCP(Cloud Run)
機械学習
- LightGBM
その他
- Git
- Github
- Docker
- Docker-compose
アプリケーション構成図
以下はこのアプリケーションの流れを示した図になります。
開発の流れ
1. データセットの構築
過去のホタルイカの身投げ量(実績値)と、それに対応する日の気象データ、潮汐データ、月齢などの特徴量を収集し、一つのJSONファイルにまとめる。
2. 機械学習モデルの訓練
構築したデータセットを利用して、LightGBMでホタルイカの身投げ量を予測するためのモデルを学習させる。
3. 予測APIの開発
学習済みのモデルを組み込み、未来の天気予報や月齢などを入力として受け取り、身投げ量を予測し、予測した身投げ量をJSON形式で返すAPIを作成。
4. Webサイトの構築
作成した予測APIを用いてWebサイトを構築。
機械学習モデルの訓練
私は、機械学習などの分野は全くの未経験だったのでAIに聞きながら行いました。これが正しい方法だったかは分かりません。
予測モデル構築のステップ
予測モデルの構築は、大きく以下のステップで進めました。
1. データ読み込みと前処理: JSON形式のデータを読み込み、扱いやすい形に整形
2. 特徴量エンジニアリング: 予測に役立ちそうな新しい特徴量を作成
3. モデル学習: LightGBMを使ってモデルを学習
4. 評価と可視化: モデルの精度を評価し、結果を可視化して考察
1. データ読み込みと前処理
まずは、様々なデータソースから収集・統合したJSONファイルを読み込みます。このファイルには、日ごとのホタルイカの身投げ量、月齢、気温、風、降水量のデータが含まれています。データ量は1220日分です。
2. 特徴量エンジニアリング
今回は「周期性」と「過去の情報」の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の範囲に正規化された値で評価指標を計算します。
R² (決定係数): 1に近いほど良いモデル。
MAE (平均絶対誤差): 予測値と実測値の誤差の平均。小さいほど良い。
RMSE (二乗平均平方根誤差): MAEと同様に誤差の指標。大きな誤差をより重視する。
算出された評価指数
このようになりました。
決定係数 (R²): 0.7008
平均絶対誤差 (MAE): 0.0721
二乗平均平方根誤差 (RMSE): 0.1013
予測結果と実測値の比較
学習済みモデルを使ってテストデータの身投げ量を予測し、実際の値と比較しました。
青色が実際の過去の身投げ量(正解値)で、赤色が予測された身投げ量です。
グラフを見ると、身投げ量の増減はある程度捉えられています。しかし、まだ改善の余地がありそうです。
特徴量の重要度
特徴量の重要度の上位には、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
非営利なら無料で使用できるこれらのAPIを使用しました。
APIエンドポイント
1週間分(当日を含む7日間)のホタルイの予測身投げ量データと、その他の主要な関連データ返します。
-
エンドポイント:
/predict/week -
HTTPメソッド:
GET - 認証: 不要
レスポンスボディの例 (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サイトを作成しました。
アプリケーション全体構成の中の、この赤枠の部分がこのセクションの内容になります。
Githubリンク
アプリケーション構成
アプリケーション全体は、主に以下の3つのサービスから構成され、Docker Composeで管理されています。
- フロントエンド: Next.js (TypeScript), shadcn/ui, Tailwind CSS
- バックエンド: Go
- データベース: PostgreSQL
バックエンド (Go)
バックエンドはGoを使用しています。フレームワークを使わなかったのは、今回の要件では標準ライブラリで十分だったからです。
主なAPIエンドポイント
-
予報関連
-
GET/api/prediction: 週間予報データを返す。内部で機械学習APIを叩き、結果をキャッシュして提供。 -
GET/api/detail/{date}: 指定された日付の詳細な気象・潮汐データを返す。 -
POST/api/tasks/refresh-cache:外部データにアクセスし、新しいデータで、予測データと詳細データのキャッシュを強制的に更新する。
-
-
口コミ (Posts) 関連
-
GET POST/api/posts: 口コミの一覧取得、新規作成。 -
GET POST DELETE/api/posts/{id}/...: 特定の口コミに対する操作(削除、返信、リアクション)。
-
-
管理者関連
-
POST/api/admin/login: 管理者としてログインし、JWTを発行。 -
POST/api/admin/logout: ログアウト。
-
キャッシュ
外部API(気象情報、潮汐情報、先ほど解説した予測API)へユーザーが直接ブラウザからアクセスすると、コストと時間がかかります。そこで、アプリの構成図のように、頻繁にアクセスされるデータをメモリ上にキャッシュするように実装しました。
// 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...関数群が実行されるようになっていて、キャッシュを最新の状態に更新します。本番環境では 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を利用しました。
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)
DBはPostgreSQLを使用しました。開発環境ではDockerコンテナとして、本番環境では Supabase を通じて利用しています。
テーブル設計
このサイトではログイン機能を実装していないためUserテーブルはありません。掲示板機能のテーブルのみになっています。
-
posts: 口コミの投稿そのものを格納。 -
replies:postsテーブルへの返信を格納。parent_reply_idカラムが自身のrepliesテーブルを参照することで、返信への返信(スレッド形式)を実現している。 -
reactions: 「good」「bad」といったリアクション情報を格納。
フロントエンド (Next.js)
フロントエンドはNext.js (App Router)とTypeScriptを使用しました。
ページ構成とデータ取得
-
トップページ (
/): クライアントコンポーネントとして実装。 -
詳細ページ (
/detail/[date]): サーバーコンポーネントとして実装しており、fetchにnext: { revalidate: 3600 }オプションを指定し ISRを実現している。
湧きレベル判定
このサイトでは、予測身投げ量を「湧きなし」「プチ湧き」「チョイ湧き」「湧き」「大湧き」「爆湧き」の6段階のレベルで予報します。
APIから予測身投げ量の数値が返ってくるのですが、その値は0~4の値をとります。現時点ではそのAPIから返ってくる値を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 は、口コミの表示、投稿、フィルタリング、検索、並び替え、ページネーションといった多くの機能を持ちます。
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]);
// ...
}
-
状態管理:
useStateを多用して、ユーザーの入力や選択の状態を管理。 -
パフォーマンス最適化:
useMemoを活用し、フィルタリングやソートといった重い処理の結果をメモ化している。
ログイン機能無しでのリアクション管理
本アプリケーションでは、ユーザーの利用開始時の負担を最小限に抑えるため、あえてログイン機能を実装していません。これにより、誰でも気軽に口コミの閲覧や投稿ができるようになっています。
「good」「bad」のリアクション機能については、ユーザーが行ったリアクションを記憶するために、ブラウザの localStorage を利用しています。ブラウザやデバイスを変えると何回もリアクションを押せるようになっていますが仕方ないです。
UI
今回のアプリではUIも自分なりにこだわってみました。
全体的にモダンな雰囲気を目指し、ホタルイカの神秘的な世界観に合うよう意識しました。
また、深夜の時間帯に利用されることが多いと思うので、暗い環境でも目が疲れにくいように暗めのデザインにしました。また、スマートフォンからのアクセスがメインと思い、レスポンシブ対応にも気をつけて開発を進めました。
デザイン面が苦手なので、最初に bolt.new に自分が想像しているデザインを指示し、Next.jsのプロジェクトコードを生成してもらい、それをダウンロードして開発を開始しました。
開発環境と本番環境の構成
ローカル開発ではDocker Composeでコンテナを一括管理し、本番環境では各サービスでクラウドサービスを使用しています。
ローカル開発環境 (Docker Compose)
開発環境では、docker-compose.yml を用いて frontend, backend, db の3つのサービスを定義し、連携させています。
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)
本番環境では、以下のクラウドサービスを組み合わせて利用しています。
- フロントエンド (Next.js): Vercel
- バックエンド (Go): Google Cloud Run
- データベース (PostgreSQL) および ストレージ: Supabase
課題点や懸念点
海岸ごとの予測ができていない
現在のモデルでは、富山湾全体としてのホタルイカの湧き量(身投げ量)を予測しています。そのため、特定の海岸ごとの違いまでは反映できていません。実際には、同じ日でも場所によって湧き量に差が出ることがあり、予測精度の低下やサイトの信頼性の低下つながることが心配です。
身投げ量の予測精度が外部APIの精度に影響される
このサイトでは、自作の機械学習モデルに外部APIから取得したデータを入力し、身投げ量を予測しています。なので、そもそも外部APIの予報精度が低い場合には、予測精度が落ちてしまいます。今後は、より精度の高い気象予報APIの使用を検討する必要があると感じています。
サイトを訪れるユーザーの母体が少ない
サイトの主な利用者は最低でもホタルイカ掬いに関心のある人に限られます。そのためユーザー母数が少なく、また利用が集中するのは2〜5月のシーズン期間のみです。オフシーズンにはアクセスが減少しやすく、ユーザーが離れてしまいます。季節外でも使ってもらえる工夫が必要だと感じています。
終わりに
なんとかアプリを形にすることができてよかったです。
2026年のホタルイカシーズンの時に公開したいと思います。
大学生で時間があるうちに、もっと個人開発をしたいと思います!





