Notebook 3 — Interpersonal lexical alignment

MDIG2026 Summer School · NLP track (3 / 5)

When people talk, they tend to converge: reusing each other’s words, matching each other’s style. This alignment / entrainment is a hallmark of coordinated interaction — the language-level cousin of the postural and gestural coordination studied on other days.

With both speakers now transcribed, we measure three complementary things per trial:

  1. Lexical repetition — do partners literally reuse each other’s content words?
  2. Linguistic Style Matching (LSM) — do they match on function-word style (pronouns, articles, prepositions…), the classic implicit-coordination signal?
  3. Semantic turn-coupling — is each turn close in meaning to the partner’s previous turn, beyond a shuffled baseline?

And, as in Notebook 2, we ask whether the balance board changes any of it.

# 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.")

0 · Install (run once)

# %pip install sentence-transformers pandas numpy matplotlib

1 · Config & transcripts

# --- 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())}")

2 · Build the turn sequence

We interleave both speakers’ segments by start time into a single ordered turn sequence per trial — the back-and-forth of the interaction.

import re, numpy as np

def tokens(s):
    return re.findall(r"[a-zA-Zà-ÿ']+", str(s).lower())

def turns(trial_id):
    d = tx[tx.trial_id == trial_id].sort_values("start")
    return [(r.role, str(r.text), tokens(r.text)) for _, r in d.iterrows() if tokens(r.text)]

example = max(tx.trial_id.unique(), key=lambda t: len(turns(t)))
for role, text, _ in turns(example)[:8]:
    print(f"{role:>10} | {text}")

3 · Lexical repetition between partners

For each turn, what fraction of its content words already appeared in the partner’s previous turn? Averaged over a trial this is a simple local-alignment score. We compare it against a shuffled baseline (pair turns with random other turns) so we know we’re seeing real coupling, not just common words.

FUNCTION_STOP = set("""a an the of to in on at for and or but is are was were be been am
i you he she it we they me him her us them my your his its our their this that these those
de het een van en of maar is zijn was ik jij je hij zij ze wij we deze die dat""".split())

def content(toks):
    return [w for w in toks if w not in FUNCTION_STOP]

def repetition(seq):
    scores = []
    for i in range(1, len(seq)):
        role, _, toks = seq[i]
        prev_role, _, prev = seq[i-1]
        if role == prev_role:
            continue                     # only across a speaker change
        c, p = set(content(toks)), set(content(prev))
        if c:
            scores.append(len(c & p) / len(c))
    return np.mean(scores) if scores else np.nan

real, shuf = [], []
rng = np.random.default_rng(0)
for tid in tx.trial_id.unique():
    seq = turns(tid)
    if len(seq) >= 3:
        real.append(repetition(seq))
        order = [int(j) for j in rng.permutation(len(seq) - 1) + 1]   # shuffle turns 1..n-1
        shuf.append(repetition([seq[0]] + [seq[j] for j in order]))

import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(5, 3.2))
ax.bar(["real turns", "shuffled"], [np.nanmean(real), np.nanmean(shuf)],
       color=["#48a", "#999"])
ax.set_ylabel("content-word reuse from partner")
ax.set_title("Partners reuse each other's words above chance")
plt.tight_layout(); plt.show()
print(f"real={np.nanmean(real):.3f}  shuffled={np.nanmean(shuf):.3f}")

4 · Linguistic Style Matching (LSM)

LSM (Ireland & Pennebaker) measures matching on function-word categories — the little words we use unconsciously. For each category we compare the two speakers’ usage rates: LSM = 1 − |p_A − p_B| / (p_A + p_B), averaged over categories. High LSM = similar style. (Lists are English-first with a little Dutch, matching the cohort.)

CATEGORIES = {
 "pronouns":    "i you he she it we they me him her us them this that these those".split(),
 "articles":    "a an the de het een".split(),
 "prepositions":"of to in on at for with from by about into over under van in op aan voor met".split(),
 "conjunctions":"and or but so because if while although en of maar want omdat als".split(),
 "auxiliaries": "is are was were be been am do does did have has had will would can could "
                "zijn ben was waren heeft heb had zal zou kan".split(),
 "negations":   "no not never none nothing nee niet geen nooit".split(),
 "quantifiers": "all some many few most more less much any every alle sommige veel weinig".split(),
}

def style_profile(toks):
    n = len(toks) or 1
    return {c: sum(w in wl for w in toks) / n for c, wl in CATEGORIES.items()}

def lsm(seq):
    a = [t for r, _, ts in seq if r == "clue_giver" for t in ts]
    b = [t for r, _, ts in seq if r == "guesser"    for t in ts]
    if len(a) < 5 or len(b) < 5:
        return np.nan
    pa, pb = style_profile(a), style_profile(b)
    vals = [1 - abs(pa[c] - pb[c]) / (pa[c] + pb[c] + 1e-9) for c in CATEGORIES]
    return np.mean(vals)

rows = []
for tid in tx.trial_id.unique():
    seq = turns(tid)
    s = lsm(seq)
    if not np.isnan(s):
        rows.append(dict(trial=tid, condition=tx[tx.trial_id == tid].condition.iloc[0], lsm=s))
lsm_df = pd.DataFrame(rows)

fig, ax = plt.subplots(figsize=(5, 3.2))
lsm_df.groupby("condition").lsm.mean().plot.bar(ax=ax, color=["#c44", "#48a"])
ax.set_ylabel("LSM (style matching)"); ax.set_ylim(0, 1)
ax.set_title("Linguistic Style Matching by condition")
plt.tight_layout(); plt.show()
print(lsm_df.groupby("condition").lsm.agg(["mean", "count"]).round(3))

5 · Semantic turn-coupling

Beyond shared words, is each turn about the same thing as the partner’s previous turn? We embed every turn and average the cosine between adjacent cross-speaker turns, again against a shuffled baseline.

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.")
EMB = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")

def coupling(seq):
    if len(seq) < 3:
        return np.nan, np.nan
    V = EMB.encode([t for _, t, _ in seq], normalize_embeddings=True, show_progress_bar=False)
    # the turn pairs that cross a speaker change
    pairs = [(i - 1, i) for i in range(1, len(seq)) if seq[i][0] != seq[i-1][0]]
    if not pairs:
        return np.nan, np.nan
    adj = [float(V[a] @ V[b]) for a, b in pairs]
    # matched baseline: SAME pair structure, but on shuffled turn content
    Vp = V[np.random.default_rng(0).permutation(len(seq))]
    base = [float(Vp[a] @ Vp[b]) for a, b in pairs]
    return np.mean(adj), np.mean(base)

real, base = [], []
for tid in tx.trial_id.unique():
    a, b = coupling(turns(tid))
    if not np.isnan(a):
        real.append(a); base.append(b)

if real:
    fig, ax = plt.subplots(figsize=(5, 3.2))
    ax.bar(["adjacent turns", "shuffled"], [np.mean(real), np.mean(base)], color=["#48a", "#999"])
    ax.set_ylabel("cross-speaker turn cosine")
    ax.set_title("Are turns coupled to the partner's previous turn?")
    plt.tight_layout(); plt.show()
    print(f"adjacent={np.mean(real):.3f}  shuffled={np.mean(base):.3f}  (n={len(real)} trials)")
else:
    print("Not enough multi-turn trials to measure turn-coupling — run Notebook 1 on more trials.")

Recap & where this goes

  • Partners reuse each other’s content words above chance (local lexical alignment).
  • They show style matching (LSM) on function words — implicit coordination — and you can compare it across board vs ground.
  • Turns are semantically coupled to the partner’s previous turn beyond a shuffled baseline.

Scale & caveats (say this out loud in a project): only 2 dyads and short (~14 s) trials, so treat everything as exploratory — read effect shapes, not p-values. These same measures scale directly to the full Zenodo corpus.

Multiscale hook for the group projects: you now have language coordination at the turn scale (this notebook) and semantic homing at the trial scale (Notebook 2). Line these up against the postural (gyroscope) and gestural coordination from the other days to tell one multiscale story.