discern-vl 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Samuel Otero Agraso
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TheoLeeCJ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,271 @@
1
+ Metadata-Version: 2.4
2
+ Name: discern-vl
3
+ Version: 0.1.0
4
+ Summary: Ask a vision model a question, get the answer from its logits in one forward pass
5
+ Author-email: Samuel Otero Agraso <oteroagrasosamuel@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/XxSamaxX/discern
8
+ Project-URL: Paper, https://github.com/XxSamaxX/discern/blob/main/paper/paper.pdf
9
+ Project-URL: Issues, https://github.com/XxSamaxX/discern/issues
10
+ Keywords: vision-language-model,zero-shot-classification,logits,open-vocabulary,llm-inference,qwen
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ License-File: LICENSE.upstream
20
+ Requires-Dist: torch>=2.4
21
+ Requires-Dist: torchvision>=0.19
22
+ Requires-Dist: transformers>=5.0
23
+ Requires-Dist: accelerate>=1.0
24
+ Requires-Dist: safetensors>=0.4
25
+ Requires-Dist: huggingface-hub>=0.26
26
+ Requires-Dist: numpy>=1.24
27
+ Requires-Dist: pillow>=10.0
28
+ Provides-Extra: bench
29
+ Requires-Dist: datasets>=3.0; extra == "bench"
30
+ Requires-Dist: matplotlib>=3.8; extra == "bench"
31
+ Dynamic: license-file
32
+
33
+ # discern
34
+
35
+ [![PyPI](https://img.shields.io/pypi/v/discern-vl)](https://pypi.org/project/discern-vl/)
36
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22899311.svg)](https://doi.org/10.5281/zenodo.22899311)
37
+ [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
38
+
39
+ ![What it does](docs/hero.png)
40
+
41
+ ```bash
42
+ pip install discern-vl
43
+ ```
44
+
45
+ Ask a vision model a question. Get the answer out of its logits in one forward
46
+ pass — no decoding, nothing trained, no fixed label set.
47
+
48
+ ```python
49
+ from discern import discern
50
+
51
+ if discern("photo.jpg", "is there an identifiable person?"):
52
+ blur_faces()
53
+
54
+ v = discern("photo.jpg", "what is the camera viewpoint?",
55
+ ["from above", "at eye level", "from below"])
56
+ print(v) # at eye level [16.8 nats]
57
+ ```
58
+
59
+ A predicate a detector cannot express — *"is this photo posed?"*, *"is the
60
+ white balance wrong?"*, *"is the meal already in progress?"* — costs one
61
+ prefill and about 90 ms.
62
+
63
+ **The confidence you get back is the logit gap in nats, not a probability** —
64
+ and the reason is not the one you would guess. The probability these systems
65
+ report is a *monotone transform of the gap*: on binary questions it is exactly
66
+ `p = sigmoid(gap)`, and both predict correctness equally well (AUC 0.895 vs
67
+ 0.894 over 4,329 MMBench items). They are one signal.
68
+
69
+ What separates them is **float32**, the dtype your pipeline almost certainly
70
+ uses. Past **17.33 nats** — exactly `-ln(2**-25)` — the probability is no longer
71
+ representable in it:
72
+
73
+ | logit gap | probability | float32 |
74
+ |---|---|---|
75
+ | 12 nats | 0.99999386 | 0.99999386 |
76
+ | 20 nats | 0.999999998 | **exactly 1.0** |
77
+ | 25 nats | 0.99999999999 | **exactly 1.0** |
78
+
79
+ That is **62% of MMBench items** collapsed onto one value. In float16 the
80
+ threshold falls to 8.32 nats and it is 81%.
81
+ Within that collapsed set the gap still separates right from wrong at AUC
82
+ 0.736. Same information, one scale that survives being stored.
83
+
84
+ So `discern` refuses to be used as a condition when the gap is too small:
85
+
86
+ ```python
87
+ v = discern("blurry.jpg", "is the person wearing a helmet?")
88
+ bool(v) # raises Uncertain: 1.2 nat gap, below the trust threshold
89
+ v.answer # 'yes' — still there if you want it anyway
90
+ v.trusted # False
91
+ ```
92
+
93
+ Below the threshold it also rotates the options and re-scores before answering,
94
+ because rotation changes the winner on 11.7% of items and those have a median
95
+ gap of 2.7 nats.
96
+
97
+ Several questions about one image:
98
+
99
+ ```python
100
+ from discern import multi
101
+
102
+ for v in multi("photo.jpg", [
103
+ "is there an identifiable person?",
104
+ "is the white balance wrong?",
105
+ ("what is the camera viewpoint?", ["above", "eye level", "below"]),
106
+ ]):
107
+ print(v, v.probabilities)
108
+ ```
109
+
110
+ Built on [SemIf](https://github.com/TheoLeeCJ/SemIf) and the
111
+ [JEV-CPU](https://huggingface.co/Meanblock/JEV-CPU) port. The scoring engine is
112
+ vendored unchanged (MIT) so image results stay comparable to the published text
113
+ ones; see [THIRD_PARTY.md](THIRD_PARTY.md).
114
+
115
+ ## Findings
116
+
117
+ **It works, and it is fast.** On **MMBench en/dev (4,329 items)** Qwen3-VL-4B
118
+ scores **87.4%** with one forward pass per item at **91 ms/item**, and **82.2%**
119
+ under MMBench's own CircularEval protocol. On our own 22-item probe it answers
120
+ 22/22, including counting, absence, a false-premise question, an unanswerable
121
+ question, and reading an Adidas mark spanning ~20×15 px.
122
+
123
+ **The probability and the gap are the same signal; only one survives float32.**
124
+ *(the bars in the figure above are the gap)* We expected the gap to
125
+ out-predict the reported probability. It does not — both reach AUC ~0.894 on
126
+ MMBench, and requiring `p >= 0.99` retains 286 of 547 errors while its exact
127
+ equivalent, `gap >= 4.60 nats`, retains 288. The real difference is that 62% of
128
+ items share the single float32 value 1.0, where the gap still ranks them at AUC
129
+ 0.736. **Threshold in nats**, not because it discriminates better but because
130
+ it is the only one you can still read afterwards. It buys a real operating
131
+ point:
132
+
133
+ | gap threshold | you answer | accuracy |
134
+ |---|---|---|
135
+ | none | 100% | 87.4% |
136
+ | ≥ 12 nats | 75% | 96.8% |
137
+ | ≥ 16 nats | 66% | **98.4%** |
138
+ | ≥ 20 nats | 55% | 99.1% |
139
+
140
+ **Position bias is 1–2 nats, and the gap tells you exactly when it matters.**
141
+ Rotating the options changes the winner on **11.7% of MMBench items**. Those
142
+ items have a **median gap of 2.7 nats**; the stable ones, 22.8. The gap predicts
143
+ order-stability with **AUC 0.980**. So: rotate and average only below ~3 nats —
144
+ a rule we wrote from a 22-item probe before downloading the dataset, and which
145
+ lands on the empirical median of 4,329 items.
146
+
147
+ **The gap will not protect you from questions the model cannot answer.** On 12
148
+ normative questions (consent, retention, licensing — policy no model can
149
+ ground) the gap distribution shifts but overlaps badly: AUC 0.788, *p* = 0.006,
150
+ and the best possible threshold still admits **7 of 12**. The model claims a
151
+ photo needs legal consent at 15.6 nats, about as confidently as it reports
152
+ there is no dog in the picture at 26.4. **Use semantic ifs for perception;
153
+ keep policy in code.**
154
+
155
+ **A semantic if is an `if`, not a `switch`.** Our first cascade asked one
156
+ four-option policy question whose options were not mutually exclusive, and the
157
+ model oscillated between two *correct* answers. Independent binary predicates
158
+ composed in Python fixed it.
159
+
160
+ **The gap signal weakens as options shrink.** On POPE (9,000 binary
161
+ yes/no questions about object presence) the same readout scores **89.6%**
162
+ (F1 89.0), but the gap only reaches **AUC 0.777** against MMBench's 0.894 (and so does the
163
+ probability — they stay equivalent). With two slots
164
+ the model is *confidently* wrong: its median gap when wrong is 16.4 nats, against
165
+ 5.2 on MMBench. **Ask three or more options where you can**, and demand a much
166
+ higher threshold on binary questions.
167
+
168
+ POPE also shows where object hallucination actually hides. This model does not
169
+ over-assert presence — its yes-rate is 42.7–45.6%, *below* the 50% base rate, so
170
+ the usual headline metric says it is clean. But precision falls 98.2% → 94.0% →
171
+ 92.0% from the random to the popular to the adversarial split while recall stays
172
+ at exactly 83.9% throughout. The co-occurring distractors do pull it into false
173
+ positives; the aggregate yes-rate just hides it.
174
+
175
+ **Where it breaks.** The worst MMBench categories are `spatial_relationship`
176
+ (34.5% under CircularEval, near chance) and `image_quality` (57.3%). Our 22-item
177
+ probe had flagged exactly these two: its lowest gaps were the spatial-relation
178
+ item (8.1 nats) and the white-balance item (7.8). The confidence signal
179
+ identified the weak categories from a handful of examples.
180
+
181
+ **An optimisation that does not work.** Questions about one image share a
182
+ prefix — the system turn plus ~300 visual tokens — so caching it and running
183
+ only each question's tail looks free. It is numerically exact (max probability
184
+ difference 0.0) and **13–14% slower**, flat across 384, 852 and 1,812-token
185
+ images, with no crossover: the cached path loses the fast attention kernel and
186
+ that costs more than recomputing the prefix. Image preprocessing is only 3% of
187
+ the time; the forward is 97%. Kept behind `use_cache=True` and a
188
+ `verify_cache()` checker in case flash-attn flips the balance.
189
+
190
+ **Systems note.** On a hybrid Intel CPU (i9-12900F, 8P+8E), restricting
191
+ inference to the 8 P-cores is **1.9× faster** than using 20 threads —
192
+ scheduling onto E-cores actively hurts. Overlapping a GPU vision stage with a
193
+ CPU text stage in two threads saved 22% wall clock.
194
+
195
+ Full write-up with method and statistics: [`paper/paper.pdf`](paper/paper.pdf).
196
+
197
+ ## Layout
198
+
199
+ ```
200
+ src/discern/__init__.py the API: discern(image, question) -> Verdict
201
+ src/discern/_batch.py multi(): several questions, one image
202
+ src/discern/_readout.py the vision port; readout identical to upstream
203
+ scripts/eval_probe.py runs the hand-built 22-item probe
204
+ src/discern/_semif/ vendored upstream engine (MIT)
205
+ scripts/cascade.py GPU predicates -> CPU policy, threaded pipeline
206
+ scripts/bench_cpu4b.py CPU thread sweep and policy benchmark
207
+ scripts/eval_mmbench.py MMBench harness (resumable JSONL)
208
+ scripts/analiza_mmbench.py accuracy, AUC, abstention curves
209
+ scripts/eval_pope.py POPE harness (9,000 binary questions)
210
+ scripts/analiza_pope.py F1, yes-rate, precision/recall by split
211
+ scripts/exp_gap_por_tipo.py the perceptual-vs-normative experiment
212
+ scripts/figuras.py paper figures
213
+ data/ benchmark decisions + 4 COCO val2017 images
214
+ results/ raw output of every run reported
215
+ paper/ LaTeX sources and compiled PDF
216
+ ```
217
+
218
+ ## Running it
219
+
220
+ The library is `pip install discern-vl`. To reproduce the measurements you want
221
+ the repo and the benchmark extras:
222
+
223
+ ```bash
224
+ git clone https://github.com/XxSamaxX/discern && cd discern
225
+ uv venv --python 3.11 .venv
226
+ uv pip install --python .venv/bin/python -e ".[bench]"
227
+
228
+ .venv/bin/python scripts/eval_probe.py --rows data/vl_rows_hard.json --calibrate
229
+ .venv/bin/python scripts/exp_gap_por_tipo.py
230
+ .venv/bin/python scripts/bench_cpu4b.py # needs ~21 GiB RAM
231
+
232
+ # MMBench: ~7 min vanilla, ~25 min CircularEval
233
+ .venv/bin/python scripts/eval_mmbench.py
234
+ .venv/bin/python scripts/eval_mmbench.py --circular
235
+ .venv/bin/python scripts/analiza_mmbench.py results/mmbench_*_4329.jsonl
236
+
237
+ # POPE: ~34 min for 9,000 binary questions
238
+ .venv/bin/python scripts/eval_pope.py
239
+ .venv/bin/python scripts/analiza_pope.py
240
+ ```
241
+
242
+ First run downloads Qwen3-VL-4B-Instruct (~8 GB).
243
+
244
+ ## Caveats
245
+
246
+ The MMBench numbers are one model on one split, single run, with one prompt
247
+ template; no confidence intervals on latency. MMBench dev is public and may
248
+ sit in the model's training data, so 87.4% is not evidence about generalisation
249
+ — the claims that matter here are about the *confidence signal*, which is
250
+ measured within the same run and does not depend on the absolute score.
251
+
252
+ Our own 22-item probe is small and author-annotated; one label was wrong (we
253
+ called a logo illegible, the model read it correctly) and was corrected. Its
254
+ 22/22 never located the model's failure boundary, which is why the MMBench run
255
+ exists.
256
+
257
+ ## Citing
258
+
259
+ ```bibtex
260
+ @software{oteroagraso2026discern,
261
+ author = {Otero Agraso, Samuel},
262
+ title = {discern: semantic ifs over images from vision-language logits},
263
+ year = {2026},
264
+ doi = {10.5281/zenodo.22899311},
265
+ url = {https://github.com/XxSamaxX/discern}
266
+ }
267
+ ```
268
+
269
+ ## Licence
270
+
271
+ MIT, matching upstream. See [LICENSE](LICENSE) and [THIRD_PARTY.md](THIRD_PARTY.md).
@@ -0,0 +1,239 @@
1
+ # discern
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/discern-vl)](https://pypi.org/project/discern-vl/)
4
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22899311.svg)](https://doi.org/10.5281/zenodo.22899311)
5
+ [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
6
+
7
+ ![What it does](docs/hero.png)
8
+
9
+ ```bash
10
+ pip install discern-vl
11
+ ```
12
+
13
+ Ask a vision model a question. Get the answer out of its logits in one forward
14
+ pass — no decoding, nothing trained, no fixed label set.
15
+
16
+ ```python
17
+ from discern import discern
18
+
19
+ if discern("photo.jpg", "is there an identifiable person?"):
20
+ blur_faces()
21
+
22
+ v = discern("photo.jpg", "what is the camera viewpoint?",
23
+ ["from above", "at eye level", "from below"])
24
+ print(v) # at eye level [16.8 nats]
25
+ ```
26
+
27
+ A predicate a detector cannot express — *"is this photo posed?"*, *"is the
28
+ white balance wrong?"*, *"is the meal already in progress?"* — costs one
29
+ prefill and about 90 ms.
30
+
31
+ **The confidence you get back is the logit gap in nats, not a probability** —
32
+ and the reason is not the one you would guess. The probability these systems
33
+ report is a *monotone transform of the gap*: on binary questions it is exactly
34
+ `p = sigmoid(gap)`, and both predict correctness equally well (AUC 0.895 vs
35
+ 0.894 over 4,329 MMBench items). They are one signal.
36
+
37
+ What separates them is **float32**, the dtype your pipeline almost certainly
38
+ uses. Past **17.33 nats** — exactly `-ln(2**-25)` — the probability is no longer
39
+ representable in it:
40
+
41
+ | logit gap | probability | float32 |
42
+ |---|---|---|
43
+ | 12 nats | 0.99999386 | 0.99999386 |
44
+ | 20 nats | 0.999999998 | **exactly 1.0** |
45
+ | 25 nats | 0.99999999999 | **exactly 1.0** |
46
+
47
+ That is **62% of MMBench items** collapsed onto one value. In float16 the
48
+ threshold falls to 8.32 nats and it is 81%.
49
+ Within that collapsed set the gap still separates right from wrong at AUC
50
+ 0.736. Same information, one scale that survives being stored.
51
+
52
+ So `discern` refuses to be used as a condition when the gap is too small:
53
+
54
+ ```python
55
+ v = discern("blurry.jpg", "is the person wearing a helmet?")
56
+ bool(v) # raises Uncertain: 1.2 nat gap, below the trust threshold
57
+ v.answer # 'yes' — still there if you want it anyway
58
+ v.trusted # False
59
+ ```
60
+
61
+ Below the threshold it also rotates the options and re-scores before answering,
62
+ because rotation changes the winner on 11.7% of items and those have a median
63
+ gap of 2.7 nats.
64
+
65
+ Several questions about one image:
66
+
67
+ ```python
68
+ from discern import multi
69
+
70
+ for v in multi("photo.jpg", [
71
+ "is there an identifiable person?",
72
+ "is the white balance wrong?",
73
+ ("what is the camera viewpoint?", ["above", "eye level", "below"]),
74
+ ]):
75
+ print(v, v.probabilities)
76
+ ```
77
+
78
+ Built on [SemIf](https://github.com/TheoLeeCJ/SemIf) and the
79
+ [JEV-CPU](https://huggingface.co/Meanblock/JEV-CPU) port. The scoring engine is
80
+ vendored unchanged (MIT) so image results stay comparable to the published text
81
+ ones; see [THIRD_PARTY.md](THIRD_PARTY.md).
82
+
83
+ ## Findings
84
+
85
+ **It works, and it is fast.** On **MMBench en/dev (4,329 items)** Qwen3-VL-4B
86
+ scores **87.4%** with one forward pass per item at **91 ms/item**, and **82.2%**
87
+ under MMBench's own CircularEval protocol. On our own 22-item probe it answers
88
+ 22/22, including counting, absence, a false-premise question, an unanswerable
89
+ question, and reading an Adidas mark spanning ~20×15 px.
90
+
91
+ **The probability and the gap are the same signal; only one survives float32.**
92
+ *(the bars in the figure above are the gap)* We expected the gap to
93
+ out-predict the reported probability. It does not — both reach AUC ~0.894 on
94
+ MMBench, and requiring `p >= 0.99` retains 286 of 547 errors while its exact
95
+ equivalent, `gap >= 4.60 nats`, retains 288. The real difference is that 62% of
96
+ items share the single float32 value 1.0, where the gap still ranks them at AUC
97
+ 0.736. **Threshold in nats**, not because it discriminates better but because
98
+ it is the only one you can still read afterwards. It buys a real operating
99
+ point:
100
+
101
+ | gap threshold | you answer | accuracy |
102
+ |---|---|---|
103
+ | none | 100% | 87.4% |
104
+ | ≥ 12 nats | 75% | 96.8% |
105
+ | ≥ 16 nats | 66% | **98.4%** |
106
+ | ≥ 20 nats | 55% | 99.1% |
107
+
108
+ **Position bias is 1–2 nats, and the gap tells you exactly when it matters.**
109
+ Rotating the options changes the winner on **11.7% of MMBench items**. Those
110
+ items have a **median gap of 2.7 nats**; the stable ones, 22.8. The gap predicts
111
+ order-stability with **AUC 0.980**. So: rotate and average only below ~3 nats —
112
+ a rule we wrote from a 22-item probe before downloading the dataset, and which
113
+ lands on the empirical median of 4,329 items.
114
+
115
+ **The gap will not protect you from questions the model cannot answer.** On 12
116
+ normative questions (consent, retention, licensing — policy no model can
117
+ ground) the gap distribution shifts but overlaps badly: AUC 0.788, *p* = 0.006,
118
+ and the best possible threshold still admits **7 of 12**. The model claims a
119
+ photo needs legal consent at 15.6 nats, about as confidently as it reports
120
+ there is no dog in the picture at 26.4. **Use semantic ifs for perception;
121
+ keep policy in code.**
122
+
123
+ **A semantic if is an `if`, not a `switch`.** Our first cascade asked one
124
+ four-option policy question whose options were not mutually exclusive, and the
125
+ model oscillated between two *correct* answers. Independent binary predicates
126
+ composed in Python fixed it.
127
+
128
+ **The gap signal weakens as options shrink.** On POPE (9,000 binary
129
+ yes/no questions about object presence) the same readout scores **89.6%**
130
+ (F1 89.0), but the gap only reaches **AUC 0.777** against MMBench's 0.894 (and so does the
131
+ probability — they stay equivalent). With two slots
132
+ the model is *confidently* wrong: its median gap when wrong is 16.4 nats, against
133
+ 5.2 on MMBench. **Ask three or more options where you can**, and demand a much
134
+ higher threshold on binary questions.
135
+
136
+ POPE also shows where object hallucination actually hides. This model does not
137
+ over-assert presence — its yes-rate is 42.7–45.6%, *below* the 50% base rate, so
138
+ the usual headline metric says it is clean. But precision falls 98.2% → 94.0% →
139
+ 92.0% from the random to the popular to the adversarial split while recall stays
140
+ at exactly 83.9% throughout. The co-occurring distractors do pull it into false
141
+ positives; the aggregate yes-rate just hides it.
142
+
143
+ **Where it breaks.** The worst MMBench categories are `spatial_relationship`
144
+ (34.5% under CircularEval, near chance) and `image_quality` (57.3%). Our 22-item
145
+ probe had flagged exactly these two: its lowest gaps were the spatial-relation
146
+ item (8.1 nats) and the white-balance item (7.8). The confidence signal
147
+ identified the weak categories from a handful of examples.
148
+
149
+ **An optimisation that does not work.** Questions about one image share a
150
+ prefix — the system turn plus ~300 visual tokens — so caching it and running
151
+ only each question's tail looks free. It is numerically exact (max probability
152
+ difference 0.0) and **13–14% slower**, flat across 384, 852 and 1,812-token
153
+ images, with no crossover: the cached path loses the fast attention kernel and
154
+ that costs more than recomputing the prefix. Image preprocessing is only 3% of
155
+ the time; the forward is 97%. Kept behind `use_cache=True` and a
156
+ `verify_cache()` checker in case flash-attn flips the balance.
157
+
158
+ **Systems note.** On a hybrid Intel CPU (i9-12900F, 8P+8E), restricting
159
+ inference to the 8 P-cores is **1.9× faster** than using 20 threads —
160
+ scheduling onto E-cores actively hurts. Overlapping a GPU vision stage with a
161
+ CPU text stage in two threads saved 22% wall clock.
162
+
163
+ Full write-up with method and statistics: [`paper/paper.pdf`](paper/paper.pdf).
164
+
165
+ ## Layout
166
+
167
+ ```
168
+ src/discern/__init__.py the API: discern(image, question) -> Verdict
169
+ src/discern/_batch.py multi(): several questions, one image
170
+ src/discern/_readout.py the vision port; readout identical to upstream
171
+ scripts/eval_probe.py runs the hand-built 22-item probe
172
+ src/discern/_semif/ vendored upstream engine (MIT)
173
+ scripts/cascade.py GPU predicates -> CPU policy, threaded pipeline
174
+ scripts/bench_cpu4b.py CPU thread sweep and policy benchmark
175
+ scripts/eval_mmbench.py MMBench harness (resumable JSONL)
176
+ scripts/analiza_mmbench.py accuracy, AUC, abstention curves
177
+ scripts/eval_pope.py POPE harness (9,000 binary questions)
178
+ scripts/analiza_pope.py F1, yes-rate, precision/recall by split
179
+ scripts/exp_gap_por_tipo.py the perceptual-vs-normative experiment
180
+ scripts/figuras.py paper figures
181
+ data/ benchmark decisions + 4 COCO val2017 images
182
+ results/ raw output of every run reported
183
+ paper/ LaTeX sources and compiled PDF
184
+ ```
185
+
186
+ ## Running it
187
+
188
+ The library is `pip install discern-vl`. To reproduce the measurements you want
189
+ the repo and the benchmark extras:
190
+
191
+ ```bash
192
+ git clone https://github.com/XxSamaxX/discern && cd discern
193
+ uv venv --python 3.11 .venv
194
+ uv pip install --python .venv/bin/python -e ".[bench]"
195
+
196
+ .venv/bin/python scripts/eval_probe.py --rows data/vl_rows_hard.json --calibrate
197
+ .venv/bin/python scripts/exp_gap_por_tipo.py
198
+ .venv/bin/python scripts/bench_cpu4b.py # needs ~21 GiB RAM
199
+
200
+ # MMBench: ~7 min vanilla, ~25 min CircularEval
201
+ .venv/bin/python scripts/eval_mmbench.py
202
+ .venv/bin/python scripts/eval_mmbench.py --circular
203
+ .venv/bin/python scripts/analiza_mmbench.py results/mmbench_*_4329.jsonl
204
+
205
+ # POPE: ~34 min for 9,000 binary questions
206
+ .venv/bin/python scripts/eval_pope.py
207
+ .venv/bin/python scripts/analiza_pope.py
208
+ ```
209
+
210
+ First run downloads Qwen3-VL-4B-Instruct (~8 GB).
211
+
212
+ ## Caveats
213
+
214
+ The MMBench numbers are one model on one split, single run, with one prompt
215
+ template; no confidence intervals on latency. MMBench dev is public and may
216
+ sit in the model's training data, so 87.4% is not evidence about generalisation
217
+ — the claims that matter here are about the *confidence signal*, which is
218
+ measured within the same run and does not depend on the absolute score.
219
+
220
+ Our own 22-item probe is small and author-annotated; one label was wrong (we
221
+ called a logo illegible, the model read it correctly) and was corrected. Its
222
+ 22/22 never located the model's failure boundary, which is why the MMBench run
223
+ exists.
224
+
225
+ ## Citing
226
+
227
+ ```bibtex
228
+ @software{oteroagraso2026discern,
229
+ author = {Otero Agraso, Samuel},
230
+ title = {discern: semantic ifs over images from vision-language logits},
231
+ year = {2026},
232
+ doi = {10.5281/zenodo.22899311},
233
+ url = {https://github.com/XxSamaxX/discern}
234
+ }
235
+ ```
236
+
237
+ ## Licence
238
+
239
+ MIT, matching upstream. See [LICENSE](LICENSE) and [THIRD_PARTY.md](THIRD_PARTY.md).
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "discern-vl"
7
+ version = "0.1.0"
8
+ description = "Ask a vision model a question, get the answer from its logits in one forward pass"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE", "LICENSE.upstream"]
13
+ authors = [{name = "Samuel Otero Agraso", email = "oteroagrasosamuel@gmail.com"}]
14
+ keywords = ["vision-language-model", "zero-shot-classification", "logits",
15
+ "open-vocabulary", "llm-inference", "qwen"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Science/Research",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
21
+ "Topic :: Scientific/Engineering :: Image Recognition",
22
+ ]
23
+ dependencies = [
24
+ "torch>=2.4",
25
+ "torchvision>=0.19", # el procesador de video de Qwen3-VL lo exige
26
+ "transformers>=5.0",
27
+ "accelerate>=1.0",
28
+ "safetensors>=0.4",
29
+ "huggingface-hub>=0.26",
30
+ "numpy>=1.24",
31
+ "pillow>=10.0",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ bench = ["datasets>=3.0", "matplotlib>=3.8"]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/XxSamaxX/discern"
39
+ Paper = "https://github.com/XxSamaxX/discern/blob/main/paper/paper.pdf"
40
+ Issues = "https://github.com/XxSamaxX/discern/issues"
41
+
42
+ [project.scripts]
43
+ discern = "discern.__main__:main"
44
+
45
+ [tool.setuptools]
46
+ package-dir = {"" = "src"}
47
+
48
+ [tool.setuptools.packages.find]
49
+ where = ["src"]
50
+ include = ["discern*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+