contemmcm/qwen2.5-vl-3b-cls-full-bluesky-moderation
qwen2.5-vl-3b-cls-full-bluesky-moderation
This model is a fine-tuned version of Qwen/Qwen2.5-VL-3B-Instruct on the ModerationBenchV2 dataset. It achieves the following results on the evaluation set:
- Loss: 0.2429
- Micro F1: 0.6770
- Macro F1: 0.2598
- Macro F1 Seen: 0.3008
- Macro Ap: 0.3687
- Exact Match: 0.8086
- Safe Accuracy: 0.8949
Model description
A multi-label content-moderation classifier for Bluesky posts (text and images). It is a full fine-tune of Qwen2.5-VL 3B with the language-model head replaced by a classification head: one forward pass returns 22 independent sigmoid scores, one per moderation label, trained with binary cross-entropy. A post can carry several labels at once, and no label reaching its threshold means "safe". There is no generated text to parse.
The 22 labels, in output order (also in label_space.json):
How to use
transformers ships no classification head for Qwen2.5-VL, so the script defines one: three lines composing the library's generic head with the Qwen2.5-VL backbone. Two details are easy to get wrong and both fail silently, so keep them:
- `key_mapping`: without it the language model loads as random weights and every score is noise. transformers prints a load report listing
MISSINGkeys when that happens; a correct load reports none. - Image size: shrink images so the longest side is at most 896 pixels, as in training. An unresized phone photo costs several thousand visual tokens and looks nothing like the training data.
Requires transformers>=5.
import json
from urllib.request import urlopen
import torch
from huggingface_hub import hf_hub_download
from PIL import Image
from transformers import AutoProcessor
from transformers.modeling_layers import GenericForSequenceClassification
from transformers.models.qwen2_5_vl.modeling_qwen2_5_vl import Qwen2_5_VLPreTrainedModel
MODEL = "contemmcm/qwen2.5-vl-3b-cls-full-bluesky-moderation"
# must be exactly this text: the model was trained with it
SYSTEM_PROMPT = (
"You are a content moderation classifier for social media posts. "
"Read the post and assess which moderation labels apply."
)
class Qwen2_5_VLForSequenceClassification(
GenericForSequenceClassification, Qwen2_5_VLPreTrainedModel
):
"""transformers ships no classification head for Qwen2.5-VL. This is its generic one
(a linear layer over the last token) on the Qwen2.5-VL backbone, which is the class
the checkpoint was trained as."""
# The weights are stored under Qwen2.5-VL's original names. Without this mapping the
# language model is silently left randomly initialized and the scores are noise.
KEY_MAPPING = {
"^visual": "model.visual",
r"^model(?!\.(language_model|visual))": "model.language_model",
}
processor = AutoProcessor.from_pretrained(MODEL)
model = Qwen2_5_VLForSequenceClassification.from_pretrained(
MODEL, dtype=torch.bfloat16, device_map="cuda:0", key_mapping=KEY_MAPPING
).eval()
labels = json.load(open(hf_hub_download(MODEL, "label_space.json")))["labels"]
thresholds = json.load(open(hf_hub_download(MODEL, "thresholds.json")))["thresholds"]
# A real post: https://bsky.app/profile/did:plc:3htuuatm2fchjvon7tpc2jge/post/3mtd2ffcsvk22
text = (
"Get Your Summer Tees Here & Please FOLLOW & SHARE. Tees Starting At Just $16. (Wait for "
"the 35-40% off sales, 2-3 times a month.) I, Also, Have Mugs, Tote Bags, Magnets, "
"Stickers, Etc., Available. Over 250 Designs! If You DO Make A Purchase, Thanks In "
"Advance! www.teepublic.com/user/roszelle-art"
)
image = Image.open(urlopen(
"https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:3htuuatm2fchjvon7tpc2jge/"
"bafkreigp5m25icqkxlrm6pvbpzvey2g5bchrqonlyaypo7cs6ahqsekaey"
)).convert("RGB")
# Qwen spends visual tokens in proportion to resolution, and the model was trained on
# images whose longest side is at most 896 pixels. Larger ones must be shrunk first.
image.thumbnail((896, 896), Image.BILINEAR)
messages = [
{"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT}]},
{"role": "user", "content": [{"type": "image", "image": image},
{"type": "text", "text": text}]},
]
inputs = processor.apply_chat_template(
messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt"
).to(model.device)
with torch.no_grad():
probs = torch.sigmoid(model(**inputs).logits[0].float()).tolist()
# a label applies when its probability reaches that label's threshold; none means "safe"
for name, p, cut in zip(labels, probs, thresholds):
if p >= cut:
print(f"{name}: {p:.3f}")Output:
spam: 0.534Pass every image of the post, in order, as its own {"type": "image", "image": ...} entry before the text, each resized the same way. A text-only post simply has no image entries.
Training procedure
Training hyperparameters
The following hyperparameters were used during training:
- learning_rate: 1e-05
- trainbatchsize: 2
- evalbatchsize: 8
- seed: 42
- distributed_type: multi-GPU
- num_devices: 8
- gradientaccumulationsteps: 2
- totaltrainbatch_size: 32
- totalevalbatch_size: 64
- optimizer: Use OptimizerNames.ADAMWTORCH with betas=(0.9,0.999) and epsilon=1e-08 and optimizerargs=No additional optimizer arguments
- lrschedulertype: cosine
- lrschedulerwarmup_steps: 0.03
- num_epochs: 4.0
Training results
Framework versions
- Transformers 5.12.1
- Pytorch 2.6.0+cu124
- Datasets 5.0.1
- Tokenizers 0.22.2
