How to deal with right-censored observations

I’ve recently been interested in understanding survival models, which model the time to an event of interest (tte) but where we are not always able to wait until that event occurs. This happens, for example, when modelling the time until a customer churns: some of your customers may have cancelled their subscriptions but many hopefully haven’t. Those that haven’t are said to be censored because we haven’t observed them cancel their subscription yet.

As a first step in that direction, we’ll take a look at modelling censoring when the tte has a Poisson distribution (minor modifications can be made to extend to other distributions). We’ll use Stan to implement our model since Bayesian notation very nicely reflects our statistical understanding. Don’t worry if you aren’t familiar with Stan or Bayesian inference - it should be possible to follow along regardless.

You can download the R markdown and the stan model to try it out.

Some theory

The Problem

Let’s generate some data. We will assume that the time to event (tte) is poisson distributed with mean \(\mu = 10\). However, we will also assume that we don’t get to observe the event of interest in every case, i.e. some cases are censored. What we measure is the time to observation (tto).

N <- 10000
mu <- 10

df <- tibble(
  id = 1:N,
  tte = rpois(N, mu),
  tto = pmin(rpois(N, 12), tte),
  censored = tto < tte

df %>% 
  head() %>% 
  kable() %>% kable_styling()
id tte tto censored
1 12 11 TRUE
2 7 7 FALSE
3 4 4 FALSE
4 13 8 TRUE
5 10 10 FALSE
6 15 8 TRUE

Note that we observe tto but not tte. How might we estimate \(\mu\)? One way is to take the mean.

df %>% 
  select(tte, tto) %>% 
  summarise_all(mean) %>% 
  kable() %>% kable_styling()
tte tto
9.9916 8.9525

This estimate is fairly good for tte but is too low for tto. This was to be expected because we know that tto is smaller than tte for censored observations.

It’s not possible to just filter out the censored values as this also gives biased estimates. In fact, it makes our estimate worse in this case.

df %>% 
  filter(!censored) %>% 
  select(tte, tto) %>% 
  summarise_all(mean) %>% 
  kable() %>% kable_styling()
tte tto
8.916655 8.916655

A more sophisticated way to be wrong

So how can we estimate μ using just tto? The first step is to reinterperet the mean as the estimator that maximises a likelihood (the maximum likelihood estimator, or MLE). The likelihood is defined as the probability of the data given the estimate. Under the true model, this probability is \(f(tte_i \mid \mu) = \text{Poisson}(tte_i \mid \mu)\) for the \(i\)th case, giving the likelihood of the whole dataset as:

\[ L(\mu) := \prod_{i = 1}^N f(tte_i | \mu) . \]

The mean maximises this likelihood, which is why the mean of tte is close to the true value.

To further illustrate this point, note that this is the estimate we get when regressing tte on a constant.

df %>% 
    formula = tte ~ 1,
    family = poisson(link = 'log'),
    data = .
  ) %>% 
  tidy() %>% 
  pull(estimate) %>% 
[1] 9.9916

However, we don’t observe tte; we observe tto. Simply replacing tte with tto and maximising

\[ L(\mu) := \prod_{i = 1}^N f(tto_i \mid \mu) \]

gives us the mean of tto, which is a bad estimate because this ‘likelihood’ does not take censoring into account.

The correct likelihood

So what likelihood can we use? Note that in uncensored cases, \(f(tto_i \mid \mu) = f(tte_i \mid \mu)\), just like above.

In the censored cases, all we know is that tte must be larger than what was observed. This means that we need to sum over the probabilities of all possibilities: \(S(tto_i \mid \mu) := \sum_{t > tto_i} f(t \mid \mu)\). The full likelihood is then

\[ L(\mu) := \prod_{i = 1}^N \delta_i f(tto_i \mid \mu) \times \prod_{i = 1}^N (1 - \delta_i) S(tto_i \mid \mu) , \]

where \(\delta_i\) is 1 if the event was observed and 0 if censored. Although we’ll stick to the Poisson model in this post, we can use these ideas to create a likelihood for many different choices of distribution by using the appropriate probability/survival functions \(f\), \(S\).


We will fit this model using Stan because it is relatively easy to write a Bayesian model once we have understood the data generating process. This will require us to define prior distributions (on \(\mu\)) just like with any Bayesian method. Since we are mostly interested in understanding the likelihood here, we will not give much consideration to the prior. However, if applying this to a real problem, it would be a good idea to give this more thought in a principled Bayesian workflow.


Stan makes the following abbreviations:

probability mass function, \(f(tto_i \mid \mu) = \text{Poisson}(tto_i \mid \mu)\)
survival function, a.k.a. complementary cumulative distribution function, \(S(tto_i | \mu) := \sum_{t > tto_i} \text{Poisson}(t | \mu)\)
log(posterior probability density) = log(likelihood x prior).

Stan uses the log-scale for its calculations, so we will need the log-likelihood:

\[ \log L(\mu) := \sum_{i = 1}^N \delta_i \log f(tto_i \mid \mu) + \sum_{i = 1}^N (1 - \delta_i) \log S(tto_i \mid \mu) . \]

The model

In our model, we add the lccdf if the observation is censored and lpmf if not censored. Let’s load our model and take a look.

model <- stan_model('censored_poisson.stan')
S4 class stanmodel 'censored_poisson' coded as follows:
data {
  // the input data
  int<lower = 1> n;                      // number of observations
  int<lower = 0> tto[n];                 // tto is a list of ints
  int<lower = 0, upper = 1> censored[n]; // list of 0s and 1s
  // parameters for the prior
  real<lower = 0> shape;
  real<lower = 0> rate;

parameters {
  // the parameters used in our model
  real<lower = 0> mu; 

model {
  // posterior = prior * likelihood
  // prior
  mu ~ gamma(shape, rate);
  // likelihood
  for (i in 1:n) {
    if (censored[i]) {
      target += poisson_lccdf(tto[i] | mu);  
    } else {
      target += poisson_lpmf(tto[i] | mu);

The language used in the Stan model is slightly different from R-notation but hopefully intuitive enough to convince yourself that it’s the same model we described above.

Now we can sample from the posterior of our model.

fit <- model %>% 
    data = compose_data(df, shape = 2, rate = 0.05),
    iter = 2000,
    warmup = 500

Inference for Stan model: censored_poisson.
4 chains, each with iter=2000; warmup=500; thin=1; 
post-warmup draws per chain=1500, total post-warmup draws=6000.

          mean se_mean   sd      2.5%       25%       50%       75%
mu        9.98    0.00 0.03      9.92      9.96      9.98     10.01
lp__ -19613.55    0.01 0.69 -19615.54 -19613.71 -19613.29 -19613.09
         97.5% n_eff Rhat
mu       10.05  2002    1
lp__ -19613.04  2924    1

Samples were drawn using NUTS(diag_e) at Sat Aug  4 15:49:36 2018.
For each parameter, n_eff is a crude measure of effective sample size,
and Rhat is the potential scale reduction factor on split chains (at 
convergence, Rhat=1).

Stan has an amazing array of diagnostics to check the quality of the fitted model. Since our model is fairly simple and all checks are in order, I won’t describe them here.

The point estimate for mu is 9.98 and the true value is contained within the 95% credible interval [9.92, 10.05]. We can also plot all the samples from our posterior.


We have seen how to change the likelihood to take censored observations into account. Moreover, the same process works for most distributions, so you can swap out the Poisson for Weibull/gamma/lognormal or whatever you want. Using the Bayesian modelling language, Stan, makes it super easy to test your statistical intuitions by turning them into a workable model, so I’ll definitely be exploring Stan more in the future.