Optimal Multiple Hypothesis Testing
Davide Viviano, Kaspar Wuthrich, Paul Niehaus
Overview
The mhtopt package implements the economically optimal
multiple hypothesis testing (MHT) framework from Viviano, Wuthrich, and
Niehaus (2026). Unlike ad hoc procedures (Bonferroni, Holm,
Benjamini-Hochberg), this package derives the correct
adjustment from the structure of research costs.
Key insight: The optimal per-test significance level
is (Proposition 4.1):
\[
\alpha^*(J) = \frac{C(J, n)}{b \cdot \bar{\omega}(J)}
\]
where \(C(J, n)\) is the research
cost function, \(b\) is the
researcher’s benefit from a rejection, and \(\bar{\omega}(J)\) is the sum of hypothesis
weights. This simplifies to closed-form expressions depending on the
cost structure.
Package functions
mht_est() |
Main function: test J treatment coefficients from a regression |
mht_critical() |
Compute optimal critical values for given J |
mht_test() |
Apply adjustment to a vector of p-values |
mht_cost_estimate() |
Estimate cost function parameters from data |
mht_table() |
Generate reference tables of critical values |
Installation
# From CRAN (once published)
install.packages("mhtopt")
# Development version from GitHub
# remotes::install_github("dviviano/mhtopt", subdir = "r/mhtopt")
Quick Start
# After running a regression with multiple treatments
fit <- lm(outcome ~ treat1 + treat2 + treat3 + controls, data = mydata)
# Test all treatment coefficients with optimal MHT adjustment
result <- mht_est(fit,
vars = c("treat1", "treat2", "treat3"),
alpha_bar = 0.05)
# View results: shows rejections under 5 procedures
print(result)
The function returns rejection decisions under five procedures: -
Optimal — derived from cost structure (default: FDA
calibration) - Bonferroni — traditional FWER control -
Holm — step-down Bonferroni -
Benjamini-Hochberg — FDR control -
Unadjusted — no MHT correction
When to Adjust?
The paper’s central insight: the need for MHT adjustment depends on
the research cost function — specifically, whether
costs exhibit returns to scale with respect to the number of
hypotheses.
Cost structure determines the optimal correction
Fixed costs dominate (cf_share near
1, or beta near 0): Adding more hypotheses barely increases
costs → researchers have incentive to “test many and get lucky” →
optimal correction approaches Bonferroni
Variable costs dominate (cf_share
near 0, or beta near 1): Each hypothesis costs
proportionally more → cost itself deters excessive testing →
little/no adjustment needed
Intermediate (empirically relevant): Optimal
threshold lies between Bonferroni and unadjusted, determined by cost
parameters
Default calibrations
Linear model (FDA calibration):
# Default: cf_share = 0.46, J_bar = 3 (from Sertkaya et al. 2016)
mht_critical(J = 5, alpha_bar = 0.05)
#>
#> ------------------------------------------------------------
#> Optimal MHT Critical Values
#> Viviano, Wuthrich, and Niehaus (2026)
#> ------------------------------------------------------------
#>
#> Cost model: Linear (Eq. 27)
#> Number of hypotheses: 5
#> Benchmark alpha: 0.0500
#> Sample size ratio: 1.00
#>
#> ------------------------------------------------------------
#> Optimal test size: 0.021250
#> Optimal z-threshold: 2.0286
#>
#> Bonferroni size: 0.010000
#> Bonferroni z-thresh: 2.3263
#> Sidak size: 0.010206
#> Sidak z-threshold: 2.3187
#> Unadjusted size: 0.050000
#> ------------------------------------------------------------
Cobb-Douglas (J-PAL calibration):
# beta = 0.13, iota = 0.075 (from J-PAL data, Appendix A)
mht_critical(J = 5, alpha_bar = 0.05, model = "cobbdouglas")
#>
#> ------------------------------------------------------------
#> Optimal MHT Critical Values
#> Viviano, Wuthrich, and Niehaus (2026)
#> ------------------------------------------------------------
#>
#> Cost model: Cobb-Douglas (Appendix A)
#> Number of hypotheses: 5
#> Benchmark alpha: 0.0500
#> Sample size ratio: 1.00
#>
#> ------------------------------------------------------------
#> Optimal test size: 0.012327
#> Optimal z-threshold: 2.2468
#>
#> Bonferroni size: 0.010000
#> Bonferroni z-thresh: 2.3263
#> Sidak size: 0.010206
#> Sidak z-threshold: 2.3187
#> Unadjusted size: 0.050000
#> ------------------------------------------------------------
Notice: Cobb-Douglas is more conservative because beta ≈ 0
implies near-constant marginal cost (fixed-cost-like).
Main Function: mht_est()
Tests J coefficients from a fitted regression model.
Basic usage
mht_est(fit,
vars = NULL, # coefficient names (default: all non-intercept)
alpha_bar, # benchmark significance level
onesided = TRUE, # one-sided (positive) vs two-sided
model = "linear", # "linear" or "cobbdouglas"
cf_share = 0.46, # fixed cost share (linear model)
J_bar = 3, # avg arms per trial (linear model)
nm_ratio = 1.0, # sample size ratio n/m
mbar = NULL, # benchmark arm size (overrides nm_ratio)
beta = 0.13, # arms elasticity (Cobb-Douglas)
iota = 0.075) # sample elasticity (Cobb-Douglas)
Supported model classes
lm — base R linear models
glm — generalized linear models
lm_robust, iv_robust — from
estimatr (cluster/HC standard errors)
fixest — from fixest (feols,
fepois, etc.)
- Any class with a
coef(summary(fit)) method
Return value
A data.frame with columns:
term — coefficient name
estimate, std_error, t_stat,
p_value — from the model
reject_optimal, reject_bonferroni,
reject_holm, reject_bh,
reject_unadjusted — rejection indicators
Attributes: alpha_opt, alpha_bonf,
alpha_bar, J, model,
onesided, nm_ratio
Examples
Multiple treatment arms:
library(estimatr)
fit <- lm_robust(outcome ~ treat_A + treat_B + treat_C + controls,
fixed_effects = ~village,
clusters = ~hh_id,
data = df)
result <- mht_est(fit,
vars = c("treat_A", "treat_B", "treat_C"),
alpha_bar = 0.05)
# Extract optimal alpha used
attr(result, "alpha_opt")
# How many hypotheses rejected under each procedure?
colSums(result[, c("reject_optimal", "reject_bonferroni",
"reject_holm", "reject_bh", "reject_unadjusted")])
Two-sided test:
fit_logit <- glm(uptake ~ treat1 + treat2 + controls,
family = binomial,
data = df)
mht_est(fit_logit,
vars = c("treat1", "treat2"),
alpha_bar = 0.05,
onesided = FALSE)
Using benchmark sample size (mbar):
# If you know typical arm size is 500 observations
# and your study has N = 1500 with J = 3 arms
# then nm_ratio = (1500/3) / 500 = 1.0
fit <- lm(outcome ~ treat1 + treat2 + treat3, data = df)
mht_est(fit,
vars = c("treat1", "treat2", "treat3"),
alpha_bar = 0.05,
mbar = 500) # automatically computes nm_ratio
Custom cost parameters:
# Suppose you estimated cost parameters from your data
est <- mht_cost_estimate(cost, arms, sample_size, alpha_bar = 0.05)
mht_est(fit,
vars = c("treat1", "treat2", "treat3"),
alpha_bar = 0.05,
model = "cobbdouglas",
beta = est$beta,
iota = est$iota)
Supporting Functions
mht_critical() — Compute Optimal Critical Values
The core formula. Computes \(\alpha^*\) for given J and cost model.
# Linear model (default)
mht_critical(J = 5, alpha_bar = 0.05)
#>
#> ------------------------------------------------------------
#> Optimal MHT Critical Values
#> Viviano, Wuthrich, and Niehaus (2026)
#> ------------------------------------------------------------
#>
#> Cost model: Linear (Eq. 27)
#> Number of hypotheses: 5
#> Benchmark alpha: 0.0500
#> Sample size ratio: 1.00
#>
#> ------------------------------------------------------------
#> Optimal test size: 0.021250
#> Optimal z-threshold: 2.0286
#>
#> Bonferroni size: 0.010000
#> Bonferroni z-thresh: 2.3263
#> Sidak size: 0.010206
#> Sidak z-threshold: 2.3187
#> Unadjusted size: 0.050000
#> ------------------------------------------------------------
# Cobb-Douglas
mht_critical(J = 5, alpha_bar = 0.05, model = "cobbdouglas")
#>
#> ------------------------------------------------------------
#> Optimal MHT Critical Values
#> Viviano, Wuthrich, and Niehaus (2026)
#> ------------------------------------------------------------
#>
#> Cost model: Cobb-Douglas (Appendix A)
#> Number of hypotheses: 5
#> Benchmark alpha: 0.0500
#> Sample size ratio: 1.00
#>
#> ------------------------------------------------------------
#> Optimal test size: 0.012327
#> Optimal z-threshold: 2.2468
#>
#> Bonferroni size: 0.010000
#> Bonferroni z-thresh: 2.3263
#> Sidak size: 0.010206
#> Sidak z-threshold: 2.3187
#> Unadjusted size: 0.050000
#> ------------------------------------------------------------
# Larger sample size => less conservative
mht_critical(J = 5, alpha_bar = 0.05, nm_ratio = 2.0)
#>
#> ------------------------------------------------------------
#> Optimal MHT Critical Values
#> Viviano, Wuthrich, and Niehaus (2026)
#> ------------------------------------------------------------
#>
#> Cost model: Linear (Eq. 27)
#> Number of hypotheses: 5
#> Benchmark alpha: 0.0500
#> Sample size ratio: 2.00
#>
#> ------------------------------------------------------------
#> Optimal test size: 0.035312
#> Optimal z-threshold: 1.8079
#>
#> Bonferroni size: 0.010000
#> Bonferroni z-thresh: 2.3263
#> Sidak size: 0.010206
#> Sidak z-threshold: 2.3187
#> Unadjusted size: 0.050000
#> ------------------------------------------------------------
Cost model formulas:
Linear (Eq. 26 of the paper): \[
\alpha^* = \alpha_{\text{bar}} \left[ \frac{1 + r/J}{1 + r} + \frac{n/m
- 1}{1 + r} \right]
\] where \(r = \frac{c_f \cdot
\bar{J}}{c_v \cdot m} = \frac{\text{cf\_share} \cdot J_{\text{bar}}}{1 -
\text{cf\_share}}\)
Cobb-Douglas: \[
\alpha^* = \alpha_{\text{bar}} \cdot J^{\beta - 1} \cdot (n/m)^\iota
\]
mht_test() — Test a Vector of P-Values
Use when you have pre-computed p-values (not from a regression).
pvals <- c(0.003, 0.015, 0.030, 0.048, 0.120, 0.500)
result <- mht_test(p = pvals, alpha_bar = 0.05)
print(result)
#>
#> -----------------------------------------------------------------
#> Multiple Hypothesis Testing Results
#> Viviano, Wuthrich, and Niehaus (2026)
#> -----------------------------------------------------------------
#>
#> Hypotheses tested: 6
#> Benchmark alpha: 0.0500
#> Cost model: Linear
#>
#> -------------------------------------------------------
#> Procedure Test size Rejections
#> -------------------------------------------------------
#> Optimal (model-based) 0.020052 2
#> Bonferroni 0.008333 1
#> Holm (step-down) 0.0500 * 1
#> BH (FDR control) 0.0500 * 2
#> Unadjusted 0.050000 4
#> -------------------------------------------------------
#> * Step-wise; effective threshold varies by rank
#>
#> Detailed results:
#> p_value reject_optimal reject_bonferroni reject_holm reject_bh
#> 0.003 TRUE TRUE TRUE TRUE
#> 0.015 TRUE FALSE FALSE TRUE
#> 0.030 FALSE FALSE FALSE FALSE
#> 0.048 FALSE FALSE FALSE FALSE
#> 0.120 FALSE FALSE FALSE FALSE
#> 0.500 FALSE FALSE FALSE FALSE
#> reject_unadjusted
#> TRUE
#> TRUE
#> TRUE
#> TRUE
#> FALSE
#> FALSE
Alternatively, pass z-statistics instead of p-values:
zstats <- c(3.2, 2.1, 1.8, 1.3, 0.8, -0.2)
mht_test(z = zstats, alpha_bar = 0.05)
#>
#> -----------------------------------------------------------------
#> Multiple Hypothesis Testing Results
#> Viviano, Wuthrich, and Niehaus (2026)
#> -----------------------------------------------------------------
#>
#> Hypotheses tested: 6
#> Benchmark alpha: 0.0500
#> Cost model: Linear
#>
#> -------------------------------------------------------
#> Procedure Test size Rejections
#> -------------------------------------------------------
#> Optimal (model-based) 0.020052 2
#> Bonferroni 0.008333 1
#> Holm (step-down) 0.0500 * 1
#> BH (FDR control) 0.0500 * 1
#> Unadjusted 0.050000 3
#> -------------------------------------------------------
#> * Step-wise; effective threshold varies by rank
#>
#> Detailed results:
#> p_value reject_optimal reject_bonferroni reject_holm reject_bh
#> 0.0006871379 TRUE TRUE TRUE TRUE
#> 0.0178644206 TRUE FALSE FALSE FALSE
#> 0.0359303191 FALSE FALSE FALSE FALSE
#> 0.0968004846 FALSE FALSE FALSE FALSE
#> 0.2118553986 FALSE FALSE FALSE FALSE
#> 0.5792597094 FALSE FALSE FALSE FALSE
#> reject_unadjusted
#> TRUE
#> TRUE
#> TRUE
#> FALSE
#> FALSE
#> FALSE
mht_table() — Generate Reference Tables
Reproduces Table 1 from the paper (or custom tables).
# Default: reproduces paper Table 1 exactly
# (J = 1..9 plus infinity, multiple alpha_bar and nm_ratio values)
mht_table()
#>
#> =====================================================================================================================================================
#> Optimal Critical Values (Linear (Eq. 27) model)
#> Viviano, Wuthrich, and Niehaus (2026)
#> =====================================================================================================================================================
#>
#> n/m=50% n/m=100% n/m=150% n/m=200% Sidak
#> -----------------------------------------------------------------------------------------------------------------------------------------------------
#> |J| a=0.025 a=0.050 a=0.100 a=0.150 a=0.025 a=0.050 a=0.100 a=0.150 a=0.025 a=0.050 a=0.100 a=0.150 a=0.025 a=0.050 a=0.100 a=0.150 a=0.025 a=0.050
#> -----------------------------------------------------------------------------------------------------------------------------------------------------
#> 1 0.021 0.043 0.086 0.129 0.025 0.050 0.100 0.150 0.029 0.057 0.114 0.171 0.032 0.064 0.128 0.192 0.025 0.050
#> 2 0.012 0.025 0.050 0.075 0.016 0.032 0.064 0.096 0.020 0.039 0.078 0.117 0.023 0.046 0.092 0.138 0.013 0.025
#> 3 0.010 0.019 0.038 0.057 0.013 0.026 0.052 0.078 0.017 0.033 0.066 0.099 0.020 0.040 0.080 0.120 0.008 0.017
#> 4 0.008 0.016 0.032 0.048 0.012 0.023 0.046 0.069 0.015 0.030 0.060 0.090 0.019 0.037 0.074 0.111 0.006 0.013
#> 5 0.007 0.014 0.028 0.043 0.011 0.021 0.042 0.064 0.014 0.028 0.057 0.085 0.018 0.035 0.071 0.106 0.005 0.010
#> 6 0.007 0.013 0.026 0.039 0.010 0.020 0.040 0.060 0.014 0.027 0.054 0.081 0.017 0.034 0.068 0.102 0.004 0.009
#> 7 0.006 0.012 0.024 0.036 0.010 0.019 0.038 0.058 0.013 0.026 0.052 0.079 0.017 0.033 0.067 0.100 0.004 0.007
#> 8 0.006 0.012 0.023 0.035 0.009 0.019 0.037 0.056 0.013 0.026 0.051 0.077 0.016 0.033 0.065 0.098 0.003 0.006
#> 9 0.006 0.011 0.022 0.033 0.009 0.018 0.036 0.054 0.013 0.025 0.050 0.075 0.016 0.032 0.064 0.096 0.003 0.006
#> Inf 0.004 0.007 0.014 0.021 0.007 0.014 0.028 0.042 0.011 0.021 0.042 0.063 0.014 0.028 0.056 0.084 0.000 0.000
#> -----------------------------------------------------------------------------------------------------------------------------------------------------
# Custom table
mht_table(alpha_bar = 0.05,
J_range = 1:10,
nm_ratios = c(0.5, 1.0, 1.5, 2.0))
#>
#> =====================================================
#> Optimal Critical Values (Linear (Eq. 27) model)
#> Viviano, Wuthrich, and Niehaus (2026)
#> =====================================================
#>
#> n/m=50% n/m=100% n/m=150% n/m=200% Sidak
#> -----------------------------------------------------
#> |J| a=0.050 a=0.050 a=0.050 a=0.050 a=0.025 a=0.050
#> -----------------------------------------------------
#> 1 0.043 0.050 0.057 0.064 0.025 0.050
#> 2 0.025 0.032 0.039 0.046 0.013 0.025
#> 3 0.019 0.026 0.033 0.040 0.008 0.017
#> 4 0.016 0.023 0.030 0.037 0.006 0.013
#> 5 0.014 0.021 0.028 0.035 0.005 0.010
#> 6 0.013 0.020 0.027 0.034 0.004 0.009
#> 7 0.012 0.019 0.026 0.033 0.004 0.007
#> 8 0.012 0.019 0.026 0.033 0.003 0.006
#> 9 0.011 0.018 0.025 0.032 0.003 0.006
#> 10 0.011 0.018 0.025 0.032 0.003 0.005
#> -----------------------------------------------------
Returns a formatted table and stores all values in
attr(result, "values").
Estimating Cost Functions
mht_cost_estimate() — Estimate Parameters from
Data
If you have data on research project costs, you can estimate cost
function parameters.
Cobb-Douglas model (default):
Estimates: \(\log(C) = \text{const} + \beta
\log(J) + \iota \log(n) + \text{controls}\)
Returns \(\beta\) and \(\iota\) with hypothesis tests: - \(H_0: \beta = 0\) ⇒ Bonferroni is optimal -
\(H_0: \beta = 1\) ⇒ no adjustment
needed
# Example data: cost, number of arms, sample size
projects <- data.frame(
cost = c(500000, 750000, 1200000, 600000, 900000),
arms = c(2, 3, 5, 2, 4),
n = c(1000, 1500, 2000, 800, 1200)
)
est <- mht_cost_estimate(
cost = projects$cost,
arms = projects$arms,
sample_size = projects$n,
alpha_bar = 0.05,
model = "cobbdouglas"
)
# View estimates
print(est)
# Use in mht_est()
fit <- lm(outcome ~ treat1 + treat2 + treat3, data = mydata)
mht_est(fit,
vars = c("treat1", "treat2", "treat3"),
alpha_bar = 0.05,
model = "cobbdouglas",
beta = est$beta,
iota = est$iota)
Linear model:
est_linear <- mht_cost_estimate(
cost = projects$cost,
arms = projects$arms,
sample_size = projects$n,
alpha_bar = 0.05,
model = "linear_share"
)
# Returns cf_share (fixed cost share)
mht_est(fit,
vars = c("treat1", "treat2", "treat3"),
alpha_bar = 0.05,
cf_share = est_linear$cf_share)
Worked Example
# Simulate a simple experiment
set.seed(123)
n <- 300
df <- data.frame(
treat1 = rbinom(n, 1, 0.33),
treat2 = rbinom(n, 1, 0.33),
treat3 = rbinom(n, 1, 0.33)
)
df$outcome <- 0.3 * df$treat1 + 0.2 * df$treat2 + 0.05 * df$treat3 + rnorm(n)
# Estimate effects
fit <- lm(outcome ~ treat1 + treat2 + treat3, data = df)
summary(fit)
# Apply optimal MHT adjustment
result <- mht_est(fit,
vars = c("treat1", "treat2", "treat3"),
alpha_bar = 0.05)
print(result)
# Compare: how many rejections under each procedure?
colSums(result[, grep("^reject_", names(result))])
One-Sided vs Two-Sided Tests
Default behavior (onesided = TRUE): tests \(H_0: \beta_j \leq 0\) vs \(H_1: \beta_j > 0\).
Justification (Remark 4.1, paper v10): When the
status quo is “no intervention,” a negative effect means the status quo
holds (no social value lost by failing to reject). One-sided tests are
appropriate.
For two-sided tests (Appendix B.1):
mht_est(fit,
vars = c("treat1", "treat2", "treat3"),
alpha_bar = 0.05,
onesided = FALSE)
P-values are multiplied by 2 and compared against the same \(\alpha^*\).
Sample Size Adjustment
The nm_ratio parameter captures how your study’s sample
size compares to a benchmark:
\[
\text{nm\_ratio} = \frac{n/J}{m}
\]
where: - \(n\) = total sample size
in your study - \(J\) = number of
hypotheses/arms - \(m\) = benchmark
per-arm sample size
Larger studies are more expensive → optimal \(\alpha^*\) is less conservative
(encourages research):
# Small study (half the benchmark size)
mht_critical(J = 3, alpha_bar = 0.05, nm_ratio = 0.5)
#>
#> ------------------------------------------------------------
#> Optimal MHT Critical Values
#> Viviano, Wuthrich, and Niehaus (2026)
#> ------------------------------------------------------------
#>
#> Cost model: Linear (Eq. 27)
#> Number of hypotheses: 3
#> Benchmark alpha: 0.0500
#> Sample size ratio: 0.50
#>
#> ------------------------------------------------------------
#> Optimal test size: 0.019010
#> Optimal z-threshold: 2.0746
#>
#> Bonferroni size: 0.016667
#> Bonferroni z-thresh: 2.1280
#> Sidak size: 0.016952
#> Sidak z-threshold: 2.1212
#> Unadjusted size: 0.050000
#> ------------------------------------------------------------
# Benchmark size
mht_critical(J = 3, alpha_bar = 0.05, nm_ratio = 1.0)
#>
#> ------------------------------------------------------------
#> Optimal MHT Critical Values
#> Viviano, Wuthrich, and Niehaus (2026)
#> ------------------------------------------------------------
#>
#> Cost model: Linear (Eq. 27)
#> Number of hypotheses: 3
#> Benchmark alpha: 0.0500
#> Sample size ratio: 1.00
#>
#> ------------------------------------------------------------
#> Optimal test size: 0.026042
#> Optimal z-threshold: 1.9424
#>
#> Bonferroni size: 0.016667
#> Bonferroni z-thresh: 2.1280
#> Sidak size: 0.016952
#> Sidak z-threshold: 2.1212
#> Unadjusted size: 0.050000
#> ------------------------------------------------------------
# Large study (double the benchmark)
mht_critical(J = 3, alpha_bar = 0.05, nm_ratio = 2.0)
#>
#> ------------------------------------------------------------
#> Optimal MHT Critical Values
#> Viviano, Wuthrich, and Niehaus (2026)
#> ------------------------------------------------------------
#>
#> Cost model: Linear (Eq. 27)
#> Number of hypotheses: 3
#> Benchmark alpha: 0.0500
#> Sample size ratio: 2.00
#>
#> ------------------------------------------------------------
#> Optimal test size: 0.040104
#> Optimal z-threshold: 1.7495
#>
#> Bonferroni size: 0.016667
#> Bonferroni z-thresh: 2.1280
#> Sidak size: 0.016952
#> Sidak z-threshold: 2.1212
#> Unadjusted size: 0.050000
#> ------------------------------------------------------------
The mbar argument automates this: if you know the
typical per-arm sample size in your field, pass it via
mbar, and the function computes nm_ratio
automatically from the model’s sample size.
References
Viviano, D., Wuthrich, K., & Niehaus, P. (2026). A Model of
Multiple Hypothesis Testing. arXiv:2104.13367v10.
Sertkaya, A., Wong, H. H., Jessup, A., & Beleche, T. (2016). Key
cost drivers of pharmaceutical clinical trials in the United States.
Clinical Trials, 13(2), 117-126.