# -*- coding: utf-8 -*- """ShotNet — net suy ngược thông số cú đánh từ quỹ đạo bẩn (BG27, design §6). Bài toán ngược của action space RL: chuỗi quan sát {vị trí các bi (toạ độ bàn), visibility mask, timestamp} của MỘT cú → (V0, φ, a, b) đúng 4 chiều ``cue.set_state``. Train THUẦN synthetic (``datasets/bb9_synth``, nhiễu P0 là hợp đồng — gen_synth_shots.py); loader ở đây đọc npz KHÔNG cần pooltool để chạy được trong venv CV (thiết kế BG26). Kiến trúc theo design §6: transformer encoder nhỏ, một **shot token** học được đứng đầu chuỗi đọc ra output, **RoPE theo timestamp thật** (chịu fps lệch 25–60 + frame thiếu + frame trùng — RoPE quay q/k theo GIÂY, không theo chỉ số frame). Token = frame; feature mỗi frame là 10 slot bi cố định theo số bi (slot 0 = cue, slot k = bi k) × (x, y, vis) + ``img_diff`` — cột cuối là yêu cầu từ HANDOFF 23 Bất ngờ 1: frame trùng 25→30 upconvert có PTS hợp lệ, CHỈ img_diff lộ, nên timestamp một mình không đủ cho net phân biệt. Output head (quyết định Cowork từ HANDOFF 26b, BRIEF 27): - ``v0`` — thước GẬY, so ``raw`` với label_v0. Dự đoán trong LOG-space (Huber trên log V0): gate là sai số TƯƠNG ĐỐI nên residual log ∝ rel err, Huber-raw sẽ dồn gradient về cú nhanh 8 m/s mà bỏ rơi cú 0.5 m/s. - ``phi`` — encode (cos, sin), loss MSE tới vector đơn vị của label: liên tục quanh 0°/360°, không cần xử wrap; φ đọc ra bằng atan2. - ``a, b`` — Huber, **mask trên cú non-identifiable** (spin chỉ lộ qua va chạm — design §3.2; đừng bắt net học điều không xác định, văn hoá null). - ``identifiable`` — BCE logit. - aux tuỳ config: ``v0_ball``/``phi_ball`` (label có sẵn trong dataset — BG26b Bất ngờ 4); gate vẫn chấm trên V0 gậy. Val split cắt ~2% từ TRAIN theo seed (``val_split_indices``) — 5k held-out là TEST, cấm đụng khi train/tune (BRIEF 27 bước 1.1). BG29 thêm HAI thứ vào **đường train** (và chỉ đường train — xem ``ShardDataset.set_train_mode``): ``SlotDropoutConfig`` làm thưa detections bi mục tiêu on-the-fly để phủ vùng mật độ của clip thật, và ``TargetReweight`` kéo trọng số loss về lát chạm đầu ≥0.3s. Kiến trúc, thước và mapping KHÔNG đổi một dòng. Hằng phân lớp spin (A_SIDE_MIN/B_STUN_MAX) chép từ ``scripts/broadcast/eval_baseline_synth.py`` — GT của gate chấm ở đó; ở đây chỉ dùng cho metric val + map (a,b) dự đoán → lớp khi cắm vào harness. """ from __future__ import annotations import math from dataclasses import dataclass, field from pathlib import Path import numpy as np import torch import torch.nn as nn import torch.nn.functional as F # ------------------------------------------------------------ bảng hằng # Bàn synth = pooltool default (spec.json bb9_synth) — KHÔNG phải bàn giải # 1.27×2.54 của broadcast.py. TABLE_W_M = 0.9906 TABLE_L_M = 1.9812 N_SLOTS = 10 # slot bi cố định: 0 = cue, 1..9 = bi số FEAT_DIM = 3 * N_SLOTS + 1 # (x, y, vis) × 10 + img_diff = 31 FEAT_DIM_DELTAS = 5 * N_SLOTS + 1 # + (dx, dy) × 10 (chế độ deltas, config c2): # φ/V0 là hàm của VẬN TỐC — c1 bắt encoder tự # dựng chuyển động từ toạ độ tuyệt đối qua # attention và trần ở 11.7°; đưa thẳng finite # difference vào layer 1 IMG_DIFF_CLIP = 2.0 # img_diff cú nhanh ~0.8–2; frame đầu sentinel # −1 → clip về 0 (t=0 tự khai frame đầu) # GT spin — CHÉP từ eval_baseline_synth.py (một nguồn, hai bản có chủ đích: # src/ không import được scripts/; test khoá hai bản bằng nhau nếu cần): B_STUN_MAX = 0.10 # |b| ≤ 0.10 = stun A_SIDE_MIN = 0.10 # |a| ≤ 0.10 = neutral (side không chấm) # ------------------------------------------------------------- featurize def featurize_shot(shot: dict, w: float = TABLE_W_M, l: float = TABLE_L_M, deltas: bool = False) -> tuple[np.ndarray, np.ndarray]: """Một cú (dict của ``iter_shots``/``ShardDataset``) → (feats (F, 31 hoặc 51 nếu ``deltas``), t (F,)) float32. Chuẩn hoá vị trí ĐẲNG HƯỚNG quanh tâm bàn: x' = (x − w/2)·2/l, y' = (y − l/2)·2/l — khoảng cách vật lý giữ nguyên tỷ lệ hai trục (net ước lượng vận tốc từ hiệu vị trí). Bi không thấy (covered=False) hoặc slot không có bi trên bàn: (0, 0, vis=0). ``deltas`` (config c2): thêm khối (dx, dy) per-slot = hiệu vị trí với frame TRƯỚC, cùng thang chuẩn hoá, chỉ khi CẢ HAI frame thấy bi (khác đi bằng 0 — không nhảy vọt qua gap); frame trùng upconvert cho Δ=0, kết hợp img_diff net phân biệt được dup với đứng yên. img_diff luôn là cột CUỐI ở cả hai chế độ. """ xy, cov = shot["xy"], shot["covered"] ball_ids = np.asarray(shot["ball_ids"], dtype=np.int64) F_n = xy.shape[0] dim = FEAT_DIM_DELTAS if deltas else FEAT_DIM feats = np.zeros((F_n, dim), dtype=np.float32) xn = ((xy[:, :, 0] - w / 2.0) * (2.0 / l)).astype(np.float32) yn = ((xy[:, :, 1] - l / 2.0) * (2.0 / l)).astype(np.float32) vis = cov.astype(np.float32) for j, s in enumerate(ball_ids): feats[:, 3 * s] = xn[:, j] * vis[:, j] feats[:, 3 * s + 1] = yn[:, j] * vis[:, j] feats[:, 3 * s + 2] = vis[:, j] if deltas: both = np.zeros(F_n, dtype=np.float32) both[1:] = vis[1:, j] * vis[:-1, j] d0 = 3 * N_SLOTS + 2 * s feats[1:, d0] = (xn[1:, j] - xn[:-1, j]) * both[1:] feats[1:, d0 + 1] = (yn[1:, j] - yn[:-1, j]) * both[1:] feats[:, -1] = np.clip(shot["img_diff"], 0.0, IMG_DIFF_CLIP) return feats, np.asarray(shot["t"], dtype=np.float32) LABEL_KEYS = ("label_v0", "label_phi", "label_a", "label_b", "identifiable", "v0_ball", "phi_ball", "fps", "upconvert", "scratch") # ------------------------- augmentation "detections thưa bi mục tiêu" (BG29) # Chẩn đoán BG28 (ĐO ĐƯỢC, không đoán): net c3 sập trên clip thật vì PHÂN # PHỐI mật độ detection bi mục tiêu, không phải vì kiến trúc hay input path # — synth train 95–96% visibility (≈5.3 det không-cue/frame) trong khi cú 11 # thật chỉ 1.01 det/frame, cú 12 3.09. Augmentation dưới đây làm THƯA # detections ngay trong loader train (on-the-fly, KHÔNG sinh dataset mới ra # đĩa — BRIEF 29 bước 1.1) để dải huấn luyện phủ được vùng thật đã đo. # # Dropout ra mask 0 ĐÚNG như bi mất frame (featurize_shot nhân toạ độ với # vis, khối delta nhân tích vis hai frame) — không đụng img_diff/timestamp, # không đụng feature nào khác. @dataclass class SlotDropoutConfig: """Dropout slot bi mục tiêu, mỗi cú một keep-rate. ``p_apply``: tỉ lệ cú ĐƯỢC augment; phần còn lại đi qua loader **bit giống** đường cũ (giữ nguyên vùng 95–96% visibility của synth trong phân phối train — đó cũng là đầu trên của dải). ``keep_min``/``keep_max``: keep-rate ~ U(min, max), Bernoulli độc lập trên từng ô (frame, slot không-cue). Dải mặc định [0.10, 0.95] cộng với nhánh không-augment phủ trọn vùng thật đã đo: cú 11 ~15% visibility per-ball, cú 12 ~50%, synth gốc 95–96%. ``cue`` KHÔNG bao giờ bị dropout thêm: track cue trên clip thật vốn tốt (coverage 0.90–0.97 ở BG28) và nhiễu đầu cú đã nằm trong hợp đồng P0. """ p_apply: float = 0.75 keep_min: float = 0.10 keep_max: float = 0.95 seed: int = 20260812 def apply_slot_dropout(shot: dict, cfg: SlotDropoutConfig, rng: np.random.Generator) -> tuple[dict, float]: """Thưa detections bi mục tiêu của MỘT cú → (shot mới, keep-rate đã dùng; 1.0 = không augment). KHÔNG sửa tại chỗ: ``covered`` của ``ShardDataset.raw_shot`` là view vào mảng shard nạp sẵn trong RAM — ghi đè lên đó là hỏng dataset cho mọi epoch sau. """ cov = shot["covered"] tgt = np.flatnonzero(np.asarray(shot["ball_ids"]) != 0) if tgt.size == 0 or rng.random() >= cfg.p_apply: return shot, 1.0 keep = float(rng.uniform(cfg.keep_min, cfg.keep_max)) cov = np.array(cov, dtype=bool, copy=True) cov[:, tgt] &= rng.random((cov.shape[0], tgt.size)) < keep out = dict(shot) out["covered"] = cov return out, keep # Lát target-matched — CHÉP từ scripts/broadcast/eval_baseline_synth.py # (T_FIRST_TARGET_S; src/ không import được scripts/, nếp B_STUN_MAX ở trên). # Quần thể khớp clip thật P0 và là thước chính thức của gate P2′. T_FIRST_TARGET_S = 0.3 @dataclass class TargetReweight: """Reweight về lát target (BRIEF 29 bước 1.2): cú có chạm đầu ≥ ``t_first_s`` được nhân trọng số ``weight`` trong loss. Chọn **weight loss** thay vì oversample có chủ đích: oversample lát 13.6% với hệ số 3 sẽ kéo dài epoch ~27%, phá điều kiện "ngân sách bước y hệt c3" của BRIEF 29 bước 2.1 — hai config sẽ không so được với c3 nữa. Trọng số áp cho MỌI head (v0/phi/ab/ident) qua trung bình có trọng số, nên thang gradient tổng giữ nguyên ~1 (bài học c1 BG27: lệch thang gradient là head chết). """ weight: float = 3.0 t_first_s: float = T_FIRST_TARGET_S def t_first_contact_s(shot: dict) -> float: """Chạm đầu của cue kể từ strike: min(t_first_bb, t_first_cush) — NaN nếu cú không có va chạm nào. Cùng công thức eval_baseline_synth.""" vals = [float(v) for v in (shot["t_first_bb"], shot["t_first_cush"]) if not math.isnan(float(v))] return min(vals) if vals else math.nan # ------------------------------------- sim→real: dựng shot từ track app (BG28) # Khe input sim→real là rủi ro chính của bàn giao 28: net train với slot bi # mục tiêu bền theo thời gian (slot k = bi k trong sim), còn pipeline app chỉ # track cue — detections mỗi frame CÓ các bi khác nhưng chưa nối. Dựng slot # bằng greedy nearest-neighbor qua frame (BRIEF 28 bước 2.2): KHÔNG cần # BallID — slot chỉ cần BỀN theo thời gian như slot sim; bi mất frame → # mask 0. Đơn giản có chủ đích. # # Hằng nối det→slot MƯỢN đúng số đã nghiệm thu của track cue (broadcast.py # VMAX_MPS/GATE_SLACK_M/BALL_DEDUP_M — không import được vì src/ không kéo # scripts/, và broadcast.py là tầng cấm sửa; chép số kèm nguồn, nếp # B_STUN_MAX ở trên): SLOT_GATE_MPS = 11.0 # trần vận tốc nối det giữa 2 frame (= VMAX_MPS) SLOT_GATE_SLACK_M = 0.06 # nhiễu tâm bbox (~2R) (= GATE_SLACK_M) SLOT_DEDUP_M = 0.03 # 2 det cùng frame < ~1R = double-detect, bỏ det # sau (= BALL_DEDUP_M) MAX_TARGET_SLOTS = N_SLOTS - 1 # tối đa 9 slot bi mục tiêu (slot 0 = cue) def build_target_slots(times: np.ndarray, dets_by_frame: list[list[tuple[float, float]]] ) -> tuple[np.ndarray, np.ndarray]: """Nối detections KHÔNG-cue rời rạc thành slot bền theo thời gian. ``times`` (F,) giây; ``dets_by_frame`` list F phần tử, mỗi phần tử là list (x, y) mét của frame đó. Trả (slot_xy (F, K, 2), slot_vis (F, K)) với K ≤ MAX_TARGET_SLOTS slot theo thứ tự XUẤT HIỆN. Mỗi frame: dedup double-detect (< SLOT_DEDUP_M) rồi ghép det↔slot greedy theo khoảng cách tăng dần, gate = SLOT_GATE_MPS·Δt_kể_từ_lần_thấy_cuối + SLOT_GATE_SLACK_M (bi có thể đã chạy trong lúc mất frame — gate nở theo thời gian mất). Det không ghép được slot nào → slot MỚI (quá MAX_TARGET_SLOTS thì bỏ det). Frame không có det của slot → vis 0, đúng ngữ nghĩa ``covered`` của loader synth. """ F = len(times) slots: list[dict] = [] # {x, y, t_last} raw_xy: list[np.ndarray] = [] # (F, 2) per slot — cấp phát lười raw_vis: list[np.ndarray] = [] for f in range(F): t = float(times[f]) dets: list[tuple[float, float]] = [] for p in dets_by_frame[f]: if any(math.hypot(p[0] - q[0], p[1] - q[1]) < SLOT_DEDUP_M for q in dets): continue dets.append((float(p[0]), float(p[1]))) if not dets: continue # mọi cặp (slot, det) trong gate, xét theo khoảng cách tăng dần pairs = [] for si, s in enumerate(slots): gate = SLOT_GATE_MPS * max(t - s["t_last"], 0.0) \ + SLOT_GATE_SLACK_M for di, p in enumerate(dets): d = math.hypot(p[0] - s["x"], p[1] - s["y"]) if d <= gate: pairs.append((d, si, di)) pairs.sort(key=lambda z: z[0]) used_s: set[int] = set() used_d: set[int] = set() for d, si, di in pairs: if si in used_s or di in used_d: continue used_s.add(si) used_d.add(di) s = slots[si] s["x"], s["y"], s["t_last"] = dets[di][0], dets[di][1], t raw_xy[si][f] = dets[di] raw_vis[si][f] = True for di, p in enumerate(dets): if di in used_d or len(slots) >= MAX_TARGET_SLOTS: continue slots.append({"x": p[0], "y": p[1], "t_last": t}) raw_xy.append(np.zeros((F, 2), dtype=np.float32)) raw_vis.append(np.zeros(F, dtype=bool)) raw_xy[-1][f] = p raw_vis[-1][f] = True if not slots: return (np.zeros((F, 0, 2), dtype=np.float32), np.zeros((F, 0), dtype=bool)) return (np.stack(raw_xy, axis=1).astype(np.float32), np.stack(raw_vis, axis=1)) def shot_from_track(rows: list[dict], others: list[dict] | None ) -> dict: """Dựng dict cú (cùng shape ``iter_shots``/``featurize_shot`` cần) từ ĐÚNG input của ``broadcast.analyze_track``: ``rows`` per-frame của track cue (t_s, covered, table_x_m/y_m, img_diff) + ``others`` detection KHÔNG-cue rời rạc ({t_s, x_m, y_m}). Một nguồn sự thật với loader — caller featurize bằng ``featurize_shot(shot, w=, l=)``; chuẩn hoá đẳng hướng của featurize làm phần còn lại (hai bàn cùng tỷ lệ 2:1 nên toạ độ chuẩn hoá trùng khớp phân phối synth). KHÔNG dedup frame trùng, KHÔNG smooth — net train trên chuỗi thô có frame dup + img_diff, đưa thô vào đúng như loader (HANDOFF 23 BN1). Timestamp trừ mốc frame đầu (synth luôn bắt đầu ở 0; PTS container có thể có offset). """ F = len(rows) t0 = float(rows[0]["t_s"]) t = np.array([float(r["t_s"]) - t0 for r in rows], dtype=np.float32) cue_xy = np.zeros((F, 2), dtype=np.float32) cue_vis = np.zeros(F, dtype=bool) img_diff = np.zeros(F, dtype=np.float32) for i, r in enumerate(rows): on = str(r.get("covered", "0")) in ("1", "True", "true") cue_vis[i] = on if on: cue_xy[i] = (float(r["table_x_m"]), float(r["table_y_m"])) img_diff[i] = float(r.get("img_diff", -1.0)) # gom detection theo frame: t_s của others là ĐÚNG t_s của frame sinh ra # nó (analyze_clip đọc từ cùng meta), map bằng giá trị by_t: dict[float, list[tuple[float, float]]] = {} for o in (others or []): by_t.setdefault(float(o["t_s"]), []).append( (float(o["x_m"]), float(o["y_m"]))) dets_by_frame = [by_t.get(float(r["t_s"]), []) for r in rows] slot_xy, slot_vis = build_target_slots(t, dets_by_frame) K = slot_xy.shape[1] xy = np.concatenate([cue_xy[:, None, :], slot_xy], axis=1) covered = np.concatenate([cue_vis[:, None], slot_vis], axis=1) return {"t": t, "xy": xy, "covered": covered, "img_diff": img_diff, "ball_ids": np.arange(K + 1, dtype=np.int64), "n_frames": F, "n_balls": K + 1} # --------------------------------------------------------------- dataset class ShardDataset: """Nạp danh sách shard npz vào RAM (ragged + offset, ~2.4GB cho 150k), trả từng cú đã featurize. KHÔNG cần pooltool; không phụ thuộc torch cho tới ``collate`` (test loader chạy được thuần numpy). ``indices``: chỉ số TOÀN CỤC (theo thứ tự shard đã sort + local idx) — dùng cho train/val split. ``limit_shots``: cắt mỗi shard (smoke test). ``augment``/``reweight`` (BG29): CHỈ có hiệu lực khi ``set_train_mode( True)``. Mặc định TẮT — mọi đường eval (harness held-out, worker app, val mỗi epoch) phải thấy dữ liệu y hệt c3, nếu không số không so được. """ _META = ("label_v0", "label_phi", "label_a", "label_b", "v0_ball", "phi_ball", "identifiable", "scratch", "fps", "upconvert", "t_first_bb", "t_first_cush", "n_frames", "n_balls", "shot_idx") def __init__(self, shard_paths: list[Path], limit_shots: int = 0, deltas: bool = False, augment: SlotDropoutConfig | None = None, reweight: TargetReweight | None = None): self.deltas = deltas self.augment = augment self.reweight = reweight self._train_mode = False self._epoch = 0 self.shards = [] self._index: list[tuple[int, int]] = [] for si, p in enumerate(sorted(Path(q) for q in shard_paths)): z = np.load(p) S = len(z["shot_idx"]) if limit_shots: S = min(S, limit_shots) sh = {k: np.asarray(z[k]) for k in self._META} sh["xy"] = np.asarray(z["xy"], dtype=np.float32) sh["covered"] = np.asarray(z["covered"]).astype(bool) sh["img_diff"] = np.asarray(z["img_diff"], dtype=np.float32) sh["ball_ids"] = np.asarray(z["ball_ids"]) sh["xy_off"] = np.asarray(z["xy_off"], dtype=np.int64) sh["f_off"] = np.asarray(z["f_off"], dtype=np.int64) sh["b_off"] = np.asarray(z["b_off"], dtype=np.int64) self.shards.append(sh) self._index.extend((si, li) for li in range(S)) self.lengths = np.array( [self.shards[si]["n_frames"][li] for si, li in self._index], dtype=np.int64) def __len__(self) -> int: return len(self._index) def set_train_mode(self, on: bool, epoch: int = 0) -> None: """Bật/tắt augmentation + reweight, chốt epoch cho RNG. Train bật (epoch khác nhau → mask khác nhau, vẫn tái lập được từ ``[seed, epoch, i]``); val/eval TẮT. Một cờ cho cả hai vì cả hai chỉ có nghĩa ở đường train — số val phải so được với c3 và giữa các config. """ self._train_mode = bool(on) self._epoch = int(epoch) def raw_shot(self, i: int) -> dict: """Dict cú thô cùng shape với ``gen_synth_shots.iter_shots``.""" si, li = self._index[i] sh = self.shards[si] F_n = int(sh["n_frames"][li]) B = int(sh["n_balls"][li]) x0, f0, b0 = (int(sh["xy_off"][li]), int(sh["f_off"][li]), int(sh["b_off"][li])) shot = {k: sh[k][li].item() for k in self._META if k not in ("n_frames", "n_balls")} shot["n_frames"], shot["n_balls"] = F_n, B shot["t"] = (np.arange(F_n) / float(sh["fps"][li])).astype(np.float32) shot["xy"] = sh["xy"][x0:x0 + F_n * B].reshape(F_n, B, 2) shot["covered"] = sh["covered"][x0:x0 + F_n * B].reshape(F_n, B) shot["img_diff"] = sh["img_diff"][f0:f0 + F_n] shot["ball_ids"] = sh["ball_ids"][b0:b0 + B] return shot def __getitem__(self, i: int) -> dict: shot = self.raw_shot(i) keep = 1.0 if self._train_mode and self.augment is not None: # RNG khoá theo [seed, epoch, i]: cùng bộ ba → cùng mask (tái # lập được từng epoch), khác epoch → mask khác (augmentation # thật sự on-the-fly chứ không phải một bản thưa cố định) rng = np.random.default_rng( [self.augment.seed, self._epoch, int(i)]) shot, keep = apply_slot_dropout(shot, self.augment, rng) feats, t = featurize_shot(shot, deltas=self.deltas) item = {"feats": feats, "t": t} for k in LABEL_KEYS: item[k] = float(shot[k]) item["keep_rate"] = keep # chẩn đoán/test, không vào collate if self._train_mode and self.reweight is not None: tf = t_first_contact_s(shot) item["sample_w"] = (self.reweight.weight if (not math.isnan(tf)) and tf >= self.reweight.t_first_s else 1.0) return item def val_split_indices(n_total: int, val_frac: float, seed: int) -> tuple[np.ndarray, np.ndarray]: """Split val ~``val_frac`` cắt từ TRAIN theo seed — tất định, rời nhau, độc lập machine/shard. Trả (train_idx, val_idx) đã sort.""" perm = np.random.default_rng(seed).permutation(n_total) n_val = int(round(n_total * val_frac)) return np.sort(perm[n_val:]), np.sort(perm[:n_val]) class BucketBatcher: """Gom batch theo NGÂN SÁCH TOKEN (max_len_trong_batch × cỡ_batch ≤ budget) sau khi sort-theo-độ-dài có jitter — chuỗi 13..1192 frame mà batch cố định thì hoặc tràn VRAM ở đuôi dài hoặc phí padding ở đuôi ngắn (VRAM 3070 = 8GB, BRIEF bối cảnh 6). Thứ tự batch xáo theo [seed, epoch] — tái lập được từng epoch.""" def __init__(self, lengths: np.ndarray, indices: np.ndarray, token_budget: int = 40_000, max_batch: int = 256, seed: int = 0): self.lengths, self.indices = lengths, np.asarray(indices) self.token_budget, self.max_batch = token_budget, max_batch self.seed = seed def epoch_batches(self, epoch: int, shuffle: bool = True) -> list[np.ndarray]: rng = np.random.default_rng([self.seed, epoch]) lens = self.lengths[self.indices].astype(np.float64) if shuffle: lens = lens * rng.uniform(0.9, 1.1, len(lens)) order = self.indices[np.argsort(lens, kind="stable")] batches, cur, cur_max = [], [], 0 for i in order: li = int(self.lengths[i]) new_max = max(cur_max, li) if cur and ((len(cur) + 1) * new_max > self.token_budget or len(cur) >= self.max_batch): batches.append(np.array(cur)) cur, cur_max = [], 0 new_max = li cur.append(int(i)) cur_max = new_max if cur: batches.append(np.array(cur)) if shuffle: rng.shuffle(batches) return batches def collate(items: list[dict]) -> dict: """Pad về max-len trong batch → tensor torch. ``mask`` True = frame thật, False = padding. ``sample_w`` (BG29 reweight) CHỈ xuất hiện khi loader phát ra nó — batch không có khoá này đi qua ĐÚNG code path loss cũ (c3/c4a), không phải nhánh trung bình-có-trọng-số. """ B = len(items) T = max(len(it["t"]) for it in items) dim = items[0]["feats"].shape[1] x = torch.zeros(B, T, dim, dtype=torch.float32) t = torch.zeros(B, T, dtype=torch.float32) mask = torch.zeros(B, T, dtype=torch.bool) for i, it in enumerate(items): n = len(it["t"]) x[i, :n] = torch.from_numpy(np.ascontiguousarray(it["feats"])) t[i, :n] = torch.from_numpy(np.ascontiguousarray(it["t"])) mask[i, :n] = True out = {"x": x, "t": t, "mask": mask} for k in LABEL_KEYS: out[k] = torch.tensor([it[k] for it in items], dtype=torch.float32) if any("sample_w" in it for it in items): out["sample_w"] = torch.tensor( [float(it.get("sample_w", 1.0)) for it in items], dtype=torch.float32) return out # ----------------------------------------------------------------- model @dataclass class ShotNetConfig: d_model: int = 128 n_layers: int = 4 n_heads: int = 4 d_ff: int = 512 dropout: float = 0.1 feat_dim: int = FEAT_DIM use_deltas: bool = False # featurize thêm khối (dx, dy) — xem # featurize_shot; feat_dim tự nâng 51 ab_scale: float = 1.0 # target (a,b)/ab_scale khi tính loss — # 0.4 (c2) đưa target về ±1 để gradient # spin ngang phi (c1: head a sập về hằng # 0, corr 0.02 — gradient 0.05 quá yếu) # RoPE theo GIÂY: chu kỳ hình học từ min (phân giải ~nửa bước frame 60fps # ×2) tới max (dài hơn clip dài nhất ~21.5s) rope_min_period_s: float = 0.04 rope_max_period_s: float = 64.0 shot_token_t_s: float = -1.0 # timestamp gán cho shot token (trước clip) v0_log: bool = True # Huber trên log V0 (xem docstring module) huber_v0: float = 0.25 huber_ab: float = 0.05 phi_loss: str = "vec_mse" # "vec_mse" (c1/c2) | "ang_huber" (c3): # vec-MSE có gradient ∝ sin(Δ) nên cú vô # vọng (Δ~90°) thống trị; gate chấm MEDIAN # — Huber trên GÓC (linear ngoài delta) ép # khối giữa phân phối về dưới bar phi_huber_deg: float = 5.0 # delta của ang_huber (độ) w_v0: float = 1.0 w_phi: float = 2.0 w_ab: float = 1.0 w_ident: float = 0.2 aux_ball: bool = False # head phụ v0_ball/phi_ball (BG26b BN4) w_aux: float = 0.5 def __post_init__(self): if self.use_deltas and self.feat_dim == FEAT_DIM: self.feat_dim = FEAT_DIM_DELTAS class RoPE(nn.Module): """Rotary embedding theo timestamp THỰC (giây), tần số hình học.""" def __init__(self, head_dim: int, min_period_s: float, max_period_s: float): super().__init__() n_pairs = head_dim // 2 k = torch.arange(n_pairs, dtype=torch.float32) / max(n_pairs - 1, 1) omega = (2.0 * math.pi / max_period_s) * \ (max_period_s / min_period_s) ** k self.register_buffer("omega", omega, persistent=False) def rotate(self, q: torch.Tensor, t: torch.Tensor) -> torch.Tensor: # q: (B, H, T, Dh) · t: (B, T) → quay từng cặp (2i, 2i+1) ang = t[:, None, :, None] * self.omega[None, None, None, :] cos, sin = torch.cos(ang), torch.sin(ang) q1, q2 = q[..., 0::2], q[..., 1::2] out = torch.empty_like(q) out[..., 0::2] = q1 * cos - q2 * sin out[..., 1::2] = q1 * sin + q2 * cos return out class EncoderLayer(nn.Module): def __init__(self, cfg: ShotNetConfig, rope: RoPE): super().__init__() self.h = cfg.n_heads self.dh = cfg.d_model // cfg.n_heads self.rope = rope self.ln1 = nn.LayerNorm(cfg.d_model) self.qkv = nn.Linear(cfg.d_model, 3 * cfg.d_model) self.proj = nn.Linear(cfg.d_model, cfg.d_model) self.ln2 = nn.LayerNorm(cfg.d_model) self.ff = nn.Sequential( nn.Linear(cfg.d_model, cfg.d_ff), nn.GELU(), nn.Dropout(cfg.dropout), nn.Linear(cfg.d_ff, cfg.d_model)) self.drop = nn.Dropout(cfg.dropout) def forward(self, h: torch.Tensor, t: torch.Tensor, key_mask: torch.Tensor) -> torch.Tensor: B, T, D = h.shape q, k, v = self.qkv(self.ln1(h)).chunk(3, dim=-1) q = q.view(B, T, self.h, self.dh).transpose(1, 2) k = k.view(B, T, self.h, self.dh).transpose(1, 2) v = v.view(B, T, self.h, self.dh).transpose(1, 2) q, k = self.rope.rotate(q, t), self.rope.rotate(k, t) att = F.scaled_dot_product_attention( q, k, v, attn_mask=key_mask[:, None, None, :]) att = att.transpose(1, 2).reshape(B, T, D) h = h + self.drop(self.proj(att)) h = h + self.drop(self.ff(self.ln2(h))) return h class ShotNet(nn.Module): def __init__(self, cfg: ShotNetConfig): super().__init__() self.cfg = cfg self.inp = nn.Linear(cfg.feat_dim, cfg.d_model) self.shot_token = nn.Parameter(torch.zeros(1, 1, cfg.d_model)) nn.init.normal_(self.shot_token, std=0.02) rope = RoPE(cfg.d_model // cfg.n_heads, cfg.rope_min_period_s, cfg.rope_max_period_s) self.layers = nn.ModuleList( EncoderLayer(cfg, rope) for _ in range(cfg.n_layers)) self.ln_f = nn.LayerNorm(cfg.d_model) d = cfg.d_model self.head_v0 = nn.Linear(d, 1) self.head_phi = nn.Linear(d, 2) # (cos, sin) self.head_ab = nn.Linear(d, 2) # (a, b) self.head_ident = nn.Linear(d, 1) if cfg.aux_ball: self.head_v0_ball = nn.Linear(d, 1) self.head_phi_ball = nn.Linear(d, 2) # bias V0 khởi tạo giữa dải log [0.5, 8] — đỡ vài epoch đầu nn.init.constant_(self.head_v0.bias, math.log(2.0)) def forward(self, x: torch.Tensor, t: torch.Tensor, mask: torch.Tensor) -> dict: B = x.shape[0] h = self.inp(x) tok = self.shot_token.expand(B, 1, -1) h = torch.cat([tok, h], dim=1) t_full = torch.cat( [torch.full((B, 1), self.cfg.shot_token_t_s, dtype=t.dtype, device=t.device), t], dim=1) key_mask = torch.cat( [torch.ones(B, 1, dtype=torch.bool, device=mask.device), mask], dim=1) for layer in self.layers: h = layer(h, t_full, key_mask) g = self.ln_f(h[:, 0]) out = {"v0_z": self.head_v0(g).squeeze(-1), "phi_vec": self.head_phi(g), "ab": self.head_ab(g), "ident_logit": self.head_ident(g).squeeze(-1)} if self.cfg.aux_ball: out["v0_ball_z"] = self.head_v0_ball(g).squeeze(-1) out["phi_ball_vec"] = self.head_phi_ball(g) return out @torch.no_grad() def predict(self, x: torch.Tensor, t: torch.Tensor, mask: torch.Tensor) -> dict: """Suy đoán → numpy: v0 (m/s, thước gậy), phi_deg [0,360), a, b, p_ident.""" self.eval() out = self.forward(x, t, mask) v0 = torch.exp(out["v0_z"]) if self.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.cpu().numpy(), "phi_deg": phi.cpu().numpy(), "a": (out["ab"][:, 0] * self.cfg.ab_scale).cpu().numpy(), "b": (out["ab"][:, 1] * self.cfg.ab_scale).cpu().numpy(), "p_ident": torch.sigmoid(out["ident_logit"]).cpu().numpy()} # ------------------------------------------------------------------ loss def _phi_vec_loss(pred_vec: torch.Tensor, phi_deg: torch.Tensor) -> torch.Tensor: """MSE tới vector đơn vị (cos φ, sin φ) — liên tục quanh 0°/360°.""" rad = torch.deg2rad(phi_deg) target = torch.stack([torch.cos(rad), torch.sin(rad)], dim=-1) return F.mse_loss(pred_vec, target, reduction="none").sum(-1) def _phi_ang_huber(pred_vec: torch.Tensor, phi_deg: torch.Tensor, delta_deg: float) -> torch.Tensor: """Huber trên KHOẢNG CÁCH GÓC (radian, delta cho bằng độ): Δ qua atan2(sin, cos) của hiệu góc — liên tục quanh 0°/360°. So vec-MSE: cùng độ cong ~Δ² khi nhỏ, nhưng ngoài delta gradient KHÔNG tăng theo sin(Δ) — cú vô vọng (Δ~90°) bị chặn trần, khối giữa phân phối (thứ quyết định MEDIAN của gate) không bị đuôi đè. Cộng phạt nhẹ giữ ‖pred_vec‖ ≈ 1 (hướng là tín hiệu, norm không được sập).""" rad = torch.deg2rad(phi_deg) norm = torch.linalg.vector_norm(pred_vec, dim=-1) u = pred_vec / (norm[..., None] + 1e-6) cos_d = u[..., 0] * torch.cos(rad) + u[..., 1] * torch.sin(rad) sin_d = u[..., 1] * torch.cos(rad) - u[..., 0] * torch.sin(rad) d_rad = torch.atan2(sin_d, cos_d).abs() hub = F.huber_loss(d_rad, torch.zeros_like(d_rad), delta=math.radians(delta_deg), reduction="none") return hub + 0.01 * (norm - 1.0) ** 2 def shotnet_loss(out: dict, batch: dict, cfg: ShotNetConfig) -> dict: """Loss tổng + từng head. (a, b) chỉ tính trên cú identifiable. ``batch["sample_w"]`` (BG29 bước 1.2) — nếu CÓ, mọi head thu về bằng TRUNG BÌNH CÓ TRỌNG SỐ ``Σwl/Σw`` thay vì trung bình thường: thang loss (và do đó thang gradient) giữ nguyên ~1 dù trọng số lát target là 3. KHÔNG có khoá này → chạy đúng biểu thức cũ, không đổi một phép tính (c3/c4a phải so được từng số). """ w = batch.get("sample_w") if w is None: def red(x): return x.mean() def red_m(x, m): return (x * m).sum() / m.sum().clamp(min=1.0) else: def red(x): return (x * w).sum() / w.sum().clamp(min=1e-6) def red_m(x, m): return (x * m * w).sum() / (m * w).sum().clamp(min=1e-6) v0_t = batch["label_v0"] v0_target = torch.log(v0_t) if cfg.v0_log else v0_t l_v0 = red(F.huber_loss(out["v0_z"], v0_target, delta=cfg.huber_v0, reduction="none")) if cfg.phi_loss == "ang_huber": l_phi = red(_phi_ang_huber(out["phi_vec"], batch["label_phi"], cfg.phi_huber_deg)) else: l_phi = red(_phi_vec_loss(out["phi_vec"], batch["label_phi"])) ab_target = torch.stack([batch["label_a"], batch["label_b"]], dim=-1) / cfg.ab_scale l_ab_per = F.huber_loss(out["ab"], ab_target, delta=cfg.huber_ab, reduction="none").mean(-1) m = batch["identifiable"] l_ab = red_m(l_ab_per, m) l_id = red(F.binary_cross_entropy_with_logits(out["ident_logit"], m, reduction="none")) total = (cfg.w_v0 * l_v0 + cfg.w_phi * l_phi + cfg.w_ab * l_ab + cfg.w_ident * l_id) parts = {"v0": l_v0, "phi": l_phi, "ab": l_ab, "ident": l_id} if cfg.aux_ball: vb = batch["v0_ball"].clamp(min=1e-3) l_avb = red(F.huber_loss(out["v0_ball_z"], torch.log(vb) if cfg.v0_log else vb, delta=cfg.huber_v0, reduction="none")) if cfg.phi_loss == "ang_huber": l_apb = red(_phi_ang_huber(out["phi_ball_vec"], batch["phi_ball"], cfg.phi_huber_deg)) else: l_apb = red(_phi_vec_loss(out["phi_ball_vec"], batch["phi_ball"])) total = total + cfg.w_aux * (l_avb + l_apb) parts.update({"aux_v0_ball": l_avb, "aux_phi_ball": l_apb}) parts["total"] = total return parts # --------------------------------------------------------------- metrics def circ_diff_deg(x: np.ndarray, y: np.ndarray) -> np.ndarray: """|Δφ| trên vòng tròn, độ — cùng công thức eval_baseline_synth.""" return np.abs(((np.asarray(x) - np.asarray(y) + 180.0) % 360.0) - 180.0) def spin_classes(a: np.ndarray, b: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """(a, b) → (vert, side) theo đúng ngưỡng GT của harness. vert luôn có lớp (follow/draw/stun); side rỗng khi |a| ≤ A_SIDE_MIN (neutral).""" a, b = np.asarray(a), np.asarray(b) vert = np.where(b > B_STUN_MAX, "follow", np.where(b < -B_STUN_MAX, "draw", "stun")) side = np.where(a > A_SIDE_MIN, "side-L", np.where(a < -A_SIDE_MIN, "side-R", "")) return vert, side def gate_metrics(pred: dict, labels: dict) -> dict: """4 chỉ số gate G-27.3 đo trên một tập (val mỗi epoch): - dphi_med: median |Δφ| toàn tập (độ) - v0_relerr_med: median |V0̂−V0|/V0 (raw, thước gậy) toàn tập - side_acc: dấu a (sign, KHÔNG dead-zone — net không abstain) trên cú identifiable có GT side ≠ neutral - vert_acc: 3 lớp follow/stun/draw (quantize b̂ theo B_STUN_MAX) trên TOÀN identifiable """ ident = np.asarray(labels["identifiable"]) > 0.5 dphi = circ_diff_deg(pred["phi_deg"], labels["label_phi"]) relerr = np.abs(pred["v0"] - labels["label_v0"]) / labels["label_v0"] gt_vert, gt_side = spin_classes(labels["label_a"], labels["label_b"]) pv, _ = spin_classes(pred["a"], pred["b"]) side_mask = ident & (gt_side != "") side_ok = (np.sign(pred["a"]) == np.sign(labels["label_a"]))[side_mask] vert_ok = (pv == gt_vert)[ident] id_acc = ((pred["p_ident"] > 0.5) == ident).mean() return {"dphi_med": float(np.median(dphi)), "v0_relerr_med": float(np.median(relerr)), "side_acc": float(side_ok.mean()) if side_ok.size else float("nan"), "vert_acc": float(vert_ok.mean()) if vert_ok.size else float("nan"), "n_side": int(side_mask.sum()), "n_ident": int(ident.sum()), "ident_acc": float(id_acc)}