Example Multilevel Model in PyMC
Switch branches/tags
Nothing to show


PyMC Multilevel Example

A nice example of using PyMC for multilevel (aka "Random Effects")
modeling came through on the PyMC mailing list today.  I've put it
into a git repo so that I can play around with it a little, and
collect up the feedback that the list generates.

The original message, from Scott B, lays out the situation with
refreshing precision.  I continue to be impressed with the skill-level
that new PyMC users frequently begin with (for fun, compare to the
questions on the NetworkX mailing list sometime...  I guess this is an
indication of the relative success in the popularization of network
science and Bayesian statistics).

Anyways, here is the original message::

     Hi All,

     New to this group and to PyMC (and mostly new to Python). In any case,
     I'm writing to ask for help in specifying a multilevel model (mixed
     model) for what I call partially clustered designs. An example of a
     partially clustered design is a 2-condition randomized psychotherapy
     study where subjects are randomly assigned to conditions. In condition
     1, subjects are treated in small groups of say 8 people a piece. In
     the condition 2, usually a control, subjects are only assessed on the
     outcome (they don't receive an intervention). Thus you have clustering
     in condition 1 but not condition 2.

     The model for a 2-condition study looks like (just a single time point
     to keep things simpler):

     y_ij = b_0 + b_1*Tx + u_j*Tx + e_ij

     where y_ij is the outcome for the ith person in cluster j (in most
     multilevel modeling software and in PROC MCMC in SAS, subjects in the
     unclustered condition are all in clusters of just 1 person), b_0 is
     the overall intercept, b_1 is the treatment effect, Tx is a dummy
     coded variable coded as 1 for the clustered condition and 0 for the
     unclustered condition, u_j is the random effect for cluster j, and
     e_ij is the residual error. The variance among clusters is \sigma^2_u
     and the residual term is \sigma^2_e (ideally you would estimate a
     unique residual by condition).

     Because u_j interacts with Tx, the random effect is only contributes
     to the clustered condition.

     In my PyMC model, I expressed the model in matrix form - I find it
     easier to deal with especially for dealing with the cluster effects.

     Y = XB + ZU + R

     where X is an n x 2 design matrix for the overall intercept and
     intervention effect, B is a 1 x 2 matrix of regression coefficients, Z
     is an n x c design matrix for the cluster effects (where c is equal to
     the number of clusters in the clustered condition), and U is a c x 1
     matrix of cluster effects. The way I've written the model below, I
     have R as an n x n diagonal matrix with \sigma^2_e on the diagonal and
     0's on the off-diagonal.

     All priors below are based on a model fit in PROC MCMC in SAS. I'm
     trying to replicate the analyses in PyMC so I don't want to change the

     The data for my code can be downloaded here:

     The data consist of 200 total subjects. 100 in the clustered condition
     and 100 in the unclustered. In the clustered condition there are 10
     clusters of 10 people each. There is a single outcome variable.

     The PyMC set-up file can be downloaded here (also pasted below):

     The file for running the PyMC set-up file can be downloaded here:

     I have 3 specific questions about the model:

     1 - Given the description of the model, have I successfully specified
     the model? The results are quite similar to PROC MCMC, though the
     cluster variance (\sigma^2_u) differs more than I would expect due to
     Monte Carlo error. The differences make me wonder if I haven't got it
     quite right in PyMC.

     2 - Is there a better (or more efficient) way to set up the model? The
     model runs quickly but I am trying to learn Python and to get better
     at optimizing how to set up models (especially multilevel models).

     3 - How can change my specification so that I can estimate unique
     residual variances for clustered and unclustered conditions? Right now
     I've estimated just a single residual variance. But I typically want
     separate estimates for the residual variances per intervention

     Thanks so much for your help. My code follows my signature.


Here are my notes on the matter:

1.  This code worked for me without any modification. :) Although when
    I tried to run it twice in the same ipython session, it gave me
    strange complaints. (for pymc version 2.1alpha, wall time 78s).
    For the newest version in the git repo (pymc version 2.2grad,
    commit ca77b7aa28c75f6d0e8172dd1f1c3f2cf7358135, wall time 75s) it
    didn't complain.

2.  I find the data wrangling section of the model quite opaque.  If
    there is a difference between the PROC MCMC and PyMC results, this
    is the first place I would look.  I've been searching for the most
    transparent ways to deal with data in Python, so I can share some
    of my findings as applied to this block of code.

3.  The model could probably be faster.  This is the time for me to
    recall the two cardinal rules of program optimization: 1) Don't
    Optimize, and 2) (for experts only) Don't Optimize Yet.

    That said, the biggest change to the time PyMC takes to run is in
    the step methods.  But adjusting this is a delicate operation.
    More on this to follow.

4.  Changing the specification is the true power of the PyMC approach,
    and why this is worth the extra effort, since a random effects
    model like yours is one line of STATA.  So I'd like to write out
    in detail how to change it.  More on this to follow.

5.  Indentation should be 4 spaces.  Diverging from this inane detail
    will make python people itchy.

I think that speeding up the MCMC is more fun than cleaning up the
data wrangling, so I'll assume that the data is being set up as
desired for now and mess around with the step methods.  Commit linked
is a small example of this, which doesn't speed things up, but could
mean less samples are required of the chain.

To actually speed things up (and also potentially reduce the number of
samples required), I'm going to combine the beta0 and beta1
stochs. This reduces the wall time to 58s, but the diagnostic plots
make me think the it needs a longer burn-in period.  I like to throw
out the first half of my samples, so I'll make that change, too.  Pro
tip: I added a <code>reload(analysis1)</code> to the run_analysis1.py
script, so that my changes to the model automatically make it into
ipython when I say <code>time run run_analysis1</code>. Commit linked

I'm not keeping careful track of if the results change as I modify the
step methods, which is probably not a recommended practice.  But it is
fun to see the methods get faster and faster as I group more and more
in the AM step method.  (43s wall time for M.B and M.U in one AM,
commit here; 32s wall time for all stochs together, but the results
are not pretty)

Sometimes I've had good luck with choosing initial values for the MCMC
by maximizing the posterior or something related to it.  Here is code
to do that, which doesn't take very long (36s wall time, and
respectible looking results are back)

So that little burst of messing around has halved the run time.  Too
bad I didn't work on the things I was supposed to be doing for the
last hour.

Another hour of messing around has given me some confidence that 200K
samples is certainly enough, and 20K samples probably is enough to
guide model development.  To assess this, I relied on the
autocorrelation plot of the stoch traces, which I added to the
Matplot.plot routine, since I think everyone should use it
approach. Commit linked here

Now I'm ready to try simplifying the data wrangling section, and then
everything will be in order to extend the model to have different
variance for the clustered and unclustered observations.

I like to use pylab's csv2rec function for loading data files.  It
deals nicely with the column headings, and converts data to the
correct type pretty routinely.  I'm going to do two commits in the
process of changing this code, one which checks carefully that I'm not
breaking anything, and then another to yield a nice short block of
code. Commits linked here
and here

Now, on to changing the model!  I added a separate variance parameter
for the treatment and control groups in a slightly sneaky way.  It
only took 10 keystrokes (roughly), but maybe it would be better to use
more code and have a clearer approach.  Commit linked here

         B: [-0.07  0.3 ]
         u: [-0.14  0.02  0.3   0.5   0.36 -1.02  0.33  0.23 -0.28 -0.35]
    var_e1: 0.94
     var_u: 0.4

         B: [-0.06  0.31]
         u: [-0.13  0.    0.28  0.53  0.39 -1.08  0.34  0.22 -0.31 -0.35]
    var_e1: [ 1.12  0.79]
     var_u: 0.41

Since I sneakily used the indicator run1.treat to index the var_e1
array, var_e1[0] is the control variance and var_e1[1] is the
treatment variance.  So the change in the model doesn't change any of
the parameter means, but it does show that the variance of the
control group is higher than for the treatment group.

Too bad I didn't generate before-and-after numbers on the uncertainty
intervals, that is something that might have changed in an interesting
way with this more complicated model.

In conclusion, here is why I think that this approach is worth the
extra effort, compared to using xtmixed in STATA.  Although the STATA
model is almost done when figure out the single line <code>xtmixed y
treat ||j:</code>, that is not quite what you want.  The control
groups are not supposed to have random effects, so you have to find a
hack for that, maybe <code>xtmixed y treat ||j: treat,
nointercept</code>.  And then you want to add different variances for
treatment and control, which is doable, but also hacky, maybe with
<code>xtmixed y treat ||j: treat, nointercept ||treat:</code>.  And
now you've got a very short line, but one that is going to be very
hard to understand in the future.  And what if you want to fiddle with
the priors?  Or change the noise model?  Much better to have 30 clear
lines of PyMC, in my opinion.

p.s. Django and Rails have gotten a lot of mileage out of emphasizing
_convention_ in frequently performed tasks, and I think that PyMC
models could also benefit from this approach.  I'm sure I can't
develop our conventions myself, but I have changed all the file names
to move towards what I think we might want them to look like. Commit
linked here
My analyses often have these basic parts: data, model, fitting code,
graphics code.  Maybe your do to.