--- title: "Scientific validation" output: rmarkdown::pdf_document vignette: > %\VignetteIndexEntry{Scientific validation} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, echo = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>", eval = TRUE) ``` ```{r setup} library(testflow) ``` ## Purpose Passing tests only proves a package is internally consistent: a formula reproduces its own citation, a search converges to *some* answer, `R CMD check` runs clean. None of that proves the formula is the *right* one, or that the search converges to the *correct* answer. This vignette documents a different, stronger standard applied across `testflow`'s statistical surface: every formula with real risk of being wrong - anything bespoke, iterative, or approximate - was checked against an independent source: a hand re-derivation, a Monte Carlo simulation, or an established, separately maintained R package that implements the same calculation. That distinction is not academic. Two real defects were found this way, both silent - no error, no warning, a clean `R CMD check`, and numbers plausible enough that nothing about them looked wrong until they were checked against an outside reference. Both are described below, together with the wider set of checks that turned up nothing. ## What "validated" means here For a formula to count as validated in this document, one of the following had to hold: - **External package agreement.** The result matches an independently maintained R package that implements the same statistic, to numerical precision (typically 1e-6 or better; sometimes exact, e.g. `1e-16` for the Wilson interval against `stats::prop.test()`). - **Independent re-derivation.** The formula was re-derived from the underlying probability model (e.g. integrating the exponential hazard over uniform accrual by hand) and checked against `stats::integrate()` or a closed-form solution, without re-reading the package's own code. - **Simulation.** For quantities defined by a data-generating process (e.g. the probability of an event under staggered trial accrual), a large Monte Carlo simulation (millions of draws) confirmed the closed form to within simulation error. Internal-only checks - "the code reproduces the formula written in its own roxygen block" - do not count on their own, precisely because that is the standard that let both defects below ship undetected. ## Two defects found and fixed ### Bioequivalence sample size: `iterative_tost` was not exact `sample_size_bioequivalence(method = "iterative_tost")` searches for the smallest sample size achieving a target two-one-sided-test (TOST) power for average bioequivalence. Until this validation pass, the power calculation used a **normal approximation** (`stats::pnorm()`/`qnorm()`), even though it was documented as exact. The correct calculation uses the *noncentral* t distribution, because the estimated standard error carries its own sampling variability that a normal approximation ignores. The consequence was concrete: at `GMR = 0.95`, `CV = 15%`, 90% power, the old (normal-approximation) code returned `n = 13`; the correct noncentral-t calculation requires `n = 16` - an under-sized study by about 23%. ```{r, eval = requireNamespace("PowerTOST", quietly = TRUE)} # Current (fixed) implementation: testflow treats n as one continuous total # and rounds up to the nearest even number, since a 2x2 crossover has two # equal-sized sequences x <- sample_size_bioequivalence( design = "crossover", gmr = 0.95, cv_within = 0.15, alpha = 0.05, power = 0.90, method = "iterative_tost" ) n_testflow <- 2 * ceiling(x$n / 2) n_testflow # Cross-check against PowerTOST's Owen's-Q-based exact calculation (the # calculation used in real regulatory bioequivalence submissions) PowerTOST::sampleN.TOST( alpha = 0.05, targetpower = 0.90, theta0 = 0.95, theta1 = 0.80, theta2 = 1.25, CV = 0.15, design = "2x2", print = FALSE )$`Sample size` ``` The two match exactly. See `?sample_size_bioequivalence` for the full noncentral-t formula. ### Cohen's kappa: the hypothesis test used the wrong standard error `test_agreement()` reports Cohen's kappa with a confidence interval and a test of \(H_0: \kappa = 0\). Two *different* standard errors are needed for these two purposes, because the sampling variance of \(\hat\kappa\) depends on \(\kappa\) itself: - the CI needs the SE *at* \(\hat\kappa\) (Fleiss, Cohen & Everitt, 1969); - the null test needs the SE *at* \(\kappa = 0\) (Fleiss, 1981) - a different, generally larger, quantity whenever agreement is materially above chance, which is the typical case. The code computed the CI's SE correctly, then reused it as the test denominator instead of the null SE. This inflated the z-statistic and made p-values anti-conservative (too small - a false sense of significance), though the point estimate and CI themselves were unaffected. ```{r, eval = requireNamespace("irr", quietly = TRUE)} set.seed(2) n <- 200 cats <- c("A", "B", "C") rater1 <- sample(cats, n, replace = TRUE, prob = c(0.5, 0.3, 0.2)) rater2 <- ifelse(runif(n) < 0.75, rater1, sample(cats, n, replace = TRUE)) dat <- data.frame(r1 = factor(rater1, levels = cats), r2 = factor(rater2, levels = cats)) x <- test_agreement(dat, rater1 = r1, rater2 = r2) x$primary_test[, c("statistic", "p.value")] irr::kappa2(dat[, c("r1", "r2")])[c("statistic", "p.value")] ``` The two now match exactly. Before the fix, `testflow` reported z = 17.2 here instead of the correct 14.4. ## Everything else checked, with nothing found The rest of the package's statistical surface was checked the same way. No further defects were found; results are summarized rather than reproduced live here, since exhaustively re-running every cross-check would require installing a dozen reference packages just to build this vignette. | Area | Checked against | Result | |---|---|---| | One-proportion precision (Wald/Wilson/exact), minimum-*n* search | `stats::prop.test()`, `stats::binom.test()`, exhaustive brute-force scan | Exact match; brute-force testing caught and fixed a separate non-monotonicity bug in the search algorithm itself (see `NEWS.md`) | | Survival: uniform-accrual event probability | `stats::integrate()`, Monte Carlo simulation (2M draws) | Matches to 1e-14 / within simulation error | | Survival: Schoenfeld and Freedman event formulas | Hand-derivation from the cited references | Exact match | | Precision planning: risk-difference (unequal allocation), log-odds-ratio | Independent variance-formula derivation | Exact match | | Continuous/binary sample size (superiority, NI, equivalence) | `TrialSize` (same methodology), `pwr` (different, exact-t methodology; small expected divergence confirmed) | Exact match vs. `TrialSize`; expected small gap vs. `pwr` | | Linear regression (F-test, R2, adjusted R2), logistic regression (LR-test, McFadden R2), VIF | `stats::lm`/`glm`, `lmtest::lrtest()`, `pscl::pR2()` | Exact match | | Symmetry test (Cabilio-Masaro) | `lawstat::symmetry.test(option = "CM")` | Exact match | | Factorial ANOVA (Type I/II/III), eta squared | `car::Anova()`, `effectsize::eta_squared()` | Exact match | | Repeated-measures ANOVA, Mauchly's W, generalized eta squared | `rstatix::anova_test()` | Exact match | | Friedman test effect size, Cochran's Q | Hand-derivation, `DescTools::CochranQTest()` | Exact match | | Diagnostic accuracy (sensitivity, specificity, PPV, NPV, LR+, LR-) | `epiR::epi.tests()` | Exact match | | ROC / AUC (Hanley-McNeil), Youden's J | Independent re-derivation, `pROC` | Exact match | | ICC(1,1), ICC(2,1), ICC(3,1), including the Satterthwaite-approximated CI | `psych::ICC()` | Matches to 5+ significant figures | | Cohen's d (independent, one-sample, paired), Cramer's V, rank-biserial correlation | `effectsize` | Exact match | | Kruskal-Wallis epsilon squared | `rstatix::kruskal_effsize()` | Exact match | | Cox regression (LR-test, concordance) | `survival::coxph()` internals | Exact match | | Correlation, categorical association, proportion tests | Thin `stats::` wrappers | Delegate correctly by construction | ## What this does and does not guarantee This validation targeted correctness of the implemented formulas against their stated methodology - not every possible methodological choice. A formula can be correctly implemented and still not be the specific convention you expect (for example, `testflow`'s Cramer's V is the raw, non-bias-corrected statistic, and its rank-biserial correlation uses the opposite sign convention to some other packages) - these are documented choices, not defects, and worth checking against your own convention if it matters for your use case. It also does not certify code paths with no numerically bespoke logic to get wrong in the first place (formatting, plotting, printing) - those carry essentially no statistical risk and were not part of this exercise. If you find a discrepancy against another tool, please open an issue with a reproducible example: `https://github.com/ielbadisy/testflow/issues`. The two defects above were both silent for some time before being checked this way, and the most reliable way to keep it from happening again is the same one that found them - compare against an independent source, not just the package's own tests.