Buckets:
| """Combo-decomposition experiment: train CTM, BiLSTM, and xLSTM on FS-Jump3D singles, | |
| then infer on the 13 held-out Comb clips with multiple strategies. | |
| Phase 1: Data prep (reuses pipeline.py's existing logic) | |
| Phase 2: Train 3 architectures in parallel | |
| Phase 3: Sliding-window inference at multiple window/stride settings | |
| Phase 4: Per-frame scoring (timestep-level predictions from each architecture) | |
| Phase 5: Confidence-thresholded analysis | |
| Usage: | |
| python -m temporal_scripts.run_experiment | |
| """ | |
| from __future__ import annotations | |
| import subprocess | |
| import sys | |
| import time | |
| from collections import defaultdict | |
| from pathlib import Path | |
| import numpy as np | |
| import torch | |
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) | |
| import config as dataset_config | |
| import labels | |
| import pipeline | |
| import preprocessing | |
| SINGLES_DIR = Path("../data/processed_fs_jump3d_singles_temporal") | |
| COMBO_BUNDLE_PATH = SINGLES_DIR / "combo_holdout.pkl" | |
| LOG_DIR = SINGLES_DIR / "logs" | |
| WINDOW_CONFIGS = [ | |
| {"window": 200, "stride": 10, "label": "200/10 (short, fine)"}, | |
| {"window": 280, "stride": 20, "label": "280/20 (baseline)"}, | |
| {"window": 400, "stride": 30, "label": "400/30 (long, coarse)"}, | |
| ] | |
| CONFIDENCE_THRESHOLD = 0.4 | |
| SCRIPTS_DIR = Path(__file__).resolve().parent.parent | |
| # --------------------------------------------------------------------------- | |
| # Phase 1: Data prep | |
| # --------------------------------------------------------------------------- | |
| def preprocess_full_sequence(sample: dict, config: dict) -> np.ndarray: | |
| width = float(sample.get("width", config["default_width"])) | |
| height = float(sample.get("height", config["default_height"])) | |
| fps = float(sample.get("fps", config["fps"])) | |
| stride = max(1, int(sample.get("extract_every_n_frames", 1) or 1)) | |
| effective_fps = fps / stride | |
| skeleton = preprocessing.correct_aspect_ratio(sample["skeleton"], width, height) | |
| skeleton = preprocessing.filter_low_confidence(skeleton, config["confidence_threshold"]) | |
| skeleton = preprocessing.interpolate_missing(skeleton) | |
| skeleton = preprocessing.smooth_skeleton( | |
| skeleton, config["smoothing_window"], config["smoothing_polyorder"] | |
| ) | |
| skeleton = preprocessing.center_on_hips(skeleton) | |
| skeleton = preprocessing.normalize_scale(skeleton) | |
| return preprocessing.build_feature_tensor(skeleton, effective_fps) | |
| def prepare_data(source_config: dict) -> list[dict]: | |
| print("=" * 72) | |
| print("PHASE 1: data prep") | |
| print("=" * 72) | |
| all_samples = pipeline.load_source_samples("fs_jump3d", source_config) | |
| print(f"loaded {len(all_samples)} fs_jump3d samples total") | |
| singles_features, singles_labels, singles_names = [], [], [] | |
| combo_samples = [] | |
| for sample in all_samples: | |
| label_name, _ = pipeline.map_label_for_source("fs_jump3d", sample["label"]) | |
| if label_name == "Comb": | |
| combo_samples.append(sample) | |
| continue | |
| features, _, _ = pipeline.process_sample(sample, "fs_jump3d", source_config) | |
| singles_features.append(features) | |
| singles_labels.append(labels.FS_JUMP3D_SINGLES_LABEL_TO_IDX[label_name]) | |
| singles_names.append(label_name) | |
| print(f"singles: {len(singles_features)} | held-out Comb: {len(combo_samples)}") | |
| assert len(combo_samples) == 13, f"expected 13 Comb clips, found {len(combo_samples)}" | |
| features = np.stack(singles_features, axis=0) | |
| y = np.asarray(singles_labels, dtype=np.int64) | |
| save_config = dict(source_config) | |
| save_config["output_dir"] = SINGLES_DIR | |
| pipeline.save_outputs(features, y, singles_names, save_config, | |
| taxonomy=labels.FS_JUMP3D_SINGLES_TAXONOMY) | |
| print(f"saved singles-only train/val/test -> {SINGLES_DIR.resolve()}") | |
| combo_bundle = [] | |
| for sample in combo_samples: | |
| full_features = preprocess_full_sequence(sample, source_config) | |
| combo_bundle.append({"source_file": sample["source_file"], "features": full_features}) | |
| print(f" {Path(sample['source_file']).name}: full sequence shape {full_features.shape}") | |
| SINGLES_DIR.mkdir(parents=True, exist_ok=True) | |
| pipeline.save_pickle(combo_bundle, COMBO_BUNDLE_PATH) | |
| print(f"saved {len(combo_bundle)} held-out Comb sequences -> {COMBO_BUNDLE_PATH.resolve()}") | |
| return combo_bundle | |
| # --------------------------------------------------------------------------- | |
| # Phase 2: Parallel training | |
| # --------------------------------------------------------------------------- | |
| def launch_training_jobs() -> dict[str, subprocess.Popen]: | |
| print("\n" + "=" * 72) | |
| print("PHASE 2: training BiLSTM, CTM-GCN, and xLSTM in parallel") | |
| print("=" * 72) | |
| LOG_DIR.mkdir(parents=True, exist_ok=True) | |
| jobs = { | |
| "bilstm": ( | |
| f"import sys; sys.path.insert(0, '{SCRIPTS_DIR}'); " | |
| f"import model_bilstm; from pathlib import Path; " | |
| f"model_bilstm.train(Path('{SINGLES_DIR}'), coarse=False)" | |
| ), | |
| "gcn_ctm": ( | |
| f"import sys; sys.path.insert(0, '{SCRIPTS_DIR}'); " | |
| f"import model_ctm; from pathlib import Path; " | |
| f"model_ctm.train(Path('{SINGLES_DIR}'), coarse=False, backbone='gcn', tag='gcn_ctm')" | |
| ), | |
| "xlstm": ( | |
| f"import sys; sys.path.insert(0, '{SCRIPTS_DIR}'); " | |
| f"sys.path.insert(0, '{SCRIPTS_DIR / 'temporal_scripts'}'); " | |
| f"from model_xlstm import train; from pathlib import Path; " | |
| f"train(Path('{SINGLES_DIR}'), coarse=False)" | |
| ), | |
| } | |
| procs = {} | |
| for name, code in jobs.items(): | |
| log_path = LOG_DIR / f"{name}.log" | |
| log_file = open(log_path, "w") | |
| print(f" launching {name} -> log: {log_path.resolve()}") | |
| procs[name] = subprocess.Popen( | |
| [sys.executable, "-c", code], | |
| stdout=log_file, stderr=subprocess.STDOUT, | |
| cwd=str(SCRIPTS_DIR), | |
| ) | |
| return procs | |
| def wait_for_jobs(procs: dict[str, subprocess.Popen]) -> dict[str, bool]: | |
| results = {} | |
| for name, proc in procs.items(): | |
| ret = proc.wait() | |
| ok = ret == 0 | |
| results[name] = ok | |
| status = "OK" if ok else f"FAILED (exit {ret})" | |
| print(f" {name}: {status} (see {LOG_DIR / (name + '.log')})") | |
| if not ok: | |
| tail = (LOG_DIR / f"{name}.log").read_text().splitlines()[-30:] | |
| print(" --- last lines ---") | |
| print("\n".join(f" {line}" for line in tail)) | |
| return results | |
| # --------------------------------------------------------------------------- | |
| # Phase 3: Sliding-window inference | |
| # --------------------------------------------------------------------------- | |
| def sliding_windows(num_frames: int, window: int, stride: int) -> list[tuple[int, int]]: | |
| if num_frames <= window: | |
| return [(0, num_frames)] | |
| spans = [] | |
| start = 0 | |
| while start + window <= num_frames: | |
| spans.append((start, start + window)) | |
| start += stride | |
| if spans[-1][1] < num_frames: | |
| spans.append((num_frames - window, num_frames)) | |
| return spans | |
| def load_model_bilstm(device): | |
| from model_bilstm import SkatingBiLSTMClassifier | |
| ckpt = torch.load(SINGLES_DIR / "model_bilstm.pt", map_location=device, weights_only=False) | |
| model = SkatingBiLSTMClassifier(ckpt["in_features"], ckpt["num_classes"]).to(device) | |
| model.load_state_dict(ckpt["state_dict"]) | |
| model.eval() | |
| mu = ckpt["feature_mean"].reshape(-1) | |
| sd = ckpt["feature_std"].reshape(-1) | |
| return model, mu, sd | |
| def load_model_ctm(device): | |
| from model_ctm import SkatingCTM | |
| ckpt_path = SINGLES_DIR / "model_ctm_gcn_ctm_gcnbackbone.pt" | |
| ckpt = torch.load(ckpt_path, map_location=device, weights_only=False) | |
| model = SkatingCTM( | |
| in_features=ckpt["in_features"], num_classes=ckpt["num_classes"], | |
| backbone=ckpt.get("backbone", "gcn"), max_iterations=ckpt["max_iterations"], | |
| memory_length=ckpt["memory_length"], use_act=ckpt["use_act"], | |
| node_features=ckpt.get("node_features", "full"), | |
| gcn_layers=ckpt.get("gcn_layers", 2), | |
| ).to(device) | |
| model.load_state_dict(ckpt["state_dict"]) | |
| model.eval() | |
| mu = ckpt["feature_mean"].reshape(-1) | |
| sd = ckpt["feature_std"].reshape(-1) | |
| return model, mu, sd | |
| def load_model_xlstm(device): | |
| from temporal_scripts.model_xlstm import SkatingxLSTMClassifier | |
| ckpt = torch.load(SINGLES_DIR / "model_xlstm.pt", map_location=device, weights_only=False) | |
| model = SkatingxLSTMClassifier( | |
| ckpt["in_features"], ckpt["num_classes"], | |
| num_blocks=ckpt.get("num_blocks", 4), | |
| context_length=ckpt.get("context_length", 128), | |
| ).to(device) | |
| model.load_state_dict(ckpt["state_dict"]) | |
| model.eval() | |
| mu = ckpt["feature_mean"].reshape(-1) | |
| sd = ckpt["feature_std"].reshape(-1) | |
| return model, mu, sd | |
| def classify_window_standard(model, window: np.ndarray, mu, sd, device, taxonomy) -> tuple[str, float]: | |
| resized = preprocessing.resample_to_length(window, 128).astype(np.float32) | |
| resized = (resized - mu) / sd | |
| xb = torch.from_numpy(resized).unsqueeze(0).to(device) | |
| probs = torch.softmax(model(xb), dim=1) | |
| pred = int(probs.argmax(1).item()) | |
| return taxonomy[pred], float(probs.max(1).values.item()) | |
| def classify_window_ctm(model, window: np.ndarray, mu, sd, device, taxonomy) -> tuple[str, float]: | |
| resized = preprocessing.resample_to_length(window, 128).astype(np.float32) | |
| resized = (resized - mu) / sd | |
| xb = torch.from_numpy(resized).unsqueeze(0).to(device) | |
| preds, halt_weights, _, _ = model(xb) | |
| weighted = (halt_weights.unsqueeze(1) * preds).sum(dim=-1) | |
| probs = torch.softmax(weighted, dim=1) | |
| pred = int(probs.argmax(1).item()) | |
| return taxonomy[pred], float(probs.max(1).values.item()) | |
| def run_sliding_window_inference(combo_bundle: list[dict], model_zoo: dict, | |
| taxonomy: dict) -> dict: | |
| print("\n" + "=" * 72) | |
| print("PHASE 3: sliding-window inference on held-out Comb clips") | |
| print("=" * 72) | |
| all_results = {} | |
| for wcfg in WINDOW_CONFIGS: | |
| window_size, stride, wlabel = wcfg["window"], wcfg["stride"], wcfg["label"] | |
| print(f"\n--- Window config: {wlabel} ---") | |
| config_results = {} | |
| for sample in combo_bundle: | |
| name = Path(sample["source_file"]).name | |
| features = sample["features"] | |
| spans = sliding_windows(features.shape[0], window_size, stride) | |
| print(f"\n {name} ({features.shape[0]} frames, {len(spans)} window(s))") | |
| header = f" {'window':<16}" | |
| for mname in model_zoo: | |
| header += f" {mname:<10} {'conf':<6}" | |
| print(header) | |
| clip_results = [] | |
| for start, end in spans: | |
| window = features[start:end] | |
| row = {"start": start, "end": end} | |
| row_str = f" {f'{start:4d}-{end:<4d}':<16}" | |
| for mname, minfo in model_zoo.items(): | |
| classify_fn = minfo["classify_fn"] | |
| pred, conf = classify_fn( | |
| minfo["model"], window, minfo["mu"], minfo["sd"], | |
| minfo["device"], taxonomy, | |
| ) | |
| row[f"{mname}_pred"] = pred | |
| row[f"{mname}_conf"] = conf | |
| row_str += f" {pred:<10} {conf:<6.2f}" | |
| print(row_str) | |
| clip_results.append(row) | |
| config_results[name] = clip_results | |
| all_results[wlabel] = config_results | |
| return all_results | |
| # --------------------------------------------------------------------------- | |
| # Phase 4: Per-frame scoring | |
| # --------------------------------------------------------------------------- | |
| def per_frame_score_bilstm(model, features: np.ndarray, mu, sd, device, | |
| taxonomy: dict) -> list[tuple[str, float]]: | |
| """Run BiLSTM on the full sequence and extract per-timestep hidden states for classification. | |
| Since the standard BiLSTM model only outputs a single pooled prediction, we access the | |
| LSTM hidden states before mean-pooling to get per-frame representations, then classify each. | |
| """ | |
| resized = preprocessing.resample_to_length(features, 128).astype(np.float32) | |
| resized = (resized - mu) / sd | |
| xb = torch.from_numpy(resized).unsqueeze(0).to(device) | |
| x = xb.transpose(1, 2) | |
| x = torch.relu(model.stem(x)) | |
| x = model.stem_bn(x) | |
| x = model.cb3(model.cb2(model.cb1(x))) | |
| x = x.transpose(1, 2) | |
| x, _ = model.lstm(x) | |
| B, T, D = x.shape | |
| flat = x.reshape(B * T, D) | |
| logits = model.out(model.head(flat)).reshape(B, T, -1) | |
| probs = torch.softmax(logits, dim=-1) | |
| results = [] | |
| for t in range(T): | |
| pred = int(probs[0, t].argmax().item()) | |
| conf = float(probs[0, t].max().item()) | |
| results.append((taxonomy[pred], conf)) | |
| return results | |
| def per_frame_score_ctm(model, features: np.ndarray, mu, sd, device, | |
| taxonomy: dict, window: int = 128, stride: int = 8, | |
| ) -> list[tuple[str, float]]: | |
| """CTM per-frame: the CTM has no frame-indexed output head -- its cross-attention pools | |
| over the *whole* input window with a single query per thinking step, so one forward pass | |
| only ever yields one clip-level prediction (this is why the old implementation could only | |
| repeat a single (label, conf) 128 times: there was nothing else to report). To get a real | |
| per-frame signal without pretending the architecture has resolution it doesn't, run many | |
| small overlapping windows across the raw sequence (same sliding-window technique Phase 3 | |
| already uses, just at frame resolution instead of clip resolution) and assign each frame | |
| the softmax-probability average over every window that covers it. | |
| """ | |
| T = features.shape[0] | |
| win = min(window, T) | |
| spans = sliding_windows(T, win, stride) | |
| num_classes = len(taxonomy) | |
| prob_sum = np.zeros((T, num_classes), dtype=np.float64) | |
| count = np.zeros(T, dtype=np.int64) | |
| for start, end in spans: | |
| resized = preprocessing.resample_to_length(features[start:end], 128).astype(np.float32) | |
| resized = (resized - mu) / sd | |
| xb = torch.from_numpy(resized).unsqueeze(0).to(device) | |
| preds, halt_weights, _, _ = model(xb) | |
| weighted = (halt_weights.unsqueeze(1) * preds).sum(dim=-1) | |
| probs = torch.softmax(weighted, dim=1).cpu().numpy()[0] | |
| prob_sum[start:end] += probs | |
| count[start:end] += 1 | |
| avg_probs = prob_sum / np.maximum(count, 1)[:, None] | |
| # Resample the raw-frame-resolution predictions to a fixed length of 128 so CTM's output | |
| # is directly comparable to BiLSTM/xLSTM's 128-step per_frame_score_* output. | |
| idxs = np.linspace(0, T - 1, 128).round().astype(int) | |
| results = [] | |
| for i in idxs: | |
| pred = int(avg_probs[i].argmax()) | |
| conf = float(avg_probs[i].max()) | |
| results.append((taxonomy[pred], conf)) | |
| return results | |
| def per_frame_score_xlstm(model, features: np.ndarray, mu, sd, device, | |
| taxonomy: dict) -> list[tuple[str, float]]: | |
| resized = preprocessing.resample_to_length(features, 128).astype(np.float32) | |
| resized = (resized - mu) / sd | |
| xb = torch.from_numpy(resized).unsqueeze(0).to(device) | |
| logits = model.forward_per_step(xb) | |
| probs = torch.softmax(logits, dim=-1) | |
| results = [] | |
| for t in range(probs.shape[1]): | |
| pred = int(probs[0, t].argmax().item()) | |
| conf = float(probs[0, t].max().item()) | |
| results.append((taxonomy[pred], conf)) | |
| return results | |
| def run_per_frame_scoring(combo_bundle: list[dict], model_zoo: dict, | |
| taxonomy: dict) -> dict: | |
| print("\n" + "=" * 72) | |
| print("PHASE 4: per-frame scoring on held-out Comb clips") | |
| print("=" * 72) | |
| per_frame_fns = { | |
| "BiLSTM": per_frame_score_bilstm, | |
| "CTM": per_frame_score_ctm, | |
| "xLSTM": per_frame_score_xlstm, | |
| } | |
| all_results = {} | |
| for sample in combo_bundle: | |
| name = Path(sample["source_file"]).name | |
| features = sample["features"] | |
| print(f"\n {name} ({features.shape[0]} frames)") | |
| clip_results = {} | |
| for mname, minfo in model_zoo.items(): | |
| if mname not in per_frame_fns: | |
| continue | |
| fn = per_frame_fns[mname] | |
| frame_preds = fn( | |
| minfo["model"], features, minfo["mu"], minfo["sd"], | |
| minfo["device"], taxonomy, | |
| ) | |
| clip_results[mname] = frame_preds | |
| segments = _compress_predictions(frame_preds) | |
| seg_str = " -> ".join( | |
| f"{pred}({s}-{e}, conf={c:.2f})" | |
| for pred, c, s, e in segments | |
| ) | |
| print(f" {mname}: {seg_str}") | |
| all_results[name] = clip_results | |
| return all_results | |
| def _compress_predictions(preds: list[tuple[str, float]], | |
| ) -> list[tuple[str, float, int, int]]: | |
| """Compress a per-frame prediction list into segments of consecutive same-class frames.""" | |
| if not preds: | |
| return [] | |
| segments = [] | |
| current_label, current_conf, start = preds[0][0], preds[0][1], 0 | |
| confs = [preds[0][1]] | |
| for i in range(1, len(preds)): | |
| if preds[i][0] != current_label: | |
| segments.append((current_label, sum(confs) / len(confs), start, i)) | |
| current_label = preds[i][0] | |
| start = i | |
| confs = [] | |
| confs.append(preds[i][1]) | |
| segments.append((current_label, sum(confs) / len(confs), start, len(preds))) | |
| return segments | |
| # --------------------------------------------------------------------------- | |
| # Phase 5: Confidence-thresholded analysis | |
| # --------------------------------------------------------------------------- | |
| def run_confidence_analysis(all_sw_results: dict, threshold: float = CONFIDENCE_THRESHOLD): | |
| print("\n" + "=" * 72) | |
| print(f"PHASE 5: confidence-thresholded analysis (threshold={threshold})") | |
| print("=" * 72) | |
| for wlabel, config_results in all_sw_results.items(): | |
| print(f"\n--- {wlabel} ---") | |
| for clip_name, rows in config_results.items(): | |
| print(f"\n {clip_name}:") | |
| for row in rows: | |
| for mname_key in [k for k in row if k.endswith("_pred")]: | |
| mname = mname_key.replace("_pred", "") | |
| pred = row[mname_key] | |
| conf = row[f"{mname}_conf"] | |
| label = pred if conf >= threshold else "???" | |
| marker = " " if conf >= threshold else "*" | |
| if marker == "*": | |
| print(f" {row['start']:4d}-{row['end']:<4d} {mname:<8} " | |
| f"{label:<10} {conf:.2f} {marker}") | |
| # --------------------------------------------------------------------------- | |
| # Main | |
| # --------------------------------------------------------------------------- | |
| def main() -> int: | |
| t0 = time.perf_counter() | |
| source_config = dataset_config.get_source_config("fs_jump3d", dict(pipeline.CONFIG)) | |
| # Phase 1 | |
| if COMBO_BUNDLE_PATH.exists() and (SINGLES_DIR / "train_features.pkl").exists(): | |
| print("Phase 1: reusing existing data prep") | |
| combo_bundle = pipeline.load_pickle(COMBO_BUNDLE_PATH) | |
| else: | |
| combo_bundle = prepare_data(source_config) | |
| # Phase 2 | |
| procs = launch_training_jobs() | |
| job_results = wait_for_jobs(procs) | |
| if not any(job_results.values()): | |
| print("\nAll training jobs failed. Aborting.") | |
| return 1 | |
| # Phase 3-5 | |
| device = "cpu" | |
| if torch.cuda.is_available(): | |
| device = "cuda" | |
| taxonomy = labels.FS_JUMP3D_SINGLES_TAXONOMY | |
| model_zoo = {} | |
| if job_results.get("bilstm"): | |
| model, mu, sd = load_model_bilstm(device) | |
| model_zoo["BiLSTM"] = { | |
| "model": model, "mu": mu, "sd": sd, "device": device, | |
| "classify_fn": classify_window_standard, | |
| } | |
| if job_results.get("gcn_ctm"): | |
| model, mu, sd = load_model_ctm(device) | |
| model_zoo["CTM"] = { | |
| "model": model, "mu": mu, "sd": sd, "device": device, | |
| "classify_fn": classify_window_ctm, | |
| } | |
| if job_results.get("xlstm"): | |
| model, mu, sd = load_model_xlstm(device) | |
| model_zoo["xLSTM"] = { | |
| "model": model, "mu": mu, "sd": sd, "device": device, | |
| "classify_fn": classify_window_standard, | |
| } | |
| if not model_zoo: | |
| print("\nNo models loaded. Cannot run inference.") | |
| return 1 | |
| print(f"\nLoaded {len(model_zoo)} model(s): {list(model_zoo.keys())}") | |
| # Phase 3 | |
| all_sw_results = run_sliding_window_inference(combo_bundle, model_zoo, taxonomy) | |
| # Phase 4 | |
| per_frame_results = run_per_frame_scoring(combo_bundle, model_zoo, taxonomy) | |
| # Phase 5 | |
| run_confidence_analysis(all_sw_results) | |
| elapsed = time.perf_counter() - t0 | |
| print(f"\n{'=' * 72}") | |
| print(f"Experiment complete in {elapsed:.1f}s") | |
| print(f"Models trained: {[k for k, v in job_results.items() if v]}") | |
| print(f"Models failed: {[k for k, v in job_results.items() if not v]}") | |
| print(f"Window configs tested: {len(WINDOW_CONFIGS)}") | |
| print(f"Comb clips analyzed: {len(combo_bundle)}") | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |
Xet Storage Details
- Size:
- 21.5 kB
- Xet hash:
- eaafb8de7828e02ac4728b9401acfd17105dd58d282dcf9fb95cd8440d64ebce
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.