ホタルイカのオフシーズン中(2〜5月以外)は予報や詳細情報を表示されず「オフシーズン」と表示されます。シーズン中の表示は、デモデータを用いた以下のプレビューページで確認できます。
アプリ全体で使用している技術です。
以下はこのアプリの流れを示した図になります。
ここからはこれらを順に解説していきます。
ホタルイカの身投げ量は公的なデータが存在しないため、掲示板サイトの口コミを利用して数値化しました。
気象庁公式サイト より、過去の天気、風、気温、降水量などのCSVデータをダウンロードし、JSONに変換しました。 ※身投げデータが存在する日付に対応するデータを取得。
外部API(tide736.net)を経由して、対象日の潮位、潮の種類を取得しJSON化しました。
西暦と日付から計算式を用いて算出しました。
上記で作成した複数のJSONファイルを、日付をキー(主キー)として一つのJSONデータセットに結合しました。
私は、機械学習などの分野は全くの未経験だったのでAIに聞きながら行いました。これが正しい方法だったかは分かりません。
まず、様々なデータソースから収集・統合した JSON ファイルを読み込みます。 このファイルには、日ごとのホタルイカの平均身投げ量と、その日に対応する1時間ごとの気象データ・潮位データ・月齢データが含まれています。 対象期間は2〜5月の10年間で、計1220日分のデータがあります。
予測に使用した主な特徴量は以下の通りです。
「周期性」と「過去の情報」の2つの観点を工夫し特徴量を作成しました。
# 月齢を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)
# ラグ特徴量を作成したいカラムのリスト
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日前の値
今回は高精度で計算も高速な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_
モデルの精度を評価します。 ここでは、学習には使っていない 全データの最後の20% を「テストデータ」として使用し、予測値と実際の値を比較しました。
評価指標は以下の通りです。今回は、0から1の範囲に正規化された値で評価指標を計算します。
このようになりました。
学習済みモデルを使ってテストデータの身投げ量を予測し、実際の値と比較しました。
青色が実際の過去の身投げ量(正解値)で、赤色が予測された身投げ量です。 グラフを見ると、身投げ量の増減はある程度捉えられています。しかし、まだ改善の余地がありそうです。
特徴量の重要度の上位には、temperature_std、precipitation_sum、day_of_year_sin、moon_age_cos、wind_speed_std_lag1、temperature_std_lag1 などがあり、季節、気温、月齢、降水量などが特に身投げ量に影響することがわかりました。
前回で学習させたモデルを使用して予測値を返すAPIを開発しました。フレームワークはFastAPI、GCP(Cloud Run)上にデプロイしています。
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で予測時に使用した特徴量の値をログで出して、実際の値との違いを比べるといった作業を何回も行いました。
作成した予測APIを用いて、Webサイトを作成しました。 アプリケーション全体構成の中の、この赤枠の部分がこのセクションの内容になります。
外部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を利用しました。
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としてクライアントに送信します。
このサイトではログイン機能を実装していないためUserテーブルはありません。掲示板機能のテーブルのみになっています。
このサイトでは、予測身投げ量を「湧きなし」「プチ湧き」「チョイ湧き」「湧き」「大湧き」「爆湧き」の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も自分なりにこだわってみました。 全体的にモダンな雰囲気を目指し、ホタルイカの神秘的な世界観に合うよう意識しました。 また、深夜の時間帯に利用されることが多いと思うので、暗い環境でも目が疲れにくいように暗めのデザインにしました。また、スマートフォンからのアクセスがメインと思い、レスポンシブ対応にも気をつけて開発を進めました。 デザイン面が苦手なので、最初に bolt.new に自分が想像しているデザインを指示し、Next.jsのプロジェクトコードを生成してもらい、それをダウンロードして開発を開始しました。
開発環境では、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
# ...
本番環境では、以下のクラウドサービスを組み合わせて利用しています。
現在のモデルでは、富山湾全体としてのホタルイカの湧き量(身投げ量)を予測しています。そのため、特定の海岸ごとの違いまでは反映できていません。実際には、同じ日でも場所によって湧き量に差が出ることがあり、予測精度の低下やサイトの信頼性の低下つながることが心配です。
このサイトでは、自作の機械学習モデルに外部APIから取得したデータを入力し、身投げ量を予測しています。なので、そもそも外部APIの予報精度が低い場合には、予測精度が落ちてしまいます。今後は、より精度の高い気象予報APIの使用を検討する必要があると感じています。
サイトの主な利用者は最低でもホタルイカ掬いに関心のある人に限られます。そのためユーザー母数が少なく、また利用が集中するのは2〜5月のシーズン期間のみです。オフシーズンにはアクセスが減少しやすく、ユーザーが離れてしまいます。季節外でも使ってもらえる工夫が必要だと感じています。
なんとかアプリを形にすることができてよかったです。