aegis-ml / app /classifiers /hf_classifier.py
billybitcoin's picture
Deploy Aegis-ML to HF Spaces
5c76335 verified
Raw
History Blame Contribute Delete
6.04 kB
"""
app/classifiers/hf_classifier.py
=================================
Phase 2 classifier — fine-tuned DistilBERT / DeBERTa-v3-small with optional
4-bit quantisation (bitsandbytes) for low VRAM usage.
Falls back gracefully to CPU if no GPU is available.
Async wrapper: runs torch inference in a thread pool.
This module imports torch/transformers lazily so the base service can start
without the heavy ML stack installed (using CLASSIFIER_TYPE=sklearn).
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class HFClassifier:
"""
Wraps a fine-tuned HuggingFace sequence classification model.
The model directory must contain:
- config.json
- tokenizer files
- model weights (model.safetensors or adapter_model.safetensors for QLoRA)
Label mapping expected in config.json:
id2label: {"0": "BENIGN", "1": "MALICIOUS"}
"""
def __init__(self, model_path: str, use_4bit: bool = False) -> None:
self.model_path = model_path
self.use_4bit = use_4bit
self._pipeline = None
self._loaded = False
# ── Lifecycle ─────────────────────────────────────────────────────────────
@staticmethod
def _probe_device(torch) -> int:
"""
Return GPU device index (0) if CUDA/ROCm is available AND functional,
otherwise -1 (CPU). A quick kernel probe catches broken HIP setups
(e.g. PyTorch built for a different GPU architecture) before they
silently trigger the fail-secure block on every inference call.
"""
if not torch.cuda.is_available():
return -1
try:
t = torch.zeros(4).cuda()
torch.isfinite(t) # Exercises a GPU kernel; fails fast on bad HIP builds
return 0
except RuntimeError as exc:
logger.warning(
"GPU detected but not functional (%s) — falling back to CPU. "
"Set HSA_OVERRIDE_GFX_VERSION or reinstall PyTorch with matching "
"ROCm support to enable GPU inference.",
exc,
)
return -1
def load(self) -> None:
"""
Load the fine-tuned model. Heavy imports are deferred to here
so the rest of the app doesn't pay the import cost when using sklearn.
"""
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification, pipeline
path = Path(self.model_path)
if not path.exists():
raise FileNotFoundError(
f"HuggingFace model not found at {path}. "
"Run 'python -m training.phase2_hf.train' first."
)
device = self._probe_device(torch)
device_label = "CUDA/ROCm" if device == 0 else "CPU"
logger.info("Loading HF classifier from %s on %s", path, device_label)
# Optional 4-bit quantisation for GPU inference (reduces VRAM ~4x)
model_kwargs: dict[str, Any] = {}
if self.use_4bit and device == 0:
try:
from transformers import BitsAndBytesConfig
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
)
model_kwargs["quantization_config"] = bnb_config
logger.info("4-bit quantisation enabled for HF inference")
except ImportError:
logger.warning(
"bitsandbytes not installed — 4-bit inference disabled."
)
tokenizer = AutoTokenizer.from_pretrained(str(path))
model = AutoModelForSequenceClassification.from_pretrained(
str(path), dtype=torch.float32, **model_kwargs
)
self._pipeline = pipeline(
"text-classification",
model=model,
tokenizer=tokenizer,
device=device,
top_k=None, # Return all class scores
truncation=True,
max_length=512,
)
self._loaded = True
logger.info("HF classifier loaded successfully.")
def is_loaded(self) -> bool:
return self._loaded
# ── Inference ──────────────────────────────────────────────────────────────
async def predict(self, text: str) -> dict[str, Any]:
"""
Async prediction wrapper.
Returns:
{
"label": "benign" | "malicious",
"malicious_prob": float,
"benign_prob": float,
}
"""
if not self._loaded or self._pipeline is None:
raise RuntimeError("HFClassifier 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
# HF pipeline returns: [[{"label": "BENIGN", "score": 0.95}, {"label": "MALICIOUS", ...}]]
results: list[list[dict]] = pipeline(text)
scores = {r["label"].upper(): r["score"] for r in results[0]}
malicious_prob = scores.get("MALICIOUS", scores.get("LABEL_1", 0.0))
benign_prob = scores.get("BENIGN", scores.get("LABEL_0", 1.0 - malicious_prob))
label = "malicious" if malicious_prob >= 0.5 else "benign"
return {
"label": label,
"malicious_prob": float(malicious_prob),
"benign_prob": float(benign_prob),
}