Skip to contents

This article rebuilds the size and power figures of van der Meulen, Raymond and van der Meulen (2021) from package functions alone, and the same helpers defined here can be pointed at your own design. For an overview of the function’s algorithm, see the Overview of algorithm article.

The size is maximised over the nuisance parameter \(\pi_1\) at every grid point, so larger sample sizes are slow. For that reason, the code and plots below use coarse grids by default; increase this grid to match a specific figure more closely.

Helpers

Construct size curves

Size is a function of the nuisance parameter \(\pi_1\). The helper evaluates all four tests over a grid of \(\pi_1\) for given sample sizes and null odds ratio, returning a data frame of size percentages. It needs the optimal \(\gamma_0\) first. Note that the conservative test is the modified test with \(\gamma_0 = 1\) (i.e. no boundary outcome ever rejected).

compute_size_curves <- function(m, n, alpha = 0.05, or = 1, precision = 1e-3,
                                p0_grid = seq(0.05, 0.95, by = 0.05),
                                method = "zoom", maze = 10, zoom_iter = 6) {

  df <- construct_test_frame(.odds_ratio = or, .m = m, .n = n,
                             .alpha = alpha, .precision = precision)

  gamma0_opt <- optimise_gamma0(.odds_ratio = or, .m = m, .n = n,
                                .alpha = alpha, .precision = precision,
                                .method = method, .maze = maze,
                                .zoom_iter = zoom_iter, .df = df)

  # Rejection matrices depend only on (df, gamma0), not on p0: build each
  # once and evaluate the whole p0_grid in a single batched call, rather than
  # rebuilding the matrix and re-deriving dbinom() vectors at every point.
  R_modified     <- modifiedfisher:::.build_rejection_matrix(df, gamma0_opt, m, n)
  R_conservative <- modifiedfisher:::.build_rejection_matrix(df, 1, m, n)

  data.frame(
    p0 = p0_grid,
    modified = modifiedfisher:::.local_size_modified_grid(
      p0_grid, .odds_ratio = or, .m = m, .n = n,
      .rejection_matrix = R_modified) * 100,
    woolf = sapply(p0_grid, function(p)
      local_size_asymptotic(p, .odds_ratio = or, .m = m, .n = n,
                            .alpha = alpha) * 100),
    sas_freq = sapply(p0_grid, function(p)
      local_size_probability(p, .odds_ratio = or, .m = m, .n = n,
                             .alpha = alpha) * 100),
    conservative = modifiedfisher:::.local_size_modified_grid(
      p0_grid, .odds_ratio = or, .m = m, .n = n,
      .rejection_matrix = R_conservative) * 100
  )
}

Construct power curves

Power is a function of the two response rates \(\pi_1\) and \(\pi_2\). Holding \(\pi_1\) fixed, the helper sweeps \(\pi_2\) and returns each test’s power as a percentage. The modified and conservative tests reuse the same test frame and \(\gamma_0\) as above.

compute_power_curves <- function(m, n, pi1 = 0.2, alpha = 0.05, or = 1,
                                 precision = 1e-3,
                                 pi2_grid = seq(0.25, 0.6, by = 0.025),
                                 method = "zoom", maze = 10, zoom_iter = 6) {

  df <- construct_test_frame(.odds_ratio = or, .m = m, .n = n,
                             .alpha = alpha, .precision = precision)

  gamma0_opt <- optimise_gamma0(.odds_ratio = or, .m = m, .n = n,
                                .alpha = alpha, .precision = precision,
                                .method = method, .maze = maze,
                                .zoom_iter = zoom_iter, .df = df)

  R_modified <- modifiedfisher:::.build_rejection_matrix(df, gamma0_opt, m, n)

  data.frame(
    pi2 = pi2_grid,
    modified = modifiedfisher:::.power_modified_grid(
      pi1, pi2_grid, .odds_ratio = or, .m = m, .n = n,
      .rejection_matrix = R_modified, .superiority = FALSE) * 100,
    woolf = sapply(pi2_grid, function(pi2)
      power_asymptotic(c(pi1, pi2), .m = m, .n = n, .alpha = alpha) * 100),
    sas_freq = sapply(pi2_grid, function(pi2)
      power_probability(c(pi1, pi2), .m = m, .n = n, .alpha = alpha) * 100),
    conservative = sapply(pi2_grid, function(pi2)
      power_conservative(c(pi1, pi2), .m = m, .n = n, .df = df,
                         .alpha = alpha, .precision = precision,
                         .superiority = FALSE) * 100)
  )
}

Define shared plotting style

Both figures share a colour and line-type scheme, with the nominal level α drawn as a dashed reference line on the size plot.

test_levels <- c("Modified", "SAS PROC Freq", "Woolf", "Conservative")

test_cols <- c(
  "Modified"      = "#016c59",
  "SAS PROC Freq" = "#3690c0",
  "Woolf"         = "#67a9cf",
  "Conservative"  = "grey70"
)

test_lty <- c(
  "Modified"      = 1,
  "SAS PROC Freq" = 4,
  "Woolf"         = 2,
  "Conservative"  = 6
)

to_long <- function(df, value_col, key_col) {
  df |>
    pivot_longer(modified:conservative, names_to = "test",
                 values_to = value_col) |>
    mutate(test_label = factor(case_when(
      test == "modified"     ~ "Modified",
      test == "woolf"        ~ "Woolf",
      test == "sas_freq"     ~ "SAS PROC Freq",
      test == "conservative" ~ "Conservative"
    ), levels = test_levels))
}

Size figure

We use \(m = 27\), \(n = 21\) for the null \(\mathrm{OR} = 1\), which corresponds to Figure 2(a) in the paper. The modified test (solid) tracks just under the dashed α line across the whole range, while the conservative test sits well below it.

alpha <- 0.05
size_df <- compute_size_curves(m = 27, n = 21, alpha = alpha, or = 1)

size_long <- to_long(size_df, "size", "test")

ggplot(size_long, aes(p0, size, colour = test_label, linetype = test_label)) +
  geom_hline(yintercept = alpha * 100, linetype = 2, colour = "grey30") +
  geom_line(linewidth = 0.9) +
  scale_colour_manual(values = test_cols) +
  scale_linetype_manual(values = test_lty) +
  coord_cartesian(ylim = c(0, 1.1 * alpha * 100)) +
  labs(x = expression(pi[1]), y = "Size (%)",
       colour = NULL, linetype = NULL,
       title = "Size of the modified Fisher exact test",
       subtitle = "m = 27, n = 21, H0: OR = 1") +
  theme_bw() +
  theme(legend.position = "bottom")

Power figure

For power we use \(m = n = 30\) with \(\pi_1 = 0.2\), matching the configuration of Figure 5(b). The modified test (solid) should sit at or above the probability-based test (the dashed comparator in the paper) across the alternative space.

power_df <- compute_power_curves(m = 30, n = 30, pi1 = 0.2, or = 1)

power_long <- to_long(power_df, "power", "test")

ggplot(power_long, aes(pi2, power, colour = test_label, linetype = test_label)) +
  geom_line(linewidth = 0.9) +
  scale_colour_manual(values = test_cols) +
  scale_linetype_manual(values = test_lty) +
  labs(x = expression(pi[2]), y = "Power (%)",
       colour = NULL, linetype = NULL,
       title = "Power of the modified Fisher exact test",
       subtitle = "m = n = 30, pi1 = 0.2, H0: OR = 1") +
  theme_bw() +
  theme(legend.position = "bottom")

Matching a specific figure

To reproduce a paper figure exactly, set m and n to that figure’s values, use a fine grid (the paper steps \(\pi_1\) in 0.01), and for the OR = 2 panels pass or = 2 to both helpers. For example, Figure 3(a) is m = 65, n = 71, or = 1.

Speed gain over the SAS macro

The original paper reports CPU times of roughly a minute at \(N = 50\) rising to an hour at \(N = 300\), mainly because the size is re-maximised over the nuisance parameter at every grid point in the SAS macro.

As of v0.0.4 of this package, the R function, using these same values, takes:

  • roughly 1-2 seconds when \(N = 20\) (i.e. p0_grid = seq(1/20, 19/20, by = 1/20)),
  • roughly 4 seconds when \(N = 50\),
  • roughly 8 seconds when \(N = 100\), and
  • roughly 24 seconds when \(N = 300\),

measured on a moderately powerful laptop CPU (Apple M3 Pro).

The speed gain over the SAS macro comes primarily from the rejection matrix built in .build_rejection_matrix(): instead of re-evaluating the rejection rule for every table \((u, T)\) at each \(\pi_1\) grid point, the R implementation builds the \((m+1) \times (n+1)\) matrix \(R\) once per \(\gamma_0\) candidate and evaluates the local size at any \(\pi_1\) as the bilinear form \(p_u^\top R\, p_v\) (a single matrix-vector multiplication).

Most of this package’s own internal speedups (v0.0.4) aren’t visible above, since compute_size_curves() also computes the Woolf and SAS Proc FREQ comparator sizes at every grid point, and neither was part of that work. The SAS Proc FREQ comparator in particular recomputes the full conditional hypergeometric distribution for every table and dominates the total time shown here regardless of how fast the modified test’s own calculation is.

# Figure 3(a) of the paper (slow):
size_fig3a <- compute_size_curves(
  m = 65, n = 71, or = 1,
  p0_grid = seq(1/100, 99/100, by = 1/100)
)

# Calculate time taken in R:
# microbenchmark::microbenchmark(compute_size_curves(m = 65, n = 71, or = 1, p0_grid = seq(1/20, 19/20, by = 1/20)), times = 10)

For context, the modified_fisher_exact_test() call itself (with u = 13, m = 41, v = 6, n = 47, and odds_ratio = 1 from Table 2 Example 2) takes 2.9 seconds.

Errors in Table 2

Two typos were identified in Table 2 of van der Meulen, Raymond and van der Meulen (2021) while verifying the package against the worked examples. The modifiedfisher package produces the correct numbers in both cases.

  • Example 1 (5/12 vs 7/11): lower confidence limits are missing a leading zero. The correct lower limit for the modified test is 0.0716, not the printed 0.716 (and likewise 0.076 not 0.760 for Woolf, and 0.055 not 0.550 for Proc FREQ). The misprint is also self-contradictory: a test-based interval of (0.716, 2.210) cannot exclude its own point estimate of 0.408.

  • Example 4: the success count for Group A should be \(u = 71\), not 72. The heading “72/128 vs 58/142” is incorrect. With \(u = 71\), the estimate (1.804), p-value (0.0175), and confidence interval (1.108, 2.936) all match the printed values exactly; with \(u = 72\) none of them do.

Examples 2 and 3 are correct as printed. Both typos were confirmed using closed-form Woolf limits and base R’s fisher.test(), neither of which relies on any package code.

Reference

van der Meulen EA, Raymond K, van der Meulen PJ (2021). Consistent Confidence Limits, P Values, and Power of the Non-Conservative, Size-α Modified Fisher Exact Test. Journal of Biostatistics and Biometric Applications 6(1):102.