Text Classification
Transformers
ONNX
Safetensors
Transformers.js
modernbert
abacusai
toxicity
content-moderation
trust-and-safety
perspective-api
hate-speech
multi-label-classification
text-embeddings-inference
Instructions to use abacusai/Toxicity-Lens with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use abacusai/Toxicity-Lens with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-classification", model="abacusai/Toxicity-Lens")# pip install -U transformers accelerate # Load model directly from transformers import AutoTokenizer, AutoModelForSequenceClassification tokenizer = AutoTokenizer.from_pretrained("abacusai/Toxicity-Lens") model = AutoModelForSequenceClassification.from_pretrained("abacusai/Toxicity-Lens", device_map="auto") - Transformers.js
How to use abacusai/Toxicity-Lens with Transformers.js:
// npm i @huggingface/transformers import { pipeline } from '@huggingface/transformers'; // Allocate pipeline const pipe = await pipeline('text-classification', 'abacusai/Toxicity-Lens'); - Notebooks
- Google Colab
- Kaggle
File size: 14,761 Bytes
115e6b1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 | """Scoring server for Toxicity-Lens, compatible with the Perspective API.
POST /v1alpha1/comments:analyze the Perspective API's request and response JSON (also served at /v1/comments:analyze)
POST /v1alpha1/comments:suggestscore accepted so clients that send feedback keep working; stored only if FEEDBACK_FILE is set
GET /$discovery/rest discovery document, so Google API client libraries can be pointed at this server
POST /v1/moderations OpenAI-style moderation shape, from the same scores
POST /beta/litellm_basic_guardrail_api LiteLLM's Generic Guardrail API (guardrail: generic_guardrail_api, api_base: this server)
GET /health
GPU or CPU (PyTorch): MODEL_DIR=abacusai/Toxicity-Lens uvicorn server:app --host 0.0.0.0 --port 8080
CPU without PyTorch: MODEL_DIR=abacusai/Toxicity-Lens BACKEND=onnx uvicorn server:app --host 0.0.0.0 --port 8080
Optional: ONNX_FILE (default onnx/model_int8_block32.onnx, 8-bit, about 0.001 from float32 on average on any CPU; onnx/model.onnx is
the exact float32 export; onnx/model_int8.onnx is plain dynamic int8 for transformers.js), API_KEYS=k1,k2 (requests must
carry one as ?key=, x-goog-api-key or a Bearer token), PUBLIC_URL (rootUrl in the discovery document, for use behind a proxy),
MODEL_REVISION (a commit hash of the model repo: pinning it keeps every score reproducible), ORT_THREADS (caps onnxruntime's
threads; set it to the container's CPU count, for example 2 on a Hugging Face cpu-basic Space).
"""
import json
import os
import re
import threading
import uuid
import numpy as np
from fastapi import FastAPI, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse
ATTRS = ["TOXICITY", "SEVERE_TOXICITY", "IDENTITY_ATTACK", "INSULT", "PROFANITY", "THREAT", "SEXUALLY_EXPLICIT"]
MAX_BYTES = 20480 # Perspective's documented per-comment limit
MAX_TOKENS, STRIDE, BATCH = 256, 32, 32 # training, calibration and every evaluation scored 256-token windows
SENTENCE = re.compile(r"\S[^.!?。!?\n]*[.!?。!?]*") # spanAnnotations: Perspective returns per-sentence spans
# OpenAI moderation categories we can fill honestly from our attributes (plus our own "toxicity").
OPENAI_MAP = {"harassment": "INSULT", "harassment/threatening": "THREAT", "hate": "IDENTITY_ATTACK",
"sexual": "SEXUALLY_EXPLICIT", "violence": "THREAT", "toxicity": "TOXICITY"}
class Scorer:
"""Scores texts with a saved Toxicity-Lens model. A text longer than one 256-token window is scored in overlapping
windows and each attribute keeps its highest window score, so an insult deep inside a long comment still counts."""
def __init__(self, path: str, backend: str = "torch", onnx_file: str = "onnx/model_int8_block32.onnx", logits_fn=None, revision=None):
from tokenizers import Tokenizer
if not os.path.isdir(path):
from huggingface_hub import snapshot_download
weights = ["model.safetensors"] if backend == "torch" else [onnx_file, onnx_file + ".data"]
path = snapshot_download(path, revision=revision, allow_patterns=["config.json", "tokenizer.json", "tokenizer_config.json", *weights])
labels = json.load(open(f"{path}/config.json"))["id2label"]
assert [labels[str(i)] for i in range(len(labels))] == ATTRS, f"model labels {labels} are not the Perspective attribute set"
self.tok = Tokenizer.from_file(f"{path}/tokenizer.json")
self.tok.enable_truncation(MAX_TOKENS, stride=STRIDE)
self.tok.no_padding()
self.pad_id = self.tok.token_to_id(json.load(open(f"{path}/tokenizer_config.json"))["pad_token"])
self.lock = threading.Lock() # ponytail: one forward pass at a time; add micro-batching if one GPU must serve >200 requests/s
self.logits = logits_fn or (self._onnx(path, onnx_file) if backend == "onnx" else self._torch(path))
@staticmethod
def _onnx(path, onnx_file):
import onnxruntime as ort
so = ort.SessionOptions()
so.intra_op_num_threads = int(os.environ.get("ORT_THREADS", "0")) # 0 = onnxruntime's default, one thread per core it detects
sess = ort.InferenceSession(f"{path}/{onnx_file}", so, providers=["CPUExecutionProvider"])
return lambda ids, mask: sess.run(None, {"input_ids": ids, "attention_mask": mask})[0]
@staticmethod
def _torch(path):
import torch
from transformers import AutoModelForSequenceClassification
dev = "cuda" if torch.cuda.is_available() else "cpu"
dtype = torch.bfloat16 if dev == "cuda" else torch.float32
model = AutoModelForSequenceClassification.from_pretrained(path, dtype=dtype).to(dev).eval()
def run(ids, mask):
with torch.no_grad():
out = model(input_ids=torch.from_numpy(ids).to(dev), attention_mask=torch.from_numpy(mask).to(dev))
return out.logits.float().cpu().numpy()
return run
def __call__(self, texts):
"""One (summary {attribute: probability}, [(begin, end, {attribute: probability}) per window]) per text."""
wins, owner = [], []
for i, enc in enumerate(self.tok.encode_batch(list(texts))):
for w in [enc, *enc.overflowing]:
wins.append(w)
owner.append(i)
probs = []
with self.lock:
for b in range(0, len(wins), BATCH):
part = wins[b:b + BATCH]
ids = np.full((len(part), max(len(w.ids) for w in part)), self.pad_id, dtype=np.int64)
mask = np.zeros_like(ids)
for j, w in enumerate(part):
ids[j, :len(w.ids)], mask[j, :len(w.ids)] = w.ids, 1
probs.append(1 / (1 + np.exp(-self.logits(ids, mask).astype(np.float64))))
out = [[None, []] for _ in texts]
for w, i, p in zip(wins, owner, np.concatenate(probs)):
offs = [o for o, special in zip(w.offsets, w.special_tokens_mask) if not special] or [(0, 0)]
out[i][1].append((offs[0][0], offs[-1][1], dict(zip(ATTRS, p.tolist()))))
out[i][0] = p if out[i][0] is None else np.maximum(out[i][0], p)
return [(dict(zip(ATTRS, s.tolist())), spans) for s, spans in out]
def error(code: int, message: str, status: str = "INVALID_ARGUMENT"):
return JSONResponse(status_code=code, content={"error": {"code": code, "message": message, "status": status}})
def discovery_doc(root: str) -> dict:
"""Just enough of a discovery document for googleapiclient / googleapis to build a commentanalyzer v1alpha1 client."""
def method(name, request, response):
return {"id": f"commentanalyzer.comments.{name}", "path": f"v1alpha1/comments:{name}", "flatPath": f"v1alpha1/comments:{name}",
"httpMethod": "POST", "parameters": {}, "parameterOrder": [], "request": {"$ref": request}, "response": {"$ref": response}}
names = ["AnalyzeCommentRequest", "AnalyzeCommentResponse", "SuggestCommentScoreRequest", "SuggestCommentScoreResponse"]
return {"kind": "discovery#restDescription", "discoveryVersion": "v1", "id": "commentanalyzer:v1alpha1", "name": "commentanalyzer",
"version": "v1alpha1", "title": "Toxicity-Lens (compatible with the Perspective API)", "protocol": "rest",
"rootUrl": root, "servicePath": "", "baseUrl": root, "batchPath": "batch",
"parameters": {"key": {"type": "string", "location": "query"}, "alt": {"type": "string", "location": "query", "default": "json"}},
"schemas": {n: {"id": n, "type": "object"} for n in names},
"resources": {"comments": {"methods": {"analyze": method("analyze", *names[:2]), "suggestscore": method("suggestscore", *names[2:])}}}}
def create_app(scorer, model_name: str = "toxicity-lens") -> FastAPI:
app = FastAPI(title="Toxicity-Lens scorer, compatible with the Perspective API")
keys = {k for k in os.environ.get("API_KEYS", "").split(",") if k}
feedback = os.environ.get("FEEDBACK_FILE")
def denied(request: Request):
if not keys:
return None
auth = request.headers.get("authorization", "")
key = request.query_params.get("key") or request.headers.get("x-goog-api-key") or (auth[7:] if auth.lower().startswith("bearer ") else None)
if not key:
return error(403, "The request is missing a valid API key.", "PERMISSION_DENIED")
return None if key in keys else error(400, "API key not valid. Please pass a valid API key.")
async def read_json(request: Request):
try:
body = await request.json()
except Exception:
return None
return body if isinstance(body, dict) else None
@app.get("/health")
def health():
return {"status": "ok", "model": model_name}
@app.get("/$discovery/rest")
def discovery(request: Request):
return discovery_doc(os.environ.get("PUBLIC_URL", str(request.base_url)).rstrip("/") + "/")
async def analyze(request: Request):
if (refused := denied(request)) is not None:
return refused
body = await read_json(request)
if body is None:
return error(400, "Invalid JSON payload received.")
comment = body.get("comment")
text = comment.get("text") if isinstance(comment, dict) else None
if not isinstance(text, str) or not text.strip():
return error(400, "Comment must be non-empty.")
if comment.get("type", "PLAIN_TEXT") != "PLAIN_TEXT":
return error(400, "Only PLAIN_TEXT comments are supported.")
if len(text.encode("utf-8")) > MAX_BYTES:
return error(400, f"Comment text too long. Must be at most {MAX_BYTES} bytes.")
requested = body.get("requestedAttributes")
if not isinstance(requested, dict) or not requested:
return error(400, "Must specify at least one requestedAttribute.")
for attr, opts in requested.items():
if attr not in ATTRS:
return error(400, f"Attribute {attr} is not supported by this server.")
if isinstance(opts, dict) and opts.get("scoreType", "PROBABILITY") != "PROBABILITY":
return error(400, f"Score type {opts['scoreType']} is not supported.")
summary = (await run_in_threadpool(scorer, [text]))[0][0]
spans = [(0, len(text), summary)]
if body.get("spanAnnotations"):
sents = [m.span() for m in SENTENCE.finditer(text)]
spans = [(b, e, s) for (b, e), (s, _) in zip(sents, await run_in_threadpool(scorer, [text[b:e] for b, e in sents]))]
out = {}
for attr, opts in requested.items():
threshold = opts.get("scoreThreshold") if isinstance(opts, dict) else None
if threshold is not None and summary[attr] < float(threshold):
continue # Perspective omits attributes scored below the requested threshold
out[attr] = {"spanScores": [{"begin": b, "end": e, "score": {"value": s[attr], "type": "PROBABILITY"}} for b, e, s in spans],
"summaryScore": {"value": summary[attr], "type": "PROBABILITY"}}
resp = {"attributeScores": out, "languages": body.get("languages") or ["und"]} # no language detection: echoes the request
if "clientToken" in body:
resp["clientToken"] = body["clientToken"]
return resp
app.post("/v1alpha1/comments:analyze")(analyze)
app.post("/v1/comments:analyze")(analyze)
@app.post("/v1alpha1/comments:suggestscore")
async def suggest_score(request: Request):
if (refused := denied(request)) is not None:
return refused
body = await read_json(request)
if body is None or not isinstance(body.get("comment"), dict) or not isinstance(body.get("attributeScores"), dict):
return error(400, "Request must include comment and attributeScores.")
if feedback:
with open(feedback, "a", encoding="utf-8") as f:
f.write(json.dumps(body, ensure_ascii=False) + "\n")
resp = {"requestedLanguages": body.get("languages") or []}
if "clientToken" in body:
resp["clientToken"] = body["clientToken"]
return resp
@app.post("/v1/moderations")
async def moderations(request: Request):
if (refused := denied(request)) is not None:
return refused
body = await read_json(request)
inp = body.get("input") if body else None
texts = [inp] if isinstance(inp, str) else inp
if not isinstance(texts, list) or not texts or not all(isinstance(t, str) and t for t in texts):
return JSONResponse(status_code=400, content={"error": {"message": "input must be a non-empty string or list of strings",
"type": "invalid_request_error"}})
results = []
for summary, _ in await run_in_threadpool(scorer, texts):
cs = {cat: summary[attr] for cat, attr in OPENAI_MAP.items()}
results.append({"flagged": any(v >= 0.5 for v in cs.values()), "categories": {k: v >= 0.5 for k, v in cs.items()},
"category_scores": cs})
return {"id": f"modr-{uuid.uuid4().hex[:24]}", "model": body.get("model") or model_name, "results": results}
@app.post("/beta/litellm_basic_guardrail_api")
async def litellm_guardrail(request: Request):
"""LiteLLM's Generic Guardrail API: BLOCKED when any text reaches the threshold on any chosen attribute. Set
additional_provider_specific_params {threshold, attributes} in the LiteLLM guardrail config to change the defaults."""
if (refused := denied(request)) is not None:
return refused
body = await read_json(request) or {}
texts = [t for t in body.get("texts") or [] if isinstance(t, str) and t.strip()]
params = body.get("additional_provider_specific_params") or {}
threshold = float(params.get("threshold", 0.8))
attrs = [a for a in params.get("attributes") or ATTRS if a in ATTRS]
hits = sorted({a for s, _ in await run_in_threadpool(scorer, texts) for a in attrs if s[a] >= threshold}) if texts else []
if hits:
return {"action": "BLOCKED", "blocked_reason": f"{model_name}: {', '.join(hits)} at or above {threshold}"}
return {"action": "NONE"}
return app
if os.environ.get("MODEL_DIR"):
app = create_app(Scorer(os.environ["MODEL_DIR"], os.environ.get("BACKEND", "torch"), os.environ.get("ONNX_FILE", "onnx/model_int8_block32.onnx"),
revision=os.environ.get("MODEL_REVISION")), os.environ.get("MODEL_NAME", "toxicity-lens"))
|