📋 模型訓練 SOP / 注意事項
2026-05-18 v1 · 整合自過往訓練踩過的坑 + 設計決策
📌 訓練前 5 分鐘 checklist
讀上一版 summary.json(backbone, img_size, aug, loss, weights, ep best)
列出本版 hyperparams diff vs 上一版(即使全沿用也要列)
user confirm hyperparams(不靠命名推斷)
cvat2 對應 project 全 task scan:subset 分布、deleted_frames、(empty) 殘留
**掃描所有 annotation shape type 分布**(polygon / rectangle / polyline),rectangle 數 > 0 → 必走 bbox YOLO det
**確認每 task 的 jobs stage=acceptance + state=completed**(光 subset != "" 還不夠,標注可能還 in_progress / in_validation)
資料 distribution:是否 yes/no 嚴重不平衡?某 attr 樣本 < 30?val 完全沒 negative?
**有任何資料問題先停下跟 user 討論**,不要自己改
checkout disk 空間(5090-2 / gx10)夠不夠
checkout GPU memory 是否被別人占用
確認 tmux session 不會撞到別人
📦 CVAT export 規則(最常踩坑)
必守 訓練前掃 shape type 分布;含 rectangle → 強制 bbox YOLO(不准只取 polygon)
Why:2026-05-19 forklift v20260518 翻車:cvat #9 有 10,625 rectangle + 4,105 polygon + 1 polyline,但 export_p9_v20260518.py 寫 if sh["type"] != "polygon": continue → 直接丟掉 72% 的 annotation(10,625 個 rectangle),訓練只用了 4,017 個 bbox。導致看起來 model 大退步,實際是資料餓死。
How to apply:
from collections import Counter
ctr = Counter()
for ann in annotations:
for sh in ann.get("shapes", []):
ctr[sh["type"]] += 1
print(ctr) # {"rectangle": 10625, "polygon": 4105, "polyline": 1}
if ctr.get("rectangle", 0) > 0:
# 必須走 bbox YOLO det,rectangle 直接取 points[0:4]
# polygon 也轉 bbox (min/max xy) 一併納入
task_type = "det" # NOT "seg"
elif ctr.get("polygon", 0) > 0:
# 可選 seg 或 det(polygon→bbox 轉換)
task_type = "seg or det"
規則:
有 rectangle 就絕不能丟。rectangle 是標記師明確意圖「只標 bbox」的 annotation,丟掉等於丟一手資料。polyline 一律忽略(forklift 是 noise)。
對應 task 類型:cvat #1 (person) 全 polygon → polygon→bbox;cvat #9 (forklift) 混 polygon+rectangle → 必走 bbox det 把兩者合併;cvat #16 (river_debris) 看分布決定。
必守 用 data_id 拼 disk path,不用 task_id
Why:cvat2 上 task_id ≠ data_id 占 30%。用 task_id 拼 path 會撈到別 task 的 frame。2026-05-03 v6 safety_rope 訓練 + audit 都中過此坑。
How to apply:從 cvat API GET /api/tasks/{id} 拿 data field 當 data_id,path = /mnt/ssd/cvat2/data/cvat_data/data/{data_id}/raw/<fname>
def resolve_disk(did, fname):
cands = []
if "/" in fname: cands.append(SHARE_ROOT / fname)
cands.append(DATA_ROOT / str(did) / "raw" / Path(fname).name)
cands.append(DATA_ROOT / str(did) / "compressed" / Path(fname).name)
if "/" not in fname: cands.append(SHARE_ROOT / fname)
for c in cands:
if c.exists(): return c
return None
必守 過濾 deleted_frames
Why:cvat 標記師會用「delete frame」標記不要的 frame。如果沒過濾,會包含 user 明確認為「不該訓練」的 frame。
How to apply:meta = GET /api/tasks/{id}/data/meta; deleted = set(meta["deleted_frames"]) 跑迴圈時 skip
必守 用 task.subset 切分 Train/Val/Test,不能 hash split
Why:同一支影片抽不同 frame 高度相關,hash split 會導致 train/val leak。task.subset 是標記師明確分配的 split,遵守它即可避免 leak。
How to apply:整個 task 進一個 subset。SUBSET_MAP = {"Train": "train", "Validation": "val", "Test": "test"},(empty) 跳過或 ping user 確認
必守 task 的 jobs 必須 stage=acceptance + state=completed 才可訓
Why:cvat job stage 流程是 annotation → validation → acceptance,state 是 new → in progress → completed。**有 tag 不等於通過 acceptance review**。例如 PUBLIC_FASDD 等公開 dataset task 有 10000+ tag 但 stage 仍是 annotation/new,標注品質未驗證。2026-05-18 cvat #2 1257 個 task 看 tag 似乎 311k 可用,但實際 acceptance 完成只 110 個(8.7%)。
How to apply:
jobs = s.get(f"{CVAT2}/api/jobs?project_id={pid}&page_size=500").json()
done_tasks = {j["task_id"] for j in jobs
if j["stage"] == "acceptance" and j["state"] == "completed"}
# 訓練只用 done_tasks ∩ subset!="" 的 task
若需批次 force 完成(user 明確授權):
PATCH /api/jobs/{id} {"stage": "acceptance", "state": "completed"}
注意 cvat2 API quirks
- API URL Cloudflare WAF 擋 default urllib UA → 加
User-Agent: Mozilla/5.0
- API pagination 的
next 是 absolute URL,docker 內部訪問會 DNS 失敗 → 自己 page+=1 不要 follow next
- polygon 為主(SAM3 自動標)vs rectangle bbox — det 訓練要 polygon→bbox 轉換(min/max xy)
- label name 跟 spec 常不符 — 用 spec_id 比對才穩
- COCO export 不含 attributes — multi-attr 任務用 API
/annotations raw shapes 拿
優先 下載走 disk path > rsync > bulk API
How to apply:5090-2 上 cvat2 在 docker,原圖 mount 在 /mnt/ssd/cvat2/data/cvat_data/data/{data_id}/raw/,直接 cp / symlink,**100× 快於 API 下載**。從 mac local 跑要 ssh 進 5090-2。
🔬 Partial-label / unknown 處理
必守 unknown 完全不參與 loss / metric 計算
Why:cvat2 多 attr 任務裡,標記師沒空把每個 attr 都標 yes/no,多數 attr 留 unknown。若把 unknown 當 negative 訓,模型會錯誤學「沒標 = 沒有」。
How to apply:
def masked_bce(logits, y, mask, pw, nw=None):
if nw is None:
raw = F.binary_cross_entropy_with_logits(logits, y, pos_weight=pw, reduction="none")
else:
lp = F.logsigmoid(logits); l1p = F.logsigmoid(-logits)
raw = -pw * y * lp - nw * (1.0 - y) * l1p
m = mask.sum()
return (raw * mask).sum() / m if m > 0 else raw.mean()*0 # 分母用 mask.sum()
mixup 也要 element-wise min mask:
torch.minimum(m, m[idx])
關鍵點:unknown 對 loss 是 zero contribution,但該 frame 過 backbone 仍會貢獻 visual representation 學習。這是好事,partial-label 仍能榨出 unlabeled attr 的視覺資訊。
必守 eval 同樣 mask unknown,不要當 0/負或忽略整個 sample
How to apply:keep = masks[:, attr_idx] > 0.5,per-attr 不同 sample 集合是合理的。用 sklearn.metrics.precision_recall_curve 不要自己寫 metric(v1 合成圍裙踩坑教訓)。
⚙️ Hyperparams 規則
必守 訓練前必讀上一版 summary,列差異 user confirm
Why:2026-05-15 PPE v515 第一輪用了錯的 harness=20(從命名 negw_harness20 推斷),實際上一版 v504 summary 寫 harness=2.0 + hard_hat=1.3 + safety_vest=1.3。**不能靠命名推斷 hyperparams**。
How to apply:cat ~/runs_new/<previous_version>/summary.json,列:backbone / img_size / aug / loss / weights / best epoch。user confirm 後再訓。
建議 新 dataset → 預設沿用 baseline hyperparams
Why:2026-05-15 PPE v515b 對齊 v504 hyperparams 反而比 v515 第一輪略差。新 dataset 跟舊 hyperparams 不一定 match,但**完全沿用是最 conservative 起點**,效果不好再 ablation。
🏗️ 模型架構規則(per-task baseline)
safety_rope RoI Align + 外擴 1.0/0.2/1.5 + HD 1280×720
Why:rotation+blur aug 降 FP -35%。外擴比例 (x=1.0, y_top=0.2, y_bot=1.5) 是 v5+ 試出來的最佳值。
How to apply:backbone DINOv3-S 22M params,class_weights wrong=1.5/correct=1.0,jitter center=0.2 size=[0.7,1.4]
注意:場域 FP 真嫌是 YOLO det 假框,不是 ViT 分類器。e2e audit 揭露 78% FP 來自 YOLO 假框、漏報 97% 來自 YOLO 漏框。改 ViT 邊際遞減,要救先救 person YOLO。
PPE 21/22-attr MobileNetV3-L baseline / 大 backbone +7-8pp
- baseline: MobileNetV3-L 4M params, img 384×192, partial-label BCE + per-attr pos_weight
- 大 backbone (ConvNeXt-L 198M) +7-8pp R@P95
- Ensemble (per-attr greedy) 再 +3pp(cost 4-7×)
- VLM zero-shot (Qwen2.5-VL-3B) 對 hard_hat/rubber_gloves/harness 有信號但整體 R@P95 0.195 弱
- 真實弱點 attr 補資料 > 換 backbone(cotton_gloves 全 model R@P95 0.04-0.14,ensemble 也救不了)
person YOLO yolo11n.pt detect, 100 ep, batch=64, imgsz=640, 雙卡
v20260501 baseline: SGD lr0=0.01 mom=0.937 wd=0.0005, mosaic=1.0 close_mosaic=10, fliplr=0.5, cache=ram。
**cvat #1 全 polygon (SAM3 標)** → 訓 det 需要 polygon → bbox 轉 (min/max xy)。
door_state / hatch MobileNetV3-L + 2 binary heads + SWA-4
partial-label BCE, has_open / has_close 兩 head, img 384, val_mAP 0.99
river_debris YOLO11n det, cvat2 #16 為主
iseek-river.intemotech.com 上線使用。重訓後熱換模型。
fire_smoke MobileNetV3-L + 2 binary heads (smoke + fire), partial-label BCE
- baseline: v20260410 SK 報告基準 —
smoke:X% fire:Y% 並行輸出
- cvat #2 schema:
smoke tag (含 color attr: black/white/mix/no_set) + fire tag (無 attr)
- 暫不訓 smoke color:96% no_set,white/mix 樣本不足
- per-source 分開評估:PUBLIC dataset (FASDD / FORESTFIRESMOKE / HUSSAIN) vs INTERNAL (TUNGHAI / DAJIA) distribution 差距大,混算 metric 會誤導
- per-channel threshold 不入 ckpt:v20260410 SK 報告顯示 CH05=0.01 / CH15=0.81 差 80×,threshold 屬 inference layer config 而非 model weight
v20260410 SK 暴露的問題(v20260518 要解):
- 連續幀 flip rate 30-43% — 加 N-frame sliding mean 推論
- per-channel prob 中位數從 0.002 (CH10) 到 0.46 (CH01) 差 200× — domain shift 嚴重
- 36/76 SK task NOT READY (recall < 50%) — 補該 channel 資料
- 公開 dataset 多是森林火災,跟工廠煙場景 distribution 不同 — train 可加但 eval 必分
🚀 部署與驗證
必守 Pages 用 scripts/deploy_reports.sh 全量部署
Why:不全量會讓舊報告消失。deploy_reports.sh 從 gx10 rsync 所有 reports → inject R2 banner → 生 index.html → wrangler pages publish。
How to apply:
scp my_report.html gx10:/home/rai/kaggle_work/reports/
set -a; source ~/code/秘書/infra/cloudflare.env; set +a
export CLOUDFLARE_API_TOKEN="$CLOUDFLARE_PAGES_TOKEN"
bash scripts/deploy_reports.sh
必守 Cloudflare token 走 secretary env,不要自己 wrangler login
Token 在 ~/code/秘書/infra/cloudflare.env:CLOUDFLARE_API_TOKEN / CLOUDFLARE_PAGES_TOKEN。R2 用 ~/code/秘書/scripts/r2.sh 不用 wrangler r2(wrangler put 要加 --remote 否則只上 local cache)。
必守 部署後要 curl / logs 驗證上線
Why:wrangler publish 顯示「Success」不代表 production 真的吃新版。CDN cache、worker hot-reload、reverse proxy 都可能擋。
How to apply:curl -sI "https://<url>" -H "User-Agent: Mozilla/5.0" 確認 200,必要時 curl ... | grep <new content> 確認內容也是新的。
📊 評估與 audit
必守 safety_rope audit 不要單張看,要時序 ±N frame
Why:動態作業任務(拉繩 / 掛勾)單張難判斷對錯。verdict JSON 不可直接信任,要看前後幀 motion / context。
建議 用 recall @ precision=0.95 當 low-FP 主指標
Why:iSeek 通報路徑最忌 false alarm。F1 / mAP 不夠針對「低誤報」,R@P95 直接量化「鎖死 95% precision 能拉多少 recall」。
教訓 不要信 CLIP / VLM 自動 eval(先 calibrate)
Why:v1 合成圍裙 CLIP eval 96.7% 假分數,人工肉眼真實 4.7%。CLIP 對「銀色金屬反光」過敏,把氣瓶 / 防護衣 / 水印商品圖都當高分。
How to apply:新 metric 必須先對 baseline 數據回測,看是否跟人工 audit 結果吻合(v2 用 GroundingDINO+HSV+neg-check 三閘對 v1 prod 43 張回測 9.3% vs 人工 5-11% → 校準)才能用。
建議 Ensemble 對「真實弱點 attr」無效,要補資料
Why:cotton_gloves 11 個 model R@P95 都 0.04-0.14。當所有 model 同樣拉胯 → 不是 model capacity 問題,是 train data signal 問題。**換大 backbone / 加 ensemble 都白做工**。
必守 Per-channel / per-source 分開評估,不混算 metric
Why:fire_smoke v20260410 SK 報告:CH05/CH06 t=0.01 達 100% recall,CH15 要 t=0.81 才 51% recall。整體 mAP 0.99 但 36/76 task NOT READY。單一整體 metric 會把 domain shift 蓋掉。
How to apply:
- per-camera channel 各算 P/R/F1/AP(fire_smoke、河川 debris、PPE)
- per-source 各算(PUBLIC dataset vs INTERNAL 真實場域),混合 dataset 訓練時必做
- per-task Grad-CAM 抽 3-5 個 FP/FN 看模型「看錯位置」
建議 連續幀 flip rate 是 temporal smoothing 觸發指標
Why:v20260410 SK 報告 CH01 flip rate 43%、CH04 30% — 同一段影片忽抓忽漏。模型本身可能已不可能更穩,但推論側加 N-frame sliding mean / track-aware smoothing 能直接救。
建議 Per-channel threshold 不入 ckpt,放 inference layer config
Why:fire_smoke v20260410 SK 報告 per-channel threshold 從 0.01-0.81 變化 80×。若寫死 ckpt 內 → 新場景就要重訓;放 iSeek / ppe-demo config → 可動態調。
How to apply:model 內保 default threshold (如 0.5),per-camera 在 iSeek rule engine / ppe-demo handler 階段套自己的 threshold。便於 production 持續 tune 不重訓。
建議 Grad-CAM audit 是黃金標準(不只看數字)
Why:v20260410 SK 報告抓出「prob 0.93 但看錯位置」「prob 0.05 但其實有看到煙」的案例。數字對不代表注意力對。
How to apply:per-task 抽 3-5 個 FP/FN(極端 prob)做 Grad-CAM 視覺化,貼進 report。CH13 / CH04 等 problem channel 必看。
🖥️ 5090-2 / gx10 環境細節
| 項目 | 路徑 / 細節 |
| cvat2 mount | /mnt/ssd/cvat2/data/cvat_data/data/{data_id}/raw/ |
| cvat_share | /mnt/ssd/cvat2/data/cvat_share/ |
| cvat2 API(內網) | http://localhost:8181 + Host: raicvat5090.intemotech.com |
| cvat2 API(外部) | https://raicvat5090.intemotech.com + User-Agent: Mozilla/5.0 |
| HF cache(多服務共用) | ~/hf_cache 144 GB — 不要砍 |
| 5090-2 GPU 占用 | GPU 0 已被 ppe-demo + hunyuan worker 占 ~18 GB,剩 ~13 GB free |
| R2 base url | https://pub-478929a98a5c440cb22c2241c0bde314.r2.dev |
| R2 bucket | rai-models |
| Pages | kaggle-reports.pages.dev 從 gx10:~/kaggle_work/reports/ 同步 |
| gx10 LAN | 192.168.53.21 (rai) |
| 5090-2 alias | ssh 5090-2 (ubuntu user) |
tmux send-keys 注意
claude code TUI 對 multi-byte (中文) prompt 後接 Enter 偶爾沒 submit。tmux capture-pane 看 idle 就單獨補 tmux send-keys ... Enter。
❌ 過往失敗教訓(避免重蹈)
| 日期 | 事件 | 根因 | 學到的規則 |
| 2026-05-03 | safety_rope v6 訓練 + audit 30% 撈到別 task frame | 用 task_id 拼 disk path | 用 data_id 不用 task_id |
| 2026-05-15 | PPE v515 第一輪 harness=20 訓壞 | 從命名推斷 hyperparams 沒讀 summary.json | 訓練前必讀上一版 summary |
| 2026-05-17 | aluminized_apron 合成 v1 CLIP 96.7% 假分數 | 自動 eval 沒校準 | 新 metric 必須對 baseline 回測校準 |
| 2026-05-17 | aluminized_apron 合成 v2 仍 10% 失敗 | garment_set 爬蟲污染(戰士 / 防護衣 / 水印) | 合成路線:標記師親手挑 reference set 才能啟動 |
| 2026-05-17 | 覆蓋 5090-2 上別人的 training_mission_device2/model_viewer/app.py | 同名 dir 但完全不同服務 | 動 5090 source 前先 git status 確認 |
| 2026-05-18 | person v20260518 export pagination DNS 失敗 | cvat API 回 next absolute URL,docker 內部 DNS 不到 | 自己 page+=1 不 follow next URL |
| 2026-04-10 | fire_smoke SK 36/76 task NOT READY,per-channel threshold 差 80× | 單一 model 對所有 channel 不一致,整體 mAP 0.99 蓋住 channel-level domain shift | per-channel/per-source 分開評估、per-channel threshold 不入 ckpt |
| 2026-04-10 | fire_smoke SK 連續幀 flip rate 30-43% | 純 single-frame model 缺時序穩定性 | temporal smoothing 推論層 |
| 2026-05-18 | cvat #2 / #11 多個 task subset 已分但 jobs 還在 in_progress / in_validation | 標記師分 subset 跟 acceptance review 是兩個獨立流程,被誤以為「分 split = 完成」 | 訓練前要 scan job stage/state,不只看 task subset;可批次 PATCH 強制 acceptance/completed |
| 2026-05-19 | forklift v20260518 看似大退步(mAP50 0.02) | cvat #9 有 10625 rectangle + 4105 polygon,export 寫 if type != "polygon": continue 把 72% annotation 丟掉,訓練只用 4017 bbox(資料餓死) | 訓練前必掃 shape type 分布;含 rectangle 強制 bbox YOLO det,不准只取 polygon |
| 2026-05-22 | factory_ppe v521 export pagination bug 揭露(v519 也中) | export_p12_v519.py 用 page_size=100 沒 paginate,cvat #12 有 704 task 但只 fetch 前 100,v519 訓練 dataset 嚴重不完整。v521 修了 best_val_mAP 從 0.9503 跳到 0.9741(+2.4pp) | cvat API 任何 list endpoint 必 paginate(while r.get("next"): page+=1) |
| 2026-05-22 | 5090-2 root disk 100% 滿,factory_ppe export crash | hf_cache 144G + cvat-trainer 135G + datasets 94G 堆系統碟 / dev/nvme1n1p2 937G 用盡 | 訓練前 df -h / 看 < 80% 才動;hf_cache / datasets 建議搬 /mnt/ssd + symlink;crops_v* 舊版定期清 |
| 2026-05-25 | forklift v20260525 翻車(video-mode task 整支影片只 export 第 1 frame) | 既有 export_p1 / p9 / p12 / hatch 用 meta["frames"] iterate,但 cvat task mode="interpolation" 是 video-mode,meta["frames"] 只 1 entry → frames 全丟 | cvat task ["mode"]=="annotation" → image-mode iterate frames OK;==="interpolation" → video-mode 需走 disk chunks 或 cv2.VideoCapture 解 frame。寫共用 helper cvat_frame_iter() 三個 export 共用 |
| 2026-05-26 | person v526 訓練 DataLoader ConnectionResetError 崩潰 | train script device="1" 但 GPU 1 被 ComfyUI 占 24GB;同事直接 ssh 跑前景沒 tmux,ssh 斷則 process die;DataLoader workers=8 多進程 queue read 被 reset | 訓練前 nvidia-smi 確認 GPU 空間;**必走 tmux session**(不要 ssh 前景跑 train);train chain script 帶 done file 給 watcher 通知 |
| 2026-05-26 | CLAUDE.md 寫錯 hatch v518 production;同事踩 hatch (#7) vs door_state (#11) 混淆 | cvat #7 hatch 系列已凍結(最新 v20260505_swa);cvat #11 door_state 4-class 是新接班(v20260518);CLAUDE.md 寫成同一條對應「hatch v518」是 typo | 不要改 export_hatch.py PROJECT_ID=11;要訓 door_state 必須複製新 export_door_state_v*.py 設 PROJECT_ID=11 |
🔗 相關連結