Skip to content

Commit b96311e

Browse files
authored
Build structural equation modeling portfolio project (#1)
Add a reproducible R/lavaan SEM case study with CFA, measurement invariance, latent mediation, diagnostics, documentation, visuals, tests, and GitHub Actions validation.
1 parent 3ce3396 commit b96311e

20 files changed

Lines changed: 971 additions & 2 deletions

.github/workflows/validate.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Validate structural equation modeling project
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
validate:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: r-lib/actions/setup-r@v2
18+
with:
19+
r-version: "release"
20+
- name: Install lavaan
21+
run: Rscript -e 'install.packages("lavaan", repos = "https://cloud.r-project.org")'
22+
- name: Run end-to-end SEM workflow
23+
run: make all
24+
- name: Check required outputs
25+
run: |
26+
test -s outputs/cfa_fit_indices.csv
27+
test -s outputs/measurement_invariance.csv
28+
test -s outputs/sem_fit_indices.csv
29+
test -s outputs/structural_paths.csv
30+
test -s outputs/indirect_effects.csv
31+
test -s outputs/residual_variances.csv

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
data/
2+
artifacts/
3+
.Rhistory
4+
.RData
5+
.Rproj.user/
6+
outputs/*.csv
7+
!outputs/README.md

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Matthew Jeans
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Makefile

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
.PHONY: all data measurement structural diagnostics test clean
2+
3+
all: data measurement structural diagnostics test
4+
5+
data:
6+
Rscript scripts/01_generate_data.R
7+
8+
measurement: data
9+
Rscript scripts/02_measurement_models.R
10+
11+
structural: measurement
12+
Rscript scripts/03_structural_model.R
13+
14+
diagnostics: structural
15+
Rscript scripts/04_diagnostics.R
16+
17+
test:
18+
Rscript tests/test_pipeline.R
19+
20+
clean:
21+
rm -rf data artifacts
22+
rm -f outputs/*.csv

R/data_generation.R

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
clamp <- function(x, lower, upper) {
2+
pmin(pmax(x, lower), upper)
3+
}
4+
5+
make_indicator <- function(latent, loading, intercept = 3) {
6+
residual_sd <- sqrt(1 - loading^2)
7+
clamp(intercept + loading * latent + rnorm(length(latent), 0, residual_sd), 1, 5)
8+
}
9+
10+
generate_sem_data <- function(n = 2400L, seed = 20260813L) {
11+
stopifnot(n >= 600L, n %% 2L == 0L)
12+
set.seed(seed)
13+
14+
grade_band <- rep(c("Middle school", "High school"), each = n / 2L)
15+
high_school <- as.integer(grade_band == "High school")
16+
school_id <- sprintf("SCH-%02d", sample(seq_len(24L), n, replace = TRUE))
17+
18+
baseline_z <- rnorm(n)
19+
support <- rnorm(n, mean = -0.08 * high_school, sd = 1)
20+
engagement <-
21+
0.55 * support + 0.22 * baseline_z - 0.12 * high_school + rnorm(n, 0, 0.72)
22+
confidence <-
23+
0.48 * engagement + 0.18 * support + 0.22 * baseline_z +
24+
0.05 * high_school + rnorm(n, 0, 0.70)
25+
followup_score <-
26+
70 + 7.0 * baseline_z + 4.0 * engagement + 4.5 * confidence +
27+
1.5 * support - 1.0 * high_school + rnorm(n, 0, 5.5)
28+
29+
data <- data.frame(
30+
student_id = sprintf("STU-%05d", seq_len(n)),
31+
school_id = school_id,
32+
grade_band = grade_band,
33+
high_school = high_school,
34+
baseline_z = round(baseline_z, 4),
35+
followup_score = round(followup_score, 2),
36+
support_1 = make_indicator(support, 0.82),
37+
support_2 = make_indicator(support, 0.76),
38+
support_3 = make_indicator(support, 0.72),
39+
support_4 = make_indicator(support, 0.68),
40+
engagement_1 = make_indicator(engagement, 0.84),
41+
engagement_2 = make_indicator(engagement, 0.79),
42+
engagement_3 = make_indicator(engagement, 0.73),
43+
engagement_4 = make_indicator(engagement, 0.69),
44+
confidence_1 = make_indicator(confidence, 0.86),
45+
confidence_2 = make_indicator(confidence, 0.78),
46+
confidence_3 = make_indicator(confidence, 0.71),
47+
stringsAsFactors = FALSE
48+
)
49+
50+
item_names <- grep("^(support|engagement|confidence)_", names(data), value = TRUE)
51+
lower_baseline <- as.integer(data$baseline_z < -0.5)
52+
53+
for (item_index in seq_along(item_names)) {
54+
item <- item_names[[item_index]]
55+
missing_probability <- plogis(
56+
-3.25 + 0.35 * lower_baseline + 0.12 * high_school +
57+
0.05 * ((item_index - 1L) %% 3L)
58+
)
59+
data[[item]][runif(n) < missing_probability] <- NA_real_
60+
}
61+
62+
numeric_items <- c(item_names, "followup_score")
63+
data[numeric_items] <- lapply(data[numeric_items], function(x) round(x, 3))
64+
data
65+
}

R/sem_helpers.R

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
required_fit_measures <- c(
2+
"chisq.scaled", "df.scaled", "pvalue.scaled",
3+
"cfi.scaled", "tli.scaled", "rmsea.scaled",
4+
"rmsea.ci.lower.scaled", "rmsea.ci.upper.scaled", "srmr"
5+
)
6+
7+
fit_summary_row <- function(fit, model_name) {
8+
values <- lavaan::fitMeasures(fit, required_fit_measures)
9+
data.frame(
10+
model = model_name,
11+
chisq = unname(values[["chisq.scaled"]]),
12+
df = unname(values[["df.scaled"]]),
13+
p_value = unname(values[["pvalue.scaled"]]),
14+
cfi = unname(values[["cfi.scaled"]]),
15+
tli = unname(values[["tli.scaled"]]),
16+
rmsea = unname(values[["rmsea.scaled"]]),
17+
rmsea_lower = unname(values[["rmsea.ci.lower.scaled"]]),
18+
rmsea_upper = unname(values[["rmsea.ci.upper.scaled"]]),
19+
srmr = unname(values[["srmr"]]),
20+
stringsAsFactors = FALSE
21+
)
22+
}
23+
24+
round_numeric <- function(data, digits = 3L) {
25+
numeric_columns <- vapply(data, is.numeric, logical(1))
26+
data[numeric_columns] <- lapply(data[numeric_columns], round, digits = digits)
27+
data
28+
}
29+
30+
standardized_loadings <- function(fit) {
31+
estimates <- lavaan::parameterEstimates(fit, standardized = TRUE)
32+
estimates <- estimates[estimates$op == "=~", , drop = FALSE]
33+
round_numeric(estimates[c("lhs", "rhs", "est", "se", "pvalue", "std.all")])
34+
}
35+
36+
composite_reliability <- function(fit) {
37+
standardized <- lavaan::standardizedSolution(fit)
38+
factors <- unique(standardized$lhs[standardized$op == "=~"])
39+
40+
rows <- lapply(factors, function(factor_name) {
41+
loadings <- standardized$est.std[
42+
standardized$op == "=~" & standardized$lhs == factor_name
43+
]
44+
error_variance <- 1 - loadings^2
45+
omega <- sum(loadings)^2 / (sum(loadings)^2 + sum(error_variance))
46+
ave <- sum(loadings^2) / (sum(loadings^2) + sum(error_variance))
47+
data.frame(
48+
factor = factor_name,
49+
indicators = length(loadings),
50+
composite_reliability = omega,
51+
average_variance_extracted = ave
52+
)
53+
})
54+
55+
round_numeric(do.call(rbind, rows))
56+
}
57+
58+
measurement_invariance_table <- function(configural, metric, scalar) {
59+
fits <- list(configural = configural, metric = metric, scalar = scalar)
60+
table <- do.call(rbind, Map(fit_summary_row, fits, names(fits)))
61+
table$delta_cfi <- c(NA_real_, diff(table$cfi))
62+
table$delta_rmsea <- c(NA_real_, diff(table$rmsea))
63+
round_numeric(table)
64+
}
65+
66+
missingness_summary <- function(data, variables) {
67+
data.frame(
68+
variable = variables,
69+
n_missing = vapply(data[variables], function(x) sum(is.na(x)), integer(1)),
70+
percent_missing = vapply(
71+
data[variables],
72+
function(x) mean(is.na(x)) * 100,
73+
numeric(1)
74+
),
75+
row.names = NULL
76+
) |>
77+
round_numeric()
78+
}
79+
80+
model_diagnostics <- function(fit) {
81+
estimates <- lavaan::parameterEstimates(fit, standardized = TRUE)
82+
residual_variances <- estimates[
83+
estimates$op == "~~" & estimates$lhs == estimates$rhs,
84+
c("lhs", "est", "se", "pvalue", "std.all")
85+
]
86+
residual_variances$negative_variance <- residual_variances$est < 0
87+
88+
modification_indices <- lavaan::modindices(fit, sort. = TRUE)
89+
modification_indices <- modification_indices[
90+
modification_indices$mi >= 10,
91+
c("lhs", "op", "rhs", "mi", "epc", "sepc.all")
92+
]
93+
94+
list(
95+
residual_variances = round_numeric(residual_variances),
96+
modification_indices = round_numeric(head(modification_indices, 20L))
97+
)
98+
}

README.md

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,111 @@
1-
# Structural Equation Modeling
1+
# Structural Equation Modeling of Student Engagement
22

3-
Project build in progress.
3+
[![Validate structural equation modeling project](https://github.com/mjeans/structural-equation-modeling/actions/workflows/validate.yml/badge.svg)](https://github.com/mjeans/structural-equation-modeling/actions/workflows/validate.yml)
4+
5+
An end-to-end R and `lavaan` case study connecting measurement quality to a structural model of student support, engagement, academic confidence, and later achievement. The project demonstrates confirmatory factor analysis, measurement invariance, full-information maximum likelihood, latent-variable mediation, model diagnostics, and careful interpretation.
6+
7+
> All records are deterministic and synthetic. No student, school, district, or client data are included.
8+
9+
![Structural equation model linking support, engagement, confidence, and achievement](assets/structural-model.svg)
10+
11+
## Research question
12+
13+
How are perceived support, student engagement, and academic confidence associated with later achievement after accounting for baseline performance and grade band—and is the measurement structure sufficiently comparable across middle- and high-school students to support group comparisons?
14+
15+
The workflow separates that question into two stages:
16+
17+
1. **Measurement:** Do 11 survey indicators represent three distinct latent constructs, and do the loadings and intercepts operate similarly across grade bands?
18+
2. **Structure:** Are support, engagement, confidence, and follow-up achievement connected through the prespecified direct and indirect pathways?
19+
20+
## Verified reference results
21+
22+
GitHub Actions regenerates the complete 2,400-record dataset and executes every model and test. The validated reference run produced:
23+
24+
| Analysis | CFI | TLI | RMSEA | SRMR |
25+
|---|---:|---:|---:|---:|
26+
| Three-factor pooled CFA | 0.998 | 0.997 | 0.015 | 0.012 |
27+
| Structural equation model | 0.998 | 0.998 | 0.013 | 0.015 |
28+
29+
The scalar-invariance step changed CFI and RMSEA by less than 0.001. The serial indirect association from support through engagement and confidence to follow-up achievement was 1.184 (`p < .001`). Diagnostics examined 17 residual variances, found no negative variances, and retained one modification index above 10 for transparent review rather than automatically respecifying the model.
30+
31+
The excellent fit is expected because the synthetic data were generated from the prespecified structure. It demonstrates correct implementation and recovery under known conditions; it is not evidence that comparable fit should be expected in real data.
32+
33+
## What the project demonstrates
34+
35+
- Three-factor confirmatory factor analysis with robust maximum likelihood
36+
- Configural, metric, and scalar measurement-invariance testing
37+
- Model comparison using changes in CFI and RMSEA rather than chi-square alone
38+
- Full-information maximum likelihood for incomplete continuous indicators
39+
- Composite reliability and average variance extracted
40+
- A latent-variable structural model with serial and specific indirect effects
41+
- Baseline and grade-band covariate adjustment
42+
- Standardized path estimates, confidence intervals, and explained variance
43+
- Residual-variance and modification-index diagnostics
44+
- Deterministic synthetic data, automated tests, and GitHub Actions
45+
- Explicit separation of statistical association from causal interpretation
46+
47+
## Measurement model
48+
49+
![Three-factor measurement model](assets/measurement-model.svg)
50+
51+
The prespecified model includes:
52+
53+
- **Support:** four indicators of relational and instructional support
54+
- **Engagement:** four indicators of behavioral and cognitive engagement
55+
- **Academic confidence:** three indicators of perceived academic capability
56+
57+
The same factor structure is evaluated in middle- and high-school groups. Configural invariance tests the shared pattern, metric invariance constrains loadings, and scalar invariance additionally constrains intercepts. The decision rules flag a step when absolute CFI deterioration exceeds 0.010 or RMSEA increases by more than 0.015.
58+
59+
## Structural model
60+
61+
The final model estimates:
62+
63+
- support → engagement
64+
- support and engagement → academic confidence
65+
- support, engagement, and confidence → follow-up achievement
66+
- baseline performance and grade band as observed covariates
67+
- support’s specific and serial indirect associations with achievement
68+
69+
The serial indirect path is `support → engagement → confidence → follow-up achievement`. Bootstrap language is intentionally avoided because the reference workflow uses robust maximum-likelihood standard errors; the project reports model-based confidence intervals and labels that distinction clearly.
70+
71+
## Repository map
72+
73+
```text
74+
R/ Synthetic data generation and reusable SEM reporting helpers
75+
scripts/ Data, CFA/invariance, SEM, and diagnostic entry points
76+
config/ Prespecified measurement and structural models
77+
tests/ Reproducibility, fit, invariance, mediation, and solution checks
78+
docs/ Analysis plan, model card, data dictionary, and decision memo
79+
assets/ Measurement and structural model diagrams
80+
outputs/ Documentation for reproducibly generated result tables
81+
.github/ Continuous-integration workflow
82+
```
83+
84+
## Reproduce the analysis
85+
86+
R and `lavaan` are the only requirements.
87+
88+
```bash
89+
install.packages("lavaan")
90+
make all
91+
```
92+
93+
Or run the stages separately:
94+
95+
```bash
96+
Rscript scripts/01_generate_data.R
97+
Rscript scripts/02_measurement_models.R
98+
Rscript scripts/03_structural_model.R
99+
Rscript scripts/04_diagnostics.R
100+
Rscript tests/test_pipeline.R
101+
```
102+
103+
Generated record-level data, fitted model objects, and result tables are ignored by Git. The scripts regenerate them deterministically, and continuous integration executes the entire workflow on every pull request.
104+
105+
## Interpretation boundary
106+
107+
The structural paths are conditional associations within a synthetic cross-sectional measurement design with a later observed outcome. Model fit does not prove the model is true, establish temporal ordering among the latent constructs, eliminate omitted-variable bias, or identify causal effects. A real application would require instrument validation, sampling and clustering review, preregistration where appropriate, sensitivity analyses, and replication in an independent cohort.
108+
109+
See the [analysis plan](docs/analysis-plan.md), [model card](docs/model-card.md), [data dictionary](docs/data-dictionary.md), and [decision memo](docs/decision-memo.md).
110+
111+
Built as a public portfolio demonstration by [Matthew Jeans, PhD](https://github.com/mjeans).

0 commit comments

Comments
 (0)