Skip to contents

Why a distributional piecewise SEM?

Most structural equation modelling treats a path X -> Y as a statement about the expected value of Y: increase X, and the mean of Y shifts. That is only one of the things a predictor can do. A predictor can also change how variable a response is, how over-dispersed it is, how often it is a structural zero, or how strongly two responses co-vary in their residuals.

drmSEM makes those targets first-class — in the distributional-regression tradition of Rigby and Stasinopoulos (2005) and Brooks et al. (2017) — while keeping the piecewise d-separation framework of Shipley (2009) and Lefcheck (2016). It is the SEM / graph / d-separation / path / effect-decomposition layer; the drmTMB package is the fitting engine. Each endogenous node is exactly one drmTMB fit, the system is piecewise, and the graph must be a DAG. A causal path is component-labelled: it targets one modelled distributional component of a node:

Component Symbol What a path to it means
mean mu shifts the expected response
scale sigma changes residual scale / dispersion, not the mean
shape nu changes a shape parameter (e.g. tail/skew)
zero-infl. zi changes the probability of a structural zero
hurdle hu changes the probability of crossing the hurdle
RE scale sd(group) changes among-group heterogeneity
residual r rho12 changes the residual correlation between the two responses of a bivariate node

The cardinal rule, enforced throughout the package: a path to a non-mu component is never reported as a mean effect. A temp -> sigma(size) path is a statement about spread, and drmSEM keeps it labelled as such everywhere – in paths(), in plot(), and in the effect decomposition.

A rho12 target is the residual correlation between two responses of a bivariate node (eps_y1 <-> eps_y2); a path rho12 ~ x says x changes that residual coupling – it is not a mean effect and not a directed y1 -> y2 arrow. Targeting rho12 requires a bivariate drmTMB fit (e.g. family = c(gaussian(), gaussian()), with components mu1/mu2/sigma1/sigma2/rho12) supplied via drm_psem(); it is then extracted and labelled like any other component path. The covariance-edge grammar now ships: covary() declares a residual or higher-level covariance edge, covariances() reports those edges separately from paths(), and d-separation is aware of covarying responses (dropping the y1 _||_ y2 claim). The bivariate-node declaration grammar now also ships: drm_pair() declares a joint two-response node, rho12() / corpairs() report the declared edges, and plot() draws them as double-headed (residual) / dashed (higher-level) arcs. Only the joint bivariate fit that reads a fitted rho12 / corpair correlation back (a non-NA estimate) remains the 0.4 engine step (see docs/design/07-bivariate-covariance-edges.md).

A worked system: size -> abundance -> survival

The biological question. Body size, local abundance, and survival form a causal chain, and temperature (temp) is the exogenous driver. We want to answer one question end to end: does warming reach survival only by shifting the average size and abundance, or also by changing how variable size is and how often abundance collapses to a structural zero? The second half of that question is about non-mean components (sigma, zi), which a mean-only SEM cannot represent. By the end of this vignette we will read the answer directly off the distribution_mediated row of indirect_effects().

We use a three-node ecological chain. Conceptually:

  • size of organisms depends on temperature and habitat. We also let temperature act on the scale of size: sigma(size) ~ temp. Equation: size ~ Normal(mu = b0 + b1*temp + b2*habitat, sigma = exp(g0 + g1*temp)).
  • abundance is a count depending on size and temp, modelled with a negative binomial, with habitat acting on zero-inflation: zi(abundance) ~ habitat. Equation (link scale): log(mu) = a0 + a1*size + a2*temp, logit(zi) = c0 + c1*habitat.
  • survival is a binomial proportion (cbind(alive, dead)) depending on abundance and size, with a beta-binomial family to absorb extra-binomial variation: logit(mu) = d0 + d1*abundance + d2*size.

Temperature is exogenous and reaches survival only indirectly – through the mean, the scale, and the zero-inflation of the intermediate nodes. That is exactly the situation where coefficient-product mediation breaks down and a distribution-aware, simulation-based decomposition is required.

Simulate the data

# This chunk uses only base R, so it always runs.
set.seed(1)
n <- 400L
habitat <- factor(sample(c("A", "B"), n, replace = TRUE))
temp <- rnorm(n)

# size: mean depends on temp + habitat; SCALE (sigma) depends on temp.
mu_size <- 2 + 0.8 * temp + 0.5 * (habitat == "B")
sd_size <- exp(-0.2 + 0.4 * temp)            # temp -> sigma(size)
size <- rnorm(n, mu_size, sd_size)

# abundance: NB mean depends on size + temp; ZI depends on habitat.
mu_ab <- exp(0.5 + 0.3 * size + 0.2 * temp)
zi_ab <- plogis(-1 + 1.2 * (habitat == "B"))  # habitat -> zi(abundance)
abundance <- ifelse(rbinom(n, 1, zi_ab) == 1, 0,
                    rnbinom(n, mu = mu_ab, size = 2))

# survival: binomial proportion out of a fixed number of trials.
trials <- 20L
p_surv <- plogis(-1 + 0.05 * abundance + 0.15 * size)
alive <- rbinom(n, trials, p_surv)
dead <- trials - alive

dat <- data.frame(temp, habitat, size, abundance, alive, dead)
head(dat)
#>         temp habitat      size abundance alive dead
#> 1  0.4094018       A 1.9985932         0     6   14
#> 2  1.6888733       B 6.2683461        28    15    5
#> 3  1.5865884       A 4.0851891         0     3   17
#> 4 -0.3309078       A 2.1241479         3     8   12
#> 5 -2.2852355       B 0.6269537         0     7   13
#> 6  2.4976616       A 1.4706468         9     8   12

The data-generating process puts a real signal into a non-mean component of two different nodes (sigma(size) and zi(abundance)), which is what we want the SEM to recover and propagate.

Build the SEM

drm_sem() takes one named drm_node() per endogenous response and a shared data frame. Each drm_node() wraps a drmTMB::bf() formula (which can carry component sub-formulas like sigma ~ temp or zi ~ habitat) and a family.

sem <- drm_sem(
  size      = drm_node(drmTMB::bf(size ~ temp + habitat, sigma ~ temp),
                       family = stats::gaussian()),
  abundance = drm_node(drmTMB::bf(abundance ~ size + temp, zi ~ habitat),
                       family = drmTMB::nbinom2()),
  survival  = drm_node(drmTMB::bf(cbind(alive, dead) ~ abundance + size),
                       family = drmTMB::beta_binomial()),
  data = dat
)

If you have already fitted nodes yourself, drm_psem() assembles a SEM from existing drmTMB objects instead of fitting internally; both routes produce the same object.

Inspect the component-labelled paths

paths() returns one row per fitted fixed-effect coefficient across all nodes, each tagged with the component it targets and the link on which it acts. This is where the distributional structure becomes legible: the temp path into size appears twice – once on mu, once on sigma – and they are not the same claim.

paths(sem)

Each row is one fitted fixed-effect coefficient, with columns from, to, component, link, term, estimate, std.error, statistic, p.value, and endogenous. The component column is what makes the table distributional: the same predictor can appear against mu, sigma, zi, and so on, and each is a different scientific claim. The structure looks like this (column shape is real; the numbers are illustrative, not estimated, since the engine is not run here):

#>        from        to component     link      term
#> 1      temp      size        mu identity      temp
#> 2   habitat      size        mu identity  habitatB
#> 3      temp      size     sigma      log      temp
#> 4      size abundance        mu      log      size
#> 5      temp abundance        mu      log      temp
#> 6   habitat abundance        zi    logit  habitatB
#> 7 abundance  survival        mu    logit abundance
#> 8      size  survival        mu    logit      size

Note how temp -> size appears twice – once on mu (does warming shift mean size?) and once on sigma (does warming change how variable size is?) – and habitat -> abundance lands on zi, the probability of a structural zero, not on the mean count. These are the rows a mean-only SEM cannot express.

check_sem() reports, per node, the family, the modelled components, convergence, whether a fixed-effect covariance is available (needed for d-separation and effect intervals), and whether a realized-value sampler exists (needed for fully distribution-mediated effects).

See the DAG

plot() draws the graph with component-labelled edges, so a sigma or zi arrow is visually distinct from a mu arrow rather than collapsed into a generic “effect”.

plot(sem)

d-separation and Fisher’s C

A piecewise SEM is tested by checking the independence claims the DAG implies (Pearl 2009; Shipley 2000, 2009): every pair of non-adjacent nodes should be conditionally independent given their parents. basis_set() lists those claims.

In drmSEM, d-separation uses the any-component LRT. A missing arrow X -> Y asserts that X has no effect on any modelled component of Y. We test it by refitting Y’s node augmented with X in each component sub-model and comparing to the baseline by a likelihood-ratio test:

LRT = 2 * (logLik(augmented) - logLik(baseline)), distributed (asymptotically) as chi-squared with degrees of freedom equal to the number of added coefficients across components. dsep() runs this for every claim in the basis set.

dsep(sem)

fisher_c() combines the per-claim p-values into Fisher’s C statistic, C = -2 * sum(log(p_i)), with 2k degrees of freedom for k claims. A small p-value flags a missing path – including a missing path into a non-mean component, which a mean-only SEM tool could never detect.

Decomposing effects by simulation

Coefficient products are not a valid way to combine paths across non-Gaussian links or across distributional components. drmSEM therefore computes effects by Monte-Carlo propagation over the fitted DAG — the counterfactual, do()-style framework of Pearl (2001), Pearl (2009) and Imai et al. (2010): it sets the exogenous predictor to two contrasting values, pushes the change through every downstream node, and reads the conditional (RE = 0) response-scale change in the mean of the target.

Direct (controlled) effect. Hold all mediators at their observed values; move only temp. Only the arrows that go directly into survival operate.

direct_effects(sem, from = "temp", to = "survival")

Total effect. Let every mediator respond. With method = "simulate", mediators pass realized draws from their fitted families, so effects flowing through a mediator’s sigma, zi, or nu are included. With method = "gcomp" (the default), only mediator means propagate.

total_effects(sem, from = "temp", to = "survival", method = "simulate")

Indirect effect with a distributional decomposition. This is the heart of the package. indirect_effects() reports five quantities:

  • total_path – the full simulated effect through the chosen mediators;
  • direct – the controlled direct effect;
  • indirecttotal_path - direct;
  • mean_mediated – the part carried by mediator means;
  • distribution_mediated – the extra effect that appears only when mediators pass realized draws, i.e. the part flowing through scale, zero-inflation, or shape (total under distribution minus total under mean).
indirect_effects(sem, from = "temp", to = "survival")

The result is one row per quantity, each an effect on the response (probability-of-survival) scale, with an estimate and a confidence interval. The shape – and how to read it – looks like this (illustrative numbers, not estimated here):

#>   from       to              quantity estimate conf.low conf.high
#> 1 temp survival            total_path    0.090    0.052     0.128
#> 2 temp survival                direct    0.020    0.004     0.036
#> 3 temp survival              indirect    0.070    0.038     0.102
#> 4 temp survival         mean_mediated    0.048    0.022     0.074
#> 5 temp survival distribution_mediated    0.022    0.006     0.038

Read it top-down. total_path is the whole effect of warming on survival; direct is the slice that does not go through the mediators; indirect is the rest (total_path - direct). The indirect slice then splits into mean_mediated (carried by the means of size and abundance) and distribution_mediated – and indirect ≈ mean_mediated + distribution_mediated.

That last row is the answer to the question we posed at the start. A non-trivial distribution_mediated row is the signature of this framework: temperature reaches survival partly because it changes the spread of size and (via habitat structure) the zeros of abundance, not only their means. A mean-only SEM would report that channel as zero or silently fold it into the mean. drmSEM keeps it visible and correctly labelled. (plot() on an effect object draws the same decomposition as a forest plot.)

Standardizing

When you need comparable path strengths, standardize() returns the paths() table with one added column, std.estimate (on the link scale), while preserving the component labels. The default method = "sd_x" scales each coefficient by the standard deviation of its predictor; method = "latent" additionally divides by the fitted standard deviation of the response, giving fully standardized coefficients.

standardize(sem)                      # adds std.estimate (sd_x scaling)
standardize(sem, method = "latent")   # fully standardized

Recap

  • drmSEM is a layer on the drmTMB engine; one drmTMB fit per endogenous node, DAG-only, observed variables only.
  • Paths are component-labelled (mu, sigma, nu, zi, hu, sd(group), rho12); a non-mu path is never a mean effect.
  • d-separation is the any-component LRT, combined by Fisher’s C.
  • Direct / indirect / total effects are simulation-based, never coefficient products, and the indirect decomposition exposes distribution-mediated pathways explicitly.

For the environment and CI setup that compiles and installs the drmTMB engine, see CLOUD.md; for the operating contract, see AGENTS.md.

References

Brooks, Mollie E., Kasper Kristensen, Koen J. van Benthem, et al. 2017. glmmTMB Balances Speed and Flexibility Among Packages for Zero-Inflated Generalized Linear Mixed Models.” The R Journal 9 (2): 378–400. https://doi.org/10.32614/RJ-2017-066.
Imai, Kosuke, Luke Keele, and Teppei Yamamoto. 2010. “Identification, Inference and Sensitivity Analysis for Causal Mediation Effects.” Statistical Science 25 (1): 51–71. https://doi.org/10.1214/10-STS321.
Lefcheck, Jonathan S. 2016. piecewiseSEM: Piecewise Structural Equation Modelling in R for Ecology, Evolution, and Systematics.” Methods in Ecology and Evolution 7 (5): 573–79. https://doi.org/10.1111/2041-210X.12512.
Pearl, Judea. 2001. “Direct and Indirect Effects.” Proceedings of the Seventeenth Conference on Uncertainty in Artificial Intelligence (UAI), 411–20.
Pearl, Judea. 2009. Causality: Models, Reasoning, and Inference. 2nd ed. Cambridge University Press.
Rigby, R. A., and D. M. Stasinopoulos. 2005. “Generalized Additive Models for Location, Scale and Shape.” Journal of the Royal Statistical Society Series C: Applied Statistics 54 (3): 507–54. https://doi.org/10.1111/j.1467-9876.2005.00510.x.
Shipley, Bill. 2000. “A New Inferential Test for Path Models Based on Directed Acyclic Graphs.” Structural Equation Modeling 7 (2): 206–18. https://doi.org/10.1207/S15328007SEM0702_4.
Shipley, Bill. 2009. “Confirmatory Path Analysis in a Generalized Multilevel Context.” Ecology 90 (2): 363–68. https://doi.org/10.1890/08-1034.1.