diff --git a/NAMESPACE b/NAMESPACE index 0afd85824e..f7cd7b31b5 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -248,6 +248,7 @@ export(labels_use_control) export(level_order) export(logistic_regression_cols) export(logistic_summary_by_flag) +export(mantel_fleiss_crit) export(month2day) export(or_clogit) export(or_glm) diff --git a/NEWS.md b/NEWS.md index e78982c4da..c22b73c0f7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,8 @@ # tern 0.9.11.9000 ### Enhancements +* Added `mantel_fleiss_crit()` to check the Mantel-Fleiss criterion + for stratified 2 x 2 contingency tables.(#1512) * Updated `g_forest()` to support point estimates and confidence intervals stored in a single column. (#1499) * Added the `exclude_rows` argument to `g_forest()` to allow excluding selected diff --git a/R/prop_diff_test.R b/R/prop_diff_test.R index f36647379d..1c680fd576 100644 --- a/R/prop_diff_test.R +++ b/R/prop_diff_test.R @@ -481,3 +481,137 @@ prop_fisher <- function(tbl, alternative = c("two.sided", "less", "greater")) { tbl <- tbl[, c("TRUE", "FALSE")] stats::fisher.test(tbl, alternative = alternative)$p.value } + +#' @title Check the Mantel-Fleiss Criterion +#' +#' @description `r lifecycle::badge("stable")` +#' +#' Checks the Mantel-Fleiss criterion for stratified 2 x 2 contingency tables. +#' +#' @details +#' The Mantel-Fleiss statistic is calculated as +#' +#' \deqn{ +#' MF = \min\left( +#' [\sum_h m_{11h} - \sum_h {(n_{11h})}_L],\ +#' [\sum_h {(n_{11h})}_U - \sum_h m_{11h}] +#' \right), +#' } +#' +#' where \eqn{h} indexes the non-empty strata. For each stratum \eqn{h}, the +#' expected frequency of cell \eqn{(1, 1)} in table \eqn{h}, under the +#' hypothesis of no association between group and response, is +#' +#' \deqn{ +#' m_{11h} = \frac{n_{1.h} n_{.1h}}{n_h}. +#' } +#' +#' The lower and upper bounds for \eqn{n_{11h}}, given the marginal totals, +#' are: +#' +#' \deqn{ +#' {(n_{11h})}_L = \max(0, n_{1.h} - n_{.2h}), +#' } +#' \deqn{ +#' {(n_{11h})}_U = \min(n_{.1h}, n_{1.h}). +#' } +#' +#' The Mantel-Fleiss criterion is satisfied when \eqn{MF \ge 5}. +#' +#' Strata with all cell counts equal to zero are excluded from the +#' calculation. If all strata contain zero observations, there are no +#' non-empty strata over which to calculate the Mantel-Fleiss statistic, and +#' the statistic is therefore undefined. In this case, the function returns +#' `NA`. +#' +#' @param tbl (`array`)\cr +#' A three-dimensional contingency table containing the counts for each +#' combination of group, response, and stratum. The first two dimensions +#' must correspond to the two variables defining the 2 x 2 contingency +#' table (group and response), in either order. The third dimension must +#' correspond to the strata. The first two dimensions must each have exactly +#' two levels. All cell values must be finite, non-missing integer counts. +#' @param include_value (`logical(1)`)\cr +#' Whether to include the calculated Mantel-Fleiss statistic as an attribute +#' of the result. +#' +#' @return A logical value indicating whether the Mantel-Fleiss criterion +#' is satisfied. If `include_value = TRUE`, the result also contains a +#' value attribute with the calculated Mantel-Fleiss statistic. If there +#' are no non-empty strata, the result is `NA` and the value attribute is +#' `NA_real_`. +#' +#' @author WW +#' +#' @examples +#' set.seed(123) +#' n <- 40 +#' +#' grp <- factor(sample(c("Active", "Control"), n, replace = TRUE)) +#' rsp <- sample(c(TRUE, FALSE), n, replace = TRUE) +#' strata1 <- factor(sample(c("A", "B"), n, replace = TRUE)) +#' strata2 <- factor(sample(c("x", "y"), n, replace = TRUE)) +#' strata <- interaction(strata1, strata2) +#' +#' tbl <- table(grp, rsp, strata) +#' tbl +#' +#' mantel_fleiss_crit(tbl) +#' mantel_fleiss_crit(tbl, include_value = TRUE) +#' +#' @references +#' Mantel, N., and Fleiss, J. L. (1980). +#' Minimum Expected Cell Size Requirements for the Mantel-Haenszel +#' One-Degree-of-Freedom Chi-Square Test and a Related Rapid Procedure. +#' \emph{American Journal of Epidemiology}, 112(1), 129--134. +#' +#' @export +mantel_fleiss_crit <- function(tbl, include_value = FALSE) { + checkmate::assert_array(tbl, mode = "integerish", any.missing = FALSE, d = 3L) + checkmate::assert_true(all(tbl >= 0L)) + checkmate::assert_true(all(is.finite(tbl))) + checkmate::assert_true(nrow(tbl) == 2L) + checkmate::assert_true(ncol(tbl) == 2L) + checkmate::assert_flag(include_value) + + # Drop strata with no observations. + tbl <- tbl[, , apply(tbl, 3L, sum) > 0, drop = FALSE] + + # Add marginal totals over the group and response dimensions, + # retaining the stratum dimension. + tbl_mrgn <- stats::addmargins(tbl, margin = 1:2) + + # If there are no non-empty strata, the Mantel-Fleiss criterion is undefined + # because there are no strata over which to calculate it. + if (dim(tbl)[3L] == 0L) { + is_satisfied <- NA + if (include_value) { + attr(is_satisfied, "value") <- NA_real_ + } + return(is_satisfied) + } + + n_1dot <- tbl_mrgn[1L, "Sum", ] + n_dot1 <- tbl_mrgn["Sum", 1L, ] + n_dot2 <- tbl_mrgn["Sum", 2L, ] + n <- tbl_mrgn["Sum", "Sum", ] + + # Expected value of n_11 under the hypothesis of no association + # between group and response (within a given stratum). + m_11 <- (n_1dot * n_dot1) / n + # Lower and upper bounds for n_11 given the marginal totals (within a given stratum). + n_11_lwr <- pmax(0L, n_1dot - n_dot2) + n_11_upr <- pmin(n_dot1, n_1dot) + + mf_value <- min( + sum(m_11) - sum(n_11_lwr), + sum(n_11_upr) - sum(m_11) + ) + + is_satisfied <- mf_value >= 5 + if (include_value) { + attr(is_satisfied, "value") <- mf_value + } + + is_satisfied +} diff --git a/_pkgdown.yml b/_pkgdown.yml index d6e0e13c6f..06ce089982 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -113,6 +113,7 @@ reference: - -h_xticks - -prop_diff - check_diff_prop_ci + - mantel_fleiss_crit - title: rtables Helper Functions desc: These functions help to work with the `rtables` package and may be diff --git a/inst/WORDLIST b/inst/WORDLIST index fd4ab057f3..0c77c4e15a 100644 --- a/inst/WORDLIST +++ b/inst/WORDLIST @@ -15,6 +15,7 @@ CMH CQ Clopper Coull +Fleiss Haenszel Hauck Hilferty diff --git a/man/mantel_fleiss_crit.Rd b/man/mantel_fleiss_crit.Rd new file mode 100644 index 0000000000..819035cb0b --- /dev/null +++ b/man/mantel_fleiss_crit.Rd @@ -0,0 +1,95 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/prop_diff_test.R +\name{mantel_fleiss_crit} +\alias{mantel_fleiss_crit} +\title{Check the Mantel-Fleiss Criterion} +\usage{ +mantel_fleiss_crit(tbl, include_value = FALSE) +} +\arguments{ +\item{tbl}{(\code{array})\cr +A three-dimensional contingency table containing the counts for each +combination of group, response, and stratum. The first two dimensions +must correspond to the two variables defining the 2 x 2 contingency +table (group and response), in either order. The third dimension must +correspond to the strata. The first two dimensions must each have exactly +two levels. All cell values must be finite, non-missing integer counts.} + +\item{include_value}{(\code{logical(1)})\cr +Whether to include the calculated Mantel-Fleiss statistic as an attribute +of the result.} +} +\value{ +A logical value indicating whether the Mantel-Fleiss criterion +is satisfied. If \code{include_value = TRUE}, the result also contains a +value attribute with the calculated Mantel-Fleiss statistic. If there +are no non-empty strata, the result is \code{NA} and the value attribute is +\code{NA_real_}. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#stable}{\figure{lifecycle-stable.svg}{options: alt='[Stable]'}}}{\strong{[Stable]}} + +Checks the Mantel-Fleiss criterion for stratified 2 x 2 contingency tables. +} +\details{ +The Mantel-Fleiss statistic is calculated as + +\deqn{ +MF = \min\left( +[\sum_h m_{11h} - \sum_h {(n_{11h})}_L],\ +[\sum_h {(n_{11h})}_U - \sum_h m_{11h}] +\right), +} + +where \eqn{h} indexes the non-empty strata. For each stratum \eqn{h}, the +expected frequency of cell \eqn{(1, 1)} in table \eqn{h}, under the +hypothesis of no association between group and response, is + +\deqn{ +m_{11h} = \frac{n_{1.h} n_{.1h}}{n_h}. +} + +The lower and upper bounds for \eqn{n_{11h}}, given the marginal totals, +are: + +\deqn{ +{(n_{11h})}_L = \max(0, n_{1.h} - n_{.2h}), +} +\deqn{ +{(n_{11h})}_U = \min(n_{.1h}, n_{1.h}). +} + +The Mantel-Fleiss criterion is satisfied when \eqn{MF \ge 5}. + +Strata with all cell counts equal to zero are excluded from the +calculation. If all strata contain zero observations, there are no +non-empty strata over which to calculate the Mantel-Fleiss statistic, and +the statistic is therefore undefined. In this case, the function returns +\code{NA}. +} +\examples{ +set.seed(123) +n <- 40 + +grp <- factor(sample(c("Active", "Control"), n, replace = TRUE)) +rsp <- sample(c(TRUE, FALSE), n, replace = TRUE) +strata1 <- factor(sample(c("A", "B"), n, replace = TRUE)) +strata2 <- factor(sample(c("x", "y"), n, replace = TRUE)) +strata <- interaction(strata1, strata2) + +tbl <- table(grp, rsp, strata) +tbl + +mantel_fleiss_crit(tbl) +mantel_fleiss_crit(tbl, include_value = TRUE) + +} +\references{ +Mantel, N., and Fleiss, J. L. (1980). +Minimum Expected Cell Size Requirements for the Mantel-Haenszel +One-Degree-of-Freedom Chi-Square Test and a Related Rapid Procedure. +\emph{American Journal of Epidemiology}, 112(1), 129--134. +} +\author{ +WW +} diff --git a/tests/testthat/test-mantel_fleiss_crit.R b/tests/testthat/test-mantel_fleiss_crit.R new file mode 100644 index 0000000000..60b1e0fa87 --- /dev/null +++ b/tests/testthat/test-mantel_fleiss_crit.R @@ -0,0 +1,172 @@ +test_that("mantel_fleiss_crit() works with multiple observations and strata", { + tbl <- array( + c(9L, 8L, 6L, 9L, 6L, 5L, 8L, 5L, 5L, 5L, 5L, 4L, 11L, 5L, 7L, 2L), + dim = c(2L, 2L, 4L) + ) + + expect_silent( + result <- mantel_fleiss_crit(tbl) + ) + expect_silent( + result_val <- mantel_fleiss_crit(tbl, TRUE) + ) + + expect_identical(result, TRUE) + expect_identical(result_val, result, ignore_attr = TRUE) + expect_equal(attributes(result_val), list(value = 20.16857), tolerance = 1e-6) +}) + +test_that("mantel_fleiss_crit() works with small stratified data and dimnames", { + tbl <- array( + c(2L, 2L, 1L, 2L, 0L, 1L, 2L, 1L, 1L, 1L, 3L, 1L, 1L, 0L, 1L, 1L), + dim = c(2L, 2L, 4L), + dimnames = list(grp = c("Gr1", "Gr2"), rsp = c("T", "F"), strata = LETTERS[1:4]) + ) + + expect_silent( + result <- mantel_fleiss_crit(tbl) + ) + expect_silent( + result_val <- mantel_fleiss_crit(tbl, TRUE) + ) + + expect_identical(result, FALSE) + expect_identical(result_val, result, ignore_attr = TRUE) + expect_equal(attributes(result_val), list(value = 2.785714), tolerance = 1e-6) +}) + +test_that("mantel_fleiss_crit() works with 1 stratum", { + tbl <- array(c(4L, 4L, 7L, 5L), dim = c(2L, 2L, 1L)) + + expect_silent( + result <- mantel_fleiss_crit(tbl) + ) + expect_silent( + result_val <- mantel_fleiss_crit(tbl, TRUE) + ) + + expect_identical(result, FALSE) + expect_identical(result_val, result, ignore_attr = TRUE) + expect_equal(attributes(result_val), list(value = 3.6), tolerance = 1e-6) +}) + +test_that("mantel_fleiss_crit() ignores unobserved strata levels", { + tbl <- array( + c(1L, 4L, 3L, 3L, 5L, 0L, 7L, 4L, 0L, 0L, 0L, 0L, 3L, 1L, 0L, 6L), + dim = c(2L, 2L, 4L) + ) + + expect_silent( + result <- mantel_fleiss_crit(tbl) + ) + expect_silent( + result_val <- mantel_fleiss_crit(tbl, TRUE) + ) + + expect_identical(result, TRUE) + expect_identical(result_val, result, ignore_attr = TRUE) + expect_equal(attributes(result_val), list(value = 5.231818), tolerance = 1e-6) +}) + +test_that("mantel_fleiss_crit() returns NA when all cell counts equal zero", { + tbl <- array(rep(0L, 16L), dim = c(2L, 2L, 4L)) + + expect_silent( + result <- mantel_fleiss_crit(tbl) + ) + expect_silent( + result_val <- mantel_fleiss_crit(tbl, TRUE) + ) + + expect_identical(result, NA) + expect_identical(result_val, result, ignore_attr = TRUE) + expect_identical(attributes(result_val), list(value = NA_real_)) +}) + +test_that("mantel_fleiss_crit() handles a stratum with observations in one cell only", { + tbl <- array(c(1L, 1L, 1L, 1L, 0L, 4L, 0L, 0L), dim = c(2L, 2L, 2L)) + + result <- mantel_fleiss_crit(tbl) + result_val <- mantel_fleiss_crit(tbl, TRUE) + + expect_identical(result, FALSE) + expect_identical(result_val, result, ignore_attr = TRUE) + expect_equal(attributes(result_val), list(value = 1), tolerance = 1e-6) +}) + +test_that("mantel_fleiss_crit() includes the MF = 5 boundary", { + tbl <- array(c(5L, 5L, 10L, 10L), dim = c(2L, 2L, 1L)) + + result <- mantel_fleiss_crit(tbl, include_value = TRUE) + + expect_identical(result, TRUE, ignore_attr = TRUE) + expect_identical(attributes(result), list(value = 5)) +}) + +test_that("mantel_fleiss_crit() handles data with no non-responses", { + tbl <- array(c(2L, 4L, 0L, 0L), dim = c(2L, 2L, 2L)) + + result <- mantel_fleiss_crit(tbl) + result_val <- mantel_fleiss_crit(tbl, TRUE) + + expect_identical(result, FALSE) + expect_identical(result_val, result, ignore_attr = TRUE) + expect_identical(attributes(result_val), list(value = 0)) +}) + +test_that("mantel_fleiss_crit() handles data with no responses", { + tbl <- array(c(0L, 0L, 4L, 0L), dim = c(2L, 2L, 2L)) + + result <- mantel_fleiss_crit(tbl) + result_val <- mantel_fleiss_crit(tbl, TRUE) + + expect_identical(result, FALSE) + expect_identical(result_val, result, ignore_attr = TRUE) + expect_identical(attributes(result_val), list(value = 0)) +}) + +test_that("mantel_fleiss_crit() works with observations from one group only (Gr1)", { + tbl <- array(c(46L, 0L, 4L, 0L), dim = c(2L, 2L, 2L)) + + result <- mantel_fleiss_crit(tbl) + result_val <- mantel_fleiss_crit(tbl, TRUE) + + expect_identical(result, FALSE) + expect_identical(result_val, result, ignore_attr = TRUE) + expect_identical(attributes(result_val), list(value = 0)) +}) + +test_that("mantel_fleiss_crit() works with observations from one group only (Gr2)", { + tbl <- array(c(0L, 30L, 0L, 3L), dim = c(2L, 2L, 2L)) + + result <- mantel_fleiss_crit(tbl) + result_val <- mantel_fleiss_crit(tbl, TRUE) + + expect_identical(result, FALSE) + expect_identical(result_val, result, ignore_attr = TRUE) + expect_identical(attributes(result_val), list(value = 0)) +}) + +test_that("mantel_fleiss_crit() validates inputs", { + # tbl + expect_error(mantel_fleiss_crit(matrix(1L, nrow = 2, ncol = 2))) + expect_error(mantel_fleiss_crit(array(1L, dim = c(2L, 2L, 2L, 1L)))) + expect_error(mantel_fleiss_crit(array(1L, dim = c(3L, 2L, 2L)))) + expect_error(mantel_fleiss_crit(array(1L, dim = c(2L, 3L, 2L)))) + + # Missing / invalid values. + dim3d <- c(2L, 2L, 2L) + expect_error(mantel_fleiss_crit(array(NA_integer_, dim = dim3d))) + expect_error(mantel_fleiss_crit(array("1", dim = dim3d))) + expect_error(mantel_fleiss_crit(array(NA_real_, dim = dim3d))) + expect_error(mantel_fleiss_crit(array(NaN, dim = dim3d))) + expect_error(mantel_fleiss_crit(array(-1, dim = dim3d))) + expect_error(mantel_fleiss_crit(array(-1L, dim = dim3d))) + expect_error(mantel_fleiss_crit(array(Inf, dim = dim3d))) + + # include_value + tbl <- array(1L, dim = c(2L, 2L, 3L)) + expect_error(mantel_fleiss_crit(tbl, include_value = c(TRUE, FALSE))) + expect_error(mantel_fleiss_crit(tbl, include_value = 1L)) + expect_error(mantel_fleiss_crit(tbl, include_value = 1)) +})