← Back to work
NLP / ML Full-Stack

Cyberbullying Classifier

A fine-tuned DistilBERT model that classifies social media posts across three harm levels — outputting calibrated confidence scores to power nuanced, educational interventions rather than blunt content removal.

Role
ML Engineer & Developer
Timeline
2026
Tools
Python, DistilBERT, FastAPI, React, HuggingFace
Status
Cyberbullying Classifier UI

What is this project?

A fine-tuned transformer model that analyses social media comments and classifies them as normal, offensive, or hate speech — trained on the HateXplain dataset of 20,148 annotated social media posts. The model outputs a softmax confidence score for each class rather than a hard label, enabling graduated responses depending on how certain the model is.

The end goal is educational intervention: flagging prejudiced content and helping users understand why it's harmful, not just removing it. The system is deployed as a FastAPI backend serving a React frontend, hosted on HuggingFace Spaces.

This is Project 1 of a personal NLP portfolio, built to develop practical ML engineering skills end-to-end — from raw dataset through to a deployed, versioned model.

What needed solving?

Most content moderation approaches treat hate speech detection as a binary problem — harmful or not. This misses the reality that harm exists on a spectrum. Offensive language and hate speech are meaningfully different: one is often rude but context-dependent, the other targets people based on identity. Treating them identically produces both false positives and false negatives, and makes educational feedback impossible.

Beyond classification granularity, there's the confidence problem. A model that outputs a hard label with no uncertainty signal can't distinguish between a post it's 95% sure about and one it's 51% sure about. For something as consequential as content moderation, that gap matters.

The goal was a model that classifies at three levels of harm and communicates its own uncertainty — so downstream logic can treat borderline cases differently from clear-cut ones.

What the data revealed

The HateXplain dataset contained 60,444 rows of social media posts. Each post would have exactly 3 rows as 3 annotators would label it independently. This included labelling the post content as normal, offensive, or hate speech, along with the vulnerable group that was being targetted by that post, if any. Aggregating to a majority-voted clean dataset of 19,229 posts revealed several non-obvious patterns that shaped every subsequent modelling decision.

01
51% annotator disagreement

Half of all posts had at least one annotator who disagreed with the majority. This can be inferreed to be a result of genuine human ambiguity about what constitutes harm, causing disagreement in the labelling. Any model trained on this data has a hard ceiling on accuracy, because even humans don't agree.

Raw label distribution Sources of annotator disagreement

Label distribution across 60,444 annotator votes (left) and breakdown of where disagreement falls (right) — normal vs offensive is the most common conflict boundary. Notably, there are 919 posts where all 3 annotators labelled differently.

02
Privacy tokens are higher in normal posts

There exists tokens like <user> and <number> that were replaced to privatise the data. These appeared at 45% rate in normal posts vs. 21% in hate speech. Hate speech tends to be broadcast-style — general statements targeting groups — rather than directed at specific individuals. This shaped how special tokens were handled during tokenisation.

03
<censored> is evenly distributed across all classes

Explicit language appears equally across normal, offensive, and hate speech posts. This confirmed that profanity alone is not a signal for harm. The model needed to learn semantic context, not simply labelling based on vocabulary used.

04
Max post length of 165 words, mean of 23

The distribution made MAX_LENGTH=128 a safe truncation point with negligible information loss, and confirmed DistilBERT's 512-token limit was never going to be a constraint.

Most frequent words by class

Most frequent words per class. <user> and <number> dominate normal posts; hate speech vocabulary is far more targeted and consistent.

Word count distribution by class

Word count distributions are similar across classes — median 19–22 words. Hate speech skews slightly shorter, consistent with broadcast-style targeting rather than conversational posts.

05
African and Women are the most targeted groups

The dataset's target group annotations reveal which communities bear the most hate speech — useful context for understanding where model errors are most consequential.

Most frequently targeted groups

Target group distribution across hate speech posts. African and Women account for the two largest shares.

Key decisions explored

Two decisions in particular had significant downstream consequences and required deliberate exploration before committing.

Binary vs. three-class classification. Collapsing offensive and hate speech into a single "harmful" class would have simplified training and likely improved accuracy metrics. But it would have destroyed the product's core value — graduated intervention requires knowing which tier a post sits in. The three-class setup was kept.

Hard labels vs. confidence scores. Three output strategies were considered.

A
Hard label only

Simple, but gives no uncertainty signal.

B
Hard label + confidence score

Better, but still loses information about how the other classes scored.

C
Full softmax probabilities — chosen

The most information-rich output, enabling downstream logic to treat a 0.51 / 0.49 split very differently from a 0.97 / 0.02 split. Temperature scaling was planned post-training to recalibrate overconfident logits.

Why DistilBERT, and why these training choices?

Model choice: DistilBERT. A full BERT model would have been slower to train and deploy without meaningfully better results on short social media text. DistilBERT retains ~97% of BERT's performance at 60% of its size — a more practical choice for a simple learning project where inference speed and HuggingFace Spaces resource limits are constraints.

Class weights. The label distribution after majority voting was unbalanced: normal 40.6%, hate speech 30.9%, offensive 28.5%. Without correction, the model would over-predict the majority class. Inverse-frequency class weights (normal: 0.820, offensive: 1.170, hate speech: 1.080) were applied to the loss function to counteract this.

Training setup. AdamW optimiser with linear warmup and decay scheduling, gradient clipping at max_norm=1.0, and early stopping at patience=3. The best checkpoint was v2 at epoch 2, learning rate 2e-5. Four model versions were trained and pushed to HuggingFace Hub, with the best selected by validation macro F1.

Special token registration. The dataset contains 20 domain-specific tokens (<user>, <number>, <censored>, etc.) representing anonymised entities. These were registered with the tokenizer via add_tokens() so the model treats them as atomic units rather than splitting them into subwords.

What shipped and what the numbers mean

The best model (v2 with class weights) achieved 69.6% accuracy and a macro F1 of 0.69 on the held-out test set. Per-class F1 scores: normal 0.73, hate speech 0.78, offensive 0.57.

The offensive class underperforms which makes sense. Offensive posts sit in the middle of the harm spectrum and are the most contextually ambiguous. Hate speech, despite being the hardest to define conceptually, performs best because it contains more consistent linguistic patterns (group-targeted language, slurs).

Critically, throughout multiple iterations, the 69.6% ceiling was never broken through. This was due to the inherent noise in the dataset. The 51% annotator disagreement means that even a perfect model that would still be wrong on cases where humans themselves disagree.

Confusion matrix (row-normalised)

Row-normalised confusion matrix. Offensive is the weakest class — 25% bleeds into normal, 14% into hate speech, reflecting its position as the ambiguous middle ground.

The model mirrors human uncertainty

Before training, the 919 posts where all three annotators disagreed were set aside as a separate ambiguous_posts.csv, excluded from training and reserved for post-hoc analysis. The question was whether the model would behave differently on posts that humans found genuinely unclear.

It does. On the ambiguous set, mean confidence dropped to 0.654, compared to higher confidence on the clean test set. 13.4% of ambiguous posts fell below the 0.50 confidence threshold entirely — the model essentially abstained. The predicted distribution skewed toward offensive (47.8%) rather than the extremes, which aligns with offensive being the most ambiguous middle category.

This is the most important finding in the project. The model was mimicking the systematic uncertainty of human annotators. We can make use of this in a content moderation system: low-confidence predictions can be routed to human review rather than automated action.

Confidence distribution and average class probability on ambiguous posts

Left: confidence distribution on ambiguous posts — a cluster sits just below the 0.5 threshold where the model essentially abstains. Right: mean softmax probability per class — offensive scores highest, consistent with it being the catch-all for uncertain cases.

How it was built and deployed

The ML pipeline is structured as a series of Python modules: preprocess.py handles text normalisation, tokenisation, and label encoding; dataset.py wraps the data into a PyTorch Dataset with a 70/15/15 stratified split; train.py handles the training loop with all scheduler and optimiser logic. Each module has a smoke test.

Four model versions were pushed to HuggingFace Hub across training runs. At container startup, download_models.py fetches the best checkpoint from the Hub — so the Docker image stays lean and model updates don't require a rebuild.

AutoTokenizer was used instead of DistilBertTokenizer directly, because the saved tokenizer.json format (including the registered special tokens) requires the auto-detection path to load correctly.

Model DistilBERT (distilbert-base-uncased)
Backend FastAPI
Frontend React (Vite)
Model Registry HuggingFace Hub
Deployed on HuggingFace Spaces (Docker)
Dataset HateXplain (20,148 posts)

What happened, and what was learned

The project is deployed end-to-end: a fine-tuned transformer accessible via a live web interface, with versioned model artefacts on HuggingFace Hub. As a first NLP project, the goal was to build the full pipeline from scratch rather than call an API — and every layer of it, from dataset wrangling to Docker deployment, was done from first principles.

What worked
  • Separating ambiguous posts before training — the post-hoc analysis was the most interesting finding in the whole project
  • Three-class softmax output over hard labels — the confidence signal is genuinely useful and would enable smarter downstream logic
  • Class weights counteracted the label imbalance without requiring resampling or data augmentation
  • Modular pipeline structure made iterating on training hyperparameters fast
What I'd explore next
  • Temperature scaling to properly calibrate confidence scores
  • Rationale extraction: HateXplain includes word-level human rationales that could be used to train explainability on top of classification
  • The offensive class F1 of 0.57 has the most room to improve — targeted data augmentation or a two-stage classifier might help
Next project
Churp