logit-classifier 0.2.0__tar.gz → 0.3.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.
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/PKG-INFO +92 -15
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/README.md +90 -13
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/examples/compare_models.py +2 -1
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/examples/images.py +2 -1
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/examples/question_types.py +2 -1
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/examples/quickstart.py +2 -1
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/pyproject.toml +3 -3
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/_version.py +1 -1
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/backends/comfy_clip.py +25 -1
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/classifier.py +26 -3
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/config.py +3 -3
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/prompt.py +8 -2
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/schema.py +14 -2
- logit_classifier-0.3.0/src/logit_classifier/tags.py +36 -0
- logit_classifier-0.3.0/src/logit_classifier/toolkit/__init__.py +5 -0
- logit_classifier-0.3.0/src/logit_classifier/toolkit/comfyui/__init__.py +50 -0
- logit_classifier-0.3.0/src/logit_classifier/toolkit/comfyui/classifier.py +39 -0
- logit_classifier-0.3.0/src/logit_classifier/toolkit/comfyui/generate.py +146 -0
- logit_classifier-0.3.0/src/logit_classifier/toolkit/comfyui/progress.py +86 -0
- logit_classifier-0.3.0/src/logit_classifier/toolkit/comfyui/scopes.py +77 -0
- logit_classifier-0.3.0/src/logit_classifier/toolkit/comfyui/tagger.py +390 -0
- logit_classifier-0.3.0/src/logit_classifier/toolkit/tags.py +372 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/vision.py +34 -11
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/web/index.html +2 -2
- logit_classifier-0.3.0/tests/test_toolkit.py +1445 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests/test_unit.py +454 -177
- logit_classifier-0.3.0/tests-AB/comfy_env.py +167 -0
- logit_classifier-0.2.0/src/logit_classifier/tags.py +0 -121
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/.gitignore +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/FINDINGS.md +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/LICENSE +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/examples/http_client.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/examples/own_backend.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/__init__.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/__main__.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/backends/__init__.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/backends/_torch_window.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/backends/base.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/backends/hf.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/calibrate.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/cli.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/deps.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/errors.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/labels.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/py.typed +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/scoring.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/service.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests/fixtures/banking77_test.json +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests/fixtures/eval_set.json +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests/fixtures/many_options_request.json +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests/fixtures/quickstart_request.json +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests/test_model.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/ab_branch_packing.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/ab_determinism_scope.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/ab_env.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/ab_math_sdp_reduction.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/ab_multi_label.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/ab_noul_wording.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/ab_temperature.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/banking77.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/benchmark.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/evaluate.py +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/inputs/_full.png +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/inputs/_sheet.png +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/inputs/low-L.png +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/inputs/low-R.png +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/inputs/mid-L.png +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/inputs/mid-R.png +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/inputs/top-L.png +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/inputs/top-R.png +0 -0
- {logit_classifier-0.2.0 → logit_classifier-0.3.0}/tests-AB/tune_groups.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: logit-classifier
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Local zero-shot classifier for text and images. Declares options
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Local zero-shot classifier for text and images. Declares options and reads a calibrated probability for each from the logits. Accepts the TypeSafe System One request format, and ships a toolkit of image tag tools and ComfyUI node tools.
|
|
5
5
|
Project-URL: Repository, https://github.com/Blakeem/logit-classifier
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/Blakeem/logit-classifier/issues
|
|
7
7
|
Author: Blake
|
|
@@ -88,8 +88,8 @@ beside your project instead, which is what the examples do.
|
|
|
88
88
|
Config(models_dir=Path("models"))
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
`LOGIT_MODELS_DIR` sets the same folder for
|
|
92
|
-
Hugging Face cache itself.
|
|
91
|
+
`LOGIT_MODELS_DIR` sets the same folder for the service and for `Config.from_env()`.
|
|
92
|
+
`HF_HOME` moves the Hugging Face cache itself.
|
|
93
93
|
|
|
94
94
|
## Models
|
|
95
95
|
|
|
@@ -98,8 +98,9 @@ Hugging Face cache itself.
|
|
|
98
98
|
| reads images | yes | no |
|
|
99
99
|
| better at | choice and score | noul |
|
|
100
100
|
|
|
101
|
-
`Qwen3-VL-4B-Instruct` is the default. Set `LOGIT_MODEL_ID` to use the other one
|
|
102
|
-
chat model loads, and a model with no
|
|
101
|
+
`Qwen3-VL-4B-Instruct` is the default. Set `LOGIT_MODEL_ID` to use the other one in the
|
|
102
|
+
service or through `Config.from_env()`. Any Qwen chat model loads, and a model with no
|
|
103
|
+
fitted temperature gets 2.5.
|
|
103
104
|
|
|
104
105
|
## Python API
|
|
105
106
|
|
|
@@ -123,6 +124,14 @@ print(response.to_dict())
|
|
|
123
124
|
`load_model` loads the weights through the `hf` extra and returns a `Backend`.
|
|
124
125
|
`Classifier(Config())` calls it for you when you pass no backend.
|
|
125
126
|
|
|
127
|
+
`classifier.nouls` asks a list of statements about one state and returns one probability
|
|
128
|
+
per statement. It sends more than 256 statements as several requests.
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
probabilities = classifier.nouls(["The message is urgent", "The message is polite"],
|
|
132
|
+
state="the payment failed again")
|
|
133
|
+
```
|
|
134
|
+
|
|
126
135
|
Loading each model yourself is what lets one script compare several. The fitted
|
|
127
136
|
temperature follows the backend, so every model is scored with its own value.
|
|
128
137
|
|
|
@@ -169,7 +178,7 @@ the model.
|
|
|
169
178
|
|
|
170
179
|
```json
|
|
171
180
|
{
|
|
172
|
-
"model": "logit-classifier-0.
|
|
181
|
+
"model": "logit-classifier-0.3.0",
|
|
173
182
|
"answers": {
|
|
174
183
|
"department": {
|
|
175
184
|
"type": "choice",
|
|
@@ -230,8 +239,9 @@ Each script runs on its own.
|
|
|
230
239
|
|
|
231
240
|
## Configuration
|
|
232
241
|
|
|
233
|
-
|
|
234
|
-
|
|
242
|
+
The service and `logit-classifier config` read these variables at startup through
|
|
243
|
+
`Config.from_env()`. Library code gets them by calling `Config.from_env()`, since `Config()`
|
|
244
|
+
reads none of them. `logit-classifier config` prints what they produce.
|
|
235
245
|
|
|
236
246
|
| Variable | Default | Effect |
|
|
237
247
|
|---|---|---|
|
|
@@ -242,6 +252,7 @@ they produce.
|
|
|
242
252
|
| `LOGIT_PRIOR_DEBIAS` | `1` | set to `0` to skip the label prior |
|
|
243
253
|
| `LOGIT_BATCH_BRANCHES` | `1` | set to `0` for one forward pass per branch |
|
|
244
254
|
| `LOGIT_SCORE_METHOD` | `joint` | set to `independent` to judge each level alone |
|
|
255
|
+
| `LOGIT_PERMUTATIONS` | `1` | letterings averaged per question, each one adds branches |
|
|
245
256
|
| `LOGIT_ABSTAIN` | `1` | set to `0` to drop the `none of these` label below 52 options |
|
|
246
257
|
| `LOGIT_CALIBRATION_PATH` | `calibration.json` | where the service stores the running prior |
|
|
247
258
|
|
|
@@ -269,10 +280,10 @@ Nothing is sampled, so the same request returns bitwise identical logits.
|
|
|
269
280
|
|
|
270
281
|
Batch composition and padding length both change the low bits of a bfloat16 forward pass,
|
|
271
282
|
and both are pure functions of the request. So the same question asked inside two
|
|
272
|
-
different requests can differ slightly.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
`batch_branches=False` as a keyword
|
|
283
|
+
different requests can differ slightly. Scoring each branch alone makes a question
|
|
284
|
+
independent of the questions sent with it and costs one forward pass per branch. The service
|
|
285
|
+
turns it on with `LOGIT_BATCH_BRANCHES=0`. Library code passes `Config(batch_branches=False)`,
|
|
286
|
+
and `ComfyClipBackend` takes `batch_branches=False` as a keyword.
|
|
276
287
|
|
|
277
288
|
A forward pass needs several process-global torch settings held at known values. Torch
|
|
278
289
|
exposes none of them as a call argument, so the backend sets them around each pass and
|
|
@@ -339,6 +350,72 @@ shape `[1, H, W, 3]`. An empty state asks about the image alone. Questions share
|
|
|
339
350
|
pass of up to 4,096 tokens, and the image counts toward that limit. Any text encoder other
|
|
340
351
|
than Qwen3-VL raises `UnsupportedModelError`.
|
|
341
352
|
|
|
353
|
+
## Toolkit
|
|
354
|
+
|
|
355
|
+
`logit_classifier.toolkit` holds tools built on the classifier with the settings our
|
|
356
|
+
measurements found best. A project imports them to get good results without working out the
|
|
357
|
+
wording, thresholds and host details itself. The toolkit uses the classifier's public API,
|
|
358
|
+
and the classifier never imports the toolkit.
|
|
359
|
+
|
|
360
|
+
| Package | Runs in | Holds |
|
|
361
|
+
|---|---|---|
|
|
362
|
+
| `logit_classifier.toolkit.tags` | any Python process | text tools for image tags and prompts |
|
|
363
|
+
| `logit_classifier.toolkit.comfyui` | a ComfyUI node | a classifier over the workflow's CLIP, text generation, a progress display and an image tagger |
|
|
364
|
+
|
|
365
|
+
### Tag Text Tools
|
|
366
|
+
|
|
367
|
+
| Tool | Does |
|
|
368
|
+
|---|---|
|
|
369
|
+
| `parse_candidates` | splits a model's tag reply into clean tags with no duplicates |
|
|
370
|
+
| `split_prompt` | splits an image prompt into its fragments |
|
|
371
|
+
| `drop_subsets` | drops a tag whose words all appear in a longer tag |
|
|
372
|
+
| `prompt_word_forms`, `in_prompt` | test whether a tag's words appear in a prompt, singular or plural |
|
|
373
|
+
| `merge_candidates` | merges proposed tags with a prompt's tags under a cap |
|
|
374
|
+
| `presence_question` | writes "Is there a car in this image?" with the article the thing takes |
|
|
375
|
+
|
|
376
|
+
```python
|
|
377
|
+
from logit_classifier.toolkit.tags import drop_subsets, parse_candidates
|
|
378
|
+
|
|
379
|
+
tags = drop_subsets(parse_candidates("red car, car, wooden bench, bench"))
|
|
380
|
+
# ['red car', 'wooden bench']
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
`drop_subsets(tags, rule="head-noun")` drops a tag only into a longer tag with the same head
|
|
384
|
+
noun. The head noun is the last word before "of", "in", "on", "with" or "reading". So "cat"
|
|
385
|
+
stays beside "cat ear", and "signage" is dropped beside "signage in chinese".
|
|
386
|
+
|
|
387
|
+
### ComfyUI Tools
|
|
388
|
+
|
|
389
|
+
These run inside a ComfyUI node over the CLIP the workflow loaded. Each one imports comfy and
|
|
390
|
+
torch only when it is called.
|
|
391
|
+
|
|
392
|
+
| Tool | Does |
|
|
393
|
+
|---|---|
|
|
394
|
+
| `comfy_classifier` | builds a classifier over the CLIP, with errors that name the node and the Load CLIP fix |
|
|
395
|
+
| `generate_text` | generates one greedy reply with the Qwen chat template, and stops early on a condition |
|
|
396
|
+
| `shared_vision_encode` | runs the vision tower once per picture for every generate and classify inside the block |
|
|
397
|
+
| `skip_resident_loads` | skips core's model load while the CLIP is already on the GPU |
|
|
398
|
+
| `routed_progress_bars`, `send_status`, `run_outcome` | show one progress bar per run and a status line under the node |
|
|
399
|
+
| `fit_picture` | downscales an image to a pixel cap |
|
|
400
|
+
| `prompt_tags` | lists the physical things a prompt names |
|
|
401
|
+
| `tag_picture` | tags a picture, with a prompt's things added as candidates |
|
|
402
|
+
| `TagSettings` | holds every wording, budget, cap and threshold of the tagger |
|
|
403
|
+
|
|
404
|
+
The tagger generates candidate tags over the CLIP and verifies each one with the classifier.
|
|
405
|
+
|
|
406
|
+
```python
|
|
407
|
+
from logit_classifier.toolkit.comfyui import TagSettings, comfy_classifier, fit_picture, tag_picture
|
|
408
|
+
|
|
409
|
+
classifier = comfy_classifier(clip, node="My Tagger")
|
|
410
|
+
picture = fit_picture(image, 1024 * 1024)
|
|
411
|
+
trace = tag_picture(clip, classifier, picture, settings=TagSettings(), known={})
|
|
412
|
+
tags = trace.kept
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
`known` holds the thing check's answers across a run, so each tag is asked once. The
|
|
416
|
+
`TagSettings` defaults are the current measured values, and a later release may change them.
|
|
417
|
+
Pass every value your output depends on to keep it fixed.
|
|
418
|
+
|
|
342
419
|
## Differences From Jev
|
|
343
420
|
|
|
344
421
|
This project matches the System One request and response format and the documented limits.
|
|
@@ -348,8 +425,8 @@ Jev is trained for calibrated probabilities. This project reads them from a gene
|
|
|
348
425
|
so the choices agree more often than the confidences do.
|
|
349
426
|
|
|
350
427
|
Jev judges a score level without its number or its neighbours. This project judges all
|
|
351
|
-
levels together by default.
|
|
352
|
-
|
|
428
|
+
levels together by default. The service follows the documented behavior with
|
|
429
|
+
`LOGIT_SCORE_METHOD=independent`, and library code with `Config(score_method="independent")`.
|
|
353
430
|
|
|
354
431
|
Jev publishes status codes but no error body. The error shape here is our own.
|
|
355
432
|
|
|
@@ -55,8 +55,8 @@ beside your project instead, which is what the examples do.
|
|
|
55
55
|
Config(models_dir=Path("models"))
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
`LOGIT_MODELS_DIR` sets the same folder for
|
|
59
|
-
Hugging Face cache itself.
|
|
58
|
+
`LOGIT_MODELS_DIR` sets the same folder for the service and for `Config.from_env()`.
|
|
59
|
+
`HF_HOME` moves the Hugging Face cache itself.
|
|
60
60
|
|
|
61
61
|
## Models
|
|
62
62
|
|
|
@@ -65,8 +65,9 @@ Hugging Face cache itself.
|
|
|
65
65
|
| reads images | yes | no |
|
|
66
66
|
| better at | choice and score | noul |
|
|
67
67
|
|
|
68
|
-
`Qwen3-VL-4B-Instruct` is the default. Set `LOGIT_MODEL_ID` to use the other one
|
|
69
|
-
chat model loads, and a model with no
|
|
68
|
+
`Qwen3-VL-4B-Instruct` is the default. Set `LOGIT_MODEL_ID` to use the other one in the
|
|
69
|
+
service or through `Config.from_env()`. Any Qwen chat model loads, and a model with no
|
|
70
|
+
fitted temperature gets 2.5.
|
|
70
71
|
|
|
71
72
|
## Python API
|
|
72
73
|
|
|
@@ -90,6 +91,14 @@ print(response.to_dict())
|
|
|
90
91
|
`load_model` loads the weights through the `hf` extra and returns a `Backend`.
|
|
91
92
|
`Classifier(Config())` calls it for you when you pass no backend.
|
|
92
93
|
|
|
94
|
+
`classifier.nouls` asks a list of statements about one state and returns one probability
|
|
95
|
+
per statement. It sends more than 256 statements as several requests.
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
probabilities = classifier.nouls(["The message is urgent", "The message is polite"],
|
|
99
|
+
state="the payment failed again")
|
|
100
|
+
```
|
|
101
|
+
|
|
93
102
|
Loading each model yourself is what lets one script compare several. The fitted
|
|
94
103
|
temperature follows the backend, so every model is scored with its own value.
|
|
95
104
|
|
|
@@ -136,7 +145,7 @@ the model.
|
|
|
136
145
|
|
|
137
146
|
```json
|
|
138
147
|
{
|
|
139
|
-
"model": "logit-classifier-0.
|
|
148
|
+
"model": "logit-classifier-0.3.0",
|
|
140
149
|
"answers": {
|
|
141
150
|
"department": {
|
|
142
151
|
"type": "choice",
|
|
@@ -197,8 +206,9 @@ Each script runs on its own.
|
|
|
197
206
|
|
|
198
207
|
## Configuration
|
|
199
208
|
|
|
200
|
-
|
|
201
|
-
|
|
209
|
+
The service and `logit-classifier config` read these variables at startup through
|
|
210
|
+
`Config.from_env()`. Library code gets them by calling `Config.from_env()`, since `Config()`
|
|
211
|
+
reads none of them. `logit-classifier config` prints what they produce.
|
|
202
212
|
|
|
203
213
|
| Variable | Default | Effect |
|
|
204
214
|
|---|---|---|
|
|
@@ -209,6 +219,7 @@ they produce.
|
|
|
209
219
|
| `LOGIT_PRIOR_DEBIAS` | `1` | set to `0` to skip the label prior |
|
|
210
220
|
| `LOGIT_BATCH_BRANCHES` | `1` | set to `0` for one forward pass per branch |
|
|
211
221
|
| `LOGIT_SCORE_METHOD` | `joint` | set to `independent` to judge each level alone |
|
|
222
|
+
| `LOGIT_PERMUTATIONS` | `1` | letterings averaged per question, each one adds branches |
|
|
212
223
|
| `LOGIT_ABSTAIN` | `1` | set to `0` to drop the `none of these` label below 52 options |
|
|
213
224
|
| `LOGIT_CALIBRATION_PATH` | `calibration.json` | where the service stores the running prior |
|
|
214
225
|
|
|
@@ -236,10 +247,10 @@ Nothing is sampled, so the same request returns bitwise identical logits.
|
|
|
236
247
|
|
|
237
248
|
Batch composition and padding length both change the low bits of a bfloat16 forward pass,
|
|
238
249
|
and both are pure functions of the request. So the same question asked inside two
|
|
239
|
-
different requests can differ slightly.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
`batch_branches=False` as a keyword
|
|
250
|
+
different requests can differ slightly. Scoring each branch alone makes a question
|
|
251
|
+
independent of the questions sent with it and costs one forward pass per branch. The service
|
|
252
|
+
turns it on with `LOGIT_BATCH_BRANCHES=0`. Library code passes `Config(batch_branches=False)`,
|
|
253
|
+
and `ComfyClipBackend` takes `batch_branches=False` as a keyword.
|
|
243
254
|
|
|
244
255
|
A forward pass needs several process-global torch settings held at known values. Torch
|
|
245
256
|
exposes none of them as a call argument, so the backend sets them around each pass and
|
|
@@ -306,6 +317,72 @@ shape `[1, H, W, 3]`. An empty state asks about the image alone. Questions share
|
|
|
306
317
|
pass of up to 4,096 tokens, and the image counts toward that limit. Any text encoder other
|
|
307
318
|
than Qwen3-VL raises `UnsupportedModelError`.
|
|
308
319
|
|
|
320
|
+
## Toolkit
|
|
321
|
+
|
|
322
|
+
`logit_classifier.toolkit` holds tools built on the classifier with the settings our
|
|
323
|
+
measurements found best. A project imports them to get good results without working out the
|
|
324
|
+
wording, thresholds and host details itself. The toolkit uses the classifier's public API,
|
|
325
|
+
and the classifier never imports the toolkit.
|
|
326
|
+
|
|
327
|
+
| Package | Runs in | Holds |
|
|
328
|
+
|---|---|---|
|
|
329
|
+
| `logit_classifier.toolkit.tags` | any Python process | text tools for image tags and prompts |
|
|
330
|
+
| `logit_classifier.toolkit.comfyui` | a ComfyUI node | a classifier over the workflow's CLIP, text generation, a progress display and an image tagger |
|
|
331
|
+
|
|
332
|
+
### Tag Text Tools
|
|
333
|
+
|
|
334
|
+
| Tool | Does |
|
|
335
|
+
|---|---|
|
|
336
|
+
| `parse_candidates` | splits a model's tag reply into clean tags with no duplicates |
|
|
337
|
+
| `split_prompt` | splits an image prompt into its fragments |
|
|
338
|
+
| `drop_subsets` | drops a tag whose words all appear in a longer tag |
|
|
339
|
+
| `prompt_word_forms`, `in_prompt` | test whether a tag's words appear in a prompt, singular or plural |
|
|
340
|
+
| `merge_candidates` | merges proposed tags with a prompt's tags under a cap |
|
|
341
|
+
| `presence_question` | writes "Is there a car in this image?" with the article the thing takes |
|
|
342
|
+
|
|
343
|
+
```python
|
|
344
|
+
from logit_classifier.toolkit.tags import drop_subsets, parse_candidates
|
|
345
|
+
|
|
346
|
+
tags = drop_subsets(parse_candidates("red car, car, wooden bench, bench"))
|
|
347
|
+
# ['red car', 'wooden bench']
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
`drop_subsets(tags, rule="head-noun")` drops a tag only into a longer tag with the same head
|
|
351
|
+
noun. The head noun is the last word before "of", "in", "on", "with" or "reading". So "cat"
|
|
352
|
+
stays beside "cat ear", and "signage" is dropped beside "signage in chinese".
|
|
353
|
+
|
|
354
|
+
### ComfyUI Tools
|
|
355
|
+
|
|
356
|
+
These run inside a ComfyUI node over the CLIP the workflow loaded. Each one imports comfy and
|
|
357
|
+
torch only when it is called.
|
|
358
|
+
|
|
359
|
+
| Tool | Does |
|
|
360
|
+
|---|---|
|
|
361
|
+
| `comfy_classifier` | builds a classifier over the CLIP, with errors that name the node and the Load CLIP fix |
|
|
362
|
+
| `generate_text` | generates one greedy reply with the Qwen chat template, and stops early on a condition |
|
|
363
|
+
| `shared_vision_encode` | runs the vision tower once per picture for every generate and classify inside the block |
|
|
364
|
+
| `skip_resident_loads` | skips core's model load while the CLIP is already on the GPU |
|
|
365
|
+
| `routed_progress_bars`, `send_status`, `run_outcome` | show one progress bar per run and a status line under the node |
|
|
366
|
+
| `fit_picture` | downscales an image to a pixel cap |
|
|
367
|
+
| `prompt_tags` | lists the physical things a prompt names |
|
|
368
|
+
| `tag_picture` | tags a picture, with a prompt's things added as candidates |
|
|
369
|
+
| `TagSettings` | holds every wording, budget, cap and threshold of the tagger |
|
|
370
|
+
|
|
371
|
+
The tagger generates candidate tags over the CLIP and verifies each one with the classifier.
|
|
372
|
+
|
|
373
|
+
```python
|
|
374
|
+
from logit_classifier.toolkit.comfyui import TagSettings, comfy_classifier, fit_picture, tag_picture
|
|
375
|
+
|
|
376
|
+
classifier = comfy_classifier(clip, node="My Tagger")
|
|
377
|
+
picture = fit_picture(image, 1024 * 1024)
|
|
378
|
+
trace = tag_picture(clip, classifier, picture, settings=TagSettings(), known={})
|
|
379
|
+
tags = trace.kept
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
`known` holds the thing check's answers across a run, so each tag is asked once. The
|
|
383
|
+
`TagSettings` defaults are the current measured values, and a later release may change them.
|
|
384
|
+
Pass every value your output depends on to keep it fixed.
|
|
385
|
+
|
|
309
386
|
## Differences From Jev
|
|
310
387
|
|
|
311
388
|
This project matches the System One request and response format and the documented limits.
|
|
@@ -315,8 +392,8 @@ Jev is trained for calibrated probabilities. This project reads them from a gene
|
|
|
315
392
|
so the choices agree more often than the confidences do.
|
|
316
393
|
|
|
317
394
|
Jev judges a score level without its number or its neighbours. This project judges all
|
|
318
|
-
levels together by default.
|
|
319
|
-
|
|
395
|
+
levels together by default. The service follows the documented behavior with
|
|
396
|
+
`LOGIT_SCORE_METHOD=independent`, and library code with `Config(score_method="independent")`.
|
|
320
397
|
|
|
321
398
|
Jev publishes status codes but no error body. The error shape here is our own.
|
|
322
399
|
|
|
@@ -19,7 +19,8 @@ from pathlib import Path
|
|
|
19
19
|
from logit_classifier import Classifier, Config, load_model, parse_request
|
|
20
20
|
|
|
21
21
|
# Weights land beside the project instead of in the global Hugging Face cache.
|
|
22
|
-
#
|
|
22
|
+
# Edit MODELS_DIR to share one folder across projects.
|
|
23
|
+
# Neither LOGIT_MODELS_DIR nor HF_HOME reaches this script.
|
|
23
24
|
MODELS_DIR = Path(__file__).resolve().parent.parent / "models"
|
|
24
25
|
|
|
25
26
|
MODELS = ["Qwen/Qwen3-VL-4B-Instruct", "Qwen/Qwen3-4B-Instruct-2507"]
|
|
@@ -23,7 +23,8 @@ from logit_classifier import (
|
|
|
23
23
|
)
|
|
24
24
|
|
|
25
25
|
# Weights land beside the project instead of in the global Hugging Face cache.
|
|
26
|
-
#
|
|
26
|
+
# Edit MODELS_DIR to share one folder across projects.
|
|
27
|
+
# Neither LOGIT_MODELS_DIR nor HF_HOME reaches this script.
|
|
27
28
|
MODELS_DIR = Path(__file__).resolve().parent.parent / "models"
|
|
28
29
|
QUESTIONS = {
|
|
29
30
|
"subject": {
|
|
@@ -14,7 +14,8 @@ from pathlib import Path
|
|
|
14
14
|
from logit_classifier import Classifier, Config, load_model, parse_request
|
|
15
15
|
|
|
16
16
|
# Weights land beside the project instead of in the global Hugging Face cache.
|
|
17
|
-
#
|
|
17
|
+
# Edit MODELS_DIR to share one folder across projects.
|
|
18
|
+
# Neither LOGIT_MODELS_DIR nor HF_HOME reaches this script.
|
|
18
19
|
MODELS_DIR = Path(__file__).resolve().parent.parent / "models"
|
|
19
20
|
|
|
20
21
|
REVIEW = "Shipped two days late and the box was crushed, but the product itself works fine."
|
|
@@ -16,7 +16,8 @@ from pathlib import Path
|
|
|
16
16
|
from logit_classifier import Classifier, Config, load_model, parse_request
|
|
17
17
|
|
|
18
18
|
# Weights land beside the project instead of in the global Hugging Face cache.
|
|
19
|
-
#
|
|
19
|
+
# Edit MODELS_DIR to share one folder across projects.
|
|
20
|
+
# Neither LOGIT_MODELS_DIR nor HF_HOME reaches this script.
|
|
20
21
|
MODELS_DIR = Path(__file__).resolve().parent.parent / "models"
|
|
21
22
|
|
|
22
23
|
SAMPLE_TEXT = "I have been trying to connect my Stripe account for 3 days and it keeps failing."
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "logit-classifier"
|
|
3
3
|
dynamic = ["version"]
|
|
4
|
-
description = "Local zero-shot classifier for text and images. Declares options
|
|
4
|
+
description = "Local zero-shot classifier for text and images. Declares options and reads a calibrated probability for each from the logits. Accepts the TypeSafe System One request format, and ships a toolkit of image tag tools and ComfyUI node tools."
|
|
5
5
|
requires-python = ">=3.12"
|
|
6
6
|
license = "GPL-3.0-or-later"
|
|
7
7
|
license-files = ["LICENSE"]
|
|
@@ -136,8 +136,8 @@ show_error_codes = true
|
|
|
136
136
|
[[tool.mypy.overrides]]
|
|
137
137
|
# transformers publishes no complete stubs, and accelerate none at all. torch is
|
|
138
138
|
# listed so the type gate still runs where the hf extra is absent, such as CI.
|
|
139
|
-
# The ComfyUI host supplies comfy, and neither this venv nor CI has
|
|
140
|
-
module = ["transformers.*", "accelerate.*", "torch.*", "comfy.*"]
|
|
139
|
+
# The ComfyUI host supplies comfy, server and comfy_execution, and neither this venv nor CI has them.
|
|
140
|
+
module = ["transformers.*", "accelerate.*", "torch.*", "comfy.*", "server", "server.*", "comfy_execution.*"]
|
|
141
141
|
ignore_missing_imports = true
|
|
142
142
|
|
|
143
143
|
[[tool.mypy.overrides]]
|
{logit_classifier-0.2.0 → logit_classifier-0.3.0}/src/logit_classifier/backends/comfy_clip.py
RENAMED
|
@@ -101,6 +101,29 @@ def _check_image(image: Any) -> None:
|
|
|
101
101
|
raise ImageError(f"a ComfyUI IMAGE of shape [1, H, W, 3] is required, got shape {shape}")
|
|
102
102
|
|
|
103
103
|
|
|
104
|
+
def _resident(clip: Any) -> bool:
|
|
105
|
+
"""Whether the CLIP sits where its own load_model call leaves it, so loading again is a no-op.
|
|
106
|
+
|
|
107
|
+
Core's load_models_gpu has no fast path for a loaded model and costs about 0.1 s per call.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
import comfy.model_management as model_management
|
|
111
|
+
|
|
112
|
+
patcher = clip.patcher
|
|
113
|
+
loaded = model_management.current_loaded_models
|
|
114
|
+
# Another model's load always inserts at the head, and a changed LoRA patch set or an
|
|
115
|
+
# offloaded CLIP must reload.
|
|
116
|
+
return bool(
|
|
117
|
+
loaded
|
|
118
|
+
and loaded[0].model is patcher
|
|
119
|
+
and patcher.model.device == patcher.load_device
|
|
120
|
+
and patcher.model.current_weight_patches_uuid == patcher.patches_uuid
|
|
121
|
+
and patcher.model.model_loaded_weight_memory > 0
|
|
122
|
+
)
|
|
123
|
+
except (ImportError, AttributeError):
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
104
127
|
def _pack_passes(prefix_length: int, suffix_lengths: list[int], batch_branches: bool) -> list[list[int]]:
|
|
105
128
|
"""Group suffixes in request order into passes that stay under PACKED_TOKEN_CEILING.
|
|
106
129
|
|
|
@@ -284,7 +307,8 @@ class ComfyClipBackend:
|
|
|
284
307
|
prefix_tokens[vision["pad_index"]] = {"type": "image", "data": vision["image"], "original_type": "image"}
|
|
285
308
|
with _determinism(), torch.inference_mode():
|
|
286
309
|
encoder.reset_clip_options()
|
|
287
|
-
clip
|
|
310
|
+
if not _resident(clip):
|
|
311
|
+
clip.load_model({self._tokens_key: [prefix_tokens]})
|
|
288
312
|
device = clip.patcher.load_device
|
|
289
313
|
encoder.set_clip_options({"layer": None, "execution_device": device})
|
|
290
314
|
# BaseGenerate.generate picks its execution dtype this way, at llama.py:1115-1120.
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import random
|
|
7
|
+
from collections.abc import Sequence
|
|
7
8
|
from dataclasses import dataclass, field, replace
|
|
8
9
|
from typing import Any
|
|
9
10
|
|
|
@@ -11,7 +12,7 @@ import numpy as np
|
|
|
11
12
|
|
|
12
13
|
from .backends.base import Backend, BackendContractError, BranchLogits
|
|
13
14
|
from .calibrate import PriorStore
|
|
14
|
-
from .config import ANSWER_PREFILL, Config, fitted_temperature
|
|
15
|
+
from .config import ANSWER_PREFILL, JEV_MAX_QUESTIONS, Config, fitted_temperature
|
|
15
16
|
from .deps import MissingDependencyError
|
|
16
17
|
from .prompt import (
|
|
17
18
|
PROMPT_VERSION,
|
|
@@ -25,6 +26,7 @@ from .schema import (
|
|
|
25
26
|
Answer,
|
|
26
27
|
ChoiceAnswer,
|
|
27
28
|
ChoiceQuestion,
|
|
29
|
+
JSONContent,
|
|
28
30
|
NoulAnswer,
|
|
29
31
|
NoulQuestion,
|
|
30
32
|
Question,
|
|
@@ -180,13 +182,12 @@ class Classifier:
|
|
|
180
182
|
rounds = [questions]
|
|
181
183
|
|
|
182
184
|
for seed in range(1, max(1, self.config.permutations)):
|
|
183
|
-
shuffler = random.Random(seed)
|
|
184
185
|
reordered: dict[str, Question] = {}
|
|
185
186
|
for qid, question in questions.items():
|
|
186
187
|
if not isinstance(question, ChoiceQuestion):
|
|
187
188
|
continue
|
|
188
189
|
names = list(question.criteria)
|
|
189
|
-
|
|
190
|
+
random.Random(seed).shuffle(names)
|
|
190
191
|
criteria = {name: question.criteria[name] for name in names}
|
|
191
192
|
reordered[qid] = replace(question, criteria=criteria)
|
|
192
193
|
if reordered:
|
|
@@ -280,6 +281,28 @@ class Classifier:
|
|
|
280
281
|
)
|
|
281
282
|
return response, diagnostics
|
|
282
283
|
|
|
284
|
+
def nouls(self, statements: Sequence[str], *, image: Any = None, state: JSONContent = "") -> list[float]:
|
|
285
|
+
"""Return each statement's noul about the state, in statement order.
|
|
286
|
+
|
|
287
|
+
The statements are split into requests of JEV_MAX_QUESTIONS, one classify call each, and no
|
|
288
|
+
statements make no request. An empty state with an image is the image-only state.
|
|
289
|
+
"""
|
|
290
|
+
keys = [f"s{index}" for index in range(len(statements))]
|
|
291
|
+
questions: dict[str, Question] = {}
|
|
292
|
+
nouls: list[float] = []
|
|
293
|
+
|
|
294
|
+
for start in range(0, len(statements), JEV_MAX_QUESTIONS):
|
|
295
|
+
window = slice(start, start + JEV_MAX_QUESTIONS)
|
|
296
|
+
questions = {key: NoulQuestion(instructions=statement)
|
|
297
|
+
for key, statement in zip(keys[window], statements[window], strict=True)}
|
|
298
|
+
response, _diagnostics = self.classify(SystemOneRequest(state=state, questions=questions), image=image)
|
|
299
|
+
for key in questions:
|
|
300
|
+
answer = response.answers[key]
|
|
301
|
+
if not isinstance(answer, NoulAnswer):
|
|
302
|
+
raise TypeError(f"question {key} got a {answer.type} answer, not a noul")
|
|
303
|
+
nouls.append(answer.noul)
|
|
304
|
+
return nouls
|
|
305
|
+
|
|
283
306
|
def _round_answers(self, questions: dict[str, Question], branches: list[Branch],
|
|
284
307
|
probabilities: list[np.ndarray]) -> dict[str, Answer]:
|
|
285
308
|
answers: dict[str, Answer] = {}
|
|
@@ -141,9 +141,9 @@ class Config:
|
|
|
141
141
|
|
|
142
142
|
# "joint" scores all levels in one branch, "independent" judges each alone.
|
|
143
143
|
score_method: str = "joint"
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
144
|
+
# Relettering varies which group each option lands in once a question splits above 52 options.
|
|
145
|
+
# On Banking77's 77 options, four letterings moved accuracy from 0.554 to 0.693. At 10 options
|
|
146
|
+
# in one branch, accuracy did not move. Off by default because it multiplies the branch count.
|
|
147
147
|
permutations: int = 1
|
|
148
148
|
# An escape label absorbs the mass the model would otherwise spread over wrong
|
|
149
149
|
# options, so offering one raised accuracy from 0.881 to 0.887 as well as scoring
|
|
@@ -52,6 +52,12 @@ class Branch:
|
|
|
52
52
|
suffix_text: str
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
def inert(text: str) -> str:
|
|
56
|
+
# The backends' tokenizers read a <|...|> spelling in plain text as a control token, so
|
|
57
|
+
# client text could otherwise forge a chat turn or a second image pad.
|
|
58
|
+
return text.replace("<|", "<\u200b|")
|
|
59
|
+
|
|
60
|
+
|
|
55
61
|
def _option_lines(entries: list[tuple[str, str]]) -> str:
|
|
56
62
|
lines = []
|
|
57
63
|
for index, (name, description) in enumerate(entries):
|
|
@@ -61,7 +67,7 @@ def _option_lines(entries: list[tuple[str, str]]) -> str:
|
|
|
61
67
|
|
|
62
68
|
|
|
63
69
|
def _question_block(prompt_line: str, entries: list[tuple[str, str]]) -> str:
|
|
64
|
-
return f"{prompt_line}\nOptions:\n{_option_lines(entries)}"
|
|
70
|
+
return inert(f"{prompt_line}\nOptions:\n{_option_lines(entries)}")
|
|
65
71
|
|
|
66
72
|
|
|
67
73
|
def _choice_branches(qid: str, question: ChoiceQuestion, plan: BranchPlan,
|
|
@@ -146,7 +152,7 @@ def build_branches(questions: dict[str, Question], score_method: str = "joint",
|
|
|
146
152
|
def prefix_content(state: Any, has_image: bool = False) -> str:
|
|
147
153
|
"""Build the user-message body every branch shares, image marker included."""
|
|
148
154
|
marker = f"{IMAGE_MARKER}\n" if has_image else ""
|
|
149
|
-
return f"Context:\n{marker}{render_content(state)}\n\n"
|
|
155
|
+
return f"Context:\n{marker}{inert(render_content(state))}\n\n"
|
|
150
156
|
|
|
151
157
|
|
|
152
158
|
def branch_content(state: Any, branch: Branch, has_image: bool = False) -> str:
|
|
@@ -25,6 +25,8 @@ _REQUEST_FIELDS = frozenset({"state", "model", "questions"})
|
|
|
25
25
|
_QUESTION_FIELDS = frozenset({"type", "instructions", "criteria"})
|
|
26
26
|
_NOUL_CRITERIA_FIELDS = frozenset({"true", "false"})
|
|
27
27
|
_QUESTION_TYPES = ("choice", "score", "noul")
|
|
28
|
+
# Keeps render_content's recursive json.dumps(indent=2) far below the interpreter recursion limit.
|
|
29
|
+
MAX_CONTENT_DEPTH = 64
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
class SchemaError(LogitClassifierError, ValueError):
|
|
@@ -178,9 +180,19 @@ def _reject_unknown(mapping: dict[str, Any], allowed: frozenset[str], where: str
|
|
|
178
180
|
|
|
179
181
|
|
|
180
182
|
def _content(value: Any, where: str) -> JSONContent:
|
|
181
|
-
|
|
183
|
+
pending: list[tuple[Any, int]] = [(value, 1)]
|
|
184
|
+
|
|
185
|
+
if isinstance(value, str):
|
|
182
186
|
return value
|
|
183
|
-
|
|
187
|
+
if not isinstance(value, dict | list):
|
|
188
|
+
raise SchemaError(f"expected a string, object or array, got {type(value).__name__}", where)
|
|
189
|
+
while pending:
|
|
190
|
+
node, depth = pending.pop()
|
|
191
|
+
if depth > MAX_CONTENT_DEPTH:
|
|
192
|
+
raise SchemaError(f"content nests deeper than {MAX_CONTENT_DEPTH} levels", where)
|
|
193
|
+
children = node.values() if isinstance(node, dict) else node
|
|
194
|
+
pending.extend((child, depth + 1) for child in children if isinstance(child, dict | list))
|
|
195
|
+
return value
|
|
184
196
|
|
|
185
197
|
|
|
186
198
|
def _optional_content(value: Any, where: str) -> JSONContent | None:
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The tag text tools, which moved to logit_classifier.toolkit.tags in 0.3.0.
|
|
2
|
+
|
|
3
|
+
This path stays for code built on 0.2.x, which PyPI published. It raises no warning, so that code keeps a quiet log.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .toolkit.tags import (
|
|
9
|
+
MAX_CANDIDATES,
|
|
10
|
+
MAX_REPEAT_BLOCK,
|
|
11
|
+
MAX_TAG_CHARS,
|
|
12
|
+
MAX_TAG_WORDS,
|
|
13
|
+
clean_item,
|
|
14
|
+
complete_tags,
|
|
15
|
+
drop_subsets,
|
|
16
|
+
drop_unfinished_tag,
|
|
17
|
+
normalize_item,
|
|
18
|
+
parse_candidates,
|
|
19
|
+
repeated_block,
|
|
20
|
+
split_prompt,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"MAX_CANDIDATES",
|
|
25
|
+
"MAX_REPEAT_BLOCK",
|
|
26
|
+
"MAX_TAG_CHARS",
|
|
27
|
+
"MAX_TAG_WORDS",
|
|
28
|
+
"clean_item",
|
|
29
|
+
"complete_tags",
|
|
30
|
+
"drop_subsets",
|
|
31
|
+
"drop_unfinished_tag",
|
|
32
|
+
"normalize_item",
|
|
33
|
+
"parse_candidates",
|
|
34
|
+
"repeated_block",
|
|
35
|
+
"split_prompt",
|
|
36
|
+
]
|