Meta-regression

Meta-regression relates study effect estimates to one or more study-level moderators. It is a natural extension of subgroup analysis, but it does not turn aggregate study data into individual-level evidence.

Interpretation boundary

A Meta-regression coefficient is a study-level association. It may reflect ecological bias, confounding, measurement differences, or post-hoc model selection and must not be interpreted as an individual-level causal effect. Fits with fewer than ten studies carry an explicit warning.

Fit a numeric moderator

Provide a generic effect plus exactly one of its sampling variance or standard error:

import pandas as pd
import meta_analyze as ma

studies = pd.DataFrame(
    {
        "citation": ["A", "B", "C", "D", "E", "F"],
        "effect": [0.12, 0.25, 0.41, 0.38, 0.62, 0.76],
        "se": [0.18, 0.20, 0.17, 0.23, 0.19, 0.21],
        "mean_age": [42.0, 48.0, 51.0, 55.0, 60.0, 64.0],
    }
)

result = ma.meta_regression(
    studies,
    effect="effect",
    standard_error="se",
    moderators=["mean_age"],
    study="citation",
    model="mixed",
    tau2_method="REML",
)

print(result.summary())
print(result.coefficients)
numeric_result = result

Numeric moderators are used exactly as supplied. PyMetaAnalysis does not center, scale, transform, or impute them automatically. The numerical solver uses an internal power-of-two column scaling and converts coefficients and covariance back to the supplied units; this does not change the model or the reported design matrix.

Encode categorical moderators explicitly

Every categorical moderator requires an ordered, complete list of levels. The first level is the treatment-coding reference:

studies = studies.assign(
    region=["Europe", "Asia", "North America", "Europe", "Asia", "North America"]
)
result = ma.meta_regression(
    studies,
    effect="effect",
    standard_error="se",
    moderators=["mean_age", "region"],
    categorical={
        "region": ["Europe", "Asia", "North America"],
    },
)

This produces terms such as region[Asia] and region[North America], each relative to Europe. The reference never depends on row order. Undeclared levels, levels absent after exclusions, and string moderators omitted from categorical= are errors rather than implicit recoding decisions. Categorical matching keeps booleans distinct from numeric levels. Python and NumPy integers are equivalent, and integer-valued floats such as 1.0 match the corresponding integer level 1. This accommodates pandas integer columns that become floating point after introducing a missing value. Non-integer floats such as 1.5 do not match integer levels.

Formula parsing, automatic interactions, splines, and polynomial terms are not implemented. Construct those columns explicitly before fitting when they are scientifically prespecified.

Array-like input

Use a mapping when moderators are arrays rather than DataFrame column names:

array_result = ma.meta_regression(
    effect=[0.10, 0.32, 0.45, 0.71],
    variance=[0.04, 0.05, 0.06, 0.08],
    moderators={"dose": [0.0, 1.0, 2.0, 3.0]},
    model="common",
)

A sequence such as moderators=["mean_age", "region"] is only meaningful with a DataFrame. A mapping may mix DataFrame column selectors and array-like values, provided every input has one value per row.

Models and inference

model="mixed" is the default. It estimates residual tau-squared after the moderators using REML, PM, or DL. model="common" fixes residual tau-squared at zero and supports normal inference only.

Setting Coefficient tests and intervals Joint moderator test
normal z / normal chi-squared
hartung_knapp t with k-p df F with k-p denominator df
hartung_knapp_adhoc safeguarded t with k-p df safeguarded F

The unmodified Hartung-Knapp result warns when its covariance is below the classic covariance. The adhoc choice explicitly applies a lower-bound scale of one. Common-effect models reject both Hartung-Knapp choices.

The design matrix must be full rank and leave positive residual degrees of freedom (k > p). PyMetaAnalysis does not silently drop collinear terms or use a pseudo-inverse.

Inspect the result

Meta-regression has no single pooled effect, so MetaRegressionResult does not provide a scalar estimate or ci. Inspect its coefficient table instead:

result.coefficients
result.coefficient_covariance
result.global_test
result.test_moderator("region")
result.heterogeneity
result.tau2
result.tau2_null
result.pseudo_r2
result.diagnostics

global_test tests all non-intercept terms. test_moderator(name) tests every encoded term belonging to that original moderator, so a multi-level category receives one joint test rather than separate interpretation through dummy-term p-values.

study_results retains every input row and contains fitted values, residuals, precision weights, normalized precision weights, and leverage. A regression precision weight is not a universal percentage contribution to every coefficient.

Leave-one-out sensitivity

Use exact deleted-model refits to inspect dependence on individual studies:

diagnostics = result.leave_one_out()

print(diagnostics.table)
print(diagnostics.coefficients)

Every successful deletion re-estimates residual tau-squared, coefficients, and inference with the same resolved model configuration. Deletions that make the design unidentifiable are retained explicitly instead of aborting the other refits. See sensitivity analysis for the result contract and interpretation limits.

Predict at moderator values

Prediction replays the fitted numeric and categorical encoding:

predictions = result.predict(
    pd.DataFrame(
        {
            "mean_age": [50.0, 65.0],
            "region": ["Europe", "Asia"],
        }
    )
)

Every model returns the fitted mean and its confidence interval. Mixed-effects models also return pi_low and pi_high for the distribution of true effects in a new study with those moderators. The interval does not include an additional, unknown sampling variance for a future observed estimate. Unknown categories and missing prediction inputs are rejected.

The default prediction rule follows coefficient inference: it uses a normal critical value with inference_method="normal" and t_(k-p) with either Hartung-Knapp method. Set prediction_interval_method="riley" while fitting to use a t_(k-p-1) prediction critical value instead. Riley therefore needs at least two residual degrees of freedom and is usually wider, especially in small samples. It is an explicit alternative approximation, not an automatic claim of superior coverage. The selected canonical method is retained in result.method.prediction_interval_method and reused by predict() and bubble().

Plot a single numeric moderator

After installing the plot extra, an intercept-containing model with exactly one numeric moderator can be displayed as a weighted bubble plot:

ax = numeric_result.bubble(
    moderator_label="Mean age (years)",
    effect_label="Treatment effect",
    show_confidence_interval=True,
    show_prediction_interval=True,
)

Bubble area is proportional to normalized fitted precision weight. The fitted line and interval bands reuse result.predict(), including the selected coefficient covariance and prediction-interval rule. A prediction band is available only for mixed-effects models.

PyMetaAnalysis rejects bubble plots for categorical, multiple-moderator, or no-intercept fits. Drawing a marginal or partial-effect line for those models requires explicit choices for the other moderator values, and the library does not silently choose them.

Residual heterogeneity and pseudo-R²

The result reports residual QE, I-squared, H-squared, and tau-squared. For an intercept-containing mixed model, PyMetaAnalysis also refits the same included rows without moderators and reports:

pseudo-R² = max(0, 1 - tau²_model / tau²_null)

The raw value is retained as pseudo_r2_raw. If the null-model tau-squared is zero, pseudo-R² is undefined; if moderators increase estimated tau-squared, the public value is truncated to zero and the negative raw value is retained with a warning. Pseudo-R² is not the proportion of outcome variance explained in ordinary individual-level regression.

Inspect deleted-study influence

Use exact deletion diagnostics when a study appears unusual or the fitted association may depend strongly on one row:

influence = result.influence()

influence.table
influence.dfbetas
influence.flagged

Every included study is omitted once, with tau-squared and coefficient inference re-estimated under the original settings. The result reports the externally standardized deleted residual, Cook's distance, and term-specific DFBETAS values. Failed reduced models remain visible rather than being silently dropped.

The result also exposes its numerical screening thresholds. These thresholds identify rows for review; they do not prove that a study is erroneous, authorize automatic exclusion, or correct for trying multiple model specifications. See sensitivity analysis for the full output and interpretation contract.

Inspect moderator collinearity

Use collinearity() to inspect coefficient inflation and the geometry of the fitted weighted design:

collinearity = result.collinearity()

collinearity.term_vif
collinearity.moderator_gvif
collinearity.condition_indices
collinearity.variance_proportions
collinearity.concerning_dimensions

term_vif contains one VIF and its square-root standard-error inflation factor (sif) for every encoded non-intercept term. moderator_gvif keeps all dummy terms for one categorical moderator together and reports its GVIF plus the dimension-adjusted gsif = gvif ** (1 / (2 * term_count)). This grouping makes a multi-level moderator comparable without pretending that each treatment- coded term is a separate scientific variable.

The condition table uses the fitted inverse-variance weights and normalizes each weighted design column before singular-value decomposition. It is therefore invariant to positive changes of moderator units. The result retains the original unweighted, scale-dependent design condition number separately as raw_condition_number.

The fixed references of condition index greater than 30 and variance proportion greater than 0.5 are conventional screening heuristics. A concerning dimension must meet the condition-index reference and concentrate more than half of the coefficient variance for at least two terms. The library does not attach automatic cutoffs to VIF/GVIF, remove variables, refit models, or turn these diagnostics into evidence for a preferred specification.

Test explicit linear contrasts

Inspect result.design_info.term_names, then define a scientific comparison by term name rather than coefficient position:

north_america_vs_asia = result.contrast(
    {
        "region[North America]": 1.0,
        "region[Asia]": -1.0,
    },
    name="North America - Asia",
)

north_america_vs_asia.table
north_america_vs_asia.contrast_matrix
north_america_vs_asia.joint_test

The default null hypothesis is C beta = 0. Use rhs= for a nonzero null:

age_threshold = result.contrast(
    {"mean_age": 1.0},
    name="Age slope equals 0.02",
    rhs=0.02,
)

For several prespecified hypotheses, pass a named mapping of mappings:

contrasts = result.contrast(
    {
        "North America - Asia": {
            "region[North America]": 1.0,
            "region[Asia]": -1.0,
        },
        "Age slope": {"mean_age": 1.0},
    },
    rhs={
        "North America - Asia": 0.0,
        "Age slope": 0.02,
    },
)

A DataFrame is also accepted: rows are named contrasts and columns are fitted term names. Unspecified fitted terms receive weight zero. Unknown terms, nonfinite weights, zero rows, duplicate names, and rank-deficient multi-row contrast matrices are errors rather than implicit repairs.

Each row reports the contrast estimate, standard error, null value, estimate-minus-null, z or t statistic, unadjusted p-value, and confidence interval for C beta. A full-row-rank set also receives a joint chi-squared or F test in joint_test. Individual p-values are deliberately not adjusted for multiple testing; prespecify the contrast family and apply an external multiplicity procedure when the scientific protocol requires one.

Missing values, provenance, and reports

missing="raise" identifies every missing effect, uncertainty, study label, or moderator field by row. missing="drop" excludes the entire row from every model calculation while retaining it in study_results with all applicable reasons.

result.provenance
result.method_details()
result.report().to_json()

Provenance records moderator input roles and categorical treatment coding. Reports include coefficients, their covariance, residual heterogeneity, moderator tests, encoding, diagnostics, row decisions, and the study-level interpretation warning.

See statistical methods for the equations and scope and limitations before using the model for consequential work.