infoshield-backend / backend /classifier.py
Pavle-17's picture
InfoShield backend (Docker)
1cf82f0
Raw
History Blame Contribute Delete
7.58 kB
# =============================================================================
# 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