aegis-ml / app /classifiers /sklearn_classifier.py
billybitcoin's picture
Deploy Aegis-ML to HF Spaces
5c76335 verified
Raw
History Blame Contribute Delete
3.56 kB
"""
app/classifiers/sklearn_classifier.py
======================================
Phase 1 classifier — TF-IDF + Logistic Regression (or Random Forest).
Extremely lightweight: <50 MB RAM, <5 ms inference latency.
Model is serialised with joblib and loaded once at startup.
Async wrapper: uses asyncio.to_thread so the sync sklearn inference
doesn't block the event loop.
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Any
import joblib
import numpy as np
from sklearn.pipeline import Pipeline
logger = logging.getLogger(__name__)
class SklearnClassifier:
"""
Wraps a trained scikit-learn Pipeline (TF-IDF → classifier).
Expected pipeline steps:
- "tfidf": TfidfVectorizer
- "clf": LogisticRegression or RandomForestClassifier
The pipeline must have been trained with classes [0=benign, 1=malicious].
"""
def __init__(self, model_path: str) -> None:
self.model_path = model_path
self._pipeline: Pipeline | None = None
self._loaded = False
# ── Lifecycle ─────────────────────────────────────────────────────────────
def load(self) -> None:
"""Load the serialised model from disk. Called once during app startup."""
path = Path(self.model_path)
if not path.exists():
raise FileNotFoundError(
f"scikit-learn model not found at {path}. "
"Run 'python -m training.phase1_sklearn.train' first."
)
self._pipeline = joblib.load(path)
self._loaded = True
logger.info("scikit-learn classifier loaded from %s", path)
def is_loaded(self) -> bool:
return self._loaded
# ── Inference ──────────────────────────────────────────────────────────────
async def predict(self, text: str) -> dict[str, Any]:
"""
Async prediction wrapper.
Runs sync sklearn inference in a thread pool to avoid blocking the loop.
Returns:
{
"label": "benign" | "malicious",
"malicious_prob": float,
"benign_prob": float,
}
"""
if not self._loaded or self._pipeline is None:
raise RuntimeError("SklearnClassifier is not loaded. Call .load() first.")
return await asyncio.to_thread(self._predict_sync, text)
def _predict_sync(self, text: str) -> dict[str, Any]:
"""Synchronous inference — called inside a thread pool worker."""
pipeline = self._pipeline
assert pipeline is not None
# predict_proba returns shape (n_samples, n_classes)
proba: np.ndarray = pipeline.predict_proba([text])[0]
# Classes are [0=benign, 1=malicious] by sklearn convention
classes = pipeline.classes_
class_to_idx = {c: i for i, c in enumerate(classes)}
benign_idx = class_to_idx.get(0, 0)
malicious_idx = class_to_idx.get(1, 1)
benign_prob = float(proba[benign_idx])
malicious_prob = float(proba[malicious_idx])
label = "malicious" if malicious_prob > benign_prob else "benign"
return {
"label": label,
"malicious_prob": malicious_prob,
"benign_prob": benign_prob,
}