#author("2025-12-21T05:03:29+01:00","","")
#author("2025-12-21T05:04:57+01:00","","")
*引き継ぎ [#c96934e6]
[[引き継ぎ用ページ]]

*目次 [#title]

#CONTENTS

* アプリ概要 [#overview]

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


:アプリURL|
https://bakuwaki.jp/

:プレビューページ(シーズン中デモ)|
https://bakuwaki.jp/preview

:Webサイト構築コード(GitHub)|
https://github.com/yuchi1128/hotaruika-bakuwaki-forecast



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

**作成動機 [#motivation]
-富山出身でホタルイカ掬いにたまに行く。
-現地に行ってもホタルイカが湧かないことも多く、予測できたら便利だと思った。
-既存で一番使われているホタルイカ掬い用の掲示板サイトが使いづらく、自分で作りたくなった。

*使用技術 [#tech_stack]
アプリ全体で使用している技術です。

***サーバーサイド [#server]
-Go(サイト)
-FastAPI(予測API)

***フロントエンド [#frontend]
-TypeScript (Next.js)
-Tailwind CSS
-shadcn/ui

***データベース・ストレージ [#db]
-Supabase

***デプロイ先 [#deploy]
-Vercel
-GCP(Cloud Run)

***機械学習 [#ml]
-LightGBM

***その他 [#others]
-Git
-GitHub
-GitHub Actions
-Docker
-Docker-Compose
-Google Cloud Scheduler




*アプリ構成図 [#architecture]
以下はこのアプリの流れを示した図になります。

#ref(アプリ構成図.png,,40%)




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

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



*データセットの構築 [#data_collection]

#ref(data_set.png,,25%)

***1. ホタルイカ身投げデータの取得 (スクレイピング) [#scraping]
ホタルイカの身投げ量は公的なデータが存在しないため、掲示板サイトの口コミを利用して数値化しました。
-取得元: [[ホタルイカ掲示板:https://rara.jp/hotaruika-toyama/]]
-対象期間: 2015年〜2025年の10年間(各年2月〜5月)、約1300日分
-処理内容:
++口コミ内容をスクレイピングで取得。
++内容から身投げ量を5段階評価し、0〜4の整数で数値化。
++日ごとの平均値を算出し、その日の「身投げ量」として日付とともにJSON保存。

***2. 過去の気象データの取得 [#weather_data]
[[気象庁公式サイト:https://www.data.jma.go.jp/risk/obsdl/#]] より、過去の天気、風、気温、降水量などのCSVデータをダウンロードし、JSONに変換しました。
※身投げデータが存在する日付に対応するデータを取得。

***3. 潮位データの取得 [#tide_data]
外部API([[tide736.net:https://tide736.net/#google_vignette]])を経由して、対象日の潮位、潮の種類を取得しJSON化しました。

***4. 月齢データの取得 [#moon_data]
西暦と日付から計算式を用いて算出しました。

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




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

***モデル構築のステップ [#ml_steps]
+データ読み込みと前処理: JSON形式のデータを読み込み、扱いやすい形に整形
+特徴量エンジニアリング: 予測に役立ちそうな新しい特徴量を作成
+モデル学習: LightGBMを使ってモデルを学習
+評価と可視化: モデルの精度を評価し、結果を可視化して考察

以下はモデル構築の流れを示した図になります。

#ref(model_learn.png,,25%)
#ref(model_learn.png,,70%)


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

**2. 特徴量エンジニアリング [#feature_eng]

***使用した特徴量 (説明変数) [#features]
予測に使用した主な特徴量は以下の通りです。

:時間特徴量|年、月、日、曜日、年の第何週
:月齢 (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日間の平均値。

***工夫した点 [#r5c8c58d]

「周期性」と「過去の情報」の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. モデル学習 [#model_training]
今回は高精度で計算も高速なLightGBMを使用しました。

***時系列データの交差検証とハイパーパラメータチューニング [#tuning]
時系列データを扱う上で最も重要なのは、未来のデータを使って過去を予測しないことです。
これを防ぐため、交差検証に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. 評価と可視化 [#evaluation]
モデルの精度を評価します。
ここでは、学習には使っていない 全データの最後の20% を「テストデータ」として使用し、予測値と実際の値を比較しました。

評価指標は以下の通りです。今回は、0から1の範囲に正規化された値で評価指標を計算します。
-R² (決定係数): 1に近いほど良いモデル。
-MAE (平均絶対誤差): 予測値と実測値の誤差の平均。小さいほど良い。
-RMSE (二乗平均平方根誤差): MAEと同様に誤差の指標。大きな誤差をより重視する。

***算出された評価指数 [#score]
このようになりました。
-決定係数 (R²): 0.7008
-平均絶対誤差 (MAE): 0.0721
-二乗平均平方根誤差 (RMSE): 0.1013

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

#ref(予測精度グラフ.png,,30%)

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

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





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

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

#ref(API概要.png,,20%)

**使用した外部API [#external_api]
非営利なら無料で使用できるこれらのAPIを使用しました。

**APIエンドポイント [#endpoint]
1週間分(当日を含む7日間)のホタルイの予測身投げ量データと、その他の主要な関連データ返します。
-エンドポイント: /predict/week
-HTTPメソッド: GET
-認証: 不要

***レスポンスボディの例 [#response_example]
(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
   },
 ]

**時間の基準 [#time_standard]
ホタルイカの身投げは主に 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_challenges]
「学習時と予測時でデータの前処理を完全に一致させること」がとても大変でした。
学習時に実施した欠損値補完・スケーリング・特徴量生成などの前処理を、外部APIから取得したデータに対してもAPI側のPythonスクリプト上で厳密に再現する必要がありました。
APIで予測時に使用した特徴量の値をログで出して、実際の値との違いを比べるといった作業を何回も行いました。





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

#ref(アプリ構成図_web.png,,25%)


**アプリケーション構成 [#web_config]
-フロントエンド: Next.js (TypeScript), shadcn/ui, Tailwind CSS
-バックエンド: Go
-データベース: PostgreSQL

**バックエンド (Go) [#backend]
***主なAPIエンドポイント [#backend_endpoints]
-予報関連
--GET /api/prediction: キャッシュにある予測身投げ量を返す。
--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: ログアウト。

***キャッシュ [#cache]
外部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認証 [#admin_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) [#database]
***テーブル設計 [#table_design]
このサイトではログイン機能を実装していないためUserテーブルはありません。掲示板機能のテーブルのみになっています。

#ref(DB構成図.png,,20%)

-posts: 口コミの投稿そのものを格納。
-replies: posts テーブルへの返信を格納。parent_reply_id カラムが自身の replies テーブルを参照することで、返信への返信(スレッド形式)を実現している。
-reactions: 「good」「bad」といったリアクション情報を格納。

**フロントエンド (Next.js) [#frontend_js]
***ページ構成とデータ取得 [#page_structure]
-トップページ (/): クライアントコンポーネントとして実装。
-詳細ページ (/detail/[date]): サーバーコンポーネントとして実装しており、fetch に next: { revalidate: 3600 } オプションを指定し ISRを構成。

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

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

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

***複雑な状態を管理するコンポーネント [#component]
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]);
 
   // ...
 }

-状態管理: useState を多用して、ユーザーの入力や選択の状態を管理。
-パフォーマンス最適化: useMemo を活用し、フィルタリングやソートといった重い処理の結果をメモ化。

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

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

**開発環境と本番環境の構成 [#environments]
***ローカル開発環境 (Docker Compose) [#local_env]
開発環境では、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) [#prod_env]
本番環境では、以下のクラウドサービスを組み合わせて利用しています。
-フロントエンド (Next.js): Vercel
-バックエンド (Go): Google Cloud Run
-データベース (PostgreSQL) および ストレージ: Supabase




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

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

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

*終わりに [#conclusion]
なんとかアプリを形にすることができてよかったです。

トップ   編集 差分 履歴 添付 複製 名前変更 リロード   新規 一覧 検索 最終更新   ヘルプ   最終更新のRSS