codexsurvival-hazard-relationship.py
survival-hazard-relationship.py
"""Generate survival-hazard-relationship.png in CWD; Python 3.14, NumPy, Matplotlib.
Run from _media with MPLBACKEND=Agg and a writable MPLCONFIGDIR.
The time coordinate is in years; hazard coefficients carry inverse-time units.
"""
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
def main():
t = np.linspace(0, 10, 1001)
models = [
("Constant: h(t) = 0.2 / year", "#007c83",
np.full_like(t, 0.2), 0.2 * t),
("Increasing: h(t) = (0.04 / year²) × t", "#b35422",
0.04 * t, 0.02 * t**2),
]
fig, axes = plt.subplots(1, 3, figsize=(11.2, 4.4), dpi=110,
facecolor="white")
for label, color, hazard, accumulated in models:
survival = np.exp(-accumulated)
# Check accumulation by trapezoidal integration and the density by
# differentiating the survival curve, away from endpoint stencils.
integrated = np.r_[0, np.cumsum((hazard[1:] + hazard[:-1])
* np.diff(t) / 2)]
assert np.allclose(integrated, accumulated, atol=1e-12)
assert np.allclose(-np.gradient(survival, t)[1:-1],
(hazard * survival)[1:-1], atol=2e-6)
assert np.isclose(survival[-1], np.exp(-2))
for ax, values in zip(axes, (hazard, accumulated, survival)):
ax.plot(t, values, color=color, lw=2.6, label=label)
ax.plot(5, values[500], "o", color=color, ms=5)
axes[0].set(title="Instantaneous rate h(t)", ylabel="Hazard (1 / year)",
ylim=(0, 0.44))
axes[1].set(title="Accumulated rate H(t)",
ylabel="Cumulative hazard (dimensionless)", ylim=(0, 2.2))
axes[2].set(title="Event-free probability S(t)",
ylabel="Survival probability", ylim=(0, 1.08))
axes[1].annotate("H(5) = 1", (5, 1), xytext=(1.3, 1.35),
color=models[0][1], fontsize=10,
arrowprops={"arrowstyle": "->", "color": models[0][1]})
axes[1].annotate("H(5) = 0.5", (5, 0.5), xytext=(5.2, 0.15),
color=models[1][1], fontsize=10,
arrowprops={"arrowstyle": "->", "color": models[1][1]})
axes[2].annotate("S(5) = 0.607", (5, np.exp(-0.5)), xytext=(5.5, 0.85),
color=models[1][1], fontsize=10,
arrowprops={"arrowstyle": "->", "color": models[1][1]})
axes[2].annotate("S(5) = 0.368", (5, np.exp(-1)), xytext=(0.4, 0.16),
color=models[0][1], fontsize=10,
arrowprops={"arrowstyle": "->", "color": models[0][1]})
for ax in axes:
ax.set(xlim=(0, 10), xlabel="Time t (years)")
ax.set_xticks([0, 2, 4, 6, 8, 10])
ax.set_facecolor("white")
ax.grid(color="#e6e6e6", lw=0.7)
ax.spines[["top", "right"]].set_visible(False)
ax.title.set_fontsize(11)
handles, labels = axes[0].get_legend_handles_labels()
fig.legend(handles, labels, loc="upper center", bbox_to_anchor=(0.5, 0.92),
ncol=2, frameon=False, fontsize=10)
fig.suptitle("Integrate the rate, then exponentiate: S(t) = exp[−H(t)]",
fontsize=13, y=0.985)
fig.text(0.5, 0.03,
"Both survival curves decrease. At year 10 both reach exp(−2) ≈ 0.135, "
"despite different hazard histories.", ha="center", fontsize=10)
fig.subplots_adjust(left=0.07, right=0.98, top=0.76, bottom=0.18, wspace=0.36)
fig.savefig(Path.cwd() / "survival-hazard-relationship.png",
facecolor="white", transparent=False)
plt.close(fig)
if __name__ == "__main__":
main()