← Retour à l'épreuve

Singapore team selection 2025 · Correction IOAI Training

Implicit Toxic Comment Detection

Cette page donne toute la méthode. Lisez-la une fois que vous avez cherché.

Métrique
accuracy
Évaluée sur
official test
Notre score
78.7 % accuracy on test.csv
Niveau attendu
no threshold; majority class 67.8 %

Nous avons résolu l'épreuve nous-mêmes : téléchargé les données, mené les expériences et mesuré le score ci-dessous. L'énoncé et les données appartiennent à leurs organisateurs. Cette correction est rédigée en anglais.

The problem in brief

Short social-media posts must be classified as toxic or not, where the toxicity is often implicit: no slur, no insult, but coded language, stereotypes or dog whistles. You get 3 908 labelled posts, 11 727 unlabelled ones, a labelled test.csv of 1 956 posts for local evaluation and a hidden_test.csv of 1 956 posts for the leaderboard. The starter notebook trains a two-layer vanilla RNN on random embeddings. You are free to improve it in any way, with suggestions such as BiLSTMs, CNNs, Transformers, pretrained embeddings and semi-supervised learning with the unlabelled pool.

How you are scored

accuracy=#{correct predictions}#{posts}\text{accuracy} = \frac{\#\{\text{correct predictions}\}}{\#\{\text{posts}\}}

on hidden_test.csv (leaderboard, labels private) and on test.csv (labels public, for local checks). No threshold or leaderboard values are published. We select everything on a 25 % validation split of the training set (as the starter does) and report test.csv accuracy once per final model.

First look at the data

  • Imbalance. 68.4 % of training posts and 67.8 % of test posts have label 1. Predicting 1 for everything already gives 67.8 %: that is the bar any model must clear.
  • Short texts: 16.6 words on average, at most 67.
  • Read the examples. Label 1 includes "feature: legal alliance gains host of court victories for conservative christian movement" and "president executes the laws that congress makes not judicial legislating". Label 0 includes "black thug attacks old white man ... #blackthug" and "very important american #nazi party report". The notebook says 1 means toxic, but the examples suggest the opposite. We checked this with an independent judge: an off-the-shelf hate-speech classifier (Cardiff NLP's twitter-roberta-base-hate-latest, trained on 13 other hate datasets) flags 31 % of label-0 test posts as hate and only 10 % of label-1 posts. Label 0 is the hateful class. This matches the Implicit Hate corpus the data seem to come from, where about two thirds of posts are not hateful. It does not change accuracy, but it changes how you read precision, recall and every error analysis, and you would only discover it by reading examples.

Left: share of test posts flagged as hate by an off-the-shelf hate detector, 31 % for label 0 and 10 % for label 1. Right: test accuracy of each approach: majority 67.8, starter RNN 68.6, TF-IDF 74.1, DistilBERT 72.5, hate RoBERTa zero-shot 71.2, fine-tuned 76.3, with self-training 78.7.

A first baseline

  • The starter RNN (re-implemented without torchtext): 68.6 % on test, 68.1 % on validation, i.e. the majority class plus noise. A vanilla RNN with random embeddings, trained on 2 900 sentences, learns almost nothing; vanishing gradients and the lack of prior knowledge about words are both to blame.
  • TF-IDF + logistic regression on word 1-2-grams and character 2-5-grams: 74.1 %. Character n-grams catch hashtags, misspellings and fragments such as "#14words" that a word vocabulary misses. A linear bag-of-n-grams model is the baseline to beat on small text datasets.
  • Fine-tuned DistilBERT (3 epochs): 72.5 %, worse than TF-IDF. A general-purpose language model fine-tuned on 2 900 noisy examples does not learn what makes a sentence implicitly hateful: the signal is subtle and the labels are noisy.

The key idea

Two levers matter when labelled data are scarce and the concept is subtle.

1. Start from a model that already knows the concept. Implicit hate is defined by meaning in context (who is targeted, which stereotype is invoked), which is exactly what a Transformer pretrained on hate-speech detection has already learnt from tens of thousands of labelled posts in other datasets. We fine-tune cardiffnlp/twitter-roberta-base-hate-latest (a RoBERTa pretrained on 154 M tweets, then trained on 13 hate-speech datasets, none of which is our corpus) on our training split. Used zero-shot it already reaches 71.2 % (mapping its "HATE" to our label 0); fine-tuned for 3 epochs (AdamW, learning rate 2⋅10−52\cdot10^{-5}, linear warm-up and decay) it reaches 76.3 %. This is transfer learning at the task level, not only at the language level.

2. Use the unlabelled posts by self-training. We have three times more unlabelled than labelled posts. The fine-tuned model (the teacher) predicts the 11 727 unlabelled posts; we keep only confident predictions (p>0.9p > 0.9 or p<0.1p < 0.1: 5 280 posts) as pseudo-labels, and train a fresh student from the same pretrained checkpoint on gold plus pseudo-labelled data.

Why does learning from your own predictions help? The student sees many more examples of the patterns the teacher is sure about, which smooths its decision boundary in regions where gold data are sparse (the "cluster assumption": points in the same dense region share a label). The confidence filter matters: pseudo-labels near 0.5 are close to coin flips and would inject noise. One caveat we observed: 84 % of the kept pseudo-labels are label 1, more than the 68 % base rate, because the teacher is more often confident about the majority class. Self-training amplifies the teacher's biases, so check the class balance of what you keep.

The full solution

solution.py implements every experiment behind a command-line switch (rnn, tfidf, bert, zeroshot, hateroberta, selftrain). The final pipeline:

v, probs = finetune(tr.text, tr.label, va.text, va.label, HATE_ROBERTA, epochs=3, lr=2e-5)  # teacher
p = probs(un.text); keep = (p > 0.9) | (p < 0.1)                 # confident pseudo-labels only
T = list(tr.text) + list(un.text[keep]); Y = np.r_[tr.label, (p[keep] > 0.5).astype(int)]
v2, probs2 = finetune(T, Y, va.text, va.label, HATE_ROBERTA, epochs=3, lr=2e-5, seed=1)  # student

At each stage the best epoch is chosen on the validation split; test.csv is never used for any choice. The student's predictions for hidden_test.csv are written one per line, as the submission format asks. Fine-tuning runs on a laptop GPU (Apple MPS) with batches of 16 and sequences truncated to 96 tokens; the whole self-training pipeline takes 7 minutes.

Results

approachvalidation acc.test acc.
always label 10.6850.678
starter: 2-layer RNN, random embeddings0.6810.686
TF-IDF word 1-2-grams + char 2-5-grams, logistic regression0.7590.741
DistilBERT fine-tuned (3 epochs)0.7550.725
hate RoBERTa, zero-shot (no training)0.712
hate RoBERTa fine-tuned (batch 32)0.7790.763
hate RoBERTa fine-tuned (batch 16, the self-training teacher)0.7820.770
+ one round of self-training (final)0.7970.787

The final model gains 11 points over the majority class and 10 over the starter. The difference between the two teacher runs (0.763 and 0.770) gives an idea of run-to-run noise, about one point: the 1.7-point gain from self-training is suggestive rather than proven, and several seeds would be needed to be sure. On such a small and noisy dataset, all scores should be read with that uncertainty in mind (a 95 % interval for an accuracy of 0.79 on 1 956 posts is about ±1.8\pm 1.8 points).

What to remember

  • Read examples before modelling. Here they revealed that label 0, not label 1, is the hateful class; we confirmed it with an independent classifier.
  • Know your floor: with 68 % majority class, "68 % accuracy" means the starter learnt nothing.
  • On small text datasets, TF-IDF with character n-grams is a strong baseline, and a general pretrained Transformer does not automatically beat it.
  • Transfer the task, not just the language: a model already trained for hate detection on other corpora is the best starting point for a subtle, label-scarce variant of the same task.
  • Self-training turns unlabelled data into signal, provided you keep only confident pseudo-labels and watch the class balance they introduce.
Code complet (solution.py)
"""Singapore IOAI 2025 selection test, NLP: implicit toxic comment detection (binary, accuracy).

Data: github.com/duyngtr16061999/toxicdet_datasets (train 3 908 labelled, unlabeled 11 727, test 1 956 labelled,
hidden_test 1 956 unlabelled), cloned into work/toxicdet_datasets.
Validation: 25 % of train (as in the starter), test.csv scored once per final model.

  python solution.py rnn        # starter: 2-layer vanilla RNN, random embeddings
  python solution.py tfidf      # word and character TF-IDF + logistic regression
  python solution.py bert        # fine-tune DistilBERT on the labelled data
  python solution.py zeroshot    # off-the-shelf hate classifier (cardiffnlp/twitter-roberta-base-hate-latest), no training
  python solution.py hateroberta # fine-tune that hate-pretrained RoBERTa
  python solution.py selftrain   # + one round of self-training on confident pseudo-labels of unlabeled.csv
"""
import re, sys, json, time
import numpy as np, pandas as pd, torch, torch.nn as nn
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from scipy.sparse import hstack

torch.set_num_threads(2)
D = "work/toxicdet_datasets/"
tr_all, te = pd.read_csv(D + "train.csv"), pd.read_csv(D + "test.csv")
un, hid = pd.read_csv(D + "unlabeled.csv"), pd.read_csv(D + "hidden_test.csv")
tr, va = train_test_split(tr_all, test_size=0.25, random_state=1234, stratify=tr_all.label)
acc = lambda p, y: float(np.mean(np.asarray(p) == np.asarray(y)))

# ------------------------------------------------------------------ starter RNN (re-implemented without torchtext)
def tok(s): return re.findall(r"[a-z0-9']+|[^\sa-z0-9]", s.lower())[:256]
def run_rnn(epochs=10):
    torch.manual_seed(1234); cnt = {}
    for s in tr.text:
        for w in tok(s): cnt[w] = cnt.get(w, 0) + 1
    stoi = {w: i + 2 for i, w in enumerate(w for w in cnt if cnt[w] >= 5)}
    enc = lambda S: [[stoi.get(w, 0) for w in tok(s)] or [0] for s in S]
    class RNN(nn.Module):
        def __init__(s):
            super().__init__(); s.emb = nn.Embedding(len(stoi) + 2, 300, padding_idx=1)
            s.rnn = nn.RNN(300, 300, 2, dropout=0.5, batch_first=True); s.drop = nn.Dropout(0.5); s.fc = nn.Linear(300, 2)
        def forward(s, X):
            L = torch.tensor([len(x) for x in X]); P = nn.utils.rnn.pad_sequence([torch.tensor(x) for x in X], True, 1)
            h = s.rnn(nn.utils.rnn.pack_padded_sequence(s.drop(s.emb(P)), L, True, enforce_sorted=False))[1]
            return s.fc(s.drop(h[-1]))
    m = RNN(); opt = torch.optim.Adam(m.parameters(), 5e-4); Xtr, ytr = enc(tr.text), torch.tensor(tr.label.values)
    best = (9e9, None)
    for ep in range(epochs):
        m.train(); idx = np.random.permutation(len(Xtr))
        for k in range(0, len(idx), 512):
            b = idx[k:k + 512]; opt.zero_grad()
            nn.functional.cross_entropy(m([Xtr[i] for i in b]), ytr[b]).backward(); opt.step()
        m.eval()
        with torch.no_grad(): vl = nn.functional.cross_entropy(m(enc(va.text)), torch.tensor(va.label.values)).item()
        if vl < best[0]: best = (vl, {k: v.clone() for k, v in m.state_dict().items()})
    m.load_state_dict(best[1]); m.eval()
    with torch.no_grad():
        return {"val": acc(m(enc(va.text)).argmax(1), va.label), "test": acc(m(enc(te.text)).argmax(1), te.label)}

def run_tfidf():
    w = TfidfVectorizer(ngram_range=(1, 2), min_df=2, sublinear_tf=True)
    c = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 5), min_df=2, sublinear_tf=True)
    X = lambda S, fit=False: hstack([w.fit_transform(S), c.fit_transform(S)]) if fit else hstack([w.transform(S), c.transform(S)])
    m = LogisticRegression(C=4, max_iter=3000).fit(X(tr.text, True), tr.label)
    return {"val": acc(m.predict(X(va.text)), va.label), "test": acc(m.predict(X(te.text)), te.label)}

# ------------------------------------------------------------------ Transformer fine-tuning (+ self-training)
def finetune(texts, labels, val_texts, val_labels, name="distilbert-base-uncased", epochs=3, lr=3e-5, seed=0):
    import os; os.environ.setdefault("HF_HOME", "work/hf")
    from transformers import AutoTokenizer, AutoModelForSequenceClassification, get_linear_schedule_with_warmup
    dev = torch.device("mps" if torch.backends.mps.is_available() else "cpu"); torch.manual_seed(seed)
    tk = AutoTokenizer.from_pretrained(name)
    model = AutoModelForSequenceClassification.from_pretrained(name, num_labels=2).to(dev)
    opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
    steps = epochs * ((len(texts) + 15) // 16); sch = get_linear_schedule_with_warmup(opt, int(0.1 * steps), steps)
    def probs(T):
        model.eval(); out = []
        with torch.no_grad():
            for k in range(0, len(T), 64):
                b = tk(list(T[k:k + 64]), padding=True, truncation=True, max_length=96, return_tensors="pt").to(dev)
                out.append(model(**b).logits.softmax(-1)[:, 1].cpu())
        model.train(); return torch.cat(out).numpy()
    rng = np.random.default_rng(seed); texts, labels = list(texts), np.asarray(labels); best = (-1, None)
    for ep in range(epochs):
        for k, b in enumerate(np.array_split(rng.permutation(len(texts)), (len(texts) + 15) // 16)):
            enc = tk([texts[i] for i in b], padding=True, truncation=True, max_length=96, return_tensors="pt").to(dev)
            loss = model(**enc, labels=torch.tensor(labels[b]).to(dev)).loss
            opt.zero_grad(); loss.backward(); opt.step(); sch.step()
        v = acc(probs(val_texts) > 0.5, val_labels); print("  epoch", ep, "val acc", round(v, 4), flush=True)
        if v > best[0]: best = (v, {k: t.detach().cpu().clone() for k, t in model.state_dict().items()})
    model.load_state_dict(best[1]); return best[0], probs

def zero_shot(name="cardiffnlp/twitter-roberta-base-hate-latest"):
    """Use the off-the-shelf hate classifier WITHOUT training: which of our labels does it call hate?"""
    import os; os.environ.setdefault("HF_HOME", "work/hf")
    from transformers import AutoTokenizer, AutoModelForSequenceClassification
    tk = AutoTokenizer.from_pretrained(name); m = AutoModelForSequenceClassification.from_pretrained(name).eval()
    hate_id = [i for i, l in m.config.id2label.items() if "NOT" not in l.upper()][0]
    with torch.no_grad():
        p = np.concatenate([m(**tk(list(te.text[k:k + 64]), padding=True, truncation=True, max_length=96,
                                   return_tensors="pt")).logits.softmax(-1)[:, hate_id].numpy() for k in range(0, len(te), 64)])
    hate = p > 0.5
    return {"labels": m.config.id2label, "share_called_hate_label0": float(hate[te.label.values == 0].mean()),
            "share_called_hate_label1": float(hate[te.label.values == 1].mean()),
            "test_acc_if_hate_means_0": acc(np.where(hate, 0, 1), te.label)}

def run_bert(selftrain=False, name="distilbert-base-uncased", epochs=3, lr=3e-5):
    v, probs = finetune(tr.text, tr.label, va.text, va.label, name, epochs, lr)
    res = {"val": v, "test": acc(probs(te.text) > 0.5, te.label)}
    if selftrain:
        # pseudo-label the unlabeled pool with the teacher, keep only confident predictions,
        # then train a fresh student on gold + pseudo labels (the teacher never sees test.csv)
        p = probs(un.text); keep = (p > 0.9) | (p < 0.1)
        print("pseudo-labels kept:", int(keep.sum()), "of", len(p), "share toxic=1:", float((p[keep] > 0.5).mean()))
        T = list(tr.text) + list(un.text[keep]); Y = np.r_[tr.label.values, (p[keep] > 0.5).astype(int)]
        v2, probs2 = finetune(T, Y, va.text, va.label, name, epochs, lr, seed=1)
        res = {"teacher_val": v, "teacher_test": res["test"], "val": v2, "test": acc(probs2(te.text) > 0.5, te.label)}
        pd.Series((probs2(hid.text) > 0.5).astype(int)).to_csv("work/predictions.csv", index=False, header=False)
    return res

if __name__ == "__main__":
    what = sys.argv[1]; t0 = time.time()
    print("majority class: val", acc(np.ones(len(va)), va.label), "test", acc(np.ones(len(te)), te.label))
    HR = "cardiffnlp/twitter-roberta-base-hate-latest"
    r = {"rnn": run_rnn, "tfidf": run_tfidf, "bert": run_bert, "zeroshot": zero_shot,
         "hateroberta": lambda: run_bert(False, HR, 3, 2e-5),
         "selftrain": lambda: run_bert(True, HR, 3, 2e-5)}[what]()
    r["minutes"] = round((time.time() - t0) / 60, 1); print(what, json.dumps(r))
    json.dump(r, open(f"work/res_{what}.json", "w"))