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.
- discern_vl-0.1.0/LICENSE +21 -0
- discern_vl-0.1.0/LICENSE.upstream +21 -0
- discern_vl-0.1.0/PKG-INFO +271 -0
- discern_vl-0.1.0/README.md +239 -0
- discern_vl-0.1.0/pyproject.toml +50 -0
- discern_vl-0.1.0/setup.cfg +4 -0
- discern_vl-0.1.0/src/discern/__init__.py +145 -0
- discern_vl-0.1.0/src/discern/__main__.py +26 -0
- discern_vl-0.1.0/src/discern/_batch.py +156 -0
- discern_vl-0.1.0/src/discern/_readout.py +182 -0
- discern_vl-0.1.0/src/discern/_semif/__init__.py +3 -0
- discern_vl-0.1.0/src/discern/_semif/core.py +112 -0
- discern_vl-0.1.0/src/discern/_semif/direct.py +79 -0
- discern_vl-0.1.0/src/discern_vl.egg-info/PKG-INFO +271 -0
- discern_vl-0.1.0/src/discern_vl.egg-info/SOURCES.txt +17 -0
- discern_vl-0.1.0/src/discern_vl.egg-info/dependency_links.txt +1 -0
- discern_vl-0.1.0/src/discern_vl.egg-info/entry_points.txt +2 -0
- discern_vl-0.1.0/src/discern_vl.egg-info/requires.txt +12 -0
- discern_vl-0.1.0/src/discern_vl.egg-info/top_level.txt +1 -0
discern_vl-0.1.0/LICENSE
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/discern-vl/)
|
|
36
|
+
[](https://doi.org/10.5281/zenodo.22899311)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
|
|
39
|
+

|
|
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
|
+
[](https://pypi.org/project/discern-vl/)
|
|
4
|
+
[](https://doi.org/10.5281/zenodo.22899311)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+

|
|
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*"]
|