Buckets:

Rishik001's picture
download
raw
22.1 kB
"""Spatial-GCN stem -> Transformer-encoder architecture for figure-skating action classification.
Two temporal-modeling stages:
1. Spatial GCN stem: treats the 17 COCO joints as a graph (edges = anatomical bones from
preprocessing.BONE_PAIRS), applies Kipf-Welling style graph convolution per frame
(H' = norm(A) @ H @ W), independently at every timestep -- no temporal mixing yet.
2. Transformer encoder: sinusoidal positional encoding + a stack of full self-attn+FFN
encoder layers over the per-frame graph-pooled representations (the temporal stage).
Followed by the same temporal mean-pool + deep GELU dense head used throughout this project,
so results are directly comparable to the conv-backbone and BiLSTM variants.
The GCN stem's node input is controlled by --node-features:
"coords" (default): just the (17, 2) joint positions -- preprocessing.build_feature_tensor's
first 34 columns, reshaped back into a graph (see preprocessing.py:284-288).
"full": all 94 columns, scattered onto their anatomically natural node slot instead of being
discarded or dumped in as an undifferentiated vector:
- coords (34) -> (x, y) at every joint
- velocities (16) -> (vx, vy) at the 8 KEY_JOINTS, zero elsewhere
- angles (12) -> the angle's *vertex* joint (JOINT_ANGLE_TRIPLETS[t][1]);
a joint can be the vertex of up to 2 triplets, zero-padded
- angular velocities (12)-> same vertex/slot pairing as angles
- bone vectors (20) -> BOTH endpoints of the bone (BONE_PAIRS is already the graph's
edge list), signed +1 at the parent / -1 at the child so it
reads as "which way my neighboring bone points relative to me";
max degree in this skeleton is 2, zero-padded
This uses every one of the 94 input dims, placed where it has a real anatomical meaning, without
rewriting the conv into a full edge-conditioned message-passing layer -- bone vectors just ride
along as extra node channels at both of their endpoints instead of a separate edge computation.
All index/sign mappings are derived programmatically from preprocessing.BONE_PAIRS /
JOINT_ANGLE_TRIPLETS / KEY_JOINTS, not hand-typed, so they can't silently drift out of sync.
Usage:
python model_gcn.py --data-dir /path/to/processed
python model_gcn.py --data-dir /path/to/processed --coarse --layers 6
python model_gcn.py --data-dir /path/to/processed --coarse --node-features full
python model_gcn.py --smoke
"""
from __future__ import annotations
import argparse
from pathlib import Path
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from sklearn.metrics import f1_score
from sklearn.utils.class_weight import compute_class_weight
from torch.utils.data import DataLoader, TensorDataset
try:
from . import labels as labels_mod
from . import preprocessing
except ImportError:
import labels as labels_mod
import preprocessing
from model import (
DenseBlock,
load_split,
resolve_num_classes,
assert_label_consistency,
report_metrics,
run_epoch,
predict,
)
from model_transformers import PositionalEncoding, make_transformer_encoder
# ---------------------------------------------------------------------------
# Config (identical to model.py / model_transformer_bilstm.py for comparability)
# ---------------------------------------------------------------------------
EPOCHS = 100
BATCH_SIZE = 64
LEARNING_RATE = 5e-4
WEIGHT_DECAY = 1e-4
GRAD_CLIP = 1.0
MAX_CLASS_WEIGHT = 5.0
EARLY_STOP_PATIENCE = 20
SEED = 42
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
D_MODEL = 384
N_ATTN_BLOCKS = 3
GCN_CHANNELS = (64, 128) # first GCN block's input channels vary by --node-features
NUM_JOINTS = preprocessing.NUM_JOINTS # 17, COCO layout
COORD_DIMS = 2 * NUM_JOINTS # 34: the flattened-coords prefix of the 94-dim feature vector
# Column offsets of each block within the 94-dim feature vector, matching
# preprocessing.build_feature_tensor's concatenation order: [coords(34), angles(12),
# bone_vectors(20), velocities(16), angular_velocities(12)].
_COORD_OFFSET = 0
_ANGLE_OFFSET = COORD_DIMS
_BONE_OFFSET = _ANGLE_OFFSET + len(preprocessing.JOINT_ANGLE_TRIPLETS)
_VEL_OFFSET = _BONE_OFFSET + 2 * len(preprocessing.BONE_PAIRS)
_ANGVEL_OFFSET = _VEL_OFFSET + 2 * len(preprocessing.KEY_JOINTS)
_TOTAL_DIMS = _ANGVEL_OFFSET + len(preprocessing.JOINT_ANGLE_TRIPLETS)
assert _TOTAL_DIMS == 94, f"offset bookkeeping drifted from preprocessing.py: got {_TOTAL_DIMS}"
def build_full_node_layout(num_joints: int = NUM_JOINTS):
"""Derive the (17, C) gather-index + sign-multiplier tensors that scatter all 94 feature
columns onto their anatomically natural node slot. See module docstring for the layout.
Computed from preprocessing.py's constants, not hand-typed, so it can't drift out of sync.
"""
triplets = preprocessing.JOINT_ANGLE_TRIPLETS
bones = preprocessing.BONE_PAIRS
key_joints = preprocessing.KEY_JOINTS
vertex_triplets = [[] for _ in range(num_joints)]
for t_idx, (_, vertex, _c) in enumerate(triplets):
vertex_triplets[vertex].append(t_idx)
max_angle_slots = max(len(v) for v in vertex_triplets)
incident_bones = [[] for _ in range(num_joints)]
for e_idx, (parent, child) in enumerate(bones):
incident_bones[parent].append((e_idx, 1.0))
incident_bones[child].append((e_idx, -1.0))
max_bone_slots = max(len(v) for v in incident_bones)
key_joint_pos = {j: k for k, j in enumerate(key_joints)}
channels = 2 + 2 + max_angle_slots + max_angle_slots + 2 * max_bone_slots
src_idx = np.zeros((num_joints, channels), dtype=np.int64)
mult = np.zeros((num_joints, channels), dtype=np.float32)
for j in range(num_joints):
# (x, y) -- always valid
src_idx[j, 0] = _COORD_OFFSET + 2 * j
src_idx[j, 1] = _COORD_OFFSET + 2 * j + 1
mult[j, 0] = mult[j, 1] = 1.0
# (vx, vy) -- only for KEY_JOINTS
if j in key_joint_pos:
k = key_joint_pos[j]
src_idx[j, 2] = _VEL_OFFSET + 2 * k
src_idx[j, 3] = _VEL_OFFSET + 2 * k + 1
mult[j, 2] = mult[j, 3] = 1.0
# angle slots, zero-padded up to max_angle_slots
angle_col = 4
for slot in range(max_angle_slots):
if slot < len(vertex_triplets[j]):
t = vertex_triplets[j][slot]
src_idx[j, angle_col + slot] = _ANGLE_OFFSET + t
mult[j, angle_col + slot] = 1.0
# angular-velocity slots, same triplet pairing as the angle slots
angvel_col = angle_col + max_angle_slots
for slot in range(max_angle_slots):
if slot < len(vertex_triplets[j]):
t = vertex_triplets[j][slot]
src_idx[j, angvel_col + slot] = _ANGVEL_OFFSET + t
mult[j, angvel_col + slot] = 1.0
# bone-vector slots (x, y pairs), signed +1 at parent / -1 at child, zero-padded
bone_col = angvel_col + max_angle_slots
for slot in range(max_bone_slots):
if slot < len(incident_bones[j]):
e, sign = incident_bones[j][slot]
src_idx[j, bone_col + 2 * slot] = _BONE_OFFSET + 2 * e
src_idx[j, bone_col + 2 * slot + 1] = _BONE_OFFSET + 2 * e + 1
mult[j, bone_col + 2 * slot] = sign
mult[j, bone_col + 2 * slot + 1] = sign
return torch.from_numpy(src_idx), torch.from_numpy(mult), channels
FULL_NODE_SRC_IDX, FULL_NODE_MULT, FULL_NODE_CHANNELS = build_full_node_layout()
def make_gcn_channel_schedule(n_layers: int, start: int = 64, end: int = 128) -> tuple[int, ...]:
"""Channel width per GCN block, ramping start->end over n_layers (rounded to a multiple of 8).
n_layers=2 reproduces the original fixed (64, 128) schedule exactly, so old checkpoints /
default behavior are unaffected.
"""
if n_layers == 1:
return (end,)
raw = np.linspace(start, end, n_layers)
return tuple(int(round(c / 8) * 8) for c in raw)
# ---------------------------------------------------------------------------
# Graph adjacency (fixed skeleton topology, from preprocessing.BONE_PAIRS)
# ---------------------------------------------------------------------------
def build_normalized_adjacency(bone_pairs, num_joints: int = NUM_JOINTS) -> np.ndarray:
"""Symmetric-normalized adjacency D^-1/2 (A+I) D^-1/2 (Kipf & Welling).
A+I includes self-loops so each node's own features survive aggregation, not just its
neighbors'. Edges come from the anatomical bone list already used for bone-vector features,
so the graph structure matches what the rest of the pipeline already treats as "connected".
"""
A = np.eye(num_joints, dtype=np.float64)
for a, b in bone_pairs:
A[a, b] = 1.0
A[b, a] = 1.0
deg = A.sum(axis=1)
deg_inv_sqrt = np.power(deg, -0.5)
D_inv_sqrt = np.diag(deg_inv_sqrt)
return D_inv_sqrt @ A @ D_inv_sqrt
# ---------------------------------------------------------------------------
# Model
# ---------------------------------------------------------------------------
class GCNBlock(nn.Module):
"""Graph conv + BN + Dropout with a (projected) residual connection.
Input/return: (B, T, J, Cin/Cout). Mirrors model.py's ConvBlock, just with graph
aggregation (matmul against the fixed normalized adjacency) in place of a 1D conv.
"""
def __init__(self, in_ch: int, out_ch: int, dropout: float = 0.15):
super().__init__()
self.shortcut = nn.Linear(in_ch, out_ch) if in_ch != out_ch else nn.Identity()
self.lin = nn.Linear(in_ch, out_ch)
self.bn = nn.BatchNorm1d(out_ch)
self.drop = nn.Dropout(dropout)
def forward(self, x: torch.Tensor, A: torch.Tensor) -> torch.Tensor:
agg = torch.matmul(A, x) # (B, T, J, Cin): each node <- neighbors + self
y = F.relu(self.lin(agg)) # (B, T, J, Cout)
B, T, J, C = y.shape
y = self.drop(self.bn(y.reshape(B * T * J, C)).reshape(B, T, J, C))
return self.shortcut(x) + y
class SkatingGCNClassifier(nn.Module):
"""(B, T, 94) standardized feature sequence -> (B, num_classes) class logits.
Stage 1 (spatial): reconstruct (B, T, 17, 2) joint coords from the feature vector's
coordinate prefix, run a stack of residual GCN blocks per frame (no temporal mixing).
Stage 2 (temporal): flatten per-frame joint features, project to D_MODEL, positional
encoding + Transformer encoder (self-attn + FFN per layer) across time.
Then temporal mean pool -> the same dense head used everywhere else in this project.
"""
def __init__(self, num_classes: int, num_layers: int = N_ATTN_BLOCKS,
gcn_channels: tuple[int, ...] = GCN_CHANNELS, node_features: str = "coords"):
super().__init__()
if node_features not in ("coords", "full"):
raise ValueError(f"node_features must be 'coords' or 'full', got {node_features!r}")
self.node_features = node_features
A = build_normalized_adjacency(preprocessing.BONE_PAIRS, NUM_JOINTS)
self.register_buffer("A", torch.tensor(A, dtype=torch.float32))
if node_features == "full":
self.register_buffer("node_src_idx", FULL_NODE_SRC_IDX)
self.register_buffer("node_mult", FULL_NODE_MULT)
in_ch = FULL_NODE_CHANNELS
else:
in_ch = 2
chans = (in_ch,) + tuple(gcn_channels)
self.gcn_blocks = nn.ModuleList(
[GCNBlock(chans[i], chans[i + 1]) for i in range(len(chans) - 1)]
)
self.proj = nn.Linear(NUM_JOINTS * gcn_channels[-1], D_MODEL)
self.pos_enc = PositionalEncoding(D_MODEL)
self.encoder = make_transformer_encoder(dim=D_MODEL, heads=8, num_layers=num_layers)
self.head = nn.Sequential(
DenseBlock(D_MODEL, 1024, 0.5),
DenseBlock(1024, 512, 0.4),
DenseBlock(512, 256, 0.3),
nn.LayerNorm(256),
)
self.out = nn.Linear(256, num_classes)
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, T, _ = x.shape
if self.node_features == "full":
h = x[..., self.node_src_idx] * self.node_mult # (B, T, 17, FULL_NODE_CHANNELS)
else:
h = x[..., :COORD_DIMS].reshape(B, T, NUM_JOINTS, 2) # (B, T, 17, 2)
for blk in self.gcn_blocks:
h = blk(h, self.A)
h = h.reshape(B, T, -1) # (B, T, 17*Cout)
h = self.proj(h) # (B, T, D_MODEL)
h = self.pos_enc(h)
h = self.encoder(h)
h = h.mean(dim=1) # temporal average pool -> (B, D_MODEL)
return self.out(self.head(h))
# ---------------------------------------------------------------------------
# Train / evaluate (mirrors model_transformer_bilstm.py's train())
# ---------------------------------------------------------------------------
def train(data_dir: Path, coarse: bool = False, num_layers: int = N_ATTN_BLOCKS,
node_features: str = "coords", gcn_layers: int = len(GCN_CHANNELS),
noise_std: float = 0.0) -> dict:
torch.manual_seed(SEED)
np.random.seed(SEED)
fine_n = resolve_num_classes(data_dir)
taxonomy = labels_mod.COARSE_TAXONOMY if coarse else labels_mod.TAXONOMY
num_classes = len(taxonomy)
Xtr, ytr = load_split(data_dir, "train")
Xva, yva = load_split(data_dir, "val")
Xte, yte = load_split(data_dir, "test")
assert_label_consistency(fine_n, ytr, yva, yte)
if coarse:
def coarsen(y):
return np.array([labels_mod.FINE_TO_COARSE_IDX[int(v)] for v in y], dtype=np.int64)
ytr, yva, yte = coarsen(ytr), coarsen(yva), coarsen(yte)
assert_label_consistency(num_classes, ytr, yva, yte)
in_features = Xtr.shape[-1]
assert in_features >= COORD_DIMS, (
f"GCN stem needs the flattened-coords prefix (first {COORD_DIMS} of 94 features) but "
f"this data has only {in_features} feature columns -- wrong/incompatible processed dir."
)
print(f"[GCN] label space: {'COARSE action-level' if coarse else 'FINE'} | {num_classes} classes")
mu = Xtr.mean(axis=(0, 1), keepdims=True)
sd = Xtr.std(axis=(0, 1), keepdims=True) + 1e-6
Xtr, Xva, Xte = (Xtr - mu) / sd, (Xva - mu) / sd, (Xte - mu) / sd
print(f"[GCN] device={DEVICE} | num_classes={num_classes} | in_features={in_features} | "
f"noise_std={noise_std} (train-only Gaussian input noise, val/test always clean)")
print(f"[GCN] shapes: train={Xtr.shape} val={Xva.shape} test={Xte.shape}")
print(f"[GCN] train classes present: {sorted(set(ytr.tolist()))}")
present = np.unique(ytr)
cw = np.clip(compute_class_weight(class_weight="balanced", classes=present, y=ytr),
None, MAX_CLASS_WEIGHT)
weight = torch.ones(num_classes)
for c, w in zip(present, cw):
weight[int(c)] = float(w)
criterion = nn.CrossEntropyLoss(weight=weight.to(DEVICE))
gcn_channels = make_gcn_channel_schedule(gcn_layers)
model = SkatingGCNClassifier(num_classes, num_layers=num_layers, gcn_channels=gcn_channels,
node_features=node_features).to(DEVICE)
n_params = sum(p.numel() for p in model.parameters())
print(f"[GCN] model params: {n_params/1e6:.2f}M | encoder layers: {num_layers} | "
f"gcn layers: {gcn_layers} {gcn_channels} | node_features={node_features}")
optimizer = torch.optim.Adam(model.parameters(), lr=LEARNING_RATE, weight_decay=WEIGHT_DECAY)
tr_loader = DataLoader(
TensorDataset(torch.from_numpy(Xtr), torch.from_numpy(ytr)),
batch_size=BATCH_SIZE, shuffle=True, drop_last=False,
)
va_loader = DataLoader(
TensorDataset(torch.from_numpy(Xva), torch.from_numpy(yva)),
batch_size=BATCH_SIZE, shuffle=False,
)
best_val_f1, best_state, since_improved = -1.0, None, 0
for epoch in range(1, EPOCHS + 1):
tr_loss, tr_acc = run_epoch(model, tr_loader, criterion, optimizer, noise_std=noise_std)
va_loss, va_acc = run_epoch(model, va_loader, criterion)
va_f1 = f1_score(yva, predict(model, Xva), labels=list(range(num_classes)),
average="macro", zero_division=0)
if va_f1 > best_val_f1:
best_val_f1, since_improved = va_f1, 0
best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
else:
since_improved += 1
if epoch % 5 == 0 or epoch == 1:
print(f"[GCN] epoch {epoch:3d} | train loss {tr_loss:.3f} acc {tr_acc:.3f} "
f"| val loss {va_loss:.3f} acc {va_acc:.3f} f1(macro) {va_f1:.3f}")
if since_improved >= EARLY_STOP_PATIENCE:
print(f"[GCN] early stop at epoch {epoch} (no val-F1 improvement for {EARLY_STOP_PATIENCE})")
break
if best_state is not None:
model.load_state_dict(best_state)
print(f"\n[GCN] restored best model (val macro-F1 = {best_val_f1:.3f})")
metrics = report_metrics(yte, predict(model, Xte), taxonomy)
layer_suffix = "" if num_layers == N_ATTN_BLOCKS else f"_{num_layers}L"
feat_suffix = "" if node_features == "coords" else f"_{node_features}feat"
gcn_suffix = "" if gcn_layers == len(GCN_CHANNELS) else f"_gcn{gcn_layers}L"
noise_suffix = "" if noise_std == 0.0 else f"_noise{noise_std}"
ckpt_name = f"model_gcn{'_coarse' if coarse else ''}{layer_suffix}{feat_suffix}{gcn_suffix}{noise_suffix}.pt"
torch.save({"state_dict": model.state_dict(), "num_classes": num_classes,
"in_features": in_features, "taxonomy": taxonomy, "coarse": coarse,
"num_layers": num_layers, "node_features": node_features, "gcn_layers": gcn_layers,
"gcn_channels": gcn_channels, "noise_std": noise_std,
"feature_mean": mu, "feature_std": sd},
data_dir / ckpt_name)
print(f"\n[GCN] saved model -> {data_dir / ckpt_name}")
return metrics
def smoke() -> None:
"""Validate the full plumbing on synthetic data shaped exactly like the pipeline output."""
print("[GCN] SMOKE: synthetic (N,64,94) data, labels in TAXONOMY index space")
num_classes = len(labels_mod.TAXONOMY)
rng = np.random.default_rng(0)
X = rng.standard_normal((40, 64, 94)).astype(np.float32)
y = rng.integers(0, num_classes, size=40).astype(np.int64)
assert_label_consistency(num_classes, y)
print(f"[GCN] full-feature node layout: {FULL_NODE_CHANNELS} channels/joint "
f"(src_idx range [{FULL_NODE_SRC_IDX.min()},{FULL_NODE_SRC_IDX.max()}], "
f"nonzero mult entries: {int((FULL_NODE_MULT != 0).sum())}/{FULL_NODE_MULT.numel()})")
assert FULL_NODE_SRC_IDX.min() >= 0 and FULL_NODE_SRC_IDX.max() < 94
# Every one of the 94 input columns must be reachable by at least one (joint, channel) slot,
# or "full" mode would be silently dropping input data instead of just reshaping it.
reachable = set(FULL_NODE_SRC_IDX[FULL_NODE_MULT != 0].tolist())
assert reachable == set(range(94)), f"full-feature layout does not cover all 94 columns: missing {set(range(94)) - reachable}"
for node_features in ("coords", "full"):
model = SkatingGCNClassifier(num_classes, node_features=node_features).to(DEVICE)
logits = model(torch.from_numpy(X[:4]).to(DEVICE))
assert logits.shape == (4, num_classes), logits.shape
loss = nn.CrossEntropyLoss()(logits, torch.from_numpy(y[:4]).to(DEVICE))
loss.backward()
n_params = sum(p.numel() for p in model.parameters())
print(f"[GCN] node_features={node_features}: forward OK: logits {tuple(logits.shape)} | "
f"loss {loss.item():.3f} | backward OK | params {n_params/1e6:.2f}M")
print(f"[GCN] num_classes={num_classes} matches softmax units; argmax range in [0,{num_classes-1}]")
print("[GCN] SMOKE PASSED")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--data-dir", type=Path)
parser.add_argument("--coarse", action="store_true",
help="collapse jump rotations into action-level classes (11 instead of 28)")
parser.add_argument("--layers", type=int, default=N_ATTN_BLOCKS,
help=f"number of Transformer encoder layers (default {N_ATTN_BLOCKS})")
parser.add_argument("--node-features", choices=["coords", "full"], default="coords",
help="'coords': (x,y) only (default). 'full': all 94 dims scattered "
"onto their anatomically natural node slot -- see module docstring")
parser.add_argument("--gcn-layers", type=int, default=len(GCN_CHANNELS),
help=f"number of spatial GCN blocks (default {len(GCN_CHANNELS)}); "
"channel widths ramp 64->128 across however many layers you pick")
parser.add_argument("--noise-std", type=float, default=0.0,
help="train-only Gaussian noise std added to the standardized input "
"(0.0 = off). Val/test are never noised.")
parser.add_argument("--smoke", action="store_true", help="self-test on synthetic data")
args = parser.parse_args()
if args.smoke:
smoke()
return 0
if args.data_dir is None or not (args.data_dir / "train_features.pkl").exists():
raise SystemExit(f"No processed data at {args.data_dir}. Run the pipeline first.")
train(args.data_dir, coarse=args.coarse, num_layers=args.layers,
node_features=args.node_features, gcn_layers=args.gcn_layers, noise_std=args.noise_std)
return 0
if __name__ == "__main__":
raise SystemExit(main())

Xet Storage Details

Size:
22.1 kB
·
Xet hash:
2fdd6fbc0096303210a692d0477cd8d912d226cd8272b48aa3c34b870a65004c

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