Buckets:

Rishik001's picture
download
raw
10.6 kB
"""Dense-supervision TAS on FS-Spot -- the counterpart to fs_spot_ctc.py.
Why this exists: CTC converged to loss 0.338 on this data and still scored 0.7%
localization recall. It emitted the right elements, in the right ORDER, all
crammed into the first ~20 frames of a 4,275-frame clip. That is not a bug --
CTC's loss marginalizes over every monotonic alignment, so spikes at frame 0
score identically to spikes at the true frame. With targets at 0.5% density and a
bidirectional encoder that sees the whole clip, nothing pushes back.
Dense per-frame cross-entropy supplies exactly the pressure CTC lacks: predicting
Axel at frame 0 when the Axel is at frame 578 is directly penalized. Same encoder
and same features as the CTC run, so any difference is the loss, not the model.
Two things this must handle that CTC did not:
* 48:1 imbalance (97.94% background). Unweighted CE predicts all-background and
reports 97.9% "accuracy" while finding nothing. Hence class weights.
* Fragmented predictions. Frame-wise CE has no notion of a contiguous segment,
so it produces speckle. TMSE (the MS-TCN smoothing term) penalizes abrupt
frame-to-frame log-prob changes and glues segments together.
Run: python3 -m temporal_scripts.fs_spot_dense [--epochs N]
"""
from __future__ import annotations
import argparse
import glob
import sys
from pathlib import Path
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, Dataset
from temporal_scripts.fs_spot_ctc import STRIDE, CTCTagger, skeleton_features
from temporal_scripts.fs_spot_data import OUT_DIR, SPOT_TAXONOMY
DEVICE = "cuda:0" if torch.cuda.is_available() else "cpu"
NUM_CLASSES = len(SPOT_TAXONOMY) # 8: NONE + 6 jumps + Spin
def downsample_labels(y: np.ndarray, stride: int = STRIDE) -> np.ndarray:
"""(T,) -> (T//stride,), keeping any element that appears in each window.
Elements are only ~18 frames (~4 windows at stride 4), so plain y[::stride]
can drop one entirely by landing between samples. Take the max instead: any
non-background label in the window survives.
"""
n = len(y) // stride
return y[: n * stride].reshape(n, stride).max(axis=1)
class FSSpotDense(Dataset):
def __init__(self, files: list[Path]):
self.items = []
for f in files:
d = np.load(f, allow_pickle=True)
self.items.append(
{
"feats": skeleton_features(d["skeleton"]),
"y": downsample_labels(d["frame_labels"]),
"takeoff": d["element_takeoff"],
"landing": d["element_landing"],
"labels": [str(x) for x in d["element_label"]],
"name": f.stem,
}
)
def __len__(self) -> int:
return len(self.items)
def __getitem__(self, i: int) -> dict:
return self.items[i]
def collate(batch: list[dict]) -> dict:
T = max(len(b["feats"]) for b in batch)
Tem = T // STRIDE
F_ = batch[0]["feats"].shape[1]
x = torch.zeros(len(batch), T, F_)
y = torch.full((len(batch), Tem), -100, dtype=torch.long) # -100 = ignored by CE
for i, b in enumerate(batch):
t = len(b["feats"])
x[i, :t] = torch.from_numpy(b["feats"])
ye = b["y"]
y[i, : len(ye)] = torch.from_numpy(ye)
return {"x": x, "y": y, "meta": batch}
def tmse_loss(logp: torch.Tensor, tau: float = 4.0) -> torch.Tensor:
"""Truncated MSE over adjacent-frame log-prob deltas (MS-TCN's smoothing term).
Clamping at tau keeps a genuine action boundary -- where a large jump is
correct -- from being penalized into oblivion.
"""
d = (logp[:, 1:] - logp[:, :-1]) ** 2
return torch.clamp(d, max=tau**2).mean()
def segments_from_frames(pred: np.ndarray) -> list[tuple[int, int, int]]:
"""Collapse a per-frame prediction into (class, start, end) runs, dropping background."""
out = []
cur, start = pred[0], 0
for t in range(1, len(pred) + 1):
if t == len(pred) or pred[t] != cur:
if cur != 0:
out.append((int(cur), start, t - 1))
if t < len(pred):
cur, start = pred[t], t
return out
def evaluate(model: nn.Module, loader: DataLoader, tol_s: float = 1.0, min_len: int = 2) -> dict:
"""Same localization metric as the CTC run, so the two are directly comparable.
A hit = a predicted segment whose centre is within tol_s of a true element's
[takeoff, landing] window, with matching class, one-to-one.
"""
model.eval()
hits = gt_total = pred_total = 0
offsets = []
frame_correct = frame_total = 0
with torch.no_grad():
for b in loader:
logits = model(b["x"].to(DEVICE))
pred = logits.argmax(-1).cpu().numpy()
for i, m in enumerate(b["meta"]):
n = min(len(m["y"]), pred.shape[1])
p = pred[i][:n]
frame_correct += int((p == m["y"][:n]).sum())
frame_total += n
segs = [s for s in segments_from_frames(p) if s[2] - s[1] + 1 >= min_len]
gt = [
(lab, int(t0), int(t1))
for lab, t0, t1 in zip(m["labels"], m["takeoff"], m["landing"])
]
gt_total += len(gt)
pred_total += len(segs)
used = set()
for cls, s0, s1 in segs:
centre = (s0 + s1) / 2 * STRIDE # emission grid -> input frames
for j, (lab, t0, t1) in enumerate(gt):
if j in used or SPOT_TAXONOMY[cls] != lab:
continue
dist = max(t0 - centre, 0, centre - t1) / 25.0
if dist <= tol_s:
hits += 1
used.add(j)
offsets.append(abs(centre - (t0 + t1) / 2))
break
return {
"loc_recall": hits / max(gt_total, 1),
"loc_prec": hits / max(pred_total, 1),
"frame_acc": frame_correct / max(frame_total, 1),
"median_offset_s": float(np.median(offsets) / 25.0) if offsets else float("nan"),
"n_pred": pred_total,
"n_gt": gt_total,
}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--epochs", type=int, default=60)
ap.add_argument("--batch", type=int, default=4)
ap.add_argument("--lr", type=float, default=3e-4)
ap.add_argument("--lambda-tmse", type=float, default=0.15)
ap.add_argument("--unidirectional", action="store_true")
ap.add_argument("--tag", type=str, default="dense")
args = ap.parse_args()
files = sorted(Path(p) for p in glob.glob(str(OUT_DIR / "*.npz")))
by_bc: dict[str, list[Path]] = {}
for f in files:
by_bc.setdefault("_".join(f.stem.split("_")[:-3]), []).append(f)
bcs = sorted(by_bc)
n_val = max(1, len(bcs) // 4)
val_bcs, train_bcs = bcs[:n_val], bcs[n_val:]
train_f = [f for b in train_bcs for f in by_bc[b]]
val_f = [f for b in val_bcs for f in by_bc[b]]
print(f"clips {len(files)} | train {len(train_f)} ({len(train_bcs)} bc) | val {len(val_f)} ({val_bcs})")
tr, va = FSSpotDense(train_f), FSSpotDense(val_f)
in_features = tr[0]["feats"].shape[1]
# Inverse-frequency class weights. Background is 48x everything else and Loop
# is 0.048% of frames; without this the model predicts all-background and
# reports 97.9% frame accuracy while finding nothing. sqrt tempers the tail so
# Loop's ~300 frames don't dominate the gradient outright.
counts = np.zeros(NUM_CLASSES)
for it in tr.items:
for k, v in zip(*np.unique(it["y"], return_counts=True)):
counts[int(k)] += v
w = 1.0 / np.sqrt(np.maximum(counts, 1))
w = w / w.sum() * NUM_CLASSES
print("class weights:", {SPOT_TAXONOMY[i]: round(float(w[i]), 2) for i in range(NUM_CLASSES)})
weight = torch.tensor(w, dtype=torch.float32, device=DEVICE)
tl = DataLoader(tr, batch_size=args.batch, shuffle=True, collate_fn=collate)
vl = DataLoader(va, batch_size=args.batch, shuffle=False, collate_fn=collate)
model = CTCTagger(in_features, num_classes=NUM_CLASSES,
bidirectional=not args.unidirectional).to(DEVICE)
print(f" LSTM: {'UNIdirectional' if args.unidirectional else 'BIdirectional'}")
opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=1e-4)
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=args.epochs)
best = -1.0
for ep in range(1, args.epochs + 1):
model.train()
tot = nb = 0
for b in tl:
opt.zero_grad()
logits = model(b["x"].to(DEVICE))
y = b["y"].to(DEVICE)
# The strided conv stem's output length follows the conv formula, which
# is T//STRIDE +/- 1 rather than exactly T/4. Crop both to the common
# length so frame t of the logits lines up with frame t of the labels.
n = min(logits.shape[1], y.shape[1])
logits, y = logits[:, :n], y[:, :n]
ce = F.cross_entropy(logits.reshape(-1, NUM_CLASSES), y.reshape(-1),
weight=weight, ignore_index=-100)
logp = F.log_softmax(logits, dim=-1)
loss = ce + args.lambda_tmse * tmse_loss(logp)
if not torch.isfinite(loss):
continue
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 5.0)
opt.step()
tot += float(loss.detach()); nb += 1
sched.step()
if ep % 5 == 0 or ep == args.epochs:
m = evaluate(model, vl)
print(
f"ep {ep:3d} | loss {tot/max(nb,1):6.3f} | frame_acc {m['frame_acc']*100:5.1f}% | "
f"loc R {m['loc_recall']*100:5.1f}% P {m['loc_prec']*100:5.1f}% | "
f"med_off {m['median_offset_s']:.2f}s | pred {m['n_pred']}/gt {m['n_gt']}",
flush=True,
)
if m["loc_recall"] > best:
best = m["loc_recall"]
torch.save({"state_dict": model.state_dict(), "in_features": in_features,
"bidirectional": not args.unidirectional},
OUT_DIR.parent / f"{args.tag}_model.pt")
print(f"\nbest val loc_recall {best*100:.1f}% -> {OUT_DIR.parent/(args.tag+'_model.pt')}")
return 0
if __name__ == "__main__":
sys.exit(main())

Xet Storage Details

Size:
10.6 kB
·
Xet hash:
6aedef626b126b3af94695aa54abb76bb2b32019858a5c0bffe7655330185a4c

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.