ternary-composer-charlm
A strict all-ternary character-level language model β every linear weight is
{-1, 0, +1} β built on a Composer lane-FFN (4 conditional ternary "lanes"
gated by a ternary controller) with ternary attention, trained with the
alive-init recipe. Banked here as a checkpoint of ongoing research
(TernaryTest project). Not a chat model β a small research char-LM.
Author: Cody Dixon (Claude / Anthropic as engineering collaborator) Status: research checkpoint; the accompanying paper is unfinished and will ship with the finished model.
Results (held-out test, real corpus)
| Checkpoint | Context | Bits / char | Next-char acc | β1 / 0 / +1 |
|---|---|---|---|---|
best_model.pt (K=16, learned pos) |
16 | 1.374 | 0.711 | 21% / 58% / 21% |
ctx2048/best_model.pt (RoPE) |
2048 | 1.219 | 0.734 | 12% / 75% / 13% |
ctx16k/best_model.pt (RoPE, batch 1) |
16384 | 1.440 | 0.700 | β |
live1/best_model.pt (RoPE, streaming 12-source mix, per-source held-out) |
2048 | 2.052 mean over 12 sources (TinyStories 1.59, arithmetic 1.06) | β | β |
| matched float baseline (K=16 arch, same budget) | 16 | 4.789 (overfits) | 0.682 | β |
The 16k run used one window per step (same tokens/step as the 2048 run) and lost to it
at every step: one document per step collapses gradient diversity. Long context needs
packed multi-document windows (see train_live.py --pack) and more distinct data.
Both rows scored with eval_compare.py on the same 64 held-out windows Γ 2048
chars, all positions (unigram baseline β 0.166 acc). The K=16 model's originally
reported 0.723 / 1.305 used last-position-only sampling of the same held-out text.
Ternary params: 1,325,056 β ~323 KB @ 2 bits (vs ~2.6 MB fp16) for either model.
Evaluation caveat. The corpus is concatenated (wiki β math reasoning β arithmetic traces) and split 90/10 contiguously, so the held-out 10% is entirely arithmetic / unit-conversion traces (19.5% digits, no
User:lines). Absolute bits/char are measured on formulaic text and are flattering relative to prose; comparisons between checkpoints are fair. A document-interleaved split is planned.
At matched training the float model edges ternary on raw accuracy (+0.02) but
badly overfits the small corpus (b/c 4.79); the strict-ternary model generalizes
far better β its quantization acts as regularization. It generates coherent-ish
text and reproduces the corpus's <think>β¦</think> The answer isβ¦ reasoning
format and arithmetic/unit-conversion patterns.
What's in this repo
| File | Purpose |
|---|---|
best_model.pt |
{"state_dict", "config", "vocab"} β fp32 latent weights (see note) |
charset.json |
the 96-char vocabulary (vocab, stoi) |
ternary_lm.py |
the model definition (ternary Lin, composer LaneFFN, ternary attention; learned-position or RoPE) + load_checkpoint |
generate.py |
CLI sampler: python generate.py [--ckpt f] [--prompt "..."] [--temp t] [--greedy] [--info] β hard-rounds the latents to {-1,0,+1} and verifies before sampling |
train_best.py |
the frozen K=16 recipe that produced best_model.pt |
train_ctx.py |
the same recipe at long context (RoPE, --K 2048), with periodic held-out eval, best-checkpoint saving and immediate upload |
ctx2048/ |
the 2048-context run: best_model.pt (step 30k), progress.json (per-500-step val curve), log.txt |
ctx16k/ |
the 16384-context run (batch 1) β a negative result, kept for the record |
live1/ |
the live streaming-mix char run (train_live.py, 12 sources, per-source held-out) |
tokenizer.json (+.stats.json) |
32,768-piece byte-level BPE for the v2 token models (make_tokenizer.py) |
corpus_stream.py, train_live.py, run_live.sh, make_tokenizer.py |
the live streaming-mix training mode (control.json knobs, code reload, KD, int8 emb/KV, packing, QK-norm) |
big1/ |
the big token model (d512Γ8, 32k BPE, int8 emb/KV, QK-norm): best_model.pt (step 22k, 1.283 mean b/c over 36 held-out sources), latest.pt (full resumable state incl. Adam), logs |
flip_lm.py, fixed_corpus.py |
"Adam without Adam": low-rank-Adam trit flips β trains the ternary codes with NO fp32 latents and no per-weight optimizer state (train_live.py --flip); memory-light fixed corpus for side arms |
launch_big1.sh, rollover_big1.sh, relaunch_big1.sh |
launch / snapshot-to-HF / restore-on-a-fresh-kernel for the big run |
eval_compare.py |
scores any checkpoints on identical held-out windows (python eval_compare.py a.pt b.pt) |
Important:
best_model.ptstores the fp32 latent weights used by straight-through-estimator training. The deployed ternary weights are recovered deterministically asround(clamp(w, -1, 1))(seedet_terningenerate.py) β that is exactly what the forward pass uses. Pack those{-1,0,+1}values at 2 bits/weight for the ~323 KB deployable form.
Recipe (the part that matters)
- Alive-init: ternary weights are 0 unless
|latent| > 0.5; the default1/βdinit leaves0% alive β the net starts dead and can't train. Init latents at std β 0.30 (10% alive). This single change unlocked strict-ternary attention. - Composer lane-FFN: 4 strict-ternary lanes + a ternary sigmoid gate (conditional capacity). The biggest jump (0.45 β 0.604). Sweet spot = 4 lanes.
- Training: Adam 3e-3, cosine LR, 40β60k steps, batch 128, context 16, d=128, 4 blocks, 4 heads. QAT: fp latents + STE; deployed weights ternary.
- Data: 4.65 MB char corpus (wiki + reasoning chat records + arithmetic traces), 90/10 contiguous split.
Ablations at this scale: more lanes (8) worse; deeper (6 blocks) unstable; wider (d192/d256) worse; more data helped (0.663 β 0.723) up to the local limit.
Usage
pip install torch
python generate.py --info # arch, metrics, packed size
python generate.py --prompt "User: What is 2+2?" --temp 0.6 # sample
python generate.py --ckpt ctx2048/best_model.pt --prompt "The " --n 500 # a long-context checkpoint
from ternary_lm import load_checkpoint
m, ckpt, stats = load_checkpoint("best_model.pt") # latents hard-rounded to {-1,0,+1} and verified
print(m.arch(), stats) # e.g. {'d':128,'blocks':4,'K':16,'lanes':4,'heads':4,'pos':'learned'}
What it sounds like. Both models reproduce the corpus's surface form β spelling,
punctuation, the User: / Assistant: <think>β¦</think> The answer isβ¦ skeleton and
unit-conversion arithmetic. The K=16 model cannot hold a question in view while
answering it. The 2048-context model produces cleaner multi-line reasoning traces
but still neither computes correctly nor answers the question asked: at 1.3M
weights this is a format-and-spelling model, and capacity (not context) is what
limits it. Greedy decoding loops for both; temperature 0.6 is the most readable.
Honest limitations
- Tiny char-level model on a small corpus; a research artifact, not a product.
- Training used floating point (Adam / LayerNorm / softmax); only the weights are strictly ternary. Fully integer-only training is an open goal.
- Embeddings, LayerNorm and biases remain fp32.
Provenance
Trained on a Kaggle T4 in an autonomous improvement loop; progression
0.308 β 0.45 β 0.604 β 0.624 β 0.663 β 0.703 β 0.723 (dense β longer β
lanes β +longer β +cosine β +data β +data/steps), then the 2048-context run
(train_ctx.py --K 2048 --batch 8 --steps 30000, 35 min on one T4).
License: to be set by the author.