🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

目次

  1. 前言
  2. 應用概覽
  3. 使用技術
  4. 應用程序構成圖
  5. 開發流程
  6. 機器學習模型的訓練
  7. 預測API的開發
  8. 網站的建設
  9. 課題與顧慮
  10. 結語

前言

螢火蟹的產卵現象

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

image.png

產卵容易發生的條件

產卵主要是在以下條件重疊時更容易發生。

  • 月齡:新月前後,月光少的夜晚。
  • 潮汐:接近漲潮的時間段。
  • 天氣:晴朗且波浪平穩的日子。
  • 風向:南風吹拂時,能將沖繩海域的螢火蟹推向岸邊。

然而,即使這些條件都具備,有時候也可能一隻都不會過來,因此預測是非常困難的。

我製作了一個預測這種產卵量的網站。

image.png

應用概覽

應用URL

<iframe id="qiita-embed-content__a767851e3a53e5d7db3b90802f28596b"></iframe>

在螢火蟹的淡季期間不會顯示預報和詳細資訊。季節期間的顯示可以在以下的預覽頁面中查看,這裡使用了演示數據。

<iframe id="qiita-embed-content__f4a76a8d033757925df2414c8e17d433"></iframe>

功能

  • 產卵量預報:可以查看未來7天的預測產卵量指數。
  • 詳細資訊:可以查看每日期的逐時天氣、波高、潮位、月齡等詳細數據。
  • 公告板功能:可以分享和交流當地的最新資訊及有關螢火蟹的話題。

創建動機

  • 出生於富山,偶爾會去捕捉螢火蟹。
  • 即使親自到現場,有時也很難看到螢火蟹湧現,覺得能預測會很方便。
  • 現有最常用的螢火蟹捕捉公告網站很不方便,因此想自己做一個。

使用技術

應用中使用的技術概述。

伺服器端

  • Go(網站)
  • FastAPI(預測API)

前端

  • TypeScript (Next.js)
  • Tailwind CSS
  • shadcn/ui

資料庫・儲存

  • Supabase

部署平台

  • Vercel
  • GCP(Cloud Run)

機器學習

  • LightGBM

其他

  • Git
  • Github
  • Docker
  • Docker-compose

應用程序構成圖

以下是這個應用程序流程的示意圖。

image.png

開發流程

1. 數據集構建
收集過去的螢火蟹產卵量(實績值)及其對應日期的氣象數據、潮汐數據、月齡等特徵量,並將其整理為一個JSON文件。

2. 機器學習模型的訓練
利用所構建的數據集,使用LightGBM來學習螢火蟹的產卵量預測模型。

3. 預測API的開發
集成訓練好的模型,接收未來天氣預報和月齡等輸入,預測產卵量並以JSON格式返回預測的產卵量的API。

4. 網站的建設
利用所創建的預測API構建網站。

機器學習模型的訓練

由於我在機器學習等領域完全沒有經驗,因此在進行時依賴AI的指導。不確定這是否是正確的方法。

預測模型構建的步驟

預測模型的構建,主要分為以下步驟進行。

  1. 數據讀取與前處理: 讀取JSON格式的數據,整理為便於處理的形式
  2. 特徵量工程: 創建預測上可能有幫助的新特徵量
  3. 模型學習: 使用LightGBM進行模型的學習
  4. 評估與可視化: 評估模型精度,並對結果進行可視化分析

1. 數據讀取與前處理

首先,讀取來自各個數據源收集和統合的JSON文件。這個文件包含了每日的螢火蟹產卵量、月齡、氣溫、風力、降水量的數據。數據量涵蓋1220天的時間範圍。

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)

# 將一年中的日期轉換為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 (均方根誤差): 也是誤差指標之一,重視較大誤差。

算出的評估指標

目前結果如下。

  • 決定係數 (R²): 0.7008
  • 平均絕對誤差 (MAE): 0.0721
  • 均方根誤差 (RMSE): 0.1013

預測結果與實際值的比較

利用訓練好的模型對測試數據的產卵量進行預測,並與實際值進行比較。

image.png

藍色為實際的過去產卵量(正確值),紅色為預測的產卵量。
從圖中可以看到,產卵量的漲落在一定程度上被捕捉到了,但仍然有改進的空間。

特徵量的重要性

在特徵量的重要性排名中,temperature_stdprecipitation_sumday_of_year_sinmoon_age_coswind_speed_std_lag1temperature_std_lag1等位居前列,顯示季節、氣溫、月齡、降水量等都特別影響產卵量。

預測API的開發

建立的API概述

我開發了一個API,用於返回利用前面訓練的模型的預測值。框架使用FastAPI,部署在GCP(Cloud Run)上。

API的結構

API的運行流程如下。請求從外部API獲取最新預報,利用訓練好的機器學習模型計算預測值,並返回預測值。

image.png

使用的外部API

<iframe id="qiita-embed-content__33c8f5f5f67effab3a86e0ccd00a5f79"></iframe>

<iframe id="qiita-embed-content__9a44622d0030b62b80f71556a169e656"></iframe>

使用這些API的話非盈利用途可以免費使用。

API端點

返回一週(包括當天)的螢火蟹的預測產卵量數據和其他主要相關數據。

  • 端點: /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
因此,這個網站將 每日的切換時間設為 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建設了網站。
整個應用程序構成中,這個紅框部分是這一章的內容。

image.png

Github鏈接

<iframe id="qiita-embed-content__ad4cb42b1e685506b16ac28b5cbfb739"></iframe>

應用程序構成

整個應用程序主要由以下三個服務構成,並由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
}

當有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認證

某些操作如刪除評論等,需要限於管理員才能進行。這一認證使用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,並以HttpOnlySecureSameSite=None屬性的Cookie返回給客戶端。

資料庫 (PostgreSQL & Supabase)

資料庫使用PostgreSQL。在開發環境中作為Docker容器進行使用,在生產環境中通過Supabase來使用。

表格設計

由於本網站沒有實現登錄功能,User表不存在,只有公告板功能的表格。

image.png

  • posts: 儲存評論的內容。
  • replies: 儲存對posts表的回覆。通過 parent_reply_id 欄位參照自身的 replies 表,實現對回覆的回覆(線程形式)。
  • reactions: 儲存「好」和「壞」等反應信息。

前端 (Next.js)

前端使用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)

開發環境中,通過docker-compose.yml定義 frontendbackenddb 三個服務,並讓其協作。

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年的螢火蟹季節時能夠公開使用。


原文出處:https://qiita.com/yuchi1128/items/eca2bb94dec63ecbff93


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝28   💬4   ❤️7
840
🥈
我愛JS
📝2   💬8   ❤️2
113
🥉
御魂
💬1  
4
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付