Spaces:
Sleeping
Sleeping
Commit ·
5c88cde
1
Parent(s): 7d9c5f3
refactor(analyzer): drop unused training code from shotnet.py
Browse filesNothing in this repository trains ShotNet; the loader, batcher, collate,
loss, gate metrics and the two augmentations were dead weight that read as
a live training path. The poolcoach-rl history keeps them for a retrain.
Gate: shotnet.py 862 -> 449 lines; checkpoint shotnet_20260812_c4b still
loads and the worker boots in 7.3s; shots 6 and 10 re-analysed on CPU give
EVERY field identical to before the trim; pytest 160 passed, 7 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- src/poolcoach_cv/shotnet.py +12 -425
src/poolcoach_cv/shotnet.py
CHANGED
|
@@ -5,9 +5,8 @@ trajectory.
|
|
| 5 |
The inverse of the RL action space: the observation sequence of ONE shot
|
| 6 |
{ball positions in table coordinates, a visibility mask, timestamps} ->
|
| 7 |
(V0, phi, a, b), exactly the four dimensions of ``cue.set_state``. Trained
|
| 8 |
-
PURELY on synthetic data (the pilot noise model is the contract), and
|
| 9 |
-
|
| 10 |
-
environment.
|
| 11 |
|
| 12 |
Architecture: a small transformer encoder, a learned **shot token** at the
|
| 13 |
head of the sequence from which the outputs are read, and **RoPE on real
|
|
@@ -35,27 +34,23 @@ Output heads:
|
|
| 35 |
- optional aux heads: ``v0_ball``/``phi_ball`` (labels the dataset already
|
| 36 |
carries); the gate is still scored on the cue V0.
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
and ``TargetReweight``, which pulls the loss weight towards the slice whose
|
| 46 |
-
first contact is at least 0.3s. Neither changes the architecture, the ruler
|
| 47 |
-
or the mapping by one line.
|
| 48 |
|
| 49 |
The spin classification constants (A_SIDE_MIN/B_STUN_MAX) are copied from
|
| 50 |
-
the evaluation harness that owns the ground truth; here they
|
| 51 |
-
|
| 52 |
"""
|
| 53 |
|
| 54 |
from __future__ import annotations
|
| 55 |
|
| 56 |
import math
|
| 57 |
-
from dataclasses import dataclass
|
| 58 |
-
from pathlib import Path
|
| 59 |
|
| 60 |
import numpy as np
|
| 61 |
import torch
|
|
@@ -130,102 +125,6 @@ def featurize_shot(shot: dict, w: float = TABLE_W_M, l: float = TABLE_L_M,
|
|
| 130 |
return feats, np.asarray(shot["t"], dtype=np.float32)
|
| 131 |
|
| 132 |
|
| 133 |
-
LABEL_KEYS = ("label_v0", "label_phi", "label_a", "label_b", "identifiable",
|
| 134 |
-
"v0_ball", "phi_ball", "fps", "upconvert", "scratch")
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
# ------------------- augmentation: "sparse target-ball detections"
|
| 138 |
-
# The diagnosis was MEASURED, not guessed: the earlier checkpoint collapsed
|
| 139 |
-
# on real clips because of the DISTRIBUTION of target-ball detection
|
| 140 |
-
# density, not the architecture or the input path -- synthetic training sits
|
| 141 |
-
# at 95-96% visibility (about 5.3 non-cue detections per frame) while one
|
| 142 |
-
# real shot had 1.01 and another 3.09. The augmentation below thins the
|
| 143 |
-
# detections inside the training loader itself (on the fly, with no new
|
| 144 |
-
# dataset written to disk) so the training range covers the measured real
|
| 145 |
-
# range.
|
| 146 |
-
#
|
| 147 |
-
# The dropout produces a mask of 0 EXACTLY like a ball missing from a frame
|
| 148 |
-
# (featurize_shot multiplies coordinates by vis, and the delta block
|
| 149 |
-
# multiplies the two frames' vis together). It touches neither img_diff nor
|
| 150 |
-
# the timestamps nor any other feature.
|
| 151 |
-
|
| 152 |
-
@dataclass
|
| 153 |
-
class SlotDropoutConfig:
|
| 154 |
-
"""Target-ball slot dropout, with one keep rate per shot.
|
| 155 |
-
|
| 156 |
-
``p_apply``: the fraction of shots that get augmented; the rest pass
|
| 157 |
-
through the loader BIT IDENTICAL to before (which keeps the synthetic
|
| 158 |
-
95-96% visibility region in the training distribution -- that region is
|
| 159 |
-
also the top of the range).
|
| 160 |
-
``keep_min``/``keep_max``: the keep rate is ~ U(min, max), applied as an
|
| 161 |
-
independent Bernoulli per (frame, non-cue slot) cell. The default range
|
| 162 |
-
[0.10, 0.95] plus the un-augmented branch covers the whole measured real
|
| 163 |
-
range: one real shot at about 15% per-ball visibility, another at about
|
| 164 |
-
50%, and the original synthetic data at 95-96%.
|
| 165 |
-
The ``cue`` slot is NEVER dropped further: the cue track on real clips is
|
| 166 |
-
already good (coverage 0.90-0.97) and the noise at the start of a shot is
|
| 167 |
-
part of the pilot contract.
|
| 168 |
-
"""
|
| 169 |
-
|
| 170 |
-
p_apply: float = 0.75
|
| 171 |
-
keep_min: float = 0.10
|
| 172 |
-
keep_max: float = 0.95
|
| 173 |
-
seed: int = 20260812
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
def apply_slot_dropout(shot: dict, cfg: SlotDropoutConfig,
|
| 177 |
-
rng: np.random.Generator) -> tuple[dict, float]:
|
| 178 |
-
"""Thin ONE shot's target-ball detections -> (a new shot, the keep rate
|
| 179 |
-
used; 1.0 means no augmentation).
|
| 180 |
-
|
| 181 |
-
It does NOT modify in place: ``ShardDataset.raw_shot``'s ``covered`` is a
|
| 182 |
-
view into the shard array held in RAM, and writing over it would corrupt
|
| 183 |
-
the dataset for every later epoch.
|
| 184 |
-
"""
|
| 185 |
-
cov = shot["covered"]
|
| 186 |
-
tgt = np.flatnonzero(np.asarray(shot["ball_ids"]) != 0)
|
| 187 |
-
if tgt.size == 0 or rng.random() >= cfg.p_apply:
|
| 188 |
-
return shot, 1.0
|
| 189 |
-
keep = float(rng.uniform(cfg.keep_min, cfg.keep_max))
|
| 190 |
-
cov = np.array(cov, dtype=bool, copy=True)
|
| 191 |
-
cov[:, tgt] &= rng.random((cov.shape[0], tgt.size)) < keep
|
| 192 |
-
out = dict(shot)
|
| 193 |
-
out["covered"] = cov
|
| 194 |
-
return out, keep
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
# The target-matched slice -- COPIED from the evaluation harness
|
| 198 |
-
# (T_FIRST_TARGET_S; src/ cannot import from the scripts side, same as
|
| 199 |
-
# B_STUN_MAX above). This population matches the real pilot clips and is the
|
| 200 |
-
# official ruler of the acceptance gate.
|
| 201 |
-
T_FIRST_TARGET_S = 0.3
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
@dataclass
|
| 205 |
-
class TargetReweight:
|
| 206 |
-
"""Reweighting towards the target slice: a shot whose first contact is at
|
| 207 |
-
or after ``t_first_s`` has its loss multiplied by ``weight``.
|
| 208 |
-
|
| 209 |
-
Loss weighting was chosen over oversampling deliberately: oversampling a
|
| 210 |
-
13.6% slice by a factor of 3 would lengthen the epoch by about 27% and
|
| 211 |
-
break the "identical step budget" condition, so the configs would no
|
| 212 |
-
longer be comparable with the baseline. The weight applies to EVERY head
|
| 213 |
-
(v0/phi/ab/ident) through a weighted mean, so the overall gradient scale
|
| 214 |
-
stays about 1 (an earlier lesson: an off-scale gradient kills a head).
|
| 215 |
-
"""
|
| 216 |
-
|
| 217 |
-
weight: float = 3.0
|
| 218 |
-
t_first_s: float = T_FIRST_TARGET_S
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
def t_first_contact_s(shot: dict) -> float:
|
| 222 |
-
"""The cue's first contact after the strike: min(t_first_bb,
|
| 223 |
-
t_first_cush) -- NaN when the shot has no collision at all. The same
|
| 224 |
-
formula as the evaluation harness."""
|
| 225 |
-
vals = [float(v) for v in (shot["t_first_bb"], shot["t_first_cush"])
|
| 226 |
-
if not math.isnan(float(v))]
|
| 227 |
-
return min(vals) if vals else math.nan
|
| 228 |
-
|
| 229 |
|
| 230 |
# ------------------------- sim to real: build a shot from the app's track
|
| 231 |
# The sim-to-real input gap is the main risk here: the net trains with
|
|
@@ -366,194 +265,6 @@ def shot_from_track(rows: list[dict], others: list[dict] | None
|
|
| 366 |
"n_frames": F, "n_balls": K + 1}
|
| 367 |
|
| 368 |
|
| 369 |
-
# --------------------------------------------------------------- dataset
|
| 370 |
-
|
| 371 |
-
class ShardDataset:
|
| 372 |
-
"""Load a list of npz shards into RAM (ragged plus offsets, about 2.4 GB
|
| 373 |
-
for 150k shots) and hand back featurized shots. It needs no pooltool, and
|
| 374 |
-
nothing depends on torch until ``collate`` (so the loader tests run in
|
| 375 |
-
pure numpy).
|
| 376 |
-
|
| 377 |
-
``indices``: GLOBAL indices (shards sorted, then local index) -- used for
|
| 378 |
-
the train/val split. ``limit_shots``: truncate each shard (smoke tests).
|
| 379 |
-
|
| 380 |
-
``augment``/``reweight`` only take effect under ``set_train_mode(True)``.
|
| 381 |
-
They default to OFF, because every evaluation path (the held-out harness,
|
| 382 |
-
the app worker, the per-epoch validation) has to see the same data as the
|
| 383 |
-
baseline, otherwise the numbers are not comparable.
|
| 384 |
-
"""
|
| 385 |
-
|
| 386 |
-
_META = ("label_v0", "label_phi", "label_a", "label_b", "v0_ball",
|
| 387 |
-
"phi_ball", "identifiable", "scratch", "fps", "upconvert",
|
| 388 |
-
"t_first_bb", "t_first_cush", "n_frames", "n_balls", "shot_idx")
|
| 389 |
-
|
| 390 |
-
def __init__(self, shard_paths: list[Path], limit_shots: int = 0,
|
| 391 |
-
deltas: bool = False,
|
| 392 |
-
augment: SlotDropoutConfig | None = None,
|
| 393 |
-
reweight: TargetReweight | None = None):
|
| 394 |
-
self.deltas = deltas
|
| 395 |
-
self.augment = augment
|
| 396 |
-
self.reweight = reweight
|
| 397 |
-
self._train_mode = False
|
| 398 |
-
self._epoch = 0
|
| 399 |
-
self.shards = []
|
| 400 |
-
self._index: list[tuple[int, int]] = []
|
| 401 |
-
for si, p in enumerate(sorted(Path(q) for q in shard_paths)):
|
| 402 |
-
z = np.load(p)
|
| 403 |
-
S = len(z["shot_idx"])
|
| 404 |
-
if limit_shots:
|
| 405 |
-
S = min(S, limit_shots)
|
| 406 |
-
sh = {k: np.asarray(z[k]) for k in self._META}
|
| 407 |
-
sh["xy"] = np.asarray(z["xy"], dtype=np.float32)
|
| 408 |
-
sh["covered"] = np.asarray(z["covered"]).astype(bool)
|
| 409 |
-
sh["img_diff"] = np.asarray(z["img_diff"], dtype=np.float32)
|
| 410 |
-
sh["ball_ids"] = np.asarray(z["ball_ids"])
|
| 411 |
-
sh["xy_off"] = np.asarray(z["xy_off"], dtype=np.int64)
|
| 412 |
-
sh["f_off"] = np.asarray(z["f_off"], dtype=np.int64)
|
| 413 |
-
sh["b_off"] = np.asarray(z["b_off"], dtype=np.int64)
|
| 414 |
-
self.shards.append(sh)
|
| 415 |
-
self._index.extend((si, li) for li in range(S))
|
| 416 |
-
self.lengths = np.array(
|
| 417 |
-
[self.shards[si]["n_frames"][li] for si, li in self._index],
|
| 418 |
-
dtype=np.int64)
|
| 419 |
-
|
| 420 |
-
def __len__(self) -> int:
|
| 421 |
-
return len(self._index)
|
| 422 |
-
|
| 423 |
-
def set_train_mode(self, on: bool, epoch: int = 0) -> None:
|
| 424 |
-
"""Turn augmentation and reweighting on or off, and fix the epoch for
|
| 425 |
-
the RNG.
|
| 426 |
-
|
| 427 |
-
Training turns it on (a different epoch gives a different mask, still
|
| 428 |
-
reproducible from ``[seed, epoch, i]``); validation and evaluation
|
| 429 |
-
turn it off. One flag for both, because both only mean anything on
|
| 430 |
-
the training path -- validation numbers have to stay comparable
|
| 431 |
-
across configs.
|
| 432 |
-
"""
|
| 433 |
-
self._train_mode = bool(on)
|
| 434 |
-
self._epoch = int(epoch)
|
| 435 |
-
|
| 436 |
-
def raw_shot(self, i: int) -> dict:
|
| 437 |
-
"""The raw shot dict, in the same shape the dataset generator
|
| 438 |
-
produced."""
|
| 439 |
-
si, li = self._index[i]
|
| 440 |
-
sh = self.shards[si]
|
| 441 |
-
F_n = int(sh["n_frames"][li])
|
| 442 |
-
B = int(sh["n_balls"][li])
|
| 443 |
-
x0, f0, b0 = (int(sh["xy_off"][li]), int(sh["f_off"][li]),
|
| 444 |
-
int(sh["b_off"][li]))
|
| 445 |
-
shot = {k: sh[k][li].item() for k in self._META
|
| 446 |
-
if k not in ("n_frames", "n_balls")}
|
| 447 |
-
shot["n_frames"], shot["n_balls"] = F_n, B
|
| 448 |
-
shot["t"] = (np.arange(F_n) / float(sh["fps"][li])).astype(np.float32)
|
| 449 |
-
shot["xy"] = sh["xy"][x0:x0 + F_n * B].reshape(F_n, B, 2)
|
| 450 |
-
shot["covered"] = sh["covered"][x0:x0 + F_n * B].reshape(F_n, B)
|
| 451 |
-
shot["img_diff"] = sh["img_diff"][f0:f0 + F_n]
|
| 452 |
-
shot["ball_ids"] = sh["ball_ids"][b0:b0 + B]
|
| 453 |
-
return shot
|
| 454 |
-
|
| 455 |
-
def __getitem__(self, i: int) -> dict:
|
| 456 |
-
shot = self.raw_shot(i)
|
| 457 |
-
keep = 1.0
|
| 458 |
-
if self._train_mode and self.augment is not None:
|
| 459 |
-
# the RNG is keyed on [seed, epoch, i]: the same triple gives the
|
| 460 |
-
# same mask (every epoch is reproducible) while a different epoch
|
| 461 |
-
# gives a different one (genuinely on-the-fly augmentation rather
|
| 462 |
-
# than one fixed thinned copy)
|
| 463 |
-
rng = np.random.default_rng(
|
| 464 |
-
[self.augment.seed, self._epoch, int(i)])
|
| 465 |
-
shot, keep = apply_slot_dropout(shot, self.augment, rng)
|
| 466 |
-
feats, t = featurize_shot(shot, deltas=self.deltas)
|
| 467 |
-
item = {"feats": feats, "t": t}
|
| 468 |
-
for k in LABEL_KEYS:
|
| 469 |
-
item[k] = float(shot[k])
|
| 470 |
-
item["keep_rate"] = keep # diagnostics/tests, not collated
|
| 471 |
-
if self._train_mode and self.reweight is not None:
|
| 472 |
-
tf = t_first_contact_s(shot)
|
| 473 |
-
item["sample_w"] = (self.reweight.weight
|
| 474 |
-
if (not math.isnan(tf))
|
| 475 |
-
and tf >= self.reweight.t_first_s else 1.0)
|
| 476 |
-
return item
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
def val_split_indices(n_total: int, val_frac: float,
|
| 480 |
-
seed: int) -> tuple[np.ndarray, np.ndarray]:
|
| 481 |
-
"""A validation split of about ``val_frac`` taken off TRAIN by seed --
|
| 482 |
-
deterministic, disjoint, and independent of the machine or the shard
|
| 483 |
-
layout. Returns (train_idx, val_idx), both sorted."""
|
| 484 |
-
perm = np.random.default_rng(seed).permutation(n_total)
|
| 485 |
-
n_val = int(round(n_total * val_frac))
|
| 486 |
-
return np.sort(perm[n_val:]), np.sort(perm[:n_val])
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
class BucketBatcher:
|
| 490 |
-
"""Group batches by TOKEN BUDGET (max length in the batch x batch size <=
|
| 491 |
-
budget) after a length sort with jitter. Sequences run from 13 to 1192
|
| 492 |
-
frames, so a fixed batch size either blows up VRAM on the long tail or
|
| 493 |
-
wastes it on padding at the short end (the development GPU has 8 GB).
|
| 494 |
-
Batch order is shuffled from [seed, epoch], so every epoch is
|
| 495 |
-
reproducible."""
|
| 496 |
-
|
| 497 |
-
def __init__(self, lengths: np.ndarray, indices: np.ndarray,
|
| 498 |
-
token_budget: int = 40_000, max_batch: int = 256,
|
| 499 |
-
seed: int = 0):
|
| 500 |
-
self.lengths, self.indices = lengths, np.asarray(indices)
|
| 501 |
-
self.token_budget, self.max_batch = token_budget, max_batch
|
| 502 |
-
self.seed = seed
|
| 503 |
-
|
| 504 |
-
def epoch_batches(self, epoch: int, shuffle: bool = True) -> list[np.ndarray]:
|
| 505 |
-
rng = np.random.default_rng([self.seed, epoch])
|
| 506 |
-
lens = self.lengths[self.indices].astype(np.float64)
|
| 507 |
-
if shuffle:
|
| 508 |
-
lens = lens * rng.uniform(0.9, 1.1, len(lens))
|
| 509 |
-
order = self.indices[np.argsort(lens, kind="stable")]
|
| 510 |
-
batches, cur, cur_max = [], [], 0
|
| 511 |
-
for i in order:
|
| 512 |
-
li = int(self.lengths[i])
|
| 513 |
-
new_max = max(cur_max, li)
|
| 514 |
-
if cur and ((len(cur) + 1) * new_max > self.token_budget
|
| 515 |
-
or len(cur) >= self.max_batch):
|
| 516 |
-
batches.append(np.array(cur))
|
| 517 |
-
cur, cur_max = [], 0
|
| 518 |
-
new_max = li
|
| 519 |
-
cur.append(int(i))
|
| 520 |
-
cur_max = new_max
|
| 521 |
-
if cur:
|
| 522 |
-
batches.append(np.array(cur))
|
| 523 |
-
if shuffle:
|
| 524 |
-
rng.shuffle(batches)
|
| 525 |
-
return batches
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
def collate(items: list[dict]) -> dict:
|
| 529 |
-
"""Pad to the batch's max length and produce torch tensors. ``mask``
|
| 530 |
-
True means a real frame, False means padding.
|
| 531 |
-
|
| 532 |
-
``sample_w`` only appears when the loader emits it -- a batch without
|
| 533 |
-
that key goes through EXACTLY the old loss code path, not the
|
| 534 |
-
weighted-mean branch.
|
| 535 |
-
"""
|
| 536 |
-
B = len(items)
|
| 537 |
-
T = max(len(it["t"]) for it in items)
|
| 538 |
-
dim = items[0]["feats"].shape[1]
|
| 539 |
-
x = torch.zeros(B, T, dim, dtype=torch.float32)
|
| 540 |
-
t = torch.zeros(B, T, dtype=torch.float32)
|
| 541 |
-
mask = torch.zeros(B, T, dtype=torch.bool)
|
| 542 |
-
for i, it in enumerate(items):
|
| 543 |
-
n = len(it["t"])
|
| 544 |
-
x[i, :n] = torch.from_numpy(np.ascontiguousarray(it["feats"]))
|
| 545 |
-
t[i, :n] = torch.from_numpy(np.ascontiguousarray(it["t"]))
|
| 546 |
-
mask[i, :n] = True
|
| 547 |
-
out = {"x": x, "t": t, "mask": mask}
|
| 548 |
-
for k in LABEL_KEYS:
|
| 549 |
-
out[k] = torch.tensor([it[k] for it in items], dtype=torch.float32)
|
| 550 |
-
if any("sample_w" in it for it in items):
|
| 551 |
-
out["sample_w"] = torch.tensor(
|
| 552 |
-
[float(it.get("sample_w", 1.0)) for it in items],
|
| 553 |
-
dtype=torch.float32)
|
| 554 |
-
return out
|
| 555 |
-
|
| 556 |
-
|
| 557 |
# ----------------------------------------------------------------- model
|
| 558 |
|
| 559 |
@dataclass
|
|
@@ -721,107 +432,8 @@ class ShotNet(nn.Module):
|
|
| 721 |
"p_ident": torch.sigmoid(out["ident_logit"]).cpu().numpy()}
|
| 722 |
|
| 723 |
|
| 724 |
-
# ------------------------------------------------------------------ loss
|
| 725 |
-
|
| 726 |
-
def _phi_vec_loss(pred_vec: torch.Tensor,
|
| 727 |
-
phi_deg: torch.Tensor) -> torch.Tensor:
|
| 728 |
-
"""MSE against the unit vector (cos phi, sin phi) -- continuous across
|
| 729 |
-
0/360."""
|
| 730 |
-
rad = torch.deg2rad(phi_deg)
|
| 731 |
-
target = torch.stack([torch.cos(rad), torch.sin(rad)], dim=-1)
|
| 732 |
-
return F.mse_loss(pred_vec, target, reduction="none").sum(-1)
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
def _phi_ang_huber(pred_vec: torch.Tensor, phi_deg: torch.Tensor,
|
| 736 |
-
delta_deg: float) -> torch.Tensor:
|
| 737 |
-
"""Huber on the ANGULAR DISTANCE (in radians, with delta given in
|
| 738 |
-
degrees): the difference comes through atan2(sin, cos), so it is
|
| 739 |
-
continuous across 0/360. Compared with vec-MSE: the same ~delta^2
|
| 740 |
-
curvature when small, but beyond delta the gradient does NOT keep growing
|
| 741 |
-
with sin(delta) -- hopeless shots (delta ~90 deg) are capped and the bulk
|
| 742 |
-
of the distribution (which decides the gate's MEDIAN) is not crushed by
|
| 743 |
-
the tail. A light penalty keeps ||pred_vec|| near 1 (direction is the
|
| 744 |
-
signal, but the norm must not collapse)."""
|
| 745 |
-
rad = torch.deg2rad(phi_deg)
|
| 746 |
-
norm = torch.linalg.vector_norm(pred_vec, dim=-1)
|
| 747 |
-
u = pred_vec / (norm[..., None] + 1e-6)
|
| 748 |
-
cos_d = u[..., 0] * torch.cos(rad) + u[..., 1] * torch.sin(rad)
|
| 749 |
-
sin_d = u[..., 1] * torch.cos(rad) - u[..., 0] * torch.sin(rad)
|
| 750 |
-
d_rad = torch.atan2(sin_d, cos_d).abs()
|
| 751 |
-
hub = F.huber_loss(d_rad, torch.zeros_like(d_rad),
|
| 752 |
-
delta=math.radians(delta_deg), reduction="none")
|
| 753 |
-
return hub + 0.01 * (norm - 1.0) ** 2
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
def shotnet_loss(out: dict, batch: dict, cfg: ShotNetConfig) -> dict:
|
| 757 |
-
"""The total loss plus each head's. (a, b) is only scored on
|
| 758 |
-
identifiable shots.
|
| 759 |
-
|
| 760 |
-
When ``batch["sample_w"]`` is PRESENT, every head reduces with a WEIGHTED
|
| 761 |
-
MEAN ``sum(w.l)/sum(w)`` instead of a plain mean, so the loss scale (and
|
| 762 |
-
therefore the gradient scale) stays about 1 even though the target
|
| 763 |
-
slice's weight is 3. WITHOUT that key the expression is the old one,
|
| 764 |
-
unchanged down to the arithmetic, so the configs stay comparable number
|
| 765 |
-
for number.
|
| 766 |
-
"""
|
| 767 |
-
w = batch.get("sample_w")
|
| 768 |
-
if w is None:
|
| 769 |
-
def red(x):
|
| 770 |
-
return x.mean()
|
| 771 |
-
|
| 772 |
-
def red_m(x, m):
|
| 773 |
-
return (x * m).sum() / m.sum().clamp(min=1.0)
|
| 774 |
-
else:
|
| 775 |
-
def red(x):
|
| 776 |
-
return (x * w).sum() / w.sum().clamp(min=1e-6)
|
| 777 |
-
|
| 778 |
-
def red_m(x, m):
|
| 779 |
-
return (x * m * w).sum() / (m * w).sum().clamp(min=1e-6)
|
| 780 |
-
|
| 781 |
-
v0_t = batch["label_v0"]
|
| 782 |
-
v0_target = torch.log(v0_t) if cfg.v0_log else v0_t
|
| 783 |
-
l_v0 = red(F.huber_loss(out["v0_z"], v0_target, delta=cfg.huber_v0,
|
| 784 |
-
reduction="none"))
|
| 785 |
-
if cfg.phi_loss == "ang_huber":
|
| 786 |
-
l_phi = red(_phi_ang_huber(out["phi_vec"], batch["label_phi"],
|
| 787 |
-
cfg.phi_huber_deg))
|
| 788 |
-
else:
|
| 789 |
-
l_phi = red(_phi_vec_loss(out["phi_vec"], batch["label_phi"]))
|
| 790 |
-
ab_target = torch.stack([batch["label_a"], batch["label_b"]],
|
| 791 |
-
dim=-1) / cfg.ab_scale
|
| 792 |
-
l_ab_per = F.huber_loss(out["ab"], ab_target, delta=cfg.huber_ab,
|
| 793 |
-
reduction="none").mean(-1)
|
| 794 |
-
m = batch["identifiable"]
|
| 795 |
-
l_ab = red_m(l_ab_per, m)
|
| 796 |
-
l_id = red(F.binary_cross_entropy_with_logits(out["ident_logit"], m,
|
| 797 |
-
reduction="none"))
|
| 798 |
-
total = (cfg.w_v0 * l_v0 + cfg.w_phi * l_phi + cfg.w_ab * l_ab
|
| 799 |
-
+ cfg.w_ident * l_id)
|
| 800 |
-
parts = {"v0": l_v0, "phi": l_phi, "ab": l_ab, "ident": l_id}
|
| 801 |
-
if cfg.aux_ball:
|
| 802 |
-
vb = batch["v0_ball"].clamp(min=1e-3)
|
| 803 |
-
l_avb = red(F.huber_loss(out["v0_ball_z"],
|
| 804 |
-
torch.log(vb) if cfg.v0_log else vb,
|
| 805 |
-
delta=cfg.huber_v0, reduction="none"))
|
| 806 |
-
if cfg.phi_loss == "ang_huber":
|
| 807 |
-
l_apb = red(_phi_ang_huber(out["phi_ball_vec"],
|
| 808 |
-
batch["phi_ball"], cfg.phi_huber_deg))
|
| 809 |
-
else:
|
| 810 |
-
l_apb = red(_phi_vec_loss(out["phi_ball_vec"],
|
| 811 |
-
batch["phi_ball"]))
|
| 812 |
-
total = total + cfg.w_aux * (l_avb + l_apb)
|
| 813 |
-
parts.update({"aux_v0_ball": l_avb, "aux_phi_ball": l_apb})
|
| 814 |
-
parts["total"] = total
|
| 815 |
-
return parts
|
| 816 |
-
|
| 817 |
-
|
| 818 |
# --------------------------------------------------------------- metrics
|
| 819 |
|
| 820 |
-
def circ_diff_deg(x: np.ndarray, y: np.ndarray) -> np.ndarray:
|
| 821 |
-
"""|delta phi| on the circle, in degrees -- the same formula as the
|
| 822 |
-
evaluation harness."""
|
| 823 |
-
return np.abs(((np.asarray(x) - np.asarray(y) + 180.0) % 360.0) - 180.0)
|
| 824 |
-
|
| 825 |
|
| 826 |
def spin_classes(a: np.ndarray, b: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
| 827 |
"""(a, b) -> (vert, side) at the harness's ground-truth thresholds. vert
|
|
@@ -835,28 +447,3 @@ def spin_classes(a: np.ndarray, b: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
| 835 |
return vert, side
|
| 836 |
|
| 837 |
|
| 838 |
-
def gate_metrics(pred: dict, labels: dict) -> dict:
|
| 839 |
-
"""The four gate metrics, measured on one set (validation every epoch):
|
| 840 |
-
- dphi_med: the median |delta phi| over the whole set (degrees)
|
| 841 |
-
- v0_relerr_med: the median |V0_hat - V0|/V0 (raw, cue ruler)
|
| 842 |
-
- side_acc: the sign of a (a plain sign, NO dead zone -- the net
|
| 843 |
-
does not abstain) over identifiable shots whose ground
|
| 844 |
-
truth side is not neutral
|
| 845 |
-
- vert_acc: the three classes follow/stun/draw (b_hat quantised at
|
| 846 |
-
B_STUN_MAX) over ALL identifiable shots
|
| 847 |
-
"""
|
| 848 |
-
ident = np.asarray(labels["identifiable"]) > 0.5
|
| 849 |
-
dphi = circ_diff_deg(pred["phi_deg"], labels["label_phi"])
|
| 850 |
-
relerr = np.abs(pred["v0"] - labels["label_v0"]) / labels["label_v0"]
|
| 851 |
-
gt_vert, gt_side = spin_classes(labels["label_a"], labels["label_b"])
|
| 852 |
-
pv, _ = spin_classes(pred["a"], pred["b"])
|
| 853 |
-
side_mask = ident & (gt_side != "")
|
| 854 |
-
side_ok = (np.sign(pred["a"]) == np.sign(labels["label_a"]))[side_mask]
|
| 855 |
-
vert_ok = (pv == gt_vert)[ident]
|
| 856 |
-
id_acc = ((pred["p_ident"] > 0.5) == ident).mean()
|
| 857 |
-
return {"dphi_med": float(np.median(dphi)),
|
| 858 |
-
"v0_relerr_med": float(np.median(relerr)),
|
| 859 |
-
"side_acc": float(side_ok.mean()) if side_ok.size else float("nan"),
|
| 860 |
-
"vert_acc": float(vert_ok.mean()) if vert_ok.size else float("nan"),
|
| 861 |
-
"n_side": int(side_mask.sum()), "n_ident": int(ident.sum()),
|
| 862 |
-
"ident_acc": float(id_acc)}
|
|
|
|
| 5 |
The inverse of the RL action space: the observation sequence of ONE shot
|
| 6 |
{ball positions in table coordinates, a visibility mask, timestamps} ->
|
| 7 |
(V0, phi, a, b), exactly the four dimensions of ``cue.set_state``. Trained
|
| 8 |
+
PURELY on synthetic data (the pilot noise model is the contract), and
|
| 9 |
+
inference here needs no pooltool so it can run in the CV environment.
|
|
|
|
| 10 |
|
| 11 |
Architecture: a small transformer encoder, a learned **shot token** at the
|
| 12 |
head of the sequence from which the outputs are read, and **RoPE on real
|
|
|
|
| 34 |
- optional aux heads: ``v0_ball``/``phi_ball`` (labels the dataset already
|
| 35 |
carries); the gate is still scored on the cue V0.
|
| 36 |
|
| 37 |
+
This module holds the INFERENCE path only. The training code that produced
|
| 38 |
+
``models/shotnet_20260812_c4b`` (the shard loader, the bucketing batcher,
|
| 39 |
+
the collate function, the loss and the gate metrics, plus the slot-dropout
|
| 40 |
+
and target-reweight augmentations) lived here after the port but nothing in
|
| 41 |
+
this repository ran it, so it was removed; it is still in the poolcoach-rl
|
| 42 |
+
history if a retrain is ever needed. ``ShotNetConfig`` is unchanged, so the
|
| 43 |
+
existing checkpoints keep loading.
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
The spin classification constants (A_SIDE_MIN/B_STUN_MAX) are copied from
|
| 46 |
+
the evaluation harness that owns the ground truth; here they serve the
|
| 47 |
+
mapping from a predicted (a, b) to a class.
|
| 48 |
"""
|
| 49 |
|
| 50 |
from __future__ import annotations
|
| 51 |
|
| 52 |
import math
|
| 53 |
+
from dataclasses import dataclass
|
|
|
|
| 54 |
|
| 55 |
import numpy as np
|
| 56 |
import torch
|
|
|
|
| 125 |
return feats, np.asarray(shot["t"], dtype=np.float32)
|
| 126 |
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
# ------------------------- sim to real: build a shot from the app's track
|
| 130 |
# The sim-to-real input gap is the main risk here: the net trains with
|
|
|
|
| 265 |
"n_frames": F, "n_balls": K + 1}
|
| 266 |
|
| 267 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
# ----------------------------------------------------------------- model
|
| 269 |
|
| 270 |
@dataclass
|
|
|
|
| 432 |
"p_ident": torch.sigmoid(out["ident_logit"]).cpu().numpy()}
|
| 433 |
|
| 434 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
# --------------------------------------------------------------- metrics
|
| 436 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
|
| 438 |
def spin_classes(a: np.ndarray, b: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
| 439 |
"""(a, b) -> (vert, side) at the harness's ground-truth thresholds. vert
|
|
|
|
| 447 |
return vert, side
|
| 448 |
|
| 449 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|