Tutorial: Detecting Semantic Shift Over Time
The flagship workflow in chronowords is measuring how a word’s meaning changes between time periods. The recipe is:
Split your corpus into time slices (decades, reigns, regimes — whatever suits the question).
Train a separate
SVDAlgebraembedding space on each slice.Align the slices into a common space with
ProcrustesAligner(embedding spaces from independent SVD runs are not directly comparable — axes are arbitrary).Score each shared word by how stable its aligned vector is across slices.
Optionally, model topics per slice and align them to see how themes evolve.
The bundled examples/presidential_speeches.ipynb notebook works this through
end-to-end on U.S. presidential speeches grouped by quarter-century. The same
recipe drove Crow Intelligence’s study of gender bias in Hungarian media, where
the question was how the words used around women shifted across decades.
This tutorial uses two small synthetic slices so it runs quickly; swap in your own per-slice corpora to reproduce it for real.
1. Train one embedding space per slice
Each slice is just an iterable of text lines. Train one model per slice with the
same n_components so the spaces are the same dimensionality.
from chronowords.algebra import SVDAlgebra
def train_slice(lines):
model = SVDAlgebra(n_components=8, cms_width=10_000, cms_depth=4)
model.train(iter(lines))
return model
model_early = train_slice(corpus_1900s) # your slice-1 lines
model_late = train_slice(corpus_1990s) # your slice-2 lines
For a real study, read each slice from disk:
def load_slice(path):
with open(path, encoding="utf-8") as fh:
return list(fh)
2. Align the two spaces
ProcrustesAligner learns the
orthogonal rotation that best maps the source space onto the target space, using
words shared by both vocabularies as anchors. The frequency-rank window
(min_freq_rank / max_freq_rank) selects stable, frequent anchors and
skips the rare tail.
from chronowords.alignment import ProcrustesAligner
aligner = ProcrustesAligner(min_freq_rank=0, max_freq_rank=1000)
metrics = aligner.fit(
model_early.embeddings,
model_late.embeddings,
model_early.vocabulary,
model_late.vocabulary,
)
print(f"anchored on {metrics.num_aligned_words} words")
print(f"mean anchor cosine after alignment: {metrics.average_cosine_similarity:.3f}")
A high average_cosine_similarity means the two spaces aligned well on the
anchors; a low value means the slices are hard to compare (too few shared words,
or genuinely divergent usage).
Note
fit() raises
ValueError if there are no usable anchors. That usually means the two
slices share too few frequent words — widen max_freq_rank or check that
both vocabularies were built.
3. Score how much each word shifted
get_word_similarity()
returns the cosine similarity between a word’s early and (aligned) late vectors.
High similarity means the word stayed stable; low similarity flags a semantic
shift — the words to investigate.
shared = [w for w in model_early.vocabulary if w in model_late.vocabulary]
shifts = []
for word in shared:
sim = aligner.get_word_similarity(
word, model_early.embeddings, model_late.embeddings
)
if sim is not None:
shifts.append((word, sim))
# Smallest similarity == largest shift.
shifts.sort(key=lambda pair: pair[1])
print("most shifted words:")
for word, sim in shifts[:10]:
print(f" {word}: stability {sim:.3f}")
4. Interpret a shift
A low stability score tells you that a word moved, not how. Inspect each word’s neighbours in both slices to read the change:
word = shifts[0][0]
print(f"{word!r} early neighbours:",
[h.word for h in model_early.most_similar(word, n=8)])
print(f"{word!r} late neighbours:",
[h.word for h in model_late.most_similar(word, n=8)])
If the neighbour sets differ markedly, the word is being used in a new context — the semantic shift you set out to find.
5. (Optional) Track topics across slices
To see how whole themes evolve rather than single words, fit a
TopicModel per slice and align them. Topic
alignment uses the Hungarian algorithm to pair each source topic with its closest
target topic.
from chronowords.topics import TopicModel
topics_early = TopicModel(n_topics=10)
topics_early.fit(model_early._ppmi_sparse, model_early.vocabulary)
topics_late = TopicModel(n_topics=10)
topics_late.fit(model_late._ppmi_sparse, model_late.vocabulary)
for pair in topics_early.align_with(topics_late):
early_words = [w for w, _ in pair.source_topic.words[:5]]
late_words = [w for w, _ in pair.target_topic.words[:5]]
print(f"sim={pair.similarity:.2f} {early_words} -> {late_words}")
Pairs with low similarity are topics whose vocabulary changed most between slices.
Where to go next
examples/presidential_speeches.ipynb— the full pipeline with real data and Altair visualisations.Troubleshooting — what the common errors mean.
API Reference — full signatures and contracts for every method used above.