Measures how English word meanings changed between 1600 and 1920 — no dictionaries, no sense labels, no supervision. Just raw text, publication dates, and geometry.
Type a word, watch its meaning move:
$ python -m semantic_drift word
word> nice
nice — cumulative drift 0.636 (1700-1800 → 1870-1920), present in 3/5 eras
1600-1650 1650-1700 1700-1800 1800-1870 1870-1920
— — n=20 n=24 n=80
absent absent correct chap tilly
theod tired jolly
esteem silly dreadful
ed darling badly
aware foolish valentine
mystery frightened lovely
gross yours awfully
The above is dev-corpus output; the full-corpus rendering replaces it when the current run completes. "Nice" walks from precise/discriminating (1700s: correct, esteem) to pleasant (1870s: jolly, lovely). Neighbors that are new versus the previous era are highlighted in the terminal, so the turnover is visible without explanation.
python -m venv venv && venv/Scripts/activate # or venv/bin/activate
pip install -r requirements.txt -e .
python -m semantic_drift run-all # fetch -> prepare -> train -> validate
python -m semantic_drift word # interactive lookup (the demo)
python -m semantic_drift app # optional Streamlit explorerDevelopment runs: add --limit 20 to any stage (or run-all) for a
20-books-per-era corpus that finishes in minutes. scripts/smoke_test.sh
does that end-to-end plus the test suite.
A word's meaning is approximated by the company it keeps. Train Word2Vec on 1650–1700 text and "awful" sits near sublime, majestic, dread; train on 1870–1920 text and it sits near terrible, dreadful, horrid. The distance a word's vector travels between those spaces is a numeric measure of semantic change.
Why alignment is necessary — the critical subtlety. Word2Vec is randomly initialized and its training objective is invariant to rotation: dimension 47 of the 1650 model has no relationship to dimension 47 of the 1900 model. Two models trained on identical text produce different-looking vectors with identical internal geometry. Naive cosine distance across unaligned spaces measures nothing but random seed.
The fix: orthogonal Procrustes. Each era's space is rotated onto the 1870–1920 reference space. The rotation is fitted on ~60 anchor words assumed semantically stable (function words, numerals, concrete nouns: the, of, three, water, stone…) and then applied to the era's entire matrix. The orthogonality constraint is not a detail: an unconstrained linear map could minimize error by distorting or collapsing the space; only a rotation preserves the cosine geometry the drift metric depends on. Every era aligns directly to the reference — never chained era-to-era, because rotation error compounds.
Guard rails. Words below 100 occurrences in an era don't count as present there (low-frequency vectors are unstable noise); words must be present in ≥2 eras to be scored; anchors must clear the guard in every era; and after alignment the anchors must show near-zero drift by construction — the pipeline refuses to score if they don't. Every era corpus is downsampled to the same token count (book-level sampling, ≤5 % of tokens per author) so drift is not an artifact of how much text survived from each period.
python -m semantic_drift <stage> with stages fetch, prepare, train,
validate, app, word, or run-all. Every stage is resumable (re-runs
skip completed work, never re-download), logs timing and peak memory, and
--force recomputes. Determinism is verified: two independent end-to-end
retrains produce byte-identical drift.parquet (fixed seeds, workers=1,
PYTHONHASHSEED-independent hash function).
Dev-corpus footprint (20 books/era): fetch 402 s (rate-limited), prepare 2 s, train 231 s, peak RSS 242 MB. Full-corpus numbers will be recorded here after the current run. Disk: raw corpus ~600 MB worst case; everything fits well inside 5 GB.
python -m semantic_drift validate checks documented known drifters (awful,
gay, nice, terrific, broadcast…) against known-stable words (anchors, water,
three, mother…): PASS requires a majority of drifters in the top 20 % of
cumulative drift and a majority of stable words in the bottom 40 %.
Full-corpus validation results will be inserted here when the current run completes. For the record, the dev-scale (--limit 20) run FAILs honestly: 9 of 18 known drifters never clear the frequency guard in a 572k-token corpus, while 65 of 66 stable words land in the bottom 40 % — evidence the alignment machinery works and the corpus, not the method, is the dev bottleneck.
- Gutenberg is a biased sample: literary, Anglophone, skewed toward what was worth digitizing, and thinner the further back you go.
- Publication dates are partly estimated. Gutenberg rarely records
original publication dates, so most books use author_birth + 40 (clamped to
the author's lifespan). The manifest records
recordedvsestimatedper book; estimated dominates. - Topic change masquerades as meaning change. This method conflates genuine semantic drift with change in what people wrote about — related but distinct phenomena.
- One vector per word. A word gaining a new sense and losing an old one look identical; polysemy is averaged away.
- Sparse early eras. Pre-1700 results are the least reliable: fewer authors, fewer books, and the author-share cap must sometimes be relaxed there (always logged).
- Anchor words rank low partly by construction — they fit the rotation, so their low drift validates the machinery, not the historical claim.