File size: 22,303 Bytes
78738de
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
# -*- coding: utf-8 -*-
"""Train ShotNet trên bb9_synth 150k (BG27 bước 2) — venv CV, GPU RTX 3070.

Kỷ luật BRIEF 27:
  - Val ~2% cắt từ TRAIN theo seed (``val_split_indices``); 5k held-out là
    TEST — script này KHÔNG BAO GIỜ đọc shard heldout.
  - Ngân sách tune: tối đa 3 config chạy trọn (bảng ``CONFIGS`` — mỗi config
    một dòng giả thuyết). Checkpoint chọn theo VAL, không theo held-out.
    BG29 (12/08) mở thêm ĐÚNG 2 config c4a/c4b — cùng kiến trúc/optimizer/
    ngân sách bước với c3, chỉ đổi PHÂN PHỐI train (dropout slot bi mục tiêu
    + reweight lát target). Muốn config thứ ba là phải hỏi Cowork.
  - Log curve CSV + PNG mỗi epoch: loss từng head + 4 chỉ số gate đo trên
    VAL (dphi_med, v0_relerr_med raw, side_acc, vert_acc).
  - Checkpoint tốt nhất theo ``val_score`` vào ``models/<run_name>/`` + spec
    đầy đủ (config, seed, commit, dataset) — nếp ``qfield_20260721``.

``val_score`` = dphi_med/2 + v0_relerr_med/0.10 + (1−side_acc)/0.20 +
(1−vert_acc)/0.20 — mỗi vế = 1.0 khi đúng bar tuyệt đối của gate G-27.3,
nhỏ hơn là tốt hơn; chọn checkpoint thẳng theo thước gate thay vì theo loss
(loss trộn trọng số tay, không phải thước nghiệm thu).

Chạy (venv CV):
  D:\\Khoa luan\\poolcoach-cv-env\\Scripts\\python.exe ^
      poolcoach-rl\\scripts\\broadcast\\train_shotnet.py --config c1
  # smoke pipeline (KHÔNG tính vào ngân sách 3 config):
  ... train_shotnet.py --config c1 --smoke --out-root <scratchpad>

Bẫy quen: đọc shard lần đầu trên ổ D lạnh sẽ ì (npz 1.7GB) — đừng tưởng
treo rồi kill (bài BG26b Bất ngờ 1); console ASCII (cp1252).
"""

from __future__ import annotations

import argparse
import csv
import json
import math
import subprocess
import sys
import time
from dataclasses import asdict, dataclass, field, replace
from pathlib import Path

import numpy as np
import torch

ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "src"))

from poolcoach_cv.shotnet import (  # noqa: E402
    BucketBatcher, ShardDataset, ShotNet, ShotNetConfig, SlotDropoutConfig,
    TargetReweight, collate, gate_metrics, shotnet_loss, val_split_indices)

DATA_DIR_DEFAULT = Path(r"D:\Khoa luan\datasets\bb9_synth")


# ------------------------------------------------------------ config

@dataclass
class TrainConfig:
    name: str
    hypothesis: str                      # MỘT dòng vì sao chạy config này
    model: ShotNetConfig = field(default_factory=ShotNetConfig)
    epochs: int = 40
    lr: float = 3e-4
    weight_decay: float = 0.01
    warmup_frac: float = 0.03
    grad_clip: float = 1.0
    token_budget: int = 40_000           # max_len_batch × cỡ_batch (VRAM 8GB)
    max_batch: int = 256
    val_frac: float = 0.02
    split_seed: int = 20260811           # cùng ngày chốt spec dataset
    torch_seed: int = 0
    amp_bf16: bool = True                # Ampere: bf16 autocast, khỏi scaler
    run_date: str = "20260811"           # tiền tố thư mục run (giữ tên cũ
                                         # của c1/c2/c3 tái lập được)
    # BG29 — CHỈ tác động đường train, val vẫn sạch như c3 (set_train_mode)
    slot_dropout: SlotDropoutConfig | None = None
    target_reweight: TargetReweight | None = None


CONFIGS: dict[str, TrainConfig] = {
    "c1": TrainConfig(
        name="c1",
        hypothesis="transformer nho d128x4L (~0.8M params) + loss mac dinh "
                   "du cho bai toan nguoc tren quy dao sach-vua-phai; "
                   "aux ball OFF de lay moc don gian nhat truoc",
    ),
    # c1 do duoc (val, best ep37): dphi 11.7 / v0rel 0.136 / side 0.526 /
    # vert 0.233 — spin KHONG hoc duoc gi (a_pred sap ve hang: std 0.02,
    # corr 0.02), dphi xau dan theo v0 (4.8 cham -> 18.0 nhanh) va xau hon
    # tren identifiable (12.3 vs 5.6 non-ident). c2 = 3 sua co gia thuyet:
    "c2": TrainConfig(
        name="c2",
        hypothesis="(i) them delta-xy per-slot: phi/V0 la ham VAN TOC, c1 "
                   "bat encoder tu dung chuyen dong tu toa do tuyet doi va "
                   "tran o 11.7 deg; (ii) (a,b)/0.4 + delta 0.5 + w_ab 2.0: "
                   "gradient spin c1 chi ~0.05, head sap ve hang - can "
                   "ngang phi de encoder chiu hoc tin hieu sau va cham; "
                   "(iii) aux v0_ball/phi_ball ON (label san, BG26b BN4): "
                   "hoc trang thai bi QUAN SAT DUOC roi suy nguoc ve gay "
                   "(phi_gay = phi_bi + squirt(a)). Co model GIU NGUYEN "
                   "de quy duoc hieu ung; c3 de danh cho scale-up",
        model=ShotNetConfig(use_deltas=True, ab_scale=0.4, huber_ab=0.5,
                            w_ab=2.0, aux_ball=True),
    ),
    # c2 do duoc (val, best ep32): dphi 9.36 / v0rel 0.1275 / side 0.596 /
    # vert 0.290 — ca 3 sua deu can (spin bat dau hoc, side/vert VAN CON
    # LEO tai ep39, train loss con xuong => chua hoi tu). c3 (config cuoi
    # ngan sach) = giu nguyen c2 + 3 sua:
    "c3": TrainConfig(
        name="c3",
        hypothesis="(iv) phi loss vec-MSE co gradient ~sin(delta) nen cu "
                   "vo vong (che dau + cham som, ~90 deg) thong tri; gate "
                   "cham MEDIAN -> Huber tren GOC (delta 5 deg, w 6.0) ep "
                   "khoi giua phan phoi ve duoi bar, ap cho ca aux "
                   "phi_ball; (v) huber_v0 0.25->0.10: cung logic median "
                   "(duoi mu dropout khong duoc keo gradient); (vi) "
                   "d192x6L + 60 epoch: c2 chua hoi tu (side/vert con leo "
                   "o ep39) va spin can dung luong bieu dien hinh hoc sau "
                   "va cham",
        model=ShotNetConfig(d_model=192, n_layers=6, n_heads=6, d_ff=768,
                            use_deltas=True, ab_scale=0.4, huber_ab=0.5,
                            w_ab=2.0, aux_ball=True,
                            phi_loss="ang_huber", phi_huber_deg=5.0,
                            w_phi=6.0, huber_v0=0.10),
        epochs=60,
    ),
    # ---- BG29 (12/08): c3 SẠCH trên synth nhưng sập trên clip thật vì mật
    # độ detection bi mục tiêu (cú 11 = 1.01 det/frame, cú 12 = 3.09, synth
    # 96.4% visibility ≈ 5.3 det/frame). Kiến trúc/optimizer/ngân sách bước
    # GIỮ NGUYÊN c3 — chỉ đổi phân phối train. Ngân sách chốt trước: 2 config.
    "c4a": TrainConfig(
        name="c4a",
        hypothesis="c3 + dropout slot bi muc tieu (keep ~U[0.10, 0.95] tren "
                   "75% cu, cue KHONG dropout): khe sim->real BG28 la PHAN "
                   "PHOI mat do detection chu khong phai kien truc hay input "
                   "path (slot-builder da duoc minh oan bang thi nghiem cach "
                   "ly 30 cu). Net phai hoc tra loi khi thua.",
        model=ShotNetConfig(d_model=192, n_layers=6, n_heads=6, d_ff=768,
                            use_deltas=True, ab_scale=0.4, huber_ab=0.5,
                            w_ab=2.0, aux_ball=True,
                            phi_loss="ang_huber", phi_huber_deg=5.0,
                            w_phi=6.0, huber_v0=0.10),
        epochs=60,
        run_date="20260812",
        slot_dropout=SlotDropoutConfig(),
    ),
    "c4b": TrainConfig(
        name="c4b",
        hypothesis="c4a + reweight loss ve lat target (cham dau >=0.3s, "
                   "13.62% train, w=3.0): thuoc chinh thuc cua vong nay la "
                   "lat >=0.3s, con 74.8% cu synth cham <0.2s la vung mu "
                   "thong tin dang keo gradient di cho khac.",
        model=ShotNetConfig(d_model=192, n_layers=6, n_heads=6, d_ff=768,
                            use_deltas=True, ab_scale=0.4, huber_ab=0.5,
                            w_ab=2.0, aux_ball=True,
                            phi_loss="ang_huber", phi_huber_deg=5.0,
                            w_phi=6.0, huber_v0=0.10),
        epochs=60,
        run_date="20260812",
        slot_dropout=SlotDropoutConfig(),
        target_reweight=TargetReweight(weight=3.0),
    ),
}


# ------------------------------------------------------------ helpers

def _git_commit() -> str:
    try:
        return subprocess.run(["git", "-C", str(ROOT), "rev-parse", "HEAD"],
                              capture_output=True, text=True,
                              check=True).stdout.strip()
    except Exception:
        return "unknown"


def _predictions(out: dict, cfg: ShotNetConfig) -> dict:
    """Chuyển output forward (đang có sẵn, khỏi forward lần hai) → numpy
    prediction cùng format ``ShotNet.predict``."""
    v0 = torch.exp(out["v0_z"]) if cfg.v0_log else out["v0_z"]
    phi = torch.rad2deg(torch.atan2(out["phi_vec"][:, 1],
                                    out["phi_vec"][:, 0])) % 360.0
    return {"v0": v0.float().cpu().numpy(),
            "phi_deg": phi.float().cpu().numpy(),
            "a": (out["ab"][:, 0] * cfg.ab_scale).float().cpu().numpy(),
            "b": (out["ab"][:, 1] * cfg.ab_scale).float().cpu().numpy(),
            "p_ident": torch.sigmoid(out["ident_logit"]).float().cpu().numpy()}


def val_score(m: dict) -> float:
    """Thước chọn checkpoint — 1.0 mỗi vế = đúng bar G-27.3 (docstring)."""
    return (m["dphi_med"] / 2.0 + m["v0_relerr_med"] / 0.10
            + (1.0 - m["side_acc"]) / 0.20 + (1.0 - m["vert_acc"]) / 0.20)


def _to_device(batch: dict, device: str) -> dict:
    return {k: v.to(device, non_blocking=True) for k, v in batch.items()}


LOSS_KEYS = ("total", "v0", "phi", "ab", "ident")


def run_epoch_train(model, ds, batches, cfg: TrainConfig, opt, sched,
                    device: str) -> dict:
    model.train()
    sums = {k: 0.0 for k in LOSS_KEYS}
    n = 0
    for idxs in batches:
        batch = _to_device(collate([ds[int(i)] for i in idxs]), device)
        opt.zero_grad(set_to_none=True)
        with torch.autocast(device_type="cuda", dtype=torch.bfloat16,
                            enabled=cfg.amp_bf16 and device == "cuda"):
            out = model(batch["x"], batch["t"], batch["mask"])
            loss = shotnet_loss(out, batch, cfg.model)
        loss["total"].backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), cfg.grad_clip)
        opt.step()
        sched.step()
        bs = len(idxs)
        n += bs
        for k in LOSS_KEYS:
            sums[k] += float(loss[k].detach()) * bs
    return {k: v / max(n, 1) for k, v in sums.items()}


@torch.no_grad()
def run_val(model, ds, batches, cfg: TrainConfig, device: str) -> dict:
    model.eval()
    sums = {k: 0.0 for k in LOSS_KEYS}
    n = 0
    preds, labels = [], []
    for idxs in batches:
        batch = _to_device(collate([ds[int(i)] for i in idxs]), device)
        with torch.autocast(device_type="cuda", dtype=torch.bfloat16,
                            enabled=cfg.amp_bf16 and device == "cuda"):
            out = model(batch["x"], batch["t"], batch["mask"])
        out = {k: v.float() for k, v in out.items()}
        loss = shotnet_loss(out, batch, cfg.model)
        bs = len(idxs)
        n += bs
        for k in LOSS_KEYS:
            sums[k] += float(loss[k]) * bs
        preds.append(_predictions(out, cfg.model))
        labels.append({k: batch[k].cpu().numpy()
                       for k in ("label_v0", "label_phi", "label_a",
                                 "label_b", "identifiable")})
    pred = {k: np.concatenate([p[k] for p in preds]) for k in preds[0]}
    lab = {k: np.concatenate([q[k] for q in labels]) for k in labels[0]}
    m = gate_metrics(pred, lab)
    m.update({f"loss_{k}": v / max(n, 1) for k, v in sums.items()})
    m["score"] = val_score(m)
    return m


def draw_curves(csv_path: Path, png_path: Path) -> None:
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    rows = list(csv.DictReader(open(csv_path, encoding="utf-8")))
    ep = [int(r["epoch"]) for r in rows]

    def col(name):
        return [float(r[name]) for r in rows]

    fig, axes = plt.subplots(2, 2, figsize=(12, 8))
    ax = axes[0, 0]
    ax.plot(ep, col("train_total"), label="train")
    ax.plot(ep, col("val_loss_total"), label="val")
    ax.set_title("loss total")
    ax.legend()
    ax = axes[0, 1]
    ax.semilogy(ep, col("val_dphi_med"), label="|dphi| med (deg)")
    ax.axhline(2.0, color="r", ls="--", lw=0.8, label="bar 2 deg")
    ax.set_title("val |dphi| median")
    ax.legend()
    ax = axes[1, 0]
    ax.semilogy(ep, col("val_v0_relerr_med"), label="V0 rel err med (raw)")
    ax.axhline(0.10, color="r", ls="--", lw=0.8, label="bar 10%")
    ax.set_title("val V0 rel err median")
    ax.legend()
    ax = axes[1, 1]
    ax.plot(ep, col("val_side_acc"), label="side acc")
    ax.plot(ep, col("val_vert_acc"), label="vert acc")
    ax.axhline(0.80, color="r", ls="--", lw=0.8, label="bar 80%")
    ax.set_ylim(0, 1.02)
    ax.set_title("val spin sign acc (identifiable)")
    ax.legend()
    for a in axes.flat:
        a.set_xlabel("epoch")
        a.grid(alpha=0.3)
    fig.tight_layout()
    fig.savefig(png_path, dpi=110)
    plt.close(fig)


# ------------------------------------------------------------ main

def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="c1", choices=sorted(CONFIGS))
    ap.add_argument("--data", default=str(DATA_DIR_DEFAULT))
    ap.add_argument("--out-root", default=str(ROOT / "models"))
    ap.add_argument("--run-name", default="")
    ap.add_argument("--epochs", type=int, default=0, help="override epochs")
    ap.add_argument("--smoke", action="store_true",
                    help="1 shard + 2 epoch — kiem pipeline, KHONG tinh "
                         "vao ngan sach 3 config")
    args = ap.parse_args()

    cfg = CONFIGS[args.config]
    if args.epochs:
        cfg = replace(cfg, epochs=args.epochs)
    if args.smoke:
        cfg = replace(cfg, epochs=min(cfg.epochs, 2))
    run_name = args.run_name or (
        f"shotnet_{cfg.run_date}_{cfg.name}"
        + ("_smoke" if args.smoke else ""))
    out_dir = Path(args.out_root) / run_name
    out_dir.mkdir(parents=True, exist_ok=True)

    device = "cuda" if torch.cuda.is_available() else "cpu"
    print(f"run {run_name} | config {cfg.name} | device {device} "
          f"({torch.cuda.get_device_name(0) if device == 'cuda' else 'cpu'})")
    print(f"hypothesis: {cfg.hypothesis}")

    shards = sorted(Path(args.data).glob("train_*.npz"))
    if not shards:
        sys.exit("khong thay shard train_*.npz")
    if args.smoke:
        shards = shards[:1]
    print(f"nap {len(shards)} shard train vao RAM (lan dau o D lanh se i "
          f"vai phut - KHONG phai treo)...")
    t0 = time.time()
    ds = ShardDataset(shards, deltas=cfg.model.use_deltas,
                      augment=cfg.slot_dropout,
                      reweight=cfg.target_reweight)
    print(f"  {len(ds)} cu / {time.time() - t0:.0f}s | "
          f"frames p50 {int(np.median(ds.lengths))} max {ds.lengths.max()}")
    if cfg.slot_dropout:
        sd = cfg.slot_dropout
        print(f"augment slot dropout: p_apply {sd.p_apply} keep "
              f"U[{sd.keep_min}, {sd.keep_max}] seed {sd.seed} "
              f"(TRAIN only -- val/eval sach nhu c3)")
    if cfg.target_reweight:
        rw = cfg.target_reweight
        print(f"reweight lat target: w {rw.weight} cho cham dau >= "
              f"{rw.t_first_s}s (TRAIN only)")

    tr_idx, va_idx = val_split_indices(len(ds), cfg.val_frac, cfg.split_seed)
    print(f"split: train {len(tr_idx)} / val {len(va_idx)} "
          f"(seed {cfg.split_seed}, frac {cfg.val_frac})")
    tr_batcher = BucketBatcher(ds.lengths, tr_idx, cfg.token_budget,
                               cfg.max_batch, seed=cfg.split_seed)
    va_batches = BucketBatcher(ds.lengths, va_idx, cfg.token_budget,
                               cfg.max_batch,
                               seed=cfg.split_seed).epoch_batches(
                                   0, shuffle=False)

    torch.manual_seed(cfg.torch_seed)
    np.random.seed(cfg.torch_seed)
    model = ShotNet(cfg.model).to(device)
    n_params = sum(p.numel() for p in model.parameters())
    print(f"model {n_params} params")

    opt = torch.optim.AdamW(model.parameters(), lr=cfg.lr,
                            weight_decay=cfg.weight_decay)
    steps_per_epoch = len(tr_batcher.epoch_batches(0))
    total_steps = max(steps_per_epoch * cfg.epochs, 1)
    warmup = max(int(total_steps * cfg.warmup_frac), 1)

    def lr_lambda(step):
        if step < warmup:
            return (step + 1) / warmup
        p = (step - warmup) / max(total_steps - warmup, 1)
        return 0.5 * (1.0 + math.cos(math.pi * min(p, 1.0)))

    sched = torch.optim.lr_scheduler.LambdaLR(opt, lr_lambda)

    csv_path = out_dir / "curves.csv"
    png_path = out_dir / "curves.png"
    fields = (["epoch", "lr", "elapsed_s"]
              + [f"train_{k}" for k in LOSS_KEYS]
              + [f"val_loss_{k}" for k in LOSS_KEYS]
              + ["val_dphi_med", "val_v0_relerr_med", "val_side_acc",
                 "val_vert_acc", "val_ident_acc", "val_score", "is_best"])
    with open(csv_path, "w", newline="", encoding="utf-8") as f:
        csv.DictWriter(f, fieldnames=fields).writeheader()

    best = {"score": float("inf"), "epoch": -1, "metrics": None}
    t_start = time.time()
    for epoch in range(cfg.epochs):
        # augmentation/reweight CHỈ bật ở nửa train của epoch — val phải
        # thấy dữ liệu y hệt c3, nếu không val_score không so được và
        # checkpoint sẽ chọn theo một thước khác (BG29 bước 2.1)
        ds.set_train_mode(True, epoch)
        tr = run_epoch_train(model, ds, tr_batcher.epoch_batches(epoch),
                             cfg, opt, sched, device)
        ds.set_train_mode(False)
        vm = run_val(model, ds, va_batches, cfg, device)
        is_best = vm["score"] < best["score"]
        if is_best:
            best = {"score": vm["score"], "epoch": epoch, "metrics": vm}
            torch.save({"state_dict": model.state_dict(),
                        "model_config": asdict(cfg.model),
                        "train_config": {k: v for k, v in asdict(cfg).items()
                                         if k != "model"},
                        "epoch": epoch, "val_metrics": vm,
                        "commit": _git_commit()},
                       out_dir / "best.pt")
        row = {"epoch": epoch, "lr": f"{sched.get_last_lr()[0]:.3e}",
               "elapsed_s": round(time.time() - t_start, 1),
               **{f"train_{k}": round(v, 5) for k, v in tr.items()},
               **{f"val_loss_{k}": round(vm[f"loss_{k}"], 5)
                  for k in LOSS_KEYS},
               "val_dphi_med": round(vm["dphi_med"], 3),
               "val_v0_relerr_med": round(vm["v0_relerr_med"], 4),
               "val_side_acc": round(vm["side_acc"], 4),
               "val_vert_acc": round(vm["vert_acc"], 4),
               "val_ident_acc": round(vm["ident_acc"], 4),
               "val_score": round(vm["score"], 4), "is_best": int(is_best)}
        with open(csv_path, "a", newline="", encoding="utf-8") as f:
            csv.DictWriter(f, fieldnames=fields).writerow(row)
        draw_curves(csv_path, png_path)
        print(f"ep {epoch:3d} | train {tr['total']:.4f} | "
              f"val {vm['loss_total']:.4f} | dphi {vm['dphi_med']:6.2f} | "
              f"v0rel {vm['v0_relerr_med']:.4f} | "
              f"side {vm['side_acc']:.3f} | vert {vm['vert_acc']:.3f} | "
              f"score {vm['score']:.3f}{' *best*' if is_best else ''}",
              flush=True)

    elapsed_min = (time.time() - t_start) / 60.0
    data_spec = json.loads((Path(args.data) / "spec.json")
                           .read_text(encoding="utf-8"))
    d = cfg.run_date
    spec = {
        "run_name": run_name, "created": f"{d[:4]}-{d[4:6]}-{d[6:]}",
        "commit": _git_commit(),
        "dataset": {"dir": str(args.data),
                    "data_commit": data_spec.get("commit"),
                    "n_train_total": len(ds),
                    "shards": [p.name for p in shards]},
        "split": {"rule": "val cat tu TRAIN theo permutation seed; "
                          "5k held-out KHONG dung khi train/tune",
                  "seed": cfg.split_seed, "val_frac": cfg.val_frac,
                  "n_train": int(len(tr_idx)), "n_val": int(len(va_idx))},
        "config": asdict(cfg), "n_params": n_params,
        "torch": torch.__version__,
        "gpu": (torch.cuda.get_device_name(0) if device == "cuda" else "cpu"),
        "epochs_run": cfg.epochs, "elapsed_min": round(elapsed_min, 1),
        "best": {"epoch": best["epoch"], "val_score": round(best["score"], 4),
                 "val_metrics": {k: (round(v, 5)
                                     if isinstance(v, float) else v)
                                 for k, v in best["metrics"].items()}},
        "val_score_formula": "dphi_med/2 + v0relerr/0.10 + (1-side)/0.20 "
                             "+ (1-vert)/0.20 (1.0 moi ve = bar G-27.3)",
        "augment": ("slot dropout TRAIN-only, on-the-fly tu 150k co san "
                    "(KHONG sinh dataset moi ra dia); keep-rate ~ U[min,max] "
                    "Bernoulli doc lap tren o (frame, slot khong-cue); cue "
                    "KHONG dropout; RNG khoa [seed, epoch, shot_idx]"
                    if cfg.slot_dropout else "khong"),
        "reweight": (f"loss weighting (KHONG oversample - giu nguyen so buoc "
                     f"moi epoch nhu c3): w={cfg.target_reweight.weight} cho "
                     f"cu co cham dau >= {cfg.target_reweight.t_first_s}s "
                     f"(13.62% train), trung binh co trong so nen thang "
                     f"gradient giu ~1"
                     if cfg.target_reweight else "khong"),
        "note": "checkpoint chon theo VAL - khong dung held-out (BRIEF 27)",
    }
    (out_dir / "spec.json").write_text(json.dumps(spec, indent=2),
                                       encoding="utf-8")
    print(f"XONG {elapsed_min:.1f} phut | best ep {best['epoch']} "
          f"score {best['score']:.4f} -> {out_dir}")


if __name__ == "__main__":
    main()