aegis-ml / app /classifiers /cascade_classifier.py
billybitcoin's picture
Sync with GitHub main (9948e2e): retrained classifiers and honest benchmarks
da6191c verified
Raw History Blame Contribute Delete
5.02 kB
"""
app/classifiers/cascade_classifier.py
=======================================
Two-stage cascade classifier: sklearn fast path → ONNX/HF slow path.
For inputs where sklearn is highly confident (prob ≥ high_threshold or
prob ≤ low_threshold), the verdict is returned immediately without invoking
the heavier ONNX/HF model. Only inputs in the ambiguous "grey zone" are
escalated.
Measured on the external benchmark with the default 0.05/0.95 band (see
docs/BENCHMARKS.md): ~77% of prompts are settled by sklearn alone and ~23% are
escalated, with recall/FPR matching the transformer on its own. Hard,
out-of-distribution prompts escalate far more often (~60% of the holdout set).
The block threshold is the slow model's tuned threshold: grey-zone prompts are
scored by the slow model alone, and fast-path sklearn scores sit outside the
[low, high] band (beyond any threshold in it), so one threshold serves both.
The thresholds are tunable via CASCADE_SK_LOW_THRESHOLD /
CASCADE_SK_HIGH_THRESHOLD environment variables (see config.py).
Predict return dict is identical to individual classifiers plus a diagnostic
"stage" key ("sklearn" | "onnx") for audit logging / metrics.
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
class CascadeClassifier:
"""
Combines a fast sklearn classifier with a slower ONNX/HF classifier.
Parameters
----------
sklearn_clf : SklearnClassifier
Fast first-stage classifier (~1.5 ms/request).
slow_clf : ONNXClassifier | HFClassifier
Accurate slow-path classifier (~18 ms on GPU, ~40 ms INT8 on CPU).
low_threshold : float
sklearn malicious_prob ≤ this → benign fast path (default 0.05).
high_threshold : float
sklearn malicious_prob ≥ this → malicious fast path (default 0.95).
Both classifiers must be loaded before calling predict().
"""
def __init__(
self,
sklearn_clf,
slow_clf,
low_threshold: float = 0.05,
high_threshold: float = 0.95,
slow_clf_label: str = "onnx",
) -> None:
self.sklearn_clf = sklearn_clf
self.slow_clf = slow_clf
self.low_threshold = low_threshold
self.high_threshold = high_threshold
self.slow_clf_label = slow_clf_label
@property
def threshold(self) -> float | None:
"""Tuned threshold of the slow model (falls back to sklearn's)."""
for clf in (self.slow_clf, self.sklearn_clf):
tuned = getattr(clf, "threshold", None)
if isinstance(tuned, (int, float)):
return float(tuned)
return None
def is_loaded(self) -> bool:
return self.sklearn_clf.is_loaded() and self.slow_clf.is_loaded()
async def predict(self, text: str) -> dict[str, Any]:
"""
Run the two-stage cascade.
Returns:
{
"label": "benign" | "malicious",
"malicious_prob": float,
"benign_prob": float,
"stage": "sklearn" | "onnx", # diagnostic only
}
"""
if not self.is_loaded():
raise RuntimeError("CascadeClassifier: one or both classifiers are not loaded.")
# ── Stage 1: fast sklearn check ────────────────────────────────────────
sk_result = await self.sklearn_clf.predict(text)
sk_prob: float = sk_result["malicious_prob"]
if sk_prob >= self.high_threshold:
logger.debug(
"Cascade fast-path MALICIOUS (sklearn=%.3f ≥ %.2f)",
sk_prob,
self.high_threshold,
)
return {**sk_result, "stage": "sklearn"}
if sk_prob <= self.low_threshold:
logger.debug(
"Cascade fast-path benign (sklearn=%.3f ≤ %.2f)",
sk_prob,
self.low_threshold,
)
return {**sk_result, "stage": "sklearn"}
# ── Stage 2: grey zone → escalate to ONNX/HF ──────────────────────────
logger.debug(
"Cascade escalating to ONNX (sklearn=%.3f in grey zone [%.2f, %.2f])",
sk_prob,
self.low_threshold,
self.high_threshold,
)
slow_result = await self.slow_clf.predict(text)
# The slow model decides the grey zone on its own score. Its threshold
# is the cascade's threshold, and the two models' scores are on
# different scales (an INT8 graph's tuned threshold can be ~0.3 while
# sklearn's is ~0.55), so mixing them — e.g. max(sk, slow) — would let
# middling sklearn scores block. Strong sklearn signals are already
# honoured by the high_threshold fast path above.
return {**slow_result, "stage": self.slow_clf_label}