Using custom cost functions
Here is an example implementation of the cost function described in the following paper:
Richardson, S., Irurzun Arana, I., Nowojewski, A. et al. A machine learning approach to population pharmacokinetic modelling automation. Commun Med 5, 327 (2025). https://doi.org/10.1038/s43856-025-01054-8
The cost function penalizes high RSEs, omega values, omega shrinkage and condition number, according to the following table:
Parameter | Estimated value | Threshold | Penalty |
|---|---|---|---|
Fixed effects | Relative error | 10% | 0 |
30% | 20 | ||
75% | 100 | ||
1000% | 100000 | ||
Random effects | Relative error | 20% | 0 |
40% | 30 | ||
65% | 100 | ||
1000% | 100000 | ||
Random effects | Value (CV%) | 0 (0 CV%) | 1000000 |
0.001 (3 CV%) | 1000 | ||
0.01 (10 CV%) | 0 | ||
1 (131 CV%) | 0 | ||
1.65 (205 CV%) | 100 | ||
2.3 (300 CV%) | 500 | ||
3 (437 CV%) | 10000 | ||
3.5 (567 CV%) | 1000000 | ||
Random effects | Shrinkage | 40% | 0 |
100% | 200 | ||
Condition number | Condition number | 500 | 0 |
1000 | 100 | ||
10000 | 1000 | ||
1000000 | 10000000 |
calculate_penalized_aic <- function() {
# --- 1. Setup and Helper Functions -----------------------------------------
# Helper for piecewise-linear interpolation
# It calculates a y-value for a given x based on a set of (xp, yp) points.
piecewise_linear_interp <- function(x, xp, yp) {
# Ensure inputs are valid
stopifnot(length(xp) == length(yp), length(xp) >= 2)
# Return 0 for NA inputs and clamp values outside the interpolation range
if (is.na(x)) return(0)
if (x <= xp[1]) return(yp[1])
if (x >= tail(xp, 1)) return(tail(yp, 1))
# Perform linear interpolation
i <- findInterval(x, xp)
x0 <- xp[i]; x1 <- xp[i + 1]
y0 <- yp[i]; y1 <- yp[i + 1]
y0 + (y1 - y0) * (x - x0) / (x1 - x0)
}
# --- 2. Penalty Calculation Definitions ------------------------------------
# These functions define the penalty curves for different metrics.
# Penalty for THETA RSE (%): 10%->0, 30%->20, 75%->100, 1000%->100000
pen_theta_rse <- function(rse_pct) {
piecewise_linear_interp(rse_pct,
xp = c(10, 30, 75, 1000),
yp = c(0, 20, 100, 100000))
}
# Penalty for OMEGA RSE (%): 20%->0, 40%->30, 65%->100, 1000%->100000
pen_omega_rse <- function(rse_pct) {
piecewise_linear_interp(rse_pct,
xp = c(20, 40, 65, 1000),
yp = c(0, 30, 100, 100000))
}
# Penalty for OMEGA value (on the standard deviation scale)
pen_omega_value <- function(omega_sd_val) {
# Convert SD to variance for interpolation, as thresholds are often
# considered on the variance scale.
omega_var_val <- omega_sd_val^2
xp <- c(0.0000, 0.0010, 0.0100, 1.0000, 1.6500, 2.3000, 3.0000, 3.5000)
yp <- c(1e6, 1e3, 0, 0, 100, 500, 1e4, 1e6)
piecewise_linear_interp(omega_var_val, xp, yp)
}
# Penalty for OMEGA shrinkage (%): 40%->0, 100%->200
pen_omega_shrink <- function(shr_pct) {
piecewise_linear_interp(shr_pct, xp = c(40, 100), yp = c(0, 200))
}
# Penalty for Condition Number: 500->0, 1000->100, 10000->1000, 1e6->1e7
pen_condition_number <- function(cond_num) {
piecewise_linear_interp(cond_num,
xp = c(500, 1000, 10000, 1e6),
yp = c(0, 100, 1000, 1e7))
}
# --- 3. Extract Metrics from Monolix Project -------------------------------
# Project directory for result files
proj_dir <- getProjectSettings()$directory
# AIC (from importance sampling)
AIC <- getEstimatedLogLikelihood()$importanceSampling[["AIC"]]
if (is.null(AIC) || is.na(AIC)) {
warning("AIC from Importance Sampling not found. Using AIC from Linearization.")
AIC <- getEstimatedLogLikelihood()$linearization[["AIC"]]
if (is.null(AIC)) stop("Could not retrieve AIC value.")
}
# Population parameter estimates and standard errors
pop_params <- getEstimatedPopulationParameters()
std_errs <- getEstimatedStandardErrors()$stochasticApproximation
# Filter for THETA (fixed effects) and OMEGA (random effect variances)
is_theta <- grepl("(_pop|beta_)", names(pop_params)) & !grepl("omega", names(pop_params))
is_omega <- grepl("^omega_", names(pop_params)) & !grepl("corr|cov", names(pop_params))
theta_rse <- std_errs[is_theta, "rse"]
omega_rse <- std_errs[is_omega, "rse"]
omega_values <- pop_params[is_omega]
# Shrinkage
shrinkage_file <- file.path(proj_dir, "individualParameters", "shrinkage.txt")
shrinkage <- if (file.exists(shrinkage_file)) {
read.csv(shrinkage_file)$shrinkage_mode
} else {
warning("Shrinkage file not found. Skipping shrinkage penalty.")
numeric(0)
}
# Condition number of the Fisher Information Matrix (FIM)
fim_file <- file.path(proj_dir, "FisherInformation", "covarianceEstimatesSA.txt")
condition_number <- if (file.exists(fim_file)) {
fim <- as.matrix(read.csv(fim_file, header = FALSE, row.names = 1))
eigen_values <- eigen(fim, only.values = TRUE)$values
max(eigen_values) / min(eigen_values)
} else {
warning("FIM file not found. Skipping condition number penalty.")
NA
}
# --- 4. Compute Penalties -------------------------------------------------
# `sum(sapply(x, fun))` gracefully handles empty vectors (sum = 0)
p_theta_rse <- sum(sapply(theta_rse, pen_theta_rse))
p_omega_rse <- sum(sapply(omega_rse, pen_omega_rse))
p_omega_val <- sum(sapply(omega_values, pen_omega_value))
p_shrinkage <- sum(sapply(shrinkage, pen_omega_shrink))
p_cond_num <- pen_condition_number(condition_number)
# --- 5. Aggregate and Return Results --------------------------------------
penalty_breakdown <- c(
THETA_RSE = p_theta_rse,
OMEGA_RSE = p_omega_rse,
OMEGA_value = p_omega_val,
OMEGA_shrinkage = p_shrinkage,
Condition_number = p_cond_num
)
total_penalty <- sum(penalty_breakdown, na.rm = TRUE)
penalized_AIC <- AIC + total_penalty
penalized_AIC
}