Revisiting non-linear RNNs
Requires uv.
uv sync # creates .venv with Python 3.12, torch, numpy, rich, wandb
wandb login # only needed if wandb logging is enabled in the configRun everything through uv run ... (or activate .venv yourself).
# train NRU on the copying memory task (configs/{new,original}_nru_<task>.json exist for every task)
uv run train.py configs/original_nru_copying.json
# same task with another architecture: replace the whole model section
uv run train.py configs/new_nru_psmnist.json --set 'model={"name":"lstm","embed_size":64,"num_layers":2}'
# same run with some values overridden from the CLI
uv run train.py configs/new_nru_copying.json --set train.lr=3e-4 task.time_lag_max=200 wandb.enabled=false
# on a Mac / CPU, disable torch.compile
uv run train.py configs/gru_copying.json --set model.compile=false
# resume an interrupted run (optionally extend it)
uv run train.py --resume runs/new_nru_copying_20260926-120000 --set train.total_steps=200000jobs/ has one sbatch script per run. They read WANDB_API_KEY from .env (gitignored; copy
.env.example) and write their logs to slurm_logs/<job name>-<job id>.out. Submit from the repo root,
since the log path is relative to where sbatch is called:
cp .env.example .env # then fill in WANDB_API_KEY
sbatch jobs/new_nru_copying.shtrain.py training entry point
logger.py rich CLI log + wandb logging
configs/ one JSON per run: task, data, model, train, wandb
models/
__init__.py name -> model class registry, build_model()
original_nru.py Non-saturating Recurrent Unit as in Chandar et al. (2019)
new_nru.py revised NRU (pre-norm residual stacking, low-rank write / erase projections)
gru.py GRU (reset-after, same equations as torch.nn.GRU)
lstm.py LSTM (forget bias init 1)
fast_lstm.py LSTM with the fast forget gate sigmoid(sinh(z)) (Ohno et al., 2023)
tasks/
__init__.py name -> task class registry, build_task()
base.py Task interface (tokens in, classes out, loss mask)
copying_memory.py copying memory
denoising.py denoising
adding.py adding problem
copy.py copy
repeat_copy.py repeat copy
associative_recall.py associative recall
psmnist.py permuted sequential MNIST
data.py data sources: stream / pregenerated file / fixed dataset
jobs/ SLURM sbatch scripts, one per run
slurm_logs/ SLURM job logs (gitignored)
runs/ run outputs (created on first run, gitignored)
data/ pregenerated datasets and downloaded MNIST (gitignored)
A config JSON describes the whole run: task, data, model, optimisation and logging. Missing keys fall
back to DEFAULTS in train.py, and the fully resolved config is saved to the run directory and sent to
wandb, so every run is reproducible from its own config.json.
--set key.subkey=value ... changes any config value without editing files. Values are parsed as JSON
when possible (3e-4, true, null, [0.9, 0.95]), otherwise kept as strings. Setting a key that is not
in the config prints a warning, which catches typos. Model and task keys are passed straight to their
constructors, so a misspelled architecture hyperparameter raises an error.
All three architectures use the same scaffold, so they can be compared fairly:
- token embedding -> stack of recurrent layers -> linear head, one prediction per time step
- pre-norm residual stacking:
u <- u + cell(RMSNorm(u)), final RMSNorm on the output - hand-written cells (no cuDNN), run step by step
- optional
torch.compileover fixed-size chunks of time steps (chunk_size). This keeps cross-step fusion without the very slow compile of one graph over the whole sequence. It's intended for CUDA.
Each architecture lives in its own self-contained file.
| model | specific hyperparameters | params (embed 32, 3 layers) |
|---|---|---|
original_nru |
memory_size (64), num_heads (4); num_heads * memory_size must be a perfect square (outer product trick) |
27.6k (embed 64, 1 layer) |
new_nru |
memory_size (50), num_heads (2) |
23.2k (embed 64, 1 layer) |
gru |
hidden_size (defaults to embed_size) |
19.7k |
lstm |
hidden_size (defaults to embed_size), forget_bias (1.0) |
25.7k |
fast_lstm |
same as lstm; the forget bias starts at asinh(forget_bias), so the initial gate matches a sigmoid LSTM |
same as lstm |
Shared model keys: name, embed_size, num_layers, chunk_size (16), compile (false). When
hidden_size != embed_size, GRU/LSTM add a linear projection back into the residual stream.
All tasks follow myTorch's task folder and
share one format. Every batch is (x, y, mask), all of shape (batch, length):
xholds input token idsyholds target class ids, with one prediction per positionmaskselects the positions that count towards the loss (cross-entropy) and metrics
Tasks with real-valued or bit-vector inputs in myTorch are tokenised: each bit vector becomes one symbol, and adding values become digits.
| task | name |
loss positions | default settings | tokenisation vs. myTorch |
|---|---|---|---|---|
| Copying memory | copying_memory |
all (or recall only) | seq_len 10, time_lag_min/max 100, num_digits 8, num_noise_digits 1, mask_recall_only false |
already tokens |
| Denoising | denoising |
last seq_len |
same as copying memory | already tokens |
| Adding | adding |
last step | seq_len 100, num_levels 10 |
values are digits in [0, num_levels); the target is their exact sum as a class |
| Copy | copy |
output half | num_bits 8, min_len 1, max_len 20 |
each vector is one of 2^(num_bits-1) symbols, plus delimiter and blank tokens |
| Repeat copy | repeat_copy |
output part | num_bits 8, min/max_len 1–10, min/max_repeat 1–10 |
as copy, and the repeat count gets its own token instead of a normalised real |
| Associative recall | associative_recall |
answer + end | num_bits 8, min/max_len 2–6, block_len 3 |
each vector is one of 2^(num_bits-2) symbols, plus item-delimiter, query-marker and blank tokens |
| psMNIST | psmnist |
last step | permute true, permutation_seed 0 |
pixel intensities 0–255 are tokens (the paper feeds real values) |
Samples. Below is one validation example per task, verbatim as generated with the settings of
configs/*_nru_<task>.json: x is input tokens, y is targets, mask marks loss positions. Every run
also prints the first 3 validation examples like this before training starts.
Copying memory (seq_len=10, time_lag_min=100, time_lag_max=100, num_digits=8, num_noise_digits=1; length 120, vocab 10, 9 classes)
x 8 6 6 6 1 2 6 2 4 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 9 0 0 0 0 0 0 0 0 0 0
y 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 8 6 6 6 1 2 6 2 4 1
mask 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
Denoising (seq_len=10, time_lag_min=100, time_lag_max=100, num_digits=8, num_noise_digits=1; length 111, vocab 10, 9 classes)
x 0 0 8 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 6 0 0 0 0 0 1 4 0 0 0 4 0 0 0 0 0 0 4 8 0 0 0 0 2 0 0 0 0 0 4 0 0 0 0 0 0 0 0 0 0 0 0 0 6 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 9 0 0 0 0 0 0 0 0 0 0
y 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 8 6 1 4 4 4 8 2 4 6
mask 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1
Adding (seq_len=100; length 201, vocab 12, 19 classes)
x 4 9 3 0 3 9 7 3 7 3 1 6 6 9 8 6 6 8 4 3 6 9 1 4 4 1 9 9 9 0 1 2 3 0 5 5 2 9 1 8 8 3 6 9 1 7 3 5 2 1 0 9 3 1 1 0 3 6 6 7 9 6 3 4 5 0 8 2 8 2 7 5 0 0 8 1 9 6 1 0 2 9 4 3 9 3 9 3 9 8 5 3 2 8 5 6 6 5 7 2 10 10 10 10 10 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 10 10 10 10
y 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 15
mask 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1
Copy (num_bits=8, min_len=1, max_len=20; length 12, vocab 130, 130 classes)
x 47 117 64 67 123 128 129 129 129 129 129 129
y 129 129 129 129 129 129 47 117 64 67 123 128
mask 0 0 0 0 0 0 1 1 1 1 1 1
Repeat copy (num_bits=8, min_len=1, max_len=10, min_repeat=1, max_repeat=10; length 67, vocab 140, 130 classes)
x 117 64 67 123 67 128 139 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129 129
y 129 129 129 129 129 129 129 117 64 67 123 67 128 117 64 67 123 67 128 117 64 67 123 67 128 117 64 67 123 67 128 117 64 67 123 67 128 117 64 67 123 67 128 117 64 67 123 67 128 117 64 67 123 67 128 117 64 67 123 67 128 117 64 67 123 67 128
mask 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
Associative recall (num_bits=8, min_len=2, max_len=6, block_len=3; length 33, vocab 67, 67 classes)
x 47 53 0 64 59 3 39 64 19 21 50 64 23 6 24 64 12 58 1 64 39 23 46 64 65 59 3 39 65 66 66 66 66
y 66 66 66 66 66 66 66 66 66 66 66 66 66 66 66 66 66 66 66 66 66 66 66 66 66 66 66 66 66 19 21 50 65
mask 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1
psMNIST (permute=True, permutation_seed=0; length 784, vocab 256, 10 classes)
x 0 0 0 0 0 0 0 0 0 0 0 0 0 0 240 216 0 0 0 0 0 254 246 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 215 0 0 0 0 0 19 249 0 195 143 0 0 0 0 0 0 86 0 0 0 0 0 0 0 0 0 0 0 0 254 0 178 0 0 0 130 0 0 0 0 0 0 206 0 0 0 0 0 0 0 0 0 0 0 10 0 0 0 39 54 254 0 0 0 0 0 0 177 86 0 65 0 0 0 0 251 0 0 0 0 0 0 0 0 63 0 0 0 0 0 0 24 173 0 0 0 0 15 67 0 0 0 0 0 44 0 0 64 0 0 242 0 0 0 0 0 0 0 58 0 0 0 0 0 13 44 0 0 254 0 142 0 0 0 0 0 0 164 0 0 0 24 37 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 120 0 0 171 0 0 0 0 0 0 0 22 16 0 0 0 0 0 0 0 60 0 0 254 0 0 12 0 0 0 0 0 254 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 24 0 0 0 0 0 187 0 0 0 254 0 0 0 0 0 42 0 0 29 0 254 19 0 0 10 0 0 0 0 149 0 0 0 0 0 0 0 0 0 0 254 4 0 0 0 0 0 0 159 0 0 0 0 0 0 0 254 0 0 0 0 254 0 0 0 0 0 0 0 0 0 0 0 13 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 71 0 0 0 0 0 0 0 0 0 0 0 0 241 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 240 0 0 0 0 0 0 0 0 77 0 0 0 0 0 0 9 0 207 0 0 0 0 0 0 230 0 0 0 20 0 0 0 0 0 241 0 0 0 251 0 0 254 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 252 0 254 0 0 254 135 0 219 0 242 0 0 0 0 0 0 0 0 0 0 0 0 254 0 65 0 183 0 0 0 0 0 0 0 0 0 0 0 148 0 0 0 0 0 0 0 0 0 254 0 0 0 0 254 0 0 0 215 0 254 0 0 0 0 0 0 0 0 249 0 0 9 0 0 0 0 254 0 0 0 0 0 254 0 0 0 0 0 0 0 0 254 0 0 0 0 0 31 0 165 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 247 0 185 247 0 0 0 0 185 0 0 0 0 0 250 0 0 0 0 0 0 0 254 0 0 0 0 0 0 0 0 0 0 135 0 0 84 0 0 0 0 0 0 0 0 254 0 245 0 0 0 0 60 254 0 0 0 0 158 0 0 254 0 254 254 0 0 0 0 0 0 0 0 0 241 0 0 245 0 0 254 0 0 0 0 0 0 254 0 0 0 0 0 0 0 0 0 0 0 226 149 0 0 0 0 0 0 0 0 60 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 254 0 0 254 0 159 0 0 238 0 0 0 0 0 0 0 0 0 0 0 0 0 146 0 0 0 0 0 0 0 116 0 0 58 1 0 0 0 0 0 0 0 0 0 20 0 0 0 0 0 48 0 0 0 0 46 0 0 0 0 0
y 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 9
mask 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1
Each task file's docstring has the exact sequence layout. More details:
- Copying memory: the input is
seq_lendigits, noise, a marker atseq_len + time_lag - 1, then noise. The target is 0 except for the lastseq_lenpositions. The loss covers every position as in myTorch, or only the recall positions withmask_recall_only: true. Metrics arerecall_accandseq_acc. - Denoising: the digits are scattered at random ordered positions inside the noise, followed by a marker. The model outputs the digits in order after the marker.
- Adding: as in myTorch, values and markers come one after the other in time, not as two channels.
- Copy, repeat copy, associative recall: lengths and repeat counts are sampled once per batch. Associative recall samples the query item per example (myTorch uses one per batch).
- psMNIST: MNIST is downloaded to
<data.dir>/mniston first use. The 60k training images are split into train and val (val sizedata.num_val, 5000 in the config), and the official 10k test set is evaluated at the end of training.data.modedoes not apply. - Variable lengths: variable time lags, lengths and repeat counts are sampled once per batch, so a batch always shares one length.
- Metrics: by default,
acc(accuracy over masked positions) andseq_acc(fraction of sequences with every masked position correct). - Baseline loss: where a task defines a trivial reference, the run header shows it (e.g. predict blanks, then guess uniformly). A model stuck at that value has learned nothing about memory.
stream(default): a fresh batch is generated on the fly every step. The validation set is generated once fromdata.seed, so it is identical across runs with different run seeds.file:num_train + num_valexamples are pregenerated into a text file, then batches are sampled from it. The file is created on first use. Ifdata.pathisnull, the path is derived from a hash of the task and data settings (<data.dir>/<task>_<hash>.txt), so different task settings never collide.- Format: a JSON header line (
# {...}) recording the generator settings, then one example per line:split<TAB>input ids<TAB>target ids<TAB>mask. - The header is checked on load, so an existing file generated with different settings raises an error instead of being used silently.
- Examples are bucketed by length, so batches never mix different lengths.
- Format: a JSON header line (
- Dataset tasks (psMNIST) ignore
data.modeand sample from their own fixed train split. - Batches are sampled uniformly with replacement in every mode, so runs are measured in steps, not epochs.
- a header table with the full resolved config, parameter count, device, run directory and baseline loss
- the first 3 validation examples printed verbatim as token ids (
x,y,mask), so you can see exactly what the model is trained on - a live progress bar with elapsed time, ETA, and the latest train loss, task metrics, grad norm, LR and tokens/sec
- optionally, every
print_intervalsteps, a persistent train line with step/total, loss, task metrics, grad norm, LR, it/s, tok/s, peak GPU memory (CUDA), elapsed time and ETA. Unlike the live bar, it stays in the scrollback and in wandb's captured console log (output.log). - one line per evaluation with validation metrics, plus a sample showing target vs. greedy prediction (mismatches in red)
Enable it with wandb.enabled: true. The run name matches the run directory, the full config is logged,
and project, entity, group and tags are configurable. Logged keys:
train/loss, task metrics (e.g.train/acc,train/seq_acc),train/grad_norm,train/lrperf/steps_per_sec,perf/tokens_per_sec,perf/max_mem_gb(CUDA only)val/lossand task metrics;test/...at the end for tasks with a test split
Resuming a run continues the same wandb run.
- AdamW with configurable
lr,betasandweight_decay - LR schedule:
constantorcosine, both with optional linear warmup (warmup_steps); cosine decays tomin_lr_ratio * lr - optional global grad-norm clipping (
max_grad_norm). The grad norm is logged either way, which helps diagnose RNN instability. - optional bf16 autocast (
bf16: true); the loss is always computed in fp32 - training stops early with a message if the loss becomes NaN/inf
Each run writes to runs/<name>_<YYYYmmdd-HHMMSS>/:
| file | contents |
|---|---|
config.json |
fully resolved config |
checkpoint.pt |
model, optimizer, scheduler, step, data-sampler RNG, torch RNG, wandb run id |
model.pt |
final model state_dict |
metrics.json |
final step, validation metrics, and test metrics for tasks with a test split |
code/ |
snapshot of the source (*.py, *.json, *.toml; no data, runs or .venv) the run was started with; a resume adds code_resume_step<N>/ |
A checkpoint is saved every ckpt_interval steps, at the end, and on Ctrl-C. --resume RUN_DIR reloads
the run's own config.json (with any --set overrides applied on top), restores all state, and continues
from the saved step. For example, extend a finished run with --set train.total_steps=....
Keys under task and model depend on the chosen task and architecture (see above). Everything else,
with defaults:
| key | default | notes |
|---|---|---|
name |
"run" |
run name, prefix of the run directory |
seed |
0 | run seed (init, training batches) |
device |
"auto" |
auto picks cuda > mps > cpu, or give e.g. "cuda:1" |
data.mode |
"stream" |
stream or file (generator tasks only) |
data.dir |
"data" |
pregenerated files and downloaded datasets |
data.path |
null |
file mode only; null derives a path from the settings |
data.num_train |
1000000 | file mode only |
data.num_val |
2048 | validation set size |
data.seed |
0 | seed for the file and the validation set |
train.batch_size |
32 | |
train.total_steps |
10000 | |
train.lr |
1e-3 | |
train.weight_decay |
0.0 | |
train.betas |
[0.9, 0.999] | |
train.lr_schedule |
"constant" |
constant or cosine |
train.warmup_steps |
0 | |
train.min_lr_ratio |
0.0 | cosine floor |
train.max_grad_norm |
null |
null disables clipping |
train.bf16 |
false | |
train.log_interval |
50 | steps between train metric updates (progress bar + wandb) |
train.print_interval |
null |
steps between persistent train lines; must be a multiple of log_interval, null disables it |
train.eval_interval |
500 | steps between evaluations (also runs at the last step) |
train.eval_batch_size |
256 | |
train.ckpt_interval |
5000 | |
train.out_dir |
"runs" |
|
train.rollback_interval |
null |
snapshot model + optimizer every N steps and restore it on a spike (undone steps still count towards total_steps); null disables |
train.rollback_factor |
1000.0 | a spike is a non-finite loss / grad norm, or a grad norm above this times its running mean |
train.rollback_noise |
0.01 | after a rollback, Gaussian noise with this std relative to each weight tensor's RMS |
wandb.enabled |
false | the provided configs set this to true |
wandb.project |
"non-linear-rnn" |
|
wandb.entity |
null |
|
wandb.group |
null |
|
wandb.tags |
[] |
New architecture: add models/<name>.py with a model class taking
(vocab_size, output_size, **model_cfg), whose forward(idx, state=None) returns (logits, state) with
logits of shape (batch, length, output_size). Register it in MODELS in models/__init__.py.
New task: subclass tasks.base.Task and set input_size (vocab size) and output_size (number of
classes). Then implement one of:
- Generator task:
sample(batch_size, generator)returning(x, y, mask).xandyare LongTensors andmaska BoolTensor, all of shape(batch, length). Both data modes then work automatically. - Dataset task: set
is_dataset = Trueand implementload_splits(data_dir, num_val, seed), returning TensorDatasets fortrain,valand optionallytest. Also implementmake_batch(*tensors), which turns a slice of a split into(x, y, mask).
Optionally override metrics, format_sample and baseline_loss. Register the task in TASKS in
tasks/__init__.py.
torch.compilebuilds one graph per distinct(batch size, chunk length). With variable time lags, the last partial chunk varies, so expect a few extra compiles early on (dynamo's cache limit is raised to 64). Compiling on CPU is very slow; usemodel.compile=falseoff-GPU.nru_mods_copying_memory_optimized.ipynbis the original preliminary notebook this codebase was built from.