# Use locally-cached models only — never phone home to Hugging Face.# (The embedding model is already downloaded; this prevents a hang if the# network blocks HF's version-check request. Must run before any model load.)import osos.environ["HF_HUB_OFFLINE"] = "1"os.environ["TRANSFORMERS_OFFLINE"] = "1"print("Hugging Face offline mode ON — using cached models only.")Notebook 2 — Semantic homing in the Taboo game
MDIG2026 Summer School · NLP track (2 / 5)
A Taboo clue-giver has one job: steer their partner toward a hidden target word without naming it or its taboo words. That makes every trial a little semantic search we can watch unfold. Using sentence embeddings we ask:
- Warm-up (no transcripts needed): are the taboo words really the target’s nearest semantic neighbours? (i.e. is the game well designed?)
- Homing: does the clue-giver’s running speech get steadily closer in meaning to the target as the trial progresses?
- Taboo pressure: do good clue-givers hug the forbidden semantic neighbourhood while avoiding the literal taboo words?
- Does the balance board matter? Does postural load (board vs ground) flatten the homing curve?
Embeddings are multilingual and run on CPU.
0 · Install (run once)
# %pip install sentence-transformers scikit-learn pandas numpy matplotlib1 · Config, transcripts, metadata, embedding model
# --- Make sure model downloads can verify TLS ---
# (Some Anaconda/Windows installs set SSL_CERT_FILE to a file that doesn't exist,
# which makes Hugging Face model downloads fail with a confusing error. Fix it.)
import os, certifi
if not os.path.exists(os.environ.get("SSL_CERT_FILE", "")):
os.environ["SSL_CERT_FILE"] = certifi.where()
os.environ["REQUESTS_CA_BUNDLE"] = certifi.where()
# --- Locate the corpus (works no matter where Jupyter is launched from) ---
def find_corpus(start=None):
"""Walk upward until we find the folder holding metadata.csv + audios/."""
d = os.path.abspath(start or os.getcwd())
for _ in range(8):
if os.path.isfile(os.path.join(d, "metadata.csv")) and os.path.isdir(os.path.join(d, "audios")):
return d
parent = os.path.dirname(d)
if parent == d:
break
d = parent
raise FileNotFoundError(
"Could not find the BalanceCorpus root (a folder with metadata.csv and audios/).\n"
"Open this notebook from inside the cloned repository."
)
CORPUS = find_corpus()
TRANSCRIPTS = os.path.join(CORPUS, "scripts", "nlp_taboo", "transcripts")
os.makedirs(TRANSCRIPTS, exist_ok=True)
print("Corpus root :", CORPUS)
print("Transcripts :", TRANSCRIPTS)import os, pandas as pd
tx_path = os.path.join(TRANSCRIPTS, "transcripts_all.csv")
if not os.path.exists(tx_path):
raise FileNotFoundError(
"transcripts_all.csv not found. Run Notebook 1 first "
"(set MAX_TRIALS = None to transcribe everything).")
tx = pd.read_csv(tx_path, encoding="utf-8")
print(f"{len(tx)} segments · {tx.trial_id.nunique()} trials · roles: {sorted(tx.role.unique())}")# metadata gives us each trial's target + 5 taboo words
meta = pd.read_csv(os.path.join(CORPUS, "metadata.csv"), encoding="utf-8-sig")
meta["pair_id"] = meta["pair_id"].astype(str)try:
from sentence_transformers import SentenceTransformer
except ModuleNotFoundError:
raise ModuleNotFoundError("sentence-transformers is not installed — run the install cell "
"in Section 0, then re-run this cell.")
import numpy as np
# small, multilingual, CPU-friendly (~470 MB on first download)
EMB = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
def embed(texts):
"""L2-normalized embeddings so a dot product == cosine similarity."""
return EMB.encode(list(texts), normalize_embeddings=True, show_progress_bar=False)
print("Embedding model ready.")2 · Warm-up — is the game well designed?
Before any speech, a pure-metadata check. Embed all 40 target words and, for each, measure how close its 5 taboo words sit compared with random other words. If the game is well built, taboos should be conspicuously near neighbours — they are the traps the clue-giver must route around.
words = meta.drop_duplicates("target_word")
targets = words.target_word.tolist()
tvec = {w: v for w, v in zip(targets, embed(targets))}
taboo_sims, random_sims = [], []
rng = np.random.default_rng(0)
for _, row in words.iterrows():
t = tvec[row.target_word]
taboos = [str(row[f"taboo_{i}"]) for i in range(1, 6)]
taboo_sims += [float(a @ t) for a in embed(taboos)]
others = rng.choice([w for w in targets if w != row.target_word], 5, replace=False)
random_sims += [float(tvec[w] @ t) for w in others]
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(6, 3.2))
ax.hist(random_sims, bins=20, alpha=.6, label="target vs random word", color="#999")
ax.hist(taboo_sims, bins=20, alpha=.6, label="target vs its taboo words", color="#c44")
ax.axvline(np.mean(random_sims), color="#555", ls="--")
ax.axvline(np.mean(taboo_sims), color="#a22", ls="--")
ax.set_xlabel("cosine similarity to target"); ax.set_ylabel("count"); ax.legend()
ax.set_title("Taboo words really are the target's near neighbours")
plt.tight_layout(); plt.show()
print(f"mean cosine taboo={np.mean(taboo_sims):.3f} vs random={np.mean(random_sims):.3f}")3 · The homing curve for one trial
Now the speech. We take the clue-giver’s segments in time order, and at each moment embed everything said so far (the cumulative clue) and measure its cosine similarity to the target. That curve is the clue-giver “homing in” on the answer.
def clue_stream(trial_id):
"""Clue-giver segments for a trial, in time order."""
d = tx[(tx.trial_id == trial_id) & (tx.role == "clue_giver")].sort_values("start")
return d[["start", "end", "text"]].reset_index(drop=True)
def homing_curve(trial_id, target):
d = clue_stream(trial_id)
if len(d) == 0:
return None
tvec = embed([target])[0]
cum, times, sims = "", [], []
for _, s in d.iterrows():
cum = (cum + " " + str(s.text)).strip()
times.append(s.end)
sims.append(float(embed([cum])[0] @ tvec))
return np.array(times), np.array(sims), d
# pick a trial that has clue-giver speech
example = next((t for t in tx.trial_id.unique() if len(clue_stream(t)) >= 3), None)
if example is None:
raise ValueError("No trial has enough clue-giver speech yet — run Notebook 1 on more trials.")
target = tx[tx.trial_id == example].target_word.iloc[0]
times, sims, d = homing_curve(example, target)
fig, ax = plt.subplots(figsize=(7, 3.2))
ax.plot(times, sims, "-o", color="#c44")
ax.set_xlabel("time in trial (s)"); ax.set_ylabel(f"cosine to '{target}'")
ax.set_title(f"Semantic homing · {example}")
plt.tight_layout(); plt.show()
print("Cumulative clue:", " ".join(d.text.astype(str)))4 · Taboo pressure — hugging the boundary
A skilled clue-giver gets semantically close to the target neighbourhood — which is also where the taboo words live — without uttering them. We overlay similarity-to-target with similarity-to-nearest-taboo over the same trial.
def taboo_pressure(trial_id, target, taboos):
d = clue_stream(trial_id)
tvec = embed([target])[0]
bvecs = embed(taboos)
cum, times, to_t, to_b = "", [], [], []
for _, s in d.iterrows():
cum = (cum + " " + str(s.text)).strip()
e = embed([cum])[0]
times.append(s.end); to_t.append(float(e @ tvec))
to_b.append(float(np.max(bvecs @ e)))
return np.array(times), np.array(to_t), np.array(to_b)
row = meta[(meta.pair_id == tx[tx.trial_id == example].pair_id.iloc[0]) &
(meta.target_word == target)].iloc[0]
taboos = [str(row[f"taboo_{i}"]) for i in range(1, 6)]
t, to_t, to_b = taboo_pressure(example, target, taboos)
fig, ax = plt.subplots(figsize=(7, 3.2))
ax.plot(t, to_t, "-o", color="#c44", label=f"to target '{target}'")
ax.plot(t, to_b, "-s", color="#48a", label="to nearest taboo")
ax.set_xlabel("time in trial (s)"); ax.set_ylabel("cosine similarity"); ax.legend()
ax.set_title(f"Target vs taboo pressure · taboos: {', '.join(taboos)}")
plt.tight_layout(); plt.show()5 · Does the balance board flatten homing?
The corpus exists to study postural load — half the trials are played on a balance board, half on the ground. Does the extra embodied effort change how efficiently clue-givers home in? We time-normalize every trial’s homing curve to [0, 1] and average within each condition.
grid = np.linspace(0, 1, 20)
curves = {"board": [], "ground": []}
for tid in tx.trial_id.unique():
tgt = tx[tx.trial_id == tid].target_word.iloc[0]
cond = tx[tx.trial_id == tid].condition.iloc[0]
res = homing_curve(tid, tgt)
if res is None or len(res[0]) < 3:
continue
times, sims, _ = res
tn = (times - times.min()) / (times.max() - times.min() + 1e-9)
curves[cond].append(np.interp(grid, tn, sims))
fig, ax = plt.subplots(figsize=(7, 3.4))
for cond, col in [("ground", "#48a"), ("board", "#c44")]:
if curves[cond]:
arr = np.vstack(curves[cond])
ax.plot(grid, arr.mean(0), color=col, label=f"{cond} (n={len(arr)})")
ax.fill_between(grid, arr.mean(0) - arr.std(0)/np.sqrt(len(arr)),
arr.mean(0) + arr.std(0)/np.sqrt(len(arr)), color=col, alpha=.15)
ax.set_xlabel("normalized trial time"); ax.set_ylabel("cosine to target")
ax.set_title("Average semantic-homing curve · board vs ground"); ax.legend()
plt.tight_layout(); plt.show()
print("Exploratory only (2 dyads). Interpret the *shape*, not significance.")6 · Optional — can the embedding guess the word?
A fun validity check. Embed the clue-giver’s full transcript and ask which of the 40 target words it is closest to. Because we know the real target, we can score accuracy (this scores the model, not the human guesser — the corpus doesn’t record human success).
candidates = targets # the 40 possible words
cand_vecs = embed(candidates)
hits1 = hits5 = n = 0
for tid in tx.trial_id.unique():
d = clue_stream(tid)
if len(d) == 0:
continue
true = tx[tx.trial_id == tid].target_word.iloc[0]
e = embed([" ".join(d.text.astype(str))])[0]
rank = np.argsort(-(cand_vecs @ e))
top = [candidates[i] for i in rank[:5]]
hits1 += (top[0] == true); hits5 += (true in top); n += 1
if n == 0:
print("No clue-giver transcripts found — run Notebook 1 first.")
else:
print(f"retrieve-the-target from the clue transcript (of 40 words):")
print(f" top-1 accuracy: {hits1}/{n} = {hits1/n:.0%}")
print(f" top-5 accuracy: {hits5}/{n} = {hits5/n:.0%}")
print(" (chance top-1 = 2.5%)")Recap
- Taboo words sit measurably close to their targets — the game is well constructed.
- Clue-givers home in: cumulative-clue similarity to the target rises over a trial.
- They ride the taboo boundary — near the forbidden neighbourhood without naming it.
- A board-vs-ground contrast is set up for you to interpret (exploratory, 2 dyads).
Next → Notebook 3: instead of one speaker homing on a word, we look at both speakers and ask whether their language converges.