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:

  1. Split your corpus into time slices (decades, reigns, regimes — whatever suits the question).

  2. Train a separate SVDAlgebra embedding space on each slice.

  3. Align the slices into a common space with ProcrustesAligner (embedding spaces from independent SVD runs are not directly comparable — axes are arbitrary).

  4. Score each shared word by how stable its aligned vector is across slices.

  5. 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.