24  Check a hazard model

24.1 When to use it

A smooth curve is not evidence of a faithful model. Once the optimizer has converged, we still have five decisions to make. Does the fitted shape follow the empirical survival? Is a continuous covariate sensible on the scale we gave it? Does predicted risk agree with observed experience across the cohort? Would variable selection or resampling tell a different story? And have we defined competing outcomes with the right risk set?

These checks form a sequence, not a collection of stamps to put on a model. Global misfit sends us back to the hazard shape. Curvature in a grouped functional-form check sends us back to the covariate transformation. Poor decile agreement sends us back to the linear predictor or baseline hazard. Unstable selection or bootstrap estimates tell us to simplify the model or collect more information. None of them turns an observational association into a causal effect.

Everything here uses public data bundled with TemporalHazard (Ehrlinger 2026). The chapter is independently runnable and requires no patient extract.

24.2 Refit the reviewed model

The AVC example follows 305 complete records after atrioventricular canal repair. Follow-up is in months. We refit the reviewed Weibull model so every diagnostic below refers to one known object and one analysis cohort.

data(avc, package = "TemporalHazard")
avc <- na.omit(avc)

fit <- hazard(
  survival::Surv(int_dead, dead) ~ age + status + mal + com_iv,
  data = avc,
  dist = "weibull",
  theta = c(mu = 0.20, nu = 1.0, rep(0, 4)),
  fit = TRUE,
  control = list(maxit = 500)
)

data.frame(
  records = nrow(avc),
  deaths = sum(avc$dead),
  maximum_follow_up_months = max(avc$int_dead),
  converged = fit$fit$converged,
  log_likelihood = fit$fit$objective
)
  records deaths maximum_follow_up_months converged log_likelihood
1     305     68                 170.5826      TRUE      -200.9611

Stop here if converged is not TRUE. A diagnostic cannot repair a fit that never reached its optimum.

24.3 Does the overall shape fit?

hzr_gof() places the fitted Weibull survival beside the Kaplan-Meier estimate and accumulates observed and expected events over follow-up. With covariates in the model, the parametric curve is evaluated at the mean design values. The Kaplan-Meier curve is marginal over the cohort. Thus this is a first-pass shape and conservation check, not patient-specific calibration.

gof <- hzr_gof(fit)
gof_summary <- attr(gof, "summary")

data.frame(
  records = gof_summary$n,
  observed_events = gof_summary$total_observed,
  expected_events_at_mean_profile = gof_summary$total_expected,
  final_residual_expected_minus_observed = gof_summary$final_residual
)
  records observed_events expected_events_at_mean_profile
1     305              68                        50.43254
  final_residual_expected_minus_observed
1                              -17.56746
ggplot() +
  geom_step(
    data = gof,
    aes(time, km_surv * 100, colour = "Kaplan-Meier, cohort",
        linetype = "Kaplan-Meier, cohort"),
    linewidth = 0.75
  ) +
  geom_line(
    data = gof,
    aes(time, par_surv * 100, colour = "Weibull, mean profile",
        linetype = "Weibull, mean profile"),
    linewidth = 0.75
  ) +
  scale_colour_manual(
    values = c("Kaplan-Meier, cohort" = "grey35",
               "Weibull, mean profile" = "steelblue"),
    name = NULL
  ) +
  scale_linetype_manual(
    values = c("Kaplan-Meier, cohort" = "solid",
               "Weibull, mean profile" = "dashed"),
    name = NULL
  ) +
  scale_y_continuous(limits = c(0, 100), breaks = seq(0, 100, 20),
                     labels = function(x) paste0(x, "%")) +
  labs(x = "Months after AVC repair", y = "Freedom from death") +
  theme_hv_manuscript() +
  theme(legend.position = "top")
Figure 24.1: Kaplan-Meier survival for the AVC cohort compared with Weibull survival evaluated at the mean covariate profile

The curves differ, and the summary reports fewer expected events at the mean covariate profile than were observed in the cohort. Those quantities have different estimands: one is conditional on a single reference profile, while the other is marginal over the cohort. The discrepancy therefore demonstrates an estimand mismatch, not underprediction or a misspecified hazard shape. Before concluding that the model is miscalibrated, standardize predictions over the cohort or use a subject-specific check such as the decile observed-to-expected comparison below.

24.4 Is age sensible on the logit scale?

The name hzr_calibrate() comes from the historical grouped-screening workflow, but its current contract is narrower than a fitted survival-model calibration. It bins a continuous x, counts a binary event within each group, and transforms that observed proportion. It does not use event time or censoring. Here it asks whether eventual death is roughly linear in age on the logit scale, which is a functional-form screen for the next model fit.

cal <- hzr_calibrate(
  x = avc$age,
  event = avc$dead,
  groups = 10,
  link = "logit"
)
cal
Variable calibration (logit link, 10 groups)

 group  n events    mean     min     max  prob link_value
     1 30     11   3.519   1.051   5.388 0.367     -0.547
     2 31     11   8.665   5.421  11.532 0.355     -0.598
     3 30     13  15.194  11.631  18.497 0.433     -0.268
     4 31     11  23.077  18.990  27.828 0.355     -0.598
     5 30      7  43.544  28.124  57.167 0.233     -1.190
     6 31      3  72.066  59.730  86.408 0.097     -2.234
     7 30      2 101.154  86.507 117.522 0.067     -2.639
     8 31      3 162.739 121.169 203.733 0.097     -2.234
     9 30      4 247.051 205.343 297.140 0.133     -1.872
    10 31      3 530.623 324.573 790.981 0.097     -2.234
ggplot(cal, aes(mean, link_value)) +
  geom_point(size = 2.4, colour = "steelblue") +
  geom_line(colour = "steelblue", linewidth = 0.6) +
  geom_smooth(method = "lm", formula = y ~ x, se = FALSE,
              colour = "grey35", linetype = "dashed", linewidth = 0.65) +
  labs(x = "Mean age at repair within group (months)",
       y = "Logit of observed death proportion") +
  theme_hv_manuscript()
Figure 24.2: Observed binary death proportion by age group, shown on the logit scale as a functional-form check

Strong curvature would argue for a transformation, spline, or clinically defined age categories before refitting. A straight pattern would support a linear age term, but it would not prove that the full survival model is calibrated.

24.5 Does risk-group agreement hold?

hzr_deciles() asks a different question. It ranks all 305 patients by predicted survival at 120 months and divides them into ten nearly equal groups. The horizon defines the ranking only. Within each group, observed events use each patient’s full follow-up and expected events sum predicted cumulative hazard at each patient’s own follow-up time. No patient is excluded for having less than 120 months of observation.

dec <- hzr_deciles(fit, time = 120, groups = 10)
dec_overall <- attr(dec, "overall")

data.frame(
  risk_horizon_months = dec_overall$time,
  included = dec_overall$n_included,
  excluded = dec_overall$n_excluded,
  observed_events = dec_overall$total_events,
  expected_events = dec_overall$total_expected,
  chi_square = dec_overall$chi_sq,
  degrees_freedom = dec_overall$df,
  p_value = dec_overall$p_value
)
  risk_horizon_months included excluded observed_events expected_events
1                 120      305        0              68        67.99544
  chi_square degrees_freedom  p_value
1   12.71316               9 0.176021
dec
Decile-of-risk calibration (risk grouped at time = 120 )
305 subjects, all included.
10 groups, 68 observed events, 68 expected

 group  n events expected observed_rate expected_rate chi_sq p_value
     1 31      0     1.37        0.0000        0.0441 1.3700  0.2420
     2 30      1     1.64        0.0333        0.0546 0.2480  0.6190
     3 31      1     2.53        0.0323        0.0816 0.9250  0.3360
     4 30      2     3.49        0.0667        0.1160 0.6350  0.4260
     5 31      7     4.58        0.2260        0.1480 1.2800  0.2570
     6 30     10     5.67        0.3330        0.1890 3.3100  0.0689
     7 31      8     7.59        0.2580        0.2450 0.0218  0.8830
     8 30     14     8.71        0.4670        0.2900 3.2100  0.0734
     9 31     10    13.50        0.3230        0.4350 0.8960  0.3440
    10 30     15    18.90        0.5000        0.6320 0.8220  0.3650
 mean_survival mean_cumhaz
         0.950      0.0441
         0.931      0.0546
         0.906      0.0816
         0.865      0.1160
         0.818      0.1480
         0.708      0.1890
         0.655      0.2450
         0.538      0.2900
         0.441      0.4350
         0.221      0.6320

Overall: chi-sq = 12.7 on 9 df, p = 0.176 
dec_long <- rbind(
  data.frame(group = dec$group, rate = dec$observed_rate * 100,
             series = "Observed"),
  data.frame(group = dec$group, rate = dec$expected_rate * 100,
             series = "Expected")
)

ggplot(dec_long, aes(group, rate, fill = series)) +
  geom_col(position = position_dodge(width = 0.78), width = 0.7) +
  scale_fill_manual(values = c("Observed" = "grey55",
                               "Expected" = "steelblue"), name = NULL) +
  scale_x_continuous(breaks = 1:10) +
  labs(x = "Predicted-risk group (1 = lowest)",
       y = "Full-follow-up events per 100 patients") +
  theme_hv_manuscript() +
  theme(legend.position = "top")
Figure 24.3: Full-follow-up observed and expected event rates within groups ranked by predicted 120-month risk

The omnibus result is compatible with agreement across these groups, but a non-significant test is not proof of calibration. Read the group sizes and the observed-versus-expected pattern with the statistic. Sparse events and the choice of groups both limit what this check can see.

24.6 Does automated selection tell a stable story?

For the selection demonstration, we start from the reviewed early-plus- constant model. The scope is phase-specific, so age can enter the early phase without being forced into the constant phase. We request two-way selection with the refit-based Wald criterion and keep the optimizer bounded.

base_mp <- hazard(
  survival::Surv(int_dead, dead) ~ 1,
  data = avc,
  dist = "multiphase",
  phases = list(
    early = hzr_phase(
      "cdf", t_half = 0.5, nu = 1, m = 1,
      fixed = "shapes"
    ),
    constant = hzr_phase("constant")
  ),
  fit = TRUE,
  control = list(n_starts = 3, maxit = 500)
)

selection_warnings <- character()
selected <- withCallingHandlers(
  hzr_stepwise(
    base_mp,
    scope = list(
      early = ~ age + status + mal + com_iv,
      constant = ~ age + status + mal + com_iv
    ),
    data = avc,
    direction = "both",
    criterion = "wald",
    trace = FALSE,
    control = list(n_starts = 2, maxit = 500)
  ),
  warning = function(w) {
    selection_warnings <<- c(selection_warnings, conditionMessage(w))
    invokeRestart("muffleWarning")
  }
)

trace <- stepwise_trace(selected)
cat(paste(trace, collapse = "\n"))
Stepwise selection (direction = both, criterion = wald, slentry = 0.30, slstay = 0.20)

Step 1: ENTER  status  into  early   (p = 0.000)
Step 2: ENTER  com_iv  into  early   (p = 0.000)
Step 3: ENTER  status  into  constant   (p = 0.065)
Step 4: ENTER  mal  into  early   (p = 0.109)
Step 5: ENTER  age  into  early   (p = 0.117)
(no further action after 5 steps)

Final model: 5 covariates, logLik = -192.10, AIC = 398.21
data.frame(
  final_converged = selected$fit$converged,
  final_positive_definite = selected$fit$pd,
  final_reciprocal_condition = selected$fit$rcond,
  accepted_steps = nrow(selected$steps),
  intermediate_ill_conditioned = sum(grepl("ill-conditioned", selection_warnings)),
  intermediate_not_positive_definite = sum(grepl("not positive-definite", selection_warnings)),
  intermediate_nonpositive_variance = sum(grepl("Non-positive variance", selection_warnings)),
  intermediate_unidentified_phase = sum(grepl("contributes at most", selection_warnings))
)
  final_converged final_positive_definite final_reciprocal_condition
1            TRUE                    TRUE               8.584682e-07
  accepted_steps intermediate_ill_conditioned
1              5                            8
  intermediate_not_positive_definite intermediate_nonpositive_variance
1                                  6                                 6
  intermediate_unidentified_phase
1                               1

The trace names the criterion and records each accepted addition or removal. This run adds five phase-specific terms. The final model converges with a positive-definite covariance matrix, but intermediate candidate fits produce ill-conditioned Hessians, non-positive variances, and one unidentified phase. That instability is part of the selection result. The entry and stay thresholds are a search rule, not a correction for repeated testing, and the selected path is conditional on this sample. Preserve clinically required covariates with force_in, examine the fit after every accepted step, and do not describe a selected coefficient as a causal effect.

24.7 How much do the estimates move under resampling?

hzr_bootstrap() resamples the analysis rows, refits the same Weibull formula, and summarizes the fitted parameters among successful replicates. The seed makes this teaching run reproducible. Thirty replicates are enough to exercise the real refit path in a book render; they are not enough for study inference.

boot <- hzr_bootstrap(fit, n_boot = 30, seed = 20260828)

data.frame(
  requested_replicates = 30,
  successful_replicates = boot$n_success,
  failed_replicates = boot$n_failed,
  parameter_rows = nrow(boot$replicates)
)
  requested_replicates successful_replicates failed_replicates parameter_rows
1                   30                    30                 0            180
boot$summary
  parameter  n pct          mean           sd           min          max
4        mu 30 100  1.043892e-07 1.934879e-07  1.652265e-09 8.646472e-07
5        nu 30 100  2.418287e-01 1.469051e-02  2.213795e-01 2.791756e-01
1       age 30 100 -2.565719e-03 1.559578e-03 -5.217073e-03 2.361630e-04
6    status 30 100  6.456878e-01 1.606820e-01  3.785076e-01 1.038853e+00
3       mal 30 100  5.246384e-01 3.449093e-01 -1.752877e-01 1.495868e+00
2    com_iv 30 100  8.551437e-01 3.661216e-01  8.673703e-02 1.594166e+00
       ci_lower      ci_upper
4  1.813842e-09  5.751711e-07
5  2.228590e-01  2.749559e-01
1 -5.201045e-03 -3.900544e-04
6  3.946231e-01  9.880887e-01
3 -1.727194e-02  1.071379e+00
2  1.808295e-01  1.404523e+00
boot_beta <- subset(
  boot$summary,
  parameter %in% c("age", "status", "mal", "com_iv")
)
boot_beta$parameter <- factor(
  boot_beta$parameter,
  levels = boot_beta$parameter[order(boot_beta$mean)]
)
boot_beta$parameter <- factor(
  boot_beta$parameter,
  levels = c("age", "mal", "status", "com_iv"),
  labels = c("Age (per month)", "Malalignment", "NYHA status (per class)",
             "Interventricular communication")
)

ggplot(boot_beta, aes(mean, parameter)) +
  geom_vline(xintercept = 0, colour = "grey65", linetype = "dashed") +
  geom_errorbarh(aes(xmin = ci_lower, xmax = ci_upper),
                 height = 0.16, colour = "steelblue") +
  geom_point(size = 2.4, colour = "steelblue") +
  labs(x = "Bootstrap mean and percentile interval",
       y = "Covariate coefficient") +
  theme_hv_manuscript()
Figure 24.4: Teaching-run percentile intervals for the four Weibull covariate coefficients across 30 bootstrap refits

The mal interval crosses zero in this small run, while the other displayed intervals do not. That is a stability signal, not a final confidence interval. For a study analysis, prespecify a substantially larger resample, review the failure count, and report the resampling method with the model specification.

24.8 Are competing events using the right denominator?

Competing-risk incidence is built from a shared risk set. At each event time, n_risk is the number still free of every modeled event just before the events at that time. A cause-specific cumulative-incidence curve adds the current event-free survival multiplied by that cause’s event proportion. The curves therefore share one probability budget: event-free survival plus every cause’s cumulative incidence equals 1.

The public omc extract contains death/last-contact follow-up and indicators that one or more thromboembolic events were recorded, but it does not contain the thromboembolism date. We use the required three-state construction to show the estimator mechanics. Do not interpret the resulting type 1 curve as a clinical time-to-thromboembolism estimate. For that analysis, supply the time to first thromboembolism or death and code the first observed event.

data(omc, package = "TemporalHazard")

event <- with(
  omc,
  ifelse(
    te1 == 1 | te2 == 1 | te3 == 1,
    1L,
    ifelse(dead == 1, 2L, 0L)
  )
)
cr <- hzr_competing_risks(omc$int_dead, event)

data.frame(
  records = nrow(omc),
  censored = sum(event == 0),
  any_thromboembolism_recorded = sum(event == 1),
  death_without_recorded_thromboembolism = sum(event == 2),
  event_times = nrow(cr),
  final_probability_sum = tail(cr$surv + cr$incid_1 + cr$incid_2, 1)
)
  records censored any_thromboembolism_recorded
1     339      257                           33
  death_without_recorded_thromboembolism event_times final_probability_sum
1                                     49          82                     1
head(as.data.frame(cr)[
  c("time", "n_risk", "n_event_1", "n_event_2", "n_censor",
    "surv", "incid_1", "incid_2")
])
      time n_risk n_event_1 n_event_2 n_censor      surv     incid_1
1  0.06571    339         1         0        0 0.9970501 0.002949853
2  6.93238    334         0         1        0 0.9940650 0.002949853
3  8.37800    333         0         1        0 0.9910798 0.002949853
4  8.87082    331         1         0        0 0.9880856 0.005944051
5 16.85456    326         0         1        0 0.9850547 0.005944051
6 16.98598    325         0         1        0 0.9820237 0.005944051
      incid_2
1 0.000000000
2 0.002985180
3 0.005970360
4 0.005970360
5 0.009001298
6 0.012032235
cif <- rbind(
  data.frame(time = cr$time, incidence = cr$incid_1 * 100,
             cause = "Any thromboembolism recorded"),
  data.frame(time = cr$time, incidence = cr$incid_2 * 100,
             cause = "Death without recorded thromboembolism")
)

ggplot(cif, aes(time, incidence, colour = cause, linetype = cause)) +
  geom_step(linewidth = 0.75) +
  scale_colour_manual(
    values = c("Any thromboembolism recorded" = "steelblue",
               "Death without recorded thromboembolism" = "firebrick"),
    name = NULL
  ) +
  scale_linetype_manual(
    values = c("Any thromboembolism recorded" = "solid",
               "Death without recorded thromboembolism" = "dashed"),
    name = NULL
  ) +
  labs(x = "Months to death or last contact",
       y = "Cumulative incidence in constructed example (%)") +
  theme_hv_manuscript() +
  theme(legend.position = "top")
Figure 24.5: Estimator demonstration using OMC follow-up classification; the public extract does not contain the thromboembolism time required for clinical incidence

If the probability sum is not 1 to numerical precision, the event coding or risk-set construction needs attention. In a real competing-risk analysis, also verify that every row carries the time to the first event of any modeled cause, not a later administrative time.