aegis-ml / app /classifiers /hf2_classifier.py
billybitcoin's picture
Deploy Aegis-ML to HF Spaces
5c76335 verified
Raw
History Blame Contribute Delete
7.07 kB
"""
app/classifiers/hf2_classifier.py
===================================
Phase 3 (HF2 Ultra) runtime classifier — loads and serves AegisMTModel.
Returns an extended predict dict (backward-compatible with Phase 1/2):
{
# Existing keys (all classifiers)
"label": "benign" | "malicious",
"malicious_prob": float,
"benign_prob": float,
# Extended keys (hf2 only)
"threat_category_probs": {
"prompt_injection": float,
"jailbreak": float,
"data_exfiltration": float,
"canary_leak": float,
"pii_leak": float,
"harmful_content": float,
"none": float,
},
"threat_category": str, # argmax of threat_category_probs
"classifier_stage": "hf2",
"preprocessing_flags": dict, # from TextPreprocessor
}
The temperature scaling scalar T* is read from config.json and applied
before converting logits to probabilities.
TextPreprocessor (Unicode normalization + invisible char detection) runs
internally before every inference call.
Lazy imports: torch/transformers are only imported when .load() is called,
so the rest of the service starts without the ML stack.
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class HF2Classifier:
"""
Wraps a trained AegisMTModel for runtime inference.
The model directory must contain:
model.pt — PyTorch state dict (from AegisMTModel.save_pretrained)
config.json — config with temperature_scaling + aegis_* metadata
tokenizer files — tokenizer.json / spiece.model
"""
def __init__(self, model_path: str) -> None:
self.model_path = model_path
self._module = None
self._tokenizer = None
self._temperature: float = 1.0
self._device = None
self._loaded = False
self._preprocessor = None
# ── Lifecycle ─────────────────────────────────────────────────────────────
@staticmethod
def _probe_device(torch) -> int:
"""Return GPU device index (0) if available and functional, else -1 (CPU)."""
if not torch.cuda.is_available():
return -1
try:
t = torch.zeros(4).cuda()
torch.isfinite(t)
return 0
except RuntimeError as exc:
logger.warning(
"GPU detected but not functional (%s) — falling back to CPU.", exc
)
return -1
def load(self) -> None:
"""Load the AegisMTModel. Heavy imports deferred to here."""
import torch
from transformers import AutoTokenizer
from training.phase3_hf2.model import AegisMTModel
from app.classifiers.text_preprocessor import TextPreprocessor
path = Path(self.model_path)
if not path.exists():
raise FileNotFoundError(
f"HF2 model not found at {path}. "
"Run 'python -m training.phase3_hf2.train' first."
)
device_id = self._probe_device(torch)
device_label = "CUDA/ROCm" if device_id == 0 else "CPU"
logger.info("Loading HF2 classifier from %s on %s", path, device_label)
aegis_model, temperature = AegisMTModel.from_pretrained(path)
self._module = aegis_model.module
self._temperature = temperature
device = torch.device(f"cuda:{device_id}" if device_id >= 0 else "cpu")
self._module.to(device)
self._module.eval()
self._device = device
self._tokenizer = AutoTokenizer.from_pretrained(str(path))
self._preprocessor = TextPreprocessor()
self._loaded = True
logger.info(
"HF2 classifier loaded successfully (T=%.4f).", self._temperature
)
def is_loaded(self) -> bool:
return self._loaded
# ── Inference ──────────────────────────────────────────────────────────────
async def predict(self, text: str) -> dict[str, Any]:
"""
Async prediction wrapper.
Returns the extended predict dict (see module docstring).
"""
if not self._loaded or self._module is None:
raise RuntimeError("HF2Classifier 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."""
import torch
import torch.nn.functional as F
from training.phase3_hf2.model import ID2THREAT, THREAT_CATEGORIES
assert self._module is not None
assert self._tokenizer is not None
assert self._preprocessor is not None
# ── Preprocessing (Unicode normalization + invisible char detection) ──
cleaned_text, preprocess_flags = self._preprocessor.preprocess(text)
# ── Tokenise ──────────────────────────────────────────────────────────
inputs = self._tokenizer(
cleaned_text,
return_tensors="pt",
truncation=True,
max_length=512,
padding=True,
)
inputs = {k: v.to(self._device) for k, v in inputs.items()}
# ── Inference ─────────────────────────────────────────────────────────
with torch.no_grad():
outputs = self._module(**inputs)
binary_logits = outputs.binary_logits
threat_logits = outputs.threat_logits
# Apply temperature scaling to binary logits
binary_probs = F.softmax(binary_logits / self._temperature, dim=-1)[0]
threat_probs = F.softmax(threat_logits, dim=-1)[0]
malicious_prob = float(binary_probs[1].item())
benign_prob = float(binary_probs[0].item())
label = "malicious" if malicious_prob >= 0.5 else "benign"
# Build threat_category_probs dict
threat_probs_dict = {
THREAT_CATEGORIES[i]: float(threat_probs[i].item())
for i in range(len(THREAT_CATEGORIES))
}
threat_category = max(threat_probs_dict, key=threat_probs_dict.get)
return {
"label": label,
"malicious_prob": malicious_prob,
"benign_prob": benign_prob,
"threat_category_probs": threat_probs_dict,
"threat_category": threat_category,
"classifier_stage": "hf2",
"preprocessing_flags": preprocess_flags,
}