diff --git a/DESCRIPTION b/DESCRIPTION index 1a67e08..8a22b11 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 6fc6df2..0ffb2e0 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 ab3a89d..6e1c56a 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 c0f7e1c..1da9d60 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 34bf688..5e17642 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, diff --git a/vignettes/indirect.Rmd b/vignettes/indirect.Rmd index 07d9687..e1b7e3e 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!