Bug in function power.TOST() [🇷 for BE/BA]

posted by Helmut Homepage – Vienna, Austria, 2025-08-22 12:28 (306 d 13:17 ago) – Posting: # 24434
Views: 4,203

Dear all,

[image]THX to ElMaestro for detecting a bug in power.TOST(). Say, you dosed N subjects and are interested in post hoc power of a fraction f of this sample size (e.g., of eligible subjects). Calling power.TOST(..., n = f * N) might give a lower than the correct power.
Example for CV = 0.3, N = 90, f = 0.7 (using all the defaults of a 2×2 design):

power.TOST(CV = 0.3, n = 63)              # eligible subjects
Unbalanced design. n(i)=32/31 assumed.
[1] 0.9453907
                            # correct (given the assumption)

power.TOST(CV = 0.3, n = c(32, 31))       # explicit subjects / sequence
[1] 0.9453907                             # correct

power.TOST(CV = 0.3, n = 0.7 * 90)        # eligible subjects calculated
[1] 0.9424189                             # wrong (too low)

power.TOST(CV = 0.3, n = round(0.7 * 90)) # quick fix
Unbalanced design. n(i)=32/31 assumed.
[1] 0.9453907
                            # correct (given the assumption)


Why is that so? in the vectorization of the total sample size (to get subjects / sequence), trunc(n) is used. That is correct for n = 63 but not for n = 0.7 * 90.

trunc(63); trunc(0.7 * 90)
[1] 63
[1] 62

Therefore, the degrees of freedom are only 60 instead of 61 and, consequently, the post hoc power is lower. The third example should rise an eyebrow because we don’t get a message about an unbalanced design. The quick fix works:

63; round(0.7 * 90)
[1] 63
[1] 63

We can look behind the scenes of the internal function nvec(n, grps) of PowerTOST and the planned new function:

new.fun <- function(n, grps) {
  n.int     <- as.integer(round(n))
  quotient  <- n.int %/% grps
  remainder <- n.int %% grps
  nv        <- c(rep.int(quotient + 1L, remainder),
                 rep.int(quotient, grps - remainder))
  return(as.integer(nv))
}
res <- data.frame(Approach =c("Explicit sequences", "Total sample size",
                              "f * N", "Workaround", "New function"),
                  N = c(NA, 63, 0.7 * 90, round(0.7 * 90), 0.7 * 90),
                  n1 = c(32L, rep(NA_integer_, 4)),
                  n2 = c(31L, rep(NA_integer_, 4)),
                  n = c(32L + 31L, rep(NA_integer_, 4)), correct = TRUE,
                  df = c(63L - 2L, rep(NA_integer_, 4)))
for (j in 2:nrow(res)) {
  if (j < nrow(res)) {
    res[j, 3:4] <- PowerTOST:::nvec(res$N[j], 2)
  } else {
    res[j, 3:4] <- new.fun(res$N[j], 2)
  }
  res[j, 5]   <- sum(res[j, 3:4])
  res$df[j]   <- res[j, 5] - 2
  if (!res$df[j] == res$df[1]) res$correct[j] <- FALSE
}
print(res, row.names = FALSE, right = FALSE)

 Approach           N  n1 n2 n  correct df
 Explicit sequences NA 32 31 63  TRUE   61
 Total sample size  63 32 31 63  TRUE   61
 f * N              63 31 31 62 FALSE   60
 Workaround         63 32 31 63  TRUE   61
 New function       63 32 31 63  TRUE   61


Not for the first time we are a victim of floating-point arithmetic.

print(63); print(0.7 * 90)               # by default 7 significant digits are shown
[1] 63
[1] 63                                   # they look the same
63 == 0.7 * 90; identical(63, 0.7 * 90)  # are they the same?
[1] FALSE
[1] FALSE
                                # obviously not
print(63, digits = 16); print(0.7 * 90, digits = 16); print(round(0.7 * 90), digits = 16)
[1] 63
[1] 62.99999999999999
                    # gotcha!
[1] 63                                   # the workaround
63 == round(0.7 * 90); identical(63, round(0.7 * 90))
[1] TRUE
[1] TRUE


For details see the issue at GitHub. Until it is resolved and the library updated, please use power.TOST(n = round(f * N)).
[image]However, best is to give the subjects / sequence explicitly. The functions of PowerTOST don’t have a crystal ball and can only guess while keeping the sequences as balanced as possible. Only you know the actual values. If the sample size is divisible by the number of sequence, power.TOST() assumes balanced sequences. Quoting the note in the man-page of power.TOST():

If n is given as scalar (total sample size) and this number is not divisible by the number of (sequence) groups of the design an unbalanced design with small imbalance is assumed. A corresponding message is thrown showing the assumed numbers of subjects in (sequence) groups.

This may, of course, be incorrect.

n    <- 40
nseq <- 2
n %% nseq     # modulo
[1] 0         # remainder zero, power.TOST() assumes balanced sequences
power.TOST(CV = 0.3, n = n)
[1] 0.8158453 # in good faith
power.TOST(CV = 0.3, n = c(24, 16))
[1] 0.8002523 # actually quite imbalanced, lower power



The internal function nvec(n, grps) of PowerTOST is called from its following functions:

CVfromCI(), power.HVNTID(), power.NTID(), power.RatioF(), power.RSABE(), power.scABEL(), power.TOST(), power.TOST.sds(), power.TOST.sim(), pvalues.TOST(), scABEL.ad().

Until the package is updated, please give the total samples size n (assuming almost balanced sequences), the observed sequences as a vector (preferred), or use the workaround with rounding. Sorry for the inconvenience.

Dif-tor heh smusma 🖖🏼 Довге життя Україна! [image]
Helmut Schütz
[image]

The quality of responses received is directly proportional to the quality of the question asked. 🚮
Science Quotes

Complete thread:

UA Flag
Activity
 Admin contact
23,655 posts in 4,993 threads, 1,570 registered users;
135 visitors (0 registered, 135 guests [including 29 identified bots]).
Forum time: 01:46 CEST (Europe/Vienna)

Scientists often have a naïve faith that
if only they could discover enough facts about a problem,
these facts would somehow arrange themselves
in a compelling and true solution.    Theodosius Dobzhansky

The Bioequivalence and Bioavailability Forum is hosted by
BEBAC Ing. Helmut Schütz
HTML5