← Retour à l'épreuve

Singapore team selection 2024 · Correction IOAI Training

Named Entity Recognition

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

Métrique
entity-level micro F1 (seqeval)
Évaluée sur
official test
Notre score
0.802 entity F1 on ned.testb
Niveau attendu
no threshold; BiLSTM-CNN-CRF reference 0.817

Niveau attendu atteint

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

You receive a working notebook that tags Dutch newspaper sentences (CoNLL-2002) with named entities: persons, locations, organisations and "miscellaneous" names, in the BIO scheme (B-PER opens a person name, I-PER continues it, O is everything else). The starter model is a one-layer BiLSTM over randomly initialised word embeddings, trained on 10 000 sampled training sentences. You must (1) stop padding tokens from polluting training and evaluation, (2) replace the token-level metric by a proper entity-level one, (3) initialise the embeddings with pretrained vectors, and (4) improve the architecture, explaining each choice.

How you are scored

This is a camp test graded by the coaches on the notebook: each task must be implemented, run, and explained. There is no hidden test server, and no score threshold is published. The natural yardstick is the entity-level micro F1 on the official test split ned.testb (labels public), the metric of the CoNLL shared task:

P=#predicted entities that exactly match a gold entity#predicted entities,R=#matched#gold entities,F1=2PRP+R.P = \frac{\#\text{predicted entities that exactly match a gold entity}}{\#\text{predicted entities}},\quad R = \frac{\#\text{matched}}{\#\text{gold entities}},\quad F_1 = \frac{2PR}{P+R}.

An entity counts only if both its span and its type are exactly right. For reference, the classic BiLSTM-CRF with character features (Lample et al., 2016) reports 81.7 F1 on this test set, trained on the full 15 806 training sentences; we train on the 10 000-sentence sample imposed by the notebook.

First look at the data

Three facts shape the whole solution.

  • The classes are wildly imbalanced. 90.3 % of the training tokens are O. A tagger that answers O everywhere already has 90 % token accuracy while finding zero entities.
  • Entities are often words never seen in training. In the test set, 44 % of entity tokens are outside the training vocabulary, against 12 % of tokens overall. A word-embedding model maps all of them to the same <unk> vector.
  • But entities look like entities. 95 % of entity tokens start with a capital letter, against 6 % of the other tokens. Names also have telling suffixes (-sen, -burg, -dam).

Left: 44 % of test entity tokens are unseen in training versus 12 % of all tokens. Right: 95 % of entity tokens start with a capital versus 6 % of O tokens.

The conclusion writes itself: the model must be able to read the spelling of a word, not just its identity. That is Task 4.

A first baseline

The starter code (rewritten in plain PyTorch since torchtext 0.6 no longer installs) reports a dev "F-score" of 0.906 after 5 epochs. That number is an illusion, for two reasons that are exactly Tasks 1 and 2.

Task 1: padding. Sentences in a batch are padded to the same length. The starter masks <pad> in the loss (ignore_index), but its evaluation flattens the whole padded batch and feeds every position to precision_recall_fscore_support. Padding positions, trivially predicted, inflate the score. The fix is a boolean mask built from the sentence lengths, applied everywhere:

loss = cross_entropy(emissions[mask], gold[mask])          # training
pred = model.predict(...)[j, :length_j]                     # evaluation: cut each sentence back

Task 2: the metric. Even without padding, average="micro" over all tags is just token accuracy, dominated by the 90 % of O. And token-level scores give partial credit: if the gold entity is B-ORG I-ORG I-ORG ("Raad van State") and the model predicts B-ORG I-ORG O, two tokens out of three are right, yet the organisation was not found. NER is evaluated on whole entities, so we use seqeval, which rebuilds the spans from the BIO tags and applies the formula above.

The same baseline model, measured three ways on the test set:

metric on ned.testbvalue
token accuracy, O included (what the starter effectively measures)0.951
token micro F1 on the 8 entity tags, padding removed0.581
entity-level F1 (seqeval)0.510

From 0.95 to 0.51: the honest metric reveals that the baseline misses about half of the entities.

The key idea

Two weaknesses remain once we measure correctly, and each has a classic fix.

1. The model cannot read words (fixed by pretrained vectors and a character CNN). With random embeddings learnt from 10 000 sentences, rare words get poor vectors and unseen words are all <unk>. Task 3 initialises the embedding matrix with Dutch fastText vectors (cc.nl.300, trained on Common Crawl): 95 % of our vocabulary gets a vector that already places "Amsterdam" next to "Rotterdam". Because these vectors are unsupervised, we may also add to the vocabulary the dev and test words that have a fastText vector (no labels are involved). For Task 4 we add a character-level CNN: each word is spelled into character embeddings (dimension 30), a 1D convolution of width 3 with 50 filters slides over them, and a max over positions gives a 50-dimensional "spelling vector" concatenated to the word vector. A filter can learn "capital letter at the start", "ends in -sen", "contains a digit", so even an unseen surname gets a meaningful representation.

2. The model decides each tag independently (fixed by a CRF). A softmax per token can output O I-PER, an I- tag that continues nothing, or B-LOC I-ORG, a span with two types. Each such inconsistency destroys an entity under the exact-match metric. A linear-chain CRF scores the whole tag sequence:

s(y)=∑t=1n(Et,yt+Tyt−1,yt),p(y∣x)=es(y)∑y′es(y′),s(\mathbf{y}) = \sum_{t=1}^{n} \Big( E_{t, y_t} + T_{y_{t-1}, y_t} \Big), \qquad p(\mathbf{y} \mid \mathbf{x}) = \frac{e^{s(\mathbf{y})}}{\sum_{\mathbf{y}'} e^{s(\mathbf{y}')}},

where EE are the BiLSTM emission scores and TT is a learnt 9×99 \times 9 transition matrix (plus start and end scores). The model learns, for example, a very negative TO,I-PERT_{\texttt{O},\texttt{I-PER}}. Training minimises −log⁡p(ygold)-\log p(\mathbf{y}^{\text{gold}}); the sum over the 9n9^n sequences in the denominator is computed in O(nK2)O(n K^2) by the forward algorithm (a running log-sum-exp), and prediction uses Viterbi, the same recursion with a max. This is dynamic programming, the same trick as in HMMs.

The full solution

solution.py runs the three models with the same data protocol (same 10 000 training sentences, random.seed(42)), selecting the epoch with the best dev entity F1 and reporting the test score once.

  1. Data and masks. Sentences are encoded to word ids, character ids (words truncated to 20 characters) and tag ids; every batch carries a boolean mask of real tokens. Vocabulary: the 20 000 most frequent training words, as in the starter.
  2. Embeddings (Task 3). Rows of the embedding matrix are copied from fastText when the word (or its lower-cased form) has a vector, random otherwise; they stay trainable. To save disk we streamed the 1.2 GB cc.nl.300.vec.gz once and kept only the 36 213 words that occur in the corpus.
  3. Encoder (Task 4). [word vector ; char-CNN vector] with dropout 0.5, a BiLSTM (256 units per direction), dropout 0.5, a linear layer to 9 emission scores.
  4. CRF (Task 4). The CRF fits in about 30 lines; the heart of the forward algorithm is
alpha = start + E[:, 0]                                   # log-scores of all prefixes, per last tag
for t in range(1, L):
    nxt = torch.logsumexp(alpha[:, :, None] + T[None] + E[:, t, None, :], dim=1)
    alpha = torch.where(mask[:, t, None], nxt, alpha)     # padded positions leave alpha unchanged
log_Z = torch.logsumexp(alpha + end, dim=1)
  1. Training. Adam (learning rate 10−310^{-3}), batches of 32, gradient clipping at 5, at most 8 epochs with early stopping on dev entity F1 (patience 3).

Results

All numbers are on the official test set ned.testb (5 195 sentences, 3 941 entities), model chosen on ned.testa.

approachdev entity F1test entity F1test token acc. (with O)
starter as given, its own metric (padding + O counted)"0.906"not measured
Tasks 1-2: same BiLSTM, random embeddings, honest metric0.4730.5100.951
Task 3: + fastText initialisation0.6400.6850.969
Task 4: + char-CNN + CRF (+ input dropout)0.7830.8020.981

Per type for the final model: PER 0.883, LOC 0.855, ORG 0.744, MISC 0.730. MISC is the hardest class (nationalities, events, titles: a fuzzy category), and ORG suffers from long multi-word names where one wrong boundary loses the whole entity.

Observations worth keeping:

  • Token accuracy barely moves (0.951 to 0.981) while entity F1 jumps from 0.51 to 0.80: the wrong metric would have hidden almost all of the progress.
  • Pretrained vectors bring +17.5 points, character features and the CRF another +11.7. We changed the char-CNN and the CRF together, so their separate contributions are not measured here; Lample et al. find both matter.
  • The models overfit quickly (the dev curve peaks at epoch 3 to 5, then drifts down while the training loss keeps falling), which is why we keep the best dev epoch rather than the last one.
  • Our 0.802 is close to the published 0.817 for this architecture while using 63 % of the training sentences and 8 epochs on a CPU.

Dev F1 per epoch for the three models. Solid lines: entity-level F1; dotted: token-level F1 on entity tags. The char-CNN + CRF model reaches 0.78, fastText 0.64, the baseline 0.47; the dotted token curves always sit above the entity curves.

What to remember

  • Mask padding everywhere, in the loss and in the metric; a mask built from sequence lengths is the cleanest way.
  • Evaluate the unit the task cares about. NER is about entities, so use span-level F1 (seqeval); token accuracy on a 90 %-O corpus is nearly meaningless.
  • Unseen words are the main enemy in NER. Pretrained embeddings give them semantics, a character CNN gives them shape (capitals, suffixes, digits).
  • Structured outputs deserve structured decoders. A CRF learns which tag transitions are legal and decodes the best whole sequence with Viterbi, an O(nK2)O(nK^2) dynamic program.
  • Select the model on the dev set with the final metric, and look at per-class scores: they tell you where the remaining errors live (here MISC and ORG).
Code complet (solution.py)
"""Singapore IOAI 2024 residential camp, NLP part 1: Named Entity Recognition on Dutch CoNLL-2002.

Pure PyTorch rewrite of the starter notebook (torchtext 0.6 no longer installs on recent Python),
same data protocol: 10 000 training sentences sampled with random.seed(42), dev = ned.testa,
test = ned.testb.

  Task 1  mask the padding positions in the loss AND in the metric
  Task 2  evaluate entities, not tokens: seqeval (CoNLL span-level micro F1)
  Task 3  initialise the embeddings with Dutch fastText vectors (cc.nl.300)
  Task 4  add a character-level CNN (word shape, capitals, suffixes) and a CRF output layer

Run:  python solution.py prepare      (download CoNLL-2002 + stream fastText, keep corpus words only)
      python solution.py [baseline|fasttext|charcrf|all]
"""
import json, math, random, sys, time
import numpy as np, torch, torch.nn as nn
import nltk
from seqeval.metrics import f1_score, classification_report
from sklearn.metrics import precision_recall_fscore_support

torch.set_num_threads(2)
if sys.argv[1:] == ["prepare"]:
    import gzip, urllib.request
    nltk.download("conll2002", download_dir="work/nltk_data", quiet=True)
nltk.data.path.insert(0, "work/nltk_data")
from nltk.corpus import conll2002 as C

random.seed(42)
train_s = random.sample(list(C.iob_sents("ned.train")), 10000)
dev_s, test_s = list(C.iob_sents("ned.testa")), list(C.iob_sents("ned.testb"))
split = lambda S: ([[w for w, _, _ in s] for s in S], [[t for _, _, t in s] for s in S])
(trX, trY), (dvX, dvY), (teX, teY) = split(train_s), split(dev_s), split(test_s)

# ------------------------------------------------------------------ vocabularies
PAD, UNK = 0, 1
TAGS = sorted({t for s in trY for t in s})
tag2i = {t: i for i, t in enumerate(TAGS)}
def build_vocab(extra=()):
    cnt = {}
    for s in trX:
        for w in s: cnt[w] = cnt.get(w, 0) + 1
    words = sorted(cnt, key=lambda w: -cnt[w])[:20000] + [w for w in extra if w not in cnt]
    return {w: i + 2 for i, w in enumerate(words)}
chars = {c: i + 2 for i, c in enumerate(sorted({c for s in trX for w in s for c in w}))}

def load_fasttext(path="work/ft_nl_filtered.vec"):
    vec = {}
    for line in open(path, encoding="utf-8"):
        p = line.rstrip().split(" ")
        if len(p) == 301: vec[p[0]] = np.asarray(p[1:], dtype=np.float32)
    return vec

# ------------------------------------------------------------------ batching
def encode(X, Y, w2i):
    out = []
    for ws, ts in zip(X, Y):
        wi = [w2i.get(w, w2i.get(w.lower(), UNK)) for w in ws]
        ci = [[chars.get(c, UNK) for c in w[:20]] for w in ws]
        out.append((wi, ci, [tag2i.get(t, tag2i["O"]) for t in ts]))
    return out

def batches(data, bs, shuffle):
    idx = list(range(len(data)))
    if shuffle: random.shuffle(idx)
    for k in range(0, len(idx), bs):
        b = [data[i] for i in idx[k:k + bs]]
        L = max(len(x[0]) for x in b); W = max(len(c) for x in b for c in x[1])
        w = torch.zeros(len(b), L, dtype=torch.long); y = torch.zeros_like(w)
        c = torch.zeros(len(b), L, W, dtype=torch.long); m = torch.zeros(len(b), L, dtype=torch.bool)
        for j, (wi, ci, ti) in enumerate(b):
            n = len(wi); w[j, :n] = torch.tensor(wi); y[j, :n] = torch.tensor(ti); m[j, :n] = True
            for t, cc in enumerate(ci): c[j, t, :len(cc)] = torch.tensor(cc)
        yield w, c, y, m, idx[k:k + bs]

# ------------------------------------------------------------------ model
class CRF(nn.Module):
    """Linear-chain CRF: score(y) = sum_t emit[t, y_t] + trans[y_{t-1}, y_t] (+ start/end)."""
    def __init__(s, K):
        super().__init__(); s.trans = nn.Parameter(torch.zeros(K, K))
        s.start = nn.Parameter(torch.zeros(K)); s.end = nn.Parameter(torch.zeros(K))
    def nll(s, e, y, m):                                  # e: B,L,K  y: B,L  m: B,L
        B, L, K = e.shape; ar = torch.arange(B)
        gold = s.start[y[:, 0]] + e[ar, 0, y[:, 0]]
        alpha = s.start + e[:, 0]                         # log-sum-exp over all paths so far
        for t in range(1, L):
            mt = m[:, t]
            gold = gold + (e[ar, t, y[:, t]] + s.trans[y[:, t - 1], y[:, t]]) * mt
            nxt = torch.logsumexp(alpha[:, :, None] + s.trans[None] + e[:, t, None, :], 1)
            alpha = torch.where(mt[:, None], nxt, alpha)
        last = y[ar, m.sum(1) - 1]
        return (torch.logsumexp(alpha + s.end, 1) - gold - s.end[last]).mean()
    def decode(s, e, m):                                  # Viterbi
        B, L, K = e.shape; score = s.start + e[:, 0]; back = []
        for t in range(1, L):
            v, arg = (score[:, :, None] + s.trans[None]).max(1)
            nxt = v + e[:, t]; mt = m[:, t, None]
            score = torch.where(mt, nxt, score)
            back.append(torch.where(mt, arg, torch.arange(K).expand(B, K)))
        best = (score + s.end).argmax(1); path = [best]
        for bp in reversed(back):
            best = bp.gather(1, best[:, None])[:, 0]; path.append(best)
        return torch.stack(path[::-1], 1)

class Tagger(nn.Module):
    def __init__(s, V, K, emb=None, char_cnn=False, crf=False, E=300, H=256):
        super().__init__()
        s.emb = nn.Embedding(V, E, padding_idx=PAD)
        if emb is not None: s.emb.weight.data.copy_(emb)
        s.char_cnn = char_cnn
        if char_cnn:
            s.cemb = nn.Embedding(len(chars) + 2, 30, padding_idx=PAD)
            s.conv = nn.Conv1d(30, 50, 3, padding=1)
        s.lstm = nn.LSTM(E + (50 if char_cnn else 0), H, batch_first=True, bidirectional=True)
        s.drop = nn.Dropout(0.5); s.out = nn.Linear(2 * H, K)
        s.crf = CRF(K) if crf else None
    def emissions(s, w, c, m):
        x = s.emb(w)
        if s.char_cnn:                                    # max-pooled char CNN per word
            B, L, W = c.shape
            h = s.conv(s.cemb(c.view(B * L, W)).transpose(1, 2)).max(2).values
            x = torch.cat([x, h.view(B, L, -1)], -1)
        x = s.drop(x) if s.char_cnn else x
        lens = m.sum(1).cpu()
        p = nn.utils.rnn.pack_padded_sequence(x, lens, batch_first=True, enforce_sorted=False)
        h, _ = s.lstm(p)
        h, _ = nn.utils.rnn.pad_packed_sequence(h, batch_first=True, total_length=w.shape[1])
        return s.out(s.drop(h))
    def loss(s, w, c, y, m):
        e = s.emissions(w, c, m)
        if s.crf: return s.crf.nll(e, y, m)
        return nn.functional.cross_entropy(e[m], y[m])   # TASK 1: padding masked out of the loss
    def predict(s, w, c, m):
        e = s.emissions(w, c, m)
        return s.crf.decode(e, m) if s.crf else e.argmax(-1)

# ------------------------------------------------------------------ evaluation
def predict_tags(model, data):
    model.eval(); out = [None] * len(data)
    with torch.no_grad():
        for w, c, y, m, ids in batches(data, 64, False):
            p = model.predict(w, c, m)
            for j, i in enumerate(ids): out[i] = [TAGS[k] for k in p[j, :m[j].sum()].tolist()]
    return out

def token_scores(gold, pred):
    """Token-level micro P/R/F over the 8 entity tags, padding never included (TASK 1)."""
    g = [t for s in gold for t in s]; p = [t for s in pred for t in s]
    labs = [t for t in TAGS if t != "O"]
    return precision_recall_fscore_support(g, p, labels=labs, average="micro", zero_division=0)[:3]

def run(name, emb_mode="random", char_cnn=False, crf=False, epochs=5, patience=3, lr=1e-3):
    random.seed(0); torch.manual_seed(0)
    ft = load_fasttext() if emb_mode == "fasttext" else None
    extra = [w for w in {w for S in (dvX, teX) for s in S for w in s} if w in ft] if ft else ()
    w2i = build_vocab(extra)
    emb = None
    if ft:                                                # TASK 3: pretrained Dutch vectors
        emb = torch.randn(len(w2i) + 2, 300) * 0.1; hit = 0
        for w, i in w2i.items():
            v = ft.get(w, ft.get(w.lower()))
            if v is not None: emb[i] = torch.from_numpy(v); hit += 1
        print(f"fastText coverage {hit}/{len(w2i)}")
    tr, dv, te = encode(trX, trY, w2i), encode(dvX, dvY, w2i), encode(teX, teY, w2i)
    model = Tagger(len(w2i) + 2, len(TAGS), emb, char_cnn, crf)
    opt = torch.optim.Adam(model.parameters(), lr=lr)
    best, best_state, hist, bad = -1, None, [], 0
    for ep in range(epochs):
        model.train(); t0 = time.time(); tot = 0
        for w, c, y, m, _ in batches(tr, 32, True):
            opt.zero_grad(); loss = model.loss(w, c, y, m); loss.backward()
            nn.utils.clip_grad_norm_(model.parameters(), 5.0); opt.step(); tot += loss.item()
        dvP = predict_tags(model, dv); f = f1_score(dvY, dvP)   # select on dev ENTITY F1
        hist.append({"epoch": ep, "loss": tot, "dev_entity_f1": f, "dev_token_f1": token_scores(dvY, dvP)[2]})
        print(name, hist[-1], f"{time.time() - t0:.0f}s", flush=True)
        if f > best: best, bad, best_state = f, 0, {k: v.clone() for k, v in model.state_dict().items()}
        else:
            bad += 1
            if bad >= patience: break
    model.load_state_dict(best_state)
    teP = predict_tags(model, te)
    res = {"name": name, "dev_entity_f1": best, "test_entity_f1": f1_score(teY, teP),
           "test_token_prf": token_scores(teY, teP), "history": hist,
           "test_token_acc_incl_O": float(np.mean([a == b for g, p in zip(teY, teP) for a, b in zip(g, p)]))}
    print(classification_report(teY, teP, digits=3))
    print(json.dumps({k: v for k, v in res.items() if k != "history"}))
    json.dump(res, open(f"work/res_{name}.json", "w"))
    return res

def prepare_fasttext(url="https://dl.fbaipublicfiles.com/fasttext/vectors-crawl/cc.nl.300.vec.gz"):
    """Stream the 1.2 GB file once, keep only words of the corpus (any case): ~36k vectors, 80 MB."""
    words = {v for S in (trX, dvX, teX) for s in S for w in s for v in (w, w.lower())}
    with urllib.request.urlopen(url) as r, gzip.open(r, "rt", encoding="utf-8", errors="ignore") as f, \
            open("work/ft_nl_filtered.vec", "w") as out:
        f.readline()
        for line in f:
            if line.split(" ", 1)[0] in words: out.write(line)

if __name__ == "__main__":
    if sys.argv[1:] == ["prepare"]: prepare_fasttext(); sys.exit()
    what = sys.argv[1] if len(sys.argv) > 1 else "all"
    if what in ("baseline", "all"):  run("baseline", epochs=5)                       # tasks 1-2
    if what in ("fasttext", "all"):  run("fasttext", "fasttext", epochs=8)          # task 3
    if what in ("charcrf", "all"):   run("charcrf", "fasttext", True, True, epochs=8, patience=3)  # task 4