R/data_prep.R

Defines functions prepare_dyad_data

Documented in prepare_dyad_data

#' Prepare dyadic data for multilevel models
#'
#' Validates dyadic data, records the structural variables, and adds metadata
#' and model-ready columns for dyadic multilevel model parameterizations.
#'
#' Data must be in long format. Cross-sectional dyadic data may contain at most
#' one row per member within dyad. Intensive longitudinal dyadic data may
#' contain at most one row per member and observed measurement occasion within
#' dyad. Measured variables may contain missing values. Numeric `dyad`, `member`,
#' `time`, and selected predictor columns must not contain infinite values.
#' Structural completeness is assessed across all rows. `incomplete_dyads`
#' controls dyads with fewer than two members; dyads with more than two members
#' always cause an error.
#' When `role` is supplied, stable member roles are resolved across repeated
#' rows before `missing_role` is applied.
#'
#' Dyad composition labels are canonical: role labels are sorted alphabetically
#' before being combined, so labels do not depend on row or member order.
#'
#' @param data A data frame or tibble. Data must be in long format. For
#' cross-sectional dyadic data, each observed member of each dyad has one row.
#' For intensive longitudinal dyadic data, each observed member of each dyad has
#' one row per observed time point.
#' @param dyad Column identifying the dyad.
#' @param member Column identifying a person or the member within dyad.
#' @param role Optional column identifying a stable member role, such as gender.
#'   Non-missing values must be consistent within each `dyad` x `member` and
#'   must not contain `_x_`. In repeated-measures data, an observed role is
#'   propagated to missing rows for the same member within a dyad.
#'   `missing_role` controls dyads in which a member has no non-missing role on
#'   any row. If no role is supplied, all dyads are treated as the same type of
#'   exchangeable dyads.
#' @param time Optional column identifying time or measurement order of repeated
#' measures.
#' @param predictors Optional variables to use for temporal predictor
#'   decomposition and model-ready predictor construction.
#' @param lag1_predictors Optional subset of `predictors` for which lag-1
#'   model-ready columns should be created. Requires `time` to be a finite,
#'   integer-valued numeric measurement index. Lagging respects the dyad and
#'   member structure, matches observations at exactly `time - 1`, and does not
#'   bridge missing occasions. If occasions are unequally spaced, for example
#'   waves in 2008, 2010, and 2013, number the study's occasions (1, 2, 3) and
#'   supply that index as `time`. The lag then refers to the previous occasion,
#'   regardless of how much time lies between them. Eligible raw,
#'   within-person, and APIM GMC variables are lagged, but stable
#'   between-person variables are not.
#' @param model_types Model-ready column families to construct. Can contain one
#'   or more of `"apim"`, `"dim"`, and `"dsm"`. `"apim"` creates
#'   actor and partner predictors. `"dim"` creates dyad-mean and
#'   within-dyad member-deviation predictors. DIM requires one final
#'   exchangeable dyad composition, which may result from pooling
#'   exchangeable compositions. `"dsm"` creates dyadic-score model
#'   predictor columns. `"none"` skips model-specific predictor
#'   construction after validation, composition inference, and optional
#'   temporal predictor decomposition, and must be used alone. `"dim"` and
#'   `"dsm"` must be requested in separate calls.
#' @param dsm_role_order For `model_types = "dsm"`, a character vector giving
#'   the two distinguishable roles in the order used for directional
#'   differences. For example, `c("female", "male")` defines predictor
#'   differences as female minus male and assigns the DSM role contrast `+0.5`
#'   to female partners and `-0.5` to male partners. Required when DSM columns
#'   are requested and must be `NULL` otherwise.
#' @param temporal_decomposition Temporal decomposition strategy for
#'   `predictors`.
#'   `"none"` leaves predictors undecomposed before model-specific columns are
#'   constructed. `"2l"` indicates a two-level temporal predictor
#'   decomposition into within-person and between-person components. `"auto"`
#'   resolves to `"2l"` when both `time` and `predictors` are supplied, and
#'   to `"none"` otherwise. `"2l"` retains raw model-ready predictors in
#'   addition to their within-person and between-person components. For
#'   longitudinal DIM and DSM construction, raw and within-person dyadic scores
#'   are computed within each dyad occasion, while between-person scores are
#'   computed within dyads. Raw DIM and DSM dyad means are grand-mean centered.
#'   Do not include the raw, within-person, and between-person versions of the
#'   same contemporaneous predictor in one model because they are linearly
#'   dependent.
#' @param set_exchangeable_compositions Optionally specify dyad compositions
#'   to treat as exchangeable, when their roles would otherwise imply distinguishability.
#'   Requires `role`. Compositions that are already
#'   exchangeable should not be listed. Each composition must be supplied as one
#'   string, using `_x_`, `-`, `_`, or whitespace (` `) between the two role labels,
#'   for example `"female_x_male"`, `"female-male"`, `"female_male"`, or
#'   `"female male"`, in arbitrary order.
#'   To set multiple compositions, use a character vector of such strings.
#'   Unlike `include_arbitrary_member_contrast`, this reclassifies the selected
#'   compositions as exchangeable and changes their composition-role coding and
#'   indicators, which is required for composition pooling and DIM
#'   compatibility.
#' @param keep_compositions Optional observed dyad compositions to keep
#'   before exchangeability overrides and pooling. Requires `role`. Composition
#'   references use the same format as `set_exchangeable_compositions`. `NULL`
#'   keeps all observed compositions.
#' @param pool_compositions Optionally pool exchangeable dyad compositions
#'   into a shared final composition label. Must be a named list where each name
#'   is the final composition label and each value is a character vector of
#'   composition references, for example
#'   `list(same_sex_couples = c("female-female", "male-male"))`. Only
#'   exchangeable compositions can be pooled. Each pool must contain at least
#'   two distinct observed compositions after composition references are
#'   resolved.
#' @param incomplete_dyads How to handle dyads with fewer than two unique
#'   members across all rows in `data`. `"error"` stops with an error and
#'   `"drop"` removes the entire dyad. A dyad with more than two unique members
#'   is invalid and always causes an error, regardless of this setting.
#' @param missing_role How to handle dyads in which at least one member has no
#'   non-missing `role` value on any row. A consistent non-missing role observed
#'   for a member is propagated to that member's other rows before this policy
#'   is applied. `"error"` stops with an error and `"drop"` removes the entire
#'   dyad. Conflicting non-missing roles always cause an error. Ignored when no
#'   `role` column is supplied.
#' @param seed Optional seed for random `.member_contrast_*` sign assignment.
#'   If `NULL`, the current R session's RNG state is used.
#' @param short_colnames Whether to use shorter composition-dependent generated
#'   column names when the final data contain one composition. The default `TRUE`
#'   omits the redundant composition label from `.is_*` and
#'   `.member_contrast_*` names. `FALSE` always retains composition-qualified
#'   names. Other generated column names are unaffected.
#' @param include_arbitrary_member_contrast Whether to also generate arbitrary
#'   `.member_contrast_*` columns for distinguishable compositions. The default
#'   `FALSE` generates these columns only for exchangeable compositions.
#'   `TRUE` retains distinguishable composition metadata, composition roles,
#'   and role indicators. It only adds the contrast needed to fit an
#'   exchangeability-constrained random-effects model to the same prepared
#'   data. Use `set_exchangeable_compositions` instead to reclassify
#'   compositions for pooling or DIM compatibility.
#' @param add_apim_gmc_predictors Whether to add APIM GMC variants for numeric
#'   predictors. `TRUE` retains the raw columns and adds `.{pred}_gmc` plus its
#'   actor and partner versions, centered over all retained non-missing values
#'   after filtering. Lagged variants use the same mean. Mixed non-numeric
#'   predictors remain raw and are listed in one warning. In longitudinal data,
#'   the mean is observation-weighted. Requires `"apim"` in `model_types`, at
#'   least one numeric predictor, and resolved `temporal_decomposition = "none"`.
#'   Do not use raw and GMC variants together in a model with an intercept.
#'
#' @return The original data as a tibble with class `dyadMLM_data`,
#'   `.composition` and `.composition_role` factor columns,
#'   `.is_*` numeric indicator columns, and numeric
#'   `.member_contrast_*` columns coded `-1` and `1` for the two members of
#'   each matching composition and `0` otherwise. With one final composition,
#'   their default names omit the composition label. The `dyadMLM` attribute
#'   contains structural metadata, `dyad_compositions`, and
#'   predictor metadata such as `temporal_decompositions`, `lag1_predictors`,
#'   `apim_predictors`, and `dim_predictors`, as well as `dsm_predictors` and
#'   `dsm_role_order` when applicable. The `generated_columns` table records each
#'   package-generated column retained in the returned data. Modifying prepared
#'   data does not automatically regenerate package-generated columns.
#'
#' @examples
#' data <- data.frame(
#'   dyad_id = c(1, 1, 2, 2, 3, 3),
#'   person_id = c(1, 2, 3, 4, 5, 6),
#'   role = c("female", "male", "female", "female", "male", "male"),
#'   x = c(4, 7, 5, 6, 3, 8)
#' )
#'
#' prepared <- prepare_dyad_data(
#'   data,
#'   dyad = dyad_id,
#'   member = person_id,
#'   role = role,
#'   predictors = x,
#'   model_types = "apim"
#' )
#'
#' print(prepared)
#'
#' pooled <- prepare_dyad_data(
#'   data,
#'   dyad = dyad_id,
#'   member = person_id,
#'   role = role,
#'   predictors = x,
#'   model_types = "apim",
#'   set_exchangeable_compositions = "female-male",
#'   pool_compositions = list(
#'     romantic_couples = c("female-female", "male-male", "female-male")
#'   )
#' )
#'
#' print(pooled)
#'
#' ild_data <- data.frame(
#'   dyad_id = rep(c(1, 2), each = 4),
#'   person_id = rep(c(1, 2), times = 4),
#'   time = rep(c(1, 1, 2, 2), times = 2),
#'   x = c(4, 7, 5, 8, 3, 6, 4, 7)
#' )
#'
#' ild_prepared <- prepare_dyad_data(
#'   ild_data,
#'   dyad = dyad_id,
#'   member = person_id,
#'   time = time,
#'   predictors = x,
#'   lag1_predictors = x,
#'   model_types = "apim",
#'   seed = 123
#' )
#'
#' print(ild_prepared)
#'
#' @export
prepare_dyad_data <- function(
    data,
    dyad,
    member,
    role = NULL,
    time = NULL,
    predictors = NULL,
    lag1_predictors = NULL,
    model_types = "apim",
    dsm_role_order = NULL,
    temporal_decomposition = c("auto", "2l", "none"),
    set_exchangeable_compositions = NULL,
    keep_compositions = NULL,
    pool_compositions = NULL,
    incomplete_dyads = c("error", "drop"),
    missing_role = c("error", "drop"),
    seed = NULL,
    short_colnames = TRUE,
    include_arbitrary_member_contrast = FALSE,
    add_apim_gmc_predictors = FALSE
  ) {

  model_types <- normalize_model_types(model_types)
  temporal_decomposition <- rlang::arg_match(temporal_decomposition)
  incomplete_dyads <- rlang::arg_match(incomplete_dyads)
  missing_role <- rlang::arg_match(missing_role)

  if (!is.logical(add_apim_gmc_predictors) ||
      length(add_apim_gmc_predictors) != 1L ||
      is.na(add_apim_gmc_predictors)) {
    stop(
      "`add_apim_gmc_predictors` must be `TRUE` or `FALSE`.",
      call. = FALSE
    )
  }
  if (add_apim_gmc_predictors && !"apim" %in% model_types) {
    stop(
      paste0(
        "`add_apim_gmc_predictors = TRUE` requires that `model_types` ",
        "includes \"apim\"."
      ),
      call. = FALSE
    )
  }

  out <- validate_dyad_data(
    data = data,
    dyad = {{ dyad }},
    member = {{ member }},
    role = {{ role }},
    time = {{ time }},
    predictors = {{ predictors }},
    lag1_predictors = {{ lag1_predictors }},
    model_types = model_types,
    dsm_role_order = dsm_role_order,
    temporal_decomposition = temporal_decomposition,
    incomplete_dyads = incomplete_dyads,
    missing_role = missing_role
  )

  if (add_apim_gmc_predictors) {
    meta_data <- attr(out, "dyadMLM")
    if (meta_data$temporal_decomposition != "none") {
      stop(
        paste0(
          "`add_apim_gmc_predictors = TRUE` requires ",
          "`temporal_decomposition = \"none\"` after `\"auto\"` is resolved."
        ),
        call. = FALSE
      )
    }

    predictors <- meta_data$predictors
    predictor_is_numeric <- logical(length(predictors))
    for (predictor_index in seq_along(predictors)) {
      predictor <- predictors[[predictor_index]]
      predictor_is_numeric[[predictor_index]] <- is.numeric(out[[predictor]])
    }

    numeric_predictors <- predictors[predictor_is_numeric]
    if (length(numeric_predictors) == 0L) {
      stop(
        paste0(
          "`add_apim_gmc_predictors = TRUE` requires at least one numeric ",
          "variable selected by `predictors`."
        ),
        call. = FALSE
      )
    }

    non_numeric_predictors <- predictors[!predictor_is_numeric]
    if (length(non_numeric_predictors) > 0L) {
      warning(
        paste0(
          "`add_apim_gmc_predictors = TRUE` was not applied to non-numeric ",
          "predictor(s): ",
          paste0("`", non_numeric_predictors, "`", collapse = ", "),
          ". Their raw actor and partner columns were retained. Recode ",
          "predictors explicitly as numeric only when grand-mean centering ",
          "is substantively meaningful."
        ),
        call. = FALSE
      )
    }
  }

  if (attr(out, "dyadMLM")$longitudinal) {
    # Temporarily add an absent partner row at occasions observed for at least
    # one member. This lets centering, lagging, and dyadic columns use complete
    # structural pairs. The temporary rows are removed before returning.
    out <- temporarily_complete_dyad_occasions(out)
  }

  out <- infer_dyad_compositions(
    out,
    seed = seed,
    keep_compositions = keep_compositions,
    set_exchangeable_compositions = set_exchangeable_compositions,
    pool_compositions = pool_compositions,
    short_colnames = short_colnames,
    include_arbitrary_member_contrast = include_arbitrary_member_contrast
  )

  # Validate model compatibilities
  if ("dsm" %in% model_types) {
    validate_dsm_compatibility(out)
  }

  out <- center_predictors(
    out,
    add_apim_gmc_predictors = add_apim_gmc_predictors
  )
  out <- add_temporal_lag_columns(out)

  if ("dim" %in% model_types) {
    # Current DIM construction supports one exchangeable composition.
    validate_dim_compatibility(out)
  }

  # Add model cols
  if ("apim" %in% model_types) {
    out <- add_actor_partner_columns(out)
  }

  if ("dim" %in% model_types) {
    out <- add_dyad_individual_columns(out)
  }

  if ("dsm" %in% model_types) {
    out <- add_dyadic_score_columns(out)
  }

  if (attr(out, "dyadMLM")$longitudinal) {
    out <- restore_observed_dyad_rows(out)
  }

  warn_if_lags_missing(out)

  return(out)
}

Try the dyadMLM package in your browser

Any scripts or data that you put into this service are public.

dyadMLM documentation built on Oct. 28, 2026, 1:06 a.m.