Recitation 8: Review of hierarchical modeling

(c) 2018 Julian Wagner. With the exception of pasted graphics, where the source is noted, this work is licensed under a Creative Commons Attribution License CC-BY 4.0. All code contained herein is licensed under an MIT license.

This document was prepared at Caltech with financial support from the Donna and Benjamin M. Rosen Bioengineering Center.

This tutorial was generated from an Jupyter notebook. You can download the notebook here.

In [1]:
import numpy as np
import pandas as pd
import bokeh.io
import bebi103
import skimage.io
bokeh.io.output_notebook()
/Users/bois/Dropbox/git/bebi103/bebi103/viz.py:30: UserWarning: DataShader import failed with error "No module named 'datashader'".
Features requiring DataShader will not work and you will get exceptions.
  Features requiring DataShader will not work and you will get exceptions.""")
Loading BokehJS ...

We are working with some data for how defensive glands might effect survival of beetles against ant foes. You can download the data set here. We will start with some EDA on the data. This data was crudely hand annotated from videos of a behavioral set up nicknamed the 'hotdog' arena (pictured below). A single beetle was loaded into the chamber with three vicious ants and the time until ant-induced-beetle-death was assessed. These beetles posess a defensive gland with irritant compounds for use against their enemies; however, some beetles in this set up were 'disarmed.' Agitating the beetles before putting them in the arena causes them to use up their defensive compound, which takes about a week to regenerate.

Lets take a look at the data format.

In [2]:
df = pd.read_csv('../data/beetle_time_to_death_j_wagner.csv', index_col=0)
df.head(10)
Out[2]:
arena well trial defence time to death (min)
0 5d 1 arm 90
1 1d 1 arm 80
2 2d 1 arm 100
3 3d 1 arm 200
4 4d 1 arm 220
0 4d 2 arm 40
1 2d 2 arm 90
2 1d 2 arm 120
3 3d 2 arm 180
4 5d 2 arm 450

We can immidiatly see that there are mulitple trials of the experiment. In this case, each trial was done at a different day/time of day. This lends itself to hierarchy. Let's take a look at how much variation there is from trial to trial.

In [3]:
bokeh.io.show(bebi103.viz.jitter(data=df, 
                                 cats='trial', 
                                 val='time to death (min)', 
                                 color_column='defence', 
                                 show_legend=True))

There's not much data here, but it does look like there is a reasonably high level of variability from day to day, especially in the disarmed beetle case. Lets take a look at the shape of the data for the disarmed beetles.

In [4]:
bokeh.io.show(bebi103.viz.ecdf(df.loc[df['defence'] == 'disarm']['time to death (min)']))

One story for data generating process is that it is the wait time for the first encounter with an ant, after which the beetle is toast. Another is that there might need to be multiple encounters before a beetle dies, in which case this would look be more likely to look gamma disctributed. Based on the inflection points, we have a good sense that this isn't exponential, but lets try to model it this way for simplicity. Before we move on to hierarchy, it is a good idea to get a first version of the model written down and implemented. In this case, we will start by pooling the data over the different days. Since the 'arrival rate' $\beta$ is evidently rather small, it is more convenient for us to parameterize in terms of $\tau$. We let

\begin{align} \tau &\sim \mathrm{Normal}(150, 25) \\ t_i &\sim \mathrm{Exponential}(1/\tau) \, \forall i \end{align}

With this in hand, we go for a prior predictive check.

In [5]:
t = df.loc[df['defence']=='disarm', 'time to death (min)']

taus = np.random.normal(150, 25, size=50)

ppc_exponential_time = [np.random.exponential(tau, size=len(t)) for tau in taus]

p = bebi103.viz.ecdf(ppc_exponential_time[0], 
                     formal=True, 
                     line_width=0.5, 
                     x_axis_label='time to death (min)')

for i in range(1, len(ppc_exponential_time)):
    p = bebi103.viz.ecdf(ppc_exponential_time[i], 
                         formal=True, 
                         line_width=0.5, 
                         p=p)

bokeh.io.show(p)

This looks quite reasonable, so let's code up the model in Stan.

In [6]:
model_code = """

data {
    int N;
    real t[N];
}

parameters{
    real<lower=0> tau;
}

transformed parameters {
    real<lower=0> beta_ = 1/tau;
}

model {
    tau ~ normal(100, 25);
    t ~ exponential(beta_);
}

generated quantities {
  real t_ppc[N];

  for (i in 1:N) {
    t_ppc[i] = exponential_rng(beta_);
  }
}

"""
sm_exp = bebi103.stan.StanModel(model_code=model_code)
Using cached StanModel.

Now we will perform the sampling and take a look at some diagnostics.

In [7]:
data = dict(N=len(t), t=t)
samples_exp = sm_exp.sampling(data=data)
bebi103.stan.check_all_diagnostics(samples_exp)
n_eff / iter looks reasonable for all parameters.
Rhat looks reasonable for all parameters.
0.0 of 4000 (0.0%) iterations ended with a divergence.
0 of 4000 (0.0%) iterations saturated the maximum tree depth of 10.
E-BFMI indicated no pathological behavior.
Out[7]:
0

Let's take a look at the samples.

In [8]:
samples_exp
Out[8]:
Inference for Stan model: anon_model_6a665a9fa1d02f5bb743c73225cac035.
4 chains, each with iter=2000; warmup=1000; thin=1; 
post-warmup draws per chain=1000, total post-warmup draws=4000.

            mean se_mean     sd   2.5%    25%    50%    75%  97.5%  n_eff   Rhat
tau        99.75    0.45  17.78  68.12  87.01  98.96 111.46 136.89   1543    1.0
beta_       0.01  5.0e-5 1.9e-3 7.3e-3 9.0e-3   0.01   0.01   0.01   1472    1.0
t_ppc[0]   101.3    1.84 104.63    2.4  29.04  67.27 136.49 387.52   3232    1.0
t_ppc[1]   98.04     1.6  99.01   2.38  27.31  68.38 135.51 369.24   3840    1.0
t_ppc[2]   97.87    1.62 100.07   2.21  27.94  65.54 136.95 367.57   3817    1.0
t_ppc[3]  101.23    1.74 106.99   2.32  27.95  67.59 138.11 386.73   3761    1.0
t_ppc[4]  103.24    1.67  105.0   2.44  28.51  70.13  143.3  389.2   3948    1.0
t_ppc[5]   97.14    1.57  99.45   2.35  26.35  64.82 134.08 371.94   4000    1.0
t_ppc[6]   99.83    1.63 103.08   2.78  27.57  68.81 136.95 376.28   4000    1.0
t_ppc[7]   98.74    1.61 100.87   2.35  26.79  66.29 138.56 377.26   3945    1.0
t_ppc[8]  101.03    1.83 105.94   3.04  27.69  69.51 137.97 394.15   3337    1.0
t_ppc[9]   99.21    1.72 102.18    2.3  27.58  68.39  135.7 388.51   3541    1.0
t_ppc[10] 100.81    1.63 102.42   2.49  28.07  69.27 139.89 370.55   3948    1.0
t_ppc[11]  98.77     1.6 101.47   2.49  27.58  69.12  134.4 369.63   4000    1.0
t_ppc[12] 100.64    1.61 101.88   2.27  27.74  69.07 140.72  372.1   4000    1.0
t_ppc[13]  98.81    1.62 100.98   2.47  28.72  68.84 133.35 368.12   3872    1.0
t_ppc[14]  99.43    1.64 102.76   2.62  27.26  67.95 136.29 370.98   3930    1.0
lp__      -78.96    0.01   0.67 -80.88 -79.15  -78.7 -78.52 -78.47   2007    1.0

Samples were drawn using NUTS at Thu Nov 29 16:34:05 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).

We can now do a quick postereior predictive check to assess the model.

In [9]:
bokeh.io.show(bebi103.viz.predictive_ecdf(
    samples_exp, 
    name='t_ppc', 
    data=t, 
    title='Exponential',
    data_line=False,
    diff=True))

This doesn't look great, but lets also plot the ECDF of this data for good measure.

In [10]:
df_t_ppc = bebi103.stan.extract_array(samples_exp, 't_ppc')
p = bebi103.viz.ecdf(t, x_axis_label='time to death (min)', 
                     color='orange', level='overlay')

# Plot posterior predictive ECDFs
for i in df_t_ppc['chain_idx'].unique()[::40]:
    p = bebi103.viz.ecdf(df_t_ppc.loc[df_t_ppc['chain_idx']==i, 't_ppc'],
                         alpha=0.1, p=p)

bokeh.io.show(p)

Again, not great, especially considering how many of our few data points end up outside the data generated from our samples. We might want to attempt something like the gamma distribution for this instead, but in the interest of time we will see if we can get a hierarchical veresion of this model up and running. Based on some trial and error, I found that the $\tau$ parameterization is easier to interpret and set up for this model as well. On top of that, noncentering the model is a must to stave of divergences. With that in mind, please write down a hierarchical model for this data.

With this in hand, we will perform the prior predictive checks. We will do this in Stan this time for variety.

In [ ]:
model_code_prior_pc = """

data {
    int N;
    int J_1;
    int index_1[N];
}

generated quantities {
    vector<lower=0>[J_1] tau;
    vector[N] t;
    
    INSERT DECLARATION OF AND PRIORS FOR HYPERPARAMETERS HERE
    
    
    for (i in 1:J_1){ 
        tau[i] = ??????????????;
    }
    
    for (i in 1:N) {
        t[i] = exponential_rng(??????????????);
    }
}

"""
sm_exp_heir_prior_pred = bebi103.stan.StanModel(model_code=model_code_prior_pc)

We need to make sure our data is in the right format for the model, so we use Justin's remarkably intuitive function to do this. We are only treating the day the experiment was performed via the hierachy here.

In [ ]:
treatment = 'disarm'
data, _ = bebi103.stan.df_to_datadict_hier(df.loc[df['defence']==treatment, :],
                                               level_cols='trial',
                                               data_cols='time to death (min)')
#rename the time column
data['t'] = data.pop('time to death (min)')

We will now draw the samples. To match above, we will do 50 data set generations.

In [ ]:
# Generate prior predictive data sets
samples_gen = sm_exp_heir_prior_pred.sampling(data=data, 
                              warmup=0, 
                              iter=50, 
                              chains=1, 
                              algorithm='Fixed_param')

We take a look.

In [ ]:
samples_gen

Let is refactor this output and make a plot!

In [ ]:
t_gen = bebi103.stan.extract_array(samples_gen, 't')

trials = pd.DataFrame(np.array([t_gen.loc[t_gen['index_1'] == i]['t'].values for i in range(15)[1:]]))

p = bebi103.viz.ecdf(trials[0],
                      x_axis_label='time to death',
                      plot_width=300,
                      plot_height=300, formal=True, line_width=0.5)

[bebi103.viz.ecdf(trials[i], formal=True, p=p, line_width=0.5) for i in range(50)[1:]]

bokeh.io.show(p)

This looks pretty similar to our previous prior predictive check, so we are good to go with the model.

In [ ]:
model_code = """

data {
    int N;
    real t[N];
    int J_1;
    int index_1[N];
}

parameters{
    INSERT HERE
}

transformed parameters {
    
    INSERT HERE
    
}

model {

    INSERT MODEL HERE
    
    t ~ exponential(beta_[index_1]);
}

generated quantities {
  real t_ppc[N];
  real log_lik[N];

  for (i in 1:N) {
    log_lik[i] = exponential_lpdf(t[i] | beta_[index_1[i]]);
    t_ppc[i] = exponential_rng(beta_[index_1[i]]);
  }
}

"""
sm_exp_h = bebi103.stan.StanModel(model_code=model_code)

It compiled! (I hope!) Now we can perform our sampling and take a look at the diagnostics.

In [ ]:
samples_heir = sm_exp_h.sampling(data=data, control=dict(adapt_delta=0.999))
bebi103.stan.check_all_diagnostics(samples_heir)

Looks good so far! We will take a look at the sampkles before moving forward.

In [ ]:
samples_heir

We next look at the parallel coordinate plot for our parameters.

YOU WILL NEED TO MODIFY THIS DEPENDING ON THE NAME OF YOUR PARAMETERS

In [ ]:
df_mcmc_noncentered = bebi103.stan.to_dataframe(samples_heir)

# Which parametets to use in parallel coordinate plot
pars = (['theta', 'sigma'] +
        list(df_mcmc_noncentered.columns[df_mcmc_noncentered.columns.str.contains('tau_')]) + 
        ['lp__'])

# Transformations to get them all on the same scale
transformation = (  [lambda x: x / 10, None, None] 
                  + [lambda x: x / 10]*(len(pars)-4)
                  + [lambda x: x / np.abs(x).max()])

bokeh.io.show(bebi103.viz.parcoord_plot(samples_heir,
                                        pars=pars,
                                        transformation=transformation,
                                        xtick_label_orientation='vertical'))

Next, we take a look at the corner plot for the hyperparameters, since these are what we are really interested in.

YOU WILL NEED TO MODIFY THIS DEPENDING ON THE NAME OF YOUR PARAMETERS

In [ ]:
bokeh.io.show(bebi103.viz.corner(samples_heir, 
                                 pars=['theta', 'sigma']))

As a last step, we will look at the posterior predictive check with an ECDF and see how this model compared to the previous exponential one.

In [ ]:
df_t_ppc = bebi103.stan.extract_array(samples_heir, 't_ppc')
p = bebi103.viz.ecdf(t, x_axis_label='time',
                     color='orange',
                     level='overlay')

# Plot posterior predictive ECDFs
for i in df_t_ppc['chain_idx'].unique()[::40]:
    p = bebi103.viz.ecdf(df_t_ppc.loc[df_t_ppc['chain_idx']==i, 't_ppc'],
                         alpha=0.1, p=p)

bokeh.io.show(p)

We got quite similar results here. A next step would be to implement this whole analysis again for the gamma distribution and see how this compares.