Time-lagged cross-correlation of 168 observer/target dyads¶

Design. 28 observers (14 Test, 14 Control) x 6 target trials = 168 dyadic analyses. Continuous tracking sampled at 1000 ms.

Lag window. -5 s to +10 s evaluated at every 1000 ms increment. Both endpoints are included, so each trial yields exactly 16 coefficients.

Sign convention. r(tau) = pearson(x[t], y[t - tau]). A positive peak lag means the observer lags the target, which is the quantity the study calls temporal cognitive lag.

The data in this notebook is synthetic, generated with a known per-observer lag so that every step can be checked against ground truth. No study data is used.

In [1]:
import numpy as np, pandas as pd
from scipy.stats import pearsonr
import statsmodels.formula.api as smf

from tlcc.core import lag_grid, tlcc, peak_lag, fit_random_intercept_lmm
from tlcc.simulate import build_dataset

ds = build_dataset()
lags = lag_grid(-5, 10, step_ms=1000)
print("lags:", list(lags))
print("n coefficients per trial:", len(lags))
print("n dyads:", len(ds["dyads"]))
lags: [np.int64(-5), np.int64(-4), np.int64(-3), np.int64(-2), np.int64(-1), np.int64(0), np.int64(1), np.int64(2), np.int64(3), np.int64(4), np.int64(5), np.int64(6), np.int64(7), np.int64(8), np.int64(9), np.int64(10)]
n coefficients per trial: 16
n dyads: 168

1. The lag grid is 16 points, not 15 or 17¶

np.arange(-5, 10) silently drops the +10 endpoint and gives 15. lag_grid is endpoint-inclusive by construction, and the assertion below is in the test suite.

In [2]:
assert len(lags) == 16 and lags[0] == -5 and lags[-1] == 10
print("OK: 16 coefficients spanning -5 s .. +10 s inclusive")
OK: 16 coefficients spanning -5 s .. +10 s inclusive

2. At zero lag, the TLCC coefficient is the Pearson r¶

A cheap but load-bearing check: if this fails, the shift indexing is wrong.

In [3]:
d0 = ds["dyads"][0]
ours = tlcc(d0["x"], d0["y"], np.array([0]))[0]
scipy_r = pearsonr(d0["x"], d0["y"])[0]
print(f"tlcc(lag=0) = {ours:.15f}")
print(f"scipy       = {scipy_r:.15f}")
print("max abs diff:", abs(ours - scipy_r))
tlcc(lag=0) = 0.824175278245446
scipy       = 0.824175278245446
max abs diff: 0.0

3. One dyad, all 16 lags¶

Correlations at non-zero lag use the overlapping segment only. Zero padding would bias r toward 0 as |tau| grows; circular wrap would invent structure.

In [4]:
coeffs = tlcc(d0["x"], d0["y"], lags)
lag, r = peak_lag(coeffs, lags)
pd.DataFrame({"lag_s": lags, "r": np.round(coeffs, 4)}).T
Out[4]:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
lag_s -5.0000 -4.0000 -3.0000 -2.0000 -1.0000 0.0000 1.0000 2.0000 3.0000 4.0000 5.0000 6.0000 7.0000 8.0000 9.000 10.0000
r 0.5428 0.5953 0.6509 0.7085 0.7668 0.8242 0.8769 0.9224 0.9537 0.9641 0.9514 0.9175 0.8678 0.8089 0.746 0.6836
In [5]:
print(f"peak lag = {lag} s   r = {r:.4f}   true injected lag = {d0['true_lag']} s")
peak lag = 4 s   r = 0.9641   true injected lag = 4 s

4. The curve, not just the argmax¶

The target is a smoothed random walk, so it is strongly autocorrelated and the correlation peak is broad. Reporting the argmax alone hides that. The shaded band is where r is within 0.01 of the peak: several lags are nearly indistinguishable.

In [6]:
import matplotlib.pyplot as plt
plt.rcParams.update({"figure.dpi": 110, "font.size": 9})
fig, ax = plt.subplots(figsize=(7, 2.8))
near = coeffs >= (np.nanmax(coeffs) - 0.01)
ax.fill_between(lags, 0, 1, where=near, transform=ax.get_xaxis_transform(),
                color="#C4462F", alpha=0.10, lw=0)
ax.axvline(0, color="#B9B2A6", lw=0.8)
ax.plot(lags, coeffs, "-o", color="#1B1917", ms=3.5, lw=1.2)
ax.plot([lag], [r], "o", color="#C4462F", ms=7)
ax.set_xlabel("lag (s)  -  positive = observer lags target")
ax.set_ylabel("Pearson r"); ax.set_xticks(lags)
ax.set_title(f"Observer {d0['observer']} / target {d0['target']}: peak at {lag} s")
ax.spines[["top", "right"]].set_visible(False)
plt.tight_layout(); plt.show()
Matplotlib is building the font cache; this may take a moment.
No description has been provided for this image

5. All 168 dyads, and how often the peak recovers the truth¶

In [7]:
rows = []
for d in ds["dyads"]:
    c = tlcc(d["x"], d["y"], lags)
    pl, pr = peak_lag(c, lags)
    rows.append({"observer": d["observer"], "target": d["target"], "group": d["group"],
                 "group_code": d["group_code"], "true_lag": d["true_lag"],
                 "peak_lag": pl, "peak_r": pr})
df = pd.DataFrame(rows)
err = (df.peak_lag - df.true_lag).abs()
print(f"dyads analysed      : {len(df)}")
print(f"exact peak recovery : {(err == 0).mean():.4f}")
print(f"within +/- 1 sample : {(err <= 1).mean():.4f}")
print(f"worst error         : {err.max()} sample(s)")
df.head(8)
dyads analysed      : 168
exact peak recovery : 0.9464
within +/- 1 sample : 1.0000
worst error         : 1 sample(s)
Out[7]:
observer target group group_code true_lag peak_lag peak_r
0 0 0 Test 1 4 4 0.964150
1 0 1 Test 1 4 4 0.965023
2 0 2 Test 1 4 5 0.967165
3 0 3 Test 1 4 4 0.964453
4 0 4 Test 1 4 4 0.971515
5 0 5 Test 1 4 4 0.974981
6 1 0 Test 1 3 3 0.971505
7 1 1 Test 1 3 3 0.963569

The argmax misses by one sample on ~5% of dyads and never by more. That is a property of peak-picking on autocorrelated signals, not of the implementation. It is also the reason a study like this should model the peak lag per dyad and carry the uncertainty into the group model, rather than treat each argmax as exact.

6. Group comparison: trials nested within observers¶

Each observer contributes 6 trials, so the trials are not independent. A random intercept per observer absorbs the between-observer spread:

peak_lag_ij = b0 + b1 * Test_i + u_i + e_ij,     u_i ~ N(0, sigma_re^2)

fit_random_intercept_lmm fits this by maximum likelihood, profiling out the variance ratio. statsmodels fits the same model independently. They must agree.

In [8]:
ours = fit_random_intercept_lmm(df.peak_lag.astype(float), df.observer, df.group_code)
ref = smf.mixedlm("peak_lag ~ group_code", df, groups=df.observer).fit(reml=False)

cmp = pd.DataFrame({
    "ours":        [ours.beta[0], ours.beta[1], ours.se[1], ours.s2_re, ours.s2_resid, ours.loglike],
    "statsmodels": [ref.params["Intercept"], ref.params["group_code"], ref.bse["group_code"],
                    float(ref.cov_re.iloc[0, 0]), ref.scale, ref.llf],
}, index=["b0 (Control mean)", "b1 (Test - Control)", "SE(b1)",
          "sigma_re^2", "sigma_resid^2", "logLik"])
cmp["abs diff"] = (cmp.ours - cmp.statsmodels).abs()
cmp.round(6)
Out[8]:
ours statsmodels abs diff
b0 (Control mean) 0.833333 0.833333 0.000000
b1 (Test - Control) 2.035714 2.035714 0.000000
SE(b1) 0.332132 0.332126 0.000005
sigma_re^2 0.763251 0.763226 0.000025
sigma_resid^2 0.053571 0.053572 0.000000
logLik -54.975016 -54.975016 0.000000

7. APA-style results line¶

Ground truth for the simulation: Control lag 1.0 s, Test lag 3.0 s, so the true group difference is 2.0 s.

In [9]:
b1, se1, z1, p1 = ours.beta[1], ours.se[1], ours.z[1], ours.p[1]
lo, hi = b1 - 1.96 * se1, b1 + 1.96 * se1
p_txt = "< .001" if p1 < .001 else f"= {p1:.3f}".replace("0.", ".")
print(f"Observers in the Test group showed a longer temporal lag than Control")
print(f"observers, b = {b1:.2f} s, SE = {se1:.2f}, 95% CI [{lo:.2f}, {hi:.2f}],")
print(f"z = {z1:.2f}, p {p_txt}. A random intercept for observer was included to")
print(f"account for the {ours.n_obs // ours.n_groups} trials nested within each of the")
print(f"{ours.n_groups} observers (sigma_re^2 = {ours.s2_re:.3f}, sigma_resid^2 = {ours.s2_resid:.3f}).")
print()
print(f"[simulation ground truth: true difference = 2.00 s]")
Observers in the Test group showed a longer temporal lag than Control
observers, b = 2.04 s, SE = 0.33, 95% CI [1.38, 2.69],
z = 6.13, p < .001. A random intercept for observer was included to
account for the 6 trials nested within each of the
28 observers (sigma_re^2 = 0.763, sigma_resid^2 = 0.054).

[simulation ground truth: true difference = 2.00 s]

8. Mirror export¶

The brief asks for the notebook and an automatically exported .py mirror. jupyter nbconvert --to script is what produces it, run from build_notebook.py so the two can never drift.