choice, score, noul and head mean.
How these were run
Every block below was executed and its output pasted back verbatim. The machine had no network for model weights, so all of them ran against a deterministic stand-in encoder instead of SigLIP. That istoy.py, which hashes the pixel bytes and knows nothing
about anything:
jevimage.load(Toy()) calls a plain red square a
screenshot and is very sure about it. What is real: the shapes, the field names, the
control flow, the errors, and the relative cost of one encode versus many questions.
Swap jevimage.load(Toy()) for jevimage.load() and the same scripts run on
siglip2-base-224 with answers that mean something.
The images are the 12 flat 64x64 squares in ds/, laid out the way read_folder
expects:
examples/make_fixtures.py in the
directory you run these scripts from. The stand-in encoder hashes the pixel bytes, so
every probability below is a number about those squares: with your own images the
shapes and the control flow are identical and the numbers are not.
The timings were measured on an otherwise quiet 16-thread laptop CPU (12th Gen Core
i7-12650H, OMP_NUM_THREADS=1, no GPU). They are microseconds of arithmetic, so they
move with the machine; the ratios between them are the part to read.
Recipe 7 also needs a server. Anything that speaks the API will do:
1. Screen one upload against every policy at once
Problem: a user uploads an image and five separate policies want an opinion on it before it goes live.siglip2-base-224 a cold caption costs a real text-tower pass, and the same split
measured 1020 ms cold against 0.39 ms cached for 16 questions. A fixed policy list is
what makes this pattern pay (timings).
Watch out: a noul pair is a contrast, not an absolute. 0.920 for medical
means “closer to the true caption than the false one”, not “92% likely to be a medical
photo”, so the false caption does real work. "no weapon of any kind" is a much better
opposite than "" or "something else". One
ask() takes at most 64 questions; past that it raises rather than truncating.
2. Tag a product shot on every axis at once
Problem: one photo, four independent catalogue fields, one row out.repr() is the short form for logs; json.dumps is the full shape, because an Answer
is a dict subclass and needs no conversion step. A score is the expected level index.
1.07 on a three-level rubric means “a few props, leaning very slightly towards crowded”,
which is a more useful number for sorting than an argmax would be.
Watch out: shot used a bare list, so the label is the caption and the value you
store is a whole sentence. Use a {label: description} object whenever the label lands
in a database column. A bare list with two identical captions raises rather than
silently collapsing to one option.
3. Auto-approve what is confident, queue the rest
Problem: you want the model to handle the easy 90% and a human to see the rest.confidence, not max(probabilities). The last two lines are why: a top
probability of 0.5 is a coin flip over two options and a strong signal over fifty.
confidence is (max - 1/K) / (1 - 1/K), so 0 always means uniform and 1 always
means certain, and one threshold survives you adding a third option to the question.
Watch out: confidence measures how peaked the distribution is, not how right it
is. The stand-in encoder above is confidently wrong on five of six images. Pick the
threshold by running a few hundred labelled examples through and looking at where the
errors sit. Remember that an image whose true answer is not among your options
still comes back confident, because the distribution only covers the options you gave.
4. Cache the embedding, ask new questions later
Problem: the images were encoded last week; today someone wants a new field, and re-encoding the archive is the expensive part.dim floats (768 for siglip2-base-224, so 1.5 KB in fp16) and it is
the entire cost of a request. Store it next to the row, keep the original image
somewhere cold, and any future question is a matmul. The 3x here is small only because
the stand-in encoder is a hash function; with a real encoder on a GPU the ratio is the
whole forward pass against a few microseconds.
Watch out: do not key, hash or diff on the bytes of an embedding. It is stable for a
fixed encoder, device, dtype and batch composition, and not otherwise: encoding the same
image alone and inside a batch of four on siglip2-base-224/CPU differed by 1.8e-07,
which left the answer unchanged at six decimals but not the vector. jev serve runs with
batching on, so what an image embeds to depends on who else was in flight. Compare with a
tolerance.
Watch out: an embedding is meaningful only in the encoder that produced it, so store
encoder.name beside it and check it, as above; nothing downstream will notice
otherwise, and two encoders of the same dim will silently produce nonsense.
ask_embedding() exists only on a local Jev: its whole purpose is skipping the
network, so connect() does not have it. Remote.embed() still gives you the vectors:
a torch.Tensor if torch is installed, a plain list of lists on an API-only install,
where .half() and .float() do not exist.
5. Sweep a directory and route it into folders
Problem: a shoot lands as a flat directory and needs sorting into bins, with the unusable frames set aside. Hereshoot/ holds the same 12 images as ds/, flat and
unlabelled, which is how they actually arrive. Flattening two label folders means two
0.pngs, so the blue ones are prefixed: ds/red/0-5.png become 0-5.png, and
ds/blue/0-5.png become b0-b5.png.
embed(list) hands the whole list to the encoder in one go; a loop of ask() calls
would hand it one image at a time and waste the GPU. Encode in bulk, then read each
embedding. The loop that follows does no model work at all, so it can do arbitrary
routing logic without thinking about cost.
Watch out: Jev.embed() has no size cap, so a directory of 50,000 images is 50,000
decoded bitmaps in RAM at once, so slice it into chunks of a few hundred. Going through a
server, Remote.embed() chunks for you at 8 images per request, which is the server’s
own limit. IMAGE_SUFFIXES is deliberately short (.jpg .jpeg .png .webp); a .gif or
a .tif in the folder is skipped here and refused with a 415 by a server.
6. Train a head when the prompts plateau
Problem: zero-shot prompts get one class right and the other wrong, and you have a folder of labelled examples.red from 0.167 to 0.500 while pulling blue down
from 0.667 to 0.500. On this data you would keep the prompts for blue and the head for
red, or go and find more blue examples. That per-class split is the reason the
comparison exists; an overall delta alone would have hidden it.
Watch out: accuracy is stratified k-fold with k = min(5, thinnest class), so six
examples per class means folds of one or two images and a number with enormous error
bars. 0.500 on 12 examples is a direction, not a measurement. The baseline uses
"a photo of {}" unless you pass template=, as this script does; comparing a tuned
head against an untuned prompt you would never have written is how a head looks better
than it is. Training on a server works the same way (Remote.train), but it has no
template parameter, so the baseline there is always the default one.
7. The same script against a local encoder and a server
Problem: develop against a server your team already runs, then move the encoder in-process for a batch job, without touching the code in between.screen() takes whichever object it is handed. Training goes over the wire too: the
labelled images upload, the encoder that fits the head is the server’s, and what comes
back is the same Head object with the same comparison; the weights never leave.
delete_head returns True/False on both sides rather than raising on the second
call, because that is what the local one does.
Watch out: two gaps are deliberate and both are visible above. ask_embedding is
local-only, because skipping the network is its entire point, and health() is
remote-only, because there is no server to report on locally. A third asymmetry is
quieter and this script does not show it: Jev.train(template=) and
Jev.compare(template=) have no remote equivalent. The numbers agree only because both
sides run the same encoder, so check health()["encoder"] before you assume a server’s
answers are comparable with your local ones, and remember heads live where the encoder
is, so a head you trained locally is not on the server.
8. The failures you will hit
Problem: knowing in advance what jevimage refuses to do, and what it says when it refuses.dim would otherwise
produce plausible nonsense, so a head records the encoder that fitted it and refuses to
run under another. The single-caption noul needs both logit_scale and logit_bias:
SigLIP publishes both, CLIP neither, and an Ensemble averages its members’ scale but
has no bias. The {"true", "false"} pair form works everywhere and is better
calibrated, so prefer it.
Watch out: catch ValueError. Locally that is what the engine raises; remotely
JevError is a subclass of it, carrying .status and the server’s own .detail, so
one except ValueError covers both and an existing handler keeps working when a
load() line becomes a connect() line. A dead server and a rejected request are both
JevError; branch on .status is None if you want to retry only the former. Two
things ask() can raise are not refusals of the question and are outside ValueError:
a list passed as image is a TypeError, and a head file that is present but will not
load is a KeyError — see
ask() takes one image and
A head file that will not load.
Where to go next
- Question types: every field of every question and answer.
- Training a head: what the comparison measures, and when to trust it.
- Choosing an encoder: the registry, ensembles, and bringing your own.
- Python reference: the exact public surface.
- HTTP reference: the routes, if you are writing a client.
- Install: which of the four installs you want.
← HTTP reference · Docs index · Troubleshooting →