spaCy · en_core_web_sm · a real run
Who, where, when, how much — in ….
A paragraph of business news goes in. Out come the people, the companies, the countries, the money, the percentages, the dates and the statute — each span found, labelled and located, by a 12 MB statistical model running on one CPU core. Nothing on this page was written by a language model. Everything on it was measured.
…
01 — the centrepiece
The annotated article
This is the model's actual output, rebuilt from the ordered
segment list in results.json — the site never re-parses anything.
Press the toggle to strip the highlighting: the difference between those two
states is the entire product.
02 — grouped and counted
Entity summary
Every span, grouped under a human-readable category and counted.
×N is the number of separate mentions — repetition is how you tell
the article's protagonists from its scenery.
03 — noun chunks
Key phrases
Noun chunks taken straight off the dependency parse, lowercased, then filtered to drop pronoun and stopword heads. Sized by mention count. This is what a search index would key on.
04 — dependency parse
Sentence skeletons
Each sentence stripped to subject, root verb lemma and object. It looks trivial written down, and it is exactly the shape a knowledge graph stores. A dash means the parse found no such argument in that sentence.
| Subject | Verb (lemma) | Object |
|---|
05 — why this matters
Extraction as infrastructure
Extraction is the layer nobody demos, and the layer everything sits on.
Before a search engine can rank a document it has to know what the document is
about; before a compliance pipeline can flag a filing it has to know which
parties, amounts and dates the filing names — and that reduction from prose to
structure is a job, not a side effect. A generative model can do it, at roughly
a thousand times the latency and a per-token bill, with an output you then have
to validate; this pipeline does it in milliseconds on a CPU, deterministically,
for the cost of the electricity. The trade is honest, and it goes both ways:
en_core_web_sm is a small statistical model, it will mislabel an
unfamiliar company as a product and a city as a person, and every serious
deployment pairs it with rules, gazetteers and a human review queue for the
cases that matter. But at a hundred million documents the question stops being
which model is cleverest and becomes which model you can afford to run on all of
them — and that is why the un-flashy extraction layer is still, quietly,
underneath the interesting things.
06 — the source
The extraction core
Verbatim from article_xray.py — the whole pipeline
call, the entity grouping, the noun-chunk filter, the skeletons, and the
reverse-offset annotation trick. Stdlib plus spaCy, nothing else.
# This single line runs the whole pipeline: tokenizer -> tagger -> parser -> NER.
start = perf_counter()
doc = nlp(text)
pipeline_ms = (perf_counter() - start) * 1000
# --- ENTITIES ---------------------------------------------------------
# Group every entity under its human label, counting repeats so that the
# article's actual protagonists float to the top.
by_label = {}
for ent in doc.ents:
human = LABEL_HUMAN.get(ent.label_, ent.label_.title())
by_label.setdefault(human, Counter())[ent.text.strip()] += 1
# --- KEY NOUN PHRASES -------------------------------------------------
# noun_chunks come straight off the dependency parse; filtering out pronoun
# and stopword heads leaves the phrases a reader would actually index on.
phrase_counts = Counter()
for chunk in doc.noun_chunks:
phrase = chunk.text.lower().strip()
root = chunk.root
if len(phrase) <= 3 or root.is_stop or root.pos_ == "PRON":
continue
phrase_counts[phrase] += 1
# --- SENTENCE SKELETONS -----------------------------------------------
# Dependency parsing doing information extraction: strip each sentence down
# to who did what to whom, which is the shape a knowledge graph stores.
skeletons = []
for sent in sents[:MAX_SKELETONS]:
root = sent.root
subject = next((t.text for t in root.lefts if "subj" in t.dep_), "-")
obj = next((t.text for t in root.rights
if t.dep_ in ("dobj", "pobj", "attr")), "-")
skeletons.append({"subject": subject, "verb": root.lemma_, "object": obj})
# --- ANNOTATED TEXT ---------------------------------------------------
# Inserting markers shifts every character after the insertion point, so we
# walk each sentence's entities in REVERSE: the classic trick that keeps the
# earlier offsets valid while we rewrite from the end backwards.
for sent in sents[:MAX_ANNOTATED_SENTS]:
marked = sent.text
for ent in reversed(sent.ents):
s = ent.start_char - sent.start_char
e = ent.end_char - sent.start_char
marked = f"{marked[:s]}[{ent.text}|{ent.label_}]{marked[e:]}"
print(f" {' '.join(marked.split())}")
07 — reproduce it
Run it yourself
Four commands. The numbers you get will differ from the ones on this page only in the millisecond count, which depends on your CPU.
-
Create an environment
Python 3.9 or newer. A virtualenv keeps the model out of your system site-packages.
python3 -m venv .venv && source .venv/bin/activate -
Install spaCy
The library only. It ships no models — that is the next step, and the one people forget.
pip install spacy -
Download the model
en_core_web_smis about 12 MB. Without it,spacy.load()raisesOSErrorand the script prints this exact command back at you.python -m spacy download en_core_web_sm -
X-ray something
No argument runs the built-in sample. Pass a path to read a file, and add
--jsonto write theresults.jsonthis page is built from.python article_xray.pypython article_xray.py my_article.txt --json