From 19edd409a66fa964fa2bac2c3be7b1398f694ecf Mon Sep 17 00:00:00 2001 From: Lukas Scheucher Date: Sat, 6 Jun 2026 20:28:02 +0200 Subject: [PATCH 1/2] feat(contextual): drift analysis, eval, and orchestrator drift stage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 3/3 of the contextual word-drift pipeline (issue #34). - analysis/contextual_drift.py: load centered per-year centroids into one coordinate system (frozen encoder => no alignment), compute cosine drift vs the anchor year, gate every (word,year) on min observation count so noisy low-frequency centroids don't pollute the ranking, and emit a long table + a per-word summary with a dispersion (polysemy) column. Reuses analysis/drift.py::compute_drift_from_base; summary aggregation is local because that drift is keyed by a single `year` column. - scripts/contextual_drift.py: add a `drift` stage (and fold it into `all`) that writes drift_vs_{anchor}.parquet + drift_summary.parquet. - scripts/contextual_eval.py: local eval (plan Verification) — anisotropy, stable words, dispersion (polysemous vs monosemous), drift ranking + a coarse noise-floor/SNR. Works with however many years are present. Verified on the 2-year smoke (2014 vs 2018, models/contextual_smoke): - anisotropy: raw ~0.47 both years -> centered ~0.000 both years. - stable words low drift: house 0.117, water 0.026, mother 0.069. - dispersion: polysemous 104.8 > monosemous 100.2 (weak at smoke scale). - drift: 771 gated words, noise floor 0.034; top words are high-variance topical words (sampling noise between two tiny snapshots). Real drifters (covid/zoom/delve) and SNR-vs-Word2Vec need the full 12-year run. Co-Authored-By: Claude Opus 4.8 (1M context) --- analysis/contextual_drift.py | 148 +++++++++++++++++++++++++++++++++++ scripts/contextual_drift.py | 18 +++-- scripts/contextual_eval.py | 122 +++++++++++++++++++++++++++++ 3 files changed, 282 insertions(+), 6 deletions(-) create mode 100644 analysis/contextual_drift.py create mode 100644 scripts/contextual_eval.py diff --git a/analysis/contextual_drift.py b/analysis/contextual_drift.py new file mode 100644 index 000000000..6333f96a5 --- /dev/null +++ b/analysis/contextual_drift.py @@ -0,0 +1,148 @@ +"""Contextual drift vs the anchor year, from finalized per-year centroids. + +Loads the centered contextual centroids (`models/contextual/{year}_centered.npy`) +into one coordinate system (frozen encoder => no alignment needed), computes +cosine drift of each word vs the anchor year, gates every (word, year) on a +minimum observation count so noisy low-frequency centroids don't pollute the +ranking, and emits a long table + a per-word summary that carries a dispersion +(polysemy) column. + +Reuses `analysis/drift.py::compute_drift_from_base`; the summary aggregation is +local because that drift is keyed by a single `year` column (vs the consecutive +`year_a`/`year_b` pairs that `compute_drift_summary` expects). +""" +from __future__ import annotations + +import warnings +from pathlib import Path + +import numpy as np +import pandas as pd + +from analysis.drift import compute_drift_from_base +from config import ANCHOR_YEAR, CONTEXTUAL_MIN_COUNT + + +def load_centroids( + contextual_dir: Path, years: list[int] | None = None +) -> tuple[dict[int, np.ndarray], dict[int, np.ndarray], dict[int, np.ndarray]]: + """Return (centered centroids, counts, dispersion) keyed by year, for years present on disk.""" + contextual_dir = Path(contextual_dir) + aligned: dict[int, np.ndarray] = {} + counts: dict[int, np.ndarray] = {} + disp: dict[int, np.ndarray] = {} + + candidate_years = years + if candidate_years is None: + candidate_years = sorted( + int(p.stem.replace("_centered", "")) + for p in contextual_dir.glob("*_centered.npy") + ) + + for y in candidate_years: + cen = contextual_dir / f"{y}_centered.npy" + if not cen.exists(): + continue + aligned[y] = np.load(cen) + counts[y] = np.load(contextual_dir / f"{y}_count.npy") + disp[y] = np.load(contextual_dir / f"{y}_dispersion.npy") + return aligned, counts, disp + + +def _mean_dispersion(disp: dict[int, np.ndarray], counts: dict[int, np.ndarray], min_count: int) -> np.ndarray: + """Per-word mean dispersion across years where the word is trusted (count >= min_count).""" + years = sorted(disp.keys()) + disp_stack = np.stack([disp[y] for y in years]) # [Y, V] + cnt_stack = np.stack([counts[y] for y in years]) # [Y, V] + masked = np.where(cnt_stack >= min_count, disp_stack, np.nan) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=RuntimeWarning) # all-nan slices -> nan + return np.nanmean(masked, axis=0) # [V]; nan where never trusted + + +def compute_contextual_drift( + contextual_dir: Path, + vocab: dict[str, int], + *, + base_year: int = ANCHOR_YEAR, + min_count: int = CONTEXTUAL_MIN_COUNT, + years: list[int] | None = None, +) -> tuple[pd.DataFrame, pd.DataFrame]: + """Return (long_df, summary_df). long_df is per (word, year) drift vs base_year, + gated to (word,year) pairs where both that year and the base year have + count >= min_count. summary_df is per-word, sorted by total drift, with a + dispersion column.""" + aligned, counts, disp = load_centroids(contextual_dir, years) + if base_year not in aligned: + raise FileNotFoundError( + f"Anchor year {base_year} not found in {contextual_dir} (have {sorted(aligned)})." + ) + + long_df = compute_drift_from_base(aligned, vocab, base_year) + if long_df.empty: + return long_df, long_df + + long_df["wid"] = long_df["word"].map(vocab).astype(int) + base_cnt = counts[base_year] + long_df["count_base"] = base_cnt[long_df["wid"].values] + long_df["count_year"] = 0.0 + for y in aligned: + if y == base_year: + continue + m = long_df["year"] == y + long_df.loc[m, "count_year"] = counts[y][long_df.loc[m, "wid"].values] + + valid = (long_df["count_year"] >= min_count) & (long_df["count_base"] >= min_count) + long_df = long_df[valid].reset_index(drop=True) + if long_df.empty: + return long_df, long_df + + summary = ( + long_df.groupby("word") + .agg( + total_drift=("cosine_distance", "sum"), + mean_drift=("cosine_distance", "mean"), + max_drift=("cosine_distance", "max"), + n_years=("year", "count"), + ) + .reset_index() + ) + max_rows = long_df.loc[long_df.groupby("word")["cosine_distance"].idxmax()][ + ["word", "year"] + ].rename(columns={"year": "max_drift_year"}) + summary = summary.merge(max_rows, on="word") + + mean_disp = _mean_dispersion(disp, counts, min_count) + summary["dispersion"] = mean_disp[summary["word"].map(vocab).astype(int).values] + + summary = summary.sort_values("total_drift", ascending=False).reset_index(drop=True) + return long_df, summary + + +def run_drift( + contextual_dir: Path, + vocab: dict[str, int], + *, + base_year: int = ANCHOR_YEAR, + min_count: int = CONTEXTUAL_MIN_COUNT, + years: list[int] | None = None, +) -> pd.DataFrame: + """Compute drift and write drift_vs_{base}.parquet + drift_summary.parquet.""" + contextual_dir = Path(contextual_dir) + long_df, summary = compute_contextual_drift( + contextual_dir, vocab, base_year=base_year, min_count=min_count, years=years + ) + if summary.empty: + print(f" [drift] no (word,year) pairs passed min_count={min_count}; nothing written", flush=True) + return summary + + long_path = contextual_dir / f"drift_vs_{base_year}.parquet" + summary_path = contextual_dir / "drift_summary.parquet" + long_df.to_parquet(long_path) + summary.to_parquet(summary_path) + print( + f" [drift] {len(long_df):,} (word,year) rows, {len(summary):,} words " + f"(min_count={min_count}) -> {long_path.name}, {summary_path.name}", + flush=True, + ) + return summary diff --git a/scripts/contextual_drift.py b/scripts/contextual_drift.py index 995a83075..2e1b91995 100644 --- a/scripts/contextual_drift.py +++ b/scripts/contextual_drift.py @@ -5,14 +5,14 @@ finalize that year (writes models/contextual/{year}*.npy). Resumable. finalize re-derive {year}*.npy from existing partial accumulators (cheap way to re-tune centering / PCA-removal without re-streaming). - all stream (which finalizes each year on completion). - -The drift stage (parquet + summary + eval) lands in a follow-up PR alongside -analysis/contextual_drift.py. + all stream (finalizing each year on completion) + drift. + drift compute cosine drift vs the anchor year from finalized centroids, + writing drift_vs_{anchor}.parquet + drift_summary.parquet. uv run python scripts/contextual_drift.py --smoke # ~2 min code-path + resume check uv run python scripts/contextual_drift.py --stage stream # full run (background, ~1.7 days) uv run python scripts/contextual_drift.py --stage finalize # re-finalize from partials + uv run python scripts/contextual_drift.py --stage drift # (re)compute drift artifacts """ import argparse import hashlib @@ -25,8 +25,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from config import ( + ANCHOR_YEAR, CONTEXTUAL_DIR, CONTEXTUAL_LAYER, + CONTEXTUAL_MIN_COUNT, CONTEXTUAL_MODEL, CONTEXTUAL_POOLING, CONTEXTUAL_MAX_LEN, @@ -37,6 +39,7 @@ VOCAB_DIR, YEARS, ) +from analysis.contextual_drift import run_drift from pipeline.contextual_aggregate import ContextualAccumulator from pipeline.contextual_finalize import finalize_year, load_partial from pipeline.contextual_model import ContextualEncoder @@ -67,7 +70,7 @@ def main() -> None: ap.add_argument("--device", default="cuda") ap.add_argument("--model", default=CONTEXTUAL_MODEL) ap.add_argument("--years", type=int, nargs="+", default=None) - ap.add_argument("--stage", choices=["stream", "finalize", "all"], default="all") + ap.add_argument("--stage", choices=["stream", "finalize", "drift", "all"], default="all") ap.add_argument("--target-tokens", type=int, default=None) a = ap.parse_args() @@ -104,7 +107,7 @@ def snapshots_for(y: int) -> list[str] | None: encoder = ContextualEncoder(model_name=a.model, device=a.device) print(f" encoder hidden_dim={encoder.hidden_dim}", flush=True) - for year in years: + for year in years if a.stage != "drift" else []: final_path = out_dir / f"{year}.npy" if a.stage == "finalize": @@ -141,6 +144,9 @@ def snapshots_for(y: int) -> list[str] | None: if a.device == "cuda": torch.cuda.empty_cache() + if a.stage in ("drift", "all"): + run_drift(out_dir, vocab, base_year=ANCHOR_YEAR, min_count=CONTEXTUAL_MIN_COUNT, years=years) + print("DONE", flush=True) diff --git a/scripts/contextual_eval.py b/scripts/contextual_eval.py new file mode 100644 index 000000000..cdaff0447 --- /dev/null +++ b/scripts/contextual_eval.py @@ -0,0 +1,122 @@ +"""Local evaluation of the contextual-drift artifacts (plan section Verification). + +Runs the sanity checks that tell us the contextual signal is real: + - anisotropy: random-pair cosine high on raw centroids, ~0 after centering. + - stable words: house/table/water move little between first and last year. + - drifters: known sense-shifters (covid/zoom/mask/remote/delve) rank high; + function-ish words sit low (top-N from the drift summary). + - dispersion: polysemous words (bank/apple/python) > monosemous (house/water). + - noise floor: median per-word total drift vs the drifters' drift (a coarse + SNR; only meaningful with the full multi-year run). + +Works with whatever years are present, so it is useful on the 2-year smoke too. + + uv run python scripts/contextual_eval.py --smoke + uv run python scripts/contextual_eval.py # models/contextual +""" +import argparse +import sys +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from analysis.contextual_drift import compute_contextual_drift, load_centroids +from analysis.drift import cosine_similarity_rows +from config import ANCHOR_YEAR, CONTEXTUAL_DIR, CONTEXTUAL_MIN_COUNT, VOCAB_DIR +from pipeline.vocab import load_vocab + +STABLE = ["house", "table", "water", "mother", "river", "stone"] +DRIFTERS = ["covid", "zoom", "mask", "remote", "delve", "lockdown", "vaccine", "pandemic"] +POLYSEMOUS = ["bank", "apple", "python", "mouse", "spring", "crane"] + + +def _mean_pair_cos(mat: np.ndarray, ids: np.ndarray, n: int = 4000, seed: int = 0) -> float: + rng = np.random.default_rng(seed) + a, b = rng.choice(ids, n), rng.choice(ids, n) + va, vb = mat[a], mat[b] + na, nb = np.linalg.norm(va, axis=1), np.linalg.norm(vb, axis=1) + ok = (na > 0) & (nb > 0) + return float((np.sum(va[ok] * vb[ok], axis=1) / (na[ok] * nb[ok])).mean()) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--smoke", action="store_true") + ap.add_argument("--dir", default=None) + ap.add_argument("--min-count", type=int, default=CONTEXTUAL_MIN_COUNT) + a = ap.parse_args() + + contextual_dir = Path(a.dir) if a.dir else (Path("models/contextual_smoke") if a.smoke else CONTEXTUAL_DIR) + vocab = load_vocab(VOCAB_DIR / "vocab.json") + aligned, counts, disp = load_centroids(contextual_dir) + years = sorted(aligned) + if not years: + print(f"No centered centroids in {contextual_dir}; run the pipeline first.") + return + print(f"dir={contextual_dir} years={years} min_count={a.min_count}\n") + + # 1) Anisotropy (per year): raw {year}.npy vs centered {year}_centered.npy. + print("== anisotropy (mean random-pair cosine; raw high, centered ~0) ==") + for y in years: + raw = np.load(contextual_dir / f"{y}.npy") + cen = aligned[y] + trusted = np.where(counts[y] >= a.min_count)[0] + trusted = trusted[trusted != 0] + if len(trusted) < 50: + print(f" {y}: only {len(trusted)} trusted words, skipping") + continue + print(f" {y}: raw {_mean_pair_cos(raw, trusted):+.4f} centered {_mean_pair_cos(cen, trusted):+.4f}") + + # 2) Stable words: similarity between first and last available year. + y0, y1 = years[0], years[-1] + print(f"\n== stable words: cos(centered {y0}, centered {y1}) [expect high] ==") + a0, a1 = aligned[y0], aligned[y1] + for w in STABLE: + wid = vocab.get(w) + if wid is None or counts[y0][wid] < a.min_count or counts[y1][wid] < a.min_count: + print(f" {w:9s} (insufficient obs)") + continue + sim = float(cosine_similarity_rows(a0[wid:wid+1], a1[wid:wid+1])[0]) + print(f" {w:9s} cos={sim:+.3f} drift={1-sim:.3f} (n={counts[y0][wid]:.0f}/{counts[y1][wid]:.0f})") + + # 3) Dispersion: polysemous vs monosemous (year-invariant polysemy signal). + print("\n== dispersion (polysemous should exceed monosemous) ==") + for label, words in [("polysemous", POLYSEMOUS), ("monosemous", STABLE)]: + vals = [] + for w in words: + wid = vocab.get(w) + ds = [disp[y][wid] for y in years if wid is not None and counts[y][wid] >= a.min_count] + if ds: + vals.append(np.mean(ds)) + if vals: + print(f" {label:11s} mean dispersion = {np.mean(vals):.2f} (over {len(vals)} words)") + + # 4) Drift ranking + coarse SNR. + print(f"\n== drift vs {ANCHOR_YEAR}: ranking + noise floor ==") + if ANCHOR_YEAR not in aligned: + print(f" anchor {ANCHOR_YEAR} absent; skipping drift ranking") + return + _long, summary = compute_contextual_drift(contextual_dir, vocab, base_year=ANCHOR_YEAR, min_count=a.min_count) + if summary.empty: + print(" no words passed gating") + return + noise = float(summary["total_drift"].median()) + print(f" words ranked: {len(summary):,} median total_drift (noise floor) = {noise:.3f}") + print(" top 15 drifters:") + for _, r in summary.head(15).iterrows(): + print(f" {r['word']:15s} total={r['total_drift']:.3f} max={r['max_drift']:.3f}@{int(r['max_drift_year'])} disp={r['dispersion']:.1f}") + present = summary[summary["word"].isin(DRIFTERS)] + if not present.empty: + print(" known drifters present:") + for _, r in present.iterrows(): + snr = r["total_drift"] / noise if noise else float("nan") + print(f" {r['word']:15s} total={r['total_drift']:.3f} SNR vs noise floor = {snr:.1f}x") + else: + print(" (no known drifters in gated set — expected on the 2-year smoke; " + "covid/zoom emerge only with 2020+ years in the full run)") + + +if __name__ == "__main__": + main() From 99cd81d799f93dc1d6530f105c94229a60f17d49 Mon Sep 17 00:00:00 2001 From: Lukas Scheucher Date: Sun, 7 Jun 2026 08:34:39 +0200 Subject: [PATCH 2/2] feat(contextual): noise-normalized drift z-score + trajectory eval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cosine total_drift ranking is dominated by high-variance, near-threshold words whose centroids merely wobble. Add a t-like significance score so the ranking reflects real shifts. - analysis/contextual_drift.py: per (word,year), z = ||Δcentroid|| / SE where SE^2 = dispersion_base/count_base + dispersion_year/count_year (a centroid is a sample mean of `count` vectors, so its squared standard error is dispersion/count; SE^2 adds across two independent years). Δ is on the centered centroids. summary now carries total_z/max_z/peak_year alongside the cosine effect-size columns, and is sorted by total_z. - scripts/contextual_eval.py: z-ranked top list + per-year trajectory table for known drifters vs stable controls (the clean, interpretable view). Validated on the 4-year run (2018-2021, 200M tok/yr): trajectories show the expected flat-then-2020-spike — zoom 0.002/0.033/0.053, pandemic 0.006/0.044/0.050, lockdown 0.031/0.059/0.064 — while stable controls (house/water/mother/river) sit at ~0.000 and delve stays flat (its LLM-ese shift is 2023+, outside this window: a correct negative). Tradeoff worth knowing: z (significance) favors ubiquitous high-count words with tiny effects, while cosine (effect) favors low-count noise; both columns are kept so downstream can balance them. Also: a high min_count gates out emergent words that were rare in the 2018 base year (lockdown/pandemic/moderna vanish at min_count=300), so the default 50 is better for catching neologisms. Unsupervised top-N is ultimately limited by FineWeb vocab noise (foreign tokens, names, SEO/spam drift); curated trajectories are the trustworthy view. Co-Authored-By: Claude Opus 4.8 (1M context) --- analysis/contextual_drift.py | 33 +++++++++++++++++++++++++++++---- scripts/contextual_eval.py | 34 +++++++++++++++++++++++++++------- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/analysis/contextual_drift.py b/analysis/contextual_drift.py index 6333f96a5..a159f860c 100644 --- a/analysis/contextual_drift.py +++ b/analysis/contextual_drift.py @@ -97,9 +97,33 @@ def compute_contextual_drift( if long_df.empty: return long_df, long_df + # Noise-normalized drift (a t-like statistic): how many sampling-error units + # the two yearly centroids sit apart. A centroid is a sample mean of `count` + # contextual vectors, so its squared standard error is dispersion/count + # (dispersion = E||h - c||^2 = trace of the within-word covariance); for the + # difference of two independent years the SE^2 adds. z = ||Δcentroid|| / SE + # therefore de-ranks high-variance / low-count words whose centroids merely + # wobble, while leaving genuine sense shifts (||Δ|| >> SE) high. Δ is taken + # on the *centered* centroids (anisotropy/global drift removed). + base_vec = aligned[base_year] + long_df["disp_base"] = disp[base_year][long_df["wid"].values] + long_df["disp_year"] = 0.0 + long_df["euclid"] = 0.0 + for y in aligned: + if y == base_year: + continue + m = (long_df["year"] == y).values + wids = long_df.loc[m, "wid"].values + long_df.loc[m, "disp_year"] = disp[y][wids] + long_df.loc[m, "euclid"] = np.linalg.norm(aligned[y][wids] - base_vec[wids], axis=1) + se = np.sqrt(long_df["disp_base"] / long_df["count_base"] + long_df["disp_year"] / long_df["count_year"]) + long_df["z_score"] = (long_df["euclid"] / se.replace(0.0, np.nan)).fillna(0.0) + summary = ( long_df.groupby("word") .agg( + total_z=("z_score", "sum"), + max_z=("z_score", "max"), total_drift=("cosine_distance", "sum"), mean_drift=("cosine_distance", "mean"), max_drift=("cosine_distance", "max"), @@ -107,15 +131,16 @@ def compute_contextual_drift( ) .reset_index() ) - max_rows = long_df.loc[long_df.groupby("word")["cosine_distance"].idxmax()][ + peak_rows = long_df.loc[long_df.groupby("word")["z_score"].idxmax()][ ["word", "year"] - ].rename(columns={"year": "max_drift_year"}) - summary = summary.merge(max_rows, on="word") + ].rename(columns={"year": "peak_year"}) + summary = summary.merge(peak_rows, on="word") mean_disp = _mean_dispersion(disp, counts, min_count) summary["dispersion"] = mean_disp[summary["word"].map(vocab).astype(int).values] - summary = summary.sort_values("total_drift", ascending=False).reset_index(drop=True) + # Rank by the noise-normalized score so high-variance words don't dominate. + summary = summary.sort_values("total_z", ascending=False).reset_index(drop=True) return long_df, summary diff --git a/scripts/contextual_eval.py b/scripts/contextual_eval.py index cdaff0447..57995c351 100644 --- a/scripts/contextual_eval.py +++ b/scripts/contextual_eval.py @@ -98,25 +98,45 @@ def main() -> None: if ANCHOR_YEAR not in aligned: print(f" anchor {ANCHOR_YEAR} absent; skipping drift ranking") return - _long, summary = compute_contextual_drift(contextual_dir, vocab, base_year=ANCHOR_YEAR, min_count=a.min_count) + long_df, summary = compute_contextual_drift(contextual_dir, vocab, base_year=ANCHOR_YEAR, min_count=a.min_count) if summary.empty: print(" no words passed gating") return noise = float(summary["total_drift"].median()) - print(f" words ranked: {len(summary):,} median total_drift (noise floor) = {noise:.3f}") - print(" top 15 drifters:") + print(f" words ranked by noise-normalized z: {len(summary):,} " + f"median total_drift (cosine noise floor) = {noise:.3f}") + print(" top 15 drifters (z-ranked):") for _, r in summary.head(15).iterrows(): - print(f" {r['word']:15s} total={r['total_drift']:.3f} max={r['max_drift']:.3f}@{int(r['max_drift_year'])} disp={r['dispersion']:.1f}") - present = summary[summary["word"].isin(DRIFTERS)] + print(f" {r['word']:15s} z_sum={r['total_z']:6.1f} peak@{int(r['peak_year'])} " + f"cos_total={r['total_drift']:.3f} disp={r['dispersion']:.1f}") + present = summary[summary["word"].isin(DRIFTERS)].copy() if not present.empty: print(" known drifters present:") - for _, r in present.iterrows(): + for _, r in present.sort_values("total_z", ascending=False).iterrows(): snr = r["total_drift"] / noise if noise else float("nan") - print(f" {r['word']:15s} total={r['total_drift']:.3f} SNR vs noise floor = {snr:.1f}x") + print(f" {r['word']:15s} z_sum={r['total_z']:6.1f} peak@{int(r['peak_year'])} " + f"cos_total={r['total_drift']:.3f} (cos SNR {snr:.1f}x)") else: print(" (no known drifters in gated set — expected on the 2-year smoke; " "covid/zoom emerge only with 2020+ years in the full run)") + # Per-year trajectories: the clean, interpretable view. Drifters should be + # flat then spike at their event year; stable controls flat throughout. + drift_years = sorted(long_df["year"].unique()) + print(f"\n== per-year drift vs {ANCHOR_YEAR} (cosine distance) — trajectories ==") + print(" " + "word".ljust(13) + "".join(f"{y:>8}" for y in drift_years)) + for label, words in [("drifters", DRIFTERS), ("stable", STABLE)]: + print(f" -- {label} --") + for w in words: + sub = long_df[long_df["word"] == w].set_index("year") + if sub.empty: + continue + cells = "".join( + f"{sub.loc[y, 'cosine_distance']:8.3f}" if y in sub.index else f"{'-':>8}" + for y in drift_years + ) + print(f" {w:13s}{cells}") + if __name__ == "__main__": main()