Named Entity Extractor: Structured Information From Real Documents

Data Science & ML Projects (Intermediate)

Chapter 6 · Named Entity Extractor: Structured Information From Real Documents

nlp1-7 built a BiLSTM tagger on a five-word toy sentence. Chapter 1 of this course already proved the pattern for sentiment: take nlp1's own architecture unchanged, point it at real data. This chapter does that again for NER — and it's a structural sequel to Chapter 1 specifically, since tagging every token is a genuinely different shape of problem than classifying a whole document.

What We're Building

An entity extractor identifying people, organizations, and locations across a real batch of documents — trained on CoNLL-2003, the canonical NER benchmark (comparable in stature to MNIST for images or IMDb for sentiment), then run over genuinely new text to produce structured, per-document extracted-entity output.

Step 1: A Real Labeling Scheme, More Detailed Than nlp1-7's Own

# CoNLL-2003 format: one token and tag per line
Sarah    B-PER
Chen     I-PER
works    O
at       O
New      B-ORG
York     I-ORG
Times    I-ORG
A real complication nlp1-7's own simplified tags skipped
nlp1-7 tagged each entity token with a single label (PER, ORG, LOC). Real NER data uses IOB tagging: B- marks the beginning of an entity, I- marks a continuation of the same entity, and O marks "not an entity." Without this distinction, two adjacent single-token entities ("Paris" then "London," two separate places) would be indistinguishable from one genuine two-token entity ("New York," one place) — the B-/I- split is what makes multi-word entities representable at all.

Step 2: Vocabulary — Reusing Chapter 1's Own Approach

from collections import Counter

all_tokens = [tok for sentence in sentences for tok, tag in sentence]
vocab = {word: i + 2 for i, (word, _) in enumerate(Counter(all_tokens).most_common(15000))}
vocab["<pad>"], vocab["<unk>"] = 0, 1

tag_set = sorted({tag for sentence in sentences for _, tag in sentence})
tag2idx = {tag: i for i, tag in enumerate(tag_set)}

Same frequency-capped vocabulary pattern dsproj2-1 established — plus a second small vocabulary, this time for the tag set itself.

Step 3: The Exact BiLSTM Tagger From nlp1-7

import torch.nn as nn

class SequenceLabeler(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, num_tags):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        self.lstm = nn.LSTM(embed_dim, hidden_dim, batch_first=True, bidirectional=True)
        self.output = nn.Linear(hidden_dim * 2, num_tags)

    def forward(self, x):
        embedded = self.embedding(x)
        outputs, _ = self.lstm(embedded)
        return self.output(outputs)   # one prediction per token, per nlp1-7

Character for character nlp1-7's own architecture — every hidden state kept, bidirectional for exactly the "Washington could be a place or a person" reason that chapter demonstrated.

Step 4: Training — A Real Batching Gotcha Toy Data Never Surfaced

import torch

loss_fn = nn.CrossEntropyLoss(ignore_index=tag2idx["<pad>"])

for epoch in range(5):
    for X_batch, y_batch in train_loader:
        optimizer.zero_grad()
        logits = model(X_batch)                     # (batch, seq_len, num_tags)
        loss = loss_fn(logits.view(-1, len(tag_set)), y_batch.view(-1))
        loss.backward()
        optimizer.step()
A gotcha specific to sequence labeling at batch scale
Padding shorter sentences to a fixed length (needed for batching, per Chapter 1) means the padded positions have no real tag at all — without ignore_index=tag2idx["<pad>"], the loss function would be penalized for its own predictions on positions that were never real tokens to begin with, actively teaching the model to care about padding. A single toy sentence, batch size one, never surfaces this problem — it only appears once real batches mix sentences of different lengths.

Step 5: Evaluating — The Accuracy Paradox, Again, in a New Form

from sklearn.metrics import classification_report

print(classification_report(all_true_tags, all_predicted_tags))
dsproj2-2's own lesson, back again
Most tokens in any real sentence are tagged O — not part of any entity at all. A model that predicted O for every single token would score deceptively high raw accuracy while catching zero real entities, exactly dsproj2-2's own accuracy-paradox finding, now showing up in a per-token task instead of a per-customer one. Per-entity-type precision and recall (from classification_report) are what actually matter here, not overall token accuracy.

Step 6: Extracting Structured Output From New Documents

def extract_entities(text, model, vocab, idx2tag):
    tokens = text.split()
    ids = torch.tensor([[vocab.get(t, 1) for t in tokens]])
    predicted_tags = model(ids).argmax(dim=-1)[0]

    entities = []
    current = []
    for token, tag_idx in zip(tokens, predicted_tags):
        tag = idx2tag[tag_idx.item()]
        if tag.startswith("B-"):
            if current:
                entities.append(" ".join(current))
            current = [token]
        elif tag.startswith("I-") and current:
            current.append(token)
        else:
            if current:
                entities.append(" ".join(current))
            current = []
    return entities

This is the genuine payoff of the B-/I- scheme from Step 1 — reassembling consecutive B-/I- tokens back into whole multi-word entity strings, turning a stream of per-token tags into the structured, readable output the chapter's own title promised.

Honest limitation: domain shift
A model trained on CoNLL-2003's own news-style text learned news-style entity patterns specifically. Pointed at a genuinely different kind of document — casual social posts, technical manuals, legal text — real accuracy can drop meaningfully, since the patterns that identify an entity in one writing style don't automatically transfer to another. This is a real, well-documented limitation of any trained model, not a flaw specific to this implementation.

IOB tagging

B-/I-/O distinguishes entity starts from continuations — required for multi-word entities.

ignore_index in the loss

Stops padding positions from corrupting real training signal.

Per-entity metrics, not token accuracy

The accuracy paradox, reappearing in a per-token task.

Reassembling tags into entities

Turning a per-token prediction stream back into structured output.

Extend This Project

Try these on your own:

  • Run extract_entities() over several real documents and build a summary table counting entity mentions by type across the whole batch.
  • Add a MISC tag category (CoNLL-2003 includes one) and see how it affects the per-entity-type metrics.
  • Test the trained model on a document type genuinely different from news text, and measure how much real accuracy actually drops.
  • Compare this from-scratch model's accuracy against a pretrained NER pipeline from a library like spaCy — a real, honest build-vs-use comparison.

What's Next

Chapter 7: Using an LLM API as a Data Labeling Tool — a light, deliberate touch of llm1/claude-adv1, using an LLM as one stage inside a larger data pipeline rather than training a model from scratch.

Chapter 6 Quick Reference

  • nlp1-7's own exact BiLSTM tagger architecture, applied to the real CoNLL-2003 benchmark — a structural sequel to this course's own Chapter 1
  • IOB tagging (B-/I-/O) is a real complication beyond nlp1-7's own simplified single-label tags — required to represent multi-word entities
  • ignore_index on the loss function prevents padding positions from corrupting training — a batching gotcha toy data never surfaces
  • dsproj2-2's own accuracy paradox reappears here — per-entity precision/recall matters more than raw token accuracy
  • Domain shift is a real, honest limitation — a news-trained model won't automatically generalize to every document type