tevro 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tevro-0.1.0/.gitignore +17 -0
- tevro-0.1.0/LICENSE +21 -0
- tevro-0.1.0/PKG-INFO +199 -0
- tevro-0.1.0/README.md +179 -0
- tevro-0.1.0/pyproject.toml +60 -0
- tevro-0.1.0/tevro/__init__.py +13 -0
- tevro-0.1.0/tevro/cli.py +24 -0
- tevro-0.1.0/tevro/inputs.py +64 -0
- tevro-0.1.0/tevro/network.py +137 -0
- tevro-0.1.0/tevro/predict.py +85 -0
- tevro-0.1.0/tevro/tokenization.py +87 -0
tevro-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
*.egg-info
|
|
7
|
+
|
|
8
|
+
# Virtual environments
|
|
9
|
+
.venv
|
|
10
|
+
|
|
11
|
+
# Training runs (LoRA + head checkpoints, metrics)
|
|
12
|
+
runs/
|
|
13
|
+
|
|
14
|
+
# Local environments and data
|
|
15
|
+
.env
|
|
16
|
+
data/
|
|
17
|
+
.DS_Store
|
tevro-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kartik Gadad
|
|
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.
|
tevro-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tevro
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small decision model: picks one option and gives a probability for every option, in one forward pass
|
|
5
|
+
Project-URL: Homepage, https://github.com/kartikg-gk/tevro
|
|
6
|
+
Project-URL: Model, https://huggingface.co/kartikg1/tevro-decider
|
|
7
|
+
Author: Kartik Gadad
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: calibration,classification,decision model,lora,qwen3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: huggingface-hub>=0.25.0
|
|
15
|
+
Requires-Dist: peft>=0.21.0
|
|
16
|
+
Requires-Dist: safetensors>=0.6.0
|
|
17
|
+
Requires-Dist: torch>=2.14.0
|
|
18
|
+
Requires-Dist: transformers>=5.17.0
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# Tevro
|
|
22
|
+
|
|
23
|
+
Tevro is a small decision model, and this repository holds its training code and a pip package for running it. It reads a state, a question and a fixed set of options, and returns one probability per option in a single forward pass. It handles three question types: multiple choice, yes/no, and ordinal scores.
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
## Use the model
|
|
28
|
+
|
|
29
|
+
The trained model is on Hugging Face: **[kartikg1/tevro-decider](https://huggingface.co/kartikg1/tevro-decider)**. It needs Python 3.12 and about 3 GB of free memory; a GPU is optional.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install tevro
|
|
33
|
+
tevro predict request.json --device cpu
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`tevro predict` reads one request from a JSON file (examples below) and prints the answer. The weights and the base model are downloaded on the first run. Use `--device cuda` on an NVIDIA GPU. From Python:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import tevro
|
|
40
|
+
|
|
41
|
+
model = tevro.load() # downloads kartikg1/tevro-decider on first use
|
|
42
|
+
model.answer("Parcel A is on shelf B2. Parcel B is on shelf C3.",
|
|
43
|
+
{"type": "choice", "instructions": "Which shelf holds Parcel A?",
|
|
44
|
+
"criteria": {"a": "B2", "b": "C3"}})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The pip package only runs the model. Training code, data tools and evaluation scripts live in this repository.
|
|
48
|
+
|
|
49
|
+
**Multiple choice**
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{"state": "Parcel A is on shelf B2. Parcel B is on shelf C3.",
|
|
53
|
+
"question": {"type": "choice", "instructions": "Which shelf holds Parcel A?",
|
|
54
|
+
"criteria": {"a": "B2", "b": "C3"}}}
|
|
55
|
+
```
|
|
56
|
+
```json
|
|
57
|
+
{"confidence": 0.99996, "abstain": false, "choice": "a",
|
|
58
|
+
"probabilities": {"a": 0.99996, "b": 0.00004}}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Yes/no**
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{"state": "Refunds need a receipt and a purchase within 30 days. The customer bought it 12 days ago and has no receipt.",
|
|
65
|
+
"question": {"type": "noul", "instructions": "Should the refund be issued?"}}
|
|
66
|
+
```
|
|
67
|
+
```json
|
|
68
|
+
{"confidence": 0.933, "abstain": false, "noul": 0.067}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`noul` is the probability of yes.
|
|
72
|
+
|
|
73
|
+
**Score**
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{"state": "The app crashes for every user when they open the payments page. No data is lost.",
|
|
77
|
+
"question": {"type": "score", "instructions": "How severe is this bug?",
|
|
78
|
+
"criteria": ["Cosmetic only", "Minor, with a workaround", "A core feature is blocked", "Data loss or harm"]}}
|
|
79
|
+
```
|
|
80
|
+
```json
|
|
81
|
+
{"confidence": 0.431, "abstain": false, "score": 1.65,
|
|
82
|
+
"probabilities": [0.019, 0.431, 0.428, 0.122]}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`score` is the expected level (0 to 3 here). A low `confidence` like this one means the model is unsure; send those cases to a person.
|
|
86
|
+
|
|
87
|
+
## Results
|
|
88
|
+
|
|
89
|
+
| Evaluation | Accuracy |
|
|
90
|
+
| --- | ---: |
|
|
91
|
+
| Separate held-out test split (5,000) | 80.2% |
|
|
92
|
+
| — multiple choice / yes-no / score | 80.8% / 88.0% / 65.8% |
|
|
93
|
+
| Training dataset's test split (5,000) | 80.9% |
|
|
94
|
+
| JevBench public (231) | 57.6% |
|
|
95
|
+
| — easy / standard / hard | 97.9% / 66.7% / 34.2% |
|
|
96
|
+
|
|
97
|
+
On the held-out split, yes/no AUC is 0.953 and expected calibration error is 0.037. At confidence 0.95 or higher the model answers 48.9% of questions and is right on 98.0% of them.
|
|
98
|
+
|
|
99
|
+
## Set up for training
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
git clone https://github.com/kartikg-gk/tevro
|
|
103
|
+
cd tevro
|
|
104
|
+
uv sync
|
|
105
|
+
uv run pytest -q tests
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Python 3.12, managed with [uv](https://docs.astral.sh/uv/). On Windows, `uv sync` installs the CUDA 13.0 build of PyTorch; on Linux it uses the default PyPI build. The tests use a tiny randomly initialised model and run on CPU.
|
|
109
|
+
|
|
110
|
+
## Train
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
uv run python train.py configs/smoke.yaml
|
|
114
|
+
uv run python train.py configs/check_20k.yaml
|
|
115
|
+
uv run python train.py configs/release_12k.yaml
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
| Config | What it does | Hardware |
|
|
119
|
+
| --- | --- | --- |
|
|
120
|
+
| `smoke.yaml` | Overfits 10 questions; train accuracy should reach 1.0 | any GPU |
|
|
121
|
+
| `check_20k.yaml` | 20,000 questions, 1,000 test, bf16 weights, micro-batch 2 × 32 | 6 GB laptop GPU, ~45 min |
|
|
122
|
+
| `release_12k.yaml` | The released model: 12,000 updates of 64 questions, fp32 | A100 80 GB, ~6.5 h |
|
|
123
|
+
| `finetune_rules.yaml`, `finetune_v2.yaml` | Short fine-tunes of the released model on a 20K rule-reasoning mix (`--resume` the release checkpoint); ~10 min | A100 80 GB |
|
|
124
|
+
|
|
125
|
+
Each run writes `runs/<name>-<timestamp>/` with the config, `metrics.jsonl`, `summary.json` and checkpoints (`best`, `latest`, `final`; LoRA adapter and head only). Every training log line shows `eta_min`, the projected minutes left. `--resume <run or checkpoint>` reloads the weights and starts a fresh optimizer.
|
|
126
|
+
|
|
127
|
+
### On Modal
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
modal run --detach modal_app.py --config configs/release_12k.yaml
|
|
131
|
+
modal run modal_app.py::evaluate --checkpoint runs/<run>/checkpoints/final --out eval.txt
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`modal_app.py` uploads this folder, keeps `runs/` and the Hugging Face cache on Modal Volumes, runs on an A100 80 GB (set `TEVRO_GPU` to change it), and caps a training job at 9 hours.
|
|
135
|
+
|
|
136
|
+
## Evaluate
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
uv run python inference.py runs/<run>/checkpoints/final --max_questions 5000
|
|
140
|
+
uv run python jevbench_eval.py runs/<run>/checkpoints/final <jevbench>/datasets/public
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The first scores a checkpoint on the training dataset's test split: accuracy per question type, yes/no AUC, ECE, Brier score, reliability and resolution, and accuracy at confidence 0.9 and 0.95. The second scores the 231 public [JevBench](https://github.com/fstandhartinger/jevbench) items by tier.
|
|
144
|
+
|
|
145
|
+
## Data
|
|
146
|
+
|
|
147
|
+
Each row is one state with one or more questions:
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
{
|
|
151
|
+
"state": "Parcel A is on shelf B2. Parcel B is on shelf C3.",
|
|
152
|
+
"questions_json": "{\"q1\": {\"type\": \"choice\", \"instructions\": \"Which shelf holds Parcel A?\", \"criteria\": {\"a\": \"B2\", \"b\": \"C3\"}, \"label\": \"a\"}}"
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- `choice`: `criteria` maps option keys to descriptions; `label` is a key.
|
|
157
|
+
- `noul` (yes/no): `label` is `true` or `false`.
|
|
158
|
+
- `score`: `criteria` is an ordered list of levels; `label` is the level index.
|
|
159
|
+
|
|
160
|
+
By default the code loads [kartikg1/tevro-decision-v1](https://huggingface.co/datasets/kartikg1/tevro-decision-v1) (250,000 training and 5,000 test questions; private). Point `data.question_files` at your own parquet files (`train` and `test`) to train on your data. `audit.py` reports label-position, length and negation shortcuts before you spend GPU time.
|
|
161
|
+
|
|
162
|
+
## Model
|
|
163
|
+
|
|
164
|
+
| Part | Setting |
|
|
165
|
+
| --- | --- |
|
|
166
|
+
| Base | Qwen3-0.6B, first 20 of 28 layers |
|
|
167
|
+
| Adapter | LoRA rank 8, alpha 16, on q/k/v/o of layers 6–19 |
|
|
168
|
+
| Input | task hint + question + state, then each option as its own span starting at the same position, then `The answer is:` |
|
|
169
|
+
| Head | question-type embedding on every token, 2 bidirectional transformer layers (1024-d, 16 heads, dropout 0.1), scalar scorer at each option's last token |
|
|
170
|
+
| Loss | cross-entropy over the options |
|
|
171
|
+
| Optimizer | AdamW, LoRA lr 1e-4, head lr 1e-3, weight decay 0.01, cosine schedule, 5% warmup, gradient clip 1.0 |
|
|
172
|
+
|
|
173
|
+
Options cannot attend to each other, so reordering them only reorders the probabilities. The test suite checks this.
|
|
174
|
+
|
|
175
|
+
## Repository layout
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
tevro/ the pip package: copies of network.py and tokenization.py, plus predict.py and cli.py
|
|
179
|
+
tokenization.py input layout: positions, attention mask, read indices
|
|
180
|
+
network.py base model, LoRA, decision head
|
|
181
|
+
dataset.py loading, question expansion, batching
|
|
182
|
+
train.py training loop
|
|
183
|
+
inference.py evaluation metrics and single answers
|
|
184
|
+
calibrate.py temperature fitting, abstention thresholds, calibration metrics
|
|
185
|
+
jevbench_eval.py JevBench public evaluation
|
|
186
|
+
audit.py data shortcut checks
|
|
187
|
+
synthetic.py procedural decision questions
|
|
188
|
+
data_variants.py paired question variations
|
|
189
|
+
reasoning_data.py procedural rule-reasoning questions (policies, payouts, counting)
|
|
190
|
+
build_mix.py builds a 20K fine-tuning mix from generated, public and replayed questions
|
|
191
|
+
llm_items.py writes hard items with an LLM through OpenRouter and keeps those a blind second pass agrees with (needs `pydantic` and `OPENROUTER_API_KEY`)
|
|
192
|
+
modal_app.py Modal training and evaluation
|
|
193
|
+
configs/ run settings
|
|
194
|
+
tests/ layout, invariance, loss and data tests
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT. The base model, Qwen3-0.6B, is released under its own license.
|
tevro-0.1.0/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# Tevro
|
|
2
|
+
|
|
3
|
+
Tevro is a small decision model, and this repository holds its training code and a pip package for running it. It reads a state, a question and a fixed set of options, and returns one probability per option in a single forward pass. It handles three question types: multiple choice, yes/no, and ordinal scores.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Use the model
|
|
8
|
+
|
|
9
|
+
The trained model is on Hugging Face: **[kartikg1/tevro-decider](https://huggingface.co/kartikg1/tevro-decider)**. It needs Python 3.12 and about 3 GB of free memory; a GPU is optional.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install tevro
|
|
13
|
+
tevro predict request.json --device cpu
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`tevro predict` reads one request from a JSON file (examples below) and prints the answer. The weights and the base model are downloaded on the first run. Use `--device cuda` on an NVIDIA GPU. From Python:
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import tevro
|
|
20
|
+
|
|
21
|
+
model = tevro.load() # downloads kartikg1/tevro-decider on first use
|
|
22
|
+
model.answer("Parcel A is on shelf B2. Parcel B is on shelf C3.",
|
|
23
|
+
{"type": "choice", "instructions": "Which shelf holds Parcel A?",
|
|
24
|
+
"criteria": {"a": "B2", "b": "C3"}})
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The pip package only runs the model. Training code, data tools and evaluation scripts live in this repository.
|
|
28
|
+
|
|
29
|
+
**Multiple choice**
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{"state": "Parcel A is on shelf B2. Parcel B is on shelf C3.",
|
|
33
|
+
"question": {"type": "choice", "instructions": "Which shelf holds Parcel A?",
|
|
34
|
+
"criteria": {"a": "B2", "b": "C3"}}}
|
|
35
|
+
```
|
|
36
|
+
```json
|
|
37
|
+
{"confidence": 0.99996, "abstain": false, "choice": "a",
|
|
38
|
+
"probabilities": {"a": 0.99996, "b": 0.00004}}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Yes/no**
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{"state": "Refunds need a receipt and a purchase within 30 days. The customer bought it 12 days ago and has no receipt.",
|
|
45
|
+
"question": {"type": "noul", "instructions": "Should the refund be issued?"}}
|
|
46
|
+
```
|
|
47
|
+
```json
|
|
48
|
+
{"confidence": 0.933, "abstain": false, "noul": 0.067}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`noul` is the probability of yes.
|
|
52
|
+
|
|
53
|
+
**Score**
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{"state": "The app crashes for every user when they open the payments page. No data is lost.",
|
|
57
|
+
"question": {"type": "score", "instructions": "How severe is this bug?",
|
|
58
|
+
"criteria": ["Cosmetic only", "Minor, with a workaround", "A core feature is blocked", "Data loss or harm"]}}
|
|
59
|
+
```
|
|
60
|
+
```json
|
|
61
|
+
{"confidence": 0.431, "abstain": false, "score": 1.65,
|
|
62
|
+
"probabilities": [0.019, 0.431, 0.428, 0.122]}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`score` is the expected level (0 to 3 here). A low `confidence` like this one means the model is unsure; send those cases to a person.
|
|
66
|
+
|
|
67
|
+
## Results
|
|
68
|
+
|
|
69
|
+
| Evaluation | Accuracy |
|
|
70
|
+
| --- | ---: |
|
|
71
|
+
| Separate held-out test split (5,000) | 80.2% |
|
|
72
|
+
| — multiple choice / yes-no / score | 80.8% / 88.0% / 65.8% |
|
|
73
|
+
| Training dataset's test split (5,000) | 80.9% |
|
|
74
|
+
| JevBench public (231) | 57.6% |
|
|
75
|
+
| — easy / standard / hard | 97.9% / 66.7% / 34.2% |
|
|
76
|
+
|
|
77
|
+
On the held-out split, yes/no AUC is 0.953 and expected calibration error is 0.037. At confidence 0.95 or higher the model answers 48.9% of questions and is right on 98.0% of them.
|
|
78
|
+
|
|
79
|
+
## Set up for training
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
git clone https://github.com/kartikg-gk/tevro
|
|
83
|
+
cd tevro
|
|
84
|
+
uv sync
|
|
85
|
+
uv run pytest -q tests
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Python 3.12, managed with [uv](https://docs.astral.sh/uv/). On Windows, `uv sync` installs the CUDA 13.0 build of PyTorch; on Linux it uses the default PyPI build. The tests use a tiny randomly initialised model and run on CPU.
|
|
89
|
+
|
|
90
|
+
## Train
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
uv run python train.py configs/smoke.yaml
|
|
94
|
+
uv run python train.py configs/check_20k.yaml
|
|
95
|
+
uv run python train.py configs/release_12k.yaml
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
| Config | What it does | Hardware |
|
|
99
|
+
| --- | --- | --- |
|
|
100
|
+
| `smoke.yaml` | Overfits 10 questions; train accuracy should reach 1.0 | any GPU |
|
|
101
|
+
| `check_20k.yaml` | 20,000 questions, 1,000 test, bf16 weights, micro-batch 2 × 32 | 6 GB laptop GPU, ~45 min |
|
|
102
|
+
| `release_12k.yaml` | The released model: 12,000 updates of 64 questions, fp32 | A100 80 GB, ~6.5 h |
|
|
103
|
+
| `finetune_rules.yaml`, `finetune_v2.yaml` | Short fine-tunes of the released model on a 20K rule-reasoning mix (`--resume` the release checkpoint); ~10 min | A100 80 GB |
|
|
104
|
+
|
|
105
|
+
Each run writes `runs/<name>-<timestamp>/` with the config, `metrics.jsonl`, `summary.json` and checkpoints (`best`, `latest`, `final`; LoRA adapter and head only). Every training log line shows `eta_min`, the projected minutes left. `--resume <run or checkpoint>` reloads the weights and starts a fresh optimizer.
|
|
106
|
+
|
|
107
|
+
### On Modal
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
modal run --detach modal_app.py --config configs/release_12k.yaml
|
|
111
|
+
modal run modal_app.py::evaluate --checkpoint runs/<run>/checkpoints/final --out eval.txt
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`modal_app.py` uploads this folder, keeps `runs/` and the Hugging Face cache on Modal Volumes, runs on an A100 80 GB (set `TEVRO_GPU` to change it), and caps a training job at 9 hours.
|
|
115
|
+
|
|
116
|
+
## Evaluate
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
uv run python inference.py runs/<run>/checkpoints/final --max_questions 5000
|
|
120
|
+
uv run python jevbench_eval.py runs/<run>/checkpoints/final <jevbench>/datasets/public
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The first scores a checkpoint on the training dataset's test split: accuracy per question type, yes/no AUC, ECE, Brier score, reliability and resolution, and accuracy at confidence 0.9 and 0.95. The second scores the 231 public [JevBench](https://github.com/fstandhartinger/jevbench) items by tier.
|
|
124
|
+
|
|
125
|
+
## Data
|
|
126
|
+
|
|
127
|
+
Each row is one state with one or more questions:
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"state": "Parcel A is on shelf B2. Parcel B is on shelf C3.",
|
|
132
|
+
"questions_json": "{\"q1\": {\"type\": \"choice\", \"instructions\": \"Which shelf holds Parcel A?\", \"criteria\": {\"a\": \"B2\", \"b\": \"C3\"}, \"label\": \"a\"}}"
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- `choice`: `criteria` maps option keys to descriptions; `label` is a key.
|
|
137
|
+
- `noul` (yes/no): `label` is `true` or `false`.
|
|
138
|
+
- `score`: `criteria` is an ordered list of levels; `label` is the level index.
|
|
139
|
+
|
|
140
|
+
By default the code loads [kartikg1/tevro-decision-v1](https://huggingface.co/datasets/kartikg1/tevro-decision-v1) (250,000 training and 5,000 test questions; private). Point `data.question_files` at your own parquet files (`train` and `test`) to train on your data. `audit.py` reports label-position, length and negation shortcuts before you spend GPU time.
|
|
141
|
+
|
|
142
|
+
## Model
|
|
143
|
+
|
|
144
|
+
| Part | Setting |
|
|
145
|
+
| --- | --- |
|
|
146
|
+
| Base | Qwen3-0.6B, first 20 of 28 layers |
|
|
147
|
+
| Adapter | LoRA rank 8, alpha 16, on q/k/v/o of layers 6–19 |
|
|
148
|
+
| Input | task hint + question + state, then each option as its own span starting at the same position, then `The answer is:` |
|
|
149
|
+
| Head | question-type embedding on every token, 2 bidirectional transformer layers (1024-d, 16 heads, dropout 0.1), scalar scorer at each option's last token |
|
|
150
|
+
| Loss | cross-entropy over the options |
|
|
151
|
+
| Optimizer | AdamW, LoRA lr 1e-4, head lr 1e-3, weight decay 0.01, cosine schedule, 5% warmup, gradient clip 1.0 |
|
|
152
|
+
|
|
153
|
+
Options cannot attend to each other, so reordering them only reorders the probabilities. The test suite checks this.
|
|
154
|
+
|
|
155
|
+
## Repository layout
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
tevro/ the pip package: copies of network.py and tokenization.py, plus predict.py and cli.py
|
|
159
|
+
tokenization.py input layout: positions, attention mask, read indices
|
|
160
|
+
network.py base model, LoRA, decision head
|
|
161
|
+
dataset.py loading, question expansion, batching
|
|
162
|
+
train.py training loop
|
|
163
|
+
inference.py evaluation metrics and single answers
|
|
164
|
+
calibrate.py temperature fitting, abstention thresholds, calibration metrics
|
|
165
|
+
jevbench_eval.py JevBench public evaluation
|
|
166
|
+
audit.py data shortcut checks
|
|
167
|
+
synthetic.py procedural decision questions
|
|
168
|
+
data_variants.py paired question variations
|
|
169
|
+
reasoning_data.py procedural rule-reasoning questions (policies, payouts, counting)
|
|
170
|
+
build_mix.py builds a 20K fine-tuning mix from generated, public and replayed questions
|
|
171
|
+
llm_items.py writes hard items with an LLM through OpenRouter and keeps those a blind second pass agrees with (needs `pydantic` and `OPENROUTER_API_KEY`)
|
|
172
|
+
modal_app.py Modal training and evaluation
|
|
173
|
+
configs/ run settings
|
|
174
|
+
tests/ layout, invariance, loss and data tests
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT. The base model, Qwen3-0.6B, is released under its own license.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tevro"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A small decision model: picks one option and gives a probability for every option, in one forward pass"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Kartik Gadad" }]
|
|
14
|
+
keywords = ["decision model", "classification", "lora", "qwen3", "calibration"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"huggingface_hub>=0.25.0",
|
|
21
|
+
"peft>=0.21.0",
|
|
22
|
+
"safetensors>=0.6.0",
|
|
23
|
+
"torch>=2.14.0",
|
|
24
|
+
"transformers>=5.17.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/kartikg-gk/tevro"
|
|
29
|
+
Model = "https://huggingface.co/kartikg1/tevro-decider"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
tevro = "tevro.cli:main"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["tevro"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.sdist]
|
|
38
|
+
include = ["tevro", "README.md", "LICENSE", "pyproject.toml"]
|
|
39
|
+
|
|
40
|
+
# Training and data tools; not part of the published package. uv installs both groups by default.
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=8.0.0",
|
|
44
|
+
]
|
|
45
|
+
train = [
|
|
46
|
+
"datasets>=5.0.1",
|
|
47
|
+
"pyyaml>=6.0.3",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.uv]
|
|
51
|
+
default-groups = ["dev", "train"]
|
|
52
|
+
|
|
53
|
+
# Windows has no CUDA torch on PyPI; take it from the PyTorch CUDA 13.0 index (Linux keeps PyPI's CUDA build)
|
|
54
|
+
[tool.uv.sources]
|
|
55
|
+
torch = [{ index = "pytorch-cu130", marker = "sys_platform == 'win32'" }]
|
|
56
|
+
|
|
57
|
+
[[tool.uv.index]]
|
|
58
|
+
name = "pytorch-cu130"
|
|
59
|
+
url = "https://download.pytorch.org/whl/cu130"
|
|
60
|
+
explicit = true
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Tevro: a small decision model. Give it a state, a question and options; it returns a probability per option."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
__all__ = ["DEFAULT_MODEL", "Decider", "load"]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def __getattr__(name):
|
|
8
|
+
# Lazy, so `import tevro` and `tevro --help` do not load the model stack or the tokenizer
|
|
9
|
+
if name in __all__:
|
|
10
|
+
from tevro import predict
|
|
11
|
+
|
|
12
|
+
return getattr(predict, name)
|
|
13
|
+
raise AttributeError(f"module 'tevro' has no attribute {name!r}")
|
tevro-0.1.0/tevro/cli.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Command line: `tevro predict request.json`."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main(argv=None):
|
|
8
|
+
parser = argparse.ArgumentParser(prog="tevro", description="Tevro decision model")
|
|
9
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
10
|
+
p = sub.add_parser("predict", help="answer one question from a JSON file")
|
|
11
|
+
p.add_argument("input", help='JSON file with "state" and "question" (use - to read stdin)')
|
|
12
|
+
p.add_argument("--model", default=None, help="Hugging Face repo id or local folder (default: kartikg1/tevro-decider)")
|
|
13
|
+
p.add_argument("--device", default=None, help="cpu, cuda or mps (default: best available)")
|
|
14
|
+
args = parser.parse_args(argv)
|
|
15
|
+
|
|
16
|
+
from tevro.predict import DEFAULT_MODEL, load # imported here so --help does not load the model stack
|
|
17
|
+
|
|
18
|
+
request = json.load(sys.stdin if args.input == "-" else open(args.input, encoding="utf-8"))
|
|
19
|
+
model = load(args.model or DEFAULT_MODEL, args.device)
|
|
20
|
+
print(json.dumps(model.answer(request["state"], request["question"]), indent=2))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
main()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Turns question dicts into model inputs. Same logic the training code uses, without the dataset dependencies."""
|
|
2
|
+
import torch
|
|
3
|
+
|
|
4
|
+
from tevro.tokenization import tokenizer
|
|
5
|
+
|
|
6
|
+
TASK_TYPES = {"choice": 0, "noul": 1, "score": 2}
|
|
7
|
+
TASK_NAMES = {v: k for k, v in TASK_TYPES.items()}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def question_to_choices(question):
|
|
11
|
+
"""Turns any of the 3 question types into a list of option strings (+ label index if present)."""
|
|
12
|
+
if question["type"] == "choice":
|
|
13
|
+
keys = list(question["criteria"])
|
|
14
|
+
choices = [f"{key}: {question['criteria'][key]}" if question["criteria"][key] else key for key in keys]
|
|
15
|
+
label = keys.index(question["label"]) if "label" in question else None
|
|
16
|
+
elif question["type"] == "score":
|
|
17
|
+
# Choices share position ids, so the level index is written into the text to keep the ordering
|
|
18
|
+
choices = [f"{i}: {level}" for i, level in enumerate(question["criteria"])]
|
|
19
|
+
label = question.get("label")
|
|
20
|
+
else: # noul -> P(Yes)
|
|
21
|
+
choices = ["No", "Yes"]
|
|
22
|
+
label = int(question["label"]) if "label" in question else None
|
|
23
|
+
return choices, label
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def collate_fn(batch):
|
|
27
|
+
B = len(batch)
|
|
28
|
+
L = max(len(ex["input_ids"]) for ex in batch)
|
|
29
|
+
C = max(len(ex["choice_read_idx"]) for ex in batch)
|
|
30
|
+
|
|
31
|
+
input_ids = torch.full((B, L), tokenizer.pad_token_id, dtype=torch.long)
|
|
32
|
+
position_ids = torch.zeros((B, L), dtype=torch.long)
|
|
33
|
+
# Padded query rows attend only to themselves, otherwise their softmax row is all -inf -> NaN
|
|
34
|
+
attention_mask = torch.eye(L).repeat(B, 1, 1)
|
|
35
|
+
token_mask = torch.zeros((B, L), dtype=torch.bool)
|
|
36
|
+
choice_read_idx = torch.zeros((B, C), dtype=torch.long)
|
|
37
|
+
choice_mask = torch.zeros((B, C), dtype=torch.bool)
|
|
38
|
+
answer_end_idx = torch.zeros(B, dtype=torch.long)
|
|
39
|
+
|
|
40
|
+
for b, ex in enumerate(batch):
|
|
41
|
+
n, c = len(ex["input_ids"]), len(ex["choice_read_idx"])
|
|
42
|
+
token_mask[b, :n] = True
|
|
43
|
+
input_ids[b, :n] = torch.tensor(ex["input_ids"])
|
|
44
|
+
position_ids[b, :n] = torch.tensor(ex["position_ids"])
|
|
45
|
+
attention_mask[b, :n, :n] = ex["attention_mask"]
|
|
46
|
+
choice_read_idx[b, :c] = torch.tensor(ex["choice_read_idx"])
|
|
47
|
+
choice_mask[b, :c] = True
|
|
48
|
+
answer_end_idx[b] = ex["answer_end_idx"]
|
|
49
|
+
|
|
50
|
+
attention_mask = torch.where(attention_mask == 0, -float("inf"), 0.0).unsqueeze(1) # [B, 1, L, L]
|
|
51
|
+
|
|
52
|
+
out = {
|
|
53
|
+
"input_ids": input_ids,
|
|
54
|
+
"position_ids": position_ids,
|
|
55
|
+
"attention_mask": attention_mask,
|
|
56
|
+
"token_mask": token_mask,
|
|
57
|
+
"choice_read_idx": choice_read_idx,
|
|
58
|
+
"choice_mask": choice_mask,
|
|
59
|
+
"answer_end_idx": answer_end_idx,
|
|
60
|
+
"task_type": torch.tensor([ex["task_type"] for ex in batch], dtype=torch.long),
|
|
61
|
+
}
|
|
62
|
+
if all(ex["label"] is not None for ex in batch):
|
|
63
|
+
out["labels"] = torch.tensor([ex["label"] for ex in batch], dtype=torch.long)
|
|
64
|
+
return out
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
import torch.nn as nn
|
|
5
|
+
from peft import LoraConfig, get_peft_model
|
|
6
|
+
from transformers import Qwen3Model
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ChoiceHead(nn.Module):
|
|
10
|
+
"""Self-attention over [task token, choice vectors..., answer vector], then a bilinear score per choice.
|
|
11
|
+
|
|
12
|
+
No positional encoding: permuting the choices permutes the logits, so any number of choices works.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, hidden_dim, num_task_types, new_dim=512, num_layers=2, num_heads=8):
|
|
16
|
+
super().__init__()
|
|
17
|
+
self.new_dim = new_dim
|
|
18
|
+
self.task_embedding = nn.Embedding(num_task_types, new_dim)
|
|
19
|
+
self.input_proj = nn.Linear(hidden_dim, new_dim)
|
|
20
|
+
layer = nn.TransformerEncoderLayer(new_dim, num_heads, 4 * new_dim, dropout=0.0,
|
|
21
|
+
activation="gelu", batch_first=True, norm_first=True)
|
|
22
|
+
self.layers = nn.TransformerEncoder(layer, num_layers, enable_nested_tensor=False)
|
|
23
|
+
self.final_norm = nn.LayerNorm(new_dim)
|
|
24
|
+
self.answer_proj = nn.Linear(new_dim, new_dim)
|
|
25
|
+
self.choice_proj = nn.Linear(new_dim, new_dim)
|
|
26
|
+
|
|
27
|
+
def forward(self, choice_embeddings, answer_embedding, choice_mask, task_type):
|
|
28
|
+
# choice_embeddings: [B, C, H], answer_embedding: [B, H], choice_mask: [B, C], task_type: [B]
|
|
29
|
+
B = choice_embeddings.shape[0]
|
|
30
|
+
x = torch.cat([
|
|
31
|
+
self.task_embedding(task_type).unsqueeze(1),
|
|
32
|
+
self.input_proj(choice_embeddings),
|
|
33
|
+
self.input_proj(answer_embedding).unsqueeze(1),
|
|
34
|
+
], dim=1) # [B, C+2, D]
|
|
35
|
+
|
|
36
|
+
always_valid = torch.ones(B, 1, dtype=torch.bool, device=choice_mask.device)
|
|
37
|
+
ignore = ~torch.cat([always_valid, choice_mask, always_valid], dim=1)
|
|
38
|
+
x = self.final_norm(self.layers(x, src_key_padding_mask=ignore))
|
|
39
|
+
|
|
40
|
+
answer = self.answer_proj(x[:, -1]) # [B, D]
|
|
41
|
+
choices = self.choice_proj(x[:, 1:-1]) # [B, C, D]
|
|
42
|
+
logits = torch.einsum("bd,bcd->bc", answer, choices) / math.sqrt(self.new_dim)
|
|
43
|
+
return logits.masked_fill(~choice_mask, -float("inf"))
|
|
44
|
+
|
|
45
|
+
def score(self, hidden, token_mask, choice_read_idx, choice_mask, answer_end_idx, task_type):
|
|
46
|
+
rows = torch.arange(hidden.shape[0], device=hidden.device)
|
|
47
|
+
return self(hidden[rows[:, None], choice_read_idx], hidden[rows, answer_end_idx], choice_mask, task_type)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PostHead(nn.Module):
|
|
51
|
+
"""Decision head over every token: type embedding added to all hidden states, a few bidirectional
|
|
52
|
+
transformer layers over the whole sequence, then a scalar scorer at each choice's read token.
|
|
53
|
+
|
|
54
|
+
No answer token is needed: the bidirectional layers let every choice see the state and the other
|
|
55
|
+
choices before scoring. No positional encoding, and the trunk gives choices shared positions,
|
|
56
|
+
so permuting the choices permutes the logits.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, hidden_dim, num_task_types, num_layers=2, dropout=0.1):
|
|
60
|
+
super().__init__()
|
|
61
|
+
num_heads = max(1, hidden_dim // 64)
|
|
62
|
+
self.task_embedding = nn.Embedding(num_task_types, hidden_dim)
|
|
63
|
+
layer = nn.TransformerEncoderLayer(hidden_dim, num_heads, 4 * hidden_dim, dropout,
|
|
64
|
+
batch_first=True, norm_first=True)
|
|
65
|
+
self.layers = nn.TransformerEncoder(layer, num_layers, enable_nested_tensor=False)
|
|
66
|
+
self.scorer = nn.Sequential(nn.LayerNorm(hidden_dim), nn.Linear(hidden_dim, hidden_dim), nn.GELU(),
|
|
67
|
+
nn.Linear(hidden_dim, 1))
|
|
68
|
+
|
|
69
|
+
def score(self, hidden, token_mask, choice_read_idx, choice_mask, answer_end_idx, task_type):
|
|
70
|
+
h = hidden + self.task_embedding(task_type)[:, None, :]
|
|
71
|
+
h = self.layers(h, src_key_padding_mask=~token_mask)
|
|
72
|
+
rows = torch.arange(h.shape[0], device=h.device)
|
|
73
|
+
logits = self.scorer(h[rows[:, None], choice_read_idx]).squeeze(-1) # [B, C]
|
|
74
|
+
return logits.masked_fill(~choice_mask, -float("inf"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TevroNetwork(nn.Module):
|
|
78
|
+
def __init__(self, backbone, head):
|
|
79
|
+
super().__init__()
|
|
80
|
+
self.backbone = backbone
|
|
81
|
+
self.head = head
|
|
82
|
+
|
|
83
|
+
def forward(self, input_ids, position_ids, attention_mask, token_mask, choice_read_idx, choice_mask,
|
|
84
|
+
answer_end_idx, task_type, **kwargs):
|
|
85
|
+
output = self.backbone(input_ids=input_ids, position_ids=position_ids, attention_mask=attention_mask,
|
|
86
|
+
use_cache=False)
|
|
87
|
+
hidden = output.last_hidden_state.float()
|
|
88
|
+
# Head runs in fp32 even under bf16 autocast: bf16 logits are too coarse for a stable loss
|
|
89
|
+
with torch.autocast(device_type=hidden.device.type, enabled=False):
|
|
90
|
+
return self.head.score(hidden, token_mask, choice_read_idx, choice_mask, answer_end_idx, task_type)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def load_backbone(model_name, num_layers=None, dtype="float32"):
|
|
94
|
+
# Only the first num_layers decoder layers are built and loaded (null = all of them).
|
|
95
|
+
# bfloat16 halves the frozen weights for small GPUs; compute already runs in bf16 under autocast
|
|
96
|
+
overrides = {} if num_layers is None else {"num_hidden_layers": num_layers}
|
|
97
|
+
return Qwen3Model.from_pretrained(model_name, dtype=getattr(torch, dtype), **overrides)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def lora_target_regex(num_layers, last_k_layers):
|
|
101
|
+
layer_ids = "|".join(str(i) for i in range(num_layers - last_k_layers, num_layers))
|
|
102
|
+
return rf".*layers\.({layer_ids})\.self_attn\.(q_proj|k_proj|v_proj|o_proj)"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def add_lora(backbone, r, alpha, dropout, last_k_layers):
|
|
106
|
+
if last_k_layers == 0: # frozen Qwen, only the head trains
|
|
107
|
+
backbone.requires_grad_(False)
|
|
108
|
+
return backbone
|
|
109
|
+
config = LoraConfig(
|
|
110
|
+
r=r,
|
|
111
|
+
lora_alpha=alpha,
|
|
112
|
+
lora_dropout=dropout,
|
|
113
|
+
target_modules=lora_target_regex(backbone.config.num_hidden_layers, last_k_layers),
|
|
114
|
+
)
|
|
115
|
+
return get_peft_model(backbone, config)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def build_network(model_name, num_layers, lora_config, head_config, num_task_types, backbone=None,
|
|
119
|
+
dtype="float32"):
|
|
120
|
+
if backbone is None:
|
|
121
|
+
backbone = load_backbone(model_name, num_layers, dtype)
|
|
122
|
+
backbone = add_lora(backbone, lora_config["r"], lora_config["alpha"], lora_config["dropout"],
|
|
123
|
+
lora_config["last_k_layers"])
|
|
124
|
+
head = build_head(head_config, backbone.config.hidden_size, num_task_types)
|
|
125
|
+
return TevroNetwork(backbone, head)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
HEADS = {"choicehead": ChoiceHead, "posthead": PostHead}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_head(head_config, hidden_dim, num_task_types):
|
|
132
|
+
"""head.type: choicehead = attention over [task, choices, answer token] + bilinear score |
|
|
133
|
+
posthead = bidirectional layers over every token + scalar scorer per choice."""
|
|
134
|
+
head_config = dict(head_config)
|
|
135
|
+
head_type = head_config.pop("type", "choicehead")
|
|
136
|
+
head_config.pop("num_task_types", None)
|
|
137
|
+
return HEADS[head_type](hidden_dim, num_task_types, **head_config)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Load a trained Tevro model and answer one question at a time."""
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
from huggingface_hub import snapshot_download
|
|
7
|
+
from peft import PeftModel
|
|
8
|
+
from safetensors.torch import load_file
|
|
9
|
+
|
|
10
|
+
from tevro.inputs import TASK_TYPES, collate_fn, question_to_choices
|
|
11
|
+
from tevro.network import TevroNetwork, build_head, load_backbone
|
|
12
|
+
from tevro.tokenization import encode_example
|
|
13
|
+
|
|
14
|
+
DEFAULT_MODEL = "kartikg1/tevro-decider"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_device():
|
|
18
|
+
if torch.cuda.is_available():
|
|
19
|
+
return torch.device("cuda")
|
|
20
|
+
if torch.backends.mps.is_available():
|
|
21
|
+
return torch.device("mps")
|
|
22
|
+
return torch.device("cpu")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve(model):
|
|
26
|
+
"""A local folder is used as is; anything else is treated as a Hugging Face repo id."""
|
|
27
|
+
if Path(model).is_dir():
|
|
28
|
+
return Path(model)
|
|
29
|
+
return Path(snapshot_download(model, allow_patterns=["meta.json", "head.safetensors", "lora/*"]))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Decider:
|
|
33
|
+
"""A loaded model. `answer(state, question)` returns the chosen option and a probability for every option."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, network, meta, device):
|
|
36
|
+
self.network = network.to(device).eval()
|
|
37
|
+
self.meta = meta
|
|
38
|
+
self.device = torch.device(device)
|
|
39
|
+
|
|
40
|
+
@torch.no_grad()
|
|
41
|
+
def answer(self, state, question):
|
|
42
|
+
"""state: text or any JSON-able object. question: {"type": "choice"|"noul"|"score", "instructions": ...,
|
|
43
|
+
"criteria": {key: description} for choice, a list of ordered levels for score, optional for noul}."""
|
|
44
|
+
if not isinstance(state, str):
|
|
45
|
+
state = json.dumps(state, ensure_ascii=False)
|
|
46
|
+
choices, _ = question_to_choices(question)
|
|
47
|
+
example = encode_example(state, question["instructions"], choices, question["type"],
|
|
48
|
+
self.meta["max_state_tokens"], self.meta["max_choice_tokens"],
|
|
49
|
+
self.meta.get("use_answer_prompt", True))
|
|
50
|
+
example.update(task_type=TASK_TYPES[question["type"]], label=None)
|
|
51
|
+
batch = {k: v.to(self.device) for k, v in collate_fn([example]).items()}
|
|
52
|
+
|
|
53
|
+
autocast = torch.autocast(self.device.type, dtype=torch.bfloat16, enabled=self.device.type in ("cuda", "mps"))
|
|
54
|
+
with autocast:
|
|
55
|
+
logits = self.network(**batch)[0].float().cpu()
|
|
56
|
+
calibration = self.meta.get("calibration") or {}
|
|
57
|
+
logits = logits / calibration.get("temperatures", {}).get(question["type"], 1.0)
|
|
58
|
+
probs = torch.softmax(logits, -1)
|
|
59
|
+
confidence = probs.max().item()
|
|
60
|
+
threshold = calibration.get("thresholds", {}).get(question["type"], 0.0)
|
|
61
|
+
out = {"confidence": confidence, "abstain": confidence < threshold}
|
|
62
|
+
probs = probs.tolist()
|
|
63
|
+
|
|
64
|
+
if question["type"] == "choice":
|
|
65
|
+
keys = list(question["criteria"])
|
|
66
|
+
out.update(choice=keys[max(range(len(keys)), key=probs.__getitem__)], probabilities=dict(zip(keys, probs)))
|
|
67
|
+
elif question["type"] == "score":
|
|
68
|
+
out.update(score=sum(i * p for i, p in enumerate(probs)), probabilities=probs)
|
|
69
|
+
else:
|
|
70
|
+
out.update(noul=probs[1])
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load(model=DEFAULT_MODEL, device=None):
|
|
75
|
+
"""Loads the base model, the LoRA adapter and the decision head. `model` is a Hugging Face repo id or a folder."""
|
|
76
|
+
folder = resolve(model)
|
|
77
|
+
meta = json.loads((folder / "meta.json").read_text())
|
|
78
|
+
device = torch.device(device) if device else get_device()
|
|
79
|
+
|
|
80
|
+
backbone = load_backbone(meta["model_name"], meta.get("num_layers"), meta.get("dtype", "float32"))
|
|
81
|
+
if (folder / "lora").exists():
|
|
82
|
+
backbone = PeftModel.from_pretrained(backbone, folder / "lora")
|
|
83
|
+
head = build_head(meta["head_config"], backbone.config.hidden_size, meta["head_config"]["num_task_types"])
|
|
84
|
+
head.load_state_dict(load_file(str(folder / "head.safetensors"), device="cpu"))
|
|
85
|
+
return Decider(TevroNetwork(backbone, head), meta, device)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
from transformers import AutoTokenizer
|
|
5
|
+
|
|
6
|
+
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") # the tokenizer is shared with DataLoader worker processes
|
|
7
|
+
|
|
8
|
+
model_name = "Qwen/Qwen3-0.6B"
|
|
9
|
+
tokenizer = AutoTokenizer.from_pretrained(model_name)
|
|
10
|
+
|
|
11
|
+
OPTION_OPEN = tokenizer.encode("<option>")
|
|
12
|
+
OPTION_CLOSE = tokenizer.encode("</option>")
|
|
13
|
+
ANSWER_PROMPT = tokenizer.encode("The answer is:")
|
|
14
|
+
|
|
15
|
+
# Short natural-language task hint
|
|
16
|
+
TASK_PROMPTS = {
|
|
17
|
+
"choice": "Choose the best option.",
|
|
18
|
+
"score": "Rate it on the given scale.",
|
|
19
|
+
"noul": "Answer yes or no.",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def truncate(text, max_tokens):
|
|
24
|
+
tokens = tokenizer.encode(text)
|
|
25
|
+
if len(tokens) <= max_tokens:
|
|
26
|
+
return text
|
|
27
|
+
return tokenizer.decode(tokens[:max_tokens])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_question(state, instructions, task_type, max_state_tokens):
|
|
31
|
+
# Task hint and question come before the state, so every state token is read knowing what is asked
|
|
32
|
+
return f"{TASK_PROMPTS[task_type]}\nQuestion: {instructions}\n\n{truncate(state, max_state_tokens)}\n"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_layout(question_ids, choice_ids, use_answer_prompt=True):
|
|
36
|
+
"""One sequence: question, then every choice wrapped in <option>...</option>, then "The answer is:".
|
|
37
|
+
|
|
38
|
+
Returns token ids, position ids, a [L, L] attention mask (1 = may attend) and the read indices.
|
|
39
|
+
Every choice starts at the same position and cannot see the other choices, so the choices are
|
|
40
|
+
read in parallel: reordering them only reorders the outputs. The answer tokens come after the
|
|
41
|
+
longest choice and see everything, which is where the choices are compared.
|
|
42
|
+
"""
|
|
43
|
+
q = len(question_ids)
|
|
44
|
+
input_ids = list(question_ids)
|
|
45
|
+
position_ids = list(range(q))
|
|
46
|
+
spans = []
|
|
47
|
+
for ids in choice_ids:
|
|
48
|
+
wrapped = OPTION_OPEN + ids + OPTION_CLOSE
|
|
49
|
+
start = len(input_ids)
|
|
50
|
+
input_ids += wrapped
|
|
51
|
+
position_ids += range(q, q + len(wrapped))
|
|
52
|
+
spans.append((start, len(input_ids))) # [start, end)
|
|
53
|
+
|
|
54
|
+
answer_start = len(input_ids)
|
|
55
|
+
answer_pos = max(position_ids) + 1
|
|
56
|
+
answer_ids = ANSWER_PROMPT if use_answer_prompt else []
|
|
57
|
+
input_ids += answer_ids
|
|
58
|
+
position_ids += range(answer_pos, answer_pos + len(answer_ids))
|
|
59
|
+
L = len(input_ids)
|
|
60
|
+
|
|
61
|
+
mask = torch.zeros((L, L))
|
|
62
|
+
mask[:q, :q] = torch.tril(torch.ones(q, q)) # question: causal
|
|
63
|
+
for start, end in spans:
|
|
64
|
+
n = end - start
|
|
65
|
+
mask[start:end, :q] = 1 # each choice reads the question and state
|
|
66
|
+
mask[start:end, start:end] = torch.tril(torch.ones(n, n)) # and itself, causally
|
|
67
|
+
n = L - answer_start
|
|
68
|
+
mask[answer_start:, :answer_start] = 1 # answer reads everything before it
|
|
69
|
+
mask[answer_start:, answer_start:] = torch.tril(torch.ones(n, n))
|
|
70
|
+
|
|
71
|
+
# Choices are read at their last content token, not at the closing tag
|
|
72
|
+
choice_read_idx = [end - len(OPTION_CLOSE) - 1 for _, end in spans]
|
|
73
|
+
return {
|
|
74
|
+
"input_ids": input_ids,
|
|
75
|
+
"position_ids": position_ids,
|
|
76
|
+
"attention_mask": mask,
|
|
77
|
+
"choice_read_idx": choice_read_idx,
|
|
78
|
+
"answer_end_idx": L - 1,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def encode_example(state, instructions, choices, task_type, max_state_tokens, max_choice_tokens,
|
|
83
|
+
use_answer_prompt=True):
|
|
84
|
+
question = build_question(state, instructions, task_type, max_state_tokens)
|
|
85
|
+
choices = [truncate(choice, max_choice_tokens) for choice in choices]
|
|
86
|
+
return build_layout(tokenizer.encode(question), [tokenizer.encode(choice) for choice in choices],
|
|
87
|
+
use_answer_prompt)
|