A jury of tiny open models that vote — and tell you how sure they are.
Can an ensemble of tiny open models match — or beat — a paid, closed "calibrated decision" API on intent classification? Two honest results, both on public benchmarks, both with paired significance tests.
Links: 🤗 Quorum collection · live demo (Space) · gated 24-shot adapter
The demo is a static frontend that calls a Quorum API you self-host (see
serve/) — free, and the inference stays on your machine.
This repo accompanies a small reproduction study. The comparison target is Jev, a closed commercial calibrated-decision model. On the standard Banking77 intent benchmark (77 classes, full 3,080-item test):
Jaehun/PrismNLI-0.4B + BAAI/bge-large-en-v1.5 + BAAI/bge-base-en-v1.5 — scores
0.774 when each class is given a one-line plain-language description (a rephrasing of the label name that
anyone can write; nothing is taken from the dataset), and 0.756 with bare class names. Both are no
training, no calibration, and run on a CPU (≈120 ms/item on a GPU; ~1.5–2 s for a handful of labels on a CPU).
But the NLI member's base model was trained on the Banking77 train split, so these numbers are not fully held
out: with an NLI member never trained on Banking77 the ensemble scores 0.716. Jev, measured by us through
its API on the same sentences and label text, scores 0.803, and it is ahead on all five benchmarks we
measured (by 3.4–22.7 points).What this is not. This is not a claim of open zero-shot state-of-the-art. Our baseline throughout is our own sub-1B components; a 7–9B open LLM will beat our zero-shot absolute accuracy on some datasets (e.g. reported ~0.84 on CLINC vs our 0.65). The point is different: a cheap, calibrated-by-design ensemble reliably beats its own strongest part, across domains — and, at the 24-shot budget with a 4B reader, matches a paid closed API. Licences differ by component: the code is MIT; the default NLI member's base model was trained partly on data with non-commercial licences, so check each model's terms before commercial use.
Zero-shot — three complementary members vote, geometric mean, done (no training, no calibration):
flowchart LR
T["customer message"] --> N["PrismNLI-0.4B<br/>(NLI entailment)"]
T --> E1["bge-large<br/>(cosine)"]
T --> E2["bge-base<br/>(cosine)"]
L["verbalized labels<br/>'This message is about {label}.'"] -.-> N
L -.-> E1
L -.-> E2
N --> G["softmax (T=1)<br/>→ geometric mean<br/>(log-prob mean)"]
E1 --> G
E2 --> G
G --> D["calibrated distribution<br/>→ label + confidence"]
24-shot — retrieve 24 examples, two mechanisms read the same 24, geometric mean (the pipeline that ties Jev; adapter release pending):
flowchart LR
T["message"] --> BM["BM25 retrieve<br/>24 examples"]
BM --> R["in-context reader<br/>Qwen3-4B + LoRA<br/>(scores K options, 1 forward)"]
BM --> K["bge-large kNN<br/>over the same 24"]
T --> R
T --> K
R --> G2["geometric mean"]
K --> G2
G2 --> D2["distribution → label + confidence"]
No single small model is a great zero-shot intent classifier. But three complementary ones — one NLI model (reads the message against "This message is about {label}.") and two sentence embedders (cosine to the same verbalized labels) — disagree in useful ways. Softmax each at temperature 1, take the geometric mean (log-probability mean), renormalise. No labels, no training, no per-task calibration.
from quorum import ZeroShotEnsemble
clf = ZeroShotEnsemble(device="cpu")
labels = ["card arrival", "card delivery estimate", "lost or stolen card", ...]
name, confidence, dist = clf.predict("when will my new card arrive?", range(len(labels)), label_texts=labels)
On Banking77 (full 3,080 test) the ensemble beats every single member by a clear, significant margin (paired McNemar p < 0.001, b = 223 / c = 65). With bare class names (single members shown for the ensemble-beats-parts property), and with generic one-line descriptions:
| system | accuracy |
|---|---|
PrismNLI-0.4B alone (names) | 0.704 |
bge-large alone (names) | 0.700 |
bge-base alone (names) | 0.672 |
| ensemble — bare names (zero authoring) | 0.756 |
| ensemble — + generic descriptions | 0.774 |
| ensemble — descriptions, NLI member never trained on Banking77 † | 0.716 |
Jev (measured via its API, jev-1.13.0, same descriptions) | 0.803 |
Both ensemble numbers are reproduced in this repo on the full test. Reproduce:
python scripts/eval_zeroshot.py --dataset banking77 --device cpu (names; ≈120 ms/item on GPU), or pass the
descriptions from examples/banking77_descriptions.py as label_texts for the 0.774 row. Jev leads zero-shot by
a wide margin; the open stack only ties it at the 24-shot budget, with a 4B reader (below).
† The default NLI member's base model saw the Banking77 train split (Trap 2 below), so the 0.756/0.774 rows are not
fully held out. With an NLI member never trained on Banking77 (MoritzLaurer/deberta-v3-large-zeroshot-v2.0-c), the
ensemble no longer beats its best member on Banking77 (0.690 vs 0.700 with names, n.s.; 0.716 vs 0.738 with
descriptions).
The default matches against bare label names — zero authoring. When names are short or opaque, a one-line
description per class carries far more signal, and the ensemble already takes it: pass label_texts the
descriptions instead of the names (the returned label stays whatever you pass as labels). No extra code, no
extra model.
descriptions = ["adding a song to a playlist", "reserving a table at a restaurant", ...]
name, conf, dist = clf.predict(text, labels, label_texts=descriptions) # match on descriptions, return names
Measured, full test, same 3-model ensemble, names → descriptions:
Banking77 0.756 → 0.774 (+0.018); SNIPS 0.853 → 0.944 (+0.091, above the description-based "dataless"
method's reported 0.926); CLINC150 0.652 → 0.682 (+0.030) — all McNemar p < 1e-6. The gain scales with how
opaque the class names are: large on SNIPS (short, ambiguous names), smaller on Banking77/CLINC (names
already fairly descriptive; a one-line description doesn't fully close the gap to specialised methods on the
151-intent CLINC). See examples/{banking77,snips,clinc}_descriptions.py for the exact descriptions, and
docs/phases/1.1/ for the write-up. Descriptions are the recommended path when you have them; names remain the
zero-effort default.
The "ensemble beats its own best member" property holds across six different-domain intent datasets (full official test, paired McNemar vs the best single member):
| dataset | classes | ensemble | best single | verdict |
|---|---|---|---|---|
| MASSIVE | 60 | 0.739 | 0.691 | ✅ win (p<.001) |
| MTOP | 113 | 0.614 | 0.587 | ✅ win (p=5e-5) |
| Bitext customer-support | 27 | 0.775 | 0.712 | ✅ win (p<.001) |
| CLINC150 | 151 | 0.652 | 0.630 | ✅ win (p<.001) |
| HWU64 | 64 | 0.739 | 0.643 | ✅ win (p<.001) |
| SNIPS | 7 | 0.853 | 0.868 | ➖ tie (p=0.06, ns) |
5 significant wins of 6. The exception (SNIPS, 7 classes, near-saturated): the ensemble is slightly lower than the best single member but not significantly (p ≈ 0.06). It is honest and instructive — when the label set is tiny and one member is already excellent, averaging in weaker members stops helping. Ensembling pays off where the label space is large and the components are complementary. (Every number here is what this repo prints on the full official test set.) MASSIVE and HWU64 are not fully held out for the NLI member (Trap 2); with an NLI member never trained on intent data, the ensemble still beats its best member in all eight conditions (names and descriptions) on the four benchmarks with no known exposure: CLINC150, MTOP, SNIPS and Bitext.
At the 24-shot setting of the public Jev reproduction (retrieve 24 examples per query with BM25, read them in-context, never update
weights), we ensemble two mechanisms over the same 24 examples: a 4B open reader (Qwen3-4B with a small
LoRA fine-tuned on other public intent datasets, Banking77 excluded) that scores all classes in one forward
pass, and a frozen bge-large nearest-neighbour, geometric mean.
On the Jev numbers (provenance). TypeSafe's official Jev evaluation is on their own private 4-workflow
suite, not on public academic benchmarks — there is no official Jev Banking77 number. The community
reproduction's figure (simonmesmith's; 2846/3080 = 92.40%) is exactly what Jev scores when we send the
reproduction's own requests to its API (jev-1.13.0). Every other Jev number here was measured by us through its
API on the same test sentences and label text, after all of our configurations were frozen; Jev's outputs were
used only for evaluation, never for training or tuning.
Reported honestly:
python scripts/eval_24shot.py --dataset banking77 --adapter DataAgent/Quorum-Reader-Qwen3-4B-Adapter (the
adapter is gated on the Hub — request access).The adapter (DataAgent/Quorum-Reader-Qwen3-4B-Adapter) is a LoRA on Qwen3-4B trained only on public intent
datasets with Banking77 held out. It is contaminated for datasets inside its training mix (CLINC/HWU/
SNIPS), so don't use it to judge zero-shot generalization on those.
Negative results, so you don't repeat them:
bge-large for Qwen3-Embedding-8B (top of the MTEB
leaderboard) made the nearest-neighbour member weaker on this task — leaderboard rank is not task rank.The methodology is the point. Traps we hit and fixed (each flipped a conclusion):
PrismNLI-0.4B — and later found its base model was
trained on the Banking77 and MASSIVE train splits too (contamination through model lineage). Those zero-shot
results are therefore marked "not fully held out"; with an NLI member never trained on intent data, Banking77
drops to 0.716.pip install -r requirements.txt
python scripts/eval_zeroshot.py --dataset banking77 --device cpu # or --device cuda
First run downloads the three open models (~1 GB) and the dataset. All numbers here are from the full official test sets.
DataAgent/Quorum-Reader-Qwen3-4B-Adapter).quorum) for a self-hosted service.Jaehun/PrismNLI-0.4B · BAAI/bge-large-en-v1.5 · BAAI/bge-base-en-v1.5 · Qwen3-4B (24-shot reader).
See each model card for its own license.
Code: MIT (see LICENSE). The datasets and models retain their own licenses.
A jury of tiny open models that vote — and tell you how sure they are.
Can an ensemble of tiny open models match — or beat — a paid, closed "calibrated decision" API on intent classification? Two honest results, both on public benchmarks, both with paired significance tests.
Links: 🤗 Quorum collection · live demo (Space) · gated 24-shot adapter
The demo is a static frontend that calls a Quorum API you self-host (see
serve/) — free, and the inference stays on your machine.
This repo accompanies a small reproduction study. The comparison target is Jev, a closed commercial calibrated-decision model. On the standard Banking77 intent benchmark (77 classes, full 3,080-item test):
Jaehun/PrismNLI-0.4B + BAAI/bge-large-en-v1.5 + BAAI/bge-base-en-v1.5 — scores
0.774 when each class is given a one-line plain-language description (a rephrasing of the label name that
anyone can write; nothing is taken from the dataset), and 0.756 with bare class names. Both are no
training, no calibration, and run on a CPU (≈120 ms/item on a GPU; ~1.5–2 s for a handful of labels on a CPU).
But the NLI member's base model was trained on the Banking77 train split, so these numbers are not fully held
out: with an NLI member never trained on Banking77 the ensemble scores 0.716. Jev, measured by us through
its API on the same sentences and label text, scores 0.803, and it is ahead on all five benchmarks we
measured (by 3.4–22.7 points).What this is not. This is not a claim of open zero-shot state-of-the-art. Our baseline throughout is our own sub-1B components; a 7–9B open LLM will beat our zero-shot absolute accuracy on some datasets (e.g. reported ~0.84 on CLINC vs our 0.65). The point is different: a cheap, calibrated-by-design ensemble reliably beats its own strongest part, across domains — and, at the 24-shot budget with a 4B reader, matches a paid closed API. Licences differ by component: the code is MIT; the default NLI member's base model was trained partly on data with non-commercial licences, so check each model's terms before commercial use.
Zero-shot — three complementary members vote, geometric mean, done (no training, no calibration):
flowchart LR
T["customer message"] --> N["PrismNLI-0.4B<br/>(NLI entailment)"]
T --> E1["bge-large<br/>(cosine)"]
T --> E2["bge-base<br/>(cosine)"]
L["verbalized labels<br/>'This message is about {label}.'"] -.-> N
L -.-> E1
L -.-> E2
N --> G["softmax (T=1)<br/>→ geometric mean<br/>(log-prob mean)"]
E1 --> G
E2 --> G
G --> D["calibrated distribution<br/>→ label + confidence"]
24-shot — retrieve 24 examples, two mechanisms read the same 24, geometric mean (the pipeline that ties Jev; adapter release pending):
flowchart LR
T["message"] --> BM["BM25 retrieve<br/>24 examples"]
BM --> R["in-context reader<br/>Qwen3-4B + LoRA<br/>(scores K options, 1 forward)"]
BM --> K["bge-large kNN<br/>over the same 24"]
T --> R
T --> K
R --> G2["geometric mean"]
K --> G2
G2 --> D2["distribution → label + confidence"]
No single small model is a great zero-shot intent classifier. But three complementary ones — one NLI model (reads the message against "This message is about {label}.") and two sentence embedders (cosine to the same verbalized labels) — disagree in useful ways. Softmax each at temperature 1, take the geometric mean (log-probability mean), renormalise. No labels, no training, no per-task calibration.
from quorum import ZeroShotEnsemble
clf = ZeroShotEnsemble(device="cpu")
labels = ["card arrival", "card delivery estimate", "lost or stolen card", ...]
name, confidence, dist = clf.predict("when will my new card arrive?", range(len(labels)), label_texts=labels)
On Banking77 (full 3,080 test) the ensemble beats every single member by a clear, significant margin (paired McNemar p < 0.001, b = 223 / c = 65). With bare class names (single members shown for the ensemble-beats-parts property), and with generic one-line descriptions:
| system | accuracy |
|---|---|
PrismNLI-0.4B alone (names) | 0.704 |
bge-large alone (names) | 0.700 |
bge-base alone (names) | 0.672 |
| ensemble — bare names (zero authoring) | 0.756 |
| ensemble — + generic descriptions | 0.774 |
| ensemble — descriptions, NLI member never trained on Banking77 † | 0.716 |
Jev (measured via its API, jev-1.13.0, same descriptions) | 0.803 |
Both ensemble numbers are reproduced in this repo on the full test. Reproduce:
python scripts/eval_zeroshot.py --dataset banking77 --device cpu (names; ≈120 ms/item on GPU), or pass the
descriptions from examples/banking77_descriptions.py as label_texts for the 0.774 row. Jev leads zero-shot by
a wide margin; the open stack only ties it at the 24-shot budget, with a 4B reader (below).
† The default NLI member's base model saw the Banking77 train split (Trap 2 below), so the 0.756/0.774 rows are not
fully held out. With an NLI member never trained on Banking77 (MoritzLaurer/deberta-v3-large-zeroshot-v2.0-c), the
ensemble no longer beats its best member on Banking77 (0.690 vs 0.700 with names, n.s.; 0.716 vs 0.738 with
descriptions).
The default matches against bare label names — zero authoring. When names are short or opaque, a one-line
description per class carries far more signal, and the ensemble already takes it: pass label_texts the
descriptions instead of the names (the returned label stays whatever you pass as labels). No extra code, no
extra model.
descriptions = ["adding a song to a playlist", "reserving a table at a restaurant", ...]
name, conf, dist = clf.predict(text, labels, label_texts=descriptions) # match on descriptions, return names
Measured, full test, same 3-model ensemble, names → descriptions:
Banking77 0.756 → 0.774 (+0.018); SNIPS 0.853 → 0.944 (+0.091, above the description-based "dataless"
method's reported 0.926); CLINC150 0.652 → 0.682 (+0.030) — all McNemar p < 1e-6. The gain scales with how
opaque the class names are: large on SNIPS (short, ambiguous names), smaller on Banking77/CLINC (names
already fairly descriptive; a one-line description doesn't fully close the gap to specialised methods on the
151-intent CLINC). See examples/{banking77,snips,clinc}_descriptions.py for the exact descriptions, and
docs/phases/1.1/ for the write-up. Descriptions are the recommended path when you have them; names remain the
zero-effort default.
The "ensemble beats its own best member" property holds across six different-domain intent datasets (full official test, paired McNemar vs the best single member):
| dataset | classes | ensemble | best single | verdict |
|---|---|---|---|---|
| MASSIVE | 60 | 0.739 | 0.691 | ✅ win (p<.001) |
| MTOP | 113 | 0.614 | 0.587 | ✅ win (p=5e-5) |
| Bitext customer-support | 27 | 0.775 | 0.712 | ✅ win (p<.001) |
| CLINC150 | 151 | 0.652 | 0.630 | ✅ win (p<.001) |
| HWU64 | 64 | 0.739 | 0.643 | ✅ win (p<.001) |
| SNIPS | 7 | 0.853 | 0.868 | ➖ tie (p=0.06, ns) |
5 significant wins of 6. The exception (SNIPS, 7 classes, near-saturated): the ensemble is slightly lower than the best single member but not significantly (p ≈ 0.06). It is honest and instructive — when the label set is tiny and one member is already excellent, averaging in weaker members stops helping. Ensembling pays off where the label space is large and the components are complementary. (Every number here is what this repo prints on the full official test set.) MASSIVE and HWU64 are not fully held out for the NLI member (Trap 2); with an NLI member never trained on intent data, the ensemble still beats its best member in all eight conditions (names and descriptions) on the four benchmarks with no known exposure: CLINC150, MTOP, SNIPS and Bitext.
At the 24-shot setting of the public Jev reproduction (retrieve 24 examples per query with BM25, read them in-context, never update
weights), we ensemble two mechanisms over the same 24 examples: a 4B open reader (Qwen3-4B with a small
LoRA fine-tuned on other public intent datasets, Banking77 excluded) that scores all classes in one forward
pass, and a frozen bge-large nearest-neighbour, geometric mean.
On the Jev numbers (provenance). TypeSafe's official Jev evaluation is on their own private 4-workflow
suite, not on public academic benchmarks — there is no official Jev Banking77 number. The community
reproduction's figure (simonmesmith's; 2846/3080 = 92.40%) is exactly what Jev scores when we send the
reproduction's own requests to its API (jev-1.13.0). Every other Jev number here was measured by us through its
API on the same test sentences and label text, after all of our configurations were frozen; Jev's outputs were
used only for evaluation, never for training or tuning.
Reported honestly:
python scripts/eval_24shot.py --dataset banking77 --adapter DataAgent/Quorum-Reader-Qwen3-4B-Adapter (the
adapter is gated on the Hub — request access).The adapter (DataAgent/Quorum-Reader-Qwen3-4B-Adapter) is a LoRA on Qwen3-4B trained only on public intent
datasets with Banking77 held out. It is contaminated for datasets inside its training mix (CLINC/HWU/
SNIPS), so don't use it to judge zero-shot generalization on those.
Negative results, so you don't repeat them:
bge-large for Qwen3-Embedding-8B (top of the MTEB
leaderboard) made the nearest-neighbour member weaker on this task — leaderboard rank is not task rank.The methodology is the point. Traps we hit and fixed (each flipped a conclusion):
PrismNLI-0.4B — and later found its base model was
trained on the Banking77 and MASSIVE train splits too (contamination through model lineage). Those zero-shot
results are therefore marked "not fully held out"; with an NLI member never trained on intent data, Banking77
drops to 0.716.pip install -r requirements.txt
python scripts/eval_zeroshot.py --dataset banking77 --device cpu # or --device cuda
First run downloads the three open models (~1 GB) and the dataset. All numbers here are from the full official test sets.
DataAgent/Quorum-Reader-Qwen3-4B-Adapter).quorum) for a self-hosted service.Jaehun/PrismNLI-0.4B · BAAI/bge-large-en-v1.5 · BAAI/bge-base-en-v1.5 · Qwen3-4B (24-shot reader).
See each model card for its own license.
Code: MIT (see LICENSE). The datasets and models retain their own licenses.