using Pumas
using DataFramesMeta
using HypothesisTests
using StatsBase
using CategoricalArrays
using Copulas
using JuMP
using HiGHS
using AlgebraOfGraphics
using CairoMakie
using SummaryTables
using Random
using Statistics
# Set seed for reproducibility
Random.seed!(1234)
Virtual Clinical Trial Simulation
Multi-Arm HBV Treatment Comparison with Functional Cure Endpoints
This is Tutorial 6 of 6 in the HBV series.
| # | Tutorial | Status |
|---|---|---|
| 1 | Model Introduction | ✓ Complete |
| 2 | Global Sensitivity Analysis | ✓ Complete |
| 3 | Structural Identifiability | ✓ Complete |
| 4 | Copula Vpop Generation | ✓ Complete |
| 5 | MILP Calibration | ✓ Complete |
| 6 | VCT Simulation (current) | ← You are here |
1 Learning Outcomes
This tutorial brings together all concepts from the HBV series to run a comprehensive Virtual Clinical Trial comparing multiple treatment strategies.
In this tutorial, you will learn:
- How to structure HBV treatment phases (untreated → NA background → treatment → off-treatment)
- How to simulate multiple treatment arms (Control, NA, IFN, Combination)
- How to calculate functional cure and HBsAg loss rates
- How to analyze and visualize VCT results
2 Libraries
3 Part 1: HBV VCT Structure
3.1 Treatment Phases
A complete HBV VCT simulates multiple phases:
hbv_phases = (
untreated = 5 * 365, # 5 years - establish chronic infection
na_background = 4 * 365, # 4 years - NA background (suppressed patients)
treatment = 48 * 7, # 48 weeks - active treatment
off_treatment = 24 * 7, # 24 weeks - durability assessment
)3.2 Treatment Arms
treatment_arms = DataFrame(
arm = ["Control", "NA", "IFN", "NA + IFN"],
NA = [false, true, false, true],
IFN = [false, false, true, true],
description = [
"No treatment during treatment phase",
"Nucleoside analog only",
"Interferon only",
"Combination therapy",
]
)
simple_table(treatment_arms, [:arm => "Arm", :NA, :IFN, :description => "Description"])| Arm | NA | IFN | Description |
| Control | false | false | No treatment during treatment phase |
| NA | true | false | Nucleoside analog only |
| IFN | false | true | Interferon only |
| NA + IFN | true | true | Combination therapy |
4 Part 2: Model, Parameters, and Virtual Population
We use the HBV model and parameters from HBV-01 and HBV-05. See those tutorials for details on the model structure and parameter estimation.
4.1 Model
HBV model definition (same as HBV-01 and HBV-05)
hbv_model = @model begin
@param begin
# Estimated parameters (log10 scale)
"Production/synthesis rate of HBsAg subviral particles from infected hepatocytes [molec./cell/d]"
tv_log10_p_S ∈ RealDomain(; lower = 2, upper = 12)
"HBV infectivity [mL/virus/d]"
tv_log10_β ∈ RealDomain(; lower = -10, upper = 0)
"Killing rate of infected hepatocytes by cellular immunity [mL/cells/d]"
tv_log10_m ∈ RealDomain(; lower = -3, upper = 5)
"Synthesis rate of antigen presenting cells [cells/d]"
tv_log10_k_P ∈ RealDomain()
"Synthesis rate of ALT from debris hepatocytes [10^3 U/cells/d]"
tv_log10_k_A ∈ RealDomain()
"PD efficacy of NA on HBV production []"
tv_log10_ϵ_NA ∈ RealDomain(; lower = -5, upper = 0)
"PD efficacy of IFNα on HBV production []"
tv_log10_ϵ_IFN ∈ RealDomain(; lower = -5, upper = 0)
"PD efficacy of IFNα on activation of cellular immunity []"
tv_log10_r_E_IFN ∈ RealDomain(; lower = 0, upper = 4)
"Scaling factor for observed effector CD4/8 T cell numbers []"
tv_log10_conv_E ∈ RealDomain()
# IIV of estimated parameters in log10 scale
Ω ∈ PDiagDomain(9)
# Fixed parameters
"Baseline of HBV [copies/mL]"
ini_V ∈ RealDomain(; lower = 0.0)
"Baseline of ALT [U/L]"
ini_A ∈ RealDomain(; lower = 0.0)
"Max. proliferation rate of uninfected hepatocytes [1/d]"
r_T ∈ RealDomain(; lower = 0.0)
"Max. proliferation rate of cellular immunity [1/d]"
r_E ∈ RealDomain(; lower = 0.0)
"Max. proliferation rate of innate immunity [1/d]"
r_X ∈ RealDomain(; lower = 0.0)
"Fraction of cellular immunity that killing uninfected and refractory cells []"
n ∈ RealDomain(; lower = 0.0)
"Max. rate of conversion of infected cells to refractory cells due to the action of innate immunity [1/d]"
ρ ∈ RealDomain(; lower = 0.0)
"Max. rate of conversion of refractory cells back to target cells in the absence of innate immune responses []"
ρ_o_ρ_r ∈ RealDomain(; lower = 0.0)
"Production/synthesis rate of HBV from infected hepatocytes [virus/cell/d]"
p_V ∈ RealDomain(; lower = 0.0)
"Death rate of infected hepatocytes [1/d]"
d_TI ∈ RealDomain(; lower = 0.0)
"Degradation/clearance of HBV HBsAg subviral particles [1/d]"
d_V ∈ RealDomain(; lower = 0.0)
"Rate of systemic induction/recovery from exhaustion [1/d]"
d_Q ∈ RealDomain(; lower = 0.0)
"Degradation/clearance of innate immunity [1/d]"
d_X ∈ RealDomain(; lower = 0.0)
"Degradation/clearance of cellular immunity [1/d]"
d_E ∈ RealDomain(; lower = 0.0)
"Degradation/clearance of antigen presenting cells [1/d]"
d_P ∈ RealDomain(; lower = 0.0)
"Degradation/clearance of debris [1/d]"
d_D ∈ RealDomain(; lower = 0.0)
"Degradation/clearance of ALT [1/d]"
d_A ∈ RealDomain(; lower = 0.0)
"Max. rate of exhaustion [1/d]"
d_E_Q ∈ RealDomain(; lower = 0.0)
"Hepatocyte carrying capacity in the liver [cells/mL]"
T_max ∈ RealDomain(; lower = 0.0)
"Half-maximal (saturation) parameter for the effect of systemic exhaustion []"
ϕ_Q ∈ RealDomain(; lower = 0.0)
"Half-maximal (saturation) parameter for the activation of cellular immunity [cells/mL]"
ϕ_E ∈ RealDomain(; lower = 0.0)
"Max. inhibitory effect of HBsAg subviral particles on innate immunity []"
S_max ∈ RealDomain(; lower = 0.0)
"Half-maximal (saturation) parameter for inhibitory effect of HBsAg subviral particles on the generation of innate immunity [μg/mL]"
ϕ_S ∈ RealDomain(; lower = 0.0)
"Hill coefficient for describing HBsAg-induced inhibition of innate immunity []"
nS ∈ RealDomain()
"Threshold of infected hepatocytes to produce dynamical switch from progressive disease to long-lasting control of HBV infection [cells/mL]"
I_threshold ∈ RealDomain(; lower = 0.0)
# Residual errors
σ_add_log10_HBsAg ∈ RealDomain(lower = 0.0)
σ_prop_log10_HBsAg ∈ RealDomain(lower = 0.0)
σ_add_log10_HBV ∈ RealDomain(lower = 0.0)
σ_prop_log10_HBV ∈ RealDomain(lower = 0.0)
σ_prop_log10_ALT ∈ RealDomain(lower = 0.0)
σ_prop_log10_CD8 ∈ RealDomain(lower = 0.0)
end
@random begin
η ~ MvNormal(Ω)
end
# Time-varying covariates for treatment phases
@covariates NA IFN
@pre begin
# Individual parameters
# Logit-normal distribution of log10(p_S), scaled to range [2, 12]
p_S = exp10(10 * logistic(logit((tv_log10_p_S - 2) / 10) + η[1]) + 2)
# Logit-normal distribution of log10(β), scaled to range [-10, 0]
β = exp10(10 * (logistic(logit(tv_log10_β / 10 + 1) + η[2]) - 1))
# Logit-normal distribution of log10(m), scaled to range [-3, 5]
m = exp10(8 * logistic(logit((tv_log10_m + 3) / 8) + η[3]) - 3)
# Normal distribution of log10(k_P)
k_P = exp10(tv_log10_k_P + η[4])
# Normal distribution of log10(k_A)
k_A = exp10(tv_log10_k_A + η[5])
# Logit-normal distribution of log10(ϵ_NA), scaled to range [-5, 0]
ϵ_NA = exp10(NA * 5 * (logistic(logit(tv_log10_ϵ_NA / 5 + 1) + η[6]) - 1))
# Logit-normal distribution of log10(ϵ_IFN), scaled to range [-5, 0]
ϵ_IFN = exp10(IFN * 5 * (logistic(logit(tv_log10_ϵ_IFN / 5 + 1) + η[7]) - 1))
# Logit-normal distribution of log10(r_E_IFN), scaled to range [0, 4]
r_E_IFN = exp10(IFN * 4 * logistic(logit(tv_log10_r_E_IFN / 4) + η[8]))
# Normal distribution
conv_E = exp10(tv_log10_conv_E + η[9])
end
# Initial conditions
@init begin
V = ini_V
I = d_V * V / p_V
T = T_max - I
S = p_S * I / d_V
A = ini_A
end
@dynamics begin
# Target uninfected hepatocytes
T' = -β * V * T + r_T * T * (1 - (T + Ieff + R) / T_max) -
d_TI * T + ρ_o_ρ_r * R - (m / n) * r_E_IFN * E * T
# Infected hepatocytes
I' = β * V * T + r_T * I * (1 - (T + I + R) / T_max) -
ρ * X * I - d_TI * I - m * r_E_IFN * E * I
# Resistant hepatocytes
R' = ρ * X * Ieff - ρ_o_ρ_r * R + r_T * R * (1 - (T + Ieff + R) / T_max) -
d_TI * R - (m / n) * r_E_IFN * E * R
# Dead cell marker
D' = m * r_E_IFN * E * Ieff + (m / n) * r_E_IFN * E * (T + R) - d_D * D
# Immune response (ALT proxy)
A' = d_A * (ini_A - A) + k_A * D
# HBsAg dynamics
S' = p_S * Ieff - d_V * S
# Viral load (NA and IFN reduce production)
V' = p_V * Ieff * ϵ_NA * ϵ_IFN - d_V * V
# Dendritic cell activation
P' = k_P * (Ieff / (ϕ_E + Ieff)) - d_P * P
# Effector T cells
E' = (d_E + r_E * E) * Peff - d_E_Q * E * (Q^4 / (ϕ_Q^4 + Q^4)) - d_E * E
# Delayed effector signal
Q' = d_Q * (Peff - Q)
# Cytotoxic effect
X' = r_X * (1 - X) * (Ieff / (ϕ_E + Ieff)) *
(1 - S_max * (HBsAg_ug_mL^nS / (ϕ_S^nS + HBsAg_ug_mL^nS))) - d_X * X
end
@vars begin
# Switch between progressive infection (I ≥ I_threshold) and clearance of infection (I < I_threshold)
Ieff = ifelse(I < I_threshold, zero(I), I)
Peff = ifelse(I < I_threshold, zero(P), P)
# HBsAg [μg/mL]: Each particle of V and S is assumed to harbor 96 molecules of S envelope protein
f_molec2ug := 24000 * 1.0e6 / 6.022e23
HBsAg_ug_mL := 96 * (V + S) * f_molec2ug
end
# Error models: Values are log10-transformed
@derived begin
"HBsAg [IU/mL]"
log10_HBsAg ~ @. CombinedNormal(log10(HBsAg_ug_mL / 0.98e-3), σ_add_log10_HBsAg, σ_prop_log10_HBsAg)
"Viral load [copies/mL]"
log10_HBV ~ @. CombinedNormal(log10(V), σ_add_log10_HBV, σ_prop_log10_HBV)
"ALT [U/L]"
log10_ALT ~ @. ProportionalNormal(log10(A), σ_prop_log10_ALT)
"CD8+ T cell counts [cells/mL]"
log10_CD8 ~ @. ProportionalNormal(log10(conv_E * E), σ_prop_log10_CD8)
end
end4.2 Parameters
Fixed and estimated parameters
hbv_fixed_params = (
ini_V = exp10(-0.481),
ini_A = exp10(1.25),
r_T = 1.0,
r_E = 0.1,
r_X = 1.0,
n = 100,
ρ = 0.001,
ρ_o_ρ_r = 1.0e-5,
p_V = 100.0,
d_TI = 0.0039,
d_V = 0.67,
d_Q = 0.00385,
d_X = 0.2,
d_E = 0.01,
d_P = 0.239,
d_D = 1.66,
d_A = 0.47,
d_E_Q = 0.3,
T_max = 1.36e7,
ϕ_Q = 0.8,
ϕ_E = 100.0,
S_max = 1.0,
ϕ_S = 0.1473,
nS = 0.486,
I_threshold = 1.0e-4,
)
# IIV standard deviations (on logit/log10 scale)
ω_p_S = 0.632
ω_β = 2.489
ω_m = 0.827
ω_k_P = 1.292
ω_k_A = 1.156
ω_ϵ_NA = 1.583
ω_ϵ_IFN = 1.626
ω_r_E_IFN = 2.739
ω_conv_E = 1.248
hbv_estimated_params = (;
tv_log10_p_S = 7.643,
tv_log10_β = -4.358,
tv_log10_m = 1.618,
tv_log10_k_P = -3.968,
tv_log10_k_A = -4.869,
tv_log10_ϵ_NA = -1.485,
tv_log10_ϵ_IFN = -1.466,
tv_log10_r_E_IFN = 0.313,
tv_log10_conv_E = 2.745,
Ω = Diagonal([ω_p_S^2, ω_β^2, ω_m^2, ω_k_P^2, ω_k_A^2, ω_ϵ_NA^2, ω_ϵ_IFN^2, ω_r_E_IFN^2, ω_conv_E^2]),
σ_add_log10_HBsAg = 0.0972,
σ_prop_log10_HBsAg = 2.2204e-16,
σ_add_log10_HBV = 0.3206,
σ_prop_log10_HBV = 0.0838,
σ_prop_log10_ALT = 0.0478,
σ_prop_log10_CD8 = 0.4149,
)
hbv_params = merge(hbv_fixed_params, hbv_estimated_params)4.3 Copula Sampling and Baseline Simulation
We sample correlated random effects using the Gaussian copula (see HBV-04 and HBV-05).
# Spearman correlation matrix (9×9) from @cortes2025HBV Figure S5B
Rho = [
# p_S β m k_P k_A ϵ_NA ϵ_IFN r_E_IFN conv_E
1.0 0.43 0.46 0.51 -0.7 0.58 -0.14 -0.38 0.0; # p_S
0.43 1.0 0.3 -0.01 -0.5 0.38 -0.52 -0.21 0.0; # β
0.46 0.3 1.0 -0.16 -0.5 0.29 0.07 -0.33 0.0; # m
0.51 -0.01 -0.16 1.0 -0.45 0.25 0.14 -0.1 0.0; # k_P
-0.7 -0.5 -0.5 -0.45 1.0 -0.41 0.01 0.05 0.0; # k_A
0.58 0.38 0.29 0.25 -0.41 1.0 -0.61 -0.36 0.0; # ϵ_NA
-0.14 -0.52 0.07 0.14 0.01 -0.61 1.0 0.26 0.0; # ϵ_IFN
-0.38 -0.21 -0.33 -0.1 0.05 -0.36 0.26 1.0 0.0; # r_E_IFN
0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0; # conv_E
]
copula = GaussianCopula(Rho)
correlated_randeffs_dist = SklarDist(
copula, (
Normal(0, ω_p_S),
Normal(0, ω_β),
Normal(0, ω_m),
Normal(0, ω_k_P),
Normal(0, ω_k_A),
Normal(0, ω_ϵ_NA),
Normal(0, ω_ϵ_IFN),
Normal(0, ω_r_E_IFN),
Normal(0, ω_conv_E),
)
)The Everest trial enrolls NA-suppressed patients, so baseline HBsAg is the level after 5 years of untreated chronic infection followed by 4 years of NA suppression (Cortés‐Ríos et al., 2025). To reduce computation time in this tutorial, we only generate 500 virtual patients. We resample random effects that lead to integration errors:
# Sample `nvps` virtual patients
nvps = 500
baseline_pop = map(1:nvps) do id
Subject(;
id,
covariates = (; NA = [false, true], IFN = [false, false]),
covariates_time = [0, hbv_phases.untreated],
)
end
baseline_vrandeffs = map(baseline_pop) do _
return (; η = rand(correlated_randeffs_dist))
end
# Compute baseline HBsAg (after 5 yr chronic infection + 4 year NA suppression)
baseline_obstimes = [hbv_phases.untreated + hbv_phases.na_background]
baseline_sims = simobs(hbv_model, baseline_pop, hbv_params, baseline_vrandeffs; simulate_error = false, obstimes = baseline_obstimes)
while true
# Check whether all simulations succeeded
baseline_invalid = findall(!isvalid, baseline_sims)
isempty(baseline_invalid) && break
# Resample random effects for failed simulation
pop = @view(baseline_pop[baseline_invalid])
vrandeffs = @view(baseline_vrandeffs[baseline_invalid])
map!(vrandeffs, pop) do _
return (; η = rand(correlated_randeffs_dist))
end
baseline_sims[baseline_invalid] = simobs(hbv_model, pop, hbv_params, vrandeffs; simulate_error = false, obstimes = baseline_obstimes)
end
# Extract vector of baseline HBsAg
baseline_log10_HBsAg = postprocess((gen, _) -> only(gen.log10_HBsAg), baseline_sims)4.4 ILP Calibration to Everest Trial
We calibrate the virtual population to match the baseline HBsAg distribution from the Everest trial (see HBV-05 for the full ILP theory and derivation):
# Everest trial baseline HBsAg distribution
everest_target = DataFrame(
hbsag_range = ["<100", "[100, 200)", "[200, 500)", "[500, 1000)", "≥1000"],
log10_lower = [-Inf, log10(100), log10(200), log10(500), log10(1000)],
log10_upper = [log10(100), log10(200), log10(500), log10(1000), Inf],
pct = [37.5, 10.9, 19.1, 21.5, 11.0]
)
simple_table(everest_target, [:hbsag_range => "HBsAg", :pct => "Target (%)"])| HBsAg | Target (%) |
| <100 | 37.5 |
| [100, 200) | 10.9 |
| [200, 500) | 19.1 |
| [500, 1000) | 21.5 |
| ≥1000 | 11 |
# Calibration tolerance
epsilon = 0.05
# Create optimization model
ilp = Model(HiGHS.Optimizer)
set_silent(ilp)
set_attribute(ilp, "presolve", "on")
set_time_limit_sec(ilp, 60.0)
# Decision variables
@variable(ilp, x[1:length(baseline_log10_HBsAg)], Bin)
# Objective: maximize number of selected VPs
ntotal = sum(x)
@objective(ilp, Max, ntotal)
# Constraints: distribution matching for each category
for row in eachrow(everest_target)
pc = row.pct / 100
nc = sum(x[row.log10_lower .<= baseline_log10_HBsAg .< row.log10_upper])
@constraint(ilp, nc <= (pc + epsilon) * ntotal)
@constraint(ilp, nc >= (pc - epsilon) * ntotal)
endoptimize!(ilp)
assert_is_solved_and_feasible(ilp)
solution_summary(ilp)solution_summary(; result = 1, verbose = false)
├ solver_name : HiGHS
├ Termination
│ ├ termination_status : OPTIMAL
│ ├ result_count : 1
│ ├ raw_status : kHighsModelStatusOptimal
│ └ objective_bound : 2.34000e+02
├ Solution (result = 1)
│ ├ primal_status : FEASIBLE_POINT
│ ├ dual_status : NO_SOLUTION
│ ├ objective_value : 2.34000e+02
│ ├ dual_objective_value : NaN
│ └ relative_gap : 0.00000e+00
└ Work counters
├ solve_time (sec) : 5.07442e-03
├ simplex_iterations : 0
├ barrier_iterations : -1
└ node_count : 1
selected_vps = findall(xi -> value(xi) == 1, x)
calibrated_log10_HBsAg = baseline_log10_HBsAg[selected_vps]
calibrated_vrandeffs = baseline_vrandeffs[selected_vps]We compare the baseline HBsAg of the virtual population before and after calibration:
df = vcat(
DataFrame(; log10_HBsAg = baseline_log10_HBsAg),
DataFrame(; log10_HBsAg = calibrated_log10_HBsAg);
source = "stage" => ["Original", "Calibrated"],
)
data_specs = data(df) * mapping(
:log10_HBsAg,
col = :stage => sorter("Original", "Calibrated"),
) * histogram(; bins = vcat(everest_target.log10_lower, everest_target.log10_upper[end]), normalization = :probability)
target_specs = mapping(
collect(Iterators.flatten((row.log10_lower, row.log10_upper) for row in eachrow(everest_target))),
repeat(everest_target.pct; inner = 2) ./ 100,
group = repeat(1:nrow(everest_target); inner = 2),
) * visual(Lines; label = "Everest target")
draw(
data_specs + target_specs;
axis = (; xlabel = "Baseline HBsAg (log₁₀ IU/mL)", ylabel = "Proportion"),
)5 Part 3: VCT Simulation
We simulate the full VCT for each treatment arm using the Pumas model with time-varying NA and IFN covariates. Each patient goes through the four phases defined in Part 1.
5.1 Run VCT for All Arms
Biomarkers are measured weekly during treatment and post-treatment, and monthly otherwise:
# Measurement times
vct_obstimes = unique!(
vcat(
# Monthly observations during untreated + NA background
0:30:hbv_phases.untreated,
hbv_phases.untreated .+ (0:30:hbv_phases.na_background),
# Weekly observations during treatment and post-treatment
(hbv_phases.untreated + hbv_phases.na_background) .+ (0:7:hbv_phases.treatment),
(hbv_phases.untreated + hbv_phases.na_background + hbv_phases.treatment) .+ (0:7:hbv_phases.off_treatment),
)
)
# Covariate time points
vct_covariates_time = cumsum([0, hbv_phases.untreated, hbv_phases.na_background, hbv_phases.treatment])
# Simulate all treatment arms
vct_all_sims = mapreduce(vcat, eachrow(treatment_arms)) do arm
pop = map(selected_vps) do id
return Subject(;
id,
covariates = (;
NA = [false, true, arm.NA, false],
IFN = [false, false, arm.IFN, false],
),
covariates_time = vct_covariates_time,
)
end
sims = simobs(hbv_model, pop, hbv_params, calibrated_vrandeffs; simulate_error = false, obstimes = vct_obstimes)
filter!(isvalid, sims)
sims_df = DataFrame(sims)
sims_df.arm .= arm.arm
return sims_df
end6 Part 4: Endpoint Analysis
6.1 Calculate Functional Cure Rates
Functional cure is defined as HBsAg < 0.05 IU/mL and HBV DNA < 10^1.4 copies/mL for 24 weeks post-treatment.
# End of treatment
t_eot = hbv_phases.untreated + hbv_phases.na_background + hbv_phases.treatment
# End of follow-up
t_fu = t_eot + hbv_phases.off_treatment
fc_df = @by vct_all_sims [:arm, :id] @astable begin
t_eot_fu = findall(t -> t_eot <= t <= t_fu, :time)
:functional_cure = all(x -> x < log10(0.05), :log10_HBsAg[t_eot_fu]) && all(x -> x < 1.4, :log10_HBV[t_eot_fu])
end
fc_rate_df = @by fc_df :arm :fc_rate = 100 * mean(:functional_cure)
simple_table(fc_rate_df, [:arm => "Arm", :fc_rate => "Functional Cure (%)"])| Arm | Functional Cure (%) |
| Control | 0 |
| NA | 0.427 |
| IFN | 11.1 |
| NA + IFN | 20.5 |
6.2 Confidence Intervals
We compute 95% confidence intervals (Clopper-Pearson) for the complete response rate:
# Calculate all functional cure rates with CI
fc_ci_df = @by fc_df :arm @astable begin
npatients_total = length(:functional_cure)
npatients_fc = sum(:functional_cure)
:rate = 100 * npatients_fc / npatients_total
:ci = round.(100 .* confint(BinomialTest(npatients_fc, npatients_total); level = 0.95); sigdigits = 3)
end
simple_table(fc_ci_df, [:arm => "Arm", :rate => "Functional Cure (%)", :ci => "95% CI"])| Arm | Functional Cure (%) | 95% CI |
| Control | 0 | (0.0, 1.56) |
| NA | 0.427 | (0.0108, 2.36) |
| IFN | 11.1 | (7.39, 15.9) |
| NA + IFN | 20.5 | (15.5, 26.3) |
7 Part 5: Visualization
7.1 HBsAg Dynamics by Arm
log10_HBsAg_time_summary = @chain vct_all_sims begin
@rsubset !ismissing(:log10_HBsAg)
@by [:time, :arm] @astable begin
q05, q25, q50, q75, q95 = quantile(:log10_HBsAg, (0.05, 0.25, 0.5, 0.75, 0.95))
:median = q50
:q25 = q25
:q75 = q75
:q05 = q05
:q95 = q95
end
end
sort!(log10_HBsAg_time_summary, [:time, :arm])
simple_table(first(log10_HBsAg_time_summary, 10), [:time, :arm => "Arm", :median => "Median", :q25, :q75, :q05, :q95])| Time | Arm | Median | q25 | q75 | q05 | q95 |
| 0 | Control | -3.46 | -4.45 | -2.76 | -6.3 | -1.83 |
| 0 | IFN | -3.46 | -4.45 | -2.76 | -6.3 | -1.83 |
| 0 | NA | -3.46 | -4.45 | -2.76 | -6.3 | -1.83 |
| 0 | NA + IFN | -3.46 | -4.45 | -2.76 | -6.3 | -1.83 |
| 30 | Control | 5.87 | 4.19 | 6.72 | -1.1 | 7.49 |
| 30 | IFN | 5.87 | 4.19 | 6.72 | -1.1 | 7.49 |
| 30 | NA | 5.87 | 4.19 | 6.72 | -1.1 | 7.49 |
| 30 | NA + IFN | 5.87 | 4.19 | 6.72 | -1.1 | 7.49 |
| 60 | Control | 5.74 | 3.79 | 6.53 | 1.42 | 7.4 |
| 60 | IFN | 5.74 | 3.79 | 6.53 | 1.42 | 7.4 |
# Time summary
specs_time_summary = data(log10_HBsAg_time_summary) *
mapping(:time => (t -> t / 7) => "Time [week]", color = :arm => sorter("Control", "NA", "IFN", "NA + IFN") => "Arm") * (
# IQR band layer
mapping(:q25, :q75) * visual(Band; alpha = 0.3) +
# Median line layer
mapping(:median) * visual(Lines)
)
# Reference lines
specs_references = mapping([log10(0.05)], linestyle = ["Functional cure"] => "Threshold") * visual(HLines)
draw(specs_time_summary + specs_references; axis = (; ylabel = "HBsAg (log₁₀ IU/mL)"))7.2 Functional Cure Rate Comparison
bar_layer = data(fc_ci_df) *
mapping(
:arm => sorter("Control", "NA", "IFN", "NA + IFN") => "Arm"
) * (
mapping(
:rate,
) *
visual(BarPlot) +
mapping(:ci => first, :ci => last) * visual(Rangebars; color = :black, linewidth = 1.5, whiskerwidth = 8)
)
draw(bar_layer; axis = (; ylabel = "Functional cure rate (%)"))7.3 Waterfall Plot
# Calculate change from baseline for combination treatment
# Baseline time
t_bl = hbv_phases.untreated + hbv_phases.na_background
# End of treatment
t_eot = t_bl + hbv_phases.treatment
vct_combo_change = @chain vct_all_sims begin
@rsubset :arm == "NA + IFN"
@by :id @astable begin
:change = only(:log10_HBsAg[:time .== t_eot]) - only(:log10_HBsAg[:time .== t_bl])
end
@orderby -:change
@transform! :rank = 1:length(:change)
end
leftjoin!(vct_combo_change, @rsubset(fc_df, :arm == "NA + IFN"); on = "id")
@rtransform! vct_combo_change :response = :functional_cure ? "Functional cure" : (:change < 0 ? "Partial response" : "No response")
# Data
specs_data = data(vct_combo_change) *
mapping(
:rank => "Patient (sorted by change from baseline)",
:change => "Change from baseline HBsAg (log₁₀ IU/mL)",
color = :response => sorter("Functional cure", "Partial response", "No response") => "Response",
) *
visual(BarPlot, gap = 0)
draw(
specs_data,
)8 Summary
8.1 Key Results
# Best arm
fc_ci_df[argmax(fc_ci_df.rate), :arm]"NA + IFN"
# Best rate
maximum(fc_ci_df.rate)20.512820512820515
8.2 VCT Workflow Complete
Congratulations! You have completed the HBV Tutorial Series. You now have the skills to:
- Understand HBV biology and the QSP model structure
- Analyze parameter sensitivity with GSA
- Assess identifiability for clinical measurements
- Generate virtual populations with copula sampling
- Calibrate to clinical distributions with MILP
- Run comprehensive VCTs with multiple treatment arms