Skip to content

Power Analysis

Comprehensive guide for sample size calculation and power analysis in clinical trial simulation.


Overview

Power analysis enables determination of the sample size required to detect a clinically meaningful effect with adequate statistical power.

using NeoPKPD

# Calculate sample size for parallel design
result = power_analysis(
    design = :parallel,
    effect_size = 0.5,
    alpha = 0.05,
    power = 0.80,
    cv = 0.30
)

println("Required N per arm: $(result.n_per_arm)")

Key Concepts

Statistical Power

Power is the probability of detecting an effect when one truly exists:

  • Power = 1 - β (β = Type II error rate)
  • Typical target: 80% or 90%
  • Higher power requires larger sample size

Effect Size

The magnitude of the difference to detect:

# Cohen's d for continuous outcomes
cohens_d = (mean1 - mean2) / pooled_sd

# Geometric Mean Ratio (GMR) for PK
gmr = exp(mean_log_test - mean_log_reference)

# Relative difference
relative_diff = (mean_test - mean_reference) / mean_reference

Parallel Design Power

Two-Arm Comparison

struct ParallelPowerSpec
    effect_size::Float64        # Expected difference / SD
    alpha::Float64              # Type I error rate
    power::Float64              # Target power
    cv::Float64                 # Coefficient of variation
    allocation_ratio::Float64   # N_treatment / N_control
    test::Symbol                # :two_sided, :one_sided
end

# Create specification
spec = ParallelPowerSpec(
    effect_size = 0.5,          # Medium effect
    alpha = 0.05,
    power = 0.80,
    cv = 0.30,
    allocation_ratio = 1.0,
    test = :two_sided
)

# Calculate sample size
result = calculate_sample_size(spec)
println("N per arm: $(result.n_per_arm)")
println("Total N: $(result.n_total)")

Sample Size Formula

function sample_size_parallel(
    effect_size::Float64,
    alpha::Float64,
    power::Float64;
    test::Symbol = :two_sided
)
    # Z values
    z_alpha = test == :two_sided ? quantile(Normal(), 1 - alpha/2) : quantile(Normal(), 1 - alpha)
    z_beta = quantile(Normal(), power)

    # Sample size per group
    n = 2 * ((z_alpha + z_beta) / effect_size)^2

    return ceil(Int, n)
end

# Example
n = sample_size_parallel(0.5, 0.05, 0.80)
println("Required n per arm: $n")  # 64 per arm

Multi-Arm Designs

# Sample size for multiple treatment arms
function sample_size_multiarm(
    n_arms::Int,
    effect_size::Float64,
    alpha::Float64,
    power::Float64;
    comparison::Symbol = :dunnett  # :dunnett, :bonferroni, :none
)
    # Adjust alpha for multiple comparisons
    adjusted_alpha = if comparison == :bonferroni
        alpha / (n_arms - 1)
    elseif comparison == :dunnett
        dunnett_alpha(alpha, n_arms - 1)
    else
        alpha
    end

    # Per-arm sample size
    z_alpha = quantile(Normal(), 1 - adjusted_alpha/2)
    z_beta = quantile(Normal(), power)

    n_per_arm = ceil(Int, 2 * ((z_alpha + z_beta) / effect_size)^2)

    return (
        n_per_arm = n_per_arm,
        n_total = n_per_arm * n_arms,
        adjusted_alpha = adjusted_alpha
    )
end

Crossover Design Power

2×2 Crossover

struct CrossoverPowerSpec
    gmr::Float64                # Expected geometric mean ratio
    cv_within::Float64          # Within-subject CV
    theta1::Float64             # Lower BE limit (e.g., 0.80)
    theta2::Float64             # Upper BE limit (e.g., 1.25)
    alpha::Float64              # Type I error rate
    power::Float64              # Target power
end

# BE study sample size
spec = CrossoverPowerSpec(
    gmr = 0.95,                 # Expected GMR of 95%
    cv_within = 0.25,           # 25% within-subject CV
    theta1 = 0.80,
    theta2 = 1.25,
    alpha = 0.05,
    power = 0.80
)

result = calculate_be_sample_size(spec)
println("Required N: $(result.n_total)")

Sample Size Calculation

function sample_size_crossover_be(
    gmr::Float64,
    cv_within::Float64,
    theta1::Float64,
    theta2::Float64,
    alpha::Float64,
    power::Float64
)
    # Convert CV to log-scale SD
    sigma_w = sqrt(log(1 + cv_within^2))

    # Z values
    z_alpha = quantile(Normal(), 1 - alpha)
    z_beta = quantile(Normal(), power)

    # Distance from GMR to nearest limit
    delta = min(log(gmr) - log(theta1), log(theta2) - log(gmr))

    # Sample size (2×2 crossover)
    n = 2 * ((z_alpha + z_beta) * sigma_w / delta)^2

    return ceil(Int, n)
end

# Example: 25% CV, GMR = 0.95
n = sample_size_crossover_be(0.95, 0.25, 0.80, 1.25, 0.05, 0.80)
println("Required N: $n")  # ~24 subjects

Replicate Crossover Designs

# Sample size for 4-period replicate design
function sample_size_replicate_be(
    gmr::Float64,
    cv_within::Float64,
    theta1::Float64,
    theta2::Float64,
    alpha::Float64,
    power::Float64;
    design::Symbol = :trtr_rtrt  # :trtr_rtrt, :trt_rtr, etc.
)
    # Design efficiency factors
    efficiency = Dict(
        :trtr_rtrt => 2.0,      # Full replicate
        :trt_rtr => 1.5,        # Partial replicate (TRR, RTR, RRT)
        :tr_rt => 1.0           # Standard 2×2
    )

    # Adjusted sample size
    n_2x2 = sample_size_crossover_be(gmr, cv_within, theta1, theta2, alpha, power)
    n_rep = ceil(Int, n_2x2 / efficiency[design])

    return n_rep
end

Highly Variable Drugs

RSABE Sample Size (FDA)

function sample_size_rsabe(
    gmr::Float64,
    cv_reference::Float64;
    theta_s::Float64 = 0.8928,  # Scaling factor
    sigma_w0::Float64 = 0.25,   # Regulatory cutoff
    alpha::Float64 = 0.05,
    power::Float64 = 0.80
)
    sigma_wr = sqrt(log(1 + cv_reference^2))

    # Use scaled limits if CV > 30%
    if sigma_wr > sigma_w0
        # Scaled limits
        scaled_limit = exp(theta_s * sigma_wr)
        n = sample_size_scaled_be(gmr, cv_reference, scaled_limit, alpha, power)
    else
        # Standard limits
        n = sample_size_crossover_be(gmr, cv_reference, 0.80, 1.25, alpha, power)
    end

    return n
end

# HVD with 45% CV
n = sample_size_rsabe(0.95, 0.45)
println("RSABE sample size: $n")

ABEL Sample Size (EMA)

function sample_size_abel(
    gmr::Float64,
    cv_reference::Float64;
    cv_cutoff::Float64 = 0.30,
    max_widening::Float64 = 0.50,  # Up to 69.84%-143.19%
    alpha::Float64 = 0.05,
    power::Float64 = 0.80
)
    if cv_reference > cv_cutoff
        # Calculate widened limits
        k = 0.760  # Regulatory constant
        sigma_wr = sqrt(log(1 + cv_reference^2))
        widening = min(k * sigma_wr, max_widening)

        lower = exp(log(0.80) - widening)
        upper = exp(log(1.25) + widening)

        n = sample_size_crossover_be(gmr, cv_reference, lower, upper, alpha, power)
    else
        n = sample_size_crossover_be(gmr, cv_reference, 0.80, 1.25, alpha, power)
    end

    return n
end

Power Curves

Generating Power Curves

function power_curve(
    design_spec::PowerSpec,
    n_range::UnitRange{Int}
)
    powers = Float64[]

    for n in n_range
        pow = calculate_power(design_spec, n)
        push!(powers, pow)
    end

    return (n = collect(n_range), power = powers)
end

# Example
spec = ParallelPowerSpec(effect_size=0.5, alpha=0.05, cv=0.30)
curve = power_curve(spec, 10:100)

# Find minimum N for 80% power
min_n = findfirst(p -> p >= 0.80, curve.power)
println("Minimum N for 80% power: $(curve.n[min_n])")

Effect Size Sensitivity

function effect_sensitivity(
    n::Int,
    alpha::Float64,
    effect_sizes::Vector{Float64}
)
    powers = Float64[]

    for es in effect_sizes
        spec = ParallelPowerSpec(effect_size=es, alpha=alpha)
        pow = calculate_power(spec, n)
        push!(powers, pow)
    end

    return (effect_size = effect_sizes, power = powers)
end

# What effect sizes can we detect with N=50 per arm?
sens = effect_sensitivity(50, 0.05, 0.1:0.1:1.0)

Dropout Adjustment

Adjusting for Anticipated Dropout

function adjust_for_dropout(
    n_calculated::Int,
    dropout_rate::Float64
)
    n_adjusted = ceil(Int, n_calculated / (1 - dropout_rate))
    return n_adjusted
end

# Example: 20% anticipated dropout
n_base = 64
n_adjusted = adjust_for_dropout(n_base, 0.20)
println("Enroll: $n_adjusted to retain $n_base")  # 80 subjects

Per-Period Dropout (Crossover)

function adjust_crossover_dropout(
    n_calculated::Int,
    dropout_per_period::Float64,
    n_periods::Int
)
    # Expected completers
    retention = (1 - dropout_per_period)^n_periods

    n_adjusted = ceil(Int, n_calculated / retention)
    return n_adjusted
end

# 2×2 crossover with 5% dropout per period
n = adjust_crossover_dropout(24, 0.05, 2)  # ~27 subjects

Simulation-Based Power

Monte Carlo Power Estimation

function simulate_power(
    trial_spec::TrialSpec,
    n_subjects::Int,
    n_simulations::Int = 1000;
    alpha::Float64 = 0.05,
    seed::Int = 42
)
    Random.seed!(seed)
    significant_count = 0

    for _ in 1:n_simulations
        # Simulate trial
        result = simulate_trial(trial_spec, n=n_subjects)

        # Perform test
        pvalue = perform_test(result)

        if pvalue < alpha
            significant_count += 1
        end
    end

    power = significant_count / n_simulations
    se = sqrt(power * (1 - power) / n_simulations)

    return (power = power, se = se, ci_lower = power - 1.96*se, ci_upper = power + 1.96*se)
end

Power for BE Studies

function simulate_be_power(
    pk_model::PKModel,
    pk_params::PKParams,
    omega::Matrix{Float64},
    n_subjects::Int;
    formulation_effect::Dict = Dict(:Ka => 1.05),
    n_simulations::Int = 1000,
    seed::Int = 42
)
    Random.seed!(seed)
    be_pass = 0

    for _ in 1:n_simulations
        # Simulate crossover trial
        result = simulate_crossover_be(
            pk_model, pk_params, omega,
            n = n_subjects,
            formulation_effect = formulation_effect
        )

        # Assess BE
        be = assess_bioequivalence(result.test_auc, result.ref_auc)

        if be.is_bioequivalent
            be_pass += 1
        end
    end

    return be_pass / n_simulations
end

# Example: Power analysis for BE study
power = simulate_be_power(
    TwoCompOral(),
    TwoCompOralParams(Ka=1.5, CL=10.0, V1=50.0, Q=5.0, V2=100.0),
    [0.09 0.0; 0.0 0.04],
    24
)
println("BE power with N=24: $(round(power*100, digits=1))%")

Special Populations

Pediatric Trial Power

function pediatric_power_adjustment(
    adult_n::Int,
    age_groups::Vector{Tuple{Int, Int}},  # (min_age, max_age)
    group_weights::Vector{Float64};
    variability_inflation::Float64 = 1.2
)
    # Adjust for higher variability in pediatrics
    adjusted_n = ceil(Int, adult_n * variability_inflation)

    # Distribute across age groups
    group_n = [ceil(Int, adjusted_n * w) for w in group_weights]

    return (
        total_n = sum(group_n),
        group_allocation = Dict(zip(age_groups, group_n))
    )
end

# Example: Pediatric study
result = pediatric_power_adjustment(
    64,  # Adult study N
    [(2, 6), (6, 12), (12, 18)],  # Age groups
    [0.3, 0.4, 0.3]  # Allocation weights
)

Renal/Hepatic Impairment

function impairment_study_size(
    base_effect::Float64,
    cv::Float64;
    impairment_groups::Vector{Symbol} = [:mild, :moderate, :severe],
    alpha::Float64 = 0.05,
    power::Float64 = 0.80
)
    # Typically 6-8 per group for PK studies
    # Calculate based on detecting specified fold-change

    n_per_group = sample_size_parallel(base_effect, alpha, power) ÷ 2

    # Ensure minimum of 6 per group (FDA guidance)
    n_per_group = max(n_per_group, 6)

    return (
        n_per_group = n_per_group,
        n_total = n_per_group * (length(impairment_groups) + 1),  # +1 for normal
        groups = [:normal; impairment_groups]
    )
end

Adaptive Sample Size

Sample Size Re-estimation

struct AdaptiveSampleSizeSpec
    initial_n::Int              # Initial sample size
    interim_fraction::Float64   # Fraction for interim (e.g., 0.5)
    conditional_power_target::Float64  # Target conditional power
    max_n::Int                  # Maximum total N
    min_increase::Int           # Minimum increase if needed
end

function sample_size_reestimation(
    spec::AdaptiveSampleSizeSpec,
    interim_result::InterimResult
)
    # Calculate observed effect size
    observed_es = interim_result.effect_estimate / interim_result.se

    # Calculate conditional power at current N
    n_remaining = spec.initial_n - interim_result.n
    cp_current = conditional_power(observed_es, interim_result.n, n_remaining)

    if cp_current >= spec.conditional_power_target
        return spec.initial_n  # No increase needed
    end

    # Calculate new sample size for target conditional power
    new_n = find_n_for_conditional_power(
        observed_es,
        interim_result.n,
        spec.conditional_power_target
    )

    new_n = min(new_n, spec.max_n)
    new_n = max(new_n, spec.initial_n + spec.min_increase)

    return new_n
end

Complete Example

using NeoPKPD

# ============================================
# Power Analysis for Phase III Parallel Study
# ============================================

println("=== Power Analysis ===\n")

# 1. Study Parameters
println("--- Study Parameters ---")
effect_size = 0.40            # Expected treatment effect (Cohen's d)
cv = 0.35                     # Between-subject CV
alpha = 0.05                  # Type I error
target_power = 0.80           # Target power
dropout_rate = 0.15           # Expected dropout

println("Effect size (Cohen's d): $effect_size")
println("Between-subject CV: $(cv * 100)%")
println("Alpha: $alpha")
println("Target power: $(target_power * 100)%")
println("Expected dropout: $(dropout_rate * 100)%")

# 2. Calculate base sample size
println("\n--- Sample Size Calculation ---")
n_per_arm = sample_size_parallel(effect_size, alpha, target_power)
println("Base sample size: $n_per_arm per arm")

# 3. Adjust for dropout
n_adjusted = adjust_for_dropout(n_per_arm, dropout_rate)
println("Adjusted for dropout: $n_adjusted per arm")
println("Total enrollment: $(2 * n_adjusted)")

# 4. Power curve
println("\n--- Power by Sample Size ---")
println("N/arm    Power")
println("-" ^ 20)

for n in [30, 40, 50, 60, 70, 80, 90, 100]
    pow = calculate_power_parallel(effect_size, alpha, n)
    marker = pow >= 0.80 ? "*" : ""
    @printf("%4d    %5.1f%%%s\n", n, pow * 100, marker)
end

# 5. Effect size sensitivity
println("\n--- Detectable Effect Sizes ---")
println("With N = $n_per_arm per arm:")

for power_target in [0.70, 0.80, 0.90]
    min_es = minimum_detectable_effect(n_per_arm, alpha, power_target)
    @printf("  For %d%% power: Cohen's d = %.3f\n", Int(power_target*100), min_es)
end

# 6. Simulation-based verification
println("\n--- Simulation Verification ---")
println("Running 1000 simulations...")

# Define trial
design = ParallelDesign(
    n_arms = 2,
    arm_names = ["Placebo", "Treatment"],
    randomization_ratio = [1, 1]
)

pk_model = OneCompOral()
pk_params = OneCompOralParams(Ka=1.5, CL=10.0, V=60.0)
omega = [0.09 0.0; 0.0 0.04]

# Simulate power
simulated_power = simulate_trial_power(
    design = design,
    pk_model = pk_model,
    pk_params = pk_params,
    omega = omega,
    n_per_arm = n_per_arm,
    treatment_effect = effect_size,
    n_simulations = 1000
)

println("Simulated power: $(round(simulated_power.power * 100, digits=1))%")
println("95% CI: [$(round(simulated_power.ci_lower * 100, digits=1))%, " *
        "$(round(simulated_power.ci_upper * 100, digits=1))%]")

# 7. Summary
println("\n" * "=" ^ 50)
println("RECOMMENDATION")
println("=" ^ 50)
println("\nEnroll $(2 * n_adjusted) subjects ($(n_adjusted) per arm)")
println("Expected completers: ~$(2 * n_per_arm)")
println("Achieves $(Int(target_power * 100))% power to detect effect size = $effect_size")

Power Tables

Parallel Design Reference

Effect Size CV N per Arm (80% power) N per Arm (90% power)
0.3 30% 176 235
0.4 30% 99 132
0.5 30% 64 85
0.6 30% 44 59
0.3 40% 176 235
0.4 40% 99 132
0.5 40% 64 85

Crossover BE Reference

CV Within GMR N (80% power) N (90% power)
15% 0.95 10 14
20% 0.95 16 22
25% 0.95 24 32
30% 0.95 36 48
25% 1.00 18 24
25% 0.90 40 54

See Also