Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enforce positivity on sigmas in SEM model #783

Merged
merged 3 commits into from
Apr 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3,736 changes: 1,812 additions & 1,924 deletions examples/case_studies/CFA_SEM.ipynb

Large diffs are not rendered by default.

89 changes: 57 additions & 32 deletions examples/case_studies/CFA_SEM.myst.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ kernelspec:

> "Evidently, the notions of relevance and dependence are far more basic to human reasoning than the numerical values attached to probability judgments...the language used for representing probabilistic information should allow assertions about dependency relationships to be expressed qualitatively, directly, and explicitly" - Pearl in _Probabilistic Reasoning in Intelligent Systems_ {cite:t}`pearl1985prob`

Measurement data is psychometrics is often derived from a strategically constructed survey aimed at a particular target phenomena. Some intuited, but not yet measured, concept that arguably plays a determining role in human action, motivation or sentiment. The relative “fuzziness” of the subject matter in psychometrics has had a catalyzing effect on the methodological rigour sought in the science.
Measurement data in psychometrics is often derived from a strategically constructed survey aimed at a particular target phenomena. Some intuited, but not yet measured, concept that arguably plays a determining role in human action, motivation or sentiment. The relative “fuzziness” of the subject matter in psychometrics has had a catalyzing effect on the methodological rigour sought in the science.

Survey designs are agonized over for correct tone and rhythm of sentence structure. Measurement scales are doubly checked for reliability and correctness. The literature is consulted and questions are refined. Analysis steps are justified and tested under a wealth of modelling routines. Model architectures are defined and refined to better express the hypothesized structures in the data-generating process. We will see how such due diligence leads to powerful and expressive models that grant us tractability on thorny questions of human affect.

Expand Down Expand Up @@ -96,7 +96,7 @@ A measurement model is a key component within the more general structural equati

We'll start by fitting a "simple" CFA model in `PyMC` to demonstrate how the pieces fit together, we'll then expand our focus. Here we ignore the majority of our indicator variables and focus on the idea that there are two latent constructs: (1) Social Self-efficacy and (2) Life Satisfaction.

We're aiming to articulate a mathematical structure where our indicator variables $x_{ij}$ are determined by a latent factor $\text{Ksi}_{j}$ through an estimated factor loading $\lambda_{ij}$. Functionally we have a set of equations with error terms $\psi_i$ for each individual.
We're aiming to articulate a mathematical structure where our indicator variables $x_{ij}$ are determined by a latent factor $\text{Ksi}_{j}$ through an estimated factor loading $\lambda_{ij}$. We keep close to the notation used in Levy and Mislevy's Bayesian Psychometric Modelling. Functionally we have a set of equations with error terms $\psi_i$ for each individual.

$$ x_{1} = \tau_{1} + \lambda_{11}\text{Ksi}_{1} + \psi_{1} \\
x_{2} = \tau_{2} + \lambda_{21}\text{Ksi}_{1} + \psi_{2} \\
Expand All @@ -108,7 +108,7 @@ or more compactly

$$ \mathbf{x} = \tau + \Lambda\text{Ksi} + \Psi $$

The goal is to articulate the relationship between the different factors in terms of the covariances between these latent terms and estimate the relationships each latent factor has with the manifest indicator variables. At a high level, we're saying the joint distribution of the observed data can be represented through conditionalisation in the following schema.
We have greek letters to highlight traditional model parameters and use $\text{Ksi}$ to highlight latent constructs as a distinct kind of parameter. The goal is to articulate the relationship between the different factors in terms of the covariances between these latent terms and estimate the relationships each latent factor has with the manifest indicator variables. At a high level, we're saying the joint distribution of the observed data can be represented through conditionalisation in the following schema.

$$p(\mathbf{x_{i}}^{T}.....\mathbf{x_{q}}^{T} | \text{Ksi}, \Psi, \tau, \Lambda) \sim Normal(\tau + \Lambda\cdot \text{Ksi}, \Psi) $$

Expand Down Expand Up @@ -190,7 +190,7 @@ We can now see how the covariance structure among the latent constructs is integ
az.summary(idata, var_names=["lambdas1", "lambdas2"])
```

These factor loadings are generally important to interpret in terms of construct validity. Because we've specified one of the indicator variables to be fixed at 1, the other indicators which load on that factor should have a loading coefficient in broadly the same scale as the fixed point indicator that defines the construct scale. We're looking for consistency among the loadings to assess whether the indicators are reliable measures of the construct i.e. if the indicator loadings deviates too far from unit 1 then there is an argument to be made that these indicators don't belong in the same factor construct.
These factor loadings are generally important to interpret in terms of construct validity. Because we've specified one of the indicator variables to be fixed at 1, the other indicators which load on that factor should have a loading coefficient in broadly the same scale as the fixed point indicator that defines the construct scale. We're looking for consistency among the loadings to assess whether the indicators are reliable measures of the construct i.e. if the indicator loadings deviates too far from unit 1 then there is an argument to be made that these indicators don't belong in the same factor construct. The closer the factor loading parameters within a construct are to 1 the better.

```{code-cell} ipython3
idata
Expand All @@ -202,6 +202,18 @@ Let's plot the trace diagnostics to validate the sampler has converged well to t
az.plot_trace(idata, var_names=["lambdas1", "lambdas2", "tau", "Psi", "ksi"]);
```

We also examine the energy plot to assess sampling diagnostics. This looks quite healthy.

```{code-cell} ipython3
fig, axs = plt.subplots(1, 2, figsize=(20, 7))
axs = axs.flatten()
az.plot_energy(idata, ax=axs[0])
az.plot_forest(idata, var_names=["lambdas1", "lambdas2"], combined=True, ax=axs[1])
axs[1].set_title("Factor Loading Estimates")
axs[0].set_title("Energy Plot")
axs[1].axvline(1, color="black");
```

### Sampling the Latent Constructs

One thing to highlight in particular about the Bayesian manner of fitting CFA and SEM models is that we now have access to the posterior distribution of the latent quantities. These samples can offer insight into particular individuals in our survey that is harder to glean from the multivariate presentation of the manifest variables.
Expand Down Expand Up @@ -282,7 +294,7 @@ Which shows a relatively sound recovery of the observed data.

### Intermediate Cross-Loading Model

The idea of a measurement model is maybe a little opaque when we only see models that fit well. Instead we want to briefly show how an in-apt measurement model gets reflected in the estimated parameters for the factor loadings. Here we specify a measurement model which attempts to couple the `se_social` and `sup_parents` indicators and bundle them into the same factor.
The idea of a measurement model is maybe a little opaque when we only see models that fit well. Instead we want to briefly show how an inappropriate measurement model gets reflected in the estimated parameters for the factor loadings. Here we specify a measurement model which attempts to couple the `se_social` and `sup_parents` indicators and bundle them into the same factor.

```{code-cell} ipython3
coords = {
Expand Down Expand Up @@ -387,7 +399,10 @@ This is similarly reflected in the diagnostic energy plots here too.
fig, axs = plt.subplots(1, 2, figsize=(20, 9))
axs = axs.flatten()
az.plot_energy(idata, ax=axs[0])
az.plot_forest(idata, var_names=["lambdas1"], combined=True, ax=axs[1]);
az.plot_forest(idata, var_names=["lambdas1", "lambdas2"], combined=True, ax=axs[1])
axs[1].axvline(1, color="black")
axs[1].set_title("Factor Loadings Estimates")
axs[0].set_title("Energy Plot");
```

This hints at a variety of measurement model misspecification and should force us back to the drawing board. An appropriate measurement model maps the indicator variables to a consistently defined latent construct that plausibly reflects aspects of the realised indicator metrics.
Expand Down Expand Up @@ -741,7 +756,7 @@ def make_indirect_sem(priors):
obs_idx = list(range(len(df)))
with pm.Model(coords=coords) as model:

Psi = pm.InverseGamma("Psi", 5, 10, dims="indicators")
Psi = pm.Gamma("Psi", priors["gamma"], 0.5, dims="indicators")
lambdas_ = pm.Normal(
"lambdas_1", priors["lambda"][0], priors["lambda"][1], dims=("indicators_1")
)
Expand Down Expand Up @@ -772,9 +787,8 @@ def make_indirect_sem(priors):
lambdas_5 = pm.Deterministic(
"lambdas5", pt.set_subtensor(lambdas_[0], 1), dims=("indicators_5")
)
tau = pm.Normal("tau", 3, 0.5, dims="indicators")
kappa = 0
sd_dist = pm.Exponential.dist(1.0, shape=2)
sd_dist = pm.Gamma.dist(priors["gamma"], 0.5, shape=2)
chol, _, _ = pm.LKJCholeskyCov(
"chol_cov", n=2, eta=priors["eta"], sd_dist=sd_dist, compute_corr=True
)
Expand All @@ -784,25 +798,25 @@ def make_indirect_sem(priors):
# Regression Components
beta_r = pm.Normal("beta_r", 0, priors["beta_r"], dims="latent_regression")
beta_r2 = pm.Normal("beta_r2", 0, priors["beta_r2"], dims="regression")
sd_dist1 = pm.Exponential.dist(1.0, shape=2)
sd_dist1 = pm.Gamma.dist(1, 0.5, shape=2)
resid_chol, _, _ = pm.LKJCholeskyCov(
"resid_chol", n=2, eta=3, sd_dist=sd_dist1, compute_corr=True
)
_ = pm.Deterministic("resid_cov", chol.dot(chol.T))
sigmas_resid = pm.MvNormal("sigmas_resid", kappa, chol=resid_chol)
_ = pm.Deterministic("resid_cov", resid_chol.dot(resid_chol.T))
sigmas_resid = pm.MvNormal("sigmas_resid", 1, chol=resid_chol)
sigma_regr = pm.HalfNormal("sigma_regr", 1)

# SE_ACAD ~ SUP_FRIENDS + SUP_PARENTS
regression_se_acad = pm.Normal(
"regr_se_acad",
beta_r[0] * ksi[obs_idx, 0] + beta_r[1] * ksi[obs_idx, 1],
sigmas_resid[0],
pm.math.abs(sigmas_resid[0]), # ensuring positivity
)
# SE_SOCIAL ~ SUP_FRIENDS + SUP_PARENTS

regression_se_social = pm.Normal(
"regr_se_social",
beta_r[2] * ksi[obs_idx, 0] + beta_r[3] * ksi[obs_idx, 1],
sigmas_resid[1],
pm.math.abs(sigmas_resid[1]), # ensuring positivity
)

# LS ~ SE_ACAD + SE_SOCIAL + SUP_FRIEND + SUP_PARENTS
Expand All @@ -812,9 +826,10 @@ def make_indirect_sem(priors):
+ beta_r2[1] * regression_se_social
+ beta_r2[2] * ksi[obs_idx, 0]
+ beta_r2[3] * ksi[obs_idx, 1],
1,
sigma_regr,
)

tau = pm.Normal("tau", 3, 0.5, dims="indicators")
m0 = tau[0] + regression_se_acad * lambdas_1[0]
m1 = tau[1] + regression_se_acad * lambdas_1[1]
m2 = tau[2] + regression_se_acad * lambdas_1[2]
Expand All @@ -834,30 +849,36 @@ def make_indirect_sem(priors):
mu = pm.Deterministic(
"mu", pm.math.stack([m0, m1, m2, m3, m4, m5, m6, m7, m8, m9, m10, m11, m12, m13, m14]).T
)
# independent_residuals_cov = pm.Deterministic('cov_residuals', pt.diag(Psi))
# _ = pm.MvNormal('likelihood', mu, independent_residuals_cov, observed=df[drivers].values)
_ = pm.Normal("likelihood", mu, Psi, observed=df[drivers].values)

idata = pm.sample(
10_000,
chains=4,
nuts_sampler="numpyro",
target_accept=0.99,
tune=2000,
idata_kwargs={"log_likelihood": True},
random_seed=110,
idata = pm.sample_prior_predictive()
idata.extend(
pm.sample(
2000,
chains=4,
nuts_sampler="numpyro",
target_accept=0.99,
tune=1000,
idata_kwargs={"log_likelihood": True},
nuts_sampler_kwargs={"chain_method": "vectorized"},
random_seed=110,
)
)
idata.extend(pm.sample_posterior_predictive(idata))

return model, idata


model_sem0, idata_sem0 = make_indirect_sem(
priors={"eta": 2, "lambda": [1, 1], "beta_r": 0.1, "beta_r2": 0.1}
priors={"eta": 1, "lambda": [1, 2], "beta_r": 3, "beta_r2": 3, "gamma": 1},
)
model_sem1, idata_sem1 = make_indirect_sem(
priors={"eta": 2, "lambda": [1, 1], "beta_r": 0.2, "beta_r2": 0.2}
priors={"eta": 3, "lambda": [1, 2], "beta_r": 2, "beta_r2": 2, "gamma": 2}
)
model_sem2, idata_sem2 = make_indirect_sem(
priors={"eta": 2, "lambda": [1, 1], "beta_r": 0.5, "beta_r2": 0.5}
priors={"eta": 1, "lambda": [1, 2], "beta_r": 10, "beta_r2": 10, "gamma": 3}
)
```

Expand All @@ -880,11 +901,15 @@ Residual covariances
SE_Academic ~~ SE_Social
```

+++

Often you will see SEM models with a multivariate normal likelihood term, but here we've specified independent Normal distributions as the model doesn't call for a richly structured covariance matrix on the residuals terms. More complicated models are possible, but it's always a question of how much structure is needed?

```{code-cell} ipython3
pm.model_to_graphviz(model_sem0)
```

It's worth pausing to examine the nature of the dependencies sketched in this diagram. We can see here how we've replaced the simpler measurement model structure and added three regression functions that replace the draws from the multivariate normal $Ksi$. In other words we've expressed a dependency as a series of regressions all within the one model. Next we'll see how the parameter estimates change across our prior specifications for the model. Notice the relative stability of the factor loadings compared to the regression coefficients.
It's worth pausing to examine the nature of the dependencies sketched in this diagram. We can see here how we've replaced the simpler measurement model structure and added three regression functions that replace the draws from the multivariate normal $Ksi$. In other words we've expressed a dependency as a series of regressions all within the one model. Next we'll see how the parameter estimates change across our prior specifications for the model. Notice the relative stability of the factor loadings and regression coefficients indicating a robustness in these parameter estimates.

```{code-cell} ipython3
fig, ax = plt.subplots(figsize=(15, 15))
Expand Down Expand Up @@ -949,7 +974,7 @@ Similar diagnostic results hold for the other models. We now continue to assess

### Indirect and Direct Effects

We now turn the additional regression structures that we've encoded into the model graph. First we pull out the regression coefficients
We now turn to the additional regression structures that we've encoded into the model graph. First we pull out the regression coefficients

```{code-cell} ipython3
fig, axs = plt.subplots(1, 2, figsize=(20, 8))
Expand All @@ -961,7 +986,7 @@ axs[1].axvline(0, color="red", label="zero-effect")
axs[1].legend();
```

The coefficients indicate a smaller relative weight accorded to the effects of peer support than we see with parental support. This is borne out as we trace out the cumulative causal effects (direct and indirect) through our DAG or chain of regression coefficients.
The coefficients indicate a strong effect of social self-efficacy on life satisfaction, and smaller relative weight accorded to the effects of peer support than we see with parental support. This is borne out as we trace out the cumulative causal effects (direct and indirect) through our DAG or chain of regression coefficients.

```{code-cell} ipython3
def calculate_effects(idata, var="SUP_P"):
Expand Down Expand Up @@ -996,7 +1021,7 @@ def calculate_effects(idata, var="SUP_P"):
)
```

Importantly we see here the effect of priors on the implied relationships. As we pull our priors closer to 0 the total effects of parental support is pulled downwards away from .5, while the peer support remains relatively stable ~.10. However it remains clear that the impact of parental support dwarfs the effects due to peer support.
It remains clear that the impact of parental support dwarfs the effects due to peer support.

```{code-cell} ipython3
summary_p = pd.concat(
Expand All @@ -1007,7 +1032,7 @@ summary_p.index = ["SEM0", "SEM1", "SEM2"]
summary_p
```

The sensitivity of the estimated impact due to parental support varies strongly as a function of our prior on the variances. Here is a substantive example of the role of theory choice in model design. How strongly should believe that parental and peer effects have 0 effect on life-satisfaction? I'm inclined to believe we're too conservative if we try and shrink the effect toward zero and should prefer a less conservative model. However, the example here is not to dispute the issue, but demonstrate the importance of sensitivity checks.
The sensitivity of the estimated impact due to parental support does not vary strongly as a function of our prior on the variances. However, the example here is not to dispute the issue at hand, but highlight the importance of sensitivity checks. We will not always find consistency of parameter identification across model specifications.

```{code-cell} ipython3
summary_f = pd.concat(
Expand Down