Mixed models and trial-level data: random intercepts, random slopes, crossed subject-by-item effects and single-trial regression on P3 amplitude, with the lme4 equivalents

nb-6-2-lmm Level 6 · Inference and Rigor ~7 min Used in L6.2 · Mixed models and trial-level data

Downloads from ds-erpcore when you run it.

Download the notebook (.ipynb) Outputs below are the ones stored when it was executed — you do not need to run anything to read it.

nb-6-2-lmm · Mixed models and trial-level data (L6.2)

Lesson L6.2 · Level 6 · Status draft — for expert review; uncertain points carry TODO(confirm).

Everything Level 3 measured was an average. A subject's P3 was the mean of their target trials minus the mean of their standard trials, and the group test had one number per subject. That works, it is what most ERP papers do, and it throws away three things this notebook puts back:

  1. how many trials each average rests on. A subject with 18 surviving target trials and one with 40 count equally in a paired t-test, although one of the two measurements is far noisier than the other.
  2. the trial-to-trial variance itself, which is where single-trial questions live: does the P3 shrink over a block? Does it track reaction time?
  3. the stimulus. In this paradigm five letters are used, and a letter that is a target in one block is a standard in another. Subjects and letters are crossed, and treating letters as fixed is a modelling choice that averaging hides completely.

A linear mixed model keeps the trials and states the grouping structure explicitly. This notebook fits four — random intercept, random slope, crossed subject × item, and a single-trial regression with covariates — and reports what each one does to the fixed effect that the whole analysis is about.

Fitted with statsmodels (MixedLM); the R lme4 formula for each model is printed beside it.

Data. ds-erpcore — ERP CORE, Kappenman, Farrens, Zhang, Stewart & Luck (2020), ERP CORE: An Open Resource for Human Event-related Potential Research, PsyArXiv, DOI 10.31234/osf.io/4azqm; dataset DOI 10.18112/openneuro.ds003069.v1.0.0. Paradigm P3, an active visual oddball task. From data/directory.yaml: Biosemi ActiveTwo, 30 EEG + 3 EOG electrodes in a 10-20 placement scheme, 1024 Hz, CMS reference, 60 Hz mains, no software filters, 40 participants, access: open.

Licence — CC BY-SA 4.0, contested at source (the shipped LICENSE says CC BY-SA 4.0, the BIDS dataset_description.json says CC0, the OSF node thsqg record says CC BY 4.0). Spec §10.7 makes the most restrictive reading govern; data/directory.yaml records CC-BY-SA-4.0 and helpers_l3.ERPCORE_LICENCE_STATEMENTS carries all three verbatim.

No published values are quoted. Every comparison with a paper's own numbers is a literal TODO(confirm).

In [1]:
# Setup: dependencies, the shared helpers, non-interactive plotting.
import importlib.util
import subprocess
import sys
import time
import warnings
from pathlib import Path

# 1. Dependencies are pinned in notebooks/requirements.txt.  statsmodels is the one this notebook
#    needs beyond the core stack; if it is missing the notebook falls back to a documented
#    two-stage estimator and says so in every cell that uses it.
_needed = ("mne", "scipy", "matplotlib", "pandas", "pooch")
_missing = [p for p in _needed if importlib.util.find_spec(p) is None]
if _missing:
    _req = next((d / "requirements.txt" for d in (Path.cwd(), *Path.cwd().parents)
                 if (d / "requirements.txt").exists()), None)
    _cmd = [sys.executable, "-m", "pip", "install", "-q"]
    _cmd += ["-r", str(_req)] if _req else ["mne==1.10.2", "pooch>=1.8"]
    subprocess.check_call(_cmd)

HAVE_STATSMODELS = importlib.util.find_spec("statsmodels") is not None

# 2. Shared helpers, located relative to the working directory -- never through an absolute path.
_shared = next((d / "_shared" for d in (Path.cwd(), *Path.cwd().parents)
                if (d / "_shared" / "helpers_l6.py").exists()), None)
if _shared is None:
    raise FileNotFoundError("start the kernel in notebooks/L6/ (or notebooks/) so that _shared/helpers_l6.py is found")
sys.path.insert(0, str(_shared))
import helpers
import helpers_l3 as L3
import helpers_l6 as L6

# 3. Plotting: static PNGs through the inline backend; every figure cell ends with plt.show().
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import mne
from scipy import stats

mne.set_log_level("WARNING")
plt.rcParams["figure.dpi"] = 72

SEED = L6.SEED
ALPHA = L6.ALPHA
FULL_RUN = False          # this notebook has no permutation test; FULL_RUN only widens the cohort
SUBJECTS = list(L6.SUBSET_DEFAULT) if not FULL_RUN else list(range(1, 41))

if HAVE_STATSMODELS:
    import statsmodels
    import statsmodels.formula.api as smf
    print(f"PATH TAKEN: statsmodels {statsmodels.__version__} is present -- every model below is a real "
          f"MixedLM fit.")
else:
    print("PATH TAKEN: statsmodels is NOT present.  Every mixed model below falls back to a documented "
          "two-stage estimator (fit the trial-level regression inside each subject, then test the "
          "coefficients across subjects).  For a balanced design the two-stage fixed effect is very close "
          "to the mixed model's, but the variance components, the crossed-item model and the likelihood "
          "comparisons are NOT available and those cells say so.")
print(f"MNE {mne.__version__}; helpers_l6 imported from notebooks/_shared")
print(f"ERP CORE cache: {L3.erpcore_root().name}/ (resolved relative to the working directory, or "
      f"$EEG_COURSE_ERPCORE); each subject's EEGLAB pair is deleted as soon as its measurements exist")
PATH TAKEN: statsmodels 0.14.4 is present -- every model below is a real MixedLM fit.
MNE 1.10.2; helpers_l6 imported from notebooks/_shared
ERP CORE cache: erpcore/ (resolved relative to the working directory, or $EEG_COURSE_ERPCORE); each subject's EEGLAB pair is deleted as soon as its measurements exist

1 · The trial table

One row per surviving trial. The amplitude is the P3 measured on that single trial — the mean over 300–600 ms at Pz — under the same pre-specified pipeline every Level 6 notebook uses, so the group-average analysis in section 2 is exactly the Level 3 analysis and the models in sections 3–6 are fitted to the numbers it averages.

In [2]:
before = L6.disk_report("before any download")
t0 = time.time()
cohort = L6.load_cohort(SUBJECTS)
print(f"{len(cohort)} subjects in {time.time() - t0:.0f} s")
L6.disk_report("after the download loop")

i_prespec = L6.path_index(L6.PRESPECIFIED)
MIN_TRIALS = int(L6.FIXED_CHOICES["min_trials_per_condition"])
rows = []
for s in sorted(cohort):
    p = cohort[s]
    keep = p["keep"][i_prespec]
    n_t = int((keep & p["is_target"]).sum())
    n_s = int((keep & ~p["is_target"]).sum())
    if min(n_t, n_s) < MIN_TRIALS:
        print(f"EXCLUDED {s}: {n_t} target / {n_s} standard trials survive, below the stated minimum of "
              f"{MIN_TRIALS}")
        continue
    idx = np.where(keep)[0]
    rows.append(pd.DataFrame({
        "subject": s,
        "trial": p["trial"][idx],                              # position in the session, 0-based
        "condition": np.where(p["is_target"][idx], "target", "standard"),
        "letter": [chr(ord("A") + c % 10 - 1) for c in p["stim_code"][idx]],    # the letter shown
        "block_target": [chr(ord("A") + c // 10 - 1) for c in p["stim_code"][idx]],
        "amp_uv": p["amp"][i_prespec][idx].astype(float),
        "rt_ms": p["rt_ms"][idx],
        "accuracy": p["accuracy"][idx],
    }))
df = pd.concat(rows, ignore_index=True)
df["condition"] = pd.Categorical(df["condition"], categories=["standard", "target"])   # standard = reference
df["is_target"] = (df["condition"] == "target").astype(float)
df["trial_z"] = (df["trial"] - df["trial"].mean()) / df["trial"].std()
print(f"\n{len(df):,} single trials from {df.subject.nunique()} subjects, "
      f"{df.letter.nunique()} stimulus letters")
print(df.groupby("condition", observed=True)["amp_uv"].agg(["count", "mean", "std"]).round(3))
print(f"\ntrials per subject (target / standard):")
per = df.pivot_table(index="subject", columns="condition", values="amp_uv", aggfunc="count", observed=True)
print(f"   target   min {int(per['target'].min())}, median {per['target'].median():.0f}, "
      f"max {int(per['target'].max())}")
print(f"   standard min {int(per['standard'].min())}, median {per['standard'].median():.0f}, "
      f"max {int(per['standard'].max())}")
print(df.head(4).to_string(index=False))
free disk before any download: 4.27 GB  (ERP CORE cache 0.0 MB, Level-6 products 0.0 MB)
  sub-001: 200 trials, computed in 69.9 s; free disk 4.26 GB
  sub-002: 200 trials, computed in 17.8 s; free disk 4.26 GB
  sub-003: 200 trials, computed in 19.8 s; free disk 4.25 GB
  sub-004: 200 trials, computed in 20.9 s; free disk 4.25 GB
  sub-005: 200 trials, computed in 21.4 s; free disk 4.62 GB
  sub-006: 200 trials, computed in 20.9 s; free disk 4.62 GB
  sub-007: 200 trials, computed in 26.3 s; free disk 4.61 GB
  sub-008: 200 trials, computed in 20.1 s; free disk 4.62 GB
  sub-009: 200 trials, computed in 23.2 s; free disk 4.61 GB
  sub-010: 200 trials, computed in 22.6 s; free disk 4.60 GB
  sub-011: 200 trials, computed in 20.1 s; free disk 4.60 GB
  sub-012: 200 trials, computed in 20.1 s; free disk 4.60 GB
  sub-013: 200 trials, computed in 24.8 s; free disk 4.60 GB
  sub-014: 200 trials, computed in 17.1 s; free disk 4.61 GB
  sub-015: 200 trials, computed in 24.4 s; free disk 4.69 GB
  sub-016: 200 trials, computed in 25.8 s; free disk 4.68 GB
  sub-017: 200 trials, computed in 22.5 s; free disk 4.72 GB
  sub-018: 200 trials, computed in 15.8 s; free disk 4.72 GB
  sub-019: 200 trials, computed in 29.4 s; free disk 4.71 GB
  sub-020: 200 trials, computed in 21.1 s; free disk 4.71 GB
20 subjects in 484 s
free disk after the download loop: 4.71 GB  (ERP CORE cache 0.4 MB, Level-6 products 7.5 MB)
EXCLUDED sub-009: 1 target / 4 standard trials survive, below the stated minimum of 15

3,468 single trials from 19 subjects, 5 stimulus letters
           count   mean    std
condition                     
standard    2775  3.081  6.573
target       693  5.848  6.630

trials per subject (target / standard):
   target   min 18, median 38, max 40
   standard min 81, median 155, max 160
subject  trial condition letter block_target   amp_uv     rt_ms  accuracy  is_target   trial_z
sub-001      0    target      A            A 5.737538 480.46875         1        1.0 -1.714514
sub-001      1  standard      B            A 0.927250 453.12500         1        0.0 -1.697158
sub-001      2  standard      D            A 3.267735 417.96875         1        0.0 -1.679802
sub-001      3  standard      C            A 3.330027 417.96875         1        0.0 -1.662447

2 · What averaging throws away

The Level 3 analysis in one line: average within subject and condition, subtract, test the differences against zero. It is a good analysis. The two cells below say exactly what it does not know.

In [3]:
means = df.pivot_table(index="subject", columns="condition", values="amp_uv", aggfunc="mean", observed=True)
counts = df.pivot_table(index="subject", columns="condition", values="amp_uv", aggfunc="count", observed=True)
sds = df.pivot_table(index="subject", columns="condition", values="amp_uv", aggfunc="std", observed=True)
diff = (means["target"] - means["standard"]).to_numpy()
n_sub = len(diff)
t_avg, p_avg = stats.ttest_1samp(diff, 0)
ci_avg = stats.t.ppf(1 - ALPHA / 2, n_sub - 1) * diff.std(ddof=1) / np.sqrt(n_sub)
print(f"AVERAGE-THEN-TEST (the Level 3 analysis), {n_sub} subjects:")
print(f"   effect {diff.mean():+.4f} uV, 95% CI [{diff.mean() - ci_avg:+.4f}, {diff.mean() + ci_avg:+.4f}], "
      f"t({n_sub - 1}) = {t_avg:.3f}, p = {p_avg:.3g}, dz = {diff.mean() / diff.std(ddof=1):.3f}")
print()
# the standardized measurement error of each subject's difference score (L3.5)
sme = np.sqrt(sds["target"] ** 2 / counts["target"] + sds["standard"] ** 2 / counts["standard"])
print(f"{'subject':9s} {'nT':>4s} {'nS':>4s} {'difference':>11s} {'SME':>7s} "
      f"{'implied weight':>15s}")
w = 1 / sme ** 2
for s in means.index:
    print(f"{s:9s} {int(counts.loc[s, 'target']):4d} {int(counts.loc[s, 'standard']):4d} "
          f"{means.loc[s, 'target'] - means.loc[s, 'standard']:+11.3f} {sme[s]:7.3f} "
          f"{w[s] / w.sum():15.3f}")
print(f"\nThe paired t-test gives every subject the weight 1/{n_sub} = {1 / n_sub:.3f}.  Weighted by "
      f"measurement precision they would range from {(w / w.sum()).min():.3f} to {(w / w.sum()).max():.3f} -- "
      f"a factor of {(w.max() / w.min()):.1f} between the best- and worst-measured subject.")
print(f"Ratio of the noisiest subject's SME to the quietest: {sme.max() / sme.min():.2f}")
AVERAGE-THEN-TEST (the Level 3 analysis), 19 subjects:
   effect +2.7366 uV, 95% CI [+1.5997, +3.8736], t(18) = 5.057, p = 8.21e-05, dz = 1.160

subject     nT   nS  difference     SME  implied weight
sub-001     31  130      +2.776   0.962           0.058
sub-002     40  157      +9.229   1.226           0.036
sub-003     37  155      +8.013   1.322           0.031
sub-004     40  157      +3.878   0.733           0.100
sub-005     37  148      +2.421   0.914           0.065
sub-006     18   81      +2.541   3.124           0.006
sub-007     40  160      +2.476   1.058           0.048
sub-008     38  144      +2.544   1.193           0.038
sub-010     35  145      +1.626   0.750           0.096
sub-011     28  101      +1.220   1.171           0.039
sub-012     40  154      +3.439   1.026           0.051
sub-013     40  160      +1.221   0.684           0.116
sub-014     40  151      +0.096   1.018           0.052
sub-015     38  157      +0.510   0.925           0.063
sub-016     35  144      +3.309   1.305           0.032
sub-017     39  158      +1.325   1.462           0.025
sub-018     38  157      +3.664   0.975           0.057
sub-019     40  159      +1.092   1.077           0.047
sub-020     39  157      +0.618   1.161           0.040

The paired t-test gives every subject the weight 1/19 = 0.053.  Weighted by measurement precision they would range from 0.006 to 0.116 -- a factor of 20.9 between the best- and worst-measured subject.
Ratio of the noisiest subject's SME to the quietest: 4.57
In [4]:
fig, axes = plt.subplots(1, 3, figsize=(12.5, 4.0))
axes[0].hist(df.loc[df.condition == "standard", "amp_uv"], bins=60, alpha=0.6, label="standard",
             color="tab:blue", density=True)
axes[0].hist(df.loc[df.condition == "target", "amp_uv"], bins=60, alpha=0.6, label="target",
             color="tab:orange", density=True)
axes[0].axvline(0, color="gray", lw=0.7)
axes[0].set(xlabel="Single-trial P3 amplitude at Pz, 300–600 ms (µV)", ylabel="Density",
            title=f"{len(df):,} single trials, both conditions")
axes[0].legend(fontsize=8)
axes[0].grid(alpha=0.3)

for s in means.index:
    axes[1].plot([0, 1], [means.loc[s, "standard"], means.loc[s, "target"]], "o-", color="0.6", lw=0.8, ms=3)
axes[1].plot([0, 1], [means["standard"].mean(), means["target"].mean()], "o-", color="tab:red", lw=2.4, ms=7,
             label="group mean")
axes[1].set_xticks([0, 1], ["standard", "target"])
axes[1].set(ylabel="Mean amplitude (µV)", title=f"Subject means ({n_sub} subjects)")
axes[1].legend(fontsize=8)
axes[1].grid(alpha=0.3)

axes[2].scatter(counts["target"], sme, s=28, color="tab:blue")
for s in means.index:
    axes[2].annotate(s.replace("sub-", ""), (counts.loc[s, "target"], sme[s]), fontsize=6,
                     textcoords="offset points", xytext=(3, 2))
axes[2].set(xlabel="Surviving target trials (count)", ylabel="SME of the difference score (µV)",
            title="Precision differs between subjects,\nand the paired test ignores it")
axes[2].grid(alpha=0.3)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Figure 1 of notebook nb-6-2-lmm, an output plot. The text around it states what it shows and the units of every axis.

3 · Random intercepts

The first model. Every trial is a row; the condition effect is a fixed effect shared by everybody; each subject gets their own random intercept, drawn from a normal distribution whose variance the model estimates.

statsmodels : MixedLM.from_formula("amp_uv ~ condition", groups="subject", data=df)
lme4 (R)    : lmer(amp_uv ~ condition + (1 | subject), data = df, REML = TRUE)

The random intercept says people differ in overall amplitude. It does not say people differ in how much the conditions differ — that is section 4, and it is the assumption that matters most.

In [5]:
def two_stage(formula_terms, data, response="amp_uv"):
    """Fallback estimator: fit the trial-level regression inside each subject, then test the
    coefficients across subjects (Holmes & Friston's summary-statistic approach).  Returns the
    fixed-effect table.  It cannot report variance components or a likelihood."""
    import itertools
    coefs = {}
    for s, g in data.groupby("subject"):
        Xc = np.column_stack([np.ones(len(g))] + [g[t].to_numpy(float) for t in formula_terms])
        y = g[response].to_numpy(float)
        beta, *_ = np.linalg.lstsq(Xc, y, rcond=None)
        coefs[s] = beta
    B = np.array(list(coefs.values()))
    out = []
    for j, name in enumerate(["Intercept"] + list(formula_terms)):
        t, p = stats.ttest_1samp(B[:, j], 0)
        out.append({"name": name, "coef": float(B[:, j].mean()),
                    "se": float(B[:, j].std(ddof=1) / np.sqrt(len(B))), "z": float(t), "p": float(p)})
    return pd.DataFrame(out)


def fixed_effect(fit, term="condition[T.target]"):
    """(estimate, standard error, z, p) of one fixed effect, from either path."""
    if HAVE_STATSMODELS and hasattr(fit, "params"):
        return (float(fit.params[term]), float(fit.bse[term]), float(fit.tvalues[term]),
                float(fit.pvalues[term]))
    row = fit.loc[fit["name"] == term].iloc[0]
    return float(row["coef"]), float(row["se"]), float(row["z"]), float(row["p"])


t0 = time.time()
if HAVE_STATSMODELS:
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        m_ri = smf.mixedlm("amp_uv ~ condition", df, groups=df["subject"]).fit(reml=True)
    print(m_ri.summary())
    print(f"\nconverged: {m_ri.converged}; method {m_ri.method}; fitted in {time.time() - t0:.1f} s")
    ri = fixed_effect(m_ri)
else:
    m_ri = two_stage(["is_target"], df)
    print(m_ri.to_string(index=False))
    print("(two-stage fallback: no variance components, no likelihood)")
    ri = fixed_effect(m_ri, "is_target")
print(f"\nRANDOM INTERCEPT -- fixed effect of condition (target minus standard): "
      f"{ri[0]:+.4f} uV, SE {ri[1]:.4f}, z = {ri[2]:.3f}, p = {ri[3]:.3g}")
print(f"the average-then-test estimate on the same trials was {diff.mean():+.4f} uV "
      f"(difference {ri[0] - diff.mean():+.4f} uV)")
            Mixed Linear Model Regression Results
=============================================================
Model:              MixedLM  Dependent Variable:  amp_uv     
No. Observations:   3468     Method:              REML       
No. Groups:         19       Scale:               41.0247    
Min. group size:    99       Log-Likelihood:      -11384.2032
Max. group size:    200      Converged:           Yes        
Mean group size:    182.5                                    
-------------------------------------------------------------
                    Coef. Std.Err.   z    P>|z| [0.025 0.975]
-------------------------------------------------------------
Intercept           3.136    0.381  8.228 0.000  2.389  3.883
condition[T.target] 2.764    0.272 10.159 0.000  2.230  3.297
Group Var           2.473    0.141                           
=============================================================


converged: True; method REML; fitted in 0.0 s

RANDOM INTERCEPT -- fixed effect of condition (target minus standard): +2.7635 uV, SE 0.2720, z = 10.159, p = 3.02e-24
the average-then-test estimate on the same trials was +2.7366 uV (difference +0.0269 uV)

Why the two estimates are not identical

A paired t-test on subject means gives every subject weight 1/N. A random-intercept model weights each subject by how much information they carry, which depends on their trial count and on the ratio of within- to between-subject variance — the "shrinkage" a mixed model is named for. With a balanced design the two agree almost exactly; here the design is unbalanced twice over (40 targets against 160 standards by construction, and a different number of trials surviving rejection in each subject), so they differ.

Neither is wrong. They are estimating the same quantity under different weightings, and the mixed model's is the one that uses the trial counts.

4 · Random slopes

The exercise question: does letting the condition effect vary between subjects change the fixed-effect estimate?

statsmodels : MixedLM.from_formula("amp_uv ~ condition", groups="subject", re_formula="~condition", data=df)
lme4 (R)    : lmer(amp_uv ~ condition + (1 + condition | subject), data = df, REML = TRUE)

This is the model Barr and colleagues' "keep it maximal" advice points at: if the design measures the condition effect repeatedly within each subject, the subject-specific slope belongs in the model, and leaving it out makes the fixed effect's standard error too small. Whether the estimate moves is a different question, and the cell below answers it for these data.

In [6]:
t0 = time.time()
if HAVE_STATSMODELS:
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        m_rs = smf.mixedlm("amp_uv ~ condition", df, groups=df["subject"],
                           re_formula="~condition").fit(reml=True)
    print(m_rs.summary())
    print(f"\nconverged: {m_rs.converged}; fitted in {time.time() - t0:.1f} s")
    rs = fixed_effect(m_rs)
    vc = m_rs.cov_re
    print(f"\nrandom-effect covariance (uV^2):")
    print(vc.round(4).to_string())
    sd_int = float(np.sqrt(max(vc.iloc[0, 0], 0)))
    sd_slope = float(np.sqrt(max(vc.iloc[1, 1], 0)))
    corr = float(vc.iloc[0, 1] / (sd_int * sd_slope)) if sd_int > 0 and sd_slope > 0 else float("nan")
    print(f"   SD of the subject intercepts : {sd_int:.3f} uV")
    print(f"   SD of the subject slopes     : {sd_slope:.3f} uV   <- how much the condition effect itself "
          f"varies between people")
    print(f"   their correlation            : {corr:+.3f}")
    print(f"   residual (trial-level) SD    : {np.sqrt(m_rs.scale):.3f} uV")
else:
    m_rs = None
    rs = ri
    sd_int = sd_slope = corr = float("nan")
    print("statsmodels is absent, so there is no random-slope model.  The two-stage fallback already "
          "estimates a separate condition effect inside every subject, which is the same structure fitted "
          "without pooling; its across-subject SD is printed instead.")
    per_subject_slope = df.groupby("subject").apply(
        lambda g: g.loc[g.condition == "target", "amp_uv"].mean()
                  - g.loc[g.condition == "standard", "amp_uv"].mean(), include_groups=False)
    sd_slope = float(per_subject_slope.std(ddof=1))
    print(f"   across-subject SD of the condition effect (no pooling): {sd_slope:.3f} uV")

print(f"\nRANDOM SLOPE -- fixed effect of condition: {rs[0]:+.4f} uV, SE {rs[1]:.4f}, "
      f"z = {rs[2]:.3f}, p = {rs[3]:.3g}")
print(f"\nex-6-2 ANSWER PAIR:")
print(f"   random intercept : {ri[0]:+.4f} uV (SE {ri[1]:.4f}, p = {ri[3]:.3g})")
print(f"   random slope     : {rs[0]:+.4f} uV (SE {rs[1]:.4f}, p = {rs[3]:.3g})")
print(f"   the estimate moves by {rs[0] - ri[0]:+.4f} uV "
      f"({abs(rs[0] - ri[0]) / abs(ri[0]):.1%} of the effect);")
print(f"   the standard error moves by {rs[1] - ri[1]:+.4f} uV "
      f"({(rs[1] / ri[1] - 1):+.1%}), which is the change that matters.")
                 Mixed Linear Model Regression Results
========================================================================
Model:                 MixedLM      Dependent Variable:      amp_uv     
No. Observations:      3468         Method:                  REML       
No. Groups:            19           Scale:                   40.3364    
Min. group size:       99           Log-Likelihood:          -11366.5050
Max. group size:       200          Converged:               Yes        
Mean group size:       182.5                                            
------------------------------------------------------------------------
                                Coef. Std.Err.   z   P>|z| [0.025 0.975]
------------------------------------------------------------------------
Intercept                       3.137    0.353 8.881 0.000  2.445  3.829
condition[T.target]             2.758    0.550 5.018 0.000  1.681  3.836
Group Var                       2.088    0.125                          
Group x condition[T.target] Cov 0.502    0.137                          
condition[T.target] Var         4.327    0.297                          
========================================================================


converged: True; fitted in 0.2 s

random-effect covariance (uV^2):
                      Group  condition[T.target]
Group                2.0876               0.5017
condition[T.target]  0.5017               4.3266
   SD of the subject intercepts : 1.445 uV
   SD of the subject slopes     : 2.080 uV   <- how much the condition effect itself varies between people
   their correlation            : +0.167
   residual (trial-level) SD    : 6.351 uV

RANDOM SLOPE -- fixed effect of condition: +2.7583 uV, SE 0.5497, z = 5.018, p = 5.22e-07

ex-6-2 ANSWER PAIR:
   random intercept : +2.7635 uV (SE 0.2720, p = 3.02e-24)
   random slope     : +2.7583 uV (SE 0.5497, p = 5.22e-07)
   the estimate moves by -0.0052 uV (0.2% of the effect);
   the standard error moves by +0.2776 uV (+102.1%), which is the change that matters.

The estimate and the standard error are two different questions

A random slope usually leaves the fixed-effect estimate close to where it was and widens its standard error, because the model now admits that the effect itself varies between people and that the group mean of a varying effect is less well determined than the group mean of a constant one. The percentages printed above are this dataset's version of that.

The practical reading: if adding a random slope turns a significant result non-significant, the random-intercept model was reporting a precision it did not have. That is an argument about the standard error, not about whether the effect exists.

In [7]:
if HAVE_STATSMODELS:
    lr = 2 * (m_rs.llf - m_ri.llf)
    print(f"Likelihood comparison (REML fits, same fixed effects, so the REML likelihoods ARE comparable):")
    print(f"   random intercept  log-likelihood {m_ri.llf:10.3f}")
    print(f"   random slope      log-likelihood {m_rs.llf:10.3f}")
    print(f"   (statsmodels returns AIC = {m_ri.aic} for a REML fit, because an information criterion "
          f"computed from a REML likelihood is not comparable across models with different fixed effects; "
          f"refit with reml=False if you want AIC)")
    print(f"   2 x difference in log-likelihood: {lr:.3f} on 2 extra parameters")
    print(f"   naive chi-square p = {stats.chi2.sf(max(lr, 0), 2):.4g}")
    print()
    print("   TODO(confirm): that p-value is CONSERVATIVE and should not be quoted as if it were exact.")
    print("   Testing whether a variance is zero puts the null on the boundary of the parameter space, where")
    print("   the likelihood-ratio statistic is not chi-square with 2 df but a mixture.  The usual advice is")
    print("   to decide the random-effects structure from the design rather than from a test: if the design")
    print("   measures the effect repeatedly within subject, the slope belongs in the model whether or not a")
    print("   test says so.  A reviewer should settle which convention this course teaches.")
else:
    print("No likelihood comparison without statsmodels (the two-stage fallback has no likelihood).")
Likelihood comparison (REML fits, same fixed effects, so the REML likelihoods ARE comparable):
   random intercept  log-likelihood -11384.203
   random slope      log-likelihood -11366.505
   (statsmodels returns AIC = nan for a REML fit, because an information criterion computed from a REML likelihood is not comparable across models with different fixed effects; refit with reml=False if you want AIC)
   2 x difference in log-likelihood: 35.396 on 2 extra parameters
   naive chi-square p = 2.06e-08

   TODO(confirm): that p-value is CONSERVATIVE and should not be quoted as if it were exact.
   Testing whether a variance is zero puts the null on the boundary of the parameter space, where
   the likelihood-ratio statistic is not chi-square with 2 df but a mixture.  The usual advice is
   to decide the random-effects structure from the design rather than from a test: if the design
   measures the effect repeatedly within subject, the slope belongs in the model whether or not a
   test says so.  A reviewer should settle which convention this course teaches.

5 · Crossed random effects: subjects and items

Five letters are used, and the same letter is a target in one block and a standard in another. Letters are not nested inside subjects — every subject sees all five — so subject and item are crossed, and a model that ignores the item treats "the target effect for letter C" as if it were the same quantity as "the target effect for letter A".

statsmodels : MixedLM.from_formula("amp_uv ~ condition", groups=<one group>, vc_formula={"subject": "0+C(subject)",
                                   "letter": "0+C(letter)"}, data=df)
lme4 (R)    : lmer(amp_uv ~ condition + (1 | subject) + (1 | letter), data = df, REML = TRUE)

statsmodels has no first-class crossed syntax: the usual construction is one artificial group containing every observation, with the two grouping factors entered as variance components. lme4 does this directly, which is why the R line is the one worth copying.

In [8]:
if HAVE_STATSMODELS:
    t0 = time.time()
    df["all"] = 1
    vcf = {"subject": "0 + C(subject)", "letter": "0 + C(letter)"}
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        m_cr = smf.mixedlm("amp_uv ~ condition", df, groups=df["all"], vc_formula=vcf).fit(reml=True)
    cr = fixed_effect(m_cr)
    print(f"converged: {m_cr.converged}; fitted in {time.time() - t0:.1f} s")
    if not m_cr.converged:
        print("   ^ THIS MODEL DID NOT CONVERGE.  Its numbers are printed anyway, and reported as "
              "non-converged in section 7, because a model that fails to converge is a result about the "
              "data rather than something to hide.  Read the variance components below as a description "
              "of where the optimiser stopped, not as estimates.  Section 7 lists what to do about it.")
    print(f"\nvariance components (uV^2):")
    for k, v in zip(vcf, m_cr.vcomp):
        print(f"   {k:9s} {v:9.4f}   (SD {np.sqrt(max(v, 0)):.3f} uV)")
    print(f"   residual  {m_cr.scale:9.4f}   (SD {np.sqrt(m_cr.scale):.3f} uV)")
    print(f"\nCROSSED subject + letter -- fixed effect of condition: {cr[0]:+.4f} uV, SE {cr[1]:.4f}, "
          f"z = {cr[2]:.3f}, p = {cr[3]:.3g}")
    print(f"   against the random-intercept model: estimate {cr[0] - ri[0]:+.4f} uV, "
          f"SE {(cr[1] / ri[1] - 1):+.1%}")
    print()
    print(f"With only {df.letter.nunique()} letters the item variance is estimated from very few levels, so "
          f"whatever it comes out as is imprecise.  The reason to fit it is not the number: it is that "
          f"leaving it out asserts the item variance is exactly zero.")
else:
    cr = ri
    print("No crossed model without statsmodels.")
converged: False; fitted in 21.3 s
   ^ THIS MODEL DID NOT CONVERGE.  Its numbers are printed anyway, and reported as non-converged in section 7, because a model that fails to converge is a result about the data rather than something to hide.  Read the variance components below as a description of where the optimiser stopped, not as estimates.  Section 7 lists what to do about it.

variance components (uV^2):
   subject     20.4135   (SD 4.518 uV)
   letter       2.2188   (SD 1.490 uV)
   residual    40.9788   (SD 6.401 uV)

CROSSED subject + letter -- fixed effect of condition: +2.7557 uV, SE 0.2719, z = 10.134, p = 3.91e-24
   against the random-intercept model: estimate -0.0078 uV, SE -0.0%

With only 5 letters the item variance is estimated from very few levels, so whatever it comes out as is imprecise.  The reason to fit it is not the number: it is that leaving it out asserts the item variance is exactly zero.
In [9]:
by_letter = df.pivot_table(index="letter", columns="condition", values="amp_uv",
                           aggfunc=["mean", "count"], observed=True)
print("The condition effect letter by letter (unmodelled, for orientation):")
print(f"{'letter':>7s} {'nT':>6s} {'nS':>6s} {'target':>9s} {'standard':>9s} {'difference':>11s}")
for L in sorted(df.letter.unique()):
    g = df[df.letter == L]
    mt = g.loc[g.condition == "target", "amp_uv"]
    msd = g.loc[g.condition == "standard", "amp_uv"]
    print(f"{L:>7s} {len(mt):6d} {len(msd):6d} {mt.mean():9.3f} {msd.mean():9.3f} "
          f"{mt.mean() - msd.mean():+11.3f}")
The condition effect letter by letter (unmodelled, for orientation):
 letter     nT     nS    target  standard  difference
      A    146    577     5.516     3.432      +2.083
      B    138    570     6.432     2.892      +3.540
      C    137    512     5.855     2.911      +2.944
      D    140    545     5.630     3.464      +2.166
      E    132    571     5.831     2.703      +3.127

6 · Single-trial regression

The reason to keep the trials is not to re-derive the average with more machinery. It is to ask questions the average cannot hold: does the P3 change over the course of the session, and does it track the reaction time of the trial it came from?

statsmodels : MixedLM.from_formula("amp_uv ~ condition * trial_z + rt_z", groups="subject",
                                   re_formula="~condition", data=df)
lme4 (R)    : lmer(amp_uv ~ condition * trial_z + rt_z + (1 + condition | subject), data = df)

Both covariates are within-subject centred before they enter the model. An uncentred trial-level covariate mixes a within-subject effect ("later trials in this session") with a between-subject one ("subjects with longer mean RTs"), and those are different claims. Centring inside the subject keeps only the first.

In [10]:
d2 = df.dropna(subset=["rt_ms"]).copy()
d2["rt_z"] = d2.groupby("subject")["rt_ms"].transform(lambda x: (x - x.mean()) / x.std())
d2["trial_z"] = d2.groupby("subject")["trial"].transform(lambda x: (x - x.mean()) / x.std())
print(f"{len(d2):,} of {len(df):,} trials have a recorded response time "
      f"({100 * (1 - len(d2) / len(df)):.1f} % have none and are dropped from this model only)")
print(f"reaction time: median {d2.rt_ms.median():.0f} ms, IQR "
      f"{d2.rt_ms.quantile(0.25):.0f}-{d2.rt_ms.quantile(0.75):.0f} ms")

if HAVE_STATSMODELS:
    t0 = time.time()
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        m_st = smf.mixedlm("amp_uv ~ condition * trial_z + rt_z", d2, groups=d2["subject"],
                           re_formula="~condition").fit(reml=True)
    print(f"\nconverged: {m_st.converged}; fitted in {time.time() - t0:.1f} s\n")
    tab = pd.DataFrame({"estimate_uv": m_st.params, "se": m_st.bse, "z": m_st.tvalues, "p": m_st.pvalues})
    print(tab.round(4).to_string())
    st_terms = {k: fixed_effect(m_st, k) for k in
                ("condition[T.target]", "trial_z", "rt_z", "condition[T.target]:trial_z")
                if k in m_st.params.index}
else:
    m_st = two_stage(["is_target", "trial_z", "rt_z"], d2)
    print(m_st.to_string(index=False))
    st_terms = {k: fixed_effect(m_st, k) for k in ("is_target", "trial_z", "rt_z")}

print()
for k, v in st_terms.items():
    print(f"   {k:28s} {v[0]:+8.4f} uV  SE {v[1]:.4f}  z = {v[2]:+7.3f}  p = {v[3]:.3g}"
          + ("   <- significant" if v[3] < ALPHA else ""))
print()
print("Units matter for reading those: trial_z and rt_z are in standard deviations WITHIN a subject, so their")
print("coefficients are 'microvolts per within-subject SD', not microvolts per trial or per millisecond.")
3,461 of 3,468 trials have a recorded response time (0.2 % have none and are dropped from this model only)
reaction time: median 371 ms, IQR 312-461 ms
converged: True; fitted in 0.2 s

                                 estimate_uv      se       z       p
Intercept                             3.1683  0.3506  9.0363  0.0000
condition[T.target]                   2.6593  0.5466  4.8650  0.0000
trial_z                               0.0123  0.1214  0.1012  0.9194
condition[T.target]:trial_z          -0.2817  0.2724 -1.0340  0.3011
rt_z                                  0.2938  0.1109  2.6491  0.0081
Group Var                             0.0511  0.0195  2.6163  0.0089
Group x condition[T.target] Cov       0.0120  0.0213  0.5643  0.5725
condition[T.target] Var               0.1056  0.0461  2.2876  0.0222

   condition[T.target]           +2.6593 uV  SE 0.5466  z =  +4.865  p = 1.14e-06   <- significant
   trial_z                       +0.0123 uV  SE 0.1214  z =  +0.101  p = 0.919
   rt_z                          +0.2938 uV  SE 0.1109  z =  +2.649  p = 0.00807   <- significant
   condition[T.target]:trial_z   -0.2817 uV  SE 0.2724  z =  -1.034  p = 0.301

Units matter for reading those: trial_z and rt_z are in standard deviations WITHIN a subject, so their
coefficients are 'microvolts per within-subject SD', not microvolts per trial or per millisecond.
In [11]:
fig, axes = plt.subplots(1, 2, figsize=(11, 4.2))
for cond, colour in (("standard", "tab:blue"), ("target", "tab:orange")):
    g = df[df.condition == cond]
    b = np.linspace(g.trial.min(), g.trial.max(), 11)
    k = np.digitize(g.trial, b) - 1
    m = [g.amp_uv[k == i].mean() for i in range(10)]
    e = [g.amp_uv[k == i].std(ddof=1) / max(np.sqrt((k == i).sum()), 1) for i in range(10)]
    axes[0].errorbar((b[:-1] + b[1:]) / 2, m, yerr=e, marker="o", ms=4, color=colour, label=cond, capsize=2)
axes[0].axhline(0, color="gray", lw=0.7)
axes[0].set(xlabel="Trial position in the session (count)", ylabel="Mean single-trial amplitude (µV)",
            title="Amplitude over the session (±1 SEM)")
axes[0].legend(fontsize=8)
axes[0].grid(alpha=0.3)

g = d2[d2.condition == "target"]
axes[1].scatter(g.rt_ms, g.amp_uv, s=5, alpha=0.25, color="tab:orange")
qs = np.quantile(g.rt_ms, np.linspace(0, 1, 11))
mid = [(qs[i] + qs[i + 1]) / 2 for i in range(10)]
med = [g.amp_uv[(g.rt_ms >= qs[i]) & (g.rt_ms <= qs[i + 1])].mean() for i in range(10)]
axes[1].plot(mid, med, "o-", color="k", lw=1.6, ms=5, label="decile mean")
axes[1].axhline(0, color="gray", lw=0.7)
axes[1].set(xlabel="Reaction time (ms)", ylabel="Single-trial amplitude (µV)",
            title=f"Target trials: amplitude against reaction time (n = {len(g):,})")
axes[1].legend(fontsize=8)
axes[1].grid(alpha=0.3)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Figure 2 of notebook nb-6-2-lmm, an output plot. The text around it states what it shows and the units of every axis.

7 · Convergence, and what to do when it fails

statsmodels' MixedLM optimises a profiled likelihood; lme4 uses a different parameterisation and a different optimiser, so the two can disagree about whether a model converged even when they agree about the estimates. The failure is almost always the same shape: a random-effects structure the data cannot support — too few levels, or a slope variance near zero, or a correlation pinned at ±1 (a "singular fit").

What to do, in order:

  1. Look at the variance components. A slope SD of essentially zero with a correlation at ±1 means the model is overparameterised, not that the optimiser is broken.
  2. Simplify the random structure by design, not by p-value. Drop the correlation term first ((1 | subject) + (0 + condition | subject) in lme4), then the slope.
  3. Rescale the predictors. Wildly different scales (milliseconds beside microvolts) make the surface hard to optimise; the z-scoring in section 6 is partly for this.
  4. Report the failure. A model that did not converge is a result about the data, and hiding it is the same kind of omission Level 6 is about.

The cell below reports the status of every model fitted in this notebook.

In [12]:
print(f"{'model':>34s} {'converged':>10s} {'effect (uV)':>12s} {'SE':>8s} {'p':>10s}")
fits = [("average-then-test (no model)", True, diff.mean(), diff.std(ddof=1) / np.sqrt(n_sub), p_avg),
        ("random intercept", getattr(m_ri, "converged", None), ri[0], ri[1], ri[3])]
if HAVE_STATSMODELS:
    fits += [("random intercept + slope", m_rs.converged, rs[0], rs[1], rs[3]),
             ("crossed subject + letter", m_cr.converged, cr[0], cr[1], cr[3])]
    k_st = "condition[T.target]"
    fits += [("single-trial regression", m_st.converged, m_st.params[k_st], m_st.bse[k_st], m_st.pvalues[k_st])]
else:
    k_st = "is_target"
    r_ = m_st.loc[m_st["name"] == k_st].iloc[0]
    fits += [("single-trial (two-stage fallback)", None, r_["coef"], r_["se"], r_["p"])]
for name, conv, est, se, p in fits:
    print(f"{name:>34s} {str(conv):>10s} {est:+12.4f} {se:8.4f} {p:10.3g}")
print()
print(f"Every model puts the condition effect within "
      f"{max(abs(f[2] - fits[0][2]) for f in fits):.3f} uV of the average-then-test estimate, and every one "
      f"rejects zero.  The models disagree about PRECISION, not about the effect, which is the usual case for "
      f"a contrast this large and the reason the choice between them has to be made on the design.")
                             model  converged  effect (uV)       SE          p
      average-then-test (no model)       True      +2.7366   0.5412   8.21e-05
                  random intercept       True      +2.7635   0.2720   3.02e-24
          random intercept + slope       True      +2.7583   0.5497   5.22e-07
          crossed subject + letter      False      +2.7557   0.2719   3.91e-24
           single-trial regression       True      +2.6593   0.5466   1.14e-06

Every model puts the condition effect within 0.077 uV of the average-then-test estimate, and every one rejects zero.  The models disagree about PRECISION, not about the effect, which is the usual case for a contrast this large and the reason the choice between them has to be made on the design.
In [13]:
print("nb-6-2-lmm -- L6.2 numbers (draft; TODO(confirm) at author review)")
print(f"Data: ds-erpcore P3, sub-001 to sub-{SUBJECTS[-1]:03d} ({df.subject.nunique()} subjects after the "
      f"{MIN_TRIALS}-trial exclusion rule), {len(df):,} single trials; CC-BY-SA-4.0 per data/directory.yaml")
print(f"Measure: single-trial mean amplitude 300-600 ms at Pz under helpers_l6.PRESPECIFIED "
      f"({L6.describe_path(L6.PRESPECIFIED)})")
print(f"Fitted with: {'statsmodels ' + statsmodels.__version__ + ' MixedLM' if HAVE_STATSMODELS else 'the two-stage fallback (statsmodels absent)'}")
print()
print(f"1. AVERAGE-THEN-TEST (the Level 3 analysis): {diff.mean():+.4f} uV, "
      f"95% CI [{diff.mean() - ci_avg:+.4f}, {diff.mean() + ci_avg:+.4f}], t({n_sub - 1}) = {t_avg:.3f}, "
      f"p = {p_avg:.3g}")
print(f"   what it discards: trial counts ({int(counts['target'].min())}-{int(counts['target'].max())} target "
      f"trials per subject, a {(w.max() / w.min()):.1f}x spread in implied weight), the trial-level variance, "
      f"and the stimulus item.")
print()
print(f"2. ex-6-2 ANSWER PAIR -- does the random slope change the fixed effect?")
print(f"     random intercept          : {ri[0]:+.4f} uV (SE {ri[1]:.4f}, z = {ri[2]:.3f}, p = {ri[3]:.3g})")
print(f"     random intercept + slope  : {rs[0]:+.4f} uV (SE {rs[1]:.4f}, z = {rs[2]:.3f}, p = {rs[3]:.3g})")
print(f"     estimate moves {rs[0] - ri[0]:+.4f} uV; standard error moves {(rs[1] / ri[1] - 1):+.1%}")
if HAVE_STATSMODELS:
    print(f"     subject intercept SD {sd_int:.3f} uV, subject slope SD {sd_slope:.3f} uV, "
          f"correlation {corr:+.3f}, residual SD {np.sqrt(m_rs.scale):.3f} uV")
    print()
    print(f"3. CROSSED subject + letter: {cr[0]:+.4f} uV (SE {cr[1]:.4f}, p = {cr[3]:.3g}); "
          f"variance components " + ", ".join(f"{k} {v:.4f} uV^2" for k, v in zip(vcf, m_cr.vcomp))
          + f"; CONVERGED = {m_cr.converged}"
          + ("  <- do not quote its variance components as estimates" if not m_cr.converged else ""))
    print()
    print(f"4. SINGLE-TRIAL REGRESSION (amp ~ condition * trial_z + rt_z, {len(d2):,} trials with an RT):")
    for k, v in st_terms.items():
        unit = "uV" if k.startswith("condition[T.target]") and ":" not in k else "uV per within-subject SD"
        print(f"     {k:28s} {v[0]:+8.4f} {unit}, SE {v[1]:.4f}, p = {v[3]:.3g}")
print()
print("R equivalents, for a reader who works in lme4:")
print("   lmer(amp_uv ~ condition + (1 | subject), data = df, REML = TRUE)")
print("   lmer(amp_uv ~ condition + (1 + condition | subject), data = df, REML = TRUE)")
print("   lmer(amp_uv ~ condition + (1 | subject) + (1 | letter), data = df, REML = TRUE)")
print("   lmer(amp_uv ~ condition * trial_z + rt_z + (1 + condition | subject), data = df)")
print()
print("Pitfall: pf-group-demographic-confound.  No widget for this lesson.")
nb-6-2-lmm -- L6.2 numbers (draft; TODO(confirm) at author review)
Data: ds-erpcore P3, sub-001 to sub-020 (19 subjects after the 15-trial exclusion rule), 3,468 single trials; CC-BY-SA-4.0 per data/directory.yaml
Measure: single-trial mean amplitude 300-600 ms at Pz under helpers_l6.PRESPECIFIED (average ref, 0.1 Hz high-pass, baseline -200..0 ms, rejection 100 uV, 300-600 ms, Pz)
Fitted with: statsmodels 0.14.4 MixedLM

1. AVERAGE-THEN-TEST (the Level 3 analysis): +2.7366 uV, 95% CI [+1.5997, +3.8736], t(18) = 5.057, p = 8.21e-05
   what it discards: trial counts (18-40 target trials per subject, a 20.9x spread in implied weight), the trial-level variance, and the stimulus item.

2. ex-6-2 ANSWER PAIR -- does the random slope change the fixed effect?
     random intercept          : +2.7635 uV (SE 0.2720, z = 10.159, p = 3.02e-24)
     random intercept + slope  : +2.7583 uV (SE 0.5497, z = 5.018, p = 5.22e-07)
     estimate moves -0.0052 uV; standard error moves +102.1%
     subject intercept SD 1.445 uV, subject slope SD 2.080 uV, correlation +0.167, residual SD 6.351 uV

3. CROSSED subject + letter: +2.7557 uV (SE 0.2719, p = 3.91e-24); variance components subject 20.4135 uV^2, letter 2.2188 uV^2; CONVERGED = False  <- do not quote its variance components as estimates

4. SINGLE-TRIAL REGRESSION (amp ~ condition * trial_z + rt_z, 3,461 trials with an RT):
     condition[T.target]           +2.6593 uV, SE 0.5466, p = 1.14e-06
     trial_z                       +0.0123 uV per within-subject SD, SE 0.1214, p = 0.919
     rt_z                          +0.2938 uV per within-subject SD, SE 0.1109, p = 0.00807
     condition[T.target]:trial_z   -0.2817 uV per within-subject SD, SE 0.2724, p = 0.301

R equivalents, for a reader who works in lme4:
   lmer(amp_uv ~ condition + (1 | subject), data = df, REML = TRUE)
   lmer(amp_uv ~ condition + (1 + condition | subject), data = df, REML = TRUE)
   lmer(amp_uv ~ condition + (1 | subject) + (1 | letter), data = df, REML = TRUE)
   lmer(amp_uv ~ condition * trial_z + rt_z + (1 + condition | subject), data = df)

Pitfall: pf-group-demographic-confound.  No widget for this lesson.