Moderate user comments
Keep an abusive comment from being published, or put it in front of a moderator before it is.
AI, but a small model on your own machines
InexpensiveFastYour data stays with you
The verdict
N2, because it is the first level that arrives already trained on labelled comments, the comments of a public toxicity challenge, where N1 needs a corpus of your own, and because it keeps on your premises messages that are often somebody’s account of what was done to them. It does not solve context: N0 and N1 both flag, in their tests, the report of an insult as the insult itself, and the model card warns that a comment containing insulting words is scored toxic whatever the intent. That is why the snippet keeps two thresholds and sends the middle band to a human instead of ruling on it. Two caveats, and the entry carries both: the two thresholds are example values, to be calibrated against a sample of your own comments read by hand, and the model-and-labels pair is chosen with the language — the one in the snippet only scores English, and a French forum has to take the multilingual pair, which covers French but returns a single label, so a number with no reason to hand the reviewer. N3 would hand the provider the meaning of each category, the calibration, and the only explanation you could offer the author of a hidden comment; that trade only holds up when the harm you care about has a label nowhere else — and for the usual harms, the provider taken as an example publishes a dedicated moderation endpoint its documentation gives as free: nobody pays a chat model for what it returns with no meter running.
Self-hosted toxicity classifier (English, or multilingual with a single label), with a human review band
AI, but a small model on your own machines
Small self-hosted specialised modelN2InexpensiveFast
Python
"""
Route comments with a self-hosted toxicity classifier.
Rung N2. An encoder fine-tuned on the labelled comments of a public toxicity
challenge, running on your own machine. It arrives trained, which N1 makes you
gather a corpus for, and it stays inside your infrastructure, which matters
when the text you would send away is the abuse one of your users just
received.
The model and the labels that may decide go together, and the language of your
comments chooses the pair. The English one scores seven separate harms, which
is what you show a moderator; the multilingual one, whose card lists French
among fourteen languages, scores one. Picking the English model for a French
forum returns numbers, not errors, which is the failure this rung is most
likely to hand you.
What you own on this rung is not the model, it is everything around it: the
batching, the thresholds, the labels that may decide, and the answer to "what
does the code do when the model says nothing usable". The model itself is a
black box with a fixed list of labels, and the last function below is where
that becomes your problem.
"""
from __future__ import annotations
import functools
# English only: its corpus is Civil Comments. Seven labels name a harm; the
# model also scores which identities a comment mentions (male, muslim, black…),
# and those say who is mentioned, not what is done to them, so they are left out
# and never decide anything here.
ENGLISH = ("unitary/unbiased-toxic-roberta",
("toxicity", "severe_toxicity", "obscene", "identity_attack",
"insult", "threat", "sexual_explicit"))
# Fourteen languages, French among them, and one label for all of them: its
# card lists the languages and publishes a validation score per language. What
# you lose is the breakdown — "insult" or "threat" is what a moderator reads,
# "toxic 0.87" is what is left. Its licence is not the other one's either: read
# both before you ship.
MULTILINGUAL = ("gravitee-io/distilbert-multilingual-toxicity-classifier", ("toxic",))
# The examples on this page are English, so this file is set to the English
# pair. A French forum swaps the line, and loses the breakdown with it.
MODEL_NAME, HARM_LABELS = ENGLISH
# Two thresholds, not one: between "obviously fine" and "obviously not" there
# is a band that belongs to a human. These two numbers are examples, not
# defaults to leave alone: calibrate them on a sample of your own comments that
# somebody has read by hand.
DEFAULT_THRESHOLDS = {"block": 0.9, "review": 0.6}
class ToxicityModel:
"""The real model. A large download: load it once, with `default_model`."""
def __init__(self, name: str = MODEL_NAME) -> None:
from transformers import pipeline
# Truncated to the length the model reads, as Transformers.js does:
# whatever sits past it in a very long comment is not scored.
self._pipe = pipeline("text-classification", model=name, top_k=None, truncation=True)
def predict(self, comments: list[str]) -> list[dict[str, float]]:
"""One label-to-score mapping per comment, in the order given."""
return [{r["label"]: r["score"] for r in row} for row in self._pipe(comments)]
@functools.cache
def default_model() -> ToxicityModel:
"""The real model, loaded on first use and kept for the process."""
return ToxicityModel()
class ModerationUnavailable(Exception):
"""The model answered something no caller can act on."""
def moderate(comments, classifier=None, thresholds=None, labels=HARM_LABELS) -> list[dict]:
"""
Decide what to do with each comment: block, send to review, or allow.
`classifier` is injected so this can be tested without downloading the
weights. In production it defaults to the real model above, and `labels`
has to be the label set of whichever model that is.
The whole list goes to the classifier in one call. Whether the model then
reads the comments together is the library's business: transformers
batches only when given a `batch_size`, and leaves it off by default.
"""
comments = list(comments)
if not comments:
return []
classifier = classifier or default_model()
thresholds = thresholds or DEFAULT_THRESHOLDS
scored = classifier.predict(comments)
if len(scored) != len(comments):
raise ModerationUnavailable("the model returned one row per comment, and did not")
return [_decide(row, thresholds, labels) for row in scored]
def _decide(scores, thresholds: dict[str, float], labels) -> dict:
"""
Keep the strongest harm label, and fall back to a human when nothing is usable.
Falling back to "allow" would be the tempting shortcut, and it would mean
that a model failure silently publishes everything it was asked about.
"""
rows = scores.items() if isinstance(scores, dict) else ()
usable = {label: v for label, v in rows if label in labels and _is_score(v)}
if not usable:
return {"action": "review", "label": None, "score": None}
label, value = max(usable.items(), key=lambda item: item[1])
if value >= thresholds["block"]:
action = "block"
elif value >= thresholds["review"]:
action = "review"
else:
action = "allow"
return {"action": action, "label": label, "score": value}
def _is_score(value) -> bool:
"""A number the caller can act on, rather than whatever came back."""
return isinstance(value, (int, float)) and not isinstance(value, bool) and 0.0 <= value <= 1.0JavaScript
/**
* Route comments with a self-hosted toxicity classifier.
*
* Rung N2. An encoder fine-tuned on the labelled comments of a public toxicity
* challenge, running on your own machine. It arrives trained, which N1 makes
* you gather a corpus for, and it stays inside your infrastructure, which
* matters when the text you would send away is the abuse one of your users
* just received.
*
* The model and the labels that may decide go together, and the language of
* your comments chooses the pair. The English one scores seven separate harms,
* which is what you show a moderator; the multilingual one, whose card lists
* French among fourteen languages, scores one. Picking the English model for a
* French forum returns numbers, not errors, which is the failure this rung is
* most likely to hand you.
*
* What you own on this rung is not the model, it is everything around it: the
* batching, the thresholds, the labels that may decide, and the answer to
* "what does the code do when the model says nothing usable". The model itself
* is a black box with a fixed list of labels, and the last function below is
* where that becomes your problem.
*/
// English only: its corpus is Civil Comments. Seven labels name a harm; the
// model also scores which identities a comment mentions (male, muslim,
// black…), and those say who is mentioned, not what is done to them, so they
// are left out and never decide anything here. The ONNX export of the weights
// Python loads, with its files at the root of the repository.
export const ENGLISH = ['protectai/unbiased-toxic-roberta-onnx', [
'toxicity', 'severe_toxicity', 'obscene', 'identity_attack', 'insult', 'threat', 'sexual_explicit',
], ''];
// Fourteen languages, French among them, and one label for all of them: its
// card lists the languages and publishes a validation score per language. What
// you lose is the breakdown — "insult" or "threat" is what a moderator reads,
// "toxic 0.87" is what is left. Its licence is not the other one's either:
// read both before you ship. The ONNX export of the weights Python loads.
export const MULTILINGUAL = [
'onnx-community/distilbert-multilingual-toxicity-classifier-ONNX', ['toxic'], 'onnx',
];
// The examples on this page are English, so this file is set to the English
// pair. A French forum swaps the line, and loses the breakdown with it.
export const [MODEL_NAME, HARM_LABELS, SUBFOLDER] = ENGLISH;
// Two thresholds, not one: between "obviously fine" and "obviously not" there
// is a band that belongs to a human. These two numbers are examples, not
// defaults to leave alone: calibrate them on a sample of your own comments that
// somebody has read by hand.
export const DEFAULT_THRESHOLDS = { block: 0.9, review: 0.6 };
export class ModerationUnavailable extends Error {}
/** The real model. A large download: load it once, with `defaultModel`. */
export class ToxicityModel {
static async load(name = MODEL_NAME, subfolder = SUBFOLDER) {
const { pipeline } = await import('@huggingface/transformers');
// Full precision, as in Python; the pipeline truncates to the length the
// model reads, so whatever sits past it in a very long comment is not scored.
return new ToxicityModel(await pipeline('text-classification', name, { subfolder, dtype: 'fp32' }));
}
constructor(pipe) {
this.pipe = pipe;
}
/** One label-to-score mapping per comment, in the order given. */
async predict(comments) {
const rows = await this.pipe(comments, { top_k: null });
return rows.map((row) => Object.fromEntries(row.map((r) => [r.label, r.score])));
}
}
let loading;
/** The real model, loaded on first use and kept for the process. */
export function defaultModel() {
loading ??= ToxicityModel.load().catch((error) => {
loading = undefined; // a failed load is not kept: the next call retries
throw error;
});
return loading;
}
/**
* Decide what to do with each comment: block, send to review, or allow.
*
* `classifier` is injected so this can be tested without downloading the
* weights. In production it defaults to the real model above, and `labels` has
* to be the label set of whichever model that is.
*
* The whole list goes to the classifier in one call, and Transformers.js runs
* it through the model in one pass, padded to the longest comment.
*/
export async function moderate(comments, classifier, thresholds = DEFAULT_THRESHOLDS, labels = HARM_LABELS) {
const batch = [...comments];
if (batch.length === 0) return [];
const model = classifier ?? (await defaultModel());
const scored = await model.predict(batch);
if (scored.length !== batch.length) {
throw new ModerationUnavailable('the model returned one row per comment, and did not');
}
return scored.map((row) => decide(row, thresholds, labels));
}
/**
* Keep the strongest harm label, and fall back to a human when nothing is usable.
*
* Falling back to "allow" would be the tempting shortcut, and it would mean
* that a model failure silently publishes everything it was asked about.
*/
function decide(scores, thresholds, labels) {
const usable = Object.entries(scores ?? {}).filter(
([label, v]) => labels.includes(label) && typeof v === 'number' && v >= 0 && v <= 1,
);
if (usable.length === 0) return { action: 'review', label: null, score: null };
const [label, score] = usable.reduce((best, row) => (row[1] > best[1] ? row : best));
let action = 'allow';
if (score >= thresholds.block) action = 'block';
else if (score >= thresholds.review) action = 'review';
return { action, label, score };
}Try it
This code cannot run in a browser. The outputs below are computed when the site is built, by running the real code.
The model’s scores are simulated by the local double the tests use. What is computed here is what the code does with them: the decision it draws, the thresholds it applies, and what it does when the answer is unusable.
An insult aimed at someone
Given
get off this forum you blorptard
What it returns
Blocked, never published
Strongest score: “toxicity” at 0.96.
Thresholds for this run: block at 0.90, review at 0.60. One call for the batch.
An ordinary reader comment
Given
the diagram is much clearer than the text, thank you
What it returns
Published
Strongest score: “toxicity” at 0.02.
Thresholds for this run: block at 0.90, review at 0.60. One call for the batch.
A scathing opinion, but not an insult
Given
that was a spectacularly bad take, honestly
What it returns
Set aside for a human
Strongest score: “toxicity” at 0.71.
Thresholds for this run: block at 0.90, review at 0.60. One call for the batch.
The same opinion, thresholds tightened
Given
the same comment, with blocking lowered to 0.70
What it returns
Blocked, never published
Strongest score: “toxicity” at 0.71.
Thresholds for this run: block at 0.70, review at 0.30. One call for the batch.
The model returns nothing usable
Given
the same insult, but the model answers an empty set of scores
What it returns
Set aside for a human
The model returned no usable score. The code does not publish anyway: it calls a human.
Thresholds for this run: block at 0.90, review at 0.60. One call for the batch.
A home address handed out in passing
Given
he lives at the corner of rue des Lilas by the way, go and say hello
What it returns
Published
Strongest score: “toxicity” at 0.08.
Thresholds for this run: block at 0.90, review at 0.60. One call for the batch.
Here it breaksThis comment points at someone’s home and invites people to turn up. None of the seven harms the model scores names it. The low scores of this case are simulated, and the code publishes what scores low: nothing in the code is wrong, the harm has no label. You inherit somebody else’s taxonomy, and widening it takes a labelled corpus of your own.See the level above
What this means for you
- Your data stays on your own machines
- Always the same result
- Measured on a sample, not case by case
- Depends on a library
- Moderate footprint
What this adds to your regulatory scope
- Processing of user-written content on your own infrastructure
- Moderation decision taken with no human involved as soon as a comment crosses the blocking threshold
- The taxonomy is the model author’s: what it does not name does not excuse you from having to handle it
- Uses the model’s author intends: research, fine-tuning on carefully built datasets, and helping moderators flag harmful content faster
Breaking point
You inherit somebody else’s taxonomy, language and biases. The model in this snippet is trained on English comments — its card says `language: en` and its corpus is Civil Comments — and plugged into a French forum it returns scores, not errors; the multilingual pair the snippet publishes next to it does cover French, but with a single label, toxic, so the reviewer gets a number and no longer a reason. The model scores seven harms — toxicity, severe toxicity, obscenity, identity attack, insult, threat, sexually explicit content — and none of them names giving out someone’s home: “he lives at the corner of rue des Lilas by the way, go and say hello” has no label to land in. And the model card warns that a comment containing insulting words is likely to be scored toxic whatever its tone or intent: the report of an insult does not escape that. And a comment is only scored as far as the model reads: an insult at the end of a long message goes through. The test checks the plumbing only, on simulated scores.
When to move up a level
The harm you care about has no label in any available model, and you have no labelled corpus to teach it one.
The four options
- N0Term list after normalisation, with a context windowFree · Instant
- N1Linear classifier on character n-gramsnégligeable · Instant
- RecommendedN2Self-hosted toxicity classifier (English, or multilingual with a single label), with a human review bandInexpensive · Fast
- N3Scoring the comment with a general-purpose model, for the category that has a label nowhere elseélevé · About a second
The other options
N0Term list after normalisation, with a context windowFree · Instant
Term list after normalisation, with a context window
Rule and classic algorithmN0FreeInstant
Python
"""
Flag a comment against a term list, after normalisation, with context.
Rung N0. Deterministic, standard library only, and auditable: every decision
can be traced back to one entry in a list you control.
Two things make it usable rather than merely simple.
First, normalisation. Case, accents and compatibility forms (full-width
letters, ligatures, superscripts) are spellings of the same word, so they are
folded before matching. Look-alikes are not: a 0 stays a 0, which is why
bl0rptard walks past.
Second, the context window. A term list cannot decide anything on its own, so
the function returns the words around each hit. A human reads the window and
decides. A moderation tool that returns a bare boolean hides the one piece of
evidence its reviewer needs.
"""
import unicodedata
# Latin combining accents. Only those are dropped when folding: a Devanagari
# vowel sign is a mark too, and dropping it would turn one word into another.
LATIN_MARKS = ("\u0300", "\u036f")
def normalise(text: str) -> str:
"""Fold compatibility forms, then case, then Latin accents."""
# Decomposed before folding, and again after: 𝐁𝐋𝐎𝐑𝐏𝐓𝐀𝐑𝐃 has to become
# BLORPTARD before the case fold can see it, and folding can itself produce
# a compatibility form.
decomposed = unicodedata.normalize("NFKD", text)
folded = unicodedata.normalize("NFKD", decomposed.casefold())
return "".join(c for c in folded if not LATIN_MARKS[0] <= c <= LATIN_MARKS[1])
def _words(text: str) -> list[str]:
"""
Letters, digits, and the marks that spell them; anything else separates.
A regular expression does this in JavaScript, which has a class for marks;
Python's does not, and its word class leaves marks out, so a Devanagari
vowel sign would cut a word in two. Composed first, so an accent typed as a
separate mark stays in its word.
"""
words, current = [], []
for char in unicodedata.normalize("NFC", text):
if char.isalnum() or unicodedata.category(char).startswith("M"):
current.append(char)
elif current:
words.append("".join(current))
current = []
if current:
words.append("".join(current))
return words
def review(text: str, terms, window: int = 3) -> dict:
"""
Return every listed term found in `text`, with the words around it.
A term of several words matches those words in a row, whatever punctuation
separates them in the comment.
`terms` is yours: the list is policy, not code, and it belongs outside the
function that applies it.
`window` is a number of words on each side. Widen it when your reviewers
keep asking what the comment was about.
"""
listed = {tuple(normalise(w) for w in _words(t)) for t in terms}
longest = max(map(len, listed), default=0)
words = _words(text)
folded = [normalise(w) for w in words]
matches = []
for position in range(len(words)):
for size in range(1, min(longest, len(words) - position) + 1):
if tuple(folded[position:position + size]) in listed:
matches.append({
"term": " ".join(folded[position:position + size]),
"position": position,
"context": " ".join(words[max(0, position - window):position + size + window]),
})
return {"flagged": bool(matches), "matches": matches}JavaScript
/**
* Flag a comment against a term list, after normalisation, with context.
*
* Rung N0. Deterministic, no dependency, and auditable: every decision can be
* traced back to one entry in a list you control.
*
* Two things make it usable rather than merely simple.
*
* First, normalisation. Case, accents and compatibility forms (full-width
* letters, ligatures, superscripts) are spellings of the same word, so they
* are folded before matching. Look-alikes are not: a 0 stays a 0, which is
* why bl0rptard walks past.
*
* Second, the context window. A term list cannot decide anything on its own,
* so the function returns the words around each hit. A human reads the window
* and decides. A moderation tool that returns a bare boolean hides the one
* piece of evidence its reviewer needs.
*/
// Letters, digits, and the marks that spell them; anything else separates. A
// Devanagari vowel sign is a mark, and leaving it out would cut a word in two.
const TOKEN = /[\p{L}\p{N}\p{M}]+/gu;
/** Fold compatibility forms, then case, then Latin accents. */
export function normalise(text) {
// Decomposed before folding, and again after: 𝐁𝐋𝐎𝐑𝐏𝐓𝐀𝐑𝐃 has to become
// BLORPTARD before the case fold can see it. JavaScript has no casefold, so
// `toLowerCase` does the work and the two letters a Latin term list meets,
// ß and the final ς, are done by hand; there are two hundred and fifty-two
// others, in Cherokee, Greek and Cyrillic, that this line does not cover.
return text
.normalize('NFKD')
.toLowerCase()
.normalize('NFKD')
.replaceAll('ß', 'ss')
.replaceAll('ς', 'σ')
.replace(/[\u0300-\u036f]/gu, '');
}
// Composed first, so an accent typed as a separate mark stays in its word.
const words = (text) => text.normalize('NFC').match(TOKEN) ?? [];
/**
* Return every listed term found in `text`, with the words around it.
*
* A term of several words matches those words in a row, whatever punctuation
* separates them in the comment.
*
* `terms` is yours: the list is policy, not code, and it belongs outside the
* function that applies it.
*
* `window` is a number of words on each side. Widen it when your reviewers
* keep asking what the comment was about.
*/
export function review(text, terms, window = 3) {
const listed = new Set([...terms].map((t) => words(t).map(normalise).join(' ')));
const longest = [...terms].reduce((most, t) => Math.max(most, words(t).length), 0);
const found = words(text);
const folded = found.map(normalise);
const matches = [];
for (let position = 0; position < found.length; position += 1) {
for (let size = 1; size <= Math.min(longest, found.length - position); size += 1) {
const term = folded.slice(position, position + size).join(' ');
if (!listed.has(term)) continue;
matches.push({
term,
position,
context: found.slice(Math.max(0, position - window), position + size + window).join(' '),
});
}
}
return { flagged: matches.length > 0, matches };
}What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- No provider in the loop
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the comment never leaves your infrastructure, and the snippet flags without deciding
Breaking point
Two halves, both in the test. A spelling the list does not hold walks straight past: bl0rptard, blorp-tard, letters spaced one by one. And the list sees the word, never its use: “he called me a blorptard, please remove his comment” is flagged exactly like the insult it reports: same flag, same term, and only the context window, which a human has to read, tells them apart.
When to move up a level
Your moderators spend the day closing flags that were never abuse, or you are adding one more spelling to the list every week.
N1Linear classifier on character n-gramsnégligeable · Instant
Linear classifier on character n-grams
Lightweight classic modelN1négligeableInstant
Python
"""
Score a comment with a linear classifier trained on a labelled corpus.
Rung N1. The term list of N0 matches spellings. This matches shapes: character
n-grams, so `bl0rptard` and `blorptardd` share most of their features with the
form the model was shown, and a variant nobody added to a list still scores.
The whole model is a vector of weights over character n-grams. It is small
enough to keep beside the code, it trains while you read this docstring, and
every weight can be printed next to the n-gram it belongs to. Someone will ask
why their comment was hidden.
"""
import unicodedata
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
def fold(comment: str) -> str:
"""NFKC then lowercase, so full-width letters and ligatures read as the plain ones."""
return unicodedata.normalize("NFKC", comment).lower()
def train(comments: list[str], labels: list[int]):
"""
`labels` is 1 when the comment breaks the policy, 0 when it does not.
Character n-grams rather than words, because abuse is spelled creatively
and a word-level model only knows the exact tokens it was shown.
`class_weight="balanced"` because a real moderation corpus is mostly
ordinary comments, and an unweighted model learns to say no to everything.
"""
model = make_pipeline(
TfidfVectorizer(analyzer="char_wb", ngram_range=(3, 5), preprocessor=fold),
# `C` above one because a couple of dozen short examples under the
# default regulariser leave every score sitting near a half, which
# makes the threshold in `is_abusive` meaningless.
LogisticRegression(class_weight="balanced", C=10.0, max_iter=1000),
)
model.fit(comments, labels)
return model
def score(model, comment: str) -> float:
"""
How strongly the model reads this comment as breaking the policy. A comment
with no n-gram to read scores 0: the intercept alone is not evidence.
"""
if model[0].transform([comment]).nnz == 0:
return 0.0
return float(model.predict_proba([comment])[0][1])
def is_abusive(model, comment: str, threshold: float = 0.5) -> bool:
"""
Return a decision, and keep the threshold in the caller's hands.
Moderation has no neutral setting. Move it towards 1 and you silence fewer
innocent people while letting more abuse through; move it towards 0 and you
do the opposite. Someone has to choose, and it should not be this function.
"""
return score(model, comment) >= thresholdJavaScript
/**
* Score a comment with a linear classifier trained on a labelled corpus.
*
* Rung N1. The term list of N0 matches spellings. This matches shapes:
* character n-grams, so `bl0rptard` and `blorptardd` share most of their
* features with the form the model was shown, and a variant nobody added to a
* list still scores.
*
* Written out rather than pulled from a library: hashing the character
* n-grams, training a logistic regression and scoring take four short
* functions. The model is a vector of weights, small enough to keep beside the
* code and trained while you read this. Every weight can be printed, but with
* hashing a weight stands for every n-gram that lands in its bucket: to argue
* about one, list those n-grams first. Someone will ask why their comment was
* hidden.
*/
// The hashing trick: no vocabulary to build, ship or keep in sync.
const BUCKETS = 1024;
/** Character n-grams of the folded comment, hashed into a fixed vector. */
function features(text) {
// NFKC first, so full-width letters and ligatures read as the plain ones.
const padded = ` ${text.normalize('NFKC').toLowerCase()} `;
const vector = new Float64Array(BUCKETS);
for (let n = 3; n <= 5; n += 1) {
for (let i = 0; i + n <= padded.length; i += 1) {
// An n-gram of nothing but blanks is not evidence about a comment: a
// form submitted empty would otherwise be scored like any other text.
// Python never builds one, its analyser padding each word rather than
// the whole comment.
if (padded.slice(i, i + n).trim() === '') continue;
let h = 2166136261;
// Math.imul keeps the multiplication exact on 32 bits; a plain `*` goes
// past 2^53, loses the low bits, and piles n-grams into fewer buckets.
for (const c of padded.slice(i, i + n)) h = Math.imul(h ^ c.codePointAt(0), 16777619) >>> 0;
vector[h % BUCKETS] += 1;
}
}
const norm = Math.hypot(...vector);
if (norm) for (let j = 0; j < BUCKETS; j += 1) vector[j] /= norm;
return vector;
}
/**
* `labels` is 1 when the comment breaks the policy, 0 when it does not.
*
* Each class is weighted by its rarity, because a real moderation corpus is
* mostly ordinary comments and an unweighted model learns to allow everything.
*/
export function train(comments, labels, { epochs = 300, rate = 1 } = {}) {
const counts = [labels.filter((l) => l === 0).length, labels.filter((l) => l === 1).length];
// Both classes, one label per comment: otherwise the weights learn nothing
// and every comment gets the same verdict.
if (!counts[0] || !counts[1] || counts[0] + counts[1] !== comments.length) {
throw new RangeError('train needs labels 0 and 1, one per comment');
}
const rows = comments.map(features);
const weights = new Float64Array(BUCKETS);
let bias = 0;
for (let epoch = 0; epoch < epochs; epoch += 1) {
for (let i = 0; i < rows.length; i += 1) {
const step = (rate * labels.length) / (2 * counts[labels[i]]);
const error = predict(rows[i], weights, bias) - labels[i];
for (let j = 0; j < BUCKETS; j += 1) weights[j] -= step * error * rows[i][j];
bias -= step * error;
}
}
return { weights, bias };
}
function predict(vector, weights, bias) {
let z = bias;
for (let j = 0; j < BUCKETS; j += 1) z += weights[j] * vector[j];
return 1 / (1 + Math.exp(-z));
}
/**
* How strongly the model reads this comment as breaking the policy. A comment
* with no n-gram to read scores 0: the bias alone is not evidence.
*/
export function score(model, comment) {
const vector = features(comment);
return vector.some((v) => v !== 0) ? predict(vector, model.weights, model.bias) : 0;
}
/**
* Return a decision, and keep the threshold in the caller's hands.
*
* Moderation has no neutral setting. Move it towards 1 and you silence fewer
* innocent people while letting more abuse through; move it towards 0 and you
* do the opposite. Someone has to choose, and it should not be this function.
*/
export function isAbusive(model, comment, threshold = 0.5) {
return score(model, comment) >= threshold;
}What this means for you
- Your data stays on your own machines
- Always the same result
- Measured on a sample, not case by case
- Depends on a library
- Low footprint
What this adds to your regulatory scope
- Processing of user-written content on your own infrastructure
- The training corpus keeps real comments, and the labelling decisions that go with them
Breaking point
The model learns fragments of text, not an intent. The test shows both sides: “people like you should not be allowed to have an account here”, which uses none of the corpus insults, stays under the threshold, while the report “he called me a blorptard, please remove his comment” lands above it. A larger corpus changes the weights, not what the model reads: character n-grams.
When to move up a level
The hostility you are missing is written in plain words, without a single term you could list or label.
N3Scoring the comment with a general-purpose model, for the category that has a label nowhere elseélevé · About a second
Scoring the comment with a general-purpose model, for the category that has a label nowhere else
General-purpose LLM APIN3élevéAbout a second
Python
"""
Score a comment by asking a general-purpose model.
Rung N3. The rung that hands the most away: what each category means, the
calibration, the right to appeal, and the text of your users' comments, which
leaves your premises on every call.
Before writing this, look at whether your provider publishes a moderation
endpoint. The one taken as an example here does, it is not metered, and it
scores thirteen fixed categories. If the harms you care about are on that
list, this file is the expensive way to get them. What it buys, and the only
thing it buys, is a category that is yours: the last one below names giving
out where somebody lives, which no fixed taxonomy on this page carries — not
the provider's thirteen, and not the labels of the model at N2.
You name the categories; the model decides what they mean. Everything else
here — capping the input, retrying, refusing to act on an answer that is not
the shape you asked for — is plumbing you own, and it is where the bugs of
this rung live. It is also all your tests can reach, because the judgement
itself is not testable.
"""
from __future__ import annotations
import json
# The provider named here is an example, not a recommendation: the reasoning
# holds for any general-purpose model API, and the client is swappable. Pass
# any object with a `complete(prompt=..., temperature=...)` method.
MODEL = "gpt-4.1-mini" # an example id: check the parameters your model accepts
# The first four are on the provider's own moderation endpoint too. The last
# one is not, and it is the reason this file exists.
CATEGORIES = ("harassment", "hate", "violence", "self_harm", "personal_information")
PROMPT = (
"Rate the comment below on each moderation category. Answer with JSON\n"
"only: an object mapping each category to a score between 0 and 1.\n"
f"Categories: {', '.join(CATEGORIES)}\n"
"personal_information means giving out where somebody lives, works, or how\n"
"to reach them, without their consent.\n\nComment:\n{comment}"
)
MAX_CHARACTERS = 4000
DEFAULT_THRESHOLDS = {"block": 0.9, "review": 0.6}
class ProviderClient:
"""The one call this snippet makes, on top of the provider's SDK."""
def __init__(self, sdk=None, model: str = MODEL):
if sdk is None: # pragma: no cover - needs a key and a network
from openai import OpenAI
sdk = OpenAI()
self.sdk, self.model = sdk, model
def complete(self, *, prompt: str, temperature: float) -> str:
response = self.sdk.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=temperature,
)
return response.choices[0].message.content
class ModerationUnavailable(Exception):
"""The provider could not be reached, or answered something unusable."""
def moderate(comment: str, client=None, *, thresholds=None, attempts: int = 3) -> dict:
"""
Decide what to do with one comment: block, send to review, or allow.
`client` is injected so this can be tested without a network call. In
production it defaults to a real provider client.
"""
# The provider bills every token of the prompt. The cap counts characters,
# not tokens, and is checked before any call: the caller decides where a
# longer comment goes instead. On a forum that is the review queue, not the
# bin — a comment of four thousand and one characters is not an anomaly.
if len(comment) > MAX_CHARACTERS:
raise ValueError(f"comment longer than {MAX_CHARACTERS} characters")
client = client or ProviderClient()
thresholds = thresholds or DEFAULT_THRESHOLDS
scores = _ask(client, comment, attempts)
category, score = max(scores.items(), key=lambda item: item[1])
if score >= thresholds["block"]:
action = "block"
elif score >= thresholds["review"]:
action = "review"
else:
action = "allow"
return {"action": action, "category": category, "score": score, "scores": scores}
def _ask(client, comment: str, attempts: int) -> dict[str, float]:
"""
Keep the categories that came back as a number in range, and nothing else.
A category the model invented is dropped, one it omitted is simply absent.
An answer with none of them left is unusable, and unusable is raised rather
than quietly turned into "allow".
"""
last_error: Exception | None = None
for _ in range(attempts):
try:
# Temperature zero, because a moderation decision that changes between
# two identical calls cannot be explained to the person it hit.
answer = client.complete(prompt=PROMPT.format(comment=comment), temperature=0)
# No content (a refusal) and anything but a JSON object are unusable.
parsed = json.loads(_unfenced(answer)) if isinstance(answer, str) else None
if not isinstance(parsed, dict):
last_error = ValueError("the answer is not a JSON object")
continue
scores = {n: float(parsed[n]) for n in CATEGORIES if _is_score(parsed.get(n))}
if scores:
return scores
last_error = ValueError("no category came back as a score in range")
except Exception as error: # noqa: BLE001 - any provider failure is retried
last_error = error
raise ModerationUnavailable(str(last_error))
def _unfenced(answer: str) -> str:
"""
A JSON answer wrapped whole in one code fence is read; nothing else is.
Prose around it, a second block, or a fence never closed is a failed
answer, and it is asked for again rather than salvaged.
"""
text = answer.strip()
if text.startswith("```") and text.endswith("```") and text.count("```") == 2:
return text[3:-3].removeprefix("json")
return text
def _is_score(value) -> bool:
"""A number the caller can act on, rather than whatever came back."""
return isinstance(value, (int, float)) and not isinstance(value, bool) and 0.0 <= value <= 1.0JavaScript
/**
* Score a comment by asking a general-purpose model.
*
* Rung N3. The rung that hands the most away: what each category means, the
* calibration, the right to appeal, and the text of your users' comments,
* which leaves your premises on every call.
*
* Before writing this, look at whether your provider publishes a moderation
* endpoint. The one taken as an example here does, it is not metered, and it
* scores thirteen fixed categories. If the harms you care about are on that
* list, this file is the expensive way to get them. What it buys, and the only
* thing it buys, is a category that is yours: the last one below names giving
* out where somebody lives, which no fixed taxonomy on this page carries — not
* the provider's thirteen, and not the labels of the model at N2.
*
* You name the categories; the model decides what they mean. Everything else
* here — capping the input, retrying, refusing to act on an answer that is not
* the shape you asked for — is plumbing you own, and it is where the bugs of
* this rung live. It is also all your tests can reach, because the judgement
* itself is not testable.
*/
// The provider named here is an example, not a recommendation: the reasoning
// holds for any general-purpose model API, and the client is swappable. Pass
// any object with a `complete({ prompt, temperature })` method.
export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts
export async function providerClient(sdk, model = MODEL) {
if (!sdk) {
const { OpenAI } = await import('openai');
sdk = new OpenAI();
}
return {
async complete({ prompt, temperature }) {
const response = await sdk.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
temperature,
});
return response.choices[0].message.content;
},
};
}
// The first four are on the provider's own moderation endpoint too. The last
// one is not, and it is the reason this file exists.
export const CATEGORIES = ['harassment', 'hate', 'violence', 'self_harm', 'personal_information'];
const PROMPT = [
'Rate the comment below on each moderation category. Answer with JSON',
'only: an object mapping each category to a score between 0 and 1.',
`Categories: ${CATEGORIES.join(', ')}`,
'personal_information means giving out where somebody lives, works, or how',
'to reach them, without their consent.',
'',
'Comment:',
].join('\n');
export const MAX_CHARACTERS = 4000;
export const DEFAULT_THRESHOLDS = { block: 0.9, review: 0.6 };
export class ModerationUnavailable extends Error {}
/**
* Decide what to do with one comment: block, send to review, or allow.
*
* @param {string} comment
* @param {object} options
* @param {{complete: Function}} [options.client] injected so this can be
* tested without a network call; defaults to a real provider client
* @param {{block: number, review: number}} [options.thresholds]
* @param {number} [options.attempts]
*/
export async function moderate(comment, { client, thresholds = DEFAULT_THRESHOLDS, attempts = 3 } = {}) {
// The provider bills every token of the prompt. The cap counts characters
// (code points, as Python does), not tokens, and is checked before any call:
// the caller decides where a longer comment goes instead. On a forum that is
// the review queue, not the bin — a comment of four thousand and one
// characters is not an anomaly.
if ([...comment].length > MAX_CHARACTERS) {
throw new RangeError(`comment longer than ${MAX_CHARACTERS} characters`);
}
client ??= await providerClient();
const scores = await ask(client, comment, attempts);
const [category, score] = Object.entries(scores).reduce((best, row) => (row[1] > best[1] ? row : best));
let action = 'allow';
if (score >= thresholds.block) action = 'block';
else if (score >= thresholds.review) action = 'review';
return { action, category, score, scores };
}
/**
* Keep the categories that came back as a number in range, and nothing else.
*
* A category the model invented is dropped, one it omitted is simply absent.
* An answer with none of them left is unusable, and unusable is thrown rather
* than quietly turned into "allow".
*/
async function ask(client, comment, attempts) {
let lastError;
for (let i = 0; i < attempts; i += 1) {
try {
const answer = await client.complete({
prompt: `${PROMPT}\n${comment}`,
// Temperature zero, because a moderation decision that changes between
// two identical calls cannot be explained to the person it hit.
temperature: 0,
});
// No content (a refusal) and anything but a JSON object are unusable.
const parsed = typeof answer === 'string' ? JSON.parse(unfenced(answer)) : null;
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
lastError = new Error('the answer is not a JSON object');
continue;
}
const scores = Object.fromEntries(CATEGORIES.filter((n) => isScore(parsed[n])).map((n) => [n, parsed[n]]));
if (Object.keys(scores).length > 0) return scores;
lastError = new Error('no category came back as a score in range');
} catch (error) {
lastError = error;
}
}
throw new ModerationUnavailable(String(lastError));
}
/** A number the caller can act on, rather than whatever came back. */
/**
* A JSON answer wrapped whole in one code fence is read; nothing else is.
*
* Prose around it, a second block, or a fence never closed is a failed answer,
* and it is asked for again rather than salvaged.
*/
function unfenced(answer) {
const text = answer.trim();
const fences = text.match(/```/g)?.length ?? 0;
if (text.startsWith('```') && text.endsWith('```') && fences === 2) {
const inner = text.slice(3, -3);
return inner.startsWith('json') ? inner.slice(4) : inner;
}
return text;
}
function isScore(value) {
return typeof value === 'number' && value >= 0 && value <= 1;
}What this means for you
- Your data goes to a third party
- The result varies from one call to the next
- Hard to test
- Depends on an outside provider
- High footprint
What this adds to your regulatory scope
- Transfer of your users' text to a processor, including the text of people reporting the abuse they have just received
- Processing location to be confirmed with the provider
- Does not excuse you from being able to justify a decision the provider made on your behalf
Breaking point
You pay by the token for what the same provider’s moderation endpoint returns with no meter running. What justifies that fits in one line of the snippet: the fifth category, giving out where somebody lives, which neither that endpoint’s thirteen categories nor the N2 model’s labels carry. For the other four, this level is the expensive way to get what is free. The score itself is the provider’s opinion, and the code has nothing to hold it against. If “the diagram is much clearer than the text” comes back scored for harassment past the blocking threshold — the test simulates that answer —, the function blocks it, correctly by its own logic. No feature to inspect, no weight to print, and nothing to tell the commenter but that number, which the provider taken as an example does not guarantee to repeat from one call to the next, even with a fixed seed.
When to move up a level
There is no level above this one.
Further reading
- unitary/unbiased-toxic-roberta — la fiche du modèle N2 anglais et la liste de ses étiquettes
- gravitee-io/distilbert-multilingual-toxicity-classifier — le couple multilingue du niveau N2, français compris
- OpenAI — Moderation, le point d’accès dédié et gratuit, à regarder avant d’écrire le niveau N3
- Santa Clara Principles on Transparency and Accountability in Content Moderation
- Règlement (UE) 2022/2065 sur les services numériques — texte officiel
Where the figures come from
- unitary/unbiased-toxic-roberta — config.json : seize étiquettes, multi_label_classification, douze couches
- protectai/unbiased-toxic-roberta-onnx — le même modèle exporté en ONNX, poids à la racine
- Transformers — pipelines : le traitement par lots est désactivé par défaut
- Transformers.js — pipeline text-classification : un seul passage pour la liste, sigmoïde en multi-étiquette
- OpenAI Cookbook — le paramètre seed : « Determinism is not guaranteed »
- OpenAI — tarifs de l’API, prix par million de jetons
- unitary/unbiased-toxic-roberta — fiche du modèle : `language: en`, corpus Civil Comments et Wikipedia Comments, licence Apache 2.0
- gravitee-io/distilbert-multilingual-toxicity-classifier — fiche : quatorze langues dont le français, étiquettes « not-toxic » et « toxic », licence openrail++, F1 de validation français 0,9446 publié par les auteurs ; sa conversion ONNX pour Transformers.js est `onnx-community/distilbert-multilingual-toxicity-classifier-ONNX`
- OpenAI — guide Moderation : « The moderation endpoint is free to use », modèle `omni-moderation-latest` et ses treize catégories, dont aucune ne nomme la divulgation d’informations personnelles
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.