Diagnostic Accuracy (sensitivity/specificity) Sample Size Calculator

statistics
shiny
blog
Published

February 20, 2025

Diagnostics studies are relatively simple to conduct but require careful planning and data collection. It is important to identify gold standard test and diagnostic criteria you are going to use (usually some sort of standard of care) and an experimental test you are going to study. You must have an idea of the prevalence of the disease you are studying and the estimated sensitivity and specificity of the experimental diagnostics. Sometimes, we want to correlate two diagnostic measures that are not binary, like Cd4 and Viral load. This is a different type of analysis and we will cover this in a future post. There is also finding a threshold of a continuous diagnostic, perhaps a viral load, that is most predictive of a binary outcome, like death. This is also a different type of analysis and we will cover this in a future post.

It is crucial to include enough subjects in both the diseased and non-diseased groups. Two key performance measures are sensitivity (the test’s ability to correctly identify those with the disease) and specificity (the test’s ability to correctly identify those without the disease).

To estimate either sensitivity or specificity with a desired level of precision, you can use the standard formula for proportions:

\[ n=z^2×p(1−p)d^2 \]

\[ n = \frac{z^2 \times p(1-p)}{d^2} \]

\[ n=d^2z^2×p(1−p)​ \]

Where:

Practical Example:
Suppose you expect your diagnostic test to have a sensitivity of 90% (0.90) and you desire a 95% confidence interval with a margin of error of ±5% (0.05). The required number of diseased subjects is:

\[ n_{\text{disease}} =(1.96)^2×0.90(1−0.90)(0.05)^2≈139 \] \[ n_{\text{disease}} = \frac{(1.96)^2 \times 0.90(1-0.90)}{(0.05)^2} \approx 139 \] \[ n_{\text{disease}} =(0.05)^2(1.96)^2×0.90(1−0.90)​≈139 \]

Similarly, if you expect a specificity of 90%, you would also need approximately 139 non-diseased subjects to achieve the same precision.

If the prevalence of the disease in your study population is known, you can determine the overall total sample size required. For example, if the disease prevalence is 10% (0.10), then to obtain about 139 diseased subjects you would need approximately:

Total sample size (for disease)=1390.10≈1390 = 1390Total sample size (for disease)=0.10139​≈1390

Likewise, you can calculate the total number needed to yield the required number of non-diseased subjects.

Reference:
Buderer, N. M. F. (1996). Statistical methodology: I. Incorporating the prevalence of disease into the sample size calculation for sensitivity and specificity. Academic Emergency Medicine, 3(9), 895-900.

Interactive Example

Below is an interactive Shiny tool pre‐set to the example above. The default inputs assume:

  • An expected sensitivity and specificity of 90%

  • A desired margin of error of 5%

  • A 95% confidence level (with z≈1.96z 1.96z≈1.96)

  • A disease prevalence of 10%

Users can adjust these parameters to match the specifics of their own diagnostic study.

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| echo: false 
#| messages: false
#| viewerHeight: 800

library(shiny)
library(shinythemes)
library(shinyjs)

ui <- fluidPage(
  theme = shinytheme("cyborg"),
  useShinyjs(),
  tags$style(HTML("
    #explanationText {
      font-family: 'Times New Roman', Times, serif;
      white-space: pre-wrap;
    }
    .copy-button {
      margin-bottom: 10px;
    }
  ")),
  tags$script(HTML("
    function copyExplanation() {
      var explanation = document.getElementById('explanationText').innerText;
      var tempInput = document.createElement('textarea');
      tempInput.value = explanation;
      document.body.appendChild(tempInput);
      tempInput.select();
      document.execCommand('copy');
      document.body.removeChild(tempInput);
      alert('Complete calculation with your current inputs copied to clipboard!');
    }
  ")),
  
  titlePanel("Sample Size Calculator for Diagnostic Accuracy Studies"),
  sidebarLayout(
    sidebarPanel(
      numericInput("sens", "Expected Sensitivity:", value = 0.90, min = 0.5, max = 1, step = 0.01),
      numericInput("spec", "Expected Specificity:", value = 0.90, min = 0.5, max = 1, step = 0.01),
      numericInput("d", "Desired Margin of Error (d):", value = 0.05, min = 0.01, max = 0.2, step = 0.01),
      numericInput("alpha", "Significance Level (α):", value = 0.05, min = 0.001, max = 0.1, step = 0.005),
      numericInput("prev", "Disease Prevalence:", value = 0.10, min = 0.001, max = 1, step = 0.01)
    ),
    mainPanel(
      h3(textOutput("resultTitle")),
      uiOutput("resultsUI"),
      tags$button("Copy Full Calculation", class = "copy-button", onclick = "copyExplanation()"),
      uiOutput("explanationUI"),
      plotOutput("barPlot")
    )
  )
)

server <- function(input, output, session) {
  
  # Calculate critical value from alpha
  calc_z <- reactive({
    qnorm(1 - input$alpha/2)
  })
  
  # Calculate required sample sizes for sensitivity and specificity (in diseased and non-diseased groups)
  calc_sample_sizes <- reactive({
    z <- calc_z()
    sens <- input$sens
    spec <- input$spec
    d <- input$d
    prev <- input$prev
    
    # Sample size for diseased subjects (for sensitivity)
    n_disease <- (z^2 * sens * (1 - sens)) / (d^2)
    # Sample size for non-diseased subjects (for specificity)
    n_nondisease <- (z^2 * spec * (1 - spec)) / (d^2)
    
    # Overall total required given the prevalence
    total_disease <- n_disease / prev
    total_nondisease <- n_nondisease / (1 - prev)
    overall_total <- ceiling(max(total_disease, total_nondisease))
    
    list(n_disease = ceiling(n_disease),
         n_nondisease = ceiling(n_nondisease),
         total_disease = ceiling(total_disease),
         total_nondisease = ceiling(total_nondisease),
         overall_total = overall_total)
  })
  
  output$resultTitle <- renderText({
    "Estimated Sample Size Requirements"
  })
  
  output$resultsUI <- renderUI({
    sizes <- calc_sample_sizes()
    tagList(
      tags$p(paste("Required number of diseased subjects (for sensitivity):", sizes$n_disease)),
      tags$p(paste("Estimated total sample size needed to yield sufficient diseased subjects:", sizes$total_disease)),
      tags$p(paste("Required number of non-diseased subjects (for specificity):", sizes$n_nondisease)),
      tags$p(paste("Estimated total sample size needed to yield sufficient non-diseased subjects:", sizes$total_nondisease)),
      tags$p(paste("Overall total sample size required (largest estimate):", sizes$overall_total))
    )
  })
  
  output$explanationUI <- renderUI({
    sizes <- calc_sample_sizes()
    z <- calc_z()
    sens <- input$sens
    spec <- input$spec
    d <- input$d
    alpha <- input$alpha
    prev <- input$prev
    confidence_level <- (1 - alpha) * 100
    
    # Calculate intermediate values for explanation
    sens_numerator <- z^2 * sens * (1 - sens)
    spec_numerator <- z^2 * spec * (1 - spec)
    denominator <- d^2
    
    explanation <- paste0(
      "CALCULATION BASED ON YOUR INPUTS:\n\n",
      "Parameters:\n",
      "• Expected Sensitivity: ", round(sens, 3), " (", round(sens*100, 1), "%)\n",
      "• Expected Specificity: ", round(spec, 3), " (", round(spec*100, 1), "%)\n",
      "• Desired Margin of Error: ±", round(d, 3), " (±", round(d*100, 1), "%)\n",
      "• Confidence Level: ", round(confidence_level, 1), "% (α = ", alpha, ", z = ", round(z, 3), ")\n",
      "• Disease Prevalence: ", round(prev, 3), " (", round(prev*100, 1), "%)\n\n",
      "Formula: n = (z² × p(1-p)) / d²\n\n",
      "For SENSITIVITY (diseased subjects needed):\n",
      "    n_sens = (", round(z, 3), "² × ", round(sens, 3), " × (1-", round(sens, 3), ")) / ", round(d, 3), "²\n",
      "    n_sens = (", round(z^2, 3), " × ", round(sens, 3), " × ", round(1-sens, 3), ") / ", round(d^2, 4), "\n",
      "    n_sens = ", round(sens_numerator, 4), " / ", round(denominator, 4), " = ", round(sens_numerator/denominator, 1), "\n",
      "    Required diseased subjects: ", sizes$n_disease, "\n\n",
      "For SPECIFICITY (non-diseased subjects needed):\n",
      "    n_spec = (", round(z, 3), "² × ", round(spec, 3), " × (1-", round(spec, 3), ")) / ", round(d, 3), "²\n",
      "    n_spec = (", round(z^2, 3), " × ", round(spec, 3), " × ", round(1-spec, 3), ") / ", round(d^2, 4), "\n",
      "    n_spec = ", round(spec_numerator, 4), " / ", round(denominator, 4), " = ", round(spec_numerator/denominator, 1), "\n",
      "    Required non-diseased subjects: ", sizes$n_nondisease, "\n\n",
      "TOTAL SAMPLE SIZE CALCULATIONS:\n",
      "Given disease prevalence of ", round(prev*100, 1), "% (", round(prev, 3), "):\n\n",
      "To obtain ", sizes$n_disease, " diseased subjects:\n",
      "    Total needed = ", sizes$n_disease, " / ", round(prev, 3), " = ", sizes$total_disease, "\n\n",
      "To obtain ", sizes$n_nondisease, " non-diseased subjects:\n",
      "    Total needed = ", sizes$n_nondisease, " / ", round(1-prev, 3), " = ", sizes$total_nondisease, "\n\n",
      "FINAL RECOMMENDATION:\n",
      "Overall total sample size required: ", sizes$overall_total, " participants\n",
      "(This is the larger of the two total sample size estimates)\n\n",
      "With this sample size, you can expect:\n",
      "• Approximately ", round(sizes$overall_total * prev), " diseased subjects\n",
      "• Approximately ", round(sizes$overall_total * (1-prev)), " non-diseased subjects\n\n",
      "Reference: Buderer, N. M. F. (1996). Statistical methodology: I. Incorporating the prevalence of disease into the sample size calculation for sensitivity and specificity. Academic Emergency Medicine, 3(9), 895-900."
    )
    
    tags$pre(explanation, id = "explanationText")
  })
  
  output$barPlot <- renderPlot({
    sizes <- calc_sample_sizes()
    bar_data <- c(sizes$total_disease, sizes$total_nondisease)
    bar_labels <- c("Total for Diseased", "Total for Non-Diseased")
    
    barplot(bar_data, names.arg = bar_labels,
            col = c("tomato", "skyblue"),
            main = "Estimated Total Sample Sizes",
            ylab = "Number of Participants",
            ylim = c(0, max(bar_data) * 1.1))
    abline(h = sizes$overall_total, col = "darkgreen", lty = 2)
    legend("topright", legend = paste("Overall Required =", sizes$overall_total),
           bty = "n", col = "darkgreen", lty = 2)
  })
}

shinyApp(ui = ui, server = server)
../after.html