From 9beba2442d67ecd1d592bc004316ea4538ff676f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 Aug 2025 00:31:54 +0000 Subject: [PATCH 1/3] Initial plan From 7698bbd829e66a2b33050b7151089095fac7d8ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 Aug 2025 00:46:10 +0000 Subject: [PATCH 2/3] Implement total effects functionality in write_lavaan() with comprehensive tests and examples Co-authored-by: rempsyc <13123390+rempsyc@users.noreply.github.com> --- DESCRIPTION | 2 +- NEWS.md | 4 +- R/write_lavaan.R | 93 +++++++++++++++++++- tests/testthat/_snaps/write_lavaan.md | 122 +++++++++++++++++++++++++- tests/testthat/test-write_lavaan.R | 66 ++++++++++++++ 5 files changed, 280 insertions(+), 7 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 1a67e08b..8a22b11d 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: lavaanExtra Title: Convenience Functions for Package 'lavaan' -Version: 0.2.1 +Version: 0.2.1.1 Date: 2024-07-01 Authors@R: person("Rémi", "Thériault", , "remi.theriault@mail.mcgill.ca", role = c("aut", "cre"), diff --git a/NEWS.md b/NEWS.md index 6fc6df23..0ffb2e01 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,8 +1,8 @@ # lavaanExtra 0.2.2 * Incoming ✨ -# lavaanExtra 0.2.1.1 -* Incoming ✨ +## lavaanExtra 0.2.1.1 +* `write_lavaan()`: add new `total` argument for automatic calculation of total effects (direct + indirect effects), particularly useful for time series and panel data models (#4) # lavaanExtra 0.2.1 * New CRAN submission diff --git a/R/write_lavaan.R b/R/write_lavaan.R index ab3a89d0..6e1c56a9 100644 --- a/R/write_lavaan.R +++ b/R/write_lavaan.R @@ -15,6 +15,14 @@ #' and "DV" (dependent variables), `write_lavaan` attempts to #' write indirect effects automatically. In this case, the #' `mediation` argument must be specified too. +#' @param total Total effect indicators (`:=` symbol: "total +#' effect defined as"). If a named list is provided, +#' with names "IV" (independent variables) and "DV" (dependent +#' variables), `write_lavaan` attempts to write total effects +#' automatically by combining direct and indirect effects. In this +#' case, the `mediation` and `regression` arguments must be +#' specified too. This is particularly useful for time series and +#' panel data models where effects propagate across time points. #' @param latent Latent variable indicators (`=~` symbol: "is measured by"). #' @param intercept Intercept indicators (`~ 1` symbol: "intercept"). #' @param threshold Threshold indicators (`|` symbol: "threshold"). @@ -53,6 +61,7 @@ write_lavaan <- function(mediation = NULL, regression = NULL, covariance = NULL, indirect = NULL, + total = NULL, latent = NULL, intercept = NULL, threshold = NULL, @@ -133,6 +142,88 @@ write_lavaan <- function(mediation = NULL, "[--------Mediations (indirect effects)---------]" ) } + #### AUTOMATIC TOTAL EFFECTS!!! #### + if (!is.null(total)) { + if (all(names(total) %in% c("IV", "DV"))) { + # Generate total effects by combining direct and indirect effects + total_expressions <- list() + + for (iv in total$IV) { + for (dv in total$DV) { + effect_name <- paste0(iv, "_total_", dv) + paths <- c() + + # Look for direct path + if (!is.null(regression) && dv %in% names(regression) && iv %in% regression[[dv]]) { + if (isTRUE(label)) { + if (isTRUE(use.letters)) { + # Find position of iv in regression for dv + iv_pos <- which(regression[[dv]] == iv) + direct_path <- paste0(letters[iv_pos], "_", dv) + } else { + direct_path <- paste0(iv, "_", dv) + } + } else { + # No labels, just use coefficient reference + direct_path <- paste0(iv, "_", dv) + } + paths <- c(paths, direct_path) + } + + # Look for indirect paths via mediators + if (!is.null(mediation)) { + # Find mediators that iv predicts + iv_to_mediators <- names(mediation)[sapply(mediation, function(x) iv %in% x)] + # Find mediators that predict dv + mediators_to_dv <- character(0) + if (dv %in% names(mediation)) { + mediators_to_dv <- mediation[[dv]] + } + + # Find common mediators (mediators in both paths) + common_mediators <- intersect(iv_to_mediators, mediators_to_dv) + + if (length(common_mediators) > 0) { + for (mediator in common_mediators) { + if (isTRUE(label)) { + if (isTRUE(use.letters)) { + # Find position of iv in mediator's predictors + iv_pos <- which(mediation[[mediator]] == iv) + iv_to_m_path <- paste0(letters[iv_pos], "_", mediator) + + # Find position of mediator in dv's predictors + m_pos <- which(mediation[[dv]] == mediator) + m_to_dv_path <- paste0(letters[m_pos], "_", dv) + } else { + iv_to_m_path <- paste0(iv, "_", mediator) + m_to_dv_path <- paste0(mediator, "_", dv) + } + } else { + iv_to_m_path <- paste0(iv, "_", mediator) + m_to_dv_path <- paste0(mediator, "_", dv) + } + + indirect_path <- paste0(iv_to_m_path, " * ", m_to_dv_path) + paths <- c(paths, indirect_path) + } + } + } + + # Only create total effect if there are paths + if (length(paths) > 0) { + total_expressions[[effect_name]] <- paste(paths, collapse = " + ") + } + } + } + + total <- total_expressions + } + total <- process_vars( + total, + symbol = ":=", collapse = " + ", title = + "[-----------Total effects (direct + indirect)----------]" + ) + } if (!is.null(mediation)) { mediation <- process_vars( mediation, @@ -201,7 +292,7 @@ write_lavaan <- function(mediation = NULL, header <- paste0(hashtag, paste0("# ", title, "\n\n")) custom <- paste0(header, custom) } - paste0(latent, mediation, regression, covariance, indirect, intercept, + paste0(latent, mediation, regression, covariance, indirect, total, intercept, threshold, constraint, custom, collapse = "" ) diff --git a/tests/testthat/_snaps/write_lavaan.md b/tests/testthat/_snaps/write_lavaan.md index c0f7e1c4..1da9d60d 100644 --- a/tests/testthat/_snaps/write_lavaan.md +++ b/tests/testthat/_snaps/write_lavaan.md @@ -180,13 +180,121 @@ y1 + y2 ~ f1 + f2 + x1 + x2 +# write_lavaan using total effects + + Code + cat(write_lavaan(mediation = mediation_simple, regression = regression_simple, + total = total_simple, label = TRUE)) + Output + ################################################## + # [-----------Mediations (named paths)-----------] + + speed ~ visual_speed*visual + visual ~ ageyr_visual*ageyr + + ################################################## + # [---------Regressions (Direct effects)---------] + + speed ~ ageyr + + ################################################## + # [-----------Total effects (direct + indirect)----------] + + ageyr_total_speed := ageyr_speed + ageyr_visual * visual_speed + + +# write_lavaan using total effects multiple IVs and DVs + + Code + cat(write_lavaan(mediation = mediation_multi, regression = regression_multi, + total = total_multi, label = TRUE)) + Output + ################################################## + # [-----------Mediations (named paths)-----------] + + speed ~ visual_speed*visual + textual ~ visual_textual*visual + visual ~ ageyr_visual*ageyr + grade_visual*grade + + ################################################## + # [---------Regressions (Direct effects)---------] + + speed ~ ageyr + grade + textual ~ ageyr + grade + + ################################################## + # [-----------Total effects (direct + indirect)----------] + + ageyr_total_speed := ageyr_speed + ageyr_visual * visual_speed + ageyr_total_textual := ageyr_textual + ageyr_visual * visual_textual + grade_total_speed := grade_speed + grade_visual * visual_speed + grade_total_textual := grade_textual + grade_visual * visual_textual + + +# write_lavaan using total effects with letters + + Code + cat(write_lavaan(mediation = mediation_letters, regression = regression_letters, + total = total_letters, label = TRUE, use.letters = TRUE)) + Output + ################################################## + # [-----------Mediations (named paths)-----------] + + speed ~ a_speed*visual + visual ~ a_visual*ageyr + + ################################################## + # [---------Regressions (Direct effects)---------] + + speed ~ ageyr + + ################################################## + # [-----------Total effects (direct + indirect)----------] + + ageyr_total_speed := a_speed + a_visual * a_speed + + +# write_lavaan using total effects direct only + + Code + cat(write_lavaan(regression = regression_direct, total = total_direct, label = TRUE)) + Output + ################################################## + # [---------Regressions (Direct effects)---------] + + speed ~ ageyr + + ################################################## + # [-----------Total effects (direct + indirect)----------] + + ageyr_total_speed := ageyr_speed + + +# write_lavaan using total effects indirect only + + Code + cat(write_lavaan(mediation = mediation_indirect, total = total_indirect, label = TRUE)) + Output + ################################################## + # [-----------Mediations (named paths)-----------] + + speed ~ visual_speed*visual + visual ~ ageyr_visual*ageyr + + ################################################## + # [-----------Total effects (direct + indirect)----------] + + ageyr_total_speed := ageyr_visual * visual_speed + + # write_lavaan using everything Code cat(write_lavaan(mediation = mediation, regression = regression, covariance = covariance, - indirect = indirect, latent = latent, intercept = intercept, threshold = threshold, - constraint.equal = constraint.equal, constraint.smaller = constraint.smaller, - constraint.larger = constraint.larger, custom = custom, label = TRUE)) + indirect = indirect, total = total, latent = latent, intercept = intercept, + threshold = threshold, constraint.equal = constraint.equal, + constraint.smaller = constraint.smaller, constraint.larger = constraint.larger, + custom = custom, label = TRUE)) Output ################################################## # [-----Latent variables (measurement model)-----] @@ -222,6 +330,14 @@ grade_visual_speed := grade_visual * visual_speed grade_visual_textual := grade_visual * visual_textual + ################################################## + # [-----------Total effects (direct + indirect)----------] + + ageyr_total_speed := ageyr_speed + ageyr_visual * visual_speed + ageyr_total_textual := ageyr_textual + ageyr_visual * visual_textual + grade_total_speed := grade_speed + grade_visual * visual_speed + grade_total_textual := grade_textual + grade_visual * visual_textual + ################################################## # [------------------Intercepts------------------] diff --git a/tests/testthat/test-write_lavaan.R b/tests/testthat/test-write_lavaan.R index 34bf6881..5e176424 100644 --- a/tests/testthat/test-write_lavaan.R +++ b/tests/testthat/test-write_lavaan.R @@ -20,6 +20,9 @@ latent <- list( # Define indirect effects object indirect <- list(IV = IV, M = M, DV = DV) +# Define total effects object +total <- list(IV = IV, DV = DV) + # Special cases intercept <- c("mpg", "cyl", "disp") constraint.equal <- list(b1 = "(b2 + b3)^2") @@ -103,12 +106,75 @@ test_that("write_lavaan using custom", { expect_snapshot(cat(write_lavaan(custom = custom))) }) +test_that("write_lavaan using total effects", { + # Simple total effects + total_simple <- list(IV = "ageyr", DV = "speed") + mediation_simple <- list(speed = "visual", visual = "ageyr") + regression_simple <- list(speed = "ageyr") + expect_snapshot(cat(write_lavaan( + mediation = mediation_simple, + regression = regression_simple, + total = total_simple, + label = TRUE + ))) +}) + +test_that("write_lavaan using total effects multiple IVs and DVs", { + # Multiple IVs and DVs + total_multi <- list(IV = c("ageyr", "grade"), DV = c("speed", "textual")) + mediation_multi <- list(speed = "visual", textual = "visual", visual = c("ageyr", "grade")) + regression_multi <- list(speed = c("ageyr", "grade"), textual = c("ageyr", "grade")) + expect_snapshot(cat(write_lavaan( + mediation = mediation_multi, + regression = regression_multi, + total = total_multi, + label = TRUE + ))) +}) + +test_that("write_lavaan using total effects with letters", { + # Total effects with letter labeling + total_letters <- list(IV = "ageyr", DV = "speed") + mediation_letters <- list(speed = "visual", visual = "ageyr") + regression_letters <- list(speed = "ageyr") + expect_snapshot(cat(write_lavaan( + mediation = mediation_letters, + regression = regression_letters, + total = total_letters, + label = TRUE, + use.letters = TRUE + ))) +}) + +test_that("write_lavaan using total effects direct only", { + # Total effects when only direct paths exist (no mediators) + total_direct <- list(IV = "ageyr", DV = "speed") + regression_direct <- list(speed = "ageyr") + expect_snapshot(cat(write_lavaan( + regression = regression_direct, + total = total_direct, + label = TRUE + ))) +}) + +test_that("write_lavaan using total effects indirect only", { + # Total effects when only indirect paths exist (no direct paths) + total_indirect <- list(IV = "ageyr", DV = "speed") + mediation_indirect <- list(speed = "visual", visual = "ageyr") + expect_snapshot(cat(write_lavaan( + mediation = mediation_indirect, + total = total_indirect, + label = TRUE + ))) +}) + test_that("write_lavaan using everything", { expect_snapshot(cat(write_lavaan( mediation = mediation, regression = regression, covariance = covariance, indirect = indirect, + total = total, latent = latent, intercept = intercept, threshold = threshold, From b0e5c041afbbec5157eeed2ed91c21b3e8dc5351 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 Aug 2025 00:47:29 +0000 Subject: [PATCH 3/3] Complete total effects implementation with vignette documentation and final validation Co-authored-by: rempsyc <13123390+rempsyc@users.noreply.github.com> --- vignettes/indirect.Rmd | 64 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/vignettes/indirect.Rmd b/vignettes/indirect.Rmd index 07d96877..e1b7e3e1 100644 --- a/vignettes/indirect.Rmd +++ b/vignettes/indirect.Rmd @@ -397,6 +397,70 @@ nice_tidySEM(fit, layout = indirect, hide_nonsig_edges = TRUE, label = labels) Etc. +# Total Effects (Direct + Indirect) + +The `write_lavaan()` function can also calculate total effects automatically, which combine both direct and indirect effects. This is particularly useful for time series and panel data models where effects propagate across multiple time points. + +## Simple mediation with total effects + +```{r} +# Simple mediation model +mediation <- list(speed = "visual", visual = "ageyr") +regression <- list(speed = "ageyr") + +# Define total effects +total <- list(IV = "ageyr", DV = "speed") + +# Write the model with total effects +model <- write_lavaan( + mediation = mediation, + regression = regression, + total = total, + label = TRUE +) +cat(model) +``` + +In this example, the total effect of `ageyr` on `speed` combines: +- Direct effect: `ageyr_speed` (direct path from ageyr to speed) +- Indirect effect: `ageyr_visual * visual_speed` (ageyr → visual → speed) + +## Complex time series models + +For more complex scenarios like cross-lagged panel models: + +```{r} +# Cross-lagged panel model with multiple time points +mediation <- list( + outcome_T2 = c("mediator_T1", "outcome_T1"), + outcome_T3 = c("mediator_T2", "outcome_T2"), + mediator_T1 = "predictor_T0", + mediator_T2 = c("mediator_T1", "predictor_T1") +) + +regression <- list( + outcome_T2 = c("predictor_T0", "predictor_T1"), + outcome_T3 = c("predictor_T1", "predictor_T2"), + mediator_T2 = "predictor_T1" +) + +# Calculate total effects across time points +total <- list( + IV = c("predictor_T0", "predictor_T1"), + DV = c("outcome_T2", "outcome_T3") +) + +model <- write_lavaan( + mediation = mediation, + regression = regression, + total = total, + label = TRUE +) +cat(model) +``` + +This automatically calculates all total effects, which would be extremely time-consuming to code manually for complex time series models. + # Other scenarios If you experience any issues with other scenarios, please open a [GitHub issue](https://github.com/rempsyc/lavaanExtra/issues) with your example, and I will try to adapt the function to support that case. Thank you!