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
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()
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))
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.
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.
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_indexon 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