tests/testthat/test-weights-categorical.R

test_that("categorical exposure detection works correctly", {
  # Factor with 3 levels
  exposure_3 <- factor(c("A", "B", "C", "A", "B", "C"))
  set.seed(123)
  ps_matrix <- matrix(runif(18), ncol = 3)
  ps_matrix <- ps_matrix / rowSums(ps_matrix) # Normalize
  colnames(ps_matrix) <- levels(exposure_3)

  withr::local_options(propensity.quiet = FALSE)
  expect_message(
    wt_ate(ps_matrix, exposure_3),
    "Treating `.exposure` as categorical"
  )

  # Character vector converted to factor
  exposure_char <- c("low", "med", "high", "low", "med", "high")
  ps_matrix <- matrix(
    c(
      0.5,
      0.3,
      0.2,
      0.2,
      0.5,
      0.3,
      0.1,
      0.2,
      0.7,
      0.6,
      0.3,
      0.1,
      0.3,
      0.4,
      0.3,
      0.2,
      0.2,
      0.6
    ),
    ncol = 3,
    byrow = TRUE
  )
  colnames(ps_matrix) <- unique(sort(exposure_char))

  withr::local_options(propensity.quiet = FALSE)
  expect_message(
    wt_ate(ps_matrix, exposure_char),
    "Treating `.exposure` as categorical"
  )
})

test_that("categorical exposure validation works", {
  # Only 2 levels should error
  exposure_2 <- factor(c("A", "B", "A", "B"))
  ps_matrix_2 <- matrix(c(0.7, 0.3, 0.4, 0.6, 0.8, 0.2, 0.5, 0.5), ncol = 2)

  expect_propensity_error(
    wt_ate(ps_matrix_2, exposure_2, exposure_type = "categorical")
  )

  # Invalid focal category should error
  exposure_3 <- factor(c("A", "B", "C", "A"))
  ps_matrix_3 <- matrix(runif(12), ncol = 3)

  expect_propensity_error(
    wt_att(ps_matrix_3, exposure_3, .focal_level = "D")
  )
})

test_that("propensity score matrix validation works", {
  exposure <- factor(c("A", "B", "C", "A", "B"))

  # Not a matrix or data.frame
  expect_propensity_error(
    wt_ate(c(0.3, 0.4, 0.3), exposure, exposure_type = "categorical")
  )

  # Wrong number of rows
  ps_wrong_rows <- matrix(runif(9), ncol = 3)
  expect_propensity_error(
    wt_ate(ps_wrong_rows, exposure, exposure_type = "categorical")
  )

  # Wrong number of columns
  ps_wrong_cols <- matrix(runif(10), ncol = 2)
  expect_propensity_error(
    wt_ate(ps_wrong_cols, exposure, exposure_type = "categorical")
  )

  # Rows don't sum to 1
  ps_bad_sum <- matrix(
    c(
      0.3,
      0.3,
      0.3, # Sums to 0.9
      0.4,
      0.4,
      0.2,
      0.2,
      0.3,
      0.5,
      0.3,
      0.3,
      0.4,
      0.25,
      0.25,
      0.5
    ),
    ncol = 3,
    byrow = TRUE
  )

  expect_propensity_error(
    wt_ate(ps_bad_sum, exposure, exposure_type = "categorical")
  )

  # Invalid probabilities
  ps_invalid <- matrix(
    c(
      0.5,
      0.6,
      -0.1, # Negative value
      0.3,
      0.3,
      0.4,
      0.2,
      0.3,
      0.5,
      0.3,
      0.3,
      0.4,
      0.25,
      0.25,
      0.5
    ),
    ncol = 3,
    byrow = TRUE
  )

  expect_propensity_error(
    wt_ate(ps_invalid, exposure, exposure_type = "categorical")
  )
})

test_that("ATE weights work for categorical exposures", {
  set.seed(123)
  exposure <- factor(c("A", "B", "C", "A", "B", "C"))
  ps_matrix <- matrix(
    c(
      0.5,
      0.3,
      0.2,
      0.2,
      0.5,
      0.3,
      0.1,
      0.2,
      0.7,
      0.6,
      0.3,
      0.1,
      0.3,
      0.4,
      0.3,
      0.2,
      0.2,
      0.6
    ),
    ncol = 3,
    byrow = TRUE
  )
  colnames(ps_matrix) <- levels(exposure)

  weights <- wt_ate(ps_matrix, exposure, exposure_type = "categorical")

  # Check structure
  expect_s3_class(weights, "psw")
  expect_equal(estimand(weights), "ate")
  expect_equal(length(weights), 6)

  # Check attributes
  expect_equal(attr(weights, "n_categories"), 3)
  expect_equal(attr(weights, "category_names"), c("A", "B", "C"))

  # Check weight calculations
  # For ATE, h(e) = 1, so w_i = 1 / e_{i,Z_i}
  expected_weights <- c(
    1 / 0.5, # A: 1/0.5 = 2
    1 / 0.5, # B: 1/0.5 = 2
    1 / 0.7, # C: 1/0.7 = 1.43
    1 / 0.6, # A: 1/0.6 = 1.67
    1 / 0.4, # B: 1/0.4 = 2.5
    1 / 0.6 # C: 1/0.6 = 1.67
  )
  expect_equal(as.numeric(weights), expected_weights, tolerance = 0.01)
})

test_that("ATE stabilization works for categorical exposures", {
  exposure <- factor(c("A", "B", "C", "A", "B", "C"))
  ps_matrix <- matrix(
    c(
      0.5,
      0.3,
      0.2,
      0.2,
      0.5,
      0.3,
      0.1,
      0.2,
      0.7,
      0.6,
      0.3,
      0.1,
      0.3,
      0.4,
      0.3,
      0.2,
      0.2,
      0.6
    ),
    ncol = 3,
    byrow = TRUE
  )
  colnames(ps_matrix) <- levels(exposure)

  weights_stab <- wt_ate(
    ps_matrix,
    exposure,
    exposure_type = "categorical",
    stabilize = TRUE
  )

  expect_true(is_stabilized(weights_stab))

  # Check that stabilization is applied correctly
  # Marginal probabilities: A=2/6, B=2/6, C=2/6 = 1/3 each
  expected_weights <- c(
    (1 / 3) / 0.5, # A
    (1 / 3) / 0.5, # B
    (1 / 3) / 0.7, # C
    (1 / 3) / 0.6, # A
    (1 / 3) / 0.4, # B
    (1 / 3) / 0.6 # C
  )
  expect_equal(as.numeric(weights_stab), expected_weights, tolerance = 0.01)
})

test_that("ATT weights work for categorical exposures", {
  exposure <- factor(c("A", "B", "C", "A", "B", "C"))
  ps_matrix <- matrix(
    c(
      0.5,
      0.3,
      0.2,
      0.2,
      0.5,
      0.3,
      0.1,
      0.2,
      0.7,
      0.6,
      0.3,
      0.1,
      0.3,
      0.4,
      0.3,
      0.2,
      0.2,
      0.6
    ),
    ncol = 3,
    byrow = TRUE
  )
  colnames(ps_matrix) <- levels(exposure)

  # ATT with .focal_level = "A"
  weights_att_a <- wt_att(
    ps_matrix,
    exposure,
    .focal_level = "A",
    exposure_type = "categorical"
  )

  expect_equal(estimand(weights_att_a), "att")
  expect_equal(attr(weights_att_a, "focal_category"), "A")

  # For ATT, h(e) = e_focal
  # So w_i = e_{i,A} / e_{i,Z_i}
  expected_weights_a <- c(
    0.5 / 0.5, # A: 0.5/0.5 = 1
    0.2 / 0.5, # B: 0.2/0.5 = 0.4
    0.1 / 0.7, # C: 0.1/0.7 = 0.143
    0.6 / 0.6, # A: 0.6/0.6 = 1
    0.3 / 0.4, # B: 0.3/0.4 = 0.75
    0.2 / 0.6 # C: 0.2/0.6 = 0.333
  )
  expect_equal(as.numeric(weights_att_a), expected_weights_a, tolerance = 0.01)

  # ATT requires focal for categorical
  expect_propensity_error(
    wt_att(ps_matrix, exposure, exposure_type = "categorical")
  )
})

test_that("ATU weights work for categorical exposures", {
  exposure <- factor(c("A", "B", "C", "A", "B", "C"))
  ps_matrix <- matrix(
    c(
      0.5,
      0.3,
      0.2,
      0.2,
      0.5,
      0.3,
      0.1,
      0.2,
      0.7,
      0.6,
      0.3,
      0.1,
      0.3,
      0.4,
      0.3,
      0.2,
      0.2,
      0.6
    ),
    ncol = 3,
    byrow = TRUE
  )
  colnames(ps_matrix) <- levels(exposure)

  # ATU with .focal_level = "A" (weights for non-A)
  weights_atu_a <- wt_atu(
    ps_matrix,
    exposure,
    .focal_level = "A",
    exposure_type = "categorical"
  )

  expect_equal(estimand(weights_atu_a), "atu")
  expect_equal(attr(weights_atu_a, "focal_category"), "A")

  # For ATU, h(e) = 1 - e_focal
  # So w_i = (1 - e_{i,A}) / e_{i,Z_i}
  expected_weights_a <- c(
    (1 - 0.5) / 0.5, # A: 0.5/0.5 = 1
    (1 - 0.2) / 0.5, # B: 0.8/0.5 = 1.6
    (1 - 0.1) / 0.7, # C: 0.9/0.7 = 1.286
    (1 - 0.6) / 0.6, # A: 0.4/0.6 = 0.667
    (1 - 0.3) / 0.4, # B: 0.7/0.4 = 1.75
    (1 - 0.2) / 0.6 # C: 0.8/0.6 = 1.333
  )
  expect_equal(as.numeric(weights_atu_a), expected_weights_a, tolerance = 0.01)
})

test_that("ATM weights work for categorical exposures", {
  exposure <- factor(c("A", "B", "C", "A", "B", "C"))
  ps_matrix <- matrix(
    c(
      0.5,
      0.3,
      0.2,
      0.2,
      0.5,
      0.3,
      0.1,
      0.2,
      0.7,
      0.6,
      0.3,
      0.1,
      0.3,
      0.4,
      0.3,
      0.2,
      0.2,
      0.6
    ),
    ncol = 3,
    byrow = TRUE
  )
  colnames(ps_matrix) <- levels(exposure)

  weights_atm <- wt_atm(ps_matrix, exposure, exposure_type = "categorical")

  expect_equal(estimand(weights_atm), "atm")

  # For ATM, h(e) = min(e_1, ..., e_K)
  # So w_i = min(e_i) / e_{i,Z_i}
  expected_weights <- c(
    0.2 / 0.5, # A: min(0.5,0.3,0.2)=0.2, 0.2/0.5 = 0.4
    0.2 / 0.5, # B: min(0.2,0.5,0.3)=0.2, 0.2/0.5 = 0.4
    0.1 / 0.7, # C: min(0.1,0.2,0.7)=0.1, 0.1/0.7 = 0.143
    0.1 / 0.6, # A: min(0.6,0.3,0.1)=0.1, 0.1/0.6 = 0.167
    0.3 / 0.4, # B: min(0.3,0.4,0.3)=0.3, 0.3/0.4 = 0.75
    0.2 / 0.6 # C: min(0.2,0.2,0.6)=0.2, 0.2/0.6 = 0.333
  )
  expect_equal(as.numeric(weights_atm), expected_weights, tolerance = 0.01)
})

test_that("ATO weights work for categorical exposures", {
  exposure <- factor(c("A", "B", "C", "A", "B", "C"))
  ps_matrix <- matrix(
    c(
      0.5,
      0.3,
      0.2,
      0.2,
      0.5,
      0.3,
      0.1,
      0.2,
      0.7,
      0.6,
      0.3,
      0.1,
      0.3,
      0.4,
      0.3,
      0.2,
      0.2,
      0.6
    ),
    ncol = 3,
    byrow = TRUE
  )
  colnames(ps_matrix) <- levels(exposure)

  weights_ato <- wt_ato(ps_matrix, exposure, exposure_type = "categorical")

  expect_equal(estimand(weights_ato), "ato")

  # For ATO, h(e) = 1 / sum(1/e_k) - reciprocal of harmonic mean denominator
  # So w_i = h(e_i) / e_{i,Z_i}
  h_vals <- numeric(6)
  h_vals[1] <- 1 / (1 / 0.5 + 1 / 0.3 + 1 / 0.2) # 0.115
  h_vals[2] <- 1 / (1 / 0.2 + 1 / 0.5 + 1 / 0.3) # 0.095
  h_vals[3] <- 1 / (1 / 0.1 + 1 / 0.2 + 1 / 0.7) # 0.062
  h_vals[4] <- 1 / (1 / 0.6 + 1 / 0.3 + 1 / 0.1) # 0.067
  h_vals[5] <- 1 / (1 / 0.3 + 1 / 0.4 + 1 / 0.3) # 0.109
  h_vals[6] <- 1 / (1 / 0.2 + 1 / 0.2 + 1 / 0.6) # 0.086

  expected_weights <- c(
    h_vals[1] / 0.5,
    h_vals[2] / 0.5,
    h_vals[3] / 0.7,
    h_vals[4] / 0.6,
    h_vals[5] / 0.4,
    h_vals[6] / 0.6
  )
  expect_equal(as.numeric(weights_ato), expected_weights, tolerance = 0.01)
})

test_that("Entropy weights work for categorical exposures", {
  exposure <- factor(c("A", "B", "C", "A", "B", "C"))
  ps_matrix <- matrix(
    c(
      0.5,
      0.3,
      0.2,
      0.2,
      0.5,
      0.3,
      0.1,
      0.2,
      0.7,
      0.6,
      0.3,
      0.1,
      0.3,
      0.4,
      0.3,
      0.2,
      0.2,
      0.6
    ),
    ncol = 3,
    byrow = TRUE
  )
  colnames(ps_matrix) <- levels(exposure)

  weights_entropy <- wt_entropy(
    ps_matrix,
    exposure,
    exposure_type = "categorical"
  )

  expect_equal(estimand(weights_entropy), "entropy")

  # For Entropy, h(e) = -sum(e_k * log(e_k))
  # Calculate entropy for each observation
  calc_entropy <- function(probs) {
    -sum(probs * log(probs))
  }

  h_vals <- apply(ps_matrix, 1, calc_entropy)

  expected_weights <- c(
    h_vals[1] / 0.5,
    h_vals[2] / 0.5,
    h_vals[3] / 0.7,
    h_vals[4] / 0.6,
    h_vals[5] / 0.4,
    h_vals[6] / 0.6
  )
  expect_equal(as.numeric(weights_entropy), expected_weights, tolerance = 0.01)
})

test_that("data.frame input works for categorical exposures", {
  exposure <- factor(c("A", "B", "C", "A", "B", "C"))

  # Test with plain column names
  ps_df <- data.frame(
    A = c(0.5, 0.2, 0.1, 0.6, 0.3, 0.2),
    B = c(0.3, 0.5, 0.2, 0.3, 0.4, 0.2),
    C = c(0.2, 0.3, 0.7, 0.1, 0.3, 0.6)
  )

  weights_df <- wt_ate(ps_df, exposure, exposure_type = "categorical")

  # Test with parsnip-style column names
  ps_df_parsnip <- data.frame(
    .pred_A = c(0.5, 0.2, 0.1, 0.6, 0.3, 0.2),
    .pred_B = c(0.3, 0.5, 0.2, 0.3, 0.4, 0.2),
    .pred_C = c(0.2, 0.3, 0.7, 0.1, 0.3, 0.6)
  )

  weights_df_parsnip <- wt_ate(
    ps_df_parsnip,
    exposure,
    exposure_type = "categorical"
  )

  # Both should give same results
  expect_equal(as.numeric(weights_df), as.numeric(weights_df_parsnip))

  # Compare to matrix input
  ps_matrix <- as.matrix(ps_df)
  weights_matrix <- wt_ate(ps_matrix, exposure, exposure_type = "categorical")

  expect_equal(as.numeric(weights_df), as.numeric(weights_matrix))
})

test_that("stabilization works for ATE categorical exposures", {
  exposure <- factor(c("A", "B", "C", "A", "B", "C"))
  set.seed(123)
  ps_matrix <- matrix(runif(18), ncol = 3)
  ps_matrix <- ps_matrix / rowSums(ps_matrix) # Normalize
  colnames(ps_matrix) <- levels(exposure)

  # Test that stabilization works for ATE
  expect_no_error(
    wt_ate(ps_matrix, exposure, exposure_type = "categorical", stabilize = TRUE)
  )
})

test_that("categorical weights handle different column orders correctly", {
  set.seed(456)
  n <- 100

  # Create treatment with 3 categories
  trt <- factor(sample(c("low", "medium", "high"), n, replace = TRUE))

  # Create propensity score matrix in CORRECT order (matching factor levels)
  ps_correct <- matrix(runif(n * 3), nrow = n, ncol = 3)
  ps_correct <- ps_correct / rowSums(ps_correct)
  colnames(ps_correct) <- levels(trt) # "high", "low", "medium" (alphabetical)

  # Create same matrix with columns in WRONG order
  ps_wrong <- ps_correct[, c("medium", "high", "low")]

  # Calculate weights with both matrices
  w_ate_correct <- wt_ate(ps_correct, trt, exposure_type = "categorical")
  w_ate_wrong <- wt_ate(ps_wrong, trt, exposure_type = "categorical")

  # Weights should be identical after reordering
  expect_equal(as.numeric(w_ate_correct), as.numeric(w_ate_wrong))

  # Test with ATT
  w_att_correct <- wt_att(
    ps_correct,
    trt,
    exposure_type = "categorical",
    .focal_level = "medium"
  )
  w_att_wrong <- wt_att(
    ps_wrong,
    trt,
    exposure_type = "categorical",
    .focal_level = "medium"
  )

  expect_equal(as.numeric(w_att_correct), as.numeric(w_att_wrong))

  # Verify ATT weights are correct (focal group should have weight 1)
  expect_equal(unique(as.numeric(w_att_correct[trt == "medium"])), 1)
  expect_equal(unique(as.numeric(w_att_wrong[trt == "medium"])), 1)
})

test_that("categorical weights work with parsnip-style column names", {
  set.seed(789)
  n <- 50

  trt <- factor(sample(c("A", "B", "C"), n, replace = TRUE))

  # Create matrix with parsnip-style names
  ps_matrix <- matrix(runif(n * 3), nrow = n, ncol = 3)
  ps_matrix <- ps_matrix / rowSums(ps_matrix)
  colnames(ps_matrix) <- c(".pred_A", ".pred_B", ".pred_C")

  # Should work without error
  expect_no_error(
    w_ate <- wt_ate(ps_matrix, trt, exposure_type = "categorical")
  )

  # Test focal matching works correctly
  expect_no_error(
    w_att <- wt_att(
      ps_matrix,
      trt,
      exposure_type = "categorical",
      .focal_level = "B"
    )
  )

  # Focal group should have weight 1
  expect_equal(unique(as.numeric(w_att[trt == "B"])), 1)
})

test_that("categorical weights error on mismatched column names", {
  n <- 50
  trt <- factor(sample(c("A", "B", "C"), n, replace = TRUE))

  # Matrix with wrong column names
  ps_matrix <- matrix(runif(n * 3), nrow = n, ncol = 3)
  ps_matrix <- ps_matrix / rowSums(ps_matrix)
  colnames(ps_matrix) <- c("X", "Y", "Z")

  expect_propensity_error(
    wt_ate(ps_matrix, trt, exposure_type = "categorical")
  )
})

test_that("categorical weights warn on unnamed columns", {
  n <- 50
  trt <- factor(sample(c("A", "B", "C"), n, replace = TRUE))

  # Matrix with no column names
  ps_matrix <- matrix(runif(n * 3), nrow = n, ncol = 3)
  ps_matrix <- ps_matrix / rowSums(ps_matrix)

  # Test warning for all weight functions
  expect_propensity_warning(
    wt_ate(ps_matrix, trt, exposure_type = "categorical")
  )

  expect_propensity_warning(
    wt_att(ps_matrix, trt, exposure_type = "categorical", .focal_level = "A")
  )

  expect_propensity_warning(
    wt_atu(ps_matrix, trt, exposure_type = "categorical", .focal_level = "A")
  )

  expect_propensity_warning(
    wt_atm(ps_matrix, trt, exposure_type = "categorical")
  )

  expect_propensity_warning(
    wt_ato(ps_matrix, trt, exposure_type = "categorical")
  )

  expect_propensity_warning(
    wt_entropy(ps_matrix, trt, exposure_type = "categorical")
  )
})

Try the propensity package in your browser

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

propensity documentation built on March 3, 2026, 1:06 a.m.