初次見面!您知道螢火蟹的產卵現象嗎?產卵是指螢火蟹為了產卵而湧向富山縣海岸的現象。這一現象是在特定自然條件下發生的,當產卵量多的時候,海岸上可以看到被青白色光芒佔滿的幻想景象。每年春天,很多人不論是本地還是外地都會湧向富山縣海岸,享受捕捉螢火蟹的樂趣。

產卵主要是在以下條件重疊時更容易發生。
然而,即使這些條件都具備,有時候也可能一隻都不會過來,因此預測是非常困難的。
我製作了一個預測這種產卵量的網站。

<iframe id="qiita-embed-content__a767851e3a53e5d7db3b90802f28596b"></iframe>
在螢火蟹的淡季期間不會顯示預報和詳細資訊。季節期間的顯示可以在以下的預覽頁面中查看,這裡使用了演示數據。
<iframe id="qiita-embed-content__f4a76a8d033757925df2414c8e17d433"></iframe>
應用中使用的技術概述。
伺服器端
前端
資料庫・儲存
部署平台
機器學習
其他
以下是這個應用程序流程的示意圖。

1. 數據集構建
收集過去的螢火蟹產卵量(實績值)及其對應日期的氣象數據、潮汐數據、月齡等特徵量,並將其整理為一個JSON文件。
2. 機器學習模型的訓練
利用所構建的數據集,使用LightGBM來學習螢火蟹的產卵量預測模型。
3. 預測API的開發
集成訓練好的模型,接收未來天氣預報和月齡等輸入,預測產卵量並以JSON格式返回預測的產卵量的API。
4. 網站的建設
利用所創建的預測API構建網站。
由於我在機器學習等領域完全沒有經驗,因此在進行時依賴AI的指導。不確定這是否是正確的方法。
預測模型的構建,主要分為以下步驟進行。
首先,讀取來自各個數據源收集和統合的JSON文件。這個文件包含了每日的螢火蟹產卵量、月齡、氣溫、風力、降水量的數據。數據量涵蓋1220天的時間範圍。
此次我們通過「周期性」和「過去信息」兩個觀點來創建特徵量。
月齡(約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)
# 將一年中的日期轉換為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日前的值
這次選用了計算速度高且精度高的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獲取最新預報,利用訓練好的機器學習模型計算預測值,並返回預測值。

<iframe id="qiita-embed-content__33c8f5f5f67effab3a86e0ccd00a5f79"></iframe>
<iframe id="qiita-embed-content__9a44622d0030b62b80f71556a169e656"></iframe>
使用這些API的話非盈利用途可以免費使用。
返回一週(包括當天)的螢火蟹的預測產卵量數據和其他主要相關數據。
/predict/weekGET響應主體的範例(返回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。
因此,這個網站將 每日的切換時間設為 5:00。
例如「4/10的預報」,實際上指的是「4/10的22:00 到 4/11的4:00左右」的產卵量。這一設定從機器學習模型的訓練階段就一致適用。
在網站開發部分稍後會提到,這個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端的Python腳本中對從外部API獲得的數據進行精確重現。
我多次進行了登記API預測時使用的特徵量值並將其與實際值進行比較的工作。
我利用所創建的預測API建設了網站。
整個應用程序構成中,這個紅框部分是這一章的內容。

<iframe id="qiita-embed-content__ad4cb42b1e685506b16ac28b5cbfb739"></iframe>
整個應用程序主要由以下三個服務構成,並由Docker Compose進行管理。
後端使用Go。由於本次需求中標準庫已經足夠,因此未使用框架。
預報相關
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
}
當有POST請求到/api/tasks/refresh-cache時,會執行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。
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。在開發環境中作為Docker容器進行使用,在生產環境中通過Supabase來使用。
由於本網站沒有實現登錄功能,User表不存在,只有公告板功能的表格。

posts: 儲存評論的內容。replies: 儲存對posts表的回覆。通過 parent_reply_id 欄位參照自身的 replies 表,實現對回覆的回覆(線程形式)。reactions: 儲存「好」和「壞」等反應信息。前端使用Next.js (App Router)和TypeScript。
/): 作為客戶端組件實現。/detail/[date]): 作為伺服器組件實現,fetch時指定 next: { revalidate: 3600 } 來實現ISR。本網站將預測產卵量分為「無湧現」「小湧現」「輕湧現」「湧現」「大湧現」「爆量湧現」共六個等級進行預報。
API返回的預測產卵量數值範圍在0到4之間,目前的設定是當這個值為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 來備忘過濾和排序等重計算的結果。本應用為了減少用戶的使用門檻,特意沒有實現登入功能。這樣,任何人都可以輕鬆瀏覽和發表評論。
對於「好」「壞」的反應功能,使用瀏覽器的 localStorage 來記錄用戶的反應。更換瀏覽器或設備則可以多次進行反應的操作,雖然這無可避免。
在這次應用中,我對UI 相當重視。
整體上旨在營造現代感,並考慮到螢火蟹神秘世界觀的配合。
此外,考慮到主要的使用時間是在深夜,因此設計了不易疲勞的暗色主題。同時也特別注意響應式設計,以便適應智能手機的訪問。
由於自己對設計較弱,所以一開始使用了 bolt.new 生成與我想的設計相符的Next.js項目代碼,並下載後進行開發。
在本地開發中,使用Docker Compose集中管理容器,生產環境則各服務使用雲端服務。
開發環境中,通過docker-compose.yml定義 frontend、backend 和 db 三個服務,並讓其協作。
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月的季節期間。在淡季期間,訪問量會減少,容易導致用戶流失。需要設法讓其在非季節時期仍能被使用。
能夠將應用實現出來真是太好了。
希望在2026年的螢火蟹季節時能夠公開使用。