test_that("ps_trim() - Basic structure and return types", { set.seed(42) n <- 50 x <- rnorm(n) z <- rbinom(n, 1, plogis(0.5 * x)) # Fit a logistic regression fit <- glm(z ~ x, family = binomial) ps_vec <- predict(fit, type = "response") # 1) A default call with method="ps" out <- ps_trim(ps_vec, method = "ps") # Basic checks # Now 'out' is a ps_trim object expect_s3_class(out, "ps_trim") # The underlying data: same length expect_equal(length(out), length(ps_vec)) # Inspect the internal meta meta <- ps_trim_meta(out) expect_true(is.list(meta)) # e.g. method, lower, upper, keep_idx, trimmed_idx expect_true(all( c("method", "lower", "upper", "keep_idx", "trimmed_idx") %in% names(meta) )) # Check that the kept indices and trimmed indices are disjoint expect_length(intersect(meta$keep_idx, meta$trimmed_idx), 0) # Confirm that out-of-range entries are NA # By default, [0.1, 0.9] below_min <- ps_vec < 0.1 above_max <- ps_vec > 0.9 expect_true(all(is.na(out[below_min]))) expect_true(all(is.na(out[above_max]))) # The rest remain the same }) test_that("ps method: default and custom cutoffs", { set.seed(1) n <- 100 x <- rnorm(n) z <- rbinom(n, 1, plogis(0.3 * x)) fit <- glm(z ~ x, family = binomial) ps <- predict(fit, type = "response") # 1) Default cutoffs (0.1, 0.9) out1 <- ps_trim(ps, method = "ps") meta1 <- ps_trim_meta(out1) expect_equal(meta1$lower, 0.1) expect_equal(meta1$upper, 0.9) # 2) Custom cutoffs out2 <- ps_trim(ps, method = "ps", lower = 0.2, upper = 0.8) meta2 <- ps_trim_meta(out2) expect_equal(meta2$lower, 0.2) expect_equal(meta2$upper, 0.8) # Check that out-of-range entries are NA # i.e. everything <0.2 or >0.8 is NA out2_data <- as.numeric(out2) expect_true(all(out2_data[!is.na(out2_data)] >= 0.2)) expect_true(all(out2_data[!is.na(out2_data)] <= 0.8)) }) test_that("adaptive method: ignores lower/upper, warns appropriately", { set.seed(2) n <- 80 x <- rnorm(n) z <- rbinom(n, 1, plogis(-0.5 * x)) ps <- predict(glm(z ~ x, family = binomial), type = "response") # 1) No user cutoffs out_adapt <- ps_trim(ps, method = "adaptive") meta_adapt <- ps_trim_meta(out_adapt) # The meta should have a 'cutoff' field expect_true("cutoff" %in% names(meta_adapt)) # 2) If user sets lower/upper, we expect a warning expect_propensity_warning( out_adapt_warn <- ps_trim( ps, method = "adaptive", lower = 0.2, upper = 0.8 ) ) }) test_that("pctl method: percentile-based trimming", { set.seed(3) n <- 100 x <- rnorm(n) ps <- plogis(0.8 * x) # 1) Default [0.05, 0.95] out1 <- ps_trim(ps, method = "pctl") meta1 <- ps_trim_meta(out1) expect_equal(meta1$lower, 0.05) expect_equal(meta1$upper, 0.95) q_l <- quantile(ps, probs = 0.05) q_u <- quantile(ps, probs = 0.95) out1_data <- as.numeric(out1) # Everything below q_l is NA, above q_u is NA expect_true(all(is.na(out1_data[ps < q_l]))) expect_true(all(is.na(out1_data[ps > q_u]))) # 2) Custom [0.2, 0.8] out2 <- ps_trim(ps, method = "pctl", lower = 0.2, upper = 0.8) meta2 <- ps_trim_meta(out2) q_l2 <- quantile(ps, probs = 0.2) q_u2 <- quantile(ps, probs = 0.8) out2_data <- as.numeric(out2) expect_true(all(is.na(out2_data[ps < q_l2]))) expect_true(all(is.na(out2_data[ps > q_u2]))) }) test_that("pref method: requires exposure, fails with all 0 or all 1", { set.seed(4) n <- 60 x <- rnorm(n) z <- rbinom(n, 1, plogis(x)) fit <- glm(z ~ x, family = binomial) ps <- predict(fit, type = "response") # 1) If exposure = NULL, should fail expect_propensity_error( ps_trim(ps, method = "pref") ) # 2) If exposure is all 0 or all 1 => fail expect_propensity_error( ps_trim(ps, .exposure = rep(0, n), method = "pref") ) expect_propensity_error( ps_trim(ps, .exposure = rep(1, n), method = "pref") ) # 3) Valid usage out_pref <- ps_trim(ps, .exposure = z, method = "pref", .focal_level = 1) meta_pref <- ps_trim_meta(out_pref) expect_equal(meta_pref$lower, 0.3) expect_equal(meta_pref$upper, 0.7) # Check final out_pref_data <- as.numeric(out_pref) # We know that we just set NA outside [0.3, 0.7] in preference-score space, # but the underlying values remain in [0,1]. # So let's just confirm it is indeed a ps_trim object expect_s3_class(out_pref, "ps_trim") }) test_that("cr method: uses min(ps_treat) / max(ps_untrt), warns if cutoffs given", { set.seed(5) n <- 50 x <- rnorm(n) z <- rbinom(n, 1, plogis(0.2 * x)) fit <- glm(z ~ x, family = binomial) ps <- predict(fit, type = "response") # Must have exposure expect_propensity_error( ps_trim(ps, method = "cr") ) # If all 0 or all 1 => fail expect_propensity_error( ps_trim(ps, .exposure = rep(1, n), method = "cr") ) # Valid usage out_cr <- ps_trim(ps, .exposure = z, method = "cr", .focal_level = 1) meta_cr <- ps_trim_meta(out_cr) ps_treat <- ps[z == 1] ps_untrt <- ps[z == 0] cr_l_exp <- min(ps_treat) cr_u_exp <- max(ps_untrt) expect_equal(meta_cr$cr_lower, cr_l_exp) expect_equal(meta_cr$cr_upper, cr_u_exp) # Check that user-specified lower/upper => warning expect_propensity_warning( ps_trim( ps, .exposure = z, method = "cr", lower = 0.2, upper = 0.8, .focal_level = 1 ) ) }) test_that("Edge cases: ps near 0 or 1, empty trimming result", { ps_edge <- c(0.0001, 0.01, 0.5, 0.99, 0.9999) # If we do normal cut: e.g. [0.01, 0.99] out <- ps_trim(ps_edge, method = "ps", lower = 0.01, upper = 0.99) # out is same length as ps_edge, but the extremely small/large => NA out_data <- as.numeric(out) expect_true(is.na(out_data[1])) # 0.0001 <0.01 => NA expect_true(is.na(out_data[5])) # 0.9999>0.99 => NA expect_equal(sum(!is.na(out_data)), 3) # If we force a scenario with [1.1,1.2] => everything is out => all NA out_empty <- ps_trim(ps_edge, method = "ps", lower = 1.1, upper = 1.2) out_empty_data <- as.numeric(out_empty) expect_true(all(is.na(out_empty_data))) meta_e <- ps_trim_meta(out_empty) expect_length(meta_e$keep_idx, 0) expect_length(meta_e$trimmed_idx, length(ps_edge)) }) test_that("ps_refit() refits on keep_idx, warns if everything trimmed, etc.", { set.seed(123) n <- 20 x <- rnorm(n) z <- rbinom(n, 1, plogis(0.5 * x)) fit <- glm(z ~ x, family = binomial) ps <- predict(fit, type = "response") # Trim to [0.2, 0.8], then refit out <- ps_trim(ps, method = "ps", lower = 0.2, upper = 0.8) # Suppose we do a normal refit refit_out <- ps_refit(out, model = fit) expect_s3_class(refit_out, "ps_trim") meta_r <- ps_trim_meta(refit_out) expect_true(isTRUE(meta_r$refit)) expect_propensity_error( ps_refit(out, model = fit, .data = data.frame(z, x)[1:10, ]) ) # If everything is trimmed => error ps_edge <- c(0.01, 0.01, 0.99, 0.99) z_edge <- c(0, 1, 1, 0) out_empty <- ps_trim(ps_edge, method = "ps", lower = 1.1, upper = 2) expect_propensity_error( ps_refit(out_empty, model = fit) ) ps_trim(ps_edge, method = "ps", lower = 1.1, upper = 2) }) test_that("Full workflow: trim -> refit -> weighting yields refit, trimmed psw", { set.seed(999) n <- 12 x <- rnorm(n) z <- rbinom(n, 1, plogis(0.5 * x)) # 1) Fit initial logistic model, get ps fit <- glm(z ~ x, family = binomial) ps <- predict(fit, type = "response") # 2) Trim the PS (e.g. method="ps" with [0.2, 0.8]) trimmed_ps <- ps_trim(ps, method = "ps", lower = 0.2, upper = 0.8) expect_false(is_refit(trimmed_ps)) # not refit yet # 3) Refit on the subset trimmed_refit <- ps_refit(trimmed_ps, model = fit) expect_true(is_refit(trimmed_refit)) # now refit=TRUE in ps_trim_meta # 4) Create ATE weights with the refitted ps_trim object w_ate <- wt_ate( trimmed_refit, .exposure = z, exposure_type = "binary", .focal_level = 1 ) # 5) Check final psw object expect_s3_class(w_ate, "psw") # Should be trimmed, per the weighting method's logic expect_true(is_ps_trimmed(w_ate)) # Should NOT be truncated or stabilized expect_false(is_ps_truncated(w_ate)) expect_false(is_stabilized(w_ate)) # Should preserve the refit info if you attach ps_trim_meta expect_true(is_refit(w_ate)) # e.g. if is_refit.psw() checks ps_trim_meta # The estimand should include "; trimmed" expect_match(estimand(w_ate), "; trimmed$") }) test_that("adaptive method: triggers uniroot path (k < 0) coverage", { # We'll craft a scenario where k = 2*mean(sum_wt) - max(sum_wt) < 0 # so that the else-branch is executed (lines 83-89). set.seed(1234) n <- 30 # We create some extreme p near 0 or 1 so sum_wt = 1/(p*(1-p)) varies greatly # e.g. half near 0.01, half near 0.99 p_vec <- c( runif(n / 2, min = 0.001, max = 0.01), runif(n / 2, min = 0.99, max = 0.999) ) # We'll treat them as if we have a binary z, not relevant for "adaptive" z <- c(rep(0, n / 2), rep(1, n / 2)) # Now call ps_trim with method="adaptive" # This should produce k < 0 => code path with uniroot out_adapt <- ps_trim( p_vec, .exposure = z, method = "adaptive", .focal_level = 1 ) # Check that the 'cutoff' field in the meta is present meta <- ps_trim_meta(out_adapt) expect_true("cutoff" %in% names(meta)) # Also confirm the result is a ps_trim object expect_s3_class(out_adapt, "ps_trim") # Because of the extremes, we likely see a fairly small cutoff # Just check it's numeric and within (0, 0.5) expect_true(is.numeric(meta$cutoff)) expect_gt(meta$cutoff, 0) expect_lt(meta$cutoff, 0.5) # Since many ps are out-of-range, we expect many NAs out_data <- as.numeric(out_adapt) # Just confirm there's at least some NA for the extreme values # e.g. near 0.001 or 0.999 expect_true(anyNA(out_data)) }) test_that("Check defaults for helper functions", { # 1) Any random object that is not ps_trim => default method => FALSE not_trim_obj <- c(0.2, 0.4, 0.6) expect_false(is_ps_trimmed(not_trim_obj)) # triggers is_ps_trimmed.default() expect_false(is_refit(not_trim_obj)) # 2) A mock ps_trim object => method => TRUE # For a real test, you'd create it via ps_trim(...). Here we simulate: fake_ps_trim <- structure( c(0.5, NA, 0.7), class = "ps_trim" ) expect_true(is_ps_trimmed(fake_ps_trim)) # triggers is_ps_trimmed.ps_trim() }) # tests/test-ps_trim-vctrs.R library(testthat) library(vctrs) test_that("vec_ptype_abbr.ps_trim() and vec_ptype_full.ps_trim() coverage", { # Create a minimal ps_trim object # for demonstration, or use ps_trim() function if you like ps_obj <- new_trimmed_ps( c(0.1, NA, 0.7), ps_trim_meta = list( method = "ps", keep_idx = c(1, 3), trimmed_idx = 2 ) ) # 1) Abbreviation abbr <- vec_ptype_abbr(ps_obj) expect_identical(abbr, "ps_trim") # 2) Full full <- vec_ptype_full(ps_obj) # E.g. "ps_trim; trimmed 1 of " # Just check it's a character containing "ps_trim" expect_true(is.character(full)) expect_true(grepl("ps_trim;", full)) }) test_that("Arithmetic with ps_trim returns numeric", { # Create two ps_trim objects or combine with numeric x <- new_trimmed_ps(c(0.2, 0.3), ps_trim_meta = list()) y <- new_trimmed_ps(c(0.4, 0.9), ps_trim_meta = list()) # Arithmetic operations should return numeric expect_type(x + 1, "double") expect_type(x + 1L, "double") expect_type(1 + x, "double") expect_type(1L + x, "double") expect_type(x + y, "double") # Verify values are correct expect_equal(x + 1, c(1.2, 1.3)) expect_equal(1 / x, c(5, 10 / 3)) expect_equal(x * 2, c(0.4, 0.6)) # List operations still fail expect_error(x + list(1)) }) test_that("Combining two ps_trim with different parameters triggers warning", { x <- ps_trim( c(0.2, 0.4, 0.8), method = "ps", lower = 0.1, upper = 0.9 ) y <- ps_trim( c(0.3, 0.5, 0.7), method = "ps", lower = 0.2, # Different lower bound upper = 0.8 # Different upper bound ) # Attempt to combine with different parameters # This will warn about different trimming parameters and return numeric expect_propensity_warning( result <- vec_c(x, y) ) expect_type(result, "double") }) test_that("Combining ps_trim with double => double", { x <- new_trimmed_ps(c(0.2, 0.5), ps_trim_meta = list()) # vctrs logic => ptype2 => double expect_propensity_warning( combined <- vec_c(x, 0.7) ) expect_type(combined, "double") expect_false(inherits(combined, "ps_trim")) }) test_that("Casting ps_trim -> double => underlying numeric data", { x <- new_trimmed_ps( c(0.2, NA, 0.9), ps_trim_meta = list(method = "ps", keep_idx = c(1, 3), trimmed_idx = 2) ) casted <- vec_cast(x, to = double()) expect_type(casted, "double") # Should match the underlying data expect_equal(casted, c(0.2, NA, 0.9)) }) test_that("Casting double -> ps_trim => minimal ps_trim object", { base_vec <- c(0.1, 0.7, NA, 0.4) # If we do vec_cast(base_vec, ps_trim()) # => calls vec_cast.ps_trim.double ps_t <- vec_cast(base_vec, to = structure(double(), class = "ps_trim")) expect_s3_class(ps_t, "ps_trim") # The meta is "unknown" method or similar meta <- attr(ps_t, "ps_trim_meta") expect_equal(meta$method, "unknown") expect_equal(meta$keep_idx, seq_along(base_vec)) expect_length(meta$trimmed_idx, 0) }) test_that("Casting integer->ps_trim likewise uses new_trimmed_ps", { base_int <- c(0L, 1L, 999L) ps_t <- vec_cast(base_int, to = structure(double(), class = "ps_trim")) expect_s3_class(ps_t, "ps_trim") # check the data is double expect_equal(as.numeric(ps_t), c(0, 1, 999)) }) test_that("is_unit_trimmed.ps_trim returns expected row-level booleans", { set.seed(100) ps_vec <- c(0.1, 0.2, 0.5, 0.85, 0.95) # Trim outside [0.2, 0.8] trimmed_obj <- ps_trim( ps_vec, method = "ps", lower = 0.2, upper = 0.8 ) expect_s3_class(trimmed_obj, "ps_trim") row_trim <- is_unit_trimmed(trimmed_obj) expect_type(row_trim, "logical") expect_length(row_trim, length(ps_vec)) expect_equal(which(row_trim), c(1, 4, 5)) }) test_that("ps_trim objects can convert to character", { ps <- c(0.01, 0.1, 0.3, 0.8, 0.95) out <- as.character(ps_trim(ps, method = "ps", lower = 0.2, upper = 0.8)) expect_type(out, "character") }) test_that("ps_trim works with summarize(mean = mean(ps))", { skip_if_not_installed("dplyr") library(dplyr, warn.conflicts = FALSE) set.seed(200) n <- 600 x <- rnorm(n) z <- rbinom(n, size = 1, prob = plogis(x + rnorm(n))) fit <- glm(z ~ x, family = binomial) ps <- predict(fit, type = "response") |> ps_trim(method = "ps", lower = 0.3, upper = 0.7) |> ps_refit(fit) out <- tibble(x, z, ps) |> group_by(trimmed = is_unit_trimmed(ps)) |> summarize(mean = mean(ps), .groups = "drop") expect_s3_class(out, "tbl_df") expect_named(out, c("trimmed", "mean")) expect_type(out$mean, "double") }) test_that("ps_trim errors when exposure is missing for methods that require it", { ps <- runif(20, 0.1, 0.9) # Test pref method without exposure expect_propensity_error( ps_trim(ps, method = "pref") ) # Test cr method without exposure expect_propensity_error( ps_trim(ps, method = "cr") ) # Should work fine with ps method (no exposure needed) expect_no_error(ps_trim(ps, method = "ps")) }) test_that("ps_trim vec_ptype_full output matches expected format", { set.seed(123) ps <- runif(20, 0.05, 0.95) # Create ps_trim with some values trimmed ps_trim_obj <- ps_trim(ps, method = "ps", lower = 0.2, upper = 0.8) n_trimmed <- length(ps_trim_meta(ps_trim_obj)$trimmed_idx) # Test the vec_ptype_full output expect_equal( vctrs::vec_ptype_full(ps_trim_obj), paste("ps_trim;", "trimmed", n_trimmed, "of ") ) # Test with no values trimmed ps_no_trim <- ps_trim(ps, method = "ps", lower = 0, upper = 1) expect_equal( vctrs::vec_ptype_full(ps_no_trim), "ps_trim; trimmed 0 of " ) # Test with all values trimmed ps_all_trim <- ps_trim(ps, method = "ps", lower = 0.99, upper = 1) expect_equal( vctrs::vec_ptype_full(ps_all_trim), paste("ps_trim;", "trimmed", 20, "of ") ) }) test_that("ps_trim index tracking works when combining objects", { set.seed(456) ps1 <- runif(10, 0.05, 0.95) ps2 <- runif(10, 0.05, 0.95) # Create ps_trim objects with same parameters ps_trim1 <- ps_trim(ps1, method = "ps", lower = 0.2, upper = 0.8) ps_trim2 <- ps_trim(ps2, method = "ps", lower = 0.2, upper = 0.8) # Get original trimmed indices meta1 <- ps_trim_meta(ps_trim1) meta2 <- ps_trim_meta(ps_trim2) n_trimmed1 <- length(meta1$trimmed_idx) n_trimmed2 <- length(meta2$trimmed_idx) # Combine the objects combined <- c(ps_trim1, ps_trim2) # Should be a ps_trim object expect_s3_class(combined, "ps_trim") # Check that indices are properly tracked combined_meta <- ps_trim_meta(combined) expect_equal(length(combined), 20) # The total number of trimmed should be the sum expect_equal( length(combined_meta$trimmed_idx), n_trimmed1 + n_trimmed2 ) # Check that NA values are at the correct positions combined_data <- vec_data(combined) expect_true(all(is.na(combined_data[combined_meta$trimmed_idx]))) expect_true(!anyNA(combined_data[combined_meta$keep_idx])) }) test_that("ps_trim warns when combining objects with different parameters", { ps1 <- runif(10, 0.05, 0.95) ps2 <- runif(10, 0.05, 0.95) # Create ps_trim objects with different parameters ps_trim1 <- ps_trim(ps1, method = "ps", lower = 0.2, upper = 0.8) ps_trim2 <- ps_trim(ps2, method = "ps", lower = 0.3, upper = 0.7) # Should warn and return numeric expect_propensity_warning( combined <- c(ps_trim1, ps_trim2) ) expect_type(combined, "double") expect_false(inherits(combined, "ps_trim")) }) test_that("ps_trim index tracking works with subsetting and combining", { set.seed(789) ps <- runif(20, 0.05, 0.95) # Create ps_trim object ps_trim_obj <- ps_trim(ps, method = "ps", lower = 0.3, upper = 0.7) meta <- ps_trim_meta(ps_trim_obj) # Subset the object subset1 <- ps_trim_obj[1:10] subset2 <- ps_trim_obj[11:20] # Recombine recombined <- c(subset1, subset2) # Should maintain ps_trim class expect_s3_class(recombined, "ps_trim") # Check indices are properly tracked recombined_meta <- ps_trim_meta(recombined) expect_equal(length(recombined_meta$trimmed_idx), length(meta$trimmed_idx)) # Check that NA values are preserved at correct positions recombined_data <- vec_data(recombined) original_data <- vec_data(ps_trim_obj) expect_equal(which(is.na(recombined_data)), which(is.na(original_data))) }) test_that("ps_trim handles multiple combines correctly", { set.seed(321) # Create three ps_trim objects ps1 <- runif(5, 0.05, 0.95) ps2 <- runif(5, 0.05, 0.95) ps3 <- runif(5, 0.05, 0.95) ps_trim1 <- ps_trim(ps1, method = "ps", lower = 0.25, upper = 0.75) ps_trim2 <- ps_trim(ps2, method = "ps", lower = 0.25, upper = 0.75) ps_trim3 <- ps_trim(ps3, method = "ps", lower = 0.25, upper = 0.75) # Combine all three combined <- c(ps_trim1, ps_trim2, ps_trim3) # Should maintain ps_trim class expect_s3_class(combined, "ps_trim") expect_equal(length(combined), 15) # Check indices combined_meta <- ps_trim_meta(combined) combined_data <- vec_data(combined) # All trimmed indices should have NA values expect_true(all(is.na(combined_data[combined_meta$trimmed_idx]))) # All kept indices should have non-NA values expect_true(!anyNA(combined_data[combined_meta$keep_idx])) })