Text Classification
PEFT
Safetensors
qwen3
moderation
toxicity
jailbreak-detection
multi-label
encoder-conversion
Instructions to use opus-research/opus-moderation-3 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use opus-research/opus-moderation-3 with PEFT:
from peft import PeftModel from transformers import AutoModelForSequenceClassification base_model = AutoModelForSequenceClassification.from_pretrained("Qwen/Qwen3-1.7B") model = PeftModel.from_pretrained(base_model, "opus-research/opus-moderation-3") - Notebooks
- Google Colab
- Kaggle
File size: 7,777 Bytes
26d007b | 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 | #!/usr/bin/env python3
"""Loader and inference for the Opus Moderation models.
Three of the four load with plain `from_pretrained`. mod-3 does not - it is a
LoRA adapter over Qwen3-1.7B that was converted from a decoder into an encoder
at training time, and inference has to rebuild that conversion exactly or the
weights mean nothing. `ModerationModel.load()` handles both paths.
from opus_moderation import ModerationModel
mod = ModerationModel.load("adapters/om4-large")
neo = ModerationModel.load("adapters/om4-neo")
mod.score(["some text"]) # -> [{"toxicity": 0.02, ...}]
neo.flag(["some text"]) # -> [{"jailbreaking": False}]
Run it directly for a smoke test:
python opus_moderation.py adapters/om4-large "you are a helpful assistant"
"""
import json
from pathlib import Path
import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer
# The eight labels, in the order the models emit them. om4-neo is the
# exception: it has one output, "jailbreaking". Always read id2label off the
# loaded config rather than assuming this list.
LABELS = ["toxicity", "severe_toxicity", "obscene", "threat", "insult",
"identity_attack", "sexual_explicit", "jailbreaking"]
# Used only when a model ships no thresholds.json. severe_toxicity sits at 0.2
# because its positives are extremely rare - at 0.5 it fires on nothing.
DEFAULT_THRESHOLDS = {c: 0.5 for c in LABELS}
DEFAULT_THRESHOLDS["severe_toxicity"] = 0.2
def _build_qwen_encoder(path, arch):
"""Rebuild the decoder-to-encoder conversion mod-3 was trained under.
Two separate changes, and the second one is not optional. Swapping the
causal mask builder is not enough on its own: for an unpadded batch the
mask builders return None, and the SDPA path then falls back to
`module.is_causal` - which Qwen3Attention hardcodes to True. Skip that step
and the model stays fully causal while appearing to load fine, quietly
scoring every input wrong.
"""
import transformers.models.qwen3.modeling_qwen3 as qm
from peft import PeftModel
from transformers.masking_utils import (
create_bidirectional_mask, create_bidirectional_sliding_window_mask)
from transformers.modeling_outputs import SequenceClassifierOutputWithPast
from transformers.models.qwen3.modeling_qwen3 import Qwen3ForSequenceClassification
if arch.get("bidirectional"):
qm.create_causal_mask = create_bidirectional_mask
qm.create_sliding_window_causal_mask = create_bidirectional_sliding_window_mask
class MeanPool(Qwen3ForSequenceClassification):
def forward(self, input_ids=None, attention_mask=None, **kw):
for k in ("labels", "num_items_in_batch", "task_ids"):
kw.pop(k, None)
out = self.model(input_ids=input_ids, attention_mask=attention_mask)
h = out.last_hidden_state
if attention_mask is None:
pooled = h.mean(1)
else:
m = attention_mask.unsqueeze(-1).to(h.dtype)
pooled = (h * m).sum(1) / m.sum(1).clamp(min=1e-6)
return SequenceClassifierOutputWithPast(loss=None, logits=self.score(pooled))
cls = MeanPool if arch.get("pool") == "mean" else Qwen3ForSequenceClassification
base = cls.from_pretrained(
arch["base_model"], num_labels=len(LABELS),
problem_type="multi_label_classification", attn_implementation="sdpa",
dtype=torch.bfloat16)
if arch.get("bidirectional"):
flipped = 0
for m in base.modules():
if getattr(m, "is_causal", False):
m.is_causal = False
flipped += 1
if flipped == 0:
raise RuntimeError(
"no attention module exposed is_causal - transformers internals "
"have moved and this model would silently score as causal.")
keep = arch.get("kept_layers")
cfg = base.config.get_text_config()
if keep and keep < cfg.num_hidden_layers:
base.model.layers = base.model.layers[:keep]
cfg.num_hidden_layers = keep
if getattr(cfg, "layer_types", None):
cfg.layer_types = cfg.layer_types[:keep]
return PeftModel.from_pretrained(base, str(path))
class ModerationModel:
def __init__(self, model, tok, labels, thresholds, name):
self.model, self.tok = model, tok
self.labels, self.thresholds, self.name = labels, thresholds, name
self.model.eval()
@classmethod
def load(cls, path, device=None):
path = Path(path)
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
arch_file = path / "opus_arch.json"
arch = json.loads(arch_file.read_text()) if arch_file.exists() else {}
# A LoRA adapter directory has no model weights of its own. That, not
# the name, is what decides which loading path to take.
is_adapter = (path / "adapter_config.json").exists()
if is_adapter or arch.get("base_model", "").startswith("Qwen"):
model = _build_qwen_encoder(path, arch)
tok_src = arch.get("base_model", str(path))
else:
model = AutoModelForSequenceClassification.from_pretrained(path)
tok_src = str(path)
tok = AutoTokenizer.from_pretrained(tok_src)
if tok.pad_token is None:
tok.pad_token = tok.eos_token
model.config.pad_token_id = tok.pad_token_id
model.to(device)
id2label = getattr(model.config, "id2label", None) or {}
labels = ([id2label[i] for i in sorted(id2label)]
if len(id2label) == model.config.num_labels else LABELS)
thr_file = path / "thresholds.json"
thresholds = json.loads(thr_file.read_text()) if thr_file.exists() else {}
thresholds = {lab: thresholds.get(lab, DEFAULT_THRESHOLDS.get(lab, 0.5))
for lab in labels}
return cls(model, tok, labels, thresholds, path.name)
@torch.no_grad()
def score(self, texts, max_len=512, batch=32):
"""Raw probabilities per label. Sorting by length first cuts padding -
it was worth ~5x on batch inference during evaluation."""
if isinstance(texts, str):
texts = [texts]
order = sorted(range(len(texts)), key=lambda i: -len(texts[i]))
out = [None] * len(texts)
device = next(self.model.parameters()).device
for i in range(0, len(order), batch):
idx = order[i:i + batch]
enc = self.tok([texts[j] for j in idx], truncation=True,
max_length=max_len, padding=True,
return_tensors="pt").to(device)
probs = torch.sigmoid(self.model(**enc).logits.float()).cpu()
for row, j in zip(probs, idx):
out[j] = {lab: float(row[k]) for k, lab in enumerate(self.labels)}
return out
def flag(self, texts, **kw):
"""Booleans at this model's deployment thresholds."""
return [{lab: p[lab] >= self.thresholds[lab] for lab in self.labels}
for p in self.score(texts, **kw)]
if __name__ == "__main__":
import sys
if len(sys.argv) < 3:
print(__doc__)
raise SystemExit(1)
m = ModerationModel.load(sys.argv[1])
print(f"loaded {m.name}: {len(m.labels)} labels {m.labels}")
print(f"thresholds: {m.thresholds}\n")
for text, probs, flags in zip(sys.argv[2:], m.score(sys.argv[2:]),
m.flag(sys.argv[2:])):
hits = [k for k, v in flags.items() if v]
print(f"{text[:70]!r}")
print(f" flagged: {hits or 'nothing'}")
print(" " + " ".join(f"{k}={v:.3f}" for k, v in probs.items()))
|