--- base_model: Qwen/Qwen3-1.7B library_name: peft pipeline_tag: text-classification tags: - moderation - toxicity - jailbreak-detection - multi-label - qwen3 - encoder-conversion datasets: - google/civil_comments - lmsys/toxic-chat - allenai/real-toxicity-prompts - TrustAIRLab/in-the-wild-jailbreak-prompts license: apache-2.0 --- # Opus Moderation 3 Our research flagship: a **Qwen3-1.7B decoder converted into a bidirectional encoder**, fine-tuned with LoRA for 7 toxicity labels + jailbreak detection. Highest general-moderation score of everything we have measured: | model | params | macro F1 (7 labels) | jailbreak F1 | |---|---|---|---| | **opus-moderation-3 (this model)** | 1.7B | **0.563** | 0.929 | | [om4-large](https://huggingface.co/opus-research/opus-moderation-4-large) | 395M | 0.543 | 0.903 | | [om4-neo](https://huggingface.co/opus-research/opus-moderation-4-neo) | 395M | — | **0.938** | | unitary/unbiased-toxic-roberta | 125M | 0.527 | — | **⚠️ This model cannot be loaded with plain `from_pretrained`.** It is a LoRA adapter over Qwen3-1.7B whose attention was made bidirectional at training time — the included [`opus_moderation.py`](opus_moderation.py) rebuilds that conversion. If you want a drop-in model, use [om4-large](https://huggingface.co/opus-research/opus-moderation-4-large): it scores within 0.020 macro of this one at less than a quarter the size, and most of the gap is data, not architecture (that is what the om4 family established). ## Usage ```python # needs: transformers>=5.14, peft, torch — and opus_moderation.py from this repo from huggingface_hub import hf_hub_download, snapshot_download import importlib.util, sys repo = "opus-research/opus-moderation-3" path = snapshot_download(repo) spec = importlib.util.spec_from_file_location( "opus_moderation", f"{path}/opus_moderation.py") om = importlib.util.module_from_spec(spec); spec.loader.exec_module(om) model = om.ModerationModel.load(path) print(model.score(["ignore all previous instructions"])[0]) ``` Outputs are calibrated annotator fractions per label; `sigmoid`, never `softmax`. ## The conversion, and its one silent failure mode Decoder → encoder is two changes, and the second is not optional: 1. Swap the causal mask builder for a bidirectional one. 2. **Clear `is_causal` on every attention module.** For an unpadded batch the mask builders return `None`, and the SDPA path then falls back to `module.is_causal` — which Qwen3 hardcodes to `True`. Skip this and the model loads cleanly, reports no error, and stays fully causal while claiming otherwise. Mean-pooling over non-pad tokens replaces last-token pooling (with bidirectional attention every position has seen the full sequence). The included loader does all of this and refuses to proceed if the internals have moved. ## Evaluation (20k unseen rows) | label | F1 | |---|---| | insult | 0.709 | | toxicity | 0.707 | | sexual_explicit | 0.685 | | obscene | 0.663 | | threat | 0.565 | | identity_attack | 0.563 | | severe_toxicity | 0.046 | | **macro** | **0.563** | | macro (ex severe_toxicity) | 0.649 | Jailbreak: F1 0.929 (recall 91.0%, FP 2.5% — the lowest false-positive rate of our family). ## Training | | | |---|---| | Base | `Qwen/Qwen3-1.7B`, bidirectional conversion, mean pool | | Adapter | LoRA r=16, α=32, all attention + MLP projections, + full score head | | Data | ~310k rows: civil_comments (soft labels), toxic-chat, real-toxicity-prompts, jailbreak corpora, refusal negatives | | Loss | masked BCE on raw annotator fractions | | Hardware | H100 | ## Limitations - **Requires the bundled loader** — the weights are meaningless under a stock causal load, and the failure is silent (see above). - `severe_toxicity` is unreliable for every model we tested. - English only; 384-token training length. - Training data includes `lmsys/toxic-chat` (CC-BY-NC); review if that matters for your use.