CoolFace
Modelpublic

contemmcm/qwen2.5-vl-3b-cls-full-bluesky-moderation

sourceHugging Faceupdated 6d agoView on Hugging Face
0likes34downloads
Model Card

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):

pornsexualnuditysexual-figurative
graphic-mediaself-harmsensitiveextremist
intolerantthreatrudeillicit
securityunsafe-linkimpersonationmisinformation
rumormisleadingscamengagement-farming
spaminauthentic

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 MISSING keys 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.

python
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.534

Pass 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

Training LossEpochStepValidation LossMicro F1Macro F1Macro F1 SeenMacro ApExact MatchSafe Accuracy
0.58160.28452000.32140.54620.14130.16360.24370.77010.8513
0.58070.56904000.27900.62640.17800.20610.30770.78630.8721
0.55030.85356000.27110.60920.18830.21810.32580.78990.8625
0.40871.13808000.25630.64620.22150.25640.34300.80090.8798
0.39271.422510000.25990.65880.24360.28210.35580.80090.8897
0.46321.707012000.24330.67540.24840.28760.36490.80790.8881
0.45001.991514000.24440.66360.24170.27980.36580.80830.8879
0.43052.276016000.24650.66860.25990.30090.36590.80730.8918
0.36862.560518000.24360.67750.26070.30180.36830.80980.8922
0.44622.845020000.24370.67340.25930.30030.36780.80860.8918
0.42383.129422000.24370.67540.26870.31110.36800.80860.8929
0.35733.413924000.24270.67830.26660.30870.36900.80790.8941
0.34813.698426000.24350.67520.26800.31030.36860.80770.8935
0.36483.982928000.24290.67720.25980.30080.36860.80880.8949
0.36484.028120.24290.67700.25980.30080.36870.80860.8949

Framework versions

  • —Transformers 5.12.1
  • —Pytorch 2.6.0+cu124
  • —Datasets 5.0.1
  • —Tokenizers 0.22.2