---
title: "Thresholds in a Discrete Predictor"
subtitle: "Lymph node burden in stage III colon cancer"
author:
  - "Payton Yau"
  - "Suhirthakumar Puvanendran"
date: "`r Sys.Date()`"
output:
  rmarkdown::html_vignette:
    toc: true
    fig_caption: yes
vignette: >
  %\VignetteIndexEntry{Thresholds in a Discrete Predictor}
  %\VignetteEncoding{UTF-8}
  %\VignetteEngine{knitr::rmarkdown}
editor_options: 
  markdown: 
    wrap: 72
---

```{r setup, include=FALSE}
knitr::opts_chunk$set(
  collapse = TRUE, comment = "+",
  fig.width = 7, fig.height = 4, fig.align = "center",
  warning = FALSE, message = TRUE
)
options(cli.num_colors = 1)
# Suppress cli progress bars (validate_cutpoint()'s "Bootstrapping" bar):
# same rationale as bilirubin.Rmd - see that vignette's setup chunk.
options(cli.progress_show_after = Inf)
```

# Introduction

The companion vignette applies the workflow to a continuous marker and
arrives at a precise threshold. This one uses a **count**, and does not.

Counts take few distinct values, cluster at the low end, and admit
boundaries only at whole numbers. A threshold cannot shift slightly: it
jumps, and a single step can reassign a large block of patients. This
vignette shows how that behaves in practice and how to report the result.

## The data

The `colon` dataset comes from two North Central Cancer Treatment Group
trials of adjuvant levamisole and fluorouracil after resection of stage
III colon cancer (Laurie et al., 1989; Moertel et al., 1990). Stage III
means the tumour has spread to regional lymph nodes but not beyond, and
the number of involved nodes is among the strongest predictors of
recurrence.

Staging already uses this count: the AJCC system divides N1 from N2 at
four positive nodes. Here we ask what boundary the trial data support,
adjusting for age, sex and tumour differentiation grade, using
recurrence-free survival capped at five years.

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

# 1. Setup

```{r load-libraries}
library(dplyr)
library(survival)
library(ggplot2)
library(knitr)
library(OptSurvCutR)

options(cli.num_colors = 1)
```

```{r configuration}
covariates_to_adjust_for <- c("age", "sex", "differ")

# One minimum group size, used at every step
NMIN <- 0.15
```

A single `nmin` value is used at every step. If model selection and
threshold location run under different constraints they search different
spaces, and their results are not directly comparable.

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

# 2. Data preparation

The dataset holds **two rows per patient**, one for recurrence and one
for death (`etype`). Modelling both without filtering would treat each
patient as two independent observations.

```{r load-data}
# library(survival), loaded above, already exposes `colon` as a lazy-loaded
# object. An explicit data("colon", package = "survival") call here would
# emit a spurious "data set 'colon' not found" warning in a clean session -
# the dataset lives inside survival's multi-object cancer.rda, and data()'s
# own name-based lookup does not resolve it the same way library() does.
# Confirmed harmless and unnecessary; see the equivalent fix in Figure_3.R.

analysis_data <- colon %>%
  filter(etype == 1) %>%                      # recurrence records only
  select(patient_id = id, time_days = time, status,
         nodes, all_of(covariates_to_adjust_for)) %>%
  mutate(
    time_months  = time_days / 30.4375,
    status_final = ifelse(time_months > 60, 0, status),   # 5-year censoring
    time_final   = pmin(time_months, 60)
  ) %>%
  select(patient_id, time = time_final, status = status_final,
         nodes, all_of(covariates_to_adjust_for)) %>%
  filter(complete.cases(time, status, nodes,
                        across(all_of(covariates_to_adjust_for))))

nrow(analysis_data)
```

This leaves **888 patients** — the number to report, rather than the 929
recurrence records or the 1,858 rows in the raw file.

It is worth inspecting the predictor before searching:

```{r predictor-table}
table(analysis_data$nodes)
```

Most patients have one to three positive nodes, and the tail is long and
sparse. Everything that follows is shaped by this distribution.

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

# 3. Finding the cut-points

## How many?

BIC is used rather than AIC: a staging rule is confirmatory work, and BIC
penalises additional groups more heavily.

```{r find-number}
number_result <- find_cutpoint_number(
  data = analysis_data, predictor = "nodes",
  outcome_time = "time", outcome_event = "status",
  covariates = covariates_to_adjust_for,
  method = "genetic", criterion = "BIC",
  max_cuts = 4, nmin = NMIN,
  max.generations = NULL, pop.size = NULL,
  boundary.enforcement = 2, seed = 123
)

summary(number_result)
```

```{r plot-number}
plot(number_result)
```

BIC is minimised at **two cut-points**, holding 92.8% of the BIC weight;
the single-cut model is the runner-up at ΔBIC = 5.11 (7.2%).

Larger models return no valid configuration under `nmin = 0.15`, which
requires 133 patients per group. In a separate run with `nmin` lowered to
0.125 (a floor of 111 patients) the three-cut model completed and was
clearly inferior (BIC = 5510.22, ΔBIC = 9.04). The constraint therefore
excluded a model rather than protecting against a better one, though this
can only be established by testing: a message reporting that subgroups
violated the constraint is not evidence that a configuration is
infeasible.

The Schoenfeld test in this summary already flags a departure from
proportional hazards (*p* = 0.000682), which we return to in section 5.

## Where?

`nodes` takes few distinct values, so an exhaustive search is fast and
exact.

```{r find-cutpoint}
cutpoint_result <- find_cutpoint(
  data = analysis_data, predictor = "nodes",
  outcome_time = "time", outcome_event = "status",
  covariates = covariates_to_adjust_for,
  num_cuts = number_result$optimal_num_cuts,
  method = "systematic", criterion = "logrank",
  nmin = NMIN,
  n_perm = 100,          # low for build speed; use >= 1000 when reporting
  seed = 123, n_cores = 1
)

summary(cutpoint_result)
```

The thresholds are **2 and 4 positive nodes**:

| Group | Nodes | N | Events | Median RFS | 5-year RFS |
|:---|:---|---:|---:|:---|:---|
| G1 (Low) | 1–2 | 457 | 168 | Not reached | 62.6% (58.3–67.2) |
| G2 (Intermediate) | 3–4 | 202 | 102 | 51 months | 48.7% (42.2–56.2) |
| G3 (High) | ≥ 5 | 229 | 159 | 15 months | 29.8% (24.3–36.4) |

Adjusted hazard ratios are 1.57 and 2.75 relative to G1. Among the
covariates only differentiation grade is independently associated with
recurrence (HR = 1.24, *p* = 0.028). Concordance is 0.63 — real but
moderate discrimination.

Note that G1's five-year survival is 62.6%, not 58.3%; the lower figure
is the confidence bound. The permutation *p* of 0.0099 is exactly
1/(100+1), the floor for `n_perm = 100`.

```{r plot-surface, fig.width=7, fig.height=4.75}
plot(cutpoint_result, type = "surface")
```

Because the search was exhaustive, every evaluated pair is retained. A
broad bright region rather than a sharp peak indicates that many
threshold pairs score almost as well as the winner — an early sign that
the boundaries will not be stable.

```{r plot-distribution}
plot(cutpoint_result, type = "distribution")
```

The thresholds are overlaid on the distribution of node counts. The
contrast with a continuous marker is visible here: values stack at each
integer, so the boundaries fall between discrete columns rather than
within a smooth density.

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

# 4. Stability

```{r validate}
validation_result <- validate_cutpoint(
  cutpoint_result = cutpoint_result,
  num_replicates = 30,      # reduced for build speed; use >= 500 when reporting
  n_cores = 1, seed = 123   # single core: vignette builds cannot use parallel workers
)

summary(validation_result)
```

| 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, and
the percentages are practical conventions.

```{r plot-validation}
plot(validation_result)
```

```{r plot-validation-2d, fig.width=7, fig.height=4}
plot_validation(validation_result, focus_cuts = c(1, 2),
                main = "Cuts 1 and 2 across resamples")
```

The resampled boundaries sit on a handful of discrete positions rather
than forming a smooth cloud. This is characteristic of a count predictor:
the boundary can only take whole-number values, so it jumps between them.

Thirty replicates is sufficient to illustrate the output. With few
replicates the intervals are narrower than they should be, so use at
least 500 replicates for any reported analysis.

## Results at 500 replicates

| Model | Threshold(s) | Widest interval | Tier |
|:---|:---|---:|:---|
| Two cut-points (BIC choice) | 2, 4 | 42.9% | 3 — CAUTION |
| One cut-point | 4 | 57.1% | 2 — DISTINCT |

For the two-cut model the intervals are 1–3 and 3–6. They meet at 3, so
adjacent groups are not cleanly separated. The lower boundary is
reasonably precise at 28.6%; the upper one is not. 

The threshold is **4 positive nodes** with a bootstrap interval of 2–6.
The median across resamples is 4 and the interquartile range 3–4, so the
boundary is usually recovered, although the tail extends to 2 and 6. With
one boundary there is no separation to assess, so the model is graded on
width alone.

The single-cut model was already the BIC runner-up, and was therefore a
candidate on model-selection grounds before any stability result. This is
worth stating when reporting a simplification made after inspecting the
bootstrap.

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

# 5. Reporting and diagnostics

## Group composition

```{r group-composition}
grouped <- plot(cutpoint_result, return_data = TRUE)

grouped %>%
  group_by(group) %>%
  summarise(
    Nodes    = paste0(min(factor), " - ", max(factor)),
    N        = n(),
    Events   = sum(event),
    Mean_Age = round(mean(age), 1),
    Grade    = round(mean(differ), 2)
  ) %>%
  kable(caption = "Composition of the three nodal burden groups")
```

Checking this table before reporting is worthwhile. Mean age is similar
across groups, so the survival gradient is not age acting through node
count. Mean differentiation grade rises with nodal burden, which is why
it is included as an adjustment variable.

## Hazard ratios

```{r plot-forest, fig.width=7, fig.height=3.5}
plot(cutpoint_result, type = "forest",
     reference_group = "G1",
     main = "Adjusted hazard ratios for nodal burden groups")
```

The estimates are adjusted for age, sex and differentiation grade, so
nodal burden is not standing in for those. Two qualifications apply: they
were estimated on the same data that selected the thresholds and are
therefore optimistic, and because proportional hazards does not hold
(below) each is an average effect over the 60-month window.

## Proportional hazards

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

The Schoenfeld test returns global *p* = 0.000682, so proportional
hazards does not hold. The effect of nodal burden is strongest early and
attenuates, consistent with most stage III recurrences occurring within
two years. The grouping remains valid; the hazard ratios should be
described as averages over the 60-month window rather than constant
multipliers.

## Landmark analysis

```{r plot-landmark, fig.width=7, fig.height=4.5}
plot(cutpoint_result, type = "landmark", landmark = 12,
     legend.title = "Nodal burden group")
```

A landmark analysis restarts the clock at 12 months among patients still
event-free. Separation persisting there indicates the groups carry
information beyond early events. This addresses immortal time bias rather
than eliminating it.

## Survival curves

```{r plot-km}
plot(cutpoint_result, type = "outcome",
     title = "Five-year recurrence-free survival by nodal burden",
     xlab = "Time (months)", ylab = "Recurrence-free survival",
     legend.title = "Nodal burden group",
     legend.labs = c("Low (G1)", "Intermediate (G2)", "High (G3)"))
```

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

# 6. Conclusion

Node count separates these 888 patients into three groups with clearly
different recurrence-free survival, at 2 and 4 positive nodes. The result
is highly significant.

The boundaries are not precise. Across 500 resamples the first moves
between 1 and 3 and the second between 3 and 6, and the two overlap.
Reducing to one cut-point removes the overlap but leaves an interval of
2–6 around a threshold of 4.

The conclusion is not that node count fails to predict recurrence, which
it plainly does, but that a discrete predictor with few distinct values
supports a **range** rather than a single value. Recurrence risk rises
somewhere around three to five positive nodes. A rule using four is
defensible; a claim that four is specifically optimal is not.

The data-driven optimum coincides with the AJCC N1/N2 boundary at four
nodes. Given the width of the interval this is best described as
consistent with the staging convention rather than as independent support
for it.

For discrete predictors: tabulate the values first, prefer
`method = "systematic"`, use one `nmin` throughout, expect wide
intervals, and report the boundary as a range when the width is large.

## References

Laurie JA, Moertel CG, Fleming TR, et al. (1989). Surgical adjuvant
therapy of large-bowel carcinoma. *J Clin Oncol* 7:1447–1456.

Moertel CG, Fleming TR, Macdonald JS, et al. (1990). Levamisole and
fluorouracil for adjuvant therapy of resected colon carcinoma.
*N Engl J Med* 322:352–358.

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