Buckets:
| """Continuous Thought Machine with Adaptive Computation Time for figure-skating classification. | |
| Architecture: Conv1D backbone (jointly trained) + CTM thinking loop with ACT halting. | |
| The model learns to halt early on easy samples (spins) and think longer on hard ones | |
| (ambiguous jump rotations), instead of running a fixed number of iterations. | |
| Usage: | |
| python model_ctm.py --data-dir /path/to/processed | |
| python model_ctm.py --data-dir /path/to/processed --coarse | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import math | |
| 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 | |
| except ImportError: | |
| import labels as labels_mod | |
| from ctm_components import NeuronModel, SynapseNet, compute_synchronization | |
| from model import ( | |
| ConvBlock, | |
| load_split, | |
| resolve_num_classes, | |
| assert_label_consistency, | |
| report_metrics, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Config | |
| # --------------------------------------------------------------------------- | |
| EPOCHS = 100 | |
| BATCH_SIZE = 64 | |
| LEARNING_RATE = 3e-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" | |
| CTM_MAX_ITERATIONS = 30 | |
| CTM_D_MODEL = 256 | |
| CTM_D_BACKBONE = 384 | |
| CTM_MEMORY_LENGTH = 50 | |
| CTM_N_SYNCH = 64 | |
| CTM_NUM_HEADS = 8 | |
| CTM_DROPOUT = 0.1 | |
| ACT_EPSILON = 0.01 | |
| PONDER_LAMBDA = 0.01 | |
| # --------------------------------------------------------------------------- | |
| # Conv backbone (jointly trained feature encoder) | |
| # --------------------------------------------------------------------------- | |
| class ConvBackbone(nn.Module): | |
| """Conv1D encoder: (B, T, in_features) -> (B, T, d_backbone).""" | |
| def __init__(self, in_features: int, d_backbone: int = 384): | |
| super().__init__() | |
| self.stem = nn.Conv1d(in_features, 128, 3, padding="same") | |
| self.stem_bn = nn.BatchNorm1d(128) | |
| self.cb1 = ConvBlock(128, 192, 3) | |
| self.cb2 = ConvBlock(192, 256, 3) | |
| self.cb3 = ConvBlock(256, d_backbone, 5) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| x = x.transpose(1, 2) | |
| x = F.relu(self.stem(x)) | |
| x = self.stem_bn(x) | |
| x = self.cb3(self.cb2(self.cb1(x))) | |
| return x.transpose(1, 2) | |
| # --------------------------------------------------------------------------- | |
| # Skating CTM + ACT | |
| # --------------------------------------------------------------------------- | |
| class SkatingCTM(nn.Module): | |
| """Conv backbone + CTM thinking loop with Adaptive Computation Time. | |
| Instead of a fixed iteration count, each sample halts independently once | |
| the learned halt probability accumulates past 1 - epsilon. Easy samples | |
| (spins) halt early; ambiguous jumps get more thinking steps. | |
| """ | |
| def __init__( | |
| self, | |
| in_features: int, | |
| num_classes: int, | |
| d_model: int = CTM_D_MODEL, | |
| d_backbone: int = CTM_D_BACKBONE, | |
| num_heads: int = CTM_NUM_HEADS, | |
| memory_length: int = CTM_MEMORY_LENGTH, | |
| max_iterations: int = CTM_MAX_ITERATIONS, | |
| n_synch: int = CTM_N_SYNCH, | |
| dropout: float = CTM_DROPOUT, | |
| act_epsilon: float = ACT_EPSILON, | |
| use_act: bool = True, | |
| ): | |
| super().__init__() | |
| self.d_model = d_model | |
| self.num_classes = num_classes | |
| self.max_iterations = max_iterations | |
| self.n_synch = n_synch | |
| self.act_epsilon = act_epsilon | |
| self.use_act = use_act | |
| self.backbone = ConvBackbone(in_features, d_backbone) | |
| self.kv_proj = nn.Sequential( | |
| nn.Linear(d_backbone, d_model), | |
| nn.LayerNorm(d_model), | |
| ) | |
| self.nlm = NeuronModel(memory_length, d_model, dropout=dropout) | |
| self.synapses = SynapseNet(d_model, d_model, dropout=dropout) | |
| self.attention = nn.MultiheadAttention( | |
| d_model, num_heads, dropout=dropout, batch_first=True, | |
| ) | |
| synch_rep = (n_synch * (n_synch + 1)) // 2 | |
| self.q_proj = nn.Linear(synch_rep, d_model) | |
| self.output_proj = nn.Linear(synch_rep, num_classes) | |
| self.decay_action = nn.Parameter(torch.zeros(synch_rep)) | |
| self.decay_out = nn.Parameter(torch.zeros(synch_rep)) | |
| self.start_act = nn.Parameter(torch.zeros(d_model).uniform_(-0.1, 0.1)) | |
| self.start_trace = nn.Parameter(torch.zeros(d_model, memory_length).uniform_(-0.1, 0.1)) | |
| # ACT halting unit — bias initialized negative so initial halt prob is | |
| # low (~0.05) and the model starts by using ~20 steps, learning to halt | |
| # earlier as training progresses. | |
| self.halt_proj = nn.Linear(d_model, 1) | |
| nn.init.constant_(self.halt_proj.bias, -3.0) | |
| self.register_buffer("idx_left_action", torch.arange(d_model - n_synch, d_model)) | |
| self.register_buffer("idx_right_action", torch.arange(d_model - n_synch, d_model)) | |
| self.register_buffer("idx_left_out", torch.arange(0, n_synch)) | |
| self.register_buffer("idx_right_out", torch.arange(0, n_synch)) | |
| def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: | |
| """ | |
| Returns: | |
| preds: (B, num_classes, T_actual) per-step predictions | |
| halt_weights: (B, T_actual) ACT weights per step (sum to ~1 per sample) | |
| ponder_cost: scalar, mean steps taken across batch | |
| n_steps: (B,) per-sample step count | |
| """ | |
| features = self.backbone(x) | |
| B = features.size(0) | |
| kv = self.kv_proj(features) | |
| state_trace = self.start_trace.unsqueeze(0).expand(B, -1, -1).clone() | |
| activated_state = self.start_act.unsqueeze(0).expand(B, -1).clone() | |
| decay_action = torch.exp(-self.decay_action.clamp(0, 15)).unsqueeze(0).expand(B, -1) | |
| decay_out = torch.exp(-self.decay_out.clamp(0, 15)).unsqueeze(0).expand(B, -1) | |
| _, ema_n_out, ema_d_out = compute_synchronization( | |
| activated_state, None, None, decay_out, | |
| self.n_synch, self.idx_left_out, self.idx_right_out, | |
| ) | |
| preds_list: list[torch.Tensor] = [] | |
| halt_list: list[torch.Tensor] = [] | |
| cumulative_p = torch.zeros(B, device=x.device) | |
| halted = torch.zeros(B, dtype=torch.bool, device=x.device) | |
| n_steps = torch.zeros(B, device=x.device) | |
| ema_n_act = ema_d_act = None | |
| for t in range(self.max_iterations): | |
| # --- thinking step --- | |
| sync_act, ema_n_act, ema_d_act = compute_synchronization( | |
| activated_state, ema_n_act, ema_d_act, decay_action, | |
| self.n_synch, self.idx_left_action, self.idx_right_action, | |
| ) | |
| q = self.q_proj(sync_act).unsqueeze(1) | |
| attn_out, _ = self.attention(q, kv, kv, need_weights=False) | |
| attn_out = attn_out.squeeze(1) | |
| state = self.synapses(torch.cat([attn_out, activated_state], dim=-1)) | |
| state_trace = torch.cat([state_trace[:, :, 1:], state.unsqueeze(-1)], dim=-1) | |
| activated_state = self.nlm(state_trace) | |
| sync_out, ema_n_out, ema_d_out = compute_synchronization( | |
| activated_state, ema_n_out, ema_d_out, decay_out, | |
| self.n_synch, self.idx_left_out, self.idx_right_out, | |
| ) | |
| preds_list.append(self.output_proj(sync_out)) | |
| if not self.use_act: | |
| # Fixed-iteration mode: no halting: every step contributes equally to the | |
| # loss/prediction and every sample always runs exactly max_iterations steps. | |
| halt_list.append(torch.full((B,), 1.0 / self.max_iterations, device=x.device)) | |
| n_steps += 1.0 | |
| continue | |
| # --- ACT halting --- | |
| p = torch.sigmoid(self.halt_proj(activated_state)).squeeze(-1) | |
| still_running = ~halted | |
| new_cumulative = cumulative_p + p | |
| halt_now = still_running & (new_cumulative >= 1.0 - self.act_epsilon) | |
| remainder = 1.0 - cumulative_p | |
| w = torch.where(halt_now, remainder, p) * still_running.float() | |
| halt_list.append(w) | |
| cumulative_p = torch.where(halt_now | halted, cumulative_p, new_cumulative) | |
| n_steps += still_running.float() | |
| halted = halted | halt_now | |
| if halted.all(): | |
| break | |
| # Assign remainder to samples that never halted within max_iterations | |
| if self.use_act and not halted.all(): | |
| still_running = ~halted | |
| halt_list[-1] = halt_list[-1] + (1.0 - cumulative_p) * still_running.float() | |
| preds = torch.stack(preds_list, dim=-1) | |
| halt_weights = torch.stack(halt_list, dim=-1) | |
| ponder_cost = n_steps.mean() | |
| return preds, halt_weights, ponder_cost, n_steps | |
| # --------------------------------------------------------------------------- | |
| # Loss | |
| # --------------------------------------------------------------------------- | |
| def ctm_act_loss( | |
| preds: torch.Tensor, | |
| targets: torch.Tensor, | |
| halt_weights: torch.Tensor, | |
| ponder_cost: torch.Tensor, | |
| class_weight: torch.Tensor | None = None, | |
| ponder_lambda: float = PONDER_LAMBDA, | |
| ) -> torch.Tensor: | |
| """Halt-weighted CE across thinking steps + ponder cost regularization.""" | |
| _, _, T = preds.shape | |
| total = preds.new_zeros(()) | |
| for t in range(T): | |
| ce = F.cross_entropy(preds[:, :, t], targets, weight=class_weight, reduction="none") | |
| total = total + (halt_weights[:, t] * ce).mean() | |
| return total + ponder_lambda * ponder_cost | |
| # --------------------------------------------------------------------------- | |
| # Train / evaluate | |
| # --------------------------------------------------------------------------- | |
| def predict(model: nn.Module, X: np.ndarray) -> tuple[np.ndarray, float]: | |
| """Returns (predicted_classes, mean_steps).""" | |
| model.eval() | |
| out = [] | |
| total_steps = 0.0 | |
| total_n = 0 | |
| for i in range(0, len(X), 256): | |
| xb = torch.from_numpy(X[i : i + 256]).to(DEVICE) | |
| preds, halt_weights, _, n_steps = model(xb) | |
| weighted = (halt_weights.unsqueeze(1) * preds).sum(dim=-1) | |
| out.append(weighted.argmax(1).cpu().numpy()) | |
| total_steps += n_steps.sum().item() | |
| total_n += xb.size(0) | |
| classes = np.concatenate(out) if out else np.array([], dtype=np.int64) | |
| return classes, total_steps / max(total_n, 1) | |
| def train( | |
| data_dir: Path, | |
| coarse: bool = False, | |
| max_iterations: int = CTM_MAX_ITERATIONS, | |
| memory_length: int = CTM_MEMORY_LENGTH, | |
| use_act: bool = True, | |
| ponder_lambda: float = PONDER_LAMBDA, | |
| tag: str = "CTM-ACT", | |
| ) -> 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] | |
| print(f"[{tag}] label space: {'COARSE' 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"[{tag}] device={DEVICE} | in_features={in_features}") | |
| print(f"[{tag}] max_iterations={max_iterations} | memory={memory_length} | use_act={use_act} | ponder_lambda={ponder_lambda}") | |
| print(f"[{tag}] shapes: train={Xtr.shape} val={Xva.shape} test={Xte.shape}") | |
| 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) | |
| class_weight = weight.to(DEVICE) | |
| model = SkatingCTM( | |
| in_features, num_classes, | |
| max_iterations=max_iterations, memory_length=memory_length, use_act=use_act, | |
| ).to(DEVICE) | |
| n_params = sum(p.numel() for p in model.parameters()) | |
| print(f"[{tag}] params: {n_params / 1e6:.2f}M") | |
| optimizer = torch.optim.AdamW(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): | |
| model.train() | |
| tr_loss, tr_correct, tr_n = 0.0, 0, 0 | |
| epoch_steps = 0.0 | |
| for xb, yb in tr_loader: | |
| xb, yb = xb.to(DEVICE), yb.to(DEVICE) | |
| preds, halt_weights, ponder_cost, n_steps = model(xb) | |
| loss = ctm_act_loss(preds, yb, halt_weights, ponder_cost, class_weight, ponder_lambda=ponder_lambda) | |
| if not torch.isfinite(loss): | |
| continue | |
| optimizer.zero_grad() | |
| loss.backward() | |
| torch.nn.utils.clip_grad_norm_(model.parameters(), GRAD_CLIP) | |
| optimizer.step() | |
| tr_loss += loss.item() * xb.size(0) | |
| weighted = (halt_weights.unsqueeze(1) * preds).sum(dim=-1) | |
| tr_correct += (weighted.argmax(1) == yb).sum().item() | |
| tr_n += xb.size(0) | |
| epoch_steps += n_steps.sum().item() | |
| model.eval() | |
| va_loss, va_correct, va_n = 0.0, 0, 0 | |
| va_steps = 0.0 | |
| with torch.no_grad(): | |
| for xb, yb in va_loader: | |
| xb, yb = xb.to(DEVICE), yb.to(DEVICE) | |
| preds, halt_weights, ponder_cost, n_steps = model(xb) | |
| loss = ctm_act_loss(preds, yb, halt_weights, ponder_cost, class_weight, ponder_lambda=ponder_lambda) | |
| va_loss += loss.item() * xb.size(0) | |
| weighted = (halt_weights.unsqueeze(1) * preds).sum(dim=-1) | |
| va_correct += (weighted.argmax(1) == yb).sum().item() | |
| va_n += xb.size(0) | |
| va_steps += n_steps.sum().item() | |
| tr_loss /= tr_n | |
| tr_acc = tr_correct / tr_n | |
| va_loss /= va_n | |
| va_acc = va_correct / va_n | |
| mean_tr_steps = epoch_steps / tr_n | |
| mean_va_steps = va_steps / va_n | |
| va_preds, _ = predict(model, Xva) | |
| va_f1 = f1_score( | |
| yva, va_preds, | |
| 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"[{tag}] epoch {epoch:3d} | train loss {tr_loss:.3f} acc {tr_acc:.3f} " | |
| f"| val loss {va_loss:.3f} acc {va_acc:.3f} f1 {va_f1:.3f} " | |
| f"| steps tr={mean_tr_steps:.1f} va={mean_va_steps:.1f}" | |
| ) | |
| if since_improved >= EARLY_STOP_PATIENCE: | |
| print(f"[{tag}] 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[{tag}] restored best model (val macro-F1 = {best_val_f1:.3f})") | |
| te_preds, te_mean_steps = predict(model, Xte) | |
| print(f"[{tag}] test mean thinking steps: {te_mean_steps:.1f}") | |
| metrics = report_metrics(yte, te_preds, taxonomy) | |
| metrics["mean_test_steps"] = te_mean_steps | |
| metrics["n_params"] = n_params | |
| suffix = f"_{tag.lower()}" if tag != "CTM-ACT" else "" | |
| ckpt_name = f"model_ctm{suffix}{'_coarse' if coarse else ''}.pt" | |
| torch.save( | |
| { | |
| "state_dict": model.state_dict(), | |
| "num_classes": num_classes, | |
| "in_features": in_features, | |
| "taxonomy": taxonomy, | |
| "coarse": coarse, | |
| "feature_mean": mu, | |
| "feature_std": sd, | |
| "max_iterations": max_iterations, | |
| "memory_length": memory_length, | |
| "use_act": use_act, | |
| "act_epsilon": ACT_EPSILON, | |
| }, | |
| data_dir / ckpt_name, | |
| ) | |
| print(f"\n[{tag}] saved -> {data_dir / ckpt_name}") | |
| return metrics | |
| def main() -> int: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--data-dir", type=Path, required=True) | |
| parser.add_argument("--coarse", action="store_true", | |
| help="collapse jump rotations into action-level classes (11 instead of 28)") | |
| parser.add_argument("--iterations", type=int, default=CTM_MAX_ITERATIONS, | |
| help="thinking steps: max steps for ACT, exact fixed steps otherwise") | |
| parser.add_argument("--no-act", action="store_true", | |
| help="disable adaptive halting: run exactly --iterations steps, uniform loss/prediction averaging") | |
| parser.add_argument("--memory-length", type=int, default=CTM_MEMORY_LENGTH) | |
| parser.add_argument("--ponder-lambda", type=float, default=PONDER_LAMBDA) | |
| parser.add_argument("--tag", type=str, default=None, help="log/checkpoint tag, e.g. CTM-10") | |
| args = parser.parse_args() | |
| if not (args.data_dir / "train_features.pkl").exists(): | |
| raise SystemExit(f"No processed data at {args.data_dir}. Run the pipeline first.") | |
| use_act = not args.no_act | |
| tag = args.tag or (f"CTM-{args.iterations}" if not use_act else "CTM-ACT") | |
| train( | |
| args.data_dir, coarse=args.coarse, | |
| max_iterations=args.iterations, memory_length=args.memory_length, | |
| use_act=use_act, ponder_lambda=(0.0 if not use_act else args.ponder_lambda), | |
| tag=tag, | |
| ) | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |
Xet Storage Details
- Size:
- 18.5 kB
- Xet hash:
- 2a9ecef6c1d0a2103fcc26fa2f768475a4cc901e1374b41d537622ee99582f42
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.