axiom

Example 11 · Behavioural science

What study twenty-one will actually find

Twenty published studies test the same nudge and disagree with each other. Pooling them gives a number. The question is whether that number is the one a replication team should power against.

Pool 6 steps 5 figures 389 lines runs in 0.8s

Behavioural science — pooling a literature that disagrees with itself.

The question

Twenty published studies test the same behavioural nudge. The effects range from trivial to large. What does the literature say, how much do the studies really disagree, and what should you expect if you run study twenty-one?

Three things that get skipped

The pooled mean is the least interesting output. What a replication team actually needs is the PREDICTION interval -- where a new study would land -- which is much wider than the interval on the mean and is the number that predicts replication success.

Heterogeneity is not a nuisance to be averaged away; a high I-squared means the single pooled number is answering a question nobody asked.

And small-study effects deserve a look before any of it is believed.

Pillars: meta (pooling, heterogeneity, small-study effects, influence)

The route

Before the first step

What this example imports, and why that list is short

Everything below runs on the four core dependencies. No sampler, no extras.

examples/11_psychology_meta_analysis.py
from _walkthrough import Walkthrough

from axiom.build import MetaBuilder
from axiom.meta import (
    baujat,
    egger,
    funnel_data,
    heterogeneity,
    leave_one_out,
    prediction_interval,
    random_effects,
)

# Twenty nudge studies: effect size and standard error, as a review would collect
# them. Larger studies are more precise, and the smaller ones scatter wider --
# partly by sampling and partly, as the funnel below suggests, by what got
# published at all.
STUDIES = [
    ("s01", 0.09, 0.041, 1180),
    ("s02", 0.31, 0.152, 88),
    ("s03", 0.15, 0.033, 1840),
    ("s04", 0.22, 0.131, 118),
    ("s05", 0.19, 0.058, 602),
    ("s06", 0.48, 0.186, 61),
    ("s07", 0.13, 0.028, 2510),
    ("s08", 0.05, 0.104, 187),
    ("s09", 0.21, 0.049, 842),
    ("s10", 0.42, 0.168, 72),
    ("s11", 0.11, 0.031, 2080),
    ("s12", 0.34, 0.088, 258),
    ("s13", 0.17, 0.126, 128),
    ("s14", 0.24, 0.044, 1030),
    ("s15", 0.39, 0.147, 94),
    ("s16", 0.06, 0.030, 2240),
    ("s17", 0.28, 0.076, 344),
    ("s18", 0.51, 0.198, 54),
    ("s19", 0.16, 0.052, 742),
    ("s20", 0.12, 0.097, 214),
]

w = Walkthrough(
    field="Behavioural science",
    title="What study twenty-one will actually find",
    question="""Twenty published studies test the same nudge and disagree with each
        other. Pooling them gives a number. The question is whether that number is
        the one a replication team should power against.""",
)

Step 1 of 6

Build the corpus through a vocabulary that refuses the wrong pool

'standardized_contrast' is not free text: poolable quantities are a controlled vocabulary, so two reviews cannot quietly pool an odds ratio with a standardized mean difference. Pass something outside the vocabulary and the record refuses to be built, at collection time rather than at interpretation time.

The path not taken

A dataframe of estimates and standard errors. It pools anything you put in it, including things that are not the same kind of number, and the mistake surfaces — if it ever does — in review.

examples/11_psychology_meta_analysis.py
builder = MetaBuilder().name("nudge-replication").family("behavioural_nudge")
for study, estimate, se_i, n in STUDIES:
    builder = builder.study(
        study,
        estimate=estimate,
        se=se_i,
        read="experiment",
        contributor=study,
        quantity="standardized_contrast",
        n=n,
        source=f"paper-{study}",
    )
corpus = builder.build()
y, se = corpus.arrays()
w.out(f"corpus   : {corpus.name}, k = {len(corpus)} studies")
w.out(f"quantity : {corpus.records[0].quantity}")
w.out(f"effects  : {min(y):.2f} to {max(y):.2f}")
w.out(f"n        : {min(n for _, _, _, n in STUDIES):,} to {max(n for _, _, _, n in STUDIES):,}")
what it printed
corpus   : nudge-replication, k = 20 studies
quantity : standardized_contrast
effects  : 0.05 to 0.51
n        : 54 to 2,510

Step 2 of 6

Pool it three ways, because the estimators disagree

The between-study variance has to be estimated, and DerSimonian-Laird, Paule-Mandel and REML do it differently. When they agree, quoting one is fine. When they do not, the disagreement is information about how much of the answer is coming from the tau estimator rather than from the studies — and it is worth knowing before quoting any of them.

The path not taken

Using DerSimonian-Laird because it is the default in most software. It is known to understate tau-squared when heterogeneity is high, which is exactly the regime this literature is in.

examples/11_psychology_meta_analysis.py
pooled_rows = []
for method, label in (("dl", "DerSimonian-Laird"), ("pm", "Paule-Mandel"), ("reml", "REML")):
    re = random_effects(y, se, tau_method=method)
    w.out(
        f"{label:<20}{re.estimate:>9.4f}  se {re.se:.4f}  tau2 {re.tau2:.5f}   "
        f"[{re.interval.lower:.3f}, {re.interval.upper:.3f}]"
    )
    pooled_rows.append(
        {
            "label": label,
            "estimate": float(re.estimate),
            "lower": float(re.interval.lower),
            "upper": float(re.interval.upper),
            "note": f"tau^2 = {re.tau2:.5f}",
        }
    )
w.figure(
    "estimators",
    kind="intervals",
    data={"rows": pooled_rows},
    opt={
        "rows": "@rows",
        "zero": True,
        "xLabel": "pooled effect",
        "labelWidth": 150,
        "rowHeight": 36,
        "mass": 95,
    },
    # title, note and legend: the caption shown with the chart
)
what it printed
DerSimonian-Laird      0.1731  se 0.0204  tau2 0.00338   [0.133, 0.213]
Paule-Mandel           0.1784  se 0.0228  tau2 0.00488   [0.134, 0.223]
REML                   0.1736  se 0.0206  tau2 0.00350   [0.133, 0.214]

Three ways of estimating the between-study variance

Close enough here that the choice does not change the conclusion — which is itself worth establishing rather than assuming, and takes one loop.

Step 3 of 6

Draw all twenty, because the pooled number hides them

A forest plot is not decoration. The pooled mean is a summary of a distribution, and the only way to see whether that distribution has a centre worth summarising is to look at the studies. Here the effects run from 0.05 to 0.51 and the small studies sit systematically to the right.

The path not taken

Reporting the pooled estimate and I-squared as two numbers. I-squared of 70% and I-squared of 70% look identical in a table and can mean a tidy spread or two distinct clusters, which imply completely different next steps.

examples/11_psychology_meta_analysis.py
het = heterogeneity(y, se)
kh = random_effects(y, se, tau_method="reml", knapp_hartung=True)
w.out(f"Q  = {het.q:.1f} on {het.df} df (p = {het.p_value:.4f})")
w.out(f"I2 = {het.i2:.0%}")
w.out(
    f"pooled (REML, Knapp-Hartung) = {kh.estimate:.4f} "
    f"[{kh.interval.lower:.3f}, {kh.interval.upper:.3f}]"
)
w.say(f"""An I-squared of {het.i2:.0%} means most of the variation between these studies
    is real disagreement rather than sampling noise. The pooled mean is a summary of
    a distribution, not an estimate of one shared number — and that distinction is
    what the next two steps are about.""")
order = sorted(range(len(STUDIES)), key=lambda i: STUDIES[i][1])
weights = [1.0 / (s * s) for s in se]
total_weight = sum(weights)
w.figure(
    "forest",
    kind="intervals",
    data={
        "rows": [
            {
                "label": STUDIES[i][0],
                "estimate": float(y[i]),
                "lower": float(y[i] - 1.96 * se[i]),
                "upper": float(y[i] + 1.96 * se[i]),
                "weight": weights[i] / total_weight,
                "note": f"n = {STUDIES[i][3]:,}, se {se[i]:.3f}",
            }
            for i in order
        ],
        "pooled": {
            "estimate": float(kh.estimate),
            "lower": float(kh.interval.lower),
            "upper": float(kh.interval.upper),
        },
    },
    opt={
        "rows": "@rows",
        "pooled": "@pooled",
        "pooledLabel": "pooled (REML)",
        "weightBy": "weight",
        "zero": True,
        "xLabel": "standardized effect",
        "labelWidth": 90,
        "rowHeight": 26,
        "mass": 95,
    },
    # title, note and legend: the caption shown with the chart
)
what it printed
Q  = 41.1 on 19 df (p = 0.0023)
I2 = 54%
pooled (REML, Knapp-Hartung) = 0.1736 [0.128, 0.219]

An I-squared of 54% means most of the variation between these studies is real disagreement rather than sampling noise. The pooled mean is a summary of a distribution, not an estimate of one shared number — and that distinction is what the next two steps are about.

Every study, sorted by effect, sized by weight

The dot size is the study's weight in the pool. Notice that the largest dots — the most precise studies — sit at the left end, and the studies at the right are the small ones. That pattern is what the funnel below is for.

95% interval, sized by weight

Step 4 of 6

Then compute the number a replication team actually needs

The interval on the mean says where the average of this literature sits. The prediction interval says where a NEW study would land, and it includes the between-study variance rather than averaging it away. When heterogeneity is real these are very different numbers, and only the second one predicts replication.

The path not taken

Powering the replication off the pooled mean. It is the single most common way a replication ends up underpowered for the effect it actually meets: the mean is the centre of a distribution the new study is drawn from, not the value it will take.

examples/11_psychology_meta_analysis.py
pi = prediction_interval(kh)
ratio = (pi.upper - pi.lower) / (kh.interval.upper - kh.interval.lower)
w.out(f"pooled mean        {kh.estimate:.4f}  [{kh.interval.lower:.3f}, {kh.interval.upper:.3f}]")
w.out(f"NEXT study lands in           [{pi.lower:.3f}, {pi.upper:.3f}]")
w.out(f"the prediction interval is {ratio:.1f}x wider")
w.figure(
    "prediction",
    kind="intervals",
    data={
        "rows": [
            {
                "label": "where the mean is",
                "estimate": float(kh.estimate),
                "lower": float(kh.interval.lower),
                "upper": float(kh.interval.upper),
                "note": "95% interval on the pooled mean",
            },
            {
                "label": "where study 21 lands",
                "estimate": float(kh.estimate),
                "lower": float(pi.lower),
                "upper": float(pi.upper),
                "note": "95% prediction interval",
                "colour": "s2",
            },
        ]
    },
    opt={
        "rows": "@rows",
        "zero": True,
        "xLabel": "standardized effect",
        "labelWidth": 160,
        "rowHeight": 42,
        "mass": 95,
    },
    # title, note and legend: the caption shown with the chart
)
what it printed
pooled mean        0.1736  [0.128, 0.219]
NEXT study lands in           [0.041, 0.306]
the prediction interval is 2.9x wider

Two intervals that are routinely confused

Same centre, 2.9 times the width. A replication powered off the mean is powered for the wrong quantity, and the bottom of the prediction interval — 0.04 — is where an honest power calculation has to start.

interval on the meanprediction interval

Step 5 of 6

Check whether the small studies are telling a different story

Egger's test asks whether effect size is associated with precision. A non-zero intercept means the imprecise studies report systematically larger effects — the signature of publication bias. The funnel plot is the same question drawn, and it shows the asymmetry rather than compressing it into a p-value.

The path not taken

Treating a significant Egger test as proof of publication bias. It is not: real heterogeneity correlated with study size looks identical. Small studies often run more intensive versions of an intervention, and that would produce the same picture with nobody hiding anything.

examples/11_psychology_meta_analysis.py
eg = egger(y, se)
w.out(f"Egger intercept {eg.intercept:+.3f} +/- {eg.se:.3f}")
w.out(f"t = {eg.t:+.2f} on {eg.df} df, p = {eg.p:.4f}")
fu = funnel_data(y, se, kh)
w.figure(
    "funnel",
    kind="funnel",
    data={
        "y": [float(v) for v in y],
        "se": [float(v) for v in se],
        "labels": [r.study for r in corpus.records],
        "pooled": float(fu.pooled),
        "contours": [
            {
                "mass": c.mass,
                "se": [float(v) for v in c.se],
                "lower": [float(v) for v in c.lower],
                "upper": [float(v) for v in c.upper],
            }
            for c in fu.contours
        ],
        "max_se": float(max(se)),
    },
    opt={"key": "@", "xLabel": "standardized effect", "height": 360},
    # title, note and legend: the caption shown with the chart
)
what it printed
Egger intercept +1.822 +/- 0.474
t = +3.84 on 18 df, p = 0.0012

The funnel, with its contours

Precise studies sit at the top and should scatter symmetrically about the pooled line. Here the bottom-left of the funnel is empty: there are no small studies reporting small effects, which is either what publication looks like or what a genuine relationship between study size and effect looks like.

Step 6 of 6

And ask which studies are carrying the answer

Leave-one-out says how far the pooled estimate moves when each study is dropped; the Baujat plot separates two different reasons a study matters — contributing heterogeneity, and influencing the result. A study high on both axes is one whose inclusion decision is doing real work and should be argued for rather than defaulted into.

The path not taken

Running a sensitivity analysis that drops studies by quality rating. Quality ratings are largely uncorrelated with influence, so that procedure removes studies that do not matter and keeps the ones that do.

examples/11_psychology_meta_analysis.py
loo = leave_one_out(y, se, method="reml")
influence = sorted(
    zip((r.study for r in corpus.records), loo.influence, strict=True),
    key=lambda t: abs(t[1]),
    reverse=True,
)
by_estimate = {s: e for s, e, _, _ in STUDIES}
by_n = {s: n for s, _, _, n in STUDIES}
w.table(
    ["study", "shift when dropped", "effect", "n"],
    [
        [study, f"{shift:+.3f}", f"{by_estimate[study]:.2f}", f"{by_n[study]:,}"]
        for study, shift in influence[:5]
    ],
    caption="The five most influential studies, in pooled-se units.",
)
bj = baujat(y, se)
worst = max(range(len(bj.q_contribution)), key=lambda i: bj.q_contribution[i])
w.out(
    f"largest contributor to heterogeneity: {corpus.records[worst].study} "
    f"(Q share {bj.q_contribution[worst]:.2f})"
)
w.figure(
    "baujat",
    kind="scatter",
    data={
        "x": [float(v) for v in bj.q_contribution],
        "y": [float(v) for v in bj.influence],
        "labels": [r.study for r in corpus.records],
    },
    opt={
        "key": "@",
        "x": "x",
        "y": "y",
        "labels": "labels",
        "xLabel": "contribution to heterogeneity",
        "yLabel": "influence on the pooled estimate",
        "height": 330,
    },
    # title, note and legend: the caption shown with the chart
)


w.finding(f"""Reported as one number this literature says the nudge works: {kh.estimate:.2f},
    interval comfortably clear of zero. Reported honestly it says something more
    useful and less quotable.""")
w.finding(f"""The studies genuinely disagree — {het.i2:.0%} of the variation is real rather
    than sampling — effect size tracks precision in a way consistent with publication
    bias (p = {eg.p:.4f}), and study twenty-one could land anywhere in
    [{pi.lower:.2f}, {pi.upper:.2f}]. The bottom of that range is about a quarter of
    the pooled mean and small enough that a replication powered off {kh.estimate:.2f}
    would miss it.""")
w.finding("""None of that needed a different estimator. It needed the three quantities that
    usually end up in an appendix: heterogeneity, the prediction interval, and a
    small-study check — plus two plots, because I-squared and an Egger p-value are
    summaries of pictures and the pictures are where the shape is.""")
The five most influential studies, in pooled-se units.
studyshift when droppedeffectn
s12+0.4770.34258
s11-0.4020.112,080
s01-0.3930.091,180
s07-0.3670.132,510
s14+0.3600.241,030
what it printed
largest contributor to heterogeneity: s16 (Q share 8.21)

Baujat: which studies matter, and for which reason

Far right means a study disagrees with the rest. High up means dropping it moves the answer. The top-right corner is where an inclusion decision changes the conclusion, and those are the studies to argue about by name.

The close

What it actually showed

Written after looking at the output, which is why it does not always agree with the setup.

Reported as one number this literature says the nudge works: 0.17, interval comfortably clear of zero. Reported honestly it says something more useful and less quotable.

The studies genuinely disagree — 54% of the variation is real rather than sampling — effect size tracks precision in a way consistent with publication bias (p = 0.0012), and study twenty-one could land anywhere in [0.04, 0.31]. The bottom of that range is about a quarter of the pooled mean and small enough that a replication powered off 0.17 would miss it.

None of that needed a different estimator. It needed the three quantities that usually end up in an appendix: heterogeneity, the prediction interval, and a small-study check — plus two plots, because I-squared and an Egger p-value are summaries of pictures and the pictures are where the shape is.

The whole file, and everything this run printed
examples/11_psychology_meta_analysis.py
from _walkthrough import Walkthrough

from axiom.build import MetaBuilder
from axiom.meta import (
    baujat,
    egger,
    funnel_data,
    heterogeneity,
    leave_one_out,
    prediction_interval,
    random_effects,
)

# Twenty nudge studies: effect size and standard error, as a review would collect
# them. Larger studies are more precise, and the smaller ones scatter wider --
# partly by sampling and partly, as the funnel below suggests, by what got
# published at all.
STUDIES = [
    ("s01", 0.09, 0.041, 1180),
    ("s02", 0.31, 0.152, 88),
    ("s03", 0.15, 0.033, 1840),
    ("s04", 0.22, 0.131, 118),
    ("s05", 0.19, 0.058, 602),
    ("s06", 0.48, 0.186, 61),
    ("s07", 0.13, 0.028, 2510),
    ("s08", 0.05, 0.104, 187),
    ("s09", 0.21, 0.049, 842),
    ("s10", 0.42, 0.168, 72),
    ("s11", 0.11, 0.031, 2080),
    ("s12", 0.34, 0.088, 258),
    ("s13", 0.17, 0.126, 128),
    ("s14", 0.24, 0.044, 1030),
    ("s15", 0.39, 0.147, 94),
    ("s16", 0.06, 0.030, 2240),
    ("s17", 0.28, 0.076, 344),
    ("s18", 0.51, 0.198, 54),
    ("s19", 0.16, 0.052, 742),
    ("s20", 0.12, 0.097, 214),
]

w = Walkthrough(
    field="Behavioural science",
    title="What study twenty-one will actually find",
    question="""Twenty published studies test the same nudge and disagree with each
        other. Pooling them gives a number. The question is whether that number is
        the one a replication team should power against.""",
)

# ----------------------------------------------------------------------------------

w.step(
    "Build the corpus through a vocabulary that refuses the wrong pool",
    why="""'standardized_contrast' is not free text: poolable quantities are a
        controlled vocabulary, so two reviews cannot quietly pool an odds ratio with
        a standardized mean difference. Pass something outside the vocabulary and the
        record refuses to be built, at collection time rather than at interpretation
        time.""",
    instead="""A dataframe of estimates and standard errors. It pools anything you put
        in it, including things that are not the same kind of number, and the mistake
        surfaces — if it ever does — in review.""",
)
builder = MetaBuilder().name("nudge-replication").family("behavioural_nudge")
for study, estimate, se_i, n in STUDIES:
    builder = builder.study(
        study,
        estimate=estimate,
        se=se_i,
        read="experiment",
        contributor=study,
        quantity="standardized_contrast",
        n=n,
        source=f"paper-{study}",
    )
corpus = builder.build()
y, se = corpus.arrays()
w.out(f"corpus   : {corpus.name}, k = {len(corpus)} studies")
w.out(f"quantity : {corpus.records[0].quantity}")
w.out(f"effects  : {min(y):.2f} to {max(y):.2f}")
w.out(f"n        : {min(n for _, _, _, n in STUDIES):,} to {max(n for _, _, _, n in STUDIES):,}")

# ----------------------------------------------------------------------------------

w.step(
    "Pool it three ways, because the estimators disagree",
    why="""The between-study variance has to be estimated, and DerSimonian-Laird,
        Paule-Mandel and REML do it differently. When they agree, quoting one is
        fine. When they do not, the disagreement is information about how much of the
        answer is coming from the tau estimator rather than from the studies — and it
        is worth knowing before quoting any of them.""",
    instead="""Using DerSimonian-Laird because it is the default in most software. It
        is known to understate tau-squared when heterogeneity is high, which is
        exactly the regime this literature is in.""",
)
pooled_rows = []
for method, label in (("dl", "DerSimonian-Laird"), ("pm", "Paule-Mandel"), ("reml", "REML")):
    re = random_effects(y, se, tau_method=method)
    w.out(
        f"{label:<20}{re.estimate:>9.4f}  se {re.se:.4f}  tau2 {re.tau2:.5f}   "
        f"[{re.interval.lower:.3f}, {re.interval.upper:.3f}]"
    )
    pooled_rows.append(
        {
            "label": label,
            "estimate": float(re.estimate),
            "lower": float(re.interval.lower),
            "upper": float(re.interval.upper),
            "note": f"tau^2 = {re.tau2:.5f}",
        }
    )
w.figure(
    "estimators",
    kind="intervals",
    data={"rows": pooled_rows},
    opt={
        "rows": "@rows",
        "zero": True,
        "xLabel": "pooled effect",
        "labelWidth": 150,
        "rowHeight": 36,
        "mass": 95,
    },
    title="Three ways of estimating the between-study variance",
    note="""Close enough here that the choice does not change the conclusion — which
        is itself worth establishing rather than assuming, and takes one loop.""",
)

# ----------------------------------------------------------------------------------

w.step(
    "Draw all twenty, because the pooled number hides them",
    why="""A forest plot is not decoration. The pooled mean is a summary of a
        distribution, and the only way to see whether that distribution has a centre
        worth summarising is to look at the studies. Here the effects run from 0.05
        to 0.51 and the small studies sit systematically to the right.""",
    instead="""Reporting the pooled estimate and I-squared as two numbers. I-squared
        of 70% and I-squared of 70% look identical in a table and can mean a tidy
        spread or two distinct clusters, which imply completely different next
        steps.""",
)
het = heterogeneity(y, se)
kh = random_effects(y, se, tau_method="reml", knapp_hartung=True)
w.out(f"Q  = {het.q:.1f} on {het.df} df (p = {het.p_value:.4f})")
w.out(f"I2 = {het.i2:.0%}")
w.out(
    f"pooled (REML, Knapp-Hartung) = {kh.estimate:.4f} "
    f"[{kh.interval.lower:.3f}, {kh.interval.upper:.3f}]"
)
w.say(f"""An I-squared of {het.i2:.0%} means most of the variation between these studies
    is real disagreement rather than sampling noise. The pooled mean is a summary of
    a distribution, not an estimate of one shared number — and that distinction is
    what the next two steps are about.""")
order = sorted(range(len(STUDIES)), key=lambda i: STUDIES[i][1])
weights = [1.0 / (s * s) for s in se]
total_weight = sum(weights)
w.figure(
    "forest",
    kind="intervals",
    data={
        "rows": [
            {
                "label": STUDIES[i][0],
                "estimate": float(y[i]),
                "lower": float(y[i] - 1.96 * se[i]),
                "upper": float(y[i] + 1.96 * se[i]),
                "weight": weights[i] / total_weight,
                "note": f"n = {STUDIES[i][3]:,}, se {se[i]:.3f}",
            }
            for i in order
        ],
        "pooled": {
            "estimate": float(kh.estimate),
            "lower": float(kh.interval.lower),
            "upper": float(kh.interval.upper),
        },
    },
    opt={
        "rows": "@rows",
        "pooled": "@pooled",
        "pooledLabel": "pooled (REML)",
        "weightBy": "weight",
        "zero": True,
        "xLabel": "standardized effect",
        "labelWidth": 90,
        "rowHeight": 26,
        "mass": 95,
    },
    title="Every study, sorted by effect, sized by weight",
    note="""The dot size is the study's weight in the pool. Notice that the largest
        dots — the most precise studies — sit at the left end, and the studies at the
        right are the small ones. That pattern is what the funnel below is for.""",
    legend=(("accent", "95% interval, sized by weight"),),
)

# ----------------------------------------------------------------------------------

w.step(
    "Then compute the number a replication team actually needs",
    why="""The interval on the mean says where the average of this literature sits.
        The prediction interval says where a NEW study would land, and it includes
        the between-study variance rather than averaging it away. When heterogeneity
        is real these are very different numbers, and only the second one predicts
        replication.""",
    instead="""Powering the replication off the pooled mean. It is the single most
        common way a replication ends up underpowered for the effect it actually
        meets: the mean is the centre of a distribution the new study is drawn
        from, not the value it will take.""",
)
pi = prediction_interval(kh)
ratio = (pi.upper - pi.lower) / (kh.interval.upper - kh.interval.lower)
w.out(f"pooled mean        {kh.estimate:.4f}  [{kh.interval.lower:.3f}, {kh.interval.upper:.3f}]")
w.out(f"NEXT study lands in           [{pi.lower:.3f}, {pi.upper:.3f}]")
w.out(f"the prediction interval is {ratio:.1f}x wider")
w.figure(
    "prediction",
    kind="intervals",
    data={
        "rows": [
            {
                "label": "where the mean is",
                "estimate": float(kh.estimate),
                "lower": float(kh.interval.lower),
                "upper": float(kh.interval.upper),
                "note": "95% interval on the pooled mean",
            },
            {
                "label": "where study 21 lands",
                "estimate": float(kh.estimate),
                "lower": float(pi.lower),
                "upper": float(pi.upper),
                "note": "95% prediction interval",
                "colour": "s2",
            },
        ]
    },
    opt={
        "rows": "@rows",
        "zero": True,
        "xLabel": "standardized effect",
        "labelWidth": 160,
        "rowHeight": 42,
        "mass": 95,
    },
    title="Two intervals that are routinely confused",
    note=f"""Same centre, {ratio:.1f} times the width. A replication powered off the
        mean is powered for the wrong quantity, and the bottom of the prediction
        interval — {pi.lower:.2f} — is where an honest power calculation has to
        start.""",
    legend=(("accent", "interval on the mean"), ("s2", "prediction interval")),
)

# ----------------------------------------------------------------------------------

w.step(
    "Check whether the small studies are telling a different story",
    why="""Egger's test asks whether effect size is associated with precision. A
        non-zero intercept means the imprecise studies report systematically larger
        effects — the signature of publication bias. The funnel plot is the same
        question drawn, and it shows the asymmetry rather than compressing it into a
        p-value.""",
    instead="""Treating a significant Egger test as proof of publication bias. It is
        not: real heterogeneity correlated with study size looks identical. Small
        studies often run more intensive versions of an intervention, and that would
        produce the same picture with nobody hiding anything.""",
)
eg = egger(y, se)
w.out(f"Egger intercept {eg.intercept:+.3f} +/- {eg.se:.3f}")
w.out(f"t = {eg.t:+.2f} on {eg.df} df, p = {eg.p:.4f}")
fu = funnel_data(y, se, kh)
w.figure(
    "funnel",
    kind="funnel",
    data={
        "y": [float(v) for v in y],
        "se": [float(v) for v in se],
        "labels": [r.study for r in corpus.records],
        "pooled": float(fu.pooled),
        "contours": [
            {
                "mass": c.mass,
                "se": [float(v) for v in c.se],
                "lower": [float(v) for v in c.lower],
                "upper": [float(v) for v in c.upper],
            }
            for c in fu.contours
        ],
        "max_se": float(max(se)),
    },
    opt={"key": "@", "xLabel": "standardized effect", "height": 360},
    title="The funnel, with its contours",
    note="""Precise studies sit at the top and should scatter symmetrically about the
        pooled line. Here the bottom-left of the funnel is empty: there are no small
        studies reporting small effects, which is either what publication looks like
        or what a genuine relationship between study size and effect looks like.""",
)

# ----------------------------------------------------------------------------------

w.step(
    "And ask which studies are carrying the answer",
    why="""Leave-one-out says how far the pooled estimate moves when each study is
        dropped; the Baujat plot separates two different reasons a study matters —
        contributing heterogeneity, and influencing the result. A study high on both
        axes is one whose inclusion decision is doing real work and should be argued
        for rather than defaulted into.""",
    instead="""Running a sensitivity analysis that drops studies by quality rating.
        Quality ratings are largely uncorrelated with influence, so that procedure
        removes studies that do not matter and keeps the ones that do.""",
)
loo = leave_one_out(y, se, method="reml")
influence = sorted(
    zip((r.study for r in corpus.records), loo.influence, strict=True),
    key=lambda t: abs(t[1]),
    reverse=True,
)
by_estimate = {s: e for s, e, _, _ in STUDIES}
by_n = {s: n for s, _, _, n in STUDIES}
w.table(
    ["study", "shift when dropped", "effect", "n"],
    [
        [study, f"{shift:+.3f}", f"{by_estimate[study]:.2f}", f"{by_n[study]:,}"]
        for study, shift in influence[:5]
    ],
    caption="The five most influential studies, in pooled-se units.",
)
bj = baujat(y, se)
worst = max(range(len(bj.q_contribution)), key=lambda i: bj.q_contribution[i])
w.out(
    f"largest contributor to heterogeneity: {corpus.records[worst].study} "
    f"(Q share {bj.q_contribution[worst]:.2f})"
)
w.figure(
    "baujat",
    kind="scatter",
    data={
        "x": [float(v) for v in bj.q_contribution],
        "y": [float(v) for v in bj.influence],
        "labels": [r.study for r in corpus.records],
    },
    opt={
        "key": "@",
        "x": "x",
        "y": "y",
        "labels": "labels",
        "xLabel": "contribution to heterogeneity",
        "yLabel": "influence on the pooled estimate",
        "height": 330,
    },
    title="Baujat: which studies matter, and for which reason",
    note="""Far right means a study disagrees with the rest. High up means dropping it
        moves the answer. The top-right corner is where an inclusion decision changes
        the conclusion, and those are the studies to argue about by name.""",
)

# ----------------------------------------------------------------------------------

w.finding(f"""Reported as one number this literature says the nudge works: {kh.estimate:.2f},
    interval comfortably clear of zero. Reported honestly it says something more
    useful and less quotable.""")
w.finding(f"""The studies genuinely disagree — {het.i2:.0%} of the variation is real rather
    than sampling — effect size tracks precision in a way consistent with publication
    bias (p = {eg.p:.4f}), and study twenty-one could land anywhere in
    [{pi.lower:.2f}, {pi.upper:.2f}]. The bottom of that range is about a quarter of
    the pooled mean and small enough that a replication powered off {kh.estimate:.2f}
    would miss it.""")
w.finding("""None of that needed a different estimator. It needed the three quantities that
    usually end up in an appendix: heterogeneity, the prediction interval, and a
    small-study check — plus two plots, because I-squared and an Egger p-value are
    summaries of pictures and the pictures are where the shape is.""")
stdout, captured on this build
========================================================================================
BEHAVIOURAL SCIENCE  ---  What study twenty-one will actually find
========================================================================================
  Twenty published studies test the same nudge and disagree with each other. Pooling
  them gives a number. The question is whether that number is the one a replication team
  should power against.

----------------------------------------------------------------------------------------
STEP 1  Build the corpus through a vocabulary that refuses the wrong pool
----------------------------------------------------------------------------------------
  'standardized_contrast' is not free text: poolable quantities are a controlled
  vocabulary, so two reviews cannot quietly pool an odds ratio with a standardized mean
  difference. Pass something outside the vocabulary and the record refuses to be built,
  at collection time rather than at interpretation time.

  INSTEAD OF: A dataframe of estimates and standard errors. It pools anything you put in
  it, including things that are not the same kind of number, and the mistake surfaces —
  if it ever does — in review.

  corpus   : nudge-replication, k = 20 studies
  quantity : standardized_contrast
  effects  : 0.05 to 0.51
  n        : 54 to 2,510

----------------------------------------------------------------------------------------
STEP 2  Pool it three ways, because the estimators disagree
----------------------------------------------------------------------------------------
  The between-study variance has to be estimated, and DerSimonian-Laird, Paule-Mandel
  and REML do it differently. When they agree, quoting one is fine. When they do not,
  the disagreement is information about how much of the answer is coming from the tau
  estimator rather than from the studies — and it is worth knowing before quoting any of
  them.

  INSTEAD OF: Using DerSimonian-Laird because it is the default in most software. It is
  known to understate tau-squared when heterogeneity is high, which is exactly the
  regime this literature is in.

  DerSimonian-Laird      0.1731  se 0.0204  tau2 0.00338   [0.133, 0.213]
  Paule-Mandel           0.1784  se 0.0228  tau2 0.00488   [0.134, 0.223]
  REML                   0.1736  se 0.0206  tau2 0.00350   [0.133, 0.214]
  [figure: Three ways of estimating the between-study variance]


----------------------------------------------------------------------------------------
STEP 3  Draw all twenty, because the pooled number hides them
----------------------------------------------------------------------------------------
  A forest plot is not decoration. The pooled mean is a summary of a distribution, and
  the only way to see whether that distribution has a centre worth summarising is to
  look at the studies. Here the effects run from 0.05 to 0.51 and the small studies sit
  systematically to the right.

  INSTEAD OF: Reporting the pooled estimate and I-squared as two numbers. I-squared of
  70% and I-squared of 70% look identical in a table and can mean a tidy spread or two
  distinct clusters, which imply completely different next steps.

  Q  = 41.1 on 19 df (p = 0.0023)
  I2 = 54%
  pooled (REML, Knapp-Hartung) = 0.1736 [0.128, 0.219]
  An I-squared of 54% means most of the variation between these studies is real
  disagreement rather than sampling noise. The pooled mean is a summary of a
  distribution, not an estimate of one shared number — and that distinction is what the
  next two steps are about.

  [figure: Every study, sorted by effect, sized by weight]


----------------------------------------------------------------------------------------
STEP 4  Then compute the number a replication team actually needs
----------------------------------------------------------------------------------------
  The interval on the mean says where the average of this literature sits. The
  prediction interval says where a NEW study would land, and it includes the between-
  study variance rather than averaging it away. When heterogeneity is real these are
  very different numbers, and only the second one predicts replication.

  INSTEAD OF: Powering the replication off the pooled mean. It is the single most common
  way a replication ends up underpowered for the effect it actually meets: the mean is
  the centre of a distribution the new study is drawn from, not the value it will take.

  pooled mean        0.1736  [0.128, 0.219]
  NEXT study lands in           [0.041, 0.306]
  the prediction interval is 2.9x wider
  [figure: Two intervals that are routinely confused]


----------------------------------------------------------------------------------------
STEP 5  Check whether the small studies are telling a different story
----------------------------------------------------------------------------------------
  Egger's test asks whether effect size is associated with precision. A non-zero
  intercept means the imprecise studies report systematically larger effects — the
  signature of publication bias. The funnel plot is the same question drawn, and it
  shows the asymmetry rather than compressing it into a p-value.

  INSTEAD OF: Treating a significant Egger test as proof of publication bias. It is not:
  real heterogeneity correlated with study size looks identical. Small studies often run
  more intensive versions of an intervention, and that would produce the same picture
  with nobody hiding anything.

  Egger intercept +1.822 +/- 0.474
  t = +3.84 on 18 df, p = 0.0012
  [figure: The funnel, with its contours]


----------------------------------------------------------------------------------------
STEP 6  And ask which studies are carrying the answer
----------------------------------------------------------------------------------------
  Leave-one-out says how far the pooled estimate moves when each study is dropped; the
  Baujat plot separates two different reasons a study matters — contributing
  heterogeneity, and influencing the result. A study high on both axes is one whose
  inclusion decision is doing real work and should be argued for rather than defaulted
  into.

  INSTEAD OF: Running a sensitivity analysis that drops studies by quality rating.
  Quality ratings are largely uncorrelated with influence, so that procedure removes
  studies that do not matter and keeps the ones that do.

  The five most influential studies, in pooled-se units.
  study  shift when dropped  effect  n
  -----  ------------------  ------  -----
  s12    +0.477              0.34    258
  s11    -0.402              0.11    2,080
  s01    -0.393              0.09    1,180
  s07    -0.367              0.13    2,510
  s14    +0.360              0.24    1,030

  largest contributor to heterogeneity: s16 (Q share 8.21)
  [figure: Baujat: which studies matter, and for which reason]


========================================================================================
WHAT IT ACTUALLY SHOWED
========================================================================================
  Reported as one number this literature says the nudge works: 0.17, interval
  comfortably clear of zero. Reported honestly it says something more useful and less
  quotable.

  The studies genuinely disagree — 54% of the variation is real rather than sampling —
  effect size tracks precision in a way consistent with publication bias (p = 0.0012),
  and study twenty-one could land anywhere in [0.04, 0.31]. The bottom of that range is
  about a quarter of the pooled mean and small enough that a replication powered off
  0.17 would miss it.

  None of that needed a different estimator. It needed the three quantities that usually
  end up in an appendix: heterogeneity, the prediction interval, and a small-study check
  — plus two plots, because I-squared and an Egger p-value are summaries of pictures and
  the pictures are where the shape is.