melkholy's picture
Ship AI text detection: enable 5 models, bake detectors, docs
8392b7d verified
Raw
History Blame Contribute Delete
18 kB
"""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)
@property
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
@property
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]