Skip to content

Named Entity Recognition (NER)

Named entity recognition tags each token with the span it belongs to: mary/B-PER okafor/I-PER joined/O acme/B-ORG industries/I-ORG. It looks like classification with more labels, and that resemblance is exactly what makes it easy to measure wrongly.

The best model on this page scores 0.8011 token accuracy and 0.4389 entity F1 on the same predictions. Both numbers are correct. Only one of them is the thing you care about.

  • BIO tagging, and how a tag sequence becomes a set of spans.
  • Why token accuracy flatters: predicting O everywhere already scores 0.5653.
  • Why an entity counts only if its start, end and type are all right.
  • The measurement that separates a real tagger from a word list: 38.00% of test tokens are words the model has never seen.
  • What the errors actually are — 48.09% missed, 17.04% boundary-off — and what that implies about the fix.

CoNLL-2003 is not redistributable, so this page generates its data: templated sentences with PERSON, LOCATION and ORGANISATION spans and exact BIO tags. Generated data gets the labels right by construction, and it lets the difficulty be set deliberately rather than inherited.

The first version of this corpus was useless. Every model scored a perfect 1.0000 token accuracy and 1.0000 entity F1, because the entity pools were small closed lists — the models memorised which strings are names and never had to look at context. That measures a lookup table.

Two changes fix it, and they are the two things that make real NER hard:

  1. The test entity pools are disjoint from the training ones. Every test name, city and organisation is a string the model has never seen — 38.00% of test tokens are unknown words.
  2. Some words are ambiguous. may, hope, mark, summit, delta and orion appear both as entity words and as ordinary filler. No lookup table resolves them; only the surrounding context can.
TagTokensShare
O11,4690.5653
B-PER1,5000.0739
I-PER1,5000.0739
B-LOC1,5000.0739
I-LOC9770.0482
B-ORG1,5000.0739
I-ORG1,8420.0908

A model that predicts O for every token scores 0.5653 token accuracy and finds zero entities. On real newswire the O share is closer to 0.83, so the free score is higher still.

figure 1,500 held-out sentences matplotlib
Two panels. Left: bars of tag share where O dominates at 0.5653 and the six entity tags sit between 0.0482 and 0.0908. Right: paired bars of token accuracy against entity F1 for four models — all-O scores 0.5653 token accuracy and 0.000 F1, per-token dense 0.575 and 0.032, 1D convolution 0.801 and 0.439, bidirectional LSTM 0.784 and 0.223. Two panels. Left: bars of tag share where O dominates at 0.5653 and the six entity tags sit between 0.0482 and 0.0908. Right: paired bars of token accuracy against entity F1 for four models — all-O scores 0.5653 token accuracy and 0.000 F1, per-token dense 0.575 and 0.032, 1D convolution 0.801 and 0.439, bidirectional LSTM 0.784 and 0.223.
The right panel is the page in one picture. Every model's token accuracy is comfortably above the 0.5653 floor and looks respectable; the entity F1 next to it tells a completely different story, and the ordering of the two metrics is not even the same. The per-token dense model beats the do-nothing baseline by 0.0097 on tokens while finding almost nothing: F1 0.0317.

An entity is correct only if its start, end and type all match:

BIO tags to spans
def spans(tags):
    out, start, kind = set(), None, None
    for position, tag in enumerate(tags):
        if tag.startswith("B-"):
            if start is not None:
                out.add((start, position, kind))
            start, kind = position, tag[2:]
        elif tag.startswith("I-") and start is not None and tag[2:] == kind:
            continue                      # the span continues
        else:
            if start is not None:
                out.add((start, position, kind))
            start, kind = None, None
    if start is not None:
        out.add((start, len(tags), kind))
    return out

Getting acme/B-ORG industries/O right on one token out of two is not half an entity — it is a missed entity and a spurious one-token entity. That asymmetry is why entity F1 sits so far below token accuracy.

ModelParametersToken accuracyPrecisionRecallEntity F1
all O00.56530.00000.00000.0000
per-token dense5,9590.57500.25940.01690.0317
1D convolution10,0550.80110.59210.34870.4389
bidirectional LSTM20,4870.78450.21520.23090.2228
figure Four metrics, four models matplotlib
Grouped bars showing token accuracy, entity precision, entity recall and entity F1 for four models. The all-O baseline has token accuracy 0.57 and zeros elsewhere; per-token dense has high token accuracy but near-zero recall; the 1D convolution leads on every entity metric; the bidirectional LSTM sits between them. Grouped bars showing token accuracy, entity precision, entity recall and entity F1 for four models. The all-O baseline has token accuracy 0.57 and zeros elsewhere; per-token dense has high token accuracy but near-zero recall; the 1D convolution leads on every entity metric; the bidirectional LSTM sits between them.
The per-token dense model is the control that makes the point: with no access to neighbouring words it cannot tell an unseen name from an unseen filler word, so it scores 0.2594 precision at 0.0169 recall — it tags almost nothing, and token accuracy still reads 0.5750. Both convolution and recurrence can see context; the convolution's fixed 3-token window happened to suit this template-generated data better than the LSTM's unbounded one at this budget.

Two honest observations:

  • The per-token dense model fails by construction. It sees one embedding at a time. Since 38% of test entity words are unknown tokens, it has nothing to go on — and its 0.5750 token accuracy still looks like a working model.
  • The bidirectional LSTM lost to a 1D convolution (0.2228 against 0.4389). The entities here sit inside rigid templates where the informative context is one or two words away, which is precisely a convolution’s window. This is a property of the generated corpus, not a general result — on real newswire, where entity context is longer-range and messier, recurrent and transformer taggers win comfortably.
figure 4,500 true entities, best model matplotlib
A bar chart of entity-level outcomes for the 1D convolution: 1,569 exact at 34.87%, 0 wrong type, 767 boundary-off at 17.04%, 2,164 missed at 48.09%, and 0 spurious. A bar chart of entity-level outcomes for the 1D convolution: 1,569 exact at 34.87%, 0 wrong type, 767 boundary-off at 17.04%, 2,164 missed at 48.09%, and 0 spurious.
Nearly half the entities are missed outright — unsurprising when the words are unseen. The 17.04% boundary-off cases are the interesting ones: the model found the entity and got its type right but its start or end wrong, usually by dropping the first token of a two-word name. Those are entirely invisible to token accuracy, which happily credits the token it did get right.
OutcomeCountShare
exact1,56934.87%
wrong type00.00%
boundary off76717.04%
missed2,16448.09%
spurious00.00%

A worked example from the test set:

text
truth:     coastal/B-ORG power/I-ORG   rashid/B-PER moreau/I-PER   oslo/B-LOC
predicted:               power/I-ORG                moreau/I-PER   oslo/B-LOC

Two spans lost their first token. Under strict BIO decoding an I- tag with no B- before it opens nothing at all, so those two entities do not become shortened entities — they disappear. Token accuracy still counts five of seven tokens right (0.7143); the entity score for that sentence is one span out of three. The model has learned “the second word of a name is part of a name” without learning where names begin, and only the span-level metric can see it.

That failure mode is exactly what a CRF layer on top of the tagger is for: it scores whole tag sequences rather than independent tokens, so I-PER following O — which is not a legal BIO sequence at all — can be ruled out structurally instead of hoped for.

diagram Diagram mermaid
sketch Tags to spans, and what counts as correct p5.js
Click a token to cycle its predicted tag. The span-level score updates — and only exact matches count.
sketch The measured table, ranked p5.js
Click a column to rank every row by it. The bars are that column's values and the highest and lowest are computed from the numbers, not written in.
  • Reporting token accuracy. All-O scores 0.5653 here and 0.83+ on real newswire.
  • Building a corpus where entity strings repeat between train and test. Every model scored 1.0000 until the pools were made disjoint — the benchmark was a word list.
  • Ignoring boundary errors. 17.04% of entities were found with the right type and the wrong span; token accuracy credits them.
  • Assuming a bigger model wins. The bidirectional LSTM has twice the parameters of the convolution and scored half the F1 on this data.
  • Using a per-token softmax and expecting legal sequences. O followed by I-PER is not valid BIO; only a CRF or constrained decoding rules it out structurally.
  • Micro-averaging over a corpus with one dominant type. Report per-type F1 as well, or a large class hides the others.
  • Generalising from generated data. These templates favour a short context window; real newswire does not.
  • BIO tags become spans; an entity is correct only if start, end and type all match.
  • Predicting O everywhere scored 0.5653 token accuracy and 0.0000 entity F1.
  • Best model: 1D convolution at 0.8011 token accuracy but 0.4389 entity F1.
  • A per-token dense model — no context — reached 0.5750 token accuracy and 0.0317 F1.
  • 38.00% of test tokens were unseen words, which is what forces the model to use context instead of memorising strings.
  • Errors: 48.09% missed, 17.04% boundary-off, and the worked example lost the first token of every span.

Tagging reads a sequence and labels it in place. Generating a different sequence needs a decoder, and that is the next architecture: The Transformer Architecture.

pch.quizTag pch.quizDefaultTitle
  1. A tagger scores 0.8011 token accuracy and 0.4389 entity F1 on the same predictions. Which should you report, and why?

    pch.quizShowAnswer

    B — Entity F1 — an entity is only useful if its start, end and type are all correct, and predicting O everywhere already scores 0.5653 token accuracy while finding nothing — On real newswire the O share is around 0.83, so the free token-accuracy score is higher still.

  2. The first version of this corpus gave every model 1.0000 token accuracy and 1.0000 entity F1. What was wrong?

    pch.quizShowAnswer

    B — The entity pools were small closed lists shared between train and test, so the models memorised which strings are names and never used context — the benchmark measured a lookup table — Making the test pools disjoint pushed 38% of test tokens to unknown words, and the scores immediately became informative.

  3. A per-token dense model scored 0.5750 token accuracy but only 0.0317 entity F1. Why is it so bad at entities?

    pch.quizShowAnswer

    B — It sees one token embedding at a time with no neighbours, and 38% of test entity words are unknown — so it has no information to distinguish an unseen name from unseen filler — It is the control that proves context is doing the work in the other two models — and its token accuracy still sits above the all-O baseline.

  4. 17.04% of entities were 'boundary off' — right type, wrong span. Why does token accuracy hide these?

    pch.quizShowAnswer

    B — Because it credits every token the model got right, so a two-word name tagged on only its second token still contributes a correct token while the entity is entirely lost — The worked example lost the first token of all three spans: 0.625 token accuracy, one correct span out of three.

  5. What problem does a CRF layer on top of a tagger solve?

    pch.quizShowAnswer

    B — A per-token softmax can emit illegal sequences such as O followed by I-PER; a CRF scores whole tag sequences, so invalid transitions are ruled out structurally — That directly attacks the boundary errors, which were 17.04% of all entities here.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading