AmeenAhmed2 commited on
Commit
b7a8131
·
verified ·
1 Parent(s): 096015a

zico: checkpoint, code, model card

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ tokenizer/tokenizer.json filter=lfs diff=lfs merge=lfs -text
LICENSE-CODE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
LICENSE.md ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Licences
2
+
3
+ * **Model weights** (`backbone/`, `head.safetensors`): Creative Commons Attribution-NonCommercial 4.0 International
4
+ (CC-BY-NC-4.0), https://creativecommons.org/licenses/by-nc/4.0/legalcode. The base model is Apache-2.0, but part of the
5
+ training data is licensed for non-commercial or research use only; see the model card.
6
+ * **Code** (`zico/`, `scripts/`, `tests/`, `examples/`): Apache License 2.0, see `LICENSE-CODE`.
7
+ * **`vision/` and `tokenizer/`**: unmodified from `Qwen/Qwen3.5-4B-Base`, Apache License 2.0, see `NOTICE`.
NOTICE ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ zico
2
+ Copyright 2026 AmeenAhmed2
3
+
4
+ This checkpoint is a fine-tune of Qwen/Qwen3.5-4B-Base (https://huggingface.co/Qwen/Qwen3.5-4B-Base),
5
+ Copyright Alibaba Cloud, licensed under the Apache License, Version 2.0. The files in `vision/` (vision tower and image
6
+ preprocessor configuration) and `tokenizer/` are redistributed from that model without modification; the language-model
7
+ weights in `backbone/` were modified by fine-tuning.
README.md ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: cc-by-nc-4.0
3
+ base_model: Qwen/Qwen3.5-4B-Base
4
+ base_model_relation: finetune
5
+ language:
6
+ - en
7
+ pipeline_tag: zero-shot-classification
8
+ tags:
9
+ - classification
10
+ - zero-shot
11
+ - calibration
12
+ - decision-model
13
+ - system-one
14
+ - routing
15
+ - guardrails
16
+ - image-classification
17
+ - qwen3.5
18
+ - mlx
19
+ - pytorch
20
+ datasets:
21
+ - google/boolq
22
+ - fancyzhx/ag_news
23
+ - Yelp/yelp_review_full
24
+ - google-research-datasets/go_emotions
25
+ - mteb/banking77
26
+ - nyu-mll/glue
27
+ - ehovy/race
28
+ - google/civil_comments
29
+ - allenai/openbookqa
30
+ - tau/commonsense_qa
31
+ - allenai/qasc
32
+ - allenai/sciq
33
+ - Rowan/hellaswag
34
+ - allenai/winogrande
35
+ - aps/super_glue
36
+ - facebook/anli
37
+ - stanfordnlp/snli
38
+ - google-research-datasets/paws
39
+ - tals/vitaminc
40
+ - rajpurkar/squad_v2
41
+ - clinc/clinc_oos
42
+ - fancyzhx/dbpedia_14
43
+ - community-datasets/yahoo_answers_topics
44
+ - SetFit/20_newsgroups
45
+ - cardiffnlp/tweet_eval
46
+ - SetFit/sst5
47
+ - SetFit/amazon_reviews_multi_en
48
+ - deepset/prompt-injections
49
+ - jackhhao/jailbreak-classification
50
+ - lmsys/toxic-chat
51
+ - microsoft/ms_marco
52
+ - Team-ACE/ToolACE
53
+ - openlifescienceai/medmcqa
54
+ - derek-thomas/ScienceQA
55
+ - bitext/Bitext-customer-support-llm-chatbot-training-dataset
56
+ - benayas/snips
57
+ - benayas/atis
58
+ - zeroshot/twitter-financial-news-sentiment
59
+ - Muennighoff/natural-instructions
60
+ - chainyo/rvl-cdip
61
+ - clane9/imagenet-100
62
+ - uoft-cs/cifar100
63
+ - tanganke/stanford_cars
64
+ - bentrevett/caltech-ucsd-birds-200-2011
65
+ - Multimodal-Fatima/FGVC_Aircraft_train
66
+ - tanganke/dtd
67
+ - tanganke/eurosat
68
+ - tanganke/gtsrb
69
+ - tanganke/resisc45
70
+ - tanganke/sun397
71
+ - ashraq/fashion-product-images-small
72
+ ---
73
+
74
+ # zico
75
+
76
+ **zico is a 4B decision model.** You give it a *state* (a message, a document, a JSON object, an image) and a set of
77
+ *typed questions*. It answers every question with a **calibrated probability distribution over the options you
78
+ listed** — in one forward pass, without generating any text.
79
+
80
+ ```jsonc
81
+ // request // response
82
+ { "state": { "ticket": { "body": "Charged twice, refund me { "answers": {
83
+ or I cancel." } }, "wants_refund": { "type": "noul", "noul": 0.89 },
84
+ "questions": { "team": { "type": "choice", "choice": "billing",
85
+ "wants_refund": { "type": "noul", "probabilities": { "billing": 0.94, "technical": 0.02, … },
86
+ "instructions": "The customer asks for a refund." }, "confidence": 0.81 },
87
+ "team": { "type": "choice", "frustration": { "type": "score", "score": 2.26,
88
+ "instructions": "Which team should handle this?", "probabilities": { "0": 0.04, "1": 0.08, "2": 0.54, … } } },
89
+ "criteria": { "billing": "Charges, refunds", "usage": { "input_tokens": 240, "output_tokens": 0 } }
90
+ "technical": "Bugs, outages", … } },
91
+ "frustration": { "type": "score",
92
+ "criteria": ["Calm", "Annoyed", "Frustrated", "Angry", "Furious"] } } }
93
+ ```
94
+
95
+ It is meant for the many small decisions inside software — routing, triage, moderation, guardrails, tool selection,
96
+ "is this answer grounded?", document and image sorting — where you want a fast, cheap, *typed* answer with a
97
+ probability you can threshold, not a paragraph to parse.
98
+
99
+ | Question type | You provide | You get |
100
+ |---|---|---|
101
+ | **Choice** | 2–255 named options, each with an optional description | the most likely option, the full distribution, a confidence |
102
+ | **Score** | an ordered rubric of 2–10 level descriptions, lowest first | the expected level (e.g. `2.26`), the distribution over levels |
103
+ | **Noul** | a statement that is true or false about the state | the probability that it is true |
104
+
105
+ * **Type-safe by construction.** The output is a softmax over exactly the options in your request. An answer outside
106
+ your schema cannot happen, so there is nothing to parse, retry or validate.
107
+ * **Calibrated.** Trained with strictly proper scoring rules; on held-out tasks its stated confidence tracks its accuracy
108
+ (expected calibration error ≈ 0.04–0.06, see below). "0.9" means roughly nine in ten.
109
+ * **Many questions, one pass.** The state is encoded once and every question branches off it, so ten questions cost
110
+ little more than one. Answers are identical whether questions are asked together or alone.
111
+ * **Text and images.** Images can sit anywhere inside the state, next to text fields.
112
+ * **Fast.** ≈110 ms for 1–16 questions about a 500-token state on an H100 (64 questions: ≈260 ms); ≈300 ms for a
113
+ 4-question request on an M4 Max laptop with the included MLX backend.
114
+
115
+ ## Quick start
116
+
117
+ ```bash
118
+ pip install -U "huggingface_hub[cli]"
119
+ hf download AmeenAhmed2/zico --local-dir zico && cd zico
120
+ python -m venv .venv && source .venv/bin/activate
121
+ pip install -e ".[serve]" # Apple silicon: pip install -e ".[serve,mlx]"
122
+ python scripts/infer.py . examples/request.json
123
+ ```
124
+
125
+ The repository is the checkpoint *and* the code that runs it (`zico/`, a small Python package). The model cannot be
126
+ loaded with `transformers.AutoModel` alone: the language backbone is a standard Qwen3.5 checkpoint, but the decision
127
+ head and the input format are zico's own.
128
+
129
+ ```python
130
+ import torch
131
+ from zico import DecisionModel, Choice, Score, Noul
132
+
133
+ model, renderer, _ = DecisionModel.load(".", device="cuda", dtype=torch.bfloat16) # or "mps" / "cpu"
134
+ model.autocast_dtype = torch.bfloat16
135
+
136
+ answers = model.answer(
137
+ renderer,
138
+ state={"message": "hey can u move my 3pm with dr patel to next week, mornings are better"},
139
+ questions={
140
+ "intent": Choice("What does the patient want?", {"book": None, "reschedule": None, "cancel": None, "billing": None, "other": None}),
141
+ "time_preference": Noul("The patient states a preferred time of day."),
142
+ "urgency": Score("How urgent is this?", ["Whenever", "This week", "Today", "Emergency"]),
143
+ },
144
+ )
145
+ print(answers["answers"]["intent"]) # {'type': 'choice', 'choice': 'reschedule', 'probabilities': {...}, 'confidence': ...}
146
+ ```
147
+
148
+ An image is a JSON object inside the state — `{"type": "image", "data": "<base64>"}`, optionally with
149
+ `"detail": "low" | "high"` (64 to 1,024 image tokens; default ≤ 256):
150
+
151
+ ```python
152
+ import base64
153
+ photo = {"type": "image", "data": base64.b64encode(open("scan.png", "rb").read()).decode()}
154
+ model.answer(renderer, {"scan": photo}, {"kind": Choice("What type of document is this?", {"invoice": None, "letter": None, "form": None, "resume": None})})
155
+ ```
156
+
157
+ ### HTTP server and test page
158
+
159
+ ```bash
160
+ ZICO_CKPT=. scripts/serve.sh start # also: stop | restart | status | log | key-path
161
+ ```
162
+
163
+ This starts a small FastAPI server on `http://127.0.0.1:8787` (on Apple silicon it uses the MLX backend automatically):
164
+
165
+ * `POST /v1/systemone` with `Authorization: Bearer <key>` takes the JSON request shown at the top (plus
166
+ `"model": "zico"`) and returns the response shown there. A key is generated on first start; `scripts/serve.sh key-path`
167
+ says where it is.
168
+ * `GET /` is a **test page**: write a state, drop in an image, build questions, and watch the probability bars. "Live"
169
+ mode re-runs on every edit and shows how far each probability moved.
170
+
171
+ The request and response shapes follow the *System One* wire format that TypeSafe AI documents publicly for its API, so
172
+ client code written for that format can be pointed at this server (`ZICO_MODEL_ALIASES` adds any fixed model name such a
173
+ client sends). See "Relation to other work" below.
174
+
175
+ ## How it works
176
+
177
+ ```
178
+ row = ### State\n{state}\n\n ### Choice\n{instructions}\n- billing: Charges…<OPT>\n- technical: Bugs…<OPT>\nAnswer:<ANS>
179
+ └──── shared prefix ────┘ └──────────────────────────── one question ──────────────────────────────────────┘
180
+
181
+ logit_i = q(h[<ANS>]) · k(h[<OPT>_i]) / √d + u(h[<OPT>_i]) p = softmax over this question's own options
182
+ ```
183
+
184
+ * **Backbone.** `Qwen/Qwen3.5-4B-Base` (a hybrid of Gated-DeltaNet and attention layers), fully fine-tuned, language-model
185
+ head removed. It is only ever used for *prefill*: there is no decoding loop.
186
+ * **Slots, not tokens.** Each option ends in an `<OPT>` slot and each question in an `<ANS>` slot. Their input
187
+ embeddings come from a learned 4-row table, so the tokenizer and embedding matrix are untouched.
188
+ * **Pointer head.** The hidden state at `<ANS>` is scored against the hidden state at every `<OPT>`; the softmax runs
189
+ over exactly those positions. Choice → arg-max key, Score → Σ i·pᵢ, Noul → p(true), confidence = 1 − H(p)/log K. The
190
+ head has 1.3M parameters.
191
+ * **One state, many questions.** Training uses one `(state + question)` row per question. At inference the state is
192
+ prefilled once and its cache — attention KV *and* the recurrent / convolution state of the DeltaNet layers — is
193
+ forked across the questions, which run as one batch. Because the backbone is causal both compute the same function;
194
+ the test-suite asserts it.
195
+ * **Images** enter as embeddings from Qwen3.5's own vision tower (frozen, shipped in `vision/`), placed where the image
196
+ sits in the state's JSON, with Qwen's 3-part rotary positions. They are part of the shared prefix, so an image is
197
+ encoded once for all questions.
198
+ * **Loss.** Cross-entropy + 0.5 · Brier score, plus the ranked probability score for Score questions: all strictly proper
199
+ scoring rules, so the optimum is the true conditional distribution — accuracy and calibration are the same objective.
200
+ A per-type temperature fitted on validation data is stored in the checkpoint (1.09 / 1.10 / 1.23).
201
+
202
+ ## Training
203
+
204
+ 1. **Text pass** — 1 epoch over ≈40 public classification, NLI, reading-comprehension, multiple-choice, intent, moderation,
205
+ retrieval-relevance and tool-selection datasets plus ≈300 Super-NaturalInstructions tasks, all recast as typed
206
+ questions. To make the model *read the schema* rather than memorise label sets, records randomise option order,
207
+ option subsets, the presence of descriptions, instruction wording, whether the state is text or JSON, and add
208
+ "none of the above" cases. Whole datasets and whole instruction tasks are held out, and training text is de-duplicated
209
+ against every held-out set. ≈2.5 h on one H100 (bf16, 8-bit AdamW, ≈8k tokens/s).
210
+ 2. **Image pass** — 1 epoch over 90k images from 12 public image-classification sets (documents, objects, fine-grained
211
+ species / cars / aircraft, textures, satellite and aerial scenes, traffic signs, product attributes), vision tower
212
+ frozen. To stop the text skills drifting, 80k text rows were replayed with the *previous model's own probabilities*
213
+ as soft targets — not the gold labels. Replaying hard labels made the model markedly overconfident on tasks it had
214
+ never seen (ECE 0.045 → 0.131); anchoring to its own distributions avoided that (0.055) while keeping every image
215
+ gain. ≈85 min on one H100.
216
+
217
+ Training and data-building code is in `scripts/` (`build_data.py`, `build_image_data.py`, `make_soft_targets.py`,
218
+ `train.py`, `eval.py`).
219
+
220
+ ## Evaluation
221
+
222
+ All numbers are this checkpoint, bf16, raw probabilities unless stated. "Held out" means the dataset (or the whole
223
+ instruction task) never appeared in training, so the label set and the wording are new to the model. Sample sizes are
224
+ small (150–500 per set; 5.5k for the unseen-task pool): differences under ≈3 points are noise.
225
+
226
+ **Text, held-out (zero-shot)**
227
+
228
+ | Set | Question | Accuracy | ECE |
229
+ |---|---|---|---|
230
+ | ARC-Easy | Choice, free-text options | 97.6 % | 0.031 |
231
+ | IMDB sentiment | Noul | 94.0 % | 0.033 |
232
+ | MASSIVE, 60 intents | Choice ×60 | 74.6 % | 0.090 |
233
+ | 19 unseen Super-NaturalInstructions tasks | mixed | 75.2 % | 0.055 |
234
+ | PubMedQA | Choice ×3 | 70.8 % | 0.088 |
235
+ | dair-ai/emotion | Choice ×6 | 58.0 % | 0.200 |
236
+ | AskUbuntu duplicate reranking (pick 1 of many) | Choice | 51.8 % | 0.161 |
237
+
238
+ **Text, in-distribution test split:** 86.2 % over 37 sources (3,000 rows). Examples: Banking77 96.7 %, CLINC-150 with
239
+ out-of-scope 95.3 %, AG News 93.0 %, MNLI 92.0 %, BoolQ 92.0 %, VitaminC fact-checking 92.9 %, SQuAD-v2 answerability
240
+ 88.0 %, jailbreak / prompt-injection detection 100 % (small sets), SST-5 67.3 %, MS MARCO passage selection 57.1 %.
241
+ By question type over the whole test split (text and images): Choice 90.0 %, Noul 92.3 %, Score 68.5 % exact level.
242
+
243
+ **Images, held-out sets (zero-shot: never trained on, full label set in every question)**
244
+
245
+ | Set | Options per question | Accuracy | ECE |
246
+ |---|---|---|---|
247
+ | CIFAR-10 | 10 | 97.7 % | 0.024 |
248
+ | MNIST digits | 10 | 95.3 % | 0.032 |
249
+ | POPE object presence | Noul | 90.0 % | 0.073 |
250
+ | Food-101 | 101 | 87.7 % | 0.043 |
251
+ | Oxford-IIIT pets | 37 | 85.0 % | 0.064 |
252
+
253
+ **Images, test splits of the training sets.** As in training, a question lists either the full label set or a random
254
+ subset that contains the right answer, and about a quarter are "the image shows X" Noul questions — so these are easier
255
+ than standard full-label benchmarks and are not comparable to published leaderboard numbers. 150–300 questions per set.
256
+
257
+ | Set | Options per question | Accuracy | ECE |
258
+ |---|---|---|---|
259
+ | RVL-CDIP scanned document types | 6 or all 16 | 92.3 % | 0.036 |
260
+ | Stanford Cars | 8–64 of 196 | 98.5 % | 0.023 |
261
+ | EuroSAT land use | 5 or all 10 | 97.0 % | 0.023 |
262
+ | SUN397 scenes | 8–64 of 397 | 96.6 % | 0.028 |
263
+ | ImageNet-100 | 8–100 | 95.2 % | 0.017 |
264
+ | RESISC45 aerial scenes | 8–45 | 94.5 % | 0.032 |
265
+ | CIFAR-100 | 10–100 | 93.6 % | 0.026 |
266
+ | DTD textures | 8–47 | 93.3 % | 0.046 |
267
+ | CUB-200 birds | 8–64 of 200 | 92.8 % | 0.034 |
268
+ | GTSRB traffic signs | 8–43 | 92.7 % | 0.035 |
269
+
270
+ **Calibration.** Over all 9.9k held-out rows (text and images): ECE 0.055 raw, 0.041 with the stored temperatures. On the
271
+ in-distribution test split: 0.011 raw. Full per-source results are in `eval.json`.
272
+
273
+ **Consistency.** Forked (batched) answers equal single-question answers to ≈1e-6 in fp32 (6e-5 in bf16 on an H100). The MLX backend matches PyTorch:
274
+ 388 probabilities from real requests differ by 0.0003 on average, with the same top answer every time.
275
+
276
+ ## Limitations
277
+
278
+ * **One forward pass, no reasoning.** Weak at counting, arithmetic, dates, multi-hop logic and double negatives. If a
279
+ question needs a chain of thought, this is the wrong tool.
280
+ * **It classifies; it does not extract or generate.** No spans, no free text, no numbers beyond a 2–10 level rubric
281
+ (and on rubrics the exact level is right about two times in three; the expected value is the more useful output).
282
+ * **Trained on rows up to ≈1k tokens.** The server accepts states up to 8k tokens, but accuracy and calibration on long
283
+ inputs are untested.
284
+ * **Mostly English.** The backbone is multilingual; the fine-tuning data is almost entirely English.
285
+ * **Fine-grained subjective labels are hard** (emotion 58 %, 5-star sentiment 67 %), and there the model is overconfident.
286
+ Calibration is a property of the distributions it was tested on; check it on your own data before relying on a threshold.
287
+ * **Images:** single still images, classification-style questions. It is not an OCR engine and was not trained on
288
+ charts, screenshots, multi-page documents or video. Small text inside a photo may not be read.
289
+ * **Safety-related questions** (toxicity, jailbreaks, prompt injection) were trained on small public sets. Treat the
290
+ output as one signal, not as a complete moderation or security system, and keep a human in the loop for decisions that
291
+ matter to people.
292
+ * The training data carries the usual biases of public web, review and social-media corpora.
293
+
294
+ ## Licence and training data
295
+
296
+ * **Weights: CC-BY-NC-4.0 (non-commercial).** The base model is Apache-2.0, but some of the training data is licensed
297
+ for non-commercial or research use only — notably MS MARCO and ToxicChat (text), and ImageNet, FGVC-Aircraft,
298
+ CUB-200, Stanford Cars, SUN397 and RVL-CDIP (images). The cautious reading is that a model trained on them should not
299
+ be used commercially. `scripts/build_data.py --exclude-nc` builds the text set without the two non-commercial text
300
+ sources if you want to train a variant.
301
+ * **Code** (`zico/`, `scripts/`, `tests/`): Apache-2.0.
302
+ * `vision/` is the unmodified vision tower of `Qwen/Qwen3.5-4B-Base` (Apache-2.0, © Alibaba Cloud), redistributed so the
303
+ checkpoint is self-contained. See `NOTICE`.
304
+ * The datasets are listed in this card's metadata. They remain under their own licences; none of their content is
305
+ redistributed here.
306
+
307
+ ## Relation to other work
308
+
309
+ * Built on **Qwen3.5-4B-Base** by the Qwen team; the Apple-silicon backend builds on **mlx-lm**.
310
+ * The task format — a state plus typed Choice / Score / Noul questions answered with probabilities — and the HTTP wire
311
+ format follow the *System One* API that **TypeSafe AI** documents publicly. zico is an independent, from-scratch
312
+ project: it is not affiliated with or endorsed by TypeSafe AI, shares no code or weights with their models, and **no
313
+ output of their models was used to train or tune it**. The architecture described above is this project's own design
314
+ and says nothing about how theirs works.
315
+
316
+ ## Citation
317
+
318
+ ```bibtex
319
+ @misc{zico2026,
320
+ title = {zico: a calibrated, typed decision model in a single forward pass},
321
+ author = {AmeenAhmed2},
322
+ year = {2026},
323
+ url = {https://huggingface.co/AmeenAhmed2/zico}
324
+ }
325
+ ```
backbone/config.json ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "Qwen3_5TextModel"
4
+ ],
5
+ "attention_bias": false,
6
+ "attention_dropout": 0.0,
7
+ "attn_output_gate": true,
8
+ "bos_token_id": null,
9
+ "dtype": "bfloat16",
10
+ "eos_token_id": 248044,
11
+ "full_attention_interval": 4,
12
+ "head_dim": 256,
13
+ "hidden_act": "silu",
14
+ "hidden_size": 2560,
15
+ "initializer_range": 0.02,
16
+ "intermediate_size": 9216,
17
+ "layer_types": [
18
+ "linear_attention",
19
+ "linear_attention",
20
+ "linear_attention",
21
+ "full_attention",
22
+ "linear_attention",
23
+ "linear_attention",
24
+ "linear_attention",
25
+ "full_attention",
26
+ "linear_attention",
27
+ "linear_attention",
28
+ "linear_attention",
29
+ "full_attention",
30
+ "linear_attention",
31
+ "linear_attention",
32
+ "linear_attention",
33
+ "full_attention",
34
+ "linear_attention",
35
+ "linear_attention",
36
+ "linear_attention",
37
+ "full_attention",
38
+ "linear_attention",
39
+ "linear_attention",
40
+ "linear_attention",
41
+ "full_attention",
42
+ "linear_attention",
43
+ "linear_attention",
44
+ "linear_attention",
45
+ "full_attention",
46
+ "linear_attention",
47
+ "linear_attention",
48
+ "linear_attention",
49
+ "full_attention"
50
+ ],
51
+ "linear_conv_kernel_dim": 4,
52
+ "linear_key_head_dim": 128,
53
+ "linear_num_key_heads": 16,
54
+ "linear_num_value_heads": 32,
55
+ "linear_value_head_dim": 128,
56
+ "mamba_ssm_dtype": "float32",
57
+ "max_position_embeddings": 262144,
58
+ "mlp_only_layers": [],
59
+ "model_type": "qwen3_5_text",
60
+ "mtp_num_hidden_layers": 1,
61
+ "mtp_use_dedicated_embeddings": false,
62
+ "num_attention_heads": 16,
63
+ "num_hidden_layers": 32,
64
+ "num_key_value_heads": 4,
65
+ "pad_token_id": null,
66
+ "partial_rotary_factor": 0.25,
67
+ "rms_norm_eps": 1e-06,
68
+ "rope_parameters": {
69
+ "mrope_interleaved": true,
70
+ "mrope_section": [
71
+ 11,
72
+ 11,
73
+ 10
74
+ ],
75
+ "partial_rotary_factor": 0.25,
76
+ "rope_theta": 10000000,
77
+ "rope_type": "default"
78
+ },
79
+ "tie_word_embeddings": true,
80
+ "transformers_version": "5.17.0",
81
+ "use_cache": true,
82
+ "vocab_size": 248320
83
+ }
backbone/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6ea11662c9f4eda45d498db013c3f1d5de6cf89079b763f6e7d169bc367a72de
3
+ size 8411549456
eval.json ADDED
@@ -0,0 +1,1504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "meta": {
3
+ "base_model": "Qwen/Qwen3.5-4B-Base",
4
+ "d_head": 256,
5
+ "lora": false,
6
+ "renderer": {
7
+ "max_state_tokens": 768,
8
+ "max_instruction_tokens": 256,
9
+ "max_option_tokens": 64
10
+ },
11
+ "step": 734,
12
+ "val_nll": 0.27321311831474304
13
+ },
14
+ "temperatures": {
15
+ "choice": 1.0929638744012644,
16
+ "score": 1.1011288758276154,
17
+ "noul": 1.228405177254357
18
+ },
19
+ "test": {
20
+ "raw": {
21
+ "overall": {
22
+ "n": 5070,
23
+ "acc": 0.8964496850967407,
24
+ "nll": 0.276126503944397,
25
+ "brier": 0.1475353091955185,
26
+ "ece": 0.010809999694056847,
27
+ "mean_conf": 0.9048414826393127,
28
+ "score_mae": 0.41506025195121765
29
+ },
30
+ "by_type": {
31
+ "choice": {
32
+ "n": 3041,
33
+ "acc": 0.9003617167472839,
34
+ "nll": 0.2871852517127991,
35
+ "brier": 0.1437402367591858,
36
+ "ece": 0.013187985173783867,
37
+ "mean_conf": 0.9091227054595947
38
+ },
39
+ "score": {
40
+ "n": 273,
41
+ "acc": 0.6849817037582397,
42
+ "nll": 0.7681964039802551,
43
+ "brier": 0.4400661289691925,
44
+ "ece": 0.047672064850177796,
45
+ "mean_conf": 0.6837503910064697,
46
+ "score_mae": 0.41506025195121765
47
+ },
48
+ "noul": {
49
+ "n": 1756,
50
+ "acc": 0.9225512742996216,
51
+ "nll": 0.18047469854354858,
52
+ "brier": 0.10862862318754196,
53
+ "ece": 0.013375402451816704,
54
+ "mean_conf": 0.9318000674247742
55
+ }
56
+ },
57
+ "by_source": {
58
+ "ag_news": {
59
+ "n": 86,
60
+ "acc": 0.930232584476471,
61
+ "nll": 0.23873434960842133,
62
+ "brier": 0.10834771394729614,
63
+ "ece": 0.056332710342662184,
64
+ "mean_conf": 0.9181896448135376
65
+ },
66
+ "amazon_stars": {
67
+ "n": 86,
68
+ "acc": 0.5930232405662537,
69
+ "nll": 0.8701136112213135,
70
+ "brier": 0.5058151483535767,
71
+ "ece": 0.10280728207642115,
72
+ "mean_conf": 0.6330943703651428,
73
+ "score_mae": 0.4689413011074066
74
+ },
75
+ "anli": {
76
+ "n": 79,
77
+ "acc": 0.746835470199585,
78
+ "nll": 0.6076061129570007,
79
+ "brier": 0.3763708174228668,
80
+ "ece": 0.15590721699211704,
81
+ "mean_conf": 0.8249932527542114
82
+ },
83
+ "banking77": {
84
+ "n": 91,
85
+ "acc": 0.9670329689979553,
86
+ "nll": 0.109474316239357,
87
+ "brier": 0.05170481652021408,
88
+ "ece": 0.04274910372363963,
89
+ "mean_conf": 0.9463766813278198
90
+ },
91
+ "boolq": {
92
+ "n": 75,
93
+ "acc": 0.9200000166893005,
94
+ "nll": 0.22674815356731415,
95
+ "brier": 0.14536462724208832,
96
+ "ece": 0.07328386494371486,
97
+ "mean_conf": 0.908048689365387
98
+ },
99
+ "cifar100": {
100
+ "n": 297,
101
+ "acc": 0.936026930809021,
102
+ "nll": 0.19756226241588593,
103
+ "brier": 0.096889927983284,
104
+ "ece": 0.02602774308840307,
105
+ "mean_conf": 0.9325385689735413
106
+ },
107
+ "civil_comments": {
108
+ "n": 154,
109
+ "acc": 0.9545454382896423,
110
+ "nll": 0.1635725051164627,
111
+ "brier": 0.08106027543544769,
112
+ "ece": 0.08850163645923792,
113
+ "mean_conf": 0.8801278471946716
114
+ },
115
+ "clinc_oos": {
116
+ "n": 85,
117
+ "acc": 0.9529411792755127,
118
+ "nll": 0.10286867618560791,
119
+ "brier": 0.057649165391922,
120
+ "ece": 0.028260532641420116,
121
+ "mean_conf": 0.9765596389770508
122
+ },
123
+ "cola": {
124
+ "n": 89,
125
+ "acc": 0.8202247023582458,
126
+ "nll": 0.3538511097431183,
127
+ "brier": 0.23637528717517853,
128
+ "ece": 0.07856890498333513,
129
+ "mean_conf": 0.8428974747657776
130
+ },
131
+ "commonsense_qa": {
132
+ "n": 79,
133
+ "acc": 0.797468364238739,
134
+ "nll": 0.47439879179000854,
135
+ "brier": 0.2571961283683777,
136
+ "ece": 0.11401724499686991,
137
+ "mean_conf": 0.8429151177406311
138
+ },
139
+ "copa": {
140
+ "n": 19,
141
+ "acc": 0.9473684430122375,
142
+ "nll": 0.08234653621912003,
143
+ "brier": 0.056282687932252884,
144
+ "ece": 0.053942021972607,
145
+ "mean_conf": 0.9678375720977783
146
+ },
147
+ "cub_birds": {
148
+ "n": 209,
149
+ "acc": 0.9282296895980835,
150
+ "nll": 0.205092191696167,
151
+ "brier": 0.09457369148731232,
152
+ "ece": 0.0337111855618914,
153
+ "mean_conf": 0.9249444007873535
154
+ },
155
+ "dbpedia": {
156
+ "n": 83,
157
+ "acc": 0.9879518151283264,
158
+ "nll": 0.0267021507024765,
159
+ "brier": 0.01387640181928873,
160
+ "ece": 0.01773379953028531,
161
+ "mean_conf": 0.9858810305595398
162
+ },
163
+ "dtd": {
164
+ "n": 193,
165
+ "acc": 0.9326424598693848,
166
+ "nll": 0.1894713044166565,
167
+ "brier": 0.10953276604413986,
168
+ "ece": 0.0462280399812367,
169
+ "mean_conf": 0.9360920786857605
170
+ },
171
+ "eurosat": {
172
+ "n": 200,
173
+ "acc": 0.9700000286102295,
174
+ "nll": 0.09461035579442978,
175
+ "brier": 0.04988160729408264,
176
+ "ece": 0.022912378822553925,
177
+ "mean_conf": 0.9720562696456909
178
+ },
179
+ "go_emotions": {
180
+ "n": 156,
181
+ "acc": 0.8461538553237915,
182
+ "nll": 0.4075973331928253,
183
+ "brier": 0.2239874303340912,
184
+ "ece": 0.052582084089755954,
185
+ "mean_conf": 0.8886739015579224
186
+ },
187
+ "gtsrb": {
188
+ "n": 150,
189
+ "acc": 0.9266666769981384,
190
+ "nll": 0.2583926022052765,
191
+ "brier": 0.10076691210269928,
192
+ "ece": 0.0349075827405604,
193
+ "mean_conf": 0.9396733641624451
194
+ },
195
+ "hellaswag": {
196
+ "n": 71,
197
+ "acc": 0.9718309640884399,
198
+ "nll": 0.07258392125368118,
199
+ "brier": 0.03423016890883446,
200
+ "ece": 0.037721984350516546,
201
+ "mean_conf": 0.9680467844009399
202
+ },
203
+ "imagenet100": {
204
+ "n": 209,
205
+ "acc": 0.9521530866622925,
206
+ "nll": 0.1411520093679428,
207
+ "brier": 0.06291362643241882,
208
+ "ece": 0.01678104336341813,
209
+ "mean_conf": 0.9670758247375488
210
+ },
211
+ "jailbreak": {
212
+ "n": 43,
213
+ "acc": 1.0,
214
+ "nll": 0.003266148502007127,
215
+ "brier": 0.0003119981265626848,
216
+ "ece": 0.003183770989621637,
217
+ "mean_conf": 0.9968161582946777
218
+ },
219
+ "mnli": {
220
+ "n": 88,
221
+ "acc": 0.9204545617103577,
222
+ "nll": 0.17574532330036163,
223
+ "brier": 0.10608046501874924,
224
+ "ece": 0.039473747201574705,
225
+ "mean_conf": 0.9106850028038025
226
+ },
227
+ "ms_marco": {
228
+ "n": 77,
229
+ "acc": 0.5714285969734192,
230
+ "nll": 1.1672861576080322,
231
+ "brier": 0.5488265156745911,
232
+ "ece": 0.1069421570546632,
233
+ "mean_conf": 0.5573300123214722
234
+ },
235
+ "newsgroups": {
236
+ "n": 72,
237
+ "acc": 0.8194444179534912,
238
+ "nll": 0.656489372253418,
239
+ "brier": 0.24557192623615265,
240
+ "ece": 0.10580966421453894,
241
+ "mean_conf": 0.8494588732719421
242
+ },
243
+ "openbookqa": {
244
+ "n": 73,
245
+ "acc": 0.9452054500579834,
246
+ "nll": 0.2053627371788025,
247
+ "brier": 0.10051807016134262,
248
+ "ece": 0.07717989225561772,
249
+ "mean_conf": 0.8888569474220276
250
+ },
251
+ "paws": {
252
+ "n": 75,
253
+ "acc": 0.8799999952316284,
254
+ "nll": 0.252999871969223,
255
+ "brier": 0.16616970300674438,
256
+ "ece": 0.07703955284745612,
257
+ "mean_conf": 0.957039475440979
258
+ },
259
+ "prompt_injection": {
260
+ "n": 19,
261
+ "acc": 1.0,
262
+ "nll": 0.07026468217372894,
263
+ "brier": 0.02881462872028351,
264
+ "ece": 0.06134651247871403,
265
+ "mean_conf": 0.9386534690856934
266
+ },
267
+ "qasc": {
268
+ "n": 78,
269
+ "acc": 0.8974359035491943,
270
+ "nll": 0.22243788838386536,
271
+ "brier": 0.11997074633836746,
272
+ "ece": 0.058650574809634426,
273
+ "mean_conf": 0.9030769467353821
274
+ },
275
+ "qnli": {
276
+ "n": 79,
277
+ "acc": 0.9620253443717957,
278
+ "nll": 0.12711185216903687,
279
+ "brier": 0.06058496609330177,
280
+ "ece": 0.02656769830988215,
281
+ "mean_conf": 0.9400109648704529
282
+ },
283
+ "qqp": {
284
+ "n": 67,
285
+ "acc": 0.8507462739944458,
286
+ "nll": 0.32717540860176086,
287
+ "brier": 0.20558716356754303,
288
+ "ece": 0.05535818888270405,
289
+ "mean_conf": 0.8827617168426514
290
+ },
291
+ "race": {
292
+ "n": 71,
293
+ "acc": 0.8169013857841492,
294
+ "nll": 0.5218772292137146,
295
+ "brier": 0.26671260595321655,
296
+ "ece": 0.08260945939768694,
297
+ "mean_conf": 0.868141770362854
298
+ },
299
+ "resisc45": {
300
+ "n": 201,
301
+ "acc": 0.9452736377716064,
302
+ "nll": 0.17759159207344055,
303
+ "brier": 0.07590000331401825,
304
+ "ece": 0.03162269839005066,
305
+ "mean_conf": 0.9709147810935974
306
+ },
307
+ "rte": {
308
+ "n": 42,
309
+ "acc": 0.9047619104385376,
310
+ "nll": 0.21782515943050385,
311
+ "brier": 0.12485634535551071,
312
+ "ece": 0.05018191372233538,
313
+ "mean_conf": 0.9342974424362183
314
+ },
315
+ "rvl_cdip": {
316
+ "n": 207,
317
+ "acc": 0.9227052927017212,
318
+ "nll": 0.24853183329105377,
319
+ "brier": 0.1186482235789299,
320
+ "ece": 0.035717121188601514,
321
+ "mean_conf": 0.9394121170043945
322
+ },
323
+ "sciq": {
324
+ "n": 70,
325
+ "acc": 0.9571428298950195,
326
+ "nll": 0.08418832719326019,
327
+ "brier": 0.05511568486690521,
328
+ "ece": 0.05332319163331367,
329
+ "mean_conf": 0.9662564992904663
330
+ },
331
+ "snli": {
332
+ "n": 78,
333
+ "acc": 0.9615384340286255,
334
+ "nll": 0.13747791945934296,
335
+ "brier": 0.07794130593538284,
336
+ "ece": 0.06147045345163815,
337
+ "mean_conf": 0.9255906343460083
338
+ },
339
+ "squad_v2": {
340
+ "n": 83,
341
+ "acc": 0.8795180916786194,
342
+ "nll": 0.3885398805141449,
343
+ "brier": 0.18468858301639557,
344
+ "ece": 0.08604057614296412,
345
+ "mean_conf": 0.9535071849822998
346
+ },
347
+ "sst5": {
348
+ "n": 101,
349
+ "acc": 0.6732673048973083,
350
+ "nll": 0.8455938696861267,
351
+ "brier": 0.4966614544391632,
352
+ "ece": 0.10307709063944676,
353
+ "mean_conf": 0.6207720041275024,
354
+ "score_mae": 0.45541465282440186
355
+ },
356
+ "stanford_cars": {
357
+ "n": 199,
358
+ "acc": 0.9849246144294739,
359
+ "nll": 0.06568868458271027,
360
+ "brier": 0.033190734684467316,
361
+ "ece": 0.02284979613965564,
362
+ "mean_conf": 0.9779478907585144
363
+ },
364
+ "sun397": {
365
+ "n": 205,
366
+ "acc": 0.9658536314964294,
367
+ "nll": 0.13169661164283752,
368
+ "brier": 0.059237346053123474,
369
+ "ece": 0.027937838926008762,
370
+ "mean_conf": 0.9778258800506592
371
+ },
372
+ "toxic_chat": {
373
+ "n": 103,
374
+ "acc": 0.9805825352668762,
375
+ "nll": 0.04486699774861336,
376
+ "brier": 0.02695567160844803,
377
+ "ece": 0.011975512422466195,
378
+ "mean_conf": 0.9775879383087158
379
+ },
380
+ "tweet_irony": {
381
+ "n": 73,
382
+ "acc": 0.7397260069847107,
383
+ "nll": 0.47673916816711426,
384
+ "brier": 0.32497861981391907,
385
+ "ece": 0.10002183849680613,
386
+ "mean_conf": 0.8234904408454895
387
+ },
388
+ "tweet_offensive": {
389
+ "n": 95,
390
+ "acc": 0.800000011920929,
391
+ "nll": 0.42309972643852234,
392
+ "brier": 0.2671454846858978,
393
+ "ece": 0.11630791231693371,
394
+ "mean_conf": 0.8510119915008545
395
+ },
396
+ "tweet_sentiment": {
397
+ "n": 96,
398
+ "acc": 0.7604166865348816,
399
+ "nll": 0.6009823679924011,
400
+ "brier": 0.3709958493709564,
401
+ "ece": 0.08339798031360968,
402
+ "mean_conf": 0.7406583428382874
403
+ },
404
+ "vitaminc": {
405
+ "n": 84,
406
+ "acc": 0.9285714030265808,
407
+ "nll": 0.20010635256767273,
408
+ "brier": 0.11288775503635406,
409
+ "ece": 0.055092808689364614,
410
+ "mean_conf": 0.8885759711265564
411
+ },
412
+ "winogrande": {
413
+ "n": 82,
414
+ "acc": 0.8292682766914368,
415
+ "nll": 0.3929581940174103,
416
+ "brier": 0.2536102533340454,
417
+ "ece": 0.09293118971498115,
418
+ "mean_conf": 0.9037488698959351
419
+ },
420
+ "yahoo_topics": {
421
+ "n": 71,
422
+ "acc": 0.7746478915214539,
423
+ "nll": 0.6088139414787292,
424
+ "brier": 0.28299495577812195,
425
+ "ece": 0.06163965007121763,
426
+ "mean_conf": 0.7804079055786133
427
+ },
428
+ "yelp": {
429
+ "n": 127,
430
+ "acc": 0.834645688533783,
431
+ "nll": 0.4275256395339966,
432
+ "brier": 0.2361738681793213,
433
+ "ece": 0.047331056411305306,
434
+ "mean_conf": 0.858354926109314,
435
+ "score_mae": 0.31378617882728577
436
+ }
437
+ }
438
+ },
439
+ "calibrated": {
440
+ "overall": {
441
+ "n": 5070,
442
+ "acc": 0.8964496850967407,
443
+ "nll": 0.2756565809249878,
444
+ "brier": 0.14737455546855927,
445
+ "ece": 0.0079650055739778,
446
+ "mean_conf": 0.8922349810600281,
447
+ "score_mae": 0.42143115401268005
448
+ },
449
+ "by_type": {
450
+ "choice": {
451
+ "n": 3041,
452
+ "acc": 0.9003617167472839,
453
+ "nll": 0.28574398159980774,
454
+ "brier": 0.14335514605045319,
455
+ "ece": 0.007999743385107894,
456
+ "mean_conf": 0.8997042179107666
457
+ },
458
+ "score": {
459
+ "n": 273,
460
+ "acc": 0.6849817037582397,
461
+ "nll": 0.7671821713447571,
462
+ "brier": 0.439628005027771,
463
+ "ece": 0.046648137280912105,
464
+ "mean_conf": 0.6634480953216553,
465
+ "score_mae": 0.42143115401268005
466
+ },
467
+ "noul": {
468
+ "n": 1756,
469
+ "acc": 0.9225512742996216,
470
+ "nll": 0.18177150189876556,
471
+ "brier": 0.10889948904514313,
472
+ "ece": 0.019957571878301106,
473
+ "mean_conf": 0.9148688316345215
474
+ }
475
+ },
476
+ "by_source": {
477
+ "ag_news": {
478
+ "n": 86,
479
+ "acc": 0.930232584476471,
480
+ "nll": 0.2372349351644516,
481
+ "brier": 0.10982950031757355,
482
+ "ece": 0.06062877569098146,
483
+ "mean_conf": 0.909740686416626
484
+ },
485
+ "amazon_stars": {
486
+ "n": 86,
487
+ "acc": 0.5930232405662537,
488
+ "nll": 0.8707610964775085,
489
+ "brier": 0.5034118294715881,
490
+ "ece": 0.07147133660142413,
491
+ "mean_conf": 0.6115179657936096,
492
+ "score_mae": 0.4738217294216156
493
+ },
494
+ "anli": {
495
+ "n": 79,
496
+ "acc": 0.746835470199585,
497
+ "nll": 0.5904105305671692,
498
+ "brier": 0.3706202208995819,
499
+ "ece": 0.12736005320452623,
500
+ "mean_conf": 0.8066393733024597
501
+ },
502
+ "banking77": {
503
+ "n": 91,
504
+ "acc": 0.9670329689979553,
505
+ "nll": 0.11517834663391113,
506
+ "brier": 0.05284213647246361,
507
+ "ece": 0.04865624303995164,
508
+ "mean_conf": 0.9390164613723755
509
+ },
510
+ "boolq": {
511
+ "n": 75,
512
+ "acc": 0.9200000166893005,
513
+ "nll": 0.230869323015213,
514
+ "brier": 0.14464128017425537,
515
+ "ece": 0.05709565622368751,
516
+ "mean_conf": 0.8859748244285583
517
+ },
518
+ "cifar100": {
519
+ "n": 297,
520
+ "acc": 0.936026930809021,
521
+ "nll": 0.19978056848049164,
522
+ "brier": 0.09738589078187943,
523
+ "ece": 0.03686069778541466,
524
+ "mean_conf": 0.9233465194702148
525
+ },
526
+ "civil_comments": {
527
+ "n": 154,
528
+ "acc": 0.9545454382896423,
529
+ "nll": 0.1945398449897766,
530
+ "brier": 0.09554325044155121,
531
+ "ece": 0.11094526093531587,
532
+ "mean_conf": 0.8504656553268433
533
+ },
534
+ "clinc_oos": {
535
+ "n": 85,
536
+ "acc": 0.9529411792755127,
537
+ "nll": 0.10213149338960648,
538
+ "brier": 0.05607043579220772,
539
+ "ece": 0.03434847114305911,
540
+ "mean_conf": 0.9722728133201599
541
+ },
542
+ "cola": {
543
+ "n": 89,
544
+ "acc": 0.8202247023582458,
545
+ "nll": 0.3592163622379303,
546
+ "brier": 0.2345142364501953,
547
+ "ece": 0.07689192332149841,
548
+ "mean_conf": 0.8134418725967407
549
+ },
550
+ "commonsense_qa": {
551
+ "n": 79,
552
+ "acc": 0.797468364238739,
553
+ "nll": 0.47140878438949585,
554
+ "brier": 0.25501152873039246,
555
+ "ece": 0.08131545514081495,
556
+ "mean_conf": 0.8276560306549072
557
+ },
558
+ "copa": {
559
+ "n": 19,
560
+ "acc": 0.9473684430122375,
561
+ "nll": 0.08365084230899811,
562
+ "brier": 0.05513031780719757,
563
+ "ece": 0.05683338684464534,
564
+ "mean_conf": 0.9632882475852966
565
+ },
566
+ "cub_birds": {
567
+ "n": 209,
568
+ "acc": 0.9282296895980835,
569
+ "nll": 0.20787712931632996,
570
+ "brier": 0.09552589058876038,
571
+ "ece": 0.03924435088578615,
572
+ "mean_conf": 0.9157379269599915
573
+ },
574
+ "dbpedia": {
575
+ "n": 83,
576
+ "acc": 0.9879518151283264,
577
+ "nll": 0.02914765290915966,
578
+ "brier": 0.013987519778311253,
579
+ "ece": 0.020033360317049276,
580
+ "mean_conf": 0.9829169511795044
581
+ },
582
+ "dtd": {
583
+ "n": 193,
584
+ "acc": 0.9326424598693848,
585
+ "nll": 0.19226685166358948,
586
+ "brier": 0.10932108014822006,
587
+ "ece": 0.04905586869892781,
588
+ "mean_conf": 0.926359236240387
589
+ },
590
+ "eurosat": {
591
+ "n": 200,
592
+ "acc": 0.9700000286102295,
593
+ "nll": 0.09493371099233627,
594
+ "brier": 0.04979003965854645,
595
+ "ece": 0.019272321772114326,
596
+ "mean_conf": 0.964755117893219
597
+ },
598
+ "go_emotions": {
599
+ "n": 156,
600
+ "acc": 0.8461538553237915,
601
+ "nll": 0.3998943567276001,
602
+ "brier": 0.2204635888338089,
603
+ "ece": 0.06369368834835576,
604
+ "mean_conf": 0.8688168525695801
605
+ },
606
+ "gtsrb": {
607
+ "n": 150,
608
+ "acc": 0.9266666769981384,
609
+ "nll": 0.25059908628463745,
610
+ "brier": 0.09959184378385544,
611
+ "ece": 0.04895997661024053,
612
+ "mean_conf": 0.9357502460479736
613
+ },
614
+ "hellaswag": {
615
+ "n": 71,
616
+ "acc": 0.9718309640884399,
617
+ "nll": 0.07610221207141876,
618
+ "brier": 0.034769345074892044,
619
+ "ece": 0.0420044008320708,
620
+ "mean_conf": 0.9625624418258667
621
+ },
622
+ "imagenet100": {
623
+ "n": 209,
624
+ "acc": 0.9521530866622925,
625
+ "nll": 0.140834778547287,
626
+ "brier": 0.06210394203662872,
627
+ "ece": 0.02685701278059019,
628
+ "mean_conf": 0.9609060883522034
629
+ },
630
+ "jailbreak": {
631
+ "n": 43,
632
+ "acc": 1.0,
633
+ "nll": 0.007107510231435299,
634
+ "brier": 0.0007506122929044068,
635
+ "ece": 0.006904559023125811,
636
+ "mean_conf": 0.9930953979492188
637
+ },
638
+ "mnli": {
639
+ "n": 88,
640
+ "acc": 0.9204545617103577,
641
+ "nll": 0.18531149625778198,
642
+ "brier": 0.10737426578998566,
643
+ "ece": 0.06684734037705664,
644
+ "mean_conf": 0.8966826796531677
645
+ },
646
+ "ms_marco": {
647
+ "n": 77,
648
+ "acc": 0.5714285969734192,
649
+ "nll": 1.1738694906234741,
650
+ "brier": 0.5511745810508728,
651
+ "ece": 0.1849330931448978,
652
+ "mean_conf": 0.5303053855895996
653
+ },
654
+ "newsgroups": {
655
+ "n": 72,
656
+ "acc": 0.8194444179534912,
657
+ "nll": 0.6413953304290771,
658
+ "brier": 0.2439313530921936,
659
+ "ece": 0.0672152123840676,
660
+ "mean_conf": 0.8347481489181519
661
+ },
662
+ "openbookqa": {
663
+ "n": 73,
664
+ "acc": 0.9452054500579834,
665
+ "nll": 0.21645799279212952,
666
+ "brier": 0.10464119911193848,
667
+ "ece": 0.07754900295525902,
668
+ "mean_conf": 0.8753600716590881
669
+ },
670
+ "paws": {
671
+ "n": 75,
672
+ "acc": 0.8799999952316284,
673
+ "nll": 0.23008832335472107,
674
+ "brier": 0.15555186569690704,
675
+ "ece": 0.07505809075017267,
676
+ "mean_conf": 0.9440792798995972
677
+ },
678
+ "prompt_injection": {
679
+ "n": 19,
680
+ "acc": 1.0,
681
+ "nll": 0.0893387570977211,
682
+ "brier": 0.0387185737490654,
683
+ "ece": 0.07711471421890259,
684
+ "mean_conf": 0.9228852987289429
685
+ },
686
+ "qasc": {
687
+ "n": 78,
688
+ "acc": 0.8974359035491943,
689
+ "nll": 0.2294393628835678,
690
+ "brier": 0.12008954584598541,
691
+ "ece": 0.07760370314998621,
692
+ "mean_conf": 0.8928042650222778
693
+ },
694
+ "qnli": {
695
+ "n": 79,
696
+ "acc": 0.9620253443717957,
697
+ "nll": 0.13672247529029846,
698
+ "brier": 0.0658198893070221,
699
+ "ece": 0.04174679779133661,
700
+ "mean_conf": 0.9221668243408203
701
+ },
702
+ "qqp": {
703
+ "n": 67,
704
+ "acc": 0.8507462739944458,
705
+ "nll": 0.3204824924468994,
706
+ "brier": 0.20365533232688904,
707
+ "ece": 0.05547176195417913,
708
+ "mean_conf": 0.8596541285514832
709
+ },
710
+ "race": {
711
+ "n": 71,
712
+ "acc": 0.8169013857841492,
713
+ "nll": 0.508931040763855,
714
+ "brier": 0.2651640772819519,
715
+ "ece": 0.09953184393917758,
716
+ "mean_conf": 0.855144202709198
717
+ },
718
+ "resisc45": {
719
+ "n": 201,
720
+ "acc": 0.9452736377716064,
721
+ "nll": 0.1719132363796234,
722
+ "brier": 0.07461114972829819,
723
+ "ece": 0.03276641192363214,
724
+ "mean_conf": 0.9654509425163269
725
+ },
726
+ "rte": {
727
+ "n": 42,
728
+ "acc": 0.9047619104385376,
729
+ "nll": 0.2149493396282196,
730
+ "brier": 0.12409208714962006,
731
+ "ece": 0.09782490985591286,
732
+ "mean_conf": 0.9137647747993469
733
+ },
734
+ "rvl_cdip": {
735
+ "n": 207,
736
+ "acc": 0.9227052927017212,
737
+ "nll": 0.24763381481170654,
738
+ "brier": 0.11820948868989944,
739
+ "ece": 0.024818566785942367,
740
+ "mean_conf": 0.9276553392410278
741
+ },
742
+ "sciq": {
743
+ "n": 70,
744
+ "acc": 0.9571428298950195,
745
+ "nll": 0.08641297370195389,
746
+ "brier": 0.0542028583586216,
747
+ "ece": 0.05665828745125506,
748
+ "mean_conf": 0.9611032605171204
749
+ },
750
+ "snli": {
751
+ "n": 78,
752
+ "acc": 0.9615384340286255,
753
+ "nll": 0.14562663435935974,
754
+ "brier": 0.07991129904985428,
755
+ "ece": 0.05743888654096996,
756
+ "mean_conf": 0.9150496125221252
757
+ },
758
+ "squad_v2": {
759
+ "n": 83,
760
+ "acc": 0.8795180916786194,
761
+ "nll": 0.3431330621242523,
762
+ "brier": 0.18107561767101288,
763
+ "ece": 0.08313956865378169,
764
+ "mean_conf": 0.9385043978691101
765
+ },
766
+ "sst5": {
767
+ "n": 101,
768
+ "acc": 0.6732673048973083,
769
+ "nll": 0.8503657579421997,
770
+ "brier": 0.49845677614212036,
771
+ "ece": 0.0914942533168388,
772
+ "mean_conf": 0.5979520082473755,
773
+ "score_mae": 0.46103909611701965
774
+ },
775
+ "stanford_cars": {
776
+ "n": 199,
777
+ "acc": 0.9849246144294739,
778
+ "nll": 0.0664161965250969,
779
+ "brier": 0.03385894373059273,
780
+ "ece": 0.02054634083136521,
781
+ "mean_conf": 0.9744680523872375
782
+ },
783
+ "sun397": {
784
+ "n": 205,
785
+ "acc": 0.9658536314964294,
786
+ "nll": 0.12713490426540375,
787
+ "brier": 0.05907195806503296,
788
+ "ece": 0.03913777098139376,
789
+ "mean_conf": 0.973029613494873
790
+ },
791
+ "toxic_chat": {
792
+ "n": 103,
793
+ "acc": 0.9805825352668762,
794
+ "nll": 0.04893926531076431,
795
+ "brier": 0.027793018147349358,
796
+ "ece": 0.0175806231729555,
797
+ "mean_conf": 0.971018373966217
798
+ },
799
+ "tweet_irony": {
800
+ "n": 73,
801
+ "acc": 0.7397260069847107,
802
+ "nll": 0.46547093987464905,
803
+ "brier": 0.3147864043712616,
804
+ "ece": 0.1386445264786409,
805
+ "mean_conf": 0.7930445075035095
806
+ },
807
+ "tweet_offensive": {
808
+ "n": 95,
809
+ "acc": 0.800000011920929,
810
+ "nll": 0.4134097099304199,
811
+ "brier": 0.263822078704834,
812
+ "ece": 0.12641781317580403,
813
+ "mean_conf": 0.8215466737747192
814
+ },
815
+ "tweet_sentiment": {
816
+ "n": 96,
817
+ "acc": 0.7604166865348816,
818
+ "nll": 0.6009461283683777,
819
+ "brier": 0.37088480591773987,
820
+ "ece": 0.09440179414827354,
821
+ "mean_conf": 0.7233335375785828
822
+ },
823
+ "vitaminc": {
824
+ "n": 84,
825
+ "acc": 0.9285714030265808,
826
+ "nll": 0.21254083514213562,
827
+ "brier": 0.11658700555562973,
828
+ "ece": 0.060456716703195634,
829
+ "mean_conf": 0.87336665391922
830
+ },
831
+ "winogrande": {
832
+ "n": 82,
833
+ "acc": 0.8292682766914368,
834
+ "nll": 0.3811376690864563,
835
+ "brier": 0.24891741573810577,
836
+ "ece": 0.07569023195409919,
837
+ "mean_conf": 0.8934732675552368
838
+ },
839
+ "yahoo_topics": {
840
+ "n": 71,
841
+ "acc": 0.7746478915214539,
842
+ "nll": 0.6164750456809998,
843
+ "brier": 0.2843913733959198,
844
+ "ece": 0.10321198388129582,
845
+ "mean_conf": 0.7571716904640198
846
+ },
847
+ "yelp": {
848
+ "n": 127,
849
+ "acc": 0.834645688533783,
850
+ "nll": 0.4197368919849396,
851
+ "brier": 0.23375587165355682,
852
+ "ece": 0.036530637687290035,
853
+ "mean_conf": 0.8449493646621704,
854
+ "score_mae": 0.32252421975135803
855
+ }
856
+ }
857
+ },
858
+ "reliability_raw": [
859
+ {
860
+ "lo": 0.133,
861
+ "hi": 0.2,
862
+ "conf": 0.17327187955379486,
863
+ "acc": 0.0,
864
+ "frac": 0.0007889546104706824
865
+ },
866
+ {
867
+ "lo": 0.2,
868
+ "hi": 0.267,
869
+ "conf": 0.23755492269992828,
870
+ "acc": 0.2222222238779068,
871
+ "frac": 0.001775147975422442
872
+ },
873
+ {
874
+ "lo": 0.267,
875
+ "hi": 0.333,
876
+ "conf": 0.3051908314228058,
877
+ "acc": 0.2800000011920929,
878
+ "frac": 0.004930966533720493
879
+ },
880
+ {
881
+ "lo": 0.333,
882
+ "hi": 0.4,
883
+ "conf": 0.36935192346572876,
884
+ "acc": 0.26923078298568726,
885
+ "frac": 0.0051282052882015705
886
+ },
887
+ {
888
+ "lo": 0.4,
889
+ "hi": 0.467,
890
+ "conf": 0.43677520751953125,
891
+ "acc": 0.42307692766189575,
892
+ "frac": 0.010256410576403141
893
+ },
894
+ {
895
+ "lo": 0.467,
896
+ "hi": 0.533,
897
+ "conf": 0.5065982937812805,
898
+ "acc": 0.5289855003356934,
899
+ "frac": 0.027218934148550034
900
+ },
901
+ {
902
+ "lo": 0.533,
903
+ "hi": 0.6,
904
+ "conf": 0.5669992566108704,
905
+ "acc": 0.5779221057891846,
906
+ "frac": 0.03037475422024727
907
+ },
908
+ {
909
+ "lo": 0.6,
910
+ "hi": 0.667,
911
+ "conf": 0.63744056224823,
912
+ "acc": 0.6171428561210632,
913
+ "frac": 0.03451676666736603
914
+ },
915
+ {
916
+ "lo": 0.667,
917
+ "hi": 0.733,
918
+ "conf": 0.7005413770675659,
919
+ "acc": 0.6280487775802612,
920
+ "frac": 0.03234713897109032
921
+ },
922
+ {
923
+ "lo": 0.733,
924
+ "hi": 0.8,
925
+ "conf": 0.767250657081604,
926
+ "acc": 0.7511312365531921,
927
+ "frac": 0.043589744716882706
928
+ },
929
+ {
930
+ "lo": 0.8,
931
+ "hi": 0.867,
932
+ "conf": 0.834570586681366,
933
+ "acc": 0.80694979429245,
934
+ "frac": 0.051084812730550766
935
+ },
936
+ {
937
+ "lo": 0.867,
938
+ "hi": 0.933,
939
+ "conf": 0.9034878015518188,
940
+ "acc": 0.9073033928871155,
941
+ "frac": 0.07021696120500565
942
+ },
943
+ {
944
+ "lo": 0.933,
945
+ "hi": 1.0,
946
+ "conf": 0.9904627203941345,
947
+ "acc": 0.9853742718696594,
948
+ "frac": 0.687771201133728
949
+ }
950
+ ],
951
+ "reliability_calibrated": [
952
+ {
953
+ "lo": 0.133,
954
+ "hi": 0.2,
955
+ "conf": 0.17058970034122467,
956
+ "acc": 0.0,
957
+ "frac": 0.0009861933067440987
958
+ },
959
+ {
960
+ "lo": 0.2,
961
+ "hi": 0.267,
962
+ "conf": 0.24141208827495575,
963
+ "acc": 0.23076923191547394,
964
+ "frac": 0.0025641026441007853
965
+ },
966
+ {
967
+ "lo": 0.267,
968
+ "hi": 0.333,
969
+ "conf": 0.30261722207069397,
970
+ "acc": 0.3076923191547394,
971
+ "frac": 0.0051282052882015705
972
+ },
973
+ {
974
+ "lo": 0.333,
975
+ "hi": 0.4,
976
+ "conf": 0.3718169331550598,
977
+ "acc": 0.29032257199287415,
978
+ "frac": 0.006114398594945669
979
+ },
980
+ {
981
+ "lo": 0.4,
982
+ "hi": 0.467,
983
+ "conf": 0.4343971312046051,
984
+ "acc": 0.5079365372657776,
985
+ "frac": 0.012426035478711128
986
+ },
987
+ {
988
+ "lo": 0.467,
989
+ "hi": 0.533,
990
+ "conf": 0.5043900012969971,
991
+ "acc": 0.5302013158798218,
992
+ "frac": 0.029388559982180595
993
+ },
994
+ {
995
+ "lo": 0.533,
996
+ "hi": 0.6,
997
+ "conf": 0.5663641691207886,
998
+ "acc": 0.5602409839630127,
999
+ "frac": 0.032741617411375046
1000
+ },
1001
+ {
1002
+ "lo": 0.6,
1003
+ "hi": 0.667,
1004
+ "conf": 0.6334572434425354,
1005
+ "acc": 0.6188119053840637,
1006
+ "frac": 0.039842210710048676
1007
+ },
1008
+ {
1009
+ "lo": 0.667,
1010
+ "hi": 0.733,
1011
+ "conf": 0.7039550542831421,
1012
+ "acc": 0.6941747665405273,
1013
+ "frac": 0.040631163865327835
1014
+ },
1015
+ {
1016
+ "lo": 0.733,
1017
+ "hi": 0.8,
1018
+ "conf": 0.7678379416465759,
1019
+ "acc": 0.7991452813148499,
1020
+ "frac": 0.04615384712815285
1021
+ },
1022
+ {
1023
+ "lo": 0.8,
1024
+ "hi": 0.867,
1025
+ "conf": 0.8343891501426697,
1026
+ "acc": 0.8534798622131348,
1027
+ "frac": 0.05384615436196327
1028
+ },
1029
+ {
1030
+ "lo": 0.867,
1031
+ "hi": 0.933,
1032
+ "conf": 0.9034571051597595,
1033
+ "acc": 0.9114832282066345,
1034
+ "frac": 0.08244576305150986
1035
+ },
1036
+ {
1037
+ "lo": 0.933,
1038
+ "hi": 1.0,
1039
+ "conf": 0.9883154630661011,
1040
+ "acc": 0.9902557730674744,
1041
+ "frac": 0.6477317810058594
1042
+ }
1043
+ ]
1044
+ },
1045
+ "heldout": {
1046
+ "raw": {
1047
+ "overall": {
1048
+ "n": 9888,
1049
+ "acc": 0.7774069309234619,
1050
+ "nll": 0.6259722709655762,
1051
+ "brier": 0.3122439980506897,
1052
+ "ece": 0.055361537657564505,
1053
+ "mean_conf": 0.8327684998512268
1054
+ },
1055
+ "by_type": {
1056
+ "choice": {
1057
+ "n": 9088,
1058
+ "acc": 0.7644146084785461,
1059
+ "nll": 0.6617501974105835,
1060
+ "brier": 0.3292696177959442,
1061
+ "ece": 0.05688635497695306,
1062
+ "mean_conf": 0.8213009238243103
1063
+ },
1064
+ "noul": {
1065
+ "n": 800,
1066
+ "acc": 0.925000011920929,
1067
+ "nll": 0.21953491866588593,
1068
+ "brier": 0.11883323639631271,
1069
+ "ece": 0.042017475422659586,
1070
+ "mean_conf": 0.9630394577980042
1071
+ }
1072
+ },
1073
+ "by_source": {
1074
+ "arc_easy": {
1075
+ "n": 500,
1076
+ "acc": 0.9760000109672546,
1077
+ "nll": 0.10126849263906479,
1078
+ "brier": 0.04353426396846771,
1079
+ "ece": 0.03141405401313018,
1080
+ "mean_conf": 0.9497805237770081
1081
+ },
1082
+ "askubuntu": {
1083
+ "n": 359,
1084
+ "acc": 0.5181058645248413,
1085
+ "nll": 1.715165376663208,
1086
+ "brier": 0.6542366743087769,
1087
+ "ece": 0.16121354500097346,
1088
+ "mean_conf": 0.679319441318512
1089
+ },
1090
+ "cifar10": {
1091
+ "n": 300,
1092
+ "acc": 0.9766666889190674,
1093
+ "nll": 0.06821396946907043,
1094
+ "brier": 0.033433299511671066,
1095
+ "ece": 0.0244025032659336,
1096
+ "mean_conf": 0.9716125726699829
1097
+ },
1098
+ "emotion": {
1099
+ "n": 500,
1100
+ "acc": 0.5799999833106995,
1101
+ "nll": 1.305448293685913,
1102
+ "brier": 0.6054953932762146,
1103
+ "ece": 0.1998288362110465,
1104
+ "mean_conf": 0.7798287868499756
1105
+ },
1106
+ "food101": {
1107
+ "n": 300,
1108
+ "acc": 0.8766666650772095,
1109
+ "nll": 0.35791701078414917,
1110
+ "brier": 0.15910951793193817,
1111
+ "ece": 0.04321668811345984,
1112
+ "mean_conf": 0.8755965232849121
1113
+ },
1114
+ "imdb": {
1115
+ "n": 500,
1116
+ "acc": 0.9399999976158142,
1117
+ "nll": 0.15117090940475464,
1118
+ "brier": 0.0857759416103363,
1119
+ "ece": 0.033096101656337,
1120
+ "mean_conf": 0.9650362730026245
1121
+ },
1122
+ "massive_intent": {
1123
+ "n": 500,
1124
+ "acc": 0.7459999918937683,
1125
+ "nll": 0.9858649969100952,
1126
+ "brier": 0.3682084381580353,
1127
+ "ece": 0.09032174787909839,
1128
+ "mean_conf": 0.8352954983711243
1129
+ },
1130
+ "mnist": {
1131
+ "n": 300,
1132
+ "acc": 0.95333331823349,
1133
+ "nll": 0.15937867760658264,
1134
+ "brier": 0.06808914244174957,
1135
+ "ece": 0.03201363153018352,
1136
+ "mean_conf": 0.9333906769752502
1137
+ },
1138
+ "ni_unseen_tasks": {
1139
+ "n": 5529,
1140
+ "acc": 0.752215564250946,
1141
+ "nll": 0.6323504447937012,
1142
+ "brier": 0.34408876299858093,
1143
+ "ece": 0.05498220219487805,
1144
+ "mean_conf": 0.8071978688240051
1145
+ },
1146
+ "pets": {
1147
+ "n": 300,
1148
+ "acc": 0.8500000238418579,
1149
+ "nll": 0.5367151498794556,
1150
+ "brier": 0.22500383853912354,
1151
+ "ece": 0.06403641861143206,
1152
+ "mean_conf": 0.8197709918022156
1153
+ },
1154
+ "pope": {
1155
+ "n": 300,
1156
+ "acc": 0.8999999761581421,
1157
+ "nll": 0.33347487449645996,
1158
+ "brier": 0.17392873764038086,
1159
+ "ece": 0.07330050353340761,
1160
+ "mean_conf": 0.9597114324569702
1161
+ },
1162
+ "pubmed_qa": {
1163
+ "n": 500,
1164
+ "acc": 0.7080000042915344,
1165
+ "nll": 0.738036036491394,
1166
+ "brier": 0.4015096426010132,
1167
+ "ece": 0.08785723579766086,
1168
+ "mean_conf": 0.7890949249267578
1169
+ }
1170
+ }
1171
+ },
1172
+ "calibrated": {
1173
+ "overall": {
1174
+ "n": 9888,
1175
+ "acc": 0.7774069309234619,
1176
+ "nll": 0.6107043027877808,
1177
+ "brier": 0.30924949049949646,
1178
+ "ece": 0.040644236667579256,
1179
+ "mean_conf": 0.8180511593818665
1180
+ },
1181
+ "by_type": {
1182
+ "choice": {
1183
+ "n": 9088,
1184
+ "acc": 0.7644146084785461,
1185
+ "nll": 0.6470264792442322,
1186
+ "brier": 0.32634666562080383,
1187
+ "ece": 0.041729311528364656,
1188
+ "mean_conf": 0.8061439394950867
1189
+ },
1190
+ "noul": {
1191
+ "n": 800,
1192
+ "acc": 0.925000011920929,
1193
+ "nll": 0.1980842649936676,
1194
+ "brier": 0.11502549052238464,
1195
+ "ece": 0.03511890266276535,
1196
+ "mean_conf": 0.9533174633979797
1197
+ }
1198
+ },
1199
+ "by_source": {
1200
+ "arc_easy": {
1201
+ "n": 500,
1202
+ "acc": 0.9760000109672546,
1203
+ "nll": 0.10715165734291077,
1204
+ "brier": 0.04551118239760399,
1205
+ "ece": 0.03949616313766471,
1206
+ "mean_conf": 0.9417195320129395
1207
+ },
1208
+ "askubuntu": {
1209
+ "n": 359,
1210
+ "acc": 0.5181058645248413,
1211
+ "nll": 1.6524147987365723,
1212
+ "brier": 0.6454375982284546,
1213
+ "ece": 0.1338557621591943,
1214
+ "mean_conf": 0.651961624622345
1215
+ },
1216
+ "cifar10": {
1217
+ "n": 300,
1218
+ "acc": 0.9766666889190674,
1219
+ "nll": 0.07086114585399628,
1220
+ "brier": 0.03372351825237274,
1221
+ "ece": 0.03409448859720851,
1222
+ "mean_conf": 0.9671555757522583
1223
+ },
1224
+ "emotion": {
1225
+ "n": 500,
1226
+ "acc": 0.5799999833106995,
1227
+ "nll": 1.2474008798599243,
1228
+ "brier": 0.5944139957427979,
1229
+ "ece": 0.17934864653363677,
1230
+ "mean_conf": 0.7593486309051514
1231
+ },
1232
+ "food101": {
1233
+ "n": 300,
1234
+ "acc": 0.8766666650772095,
1235
+ "nll": 0.3655674159526825,
1236
+ "brier": 0.1599694937467575,
1237
+ "ece": 0.05726011354152369,
1238
+ "mean_conf": 0.8594070672988892
1239
+ },
1240
+ "imdb": {
1241
+ "n": 500,
1242
+ "acc": 0.9399999976158142,
1243
+ "nll": 0.1412155032157898,
1244
+ "brier": 0.08349451422691345,
1245
+ "ece": 0.025615910650256563,
1246
+ "mean_conf": 0.955990195274353
1247
+ },
1248
+ "massive_intent": {
1249
+ "n": 500,
1250
+ "acc": 0.7459999918937683,
1251
+ "nll": 0.9505149722099304,
1252
+ "brier": 0.36250603199005127,
1253
+ "ece": 0.07815012133194074,
1254
+ "mean_conf": 0.8151710033416748
1255
+ },
1256
+ "mnist": {
1257
+ "n": 300,
1258
+ "acc": 0.95333331823349,
1259
+ "nll": 0.1676100343465805,
1260
+ "brier": 0.07024406641721725,
1261
+ "ece": 0.044357041122790236,
1262
+ "mean_conf": 0.9220753908157349
1263
+ },
1264
+ "ni_unseen_tasks": {
1265
+ "n": 5529,
1266
+ "acc": 0.752215564250946,
1267
+ "nll": 0.6206715106964111,
1268
+ "brier": 0.34118887782096863,
1269
+ "ece": 0.040373205554264546,
1270
+ "mean_conf": 0.7925888299942017
1271
+ },
1272
+ "pets": {
1273
+ "n": 300,
1274
+ "acc": 0.8500000238418579,
1275
+ "nll": 0.5368061065673828,
1276
+ "brier": 0.22789603471755981,
1277
+ "ece": 0.07454185713334927,
1278
+ "mean_conf": 0.8035118579864502
1279
+ },
1280
+ "pope": {
1281
+ "n": 300,
1282
+ "acc": 0.8999999761581421,
1283
+ "nll": 0.29286548495292664,
1284
+ "brier": 0.1675771325826645,
1285
+ "ece": 0.06612932776747837,
1286
+ "mean_conf": 0.9488630890846252
1287
+ },
1288
+ "pubmed_qa": {
1289
+ "n": 500,
1290
+ "acc": 0.7080000042915344,
1291
+ "nll": 0.7209590077400208,
1292
+ "brier": 0.39785510301589966,
1293
+ "ece": 0.07103049902030684,
1294
+ "mean_conf": 0.7723875641822815
1295
+ }
1296
+ }
1297
+ },
1298
+ "reliability_raw": [
1299
+ {
1300
+ "lo": 0.133,
1301
+ "hi": 0.2,
1302
+ "conf": 0.18254680931568146,
1303
+ "acc": 0.0,
1304
+ "frac": 0.00040453075780533254
1305
+ },
1306
+ {
1307
+ "lo": 0.2,
1308
+ "hi": 0.267,
1309
+ "conf": 0.24415171146392822,
1310
+ "acc": 0.125,
1311
+ "frac": 0.004045307636260986
1312
+ },
1313
+ {
1314
+ "lo": 0.267,
1315
+ "hi": 0.333,
1316
+ "conf": 0.3000350594520569,
1317
+ "acc": 0.2711864411830902,
1318
+ "frac": 0.011933657340705395
1319
+ },
1320
+ {
1321
+ "lo": 0.333,
1322
+ "hi": 0.4,
1323
+ "conf": 0.36993393301963806,
1324
+ "acc": 0.2829268276691437,
1325
+ "frac": 0.020732199773192406
1326
+ },
1327
+ {
1328
+ "lo": 0.4,
1329
+ "hi": 0.467,
1330
+ "conf": 0.434598833322525,
1331
+ "acc": 0.356890469789505,
1332
+ "frac": 0.02862055040895939
1333
+ },
1334
+ {
1335
+ "lo": 0.467,
1336
+ "hi": 0.533,
1337
+ "conf": 0.5052576661109924,
1338
+ "acc": 0.4368070960044861,
1339
+ "frac": 0.04561084136366844
1340
+ },
1341
+ {
1342
+ "lo": 0.533,
1343
+ "hi": 0.6,
1344
+ "conf": 0.5669507384300232,
1345
+ "acc": 0.4856114983558655,
1346
+ "frac": 0.05622977390885353
1347
+ },
1348
+ {
1349
+ "lo": 0.6,
1350
+ "hi": 0.667,
1351
+ "conf": 0.6334361433982849,
1352
+ "acc": 0.5653104782104492,
1353
+ "frac": 0.047228965908288956
1354
+ },
1355
+ {
1356
+ "lo": 0.667,
1357
+ "hi": 0.733,
1358
+ "conf": 0.701598048210144,
1359
+ "acc": 0.6368821263313293,
1360
+ "frac": 0.05319579318165779
1361
+ },
1362
+ {
1363
+ "lo": 0.733,
1364
+ "hi": 0.8,
1365
+ "conf": 0.7661896347999573,
1366
+ "acc": 0.7110726833343506,
1367
+ "frac": 0.058454692363739014
1368
+ },
1369
+ {
1370
+ "lo": 0.8,
1371
+ "hi": 0.867,
1372
+ "conf": 0.8357764482498169,
1373
+ "acc": 0.7466007471084595,
1374
+ "frac": 0.08181634545326233
1375
+ },
1376
+ {
1377
+ "lo": 0.867,
1378
+ "hi": 0.933,
1379
+ "conf": 0.9009120464324951,
1380
+ "acc": 0.8152466416358948,
1381
+ "frac": 0.1127629429101944
1382
+ },
1383
+ {
1384
+ "lo": 0.933,
1385
+ "hi": 1.0,
1386
+ "conf": 0.9835764169692993,
1387
+ "acc": 0.9503800868988037,
1388
+ "frac": 0.4789643883705139
1389
+ }
1390
+ ],
1391
+ "reliability_calibrated": [
1392
+ {
1393
+ "lo": 0.133,
1394
+ "hi": 0.2,
1395
+ "conf": 0.17538046836853027,
1396
+ "acc": 0.0,
1397
+ "frac": 0.0006067961221560836
1398
+ },
1399
+ {
1400
+ "lo": 0.2,
1401
+ "hi": 0.267,
1402
+ "conf": 0.24302583932876587,
1403
+ "acc": 0.16981132328510284,
1404
+ "frac": 0.005360032431781292
1405
+ },
1406
+ {
1407
+ "lo": 0.267,
1408
+ "hi": 0.333,
1409
+ "conf": 0.30060428380966187,
1410
+ "acc": 0.25362318754196167,
1411
+ "frac": 0.013956310227513313
1412
+ },
1413
+ {
1414
+ "lo": 0.333,
1415
+ "hi": 0.4,
1416
+ "conf": 0.36858007311820984,
1417
+ "acc": 0.3234042525291443,
1418
+ "frac": 0.023766180500388145
1419
+ },
1420
+ {
1421
+ "lo": 0.4,
1422
+ "hi": 0.467,
1423
+ "conf": 0.4340149462223053,
1424
+ "acc": 0.3523809611797333,
1425
+ "frac": 0.03185679763555527
1426
+ },
1427
+ {
1428
+ "lo": 0.467,
1429
+ "hi": 0.533,
1430
+ "conf": 0.5055366158485413,
1431
+ "acc": 0.45656564831733704,
1432
+ "frac": 0.05006067827343941
1433
+ },
1434
+ {
1435
+ "lo": 0.533,
1436
+ "hi": 0.6,
1437
+ "conf": 0.5651605129241943,
1438
+ "acc": 0.5167548656463623,
1439
+ "frac": 0.05734223127365112
1440
+ },
1441
+ {
1442
+ "lo": 0.6,
1443
+ "hi": 0.667,
1444
+ "conf": 0.6337770223617554,
1445
+ "acc": 0.5630081295967102,
1446
+ "frac": 0.04975728318095207
1447
+ },
1448
+ {
1449
+ "lo": 0.667,
1450
+ "hi": 0.733,
1451
+ "conf": 0.7002703547477722,
1452
+ "acc": 0.6780238747596741,
1453
+ "frac": 0.059364885091781616
1454
+ },
1455
+ {
1456
+ "lo": 0.733,
1457
+ "hi": 0.8,
1458
+ "conf": 0.7682633399963379,
1459
+ "acc": 0.7181528806686401,
1460
+ "frac": 0.06351132690906525
1461
+ },
1462
+ {
1463
+ "lo": 0.8,
1464
+ "hi": 0.867,
1465
+ "conf": 0.8352631330490112,
1466
+ "acc": 0.7694915533065796,
1467
+ "frac": 0.08950242400169373
1468
+ },
1469
+ {
1470
+ "lo": 0.867,
1471
+ "hi": 0.933,
1472
+ "conf": 0.9024354219436646,
1473
+ "acc": 0.8416013717651367,
1474
+ "frac": 0.11620145291090012
1475
+ },
1476
+ {
1477
+ "lo": 0.933,
1478
+ "hi": 1.0,
1479
+ "conf": 0.9815592169761658,
1480
+ "acc": 0.9596588015556335,
1481
+ "frac": 0.43871358036994934
1482
+ }
1483
+ ]
1484
+ },
1485
+ "fork_invariance_max_dp": 6.145238876342773e-05,
1486
+ "latency_ms": {
1487
+ "1": {
1488
+ "fork": 108.40368270874023,
1489
+ "separate": 959.8970413208008
1490
+ },
1491
+ "4": {
1492
+ "fork": 107.42926597595215,
1493
+ "separate": 1998.2314109802246
1494
+ },
1495
+ "16": {
1496
+ "fork": 113.10410499572754,
1497
+ "separate": 7827.5275230407715
1498
+ },
1499
+ "64": {
1500
+ "fork": 256.7019462585449,
1501
+ "separate": 16031.468868255615
1502
+ }
1503
+ }
1504
+ }
examples/request.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "state": {
3
+ "ticket": {
4
+ "id": 4412,
5
+ "plan": "pro",
6
+ "subject": "Charged twice??",
7
+ "body": "Hi, I just noticed I was charged twice for my subscription this month. This is the second time it has happened and honestly I'm pretty fed up. Please refund the extra charge today or I'm cancelling."
8
+ }
9
+ },
10
+ "questions": {
11
+ "wants_refund": {"type": "noul", "instructions": "The customer is asking for money back."},
12
+ "threatens_churn": {"type": "noul", "instructions": "The customer threatens to cancel or leave."},
13
+ "team": {
14
+ "type": "choice",
15
+ "instructions": "Which team should handle this ticket?",
16
+ "criteria": {
17
+ "billing": "Charges, invoices, refunds, payment methods",
18
+ "technical": "Bugs, outages, errors, performance problems",
19
+ "sales": "Upgrades, pricing questions, new purchases",
20
+ "account": "Login, password, profile and access issues"
21
+ }
22
+ },
23
+ "frustration": {
24
+ "type": "score",
25
+ "instructions": "How frustrated is the customer?",
26
+ "criteria": ["Calm and neutral", "Mildly annoyed", "Clearly frustrated", "Furious or abusive"]
27
+ },
28
+ "language": {"type": "choice", "instructions": "Which language is the ticket written in?", "criteria": ["english", "spanish", "german", "french"]}
29
+ }
30
+ }
examples/systemone_request.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "zico-latest",
3
+ "state": {
4
+ "ticket": {
5
+ "id": 4412,
6
+ "plan": "pro",
7
+ "subject": "Charged twice??",
8
+ "body": "Hi, I just noticed I was charged twice for my subscription this month. This is the second time it has happened and honestly I'm pretty fed up. Please refund the extra charge today or I'm cancelling."
9
+ }
10
+ },
11
+ "questions": {
12
+ "wants_refund": {"type": "noul", "instructions": "The customer is asking for money back."},
13
+ "threatens_churn": {"type": "noul", "instructions": "The customer threatens to cancel or leave."},
14
+ "team": {
15
+ "type": "choice",
16
+ "instructions": "Which team should handle this ticket?",
17
+ "criteria": {
18
+ "billing": "Charges, invoices, refunds, payment methods",
19
+ "technical": "Bugs, outages, errors, performance problems",
20
+ "sales": "Upgrades, pricing questions, new purchases",
21
+ "account": "Login, password, profile and access issues"
22
+ }
23
+ },
24
+ "frustration": {
25
+ "type": "score",
26
+ "instructions": "How frustrated is the customer?",
27
+ "criteria": ["Calm and neutral", "Mildly annoyed", "Clearly frustrated", "Furious or abusive"]
28
+ },
29
+ "language": {"type": "choice", "instructions": "Which language is the ticket written in?", "criteria": {"english": null, "spanish": null, "german": null, "french": null}}
30
+ }
31
+ }
head.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:49b8b57e04c6664c067039132a8ae3c4c4e11f30768710995188d17022b99457
3
+ size 5315232
pyproject.toml ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "zico"
3
+ version = "0.1.0"
4
+ description = "zico: a typed decision model - state + typed questions -> calibrated probability distributions"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "torch",
8
+ "transformers>=5.2",
9
+ "datasets",
10
+ "peft",
11
+ "safetensors",
12
+ "numpy",
13
+ "pillow", # images in the state
14
+ "torchvision", # transformers' image processor for the vision tower
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = ["pytest", "httpx"]
19
+ serve = ["fastapi", "uvicorn"]
20
+ mlx = ["mlx", "mlx-lm"] # Apple silicon serving backend
21
+
22
+ [build-system]
23
+ requires = ["setuptools>=68"]
24
+ build-backend = "setuptools.build_meta"
25
+
26
+ [tool.setuptools.packages.find]
27
+ include = ["zico*"]
28
+
29
+ [tool.setuptools.package-data]
30
+ zico = ["ui.html"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
scripts/add_vision.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Package the base checkpoint's vision tower into a zico checkpoint: <ckpt>/vision/
2
+
3
+ python scripts/add_vision.py runs/qwen35-4b-best-bf16
4
+
5
+ Only the ViT + merger weights are copied (a few hundred MB), together with the image-processor
6
+ settings and the special-token ids the renderer needs. The vision tower is used as shipped: our
7
+ fine-tuning never touched it, and the zero-shot probe showed the alignment survived.
8
+ """
9
+ import argparse
10
+ import json
11
+ from pathlib import Path
12
+
13
+ import torch
14
+ from huggingface_hub import snapshot_download
15
+ from safetensors import safe_open
16
+ from safetensors.torch import save_file
17
+ from transformers import AutoConfig, AutoImageProcessor
18
+
19
+ p = argparse.ArgumentParser()
20
+ p.add_argument("ckpt")
21
+ p.add_argument("--base", default=None, help="defaults to the checkpoint's base_model")
22
+ p.add_argument("--dtype", default="bf16", choices=["bf16", "fp32"])
23
+ args = p.parse_args()
24
+
25
+ ckpt = Path(args.ckpt)
26
+ base = args.base or json.loads((ckpt / "zico.json").read_text())["base_model"]
27
+ cfg = AutoConfig.from_pretrained(base)
28
+ src = Path(snapshot_download(base, allow_patterns=["*.safetensors", "*.json"]))
29
+ dtype = torch.bfloat16 if args.dtype == "bf16" else torch.float32
30
+
31
+ weights = {}
32
+ for shard in sorted(src.glob("*.safetensors")):
33
+ with safe_open(shard, framework="pt") as f:
34
+ for key in f.keys():
35
+ if key.startswith("model.visual."):
36
+ weights[key[len("model.visual."):]] = f.get_tensor(key).to(dtype).contiguous()
37
+ if not weights:
38
+ raise SystemExit(f"no vision weights found in {base}")
39
+
40
+ out = ckpt / "vision"
41
+ out.mkdir(parents=True, exist_ok=True)
42
+ save_file(weights, out / "model.safetensors")
43
+ vision_cfg = {k: v for k, v in cfg.vision_config.to_dict().items() if k not in ("transformers_version", "architectures")}
44
+ (out / "config.json").write_text(json.dumps(vision_cfg, indent=2))
45
+ AutoImageProcessor.from_pretrained(base).save_pretrained(out)
46
+ (out / "zico_vision.json").write_text(json.dumps({
47
+ "base_model": base,
48
+ "image_token_id": cfg.image_token_id,
49
+ "vision_start_token_id": cfg.vision_start_token_id,
50
+ "vision_end_token_id": cfg.vision_end_token_id,
51
+ "patch_size": cfg.vision_config.patch_size,
52
+ "spatial_merge_size": cfg.vision_config.spatial_merge_size,
53
+ }, indent=2)) # fmt: skip
54
+ n = sum(w.numel() for w in weights.values())
55
+ print(f"packaged {len(weights)} tensors, {n/1e6:.0f}M parameters, {sum(w.numel()*w.element_size() for w in weights.values())/1e9:.2f} GB -> {out}")
scripts/bench_ours.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Score a zico checkpoint on the fixed benchmark rows (the same rows every other system saw).
2
+
3
+ python scripts/bench_ours.py runs/qwen35-4b/best --data data_ref --permutations 4
4
+
5
+ --permutations K averages each Choice answer over K shuffled option orders (K=1 = as given).
6
+ A long option list is read left to right by a causal backbone, so where the right answer sits
7
+ matters a little; averaging over orders removes that bias. It needs no training and no labels,
8
+ and with the forked state cache the extra orders cost only their own question tokens.
9
+ """
10
+ import argparse
11
+ import collections
12
+ import json
13
+ import random
14
+ import time
15
+
16
+ import torch
17
+
18
+ from zico import DecisionModel
19
+ from zico.backbone import pick_device
20
+ from zico.benchmark import BENCH_SETS, format_line, load_rows, summarise
21
+ from zico.data import render_records
22
+ from zico.evaluate import predict
23
+
24
+ p = argparse.ArgumentParser()
25
+ p.add_argument("ckpt")
26
+ p.add_argument("--data", default="data_ref")
27
+ p.add_argument("--n", type=int, default=300)
28
+ p.add_argument("--permutations", type=int, default=1)
29
+ p.add_argument("--out", default=None)
30
+ p.add_argument("--max-len", type=int, default=2048)
31
+ p.add_argument("--errors", default="massive_intent", help="source to print a confusion breakdown for")
32
+ args = p.parse_args()
33
+
34
+ dev = pick_device()
35
+ model, renderer, _ = DecisionModel.load(args.ckpt, device=dev)
36
+ model.autocast_dtype = torch.bfloat16
37
+ rng = random.Random(0)
38
+
39
+ # one record per (row, option order); order[j] = original index of the option shown at position j
40
+ records, orders, owner = [], [], []
41
+ base_rows = []
42
+ for split, source in BENCH_SETS:
43
+ for r in load_rows(args.data, split, source, args.n):
44
+ base_rows.append(r)
45
+ q = r["question"]
46
+ k = args.permutations if q["type"] == "choice" else 1
47
+ crit = q.get("criteria")
48
+ keys = list(crit) if crit is not None else []
49
+ for j in range(k):
50
+ order = list(range(len(keys)))
51
+ if j:
52
+ rng.shuffle(order)
53
+ if q["type"] == "choice":
54
+ new = {keys[i]: crit[keys[i]] for i in order} if isinstance(crit, dict) else [keys[i] for i in order]
55
+ rec = {**r, "sid": f"{r['sid']}#{j}", "question": {**q, "criteria": new}, "label": order.index(r["label"])}
56
+ else:
57
+ rec = r
58
+ records.append(rec)
59
+ orders.append(order)
60
+ owner.append(len(base_rows) - 1)
61
+
62
+ t0 = time.time()
63
+ rows, kept = render_records(records, renderer, args.max_len)
64
+ assert len(kept) == len(records), "rows were dropped by max_len; raise --max-len"
65
+ preds = predict(model, rows, records, renderer.slot_id)
66
+ elapsed = time.time() - t0
67
+
68
+ probs = [None] * len(base_rows)
69
+ for pr, order, o in zip(preds, orders, owner):
70
+ p_orig = torch.zeros(len(pr["p"]))
71
+ if order:
72
+ p_orig[torch.tensor(order)] = pr["p"] # position j showed original option order[j]
73
+ else:
74
+ p_orig = pr["p"]
75
+ probs[o] = p_orig if probs[o] is None else probs[o] + p_orig
76
+
77
+ results = {}
78
+ by_source = collections.defaultdict(list)
79
+ for r, pv in zip(base_rows, probs):
80
+ pv = pv / pv.sum()
81
+ by_source[r["source"]].append((int(pv.argmax()), float(pv.max()), r))
82
+ print(f"{len(records)} forward rows for {len(base_rows)} questions in {elapsed:.0f}s (permutations={args.permutations})")
83
+ for _, source in BENCH_SETS:
84
+ items = by_source[source]
85
+ m = summarise([a for a, _, _ in items], [r["label"] for _, _, r in items], [c for _, c, _ in items], [])
86
+ results[source] = m
87
+ print(source)
88
+ print(format_line(f"ours x{args.permutations}", m))
89
+
90
+ if args.errors in by_source:
91
+ items = by_source[args.errors]
92
+ conf = collections.Counter()
93
+ pos_hits = collections.defaultdict(lambda: [0, 0])
94
+ for pred, _, r in items:
95
+ keys = list(r["question"]["criteria"])
96
+ third = min(2, r["label"] * 3 // len(keys))
97
+ pos_hits[third][0] += pred == r["label"]
98
+ pos_hits[third][1] += 1
99
+ if pred != r["label"]:
100
+ conf[(keys[r["label"]], keys[pred])] += 1
101
+ print(f"\n{args.errors}: accuracy by where the gold option sits in the list: " +
102
+ ", ".join(f"{['first', 'middle', 'last'][t]} third {h}/{n} = {h / n:.2f}" for t, (h, n) in sorted(pos_hits.items())))
103
+ print("most common confusions (gold -> predicted):")
104
+ for (g, pd), c in conf.most_common(12):
105
+ print(f" {c:2d} {g} -> {pd}")
106
+
107
+ if args.out:
108
+ with open(args.out, "w") as f:
109
+ json.dump({"permutations": args.permutations, "results": results}, f, indent=2)
scripts/build_data.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Download the source datasets and write data/{train,val,test,heldout}.jsonl.
2
+
3
+ python scripts/build_data.py --out data # original 8 sources (what the 0.8B run used)
4
+ python scripts/build_data.py --out data_a --sources all --n-train 15000 --include-ni
5
+
6
+ Held-out sources are never trained on; training rows whose text also appears in a held-out
7
+ set are dropped. With --include-ni, 10% of Super-NaturalInstructions *tasks* are held out
8
+ whole, which measures zero-shot transfer to instructions the model has never seen.
9
+ """
10
+ import argparse
11
+ import collections
12
+ import json
13
+ import os
14
+ import random
15
+ import re
16
+ import sys
17
+ import traceback
18
+
19
+ from zico.data import HELDOUT_SOURCES, TRAIN_SOURCES, label_context, take, to_records, write_jsonl
20
+ from zico.sources_extra import (
21
+ EXTRA_HELDOUT_SOURCES,
22
+ EXTRA_TRAIN_SOURCES,
23
+ NONE_OK,
24
+ SOURCE_META,
25
+ add_none_option,
26
+ extra_label_context,
27
+ natural_instructions_records,
28
+ )
29
+
30
+ p = argparse.ArgumentParser()
31
+ p.add_argument("--out", default="data")
32
+ p.add_argument("--sources", default="core", choices=["core", "all"])
33
+ p.add_argument("--only", default=None, help="comma-separated source names (for testing adapters)")
34
+ p.add_argument("--exclude-nc", action="store_true", help="drop sources with non-commercial licences")
35
+ p.add_argument("--include-ni", action="store_true", help="add Super-NaturalInstructions classification tasks")
36
+ p.add_argument("--ni-scan", type=int, default=2_000_000, help="rows of the NI corpus to scan")
37
+ p.add_argument("--n-train", type=int, default=4000, help="source examples per training dataset")
38
+ p.add_argument("--n-val", type=int, default=300)
39
+ p.add_argument("--n-test", type=int, default=500)
40
+ p.add_argument("--seed", type=int, default=0)
41
+ p.add_argument("--quick", action="store_true", help="stream every source with a small buffer (adapter smoke test, skewed samples)")
42
+ args = p.parse_args()
43
+
44
+ if args.quick:
45
+ _take = take
46
+
47
+ def take(hf_id, cfg, split, n, stream, seed): # noqa: F811
48
+ return _take(hf_id, cfg, split, n, True, seed, buffer_size=1000)
49
+
50
+
51
+ rng = random.Random(args.seed)
52
+ train_sources, heldout_sources = dict(TRAIN_SOURCES), dict(HELDOUT_SOURCES)
53
+ if args.sources == "all":
54
+ train_sources.update(EXTRA_TRAIN_SOURCES)
55
+ heldout_sources.update(EXTRA_HELDOUT_SOURCES)
56
+ if args.exclude_nc:
57
+ train_sources = {k: v for k, v in train_sources.items() if SOURCE_META.get(k, {}).get("license") != "nc"}
58
+ if args.only:
59
+ keep = set(args.only.split(","))
60
+ train_sources = {k: v for k, v in train_sources.items() if k in keep}
61
+ heldout_sources = {k: v for k, v in heldout_sources.items() if k in keep}
62
+
63
+ ctx = label_context()
64
+ if args.sources == "all":
65
+ ctx.update(extra_label_context())
66
+ splits = collections.defaultdict(list)
67
+
68
+
69
+ def text_key(state) -> str:
70
+ text = " ".join(str(v) for v in state.values()) if isinstance(state, dict) else str(state)
71
+ return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()[:200]
72
+
73
+
74
+ # ------------------------------------------------------------------ held-out first, so training can be de-duplicated against it
75
+ for name, (hf_id, cfg, _, test_split, stream, adapter) in heldout_sources.items():
76
+ try:
77
+ test = take(hf_id, cfg, test_split, args.n_test, stream, args.seed + 1)
78
+ splits["heldout"] += to_records(name, test, adapter, rng, ctx, "heldout")
79
+ print(f"ok {name} (held out)", flush=True)
80
+ except Exception: # noqa: BLE001 - one broken source should not sink the build
81
+ print(f"FAIL {name}\n{traceback.format_exc()}", flush=True)
82
+ heldout_keys = {text_key(r["state"]) for r in splits["heldout"]}
83
+
84
+ for name, (hf_id, cfg, train_split, test_split, stream, adapter) in train_sources.items():
85
+ try:
86
+ # civil_comments drops most non-toxic rows inside its adapter, so over-fetch for it
87
+ mult = 3 if name == "civil_comments" else 1
88
+ pool = take(hf_id, cfg, train_split, (args.n_train + args.n_val) * mult, stream, args.seed)
89
+ n_val = min(args.n_val * mult, len(pool) // 10)
90
+ parts = {"val": to_records(name, pool[:n_val], adapter, rng, ctx, "val"),
91
+ "train": to_records(name, pool[n_val:], adapter, rng, ctx, "train")} # fmt: skip
92
+ if test_split:
93
+ test = take(hf_id, cfg, test_split, args.n_test * mult, stream, args.seed + 1)
94
+ parts["test"] = to_records(name, test, adapter, rng, ctx, "test")
95
+ for split, recs in parts.items():
96
+ if name in NONE_OK:
97
+ recs = [add_none_option(r, rng) for r in recs]
98
+ splits[split] += recs
99
+ print(f"ok {name}: {len(parts['train'])} train rows", flush=True)
100
+ except Exception: # noqa: BLE001
101
+ print(f"FAIL {name}\n{traceback.format_exc()}", flush=True)
102
+
103
+ if args.include_ni:
104
+ try:
105
+ blocked = ("imdb", "_arc_", "pubmedqa", "pubmed_qa", "massive", "askubuntu", "emotion") # overlap with held-out sets
106
+ recs = [r for r in natural_instructions_records(rng, args.ni_scan) if not any(b in r["task"].lower() for b in blocked)]
107
+ tasks = sorted({r["task"] for r in recs})
108
+ rng.shuffle(tasks)
109
+ held = set(tasks[: len(tasks) // 10]) # whole tasks held out; none if fewer than 10 qualified
110
+ for r in recs:
111
+ if r["task"] in held:
112
+ splits["heldout"].append({**r, "source": "ni_unseen_tasks", "sid": r["sid"].replace("train-", "heldout-", 1)})
113
+ else:
114
+ splits["train"].append(r)
115
+ print(f"ok natural_instructions: {len(tasks) - len(held)} train tasks, {len(held)} held-out tasks, {len(recs)} rows", flush=True)
116
+ except Exception: # noqa: BLE001
117
+ print(f"FAIL natural_instructions\n{traceback.format_exc()}", flush=True)
118
+
119
+ stats = {}
120
+ for split, records in splits.items():
121
+ if split != "heldout":
122
+ before = len(records)
123
+ records = [r for r in records if text_key(r["state"]) not in heldout_keys]
124
+ if before != len(records):
125
+ print(f"{split}: dropped {before - len(records)} rows that also appear in a held-out set")
126
+ if split == "train":
127
+ rng.shuffle(records)
128
+ write_jsonl(f"{args.out}/{split}.jsonl", records)
129
+ by = collections.Counter((r["source"], r["question"]["type"]) for r in records)
130
+ stats[split] = {"rows": len(records), "by_source_type": {f"{s}/{t}": n for (s, t), n in sorted(by.items())}}
131
+ print(f"{split:8s} {len(records):7d} rows")
132
+ with open(f"{args.out}/stats.json", "w") as f:
133
+ json.dump(stats, f, indent=2)
134
+ sys.stdout.flush()
135
+ os._exit(0) # streaming dataset readers can leave non-daemon threads behind and hang a normal exit
scripts/build_image_data.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the image training set: resized JPEGs + records, mixed with replayed text rows.
2
+
3
+ python scripts/build_image_data.py --out data_img --n-train 5000 --text-replay data_a --text-rows 25000
4
+
5
+ Every image is resized ONCE, here, to its final aspect-preserving size (multiples of 32 px, one
6
+ token per 32x32 px) and its token grid is written into the record. The trainer can then bucket
7
+ rows by length without decoding a single picture and only loads pixels per batch.
8
+
9
+ Text rows are replayed alongside so the text skills the model already has do not fade.
10
+ """
11
+ import argparse
12
+ import collections
13
+ import json
14
+ import os
15
+ import random
16
+ import sys
17
+ import traceback
18
+ from pathlib import Path
19
+
20
+ from zico.data import read_jsonl, write_jsonl
21
+ from zico.image_sources import IMAGE_HELDOUT_SOURCES, IMAGE_TRAIN_SOURCES
22
+ from zico.vision import smart_resize
23
+
24
+ p = argparse.ArgumentParser()
25
+ p.add_argument("--out", default="data_img")
26
+ p.add_argument("--n-train", type=int, default=5000, help="images per training source")
27
+ p.add_argument("--doc-mult", type=float, default=2.0, help="documents are the weak spot: take this many times more")
28
+ p.add_argument("--n-val", type=int, default=60)
29
+ p.add_argument("--n-test", type=int, default=150)
30
+ p.add_argument("--n-heldout", type=int, default=300)
31
+ p.add_argument("--text-replay", default=None, help="a text data dir (train/val/test/heldout .jsonl) to mix in")
32
+ p.add_argument("--text-rows", type=int, default=25000)
33
+ p.add_argument("--only", default=None)
34
+ p.add_argument("--quick", action="store_true", help="stream every source (adapter smoke test; samples may be class-skewed)")
35
+ p.add_argument("--min-pixels", type=int, default=256 * 256)
36
+ p.add_argument("--seed", type=int, default=0)
37
+ args = p.parse_args()
38
+
39
+ from datasets import load_dataset # noqa: E402
40
+ from PIL import Image # noqa: E402
41
+
42
+ rng = random.Random(args.seed)
43
+ out = Path(args.out)
44
+ splits = collections.defaultdict(list)
45
+ only = set(args.only.split(",")) if args.only else None
46
+
47
+
48
+ def take(hf_id, cfg, split, n, stream):
49
+ if args.quick: # adapter smoke test only: the sample is class-skewed (see below)
50
+ ds = load_dataset(hf_id, cfg, split=split, streaming=True)
51
+ return list(ds.shuffle(seed=args.seed, buffer_size=min(4000, max(500, n))).take(n)), ds.features
52
+ if stream:
53
+ return take_uniform_stream(hf_id, cfg, split, n)
54
+ ds = load_dataset(hf_id, cfg, split=split).shuffle(seed=args.seed)
55
+ return [ds[i] for i in range(min(n, len(ds)))], ds.features
56
+
57
+
58
+ def take_uniform_stream(hf_id, cfg, split, n):
59
+ """A uniform sample of a large dataset without storing it.
60
+
61
+ `streaming.shuffle(buffer).take(n)` reads only the first few files, and image datasets are usually
62
+ grouped by class -- both across files and inside them. The first image run learned 9 of RVL-CDIP's 16
63
+ document types that way (and 17 of ImageNet-100's classes) and got *worse* on documents. Here every
64
+ file contributes equally, and within a file the shuffle buffer is larger than the file, so the draw is
65
+ uniform. Files are read in parallel threads.
66
+ """
67
+ import math
68
+ from concurrent.futures import ThreadPoolExecutor
69
+
70
+ ds = load_dataset(hf_id, cfg, split=split, streaming=True)
71
+ k = ds.n_shards
72
+ per = math.ceil(n / k) + 2
73
+
74
+ def one(i):
75
+ part = ds.shard(num_shards=k, index=i) if k > 1 else ds
76
+ return list(part.shuffle(seed=args.seed + i, buffer_size=30_000).take(per))
77
+
78
+ with ThreadPoolExecutor(min(16, k)) as pool:
79
+ rows = [r for part in pool.map(one, range(k)) for r in part]
80
+ random.Random(args.seed).shuffle(rows)
81
+ return rows[:n], ds.features
82
+
83
+
84
+ def gold_classes(records) -> set:
85
+ """Distinct correct answers among the Choice questions: a cheap check that a sample covers its classes."""
86
+ return {list(r["question"]["criteria"])[r["label"]] for r in records if r["question"]["type"] == "choice"}
87
+
88
+
89
+ def label_names(features, rows):
90
+ names = {k: f.names for k, f in features.items() if getattr(f, "names", None)}
91
+ for col in ("masterCategory", "subCategory", "articleType", "baseColour", "gender", "usage"): # string attributes
92
+ if rows and col in rows[0]:
93
+ names[col] = sorted({r[col] for r in rows if r.get(col)})
94
+ return names
95
+
96
+
97
+ def emit(name, spec, rows, names, split):
98
+ _, _, _, _, _, image_col, adapter, max_pixels, keys = spec
99
+ records = []
100
+ for i, ex in enumerate(rows):
101
+ img = ex[image_col]
102
+ if img is None:
103
+ continue
104
+ img = img.convert("RGB")
105
+ w, h = smart_resize(img.width, img.height, 32, min(args.min_pixels, max_pixels), max_pixels)
106
+ rel = f"images/{name}/{split}-{i:06d}.jpg"
107
+ (out / rel).parent.mkdir(parents=True, exist_ok=True)
108
+ img.resize((w, h), Image.BICUBIC).save(out / rel, quality=92)
109
+ part = {"type": "image", "path": rel}
110
+ state = rng.choice([{rng.choice(keys): part}, part, {"input": {rng.choice(keys): part}}])
111
+ for question, label in adapter(ex, rng, names):
112
+ records.append({"source": name, "sid": f"{split}-{name}-{i}", "state": state, "question": question, "label": label,
113
+ "image_grids": [[h // 32, w // 32]]}) # fmt: skip
114
+ return records
115
+
116
+
117
+ for name, spec in IMAGE_HELDOUT_SOURCES.items():
118
+ if only and name not in only:
119
+ continue
120
+ try:
121
+ rows, feats = take(spec[0], spec[1], spec[3], args.n_heldout, spec[4])
122
+ splits["heldout"] += emit(name, spec, rows, label_names(feats, rows), "heldout")
123
+ print(f"ok {name} (held out)", flush=True)
124
+ except Exception: # noqa: BLE001
125
+ print(f"FAIL {name}\n{traceback.format_exc()}", flush=True)
126
+
127
+ for name, spec in IMAGE_TRAIN_SOURCES.items():
128
+ if only and name not in only:
129
+ continue
130
+ try:
131
+ n = int(args.n_train * (args.doc_mult if name == "rvl_cdip" else 1))
132
+ pool, feats = take(spec[0], spec[1], spec[2], n + args.n_val, spec[4])
133
+ names = label_names(feats, pool)
134
+ splits["val"] += emit(name, spec, pool[: args.n_val], names, "val")
135
+ train = emit(name, spec, pool[args.n_val :], names, "train")
136
+ splits["train"] += train
137
+ n_test_classes = 0
138
+ if spec[3]:
139
+ test, _ = take(spec[0], spec[1], spec[3], args.n_test, spec[4])
140
+ test_recs = emit(name, spec, test, names, "test")
141
+ splits["test"] += test_recs
142
+ n_test_classes = len(gold_classes(test_recs))
143
+ n_all = max((len(v) for v in names.values()), default=0)
144
+ covered = len(gold_classes(train))
145
+ flag = " <-- WARNING: skewed sample" if n_all and covered < 0.6 * min(n_all, len(train) / 4) else ""
146
+ print(f"ok {name}: {len(train)} train rows, {covered} distinct answer classes in train (largest label set {n_all}), "
147
+ f"{n_test_classes} in test{flag}", flush=True) # fmt: skip
148
+ except Exception: # noqa: BLE001
149
+ print(f"FAIL {name}\n{traceback.format_exc()}", flush=True)
150
+
151
+ if args.text_replay:
152
+ n_img = len(splits["train"])
153
+ text = read_jsonl(f"{args.text_replay}/train.jsonl")
154
+ splits["train"] += rng.sample(text, min(args.text_rows, len(text)))
155
+ for split, n in (("val", 1500), ("test", 3000)):
156
+ rows = read_jsonl(f"{args.text_replay}/{split}.jsonl")
157
+ splits[split] += rng.sample(rows, min(n, len(rows)))
158
+ splits["heldout"] += read_jsonl(f"{args.text_replay}/heldout.jsonl") # text held-out sets: did anything fade?
159
+ print(f"text replay: {len(splits['train']) - n_img} text rows added to {n_img} image rows")
160
+
161
+ stats = {}
162
+ for split, records in splits.items():
163
+ if split == "train":
164
+ rng.shuffle(records)
165
+ write_jsonl(out / f"{split}.jsonl", records)
166
+ by = collections.Counter(r["source"] for r in records)
167
+ stats[split] = {"rows": len(records), "image_rows": sum("image_grids" in r for r in records), "by_source": dict(sorted(by.items()))}
168
+ print(f"{split:8s} {len(records):7d} rows ({stats[split]['image_rows']} with images)")
169
+ (out / "stats.json").write_text(json.dumps(stats, indent=2))
170
+ sys.stdout.flush()
171
+ os._exit(0) # streaming readers leave threads behind that hang a normal exit
scripts/check_kernels.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Numerically compare the fused Gated-DeltaNet kernel (fla) against an independent reference,
2
+ forward AND backward. Run once on any new GPU / Triton combination before training:
3
+
4
+ python scripts/check_kernels.py
5
+
6
+ Motivation: fla refuses to run its backward on Hopper GPUs with Triton 3.4-3.7.0 because that
7
+ combination silently produces wrong gradients. Whatever workaround is installed (newer Triton,
8
+ tilelang backend) should be verified rather than trusted.
9
+
10
+ The reference is a naive token-by-token recurrence in float64 written here. (HF's
11
+ `torch_chunk_gated_delta_rule` is NOT independent: it is decorated to dispatch to fla when fla is
12
+ installed, so comparing against it compares fla with itself.)
13
+
14
+ S_t = exp(g_t) * S_{t-1}
15
+ S_t = S_t + k_t (beta_t * (v_t - S_t^T k_t))^T
16
+ o_t = S_t^T q_t with q, k L2-normalised and q scaled by Dk^-1/2
17
+ """
18
+ import torch
19
+ import torch.nn.functional as F
20
+ from fla.ops.gated_delta_rule import chunk_gated_delta_rule
21
+
22
+ dev = "cuda"
23
+ B, L, H, DK, DV = 2, 200, 4, 64, 64 # L deliberately not a multiple of the 64-token chunk
24
+
25
+
26
+ def naive(q, k, v, g, beta):
27
+ q, k = F.normalize(q, dim=-1), F.normalize(k, dim=-1)
28
+ q = q * DK**-0.5
29
+ S = q.new_zeros(B, H, DK, DV)
30
+ outs = []
31
+ for t in range(L):
32
+ S = S * g[:, t].exp()[..., None, None]
33
+ pred = (S * k[:, t][..., None]).sum(-2) # S^T k -> (B, H, DV)
34
+ S = S + k[:, t][..., None] * ((v[:, t] - pred) * beta[:, t][..., None])[..., None, :]
35
+ outs.append((S * q[:, t][..., None]).sum(-2))
36
+ return torch.stack(outs, 1)
37
+
38
+
39
+ def make(dtype):
40
+ gen = torch.Generator(device=dev).manual_seed(1)
41
+ q = torch.randn(B, L, H, DK, device=dev, generator=gen)
42
+ k = torch.randn(B, L, H, DK, device=dev, generator=gen)
43
+ v = torch.randn(B, L, H, DV, device=dev, generator=gen)
44
+ g = -torch.rand(B, L, H, device=dev, generator=gen) * 0.5 # log-space decay, <= 0
45
+ beta = torch.rand(B, L, H, device=dev, generator=gen)
46
+ w = torch.randn(B, L, H, DV, device=dev, generator=gen) # fixed projection: same loss on both paths
47
+ leaves = [t.to(dtype).requires_grad_(True) for t in (q, k, v, g, beta)]
48
+ return leaves, w
49
+
50
+
51
+ def grads_of(out, leaves, w):
52
+ (out.double() * w.double()).sum().backward()
53
+ return out.double().detach(), [t.grad.double() for t in leaves]
54
+
55
+
56
+ leaves, w = make(torch.float64)
57
+ ref_out, ref_grads = grads_of(naive(*leaves), leaves, w)
58
+
59
+ ok = True
60
+ rel = lambda a, b: ((a - b).norm() / b.norm().clamp_min(1e-12)).item() # noqa: E731
61
+ for dtype in (torch.float32, torch.bfloat16):
62
+ (q, k, v, g, beta), w = make(dtype)
63
+ try:
64
+ out = chunk_gated_delta_rule(q, k, v, g=g.float() if dtype == torch.bfloat16 else g, beta=beta, use_qk_l2norm_in_kernel=True)
65
+ out = out[0] if isinstance(out, tuple) else out
66
+ out, grads = grads_of(out, (q, k, v, g, beta), w)
67
+ except Exception as e: # noqa: BLE001
68
+ print(f"fla {dtype}: FAILED to run: {type(e).__name__}: {str(e)[:300]}")
69
+ ok = False
70
+ continue
71
+ errs = {"out": rel(out, ref_out), **{n: rel(a, b) for n, a, b in zip(("dq", "dk", "dv", "dg", "dbeta"), grads, ref_grads)}}
72
+ # Triton's tl.dot runs in TF32 on Ampere+ GPUs, so even "fp32" agrees only to ~1e-3. A genuinely
73
+ # wrong backward shows up as O(0.1-1) error on specific gradients, not a uniform small offset
74
+ # shared with the forward output.
75
+ tol = 1e-2 if dtype == torch.float32 else 5e-2
76
+ bad = {n: e for n, e in errs.items() if not e < tol}
77
+ ok &= not bad
78
+ print(f"fla {str(dtype):15s} rel-err vs naive fp64: " + " ".join(f"{n}={e:.1e}" for n, e in errs.items()) + (" <-- MISMATCH" if bad else " ok"))
79
+ print("KERNEL CHECK PASSED" if ok else "KERNEL CHECK FAILED")
scripts/check_mlx_parity.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compare the MLX backend with the PyTorch backend on identical requests, then time both.
2
+
3
+ python scripts/check_mlx_parity.py runs/qwen35-4b-best-bf16 --data data --states 40
4
+ """
5
+ import argparse
6
+ import collections
7
+ import json
8
+ import statistics
9
+ import time
10
+
11
+ import torch
12
+
13
+ from zico import DecisionModel, question_from_dict
14
+ from zico.backbone import pick_device
15
+ from zico.data import read_jsonl
16
+ from zico.mlx_backend import MLXDecisionModel
17
+
18
+ p = argparse.ArgumentParser()
19
+ p.add_argument("ckpt")
20
+ p.add_argument("--data", default="data")
21
+ p.add_argument("--states", type=int, default=40)
22
+ p.add_argument("--skip-torch-timing", action="store_true")
23
+ args = p.parse_args()
24
+
25
+ # requests: real rows grouped by state (so multi-question states are exercised) + the example ticket
26
+ by_state = collections.OrderedDict()
27
+ for split in ("heldout", "test"):
28
+ for r in read_jsonl(f"{args.data}/{split}.jsonl"):
29
+ by_state.setdefault(r["sid"], {"state": r["state"], "questions": {}})["questions"][f"q{len(by_state[r['sid']]['questions'])}"] = r["question"]
30
+ multi = [v for v in by_state.values() if len(v["questions"]) > 1][: args.states // 2]
31
+ single = [v for v in list(by_state.values())[:: max(1, len(by_state) // args.states)] if len(v["questions"]) == 1][: args.states - len(multi)]
32
+ example = json.load(open("examples/systemone_request.json"))
33
+ requests = [{"state": example["state"], "questions": example["questions"]}] + multi + single
34
+ print(f"{len(requests)} requests, {sum(len(r['questions']) for r in requests)} questions")
35
+
36
+ dev = pick_device()
37
+ torch_model, renderer, _ = DecisionModel.load(args.ckpt, device=dev, dtype=torch.bfloat16)
38
+ mlx_model, mlx_renderer, _ = MLXDecisionModel.load(args.ckpt)
39
+
40
+
41
+ def flat(answer):
42
+ a = answer
43
+ if a["type"] == "noul":
44
+ return [1 - a["noul"], a["noul"]]
45
+ return list(a["probabilities"].values())
46
+
47
+
48
+ def top(answer):
49
+ return answer.get("choice", round(answer.get("noul", answer.get("score", 0))))
50
+
51
+
52
+ diffs, agree, total, t_torch, t_mlx = [], 0, 0, [], []
53
+ for i, req in enumerate(requests):
54
+ qs = {k: question_from_dict(v) for k, v in req["questions"].items()}
55
+ t0 = time.perf_counter()
56
+ a = torch_model.answer(renderer, req["state"], qs)
57
+ if dev == "mps":
58
+ torch.mps.synchronize()
59
+ t1 = time.perf_counter()
60
+ b = mlx_model.answer(mlx_renderer, req["state"], qs)
61
+ t2 = time.perf_counter()
62
+ if i: # first request warms both up
63
+ t_torch.append(t1 - t0)
64
+ t_mlx.append(t2 - t1)
65
+ assert a["usage"] == b["usage"], (a["usage"], b["usage"])
66
+ for name in qs:
67
+ pa, pb = flat(a["answers"][name]), flat(b["answers"][name])
68
+ diffs += [abs(x - y) for x, y in zip(pa, pb)]
69
+ same = top(a["answers"][name]) == top(b["answers"][name])
70
+ agree += same
71
+ total += 1
72
+ if not same: # expected only for near-ties, where bf16 rounding decides
73
+ two = lambda p: [round(v, 4) for v in sorted(p, reverse=True)[:2]] # noqa: E731
74
+ print(f" top answer differs on a {a['answers'][name]['type']} question: torch top-2 {two(pa)} vs mlx top-2 {two(pb)} "
75
+ f"(torch says {top(a['answers'][name])!r}, mlx says {top(b['answers'][name])!r})") # fmt: skip
76
+
77
+ print(f"probabilities compared: {len(diffs)} | max |dp| = {max(diffs):.4f} | mean |dp| = {statistics.mean(diffs):.5f} | "
78
+ f"same top answer: {agree}/{total}") # fmt: skip
79
+ print(f"median latency per request PyTorch ({dev}, bf16): {statistics.median(t_torch)*1000:.0f} ms MLX: {statistics.median(t_mlx)*1000:.0f} ms "
80
+ f"-> {statistics.median(t_torch)/statistics.median(t_mlx):.1f}x") # fmt: skip
81
+ ex = mlx_model.answer(mlx_renderer, example["state"], {k: question_from_dict(v) for k, v in example["questions"].items()})
82
+ print("example ticket via MLX:", {k: (v.get("choice") or v.get("noul") or v.get("score")) for k, v in ex["answers"].items()})
83
+ reps = []
84
+ for _ in range(5):
85
+ t0 = time.perf_counter()
86
+ mlx_model.answer(mlx_renderer, example["state"], {k: question_from_dict(v) for k, v in example["questions"].items()})
87
+ reps.append(time.perf_counter() - t0)
88
+ print(f"example ticket (5 questions, {ex['usage']['input_tokens']} tokens) via MLX: best {min(reps)*1000:.0f} ms, median {statistics.median(reps)*1000:.0f} ms")
scripts/eval.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluate a checkpoint: accuracy + calibration on in-distribution test data and on
2
+ held-out schemas, post-hoc temperature scaling, and the fork==single invariance / latency check.
3
+
4
+ python scripts/eval.py runs/qwen35-0.8b/best --save-calibrated
5
+ """
6
+ import argparse
7
+ import json
8
+ import time
9
+ from pathlib import Path
10
+
11
+ import torch
12
+
13
+ from zico import DecisionModel, question_from_dict
14
+ from zico.backbone import pick_device
15
+ from zico.calibration import fit_temperature
16
+ from zico.data import read_jsonl, render_records, subsample
17
+ from zico.evaluate import format_report, predict, report, strip_tables
18
+ from zico.schema import QTYPES
19
+
20
+ p = argparse.ArgumentParser()
21
+ p.add_argument("ckpt")
22
+ p.add_argument("--data", default="data")
23
+ p.add_argument("--max-len", type=int, default=768)
24
+ p.add_argument("--limit", type=int, default=None, help="cap rows per split (quick runs)")
25
+ p.add_argument("--precision", default="bf16", choices=["bf16", "fp32"])
26
+ p.add_argument("--save-calibrated", action="store_true", help="write fitted temperatures into the checkpoint")
27
+ args = p.parse_args()
28
+
29
+ dev = pick_device()
30
+ model, renderer, meta = DecisionModel.load(args.ckpt, device=dev)
31
+ model.autocast_dtype = torch.bfloat16 if args.precision == "bf16" else None
32
+ results = {"ckpt": args.ckpt, "meta": meta}
33
+
34
+
35
+ def run(split):
36
+ recs = subsample(read_jsonl(f"{args.data}/{split}.jsonl"), args.limit)
37
+ image_meta = model.image_pre.meta if model.image_pre is not None else None
38
+ rows, recs = render_records(recs, renderer, args.max_len, image_meta, args.data)
39
+ return predict(model, rows, recs, renderer.slot_id), recs
40
+
41
+
42
+ def rescale(preds, temps):
43
+ out = []
44
+ for r in preds:
45
+ lg = r["logits"] / temps[r["qtype"]]
46
+ out.append({**r, "p": torch.softmax(lg, -1)})
47
+ return out
48
+
49
+
50
+ # ------------------------------------------------------------ temperature from val
51
+ model.log_temp.zero_()
52
+ val_preds, _ = run("val")
53
+ temps = []
54
+ for qi, name in enumerate(QTYPES):
55
+ sel = [r for r in val_preds if r["qtype"] == qi]
56
+ temps.append(fit_temperature([r["logits"] for r in sel], [r["label"] for r in sel]) if len(sel) >= 50 else 1.0)
57
+ print("fitted temperatures:", {n: round(t, 3) for n, t in zip(QTYPES, temps)})
58
+ results["temperatures"] = dict(zip(QTYPES, temps))
59
+
60
+ # ------------------------------------------------------------ test + held-out
61
+ for split in ("test", "heldout"):
62
+ preds, _ = run(split)
63
+ raw, cal = report(preds), report(rescale(preds, temps))
64
+ print("\n" + format_report(raw, f"{split} (raw)"))
65
+ print("\n" + format_report(cal, f"{split} (temp-scaled)"))
66
+ results[split] = {"raw": strip_tables(raw), "calibrated": strip_tables(cal),
67
+ "reliability_raw": raw["overall"]["reliability"], "reliability_calibrated": cal["overall"]["reliability"]} # fmt: skip
68
+
69
+ # ------------------------------------------------------------ fork invariance + fan-out latency
70
+ recs = [r for r in read_jsonl(f"{args.data}/test.jsonl") if "image_grids" not in r] # a text state, text questions
71
+ if not recs:
72
+ (Path(args.ckpt) / "eval.json").write_text(json.dumps(results, indent=2))
73
+ raise SystemExit("no text rows in the test split: skipping the fork / latency section")
74
+ state = next((r["state"] for r in recs if r["source"] == "yelp"), recs[0]["state"])
75
+ pool = [question_from_dict(r["question"]) for r in recs if r["source"] in ("yelp", "go_emotions", "civil_comments", "ag_news")] \
76
+ or [question_from_dict(r["question"]) for r in recs] # fmt: skip
77
+ seen, questions = set(), []
78
+ for q in pool: # distinct questions only
79
+ key = json.dumps([q.type, q.instructions, q.criteria], sort_keys=True)
80
+ if key not in seen:
81
+ seen.add(key)
82
+ questions.append(q)
83
+ questions = questions[:64]
84
+
85
+
86
+ def sync():
87
+ if dev == "mps":
88
+ torch.mps.synchronize()
89
+ elif dev == "cuda":
90
+ torch.cuda.synchronize()
91
+
92
+
93
+ def timed(fn, reps=3):
94
+ best = float("inf")
95
+ for _ in range(reps):
96
+ sync(); t = time.time(); out = fn(); sync() # noqa: E702
97
+ best = min(best, time.time() - t)
98
+ return out, best * 1000
99
+
100
+
101
+ model.autocast_dtype = None # compare the two paths in fp32 so differences mean logic, not rounding
102
+ single = [model.question_logits(renderer, state, [q], fork=False)[0][0] for q in questions[:16]]
103
+ forked = model.question_logits(renderer, state, questions[:16], fork=True)[0]
104
+ gap = max((torch.softmax(a, -1) - torch.softmax(b, -1)).abs().max().item() for a, b in zip(single, forked))
105
+ print(f"\nfork vs single-question, max |delta p| over 16 questions (fp32): {gap:.2e}")
106
+ results["fork_invariance_max_dp"] = gap
107
+
108
+ model.autocast_dtype = torch.bfloat16 if args.precision == "bf16" else None
109
+ print(f"fan-out latency on one state ({len(renderer.state(state))} state tokens):")
110
+ results["latency_ms"] = {}
111
+ for n in (1, 4, 16, 64):
112
+ if n > len(questions):
113
+ break
114
+ qs = questions[:n]
115
+ _, t_fork = timed(lambda: model.question_logits(renderer, state, qs, fork=True, max_batch=64))
116
+ _, t_seq = timed(lambda: [model.question_logits(renderer, state, [q], fork=False) for q in qs], reps=1)
117
+ print(f" {n:3d} questions: forked batch {t_fork:7.0f} ms | {n} separate calls {t_seq:7.0f} ms ({t_seq/t_fork:.1f}x)")
118
+ results["latency_ms"][n] = {"fork": t_fork, "separate": t_seq}
119
+
120
+ if args.save_calibrated:
121
+ with torch.no_grad():
122
+ model.log_temp.copy_(torch.tensor(temps).log())
123
+ head = {k: v for k, v in model.state_dict().items() if not k.startswith("backbone.")}
124
+ torch.save(head, Path(args.ckpt) / "head.pt")
125
+ print("saved temperatures into", Path(args.ckpt) / "head.pt")
126
+ (Path(args.ckpt) / "eval.json").write_text(json.dumps(results, indent=2))
scripts/eval_images.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Zero-shot image decisions through the production path: base64 image parts inside `state`.
2
+
3
+ python scripts/eval_images.py runs/qwen35-4b-best-bf16 --tasks cifar10,documents --detail auto,high --n 100
4
+
5
+ (scripts/probe_vision.py was the original feasibility probe; this goes through DecisionModel.answer,
6
+ i.e. exactly what the server runs, with aspect-preserving resize and the per-image "detail" hint.)
7
+ """
8
+ import argparse
9
+ import base64
10
+ import io
11
+ import json
12
+ import random
13
+ import time
14
+ from pathlib import Path
15
+
16
+ import torch
17
+ from datasets import load_dataset
18
+
19
+ from zico import Choice, DecisionModel
20
+ from zico.backbone import pick_device
21
+ from zico.calibration import ece
22
+
23
+ p = argparse.ArgumentParser()
24
+ p.add_argument("ckpt")
25
+ p.add_argument("--tasks", default="cifar10,documents")
26
+ p.add_argument("--detail", default="auto", help="comma-separated: auto, low, high")
27
+ p.add_argument("--n", type=int, default=100)
28
+ p.add_argument("--out", default="logs/eval_images.json")
29
+ args = p.parse_args()
30
+
31
+ SPECS = { # name: (dataset, split, image column, label column, instructions, streamed?)
32
+ "cifar10": ("uoft-cs/cifar10", "test", "img", "label", "What does the image show?", False),
33
+ "mnist": ("ylecun/mnist", "test", "image", "label", "Which digit is written in the image?", False),
34
+ "pets": ("timm/oxford-iiit-pet", "test", "image", "label", "Which breed is the animal in the photo?", True),
35
+ "food101": ("ethz/food101", "validation", "image", "label", "Which dish is shown in the photo?", True),
36
+ "documents": ("jordyvl/rvl_cdip_100_examples_per_class", "test", "image", "label", "What type of document is this scan?", False),
37
+ }
38
+
39
+ dev = pick_device()
40
+ model, renderer, _ = DecisionModel.load(args.ckpt, device=dev, dtype=torch.bfloat16 if dev != "cpu" else torch.float32,
41
+ renderer_overrides={"max_state_tokens": 8192}) # serving limit, not the training-time one
42
+ assert model.supports_images, "run scripts/add_vision.py on this checkpoint first"
43
+
44
+
45
+ def encode(img) -> str:
46
+ buf = io.BytesIO()
47
+ img.convert("RGB").save(buf, format="PNG")
48
+ return base64.b64encode(buf.getvalue()).decode()
49
+
50
+
51
+ results = {}
52
+ for task in args.tasks.split(","):
53
+ name, split, img_col, lab_col, instructions, stream = SPECS[task]
54
+ if stream:
55
+ ds = load_dataset(name, split=split, streaming=True).shuffle(seed=0, buffer_size=3000)
56
+ rows, names = list(ds.take(args.n)), load_dataset(name, split=split, streaming=True).features[lab_col].names
57
+ else:
58
+ ds = load_dataset(name, split=split).shuffle(seed=0)
59
+ rows, names = [ds[i] for i in range(min(args.n, len(ds)))], ds.features[lab_col].names
60
+ keys = [str(x).replace(" ", "_").lower() for x in names]
61
+ for detail in args.detail.split(","):
62
+ rng = random.Random(0)
63
+ conf, correct, tokens, t0 = [], [], [], time.time()
64
+ for r in rows:
65
+ order = keys[:]
66
+ rng.shuffle(order)
67
+ part = {"type": "image", "data": encode(r[img_col])}
68
+ if detail != "auto":
69
+ part["detail"] = detail
70
+ out = model.answer(renderer, {"image": part}, {"q": Choice(instructions, order)})
71
+ a = out["answers"]["q"]
72
+ conf.append(max(a["probabilities"].values()))
73
+ correct.append(a["choice"] == keys[r[lab_col]])
74
+ tokens.append(out["usage"]["input_tokens"])
75
+ e, _ = ece(torch.tensor(conf), torch.tensor(correct))
76
+ m = {"n": len(rows), "options": len(keys), "acc": sum(correct) / len(correct), "ece": e,
77
+ "mean_tokens": sum(tokens) / len(tokens), "sec_per_image": (time.time() - t0) / len(rows)} # fmt: skip
78
+ results[f"{task}/{detail}"] = m
79
+ print(f"{task:10s} detail={detail:5s} n={m['n']:4d} options={m['options']:3d} acc={m['acc']:.3f} ece={m['ece']:.3f} "
80
+ f"tokens/request={m['mean_tokens']:.0f} {m['sec_per_image']:.2f}s/img", flush=True) # fmt: skip
81
+ Path(args.out).parent.mkdir(parents=True, exist_ok=True)
82
+ Path(args.out).write_text(json.dumps(results, indent=2))
scripts/infer.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Answer a System One request (state + typed questions) with a trained checkpoint.
2
+
3
+ python scripts/infer.py runs/qwen35-0.8b/best examples/request.json
4
+
5
+ Request: {"state": <str | object>, "questions": {"name": {"type": "choice|score|noul", "instructions": ..., "criteria": ...}}}
6
+ Response: {"answers": {"name": {...}}, "usage": {...}, "model": ..., "latency_ms": ...}
7
+ """
8
+ import argparse
9
+ import json
10
+ import sys
11
+ import time
12
+
13
+ import torch
14
+
15
+ from zico import DecisionModel, question_from_dict
16
+ from zico.backbone import pick_device
17
+
18
+ p = argparse.ArgumentParser()
19
+ p.add_argument("ckpt")
20
+ p.add_argument("request", nargs="?", default="-", help="path to a JSON request, or - for stdin")
21
+ p.add_argument("--precision", default="bf16", choices=["bf16", "fp32"])
22
+ args = p.parse_args()
23
+
24
+ req = json.load(sys.stdin if args.request == "-" else open(args.request))
25
+ dev = pick_device()
26
+ model, renderer, meta = DecisionModel.load(args.ckpt, device=dev)
27
+ model.autocast_dtype = torch.bfloat16 if args.precision == "bf16" else None
28
+ questions = {name: question_from_dict(q) for name, q in req["questions"].items()}
29
+
30
+ model.answer(renderer, req["state"], questions) # warm-up (kernel compilation)
31
+ t0 = time.time()
32
+ resp = model.answer(renderer, req["state"], questions)
33
+ if dev == "mps":
34
+ torch.mps.synchronize()
35
+ resp["latency_ms"] = round((time.time() - t0) * 1000, 1)
36
+ resp["model"] = f"zico/{meta['base_model']}"
37
+ print(json.dumps(resp, indent=2))
scripts/make_soft_targets.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Label rows with a model's own probabilities, to use as soft training targets ("anchor" rows).
2
+
3
+ python scripts/make_soft_targets.py runs/qwen35-4b-best-bf16 --rows data_b/train.jsonl --n 80000 --out data_img3/anchor.jsonl
4
+
5
+ Why: every further full fine-tune we tried made the model more overconfident on tasks it had never
6
+ seen (the second text pass, both image runs). Training the replayed text rows toward the *current*
7
+ model's distribution instead of toward hard labels keeps its text behaviour -- including how unsure it
8
+ is -- where it was, while image rows still learn from hard labels. Soft targets are just another
9
+ distribution for the proper scoring losses, so nothing else in the trainer changes. Rows from tasks
10
+ the teacher never trained on are especially useful: they pin down its zero-shot behaviour.
11
+ """
12
+ import argparse
13
+ import json
14
+ import random
15
+
16
+ import torch
17
+
18
+ from zico import DecisionModel
19
+ from zico.backbone import pick_device
20
+ from zico.data import read_jsonl, render_records
21
+ from zico.evaluate import predict
22
+
23
+ p = argparse.ArgumentParser()
24
+ p.add_argument("teacher")
25
+ p.add_argument("--rows", required=True, help="jsonl of text records")
26
+ p.add_argument("--n", type=int, default=80000)
27
+ p.add_argument("--out", required=True)
28
+ p.add_argument("--max-len", type=int, default=1024)
29
+ p.add_argument("--seed", type=int, default=0)
30
+ args = p.parse_args()
31
+
32
+ records = [r for r in read_jsonl(args.rows) if "image_grids" not in r]
33
+ random.Random(args.seed).shuffle(records)
34
+ records = records[: args.n]
35
+
36
+ dev = pick_device()
37
+ model, renderer, _ = DecisionModel.load(args.teacher, device=dev)
38
+ model.autocast_dtype = torch.bfloat16 if dev != "cpu" else None
39
+ rows, kept = render_records(records, renderer, args.max_len)
40
+ preds = predict(model, rows, kept, renderer.slot_id, max_tokens=16384, max_rows=256)
41
+
42
+ agree = conf = 0.0
43
+ with open(args.out, "w") as f:
44
+ for rec, pr in zip(kept, preds):
45
+ probs = [round(float(x), 6) for x in pr["p"]]
46
+ agree += int(pr["p"].argmax()) == rec["label"]
47
+ conf += max(probs)
48
+ f.write(json.dumps({**rec, "soft": probs, "anchor": True}, ensure_ascii=False) + "\n")
49
+ n = len(kept)
50
+ print(f"wrote {n} anchor rows to {args.out}: teacher agrees with the gold label on {agree / n:.3f}, mean confidence {conf / n:.3f}")
scripts/run_4b.sh ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Runbook for the 4B run. Each stage is restartable; long stages should be launched under nohup/tmux.
3
+ # bash scripts/run_4b.sh data # build the broad dataset (~1 h, CPU + network)
4
+ # bash scripts/run_4b.sh speed # measure tokens/s and memory (~5 min)
5
+ # bash scripts/run_4b.sh train # train; re-running resumes (hours)
6
+ # bash scripts/run_4b.sh eval # test + held-out + calibration
7
+ # Tunables (env): MODEL, DATA, RUN, N_TRAIN, EPOCHS, MAX_TOKENS, ACCUM, LR
8
+ set -euo pipefail
9
+ cd "$(dirname "$0")/.."
10
+ # shellcheck disable=SC1091
11
+ if [ -f /venv/main/bin/activate ]; then source /venv/main/bin/activate; else source .venv/bin/activate; fi
12
+ export TOKENIZERS_PARALLELISM=false HF_HUB_DISABLE_PROGRESS_BARS=1
13
+
14
+ MODEL=${MODEL:-Qwen/Qwen3.5-4B-Base}
15
+ DATA=${DATA:-data_a}
16
+ RUN=${RUN:-runs/qwen35-4b}
17
+ mkdir -p logs
18
+
19
+ case "${1:-}" in
20
+ data)
21
+ python scripts/build_data.py --out "$DATA" --sources all --include-ni \
22
+ --n-train "${N_TRAIN:-15000}" --n-val 300 --n-test 500 ;;
23
+ speed)
24
+ python scripts/spike_backbone.py "$MODEL" --dtype bf16 ;;
25
+ train)
26
+ python scripts/train.py --model "$MODEL" --data "$DATA" --out "$RUN" --resume \
27
+ --grad-checkpointing --max-tokens "${MAX_TOKENS:-16384}" --max-rows 256 --grad-accum "${ACCUM:-4}" \
28
+ --max-len 1024 --max-state-tokens 768 --lr "${LR:-1e-5}" --head-lr 5e-4 \
29
+ --epochs "${EPOCHS:-1}" --eval-every 250 --val-rows 3000 --log-every 10 ;;
30
+ eval)
31
+ python scripts/eval.py "$RUN/best" --data "$DATA" --max-len 1024 --save-calibrated ;;
32
+ *)
33
+ echo "usage: $0 {data|speed|train|eval}"; exit 1 ;;
34
+ esac
scripts/run_img.sh ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Runbook for the image fine-tune on a rented CUDA box. Each stage is restartable.
3
+ # bash scripts/run_img.sh vision # package the base ViT into the starting checkpoint (~2 min)
4
+ # bash scripts/run_img.sh data # image datasets + replayed text -> data_img/ (~45 min, CPU + network)
5
+ # bash scripts/run_img.sh speed # 40 optimizer steps without saving: tokens/s and memory (~5 min)
6
+ # bash scripts/run_img.sh train # train; re-running resumes (~1-1.5 h)
7
+ # bash scripts/run_img.sh eval # image + text test/held-out, calibration, text same-row benchmark
8
+ # Needs on the box first: the starting checkpoint (INIT), the text data to replay (TEXT), data_ref/.
9
+ set -euo pipefail
10
+ cd "$(dirname "$0")/.."
11
+ # shellcheck disable=SC1091
12
+ if [ -f /venv/main/bin/activate ]; then source /venv/main/bin/activate; else source .venv/bin/activate; fi
13
+ export TOKENIZERS_PARALLELISM=false HF_HUB_DISABLE_PROGRESS_BARS=1
14
+
15
+ INIT=${INIT:-runs/qwen35-4b-best-bf16}
16
+ TEXT=${TEXT:-data_a}
17
+ DATA=${DATA:-data_img}
18
+ RUN=${RUN:-runs/qwen35-4b-img}
19
+ # lower peak learning rate than the text runs: the model is already good at images zero-shot, and the
20
+ # second text pass showed how quickly calibration degrades when we push
21
+ TRAIN_ARGS="--init $INIT --data $DATA --grad-checkpointing --max-tokens ${MAX_TOKENS:-16384} --max-rows 128 \
22
+ --grad-accum ${ACCUM:-4} --max-len 1024 --lr ${LR:-5e-6} --head-lr 2e-4 --warmup 0.05 --epochs ${EPOCHS:-1} --log-every 10"
23
+ mkdir -p logs
24
+
25
+ case "${1:-}" in
26
+ vision) python scripts/add_vision.py "$INIT" ;;
27
+ data) python scripts/build_image_data.py --out "$DATA" --n-train "${N_TRAIN:-5000}" --text-replay "$TEXT" --text-rows "${TEXT_ROWS:-25000}" ;;
28
+ speed) python scripts/train.py $TRAIN_ARGS --out runs/_speed --train-rows 9000 --val-rows 100 --eval-every 100000 --no-save ;;
29
+ train) python scripts/train.py $TRAIN_ARGS --out "$RUN" --resume --eval-every 150 --val-rows 2500 ;;
30
+ eval)
31
+ python scripts/eval.py "$RUN/best" --data "$DATA" --max-len 1024 --save-calibrated
32
+ [ -d data_ref ] && python -u scripts/bench_ours.py "$RUN/best" --data data_ref --permutations 1 --out logs/bench_img_x1.json ;;
33
+ *) echo "usage: $0 {vision|data|speed|train|eval}"; exit 1 ;;
34
+ esac
scripts/serve.sh ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Run the System-One-compatible server for a zico checkpoint.
3
+ # scripts/serve.sh start | stop | restart | keep-awake | status | log | key-path
4
+ # Config (env): ZICO_CKPT, ZICO_HOST, ZICO_PORT, ZICO_MAX_STATE_TOKENS, ZICO_MODEL_NAME
5
+ # By default it listens on this machine's Tailscale address if there is one (reachable from your
6
+ # other devices, not from the local network or the internet), otherwise on 127.0.0.1.
7
+ set -euo pipefail
8
+ cd "$(dirname "$0")/.."
9
+ if [ -f /venv/main/bin/activate ]; then source /venv/main/bin/activate; else source .venv/bin/activate; fi
10
+ mkdir -p logs
11
+ PIDFILE=logs/server.pid
12
+ LOG=logs/server.log
13
+
14
+ tailscale_ip() {
15
+ for t in tailscale /Applications/Tailscale.app/Contents/MacOS/Tailscale; do
16
+ if command -v "$t" >/dev/null 2>&1 || [ -x "$t" ]; then "$t" ip -4 2>/dev/null | head -1 && return; fi
17
+ done
18
+ }
19
+ # Optional serve.env (KEY=VALUE lines) holds this machine's choices, e.g. which checkpoint is live.
20
+ # Variables already set in the environment win, so a one-off override still works.
21
+ if [ -f serve.env ]; then
22
+ while IFS='=' read -r key value; do
23
+ case "$key" in ''|\#*) continue ;; esac
24
+ if [ -z "${!key:-}" ]; then export "$key=$value"; fi
25
+ done < serve.env
26
+ fi
27
+ export ZICO_CKPT=${ZICO_CKPT:-.}
28
+ export ZICO_PORT=${ZICO_PORT:-8787} # 8080 is commonly taken (llama.cpp's server defaults to it)
29
+ if [ -z "${ZICO_HOST:-}" ]; then ZICO_HOST=$(tailscale_ip || true); fi
30
+ export ZICO_HOST=${ZICO_HOST:-127.0.0.1}
31
+ export TOKENIZERS_PARALLELISM=false HF_HUB_OFFLINE=1
32
+
33
+ running() { [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; }
34
+
35
+ # Keep a Mac awake while the server lives (no effect elsewhere). -s matters as much as -i: a Mac that has
36
+ # already dozed off only surfaces for ~45 s "dark wakes", and those ignore the idle assertion alone.
37
+ awake() { if command -v caffeinate >/dev/null 2>&1; then (nohup caffeinate -i -s "$@" >/dev/null 2>&1 < /dev/null &); fi; }
38
+
39
+ start() {
40
+ if running; then echo "already running (pid $(cat "$PIDFILE"))"; return; fi
41
+ nohup python -m zico.server > "$LOG" 2>&1 < /dev/null &
42
+ echo $! > "$PIDFILE"
43
+ awake -w "$(cat "$PIDFILE")"
44
+ echo "starting on http://$ZICO_HOST:$ZICO_PORT (pid $(cat "$PIDFILE")); follow with: scripts/serve.sh log"
45
+ }
46
+
47
+ case "${1:-}" in
48
+ start) start ;;
49
+ restart)
50
+ # Hold the machine awake across the gap: an idle Mac sleeps within seconds of the old assertion ending.
51
+ awake -t 300
52
+ if running; then kill "$(cat "$PIDFILE")"; rm -f "$PIDFILE"; sleep 2; fi
53
+ start ;;
54
+ keep-awake) # re-assert for a server that is already running (e.g. after the Mac slept anyway)
55
+ if running; then command -v caffeinate >/dev/null 2>&1 && caffeinate -u -t 5 || true; awake -w "$(cat "$PIDFILE")"; echo "asserted for pid $(cat "$PIDFILE")"; else echo "not running"; fi ;;
56
+ stop)
57
+ if running; then kill "$(cat "$PIDFILE")"; rm -f "$PIDFILE"; echo stopped; else echo "not running"; fi ;;
58
+ status)
59
+ if running; then echo "running (pid $(cat "$PIDFILE")) on http://$ZICO_HOST:$ZICO_PORT"; else echo "not running"; fi ;;
60
+ log) tail -n 30 "$LOG" ;;
61
+ key-path) echo "${ZICO_KEY_FILE:-$HOME/.config/zico/api_key}" ;;
62
+ *) echo "usage: $0 {start|stop|restart|keep-awake|status|log|key-path}"; exit 1 ;;
63
+ esac
scripts/setup_box.sh ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # One-shot environment setup for a rented CUDA box (Vast.ai PyTorch "devel" template or similar).
3
+ # Usage: bash scripts/setup_box.sh
4
+ # Puts nothing secret on the machine: only public models and datasets are downloaded.
5
+ set -uo pipefail
6
+ cd "$(dirname "$0")/.."
7
+
8
+ echo "=== hardware"
9
+ nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader
10
+ echo "cpus: $(nproc) | ram: $(free -g | awk '/^Mem:/{print $2}') GB | disk free here: $(df -h . | awk 'NR==2{print $4}')"
11
+
12
+ echo "=== python env (reuses the image's CUDA-matched torch if there is one)"
13
+ if [ -f /venv/main/bin/activate ]; then
14
+ # Vast PyTorch images ship torch in /venv/main
15
+ # shellcheck disable=SC1091
16
+ source /venv/main/bin/activate
17
+ else
18
+ python3 -m venv --system-site-packages .venv
19
+ # shellcheck disable=SC1091
20
+ source .venv/bin/activate
21
+ fi
22
+ python -m pip --version >/dev/null 2>&1 || python -m ensurepip -q
23
+ pip install -q --upgrade pip
24
+ python -c "import torch" 2>/dev/null || pip install -q torch
25
+ export MAX_JOBS=${MAX_JOBS:-32}
26
+ pip install -q "transformers>=5.2" datasets peft safetensors numpy pytest bitsandbytes kernels
27
+ pip install -q -e .
28
+
29
+ echo "=== fused kernels for the Gated DeltaNet layers (the slow PyTorch fallback is 4x+ slower)"
30
+ pip install -q flash-linear-attention || echo "WARN: flash-linear-attention did not install"
31
+ pip install -q causal-conv1d --no-build-isolation || echo "WARN: causal-conv1d did not install (conv falls back to PyTorch)"
32
+ # fla's DeltaNet backward is numerically WRONG on Hopper GPUs with Triton 3.4-3.7.0 (fla refuses to run it);
33
+ # the tilelang backend is the fix that leaves PyTorch's pinned Triton alone. Verified below, not assumed.
34
+ pip install -q tilelang || echo "WARN: tilelang did not install"
35
+ pip install -q pillow torchvision fastapi uvicorn httpx || true
36
+
37
+ echo "=== sanity"
38
+ python - <<'PY'
39
+ import torch, transformers
40
+ print("torch", torch.__version__, "| cuda", torch.version.cuda, "| transformers", transformers.__version__)
41
+ assert torch.cuda.is_available(), "CUDA not available"
42
+ print("gpu:", torch.cuda.get_device_name(0), f"{torch.cuda.get_device_properties(0).total_memory/1e9:.0f} GB",
43
+ "| bf16:", torch.cuda.is_bf16_supported())
44
+ for mod in ("bitsandbytes", "fla", "causal_conv1d", "kernels"):
45
+ try:
46
+ __import__(mod); print(f" {mod}: ok")
47
+ except Exception as e: # noqa: BLE001
48
+ print(f" {mod}: MISSING ({type(e).__name__})")
49
+ from transformers import AutoTokenizer
50
+ for m in ("Qwen/Qwen3-0.6B", "Qwen/Qwen3.5-0.8B-Base"):
51
+ AutoTokenizer.from_pretrained(m) # tokenizers only, so the unit tests can run
52
+ PY
53
+ HF_HUB_OFFLINE=1 python -m pytest -q 2>&1 | tail -3
54
+ echo "=== fused-kernel gradients vs an independent float64 reference"
55
+ python scripts/check_kernels.py 2>&1 | grep -E "rel-err|KERNEL CHECK|FAILED"
56
+ echo "=== setup finished"
scripts/train.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fine-tune a pretrained decoder into a typed-decision model.
2
+
3
+ python scripts/train.py --model Qwen/Qwen3.5-4B-Base --data data_a --out runs/qwen35-4b --grad-checkpointing
4
+ python scripts/train.py ... --resume # continue an interrupted run from <out>/last
5
+
6
+ Stage 1 (default) is full-feedback training with proper scoring rules. Stage 2 is the
7
+ speculative bandit-feedback "RLCD" objective: continue from a checkpoint with
8
+ --init runs/... --objective bandit
9
+ """
10
+ import argparse
11
+ import json
12
+ import math
13
+ import random
14
+ import time
15
+ from pathlib import Path
16
+
17
+ import torch
18
+ from transformers import AutoTokenizer
19
+
20
+ from zico import DecisionModel, Renderer
21
+ from zico.backbone import load_text_backbone, pick_device
22
+ from zico.batching import bucketed_batches, collate
23
+ from zico.data import read_jsonl, render_records, subsample
24
+ from zico.evaluate import format_report, predict, report, strip_tables
25
+ from zico.losses import bandit_proper_score_loss, proper_score_loss
26
+
27
+ p = argparse.ArgumentParser()
28
+ p.add_argument("--model", default="Qwen/Qwen3.5-0.8B-Base")
29
+ p.add_argument("--init", default=None, help="start from a saved zico checkpoint instead of --model")
30
+ p.add_argument("--resume", action="store_true", help="continue from <out>/last (weights, optimizer, data position)")
31
+ p.add_argument("--data", default="data")
32
+ p.add_argument("--out", required=True)
33
+ p.add_argument("--epochs", type=float, default=1.0)
34
+ p.add_argument("--max-tokens", type=int, default=4096, help="padded tokens per micro-batch")
35
+ p.add_argument("--max-rows", type=int, default=32)
36
+ p.add_argument("--grad-accum", type=int, default=4)
37
+ p.add_argument("--max-len", type=int, default=768, help="rows longer than this are dropped")
38
+ p.add_argument("--max-state-tokens", type=int, default=512)
39
+ p.add_argument("--lr", type=float, default=2e-5, help="backbone lr (use ~2e-4 with --lora-rank)")
40
+ p.add_argument("--head-lr", type=float, default=1e-3)
41
+ p.add_argument("--weight-decay", type=float, default=0.01)
42
+ p.add_argument("--warmup", type=float, default=0.05)
43
+ p.add_argument("--optim", default="auto", choices=["auto", "adamw", "adamw8bit"], help="auto = 8-bit AdamW on CUDA if available")
44
+ p.add_argument("--lora-rank", type=int, default=0, help="0 = full fine-tuning")
45
+ p.add_argument("--train-embeddings", action="store_true")
46
+ p.add_argument("--grad-checkpointing", action="store_true")
47
+ p.add_argument("--precision", default="bf16", choices=["bf16", "fp32"], help="bf16 = autocast over fp32 weights")
48
+ p.add_argument("--objective", default="supervised", choices=["supervised", "bandit"])
49
+ p.add_argument("--brier-weight", type=float, default=0.5)
50
+ p.add_argument("--rps-weight", type=float, default=1.0)
51
+ p.add_argument("--bandit-eps", type=float, default=0.1)
52
+ p.add_argument("--train-rows", type=int, default=None)
53
+ p.add_argument("--val-rows", type=int, default=1200)
54
+ p.add_argument("--eval-every", type=int, default=200, help="optimizer steps; also writes the resumable <out>/last")
55
+ p.add_argument("--log-every", type=int, default=10)
56
+ p.add_argument("--no-save", action="store_true", help="skip checkpoints (speed / memory probing)")
57
+ p.add_argument("--seed", type=int, default=0)
58
+ args = p.parse_args()
59
+
60
+ torch.manual_seed(args.seed)
61
+ dev = pick_device()
62
+ out = Path(args.out)
63
+ out.mkdir(parents=True, exist_ok=True)
64
+ (out / "args.json").write_text(json.dumps(vars(args), indent=2))
65
+ log_f = open(out / "log.jsonl", "a")
66
+
67
+
68
+ def log(**kw):
69
+ log_f.write(json.dumps(kw) + "\n")
70
+ log_f.flush()
71
+
72
+
73
+ # ------------------------------------------------------------------ model
74
+ state_path = out / "last" / "trainer_state.pt"
75
+ resuming = args.resume and state_path.exists()
76
+ if args.resume and not resuming:
77
+ print(f"--resume given but {state_path} not found; starting fresh")
78
+ lora_attached = False
79
+ if resuming or args.init:
80
+ src = out / "last" if resuming else Path(args.init)
81
+ model, renderer, meta = DecisionModel.load(src, merge_lora=not resuming, lora_trainable=resuming)
82
+ lora_attached = resuming and meta["lora"]
83
+ if not resuming:
84
+ with torch.no_grad():
85
+ model.log_temp.zero_() # post-hoc temperatures belong to the old weights; refit after training
86
+ print(f"{'resuming' if resuming else 'starting'} from {src} (base {meta['base_model']})")
87
+ else:
88
+ tok = AutoTokenizer.from_pretrained(args.model)
89
+ renderer = Renderer(tok, max_state_tokens=args.max_state_tokens)
90
+ model = DecisionModel(load_text_backbone(args.model, torch.float32), base_model=args.model)
91
+
92
+ if args.lora_rank and not lora_attached:
93
+ from peft import LoraConfig, get_peft_model
94
+
95
+ model.backbone = get_peft_model(
96
+ model.backbone, LoraConfig(r=args.lora_rank, lora_alpha=2 * args.lora_rank, target_modules="all-linear", lora_dropout=0.0)
97
+ )
98
+ if not args.train_embeddings:
99
+ model.backbone.get_input_embeddings().weight.requires_grad_(False)
100
+ if args.grad_checkpointing:
101
+ model.backbone.gradient_checkpointing_enable()
102
+ model.backbone.enable_input_require_grads()
103
+ model.autocast_dtype = torch.bfloat16 if args.precision == "bf16" else None
104
+ model.to(dev).train()
105
+ if model.image_pre is not None: # frozen ViT: never in the optimizer, runs without gradients, bf16 on GPUs
106
+ model.vision.model.to(device=dev, dtype=torch.bfloat16 if dev != "cpu" and args.precision == "bf16" else torch.float32)
107
+ print("vision tower attached (frozen): image rows are supported")
108
+
109
+ backbone_params = [q for q in model.backbone.parameters() if q.requires_grad]
110
+ head_params = model.head_parameters()
111
+ groups = [
112
+ {"params": backbone_params, "lr": args.lr, "weight_decay": args.weight_decay},
113
+ {"params": head_params, "lr": args.head_lr, "weight_decay": 0.0},
114
+ ]
115
+ opt, opt_name = None, "torch AdamW"
116
+ if args.optim == "adamw8bit" or (args.optim == "auto" and dev == "cuda"):
117
+ try:
118
+ import bitsandbytes as bnb
119
+
120
+ opt, opt_name = bnb.optim.AdamW8bit(groups, betas=(0.9, 0.98)), "bitsandbytes AdamW8bit"
121
+ except ImportError:
122
+ print("bitsandbytes not installed; falling back to torch AdamW (needs far more memory)")
123
+ if opt is None:
124
+ opt = torch.optim.AdamW(groups, betas=(0.9, 0.98))
125
+ n_bb, n_head = sum(q.numel() for q in backbone_params), sum(q.numel() for q in head_params)
126
+ print(f"device={dev} precision={args.precision} optimizer={opt_name} trainable: backbone {n_bb/1e6:.1f}M + head {n_head/1e6:.2f}M")
127
+
128
+ # ------------------------------------------------------------------ data
129
+ t0 = time.time()
130
+ train_recs = subsample(read_jsonl(f"{args.data}/train.jsonl"), args.train_rows, args.seed)
131
+ val_recs = subsample(read_jsonl(f"{args.data}/val.jsonl"), args.val_rows, args.seed)
132
+ image_meta = model.image_pre.meta if model.image_pre is not None else None
133
+ train_rows, train_recs = render_records(train_recs, renderer, args.max_len, image_meta, args.data)
134
+ val_rows, val_recs = render_records(val_recs, renderer, args.max_len, image_meta, args.data)
135
+ lengths = [len(r) for r in train_rows]
136
+ n_img = sum(bool(r.image_paths) for r in train_rows)
137
+ if n_img:
138
+ print(f"{n_img} of {len(train_rows)} train rows contain images")
139
+ load_image = model.image_pre.load_prepared if model.image_pre is not None else None
140
+ print(f"rendered {len(train_rows)} train / {len(val_rows)} val rows in {time.time()-t0:.0f}s; "
141
+ f"mean len {sum(lengths)/len(lengths):.0f}, max {max(lengths)}, total {sum(lengths)/1e6:.2f}M tokens/epoch") # fmt: skip
142
+
143
+
144
+ def epoch_batches(epoch: int):
145
+ """Deterministic per epoch, so a resumed run sees exactly the batches it has not done yet.
146
+
147
+ Static (rows, length) shapes: see batching.bucketed_batches for why this matters on GPUs.
148
+ """
149
+ return bucketed_batches(lengths, args.max_tokens, args.max_rows, random.Random(args.seed * 1000 + epoch), drop_last=True)
150
+
151
+
152
+ batches_per_epoch = len(epoch_batches(0))
153
+ total_steps = max(1, int(batches_per_epoch * args.epochs) // args.grad_accum)
154
+ warmup_steps = max(1, int(args.warmup * total_steps))
155
+ print(f"{batches_per_epoch} micro-batches/epoch -> {total_steps} optimizer steps")
156
+
157
+
158
+ def prefetch(items, build, workers: int = 12, ahead: int = 24):
159
+ """Yield (item[0], item[1], build(item)) in order, building up to `ahead` items concurrently."""
160
+ import collections
161
+ from concurrent.futures import ThreadPoolExecutor
162
+
163
+ if not any(r.image_paths for r in train_rows): # text-only data: nothing worth parallelising
164
+ for it in items:
165
+ yield it[0], it[1], build(it)
166
+ return
167
+ with ThreadPoolExecutor(workers) as pool:
168
+ queue, it = collections.deque(), iter(items)
169
+ for item in it:
170
+ queue.append((item, pool.submit(build, item)))
171
+ if len(queue) >= ahead:
172
+ first, fut = queue.popleft()
173
+ yield first[0], first[1], fut.result()
174
+ while queue:
175
+ first, fut = queue.popleft()
176
+ yield first[0], first[1], fut.result()
177
+
178
+
179
+ def lr_scale(step):
180
+ if step < warmup_steps:
181
+ return (step + 1) / warmup_steps
182
+ t = (step - warmup_steps) / max(1, total_steps - warmup_steps)
183
+ return 0.1 + 0.9 * 0.5 * (1 + math.cos(math.pi * t))
184
+
185
+
186
+ def run_eval(step):
187
+ preds = predict(model, val_rows, val_recs, renderer.slot_id)
188
+ rep = report(preds)
189
+ print(format_report(rep, f"val @ step {step}"), flush=True)
190
+ log(kind="eval", step=step, **strip_tables(rep))
191
+ return rep["overall"]["nll"]
192
+
193
+
194
+ def mem() -> str:
195
+ if dev == "cuda":
196
+ return f" mem {torch.cuda.max_memory_allocated()/1e9:.0f}G"
197
+ if dev == "mps":
198
+ return f" mem {torch.mps.driver_allocated_memory()/1e9:.0f}G"
199
+ return ""
200
+
201
+
202
+ # ------------------------------------------------------------------ loop
203
+ best_nll, step, epoch, batches_done, tokens_seen = float("inf"), 0, 0, 0, 0
204
+ if resuming:
205
+ st = torch.load(state_path, map_location="cpu", weights_only=False)
206
+ opt.load_state_dict(st["opt"])
207
+ best_nll, step, epoch, batches_done, tokens_seen = st["best_nll"], st["step"], st["epoch"], st["batches_done"], st["tokens_seen"]
208
+ torch.set_rng_state(st["torch_rng"])
209
+ print(f"resumed at step {step} (epoch {epoch}, {batches_done} micro-batches into it), best val nll {best_nll:.4f}")
210
+
211
+
212
+ def snapshot():
213
+ model.save(out / "last", renderer, {"step": step})
214
+ torch.save({"opt": opt.state_dict(), "best_nll": best_nll, "step": step, "epoch": epoch, "batches_done": batches_done,
215
+ "tokens_seen": tokens_seen, "torch_rng": torch.get_rng_state()}, state_path) # fmt: skip
216
+
217
+
218
+ run_loss, run_acc, run_n, micro = 0.0, 0.0, 0, 0
219
+ step_start, tokens_start = step, tokens_seen
220
+ t_start = t_log = time.time()
221
+ tokens_log = 0
222
+ done = step >= total_steps
223
+ while not done:
224
+ todo = [(bi, idx, pad_len) for bi, (idx, pad_len) in enumerate(epoch_batches(epoch)) if bi >= batches_done]
225
+ # JPEG decoding + patchifying happens in background threads, a few batches ahead of the GPU
226
+ for bi, idx, cpu_batch in prefetch(todo, lambda t: collate([train_rows[i] for i in t[1]], renderer.slot_id, t[2], load_image)):
227
+ b = {k: v.to(dev) for k, v in cpu_batch.items()}
228
+ logits = model(**b)
229
+ if args.objective == "supervised":
230
+ loss = proper_score_loss(logits, b["opt_mask"], b["target"], b["qtype"], args.brier_weight, args.rps_weight)
231
+ else:
232
+ loss, _ = bandit_proper_score_loss(logits, b["opt_mask"], b["label"], args.bandit_eps, args.brier_weight)
233
+ (loss.mean() / args.grad_accum).backward()
234
+ with torch.no_grad():
235
+ run_loss += loss.sum().item()
236
+ run_acc += (logits.argmax(-1) == b["label"]).sum().item()
237
+ run_n += len(idx)
238
+ n_tok = int(b["attention_mask"].sum())
239
+ tokens_seen += n_tok
240
+ tokens_log += n_tok
241
+ micro += 1
242
+ batches_done = bi + 1
243
+ if micro % args.grad_accum:
244
+ continue
245
+
246
+ scale = lr_scale(step)
247
+ for g, base in zip(opt.param_groups, (args.lr, args.head_lr)):
248
+ g["lr"] = base * scale
249
+ gnorm = torch.nn.utils.clip_grad_norm_(backbone_params + head_params, 1.0).item()
250
+ opt.step()
251
+ opt.zero_grad(set_to_none=True)
252
+ step += 1
253
+
254
+ if step % args.log_every == 0:
255
+ now = time.time()
256
+ eta = (total_steps - step) * (now - t_start) / max(1, step - step_start)
257
+ print(f"step {step:5d}/{total_steps} loss {run_loss/run_n:.4f} acc {run_acc/run_n:.3f} gnorm {gnorm:.2f} "
258
+ f"lr {args.lr*scale:.2e} {tokens_log/(now-t_log):,.0f} tok/s eta {eta/60:.0f}m{mem()}", flush=True) # fmt: skip
259
+ log(kind="train", step=step, loss=run_loss / run_n, acc=run_acc / run_n, gnorm=gnorm, tokens=tokens_seen)
260
+ run_loss, run_acc, run_n, t_log, tokens_log = 0.0, 0.0, 0, now, 0
261
+ if dev == "mps" and step % 25 == 0:
262
+ torch.mps.empty_cache() # variable shapes make the MPS allocator cache balloon
263
+ if step % args.eval_every == 0 or step == total_steps:
264
+ nll = run_eval(step)
265
+ if nll < best_nll:
266
+ best_nll = nll
267
+ if not args.no_save:
268
+ model.save(out / "best", renderer, {"step": step, "val_nll": nll})
269
+ print(f" saved best (val nll {nll:.4f})", flush=True)
270
+ if not args.no_save:
271
+ snapshot()
272
+ if step >= total_steps:
273
+ done = True
274
+ break
275
+ else:
276
+ epoch, batches_done = epoch + 1, 0
277
+
278
+ trained = tokens_seen - tokens_start
279
+ print(f"done: {step} steps, {trained/1e6:.2f}M tokens this session, {(time.time()-t_start)/60:.1f} min, best val nll {best_nll:.4f}")
tests/__init__.py ADDED
File without changes
tests/test_core.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core invariants, checked on tiny randomly-initialised backbones (CPU, fp32, no training).
2
+
3
+ Tokenizers are loaded from the local HF cache; tests skip if a tokenizer is unavailable.
4
+ """
5
+ import math
6
+
7
+ import pytest
8
+ import torch
9
+ import torch.nn.functional as F
10
+
11
+ from zico import Choice, DecisionModel, Noul, Renderer, Score
12
+ from zico.batching import collate, make_row, token_budget_batches
13
+ from zico.losses import bandit_proper_score_loss, proper_score_loss
14
+ from zico.render import M_OPT
15
+
16
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
17
+ STATE = {"ticket": {"id": 4412, "body": "I was charged twice this month and want my money back.", "plan": "pro"}}
18
+ QUESTIONS = {
19
+ "refund": Noul("The customer is asking for a refund."),
20
+ "team": Choice(
21
+ "Which team should handle this?",
22
+ {"billing": "Charges, invoices, refunds", "technical": "Bugs, outages", "sales": "Upgrades and pricing"},
23
+ ),
24
+ "severity": Score("How severe is the issue?", ["Cosmetic", "Degraded but usable", "Blocking"]),
25
+ "lang": Choice("Which language is the message in?", ["english", "spanish", "german", "french", "other"]),
26
+ }
27
+
28
+
29
+ def _tiny(kind):
30
+ from transformers import AutoTokenizer
31
+
32
+ tok_id = {"qwen3": "Qwen/Qwen3-0.6B", "qwen3_5": "Qwen/Qwen3.5-0.8B-Base"}[kind]
33
+ try:
34
+ tok = AutoTokenizer.from_pretrained(tok_id, local_files_only=True)
35
+ except Exception: # noqa: BLE001
36
+ pytest.skip(f"tokenizer {tok_id} not in local cache")
37
+ torch.manual_seed(0)
38
+ if kind == "qwen3":
39
+ from transformers import Qwen3Config, Qwen3Model
40
+
41
+ cfg = Qwen3Config(
42
+ vocab_size=len(tok), hidden_size=64, intermediate_size=128, num_hidden_layers=3,
43
+ num_attention_heads=4, num_key_value_heads=2, head_dim=16, max_position_embeddings=2048,
44
+ )
45
+ backbone = Qwen3Model(cfg)
46
+ else:
47
+ from transformers.models.qwen3_5 import Qwen3_5TextConfig, Qwen3_5TextModel
48
+
49
+ cfg = Qwen3_5TextConfig(
50
+ vocab_size=len(tok), hidden_size=64, intermediate_size=128, num_hidden_layers=4,
51
+ num_attention_heads=4, num_key_value_heads=2, head_dim=16, full_attention_interval=4,
52
+ # 32-dim DeltaNet heads: mlx-lm's Metal kernel cannot be built for smaller ones
53
+ linear_num_key_heads=4, linear_num_value_heads=4, linear_key_head_dim=32, linear_value_head_dim=32,
54
+ max_position_embeddings=2048,
55
+ )
56
+ backbone = Qwen3_5TextModel(cfg)
57
+ model = DecisionModel(backbone, d_head=32, base_model=tok_id).eval()
58
+ with torch.no_grad(): # random head so logits are not trivially equal
59
+ model.unary.weight.normal_(0, 0.5)
60
+ # with fused DeltaNet kernels installed, HF routes to CUDA-only code, so test on the GPU if there is one
61
+ return model.to(DEVICE), Renderer(tok)
62
+
63
+
64
+ @pytest.fixture(scope="module", params=["qwen3", "qwen3_5"])
65
+ def tiny(request):
66
+ return _tiny(request.param)
67
+
68
+
69
+ def test_render_slots_and_shared_prefix(tiny):
70
+ _, r = tiny
71
+ s1, s2 = r.state(STATE), r.state(STATE)
72
+ assert s1 == s2 # the state segment never depends on the question
73
+ for q in QUESTIONS.values():
74
+ rq = r.question(q)
75
+ assert len(rq.opt_pos) == len(q.options()) == len(rq.keys)
76
+ assert all(rq.marker_ids[p] == M_OPT for p in rq.opt_pos)
77
+ assert rq.marker_ids[rq.ans_pos] > M_OPT and rq.ans_pos == len(rq.input_ids) - 1
78
+ assert sum(m >= 0 for m in rq.marker_ids) == len(rq.opt_pos) + 1
79
+
80
+
81
+ def test_schema_limits():
82
+ with pytest.raises(ValueError):
83
+ Choice("x", ["only-one"])
84
+ with pytest.raises(ValueError):
85
+ Choice("x", [str(i) for i in range(256)])
86
+ with pytest.raises(ValueError):
87
+ Score("x", [str(i) for i in range(11)])
88
+ assert len(Choice("x", [str(i) for i in range(255)]).options()) == 255
89
+
90
+
91
+ def test_fork_equals_rows_equals_single(tiny):
92
+ """Batched questions over a forked state cache == each question run alone with the state."""
93
+ model, r = tiny
94
+ qs = list(QUESTIONS.values())
95
+ forked, _, _ = model.question_logits(r, STATE, qs, fork=True)
96
+ rows, _, _ = model.question_logits(r, STATE, qs, fork=False)
97
+ # On CUDA the cached-continuation path runs fla's Triton kernels, whose tl.dot uses TF32: logits agree to
98
+ # ~3e-3 (probabilities to ~2e-4) rather than to float32 rounding. A logic error is orders of magnitude larger.
99
+ atol = 5e-3 if DEVICE == "cuda" else 2e-4
100
+ for i, q in enumerate(qs):
101
+ single, _, _ = model.question_logits(r, STATE, [q], fork=False)
102
+ assert torch.allclose(forked[i], single[0], atol=atol), (i, forked[i], single[0])
103
+ assert torch.allclose(rows[i], single[0], atol=2e-4), (i, rows[i], single[0])
104
+
105
+
106
+ def test_answers_are_type_safe(tiny):
107
+ model, r = tiny
108
+ out = model.answer(r, STATE, QUESTIONS)["answers"]
109
+ assert set(out) == set(QUESTIONS)
110
+ assert 0.0 <= out["refund"]["noul"] <= 1.0
111
+ team = out["team"]
112
+ assert team["choice"] in QUESTIONS["team"].criteria
113
+ assert set(team["probabilities"]) == set(QUESTIONS["team"].criteria)
114
+ assert math.isclose(sum(team["probabilities"].values()), 1.0, abs_tol=1e-4)
115
+ sev = out["severity"]
116
+ assert 0.0 <= sev["score"] <= 2.0 and set(sev["probabilities"]) == {"0", "1", "2"}
117
+ assert 0.0 <= sev["confidence"] <= 1.0
118
+
119
+
120
+ def test_gradients_reach_backbone_and_head(tiny):
121
+ model, r = tiny
122
+ s = r.state(STATE)
123
+ rows = [make_row(s, r.question(q), label=1) for q in QUESTIONS.values()]
124
+ b = {k: v.to(DEVICE) for k, v in collate(rows, r.slot_id).items()}
125
+ loss = proper_score_loss(model(**b), b["opt_mask"], b["target"], b["qtype"], 0.5, 0.5).mean()
126
+ loss.backward()
127
+ assert model.marker_emb.weight.grad.abs().sum() > 0
128
+ assert model.q_proj.weight.grad.abs().sum() > 0
129
+ assert any(p.grad is not None and p.grad.abs().sum() > 0 for p in model.backbone.layers[0].parameters())
130
+ model.zero_grad()
131
+
132
+
133
+ def test_log_score_is_cross_entropy():
134
+ torch.manual_seed(0)
135
+ logits = torch.randn(6, 5)
136
+ mask = torch.ones(6, 5, dtype=torch.bool)
137
+ label = torch.randint(0, 5, (6,))
138
+ target = F.one_hot(label, 5).float()
139
+ ours = proper_score_loss(logits, mask, target, torch.zeros(6, dtype=torch.long))
140
+ assert torch.allclose(ours, F.cross_entropy(logits, label, reduction="none"), atol=1e-6)
141
+
142
+
143
+ def test_rps_is_ordinal():
144
+ """Putting mass on a neighbouring level must cost less than putting it on a distant one."""
145
+ mask = torch.ones(2, 5, dtype=torch.bool)
146
+ target = F.one_hot(torch.tensor([0, 0]), 5).float()
147
+ logits = torch.full((2, 5), -20.0)
148
+ logits[0, 1] = 20.0 # near miss
149
+ logits[1, 4] = 20.0 # far miss
150
+ qtype = torch.ones(2, dtype=torch.long) # score
151
+ ce_only = proper_score_loss(logits, mask, target, qtype)
152
+ with_rps = proper_score_loss(logits, mask, target, qtype, rps_weight=1.0)
153
+ rps = with_rps - ce_only
154
+ assert rps[0] < rps[1]
155
+ assert torch.allclose(rps, torch.tensor([0.25, 1.0]), atol=1e-4)
156
+
157
+
158
+ def test_bandit_estimator_is_unbiased():
159
+ """E[bandit loss] == -log p_y, using only the right/wrong bit of a sampled action."""
160
+ g = torch.Generator().manual_seed(0)
161
+ logits = torch.tensor([[1.0, 0.2, -0.5, 0.0]]).repeat(200_000, 1)
162
+ mask = torch.ones_like(logits, dtype=torch.bool)
163
+ label = torch.full((logits.shape[0],), 2)
164
+ loss, _ = bandit_proper_score_loss(logits, mask, label, eps=0.2, clip=1e9, generator=g)
165
+ expected = -F.log_softmax(logits[0], -1)[2].item()
166
+ assert abs(loss.mean().item() - expected) < 0.03, (loss.mean().item(), expected)
167
+
168
+
169
+ def test_temperature_scaling_recovers_known_miscalibration():
170
+ """Labels drawn from softmax(z); an overconfident model reporting 3z must get T ~= 3."""
171
+ from zico.calibration import fit_temperature
172
+
173
+ g = torch.Generator().manual_seed(0)
174
+ z = torch.randn(4000, 6, generator=g) * 1.5
175
+ labels = torch.multinomial(torch.softmax(z, -1), 1, generator=g).squeeze(-1).tolist()
176
+ assert abs(fit_temperature(list(3 * z), labels) - 3.0) < 0.25
177
+ assert abs(fit_temperature(list(z), labels) - 1.0) < 0.1
178
+ # ragged option counts and a hopeless model stay inside the bounds instead of diverging
179
+ ragged = [torch.randn(int(k), generator=g) * 50 for k in torch.randint(2, 9, (300,), generator=g)]
180
+ t = fit_temperature(ragged, [0] * 300)
181
+ assert 0.05 <= t <= 20.0
182
+
183
+
184
+ def test_token_budget_batches_cover_everything():
185
+ import random
186
+
187
+ lengths = [random.Random(1).randint(10, 400) for _ in range(500)]
188
+ batches = token_budget_batches(lengths, max_tokens=2048, max_rows=16, shuffle_rng=random.Random(0))
189
+ assert sorted(i for b in batches for i in b) == list(range(500))
190
+ assert all(max(lengths[i] for i in b) * len(b) <= 2048 and len(b) <= 16 for b in batches)
191
+
192
+
193
+ def test_bucketed_batches_use_few_static_shapes():
194
+ import random
195
+
196
+ from zico.batching import bucketed_batches
197
+
198
+ r = random.Random(1)
199
+ n = 60_000
200
+ lengths = [r.randint(10, 1000) for _ in range(n)]
201
+ batches = bucketed_batches(lengths, max_tokens=16384, max_rows=256, shuffle_rng=random.Random(0))
202
+ assert sorted(i for idx, _ in batches for i in idx) == list(range(n)) # nothing lost without drop_last
203
+ assert all(max(lengths[i] for i in idx) <= L and len(idx) * L <= 16384 for idx, L in batches)
204
+ static = bucketed_batches(lengths, 16384, 256, random.Random(0), drop_last=True)
205
+ assert len({(len(idx), L) for idx, L in static}) <= 9 # a handful of shapes, tuned once each
206
+ assert sum(len(idx) for idx, _ in static) > 0.98 * n # only one ragged batch per bucket is dropped
207
+
208
+
209
+ def test_save_load_roundtrip(tiny, tmp_path):
210
+ model, r = tiny
211
+ qs = list(QUESTIONS.values())
212
+ before, _, _ = model.question_logits(r, STATE, qs)
213
+ model.save(tmp_path / "ckpt", r)
214
+ loaded, r2, _ = DecisionModel.load(tmp_path / "ckpt", device=DEVICE)
215
+ after, _, _ = loaded.question_logits(r2, STATE, qs)
216
+ for a, b in zip(before, after):
217
+ assert torch.allclose(a, b, atol=1e-5)
tests/test_mlx.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MLX backend parity on a tiny random Qwen3.5 (Apple silicon only; skipped elsewhere).
2
+
3
+ Guards the weight conversion (conv layout, the +1 shift on zero-centred norms, key nesting), the
4
+ slot-embedding injection, the cache fork for both layer types, and the head port.
5
+ """
6
+ import pytest
7
+
8
+ pytest.importorskip("mlx_lm")
9
+
10
+ import torch # noqa: E402
11
+
12
+ from zico.mlx_backend import MLXDecisionModel # noqa: E402
13
+ from tests.test_core import QUESTIONS, STATE, _tiny # noqa: E402
14
+ from tests.test_vision import tiny_mm # noqa: E402, F401 (pytest fixture)
15
+
16
+
17
+ @pytest.fixture(scope="module")
18
+ def pair(tmp_path_factory):
19
+ model, renderer = _tiny("qwen3_5")
20
+ with torch.no_grad(): # make every ported parameter matter
21
+ model.readout_norm.weight.normal_(1, 0.2)
22
+ model.readout_norm.bias.normal_(0, 0.2)
23
+ model.log_temp.copy_(torch.tensor([0.1, -0.2, 0.3]))
24
+ path = tmp_path_factory.mktemp("ckpt") / "tiny"
25
+ model.save(path, renderer)
26
+ mlx_model, mlx_renderer, _ = MLXDecisionModel.load(path)
27
+ return model, renderer, mlx_model, mlx_renderer
28
+
29
+
30
+ def test_mlx_matches_pytorch(pair):
31
+ model, renderer, mlx_model, mlx_renderer = pair
32
+ a = model.answer(renderer, STATE, QUESTIONS)
33
+ b = mlx_model.answer(mlx_renderer, STATE, QUESTIONS)
34
+ assert a["usage"] == b["usage"]
35
+ for name in QUESTIONS:
36
+ x, y = a["answers"][name], b["answers"][name]
37
+ assert x["type"] == y["type"]
38
+ # fp32 on both sides: agreement should be at rounding level. A looser tolerance would have hidden
39
+ # the q/k epsilon mismatch that _reference_qk_norm corrects (it showed up as 6e-2 here).
40
+ if x["type"] == "noul":
41
+ assert abs(x["noul"] - y["noul"]) < 1e-4
42
+ else:
43
+ assert all(abs(x["probabilities"][k] - y["probabilities"][k]) < 1e-4 for k in x["probabilities"])
44
+
45
+
46
+ def test_mlx_image_path_matches_pytorch(tiny_mm, tmp_path):
47
+ """Explicit 3-part rope, ViT features scattered into the state, and the fork after an image."""
48
+ from tests.test_vision import QUESTIONS as IMAGE_QUESTIONS
49
+ from tests.test_vision import STATE as IMAGE_STATE
50
+
51
+ _, model, renderer = tiny_mm
52
+ path = tmp_path / "ckpt"
53
+ model.save(path, renderer) # a checkpoint of a vision-enabled model carries its (frozen) ViT along
54
+ assert (path / "vision" / "model.safetensors").exists()
55
+ mlx_model, mlx_renderer, _ = MLXDecisionModel.load(path)
56
+ mlx_model.attach_vision(path / "vision", device="cpu", dtype=torch.float32, min_pixels=32 * 32, max_pixels=128 * 128)
57
+ qs = {f"q{i}": q for i, q in enumerate(IMAGE_QUESTIONS)}
58
+ a = model.answer(renderer, IMAGE_STATE, qs)
59
+ b = mlx_model.answer(mlx_renderer, IMAGE_STATE, qs)
60
+ assert a["usage"] == b["usage"]
61
+ for name in qs:
62
+ x, y = a["answers"][name], b["answers"][name]
63
+ pa = x["probabilities"] if "probabilities" in x else {"true": x["noul"]}
64
+ pb = y["probabilities"] if "probabilities" in y else {"true": y["noul"]}
65
+ assert all(abs(pa[k] - pb[k]) < 1e-4 for k in pa), (name, pa, pb)
66
+ # a different picture under identical token ids must not hit the cached prefix
67
+ other = {**IMAGE_STATE, "second": {"type": "image", "data": __import__("tests.test_vision", fromlist=["png"]).png(64, 128, seed=9)}}
68
+ assert mlx_model.answer(mlx_renderer, other, qs) != b
69
+
70
+
71
+ def test_state_prefix_is_reused_and_not_corrupted(pair):
72
+ _, _, mlx_model, mlx_renderer = pair
73
+ mlx_model._prefixes.clear()
74
+ first = mlx_model.answer(mlx_renderer, STATE, QUESTIONS)
75
+ assert len(mlx_model._prefixes) == 1
76
+ again = mlx_model.answer(mlx_renderer, STATE, QUESTIONS) # served from the cached prefix
77
+ assert len(mlx_model._prefixes) == 1 and first == again
78
+ for i in range(mlx_model.prefix_cache_size + 2): # LRU stays bounded
79
+ mlx_model.answer(mlx_renderer, {"n": i}, {"q": QUESTIONS["refund"]})
80
+ assert len(mlx_model._prefixes) == mlx_model.prefix_cache_size
tests/test_server.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The HTTP contract, exercised against a tiny random model (no real checkpoint needed)."""
2
+ import math
3
+
4
+ import pytest
5
+
6
+ pytest.importorskip("fastapi")
7
+ pytest.importorskip("httpx")
8
+ from fastapi.testclient import TestClient # noqa: E402
9
+
10
+ from zico.server import create_app # noqa: E402
11
+ from tests.test_core import _tiny # noqa: E402
12
+
13
+ KEY = "test-key"
14
+ AUTH = {"Authorization": f"Bearer {KEY}"}
15
+ REQUEST = {
16
+ "model": "zico-latest",
17
+ "state": {"ticket": {"body": "I was charged twice and want my money back."}},
18
+ "questions": {
19
+ "refund": {"type": "noul", "instructions": "The customer asks for a refund."},
20
+ "team": {"type": "choice", "instructions": "Which team?", "criteria": {"billing": "Charges, refunds", "technical": None}},
21
+ "severity": {"type": "score", "instructions": "How severe?", "criteria": ["Cosmetic", "Degraded", "Blocking"]},
22
+ },
23
+ }
24
+
25
+
26
+ @pytest.fixture(scope="module")
27
+ def client():
28
+ model, renderer = _tiny("qwen3")
29
+ return TestClient(create_app(model, renderer, {KEY}, "tiny-test-model"))
30
+
31
+
32
+ def test_requires_bearer_key(client):
33
+ assert client.post("/v1/systemone", json=REQUEST).status_code == 401
34
+ assert client.post("/v1/systemone", json=REQUEST, headers={"Authorization": "Bearer wrong"}).status_code == 401
35
+ assert client.get("/healthz").status_code == 200 # health check is open
36
+
37
+
38
+ def test_response_matches_the_documented_shape(client):
39
+ r = client.post("/v1/systemone", json=REQUEST, headers=AUTH)
40
+ assert r.status_code == 200
41
+ assert r.headers["x-typesafe-request-id"] == r.headers["x-request-id"]
42
+ body = r.json()
43
+ assert set(body) == {"model", "answers", "usage"}
44
+ assert body["model"] == "tiny-test-model" # always the real model, never the alias that was requested
45
+ assert body["usage"]["output_tokens"] == 0 and body["usage"]["input_tokens"] > 0
46
+ a = body["answers"]
47
+ assert set(a) == set(REQUEST["questions"])
48
+ assert a["refund"]["type"] == "noul" and 0 <= a["refund"]["noul"] <= 1 and set(a["refund"]) == {"type", "noul"}
49
+ team = a["team"]
50
+ assert set(team) == {"type", "choice", "probabilities", "confidence"} and team["choice"] in ("billing", "technical")
51
+ assert set(team["probabilities"]) == {"billing", "technical"} and math.isclose(sum(team["probabilities"].values()), 1, abs_tol=1e-4)
52
+ sev = a["severity"]
53
+ assert set(sev) == {"type", "score", "legend", "probabilities", "confidence"}
54
+ assert sev["legend"] == {"0": "Cosmetic", "1": "Degraded", "2": "Blocking"} and 0 <= sev["score"] <= 2
55
+
56
+
57
+ @pytest.mark.parametrize("mutate, status", [
58
+ (lambda b: b.pop("state"), 422),
59
+ (lambda b: b.update(model="not-a-model"), 404),
60
+ (lambda b: b.update(questions={}), 422),
61
+ (lambda b: b["questions"]["team"].update(criteria={"only": "one"}), 422),
62
+ (lambda b: b["questions"]["severity"].update(criteria=[str(i) for i in range(11)]), 422),
63
+ (lambda b: b["questions"]["refund"].update(type="essay"), 422),
64
+ (lambda b: b["questions"]["refund"].update(criteria={"maybe": "x"}), 422),
65
+ ]) # fmt: skip
66
+ def test_validation_errors(client, mutate, status):
67
+ import copy
68
+
69
+ body = copy.deepcopy(REQUEST)
70
+ mutate(body)
71
+ r = client.post("/v1/systemone", json=body, headers=AUTH)
72
+ assert r.status_code == status
73
+ detail = r.json()["detail"]
74
+ if status == 422: # published HTTPValidationError shape
75
+ assert set(detail[0]) >= {"loc", "msg", "type"} and detail[0]["loc"][0] == "body"
76
+ else:
77
+ assert isinstance(detail, str)
78
+
79
+
80
+ def test_instructions_are_optional(client):
81
+ body = {**REQUEST, "questions": {"sev": {"type": "score", "criteria": ["Low", "High"]}}}
82
+ assert client.post("/v1/systemone", json=body, headers=AUTH).status_code == 200
83
+
84
+
85
+ def test_models_listing(client):
86
+ cards = client.get("/v1/models", headers=AUTH).json()["models"]
87
+ assert cards[0]["name"] == "tiny-test-model" and set(cards[0]) == {"name", "description", "release_date"}
88
+
89
+
90
+ def test_test_page_is_public_but_the_api_stays_locked(client):
91
+ for path in ("/", "/ui"):
92
+ r = client.get(path) # no key: the page holds no secrets, the key is typed into it
93
+ assert r.status_code == 200 and r.headers["content-type"].startswith("text/html") and "zico test bench" in r.text
94
+ csp = r.headers["content-security-policy"]
95
+ assert "connect-src 'self'" in csp and "default-src 'none'" in csp and "frame-ancestors 'none'" in csp # it may only talk to this server
96
+ assert client.get("/v1/models").status_code == 401
97
+ assert client.post("/v1/systemone", json=REQUEST).status_code == 401
98
+
99
+
100
+ def test_test_page_loads_nothing_from_elsewhere():
101
+ from zico.server import UI_FILE
102
+
103
+ html = UI_FILE.read_text(encoding="utf-8")
104
+ assert "innerHTML" not in html # model output and option names only ever go in as text
105
+ for needle in ('src="http', "src='http", 'href="http', "@import", "url(http"):
106
+ assert needle not in html
107
+
108
+
109
+ def test_test_page_can_be_switched_off():
110
+ model, renderer = _tiny("qwen3")
111
+ off = TestClient(create_app(model, renderer, {KEY}, "tiny-test-model", ui=False))
112
+ assert off.get("/").status_code == 401 and off.get("/", headers=AUTH).status_code == 404
113
+ assert off.get("/healthz").status_code == 200
114
+
115
+
116
+ def test_extra_model_names_are_configuration():
117
+ model, renderer = _tiny("qwen3")
118
+ app = TestClient(create_app(model, renderer, {KEY}, "tiny-test-model", aliases=("the-name-my-client-sends",)))
119
+ ok = {**REQUEST, "model": "the-name-my-client-sends"}
120
+ assert app.post("/v1/systemone", json=ok, headers=AUTH).status_code == 200
121
+ assert app.post("/v1/systemone", json={**REQUEST, "model": "tiny-test-model"}, headers=AUTH).status_code == 200 # the real name always works
122
+ assert app.post("/v1/systemone", json=REQUEST, headers=AUTH).status_code == 404 # default aliases were replaced
tests/test_vision.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Images in the state: parsing, positions, and equivalence with Hugging Face's multimodal forward.
2
+
3
+ Uses a tiny random multimodal Qwen3.5 (vision tower + hybrid language model), CPU / fp32.
4
+ """
5
+ import base64
6
+ import io
7
+ import json
8
+
9
+ import numpy as np
10
+ import pytest
11
+ import torch
12
+
13
+ from zico import Choice, DecisionModel, Noul, Renderer
14
+ from zico.vision import ImageError, mrope_positions, render_image_state, smart_resize, split_state
15
+ from tests.test_core import DEVICE
16
+
17
+
18
+ def png(width=96, height=64, seed=0) -> str:
19
+ from PIL import Image
20
+
21
+ rng = np.random.default_rng(seed)
22
+ buf = io.BytesIO()
23
+ Image.fromarray(rng.integers(0, 255, (height, width, 3), dtype=np.uint8)).save(buf, format="PNG")
24
+ return base64.b64encode(buf.getvalue()).decode()
25
+
26
+
27
+ # --------------------------------------------------------------------------- parsing
28
+ def test_split_state_finds_images_anywhere_and_in_three_dialects():
29
+ a, b, c = png(seed=1), png(seed=2), png(seed=3)
30
+ state = {
31
+ "listing": {"title": "Blue mug", "photo": {"type": "image", "data": a}},
32
+ "extra": [{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": b}},
33
+ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{c}"}}],
34
+ "note": {"type": "image"}, # no payload: ordinary data, not an image part
35
+ } # fmt: skip
36
+ segments, images = split_state(state)
37
+ assert [base64.b64encode(i.raw).decode() for i in images] == [a, b, c]
38
+ assert [s for s in segments if isinstance(s, int)] == [0, 1, 2]
39
+ text = "".join(s if isinstance(s, str) else "<IMG>" for s in segments)
40
+ assert text == '{"listing": {"title": "Blue mug", "photo": <IMG>}, "extra": [<IMG>, <IMG>], "note": {"type": "image"}}'
41
+ assert a not in text # the base64 payload never reaches the tokenizer
42
+
43
+
44
+ def test_split_state_without_images_and_bad_images():
45
+ assert split_state({"a": 1, "b": ["x", {"type": "text"}]}) == ([], [])
46
+ assert split_state("plain text") == ([], [])
47
+ with pytest.raises(ImageError):
48
+ split_state({"type": "image", "data": "not base64!!"})
49
+ with pytest.raises(ImageError):
50
+ split_state({"type": "image", "url": "https://example.com/cat.png"}) # remote URLs are not fetched
51
+ with pytest.raises(ImageError):
52
+ split_state([{"type": "image", "data": png()}] * 9)
53
+
54
+
55
+ def test_smart_resize_respects_budget_factor_and_aspect():
56
+ for w, h in [(4000, 3000), (640, 480), (20, 20), (1000, 50)]:
57
+ rw, rh = smart_resize(w, h, factor=32, min_pixels=64 * 64, max_pixels=512 * 512)
58
+ assert rw % 32 == 0 and rh % 32 == 0 and rw * rh <= 512 * 512
59
+ assert rw * rh >= 64 * 64 or (w, h) == (1000, 50)
60
+ rw, rh = smart_resize(4000, 3000, 32, 64 * 64, 512 * 512)
61
+ assert abs(rw / rh - 4 / 3) < 0.15
62
+
63
+
64
+ # --------------------------------------------------------------------------- tiny multimodal model
65
+ @pytest.fixture(scope="module")
66
+ def tiny_mm(tmp_path_factory):
67
+ from safetensors.torch import save_file
68
+ from transformers import AutoImageProcessor, AutoTokenizer
69
+ from transformers.models.qwen3_5 import Qwen3_5Config, Qwen3_5ForConditionalGeneration
70
+
71
+ base = "Qwen/Qwen3.5-0.8B-Base"
72
+ try:
73
+ tok = AutoTokenizer.from_pretrained(base, local_files_only=True)
74
+ image_proc = AutoImageProcessor.from_pretrained(base, local_files_only=True)
75
+ real = __import__("transformers").AutoConfig.from_pretrained(base, local_files_only=True)
76
+ except Exception: # noqa: BLE001
77
+ pytest.skip("base checkpoint files are not in the local cache")
78
+ torch.manual_seed(0)
79
+ cfg = Qwen3_5Config(
80
+ text_config=dict(vocab_size=len(tok), hidden_size=64, intermediate_size=128, num_hidden_layers=4, num_attention_heads=4,
81
+ num_key_value_heads=2, head_dim=16, full_attention_interval=4, linear_num_key_heads=4,
82
+ linear_num_value_heads=4, linear_key_head_dim=32, linear_value_head_dim=32, max_position_embeddings=2048),
83
+ vision_config=dict(depth=2, hidden_size=32, intermediate_size=64, num_heads=2, patch_size=16, spatial_merge_size=2,
84
+ temporal_patch_size=2, in_channels=3, out_hidden_size=64, num_position_embeddings=256),
85
+ image_token_id=real.image_token_id, video_token_id=real.video_token_id,
86
+ vision_start_token_id=real.vision_start_token_id, vision_end_token_id=real.vision_end_token_id,
87
+ ) # fmt: skip
88
+ full = Qwen3_5ForConditionalGeneration(cfg).eval().to(DEVICE)
89
+ vdir = tmp_path_factory.mktemp("vision")
90
+ save_file({k: v.contiguous().cpu() for k, v in full.model.visual.state_dict().items()}, vdir / "model.safetensors")
91
+ (vdir / "config.json").write_text(json.dumps({k: v for k, v in cfg.vision_config.to_dict().items() if k not in ("transformers_version", "architectures")}))
92
+ image_proc.save_pretrained(vdir)
93
+ (vdir / "zico_vision.json").write_text(json.dumps({
94
+ "base_model": base, "image_token_id": cfg.image_token_id, "vision_start_token_id": cfg.vision_start_token_id,
95
+ "vision_end_token_id": cfg.vision_end_token_id, "patch_size": 16, "spatial_merge_size": 2})) # fmt: skip
96
+ model = DecisionModel(full.model.language_model, d_head=32, base_model=base).eval().to(DEVICE)
97
+ with torch.no_grad():
98
+ model.unary.weight.normal_(0, 0.5)
99
+ model.attach_vision(vdir, min_pixels=32 * 32, max_pixels=128 * 128)
100
+ return full, model, Renderer(tok)
101
+
102
+
103
+ STATE = {"listing": {"title": "Blue mug", "photo": {"type": "image", "data": png(96, 64)}, "price": 12}, "second": {"type": "image", "data": png(64, 128, seed=5)}}
104
+ QUESTIONS = [Noul("The photo shows a mug."), Choice("What is in the photo?", {"mug": "A cup", "shoe": "Footwear", "lamp": None}),
105
+ Choice("Pick a colour.", ["red", "green", "blue", "black"])] # fmt: skip
106
+
107
+
108
+ def test_positions_match_hugging_face(tiny_mm):
109
+ full, model, renderer = tiny_mm
110
+ st = render_image_state(renderer, STATE, model.image_pre)
111
+ assert [(i.rows, i.cols) for i in st.images] == [(2, 3), (4, 2)] # 96x64 and 64x128 at 32 px per token
112
+ ids = torch.tensor([st.input_ids], device=DEVICE)
113
+ grids = torch.tensor(np.stack([i.grid_thw for i in st.images]), device=DEVICE)
114
+ is_image = (ids == model.image_pre.meta["image_token_id"]).int()
115
+ ref, _ = full.model.get_rope_index(ids, is_image, image_grid_thw=grids, attention_mask=torch.ones_like(ids))
116
+ assert torch.equal(ref[:3, 0].cpu(), torch.from_numpy(st.positions))
117
+ assert st.next_pos == int(ref.max()) + 1 < len(st.input_ids) # positions advance by grid size, not token count
118
+
119
+
120
+ def test_image_state_matches_hugging_face_forward(tiny_mm):
121
+ """Our embedding scatter + positions reproduce HF's own multimodal forward pass."""
122
+ full, model, renderer = tiny_mm
123
+ st = render_image_state(renderer, STATE, model.image_pre)
124
+ ids = torch.tensor([st.input_ids], device=DEVICE)
125
+ px = torch.from_numpy(np.concatenate([i.pixel_values for i in st.images])).to(DEVICE)
126
+ grids = torch.tensor(np.stack([i.grid_thw for i in st.images]), device=DEVICE)
127
+ with torch.no_grad():
128
+ ref = full.model(input_ids=ids, pixel_values=px, image_grid_thw=grids, attention_mask=torch.ones_like(ids),
129
+ mm_token_type_ids=(ids == model.image_pre.meta["image_token_id"]).int()).last_hidden_state # fmt: skip
130
+ emb, pos = model._image_state_embeddings(st, torch.device(DEVICE))
131
+ ours = model.backbone(inputs_embeds=emb, position_ids=pos, attention_mask=torch.ones_like(ids), use_cache=False).last_hidden_state
132
+ assert torch.allclose(ours, ref, atol=1e-4), (ours - ref).abs().max()
133
+
134
+
135
+ def test_fork_equals_single_row_with_images(tiny_mm):
136
+ _, model, renderer = tiny_mm
137
+ forked, _, n1 = model.question_logits(renderer, STATE, QUESTIONS, fork=True)
138
+ rows, _, n2 = model.question_logits(renderer, STATE, QUESTIONS, fork=False)
139
+ assert n1 == n2
140
+ for a, b in zip(forked, rows):
141
+ assert torch.allclose(a, b, atol=2e-4), (a, b)
142
+
143
+
144
+ def test_answers_depend_on_the_image_and_text_states_are_untouched(tiny_mm):
145
+ _, model, renderer = tiny_mm
146
+ qs = {"q": QUESTIONS[1]}
147
+ a = model.answer(renderer, {"photo": {"type": "image", "data": png(seed=1)}}, qs)
148
+ b = model.answer(renderer, {"photo": {"type": "image", "data": png(seed=2)}}, qs)
149
+ assert a["answers"]["q"]["probabilities"] != b["answers"]["q"]["probabilities"]
150
+ assert a["usage"]["input_tokens"] < 200 # image tokens are counted; the base64 payload is not
151
+ text_state = {"note": "no pictures here"}
152
+ before = model.answer(renderer, text_state, qs)
153
+ model.vision, keep = None, model.vision # a text-only model must give the identical answer
154
+ model.image_pre, keep_pre = None, model.image_pre
155
+ try:
156
+ assert model.answer(renderer, text_state, qs) == before
157
+ finally:
158
+ model.vision, model.image_pre = keep, keep_pre
159
+
160
+
161
+ def test_detail_hint_changes_the_token_budget(tiny_mm):
162
+ _, model, renderer = tiny_mm
163
+ big = png(512, 384)
164
+ n = lambda part: render_image_state(renderer, {"img": part}, model.image_pre).images[0].n_tokens # noqa: E731
165
+ default, low, high = n({"type": "image", "data": big}), n({"type": "image", "data": big, "detail": "low"}), \
166
+ n({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{big}", "detail": "high"}}) # fmt: skip
167
+ assert default <= 16 < low <= 64 < high <= 1024 # fixture budget 128^2, "low" 256^2, "high" 1024^2 (capped by the image itself)
168
+ with pytest.raises(ImageError):
169
+ split_state({"type": "image", "data": big, "detail": "ultra"})
170
+
171
+
172
+ def test_server_accepts_and_rejects_images(tiny_mm):
173
+ pytest.importorskip("fastapi")
174
+ from fastapi.testclient import TestClient
175
+
176
+ from zico.server import create_app
177
+ from tests.test_core import _tiny
178
+
179
+ _, model, renderer = tiny_mm
180
+ client = TestClient(create_app(model, renderer, {"k"}, "tiny-vision"))
181
+ auth = {"Authorization": "Bearer k"}
182
+ body = {"model": "zico-latest", "state": {"photo": {"type": "image", "data": png()}},
183
+ "questions": {"what": {"type": "choice", "instructions": "What is shown?", "criteria": {"mug": None, "shoe": None}}}} # fmt: skip
184
+ r = client.post("/v1/systemone", json=body, headers=auth)
185
+ assert r.status_code == 200 and r.json()["answers"]["what"]["choice"] in ("mug", "shoe")
186
+ bad = {**body, "state": {"photo": {"type": "image", "data": "%%%"}}}
187
+ r = client.post("/v1/systemone", json=bad, headers=auth)
188
+ assert r.status_code == 422 and r.json()["detail"][0]["type"] == "invalid_image"
189
+ undecodable = {**body, "state": {"photo": {"type": "image", "data": base64.b64encode(b"not an image").decode()}}}
190
+ assert client.post("/v1/systemone", json=undecodable, headers=auth).status_code == 422
191
+ text_model, text_renderer = _tiny("qwen3")
192
+ text_client = TestClient(create_app(text_model, text_renderer, {"k"}, "tiny-text"))
193
+ r = text_client.post("/v1/systemone", json=body, headers=auth)
194
+ assert r.status_code == 422 and r.json()["detail"][0]["type"] == "images_not_supported"
195
+
196
+
197
+ def test_training_batch_with_images_matches_inference(tiny_mm, tmp_path):
198
+ """Image rows + a text row in one padded batch == the single-row inference path; the ViT gets no gradient."""
199
+ from PIL import Image
200
+
201
+ from zico.batching import collate
202
+ from zico.data import render_records
203
+ from zico.losses import proper_score_loss
204
+
205
+ _, model, renderer = tiny_mm
206
+ rng = np.random.default_rng(3)
207
+ files = {"a.png": (96, 64), "b.png": (64, 128)} # already multiples of 32 and inside the fixture's budget
208
+ for name, (w, h) in files.items():
209
+ Image.fromarray(rng.integers(0, 255, (h, w, 3), dtype=np.uint8)).save(tmp_path / name)
210
+ q0, q1, q2 = ({"type": "noul", "instructions": "The photo shows a mug."},
211
+ {"type": "choice", "instructions": "What is in the photo?", "criteria": {"mug": "A cup", "shoe": None, "lamp": None}},
212
+ {"type": "choice", "instructions": "Pick a colour.", "criteria": ["red", "green", "blue", "black"]}) # fmt: skip
213
+ records = [
214
+ {"source": "t", "sid": "s0", "state": {"listing": {"title": "Blue mug", "photo": {"type": "image", "path": "a.png"}}}, "question": q1, "label": 0, "image_grids": [[2, 3]]},
215
+ {"source": "t", "sid": "s0", "state": {"listing": {"title": "Blue mug", "photo": {"type": "image", "path": "a.png"}}}, "question": q0, "label": 1, "image_grids": [[2, 3]]},
216
+ {"source": "t", "sid": "s1", "state": {"note": "no picture here"}, "question": q2, "label": 2},
217
+ {"source": "t", "sid": "s2", "state": [{"type": "image", "path": "b.png"}, {"type": "image", "path": "a.png"}], "question": q2, "label": 3, "image_grids": [[4, 2], [2, 3]]},
218
+ ] # fmt: skip
219
+ rows, kept = render_records(records, renderer, None, model.image_pre.meta, tmp_path)
220
+ assert len(rows) == 4 and [len(r.image_paths) for r in rows] == [1, 1, 0, 2]
221
+ batch = {k: v.to(DEVICE) for k, v in collate(rows, renderer.slot_id, 256, model.image_pre.load_prepared).items()}
222
+ assert batch["position_ids"].shape == (3, 4, 256) and batch["image_grid_thw"].shape == (4, 3)
223
+ logits = model(**batch)
224
+
225
+ def b64(name):
226
+ return base64.b64encode((tmp_path / name).read_bytes()).decode()
227
+
228
+ api_states = [{"listing": {"title": "Blue mug", "photo": {"type": "image", "data": b64("a.png")}}}] * 2 + \
229
+ [{"note": "no picture here"}, [{"type": "image", "data": b64("b.png")}, {"type": "image", "data": b64("a.png")}]] # fmt: skip
230
+ from zico import question_from_dict
231
+
232
+ for i, (state, rec) in enumerate(zip(api_states, records)):
233
+ ref, _, _ = model.question_logits(renderer, state, [question_from_dict(rec["question"])], fork=False)
234
+ assert torch.allclose(logits[i, : len(ref[0])], ref[0], atol=2e-4), (i, logits[i], ref[0])
235
+
236
+ model.zero_grad()
237
+ proper_score_loss(logits, batch["opt_mask"], batch["target"], batch["qtype"], 0.5, 1.0).mean().backward()
238
+ assert any(p.grad is not None and p.grad.abs().sum() > 0 for p in model.backbone.layers[0].parameters())
239
+ assert all(p.grad is None for p in model.vision.model.parameters()) # the tower is never trained
240
+ model.zero_grad()
241
+
242
+
243
+ def test_mrope_layout_arithmetic():
244
+ pos, nxt = mrope_positions([("text", 2), ("image", (2, 3)), ("text", 1)])
245
+ assert pos[:, :2].tolist() == [[0, 1]] * 3
246
+ assert pos[0, 2:8].tolist() == [2] * 6 # time is constant over an image
247
+ assert pos[1, 2:8].tolist() == [2, 2, 2, 3, 3, 3] and pos[2, 2:8].tolist() == [2, 3, 4, 2, 3, 4]
248
+ assert pos[:, 8].tolist() == [5, 5, 5] and nxt == 6 # continues from max(rows, cols), not from 6 image tokens
tokenizer/chat_template.jinja ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {%- set image_count = namespace(value=0) %}
2
+ {%- set video_count = namespace(value=0) %}
3
+ {%- macro render_content(content, do_vision_count, is_system_content=false) %}
4
+ {%- if content is string %}
5
+ {{- content }}
6
+ {%- elif content is iterable and content is not mapping %}
7
+ {%- for item in content %}
8
+ {%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
9
+ {%- if is_system_content %}
10
+ {{- raise_exception('System message cannot contain images.') }}
11
+ {%- endif %}
12
+ {%- if do_vision_count %}
13
+ {%- set image_count.value = image_count.value + 1 %}
14
+ {%- endif %}
15
+ {%- if add_vision_id %}
16
+ {{- 'Picture ' ~ image_count.value ~ ': ' }}
17
+ {%- endif %}
18
+ {{- '<|vision_start|><|image_pad|><|vision_end|>' }}
19
+ {%- elif 'video' in item or item.type == 'video' %}
20
+ {%- if is_system_content %}
21
+ {{- raise_exception('System message cannot contain videos.') }}
22
+ {%- endif %}
23
+ {%- if do_vision_count %}
24
+ {%- set video_count.value = video_count.value + 1 %}
25
+ {%- endif %}
26
+ {%- if add_vision_id %}
27
+ {{- 'Video ' ~ video_count.value ~ ': ' }}
28
+ {%- endif %}
29
+ {{- '<|vision_start|><|video_pad|><|vision_end|>' }}
30
+ {%- elif 'text' in item %}
31
+ {{- item.text }}
32
+ {%- else %}
33
+ {{- raise_exception('Unexpected item type in content.') }}
34
+ {%- endif %}
35
+ {%- endfor %}
36
+ {%- elif content is none or content is undefined %}
37
+ {{- '' }}
38
+ {%- else %}
39
+ {{- raise_exception('Unexpected content type.') }}
40
+ {%- endif %}
41
+ {%- endmacro %}
42
+ {%- if not messages %}
43
+ {{- raise_exception('No messages provided.') }}
44
+ {%- endif %}
45
+ {%- if tools and tools is iterable and tools is not mapping %}
46
+ {{- '<|im_start|>system\n' }}
47
+ {{- "# Tools\n\nYou have access to the following functions:\n\n<tools>" }}
48
+ {%- for tool in tools %}
49
+ {{- "\n" }}
50
+ {{- tool | tojson }}
51
+ {%- endfor %}
52
+ {{- "\n</tools>" }}
53
+ {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>' }}
54
+ {%- if messages[0].role == 'system' %}
55
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
56
+ {%- if content %}
57
+ {{- '\n\n' + content }}
58
+ {%- endif %}
59
+ {%- endif %}
60
+ {{- '<|im_end|>\n' }}
61
+ {%- else %}
62
+ {%- if messages[0].role == 'system' %}
63
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
64
+ {{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
65
+ {%- endif %}
66
+ {%- endif %}
67
+ {%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
68
+ {%- for message in messages[::-1] %}
69
+ {%- set index = (messages|length - 1) - loop.index0 %}
70
+ {%- if ns.multi_step_tool and message.role == "user" %}
71
+ {%- set content = render_content(message.content, false)|trim %}
72
+ {%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}
73
+ {%- set ns.multi_step_tool = false %}
74
+ {%- set ns.last_query_index = index %}
75
+ {%- endif %}
76
+ {%- endif %}
77
+ {%- endfor %}
78
+ {%- if ns.multi_step_tool %}
79
+ {{- raise_exception('No user query found in messages.') }}
80
+ {%- endif %}
81
+ {%- for message in messages %}
82
+ {%- set content = render_content(message.content, true)|trim %}
83
+ {%- if message.role == "system" %}
84
+ {%- if not loop.first %}
85
+ {{- raise_exception('System message must be at the beginning.') }}
86
+ {%- endif %}
87
+ {%- elif message.role == "user" %}
88
+ {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
89
+ {%- elif message.role == "assistant" %}
90
+ {%- set reasoning_content = '' %}
91
+ {%- if message.reasoning_content is string %}
92
+ {%- set reasoning_content = message.reasoning_content %}
93
+ {%- else %}
94
+ {%- if '</think>' in content %}
95
+ {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
96
+ {%- set content = content.split('</think>')[-1].lstrip('\n') %}
97
+ {%- endif %}
98
+ {%- endif %}
99
+ {%- set reasoning_content = reasoning_content|trim %}
100
+ {%- if loop.index0 > ns.last_query_index %}
101
+ {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
102
+ {%- else %}
103
+ {{- '<|im_start|>' + message.role + '\n' + content }}
104
+ {%- endif %}
105
+ {%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
106
+ {%- for tool_call in message.tool_calls %}
107
+ {%- if tool_call.function is defined %}
108
+ {%- set tool_call = tool_call.function %}
109
+ {%- endif %}
110
+ {%- if loop.first %}
111
+ {%- if content|trim %}
112
+ {{- '\n\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
113
+ {%- else %}
114
+ {{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
115
+ {%- endif %}
116
+ {%- else %}
117
+ {{- '\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
118
+ {%- endif %}
119
+ {%- if tool_call.arguments is defined %}
120
+ {%- for args_name, args_value in tool_call.arguments|items %}
121
+ {{- '<parameter=' + args_name + '>\n' }}
122
+ {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}
123
+ {{- args_value }}
124
+ {{- '\n</parameter>\n' }}
125
+ {%- endfor %}
126
+ {%- endif %}
127
+ {{- '</function>\n</tool_call>' }}
128
+ {%- endfor %}
129
+ {%- endif %}
130
+ {{- '<|im_end|>\n' }}
131
+ {%- elif message.role == "tool" %}
132
+ {%- if loop.previtem and loop.previtem.role != "tool" %}
133
+ {{- '<|im_start|>user' }}
134
+ {%- endif %}
135
+ {{- '\n<tool_response>\n' }}
136
+ {{- content }}
137
+ {{- '\n</tool_response>' }}
138
+ {%- if not loop.last and loop.nextitem.role != "tool" %}
139
+ {{- '<|im_end|>\n' }}
140
+ {%- elif loop.last %}
141
+ {{- '<|im_end|>\n' }}
142
+ {%- endif %}
143
+ {%- else %}
144
+ {{- raise_exception('Unexpected message role.') }}
145
+ {%- endif %}
146
+ {%- endfor %}
147
+ {%- if add_generation_prompt %}
148
+ {{- '<|im_start|>assistant\n' }}
149
+ {%- if enable_thinking is defined and enable_thinking is false %}
150
+ {{- '<think>\n\n</think>\n\n' }}
151
+ {%- else %}
152
+ {{- '<think>\n' }}
153
+ {%- endif %}
154
+ {%- endif %}
tokenizer/tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:06b9509352d2af50381ab2247e083b80d32d5c0aba91c272ca9ff729b6a0e523
3
+ size 19989325
tokenizer/tokenizer_config.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_prefix_space": false,
3
+ "audio_bos_token": "<|audio_start|>",
4
+ "audio_eos_token": "<|audio_end|>",
5
+ "audio_token": "<|audio_pad|>",
6
+ "backend": "tokenizers",
7
+ "bos_token": null,
8
+ "clean_up_tokenization_spaces": false,
9
+ "eos_token": "<|endoftext|>",
10
+ "errors": "replace",
11
+ "image_token": "<|image_pad|>",
12
+ "is_local": false,
13
+ "local_files_only": false,
14
+ "model_max_length": 262144,
15
+ "model_specific_special_tokens": {
16
+ "audio_bos_token": "<|audio_start|>",
17
+ "audio_eos_token": "<|audio_end|>",
18
+ "audio_token": "<|audio_pad|>",
19
+ "image_token": "<|image_pad|>",
20
+ "video_token": "<|video_pad|>",
21
+ "vision_bos_token": "<|vision_start|>",
22
+ "vision_eos_token": "<|vision_end|>"
23
+ },
24
+ "pad_token": "<|endoftext|>",
25
+ "pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
26
+ "split_special_tokens": false,
27
+ "tokenizer_class": "Qwen2Tokenizer",
28
+ "unk_token": null,
29
+ "video_token": "<|video_pad|>",
30
+ "vision_bos_token": "<|vision_start|>",
31
+ "vision_eos_token": "<|vision_end|>"
32
+ }
vision/config.json ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "output_hidden_states": false,
3
+ "return_dict": true,
4
+ "dtype": null,
5
+ "chunk_size_feed_forward": 0,
6
+ "is_encoder_decoder": false,
7
+ "id2label": {
8
+ "0": "LABEL_0",
9
+ "1": "LABEL_1"
10
+ },
11
+ "label2id": {
12
+ "LABEL_0": 0,
13
+ "LABEL_1": 1
14
+ },
15
+ "problem_type": null,
16
+ "depth": 24,
17
+ "hidden_size": 1024,
18
+ "hidden_act": "gelu_pytorch_tanh",
19
+ "intermediate_size": 4096,
20
+ "num_heads": 16,
21
+ "in_channels": 3,
22
+ "patch_size": 16,
23
+ "spatial_merge_size": 2,
24
+ "temporal_patch_size": 2,
25
+ "out_hidden_size": 2560,
26
+ "num_position_embeddings": 2304,
27
+ "initializer_range": 0.02,
28
+ "rope_parameters": {
29
+ "rope_theta": 10000.0,
30
+ "rope_type": "axial"
31
+ },
32
+ "_name_or_path": "",
33
+ "deepstack_visual_indexes": [],
34
+ "model_type": "qwen3_5_vision",
35
+ "output_attentions": false
36
+ }
vision/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ed70be60931ea6143cf3c1b31cd21783b6e9127b01c6c74e27733ece44cea2b2
3
+ size 667057584
vision/preprocessor_config.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "do_convert_rgb": true,
3
+ "do_normalize": true,
4
+ "do_rescale": true,
5
+ "do_resize": true,
6
+ "image_mean": [
7
+ 0.5,
8
+ 0.5,
9
+ 0.5
10
+ ],
11
+ "image_processor_type": "Qwen2VLImageProcessor",
12
+ "image_std": [
13
+ 0.5,
14
+ 0.5,
15
+ 0.5
16
+ ],
17
+ "merge_size": 2,
18
+ "patch_size": 16,
19
+ "resample": 3,
20
+ "rescale_factor": 0.00392156862745098,
21
+ "size": {
22
+ "longest_edge": 16777216,
23
+ "shortest_edge": 65536
24
+ },
25
+ "temporal_patch_size": 2
26
+ }
vision/zico_vision.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "base_model": "Qwen/Qwen3.5-4B-Base",
3
+ "image_token_id": 248056,
4
+ "vision_start_token_id": 248053,
5
+ "vision_end_token_id": 248054,
6
+ "patch_size": 16,
7
+ "spatial_merge_size": 2
8
+ }
zico.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "base_model": "Qwen/Qwen3.5-4B-Base",
3
+ "d_head": 256,
4
+ "lora": false,
5
+ "renderer": {
6
+ "max_state_tokens": 768,
7
+ "max_instruction_tokens": 256,
8
+ "max_option_tokens": 64
9
+ },
10
+ "step": 734,
11
+ "val_nll": 0.27321311831474304
12
+ }
zico/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .render import Renderer
2
+ from .schema import Choice, Noul, Score, question_from_dict, question_to_dict
3
+
4
+ __all__ = ["DecisionModel", "Renderer", "Choice", "Score", "Noul", "question_from_dict", "question_to_dict"]
5
+
6
+
7
+ def __getattr__(name):
8
+ # Lazy, so the MLX serving backend can use the package without importing PyTorch.
9
+ if name == "DecisionModel":
10
+ from .model import DecisionModel
11
+
12
+ return DecisionModel
13
+ raise AttributeError(name)
zico/answers.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn per-question probability vectors into the typed answer objects (framework-free)."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+
6
+
7
+ def format_answers(names: list[str], probs: list[list[float]], rendered: list, questions: dict, n_tokens: int) -> dict:
8
+ """rendered[i] is the RenderedQuestion for names[i]; probs[i] its distribution over rendered[i].keys."""
9
+ answers = {}
10
+ for name, p, rq in zip(names, probs, rendered):
11
+ by_key = {k: round(v, 6) for k, v in zip(rq.keys, p)}
12
+ if rq.qtype == "noul":
13
+ answers[name] = {"type": "noul", "noul": by_key["true"]}
14
+ continue
15
+ entropy = -sum(v * math.log(max(v, 1e-12)) for v in p)
16
+ conf = round(1.0 - entropy / math.log(len(p)), 6)
17
+ if rq.qtype == "choice":
18
+ best = max(range(len(p)), key=p.__getitem__)
19
+ answers[name] = {"type": "choice", "choice": rq.keys[best], "probabilities": by_key, "confidence": conf}
20
+ else:
21
+ score = sum(i * v for i, v in enumerate(p))
22
+ legend = dict(questions[name].options())
23
+ answers[name] = {"type": "score", "score": round(score, 4), "legend": legend, "probabilities": by_key, "confidence": conf}
24
+ return {"answers": answers, "usage": {"input_tokens": n_tokens, "output_tokens": 0}}
zico/backbone.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backbone loading and prefix-cache forking.
2
+
3
+ Any HF causal decoder stack works: pure softmax attention (Qwen3, Ministral, ...) or
4
+ hybrids with recurrent layers (Qwen3.5 Gated DeltaNet). We never edit attention masks,
5
+ so nothing here depends on the token-mixing layer type.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+
11
+ import torch
12
+ from transformers import AutoConfig, AutoModel, AutoModelForImageTextToText
13
+
14
+
15
+ def pick_device() -> str:
16
+ if torch.cuda.is_available():
17
+ return "cuda"
18
+ if torch.backends.mps.is_available():
19
+ return "mps"
20
+ return "cpu"
21
+
22
+
23
+ def load_text_backbone(model_id: str, dtype=torch.float32):
24
+ """Return the text decoder stack only (no LM head, no vision tower)."""
25
+ cfg = AutoConfig.from_pretrained(model_id)
26
+ if hasattr(cfg, "text_config") and hasattr(cfg, "vision_config"):
27
+ full = AutoModelForImageTextToText.from_pretrained(model_id, dtype=dtype)
28
+ return full.model.language_model
29
+ return AutoModel.from_pretrained(model_id, dtype=dtype)
30
+
31
+
32
+ def fork_cache(cache, n: int):
33
+ """Copy a batch-1 prefix cache into a batch-n cache.
34
+
35
+ Handles attention KV tensors as well as the {state_idx: tensor} dicts that hybrid
36
+ layers use for conv / recurrent state (HF's batch_repeat_interleave does not).
37
+ """
38
+
39
+ def rep(v):
40
+ if torch.is_tensor(v) and v.dim() > 0 and v.shape[0] == 1:
41
+ return v.repeat_interleave(n, dim=0)
42
+ return v
43
+
44
+ forked = copy.deepcopy(cache)
45
+ for layer in forked.layers:
46
+ for name, val in list(vars(layer).items()):
47
+ if torch.is_tensor(val):
48
+ setattr(layer, name, rep(val))
49
+ elif isinstance(val, dict):
50
+ setattr(layer, name, {k: rep(v) for k, v in val.items()})
51
+ elif isinstance(val, list):
52
+ setattr(layer, name, [rep(v) for v in val])
53
+ return forked
zico/batching.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rows and collation. One row = one (state, question) pair, right-padded.
2
+
3
+ Right padding is deliberate: padded positions come after every readout slot, so with a
4
+ causal backbone (attention or recurrent) they cannot influence any value we read.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+
10
+ import torch
11
+
12
+ from .render import RenderedQuestion
13
+ from .schema import QTYPE_INDEX
14
+
15
+
16
+ @dataclass
17
+ class Row:
18
+ input_ids: list[int]
19
+ marker_ids: list[int]
20
+ opt_pos: list[int] # absolute positions
21
+ ans_pos: int
22
+ qtype: int
23
+ label: int = -1
24
+ soft: list[float] | None = None # optional soft target over options
25
+ # image rows only: 3-part rotary positions [3, len] and the prepared image files, in order of appearance
26
+ positions: object = None
27
+ image_paths: tuple = ()
28
+
29
+ def __len__(self):
30
+ return len(self.input_ids)
31
+
32
+
33
+ def make_row(state_ids: list[int], rq: RenderedQuestion, label: int = -1, soft=None) -> Row:
34
+ s = len(state_ids)
35
+ return Row(
36
+ input_ids=state_ids + rq.input_ids,
37
+ marker_ids=[-1] * s + rq.marker_ids,
38
+ opt_pos=[s + p for p in rq.opt_pos],
39
+ ans_pos=s + rq.ans_pos,
40
+ qtype=QTYPE_INDEX[rq.qtype],
41
+ label=label,
42
+ soft=soft,
43
+ )
44
+
45
+
46
+ def make_image_row(st, rq: RenderedQuestion, label: int = -1, soft=None) -> Row:
47
+ """st: vision.RenderedImageState laid out from recorded grids (its `images` are ImageParts with paths)."""
48
+ import numpy as np
49
+
50
+ s = len(st.input_ids)
51
+ q_pos = st.next_pos + np.arange(len(rq.input_ids)) # text after an image continues from the grid size
52
+ return Row(
53
+ input_ids=st.input_ids + rq.input_ids,
54
+ marker_ids=[-1] * s + rq.marker_ids,
55
+ opt_pos=[s + p for p in rq.opt_pos],
56
+ ans_pos=s + rq.ans_pos,
57
+ qtype=QTYPE_INDEX[rq.qtype],
58
+ label=label,
59
+ soft=soft,
60
+ positions=np.concatenate([st.positions, np.stack([q_pos] * 3)], axis=1),
61
+ image_paths=tuple(p.path for p in st.images),
62
+ )
63
+
64
+
65
+ def collate(rows: list[Row], pad_id: int = 0, pad_len: int | None = None, load_image=None) -> dict[str, torch.Tensor]:
66
+ """load_image(path) -> vision.EncodedImage is required when any row carries images."""
67
+ batch = _collate_text(rows, pad_id, pad_len)
68
+ if not any(r.image_paths for r in rows):
69
+ return batch
70
+ import numpy as np
71
+
72
+ B, L = batch["input_ids"].shape
73
+ pos = torch.arange(L)[None, None].repeat(3, B, 1) # text rows (and padding): all three parts advance together
74
+ pixels, grids = [], []
75
+ for i, r in enumerate(rows):
76
+ if r.positions is not None:
77
+ pos[:, i, : len(r)] = torch.from_numpy(r.positions)
78
+ for path in r.image_paths: # row order == the order of <|image_pad|> runs in the flattened batch
79
+ img = load_image(path)
80
+ pixels.append(img.pixel_values)
81
+ grids.append(img.grid_thw)
82
+ batch["position_ids"] = pos
83
+ batch["pixel_values"] = torch.from_numpy(np.concatenate(pixels))
84
+ batch["image_grid_thw"] = torch.from_numpy(np.stack(grids))
85
+ return batch
86
+
87
+
88
+ def _collate_text(rows: list[Row], pad_id: int = 0, pad_len: int | None = None) -> dict[str, torch.Tensor]:
89
+ B = len(rows)
90
+ L = max(pad_len or 0, max(len(r) for r in rows))
91
+ K = max(len(r.opt_pos) for r in rows)
92
+ input_ids = torch.full((B, L), pad_id, dtype=torch.long)
93
+ marker_ids = torch.full((B, L), -1, dtype=torch.long)
94
+ attention_mask = torch.zeros((B, L), dtype=torch.long)
95
+ opt_pos = torch.zeros((B, K), dtype=torch.long)
96
+ opt_mask = torch.zeros((B, K), dtype=torch.bool)
97
+ target = torch.zeros((B, K), dtype=torch.float32)
98
+ for i, r in enumerate(rows):
99
+ n, k = len(r), len(r.opt_pos)
100
+ input_ids[i, :n] = torch.tensor(r.input_ids)
101
+ marker_ids[i, :n] = torch.tensor(r.marker_ids)
102
+ attention_mask[i, :n] = 1
103
+ opt_pos[i, :k] = torch.tensor(r.opt_pos)
104
+ opt_mask[i, :k] = True
105
+ if r.soft is not None:
106
+ target[i, :k] = torch.tensor(r.soft, dtype=torch.float32)
107
+ elif r.label >= 0:
108
+ target[i, r.label] = 1.0
109
+ return {
110
+ "input_ids": input_ids,
111
+ "marker_ids": marker_ids,
112
+ "attention_mask": attention_mask,
113
+ "opt_pos": opt_pos,
114
+ "opt_mask": opt_mask,
115
+ "ans_pos": torch.tensor([r.ans_pos for r in rows], dtype=torch.long),
116
+ "qtype": torch.tensor([r.qtype for r in rows], dtype=torch.long),
117
+ "label": torch.tensor([r.label for r in rows], dtype=torch.long),
118
+ "target": target, # one-hot or soft distribution over options
119
+ }
120
+
121
+
122
+ LENGTH_BUCKETS = (64, 96, 128, 192, 256, 384, 512, 768, 1024, 1536, 2048, 3072, 4096, 6144, 8192)
123
+
124
+
125
+ def bucket_len(n: int) -> int:
126
+ for b in LENGTH_BUCKETS:
127
+ if n <= b:
128
+ return b
129
+ return -(-n // 2048) * 2048
130
+
131
+
132
+ def bucketed_batches(lengths: list[int], max_tokens: int, max_rows: int, shuffle_rng=None, drop_last=False):
133
+ """Batches with a small, fixed set of (rows, padded length) shapes -> list of (indices, padded length).
134
+
135
+ Fused GPU kernels (Triton autotune, fla) re-tune for every new tensor shape, which costs seconds.
136
+ Free-form token-budget batching makes nearly every batch a new shape and leaves the GPU idle
137
+ most of the time; a dozen static shapes are tuned once and then run at full speed. The price
138
+ is some padding up to the bucket boundary. `drop_last` skips the one ragged batch per bucket.
139
+ """
140
+ by_bucket: dict[int, list[int]] = {}
141
+ for i, n in enumerate(lengths):
142
+ by_bucket.setdefault(bucket_len(n), []).append(i)
143
+ batches = []
144
+ for L, idx in sorted(by_bucket.items()):
145
+ if shuffle_rng is not None:
146
+ shuffle_rng.shuffle(idx)
147
+ B = max(1, min(max_rows, max_tokens // L))
148
+ for s in range(0, len(idx), B):
149
+ chunk = idx[s : s + B]
150
+ if len(chunk) == B or not drop_last:
151
+ batches.append((chunk, L))
152
+ if shuffle_rng is not None:
153
+ shuffle_rng.shuffle(batches)
154
+ return batches
155
+
156
+
157
+ def token_budget_batches(lengths: list[int], max_tokens: int, max_rows: int, shuffle_rng=None) -> list[list[int]]:
158
+ """Group row indices into batches whose padded size stays under max_tokens.
159
+
160
+ Rows are bucketed by length (so little compute is wasted on padding) and the batch
161
+ order is shuffled; within-bucket membership is reshuffled each call via the rng.
162
+ """
163
+ idx = list(range(len(lengths)))
164
+ if shuffle_rng is not None:
165
+ shuffle_rng.shuffle(idx)
166
+ chunk = max_rows * 50
167
+ idx = [j for c in range(0, len(idx), chunk) for j in sorted(idx[c : c + chunk], key=lengths.__getitem__)]
168
+ else:
169
+ idx.sort(key=lengths.__getitem__)
170
+ batches, cur, cur_max = [], [], 0
171
+ for j in idx:
172
+ new_max = max(cur_max, lengths[j])
173
+ if cur and (new_max * (len(cur) + 1) > max_tokens or len(cur) >= max_rows):
174
+ batches.append(cur)
175
+ cur, new_max = [], lengths[j]
176
+ cur.append(j)
177
+ cur_max = new_max
178
+ if cur:
179
+ batches.append(cur)
180
+ if shuffle_rng is not None:
181
+ shuffle_rng.shuffle(batches)
182
+ return batches
zico/benchmark.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared pieces for the head-to-head benchmarks (same rows, same scoring for every system)."""
2
+ from __future__ import annotations
3
+
4
+ import statistics
5
+
6
+ import torch
7
+
8
+ from .calibration import ece
9
+ from .data import read_jsonl
10
+
11
+ # (split, source): heldout = zero-shot for every system; test = in-distribution for zico checkpoints
12
+ BENCH_SETS = [("heldout", "massive_intent"), ("heldout", "emotion"), ("heldout", "imdb"), ("heldout", "arc_easy"),
13
+ ("test", "ag_news"), ("test", "banking77")] # fmt: skip
14
+
15
+
16
+ def load_rows(data_dir: str, split: str, source: str, n: int) -> list[dict]:
17
+ return [r for r in read_jsonl(f"{data_dir}/{split}.jsonl") if r["source"] == source][:n]
18
+
19
+
20
+ def auroc(conf, correct) -> float:
21
+ """P(confidence of a correct answer > confidence of a wrong one); 0.5 = uninformative."""
22
+ pos = [c for c, ok in zip(conf, correct) if ok]
23
+ neg = [c for c, ok in zip(conf, correct) if not ok]
24
+ if not pos or not neg:
25
+ return float("nan")
26
+ return sum((a > b) + 0.5 * (a == b) for a in pos for b in neg) / (len(pos) * len(neg))
27
+
28
+
29
+ def summarise(preds, labels, confs, times) -> dict:
30
+ """preds may contain None (abstention / error), which is scored as wrong."""
31
+ answered = [p is not None for p in preds]
32
+ correct = [p == y for p, y in zip(preds, labels)]
33
+ conf = [c if c is not None else 0.0 for c in confs]
34
+ n_ans = sum(answered)
35
+ top = sorted(range(len(conf)), key=lambda i: -conf[i])[: max(1, len(conf) // 2)]
36
+ return {
37
+ "n": len(preds),
38
+ "acc": sum(correct) / len(correct),
39
+ "abstain": 1 - n_ans / len(preds),
40
+ "acc_answered": sum(c for c, a in zip(correct, answered) if a) / max(1, n_ans),
41
+ "ece": ece(torch.tensor(conf), torch.tensor(correct))[0],
42
+ "auroc": auroc(conf, correct),
43
+ "acc_top50_conf": sum(correct[i] for i in top) / len(top),
44
+ "median_ms": statistics.median(times) * 1000 if times else float("nan"),
45
+ }
46
+
47
+
48
+ def format_line(name: str, m: dict) -> str:
49
+ return (f" {name:13s} acc {m['acc']:.3f} | abstain {m['abstain']:.3f} | acc(answered) {m['acc_answered']:.3f} | "
50
+ f"ece {m['ece']:.3f} | auroc {m['auroc']:.3f} | acc@top50%conf {m['acc_top50_conf']:.3f} | "
51
+ f"median {m['median_ms']:.0f} ms") # fmt: skip
zico/calibration.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Calibration metrics and post-hoc temperature scaling."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+
6
+ import torch
7
+
8
+ from .schema import QTYPE_INDEX, QTYPES
9
+
10
+
11
+ def ece(conf: torch.Tensor, correct: torch.Tensor, n_bins: int = 15):
12
+ """Expected calibration error of the top-label confidence, plus the reliability table."""
13
+ edges = torch.linspace(0, 1, n_bins + 1)
14
+ total, table = 0.0, []
15
+ for lo, hi in zip(edges[:-1], edges[1:]):
16
+ m = (conf > lo) & (conf <= hi)
17
+ if m.any():
18
+ c, a, w = conf[m].mean().item(), correct[m].float().mean().item(), m.float().mean().item()
19
+ total += w * abs(c - a)
20
+ table.append({"lo": round(lo.item(), 3), "hi": round(hi.item(), 3), "conf": c, "acc": a, "frac": w})
21
+ return total, table
22
+
23
+
24
+ def metrics(records: list[dict]) -> dict:
25
+ """records: {"p": 1-D tensor over options, "label": int, "qtype": int, "target": optional soft tensor}."""
26
+ if not records:
27
+ return {}
28
+ conf = torch.tensor([r["p"].max().item() for r in records])
29
+ pred = torch.tensor([int(r["p"].argmax()) for r in records])
30
+ label = torch.tensor([r["label"] for r in records])
31
+ correct = pred == label
32
+ nll = torch.tensor([-math.log(max(r["p"][r["label"]].item(), 1e-12)) for r in records])
33
+ brier = torch.tensor(
34
+ [((r["p"] - torch.nn.functional.one_hot(torch.tensor(r["label"]), len(r["p"]))) ** 2).sum().item() for r in records]
35
+ )
36
+ e, table = ece(conf, correct)
37
+ out = {
38
+ "n": len(records),
39
+ "acc": correct.float().mean().item(),
40
+ "nll": nll.mean().item(),
41
+ "brier": brier.mean().item(),
42
+ "ece": e,
43
+ "mean_conf": conf.mean().item(),
44
+ "reliability": table,
45
+ }
46
+ score_rows = [r for r in records if r["qtype"] == QTYPE_INDEX["score"]]
47
+ if score_rows:
48
+ exp = torch.tensor([(r["p"] * torch.arange(len(r["p"]))).sum().item() for r in score_rows])
49
+ gold = torch.tensor([float(r["label"]) for r in score_rows])
50
+ out["score_mae"] = (exp - gold).abs().mean().item()
51
+ return out
52
+
53
+
54
+ def metrics_by(records: list[dict], key: str) -> dict:
55
+ groups: dict = {}
56
+ for r in records:
57
+ groups.setdefault(r[key], []).append(r)
58
+ return {(QTYPES[k] if key == "qtype" else k): metrics(v) for k, v in sorted(groups.items(), key=lambda kv: str(kv[0]))}
59
+
60
+
61
+ def fit_temperature(logits: list[torch.Tensor], labels: list[int], lo: float = 0.05, hi: float = 20.0) -> float:
62
+ """Single scalar T in [lo, hi] minimising NLL of softmax(logits / T).
63
+
64
+ Accuracy is unchanged by construction. The problem is one-dimensional, so we use a
65
+ bounded grid + golden-section search rather than a gradient method that can diverge.
66
+ """
67
+ k = max(len(l) for l in logits)
68
+ padded = torch.full((len(logits), k), -1e9)
69
+ for i, l in enumerate(logits):
70
+ padded[i, : len(l)] = l.detach().float().cpu()
71
+ y = torch.tensor(labels)
72
+
73
+ def nll(log_t: float) -> float:
74
+ return torch.nn.functional.cross_entropy(padded / math.exp(log_t), y).item()
75
+
76
+ grid = torch.linspace(math.log(lo), math.log(hi), 61).tolist()
77
+ best = min(range(len(grid)), key=lambda i: nll(grid[i]))
78
+ a, b = grid[max(best - 1, 0)], grid[min(best + 1, len(grid) - 1)]
79
+ phi = (math.sqrt(5) - 1) / 2
80
+ for _ in range(40):
81
+ c, d = b - phi * (b - a), a + phi * (b - a)
82
+ if nll(c) < nll(d):
83
+ b = d
84
+ else:
85
+ a = c
86
+ return min(max(math.exp((a + b) / 2), lo), hi)
zico/data.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public datasets recast as (state, typed question, label) records.
2
+
3
+ The aim is a model that *reads the schema* rather than memorising label sets, so records
4
+ vary what a fixed classifier would hold constant: option order, option subsets, presence
5
+ of descriptions, instruction wording, and whether the state is a string or a JSON object.
6
+
7
+ Record format (one JSON object per line):
8
+ {"source", "sid", "state", "question": {"type", "instructions", "criteria"}, "label", "soft"?}
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import random
14
+ from pathlib import Path
15
+
16
+ from .batching import Row, make_row
17
+ from .render import Renderer
18
+ from .schema import question_from_dict
19
+
20
+ GO_EMOTIONS = [
21
+ "admiration", "amusement", "anger", "annoyance", "approval", "caring", "confusion", "curiosity", "desire",
22
+ "disappointment", "disapproval", "disgust", "embarrassment", "excitement", "fear", "gratitude", "grief", "joy",
23
+ "love", "nervousness", "optimism", "pride", "realization", "relief", "remorse", "sadness", "surprise", "neutral",
24
+ ] # fmt: skip
25
+
26
+
27
+ def _q(qtype, instructions, criteria=None):
28
+ q = {"type": qtype, "instructions": instructions}
29
+ if criteria is not None:
30
+ q["criteria"] = criteria
31
+ return q
32
+
33
+
34
+ def _shuffled_choice(rng, instructions, options: list[tuple[str, str]], gold_key: str, describe=True):
35
+ """options: [(key, description)]. Returns (question, label index) with a random option order."""
36
+ options = options[:]
37
+ rng.shuffle(options)
38
+ keys = [k for k, _ in options]
39
+ criteria = {k: d for k, d in options} if describe and any(d for _, d in options) else keys
40
+ return _q("choice", instructions, criteria), keys.index(gold_key)
41
+
42
+
43
+ def _wrap(rng, field, text):
44
+ """States arrive as plain strings or as JSON objects; train on both."""
45
+ return {field: text} if rng.random() < 0.6 else text
46
+
47
+
48
+ # --------------------------------------------------------------------------- training sources
49
+ def boolq(ex, rng, ctx):
50
+ question = ex["question"].strip().rstrip("?")
51
+ question = question[0].upper() + question[1:] + "?"
52
+ yield _wrap(rng, "passage", ex["passage"]), _q("noul", question), int(ex["answer"]), None
53
+
54
+
55
+ _AG = [
56
+ ("world", "International news, politics, conflicts and diplomacy"),
57
+ ("sports", "Sports events, teams, athletes and results"),
58
+ ("business", "Companies, markets, the economy and finance"),
59
+ ("science_technology", "Science, technology, computing, the internet and gadgets"),
60
+ ]
61
+ _AG_T = ["Which news category does this article belong to?", "What is the main topic of the article?",
62
+ "Route this article to the right news desk."] # fmt: skip
63
+
64
+
65
+ def ag_news(ex, rng, ctx):
66
+ q, y = _shuffled_choice(rng, rng.choice(_AG_T), _AG, _AG[ex["label"]][0], describe=rng.random() < 0.7)
67
+ yield _wrap(rng, "article", ex["text"]), q, y, None
68
+
69
+
70
+ _SAT5 = ["Very dissatisfied: a bad experience, would warn others", "Dissatisfied: more negatives than positives",
71
+ "Mixed: an average or so-so experience", "Satisfied: a good experience with minor issues",
72
+ "Delighted: an excellent experience, highly recommended"] # fmt: skip
73
+ _SAT3 = ["Negative overall", "Mixed or neutral overall", "Positive overall"]
74
+
75
+
76
+ def yelp(ex, rng, ctx):
77
+ stars, state = ex["label"], _wrap(rng, "review", ex["text"])
78
+ if rng.random() < 0.6:
79
+ yield state, _q("score", "How satisfied is the reviewer with the business?", _SAT5), stars, None
80
+ else:
81
+ yield state, _q("score", "What is the overall sentiment of the review?", _SAT3), (0, 0, 1, 2, 2)[stars], None
82
+ if stars != 2 and rng.random() < 0.5:
83
+ if rng.random() < 0.5:
84
+ yield state, _q("noul", "The reviewer would recommend this business."), int(stars >= 3), None
85
+ else:
86
+ yield state, _q("noul", "The reviewer had a negative experience."), int(stars <= 1), None
87
+
88
+
89
+ def go_emotions(ex, rng, ctx):
90
+ gold = [GO_EMOTIONS[i] for i in ex["labels"]]
91
+ state = _wrap(rng, "comment", ex["text"])
92
+ if len(gold) == 1:
93
+ k = rng.randint(4, 10)
94
+ distract = rng.sample([e for e in GO_EMOTIONS if e != gold[0]], k - 1)
95
+ q, y = _shuffled_choice(rng, "Which emotion does the comment primarily express?",
96
+ [(e, "") for e in [gold[0]] + distract], gold[0]) # fmt: skip
97
+ yield state, q, y, None
98
+ non_neutral = [e for e in gold if e != "neutral"]
99
+ if non_neutral and rng.random() < 0.5:
100
+ yield state, _q("noul", f"The author expresses {rng.choice(non_neutral)}."), 1, None
101
+ else:
102
+ neg = rng.choice([e for e in GO_EMOTIONS if e not in gold and e != "neutral"])
103
+ yield state, _q("noul", f"The author expresses {neg}."), 0, None
104
+
105
+
106
+ _INTENT_T = ["Which intent best matches the customer's message?", "What is the customer trying to do?",
107
+ "Classify the support request."] # fmt: skip
108
+
109
+
110
+ def banking77(ex, rng, ctx):
111
+ names = ctx["banking77_labels"]
112
+ gold = ex["label_text"]
113
+ k = rng.choice([16, 32, len(names), len(names)]) # favour the full 77-way list
114
+ keys = [gold] + rng.sample([n for n in names if n != gold], k - 1)
115
+ q, y = _shuffled_choice(rng, rng.choice(_INTENT_T), [(n, "") for n in keys], gold)
116
+ yield _wrap(rng, "message", ex["text"]), q, y, None
117
+
118
+
119
+ _NLI = [("entailment", "The hypothesis must be true if the premise is true"),
120
+ ("neutral", "The hypothesis may or may not be true given the premise"),
121
+ ("contradiction", "The hypothesis cannot be true if the premise is true")] # fmt: skip
122
+
123
+
124
+ def mnli(ex, rng, ctx):
125
+ state = {"premise": ex["premise"], "hypothesis": ex["hypothesis"]}
126
+ if rng.random() < 0.7:
127
+ q, y = _shuffled_choice(rng, "How does the hypothesis relate to the premise?", _NLI, _NLI[ex["label"]][0],
128
+ describe=rng.random() < 0.7) # fmt: skip
129
+ yield state, q, y, None
130
+ else:
131
+ yield state, _q("noul", "The hypothesis necessarily follows from the premise."), int(ex["label"] == 0), None
132
+
133
+
134
+ def race(ex, rng, ctx):
135
+ opts = list(ex["options"])
136
+ gold = opts["ABCD".index(ex["answer"])]
137
+ rng.shuffle(opts)
138
+ criteria = {"ABCD"[i]: o for i, o in enumerate(opts)}
139
+ yield {"passage": ex["article"]}, _q("choice", ex["question"], criteria), opts.index(gold), None
140
+
141
+
142
+ _TOX = {
143
+ "toxicity": "This comment is toxic: rude, disrespectful, or likely to make someone leave a discussion.",
144
+ "insult": "This comment insults a person or group.",
145
+ "obscene": "This comment contains obscene or vulgar language.",
146
+ "threat": "This comment contains a threat.",
147
+ "identity_attack": "This comment attacks someone because of their identity.",
148
+ }
149
+
150
+
151
+ def civil_comments(ex, rng, ctx):
152
+ """Targets are annotator fractions: a natural source of *soft*, calibrated labels."""
153
+ if ex["toxicity"] == 0 and rng.random() > 0.25:
154
+ return
155
+ state = _wrap(rng, "comment", ex["text"])
156
+ attrs = ["toxicity"] + ([rng.choice(list(_TOX)[1:])] if rng.random() < 0.5 else [])
157
+ for a in attrs:
158
+ t = float(ex[a])
159
+ yield state, _q("noul", _TOX[a]), int(t >= 0.5), [1 - t, t]
160
+
161
+
162
+ # --------------------------------------------------------------------------- held-out sources
163
+ def emotion(ex, rng, ctx):
164
+ names = ["sadness", "joy", "love", "anger", "fear", "surprise"]
165
+ q, y = _shuffled_choice(rng, "Which emotion does the author express?", [(n, "") for n in names], names[ex["label"]])
166
+ yield ex["text"], q, y, None
167
+
168
+
169
+ def imdb(ex, rng, ctx):
170
+ yield {"review": ex["text"]}, _q("noul", "The reviewer liked the movie."), int(ex["label"]), None
171
+
172
+
173
+ def massive_intent(ex, rng, ctx):
174
+ names = ctx["massive_labels"]
175
+ q, y = _shuffled_choice(rng, "Which intent does the voice assistant request express?",
176
+ [(n, "") for n in names], ex["label_text"]) # fmt: skip
177
+ yield {"utterance": ex["text"]}, q, y, None
178
+
179
+
180
+ def arc_easy(ex, rng, ctx):
181
+ texts, labels = ex["choices"]["text"], ex["choices"]["label"]
182
+ if ex["answerKey"] not in labels or len(texts) < 2:
183
+ return
184
+ criteria = {"ABCDE"[i]: t for i, t in enumerate(texts)}
185
+ yield {"question": ex["question"]}, _q("choice", "Select the correct answer to the question.", criteria), \
186
+ labels.index(ex["answerKey"]), None # fmt: skip
187
+
188
+
189
+ # name -> (hf id, config, train split, test split, stream?, adapter)
190
+ TRAIN_SOURCES = {
191
+ "boolq": ("google/boolq", None, "train", "validation", False, boolq),
192
+ "ag_news": ("fancyzhx/ag_news", None, "train", "test", False, ag_news),
193
+ "yelp": ("Yelp/yelp_review_full", None, "train", "test", True, yelp),
194
+ "go_emotions": ("google-research-datasets/go_emotions", "simplified", "train", "test", False, go_emotions),
195
+ "banking77": ("mteb/banking77", None, "train", "test", False, banking77),
196
+ "mnli": ("nyu-mll/glue", "mnli", "train", "validation_matched", False, mnli),
197
+ "race": ("ehovy/race", "all", "train", "test", False, race),
198
+ "civil_comments": ("google/civil_comments", None, "train", "test", True, civil_comments),
199
+ }
200
+ HELDOUT_SOURCES = {
201
+ "emotion": ("dair-ai/emotion", "split", None, "test", False, emotion),
202
+ "imdb": ("stanfordnlp/imdb", "plain_text", None, "test", False, imdb),
203
+ "massive_intent": ("mteb/amazon_massive_intent", "en", None, "test", False, massive_intent),
204
+ "arc_easy": ("allenai/ai2_arc", "ARC-Easy", None, "test", False, arc_easy),
205
+ }
206
+
207
+
208
+ def take(hf_id, config, split, n, stream, seed, buffer_size=20_000):
209
+ from datasets import load_dataset
210
+
211
+ if stream:
212
+ ds = load_dataset(hf_id, config, split=split, streaming=True).shuffle(seed=seed, buffer_size=buffer_size)
213
+ return list(ds.take(n))
214
+ ds = load_dataset(hf_id, config, split=split).shuffle(seed=seed)
215
+ return [ds[i] for i in range(min(n, len(ds)))]
216
+
217
+
218
+ def label_context() -> dict:
219
+ from datasets import load_dataset
220
+
221
+ return {
222
+ "banking77_labels": sorted(set(load_dataset("mteb/banking77", split="train")["label_text"])),
223
+ "massive_labels": sorted(set(load_dataset("mteb/amazon_massive_intent", "en", split="train")["label_text"])),
224
+ }
225
+
226
+
227
+ def to_records(name, examples, adapter, rng, ctx, prefix):
228
+ out = []
229
+ for i, ex in enumerate(examples):
230
+ for state, question, label, soft in adapter(ex, rng, ctx):
231
+ rec = {"source": name, "sid": f"{prefix}-{name}-{i}", "state": state, "question": question, "label": label}
232
+ if soft is not None:
233
+ rec["soft"] = soft
234
+ out.append(rec)
235
+ return out
236
+
237
+
238
+ # --------------------------------------------------------------------------- loading
239
+ def read_jsonl(path) -> list[dict]:
240
+ with open(path) as f:
241
+ return [json.loads(line) for line in f if line.strip()]
242
+
243
+
244
+ def write_jsonl(path, records):
245
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
246
+ with open(path, "w") as f:
247
+ for r in records:
248
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
249
+
250
+
251
+ def render_records(records: list[dict], renderer: Renderer, max_len: int | None = None, image_meta=None,
252
+ image_root=None) -> tuple[list[Row], list[dict]]: # fmt: skip
253
+ """Tokenise records into rows; drops rows longer than max_len (returns the kept records too).
254
+
255
+ Image records carry "image_grids" (token grid per image, recorded when the data was built), so their
256
+ length and positions are known without decoding any picture; pixels are loaded per batch by collate().
257
+ image_meta = the vision tower's zico_vision.json; image_root = directory the image paths are relative to.
258
+ """
259
+ from .batching import make_image_row
260
+ from .vision import ImageError, render_image_state
261
+
262
+ rows, kept, state_cache = [], [], {}
263
+ for rec in records:
264
+ grids = rec.get("image_grids")
265
+ if grids:
266
+ if image_meta is None:
267
+ raise ValueError("the data contains images but the checkpoint has no vision tower (scripts/add_vision.py)")
268
+ if rec["sid"] not in state_cache:
269
+ try:
270
+ st = render_image_state(renderer, rec["state"], image_meta, grids=grids)
271
+ except ImageError:
272
+ st = None # state longer than the renderer's limit
273
+ if st is not None and image_root is not None:
274
+ for part in st.images:
275
+ part.path = str(Path(image_root) / part.path)
276
+ state_cache[rec["sid"]] = st
277
+ if state_cache[rec["sid"]] is None:
278
+ continue
279
+ row = make_image_row(state_cache[rec["sid"]], renderer.question(question_from_dict(rec["question"])),
280
+ label=rec["label"], soft=rec.get("soft")) # fmt: skip
281
+ if max_len is None or len(row) <= max_len:
282
+ rows.append(row)
283
+ kept.append(rec)
284
+ continue
285
+ if rec["sid"] not in state_cache:
286
+ state_cache[rec["sid"]] = renderer.state(rec["state"])
287
+ row = make_row(state_cache[rec["sid"]], renderer.question(question_from_dict(rec["question"])),
288
+ label=rec["label"], soft=rec.get("soft")) # fmt: skip
289
+ if max_len is None or len(row) <= max_len:
290
+ rows.append(row)
291
+ kept.append(rec)
292
+ return rows, kept
293
+
294
+
295
+ def subsample(records, n, seed=0):
296
+ if n is None or len(records) <= n:
297
+ return records
298
+ return random.Random(seed).sample(records, n)
zico/evaluate.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Batched prediction over rendered rows (the training-time, row-per-question path)."""
2
+ from __future__ import annotations
3
+
4
+ import torch
5
+
6
+ from .batching import Row, bucketed_batches, collate
7
+ from .calibration import metrics, metrics_by
8
+
9
+
10
+ @torch.no_grad()
11
+ def predict(model, rows: list[Row], records: list[dict], pad_id: int, max_tokens=8192, max_rows=64) -> list[dict]:
12
+ dev = next(model.parameters()).device
13
+ was_training = model.training
14
+ model.eval()
15
+ load_image = model.image_pre.load_prepared if getattr(model, "image_pre", None) is not None else None
16
+ out: list[dict | None] = [None] * len(rows)
17
+ for idx, pad_len in bucketed_batches([len(r) for r in rows], max_tokens, max_rows):
18
+ b = collate([rows[i] for i in idx], pad_id, pad_len, load_image)
19
+ logits = model(**{k: v.to(dev) for k, v in b.items()}).float().cpu()
20
+ for j, i in enumerate(idx):
21
+ k = len(rows[i].opt_pos)
22
+ lg = logits[j, :k]
23
+ out[i] = {"logits": lg, "p": torch.softmax(lg, -1), "label": rows[i].label, "qtype": rows[i].qtype,
24
+ "source": records[i]["source"]} # fmt: skip
25
+ model.train(was_training)
26
+ return out
27
+
28
+
29
+ def report(preds: list[dict]) -> dict:
30
+ return {"overall": metrics(preds), "by_type": metrics_by(preds, "qtype"), "by_source": metrics_by(preds, "source")}
31
+
32
+
33
+ def format_report(rep: dict, title: str = "") -> str:
34
+ lines = [f"{title:<22s} {'n':>6s} {'acc':>7s} {'nll':>7s} {'brier':>7s} {'ece':>7s} {'conf':>7s}"]
35
+
36
+ def row(name, m):
37
+ return f"{name:<22s} {m['n']:6d} {m['acc']:7.3f} {m['nll']:7.3f} {m['brier']:7.3f} {m['ece']:7.3f} {m['mean_conf']:7.3f}"
38
+
39
+ lines.append(row("overall", rep["overall"]))
40
+ lines += [row(f" type:{k}", m) for k, m in rep["by_type"].items()]
41
+ lines += [row(f" {k}", m) for k, m in rep["by_source"].items()]
42
+ return "\n".join(lines)
43
+
44
+
45
+ def strip_tables(rep: dict) -> dict:
46
+ """Drop reliability tables for compact logging."""
47
+ if "reliability" in rep:
48
+ return {k: v for k, v in rep.items() if k != "reliability"}
49
+ return {k: strip_tables(v) if isinstance(v, dict) else v for k, v in rep.items()}
zico/image_sources.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public image datasets recast as typed questions about an image in the state.
2
+
3
+ Same philosophy as data.py: vary what a fixed classifier would hold constant (option subsets and
4
+ order, instruction wording, where the image sits in the state) so the model reads the schema.
5
+ Held-out sources are never trained on, as with text.
6
+
7
+ An adapter yields (question, label index) pairs for one example; the builder supplies the state.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ from .data import _q, _shuffled_choice
14
+
15
+
16
+ def _clean(name) -> str:
17
+ name = re.sub(r"^\d+\.", "", str(name)).split(",")[0] # "001.Black_footed_Albatross" / "bonnet, poke bonnet"
18
+ return re.sub(r"[\s_]+", " ", name).strip()
19
+
20
+
21
+ def _article(name: str) -> str:
22
+ return ("an " if name[:1].lower() in "aeiou" else "a ") + name
23
+
24
+
25
+ def classify(label_col: str, instructions: list[str], sizes: list[int], statement: str | None = "The image shows {a}."):
26
+ """Single-label classification -> a Choice over a sampled option subset, sometimes plus a yes/no statement."""
27
+
28
+ def adapter(ex, rng, names):
29
+ names = [_clean(n) for n in names[label_col]]
30
+ gold = names[ex[label_col]]
31
+ others = sorted(set(names) - {gold})
32
+ k = min(len(others) + 1, rng.choice(sizes))
33
+ opts = [gold] + rng.sample(others, k - 1)
34
+ yield _shuffled_choice(rng, rng.choice(instructions), [(o, "") for o in opts], gold)
35
+ if statement and rng.random() < 0.35:
36
+ truth = rng.random() < 0.5
37
+ shown = gold if truth else rng.choice(others)
38
+ yield _q("noul", statement.format(a=_article(shown), x=shown)), int(truth)
39
+
40
+ return adapter
41
+
42
+
43
+ def cifar100(ex, rng, names):
44
+ yield from classify("fine_label", ["What does the image show?", "Which object is pictured?"], [10, 20, 100])(ex, rng, names)
45
+ if rng.random() < 0.5:
46
+ yield from classify("coarse_label", ["Which broad category does the pictured thing belong to?"], [20], None)(ex, rng, names)
47
+
48
+
49
+ def aircraft(ex, rng, names):
50
+ yield from classify("label", ["Which aircraft model variant is this?"], [8, 16, 32], "The aircraft is {a}.")(ex, rng, names)
51
+ which = rng.choice(["family", "manufacturer"])
52
+ ask = "Which aircraft family is this?" if which == "family" else "Which company built this aircraft?"
53
+ yield from classify(which, [ask], [8, 16, 30], None)(ex, rng, names)
54
+
55
+
56
+ _FASHION = {"masterCategory": "Which top-level category does the product belong to?", "subCategory": "Which sub-category is the product?",
57
+ "articleType": "What type of article is this product?", "baseColour": "What is the main colour of the product?",
58
+ "gender": "Who is the product intended for?", "usage": "What occasion is the product meant for?"} # fmt: skip
59
+
60
+
61
+ def fashion(ex, rng, names):
62
+ """Several attributes per product photo: natural multi-question supervision."""
63
+ for col in rng.sample(list(_FASHION), 2):
64
+ gold, pool = ex.get(col), names.get(col) or []
65
+ if not gold or gold not in pool or len(pool) < 3:
66
+ continue
67
+ others = [p for p in pool if p != gold]
68
+ opts = [gold] + rng.sample(others, min(len(others), rng.choice([3, 7, 15, 31])))
69
+ yield _shuffled_choice(rng, _FASHION[col], [(_clean(o), "") for o in opts], _clean(gold))
70
+
71
+
72
+ def pope(ex, rng, names):
73
+ yield _q("noul", ex["question"]), int(str(ex["answer"]).strip().lower() == "yes")
74
+
75
+
76
+ _DOC = ["What type of document is this scan?", "Classify the scanned document.", "Which kind of document is shown?"]
77
+
78
+ # name: (hf id, config, train split, test split, stream?, image column, adapter, max pixels, state keys)
79
+ IMAGE_TRAIN_SOURCES = {
80
+ "rvl_cdip": ("chainyo/rvl-cdip", None, "train", "test", True, "image", classify("label", _DOC, [6, 16, 16], "This is {a}."), 640 * 640, ["document", "scan", "page"]),
81
+ "imagenet100": ("clane9/imagenet-100", None, "train", "validation", True, "image", classify("label", ["What does the image show?", "What is the main subject of the photo?"], [8, 16, 32, 100]), 448 * 448, ["image", "photo"]),
82
+ "cifar100": ("uoft-cs/cifar100", "cifar100", "train", "test", False, "img", cifar100, 256 * 256, ["image", "picture"]),
83
+ "stanford_cars": ("tanganke/stanford_cars", None, "train", "test", False, "image", classify("label", ["Which car model is this?", "Identify the make, model and year of the car."], [8, 16, 32, 64], "The car is {a}."), 512 * 512, ["photo", "image"]),
84
+ "cub_birds": ("bentrevett/caltech-ucsd-birds-200-2011", None, "train", "test", False, "image", classify("label", ["Which bird species is this?"], [8, 16, 32, 64], "The bird is {a}."), 512 * 512, ["photo", "image"]),
85
+ "aircraft": ("Multimodal-Fatima/FGVC_Aircraft_train", None, "train", None, False, "image", aircraft, 512 * 512, ["photo", "image"]),
86
+ "dtd": ("tanganke/dtd", None, "train", "test", False, "image", classify("label", ["Which texture does the surface have?"], [8, 16, 47], "The texture is {x}."), 448 * 448, ["image", "surface"]),
87
+ "eurosat": ("tanganke/eurosat", None, "train", "test", False, "image", classify("label", ["What kind of land use does this satellite image show?"], [5, 10], "The satellite image shows {x}."), 256 * 256, ["satellite_image", "image"]),
88
+ "gtsrb": ("tanganke/gtsrb", None, "train", "test", False, "image", classify("label", ["Which traffic sign is this?"], [8, 16, 43], None), 256 * 256, ["image", "sign"]),
89
+ "resisc45": ("tanganke/resisc45", None, "train", "test", False, "image", classify("label", ["What does this aerial image show?"], [8, 16, 45], "The aerial image shows {a}."), 384 * 384, ["aerial_image", "image"]),
90
+ "sun397": ("tanganke/sun397", None, "train", "test", True, "image", classify("label", ["What kind of place is this?", "Which scene category fits the photo?"], [8, 16, 32, 64], "The photo was taken in {a}."), 448 * 448, ["photo", "image"]),
91
+ "fashion": ("ashraq/fashion-product-images-small", None, "train", None, False, "image", fashion, 256 * 256, ["product_photo", "image"]),
92
+ } # fmt: skip
93
+ IMAGE_HELDOUT_SOURCES = {
94
+ "pets": ("timm/oxford-iiit-pet", None, None, "test", True, "image", classify("label", ["Which breed is the animal in the photo?"], [37], None), 448 * 448, ["photo"]),
95
+ "food101": ("ethz/food101", None, None, "validation", True, "image", classify("label", ["Which dish is shown in the photo?"], [101], None), 448 * 448, ["photo"]),
96
+ "cifar10": ("uoft-cs/cifar10", None, None, "test", False, "img", classify("label", ["What does the image show?"], [10], None), 256 * 256, ["image"]),
97
+ "mnist": ("ylecun/mnist", None, None, "test", False, "image", classify("label", ["Which digit is written in the image?"], [10], None), 256 * 256, ["image"]),
98
+ "pope": ("lmms-lab/POPE", "Full", None, "random", True, "image", pope, 448 * 448, ["image"]),
99
+ } # fmt: skip
zico/losses.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training objectives: strictly proper scoring rules.
2
+
3
+ Our reading of "Reinforcement Learning for Calibrated Decisions": the reward is a strictly
4
+ proper scoring rule S(p, y) of the predicted distribution p against the realised outcome y.
5
+ A proper rule is maximised in expectation only by the true conditional distribution, so
6
+ optimising it targets calibration and accuracy together.
7
+
8
+ * Full feedback (the label y is observed). The model outputs the whole distribution, so
9
+ the expected reward and its gradient are available in closed form -- no sampling, no
10
+ policy-gradient variance. With the log score this *is* cross-entropy; Brier and the
11
+ ranked probability score (ordinal, for Score questions) are the other two we use.
12
+ Soft targets (annotator fractions, teacher vote shares) slot in unchanged.
13
+
14
+ * Bandit feedback (the model commits to an answer and only learns "right" / "wrong",
15
+ e.g. a verifier, a downstream outcome, a game). Then RL is actually needed. We use an
16
+ importance-weighted estimator that is unbiased for the same proper score:
17
+ a ~ mu, c = 1[a == y] E[ c * log p_a / mu_a ] = log p_y
18
+ E[ sum p^2 - 2 c p_a / mu_a ] = Brier(p, y) - 1
19
+ so the calibrated distribution remains the optimum although y is never revealed.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import torch
24
+ import torch.nn.functional as F
25
+
26
+ from .schema import QTYPE_INDEX
27
+
28
+
29
+ def log_probs(logits: torch.Tensor) -> torch.Tensor:
30
+ """Masked options already carry a large negative logit (model.NEG)."""
31
+ return F.log_softmax(logits.float(), dim=-1)
32
+
33
+
34
+ def proper_score_loss(logits, opt_mask, target, qtype, brier_weight=0.0, rps_weight=0.0):
35
+ """Per-row loss. `target` is one-hot or a soft distribution over the row's options."""
36
+ logp = log_probs(logits)
37
+ p = logp.exp()
38
+ mask = opt_mask.float()
39
+ loss = -(target * logp * mask).sum(-1)
40
+ if brier_weight:
41
+ loss = loss + brier_weight * (((p - target) ** 2) * mask).sum(-1)
42
+ if rps_weight:
43
+ is_score = (qtype == QTYPE_INDEX["score"]).float()
44
+ k = mask.sum(-1).clamp(min=2)
45
+ cdf_gap = (torch.cumsum(p * mask, -1) - torch.cumsum(target * mask, -1)) ** 2
46
+ loss = loss + rps_weight * is_score * (cdf_gap * mask).sum(-1) / (k - 1)
47
+ return loss
48
+
49
+
50
+ def bandit_proper_score_loss(logits, opt_mask, label, eps=0.1, brier_weight=0.0, clip=50.0, generator=None):
51
+ """Proper-score objective from correctness-only feedback (see module docstring).
52
+
53
+ Returns (per-row loss, per-row correctness of the sampled action).
54
+ """
55
+ logp = log_probs(logits)
56
+ p = logp.exp()
57
+ mask = opt_mask.float()
58
+ uniform = mask / mask.sum(-1, keepdim=True)
59
+ mu = (1 - eps) * p.detach() * mask + eps * uniform
60
+ mu = mu / mu.sum(-1, keepdim=True)
61
+ a = torch.multinomial(mu, 1, generator=generator)
62
+ correct = (a.squeeze(-1) == label).float()
63
+ w = (correct / mu.gather(1, a).squeeze(-1)).clamp(max=clip) # clipping trades a little bias for variance
64
+ loss = -w * logp.gather(1, a).squeeze(-1)
65
+ if brier_weight:
66
+ loss = loss + brier_weight * (((p**2) * mask).sum(-1) - 2 * w * p.gather(1, a).squeeze(-1))
67
+ return loss, correct