Spaces:
Runtime error
Runtime error
| # ============================================================================= | |
| # PRIVACY BY DESIGN -- InfoShield local classifier | |
| # ----------------------------------------------------------------------------- | |
| # This module classifies the *content* of a post and nothing else. It never | |
| # receives, stores, or considers usernames, handles, display names, avatars, | |
| # follower counts, or any author identity. Classification is content-only and | |
| # author-agnostic by construction: the only input is a string of post text. | |
| # All inference runs locally; no text leaves the machine from this module. | |
| # ============================================================================= | |
| """Local hate-speech / harmful-content classifier. | |
| Heavy ML imports (torch, transformers) are deferred until the model is actually | |
| loaded so the backend can start in cache-first mode without them installed. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import time | |
| from typing import Optional | |
| from config import ( | |
| FALLBACK_MODEL, | |
| FLAG_THRESHOLD, | |
| HIGH_CONFIDENCE, | |
| MAX_TOKENS, | |
| PRIMARY_MODEL, | |
| SANITY_PROBES, | |
| ) | |
| log = logging.getLogger("infoshield.classifier") | |
| # Resolving which output index is the "harmful" class is subtle: labels like | |
| # "NOT-HATE" / "NON_HATE" contain the substring "hate", so naive substring | |
| # matching inverts the map. We detect negation by TOKEN (split on space/_/-) and | |
| # only then look for a hate-like substring. | |
| _HATE_SUBSTR = ("hate", "toxic", "offensive", "abusive", "hateful") | |
| _NEG_TOKENS = {"not", "non", "none", "neither", "normal", "neutral", "clean", "no", "ok", "negative"} | |
| class HateClassifier: | |
| """Lazy, device-aware wrapper around a HF sequence-classification model.""" | |
| def __init__(self) -> None: | |
| self._pipeline = None | |
| self._tokenizer = None | |
| self._model = None | |
| self._hate_index: Optional[int] = None | |
| self.model_name: Optional[str] = None | |
| self.device: str = "cpu" | |
| self.warmup_ms: Optional[float] = None | |
| self.loaded: bool = False | |
| # -- loading ----------------------------------------------------------- | |
| def load(self) -> None: | |
| if self.loaded: | |
| return | |
| import torch # deferred | |
| from transformers import ( | |
| AutoModelForSequenceClassification, | |
| AutoTokenizer, | |
| ) | |
| self.device = "cuda" if torch.cuda.is_available() else "cpu" | |
| if self.device == "cpu": | |
| log.warning("CUDA not available -- running classifier on CPU (slower).") | |
| else: | |
| log.info("CUDA available -- running classifier on GPU.") | |
| last_err: Optional[Exception] = None | |
| for name in (PRIMARY_MODEL, FALLBACK_MODEL): | |
| try: | |
| log.info("Loading classifier '%s' ...", name) | |
| self._tokenizer = AutoTokenizer.from_pretrained(name) | |
| self._model = AutoModelForSequenceClassification.from_pretrained(name) | |
| self._model.to(self.device) | |
| self._model.eval() | |
| self.model_name = name | |
| self._resolve_hate_index() | |
| break | |
| except Exception as exc: # noqa: BLE001 - try fallback | |
| log.warning("Failed to load '%s': %s", name, exc) | |
| last_err = exc | |
| self._model = None | |
| if self._model is None: | |
| raise RuntimeError(f"Could not load any classifier model: {last_err}") | |
| self._warmup() | |
| self._sanity_check() | |
| self.loaded = True | |
| def _resolve_hate_index(self) -> None: | |
| import re | |
| id2label = {int(k): v.lower() for k, v in self._model.config.id2label.items()} | |
| for idx, label in id2label.items(): | |
| tokens = set(re.split(r"[\s_\-]+", label)) | |
| is_negated = bool(tokens & _NEG_TOKENS) | |
| is_hate = any(h in label for h in _HATE_SUBSTR) | |
| if is_hate and not is_negated: # e.g. "hate" yes, "not-hate" no | |
| self._hate_index = idx | |
| break | |
| if self._hate_index is None: | |
| # Binary models without descriptive labels: assume index 1 = positive. | |
| self._hate_index = 1 if len(id2label) == 2 else max(id2label) | |
| log.info("Resolved label map %s -> hate index %d", id2label, self._hate_index) | |
| # -- inference --------------------------------------------------------- | |
| def _hate_probability(self, text: str) -> tuple[float, bool, dict]: | |
| import torch | |
| enc = self._tokenizer( | |
| text, | |
| return_tensors="pt", | |
| truncation=True, | |
| max_length=MAX_TOKENS, | |
| return_overflowing_tokens=False, | |
| ) | |
| truncated = bool(enc["input_ids"].shape[1] >= MAX_TOKENS) | |
| enc = {k: v.to(self.device) for k, v in enc.items() if k in ("input_ids", "attention_mask")} | |
| with torch.no_grad(): | |
| logits = self._model(**enc).logits[0] | |
| probs = torch.softmax(logits, dim=-1).cpu().tolist() | |
| id2label = self._model.config.id2label | |
| raw = {id2label[i]: round(float(p), 4) for i, p in enumerate(probs)} | |
| return float(probs[self._hate_index]), truncated, raw | |
| def classify(self, text: str) -> dict: | |
| """Return a content-only classification dict for `text`.""" | |
| if not text or not text.strip(): | |
| # Empty / whitespace -> NOT_HATE without a model call. | |
| return { | |
| "label": "NOT_HATE", | |
| "flagged": False, | |
| "confidence": 0.0, | |
| "high_confidence": False, | |
| "truncated": False, | |
| "raw_scores": {}, | |
| "model": self.model_name, | |
| } | |
| if not self.loaded: | |
| self.load() | |
| prob, truncated, raw = self._hate_probability(text) | |
| flagged = prob >= FLAG_THRESHOLD | |
| return { | |
| "label": "HATE" if flagged else "NOT_HATE", | |
| "flagged": flagged, | |
| "confidence": round(prob, 4), | |
| "high_confidence": prob >= HIGH_CONFIDENCE, | |
| "truncated": truncated, | |
| "raw_scores": raw, | |
| "model": self.model_name, | |
| } | |
| # -- startup checks ---------------------------------------------------- | |
| def _warmup(self) -> None: | |
| start = time.perf_counter() | |
| self._hate_probability("This is a warmup inference for InfoShield.") | |
| self.warmup_ms = round((time.perf_counter() - start) * 1000, 1) | |
| log.info("Warmup inference on %s took ~%.1f ms", self.device, self.warmup_ms) | |
| def _sanity_check(self) -> None: | |
| """Run label-mapping probes; fail loudly if the map looks inverted.""" | |
| failures = [] | |
| for text, expect_hate in SANITY_PROBES: | |
| prob, _, _ = self._hate_probability(text) | |
| got_hate = prob >= FLAG_THRESHOLD | |
| mark = "ok" if got_hate == expect_hate else "MISMATCH" | |
| log.info("Sanity probe (%s): expect_hate=%s prob=%.3f", mark, expect_hate, prob) | |
| if got_hate != expect_hate: | |
| failures.append((text, expect_hate, prob)) | |
| if failures: | |
| raise RuntimeError( | |
| "Classifier label-mapping sanity check FAILED -- labels may be " | |
| f"inverted. Failures: {failures}" | |
| ) | |
| log.info("Label-mapping sanity check passed.") | |
| # Module-level singleton (lazy). | |
| _classifier: Optional[HateClassifier] = None | |
| def get_classifier() -> HateClassifier: | |
| global _classifier | |
| if _classifier is None: | |
| _classifier = HateClassifier() | |
| return _classifier | |