A transformer language model where every neuron is a stateful computational unit with persistent memory, learnable gating, and adaptive sensitivity. The standard feedforward sub-layer (Linear > GELU > Linear) is ripped out and replaced with a Stateful Neuron Bank where each neuron accumulates state across sequence positions through a gated recurrence.
This is a research project. The goal is to answer one question: does giving individual neurons persistent state improve language modeling, and if so, what do the neurons learn?
Built from raw PyTorch. No HuggingFace Transformers. No pretrained anything.
Every current LLM uses the same feedforward block: project up, apply nonlinearity, project back down. Each token is processed independently. There is no memory inside the neuron itself.
A squishy neuron is different. It maintains a hidden state that accumulates across the sequence via a gated recurrence, then mixes that state with the raw signal to produce output. Each neuron learns its own decay rate (how long to remember) and state weight (how much to trust memory vs the current input).
The default variant (selective) uses input-dependent decay inspired by Mamba's selective scan mechanism. Instead of a fixed decay per neuron, a learned projection produces per-token, per-neuron discretization steps:
raw = W_main @ x
gate = sigmoid(W_gate @ x)
mod = tanh(W_mod @ x)
delta = softplus(W_delta @ x)
A = -exp(A_log) # base decay rates (S4-style log-uniform init)
decay = exp(delta * A) # input-dependent per-token decay
update = (1 - decay) * gate * tanh(raw) # normalized gated update
state[t] = decay[t] * state[t-1] + update[t] # linear recurrence
mixed = (1 - state_weight) * gelu(raw) + state_weight * state[t]
output = mixed * (1 + mod)
Key design decisions and why they differ from the original formulation:
(1 - decay)normalization on the update prevents unbounded state growth. Without this, high decay + large gate values cause state to diverge. This is standard in SSM literature (S4, Mamba).gelu(raw)instead oftanh(raw)for the non-state path. Double-tanh (tanh on raw then tanh on state) creates a gradient bottleneck since tanh' is at most 1 and you're composing two of them. GELU preserves gradient flow on the direct path.- Input-dependent decay via
softplus(W_delta @ x)means each token controls how much history each neuron retains. A period token might trigger high decay (flush state), while a pronoun might trigger low decay (preserve context). This selectivity is what makes the architecture competitive with attention for context-dependent processing. - S4-style log-uniform initialization of base decay rates (
A_log) spreads neurons across different timescales at init, giving gradient signal to specialize from the start instead of all starting at the same decay.
decay, state_weight, and A_log are learnable per-neuron parameters. After training, neurons naturally differentiate: some develop long memory (slow decay), others become reactive (fast decay). This happens without supervision.
The state recurrence s[t] = decay[t] * s[t-1] + update[t] is a linear recurrence that can be parallelized using a prefix scan over the associative operator (a2, b2) compose (a1, b1) = (a2*a1, a2*b1 + b2).
The implementation uses a Hillis-Steele doubling algorithm: O(T log T) work but fully parallel across the time dimension. For each doubling step d, every position t composes its current prefix with the prefix at position t-d. After ceil(log2(T)) steps, every position holds the complete prefix.
The parallel scan activates automatically for sequences >= 64 tokens. For shorter sequences, a simple sequential loop is used since the overhead of tensor operations dominates at small T. Both produce identical results (verified by tests).
Squishy neuron block (drop-in replacement for standard transformer):
x -> RMSNorm -> MultiHeadAttention(RoPE) -> + residual
-> RMSNorm -> SelectiveNeuronBank -> + residual -> out
Baseline comparison block:
x -> RMSNorm -> MultiHeadAttention(RoPE) -> + residual
-> RMSNorm -> SwiGLU FFN -> + residual -> out
Architectural choices aligned with current best practices:
- RMSNorm instead of LayerNorm. Cheaper (no mean subtraction), works as well or better at scale. Used by LLaMA, Gemma, Mistral.
- Rotary Position Embeddings (RoPE) instead of learned positional embeddings. Better length generalization, no extra parameters, relative position encoding baked into attention. Used by LLaMA, Mistral, Qwen. Configurable via
use_ropeflag; falls back to learned embeddings if disabled. - SwiGLU FFN for the baseline (
silu(W1 @ x) * W3 @ x). Strictly better than GELU FFN at matched parameter count. This means the baseline is actually strong, not a strawman. - Gradient checkpointing support via
use_gradient_checkpointingconfig flag. Trades compute for memory, enabling larger batch sizes or model dimensions on limited hardware.
The attention mechanism is standard multi-head causal self-attention with fused QKV projection, using F.scaled_dot_product_attention(is_causal=True) for flash attention kernels on PyTorch 2.0+. Weight tying between token embedding and LM head.
Component Selective Baseline
Attention (per layer) 16,384 16,384 (identical)
FFN/NeuronBank 65,536 49,152 (4 proj + A_log vs 3 proj SwiGLU)
RMSNorm (per layer) 256 256 (identical)
Embeddings + LM head 8,256 8,256 (identical, weight-tied)
Total 189,440 142,144
Ratio 1.33x
The difference is entirely in the FFN replacement. Attention, embeddings, and norms are byte-for-byte identical.
The neuron design is the research variable. Swapping it requires exactly 3 things:
- Write a new class in
src/neuron.pyinheritingNeuronBankBase - Add one line to
NEURON_REGISTRY:"my_variant": MyVariantBank - Set
config.neuron_variant = "my_variant"
Nothing else changes. The model, trainer, scripts, and tests all work through the registry.
Currently registered variants:
"selective" SelectiveNeuronBank Input-dependent decay via softplus(W_delta @ x), S4 init, parallel scan
"stateful" StatefulNeuronBank Gated recurrence with learnable decay (fixed or selective mode)
"gated" GatedNeuronBank Same projections but no temporal state (ablation control)
model.py contains both SquishyTransformer and BaselineTransformer. They have identical forward signatures:
forward(input_ids, targets=None, neuron_states=None)
-> (logits, loss, neuron_states)The baseline ignores and returns empty neuron_states. This means every script, every test, and the trainer work with both models without any branching. build_model(config) checks config.use_baseline and returns the right one.
pip3 install torch tokenizers datasets wandb matplotlib safetensors pytest numpy tqdmOn macOS with system Python 3.9, you may need numpy<2 for torch compatibility:
pip3 install "numpy<2"python3 -m pytest tests/ -v
tests/test_attention.py::TestCausalSelfAttention::test_output_shape PASSED
tests/test_attention.py::TestCausalSelfAttention::test_output_shape_no_rope PASSED
tests/test_attention.py::TestCausalSelfAttention::test_causal_masking PASSED
tests/test_attention.py::TestCausalSelfAttention::test_gradients_flow PASSED
tests/test_attention.py::TestCausalSelfAttention::test_different_seq_lengths PASSED
tests/test_attention.py::TestCausalSelfAttention::test_single_token PASSED
tests/test_attention.py::TestCausalSelfAttention::test_batch_independence PASSED
tests/test_attention.py::TestCausalSelfAttention::test_deterministic_in_eval PASSED
tests/test_attention.py::TestRotaryEmbedding::test_cos_sin_shapes PASSED
tests/test_attention.py::TestRotaryEmbedding::test_dynamic_extension PASSED
tests/test_attention.py::TestRotaryEmbedding::test_apply_rotary_preserves_shape PASSED
tests/test_attention.py::TestRotaryEmbedding::test_rotary_equivariance PASSED
tests/test_model.py::TestRMSNorm::test_output_shape PASSED
tests/test_model.py::TestRMSNorm::test_gradients PASSED
tests/test_model.py::TestSquishyTransformer::test_forward_logits_shape PASSED
tests/test_model.py::TestSquishyTransformer::test_forward_with_targets PASSED
tests/test_model.py::TestSquishyTransformer::test_forward_no_rope PASSED
tests/test_model.py::TestSquishyTransformer::test_backward PASSED
tests/test_model.py::TestSquishyTransformer::test_training_step_reduces_loss PASSED
tests/test_model.py::TestSquishyTransformer::test_neuron_states_carry PASSED
tests/test_model.py::TestSquishyTransformer::test_count_parameters PASSED
tests/test_model.py::TestSquishyTransformer::test_gradient_checkpointing PASSED
tests/test_model.py::TestBaselineTransformer::test_forward_shape PASSED
tests/test_model.py::TestBaselineTransformer::test_forward_with_targets PASSED
tests/test_model.py::TestBaselineTransformer::test_same_api_as_squishy PASSED
tests/test_model.py::TestBaselineTransformer::test_gradient_checkpointing PASSED
tests/test_model.py::TestBuildModel::test_builds_squishy_by_default PASSED
tests/test_model.py::TestBuildModel::test_builds_baseline_when_flagged PASSED
tests/test_model.py::TestBuildModel::test_param_counts_same_order_of_magnitude PASSED
tests/test_neuron.py::TestSelectiveNeuronBank::test_output_shape PASSED
tests/test_neuron.py::TestSelectiveNeuronBank::test_state_changes_output PASSED
tests/test_neuron.py::TestSelectiveNeuronBank::test_gradients_flow PASSED
tests/test_neuron.py::TestSelectiveNeuronBank::test_state_is_detached PASSED
tests/test_neuron.py::TestSelectiveNeuronBank::test_decay_rates_bounded PASSED
tests/test_neuron.py::TestSelectiveNeuronBank::test_state_weight_bounded PASSED
tests/test_neuron.py::TestSelectiveNeuronBank::test_batch_independence PASSED
tests/test_neuron.py::TestSelectiveNeuronBank::test_single_timestep PASSED
tests/test_neuron.py::TestStatefulNeuronBank::test_output_shape PASSED
tests/test_neuron.py::TestStatefulNeuronBank::test_state_changes_output PASSED
tests/test_neuron.py::TestStatefulNeuronBank::test_selective_mode PASSED
tests/test_neuron.py::TestStatefulNeuronBank::test_decay_in_range PASSED
tests/test_neuron.py::TestStatefulNeuronBank::test_state_weight_in_range PASSED
tests/test_neuron.py::TestStatefulNeuronBank::test_gradients_flow PASSED
tests/test_neuron.py::TestStatefulNeuronBank::test_zero_state_init PASSED
tests/test_neuron.py::TestStatefulNeuronBank::test_state_is_detached PASSED
tests/test_neuron.py::TestStatefulNeuronBank::test_single_timestep PASSED
tests/test_neuron.py::TestStatefulNeuronBank::test_batch_independence PASSED
tests/test_neuron.py::TestGatedNeuronBank::test_output_shape PASSED
tests/test_neuron.py::TestGatedNeuronBank::test_no_temporal_dependence PASSED
tests/test_neuron.py::TestGatedNeuronBank::test_gradients_flow PASSED
tests/test_neuron.py::TestScanFunctions::test_sequential_scan_basic PASSED
tests/test_neuron.py::TestScanFunctions::test_parallel_scan_matches_sequential PASSED
tests/test_neuron.py::TestScanFunctions::test_parallel_scan_single_step PASSED
tests/test_neuron.py::TestScanFunctions::test_parallel_scan_non_power_of_two PASSED
tests/test_neuron.py::TestRegistry::test_build_stateful PASSED
tests/test_neuron.py::TestRegistry::test_build_selective PASSED
tests/test_neuron.py::TestRegistry::test_build_gated PASSED
tests/test_neuron.py::TestRegistry::test_unknown_variant_raises PASSED
tests/test_neuron.py::TestRegistry::test_all_registry_entries_instantiate PASSED
59 passed
Neuron tests (27 tests): Output shapes are correct for all 3 variants. State persistence works (same input with prior state produces different output). Decay rates and state_weight are always in (0,1). Gradients flow through every parameter including A_log, W_delta bias, and state_weight logits. Returned state is detached (no cross-sequence gradients). Batch elements are processed independently. Selective mode works in both SelectiveNeuronBank and StatefulNeuronBank. Parallel scan produces identical results to sequential scan for power-of-two lengths, non-power-of-two lengths, and single-step edge cases. The variant registry resolves correctly and raises on unknown keys. Every registered variant can instantiate and produce a forward pass.
Attention tests (12 tests): Output shape is correct with and without RoPE. Causal masking is proven by showing that modifying future tokens does not change past outputs. Gradients flow through the attention layer. Works with any sequence length from 1 to max_seq_len. Batch independence holds. Deterministic in eval mode. RoPE cos/sin cache shapes are correct and dynamically extend beyond initial max_seq_len. RoPE preserves dot-product structure (equivariance test).
Model tests (15 tests): RMSNorm output shapes and gradients. Full forward pass produces correct logit shapes with and without RoPE. Loss computation works with targets. Backward pass produces gradients. Multiple optimizer steps reduce loss (the model actually learns). Neuron states carry across forward passes. Gradient checkpointing works for both squishy and baseline models without breaking gradients. Both models have the same forward API. The factory builds the right model class. Parameter counts are in the expected range.
Generates a synthetic dataset with learnable patterns, trains both models for 500 steps, and compares:
python3 scripts/quick_test.py
The synthetic data uses template-based stories with character consistency, cause-and-effect chains, two-character interactions, and temporal sequences. These patterns are designed to give stateful neurons an advantage: predicting words later in the sequence requires remembering context from earlier (which character is in the story, what the weather is, etc).
Quick test config: d_model=128, n_heads=4, n_layers=3, seq_len=128, batch_size=16, 500 steps, no mixed precision. Trains on CPU in about 10 minutes total (both models).
python3 scripts/prepare_data.py
Downloads TinyStories from HuggingFace, trains a BPE tokenizer (vocab_size=8192), tokenizes both splits, saves to data/processed/{train,validation}.npy.
python3 scripts/train.py # squishy (selective variant, default)
python3 scripts/train.py --baseline # standard transformer (SwiGLU FFN)
python3 scripts/train.py --variant stateful # stateful variant (fixed or selective decay)
python3 scripts/train.py --variant gated # gated variant (ablation, no temporal state)
python3 scripts/train.py --resume checkpoints/step_5000
Default config: d_model=256, n_heads=4, n_layers=4, seq_len=256, batch_size=32, 10K steps, fp16, cosine LR with warmup, RoPE enabled. Logs to wandb if available, stdout always.
python3 scripts/generate.py --checkpoint checkpoints/step_10000
python3 scripts/generate.py --checkpoint checkpoints/step_10000 --temperature 0.5
Interactive prompt loop. Top-k/top-p sampling. Ctrl-C to exit.
python3 scripts/compare.py
Trains both models sequentially with the same seed and data, prints final validation loss and perplexity side by side.
AdamW optimizer with (beta1=0.9, beta2=0.95). Fused AdamW on CUDA for faster updates. Weight decay applied only to 2D+ parameters (projection matrices), not to biases, RMSNorm, or the per-neuron A_log/state_weight scalars. Learning rate schedule: linear warmup over warmup_steps, then cosine decay to 10% of peak.
Gradient accumulation: configurable number of micro-batches per optimizer step. Gradient clipping at max_grad_norm=1.0. Mixed precision via torch.autocast with torch.amp.GradScaler for fp16 (bf16 does not need scaling). optimizer.zero_grad(set_to_none=True) for reduced memory overhead.
Checkpoints save model weights via safetensors, config as JSON, and optimizer/scaler/step state as .pt. Everything needed to resume training or reproduce results.
Device selection: CUDA > MPS > CPU, automatic.
After training, src/analysis.py provides:
extract_neuron_params(model) pulls the learned decay rates and state_weight from every layer. For selective variants, this returns the base decay rates from A_log. Returns raw tensors.
plot_decay_distribution(model) shows per-layer histograms of learned decay rates, labeled by variant type (Selective vs Fixed). This answers the question: do neurons specialize? If you see a bimodal distribution (some neurons with decay near 0, others near 1), the neurons have differentiated.
plot_state_weight_distribution(model) same thing for state_weight. Shows how much the model learns to rely on accumulated state vs the raw signal at each layer.
plot_selectivity_heatmap(model, input_ids, layer_idx) visualizes how decay rates vary across sequence positions for a given input. This is unique to the selective variant and shows which tokens trigger memory retention vs flushing. Expects a (1, T) input tensor.
trace_neuron_states(model, input_ids, layer_idx) runs a forward pass and captures the state trajectory of individual neurons across the sequence. Returns a (T, n_neurons) tensor. Works with both selective and fixed decay variants.
plot_state_trajectories(states, neuron_indices) visualizes the above as line plots.
print_neuron_summary(model) prints a table of mean/std for decay and state_weight per layer with variant type annotation. Quick sanity check.
Everything is in config.py. One dataclass. No magic numbers.
Model: d_model=256 n_heads=4 n_layers=4 vocab_size=8192 max_seq_len=256 dropout=0.1
Neuron: neuron_variant="selective" decay_init=0.95 state_weight_init=0.5 selective_decay=True
Attention: use_rope=True rope_theta=10000.0
Training: batch_size=32 lr=3e-4 weight_decay=0.1 warmup=200 max_steps=10000
grad_accum=1 mixed_precision="fp16" max_grad_norm=1.0 seed=42
use_gradient_checkpointing=False
Data: dataset="roneneldan/TinyStories" tokenizer_vocab=8192 seq_length=256
Logging: wandb_project="squishy-neuron" log_interval=10 eval_interval=200 save_interval=1000
Generation: temperature=0.8 top_k=50 top_p=0.9
Config serializes to JSON and every checkpoint includes its config. Config.load(path) reconstructs from JSON.
-
Does selective state beat fixed state? The selective variant lets each token control decay per-neuron. The stateful variant with
selective_decay=Falseuses a single learned decay per neuron. Comparing these isolates whether input-dependent memory gating matters. The gated variant (no state at all) isolates whether state itself matters. -
Parameter efficiency. Does a stateful transformer with N parameters outperform a standard transformer with N parameters on language modeling perplexity? The squishy model has more params in the FFN portion due to extra projections. The gated variant (same projections but no state) isolates the contribution of state vs extra capacity.
-
Neuron specialization. After training, do neurons naturally develop different decay rates? Do some become long-memory context trackers while others become short-memory reactive processors? The analysis tools exist to measure this. The selectivity heatmap additionally shows whether the same neuron adapts its memory timescale based on input content.
-
State dynamics. What do the neuron states actually encode? Topic? Syntax? Character identity? The state trajectory tracing lets you correlate state values with sequence content.
-
Scaling. As model size increases, does the stateful architecture scale better, worse, or the same as standard transformers? Even directional evidence at small scale matters.
-
Gradient health. Does backpropagation through the state recurrence cause vanishing or exploding gradients? The
(1 - decay)normalization and input-dependent gating (analogous to LSTM/Mamba gates) should help, but this needs empirical verification across training runs.
PyTorch >= 2.1 Core framework, built from nn.Linear/nn.Embedding/RMSNorm
tokenizers >= 0.15 HuggingFace Rust BPE tokenizer (NOT the transformers library)
datasets >= 2.16 HuggingFace datasets for downloading TinyStories
safetensors >= 0.4 Model serialization
wandb >= 0.16 Experiment tracking (optional, graceful fallback to stdout)
matplotlib >= 3.8 Visualization
pytest >= 7.4 Testing
numpy < 2 Numeric (< 2 required for torch 2.2 compatibility on Python 3.9)
No HuggingFace Transformers. No pretrained weights. Everything is built from scratch.