Spaces:
Sleeping
Sleeping
| """SentimentModel β a thin, educational wrapper around a HuggingFace classifier. | |
| Design notes (the "why", since the "what" is short): | |
| * Singleton lifecycle: transformer weights are ~500MB in RAM. We load them | |
| exactly once at server startup (see main.py lifespan) instead of per | |
| request β loading takes seconds, inference takes milliseconds. | |
| * Lazy imports: torch/transformers are imported INSIDE methods, not at module | |
| top. That means importing this module (e.g. from unit tests or CI) costs | |
| nothing, and the heavy libraries are only pulled in when the model actually | |
| loads. CI runs the whole API test suite without torch installed. | |
| """ | |
| import asyncio | |
| from typing import Protocol | |
| from app.model_registry import ( | |
| ModelConfig, | |
| ModelTask, | |
| get_default_model_id, | |
| get_model_config, | |
| resolve_model_source, | |
| ) | |
| class SentimentModel: | |
| # A RoBERTa-base encoder fine-tuned on ~124M tweets for 3-class sentiment. | |
| # Kept as a class constant for backward compatibility (Tasks 1β7 / /api/model); | |
| # per-instance model choice now flows through the registry ModelConfig below. | |
| MODEL_NAME = "cardiffnlp/twitter-roberta-base-sentiment-latest" | |
| # RoBERTa's positional embeddings cap sequence length; longer inputs are truncated. | |
| MAX_TOKENS = 512 | |
| def __init__(self, config: ModelConfig | None = None) -> None: | |
| # Default to the registry's sentiment default so `SentimentModel()` | |
| # (no args) still means twitter-roberta, exactly as in Tasks 1β7. | |
| self._config = config or get_model_config(get_default_model_id(ModelTask.SENTIMENT)) | |
| self.model_name = self._config.name | |
| self._tokenizer = None | |
| self._model = None | |
| self.device: str | None = None | |
| # Canonical labels from the registry β available before load() and | |
| # decoupled from raw HF config.id2label casing/order. | |
| self.labels: list[str] = list(self._config.labels) | |
| def is_loaded(self) -> bool: | |
| return self._model is not None | |
| def load(self) -> None: | |
| """Download (first run only) and load tokenizer + model weights.""" | |
| import torch | |
| from transformers import AutoModelForSequenceClassification, AutoTokenizer | |
| # Apple Silicon GPU (MPS) when available; plain CPU in Docker/CI. | |
| self.device = "mps" if torch.backends.mps.is_available() else "cpu" | |
| # Local weights dir if it exists, HF Hub name otherwise β never let a | |
| # missing local folder break a fresh clone or the Docker build. | |
| source = resolve_model_source(self._config) | |
| self._tokenizer = AutoTokenizer.from_pretrained(source) | |
| self._model = AutoModelForSequenceClassification.from_pretrained(source) | |
| self._model.to(self.device) | |
| # eval() disables dropout etc. β we want deterministic inference, not training. | |
| self._model.eval() | |
| # Canonical label names from the registry β NOT config.id2label. HF | |
| # DistilBERT reports NEGATIVE/POSITIVE (uppercase) and FinBERT's index | |
| # order differs; the registry tuple is the single source of truth so | |
| # API score keys stay stable across models. | |
| self.labels = list(self._config.labels) | |
| def predict(self, texts: list[str]) -> list[dict]: | |
| """Classify a batch of texts. The full flow, spelled out: | |
| 1. Tokenize: text -> subword IDs. padding=True pads the batch to the | |
| longest member so it forms one rectangular tensor; truncation | |
| enforces the 512-token model limit. | |
| 2. Forward pass: one batched call. Batching is THE key GPU win β | |
| classifying 500 texts in one tensor is dramatically faster than | |
| 500 single-text calls, because the per-call overhead is paid once. | |
| 3. Softmax: the model outputs logits (unnormalized scores). Softmax | |
| maps them to probabilities that sum to 1, which is what humans | |
| (and our confidence bars) actually want to read. | |
| """ | |
| import torch | |
| assert self.is_loaded, "call load() before predict()" | |
| enc = self._tokenizer( | |
| texts, | |
| return_tensors="pt", | |
| padding=True, | |
| truncation=True, | |
| max_length=self.MAX_TOKENS, | |
| ).to(self.device) | |
| # no_grad(): we're not training, so skip building the autograd graph | |
| # β less memory, more speed. | |
| with torch.no_grad(): | |
| logits = self._model(**enc).logits | |
| probs = torch.softmax(logits, dim=-1).cpu() | |
| results = [] | |
| for row in probs: | |
| scores = {label: round(float(p), 4) for label, p in zip(self.labels, row)} | |
| results.append({"label": max(scores, key=scores.get), "scores": scores}) | |
| return results | |
| def explain(self, text: str) -> dict: | |
| """Token-level attribution via Layer Integrated Gradients (captum). | |
| What IG computes, in one paragraph: pick a "no information" baseline | |
| input (here: all padding tokens), then walk a straight line in | |
| embedding space from that baseline to the real input in n_steps | |
| increments, accumulating the model's gradient at each step. The | |
| integral assigns each input token a share of the change in the | |
| predicted class's logit. Tokens with large positive attribution | |
| pushed the model TOWARD its prediction; negative pushed away. | |
| We attach IG at the embedding layer (LayerIntegratedGradients) | |
| because raw token IDs are discrete β you can't differentiate | |
| through an integer lookup, but you can through its embedding. | |
| RoBERTa-only for now: LayerIntegratedGradients is wired to | |
| self._model.roberta.embeddings. DistilBERT uses distilbert.* and | |
| BERT uses bert.* β other registry models can be compared but not | |
| explained until per-architecture embedding hooks are added. | |
| """ | |
| import torch | |
| from captum.attr import LayerIntegratedGradients | |
| assert self.is_loaded, "call load() before explain()" | |
| prediction = self.predict([text])[0] | |
| target = self.labels.index(prediction["label"]) | |
| enc = self._tokenizer( | |
| text, return_tensors="pt", truncation=True, max_length=self.MAX_TOKENS | |
| ).to(self.device) | |
| input_ids = enc["input_ids"] | |
| attention_mask = enc["attention_mask"] | |
| def forward(ids, mask): | |
| return self._model(input_ids=ids, attention_mask=mask).logits | |
| lig = LayerIntegratedGradients(forward, self._model.roberta.embeddings) | |
| # Baseline = same length, but every content token replaced by <pad>, | |
| # keeping the <s>/</s> specials in place. "What would the model say | |
| # about a sentence with no words in it?" | |
| baseline = torch.full_like(input_ids, self._tokenizer.pad_token_id) | |
| baseline[0, 0] = input_ids[0, 0] | |
| baseline[0, -1] = input_ids[0, -1] | |
| attributions = lig.attribute( | |
| inputs=input_ids, | |
| baselines=baseline, | |
| additional_forward_args=(attention_mask,), | |
| target=target, | |
| n_steps=50, # more steps = better integral approximation, slower | |
| ) | |
| # One attribution per (token, embedding_dim); collapse the embedding | |
| # axis and L2-normalize so the UI gets comparable magnitudes. | |
| scores = attributions.sum(dim=-1).squeeze(0) | |
| scores = scores / (torch.norm(scores) + 1e-9) | |
| tokens = self._tokenizer.convert_ids_to_tokens(input_ids[0]) | |
| special = {self._tokenizer.bos_token, self._tokenizer.eos_token, self._tokenizer.pad_token} | |
| token_attrs = [ | |
| # "Δ " is the BPE marker for "preceded by a space" β swap it back | |
| # for display so tokens rejoin into readable text. | |
| {"token": tok.replace("Δ ", " "), "attribution": round(float(a), 4)} | |
| for tok, a in zip(tokens, scores) | |
| if tok not in special | |
| ] | |
| return {**prediction, "tokens": token_attrs} | |
| # Case-insensitive map from a detector's raw class name to the app's canonical | |
| # ("human", "ai") pair. Detectors label the same two classes with different | |
| # words β Human/AI, human/ai, real/fake β so we normalize here instead of | |
| # trusting each checkpoint's casing or vocabulary. | |
| _CANONICAL_DETECTOR_LABELS = { | |
| "human": "human", | |
| "real": "human", | |
| "ai": "ai", | |
| "fake": "ai", | |
| } | |
| def _canonicalize_detector_labels(id2label: dict[int, str], model_id: str) -> list[str]: | |
| """Map a softmax detector's raw id2label onto canonical labels in LOGIT | |
| ORDER (index 0 first). | |
| Read by integer index, not dict-iteration order: a checkpoint that puts | |
| "ai" at index 0 must still line its label up with logit 0. Unknown raw | |
| labels fail loudly β guessing which class means "ai" would silently invert | |
| every score for that model. | |
| """ | |
| ordered = [] | |
| for i in range(len(id2label)): | |
| raw = id2label[i] | |
| canonical = _CANONICAL_DETECTOR_LABELS.get(raw.strip().lower()) | |
| if canonical is None: | |
| raise ValueError( | |
| f"Detector '{model_id}' exposes unmappable label(s) " | |
| f"{list(id2label.values())}; cannot canonicalize to " | |
| "('human', 'ai'). Refusing to guess." | |
| ) | |
| ordered.append(canonical) | |
| return ordered | |
| def _load_desklib_detector(source: str): | |
| """Instantiate desklib's custom single-logit detector, copied VERBATIM from | |
| its model card (models/desklib-ai-text-detector-v1.01/README.md). | |
| The checkpoint is a custom PreTrainedModel subclass β DeBERTa-v3-large base | |
| + mean pooling + a single-logit head β not a standard sequence classifier. | |
| AutoModelForSequenceClassification would bolt a fresh 2-logit head on top | |
| and drop the trained single-logit head, so it must be built explicitly. | |
| """ | |
| import torch | |
| import torch.nn as nn | |
| from transformers import AutoConfig, AutoModel, PreTrainedModel | |
| class DesklibAIDetectionModel(PreTrainedModel): | |
| config_class = AutoConfig | |
| def __init__(self, config): | |
| super().__init__(config) | |
| # Initialize the base transformer model. | |
| self.model = AutoModel.from_config(config) | |
| # Define a classifier head. | |
| self.classifier = nn.Linear(config.hidden_size, 1) | |
| # Model card calls self.init_weights(); transformers 5.x moved the | |
| # tied-weights bookkeeping (all_tied_weights_keys) into post_init(), | |
| # which then calls init_weights() itself. Calling init_weights() | |
| # directly on v5 raises AttributeError, so use post_init(). | |
| self.post_init() | |
| def forward(self, input_ids, attention_mask=None, labels=None): | |
| # Forward pass through the transformer | |
| outputs = self.model(input_ids, attention_mask=attention_mask) | |
| last_hidden_state = outputs[0] | |
| # Mean pooling | |
| input_mask_expanded = ( | |
| attention_mask.unsqueeze(-1).expand(last_hidden_state.size()).float() | |
| ) | |
| sum_embeddings = torch.sum(last_hidden_state * input_mask_expanded, dim=1) | |
| sum_mask = torch.clamp(input_mask_expanded.sum(dim=1), min=1e-9) | |
| pooled_output = sum_embeddings / sum_mask | |
| # Classifier | |
| logits = self.classifier(pooled_output) | |
| loss = None | |
| if labels is not None: | |
| loss_fct = nn.BCEWithLogitsLoss() | |
| loss = loss_fct(logits.view(-1), labels.float()) | |
| output = {"logits": logits} | |
| if loss is not None: | |
| output["loss"] = loss | |
| return output | |
| return DesklibAIDetectionModel.from_pretrained(source) | |
| class DetectorModel: | |
| """Detector-family counterpart of SentimentModel: same interface the cache | |
| and routes rely on (load/predict/is_loaded/labels/device), but a | |
| task-appropriate load path and output head. | |
| Educational point: "binary classifier" hides two different architectures. | |
| A 2-logit softmax head and a 1-logit sigmoid head produce the same kind of | |
| answer, but conflating them mangles the probabilities β you can't softmax a | |
| single logit. So this wrapper branches on the registry's output_adapter: | |
| desklib emits ONE logit where sigmoid(logit) = P(ai); fakespot/oxidane emit | |
| two logits softmaxed over [human, ai]. Both funnel into the same | |
| {"human", "ai"} score dict so the API response shape is identical | |
| regardless of the underlying architecture. | |
| """ | |
| # RoBERTa caps at 512; DeBERTa-v3 handles more, but inputs are already | |
| # bounded to 2000 chars upstream, so 512 tokens is ample and universal. | |
| MAX_TOKENS = 512 | |
| def __init__(self, config: ModelConfig) -> None: | |
| self._config = config | |
| self.model_name = config.name | |
| self._tokenizer = None | |
| self._model = None | |
| self.device: str | None = None | |
| # Canonical labels from the registry β ("human", "ai"). Available | |
| # before load(); the softmax path additionally derives a logit-ordered | |
| # label list from config.id2label at load time. | |
| self.labels: list[str] = list(config.labels) | |
| self._softmax_labels: list[str] | None = None | |
| def is_loaded(self) -> bool: | |
| return self._model is not None | |
| def load(self) -> None: | |
| """Load tokenizer + weights via the per-architecture path the registry | |
| selected. Same local-dir β Hub-name fallback as SentimentModel.""" | |
| import torch | |
| from transformers import AutoModelForSequenceClassification, AutoTokenizer | |
| self.device = "mps" if torch.backends.mps.is_available() else "cpu" | |
| source = resolve_model_source(self._config) | |
| self._tokenizer = AutoTokenizer.from_pretrained(source) | |
| if self._config.output_adapter == "single_logit_sigmoid": | |
| # desklib: custom class β Auto* would mis-load the checkpoint. | |
| self._model = _load_desklib_detector(source) | |
| else: | |
| # fakespot/oxidane: standard sequence classifier. Derive canonical | |
| # labels from the checkpoint's own id2label so scores line up with | |
| # the logits even when a detector orders its classes ai-first. | |
| self._model = AutoModelForSequenceClassification.from_pretrained(source) | |
| self._softmax_labels = _canonicalize_detector_labels( | |
| self._model.config.id2label, self._config.id | |
| ) | |
| self._model.to(self.device) | |
| # eval() disables dropout β deterministic inference, not training. | |
| self._model.eval() | |
| def predict(self, texts: list[str]) -> list[dict]: | |
| import torch | |
| assert self.is_loaded, "call load() before predict()" | |
| enc = self._tokenizer( | |
| texts, | |
| return_tensors="pt", | |
| padding=True, | |
| truncation=True, | |
| max_length=self.MAX_TOKENS, | |
| ).to(self.device) | |
| with torch.no_grad(): | |
| if self._config.output_adapter == "single_logit_sigmoid": | |
| # desklib's custom forward returns a plain dict, not a | |
| # transformers ModelOutput β read ["logits"], not .logits. | |
| logits = self._model( | |
| input_ids=enc["input_ids"], attention_mask=enc["attention_mask"] | |
| )["logits"] | |
| else: | |
| logits = self._model(**enc).logits | |
| if self._config.output_adapter == "single_logit_sigmoid": | |
| # One logit per text: sigmoid(logit) = P(ai). Emit both sides so the | |
| # response shape matches the softmax detectors exactly. | |
| p_ai = torch.sigmoid(logits.squeeze(-1)).cpu() | |
| return [ | |
| { | |
| "label": "ai" if float(p) >= 0.5 else "human", | |
| "scores": {"human": round(1 - float(p), 4), "ai": round(float(p), 4)}, | |
| } | |
| for p in p_ai | |
| ] | |
| # softmax adapter: probabilities over the canonical, logit-ordered labels. | |
| probs = torch.softmax(logits, dim=-1).cpu() | |
| results = [] | |
| for row in probs: | |
| scores = {label: round(float(p), 4) for label, p in zip(self._softmax_labels, row)} | |
| results.append({"label": max(scores, key=scores.get), "scores": scores}) | |
| return results | |
| class BaseTextModel(Protocol): | |
| """What the cache and routes actually need from a model. SentimentModel | |
| satisfies this today; DetectorModel (Task 19) will too. The cache must | |
| not assume every model is a SentimentModel β detector checkpoints have | |
| different architectures and output heads.""" | |
| labels: list[str] | |
| device: str | None | |
| is_loaded: bool | |
| def load(self) -> None: ... | |
| def predict(self, texts: list[str]) -> list[dict]: ... | |
| def build_model(cfg: ModelConfig) -> BaseTextModel: | |
| if cfg.task == ModelTask.SENTIMENT: | |
| return SentimentModel(cfg) | |
| if cfg.task == ModelTask.AI_TEXT_DETECTION: | |
| return DetectorModel(cfg) | |
| raise ValueError(f"Unsupported model task: {cfg.task}") | |
| async def get_or_load_model(app, model_id: str) -> BaseTextModel: | |
| if model_id in app.state.model_cache: | |
| return app.state.model_cache[model_id] | |
| if model_id not in app.state.model_locks: | |
| app.state.model_locks[model_id] = asyncio.Lock() | |
| async with app.state.model_locks[model_id]: | |
| # Double-checked: another coroutine may have finished loading while we | |
| # awaited the lock, so re-check the cache before paying to load again. | |
| if model_id not in app.state.model_cache: | |
| cfg = get_model_config(model_id) | |
| m = build_model(cfg) | |
| # ~500MB of weights load synchronously; run it off the event loop | |
| # so other requests (health, in-flight analyze) stay responsive. | |
| await asyncio.to_thread(m.load) | |
| app.state.model_cache[model_id] = m | |
| return app.state.model_cache[model_id] | |