本篇階段:Prj#2 架構專案
使用介面:Claude Code(via VS Code)
Day 14 一行程式都沒寫,只做四件事:找參考資料、思考要哪些圖、研究挑變項方法、拿什麼指標驗收,最後產出一份 prompt。
今天看看那份 prompt 丟出去給 Claude 會有什麼成果。結果比預期順利:程式跑得起來,圖、Excel 報表、Word 報告都出來了,計算出來的結果和昨天那份 HTML 對得上。
但昨天在文章裡想清楚的東西,有幾條沒寫進 prompt。其中一條 Claude 自己選了,只是跟我原本打算的不一樣,而那個差別大到會改變最後留下哪幾個變項。
這一篇的程式碼、UI 設計稿與輸出範例請參考我的 GitHub 對應的專案連結
Day 13 那三個小工具,規矩是一條一條被問題逼出來的:圖示糊掉才知道 .ico 要多解析度、視窗跑出螢幕才考慮解析度問題、打包完打不開才知道 conda 的 DLL 不在搜尋路徑上。
在 prompt 最前面先撰寫了基礎的設計:
[基礎設計]
1. UI 類別: Python TTK
2. 架構: MVP
3. UI 設計: 透過 /design 設計
4. 使用 .ico 檔案
5. 製作 requirements.txt 與 .spec 檔案
五條 (不是 2.5 條),效果是這五件事都沒失效,.spec 裡那句把 conda 的 Library\bin 加進 PATH 的程式碼它自己就寫進去了。不過拿上面那三個教訓對一下會發現,「視窗跑出螢幕」那條並沒有進到這五條裡面(後果在第 3.2 節)。
不過它沒有直接開工,而是先丟回三個選擇題:要不要幫我產 .ico、打包要 onefile 還是 onedir、要不要額外的說明文件。中間那題我沒答,反問了一句:為什麼會用到 sklearn?因為有些統計功能不一定要靠 scikit-learn 來做,且依照我的經驗,打包 sklearn 檔案大小滿大的。
它沒有直接回「可以」或「不行」,而是去量了環境裡每個套件的大小,再把「拿掉的代價」實測出來。sklearn 在這個專案只用在 Bolasso 的 Lasso 求解器和挑 α 的交叉驗證,其餘都不需要它。替代方案是 statsmodels 的 fit_regularized,拿 500 筆 × 6 欄跑 200 次 bootstrap:sklearn 花 0.07 秒,它花 2.61 秒,慢 37 倍;而且 statsmodels 沒有內建 CV,挑 α 得自己手刻 k-fold。
結論是留著。省下的 40 MB 只佔核心相依約 320 MB 的 12%,真正撐大體積的是 scipy 那 114 MB,而它是必須的。
昨天第 4.2 節有這段:
實作上比較省事的是每一輪都用交叉驗證各自選一次(
LassoCV就在做這件事),代價是慢一點,好處是不必自己決定那個數字。我選後者,因為這支程式將來要吃的資料,我沒辦法預先知道一個對所有資料都合適的 α。
後來回頭打開 Prompt.txt,那一行定義:
4. 針對不同結果變項,分別進行200次 Bolasso ,以篩選欄位
沒提到 α 相關的字眼。而實作出來的是另一種:用全樣本的 LassoCV 選一次 α,固定下來,200 次 bootstrap 每一輪都用同一個。Claude 在註解的理由是「讓 200 次抽樣的懲罰強度一致、選入頻率之間可以直接比較」。
這個理由站得住腳,而且它想到了我沒想到的點:固定 α 的話,「U_motiv 中 200 次、U_harm 中 24 次」這種比較才是同一把尺量出來的;每一輪各自選 α,門檻就不一樣,票數加總起來代表什麼反而難以釐清。
三個結果變項實際選出來的 α,以及後面 200 次投票過 90% 門檻之後留下的變項數:
| 結果變項 | α | Bolasso 選入數(90% 門檻) |
|---|---|---|
| U_read 閱讀能力 | 0.2380 | 2 |
| U_arith 算術能力 | 0.0183 | 6 |
| U_spell 拼字能力 | 0.2370 | 2 |
U_arith 的 α 比另外兩個小了十三倍。α 就是罰項強度,小到 0.018 等於幾乎沒有懲罰,於是六個輸入變項全部活下來,另外兩個結果變項因為罰得重、各自只留兩個輸入變項。同支程式、同份資料,只因為結果變項換了一個,選變項的嚴格程度就差了一個數量級。
從這件事學到的是:寫進 prompt 才算數,不能假設語言模型讀得出一句話背後的所有涵義。我在腦中早就把「Bolasso」和「用 CV 選 α」綁成同一個流程,寫的時候覺得講「Bolasso」就代表了全部,卻沒想到還有別的計算路徑。AI 讀到的只有那一行字,沒寫仔細的地方它一定會用某種方式補上。它的理由確實比較好,所以我把這條寫進專案的 CLAUDE.md 留紀錄。
昨天設計 Definition 那張表的用意,今天在 UI 上兌現了。程式讀完 Excel 直接照 Feature_Type 分成三堆:ID 丟掉不管,六個 Input 排左邊、三個 Result 排右邊。使用者不必指定角色,畫面上只留一件事可以做:把不想分析的那幾欄取消勾選。十個欄位看起來沒差,但這張表如果擴展到六十個欄位,那就有差了,這才是原本設計的目的。
Data_Type 那一欄也照著用。結果變項如果填了 Categorical,程式會在格式檢查就擋下來,理由是本工具只做迴歸、分類任務不在支援範圍。這是 Claude 自己替這個專案劃的界線,我沒指定,但我覺得這個界線很對。
prompt 裡我只寫「要能確認格式符合規定」。它做出來的版本是把所有問題收集完再一起回報,而不是遇到第一個就中斷。驗收時它現做了十一個測試檔:九個該擋的(缺工作表、欄位型別填錯、沒有 Result 欄位、重複欄名、連續型欄位混進文字⋯⋯),兩個該放行的(20 筆樣本要放行但警告、多一張無關的工作表要忽略),十一個全過。
真正有用的是訊息:
Definition 工作表 C 欄:Data_Type「Numeric」不是合法值,須為 Continuous / Categorical / None。
它會把出問題的位置換算成 Excel 的欄位代號,拿到這句話可以直接開檔案跳到 C 欄。而我一開始只是想要它別在錯誤訊息裡寫「發生未預期的錯誤」而已。顯然它對邊界條件的處理比我想得完整。
prompt 第三條要求先用 /design 做 UI,它先產出五張畫板的設計稿才開始寫 tkinter。有個細節是這一步真正的價值:它沒有自己配色,而是去 ttkbootstrap 的主題定義裡把 cosmo 的色票撈出來用,理由是設計稿如果畫出圓角陰影漸層,ttk 根本做不出來,那張稿就只是好看而已。這讓設計稿變成一份真能照著實作的東西。
看完設計稿我補了兩條。第一條是依據 Claude 的建議加一顆「停止計算」按鈕,原本的規格只寫「執行中按關閉要跳確認」,等於想停下來只能關掉整個程式。第二條是視窗大小:讀取螢幕解析度後,水平佔 10% 到 90%、垂直佔 3% 到 87%。
第二條看起來簡單,實際上踩了兩層坑。第一次量到的是 1520 × 946,目標是 1536 × 907,高度多了 39 px,因為 tkinter 的 geometry() 設定的是內容區,Windows 會再往上加一條標題列;扣掉標題列變成 899,又少了 8 px,因為視窗四周還有一圈看不見的縮放邊框(寬度那 16 px 也是同一個原因),左右各 8 px。最後的做法是先擺一次、跟 DWM 問實際的可見範圍(DwmGetWindowAttribute 的 EXTENDED_FRAME_BOUNDS),拿差額校正一次。如果它第一次交出 1520 × 946 就說「做好了」,那就跟我要的差了一截。
/dataviz,實際用的不是原本預期用 /dataviz 出圖,實際跑起來 Claude Code 載入的是 python-plot,那是我自己寫的一份 matplotlib 規範,管 GridSpec 版面、y 軸留 5% 空間、dpi=300 存檔與中文字型設定。昨天那份 HTML 是 /dataviz 做的,今天這批 PNG 是 python-plot 做的,是我寫計畫的時候還沒把這兩者的分工想清楚。
python-plot 裡有一條是「標題含中文就要先設 Microsoft JhengHei」,這條有生效,但還是漏了一個。第一次跑完,主控台出現:
UserWarning: Glyph 8722 (\N{MINUS SIGN}) missing from font(s) Microsoft JhengHei.
8722 是 U+2212,數學上的減號,跟鍵盤上那個連字號不是同一個字。它出現在殘差圖的 y 軸標籤「殘差 (預測 − 實際)」裡,而 Microsoft JhengHei 沒有這個字。axes.unicode_minus 只管座標軸刻度上的負號,管不到我寫在字串裡的那一個,改成 ASCII 連字號就沒事。這種問題跑一次才能夠發現。
另外,prompt 要求六個指標畫在同一張圖上,但樣本內 R² 落在 0 到 1 之間、Max Error 可能是 20 幾,硬擠進同一組座標軸只會變成五根貼地、一根頂天。它的處理是拆成六個子圖,仍然是一張 PNG,而且每個子圖標題後面加了「↓ 越小越好」或「↑ 越大越好」。
三個結果變項各跑 200 次 bootstrap,選入門檻 90%(粗體是達到門檻、實際選入的):
| 候選變項 | U_read | U_arith | U_spell |
|---|---|---|---|
| U_motiv 學習動機 | 100.0% | 100.0% | 100.0% |
| U_verbal 語文智力 | 100.0% | 100.0% | 100.0% |
| U_ppsych 家長負向心理狀態 | 84.5% | 100.0% | 65.0% |
| U_ses 社經地位 | 80.0% | 98.5% | 49.5% |
| U_stabi 情緒穩定度 | 64.0% | 93.5% | 59.0% |
| U_harm 人際和諧 | 12.0% | 99.5% | 24.0% |
昨天為了說明「嚴格交集會兩敗俱傷」,我舉過一組假想的數字,實際跑出來完全不是那個樣子。原文是:
因為 Lasso 在相關變項之間是隨機挑的,200 次裡可能
U_motiv中了 130 次、U_harm中了 70 次。取嚴格交集的結果是:兩個都被砍掉。
實際是 200 比 24,U_motiv 全中、U_harm 幾乎全滅,一點都不像擲骰子。原因大概是:那兩個變項相關 0.77,高沒錯,但沒有高到分不出勝負,U_motiv 跟 U_read 的零階相關是 0.53,U_harm 是 0.42。Lasso 挑的不是隨機那一個,是解釋力比較強的那一個,只要差距夠穩定就會一直選同一個;真正會擲骰子的是相關 0.95 以上、解釋力又幾乎一樣的那種。
所以「投票」這個比喻要修正:它不是每一輪重新丟一次硬幣,比較像每一輪都在同一場比賽裡宣布同一個贏家。票數低不代表那個變項沒用,是它的解釋力被別人蓋過去了:U_harm 拿 12%,不是說人際和諧跟閱讀能力無關(單看相關 0.42),是說在 U_motiv 已經在模型裡的前提下,它沒有多解釋到什麼。
還有一件事得講:U_arith 那一欄六個全選入,不是因為算術能力比較特別,是因為它的 α 只有 0.0183,罰得太輕,幾乎不會有變項被壓成 0。三欄的百分比不能橫著比,這是第 2 節那個 α 差十三倍的直接後果。
昨天那份 HTML 裡有兩個變項在算術模型裡符號反過來,今天程式跑出來的是:
| 輸入特徵 | 零階 r | 標準化 β | p 值 | 昨天 HTML 上的 β |
|---|---|---|---|---|
| U_ppsych 家長負向心理狀態 | −0.240 | +0.097 | 0.0042 | +0.097 |
| U_harm 人際和諧 | +0.440 | −0.115 | 0.0155 | −0.115 |
數值一模一樣,但今天的重點在於這是兩套完全獨立的實作:昨天那份是寫在 HTML 裡的 JavaScript,今天這份是 Python 的 statsmodels,兩邊算出同一個數字,等於互相驗算過。兩日使用同一份資料,就是為了這個驗證的目的。
U_read 那組稍有差別:昨天六個變項全放時 U_verbal 的 β 是 0.645、U_motiv 是 0.217,今天 Bolasso 只留兩個,變成 0.685 和 0.194。拿掉四個不顯著的變項之後,多出來的解釋力沒有平均分下去,而是幾乎都被 U_verbal 收走,U_motiv 反而還降了一點。「少幾個變項、剩下的就會變重要」這種直覺,這裡不成立。
六個指標圖上都有畫,下面這張表列五個,因為 MSE 是 RMSE 的平方,同一件事講兩次沒必要:
| 結果變項 | 評估方式 | MAE | RMSE | Max Error | R² | Adjusted R² |
|---|---|---|---|---|---|---|
| U_read | 樣本內 | 4.747 | 6.018 | 23.395 | 0.637 | 0.636 |
| U_read | 5-fold 交叉驗證 | 4.772 | 6.060 | 23.250 | 0.632 | 0.631 |
| U_arith | 樣本內 | 5.108 | 6.475 | 20.732 | 0.580 | 0.575 |
| U_arith | 5-fold 交叉驗證 | 5.156 | 6.535 | 20.862 | 0.572 | 0.567 |
| U_spell | 樣本內 | 5.483 | 7.057 | 24.456 | 0.501 | 0.499 |
| U_spell | 5-fold 交叉驗證 | 5.530 | 7.123 | 24.644 | 0.492 | 0.490 |
交叉驗證那幾列我沒有要求,是 Claude 自己加的,理由是:樣本內 R² 一定偏樂觀,報告會高估實際預測能力。兩組數字差很小,R² 差 0.005 到 0.009。RMSE 和 MAE 的比值大約 1.27,代表誤差分布得算是平均;Max Error 23.4 放在標準差 10 的尺度上是 2.3 個標準差。
要注意的是 Adjusted R² 那一欄:自由度校正本來是為樣本內設計的,出現在交叉驗證那幾列只能當成同一條公式的延伸,不要跟樣本內那一列當成完全同一種東西來比。
殘差診斷也一起出了:Durbin-Watson 1.99 到 2.08、Breusch-Pagan 與 Jarque-Bera 的 p 值都遠大於 0.05,也就是殘差沒有自我相關、沒偵測到異質變異、可視為常態。這三個檢定也是它自己加的,prompt 裡我只寫了「統計模型結果要有合適的表與圖(可能是我沒想到的部分)」。昨天說那句留白是故意的,今天看起來留對了,它用了我沒想過、但實務上很合適的統計方法。
prompt 裡我要求輸出的 Excel 要有「誤差值(pred-true)與誤差率」,而誤差率的標準算法就是誤差除以真值,問題是這份資料的每一欄平均都是 0。實際跑出來,U_read 那張工作表有 14 筆的誤差率絕對值超過 1000%,因為那幾筆的真值接近 0。這不是算錯,是這個定義在這種資料上沒有意義。
Claude 設計的程式,實際處理方式分兩層:真值絕對值小於 1e-9 的那幾格直接留白,先擋掉除以零;但那 14 筆的真值只是接近 0、還不到留白的門檻,照樣會印出四位數的百分比,所以它另外加一欄「誤差率(佔全距 %)」,分母改成該欄的最大值減最小值。以 U_read 為例,這一欄的範圍是 −38.71% 到 +30.20%,是看得懂的數字,Word 報告的「限制與注意事項」也把這件事寫進去了,這是它跑完發現數字不對勁才回頭補的。
計畫是 onefile 和 onedir 都做,實際建置後量。Claude 事前給的估計是 onefile 大約 400 到 700 MB、首次啟動要等十幾秒。實測是這樣:
| 體積 | 檔案數 | 啟動到視窗出現 | 跑完一次分析 | |
|---|---|---|---|---|
| onedir | 221 MB | 2,484 | 3.5 秒 | 66 秒 |
| onefile | 97 MB | 1 | 7.6 秒 | 81 秒 |
onefile 反而比較小,因為 PyInstaller 會把單檔封存壓縮起來,那個 400 到 700 MB 的估計錯得離譜,而 Claude 自己在報告結果時主動承認這件事。
驗收標準這裡有一條值得記下:ui-project 那份規範寫的是「建置紀錄裡沒有任何 Library not found」,而不是「exit code 是 0」,因為漏掉相依檔的打包一樣會回 exit 0。這次兩份 spec 的建置紀錄裡,Library not found 都是 0 次,conda 那句 PATH 是關鍵,Day 13 就是漏了它才打包完打不開。
但「視窗開得起來」也不等於「能用」。視窗出現只證明 tkinter 和它的 DLL 進去了,證明不了 matplotlib 的資料檔在不在、python-docx 的樣板有沒有被收進去、sklearn 那堆 .pyd 有沒有漏。那些要真的跑一次完整分析才會暴露,而 console=False 的 exe 沒有 stdout,print() 會靜靜地什麼都不做。
它的解法是在 main.py 加一個 --selftest 參數,不開介面直接跑完整條流程,成敗用 exit code 回報。兩份打包都跑過,exit 都是 0,各自產出 3 個模型、28 個檔案。這個參數我沒要求,它加的理由是另一條路會劫持我的滑鼠:UI 自動化得把視窗搶到前景再送模擬點擊,而我當時在用這台電腦做別的事。
中間還出過一次包:它想截圖確認 UI,但截圖抓的是螢幕座標,我當下有另一個視窗壓在前面,於是截到別的視窗,之後它就不再用截圖驗證了。驗證方法本身也會出錯,而且出錯的方式跟被驗證的東西無關。
有一條我知道該做、但同樣沒寫進 prompt,這次它也沒自己補:報表上的 RMSE 只有一組數字。樣板資料標準差是 10,RMSE 等於 6 要放在這個尺度上讀才有意義;但這支程式是給別人的資料用的,別人的資料不一定標準化過,光看一個 6 沒辦法判斷好壞。該補的是一個正規化過的版本「除以標準差或全距」,就像 5.4 那一欄誤差率最後的做法。這條留著下次做。
另外一個算設計取捨:按下「停止計算」不是瞬間停,它是在每個階段的邊界和 Bolasso 每 10 次抽樣時,檢查中止旗標,最長要等當下那一步跑完(最久的是 pair plot,seaborn 的單一呼叫,中途插不了話)。要更即時得改成多行程,但那樣中止就變成殺掉行程,可能留下寫到一半的檔案。
今天最有價值的收穫不是那支程式,是第 2 節那件事。
我昨天做四個決定,今天發現漏了三條沒寫進 prompt:α 的選法、指標要算在沒參與訓練的資料上,還有 RMSE 該放在什麼尺度上讀。第二條它自己補上了,第三條兩邊都沒想到,第一條它選了另一條路。AI 讀不到我的思路,只讀得到我打出來的那幾行字。這跟 Day 13 那種「少講讓問題自己浮出來」是兩回事:那時候我不知道自己要什麼,浮出來的問題正好幫我想清楚;今天我知道自己要什麼,只是沒寫清楚,浮出來的不是問題,是個看起來正常的錯位。
另一個收穫是「視窗開得起來」不等於「能用」。差 39 px 的視窗、少一個字的字型、多一欄動輒破千 % 的誤差率,都是同一類東西:看起來對,但沒有量過。
Windows 相關
註一:所有時間與體積都是單次在同一台機器上量的(Windows 11、1920×1080、conda env
stats、Python 3.13.14),屬於定性觀察,不是效能評測,換一台機器數字會不一樣。
註二:
Data_Template_Stats.xlsx裡的 500 筆是合成資料,照 Worland et al. (1984) 已發表的相關矩陣抽出,不是原研究那 158 名兒童的原始資料。因為抽樣誤差被消去了,p 值衡量的是生成設定而不是真實抽樣,文中的顯著性只用來標示效果的相對強弱,不能拿來當任何心理學或教育學上的結論。
註三:第 5.1 節關於「Lasso 在相關變項之間怎麼挑」的解釋,是我從這一次的結果回推的,不是從論文推導出來的,僅供參考。
註四:參考的程式碼與應用程式可在 專案連結 拿到
註五:模板資料的輸出結果可在 模板資料輸出連結 閱讀