Censoring¶
Every generator censors. Two mechanisms are built in, and a handful of extra samplers are available if you want something else.
The two built-in mechanisms¶
Each generator takes model_cens and cens_par:
model_cens |
Censoring time distribution | What cens_par is |
|---|---|---|
"uniform" |
\(C \sim \mathrm{Uniform}(0, \texttt{cens\_par})\) | the upper bound |
"exponential" |
\(C \sim \mathrm{Exponential}\) | the mean — not the rate |
A subject's row records \(\min(T, C)\) and which one won. Censoring is independent of the event time in both cases, so standard survival estimators are unbiased — see Cox PH.
cens_par is a mean for exponential censoring
Larger cens_par means less censoring for both mechanisms. If you are
used to exponential parameterisations by rate, the direction here is the
opposite of what you might expect.
Hitting a target event rate¶
There is no closed-form knob for "censor 30% of subjects" — it depends on the model's own hazard. Sweep and pick:
from gen_surv import generate
for cens_par in (0.5, 1.0, 5.0, 100.0):
u = generate(model="cphm", n=20000, beta=0.5, covariate_range=2.0,
model_cens="uniform", cens_par=cens_par, seed=1)
e = generate(model="cphm", n=20000, beta=0.5, covariate_range=2.0,
model_cens="exponential", cens_par=cens_par, seed=1)
print(f"cens_par={cens_par:>6} uniform={u['status'].mean():.3f} "
f"exponential={e['status'].mean():.3f}")
cens_par= 0.5 uniform=0.324 exponential=0.448
cens_par= 1.0 uniform=0.508 exponential=0.616
cens_par= 5.0 uniform=0.870 exponential=0.886
cens_par= 100.0 uniform=0.994 exponential=0.994
Those numbers are specific to this model and these parameters. Re-run the sweep whenever you change the hazard.
Switching censoring off¶
Set cens_par to something far beyond the largest event time:
df = generate(model="cphm", n=10000, beta=0.5, covariate_range=2.0,
model_cens="uniform", cens_par=1e9, seed=42)
df["status"].mean() # 1.0 — every event observed
Useful when you want to check a generator's distribution without censoring in the way, as the model pages do.
Administrative censoring¶
competing_risks, competing_risks_weibull and mixture_cure also take
max_time, which truncates follow-up for everyone on top of the random
censoring. That is the "the study ended" kind of censoring, as opposed to the
"this subject was lost" kind.
The other samplers¶
gen_surv ships five censoring-time samplers and three class-based equivalents:
| Function | Signature | Distribution |
|---|---|---|
runifcens |
(size, cens_par, rng=None) |
Uniform(0, cens_par) |
rexpocens |
(size, cens_par, rng=None) |
Exponential(mean=cens_par) |
rweibcens |
(size, scale, shape, rng=None) |
Weibull |
rlognormcens |
(size, mean, sigma, rng=None) |
log-normal |
rgammacens |
(size, shape, scale, rng=None) |
Gamma |
| Class | Constructor | Call |
|---|---|---|
WeibullCensoring |
(scale, shape) |
instance(size, rng=None) |
LogNormalCensoring |
(mean, sigma) |
instance(size, rng=None) |
GammaCensoring |
(shape, scale) |
instance(size, rng=None) |
These are not wired into the generators
model_cens accepts only "uniform" and "exponential"; every generator
resolves it against those two functions internally. The Weibull, log-normal
and Gamma samplers are standalone — you apply them yourself, as below.
Passing model_cens="weibull" raises:
Recipe: censor with your own distribution¶
Generate effectively uncensored data, then apply the censoring you want:
import numpy as np
from gen_surv import generate, WeibullCensoring
# 1. No censoring: every `time` is a true event time
df = generate(model="cphm", n=10000, beta=0.5, covariate_range=2.0,
model_cens="uniform", cens_par=1e9, seed=42)
# 2. Your censoring distribution
rng = np.random.default_rng(0)
c = WeibullCensoring(scale=1.0, shape=1.5)(len(df), rng)
# 3. Apply it
censored = df.assign(
time=np.minimum(df["time"], c),
status=(df["time"] <= c).astype(float),
)
print(censored["status"].mean()) # 0.664
The same shape works with any of the samplers:
from gen_surv import rgammacens
c = rgammacens(len(df), shape=2.0, scale=0.5, rng=rng) # event rate 0.699
Do the minimum before you overwrite time
In the snippet above, df["time"] is still the uncensored event time when
it is compared against c. If you assign the new time first and then
compute status, every subject looks like an event. Build both columns in
one assign, as shown.
Dependent censoring¶
The recipe also lets you break the independence assumption on purpose — useful for showing that an estimator relying on it goes wrong:
# Censoring that arrives sooner for high-covariate subjects
c = rng.exponential(scale=1.0 / (0.5 + df["X0"]))
Which mechanism should you pick?¶
"uniform"gives a hard upper bound on follow-up. Everyone is out of the study bycens_par, which mimics a fixed-length trial."exponential"has no upper bound and a long tail, closer to loss-to-follow-up that can happen at any time.
For heavy censoring, uniform is the harsher of the two at equal cens_par, as
the sweep above shows.
Related¶
- Output schemas — how
statusencodes censoring per model - Summarising a dataset — event and censoring counts
- API: Censoring