Text Generation
Transformers
Safetensors
English
3-digit-basic-calc
arithmetic
process-supervision
scratchpad
chain-of-thought
from-scratch
interpretability
small-language-model
looped-transformer
custom_code
Instructions to use vmal/3-digit-basic-calc with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use vmal/3-digit-basic-calc with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-generation", model="vmal/3-digit-basic-calc", trust_remote_code=True)# Load model directly from transformers import AutoModelForCausalLM model = AutoModelForCausalLM.from_pretrained("vmal/3-digit-basic-calc", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use vmal/3-digit-basic-calc with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "vmal/3-digit-basic-calc" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "vmal/3-digit-basic-calc", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }'Use Docker
docker model run hf.co/vmal/3-digit-basic-calc
- SGLang
How to use vmal/3-digit-basic-calc with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "vmal/3-digit-basic-calc" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "vmal/3-digit-basic-calc", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "vmal/3-digit-basic-calc" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "vmal/3-digit-basic-calc", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }' - Docker Model Runner
How to use vmal/3-digit-basic-calc with Docker Model Runner:
docker model run hf.co/vmal/3-digit-basic-calc
Upload folder using huggingface_hub
Browse files- LICENSE +21 -0
- README.md +190 -0
- config.json +27 -0
- config.py +50 -0
- evaluation_results.json +105 -0
- generation_config.json +10 -0
- model.safetensors +3 -0
- modeling.py +413 -0
- notebook.ipynb +144 -0
- special_tokens_map.json +6 -0
- tokenizer.py +108 -0
- tokenizer_config.json +50 -0
- vocab.json +36 -0
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 vmal
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,3 +1,193 @@
|
|
| 1 |
---
|
| 2 |
license: mit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
license: mit
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
tags:
|
| 6 |
+
- arithmetic
|
| 7 |
+
- process-supervision
|
| 8 |
+
- scratchpad
|
| 9 |
+
- chain-of-thought
|
| 10 |
+
- from-scratch
|
| 11 |
+
- interpretability
|
| 12 |
+
- small-language-model
|
| 13 |
+
- looped-transformer
|
| 14 |
+
pipeline_tag: text-generation
|
| 15 |
+
library_name: transformers
|
| 16 |
+
datasets:
|
| 17 |
+
- vmal/3-digit-arithmetic-scratchpad-traces
|
| 18 |
---
|
| 19 |
+
|
| 20 |
+
# 3-digit-basic-calc
|
| 21 |
+
|
| 22 |
+
A from-scratch, **1.6M-parameter** looped decoder-only transformer for addition,
|
| 23 |
+
subtraction, multiplication, and division on integer operands with up to three
|
| 24 |
+
digits. It generates explicit algorithmic scratchpads and is evaluated on
|
| 25 |
+
prompt-disjoint held-out expressions. No tools or external memory: next-token
|
| 26 |
+
prediction over a 34-token character-and-control vocabulary.
|
| 27 |
+
|
| 28 |
+
| Operation | Validation | Fresh, never-seen problems |
|
| 29 |
+
|-----------|-----------:|---------------------------:|
|
| 30 |
+
| Addition `+` | 99.9% | 99.6% |
|
| 31 |
+
| Subtraction `−` | 99.7% | 99.7% |
|
| 32 |
+
| Multiplication `×` | 100% | 100% |
|
| 33 |
+
| Division `÷` | 96.8% | 93.1% |
|
| 34 |
+
| **Overall** | **99.1%** | **98.1%** |
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
## Quick start
|
| 40 |
+
|
| 41 |
+
```python
|
| 42 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 43 |
+
|
| 44 |
+
model = AutoModelForCausalLM.from_pretrained("vmal/3-digit-basic-calc", trust_remote_code=True).eval()
|
| 45 |
+
tok = AutoTokenizer.from_pretrained("vmal/3-digit-basic-calc", trust_remote_code=True)
|
| 46 |
+
|
| 47 |
+
print(model.solve(tok, "842/37")) # -> 22.757
|
| 48 |
+
print(model.solve(tok, "213*145")) # -> 30885
|
| 49 |
+
print(model.solve(tok, "-500+500")) # -> 0
|
| 50 |
+
print(model.solve(tok, "12/0")) # -> NAN
|
| 51 |
+
|
| 52 |
+
# See the model's actual reasoning (the scratchpad it generates):
|
| 53 |
+
answer, trace = model.solve(tok, "842/37", return_trace=True)
|
| 54 |
+
print(trace)
|
| 55 |
+
# 842/37=<div><pos><state>842037<step>00080008<qmul>00000<rem>008
|
| 56 |
+
# <step>00840084<qmul>20074<rem>010 ... <ans>022.757
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
Operands are integers in **[−999, 999]**. Whitespace and a trailing `=` are
|
| 60 |
+
accepted; malformed expressions and out-of-range operands raise `ValueError`.
|
| 61 |
+
Division answers are rounded half-up to three decimals, and division by zero
|
| 62 |
+
returns `NAN`.
|
| 63 |
+
|
| 64 |
+
Mixed-length batching is supported with the tokenizer's default left padding:
|
| 65 |
+
|
| 66 |
+
```python
|
| 67 |
+
batch = tok(["1+2=", "999/7="], return_tensors="pt", padding=True)
|
| 68 |
+
raw_traces = model.generate(**batch, do_sample=False)
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
## Research iterations
|
| 72 |
+
|
| 73 |
+
**Goal:** Develop a small transformer that generalizes the algorithms for
|
| 74 |
+
addition, subtraction, multiplication, and division, targeting at least 95%
|
| 75 |
+
accuracy per operation on prompt-disjoint held-out splits.
|
| 76 |
+
|
| 77 |
+
**Iterations:**
|
| 78 |
+
|
| 79 |
+
**A plain ~4M-parameter transformer.** The obvious start. It fit the training data but didn't generalize the algorithm; accuracy fell apart on unseen numbers. Scaling wasn't obviously the fix; the failures looked structural.
|
| 80 |
+
|
| 81 |
+
**A looped transformer.** Sharing a small block across many iterations gives cheap depth, the right bias for an iterative procedure. It helped a little. It didn't make the model generalize.
|
| 82 |
+
|
| 83 |
+
**Abacus embeddings.** Following *Transformers Can Do Arithmetic with the Right Embeddings* (McLeish et al., NeurIPS 2024), I added its three pieces: digit-position (abacus) embeddings, input injection, and a randomized-offset scheme for length generalization. This was the first thing that clearly moved the *hardest* cases. But it still failed to generalize division and multiplication, and even addition and subtraction stayed shakier than they should have. I also tried **GRPO** on top; it bought a small, real improvement, not the step change the problem needed.
|
| 84 |
+
|
| 85 |
+
Every one of those was a reasonable bet, and none fixed the core issue. The model was always being asked to carry too much state (a remainder, a running carry) implicitly, inside its activations. More depth, better position embeddings, and RL were all trying to make it better at holding hidden state. The move that worked was to stop asking it to.
|
| 86 |
+
|
| 87 |
+
**Process supervision (the scratchpad).** Instead of `842/37 → 22.757`, teach the model to emit the whole procedure as tokens, writing down the state at every step. Each token now predicts a local step from explicit state written in the preceding tokens, reducing the hidden-state burden. This was the turning point: the model started to *generalize* across all four operations. But two operations still fell short.
|
| 88 |
+
|
| 89 |
+
**Multiplication stalled near 65%.** Measured per step, one step did all the damage: a single token had to sum up to three digit-products and a carry at once. Meanwhile training loss had collapsed to near zero while validation sat at 65%: the model could already fit the traces perfectly. Capacity was not the primary bottleneck; one step packed too much into one decision. So I broke the column sum into **term-level micro-steps** (emit each partial product, then the digit and carry). **Multiplication → 99.6%.**
|
| 90 |
+
|
| 91 |
+
**Then division was the laggard.** A first-error diagnostic showed the errors sat almost entirely in the **interior long-division steps**, never the setup, never the rounding. The step was still doing two hard things at once: find the quotient digit *and* compute the new remainder. Same fix, **more micro-steps**: each position becomes form-the-partial → emit the quotient digit and its product → subtract for the remainder. **Division cleared its plateau and reached ~97%**, while the other operations remained near 99–100%.
|
| 92 |
+
|
| 93 |
+
Two things ran underneath all of it. **Sequence length kept growing**: more micro-steps mean longer traces, so `max_seq_len` climbed 80 → 128 → 176 to fit them. Whenever the model lagged on a specific skill (non-terminating quotients or high-carry columns), the fix was usually to *improve the data distribution* for that skill, not to change the model.
|
| 94 |
+
|
| 95 |
+
> **The lesson:** in these experiments, capacity was not the primary bottleneck; decomposition was decisive. Each plateau broke when the hardest *local function per step* was split into simpler pieces and the data was kept well-distributed. The 256-wide, 2-layer, 8-loop architecture stayed fixed through the scratchpad experiments.
|
| 96 |
+
|
| 97 |
+
## Evaluation
|
| 98 |
+
|
| 99 |
+
All numbers are greedy decoding on prompt-disjoint held-out data. Detailed
|
| 100 |
+
counts, seeds, conversion checks, and timing are available in
|
| 101 |
+
[`evaluation_results.json`](evaluation_results.json).
|
| 102 |
+
|
| 103 |
+
### Locked test split
|
| 104 |
+
|
| 105 |
+
The reserved test split was evaluated once after model selection:
|
| 106 |
+
|
| 107 |
+
| Operation | Accuracy |
|
| 108 |
+
|-----------|---------:|
|
| 109 |
+
| `+` | 99.9% (999/1,000) |
|
| 110 |
+
| `−` | 99.9% (999/1,000) |
|
| 111 |
+
| `×` | 100.0% (1,000/1,000) |
|
| 112 |
+
| `÷` | 95.2% (952/1,000) |
|
| 113 |
+
| **Overall** | **98.75% (3,950/4,000)** |
|
| 114 |
+
|
| 115 |
+
### Generality, never-seen problems
|
| 116 |
+
|
| 117 |
+
Beyond the reserved test split, I sampled and evaluated **3,000 new, uniformly
|
| 118 |
+
generated problems with operands up to three digits**, each verified to be
|
| 119 |
+
absent from all 108,000 dataset prompts:
|
| 120 |
+
|
| 121 |
+
| Operation | Accuracy |
|
| 122 |
+
|-----------|---------:|
|
| 123 |
+
| `+` | 99.6% |
|
| 124 |
+
| `−` | 99.7% |
|
| 125 |
+
| `×` | **100.0%** (750/750) |
|
| 126 |
+
| `÷` | 93.1% |
|
| 127 |
+
| **Overall** | **98.1%** |
|
| 128 |
+
|
| 129 |
+
The fresh benchmark differs from the locked test by −0.3 points on addition,
|
| 130 |
+
−0.2 on subtraction, 0.0 on multiplication, and −2.1 on division. The larger
|
| 131 |
+
division gap is consistent with uniform sampling stressing a different mixture
|
| 132 |
+
than the scenario-balanced held-out splits. Prompt exclusion rules out exact
|
| 133 |
+
training-prompt memorization, but these results should not be interpreted as
|
| 134 |
+
length generalization: operands remain within the trained three-digit range.
|
| 135 |
+
|
| 136 |
+
### Where it's hard, and hardest
|
| 137 |
+
|
| 138 |
+
- **Easiest:** multiplication (perfect on fresh samples) and addition/subtraction (~99.7%).
|
| 139 |
+
- **Hardest:** multi-step division, especially with two- and three-digit
|
| 140 |
+
divisors. On the locked test set, 47 of 48 division failures first diverged
|
| 141 |
+
at quotient-digit/product selection and one at the resulting remainder.
|
| 142 |
+
Errors occurred across interior positions rather than only at the rounding
|
| 143 |
+
digit, so a wrong quotient digit can cascade into later remainders.
|
| 144 |
+
- **Rare +/− failure:** near-cancellation with mixed signs (e.g. `239 + (−271)`), where the true result is tiny and the model occasionally loses sign or magnitude, about 0.3% of cases.
|
| 145 |
+
- **`÷0`** is a single atomic `<nan>` token.
|
| 146 |
+
|
| 147 |
+
### Is there more headroom?
|
| 148 |
+
|
| 149 |
+
For division problems the model gets wrong at greedy decoding, sampling 32
|
| 150 |
+
times still reaches the correct answer in about 78% of cases, so correct
|
| 151 |
+
alternatives often exist in the distribution. Two conservative GRPO trials
|
| 152 |
+
did **not** convert that headroom into better greedy validation: one stayed at
|
| 153 |
+
96.8% division accuracy and one regressed to 96.6%. The checkpoint-selection
|
| 154 |
+
gates therefore retained the supervised baseline released here.
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
## Technical specifications
|
| 158 |
+
|
| 159 |
+
| | |
|
| 160 |
+
|---|---|
|
| 161 |
+
| Parameters | 1,596,160 |
|
| 162 |
+
| Architecture | looped decoder-only (2 layers × 8 shared loops) |
|
| 163 |
+
| Hidden size / heads | 256 / 8 |
|
| 164 |
+
| Vocabulary | 34 tokens (character + scratchpad control tokens) |
|
| 165 |
+
| Context length | 176 |
|
| 166 |
+
| Scratchpad digit order | reversed for `+`, `−`, `×`; ordinary order for `÷` |
|
| 167 |
+
| Positional encoding | RoPE |
|
| 168 |
+
| Normalization | RMSNorm |
|
| 169 |
+
| Precision | fp32 |
|
| 170 |
+
|
| 171 |
+
## Limitations
|
| 172 |
+
|
| 173 |
+
- Operands are restricted to integers in `[-999, 999]`.
|
| 174 |
+
- The fixed-width scratchpad representation does not generalize to four-digit
|
| 175 |
+
or longer operands.
|
| 176 |
+
- Division reaches 95.2% on the locked test split but 93.1% on the fresh
|
| 177 |
+
uniform benchmark.
|
| 178 |
+
- This is a research model, not a reliable production calculator.
|
| 179 |
+
|
| 180 |
+
## Intended use
|
| 181 |
+
|
| 182 |
+
Education and research. This model is a small, fully inspectable case study in process supervision, step decomposition, and rigorous evaluation of algorithmic generalization. It is intended for studying how explicit intermediate computation can support arithmetic reasoning in small transformers.
|
| 183 |
+
|
| 184 |
+
## References
|
| 185 |
+
|
| 186 |
+
- Nye et al., *Show Your Work: Scratchpads for Intermediate Computation* (2021)
|
| 187 |
+
- McLeish et al., *Transformers Can Do Arithmetic with the Right Embeddings* (NeurIPS 2024)
|
| 188 |
+
- Dehghani et al., *Universal Transformers* (2019)
|
| 189 |
+
- Shao et al., *DeepSeekMath / GRPO* (2024)
|
| 190 |
+
|
| 191 |
+
## License
|
| 192 |
+
|
| 193 |
+
MIT.
|
config.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"architectures": [
|
| 3 |
+
"ThreeDigitBasicCalcForCausalLM"
|
| 4 |
+
],
|
| 5 |
+
"auto_map": {
|
| 6 |
+
"AutoConfig": "config.ThreeDigitBasicCalcConfig",
|
| 7 |
+
"AutoModelForCausalLM": "modeling.ThreeDigitBasicCalcForCausalLM"
|
| 8 |
+
},
|
| 9 |
+
"bos_token_id": 1,
|
| 10 |
+
"dropout": 0.05,
|
| 11 |
+
"dtype": "float32",
|
| 12 |
+
"eos_token_id": 2,
|
| 13 |
+
"hidden_size": 256,
|
| 14 |
+
"max_position_embeddings": 176,
|
| 15 |
+
"model_type": "3-digit-basic-calc",
|
| 16 |
+
"n_loops": 8,
|
| 17 |
+
"num_attention_heads": 8,
|
| 18 |
+
"num_hidden_layers": 2,
|
| 19 |
+
"pad_token_id": 0,
|
| 20 |
+
"rms_eps": 1e-06,
|
| 21 |
+
"rope_base": 10000.0,
|
| 22 |
+
"tie_word_embeddings": false,
|
| 23 |
+
"transformers_version": "5.3.0",
|
| 24 |
+
"unk_token_id": 3,
|
| 25 |
+
"use_cache": false,
|
| 26 |
+
"vocab_size": 34
|
| 27 |
+
}
|
config.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright 2026. Released under the MIT license.
|
| 2 |
+
"""Configuration for the 3-digit-basic-calc looped arithmetic transformer."""
|
| 3 |
+
|
| 4 |
+
from transformers import PretrainedConfig
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class ThreeDigitBasicCalcConfig(PretrainedConfig):
|
| 8 |
+
"""Config for a looped ("universal") decoder-only calculator transformer.
|
| 9 |
+
|
| 10 |
+
A small stack of ``num_hidden_layers`` decoder blocks is applied ``n_loops``
|
| 11 |
+
times with shared weights, giving an effective depth of
|
| 12 |
+
``num_hidden_layers * n_loops`` at the parameter cost of the stack alone.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
model_type = "3-digit-basic-calc"
|
| 16 |
+
|
| 17 |
+
def __init__(
|
| 18 |
+
self,
|
| 19 |
+
vocab_size: int = 34,
|
| 20 |
+
max_position_embeddings: int = 176,
|
| 21 |
+
hidden_size: int = 256,
|
| 22 |
+
num_attention_heads: int = 8,
|
| 23 |
+
num_hidden_layers: int = 2,
|
| 24 |
+
n_loops: int = 8,
|
| 25 |
+
dropout: float = 0.05,
|
| 26 |
+
rms_eps: float = 1e-6,
|
| 27 |
+
rope_base: float = 10000.0,
|
| 28 |
+
pad_token_id: int = 0,
|
| 29 |
+
bos_token_id: int = 1,
|
| 30 |
+
eos_token_id: int = 2,
|
| 31 |
+
unk_token_id: int = 3,
|
| 32 |
+
**kwargs,
|
| 33 |
+
):
|
| 34 |
+
self.vocab_size = vocab_size
|
| 35 |
+
self.max_position_embeddings = max_position_embeddings
|
| 36 |
+
self.hidden_size = hidden_size
|
| 37 |
+
self.num_attention_heads = num_attention_heads
|
| 38 |
+
self.num_hidden_layers = num_hidden_layers
|
| 39 |
+
self.n_loops = n_loops
|
| 40 |
+
self.dropout = dropout
|
| 41 |
+
self.rms_eps = rms_eps
|
| 42 |
+
self.rope_base = rope_base
|
| 43 |
+
self.unk_token_id = unk_token_id
|
| 44 |
+
kwargs.setdefault("tie_word_embeddings", False)
|
| 45 |
+
super().__init__(
|
| 46 |
+
pad_token_id=pad_token_id,
|
| 47 |
+
bos_token_id=bos_token_id,
|
| 48 |
+
eos_token_id=eos_token_id,
|
| 49 |
+
**kwargs,
|
| 50 |
+
)
|
evaluation_results.json
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"schema_version": 1,
|
| 3 |
+
"model": {
|
| 4 |
+
"name": "3-digit-basic-calc",
|
| 5 |
+
"parameters": 1596160,
|
| 6 |
+
"source_checkpoint": "ckp/scratchpad_v3_256x2x8_bs32.pt",
|
| 7 |
+
"representation": "scratchpad-v3",
|
| 8 |
+
"decoding": "greedy"
|
| 9 |
+
},
|
| 10 |
+
"conversion_validation": {
|
| 11 |
+
"date": "2026-07-24",
|
| 12 |
+
"dataset_rows_tokenized": 108000,
|
| 13 |
+
"tokenizer_id_mismatches": 0,
|
| 14 |
+
"learned_tensors_compared": 24,
|
| 15 |
+
"learned_tensor_mismatches": 0,
|
| 16 |
+
"export_tensor_count": 26,
|
| 17 |
+
"derived_export_tensors": [
|
| 18 |
+
"decoder_layers.0.attn.rope.inv_freq",
|
| 19 |
+
"decoder_layers.1.attn.rope.inv_freq"
|
| 20 |
+
],
|
| 21 |
+
"prompt_logit_max_abs_difference": 0.0,
|
| 22 |
+
"complete_162_token_logit_max_abs_difference": 0.0,
|
| 23 |
+
"argmax_mismatches": 0
|
| 24 |
+
},
|
| 25 |
+
"validation": {
|
| 26 |
+
"split": "val",
|
| 27 |
+
"rows": 4000,
|
| 28 |
+
"implementation": "exported Hugging Face architecture",
|
| 29 |
+
"device": "cpu",
|
| 30 |
+
"elapsed_seconds": 1513.414,
|
| 31 |
+
"answer": {
|
| 32 |
+
"+": {"correct": 999, "total": 1000, "accuracy": 0.999},
|
| 33 |
+
"-": {"correct": 997, "total": 1000, "accuracy": 0.997},
|
| 34 |
+
"*": {"correct": 1000, "total": 1000, "accuracy": 1.0},
|
| 35 |
+
"/": {"correct": 968, "total": 1000, "accuracy": 0.968},
|
| 36 |
+
"overall": {"correct": 3964, "total": 4000, "accuracy": 0.991}
|
| 37 |
+
},
|
| 38 |
+
"trace_exact": {
|
| 39 |
+
"+": {"correct": 999, "total": 1000, "accuracy": 0.999},
|
| 40 |
+
"-": {"correct": 997, "total": 1000, "accuracy": 0.997},
|
| 41 |
+
"*": {"correct": 1000, "total": 1000, "accuracy": 1.0},
|
| 42 |
+
"/": {"correct": 949, "total": 1000, "accuracy": 0.949},
|
| 43 |
+
"overall": {"correct": 3945, "total": 4000, "accuracy": 0.98625}
|
| 44 |
+
},
|
| 45 |
+
"parse_success": {"correct": 4000, "total": 4000, "accuracy": 1.0}
|
| 46 |
+
},
|
| 47 |
+
"locked_test": {
|
| 48 |
+
"split": "test",
|
| 49 |
+
"rows": 4000,
|
| 50 |
+
"device": "mps",
|
| 51 |
+
"source_report": "sample/scratchpad_v3_test_eval.json",
|
| 52 |
+
"answer": {
|
| 53 |
+
"+": {"correct": 999, "total": 1000, "accuracy": 0.999},
|
| 54 |
+
"-": {"correct": 999, "total": 1000, "accuracy": 0.999},
|
| 55 |
+
"*": {"correct": 1000, "total": 1000, "accuracy": 1.0},
|
| 56 |
+
"/": {"correct": 952, "total": 1000, "accuracy": 0.952},
|
| 57 |
+
"overall": {"correct": 3950, "total": 4000, "accuracy": 0.9875}
|
| 58 |
+
},
|
| 59 |
+
"sequence_exact": {"correct": 3925, "total": 4000, "accuracy": 0.98125},
|
| 60 |
+
"parse_success": {"correct": 4000, "total": 4000, "accuracy": 1.0}
|
| 61 |
+
},
|
| 62 |
+
"fresh_prompt_disjoint": {
|
| 63 |
+
"seed": 20260723,
|
| 64 |
+
"rows": 3000,
|
| 65 |
+
"sampling": "uniform random operands and operation-balanced, excluding all 108000 dataset prompts",
|
| 66 |
+
"implementation": "native checkpoint after exact exported-logit parity was established",
|
| 67 |
+
"answer": {
|
| 68 |
+
"+": {"correct": 747, "total": 750, "accuracy": 0.996},
|
| 69 |
+
"-": {"correct": 748, "total": 750, "accuracy": 0.9973333333},
|
| 70 |
+
"*": {"correct": 750, "total": 750, "accuracy": 1.0},
|
| 71 |
+
"/": {"correct": 698, "total": 750, "accuracy": 0.9306666667},
|
| 72 |
+
"overall": {"correct": 2943, "total": 3000, "accuracy": 0.981}
|
| 73 |
+
},
|
| 74 |
+
"trace_exact": {
|
| 75 |
+
"+": {"correct": 747, "total": 750, "accuracy": 0.996},
|
| 76 |
+
"-": {"correct": 748, "total": 750, "accuracy": 0.9973333333},
|
| 77 |
+
"*": {"correct": 750, "total": 750, "accuracy": 1.0},
|
| 78 |
+
"/": {"correct": 669, "total": 750, "accuracy": 0.892}
|
| 79 |
+
},
|
| 80 |
+
"elapsed_seconds": 1227.1992
|
| 81 |
+
},
|
| 82 |
+
"division_failure_diagnosis": {
|
| 83 |
+
"source": "locked_test",
|
| 84 |
+
"answer_failures": 48,
|
| 85 |
+
"first_wrong_component": {
|
| 86 |
+
"quotient_digit_or_product": 47,
|
| 87 |
+
"remainder": 1
|
| 88 |
+
},
|
| 89 |
+
"first_wrong_position": {
|
| 90 |
+
"1": 4,
|
| 91 |
+
"2": 11,
|
| 92 |
+
"3": 17,
|
| 93 |
+
"4": 10,
|
| 94 |
+
"5": 4,
|
| 95 |
+
"6": 2
|
| 96 |
+
}
|
| 97 |
+
},
|
| 98 |
+
"grpo": {
|
| 99 |
+
"baseline_division_accuracy": 0.968,
|
| 100 |
+
"trial_1_division_accuracy": 0.968,
|
| 101 |
+
"trial_2_division_accuracy": 0.966,
|
| 102 |
+
"selected_checkpoint": "supervised baseline",
|
| 103 |
+
"reason": "neither trial improved gated greedy validation"
|
| 104 |
+
}
|
| 105 |
+
}
|
generation_config.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_from_model_config": true,
|
| 3 |
+
"bos_token_id": 1,
|
| 4 |
+
"eos_token_id": 2,
|
| 5 |
+
"max_new_tokens": 176,
|
| 6 |
+
"output_attentions": false,
|
| 7 |
+
"output_hidden_states": false,
|
| 8 |
+
"pad_token_id": 0,
|
| 9 |
+
"transformers_version": "5.3.0"
|
| 10 |
+
}
|
model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2c3ce8133b29ab0b902ff00217d3d917555df81a1bba202bcc47eeef34ab6411
|
| 3 |
+
size 6387352
|
modeling.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright 2026. Released under the MIT license.
|
| 2 |
+
"""Self-contained looped decoder-only transformer for character-level arithmetic.
|
| 3 |
+
|
| 4 |
+
This module intentionally has no dependency on the training repository so it can
|
| 5 |
+
run from the Hub with ``trust_remote_code=True``. The building blocks mirror the
|
| 6 |
+
original ``model.py`` (RMSNorm, RoPE, causal attention, GELU MLP) and the
|
| 7 |
+
parameter names match the released checkpoint so weights load directly.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import math
|
| 13 |
+
import re
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
import torch.nn as nn
|
| 17 |
+
|
| 18 |
+
from transformers import PreTrainedModel
|
| 19 |
+
from transformers.generation import GenerationMixin
|
| 20 |
+
from transformers.modeling_outputs import CausalLMOutputWithPast
|
| 21 |
+
|
| 22 |
+
from .config import ThreeDigitBasicCalcConfig
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class WordEmbeddings(nn.Module):
|
| 26 |
+
def __init__(self, d_model: int, vocab_size: int):
|
| 27 |
+
super().__init__()
|
| 28 |
+
self.d_model = d_model
|
| 29 |
+
self.embedding = nn.Embedding(vocab_size, d_model)
|
| 30 |
+
|
| 31 |
+
def forward(self, x):
|
| 32 |
+
return self.embedding(x) * math.sqrt(self.d_model)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class RMSLayerNormalization(nn.Module):
|
| 36 |
+
def __init__(self, d_model: int, eps: float = 1e-6):
|
| 37 |
+
super().__init__()
|
| 38 |
+
self.eps = eps
|
| 39 |
+
self.weight = nn.Parameter(torch.ones(d_model))
|
| 40 |
+
|
| 41 |
+
def forward(self, x):
|
| 42 |
+
mean = x.pow(2).mean(dim=-1, keepdim=True)
|
| 43 |
+
return self.weight * x * torch.rsqrt(mean + self.eps)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class RotaryEmbedding(nn.Module):
|
| 47 |
+
def __init__(self, max_seq_len: int, h_dim: int, base: float = 10000.0):
|
| 48 |
+
super().__init__()
|
| 49 |
+
assert h_dim % 2 == 0
|
| 50 |
+
self.max_seq_len = max_seq_len
|
| 51 |
+
# Persistent so from_pretrained (low-mem loading) always materializes it;
|
| 52 |
+
# cos/sin are derived per forward to avoid stale non-persistent buffers.
|
| 53 |
+
inv_freq = 1.0 / (base ** (torch.arange(0, h_dim, 2).float() / h_dim))
|
| 54 |
+
self.register_buffer("inv_freq", inv_freq, persistent=True)
|
| 55 |
+
# Derived runtime values are deliberately not registered buffers: the
|
| 56 |
+
# checkpoint contains only inv_freq, while repeated loop passes reuse
|
| 57 |
+
# the trigonometric table.
|
| 58 |
+
self._cos_cached = None
|
| 59 |
+
self._sin_cached = None
|
| 60 |
+
self._cache_key = None
|
| 61 |
+
|
| 62 |
+
def _cos_sin(self, length: int, device):
|
| 63 |
+
key = (length, device.type, device.index, self.inv_freq.dtype)
|
| 64 |
+
if self._cache_key == key:
|
| 65 |
+
return self._cos_cached, self._sin_cached
|
| 66 |
+
t = torch.arange(length, device=device, dtype=self.inv_freq.dtype)
|
| 67 |
+
freqs = torch.outer(t, self.inv_freq)
|
| 68 |
+
self._cos_cached = freqs.cos()
|
| 69 |
+
self._sin_cached = freqs.sin()
|
| 70 |
+
self._cache_key = key
|
| 71 |
+
return self._cos_cached, self._sin_cached
|
| 72 |
+
|
| 73 |
+
def forward(self, x, position_ids=None):
|
| 74 |
+
seq = x.shape[-2]
|
| 75 |
+
if position_ids is None:
|
| 76 |
+
position_ids = torch.arange(seq, device=x.device).unsqueeze(0)
|
| 77 |
+
position_ids = position_ids.to(device=x.device, dtype=torch.long)
|
| 78 |
+
required = int(position_ids.max().item()) + 1
|
| 79 |
+
if required > self.max_seq_len:
|
| 80 |
+
raise ValueError(
|
| 81 |
+
f"position {required - 1} exceeds the configured context "
|
| 82 |
+
f"length {self.max_seq_len}"
|
| 83 |
+
)
|
| 84 |
+
cos_table, sin_table = self._cos_sin(required, x.device)
|
| 85 |
+
cos = cos_table[position_ids].unsqueeze(1)
|
| 86 |
+
sin = sin_table[position_ids].unsqueeze(1)
|
| 87 |
+
x1 = x[..., 0::2]
|
| 88 |
+
x2 = x[..., 1::2]
|
| 89 |
+
out1 = x1 * cos - x2 * sin
|
| 90 |
+
out2 = x1 * sin + x2 * cos
|
| 91 |
+
return torch.stack([out1, out2], dim=-1).flatten(-2).type_as(x)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class MultiHeadAttentionBlock(nn.Module):
|
| 95 |
+
def __init__(self, max_seq_len, d_model, h, dropout=0.0, base=10000.0):
|
| 96 |
+
super().__init__()
|
| 97 |
+
assert d_model % h == 0
|
| 98 |
+
self.heads = h
|
| 99 |
+
self.d_head = d_model // h
|
| 100 |
+
self.w_q = nn.Linear(d_model, d_model, bias=False)
|
| 101 |
+
self.w_k = nn.Linear(d_model, d_model, bias=False)
|
| 102 |
+
self.w_v = nn.Linear(d_model, d_model, bias=False)
|
| 103 |
+
self.w_o = nn.Linear(d_model, d_model, bias=False)
|
| 104 |
+
self.rope = RotaryEmbedding(max_seq_len, self.d_head, base)
|
| 105 |
+
self.dropout = nn.Dropout(dropout)
|
| 106 |
+
|
| 107 |
+
def forward(self, x, mask=None, position_ids=None):
|
| 108 |
+
b, s, d = x.shape
|
| 109 |
+
q = self.w_q(x).view(b, s, self.heads, self.d_head).transpose(1, 2)
|
| 110 |
+
k = self.w_k(x).view(b, s, self.heads, self.d_head).transpose(1, 2)
|
| 111 |
+
v = self.w_v(x).view(b, s, self.heads, self.d_head).transpose(1, 2)
|
| 112 |
+
q = self.rope(q, position_ids)
|
| 113 |
+
k = self.rope(k, position_ids)
|
| 114 |
+
attn_score = (q @ k.transpose(-2, -1)) / math.sqrt(self.d_head)
|
| 115 |
+
if mask is not None:
|
| 116 |
+
attn_score = attn_score.masked_fill(~mask, float("-inf"))
|
| 117 |
+
# Left-padded query rows before the first real token have no legal
|
| 118 |
+
# key. Their output is discarded; zeroing only those score rows
|
| 119 |
+
# avoids NaNs while leaving every real-token score bit-identical to
|
| 120 |
+
# the unpadded/native implementation.
|
| 121 |
+
no_legal_key = ~mask.any(dim=-1, keepdim=True)
|
| 122 |
+
attn_score = attn_score.masked_fill(no_legal_key, 0.0)
|
| 123 |
+
attn = torch.softmax(attn_score, dim=-1)
|
| 124 |
+
attn = self.dropout(attn)
|
| 125 |
+
out = (attn @ v).transpose(1, 2).contiguous().reshape(b, s, d)
|
| 126 |
+
return self.w_o(out)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class MLP(nn.Module):
|
| 130 |
+
def __init__(self, d_model, d_ff, dropout=0.0):
|
| 131 |
+
super().__init__()
|
| 132 |
+
self.dropout = nn.Dropout(dropout)
|
| 133 |
+
self.fc1 = nn.Linear(d_model, d_ff)
|
| 134 |
+
self.fc2 = nn.Linear(d_ff, d_model)
|
| 135 |
+
self.act = nn.GELU()
|
| 136 |
+
|
| 137 |
+
def forward(self, x):
|
| 138 |
+
return self.fc2(self.dropout(self.act(self.fc1(x))))
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class DecoderBlock(nn.Module):
|
| 142 |
+
def __init__(self, max_seq_len, d_model, attention_heads, d_ff,
|
| 143 |
+
eps=1e-6, dropout=0.0, base=10000.0):
|
| 144 |
+
super().__init__()
|
| 145 |
+
self.layer_norm_1 = RMSLayerNormalization(d_model, eps)
|
| 146 |
+
self.layer_norm_2 = RMSLayerNormalization(d_model, eps)
|
| 147 |
+
self.attn = MultiHeadAttentionBlock(
|
| 148 |
+
max_seq_len, d_model, attention_heads, dropout, base)
|
| 149 |
+
self.ffn = MLP(d_model, d_ff, dropout)
|
| 150 |
+
self.dropout = nn.Dropout(dropout)
|
| 151 |
+
|
| 152 |
+
def forward(self, x, mask, position_ids=None):
|
| 153 |
+
x = x + self.dropout(
|
| 154 |
+
self.attn(self.layer_norm_1(x), mask, position_ids)
|
| 155 |
+
)
|
| 156 |
+
x = x + self.dropout(self.ffn(self.layer_norm_2(x)))
|
| 157 |
+
return x
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class ThreeDigitBasicCalcForCausalLM(PreTrainedModel, GenerationMixin):
|
| 161 |
+
"""Looped decoder-only causal LM. Parameter names match the checkpoint."""
|
| 162 |
+
|
| 163 |
+
config_class = ThreeDigitBasicCalcConfig
|
| 164 |
+
_supports_cache_class = False
|
| 165 |
+
|
| 166 |
+
def __init__(self, config: ThreeDigitBasicCalcConfig):
|
| 167 |
+
super().__init__(config)
|
| 168 |
+
d = config.hidden_size
|
| 169 |
+
self.embedding = WordEmbeddings(d, config.vocab_size)
|
| 170 |
+
self.decoder_layers = nn.ModuleList([
|
| 171 |
+
DecoderBlock(config.max_position_embeddings, d,
|
| 172 |
+
config.num_attention_heads, 4 * d,
|
| 173 |
+
config.rms_eps, config.dropout, config.rope_base)
|
| 174 |
+
for _ in range(config.num_hidden_layers)
|
| 175 |
+
])
|
| 176 |
+
self.step_emb = nn.Embedding(config.n_loops, d)
|
| 177 |
+
self.layer_norm = RMSLayerNormalization(d, config.rms_eps)
|
| 178 |
+
self.projection = nn.Linear(d, config.vocab_size, bias=False)
|
| 179 |
+
self.config.use_cache = False
|
| 180 |
+
self.post_init()
|
| 181 |
+
|
| 182 |
+
def get_input_embeddings(self):
|
| 183 |
+
return self.embedding.embedding
|
| 184 |
+
|
| 185 |
+
def set_input_embeddings(self, value):
|
| 186 |
+
self.embedding.embedding = value
|
| 187 |
+
|
| 188 |
+
def get_output_embeddings(self):
|
| 189 |
+
return self.projection
|
| 190 |
+
|
| 191 |
+
def _init_weights(self, module):
|
| 192 |
+
if isinstance(module, nn.Linear):
|
| 193 |
+
module.weight.data.normal_(mean=0.0, std=0.02)
|
| 194 |
+
if module.bias is not None:
|
| 195 |
+
module.bias.data.zero_()
|
| 196 |
+
elif isinstance(module, nn.Embedding):
|
| 197 |
+
module.weight.data.normal_(mean=0.0, std=0.02)
|
| 198 |
+
|
| 199 |
+
def forward(
|
| 200 |
+
self,
|
| 201 |
+
input_ids,
|
| 202 |
+
attention_mask=None,
|
| 203 |
+
position_ids=None,
|
| 204 |
+
labels=None,
|
| 205 |
+
**kwargs,
|
| 206 |
+
):
|
| 207 |
+
b, s = input_ids.shape
|
| 208 |
+
if s > self.config.max_position_embeddings:
|
| 209 |
+
raise ValueError(
|
| 210 |
+
f"sequence length {s} exceeds the configured context length "
|
| 211 |
+
f"{self.config.max_position_embeddings}"
|
| 212 |
+
)
|
| 213 |
+
if attention_mask is None:
|
| 214 |
+
attention_mask = torch.ones_like(input_ids, dtype=torch.long)
|
| 215 |
+
if attention_mask.shape != input_ids.shape:
|
| 216 |
+
raise ValueError("attention_mask must have the same shape as input_ids")
|
| 217 |
+
attention_mask = attention_mask.to(device=input_ids.device)
|
| 218 |
+
if position_ids is None:
|
| 219 |
+
position_ids = attention_mask.long().cumsum(dim=-1) - 1
|
| 220 |
+
position_ids = position_ids.clamp_min(0)
|
| 221 |
+
if position_ids.shape[-1] != s:
|
| 222 |
+
raise ValueError("position_ids must have the same sequence length as input_ids")
|
| 223 |
+
|
| 224 |
+
causal = torch.tril(
|
| 225 |
+
torch.ones(s, s, dtype=torch.bool, device=input_ids.device)
|
| 226 |
+
).view(1, 1, s, s)
|
| 227 |
+
key_mask = attention_mask.to(torch.bool).view(b, 1, 1, s)
|
| 228 |
+
combined_mask = causal & key_mask
|
| 229 |
+
x = self.embedding(input_ids)
|
| 230 |
+
for t in range(self.config.n_loops):
|
| 231 |
+
x = x + self.step_emb.weight[t]
|
| 232 |
+
for decoder in self.decoder_layers:
|
| 233 |
+
x = decoder(x, combined_mask, position_ids)
|
| 234 |
+
x = self.layer_norm(x)
|
| 235 |
+
logits = self.projection(x)
|
| 236 |
+
|
| 237 |
+
loss = None
|
| 238 |
+
if labels is not None:
|
| 239 |
+
shift_logits = logits[:, :-1, :].contiguous()
|
| 240 |
+
shift_labels = labels[:, 1:].contiguous()
|
| 241 |
+
shift_attention = attention_mask[:, 1:].to(torch.bool)
|
| 242 |
+
shift_labels = shift_labels.masked_fill(
|
| 243 |
+
~shift_attention | (shift_labels == self.config.pad_token_id),
|
| 244 |
+
-100,
|
| 245 |
+
)
|
| 246 |
+
loss = nn.functional.cross_entropy(
|
| 247 |
+
shift_logits.view(-1, shift_logits.size(-1)),
|
| 248 |
+
shift_labels.view(-1),
|
| 249 |
+
ignore_index=-100,
|
| 250 |
+
)
|
| 251 |
+
return CausalLMOutputWithPast(loss=loss, logits=logits)
|
| 252 |
+
|
| 253 |
+
def prepare_inputs_for_generation(self, input_ids, **kwargs):
|
| 254 |
+
# No KV cache: the model recomputes the full sequence each step.
|
| 255 |
+
attention_mask = kwargs.get("attention_mask")
|
| 256 |
+
if attention_mask is None:
|
| 257 |
+
attention_mask = torch.ones_like(input_ids, dtype=torch.long)
|
| 258 |
+
position_ids = attention_mask.long().cumsum(dim=-1) - 1
|
| 259 |
+
position_ids = position_ids.clamp_min(0)
|
| 260 |
+
return {
|
| 261 |
+
"input_ids": input_ids,
|
| 262 |
+
"attention_mask": attention_mask,
|
| 263 |
+
"position_ids": position_ids,
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
@torch.no_grad()
|
| 267 |
+
def solve(self, tokenizer, expression: str, max_new_tokens: int = 256,
|
| 268 |
+
return_trace: bool = False):
|
| 269 |
+
"""Compute a 3-digit arithmetic expression, e.g. ``model.solve(tok, "842/37")``.
|
| 270 |
+
|
| 271 |
+
Returns the human-readable answer string (or a ``(answer, trace)`` tuple
|
| 272 |
+
when ``return_trace=True``). The model emits an explicit scratchpad; this
|
| 273 |
+
decodes its final ``<ans>`` field back to an ordinary number.
|
| 274 |
+
"""
|
| 275 |
+
a, op, b, prompt = _parse_expression(expression)
|
| 276 |
+
encoded = tokenizer(prompt, return_tensors="pt")
|
| 277 |
+
ids = encoded.input_ids.to(self.device)
|
| 278 |
+
remaining = self.config.max_position_embeddings - ids.shape[1]
|
| 279 |
+
if remaining <= 0:
|
| 280 |
+
raise ValueError("the expression leaves no context for generation")
|
| 281 |
+
max_new_tokens = min(max_new_tokens, remaining)
|
| 282 |
+
out = self.generate(
|
| 283 |
+
ids,
|
| 284 |
+
attention_mask=encoded.attention_mask.to(self.device),
|
| 285 |
+
max_new_tokens=max_new_tokens,
|
| 286 |
+
do_sample=False,
|
| 287 |
+
eos_token_id=tokenizer.eos_token_id,
|
| 288 |
+
pad_token_id=tokenizer.pad_token_id,
|
| 289 |
+
)
|
| 290 |
+
if out[0, -1].item() != tokenizer.eos_token_id:
|
| 291 |
+
raise ValueError(
|
| 292 |
+
"generation reached the context limit before emitting <eos>"
|
| 293 |
+
)
|
| 294 |
+
trace = tokenizer.decode(out[0], skip_special_tokens=False)
|
| 295 |
+
answer = _decode_answer(
|
| 296 |
+
prompt,
|
| 297 |
+
tokenizer.decode(out[0], skip_special_tokens=True),
|
| 298 |
+
parsed_expression=(a, op, b),
|
| 299 |
+
)
|
| 300 |
+
return (answer, trace) if return_trace else answer
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
_EXPRESSION_RE = re.compile(
|
| 304 |
+
r"^\s*(-?\d{1,3})\s*([+\-*/])\s*(-?\d{1,3})\s*=?\s*$"
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def _parse_expression(expression: str):
|
| 309 |
+
if not isinstance(expression, str):
|
| 310 |
+
raise TypeError("expression must be a string")
|
| 311 |
+
match = _EXPRESSION_RE.fullmatch(expression)
|
| 312 |
+
if match is None:
|
| 313 |
+
raise ValueError(
|
| 314 |
+
"expected '<integer><operator><integer>' with operands from "
|
| 315 |
+
"-999 through 999"
|
| 316 |
+
)
|
| 317 |
+
a, op, b = int(match.group(1)), match.group(2), int(match.group(3))
|
| 318 |
+
prompt = f"{a}{op}{b}="
|
| 319 |
+
return a, op, b, prompt
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def _magnitude_plan(a: int, op: str, b: int):
|
| 323 |
+
if op in ("*", "/"):
|
| 324 |
+
effective = "mul" if op == "*" else "div"
|
| 325 |
+
if op == "/" and b == 0:
|
| 326 |
+
return effective, "positive", abs(a), 0
|
| 327 |
+
sign = "negative" if (a < 0) != (b < 0) else "positive"
|
| 328 |
+
return effective, sign, abs(a), abs(b)
|
| 329 |
+
|
| 330 |
+
left_term = a
|
| 331 |
+
right_term = b if op == "+" else -b
|
| 332 |
+
if left_term == 0 and right_term == 0:
|
| 333 |
+
return "add", "positive", 0, 0
|
| 334 |
+
if left_term == 0:
|
| 335 |
+
sign = "negative" if right_term < 0 else "positive"
|
| 336 |
+
return "add", sign, 0, abs(right_term)
|
| 337 |
+
if right_term == 0:
|
| 338 |
+
sign = "negative" if left_term < 0 else "positive"
|
| 339 |
+
return "add", sign, abs(left_term), 0
|
| 340 |
+
if (left_term < 0) == (right_term < 0):
|
| 341 |
+
sign = "negative" if left_term < 0 else "positive"
|
| 342 |
+
return "add", sign, abs(left_term), abs(right_term)
|
| 343 |
+
if abs(left_term) == abs(right_term):
|
| 344 |
+
return "sub", "positive", abs(left_term), abs(right_term)
|
| 345 |
+
if abs(left_term) > abs(right_term):
|
| 346 |
+
sign = "negative" if left_term < 0 else "positive"
|
| 347 |
+
return "sub", sign, abs(left_term), abs(right_term)
|
| 348 |
+
sign = "negative" if right_term < 0 else "positive"
|
| 349 |
+
return "sub", sign, abs(right_term), abs(left_term)
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _decode_answer(
|
| 353 |
+
prompt: str,
|
| 354 |
+
decoded: str,
|
| 355 |
+
*,
|
| 356 |
+
parsed_expression=None,
|
| 357 |
+
) -> str:
|
| 358 |
+
"""Strictly validate a v3 scratchpad and decode its fixed answer field."""
|
| 359 |
+
if parsed_expression is None:
|
| 360 |
+
a, op, b, canonical_prompt = _parse_expression(prompt)
|
| 361 |
+
else:
|
| 362 |
+
a, op, b = parsed_expression
|
| 363 |
+
canonical_prompt = f"{a}{op}{b}="
|
| 364 |
+
if prompt != canonical_prompt:
|
| 365 |
+
raise ValueError("prompt is not in canonical form")
|
| 366 |
+
if not decoded.startswith(canonical_prompt):
|
| 367 |
+
raise ValueError("generated scratchpad does not begin with the prompt")
|
| 368 |
+
|
| 369 |
+
effective, sign, left, right = _magnitude_plan(a, op, b)
|
| 370 |
+
operation_token = {
|
| 371 |
+
"add": "<add>",
|
| 372 |
+
"sub": "<sub>",
|
| 373 |
+
"mul": "<mul>",
|
| 374 |
+
"div": "<div>",
|
| 375 |
+
}[effective]
|
| 376 |
+
sign_token = "<neg>" if sign == "negative" else "<pos>"
|
| 377 |
+
if effective == "div":
|
| 378 |
+
state = f"{left:03d}{right:03d}"
|
| 379 |
+
else:
|
| 380 |
+
state = f"{left:03d}"[::-1] + f"{right:03d}"[::-1]
|
| 381 |
+
prefix = (
|
| 382 |
+
re.escape(canonical_prompt)
|
| 383 |
+
+ re.escape(operation_token + sign_token + "<state>" + state)
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
if effective in ("add", "sub"):
|
| 387 |
+
body = r"(?:<step>\d{5}){3}<ans>(?P<answer>\d{4})"
|
| 388 |
+
elif effective == "mul":
|
| 389 |
+
groups = []
|
| 390 |
+
for term_count in (1, 2, 3, 2, 1):
|
| 391 |
+
groups.append(rf"(?:<step>\d{{7}}){{{term_count}}}<col>\d{{3}}")
|
| 392 |
+
body = "".join(groups) + r"<ans>(?P<answer>\d{6})"
|
| 393 |
+
elif b == 0:
|
| 394 |
+
body = r"<ans>(?P<answer><nan>)"
|
| 395 |
+
else:
|
| 396 |
+
record = r"<step>\d{8}<qmul>\d{5}<rem>\d{3}"
|
| 397 |
+
body = rf"(?:{record}){{7}}<ans>(?P<answer>\d{{3}}\.\d{{3}})"
|
| 398 |
+
|
| 399 |
+
match = re.fullmatch(prefix + body, decoded)
|
| 400 |
+
if match is None:
|
| 401 |
+
raise ValueError("generated scratchpad does not match the v3 grammar")
|
| 402 |
+
internal = match.group("answer")
|
| 403 |
+
if internal == "<nan>":
|
| 404 |
+
return "NAN"
|
| 405 |
+
if op == "/":
|
| 406 |
+
integer, fraction = internal.split(".", 1)
|
| 407 |
+
fraction = fraction.rstrip("0")
|
| 408 |
+
magnitude = str(int(integer)) + (f".{fraction}" if fraction else "")
|
| 409 |
+
else:
|
| 410 |
+
magnitude = str(int(internal[::-1]))
|
| 411 |
+
if sign == "negative" and magnitude != "0":
|
| 412 |
+
return "-" + magnitude
|
| 413 |
+
return magnitude
|
notebook.ipynb
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"metadata": {},
|
| 6 |
+
"source": [
|
| 7 |
+
"# \ud83e\uddee 3-digit-basic-calc \u2014 3-digit arithmetic with a 1.6M-parameter transformer\n",
|
| 8 |
+
"\n",
|
| 9 |
+
"A from-scratch transformer that does `+ \u2212 \u00d7 \u00f7` on 3-digit numbers by writing out\n",
|
| 10 |
+
"the algorithm step by step. This notebook loads the model and runs it.\n",
|
| 11 |
+
"\n",
|
| 12 |
+
"Model: [`vmal/3-digit-basic-calc`](https://huggingface.co/vmal/3-digit-basic-calc)\n"
|
| 13 |
+
]
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"cell_type": "code",
|
| 17 |
+
"metadata": {},
|
| 18 |
+
"execution_count": null,
|
| 19 |
+
"outputs": [],
|
| 20 |
+
"source": [
|
| 21 |
+
"%pip install -q transformers==5.3.0 torch safetensors\n"
|
| 22 |
+
]
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"cell_type": "markdown",
|
| 26 |
+
"metadata": {},
|
| 27 |
+
"source": [
|
| 28 |
+
"## Load the model\n"
|
| 29 |
+
]
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"cell_type": "code",
|
| 33 |
+
"metadata": {},
|
| 34 |
+
"execution_count": null,
|
| 35 |
+
"outputs": [],
|
| 36 |
+
"source": [
|
| 37 |
+
"from transformers import AutoModelForCausalLM, AutoTokenizer\n",
|
| 38 |
+
"\n",
|
| 39 |
+
"REPO = \"vmal/3-digit-basic-calc\"\n",
|
| 40 |
+
"model = AutoModelForCausalLM.from_pretrained(REPO, trust_remote_code=True).eval()\n",
|
| 41 |
+
"tok = AutoTokenizer.from_pretrained(REPO, trust_remote_code=True)\n",
|
| 42 |
+
"print(\"parameters:\", sum(p.numel() for p in model.parameters()))\n"
|
| 43 |
+
]
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"cell_type": "markdown",
|
| 47 |
+
"metadata": {},
|
| 48 |
+
"source": [
|
| 49 |
+
"## Solve some problems\n",
|
| 50 |
+
"\n",
|
| 51 |
+
"`model.solve(tokenizer, expression)` returns the human-readable answer.\n"
|
| 52 |
+
]
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"cell_type": "code",
|
| 56 |
+
"metadata": {},
|
| 57 |
+
"execution_count": null,
|
| 58 |
+
"outputs": [],
|
| 59 |
+
"source": [
|
| 60 |
+
"for e in [\"842/37\", \"213*145\", \"999*999\", \"-500+500\", \"3/31\", \"12/0\"]:\n",
|
| 61 |
+
" print(f\"{e:>10} = {model.solve(tok, e)}\")\n"
|
| 62 |
+
]
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"cell_type": "markdown",
|
| 66 |
+
"metadata": {},
|
| 67 |
+
"source": [
|
| 68 |
+
"## See the model's reasoning\n",
|
| 69 |
+
"\n",
|
| 70 |
+
"Pass `return_trace=True` to get the raw scratchpad the model generates \u2014\n",
|
| 71 |
+
"each `<step>`/`<qmul>`/`<rem>` is one local computation.\n"
|
| 72 |
+
]
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"cell_type": "code",
|
| 76 |
+
"metadata": {},
|
| 77 |
+
"execution_count": null,
|
| 78 |
+
"outputs": [],
|
| 79 |
+
"source": [
|
| 80 |
+
"answer, trace = model.solve(tok, \"842/37\", return_trace=True)\n",
|
| 81 |
+
"print(\"answer:\", answer)\n",
|
| 82 |
+
"print()\n",
|
| 83 |
+
"for c in ['<div>','<mul>','<add>','<sub>','<state>','<step>','<qmul>','<rem>','<col>','<ans>']:\n",
|
| 84 |
+
" trace = trace.replace(c, '\\n'+c+' ')\n",
|
| 85 |
+
"print(trace.strip())\n"
|
| 86 |
+
]
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
"cell_type": "markdown",
|
| 90 |
+
"metadata": {},
|
| 91 |
+
"source": [
|
| 92 |
+
"## Quick in-range behavior check on random problems\n",
|
| 93 |
+
"\n",
|
| 94 |
+
"This small smoke test samples the supported operand range. It does not load\n",
|
| 95 |
+
"the training prompts, so it should not be described as a leakage-controlled\n",
|
| 96 |
+
"unseen benchmark; the card reports that benchmark separately.\n"
|
| 97 |
+
]
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
"cell_type": "code",
|
| 101 |
+
"metadata": {},
|
| 102 |
+
"execution_count": null,
|
| 103 |
+
"outputs": [],
|
| 104 |
+
"source": [
|
| 105 |
+
"import random\n",
|
| 106 |
+
"random.seed(0)\n",
|
| 107 |
+
"def truth(a, b, op):\n",
|
| 108 |
+
" if op == '+': return str(a + b)\n",
|
| 109 |
+
" if op == '-': return str(a - b)\n",
|
| 110 |
+
" if op == '*': return str(a * b)\n",
|
| 111 |
+
" if b == 0: return 'NAN'\n",
|
| 112 |
+
" # Exact integer round-half-up to three decimals (no float/banker's rounding).\n",
|
| 113 |
+
" denominator = abs(b)\n",
|
| 114 |
+
" scaled, remainder = divmod(abs(a) * 1000, denominator)\n",
|
| 115 |
+
" scaled += int(2 * remainder >= denominator)\n",
|
| 116 |
+
" integer, fraction = divmod(scaled, 1000)\n",
|
| 117 |
+
" magnitude = str(integer)\n",
|
| 118 |
+
" if fraction:\n",
|
| 119 |
+
" magnitude += '.' + f'{fraction:03d}'.rstrip('0')\n",
|
| 120 |
+
" negative = (a < 0) != (b < 0)\n",
|
| 121 |
+
" return '-' + magnitude if negative and scaled else magnitude\n",
|
| 122 |
+
"good=n=0\n",
|
| 123 |
+
"for _ in range(30):\n",
|
| 124 |
+
" a=random.randint(-999,999); b=random.randint(-999,999); op=random.choice('+-*/')\n",
|
| 125 |
+
" if op=='/' and b==0: b=7\n",
|
| 126 |
+
" got=model.solve(tok, f'{a}{op}{b}'); exp=truth(a,b,op)\n",
|
| 127 |
+
" good+= (got==exp); n+=1\n",
|
| 128 |
+
"print(f'{good}/{n} correct on random in-range problems')\n"
|
| 129 |
+
]
|
| 130 |
+
}
|
| 131 |
+
],
|
| 132 |
+
"metadata": {
|
| 133 |
+
"kernelspec": {
|
| 134 |
+
"display_name": "Python 3",
|
| 135 |
+
"language": "python",
|
| 136 |
+
"name": "python3"
|
| 137 |
+
},
|
| 138 |
+
"language_info": {
|
| 139 |
+
"name": "python"
|
| 140 |
+
}
|
| 141 |
+
},
|
| 142 |
+
"nbformat": 4,
|
| 143 |
+
"nbformat_minor": 5
|
| 144 |
+
}
|
special_tokens_map.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"bos_token": "<bos>",
|
| 3 |
+
"eos_token": "<eos>",
|
| 4 |
+
"pad_token": "<pad>",
|
| 5 |
+
"unk_token": "<unk>"
|
| 6 |
+
}
|
tokenizer.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright 2026. Released under the MIT license.
|
| 2 |
+
"""Character/control-token tokenizer for the 3-digit-basic-calc calculator model.
|
| 3 |
+
|
| 4 |
+
Digits, operators and symbols are single characters; the scratchpad grammar adds
|
| 5 |
+
multi-character control tokens (``<add>``, ``<step>``, ``<qmul>`` ...). A minus
|
| 6 |
+
sign is emitted as the unary token ``~`` at the start of an expression, after
|
| 7 |
+
``=``, or immediately after a binary operator, so ``12*-34`` and ``5--3`` stay
|
| 8 |
+
unambiguous while ``-`` remains the binary subtraction operator.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import os
|
| 15 |
+
|
| 16 |
+
from transformers import PreTrainedTokenizer
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
SPECIAL_TOKENS = ["<pad>", "<bos>", "<eos>", "<unk>"]
|
| 20 |
+
DIGITS = list("0123456789")
|
| 21 |
+
OPERATORS = ["+", "-", "/", "*"]
|
| 22 |
+
SYMBOLS = ["=", ".", "~"]
|
| 23 |
+
CONTROL_TOKENS = [
|
| 24 |
+
"<add>", "<sub>", "<mul>", "<div>", "<pos>", "<neg>",
|
| 25 |
+
"<state>", "<step>", "<ans>", "<nan>", "<col>", "<qmul>", "<rem>",
|
| 26 |
+
]
|
| 27 |
+
TOKENS = SPECIAL_TOKENS + DIGITS + OPERATORS + SYMBOLS + CONTROL_TOKENS
|
| 28 |
+
_UNARY_PREDECESSORS = {"=", "+", "-", "/", "*"}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ThreeDigitBasicCalcTokenizer(PreTrainedTokenizer):
|
| 32 |
+
vocab_files_names = {"vocab_file": "vocab.json"}
|
| 33 |
+
model_input_names = ["input_ids", "attention_mask"]
|
| 34 |
+
|
| 35 |
+
def __init__(self, vocab_file=None, **kwargs):
|
| 36 |
+
if vocab_file and os.path.isfile(vocab_file):
|
| 37 |
+
with open(vocab_file, encoding="utf-8") as handle:
|
| 38 |
+
self._vocab = json.load(handle)
|
| 39 |
+
else:
|
| 40 |
+
self._vocab = {tok: i for i, tok in enumerate(TOKENS)}
|
| 41 |
+
self._ids_to_tokens = {i: t for t, i in self._vocab.items()}
|
| 42 |
+
self._ordered_controls = sorted(CONTROL_TOKENS, key=len, reverse=True)
|
| 43 |
+
kwargs.setdefault("pad_token", "<pad>")
|
| 44 |
+
kwargs.setdefault("bos_token", "<bos>")
|
| 45 |
+
kwargs.setdefault("eos_token", "<eos>")
|
| 46 |
+
kwargs.setdefault("unk_token", "<unk>")
|
| 47 |
+
# Generation takes the logits from the final sequence position. Left
|
| 48 |
+
# padding therefore keeps the final position on a real prompt token for
|
| 49 |
+
# every member of a mixed-length batch.
|
| 50 |
+
kwargs.setdefault("padding_side", "left")
|
| 51 |
+
super().__init__(**kwargs)
|
| 52 |
+
|
| 53 |
+
@property
|
| 54 |
+
def vocab_size(self):
|
| 55 |
+
return len(self._vocab)
|
| 56 |
+
|
| 57 |
+
def get_vocab(self):
|
| 58 |
+
return dict(self._vocab)
|
| 59 |
+
|
| 60 |
+
def _tokenize(self, text):
|
| 61 |
+
tokens = []
|
| 62 |
+
index = 0
|
| 63 |
+
while index < len(text):
|
| 64 |
+
control = next(
|
| 65 |
+
(t for t in self._ordered_controls if text.startswith(t, index)),
|
| 66 |
+
None,
|
| 67 |
+
)
|
| 68 |
+
if control is not None:
|
| 69 |
+
tokens.append(control)
|
| 70 |
+
index += len(control)
|
| 71 |
+
continue
|
| 72 |
+
ch = text[index]
|
| 73 |
+
if ch == "-" and (
|
| 74 |
+
index == 0 or text[index - 1] in _UNARY_PREDECESSORS):
|
| 75 |
+
tokens.append("~")
|
| 76 |
+
else:
|
| 77 |
+
tokens.append(ch)
|
| 78 |
+
index += 1
|
| 79 |
+
return tokens
|
| 80 |
+
|
| 81 |
+
def _convert_token_to_id(self, token):
|
| 82 |
+
return self._vocab.get(token, self._vocab["<unk>"])
|
| 83 |
+
|
| 84 |
+
def _convert_id_to_token(self, index):
|
| 85 |
+
return self._ids_to_tokens.get(index, "<unk>")
|
| 86 |
+
|
| 87 |
+
def convert_tokens_to_string(self, tokens):
|
| 88 |
+
out = []
|
| 89 |
+
for tok in tokens:
|
| 90 |
+
if tok in SPECIAL_TOKENS:
|
| 91 |
+
continue
|
| 92 |
+
out.append("-" if tok == "~" else tok)
|
| 93 |
+
return "".join(out)
|
| 94 |
+
|
| 95 |
+
def build_inputs_with_special_tokens(self, token_ids_0, token_ids_1=None):
|
| 96 |
+
bos = [self._vocab["<bos>"]]
|
| 97 |
+
if token_ids_1 is None:
|
| 98 |
+
return bos + token_ids_0
|
| 99 |
+
return bos + token_ids_0 + token_ids_1
|
| 100 |
+
|
| 101 |
+
def save_vocabulary(self, save_directory, filename_prefix=None):
|
| 102 |
+
path = os.path.join(
|
| 103 |
+
save_directory,
|
| 104 |
+
(filename_prefix + "-" if filename_prefix else "") + "vocab.json",
|
| 105 |
+
)
|
| 106 |
+
with open(path, "w", encoding="utf-8") as handle:
|
| 107 |
+
json.dump(self._vocab, handle, ensure_ascii=False, indent=2)
|
| 108 |
+
return (path,)
|
tokenizer_config.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"added_tokens_decoder": {
|
| 3 |
+
"0": {
|
| 4 |
+
"content": "<pad>",
|
| 5 |
+
"lstrip": false,
|
| 6 |
+
"normalized": false,
|
| 7 |
+
"rstrip": false,
|
| 8 |
+
"single_word": false,
|
| 9 |
+
"special": true
|
| 10 |
+
},
|
| 11 |
+
"1": {
|
| 12 |
+
"content": "<bos>",
|
| 13 |
+
"lstrip": false,
|
| 14 |
+
"normalized": false,
|
| 15 |
+
"rstrip": false,
|
| 16 |
+
"single_word": false,
|
| 17 |
+
"special": true
|
| 18 |
+
},
|
| 19 |
+
"2": {
|
| 20 |
+
"content": "<eos>",
|
| 21 |
+
"lstrip": false,
|
| 22 |
+
"normalized": false,
|
| 23 |
+
"rstrip": false,
|
| 24 |
+
"single_word": false,
|
| 25 |
+
"special": true
|
| 26 |
+
},
|
| 27 |
+
"3": {
|
| 28 |
+
"content": "<unk>",
|
| 29 |
+
"lstrip": false,
|
| 30 |
+
"normalized": false,
|
| 31 |
+
"rstrip": false,
|
| 32 |
+
"single_word": false,
|
| 33 |
+
"special": true
|
| 34 |
+
}
|
| 35 |
+
},
|
| 36 |
+
"backend": "custom",
|
| 37 |
+
"bos_token": "<bos>",
|
| 38 |
+
"eos_token": "<eos>",
|
| 39 |
+
"model_max_length": 176,
|
| 40 |
+
"pad_token": "<pad>",
|
| 41 |
+
"padding_side": "left",
|
| 42 |
+
"tokenizer_class": "ThreeDigitBasicCalcTokenizer",
|
| 43 |
+
"unk_token": "<unk>",
|
| 44 |
+
"auto_map": {
|
| 45 |
+
"AutoTokenizer": [
|
| 46 |
+
"tokenizer.ThreeDigitBasicCalcTokenizer",
|
| 47 |
+
null
|
| 48 |
+
]
|
| 49 |
+
}
|
| 50 |
+
}
|
vocab.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"<pad>": 0,
|
| 3 |
+
"<bos>": 1,
|
| 4 |
+
"<eos>": 2,
|
| 5 |
+
"<unk>": 3,
|
| 6 |
+
"0": 4,
|
| 7 |
+
"1": 5,
|
| 8 |
+
"2": 6,
|
| 9 |
+
"3": 7,
|
| 10 |
+
"4": 8,
|
| 11 |
+
"5": 9,
|
| 12 |
+
"6": 10,
|
| 13 |
+
"7": 11,
|
| 14 |
+
"8": 12,
|
| 15 |
+
"9": 13,
|
| 16 |
+
"+": 14,
|
| 17 |
+
"-": 15,
|
| 18 |
+
"/": 16,
|
| 19 |
+
"*": 17,
|
| 20 |
+
"=": 18,
|
| 21 |
+
".": 19,
|
| 22 |
+
"~": 20,
|
| 23 |
+
"<add>": 21,
|
| 24 |
+
"<sub>": 22,
|
| 25 |
+
"<mul>": 23,
|
| 26 |
+
"<div>": 24,
|
| 27 |
+
"<pos>": 25,
|
| 28 |
+
"<neg>": 26,
|
| 29 |
+
"<state>": 27,
|
| 30 |
+
"<step>": 28,
|
| 31 |
+
"<ans>": 29,
|
| 32 |
+
"<nan>": 30,
|
| 33 |
+
"<col>": 31,
|
| 34 |
+
"<qmul>": 32,
|
| 35 |
+
"<rem>": 33
|
| 36 |
+
}
|