跳至主要內容
林承翰
角色
獨立開發(畢業專題)
期間
2025 年 9 月 – 2026 年 6 月
類型
畢業專題
範圍
ML · 後端 · 前端行動端 · 基礎設施
技術
Django REST FrameworkReactPostgreSQLChromaDBscikit-learn
連結
heartbox.tw原始碼

HeartBox

以檢索增強生成(RAG)為基礎的心理健康日記平台,結合 Random Forest 情緒預測與自架模型推論。從機器學習、後端、前端到基礎設施,都由一個人完成。

成效指標

5-fold 交叉驗證。迴歸任務 22,796 列,高壓日分類任務 31,720 列。

情緒分數 MAE分數範圍 −1 到 +1
0.22
壓力指數 MAE指數範圍 0 到 10
1.04
高壓日 AUC排序能力接近完美
0.948
高壓日 Recall刻意偏向不漏抓
88%
HeartBox 儀表板的心情趨勢卡片:紅色的平均壓力在 0 到 9 之間反覆起伏,橘色的平均情緒貼著 0 微幅波動,時間軸涵蓋過去 180 天

一個把大型語言模型變成「不會亂講話」的心理健康日記平台:日記情緒偏低時,AI 的回饋必須引用 WHO、APA 等臨床來源;同時用 Random Forest 從過去 14 天的行為預測 3 天後的情緒,提前給出壓力預警。獨立開發的畢業專題,從機器學習、後端、前端、行動端到基礎設施,都由一個人完成。

線上體驗與原始碼

線上版本:heartbox.tw,無需註冊,直接用測試帳號登入。

三組帳號分別展示系統在不同情緒情境下的行為:

帳號密碼情境適合看什麼
test1test1波動型情緒起伏大,看圖表如何呈現波動、身心關聯如何找出睡眠與活動的影響
test2test2正向型長期正向累積,看「我的進展」如何呈現成長軌跡
test3test3負向型情緒持續低落時,系統如何主動啟動關懷機制與知識庫建議

原始碼:github.com/alanlin0604/HeartBox

要解決的問題

全球約 5.7% 的成年人受憂鬱症影響,總計約 3.32 億人(WHO),台灣青年的心理困擾比例也逐年上升。但求助這件事本身就有門檻:諮商資源不足、候診時間長,而污名化讓很多人根本不會踏出第一步。

市面上的心情記錄 App 填不了這個空缺,原因不只是功能少:

  • 回饋停留在表面。記錄了情緒,但沒有任何分析。
  • AI 會編造內容。通用 LLM 給的心理建議沒有依據,在這個領域是有實際傷害的。
  • 敏感資料多為明文儲存。心情日記是最私密的文字之一。
  • 只能事後回顧。看得到過去的低潮,看不到即將到來的。

HeartBox 要處理的是中間這一段:在還沒到需要專業介入之前,提供一個有依據、可信任、且看得到未來趨勢的自我覺察工具。它不取代諮商,而是降低「發現自己不對勁」的門檻。

限制條件

這些限制決定了後面所有的技術選擇:

  1. 每位使用者的資料極少。不是幾萬筆,是幾百筆。任何需要大量資料才能訓練的方法,一開始就不在考慮範圍內。
  2. 資料極度敏感。日記內容是使用者最私密的文字,把它送到第三方 API 是很難說服自己的決定。
  3. 建議不能是編造的。一般聊天機器人講錯話的代價是尷尬,心理健康場景講錯話的代價不是。
  4. 回饋必須即時。使用者寫完日記就要看到分析,不能等。
  5. 個體差異大到無法跨人比較。有人的壓力來源是失眠,有人是缺乏運動,「平均使用者」在這裡沒有意義。
  6. 一個人開發與維運。沒有團隊可以分攤複雜度,任何需要持續調參或照顧的方案都會變成負債。

關鍵決策

這一節是整頁的重點。每個決策都寫成「情境 → 選項 → 取捨 → 決定」。

為什麼用 RAG,而不是直接讓 LLM 回答?

情境
使用者寫下低落的日記後,系統要給出回應。通用 LLM 能產出看起來很專業的心理建議,但那些建議可能完全是編造的。而且因為語氣溫暖、結構完整,反而更容易被相信。
考慮過的選項
  • 直接呼叫 LLM,靠 prompt 要求它「謹慎回答」
  • 改用固定的罐頭回覆,完全不讓模型自由生成
  • 檢索增強生成:先從臨床文件找出相關段落,要求模型必須基於這些段落回答
取捨
RAG 讓每次回饋多了一次向量檢索與更長的 prompt,回應變慢、實作也更複雜;而且知識庫的涵蓋範圍會直接變成回答品質的天花板。罐頭回覆雖然絕對安全,但沒有針對性,使用者兩次就不看了。
決定
採用 RAG。知識庫收錄 7 份國際機構文件:WHO 的壓力管理指南、心理健康行動方案與《Doing What Matters in Times of Stress》、APA 的壓力管理技巧與復原力建構、NHS 的心理福祉建議、NIMH 的壓力因應指南。以 BGE-M3 embeddings 把每個段落轉成一串代表語意的數字,建立可依語意相近程度搜尋的向量索引,每次取語意最接近的三個段落,並要求模型的建議必須落在檢索到的內容範圍內。代價是延遲與複雜度,換到的是「這句建議出自哪裡」可以被追溯。

RAG 的完整流程是五個步驟:

  1. 使用者寫日記
  2. 模型判讀情緒,給出 −1 到 +1 的情緒分數
  3. 分數低於 −0.4 才觸發知識庫查詢
  4. ChromaDB 從 7 份臨床文件中檢索最相關的 top-3 段落
  5. 模型結合檢索內容產出回饋

為什麼把觸發門檻設在 −0.4?

情境
不是每篇日記都需要搬出臨床文件。「今天有點累」跟「什麼都不想做了」需要的回應層級完全不同,但兩者都會被判為負面情緒。門檻定在哪裡,決定了系統是恰到好處還是惱人。
考慮過的選項
  • 不設門檻,每篇日記都跑 RAG
  • 門檻設高(例如 −0.2),寧可多觸發
  • 門檻設低(例如 −0.6),只在明顯低落時介入
取捨
門檻太高會讓系統對輕微的日常抱怨也引用心理學文獻,顯得說教而且很快就被忽略。這是「狼來了」的反面:不是漏報,是報得太多而失去意義。門檻太低則會錯過那些寫得含蓄、但其實需要被接住的內容。每篇都跑則同時付出延遲與 GPU 成本。
決定
設在 −0.4。這個位置讓明確的負面情緒會觸發、一般的日常抱怨不會,在誤觸發與漏接之間取得平衡。必須誠實說明:這個數字是根據實際日記樣本的判斷結果訂出來的,沒有做系統性的敏感度分析,這是我會優先補上的實驗。

為什麼選 Random Forest,而不是 XGBoost 或 LSTM?

情境
情緒預測本質上是時間序列問題,直覺會想用 LSTM。但這裡的條件很特殊:每位使用者只有幾百筆資料、預測要在寫完日記的當下就回傳、而且使用者會問「為什麼系統覺得我壓力會變高」。
考慮過的選項
  • 線性迴歸:最簡單、最好解釋
  • 單一決策樹:規則完全透明
  • Random Forest:100 棵樹集成
  • XGBoost:調參後通常準確度最高
  • LSTM:最適合時間序列的直覺選擇
取捨
放棄 LSTM 等於放棄它可能捕捉到的長期時間結構,也放棄了資料量長大之後的準確度上限。放棄 XGBoost 等於放棄調參後通常能再多拿到的那幾個百分點。換來的是:幾百筆就能訓練、純 CPU 50ms 內完成推論、可以用特徵重要度(模型自己給出的「哪些輸入對這次預測影響最大」)解釋、而且幾乎不需要調參。
決定
選 Random Forest,100 棵樹。迴歸任務(情緒分數、壓力指數)取 100 棵樹的平均,分類任務(高壓日預警)平均各棵樹給出的機率。四項本專題的關鍵需求它同時滿足:資料量少、可解釋、CPU 即時推論、抗過擬合,而且不必調參就有堪用的表現。
五種候選演算法在資料量需求、可解釋性、推論成本、過擬合風險與準確度上的比較
演算法資料量需求可解釋性推論成本過擬合風險準確度與穩定度
線性迴歸少量即可高(係數)極低易欠擬合受限於線性假設
單一決策樹少量即可高(規則)極低高(易過擬)不穩定
Random Forest★ 選用幾百筆即可訓練中高(特徵重要度)低・純 CPU <50ms低(Bagging 天然抗過擬)高・穩定且幾乎免調參
XGBoost中等需求中高(特徵重要度)低中(需調參)高(調參後常略勝)
LSTM需大量(萬筆+)低(黑箱)中(需深度學習執行環境)高(資料少時)高(資料足時)

為什麼把 recall 調到 88%,而不是追求 precision?

情境
高壓日預警是一個分類問題。recall 是「真正的高壓日裡,系統抓到了多少」;precision 是「系統發出的警示裡,有多少是對的」。兩者互相拉扯:警示發得越寬鬆,抓到的越多,但誤報也越多。門檻要往哪邊偏,取決於兩種錯誤的代價哪個比較高。
考慮過的選項
  • 平衡 precision 與 recall,追求兩者的調和平均(F1)最高
  • 偏向 precision:只在很確定時才警示,避免打擾
  • 偏向 recall:寧可多警示,也不要漏掉
取捨
偏向 recall 的直接代價是誤報變多:使用者可能收到「未來幾天壓力可能升高」的提醒,結果什麼事也沒發生。誤報多了會降低提醒的份量,這是真實的成本,不能假裝沒有。
決定
偏向 recall,最終落在 88%。理由是兩種錯誤的代價不對稱:誤報的代價是一次多餘的提醒,漏報的代價是錯過一個本來可以被接住的低潮。在心理健康場景,這個不對稱大到足以決定門檻要往哪邊偏。系統的措辭也配合這個選擇,提醒寫成「可以留意一下」而不是警告,降低誤報時的壓迫感。

為什麼自架模型推論,而不是呼叫商用 API?

情境
呼叫商用 API 是最省事的做法:不用管 GPU、不用管模型部署、品質通常更好。但這個系統處理的是使用者的心情日記。
考慮過的選項
  • 呼叫商用 LLM API,最快也最省維運
  • 呼叫商用 API,但先把日記做去識別化處理
  • 自架開放權重模型,資料完全不出可控環境
取捨
自架的代價很實在:要自己準備 GPU、自己處理模型載入與推論服務、自己承擔服務掛掉的後果,而且開放權重的 7B 模型在中文生成品質上比不過商用大模型。去識別化聽起來是折衷,但日記內容本身就是識別資訊,一個人寫的生活細節無法真正匿名。
決定
自架。使用開放權重的 Qwen2.5-7B-Instruct,架在自己的 GPU 上,以 FastAPI 對外,透過 Cloudflare Tunnel 連回後端。跟商用 API 之間的生成品質差距是明確的代價,但「日記內容從來沒有離開過我能控制的環境」這件事,在這個產品上是不能妥協的前提。
HeartBox 系統架構圖四層架構,由上而下:前端由 Cloudflare Pages 提供,並以 Capacitor 打包 Android;透過 REST API 與 WebSocket 連到 Google Cloud Run 上的 Django REST Framework 後端,後端內含 Django Channels 即時通訊與 LangChain + ChromaDB 的 BGE-M3 向量檢索;後端再經 Cloudflare Tunnel 呼叫自架 GPU 推論服務(FastAPI,執行 Qwen2.5-7B-Instruct),日記內容不離開可控環境;後端另與 PostgreSQL 資料庫雙向讀寫,日記欄位以 Fernet 加密儲存。前端Cloudflare Pages ・ React ・ Vite ・ TailwindCapacitor 打包 Android,同步手機健康資料REST API ・ WebSocket後端 ・ Google Cloud RunDjango REST FrameworkDjango Channels(WebSocket 即時通訊)LangChain + ChromaDBBGE-M3 embeddings 向量檢索Cloudflare Tunnel自架 GPU 推論服務llm.heartbox.twFastAPIQwen2.5-7B-Instruct開放權重 ・ 支援繁體中文日記內容不離開可控環境資料庫 ・ PostgreSQL使用者資料 ・ 日記內容 ・ 預約與通知日記欄位以 Fernet 加密後儲存加密金鑰由環境變數注入,與資料庫分離資料庫外洩仍無法解密日記內容
HeartBox 系統架構圖。虛線框標示自架推論服務——高敏感的日記文字始終留在可控環境內。

為什麼把同意流程拆成三個獨立步驟?

情境
這個系統會收集日記內容、AI 分析出的情緒與壓力分數,以及選擇性的健康與睡眠資料。GDPR 第 4 條第 11 款要求同意必須針對特定目的,前言第 43 段指出不同的處理行為應該可以分開同意,第 7 條第 4 項再補上關鍵的一句:服務不能被綁在一個並非履約所必需的同意上。台灣個資法第 7 條第 2 項也要求特定目的外利用的同意必須單獨為之。一份「我全部同意」的勾選框無法滿足這個標準。
考慮過的選項
  • 單一同意書,一次勾選全部
  • 兩段式:服務條款 + 資料使用
  • 三段分離:資料使用 → AI 訓練同意 → 年齡確認
取捨
流程從一次點擊變成三個步驟,註冊轉換率一定會下降,而且允許使用者拒絕 AI 訓練,等於主動放棄一部分可用的訓練資料。這兩個都是真實的損失。
決定
三段分離。關鍵設計是:AI 訓練同意獨立勾選,而且拒絕的人仍然可以使用全部功能。如果拒絕會導致功能受限,那個同意在法律上就不是自由給予的。13 至 17 歲的使用者另外走家長驗證:系統寄出確認連結,家長點擊後才解鎖 AI 功能。轉換率的損失是這個決定的代價,我認為值得。

為什麼用「自己跟自己比」,而不是跨使用者的基準?

情境
做完預測模型之後的下一個問題是:怎麼讓使用者知道自己有沒有變好?直覺做法是跟其他使用者的平均比較,但心理健康的個體差異大到讓這種比較幾乎沒有意義。有人的基線情緒天生就比較低,那不代表他需要被警示。
考慮過的選項
  • 跨使用者百分位:「你的情緒分數高於 70% 的使用者」
  • 固定的絕對門檻:「情緒低於 −0.3 就算不好」
  • 個人基準:以使用者自己的首週為基準線
取捨
個人基準無法回答「我跟別人比起來如何」,也無法在使用者剛註冊時提供任何比較。資料不足時只能什麼都不顯示,這在產品體驗上是明顯的空窗。
決定
採用個人基準。以使用者首週為基準線,所有進步指標都是跟自己的過去比;「向上箭頭」一律代表進步,不需要讓使用者記憶方向。資料未滿 14 天時,介面顯示「再過 X 天解鎖」而不是硬算一個沒有統計意義的數字。這是整個專案裡我最滿意的一個決定,因為它承認了資料量不足這件事,而不是用一個看起來很專業的數字蓋過去。

成果

特徵工程的部分是 53 個特徵:12 個行為與生理指標(情緒平均、壓力平均與最高值、日記數、睡眠時數與品質、深睡比例、步數、運動分鐘、HRV、靜止心率、就寢時間標準差)各自展開成 1、3、7、14 天四個時間窗口,共 48 個 lag 特徵,再加上 5 個 calendar 特徵(星期幾、連續寫日記天數等)。

最後要說明這些數字代表什麼,以及不代表什麼。它們是離線交叉驗證的結果,證明模型能從歷史行為中學到有預測力的模式。它們不是臨床效度的證據:沒有做過對照試驗,也沒有證據顯示這些預警真的改善了任何人的心理健康。這個區分很重要,不應該被漂亮的數字蓋過去。

如果重做,我會怎麼改

  1. 驗證方式應該用時間序列切分,而不是隨機 5-fold。特徵裡有大量 lag 變數,隨機切分會讓同一段時間的資訊同時出現在訓練與驗證集,指標可能因此偏樂觀。改成用前面的時間訓練、預測後面的時間,才是誠實的評估。這是整個專案在方法學上最大的弱點,如果重做我會第一個修。

  2. −0.4 的觸發門檻沒有做敏感度分析。它是根據實際樣本判斷出來的合理值,但我沒有量化過門檻在 −0.3 或 −0.5 時的誤觸發率變化。這是可以補做的實驗,只是當時把時間花在功能上了。

  3. 沒有評估檢索本身的品質。整個 RAG 的價值建立在檢索到的段落確實相關這個前提上,但我沒有建立檢索評估集,也沒有量測過 top-3 的命中率。模型引用了來源,不等於引用了對的來源。

  4. 功能做得太多,稀釋了核心。社群、好友、課程、成就系統、呼吸冥想,這些都是完整的功能,但它們跟「RAG 讓 AI 不亂講話」和「預測未來情緒」這兩件真正有價值的事沒有關係。如果重做,我會把同樣的時間投在檢索品質評估與前瞻性驗證上,而不是第 103 個成就徽章。

  5. 低估了自架推論的維運成本。決定本身我仍然認為是對的,但 GPU 服務掛掉的時候整個 AI 功能就沒了,而我沒有準備降級路徑。至少應該做到推論服務不可用時回退到不含 AI 分析的純記錄模式,而不是讓使用者看到錯誤。

技術組成

層技術
前端React ・ Vite ・ Tailwind CSS ・ Recharts
後端Django REST Framework ・ Django Channels(WebSocket)
AIQwen2.5-7B-Instruct ・ BGE-M3 ・ LangChain ・ ChromaDB
機器學習scikit-learn(Random Forest)
資料庫PostgreSQL,日記欄位以 Fernet 加密(AES-128-CBC + HMAC-SHA256)
行動端Capacitor(Android)
部署Cloudflare Pages ・ Cloudflare Tunnel ・ Google Cloud Run

安全與隱私的部分:日記內容以 Fernet 對稱加密儲存,金鑰由環境變數注入且與資料庫分離,資料庫外洩本身不足以還原日記內容。登入支援兩步驟驗證,全站 HTTPS。日記、AI 聊天與匿名社群三處都有危機字眼偵測,觸發時即時顯示求助資源(1925 安心專線、1995 生命線、1980 張老師專線)。

回到專案列表