Spaces:
Running
Running
| """ | |
| 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 ───────────────────────────────────────────────────────────── | |
| 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), | |
| } | |