Download train.py from Anonymous1611/Lattice_Spike: direct link, hf CLI and curl.
- Browser
- Download file 52.7 kB
-
https://huggingface.co/Anonymous1611/Lattice_Spike/resolve/main/train.py
- Command line
-
hf download hf://Anonymous1611/Lattice_Spike/train.py
-
curl -L -o train.py https://huggingface.co/Anonymous1611/Lattice_Spike/resolve/main/train.py
52.7 kB
"""
LatticeSpike training driver.
Restores the `train_demo.py` entry point referenced by the README (see
PROJECT_IDEA.md, gap #1): it wires a data source into the end-to-end
LatticeSpike model and trains the combined unsupervised objective.
``--data-source`` selects which pipeline feeds it:
synthetic (default) : the synthetic signal generator (synthetic.py).
Calibrates the anomaly threshold and evaluates
AUPRC + lead time each epoch.
llm : real signals extracted from a HuggingFace causal
LM (llm_signals.py) over a labelled JSONL dataset.
Trains over the whole dataset each epoch and dumps
the final RSNNOutput for offline inspection.
Every internal setting of the run is exposed as a command-line flag (model
architecture, data generation/extraction, loss weights, optimization
schedule, calibration, and checkpointing), so a run can be reconfigured
without touching code.
This module is imported as part of the ``Lattice_Spike`` package (its siblings
use ``from .boxes import ...``), so the block below puts the package's parent
directory on ``sys.path`` and then imports the package by name. That makes
python Lattice_Spike/train.py # from the repo's parent dir
python train.py # from inside the package dir
python -m Lattice_Spike.train # as a module
all work the same way.
Examples
--------
# Quick CPU demo with defaults (synthetic data).
python Lattice_Spike/train.py
# Paper-scale boxes, sigmoid surrogate, robust to 15% contamination.
python Lattice_Spike/train.py --d-box 256 --n-neurons 512 \
--surrogate sigmoid --contaminate 0.15 --trim 0.2
# Resume from a checkpoint and train 5 more epochs.
python Lattice_Spike/train.py --resume runs/demo/last.pt --epochs 5
# Train on real LLM signals extracted from a labelled JSONL dataset.
python Lattice_Spike/train.py --data-source llm --limit 100 --epochs 3
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
# --- make the package importable however this file is launched --------------
_HERE = os.path.dirname(os.path.abspath(__file__))
_PARENT = os.path.dirname(_HERE)
_PKG = os.path.basename(_HERE) # the package directory name
if _PARENT not in sys.path:
sys.path.insert(0, _PARENT)
import numpy as np
import torch
_pkg = __import__(_PKG) # e.g. import Lattice_Spike
LatticeSpike = _pkg.LatticeSpike
CombinedLoss = _pkg.CombinedLoss
LossWeights = _pkg.LossWeights
auprc = _pkg.auprc
lead_time = _pkg.lead_time
make_batch = __import__(f"{_PKG}.synthetic", fromlist=["make_batch"]).make_batch
structured_token_table = __import__(
f"{_PKG}.synthetic", fromlist=["structured_token_table"]
).structured_token_table
# llm_signals is imported lazily inside train_llm() -- synthetic-only runs
# (the common case) shouldn't pay for loading the LLM-signal-extraction
# pipeline at all.
# Defaults that differ from the synthetic-mode flag defaults below, applied to
# --data-source llm runs unless the user explicitly overrides the flag.
LLM_MODE_DEFAULTS = {
"topk": 64,
"batch_size": 16,
"epochs": 5,
"lr": 1e-3,
"n_neurons": 64,
"box_encoder": "linear",
"out_dir": "output",
}
# Stable loss schema retained so anchor-off runs remain directly comparable
# with historical runs.
LOSS_KEYS = ("loss", "contain", "vol", "anchor", "recon", "mrl", "gamma")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description="Train LatticeSpike on synthetic or real LLM-signal data.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
# -- data source -----------------------------------------------------------
g = p.add_argument_group("data source")
g.add_argument("--data-source", choices=["synthetic", "llm"], default="synthetic",
help="'synthetic' uses the synthetic generator with AUPRC/lead-"
"time eval; 'llm' extracts real signals from a HuggingFace "
"LM over a labelled JSONL dataset (see 'data (LLM signals)' "
"group). Some flag defaults change under --data-source llm "
f"(see LLM_MODE_DEFAULTS: {LLM_MODE_DEFAULTS}).")
# -- model architecture --------------------------------------------------
g = p.add_argument_group("model")
g.add_argument("--d-box", type=int, default=64,
help="box latent dimension d (a box is a 2d vector).")
g.add_argument("--n-neurons", type=int, default=64,
help="number of LIF neurons in the RSNN.")
g.add_argument("--box-encoder", choices=["mlp", "linear"], default="mlp",
help="hidden-state to box encoder architecture.")
g.add_argument("--mrl-k", type=int, default=16,
help="MRL projection width k for the KL trace (channel 3).")
g.add_argument("--window", type=int, default=8,
help="causal window for spike density / anomaly smoothing.")
g.add_argument("--rho-star", type=float, default=0.15,
help="spike-density burst threshold.")
g.add_argument("--surrogate", choices=["atan", "sigmoid"], default="atan",
help="surrogate-gradient spike function.")
g.add_argument("--ablation", choices=["lstm"], default=None,
help="ablation switch: swap the RSNN dynamics layer for the "
"LSTM ablation (see docs/ablation/"
"lstm_ablation_design.md).")
g.add_argument("--lstm-use-volume", action="store_true",
help="with --ablation lstm: append log V_t to the LSTM input "
"(information-matched fair comparison with the RSNN).")
g.add_argument("--lstm-hidden", type=int, default=None,
help="with --ablation lstm: hidden-size override (default: "
"parameter-matched to the RSNN within 5%%).")
# -- synthetic data ------------------------------------------------------
g = p.add_argument_group("data (synthetic generator)")
g.add_argument("--seq-len", type=int, default=24, help="tokens per sequence T.")
g.add_argument("--d-model", type=int, default=64,
help="hidden-state width fed to the box encoder.")
g.add_argument("--num-layers", type=int, default=8,
help="number of LLM layers L in the synthetic KL trace.")
g.add_argument("--vocab", type=int, default=200, help="synthetic vocab size.")
g.add_argument("--n-in", type=int, default=100,
help="number of 'in-box' token ids (< n-in are grounded).")
g.add_argument("--topk", type=int, default=8,
help="top-K next-token candidates per step (channel 6).")
g.add_argument("--batch-size", type=int, default=32, help="sequences per step.")
g.add_argument("--train-hallu-prob", type=float, default=0.0,
help="fraction of TRAIN sequences that are overtly hallucinated "
"(unsupervised training is normally on grounded data).")
g.add_argument("--eval-hallu-prob", type=float, default=0.5,
help="fraction of EVAL sequences that are hallucinated.")
g.add_argument("--contaminate", type=float, default=0.0,
help="fraction of 'grounded' TRAIN sequences that are secretly "
"hallucinated (tests the trimmed reconstruction loss).")
g.add_argument("--onset-lo", type=float, default=0.3,
help="earliest drift onset as a fraction of T.")
g.add_argument("--onset-hi", type=float, default=0.6,
help="latest drift onset as a fraction of T.")
g.add_argument("--lead-gap-lo", type=int, default=3,
help="min tokens from drift onset to irreversibility t*.")
g.add_argument("--lead-gap-hi", type=int, default=7,
help="max tokens from drift onset to irreversibility t*.")
g.add_argument("--structured-tokens", dest="structured_tokens",
action="store_true", default=True,
help="load the structured box-space token table so the "
"concretization channel is meaningful (recommended).")
g.add_argument("--no-structured-tokens", dest="structured_tokens",
action="store_false",
help="keep the model's random frozen token table.")
# -- LLM signals (only used with --data-source llm) -----------------------
g = p.add_argument_group("data (LLM signals; --data-source llm)")
g.add_argument("--llm-data", type=str, default="data/auto_labelled_llama8b.jsonl",
help="path to the labelled JSONL file (relative to the "
"Lattice_Spike package dir, or absolute).")
g.add_argument("--llm-model", type=str, default="Qwen/Qwen2.5-0.5B-Instruct",
help="HuggingFace model ID or local path used for signal "
"extraction (requires the 'transformers' package).")
g.add_argument("--text-field", type=str, default="generated_answer",
help="JSONL field containing the text to extract signals from.")
g.add_argument("--limit", type=int, default=None,
help="max JSONL records to process (default: all).")
# -- loss weights --------------------------------------------------------
g = p.add_argument_group("loss")
g.add_argument("--lam-vol", type=float, default=0.1, help="volume-loss weight.")
g.add_argument("--lam-anchor", type=float, default=0.0,
help="anchored-contraction-loss weight; 0 disables it while "
"retaining diagnostics.")
g.add_argument("--lam-recon", type=float, default=1.0,
help="temporal-reconstruction-loss weight.")
g.add_argument("--lam-mrl", type=float, default=0.1,
help="MRL nesting-loss weight.")
g.add_argument("--lam-join", type=float, default=0.0,
help="volumetric join/intersection (meet) containment-loss "
"weight; pass 0 to disable it.")
g.add_argument("--lam-rank", type=float, default=0.0,
help="supervised hallucinated-vs-grounded margin ranking weight.")
g.add_argument("--rank-margin", type=float, default=0.1,
help="required anomaly-score gap: hallucinated >= grounded + margin.")
g.add_argument("--rank-pair-mode", choices=["sequence", "batch", "both", "none"],
default="both",
help="within-sequence, cross-sequence, or both token pair sets.")
g.add_argument("--rank-max-pairs", type=int, default=4096,
help="max deterministic pairs per sequence pair; 0 uses all pairs.")
g.add_argument("--trim", type=float, default=0.1,
help="trimmed fraction of high-residual tokens in L_recon "
"(robustness to contamination).")
# -- optimization --------------------------------------------------------
g = p.add_argument_group("optimization")
g.add_argument("--optimizer", choices=["adamw", "adam", "sgd"], default="adamw")
g.add_argument("--lr", type=float, default=2e-3, help="learning rate.")
g.add_argument("--weight-decay", type=float, default=0.0)
g.add_argument("--grad-clip", type=float, default=1.0,
help="max global grad norm (0 disables clipping).")
g.add_argument("--epochs", type=int, default=10)
g.add_argument("--steps-per-epoch", type=int, default=20,
help="optimizer steps (fresh synthetic batch) per epoch.")
# -- calibration / eval --------------------------------------------------
g = p.add_argument_group("calibration & eval")
g.add_argument("--calib-z", type=float, default=4.0,
help="anomaly threshold = mean + z*std on grounded data.")
g.add_argument("--eval-batch", type=int, default=256,
help="sequences in the held-out eval batch.")
# -- checkpointing / runtime --------------------------------------------
g = p.add_argument_group("checkpoint & runtime")
g.add_argument("--out-dir", type=str, default="runs/demo",
help="directory for checkpoints and config.json.")
g.add_argument("--save-every", type=int, default=0,
help="also snapshot every N epochs (0 = only last/best).")
g.add_argument("--no-save", action="store_true",
help="run without writing any checkpoints (dry run).")
g.add_argument("--resume", type=str, default="",
help="checkpoint path to resume model/optimizer state from.")
g.add_argument("--seed", type=int, default=0)
g.add_argument("--device", type=str, default="cpu",
help="cpu or cuda (or cuda:N).")
g.add_argument("--log-every", type=int, default=1,
help="print a training-loss line every N steps.")
g.add_argument("--viz-samples", type=int, default=6,
help="eval-batch examples to snapshot as decision-tree records "
"per tracked epoch in history.jsonl (0 disables sample "
"snapshots; scalar metrics are always logged).")
g.add_argument("--viz-every", type=int, default=1,
help="snapshot samples every N epochs.")
return p
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def set_seed(seed: int) -> None:
torch.manual_seed(seed)
np.random.seed(seed)
def cli_overrides(parser: argparse.ArgumentParser, argv) -> set[str]:
"""Return argparse dest names explicitly set on the command line."""
argv = sys.argv[1:] if argv is None else list(argv)
option_to_dest = {
opt: action.dest
for action in parser._actions
for opt in action.option_strings
}
seen: set[str] = set()
for token in argv:
if token == "--":
break
if not token.startswith("-"):
continue
option = token.split("=", 1)[0]
dest = option_to_dest.get(option)
if dest:
seen.add(dest)
return seen
def apply_defaults(args, values: dict, overrides: set[str], skip: set[str] = frozenset()) -> None:
"""Set args.<name> = value for each (name, value) not in `skip`/`overrides`."""
for name, value in values.items():
if name in skip or name in overrides or not hasattr(args, name):
continue
setattr(args, name, value)
def apply_checkpoint_args(args, ckpt: dict, overrides: set[str]) -> None:
"""Restore saved run settings on resume unless the user overrode them.
Checkpoints store the original CLI args. Reapplying them before model
construction lets `--resume path --epochs N` work for non-default model
shapes, while still allowing intentional command-line overrides.
"""
saved = ckpt.get("args") or {}
apply_defaults(args, saved, overrides, skip={"resume", "epochs", "no_save"})
def _validate_synthetic_args(args) -> list[str]:
"""Checks specific to the synthetic generator (data_source == 'synthetic')."""
errs = []
if not (0 < args.n_in < args.vocab):
errs.append(f"--n-in must satisfy 0 < n_in < vocab "
f"(got n_in={args.n_in}, vocab={args.vocab}); the generator "
f"needs both in-box (<n_in) and out-of-box (>=n_in) ids.")
if args.topk > args.vocab:
errs.append(f"--topk ({args.topk}) cannot exceed --vocab ({args.vocab}).")
if not (0.0 <= args.onset_lo < args.onset_hi <= 1.0):
errs.append(f"--onset-lo/--onset-hi must satisfy "
f"0 <= onset_lo < onset_hi <= 1 "
f"(got {args.onset_lo}, {args.onset_hi}).")
elif int(args.onset_lo * args.seq_len) >= int(args.onset_hi * args.seq_len):
errs.append(f"--seq-len ({args.seq_len}) is too short for the onset range "
f"[{args.onset_lo}, {args.onset_hi}); the integer onset "
f"bounds collapse to an empty range.")
if args.lead_gap_lo < 1 or args.lead_gap_lo > args.lead_gap_hi:
errs.append(f"--lead-gap-lo/--lead-gap-hi must satisfy "
f"1 <= lead_gap_lo <= lead_gap_hi "
f"(got {args.lead_gap_lo}, {args.lead_gap_hi}).")
for name in ("train_hallu_prob", "eval_hallu_prob", "contaminate"):
v = getattr(args, name)
if not (0.0 <= v <= 1.0):
errs.append(f"--{name.replace('_', '-')} must be in [0, 1] (got {v}).")
for name in ("seq_len", "d_model", "num_layers", "eval_batch", "steps_per_epoch"):
v = getattr(args, name)
if v < 1:
errs.append(f"--{name.replace('_', '-')} must be >= 1 (got {v}).")
if args.viz_samples < 0:
errs.append(f"--viz-samples must be >= 0 (got {args.viz_samples}).")
if args.viz_every < 1:
errs.append(f"--viz-every must be >= 1 (got {args.viz_every}).")
return errs
def _validate_llm_args(args) -> list[str]:
"""Checks specific to the LLM-signal pipeline (data_source == 'llm')."""
errs = []
if args.resume:
errs.append("--resume is not supported with --data-source llm.")
if args.limit is not None and args.limit < 1:
errs.append(f"--limit must be >= 1 (got {args.limit}).")
return errs
def _validate_shared_args(args) -> list[str]:
"""Checks that apply to both data sources (model/loss/optimizer flags)."""
errs = []
if args.topk < 1:
errs.append(f"--topk must be >= 1 (got {args.topk}).")
if not (0.0 <= args.trim < 1.0):
errs.append(f"--trim must be in [0, 1) (got {args.trim}).")
for name in ("d_box", "n_neurons", "mrl_k", "window", "batch_size", "epochs"):
v = getattr(args, name)
if v < 1:
errs.append(f"--{name.replace('_', '-')} must be >= 1 (got {v}).")
if args.lr <= 0:
errs.append(f"--lr must be > 0 (got {args.lr}).")
if args.weight_decay < 0:
errs.append(f"--weight-decay must be >= 0 (got {args.weight_decay}).")
if args.grad_clip < 0:
errs.append(f"--grad-clip must be >= 0 (got {args.grad_clip}).")
if not (0.0 <= args.rho_star <= 1.0):
errs.append(f"--rho-star must be in [0, 1] (got {args.rho_star}).")
if args.calib_z < 0:
errs.append(f"--calib-z must be >= 0 (got {args.calib_z}).")
for name in ("lam_vol", "lam_anchor", "lam_recon", "lam_mrl", "lam_join", "lam_rank"):
v = getattr(args, name)
if v < 0:
errs.append(f"--{name.replace('_', '-')} must be >= 0 (got {v}).")
if args.rank_margin < 0:
errs.append(f"--rank-margin must be >= 0 (got {args.rank_margin}).")
if args.rank_max_pairs < 0:
errs.append(f"--rank-max-pairs must be >= 0 (got {args.rank_max_pairs}).")
if args.lam_rank > 0 and args.rank_pair_mode == "none":
errs.append("--rank-pair-mode cannot be none when --lam-rank is positive.")
if args.save_every < 0:
errs.append(f"--save-every must be >= 0 (got {args.save_every}).")
if args.log_every < 0:
errs.append(f"--log-every must be >= 0 (got {args.log_every}).")
return errs
def validate_args(args) -> list[str]:
"""Return a list of human-readable problems with the flag combination.
Caught early so the user gets a clear message instead of a deep stack trace
from inside the data generator (e.g. n_in == vocab leaves no out-of-box ids
and crashes torch.randint(n_in, vocab)).
"""
mode_errs = (_validate_synthetic_args(args) if args.data_source == "synthetic"
else _validate_llm_args(args))
return mode_errs + _validate_shared_args(args)
def resolve_device(args) -> str:
"""Apply the CUDA fallback once and return the device string."""
device = args.device
if device.startswith("cuda") and not torch.cuda.is_available():
print(f"[warn] {device} requested but CUDA is unavailable; using cpu.")
device = args.device = "cpu"
return device
def build_model(args) -> LatticeSpike:
"""Build the LatticeSpike model from args.d_model/num_layers/vocab/etc.
The LLM pipeline (train_llm) doesn't know its real d_model/num_layers/
vocab_size until the HuggingFace model is loaded, so it sets those
args fields (and args.structured_tokens = False) before calling this,
the same way resolve_device() sets args.device in place.
"""
model = LatticeSpike(
d_model=args.d_model,
vocab_size=args.vocab,
d_box=args.d_box,
num_layers=args.num_layers,
mrl_k=args.mrl_k,
n_neurons=args.n_neurons,
window=args.window,
rho_star=args.rho_star,
surrogate=args.surrogate,
box_encoder=args.box_encoder,
ablation=args.ablation,
lstm_use_volume=args.lstm_use_volume,
lstm_hidden=args.lstm_hidden,
)
if args.structured_tokens:
# Replace the random frozen token table with the structured one so that
# low ids are genuinely "in-box" and high ids "out-of-box" -- this is
# what makes the concretization residual (channel 6) informative on the
# synthetic data. Stays frozen (requires_grad already False).
table = structured_token_table(args.vocab, args.d_box, args.n_in)
with torch.no_grad():
model.box_token_emb.weight.copy_(table)
return model
def build_optimizer(args, model):
params = [p for p in model.parameters() if p.requires_grad]
if args.optimizer == "adamw":
return torch.optim.AdamW(params, lr=args.lr, weight_decay=args.weight_decay)
if args.optimizer == "adam":
return torch.optim.Adam(params, lr=args.lr, weight_decay=args.weight_decay)
return torch.optim.SGD(params, lr=args.lr, momentum=0.9,
weight_decay=args.weight_decay)
def make_train_batch(args, seed: int):
"""A training batch: grounded (with optional overt/secret hallucinations)."""
return make_batch(
batch=args.batch_size, T=args.seq_len, d_model=args.d_model,
num_layers=args.num_layers, vocab=args.vocab, n_in=args.n_in,
topk=args.topk, hallu_prob=args.train_hallu_prob,
onset_lo=args.onset_lo, onset_hi=args.onset_hi,
lead_gap_lo=args.lead_gap_lo, lead_gap_hi=args.lead_gap_hi,
contaminate=args.contaminate, seed=seed, device=args.device,
)
def make_eval_batch(args, seed: int):
"""A held-out mixed batch with labels, for AUPRC + lead time."""
return make_batch(
batch=args.eval_batch, T=args.seq_len, d_model=args.d_model,
num_layers=args.num_layers, vocab=args.vocab, n_in=args.n_in,
topk=args.topk, hallu_prob=args.eval_hallu_prob,
onset_lo=args.onset_lo, onset_hi=args.onset_hi,
lead_gap_lo=args.lead_gap_lo, lead_gap_hi=args.lead_gap_hi,
contaminate=0.0, seed=seed, device=args.device,
)
@torch.no_grad()
def evaluate(args, model) -> tuple[dict, dict]:
"""Calibrate on grounded data, then score a mixed held-out batch.
Returns `(summary, detail)`: `summary` is the scalar metrics dict logged to
the console/checkpoints; `detail` exposes the per-step numpy arrays the
forward pass already computed (`anomaly`, `labels`, `burst`, `rho`, `t_star`,
`is_hallu`) so callers can build sample records without a second forward pass.
"""
model.eval()
# Calibrate the burst threshold on a purely grounded batch.
grounded, *_ = make_batch(
batch=args.eval_batch, T=args.seq_len, d_model=args.d_model,
num_layers=args.num_layers, vocab=args.vocab, n_in=args.n_in,
topk=args.topk, hallu_prob=0.0, contaminate=0.0,
seed=args.seed + 9991, device=args.device,
)
model.calibrate(grounded, z=args.calib_z)
signals, labels, t_star, is_hallu = make_eval_batch(args, seed=args.seed + 7777)
out = model(signals)
score = out.anomaly.detach().cpu().numpy()
y = labels.detach().cpu().numpy()
ap = auprc(score, y)
# Median lead time over the hallucinated sequences only.
#
# Primary metric uses `out.burst`, the *actual* detector decision
# (anomaly > anomaly_star AND rho > rho_star) -- so the reported warning
# corresponds to a token the detector would really fire on. We reuse the
# repo's `lead_time(series, t_star, threshold)` by passing the boolean burst
# as a 0/1 series thresholded at 0.5. We also report the rho-only lead time
# for reference, since that is what the bare `metrics.lead_time` measures.
burst = out.burst.float().detach().cpu().numpy()
rho = out.rho.detach().cpu().numpy()
ts = t_star.detach().cpu().numpy()
hal = is_hallu.detach().cpu().numpy().astype(bool)
rho_star = float(model.rho_star)
idx = [b for b in range(len(hal)) if hal[b]]
burst_leads = [lead_time(burst[b], int(ts[b]), 0.5) for b in idx]
rho_leads = [lead_time(rho[b], int(ts[b]), rho_star) for b in idx]
med_lead = float(np.median(burst_leads)) if burst_leads else float("nan")
med_lead_rho = float(np.median(rho_leads)) if rho_leads else float("nan")
summary = {
"auprc": float(ap),
"median_lead": med_lead, # from the calibrated burst detector
"median_lead_rho": med_lead_rho, # rho-only reference metric
"anomaly_star": float(model.anomaly_star),
"rho_star": rho_star,
"n_hallu": int(hal.sum()),
}
detail = {
"anomaly": score,
"labels": y,
"burst": burst,
"rho": rho,
"t_star": ts,
"is_hallu": hal,
}
return summary, detail
def _json_safe(x):
"""Recursively map NaN floats to None so json.dumps produces valid JSON.
metrics["auprc"]/median_lead* can be float("nan") (e.g. all-one-class eval
batches, or --eval-hallu-prob 0); plain json.dumps emits the invalid-JSON
literal `NaN` for those by default.
"""
if isinstance(x, float):
return None if x != x else x
if isinstance(x, dict):
return {k: _json_safe(v) for k, v in x.items()}
if isinstance(x, (list, tuple)):
return [_json_safe(v) for v in x]
return x
def _eval_samples_to_records(eval_detail: dict, n: int) -> list[dict]:
"""Map the first `n` eval-batch examples to scripts/visualize.py's record schema."""
anomaly = eval_detail["anomaly"]
labels = eval_detail["labels"]
burst = eval_detail["burst"]
rho = eval_detail["rho"]
t_star = eval_detail["t_star"]
is_hallu = eval_detail["is_hallu"]
B, T = anomaly.shape
n = min(n, B)
records = []
for b in range(n):
hallu = bool(is_hallu[b])
tstar_b = int(t_star[b])
question = (
f"synthetic eval example {b} "
f"({'hallucinated' if hallu else 'grounded'}"
f"{', t*=' + str(tstar_b) if hallu else ''})"
)
records.append({
"id": f"ex{b}",
"question": question,
"gen_tokens": [f"t{t}" for t in range(T)],
# The model's predicted per-step decision (not ground truth).
"token_labels": ["H" if burst[b, t] > 0.5 else "G" for t in range(T)],
"token_label_reasons": [
f"anomaly={anomaly[b, t]:.3f} rho={rho[b, t]:.3f}" for t in range(T)
],
# Ground-truth t*; synthetic.py uses T (out of range) as the "no t*"
# sentinel for grounded sequences, so this must be None, not T, or
# render_token_tree's tokens[t_star] indexing goes out of bounds.
"t_star_index": tstar_b if hallu else None,
})
return records
def _llm_epoch_eval(model, all_signals: list[dict], collate_fn, device: str,
batch_size: int, calib_z: float) -> tuple[dict, dict]:
"""Score every extracted LLM sample once against the auto-labelled ground
truth (token_labels / t_star -- real supervision, unlike the synthetic
pipeline's freshly generated held-out batch) and return (summary, detail),
mirroring evaluate()'s split.
anomaly_star has no meaning until calibrated (detector.py defaults it to
+inf). We calibrate on the mean/std of anomaly over every real
(non-padded) token across the *whole* dataset rather than a small tracked
subset -- a handful of samples lets a couple of outlier tokens blow up the
threshold so burst never fires again.
"""
was_training = model.training
model.eval()
with torch.no_grad():
flat_chunks = []
for start in range(0, len(all_signals), batch_size):
batch = all_signals[start:start + batch_size]
signals = collate_fn(batch, device)
anomaly = model(signals).anomaly.detach().cpu()
for i, s in enumerate(batch):
flat_chunks.append(anomaly[i, :s["meta"]["T"]])
flat = torch.cat(flat_chunks) if flat_chunks else torch.zeros(0)
model.anomaly_star = (flat.mean() + calib_z * flat.std()).detach().to(device)
scores, labels = [], []
kappa_grounded, kappa_hallucinated = [], []
burst_leads, rho_leads = [], []
per_sample: dict = {}
for start in range(0, len(all_signals), batch_size):
batch = all_signals[start:start + batch_size]
signals = collate_fn(batch, device)
out = model(signals)
anomaly = out.anomaly.detach().cpu().numpy()
rho = out.rho.detach().cpu().numpy()
kappa = out.channels.kappa.detach().cpu().numpy()
burst = out.burst.detach().cpu().numpy()
for i, s in enumerate(batch):
T = s["meta"]["T"]
true = s.get("token_labels") or []
# AUPRC over G/H tokens only; "U" (uncertain) is neither a
# clean positive nor negative, so it's excluded rather than
# silently counted as grounded.
for t in range(T):
lbl = true[t] if t < len(true) else None
if lbl == "U":
continue
scores.append(float(anomaly[i, t]))
labels.append(1 if lbl == "H" else 0)
if lbl == "H":
kappa_hallucinated.append(float(kappa[i, t]))
else:
kappa_grounded.append(float(kappa[i, t]))
t_star = s.get("t_star")
if t_star is not None and 0 <= t_star < T:
burst_leads.append(lead_time(burst[i, :T].astype(float), int(t_star), 0.5))
rho_leads.append(lead_time(rho[i, :T], int(t_star), float(model.rho_star)))
per_sample[s["record_id"]] = {
"anomaly": anomaly[i, :T], "rho": rho[i, :T], "burst": burst[i, :T],
}
if was_training:
model.train()
summary = {
"auprc": float(auprc(scores, labels)) if labels else float("nan"),
"median_lead": float(np.median(burst_leads)) if burst_leads else float("nan"),
"median_lead_rho": float(np.median(rho_leads)) if rho_leads else float("nan"),
"anomaly_star": float(model.anomaly_star),
"rho_star": float(model.rho_star),
"n_hallu_tokens": int(sum(labels)),
"mean_kappa_grounded": float(np.mean(kappa_grounded)) if kappa_grounded else float("nan"),
"mean_kappa_hallucinated": float(np.mean(kappa_hallucinated)) if kappa_hallucinated else float("nan"),
}
return summary, per_sample
def _llm_samples_to_records(viz_signals: list[dict], records_by_id: dict,
per_sample: dict) -> list[dict]:
"""Map a fixed LLM-sample subset's already-scored per-token predictions
(from _llm_epoch_eval's `detail`) into scripts/visualize.py's record
schema (see llm_signals.extract_all_signals for where record_id/t_star/
token_labels -- the auto-labelled ground truth -- come from)."""
records = []
for sample in viz_signals:
rid = sample["record_id"]
scored = per_sample.get(rid)
if scored is None:
continue
T = sample["meta"]["T"]
orig = records_by_id.get(rid, {})
true_labels = sample.get("token_labels") or []
true_t_star = sample.get("t_star")
anomaly, rho, burst = scored["anomaly"], scored["rho"], scored["burst"]
records.append({
"id": str(rid),
"question": orig.get("question", f"llm sample {rid}"),
"gen_tokens": [f"t{t}" for t in range(T)],
# The model's predicted per-step decision (not ground truth).
"token_labels": ["H" if burst[t] else "G" for t in range(T)],
"token_label_reasons": [
f"anomaly={anomaly[t]:.3f} rho={rho[t]:.3f} "
f"true={true_labels[t] if t < len(true_labels) else '?'}"
for t in range(T)
],
# Ground-truth t*; only meaningful if it indexes within this
# sample's actual token count (retokenized by --llm-model, which
# may not match the length of the original auto-labelled record).
"t_star_index": (true_t_star if true_t_star is not None and 0 <= true_t_star < T
else None),
})
return records
def append_history_line(out_dir: str, record: dict) -> None:
"""Append one JSON line to {out_dir}/history.jsonl (creates it on first write)."""
path = os.path.join(out_dir, "history.jsonl")
with open(path, "a") as f:
f.write(json.dumps(_json_safe(record)) + "\n")
def save_checkpoint(path: str, model, optimizer, args, epoch: int, metrics: dict):
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
torch.save({
"model": model.state_dict(),
"optimizer": optimizer.state_dict(),
"args": vars(args),
"epoch": epoch,
"metrics": metrics,
}, path)
# ---------------------------------------------------------------------------
# training: synthetic generator
# ---------------------------------------------------------------------------
def train_synthetic(args, resume_ckpt: dict | None = None) -> dict:
"""Run training on the synthetic generator with already validated args."""
set_seed(args.seed)
device = resolve_device(args)
model = build_model(args).to(device)
optimizer = build_optimizer(args, model)
weights = LossWeights(vol=args.lam_vol, anchor=args.lam_anchor,
recon=args.lam_recon, mrl=args.lam_mrl,
join=args.lam_join, rank=args.lam_rank)
loss_fn = CombinedLoss(
model,
weights=weights,
trim=args.trim,
rank_margin=args.rank_margin,
rank_pair_mode=args.rank_pair_mode,
rank_max_pairs=None if args.rank_max_pairs == 0 else args.rank_max_pairs,
)
start_epoch = 0
best_ap = -1.0
if args.resume:
# weights_only=False: these are our own trusted local checkpoints and
# carry non-tensor metadata (args/metrics dicts). Explicit so the load is
# stable across torch versions that toggle the default.
if resume_ckpt is None:
resume_ckpt = torch.load(args.resume, map_location=device,
weights_only=False)
ckpt = resume_ckpt
model.load_state_dict(ckpt["model"])
if "optimizer" in ckpt:
optimizer.load_state_dict(ckpt["optimizer"])
start_epoch = int(ckpt.get("epoch", 0))
print(f"[resume] loaded {args.resume} (epoch {start_epoch}).")
# Carry the previous best AUPRC forward so resuming doesn't overwrite a
# better best.pt with a worse post-resume eval. Prefer the best.pt that
# sits in the output dir; fall back to the resumed checkpoint's metric.
best_path = os.path.join(args.out_dir, "best.pt")
prev_best = None
if os.path.exists(best_path):
prev_best = torch.load(best_path, map_location="cpu",
weights_only=False).get("metrics", {}).get("auprc")
if prev_best is None:
prev_best = ckpt.get("metrics", {}).get("auprc")
if prev_best is not None and prev_best == prev_best: # not NaN
best_ap = float(prev_best)
print(f"[resume] carrying best AUPRC={best_ap:.4f} forward.")
n_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"[model] LatticeSpike d_box={args.d_box} n_neurons={args.n_neurons} "
f"mrl_k={args.mrl_k} surrogate={args.surrogate} | "
f"{n_params:,} trainable params | device={device}")
print(f"[data ] T={args.seq_len} d_model={args.d_model} L={args.num_layers} "
f"vocab={args.vocab} batch={args.batch_size} "
f"train_hallu={args.train_hallu_prob} contaminate={args.contaminate}")
if not args.no_save:
os.makedirs(args.out_dir, exist_ok=True)
with open(os.path.join(args.out_dir, "config.json"), "w") as f:
json.dump(vars(args), f, indent=2, sort_keys=True)
last_metrics: dict = {}
global_step = start_epoch * args.steps_per_epoch
t0 = time.time()
for epoch in range(start_epoch, start_epoch + args.epochs):
model.train()
running = {k: 0.0 for k in LOSS_KEYS}
for step in range(args.steps_per_epoch):
global_step += 1
signals, labels, *_ = make_train_batch(args, seed=args.seed + global_step)
out = model(signals)
loss, logs = loss_fn(out, signals["delta"], labels=labels)
optimizer.zero_grad(set_to_none=True)
loss.backward()
if args.grad_clip > 0:
torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
optimizer.step()
for k in LOSS_KEYS:
running[k] += logs[k]
if args.log_every and (step + 1) % args.log_every == 0:
print(f" e{epoch:02d} s{step:03d} loss={logs['loss']:.4f} "
f"(contain={logs['contain']:.3f} vol={logs['vol']:.3f} "
f"anchor={logs['anchor']:.3f} recon={logs['recon']:.3f} "
f"mrl={logs['mrl']:.3f} join={logs['join']:.3f} "
f"rank={logs['rank']:.3f} "
f"gamma={logs['gamma']:.3f})")
avg_losses = {k: running[k] / max(1, args.steps_per_epoch) for k in LOSS_KEYS}
metrics, eval_detail = evaluate(args, model)
last_metrics = metrics
elapsed = time.time() - t0
print(f"[epoch {epoch:02d}] train_loss={avg_losses['loss']:.4f} "
f"AUPRC={metrics['auprc']:.4f} "
f"lead(burst)={metrics['median_lead']:.2f} "
f"lead(rho)={metrics['median_lead_rho']:.2f} "
f"anomaly*={metrics['anomaly_star']:.4f} "
f"({elapsed:.1f}s)")
# Track best AUPRC regardless of whether we persist checkpoints
# (NaN-safe: AUPRC is NaN only when a batch has no positives).
is_best = metrics["auprc"] == metrics["auprc"] and metrics["auprc"] > best_ap
if is_best:
best_ap = metrics["auprc"]
if not args.no_save:
epoch_num = epoch + 1
history_record = {
"epoch": epoch_num,
"elapsed_sec": elapsed,
"train_loss": avg_losses,
"eval": metrics,
}
if args.viz_samples > 0 and epoch_num % args.viz_every == 0:
history_record["samples"] = _eval_samples_to_records(
eval_detail, args.viz_samples)
append_history_line(args.out_dir, history_record)
save_checkpoint(os.path.join(args.out_dir, "last.pt"),
model, optimizer, args, epoch_num, metrics)
if is_best:
save_checkpoint(os.path.join(args.out_dir, "best.pt"),
model, optimizer, args, epoch_num, metrics)
if args.save_every and epoch_num % args.save_every == 0:
save_checkpoint(
os.path.join(args.out_dir, f"epoch{epoch_num:03d}.pt"),
model, optimizer, args, epoch_num, metrics)
print(f"[done] best AUPRC={best_ap:.4f} "
f"checkpoints in {args.out_dir!r}" if not args.no_save
else f"[done] best AUPRC={best_ap:.4f} (no checkpoints written)")
return last_metrics
# ---------------------------------------------------------------------------
# training: real LLM signals
# ---------------------------------------------------------------------------
def _pad_to(tensor_list: list[torch.Tensor], T_max: int) -> torch.Tensor:
"""Pad each (B, T, ...) tensor to T_max along dim 1 and cat on dim 0."""
padded = []
for t in tensor_list:
pad_len = T_max - t.shape[1]
if pad_len > 0:
pad_shape = list(t.shape)
pad_shape[1] = pad_len
t = torch.cat([t, torch.zeros(pad_shape, dtype=t.dtype)], dim=1)
padded.append(t)
return torch.cat(padded, dim=0)
def train_llm(args) -> dict:
"""Extract real LLM signals from a JSONL dataset and train LatticeSpike.
Unlike the synthetic pipeline (fresh generated batch per step, calibrated
against a separately generated held-out batch), this trains over shuffled
minibatches of a fixed, pre-extracted dataset and evaluates AUPRC/lead-
time each epoch against the dataset's own auto-labelled token_labels/
t_star (see _llm_epoch_eval), calibrating anomaly_star over the whole set
rather than a single batch. Per-epoch losses, eval metrics, and (on
tracked epochs) decision-tree sample snapshots (_llm_samples_to_records)
are appended to {out-dir}/history.jsonl for scripts/visualize_train.py,
then the final RSNNOutput is dumped for offline inspection instead of
maintaining last.pt/best.pt checkpoints.
"""
if args.lam_rank > 0.0:
raise ValueError(
"supervised ranking is not available in train.py --data-source llm "
"because this legacy path cannot prove token-label alignment; use "
"scripts/train_latticespike_llm_eval.py instead"
)
set_seed(args.seed)
device = resolve_device(args)
print(f"[config] data-source=llm device={device} epochs={args.epochs} "
f"batch_size={args.batch_size}")
# requires `transformers`; imported lazily so the synthetic pipeline (and
# the rest of the package) runs without it installed.
from transformers import AutoModelForCausalLM, AutoTokenizer
llm_signals = __import__(
f"{_PKG}.llm_signals",
fromlist=["load_jsonl", "extract_all_signals", "collate_batch"],
)
load_jsonl = llm_signals.load_jsonl
extract_all_signals = llm_signals.extract_all_signals
collate_batch = llm_signals.collate_batch
def resolve_path(p: str) -> str:
return p if os.path.isabs(p) else os.path.join(_HERE, p)
data_path = resolve_path(args.llm_data)
out_dir = resolve_path(args.out_dir)
os.makedirs(out_dir, exist_ok=True)
print(f"[load ] LLM: {args.llm_model}")
tokenizer = AutoTokenizer.from_pretrained(args.llm_model)
llm = AutoModelForCausalLM.from_pretrained(
args.llm_model, dtype=torch.float32, attn_implementation="eager"
)
llm = llm.to(device).eval()
llm_cfg = llm.config
d_model: int = llm_cfg.hidden_size
num_layers: int = llm_cfg.num_hidden_layers
vocab_size: int = llm_cfg.vocab_size
print(f"[data ] loading {data_path}")
records = load_jsonl(data_path)
records_by_id = {rec.get("id", i): rec for i, rec in enumerate(records)}
print(f"[data ] {len(records)} records")
print("[extract] running LLM forward passes (this may take a few minutes)...")
t0 = time.time()
all_signals = extract_all_signals(
records, llm, tokenizer, device,
topk=args.topk, text_field=args.text_field, limit=args.limit,
)
print(f"[extract] {len(all_signals)} samples in {time.time() - t0:.1f}s")
viz_signals = all_signals[:args.viz_samples] if args.viz_samples > 0 else []
# Free LLM (GPU) memory before the training model is allocated.
del llm
if device.startswith("cuda"):
torch.cuda.empty_cache()
print(f"[model ] d_model={d_model} num_layers={num_layers} vocab_size={vocab_size}")
print(f"[model ] d_box={args.d_box} n_neurons={args.n_neurons} mrl_k={args.mrl_k}")
# Real dims come from the loaded LM, not the synthetic-generator flags.
args.d_model, args.num_layers, args.vocab = d_model, num_layers, vocab_size
args.structured_tokens = False
model = build_model(args).to(device)
n_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"[model ] {n_params:,} trainable params")
optimizer = build_optimizer(args, model)
weights = LossWeights(vol=args.lam_vol, anchor=args.lam_anchor,
recon=args.lam_recon, mrl=args.lam_mrl,
join=args.lam_join, rank=args.lam_rank)
loss_fn = CombinedLoss(
model,
weights=weights,
trim=args.trim,
rank_margin=args.rank_margin,
rank_pair_mode=args.rank_pair_mode,
rank_max_pairs=None if args.rank_max_pairs == 0 else args.rank_max_pairs,
)
n = len(all_signals)
print(f"[train ] {args.epochs} epochs | {n} samples | batch_size={args.batch_size}")
t0 = time.time()
avg_losses = {k: float("nan") for k in LOSS_KEYS}
for epoch in range(args.epochs):
model.train()
idx = torch.randperm(n).tolist()
running = {k: 0.0 for k in LOSS_KEYS}
steps = 0
for start in range(0, n, args.batch_size):
batch = [all_signals[i] for i in idx[start:start + args.batch_size]]
signals = collate_batch(batch, device)
out = model(signals)
loss, logs = loss_fn(out, signals["delta"])
optimizer.zero_grad(set_to_none=True)
loss.backward()
if args.grad_clip > 0:
torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
optimizer.step()
for k in LOSS_KEYS:
running[k] += logs[k]
steps += 1
avg_losses = {k: running[k] / max(1, steps) for k in LOSS_KEYS}
elapsed = time.time() - t0
eval_summary, per_sample = _llm_epoch_eval(
model, all_signals, collate_batch, device, args.batch_size, args.calib_z)
print(f" epoch {epoch:02d} avg_loss={avg_losses['loss']:.4f} "
f"AUPRC={eval_summary['auprc']:.4f} "
f"lead(burst)={eval_summary['median_lead']:.2f} "
f"lead(rho)={eval_summary['median_lead_rho']:.2f} "
f"({elapsed:.1f}s elapsed)")
epoch_num = epoch + 1
history_record = {
"epoch": epoch_num,
"elapsed_sec": elapsed,
"train_loss": avg_losses,
"eval": eval_summary,
}
if viz_signals and epoch_num % args.viz_every == 0:
history_record["samples"] = _llm_samples_to_records(viz_signals, records_by_id, per_sample)
append_history_line(out_dir, history_record)
# Final eval forward pass -- collect RSNNOutput over all samples.
print("[eval ] running final forward pass on all samples...")
model.eval()
all_spikes, all_u, all_u_pred, all_gates = [], [], [], []
all_rho, all_anomaly, all_burst = [], [], []
all_c_min, all_c_max = [], []
all_kappa, all_r = [], []
seq_lengths: list[int] = []
with torch.no_grad():
for start in range(0, n, args.batch_size):
batch = all_signals[start:start + args.batch_size]
signals = collate_batch(batch, device)
out = model(signals)
r = out.rsnn
all_spikes.append(r.spikes.cpu())
all_u.append(r.u.cpu())
all_u_pred.append(r.u_pred.cpu())
all_gates.append(r.gates.cpu())
all_rho.append(out.rho.cpu())
all_anomaly.append(out.anomaly.cpu())
all_burst.append(out.burst.cpu())
all_c_min.append(out.box.c_min.cpu())
all_c_max.append(out.box.c_max.cpu())
all_kappa.append(out.channels.kappa.cpu())
all_r.append(out.channels.leakage.cpu())
seq_lengths.extend([s["meta"]["T"] for s in batch])
# T may vary across mini-batches after padding, so pad to a global T_max.
T_max = max(s["meta"]["T"] for s in all_signals)
spikes_all = _pad_to(all_spikes, T_max)
u_all = _pad_to(all_u, T_max)
u_pred_all = _pad_to(all_u_pred, T_max)
gates_all = _pad_to(all_gates, T_max)
rho_all = _pad_to(all_rho, T_max)
anomaly_all = _pad_to(all_anomaly, T_max)
burst_all = _pad_to(all_burst, T_max)
c_min_all = _pad_to(all_c_min, T_max)
c_max_all = _pad_to(all_c_max, T_max)
kappa_all = _pad_to(all_kappa, T_max)
r_all = _pad_to(all_r, T_max)
out_path = os.path.join(out_dir, "rsnn_output.pt")
torch.save(
{
"spikes": spikes_all, "u": u_all, "u_pred": u_pred_all, "gates": gates_all,
"rho": rho_all, "anomaly": anomaly_all, "burst": burst_all,
"c_min": c_min_all, "c_max": c_max_all,
"kappa": kappa_all, "r": r_all,
"seq_lengths": seq_lengths,
"record_ids": [s["record_id"] for s in all_signals],
"t_star": [s["t_star"] for s in all_signals],
"token_labels": [s["token_labels"] for s in all_signals],
"config": {
"d_model": d_model, "vocab_size": vocab_size, "num_layers": num_layers,
"d_box": args.d_box, "n_neurons": args.n_neurons, "mrl_k": args.mrl_k,
"topk": args.topk, "epochs": args.epochs,
"box_encoder": args.box_encoder,
"lam_rank": args.lam_rank,
"rank_margin": args.rank_margin,
"rank_pair_mode": args.rank_pair_mode,
"rank_max_pairs": args.rank_max_pairs,
"ablation": args.ablation, "lstm_use_volume": args.lstm_use_volume,
"lstm_hidden": args.lstm_hidden,
},
},
out_path,
)
ckpt_path = os.path.join(out_dir, "rsnn_checkpoint.pt")
save_checkpoint(ckpt_path, model, optimizer, args, args.epochs, {"avg_loss": avg_losses["loss"]})
print(f"[save ] RSNNOutput -> {out_path}")
print(f" spikes : {tuple(spikes_all.shape)}")
print(f" u : {tuple(u_all.shape)}")
print(f" u_pred : {tuple(u_pred_all.shape)}")
print(f" gates : {tuple(gates_all.shape)}")
print(f" c_min : {tuple(c_min_all.shape)}")
print(f" c_max : {tuple(c_max_all.shape)}")
print(f" kappa : {tuple(kappa_all.shape)}")
print(f" r : {tuple(r_all.shape)}")
print(f"[save ] checkpoint -> {ckpt_path}")
print("[done ]")
return {"avg_loss": avg_losses["loss"]}
def main(argv=None) -> None:
parser = build_parser()
args = parser.parse_args(argv)
overrides = cli_overrides(parser, argv)
if args.data_source == "llm":
apply_defaults(args, LLM_MODE_DEFAULTS, overrides)
errs = validate_args(args)
if errs:
parser.error("invalid configuration:\n - " + "\n - ".join(errs))
train_llm(args)
return
resume_ckpt = None
if args.resume:
try:
resume_ckpt = torch.load(args.resume, map_location="cpu",
weights_only=False)
except Exception as exc:
parser.error(f"could not load --resume checkpoint {args.resume!r}: {exc}")
apply_checkpoint_args(args, resume_ckpt, overrides)
errs = validate_args(args)
if errs:
parser.error("invalid configuration:\n - " + "\n - ".join(errs))
train_synthetic(args, resume_ckpt=resume_ckpt)
if __name__ == "__main__":
main()
"""
LatticeSpike training driver.
Restores the `train_demo.py` entry point referenced by the README (see
PROJECT_IDEA.md, gap #1): it wires a data source into the end-to-end
LatticeSpike model and trains the combined unsupervised objective.
``--data-source`` selects which pipeline feeds it:
synthetic (default) : the synthetic signal generator (synthetic.py).
Calibrates the anomaly threshold and evaluates
AUPRC + lead time each epoch.
llm : real signals extracted from a HuggingFace causal
LM (llm_signals.py) over a labelled JSONL dataset.
Trains over the whole dataset each epoch and dumps
the final RSNNOutput for offline inspection.
Every internal setting of the run is exposed as a command-line flag (model
architecture, data generation/extraction, loss weights, optimization
schedule, calibration, and checkpointing), so a run can be reconfigured
without touching code.
This module is imported as part of the ``Lattice_Spike`` package (its siblings
use ``from .boxes import ...``), so the block below puts the package's parent
directory on ``sys.path`` and then imports the package by name. That makes
python Lattice_Spike/train.py # from the repo's parent dir
python train.py # from inside the package dir
python -m Lattice_Spike.train # as a module
all work the same way.
Examples
--------
# Quick CPU demo with defaults (synthetic data).
python Lattice_Spike/train.py
# Paper-scale boxes, sigmoid surrogate, robust to 15% contamination.
python Lattice_Spike/train.py --d-box 256 --n-neurons 512 \
--surrogate sigmoid --contaminate 0.15 --trim 0.2
# Resume from a checkpoint and train 5 more epochs.
python Lattice_Spike/train.py --resume runs/demo/last.pt --epochs 5
# Train on real LLM signals extracted from a labelled JSONL dataset.
python Lattice_Spike/train.py --data-source llm --limit 100 --epochs 3
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
# --- make the package importable however this file is launched --------------
_HERE = os.path.dirname(os.path.abspath(__file__))
_PARENT = os.path.dirname(_HERE)
_PKG = os.path.basename(_HERE) # the package directory name
if _PARENT not in sys.path:
sys.path.insert(0, _PARENT)
import numpy as np
import torch
_pkg = __import__(_PKG) # e.g. import Lattice_Spike
LatticeSpike = _pkg.LatticeSpike
CombinedLoss = _pkg.CombinedLoss
LossWeights = _pkg.LossWeights
auprc = _pkg.auprc
lead_time = _pkg.lead_time
make_batch = __import__(f"{_PKG}.synthetic", fromlist=["make_batch"]).make_batch
structured_token_table = __import__(
f"{_PKG}.synthetic", fromlist=["structured_token_table"]
).structured_token_table
# llm_signals is imported lazily inside train_llm() -- synthetic-only runs
# (the common case) shouldn't pay for loading the LLM-signal-extraction
# pipeline at all.
# Defaults that differ from the synthetic-mode flag defaults below, applied to
# --data-source llm runs unless the user explicitly overrides the flag.
LLM_MODE_DEFAULTS = {
"topk": 64,
"batch_size": 16,
"epochs": 5,
"lr": 1e-3,
"n_neurons": 64,
"box_encoder": "linear",
"out_dir": "output",
}
# Stable loss schema retained so anchor-off runs remain directly comparable
# with historical runs.
LOSS_KEYS = ("loss", "contain", "vol", "anchor", "recon", "mrl", "gamma")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------