Random Survival Forests › Visualizing Random Survival Forests
Machine Learning from ScratchAll chapters Random Survival Forests › Visualizing Random Survival ForestsSection view · Sections in this chapter5 / 10 ↓
Reading tools Definitions, links & notes
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Before fitting anything, we should see how the two ingredients of the model work. We simulate a dataset in which the truth is known, and use it to look at the split search and at the terminal-node estimate.
The data generator is a Weibull model in which the log hazard is a nonlinear function of the covariates. The Weibull generator uses a shape parameter and a rate parameter. We distinguish the generator parameters from the earlier tree-correlation symbol : the Weibull shape parameter is and the Weibull rate parameter is , so the conditional survival is . The covariates are five independent standard normal variables, the first two of which are informative and the last three of which are noise. The covariate contribution to the log hazard is
which contains a linear term, a periodic term, and a quadratic term, and therefore a linear model in and cannot represent the truth. Censoring is exponential with rate , which produces roughly half of the observations censored at the generated event-time scale.
The quantities are:
- : the five standard-normal covariates, of which only and enter
- : the covariate contribution to the log hazard; the Weibull baseline also depends on time
- : the Weibull shape parameter, here
- : the Weibull rate parameter, here
- : the conditional survival function of the event time given the covariates
The simulated censoring times are independent of both the covariates and the latent event times. This gives independent censoring in this simulation.
PythonIn [7]Expand
def make_survival_data(n, rng, nonlinear=True):
"""Simulate Weibull survival times with a nonlinear log hazard."""
X = rng.normal(size=(n, 5))
if nonlinear:
eta = (
1.2 * X[:, 0]
+ 1.2 * np.sin(2.0 * X[:, 0])
+ 1.2 * (X[:, 1] ** 2 - 1.0)
)
else:
eta = 1.0 * X[:, 0] + 0.8 * X[:, 1]
shape, rate = (
1.5,
0.20,
) # named a and b in the text to avoid clashing with rho and lambda
u = rng.uniform(size=n)
event_time = (-np.log(u) / (rate * np.exp(eta))) ** (1.0 / shape)
censor_time = rng.exponential(scale=1.0 / 0.30, size=n)
return (
X,
np.minimum(event_time, censor_time),
event_time <= censor_time,
eta,
)
rng = np.random.default_rng(20240517)
X_train, t_train, e_train, eta_train = make_survival_data(500, rng)
X_test, t_test, e_test, eta_test = make_survival_data(400, rng)ConsoleOut [8]Expand
training rows: 500, events: 246, censored: 50.8% test rows: 400, events: 186, censored: 53.5% median follow-up (training): 0.96
The event counts, censoring percentages, and median follow-up above are computed from the simulated arrays and can vary with the seed. The requested row counts remain 500 for training and 400 for testing. In this simulated example, a few hundred events allow several leaves under the minimum-events rule, while censoring makes the risk-set bookkeeping visible.
The split search
The first ingredient is the log-rank statistic. The function below implements the formula we derived, with the sorted arrays used to compute the risk sets at each distinct event time. The grouping vector is true for the left child and false for the right child; a negative returned statistic indicates fewer left-child events than expected in the summed comparison, and its absolute value is what the split search maximizes.
PythonIn [9]Expand
def logrank_statistic(time, event, group):
"""Standardised log-rank statistic for a two-group split."""
time = np.asarray(time, dtype=float)
event = np.asarray(event, dtype=bool)
group = np.asarray(group, dtype=bool)
n = time.size
order = np.argsort(time, kind="mergesort")
t_sorted = time[order]
e_sorted = event[order].astype(float)
g_sorted = group[order].astype(float)
unique_times, first = np.unique(t_sorted, return_index=True)
at_risk = n - first
deaths = np.add.reduceat(e_sorted, first)
deaths_g = np.add.reduceat(e_sorted * g_sorted, first)
group_before = np.concatenate(([0.0], np.cumsum(g_sorted)))[first]
at_risk_g = g_sorted.sum() - group_before
at_risk_o = at_risk - at_risk_g
keep = deaths > 0
d, d_g = deaths[keep], deaths_g[keep]
y, y_g, y_o = at_risk[keep], at_risk_g[keep], at_risk_o[keep]
observed = d_g
expected = y_g * d / y
variance = np.zeros_like(d)
ok = y > 1
variance[ok] = (
d[ok]
* (y[ok] - d[ok])
* y_g[ok]
* y_o[ok]
/ (y[ok] ** 2 * (y[ok] - 1.0))
)
total_variance = variance.sum()
if total_variance <= 0:
return 0.0
return float((observed - expected).sum() / np.sqrt(total_variance))The keep filter is important: the statistic is a sum over event times, because a time at which nobody dies contributes nothing to either the numerator or the variance. The ok guard handles any event time at which the risk set has only a single person, where the displayed variance fraction is undefined and the comparison variance is zero.
The split search sweeps over candidate thresholds. For each feature we take a grid of quantiles, form the group membership, and evaluate the statistic. This is deliberately the same kind of search a tree performs, restricted here to a coarse grid.
PythonIn [10]Expand
def best_split(X, time, event, features, n_cuts=10, min_leaf=15, min_events=8):
"""Search a quantile grid on each candidate feature; return the best log-rank split."""
best = None
for feature in features:
x = X[:, feature]
for cut in np.unique(np.quantile(x, np.linspace(0.1, 0.9, n_cuts))):
left = x <= cut
n_left = int(left.sum())
if n_left < min_leaf or x.size - n_left < min_leaf:
continue
d_left = event[left].sum()
if d_left < min_events or event.sum() - d_left < min_events:
continue
stat = logrank_statistic(time, event, left)
if best is None or abs(stat) > abs(best[2]):
best = (int(feature), float(cut), stat)
return bestThe two figures below sweep thresholds on the two informative features and plot the signed log-rank statistic. On this seeded sample, the curve stays negative and reaches its largest magnitude at an interior threshold near . The curve changes sign near the centre, with larger magnitudes toward the tails. Its largest magnitude on this grid occurs near , separating the upper tail from the remaining observations. The split search compares absolute values, not the heights of the signed curves.
PythonIn [11]Expand
candidates_x1 = np.quantile(X_train[:, 0], np.linspace(0.02, 0.98, 60))
candidates_x2 = np.quantile(X_train[:, 1], np.linspace(0.02, 0.98, 60))
stats_x1 = np.array(
[
logrank_statistic(t_train, e_train, X_train[:, 0] <= c)
for c in candidates_x1
]
)
stats_x2 = np.array(
[
logrank_statistic(t_train, e_train, X_train[:, 1] <= c)
for c in candidates_x2
]
)PythonIn [12]Expand
import matplotlib.pyplot as plt
from book_plot_style import PALETTE, polish_axes, theme_color, use_book_style
def pcol(name, fallback=0):
try:
return PALETTE[name]
except (KeyError, TypeError, IndexError):
return plt.rcParams["axes.prop_cycle"].by_key()["color"][fallback]
use_book_style()
plt.figure(figsize=(6.0, 4.0))
ax = plt.gca()
ax.plot(candidates_x1, stats_x1, color=PALETTE["primary"], linewidth=1.1)
ax.axhline(0.0, color=PALETTE["muted"], linewidth=0.8, linestyle=":")
k = int(np.argmax(np.abs(stats_x1)))
ax.plot(
candidates_x1[k],
stats_x1[k],
"o",
markerfacecolor=PALETTE["paper"],
markeredgecolor=PALETTE["primary"],
markersize=8,
)
ax.axvline(
candidates_x1[k], color=PALETTE["primary"], linestyle="--", linewidth=1.0
)
ax.set_xlabel("candidate threshold on $x_1$")
ax.set_ylabel("log-rank statistic $L(s)$")
ax.set_title("Split search on the linear-plus-sine feature")
polish_axes(ax)
plt.show()VisualizationOut [12]Expand

PythonIn [13]Expand
import matplotlib.pyplot as plt
from book_plot_style import PALETTE, polish_axes, theme_color, use_book_style
def pcol(name, fallback=0):
try:
return PALETTE[name]
except (KeyError, TypeError, IndexError):
return plt.rcParams["axes.prop_cycle"].by_key()["color"][fallback]
use_book_style()
plt.figure(figsize=(6.0, 4.0))
ax = plt.gca()
ax.plot(candidates_x2, stats_x2, color=PALETTE["amber"], linewidth=1.1)
ax.axhline(0.0, color=PALETTE["muted"], linewidth=0.8, linestyle=":")
k = int(np.argmax(np.abs(stats_x2)))
ax.plot(
candidates_x2[k],
stats_x2[k],
"o",
markerfacecolor=PALETTE["paper"],
markeredgecolor=PALETTE["amber"],
markersize=8,
)
ax.axvline(
candidates_x2[k], color=PALETTE["amber"], linestyle="--", linewidth=1.0
)
ax.set_xlabel("candidate threshold on $x_2$")
ax.set_ylabel("log-rank statistic $L(s)$")
ax.set_title("Split search on the quadratic feature")
polish_axes(ax)
plt.show()VisualizationOut [13]Expand

The curve has a broad interior trough rather than a monotone shape. The selected threshold near separates a lower-risk left child from a higher-risk right child in this sample. The curve shows a threshold near that isolates the upper high-risk tail. A single threshold cannot put both tails of a quadratic effect into one child while leaving the centre in the other. Later splits can isolate the two tails in separate leaves, allowing their predictions to differ from the centre. Bootstrap samples and random feature subsets can change the selected thresholds across trees.
What a terminal node stores
The second ingredient is the Nelson–Aalen estimator. The function below sorts the data, groups the times, and accumulates the increments. It returns the distinct event times and the cumulative hazard at each of them, so the result is a step function that can be evaluated anywhere.
PythonIn [14]Expand
def nelson_aalen(time, event):
"""Nelson-Aalen estimate of the cumulative hazard: event times and cumulative hazard."""
time = np.asarray(time, dtype=float)
event = np.asarray(event, dtype=bool)
n = time.size
if n == 0:
return np.empty(0), np.empty(0)
order = np.argsort(time, kind="mergesort")
t_sorted, e_sorted = time[order], event[order]
unique_times, first = np.unique(t_sorted, return_index=True)
at_risk = n - first
deaths = np.add.reduceat(e_sorted.astype(float), first)
keep = deaths > 0
return unique_times[keep], np.cumsum(deaths[keep] / at_risk[keep])The at_risk = n - first line is the whole risk-set computation in disguise: first is the index of the first occurrence of each distinct time in the sorted arrays, so n - first is the number of observations whose time is at least that value. It is the same as counting the patients with , and it takes linear time after sorting, in addition to the sorting cost.
Now we take the best split found on the full data, form the two children, and estimate the cumulative hazard in each. The visual sweeps above use a finer, wider quantile grid than this helper; here the root search also enforces at least 25 observations and 15 events in each child, so its selected threshold can differ from the sweep markers.
PythonIn [15]Expand
root_feature, root_cut, root_stat = best_split(
X_train,
t_train,
e_train,
features=range(X_train.shape[1]),
min_leaf=25,
min_events=15,
)
in_left = X_train[:, root_feature] <= root_cut
times_all, cumhaz_all = nelson_aalen(t_train, e_train)
times_left, cumhaz_left = nelson_aalen(t_train[in_left], e_train[in_left])
times_right, cumhaz_right = nelson_aalen(t_train[~in_left], e_train[~in_left])
plot_times_all = np.r_[0.0, times_all]
plot_cumhaz_all = np.r_[0.0, cumhaz_all]
plot_times_left = np.r_[0.0, times_left]
plot_cumhaz_left = np.r_[0.0, cumhaz_left]
plot_times_right = np.r_[0.0, times_right]
plot_cumhaz_right = np.r_[0.0, cumhaz_right]PythonIn [16]Expand
import matplotlib.pyplot as plt
from book_plot_style import PALETTE, polish_axes, use_book_style
use_book_style()
plt.figure(figsize=(6.0, 4.6))
ax = plt.gca()
ax.step(
plot_times_all,
plot_cumhaz_all,
where="post",
color=PALETTE["muted"],
linewidth=1.1,
label="all patients",
)
ax.step(
plot_times_left,
plot_cumhaz_left,
where="post",
color=PALETTE["primary"],
linewidth=1.1,
label=f"$x_{{{root_feature + 1}}} \\leq {root_cut:.2f}$",
)
ax.step(
plot_times_right,
plot_cumhaz_right,
where="post",
color=PALETTE["amber"],
linewidth=1.1,
label=f"$x_{{{root_feature + 1}}} > {root_cut:.2f}$",
)
ax.set_xlabel("time")
ax.set_ylabel("cumulative hazard $\\hat{H}(t)$")
ax.set_xlim(0.0, 6.0)
ax.set_title("Nelson–Aalen cumulative hazard by child")
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.20), ncol=3)
polish_axes(ax)
plt.show()VisualizationOut [16]Expand

PythonIn [17]Expand
import matplotlib.pyplot as plt
from book_plot_style import PALETTE, polish_axes, use_book_style
use_book_style()
plt.figure(figsize=(6.0, 4.6))
ax = plt.gca()
ax.step(
plot_times_all,
np.exp(-plot_cumhaz_all),
where="post",
color=PALETTE["muted"],
linewidth=1.1,
label="all patients",
)
ax.step(
plot_times_left,
np.exp(-plot_cumhaz_left),
where="post",
color=PALETTE["primary"],
linewidth=1.1,
label=f"$x_{{{root_feature + 1}}} \\leq {root_cut:.2f}$",
)
ax.step(
plot_times_right,
np.exp(-plot_cumhaz_right),
where="post",
color=PALETTE["amber"],
linewidth=1.1,
label=f"$x_{{{root_feature + 1}}} > {root_cut:.2f}$",
)
ax.set_xlabel("time")
ax.set_ylabel("survival $\\hat{S}(t) = \\exp(-\\hat{H}(t))$")
ax.set_xlim(0.0, 6.0)
ax.set_title("Survival is the exponential of the negative")
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.20), ncol=3)
polish_axes(ax)
plt.show()VisualizationOut [17]Expand

The fitted child staircases separate in this sample; a selected split can also separate curves by chance. The jumps are visible: each event time in a node produces a vertical increment of size , and their sizes depend on the local event count and number still at risk. A minimum event count does not itself bound the last jump. If a leaf had only one event and one patient, the jump would be a full unit of hazard, and the survival function would collapse to in one step. Our implementation constrains both observations and events per child. Other implementations, including the reference used below, express their leaf-size constraints differently.
Comments
Add to the conversation
A question, a correction, or an example from your own work.
No comments yet. Be the first to share your thoughts!