Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Docker image

# Builds the serving image and queries it. It only runs when something the image
# depends on changes (and weekly, to catch breakage in upstream packages), so
# ordinary PRs do not pay for it.
on:
pull_request:
paths:
- 'docker/**'
- '.dockerignore'
- 'pyproject.toml'
- 'src/adaptive_classifier/serving.py'
- 'scripts/docker_smoke.py'
- '.github/workflows/docker.yml'
push:
branches: [ main ]
paths:
- 'docker/**'
- '.dockerignore'
- 'pyproject.toml'
- 'src/adaptive_classifier/serving.py'
- 'scripts/docker_smoke.py'
- '.github/workflows/docker.yml'
workflow_dispatch:
schedule:
- cron: '43 5 * * 1'

concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip

- name: Build image
run: docker build -f docker/Dockerfile -t adaptive-classifier .

# A tiny local encoder, so no Hugging Face access is needed.
- name: Install library (to create a model to serve)
run: |
python -m pip install --upgrade pip
pip install -e .

- name: Serve and query
run: python scripts/docker_smoke.py adaptive-classifier
27 changes: 20 additions & 7 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@ on:
types: [created]

jobs:
# The full test workflow (model prefetch with retries, both Python versions, the
# Node.js example, integration tests). Nothing is published unless it passes.
tests:
uses: ./.github/workflows/test.yml
secrets: inherit

pypi-publish:
name: Publish release to PyPI
needs: tests
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/adaptive-classifier
# Only this job can publish; the test code above never sees these credentials.
permissions:
id-token: write
steps:
Expand All @@ -18,16 +26,21 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install dependencies

- name: Check the release tag matches the package version
run: |
version=$(sed -n 's/^__version__ = "\(.*\)"/\1/p' src/adaptive_classifier/__init__.py)
tag="${{ github.event.release.tag_name }}"
echo "package version: $version, release tag: $tag"
if [ "v$version" != "$tag" ] && [ "$version" != "$tag" ]; then
echo "The release tag $tag does not match the package version $version" >&2
exit 1
fi

- name: Install build tools
run: |
python -m pip install --upgrade pip
pip install build
- name: Run tests
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
pip install -e ".[test]"
pytest tests/
- name: Build package
run: |
python -m build
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ on:
pull_request:
branches: [ main ]
workflow_dispatch:
# The release workflow runs the same tests before publishing.
workflow_call:
schedule:
# Weekly, so breakage on the Hugging Face Hub or in new transformers/torch
# releases shows up without waiting for the next PR.
Expand Down Expand Up @@ -76,6 +78,10 @@ jobs:
python -m pip install --upgrade pip
pip install -e ".[test]"

- name: Install the Node.js example's dependencies
run: npm install --no-audit --no-fund
working-directory: examples/javascript

# Everything except tests marked `integration`, run offline against the
# models prefetched above. Tests marked `integration` run in the job below.
- name: Run tests
Expand Down Expand Up @@ -128,4 +134,4 @@ jobs:
- name: Integration test summary
if: always()
run: |
echo "Integration tests completed. Check logs above for detailed results."
echo "Integration tests completed. Check logs above for detailed results."
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,4 @@ scripts/benchmark_results/

# Training checkpoints written by the benchmark harness
checkpoints/
examples/javascript/node_modules/
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
# Changelog

## 0.3.1

### Fixed
- `MultiLabelAdaptiveClassifier`: `forget()` and `remove_examples()` no longer crash; `load(..., use_onnx=...)` works and the multi-label settings and per-label thresholds are saved and restored; a bare-string label (which was split into one class per character) is rejected; `max_labels=0` returns nothing and `max_labels` caps the `min_predictions` top-up; the head is trained for `head_steps` steps and retrained when labels are added, instead of ~10 steps with a softmax loss that did not apply; the public methods hold the instance lock.

- Strategic mode: `cost_coefficients` is validated (a dict of named features, as the README once showed, or a list of the wrong length used to disable strategic mode with a cryptic message; the reason now says what is wrong and how many entries are needed); the best response explored only the first few embedding dimensions and used unseeded randomness, so it now samples the cheapest and a random spread of dimensions with a fixed seed; `predict`, `predict_strategic` and `predict_robust` scores no longer change with `k`; `evaluate_strategic_robustness` no longer divides by zero, rejects unknown labels with `ValueError`, and is repeatable and thread-safe.
- Strategic-mode predictions are different from 0.3.0 because of the candidate and `k` fixes.

- Copying a classifier (`copy.deepcopy`, `pickle`) failed with "cannot pickle RLock" since the instance lock was added in 0.3.0; copies now get their own lock.
- `predict_batch` disagreed with `predict` in strategic mode (it skipped the strategic blend) and for `MultiLabelAdaptiveClassifier` (it softmaxed sigmoid scores). Both now return what `predict` returns, which also fixes the server's `/predict_batch` for those classifiers.

- `merge_classifiers`: classes taken from the other classifier were not searchable afterwards (the prototype index was not rebuilt, so the merged classifier got 0% on them); merging into an empty classifier raised; training counts were not combined, so merged classes got no head weight; stale calibration was kept; example objects were shared with the source. Merging a classifier into itself is now a no-op.

- `add_examples` now rejects blank texts and blank labels (with the index of the first offender) instead of learning them; a blank text carries no information but still counted as an example of its class and moved its prototype. **Behaviour change:** code that passes empty rows now gets a `ValueError`. `select_representative_examples(k=0)` returns an empty list instead of failing inside scikit-learn.

- `add_examples` is now all-or-nothing. An error (or Ctrl-C) while training used to leave the label map ahead of the neural head, so every later prediction raised "selected index k out of range" and every retry failed; or left the search index stale, so predictions silently returned the wrong class. Texts are encoded before any state changes, and on failure everything is restored.
- Elastic Weight Consolidation did nothing: the penalty compared the old head with its own stored copy, so it was always zero and never reached the head being trained. It now penalises how far the live head's existing rows move. With the default strength (5.0) the effect is small: re-running the class-incremental experiment from the README (MiniLM, ag_news and emotion, 3 seeds) moved final accuracy by -0.004 and +0.016, old-class loss by -0.002 and +0.002 and new-class recall by -0.012 and 0.000, all within noise. `EWC.ewc_loss` takes the model being trained as `model=`.
- `clear_memory()` with no labels cleared only the prototypes; the label map, training counts, calibration and neural head stayed, so `predict` kept answering from the head alone. It now empties the classifier.
- A classifier loaded from disk kept only a few examples per class and re-averaged its prototype from them on the first `add_examples`, jerking the prototype away from where it was saved (0.32 versus 0.03 for a 30-example class). Prototypes now fold in new examples with the weight of the examples they were saved from. The neural head is still retrained from the examples held in memory.
- Saving a classifier that was loaded from ONNX wrote the ONNX folder as `model_name`, so the copy could not be loaded once that folder was gone. It keeps the original encoder name.
- `merge_classifiers` could deadlock when two threads merged each other; it never holds two classifiers' locks at once. It also keeps the other classifier's recorded class spread (OOD radii).
- `add_examples` encoded every text in one forward pass, ignoring `batch_size`; large lists could run out of memory.
- `remove_examples` given a bare string treated it as a set of characters and removed nothing; it now raises.
- `MultiLabelAdaptiveClassifier`: an explicit `threshold` in `predict_multilabel` was ignored for every label that had a learned threshold; a saved multi-label classifier loaded through `AdaptiveClassifier.load` (or the server) came back as single-label with softmax scores; `calibrate`, `calibration_report` and `predict_set` ran but produced meaningless numbers and now raise `NotImplementedError`; `predict` lacked the `abstain_*` options; thresholds went stale after `merge_classifiers`/`remove_examples`; strategic mode, which does not apply to sigmoid outputs, is refused.
- Server: updates, `/info` and saving no longer block the event loop (a save with ONNX export froze `/health` for seconds); a multi-label classifier is refused with a clear message instead of failing every request with 500.
- Strategic mode: `predict_strategic` and `predict_robust` accept empty text and negative `k` that `predict` rejects; `evaluate_strategic_robustness` raised `KeyError` unless the levels included 0.0 and 1.0, and accepted levels outside [0, 1].
- scikit-learn wrapper: `partial_fit(classes=[...])` now lists every declared class in `classes_`; labels that would become the same class name (`1` and `"1"`), `None` and `NaN` are rejected; the docs wrongly said a fitted estimator cannot be pickled.
- `PrototypeMemory.get_strategic_prototypes` raised `NameError` (a missing import) whenever strategic prototypes existed.

- `examples/basic_usage.py` crashed on a default CPU install (it called `.model.eval()` on an ONNX Runtime model). The docs no longer recommend a `--workers` flag the server does not have, say the Docker image is unverified, or omit parameters (`use_onnx`, `abstain_*`, `include_onnx`, `pooling`, ...) that exist.
- The release workflow ran its own cut-down test step with PyPI credentials in scope. It now calls the full test workflow, only the publish job holds credentials, and the release tag must match the package version.

### Added
- Tests for strategic mode (`tests/test_strategic.py`); README and `docs/API.md` document it and `MultiLabelAdaptiveClassifier`, and the settings added in 0.3.0.
- `benchmark` and `scripts` extras pin `datasets<4` and `huggingface-hub<1.0`, which keeps `transformers` working.
- `examples/javascript/portable_inference.mjs`: a tested Node.js port of the portable inference, and a Docker smoke test (`scripts/docker_smoke.py`, `.github/workflows/docker.yml`) that builds the serving image and queries it.
- README: measured guidance on tuning `new_class_example_threshold` for new-class recall.

## 0.3.0

### Added
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ We use:
- [isort](https://github.com/PyCQA/isort) for import sorting
- [flake8](https://github.com/PyCQA/flake8) for linting

Before submitting a PR, please run:
Before submitting a PR, please run (install the tools first: `pip install black isort flake8`):
```bash
black .
isort .
Expand Down
27 changes: 21 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ Adaptive Classifier is a PyTorch-based machine learning library that revolutioni
- **📈 Continuous Learning** - Add new examples without catastrophic forgetting
- **🔄 Dynamic Classes** - Add new classes at runtime without retraining
- **⏱️ Zero Downtime** - Update models in production without service interruption
- **✅ Knows What It Doesn't Know** - Calibrated confidence, prediction sets, out-of-distribution detection and abstention
- **🧹 Fixable** - Forget a wrong example or a whole class without retraining
- **🎣 Active Learning & Drift** - Suggest which texts to label next and detect when traffic has shifted
- **🔌 Easy to Adopt** - scikit-learn interface, HTTP server, async methods, and ONNX inference outside Python

### 🛡️ **Advanced Defense**
- **🎮 Strategic Classification** - Game-theoretic defense against adversarial manipulation
Expand Down Expand Up @@ -159,6 +163,17 @@ The best split depends on how well your encoder separates your classes: an encod

**Classes with few examples.** Until a class has `new_class_example_threshold` examples (default 10), the head's weight ramps up linearly from zero to `neural_weight` and the prototype takes the rest, so a brand-new class is judged almost entirely by its prototype. (Before 0.3.0 such classes used a fixed 0.3 / 0.7 split in favour of the head, and a head fitted to a handful of points could confidently outvote a correct prototype. To get a fixed split back, set both `new_class_prototype_weight` and `new_class_neural_weight`.)

**Recognising a new class sooner.** The ramp is the dial between "protect the classes you already have" and "pick up a new class quickly". Lowering `new_class_example_threshold` gives the head a say earlier. Measured with MiniLM, adding classes one at a time with 4 examples each (3 seeds, 600 test texts; averages over the added classes):

| `new_class_example_threshold` | ag_news: final accuracy / new-class recall / old-class loss | emotion: final accuracy / new-class recall / old-class loss |
|---|---|---|
| 10 (default) | 0.76 / 0.77 / 0.06 | 0.35 / 0.43 / 0.09 |
| 5 | 0.74 / 0.85 / 0.12 | 0.35 / 0.59 / 0.14 |
| 3 | 0.72 / 0.87 / 0.15 | 0.34 / 0.59 / 0.15 |
| fixed 0.3 / 0.7 (pre-0.3.0 split) | 0.66 / 0.91 / 0.22 | 0.32 / 0.68 / 0.18 |

Each step buys new-class recall with old-class accuracy. If a new class that is missed matters more to you than one that disturbs the others, try 5; the default favours stability. Two datasets and one encoder, so check it on your own data.

**Prototype sharpness.** Prototype scores are `softmax(-distance² / prototype_temperature)` with a default temperature of 0.25. Lower values make the nearest class stand out more. Set `prototype_temperature` to `None` for the scoring used before 0.3.0, which barely separated the nearest class from the rest.

**Head training.** The head is trained for at least `head_steps` optimiser steps (default 300) with a cosine-decayed `head_learning_rate` (default 0.003). That takes roughly 1-2 seconds on a small memory; lower `head_steps` (for example to 100) if you add examples very frequently. Before 0.3.0 the head got about ten steps, too few to learn even a simple XOR pattern, which prototypes alone cannot separate.
Expand Down Expand Up @@ -336,7 +351,7 @@ clf.partial_fit(["App crashes on login"], ["bug"])
clf.classifier_.save("./model") # the underlying AdaptiveClassifier
```

`fit` starts over each time; `partial_fit` adds to what is already learned. A fitted estimator holds a FAISS index and can't be pickled, so save `classifier_` instead.
`fit` starts over each time; `partial_fit` adds to what is already learned. A fitted estimator can be pickled, but that copies the whole encoder, so save `classifier_` instead.

### Calibrated Confidence and Prediction Sets

Expand Down Expand Up @@ -473,17 +488,17 @@ print(f"Label-specific thresholds: {stats['label_thresholds']}")
config = {
'enable_strategic_mode': True,
'cost_function_type': 'linear',
'cost_coefficients': {
'sentiment_words': 0.5, # Cost to change sentiment-bearing words
'length_change': 0.1, # Cost to modify text length
'word_substitution': 0.3 # Cost to substitute words
},
# One cost per embedding dimension (384 for all-MiniLM-L6-v2, 768 for BERT-base):
# how expensive it is for an adversary to push that dimension upward.
'cost_coefficients': [0.3] * 768,
'strategic_blend_regular_weight': 0.6, # Weight for regular predictions
'strategic_blend_strategic_weight': 0.4 # Weight for strategic predictions
}

classifier = AdaptiveClassifier("bert-base-uncased", config=config)
classifier.add_examples(texts, labels)
# If cost_coefficients has the wrong length, strategic mode is switched off and the
# reason is logged; check classifier.strategic_mode.

# Robust predictions that consider potential manipulation
text = "This product has amazing quality features!"
Expand Down
Loading
Loading