---
title: "Analysing Clinical Survival Data with OptSurvCutR"
subtitle: "Discovering biomarker thresholds and tier systems in liver disease"
author:
  - "Payton Yau"
  - "Suhirthakumar Puvanendran"
date: "`r Sys.Date()`"
output:
  rmarkdown::html_vignette:
    toc: true
    fig_caption: yes
vignette: >
  %\VignetteIndexEntry{Analysing Clinical Survival Data with OptSurvCutR}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include=FALSE}
knitr::opts_chunk$set(
  collapse = TRUE, echo = TRUE, comment = "+",
  fig.width = 8, fig.height = 4.5, fig.align = "center",
  warning = TRUE, message = TRUE
)
options(cli.num_colors = 1)
# Suppress cli progress bars (validate_cutpoint()'s "Bootstrapping" bar):
# a live progress bar re-renders on every step during a vignette knit,
# producing hundreds of lines of output in the rendered document rather
# than a live terminal display. Interim measure until every
# validate_cutpoint() call below is updated to pass quiet = TRUE directly.
options(cli.progress_show_after = Inf)
```

# Introduction

Continuous biomarkers are routinely split into risk groups using a median
or a threshold borrowed from another study. Both are arbitrary, and a
single split cannot represent a relationship where risk rises in stages.

`OptSurvCutR` selects thresholds by optimising a survival criterion, and
then assesses how far those thresholds move under resampling.

| Function | Question | Output |
|:---|:---|:---|
| `find_cutpoint_number()` | How many groups? | A number, *k* |
| `find_cutpoint()` | Where are the boundaries? | *k* thresholds |
| `validate_cutpoint()` | How much do they move? | Intervals + stability tier |

## How this differs from a single optimal cut-point

Established tools solve part of this problem well. `maxstat` computes a
maximally selected rank statistic with a corrected *p*-value, which
addresses the inflation that comes from testing many candidate
thresholds; `survminer::surv_cutpoint()` wraps it in a convenient
interface. If a single unadjusted threshold is what the question needs,
they are a sound choice.

`OptSurvCutR` covers three things they do not:

| | `maxstat` / `survminer` | `OptSurvCutR` |
|:---|:---|:---|
| Number of cut-points | One | Chosen from the data, one or more |
| Covariate adjustment | No | Yes, inside the search |
| Threshold stability | Not assessed | Bootstrap intervals and a tier |

The third is the one that changes conclusions. A corrected *p*-value
tells you a threshold is unlikely to have arisen by chance; it says
nothing about whether the same threshold would be found again in a
comparable sample. Those are different questions, and in this vignette
they give different answers: the four-group model is significant by any
test, yet its boundaries are not reproducible.

## The data

The Mayo Clinic trial of D-penicillamine in **primary biliary
cholangitis** (Dickson et al., 1989) followed 418 patients with this
chronic autoimmune liver disease, in which progressive destruction of the
bile ducts leads to cholestasis, cirrhosis and eventually liver failure.

**Serum bilirubin** is the classic marker of that progression: as bile
drainage fails, bilirubin accumulates. It is the strongest single
component of the Mayo risk score, and clinicians have long used
thresholds of it to time referral for transplantation. Values above
roughly 1.2 mg/dL are considered abnormal.

Here we ask what threshold the data themselves support, adjusting for
age, sex and the presence of edema.

------------------------------------------------------------------------

# 1. Data preparation

```{r load-libraries, message=FALSE, warning=FALSE}
library(survival)
library(dplyr)
library(ggplot2)
library(knitr)
library(OptSurvCutR)
```

```{r load-data}
data(pbc, package = "survival")

pbc_clean <- na.omit(pbc[, c("time", "status", "bili", "age", "sex", "edema")])

# Endpoint: transplant-free survival
# status: 0 = censored, 1 = transplant, 2 = death
pbc_clean$event <- as.integer(pbc_clean$status %in% c(1, 2))

nrow(pbc_clean)
```

All six variables are complete across `pbc`, so the analysis retains all
418 patients. The figure of 312 often quoted for this dataset refers to
records with complete `trt` and laboratory values, which this model does
not use.

Transplant is treated as an event rather than a censoring. Transplants
are allocated preferentially to the sickest patients, so censoring them
would remove the highest-risk patients from the high-bilirubin groups
just as they were about to fail.

Adjusting for `age`, `sex` and `edema` means thresholds are selected
inside a Cox model containing those variables, so a threshold cannot
appear useful merely by tracking one of them.

------------------------------------------------------------------------

# 2. How many cut-points?

`find_cutpoint_number()` compares models of increasing complexity using
an information criterion. AIC penalises complexity lightly and suits
exploratory work; BIC is stricter and is the better choice when a
threshold is intended for clinical use. AICc corrects AIC for small
samples ($N < 200$).

```{r find-number}
num_res <- find_cutpoint_number(
  data = pbc_clean, predictor = "bili",
  outcome_time = "time", outcome_event = "event",
  covariates = c("age", "sex", "edema"),
  method = "genetic", criterion = "AIC",
  max_cuts = 5,
  nmin = 0.15,          # each group holds at least 15% of patients
  max.generations = NULL, pop.size = NULL,
  boundary.enforcement = 2, seed = 123
)

summary(num_res)
```

```{r criterion-curve}
plot(num_res)
```

AIC is minimised at **three cut-points**, carrying 98.4% of the AIC
weight against 1.6% for the two-cut model.

The four- and five-cut models return no valid solution: with
`nmin = 0.15` each group needs 62 patients, and the genetic search found
no partition that satisfied this. A search returning nothing does not
prove that nothing exists — raising `max.generations` and `pop.size`, or
lowering `nmin`, may find one.

------------------------------------------------------------------------

# 3. Where are the boundaries?

```{r find-cutpoint}
cut_res <- find_cutpoint(
  data = pbc_clean, predictor = "bili",
  outcome_time = "time", outcome_event = "event",
  covariates = c("age", "sex", "edema"),
  method = "genetic", criterion = "logrank",
  num_cuts = num_res$optimal_num_cuts,   # carried from step 1
  nmin = 0.15,
  n_perm = 20,          # low for build speed; use >= 1000 when reporting
  max.generations = NULL, pop.size = NULL,
  boundary.enforcement = 2, seed = 123, n_cores = 1
)

summary(cut_res)
```

`num_cuts` is taken from the Step 1 object rather than specified
directly. Selecting the number of groups after inspecting the survival
curves would reintroduce the selection problem that Step 1 is intended to
control.

The thresholds are **0.7, 2.3 and 5.945 mg/dL**, giving groups of 100,
175, 80 and 63 patients. Hazard ratios rise across them (3.27, 12.20,
20.93 relative to the lowest group) and median transplant-free survival
falls from not reached to 3445, 1504 and 930 days. Concordance is 0.811.

The reported permutation *p* of 0.0476 is exactly 1/(20+1), the smallest
value `n_perm = 20` can return. It means no permutation exceeded the
observed statistic, not that *p* equals 0.0476.

The Schoenfeld test gives *p* = 0.103, so there is no evidence against
proportional hazards and the hazard ratios can be read as approximately
constant over follow-up.

```{r plot-distribution}
plot(cut_res, type = "distribution") +
  geom_rug(alpha = 0.5) +
  labs(caption = "Bilirubin thresholds on the marker distribution")
```

------------------------------------------------------------------------

# 4. Are the boundaries stable?

`validate_cutpoint()` re-runs the search on resampled cohorts and records
where each boundary lands.

```{r validate-cutpoint}
val_res <- validate_cutpoint(
  cutpoint_result = cut_res,
  num_replicates = 30,      # reduced for build speed; use >= 500 when reporting
  n_cores = 1,      # single core: vignette builds cannot use parallel workers
  max.generations = NULL, pop.size = NULL,
  boundary.enforcement = 2, seed = 123
)

summary(val_res)
```

The bootstrap `nmin` is relaxed to 55 automatically. Resampled cohorts
contain duplicates and fewer distinct values, so holding the original
constraint would cause replicates to fail.

## Reading the tier

Two quantities are assessed: whether adjacent intervals **overlap**, and
each interval's **width** relative to the predictor's 10th–90th
percentile range.

| Tier | Overlap | Width | Meaning |
|:---|:---|:---|:---|
| 1 — OPTIMAL | None | < 30% | Precise boundaries, distinct groups |
| 2 — DISTINCT | None | Any | Groups distinct, boundaries move |
| 3 — CAUTION | Present | 30–60% | Adjacent groups not cleanly separated |
| 4 — UNSTABLE | Present | > 60% | Boundaries not reproducible |

These are two independent diagnostics rather than an ordinal scale:
overlap matters most for a multi-tier rule, width for a single reported
boundary. The percentages are practical conventions.

The number of replicates affects the width of the intervals. With few
replicates the 2.5th and 97.5th percentiles fall close to the extremes of
the resampled values, and the intervals are narrower than they should be.
Use at least 500 replicates for any reported analysis. The chunk above
uses 30 to keep the vignette build short; the section below reports the
500-replicate results.

------------------------------------------------------------------------

# 5. Results at 500 replicates

Running the same validation with `num_replicates = 500` gives:

| Model | Thresholds | Widest interval | Tier |
|:---|:---|---:|:---|
| Three cut-points | 0.7, 2.3, 5.945 | 54.2% | 3 — CAUTION |
| Two cut-points | 2.3, 5.945 | 59.9% | 3 — CAUTION |
| **One cut-point** | **2.3** | **18.8%** | **1 — OPTIMAL** |

For the three-cut model the intervals are 0.6–1.6, 1.4–3.3 and
3.0–7.03 mg/dL. Both adjacent pairs intersect, so a patient with a
bilirubin of 1.5 mg/dL is assigned to a different group depending on the
resample. The component widths are uneven: the two lower boundaries are
well resolved at 13.5% and 25.6%, both within Tier 1 bounds on width
alone, whereas the upper boundary, situated in the sparse right tail, is
not reproducible at 54.2%.

The two-cut model performs less well, with a widest interval of 59.9% and
the lowest boundary widening from 0.6–1.6 to 0.7–3.0 mg/dL. With three
groups the minimum stratum constraint admits a wider feasible region, so
the optimum varies more across resamples. Reducing the number of
cut-points does not necessarily improve stability, and is worth testing
rather than assuming.

The threshold is **2.3 mg/dL** with a bootstrap interval of 2.0–3.4. The
median across 500 resamples is 2.3, identical to the value found in the
full data, and the interquartile range is 2.3–3.0. This is also the
middle boundary of the three-cut model: the search returned to the same
point regardless of how many groups it was asked for.

The reduction from three cut-points to one followed the stability
assessment and constitutes a post-hoc simplification, which should be
reported as such. Two considerations support it: the sequence is
prescribed by the workflow in advance rather than selected for this
dataset, and `validate_cutpoint()` issues the recommendation as part of
its diagnostic output. The adjusted hazard ratio for the
single-threshold model nonetheless remains optimistic, since the
threshold was selected from the same data used to estimate it.

------------------------------------------------------------------------

# 6. Reporting

## Group composition

```{r group-composition}
final_dataset <- plot(cut_res, return_data = TRUE)

final_dataset %>%
  group_by(group) %>%
  summarise(
    Bilirubin = paste0(round(min(factor), 2), " – ", round(max(factor), 2)),
    N = n(),
    Events = sum(event),
    Mean_Age = round(mean(age), 1),
    Edema = round(mean(edema), 2)
  ) %>%
  kable(caption = "Composition of the four-group model")
```

Mean age is flat across groups, so the survival gradient is not age in
disguise. Edema is seven times more common in the highest group than the
lowest and is strongly associated with the outcome, which is why
it is included as an adjustment variable. The highest group contains 63
patients against a floor of 62, so its lower boundary is determined
partly by the `nmin` constraint rather than by the data alone.

## Hazard ratios and diagnostics

```{r plot-forest, fig.width=7, fig.height=3.5}
plot(cut_res, type = "forest",
     main = "Adjusted hazard ratios relative to group 1")
```

These estimates come from the data that selected the thresholds and are
therefore optimistic.

```{r plot-diagnostic}
plot(cut_res, type = "diagnostic")
```

```{r plot-km}
plot(cut_res, type = "outcome",
     title = "Transplant-free survival by bilirubin group",
     xlab = "Follow-up (days)", ylab = "Transplant-free survival",
     legend.title = "Bilirubin group")
```

## Joint stability

An interval shows how one threshold moves; it cannot show whether two
move together.

```{r plot-validation-2d-12}
plot_validation(val_res, focus_cuts = c(1, 2),
                main = "Cuts 1 and 2 across resamples")
```

```{r plot-validation-2d-23}
plot_validation(val_res, focus_cuts = c(2, 3),
                main = "Cuts 2 and 3 across resamples")
```
A tight cloud indicates both boundaries are well determined. Elongation
along one axis identifies the imprecise boundary. A diagonal spread means
the two are trading off, with several partitions scoring similarly.

------------------------------------------------------------------------

# 7. Conclusion

AIC selected four bilirubin groups with 98.4% of the model weight,
monotonic hazard ratios and concordance of 0.81. The bootstrap showed
that the boundaries overlapped and would not survive a different sample
of the same size. A single threshold at **2.3 mg/dL** — close to twice
the upper limit of normal — proved highly reproducible.

Significance and stability are different properties. A tool reporting
only thresholds and a *p*-value would have returned four groups here, and
every check it ran would have supported them. The third step is one
function call, and in this analysis it is the difference between
reporting "0.7, 2.3 and 5.945 mg/dL" and reporting "2.3 mg/dL".

When reporting an analysis of this kind: use BIC for confirmatory work,
`n_perm` of at least 1000, at least 500 bootstrap replicates, and check
the composition table for covariate imbalance. Bootstrap stability
describes sampling variability within one cohort; it is not a prediction
about an independent one.

## Reference

Dickson ER, Grambsch PM, Fleming TR, Fisher LD, Langworthy A (1989).
Prognosis in primary biliary cirrhosis: model for decision making.
*Hepatology* 10:1–7.

```{r session_info}
sessionInfo()
```
