Skip to content

0.3.0: better small-data accuracy, calibration, active learning, serving, sklearn API, and a hardened test suite - #68

Merged
codelion merged 16 commits into
mainfrom
claude/zen-hawking-qj5dfq
Oct 8, 2026
Merged

codelion merged 16 commits into
mainfrom
claude/zen-hawking-qj5dfq

Conversation

@codelion

@codelion codelion commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

Summary

Version 0.3.0. It bundles accuracy fixes (which change default predictions), new features for continual learning in production, packaging and CI work, and a much larger test suite. CHANGELOG.md has the full list; this is the shape of it.

Default behaviour changes (predictions change)

With fewer than 10 examples per class the default config scored 0.55-0.73 on perfectly separable synthetic data, where prototypes alone scored 0.96-1.0. Experiments traced it to three causes, all fixed:

  • The neural head was barely trained (~10 optimiser steps, stopped at the first plateau). It could not learn a 2-D XOR from 120 examples (0.49-0.70). It now trains for at least head_steps (300) steps and solves it (1.00).
  • Prototype scores were over-flattened (softmax(exp(-d)): nearest class ~0.45 vs ~0.21), so a confident head always outvoted them. Now softmax(-d / prototype_temperature), default 0.25.
  • A fixed 0.3/0.7 split below 10 examples (head-heavy) created an accuracy cliff. The head's weight now ramps up from zero with the number of examples.

Classifiers saved by an earlier version keep their old scoring when loaded. prototype_temperature=None and fixed new_class_* weights restore the old behaviour.

Trade-offs: a class added with only a few examples is recognised less readily than before, because the old split biased the head toward new classes at the expense of existing ones. add_examples is slower on small memories (roughly 1-2 s vs ~0.1 s); head_steps is configurable.

Real-data check: 0.2.0 vs 0.3.0 defaults, adding classes one at a time

MiniLM encoder, ag_news (start with 2 classes) and emotion (start with 3), 16 examples per starting class, remaining classes added one at a time, 3 seeds, 600 test examples, identical few-shot examples in both runs. It is a trade-off, not a pure win:

new classes have 4 examples new classes have 16 examples
final accuracy, all classes ag_news 0.604 -> 0.757; emotion 0.296 -> 0.351 ag_news 0.773 -> 0.762; emotion 0.379 -> 0.381 (no change within noise)
old-class accuracy lost when a class is added ag_news 0.242 -> 0.063; emotion 0.203 -> 0.084 ag_news 0.107 -> 0.152; emotion 0.112 -> 0.135 (slightly worse)
recall on the class just added ag_news 0.929 -> 0.766; emotion 0.720 -> 0.395 (worse) ag_news 0.918 -> 0.928; emotion 0.611 -> 0.661

Pooled over the 12 configurations, final accuracy improves by 0.05 on average (better in 8 of 12). So: clearly better when new classes arrive with few examples (far less forgetting, much better overall accuracy) at the price of the new class being under-recognised; neutral overall when classes arrive well-populated, with somewhat more old-class loss. With 3 seeds and 2 datasets this is suggestive, not conclusive.

New features

  • Calibration: calibrate() (temperature scaling, held-out ECE 0.20-0.34 -> 0.02-0.03 on synthetic overlapping classes), calibration_report(), and conformal predict_set().
  • Active learning and drift: suggest_labels() (margin / entropy / least_confidence / ood, optional diversity) and drift_report(). Uncertainty sampling did not beat random labelling on heavily overlapping synthetic classes (0.67 vs 0.69); the docs say so.
  • Forgetting and OOD: forget(), remove_examples(), ood_score() / is_ood(), and predict(..., abstain_below=, abstain_ood=).
  • scikit-learn: SklearnAdaptiveClassifier (fit, partial_fit, predict_proba, works in Pipeline / cross_val_score / GridSearchCV).
  • Serving: python -m adaptive_classifier.serving ./model (FastAPI; updates off by default and API-key protected), async methods, docker/Dockerfile, docs/serving.md.
  • Deployment guide (docs/deployment.md) and examples/portable_inference.py, a NumPy + onnxruntime reference that reproduces predict() without PyTorch or FAISS. A saved directory now includes the tokenizer and the resolved pooling mode and loads without the Hub.

Bugs fixed (found by the new tests)

  • Thread safety: predicting while another thread added/forgot classes crashed ("selected index k out of range"), and concurrent add_examples calls corrupted each other. The public operations of AdaptiveClassifier now hold a per-instance lock.
  • Integer labels were accepted, then reloaded from disk as a duplicated mix of int and str classes. add_examples now rejects non-string labels (breaking for anyone passing ints; str(label) fixes it).
  • predict(k=-1) silently dropped the last result; predict_batch(k=...) scored only k classes so its scores differed from predict; clear_memory(labels=...) left classes in the label map and head; loading a directory without config.json reported an invalid Hub repo id.
  • Saves from before 0.2.0 that have classes with fewer than 10 examples would have loaded with a different blend than they had under 0.2.0 (found by loading one under both versions); the old fixed split is now pinned.

Packaging and CI

  • PEP 621 pyproject.toml (setup.py and requirements.txt removed), Python >= 3.10, test / serve / scripts extras, tqdm no longer a core dependency.
  • CI downloads the test models once (with retries) and runs the suite offline; an earlier version of this PR hit the Hugging Face 1000-requests/5-minutes limit because four matrix legs shared one quota. Matrix is Python 3.10 and 3.13. Weekly schedule added.

Testing

  • Offline suite (~480 tests): tiny local model plus synthetic separable data; covers accuracy at several sizes, XOR, no catastrophic forgetting, class imbalance, label noise, property-based invariants (hypothesis), hostile and malformed input, damaged files, Hub failures, concurrency, the HTTP server, and parity of the portable reference with predict() for every scoring branch.
  • Mutation-checked: I deliberately broke the library in ~70 places across these files; every break is caught. A few initially survived and exposed missing tests, which were added.
  • Published classifiers: 10 of the classifiers in the adaptive-classifier Hugging Face org (saved before 0.2.0) give identical predictions under 0.2.0 and 0.3.0: 225 predictions, no changed labels, largest score difference 3e-8.
  • CI is green (Python 3.10, 3.13, and the integration job), including the pre-existing real-model tests. Two of those encoded the old behaviour and were updated: the blend-weight tests and a 0.85 < confidence < 0.95 window in test_reported_confidence_drop.py, which now asserts a consistent blend that is not the saturated pure-neural value.

Known gaps

  • docker/Dockerfile has not been built or run (no Docker daemon was available when it was written). The server it runs is covered by tests.
  • The defaults were compared against the previous ones on real data only as described above: two datasets, one encoder, three seeds. Other encoders, domains and class counts are untested. ood_threshold (1.25) was chosen on synthetic data.
  • MultiLabelAdaptiveClassifier's own methods (such as predict_multilabel) are not covered by the new lock. Strategic mode is not covered by the portable reference or by the new accuracy and calibration tests.

🤖 Generated with Claude Code

claude added 12 commits October 8, 2026 07:44
Packaging:
- Move all metadata into a PEP 621 [project] table in pyproject.toml and
  drop setup.py and requirements.txt; version is read from
  adaptive_classifier.__version__ so it lives in one place
- Require Python >=3.10 (3.8/3.9 are EOL) and add 3.11-3.13 classifiers
- Drop unused core dependencies (tqdm, setuptools, wheel); tqdm and the
  other script-only packages move to a `scripts` extra
- CI: test on Python 3.10-3.13, use actions v4/v5 with pip caching,
  install via the `test` extra, build releases with `python -m build`

Docs:
- Add docs/deployment.md: multilingual base models, the saved file
  layout, the exact inference algorithm, and C#/JS notes
- Add examples/portable_inference.py, a NumPy + onnxruntime reference
  that reproduces predict() without PyTorch or FAISS; verified against
  AdaptiveClassifier.predict for mean and cls pooling

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- save()/push_to_hub() now persist the resolved pooling mode ('mean' or
  'cls') instead of the literal 'auto', so reloads and non-Python runtimes
  no longer depend on a Hub lookup of the base model's config
- save() also writes the tokenizer files (tokenizer.json etc.) so a saved
  directory is self-contained; a failure to write them only warns
- Add tests/test_portable_export.py (offline, tiny local BERT) covering
  persisted pooling, tokenizer output, legacy-config handling, reload
  round-trips, and parity of examples/portable_inference.py with predict()
- Update docs/deployment.md and the reference script: the two manual steps
  are no longer needed for newly saved classifiers

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- forget(labels) removes whole classes from memory, label maps, training
  history and the neural head (keeping other classes' learned weights);
  clear_memory(labels) now delegates to it, fixing a gap where cleared
  classes still showed up in predictions through the head
- remove_examples(texts, label=None, retrain=True) removes examples by
  text, recomputes prototypes, drops emptied classes and optionally
  fine-tunes the head
- ood_score()/is_ood() score a text by its distance to the nearest
  prototype relative to that class's radius; radii are saved with the
  model so reloaded classifiers keep their spread
- predict() gains abstain_below and abstain_ood, both off by default
- New config keys ood_threshold and ood_min_radius
- Share the offline tiny-BERT fixture in tests/conftest.py and add
  tests/test_forget_and_ood.py (30 tests)
- Document the new methods in docs/API.md and the README

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- The integration job now runs `pytest tests/ -m integration` instead of a
  single named file, so integration-marked tests in any file are executed
  (together with the matrix job this covers the whole suite)
- Cache the Hugging Face model directory in both jobs
- Add a 45 minute timeout to the matrix job
- Add a weekly schedule and workflow_dispatch so Hub or dependency breakage
  is caught without waiting for a PR
- Cancel superseded runs on the same PR

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- New SklearnAdaptiveClassifier (fit, partial_fit, predict, predict_proba,
  predict_log_proba, score, classes_) wrapping AdaptiveClassifier, so it
  works in Pipeline, cross_val_score and GridSearchCV
- Strings or integers as labels, returned as given; input validation for
  X and y; partial_fit accepts new classes at any call
- The fitted AdaptiveClassifier is exposed as classifier_ for save,
  forget, remove_examples and OOD detection
- tests/test_sklearn_api.py (23 offline tests) including clone, CV,
  Pipeline and GridSearchCV
- Document in the README and docs/API.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…-mode tests

CI
- New `models` job downloads the test models once (with retries) via
  scripts/prefetch_test_models.py and caches them; the test jobs restore
  the cache and run with HF_HUB_OFFLINE=1. The previous run hit the Hub's
  1000-requests-per-5-minutes limit (HTTP 429) because four matrix legs
  shared one quota; every failure, including the ONNX ones, was a 429
- Matrix reduced to the ends of the supported range, Python 3.10 and 3.13
- tests/test_ci_models.py fails if a test names a model the prefetch list
  does not cover

Library fixes found by the new tests
- Non-string labels (e.g. ints) are rejected: they were accepted, then
  reloaded from disk as a duplicated mix of int and str classes
- None as a text or label, and mixed label types, raise a clear ValueError
  before any state changes
- Negative or non-integer k raises ValueError (k=-1 silently dropped the
  last prediction)
- Loading a directory without config.json says so instead of reporting an
  invalid Hub repo id

Tests
- tests/test_robustness.py: accuracy on separable synthetic data at several
  sizes, no catastrophic forgetting, class imbalance, label noise,
  determinism, order independence, and property-based checks (hypothesis)
  that predictions are always a valid ordered distribution
- tests/test_failure_modes.py: hostile and malformed input, damaged or
  missing files, Hub failures falling back to PyTorch, and randomized
  sequences of add/forget/remove checked for internal consistency
- A strict expected failure records a known weakness: with fewer than 10
  examples per class the default blend lets an undertrained neural head
  outvote correct prototypes (~75-92% training accuracy on separable data)
- Mutation-checked: 12 deliberate faults in the library, all caught

Also: version 0.3.0, CHANGELOG.md, hypothesis added to the test extra.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The models job failed on bert-base-uncased and distilbert-base-uncased: the
Hub's download-token endpoint returns 404 for the short alias names, on
every retry. Canonical ids (distilbert/distilbert-base-cased,
google-bert/bert-large-cased) downloaded fine.

- Tests and the prefetch list use google-bert/bert-base-uncased and
  distilbert/distilbert-base-uncased (same models)
- tests/test_ci_models.py rejects alias ids so they cannot return
- The prefetch script no longer retries HTTP 401/403/404, which are
  permanent, so such a failure shows up in seconds instead of minutes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…cores, ramp the head in

The default blend scored 0.55-0.73 on perfectly separable synthetic data
with fewer than 10 examples per class, where prototypes alone scored
0.96-1.0. Experiments on synthetic Gaussian, XOR and incremental-class
data traced it to three causes, all fixed (this is a default-behaviour
change, hence the 0.3.0 minor bump):

- The neural head was badly undertrained: ~10 optimiser steps and an early
  stop at the first loss plateau. It could not learn a 2-D XOR from 120
  examples (0.49-0.70). It now trains for at least `head_steps` (300) steps
  with a cosine-decayed `head_learning_rate` (0.003) and solves XOR (1.00)
- Prototype scores were softmax(exp(-d)), which barely separated the
  nearest class from the rest (~0.45 vs 0.21), so a confident head always
  outvoted them. They are now softmax(-d / prototype_temperature), 0.25
- Classes below new_class_example_threshold used a fixed 0.3 prototype /
  0.7 head split. The head's weight now ramps up from zero with the number
  of examples, so there is no accuracy cliff at the threshold. Setting both
  new_class_* weights restores a fixed split

Backward compatibility: classifiers saved by an earlier version keep the
legacy scoring when loaded (prototype_temperature absent -> None).

Also updates the portable reference implementation and the deployment guide
to the new scoring math, the README section on predictions, the changelog,
and the real-model tests that encoded the old behaviour (blend weights, and
a 0.85-0.95 confidence window that pinned the old blend).

Tests: the known-weakness expected failure is now a regression test, plus
XOR (only the head can solve it, and prototypes alone cannot), head-only
and head_steps tests, ramp continuity and configuration, scoring sharpness,
legacy-save compatibility, and portable-reference parity for every scoring
branch. Mutation-checked: ten deliberate reversions, all caught.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- calibrate(texts, labels) fits one temperature on held-out labelled data
  (minimising negative log-likelihood), so a reported 0.9 means about 90%.
  predict, predict_batch and abstain_below then use calibrated
  probabilities; the winning class never changes. On synthetic overlapping
  classes held-out ECE drops from 0.20-0.34 to 0.02-0.03
- predict_set(text, alpha) returns a split-conformal set that contains the
  true label with probability >= 1 - alpha (never empty)
- calibration_report() gives accuracy, ECE and NLL for checking on a
  separate test set
- Calibration is saved with the model and discarded when the class set
  changes (a new class, forget)
- Fix: predict_batch scored only k classes before blending, so with more
  than k classes its scores differed from predict(); it now scores all
  classes and trims to k
- Pure maths lives in adaptive_classifier.calibration

tests/test_calibration.py (45 tests): the maths against known answers
(recovering a known miscalibration, conformal order statistics, ECE), and
the classifier on separate train / calibration / test splits. Mutation-
checked with 14 deliberate faults, all caught.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- suggest_labels(texts, n, strategy, diverse) ranks unlabeled texts by how
  useful a label would be: margin, entropy, least_confidence, or ood (to
  find classes the model has not seen). diverse=True discounts texts
  similar to earlier picks so near-duplicates are not all chosen
- drift_report(texts) scores a window of incoming texts with ood_score and
  runs a one-sided binomial test of the out-of-distribution share against
  an expected rate; returns the rate, mean score, p-value and a verdict

tests/test_active_learning.py (38 tests): each strategy's ranking is
checked against an independent recomputation from predict(), boundary
texts outrank clear ones, the ood strategy finds a hidden class, diverse
selection avoids twins, and drift is flagged for shifted streams but not
for in-distribution ones. Mutation-checked with 12 deliberate faults; two
initially survived and exposed missing tests, now added.

Honest note kept in the docs: on heavily overlapping synthetic classes
uncertainty sampling did not beat random labelling (0.67 vs 0.69), so no
efficiency claim is made.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rfile

Thread safety (a bug fix that serving needs)
- Predicting while another thread added or forgot classes crashed with
  "selected index k out of range", and concurrent add_examples calls
  corrupted each other's gradient computation ("modified by an inplace
  operation"). Both reproduced reliably. Every public operation now holds a
  per-instance reentrant lock, so one classifier can be shared between
  threads. The stress tests fail 5 of 5 runs when the lock is removed

Serving
- adaptive_classifier.serving: create_app(classifier) builds a FastAPI app
  (/health, /info, /predict, /predict_batch, /predict_set, /ood, and
  /examples, /forget, /remove_examples when updates are allowed);
  python -m adaptive_classifier.serving ./model runs it
- Updates are off by default; --allow-updates enables them, an API key
  (bearer token, constant-time compare) protects them, --save-dir persists
  them. Request size limits, validation (422) and classifier errors (400)
- apredict / apredict_batch / aadd_examples run in a worker thread so they
  do not block an event loop
- New serve extra; fastapi and httpx added to the test extra
- docker/Dockerfile (CPU torch, non-root, health check) and docs/serving.md.
  The Dockerfile was not built: there was no Docker daemon available

Self-contained saves
- Loading a saved directory used to fetch the tokenizer from the base
  model's repository even though save() writes it alongside the model, so
  an ONNX-saved classifier could not start without Hub access. The saved
  tokenizer is now used (and fetched from the Hub repo with the other
  files), for both the ONNX and PyTorch paths

tests/test_concurrency.py (11) and tests/test_serving.py (43), plus tokenizer
loading tests. Mutation-checked: 15 deliberate faults, all caught.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The serving CLI tests monkeypatch uvicorn.run, so they need uvicorn
installed. CI failed with four "No module named 'uvicorn'" errors (406
other tests passed); it worked locally only because uvicorn had been
installed by hand. An audit of every third-party import in tests/ confirms
nothing else is missing from a clean install of the test extra.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@codelion codelion changed the title Modernize packaging, add deployment guide, forget/OOD/abstain, and a scikit-learn API 0.3.0: better small-data accuracy, calibration, active learning, serving, sklearn API, and a hardened test suite Oct 8, 2026
claude added 2 commits October 8, 2026 12:16
scripts/benchmark_fewshot.py compares adaptive-classifier (default and
prototypes-only), logistic regression on the same frozen embeddings, and
SetFit, all on one shared encoder with the same stratified few-shot sample and
test subset. Per run it records accuracy, macro-F1, calibration error, train
time, test-set prediction time, median single-query latency, and the cost of
adding one more labelled example (SetFit has no incremental update, so its
training time is reported and marked as a full retrain). Samples are nested
across shot counts so results are comparable.

Commands: run, report (Markdown, best per column in bold), check (streams one
example from each split to verify dataset ids and columns before a long run) and
plan (validates workflow inputs so they are never interpolated into a shell).

.github/workflows/benchmark.yml runs it by hand: a plan job validates inputs
and checks the datasets, one parallel job per dataset runs the benchmark, and a
summary job merges the results into the run summary. docs/benchmark.md explains
the methods and, deliberately, the limits of what the numbers show.

Found while verifying against the real Hub: PolyAI/banking77 is a loading
script that current `datasets` releases refuse to run, so the benchmark uses
the parquet copy mteb/banking77. SetFit also wrote ~260 MB of checkpoints into
the working directory; they now go to a temporary directory (and checkpoints/
is git-ignored).

tests/test_benchmark_harness.py (69 tests, all offline): sampling, metrics,
aggregation, the report, input validation including shell-injection attempts,
dataset verification against a stubbed `datasets`, every method run end to end
on a tiny local model, and the command line. SetFit runs end to end when it is
installed. The workflow's own shell steps were executed from the YAML with a
simulated Actions environment.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
tests/test_ci_models.py failed in CI because the harness tests pass
"sentence-transformers/all-MiniLM-L6-v2" to plan() as input data, and the guard
reads any model-like string in tests/ as a model the suite needs offline. Nothing
downloads it. Use "acme/encoder-small" for the dummy value instead of adding a
model to the prefetch list for no reason. All other tests in the run passed
(476 passed on Python 3.13).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@codelion
codelion force-pushed the claude/zen-hawking-qj5dfq branch from 00dc1f1 to 83a799e Compare October 8, 2026 12:45
claude added 2 commits October 8, 2026 13:02
The benchmark harness stays as internal tooling, but the library's public
documentation is about continual learning and does not need to advertise a
comparison with another method. Also neutralise two comments that named it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Saves from before 0.2.0 store neither prototype_temperature nor the new-class
weights. The loader already pinned the temperature to the legacy scoring, but
a missing new_class_prototype_weight / new_class_neural_weight picked up the new
default (a ramp), so a class with fewer than 10 examples was blended
(0.85, 0.15) instead of the (0.3, 0.7) it had in 0.2.0 and predictions changed.
Found by loading one stripped-down save under both versions. Missing weights
are now pinned to the old fixed split; saves that stored their own weights
keep them.

The classifiers published in the adaptive-classifier Hugging Face org were not
affected (they all have 100 examples per class): 10 of them give identical
predictions under 0.2.0 and 0.3.0 (225 predictions, no changed labels, largest
score difference 3e-8).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@codelion
codelion merged commit 13ad4c5 into main Oct 8, 2026
5 checks passed
@codelion
codelion deleted the claude/zen-hawking-qj5dfq branch October 8, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants