You can order print and ebook versions of Think Bayes 2e from Bookshop.org and Amazon.

19. MCMC#

[NOTE: this online version of Chapter 19 has been updated for PyMC version 5]

For most of this book we’ve used grid methods to approximate posterior distributions. For models with one or two parameters, grid algorithms are fast and the results are precise enough for most practical purposes. With three parameters, they start to be slow, and with more than three they are usually not practical.

In the previous chapter we saw that we can solve some problems using conjugate priors. But the problems we can solve this way tend to be the same ones we can solve with grid algorithms.

For problems with more than a few parameters, the most powerful tool we have is MCMC, which stands for “Markov chain Monte Carlo”. In this context, “Monte Carlo” refers to methods that generate random samples from a distribution. Unlike grid methods, MCMC methods don’t try to compute the posterior distribution; they sample from it instead.

It might seem strange that you can generate a sample without ever computing the distribution, but that’s the magic of MCMC.

To demonstrate, we’ll start by solving the World Cup problem. Yes, again.

# install empiricaldist if necessary

try:
    import empiricaldist
except ImportError:
    !pip install empiricaldist
    import empiricaldist
# Get utils.py

from os.path import basename, exists

def download(url):
    filename = basename(url)
    if not exists(filename):
        from urllib.request import urlretrieve
        local, _ = urlretrieve(url, filename)
        print('Downloaded ' + local)

download('https://github.com/AllenDowney/ThinkBayes2/raw/master/soln/utils.py')
from utils import set_pyplot_params
set_pyplot_params()

19.1. The World Cup Problem#

In <<_PoissonProcesses>> we modeled goal scoring in football (soccer) as a Poisson process characterized by a goal-scoring rate, denoted \(\lambda\).

We used a gamma distribution to represent the prior distribution of \(\lambda\), then we used the outcome of the game to compute the posterior distribution for both teams.

To answer the first question, we used the posterior distributions to compute the “probability of superiority” for France.

To answer the second question, we computed the posterior predictive distributions for each team, that is, the distribution of goals we expect in a rematch.

In this chapter we’ll solve this problem again using PyMC, which is a library that provide implementations of several MCMC methods. But we’ll start by reviewing the grid approximation of the prior and the prior predictive distribution.

19.2. Grid Approximation#

As we did in <<_TheGammaDistribution>> we’ll use a gamma distribution with parameter \(\alpha=1.4\) to represent the prior.

from scipy.stats import gamma

alpha = 1.4
prior_dist = gamma(alpha)

I’ll use linspace to generate possible values for \(\lambda\), and pmf_from_dist to compute a discrete approximation of the prior.

import numpy as np
from utils import pmf_from_dist

lams = np.linspace(0, 10, 101)
prior_pmf = pmf_from_dist(prior_dist, lams)

We can use the Poisson distribution to compute the likelihood of the data; as an example, we’ll use 4 goals.

from scipy.stats import poisson

data = 4
likelihood = poisson.pmf(data, lams)

Now we can do the update in the usual way.

posterior = prior_pmf * likelihood
posterior.normalize()
0.05015532557804499

Soon we will solve the same problem with PyMC, but first it will be useful to introduce something new: the prior predictive distribution.

19.3. Prior Predictive Distribution#

We have seen the posterior predictive distribution in previous chapters; the prior predictive distribution is similar except that (as you might have guessed) it is based on the prior.

To estimate the prior predictive distribution, we’ll start by drawing a sample from the prior.

sample_prior = prior_dist.rvs(1000)

The result is an array of possible values for the goal-scoring rate, \(\lambda\). For each value in sample_prior, I’ll generate one value from a Poisson distribution.

from scipy.stats import poisson

sample_prior_pred = poisson.rvs(sample_prior)

sample_prior_pred is a sample from the prior predictive distribution. To see what it looks like, we’ll compute the PMF of the sample.

from empiricaldist import Pmf

pmf_prior_pred = Pmf.from_seq(sample_prior_pred)

And here’s what it looks like:

from utils import decorate

pmf_prior_pred.bar()
decorate(xlabel='Number of goals',
         ylabel='PMF',
         title='Prior Predictive Distribution')
_images/bf62eec880e055fc719a0ff38539e2a395226ccf1997bb2da55eaf97fdba19ed.png

One reason to compute the prior predictive distribution is to check whether our model of the system seems reasonable. In this case, the distribution of goals seems consistent with what we know about World Cup football.

But in this chapter we have another reason: computing the prior predictive distribution is a first step toward using MCMC.

19.4. Introducing PyMC#

PyMC is a Python library that provides several MCMC methods. To use PyMC, we have to specify a model of the process that generates the data. In this example, the model has two steps:

  • First we draw a goal-scoring rate from the prior distribution,

  • Then we draw a number of goals from a Poisson distribution.

Here’s how we specify this model in PyMC:

import pymc as pm

with pm.Model() as model:
    lam = pm.Gamma('lam', alpha=1.4, beta=1.0)
    goals = pm.Poisson('goals', lam)

After importing PyMC, we create a Model object named model.

If you are not familiar with the with statement in Python, it is a way to associate a block of statements with an object. In this example, the two indented statements are associated with the new Model object. As a result, when we create the distribution objects, Gamma and Poisson, they are added to the Model.

Inside the with statement:

  • The first line creates the prior, which is a gamma distribution with the given parameters.

  • The second line creates the prior predictive, which is a Poisson distribution with the parameter lam.

The first parameter of Gamma and Poisson is a string variable name.

PyMC provides a function that generates a visual representation of the model.

pm.model_to_graphviz(model)
_images/ba626904994a303dc84e2ba87010abdd041a73f7a07c7f18f59c92ab41f59c21.svg

In this visualization, the ovals show that lam is drawn from a gamma distribution and goals is drawn from a Poisson distribution. The arrow shows that the values of lam are used as parameters for the distribution of goals.

19.5. Sampling the Prior#

PyMC provides a function that generates samples from the prior and prior predictive distributions. We can use a with statement to run this function in the context of the model.

with model:
    idata = pm.sample_prior_predictive(1000)
Sampling: [goals, lam]
type(idata)
arviz.data.inference_data.InferenceData

The result is an InferenceData object that contains information about the sampling process and the results. We can extract the sample of lam like this:

def get_values(array):
    return array.values.flatten()
sample_prior_pymc = get_values(idata.prior['lam'])
sample_prior_pymc.shape
(1000,)

The following figure compares the CDF of this sample to the CDF of the sample we generated using the gamma object from SciPy.

from empiricaldist import Cdf

def plot_cdf(sample, **options):
    """Plot the CDF of a sample.

    sample: sequence of quantities
    """
    Cdf.from_seq(sample).plot(**options)
plot_cdf(sample_prior,
         label='SciPy sample',
         color='C5')
plot_cdf(sample_prior_pymc,
         label='PyMC sample',
         color='C0')
decorate(xlabel=r'Goals per game ($\lambda$)',
         ylabel='CDF',
         title='Prior distribution')
_images/7dbbc0a61a9958f8c4981b2e004cf69d4780e9e4061d23c0a5c5a2e42c54df95.png

The results are similar, which confirms that the specification of the model is correct and the sampler works as advertised.

From the inference data we can also extract goals, which is a sample from the prior predictive distribution.

sample_prior_pred_pymc = get_values(idata.prior['goals'])
sample_prior_pred_pymc.shape
(1000,)

And we can compare it to the sample we generated using the poisson object from SciPy.

Because the quantities in the posterior predictive distribution are discrete (number of goals) I’ll plot the CDFs as step functions.

def plot_pred(sample, **options):
    Cdf.from_seq(sample).step(**options)
plot_pred(sample_prior_pred,
          label='SciPy sample',
          color='C5')
plot_pred(sample_prior_pred_pymc,
          label='PyMC sample',
          color='C13')
decorate(xlabel='Number of goals',
         ylabel='PMF',
         title='Prior Predictive Distribution')
_images/556e15280de1f65666e228fc5c82b02ad26d3d9869138104f48bbba74ebb6ac8.png

Again, the results are similar, so we have some confidence we are using PyMC right.

19.6. When Do We Get to Inference?#

Finally, we are ready for actual inference. We just have to make one small change. Here is the model we used to generate the prior predictive distribution:

with pm.Model() as model:
    lam = pm.Gamma('lam', alpha=1.4, beta=1.0)
    goals = pm.Poisson('goals', lam)

And here is the model we’ll use to compute the posterior distribution.

with pm.Model() as model2:
    lam = pm.Gamma('lam', alpha=1.4, beta=1.0)
    goals = pm.Poisson('goals', lam, observed=4)

The difference is that we mark goals as observed and provide the observed data, 4.

And instead of calling sample_prior_predictive, we’ll call sample, which is understood to sample from the posterior distribution of lam.

options = dict()

with model2:
    idata2 = pm.sample(500, **options)
Auto-assigning NUTS sampler...
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [lam]
100.00% [6000/6000 00:00<00:00 Sampling 4 chains, 0 divergences]
Sampling 4 chains for 1_000 tune and 500 draw iterations (4_000 + 2_000 draws total) took 1 seconds.

Although the specification of these models is similar, the sampling process is very different. I won’t go into the details of how PyMC works, but here are a few things you should be aware of:

  • Depending on the model, PyMC uses one of several MCMC methods; in this example, it uses the No U-Turn Sampler (NUTS), which is one of the most efficient and reliable methods we have.

  • When the sampler starts, the first values it generates are usually not a representative sample from the posterior distribution, so these values are discarded. This process is called “tuning”.

  • Instead of using a single Markov chain, PyMC uses multiple chains. Then we can compare results from multiple chains to make sure they are consistent.

Although we asked for a sample of 500, PyMC generated two samples of 1000, discarded half of each, and returned the remaining 1000. From idata2 we can extract a sample from the posterior distribution, like this:

sample_post_pymc = get_values(idata2.posterior['lam'])
sample_post_pymc.shape
(2000,)

And we can compare the CDF of this sample to the posterior we computed by grid approximation:

posterior.make_cdf().plot(label='posterior grid',
                          color='C5')
plot_cdf(sample_post_pymc,
         label='PyMC sample',
         color='C4')

decorate(xlabel=r'Goals per game ($\lambda$)',
         ylabel='CDF',
         title='Posterior distribution')
_images/0aca26a1231a6622d347d149b8a3ee31517eb2b4db5e1dbcc94c8a2effca1f38.png

The results from PyMC are consistent with the results from the grid approximation.

19.7. Posterior Predictive Distribution#

Finally, to sample from the posterior predictive distribution, we can use sample_posterior_predictive:

with model2:
    idata2_pred = pm.sample_posterior_predictive(idata2)
Sampling: [goals]
100.00% [2000/2000 00:00<00:00]

The result is an InferenceData object that contains a sample of goals.

sample_post_pred_pymc = get_values(idata2_pred.posterior_predictive['goals'])
sample_post_pred_pymc.shape
(2000,)

I’ll also generate a sample from the posterior distribution we computed by grid approximation.

sample_post = posterior.sample(1000)
sample_post_pred = poisson(sample_post).rvs()

And we can compare the two samples.

plot_pred(sample_post_pred,
          label='grid sample',
          color='C5')
plot_pred(sample_post_pred_pymc,
          label='PyMC sample',
          color='C12')

decorate(xlabel='Number of goals',
         ylabel='PMF',
         title='Posterior Predictive Distribution')
_images/4ff62660bf238754ded2f8bec12b246101ff30a171a992e01b35463265e0f646.png

Again, the results are consistent. So we’ve established that we can compute the same results using a grid approximation or PyMC.

But it might not be clear why. In this example, the grid algorithm requires less computation than MCMC, and the result is a pretty good approximation of the posterior distribution, rather than a sample.

However, this is a simple model with just one parameter. In fact, we could have solved it with even less computation, using a conjugate prior. The power of PyMC will be clearer with a more complex model.

19.8. Happiness#

Recently I read “Happiness and Life Satisfaction” by Esteban Ortiz-Ospina and Max Roser, which discusses (among many other things) the relationship between income and happiness, both between countries, within countries, and over time.

It cites the “World Happiness Report”, which includes results of a multiple regression analysis that explores the relationship between happiness and six potentially predictive factors:

  • Income as represented by per capita GDP

  • Social support

  • Healthy life expectancy at birth

  • Freedom to make life choices

  • Generosity

  • Perceptions of corruption

The dependent variable is the national average of responses to the “Cantril ladder question” used by the Gallup World Poll:

Please imagine a ladder with steps numbered from zero at the bottom to 10 at the top. The top of the ladder represents the best possible life for you and the bottom of the ladder represents the worst possible life for you. On which step of the ladder would you say you personally feel you stand at this time?

I’ll refer to the responses as “happiness”, but it might be more precise to think of them as a measure of satisfaction with quality of life.

In the next few sections we’ll replicate the analysis in this report using Bayesian regression.

The data from this report can be downloaded from here.

# Get the data file

download('https://happiness-report.s3.amazonaws.com/2020/WHR20_DataForFigure2.1.xls')

We can use Pandas to read the data into a DataFrame.

import pandas as pd

filename = 'WHR20_DataForFigure2.1.xls'
df = pd.read_excel(filename)
df.head(3)
Country name Regional indicator Ladder score Standard error of ladder score upperwhisker lowerwhisker Logged GDP per capita Social support Healthy life expectancy Freedom to make life choices Generosity Perceptions of corruption Ladder score in Dystopia Explained by: Log GDP per capita Explained by: Social support Explained by: Healthy life expectancy Explained by: Freedom to make life choices Explained by: Generosity Explained by: Perceptions of corruption Dystopia + residual
0 Finland Western Europe 7.8087 0.031156 7.869766 7.747634 10.639267 0.954330 71.900826 0.949172 -0.059482 0.195445 1.972317 1.285190 1.499526 0.961271 0.662317 0.159670 0.477857 2.762835
1 Denmark Western Europe 7.6456 0.033492 7.711245 7.579955 10.774001 0.955991 72.402504 0.951444 0.066202 0.168489 1.972317 1.326949 1.503449 0.979333 0.665040 0.242793 0.495260 2.432741
2 Switzerland Western Europe 7.5599 0.035014 7.628528 7.491272 10.979933 0.942847 74.102448 0.921337 0.105911 0.303728 1.972317 1.390774 1.472403 1.040533 0.628954 0.269056 0.407946 2.350267
df.shape
(153, 20)

The DataFrame has one row for each of 153 countries and one column for each of 20 variables.

The column called 'Ladder score' contains the measurements of happiness we will try to predict.

score = df['Ladder score']

19.9. Simple Regression#

To get started, let’s look at the relationship between happiness and income as represented by gross domestic product (GDP) per person.

The column named 'Logged GDP per capita' represents the natural logarithm of GDP for each country, divided by population, corrected for purchasing power parity (PPP).

log_gdp = df['Logged GDP per capita']

The following figure is a scatter plot of score versus log_gdp, with one marker for each country.

import matplotlib.pyplot as plt

plt.plot(log_gdp, score, '.')

decorate(xlabel='Log GDP per capita at PPP',
         ylabel='Happiness ladder score')
_images/6c95919974e18aed01c3f89171be4c7936dd3e15ce649d7ea74636f3cf8fd024.png

It’s clear that there is a relationship between these variables: people in countries with higher GDP generally report higher levels of happiness.

We can use linregress from SciPy to compute a simple regression of these variables.

from scipy.stats import linregress

result = linregress(log_gdp, score)

And here are the results.

pd.DataFrame([result.slope, result.intercept],
             index=['Slope', 'Intercept'],
             columns=[''])
Slope 0.717738
Intercept -1.198646

The estimated slope is about 0.72, which suggests that an increase of one unit in log-GDP, which is a factor of \(e \approx 2.7\) in GDP, is associated with an increase of 0.72 units on the happiness ladder.

Now let’s estimate the same parameters using PyMC. We’ll use the same regression model as in Section <<_RegressionModel>>:

\[y = a x + b + \epsilon\]

where \(y\) is the dependent variable (ladder score), \(x\) is the predictive variable (log GDP) and \(\epsilon\) is a series of values from a normal distribution with standard deviation \(\sigma\).

\(a\) and \(b\) are the slope and intercept of the regression line. They are unknown parameters, so we will use the data to estimate them.

The following is the PyMC specification of this model.

x_data = log_gdp
y_data = score

with pm.Model() as model3:
    a = pm.Uniform('a', 0, 4)
    b = pm.Uniform('b', -4, 4)
    sigma = pm.Uniform('sigma', 0, 2)

    y_est = a * x_data + b
    y = pm.Normal('y',
                  mu=y_est, sigma=sigma,
                  observed=y_data)

The prior distributions for the parameters a, b, and sigma are uniform with ranges that are wide enough to cover the posterior distributions.

y_est is the estimated value of the dependent variable, based on the regression equation. And y is a normal distribution with mean y_est and standard deviation sigma.

Notice how the data are included in the model:

  • The values of the predictive variable, x_data, are used to compute y_est.

  • The values of the dependent variable, y_data, are provided as the observed values of y.

Now we can use this model to generate a sample from the posterior distribution.

with model3:
    idata3 = pm.sample(500, **options)
Auto-assigning NUTS sampler...
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [a, b, sigma]
100.00% [6000/6000 00:05<00:00 Sampling 4 chains, 0 divergences]
Sampling 4 chains for 1_000 tune and 500 draw iterations (4_000 + 2_000 draws total) took 6 seconds.

When you run the sampler, you might get warning messages about “divergences” and the “acceptance probability”. You can ignore them for now.

The result is an object that contains samples from the joint posterior distribution of a, b, and sigma.

ArviZ provides plot_posterior, which we can use to plot the posterior distributions of the parameters. Here are the posterior distributions of slope, a, and intercept, b.

import arviz as az

with model3:
    az.plot_posterior(idata3, var_names=['a', 'b']);
_images/4ccfdcf709054d0f44cc4a44f2df3cbb27f8132293440f98cb57e282ef4bcfa3.png

The graphs show the distributions of the samples, estimated by KDE, and 94% credible intervals. In the figure, “HDI” stands for “highest-density interval”.

The means of these samples are consistent with the parameters we estimated with linregress.

print('Sample mean:', get_values(idata3.posterior['a'].mean()))
print('Regression slope:', result.slope)
Sample mean: [0.72130426]
Regression slope: 0.717738495630452
print('Sample mean:', get_values(idata3.posterior['b'].mean()))
print('Regression intercept:', result.intercept)
Sample mean: [-1.23201509]
Regression intercept: -1.1986460618088843

Finally, we can check the marginal posterior distribution of sigma

az.plot_posterior(get_values(idata3.posterior['sigma']));
_images/901ee020fc67709642949a9a25f231a3019eea4fc4d6597e1e0bc3e458770a8e.png

The values in the posterior distribution of sigma seem plausible.

The simple regression model has only three parameters, so we could have used a grid algorithm. But the regression model in the happiness report has six predictive variables, so it has eight parameters in total, including the intercept and sigma.

It is not practical to compute a grid approximation for a model with eight parameters. Even a coarse grid, with 20 points along each dimension, would have more than 25 billion points. And with 153 countries, we would have to compute almost 4 trillion likelihoods.

But PyMC can handle a model with eight parameters comfortably, as we’ll see in the next section.

20 ** 8 / 1e9
25.6
153 * 20 ** 8 / 1e12
3.9168

19.10. Multiple Regression#

Before we implement the multiple regression model, I’ll select the columns we need from the DataFrame.

columns = ['Ladder score',
           'Logged GDP per capita',
           'Social support',
           'Healthy life expectancy',
           'Freedom to make life choices',
           'Generosity',
           'Perceptions of corruption']

subset = df[columns]
subset.head(3)
Ladder score Logged GDP per capita Social support Healthy life expectancy Freedom to make life choices Generosity Perceptions of corruption
0 7.8087 10.639267 0.954330 71.900826 0.949172 -0.059482 0.195445
1 7.6456 10.774001 0.955991 72.402504 0.951444 0.066202 0.168489
2 7.5599 10.979933 0.942847 74.102448 0.921337 0.105911 0.303728

The predictive variables have different units: log-GDP is in log-dollars, life expectancy is in years, and the other variables are on arbitrary scales. To make these factors comparable, I’ll standardize the data so that each variable has mean 0 and standard deviation 1.

standardized = (subset - subset.mean()) / subset.std()

Now let’s build the model. I’ll extract the dependent variable.

y_data = standardized['Ladder score']

And the dependent variables.

x1 = standardized[columns[1]]
x2 = standardized[columns[2]]
x3 = standardized[columns[3]]
x4 = standardized[columns[4]]
x5 = standardized[columns[5]]
x6 = standardized[columns[6]]

And here’s the model. b0 is the intercept; b1 through b6 are the parameters associated with the predictive variables.

with pm.Model() as model4:
    b0 = pm.Uniform('b0', -4, 4)
    b1 = pm.Uniform('b1', -4, 4)
    b2 = pm.Uniform('b2', -4, 4)
    b3 = pm.Uniform('b3', -4, 4)
    b4 = pm.Uniform('b4', -4, 4)
    b5 = pm.Uniform('b5', -4, 4)
    b6 = pm.Uniform('b6', -4, 4)
    sigma = pm.Uniform('sigma', 0, 2)

    y_est = b0 + b1*x1 + b2*x2 + b3*x3 + b4*x4 + b5*x5 + b6*x6
    y = pm.Normal('y',
                  mu=y_est, sigma=sigma,
                  observed=y_data)

We could express this model more concisely using a vector of predictive variables and a vector of parameters, but I decided to keep it simple.

Now we can sample from the joint posterior distribution.

with model4:
    idata4 = pm.sample(500, **options)
Auto-assigning NUTS sampler...
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [b0, b1, b2, b3, b4, b5, b6, sigma]
100.00% [6000/6000 00:04<00:00 Sampling 4 chains, 0 divergences]
Sampling 4 chains for 1_000 tune and 500 draw iterations (4_000 + 2_000 draws total) took 4 seconds.

Because we standardized the data, we expect the intercept to be 0, and in fact the posterior mean of b0 is close to 0.

get_values(idata4.posterior['b0']).mean()
0.0012751341212245242

We can also check the posterior mean of sigma:

get_values(idata4.posterior['sigma']).mean()
0.5150208888645642

From idata4 we can extract samples from the posterior distributions of the parameters and compute their means.

param_names = ['b1', 'b3', 'b3', 'b4', 'b5', 'b6']

means = [get_values(idata4.posterior[name]).mean()
         for name in param_names]

We can also compute 94% credible intervals (between the 3rd and 97th percentiles).

def credible_interval(sample):
    """Compute 94% credible interval."""
    ci = np.percentile(sample, [3, 97])
    return np.round(ci, 3)

cis = [credible_interval(get_values(idata4.posterior[name]))
       for name in param_names]

The following table summarizes the results.

index = columns[1:]
table = pd.DataFrame(index=index)
table['Posterior mean'] = np.round(means, 3)
table['94% CI'] = cis
table
Posterior mean 94% CI
Logged GDP per capita 0.242 [0.069, 0.41]
Social support 0.225 [0.072, 0.378]
Healthy life expectancy 0.225 [0.072, 0.378]
Freedom to make life choices 0.188 [0.082, 0.289]
Generosity 0.055 [-0.032, 0.142]
Perceptions of corruption -0.100 [-0.195, -0.007]

It looks like GDP has the strongest association with happiness (or satisfaction), followed by social support, life expectancy, and freedom.

After controlling for those other factors, the parameters of the other factors are substantially smaller, and since the CI for generosity includes 0, it is plausible that generosity is not substantially related to happiness, at least as they were measured in this study.

This example demonstrates the power of MCMC to handle models with more than a few parameters. But it does not really demonstrate the power of Bayesian regression.

If the goal of a regression model is to estimate parameters, there is no great advantage to Bayesian regression compared to conventional least squares regression.

Bayesian methods are more useful if we plan to use the posterior distribution of the parameters as part of a decision analysis process.

19.11. Summary#

In this chapter we used PyMC to implement two models we’ve seen before: a Poisson model of goal-scoring in soccer and a simple regression model. Then we implemented a multiple regression model that would not have been possible to compute with a grid approximation.

MCMC is more powerful than grid methods, but that power comes with some disadvantages:

  • MCMC algorithms are fiddly. The same model might behave well with some priors and less well with others. And the sampling process often produces warnings about tuning steps, divergences, “r-hat statistics”, acceptance rates, and effective samples. It takes some expertise to diagnose and correct these issues.

  • I find it easier to develop models incrementally using grid algorithms, checking intermediate results along the way. With PyMC, it is not as easy to be confident that you have specified a model correctly.

For these reasons, I recommend a model development process that starts with grid algorithms and resorts to MCMC if necessary. As we saw in the previous chapters, you can solve a lot of real-world problems with grid methods. But when you need MCMC, it is useful to have a grid algorithm to compare to (even if it is based on a simpler model).

All of the models in this book can be implemented in PyMC, but some of them are easier to translate than others. In the exercises, you will have a chance to practice.

19.12. Exercises#

Exercise: As a warmup, let’s use PyMC to solve the Euro problem. Suppose we spin a coin 250 times and it comes up heads 140 times. What is the posterior distribution of \(x\), the probability of heads?

For the prior, use a beta distribution with parameters \(\alpha=1\) and \(\beta=1\).

See the PyMC documentation for the list of continuous distributions.

Hide code cell content
# Solution

n = 250
k_obs = 140

with pm.Model() as model5:
    x = pm.Beta('x', alpha=1, beta=1)
    k = pm.Binomial('k', n=n, p=x, observed=k_obs)
    idata5 = pm.sample(500, **options)
    az.plot_posterior(idata5)
Auto-assigning NUTS sampler...
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [x]
100.00% [6000/6000 00:00<00:00 Sampling 4 chains, 0 divergences]
Sampling 4 chains for 1_000 tune and 500 draw iterations (4_000 + 2_000 draws total) took 1 seconds.
_images/6e5ac074fc99977b0cf7f4a9bc715884b9965f00f3c2129d25978e15fbd6f4c1.png

Exercise: Now let’s use PyMC to replicate the solution to the Grizzly Bear problem in <<_TheGrizzlyBearProblem>>, which is based on the hypergeometric distribution.

I’ll present the problem with slightly different notation, to make it consistent with PyMC.

Suppose that during the first session, k=23 bears are tagged. During the second session, n=19 bears are identified, of which x=4 had been tagged.

Estimate the posterior distribution of N, the number of bears in the environment.

For the prior, use a discrete uniform distribution from 50 to 500.

See the PyMC documentation for the list of discrete distributions.

Note: HyperGeometric was added to PyMC after version 3.8, so you might need to update your installation to do this exercise.

Hide code cell content
# Solution

k = 23
n = 19
x = 4

with pm.Model() as model6:
    N = pm.DiscreteUniform('N', 50, 500)
    y = pm.HyperGeometric('y', N=N, k=k, n=n, observed=x)
    idata6 = pm.sample(1000, **options)
    az.plot_posterior(idata6)
Multiprocess sampling (4 chains in 4 jobs)
Metropolis: [N]
100.00% [8000/8000 00:00<00:00 Sampling 4 chains, 0 divergences]
Sampling 4 chains for 1_000 tune and 1_000 draw iterations (4_000 + 4_000 draws total) took 1 seconds.
_images/30ec4b1580a9470b862648966c83c9c5bb5c36d81afb04a623e5545573ef9e9e.png

Exercise: In <<_TheWeibullDistribution>> we generated a sample from a Weibull distribution with \(\lambda=3\) and \(k=0.8\). Then we used the data to compute a grid approximation of the posterior distribution of those parameters.

Now let’s do the same with PyMC.

For the priors, you can use uniform distributions as we did in <<_SurvivalAnalysis>>, or you could use HalfNormal distributions provided by PyMC.

Note: The Weibull class in PyMC uses different parameters than SciPy. The parameter alpha in PyMC corresponds to \(k\), and beta corresponds to \(\lambda\).

Here’s the data again:

data = [0.80497283, 2.11577082, 0.43308797, 0.10862644, 5.17334866,
       3.25745053, 3.05555883, 2.47401062, 0.05340806, 1.08386395]
Hide code cell content
# Solution

with pm.Model() as model7:
    lam = pm.Uniform('lam', 0.1, 10.1)
    k = pm.Uniform('k', 0.1, 5.1)
    y = pm.Weibull('y', alpha=k, beta=lam, observed=data)
    idata7 = pm.sample(1000, **options)
    az.plot_posterior(idata7)
Auto-assigning NUTS sampler...
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [lam, k]
100.00% [8000/8000 00:01<00:00 Sampling 4 chains, 0 divergences]
Sampling 4 chains for 1_000 tune and 1_000 draw iterations (4_000 + 4_000 draws total) took 2 seconds.
_images/7222540adbef26c611ca7afcdf8cc29a3ef6d7d72304a98fd02a03788e14748d.png

Exercise: In <<_ImprovingReadingAbility>> we used data from a reading test to estimate the parameters of a normal distribution.

Make a model that defines uniform prior distributions for mu and sigma and uses the data to estimate their posterior distributions.

Here’s the data again.

download('https://github.com/AllenDowney/ThinkBayes2/raw/master/data/drp_scores.csv')
import pandas as pd

df = pd.read_csv('drp_scores.csv', skiprows=21, delimiter='\t')
df.head()
Treatment Response
0 Treated 24
1 Treated 43
2 Treated 58
3 Treated 71
4 Treated 43

I’ll use groupby to separate the treated group from the control group.

grouped = df.groupby('Treatment')
responses = {}

for name, group in grouped:
    responses[name] = group['Response']

Now estimate the parameters for the treated group.

data = responses['Treated']
Hide code cell content
# Solution

with pm.Model() as model8:
    mu = pm.Uniform('mu', 20, 80)
    sigma = pm.Uniform('sigma', 5, 30)
    y = pm.Normal('y', mu, sigma, observed=data)
    idata8 = pm.sample(500, **options)
Auto-assigning NUTS sampler...
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [mu, sigma]
100.00% [6000/6000 00:01<00:00 Sampling 4 chains, 0 divergences]
Sampling 4 chains for 1_000 tune and 500 draw iterations (4_000 + 2_000 draws total) took 1 seconds.
Hide code cell content
# Solution

with model8:
    az.plot_posterior(idata8)
_images/651a2450e7c1048ad994f72987a7038a1683bedfc6af021866689f04ac9c448c.png

Exercise: In <<_TheLincolnIndexProblem>> we used a grid algorithm to solve the Lincoln Index problem as presented by John D. Cook:

“Suppose you have a tester who finds 20 bugs in your program. You want to estimate how many bugs are really in the program. You know there are at least 20 bugs, and if you have supreme confidence in your tester, you may suppose there are around 20 bugs. But maybe your tester isn’t very good. Maybe there are hundreds of bugs. How can you have any idea how many bugs there are? There’s no way to know with one tester. But if you have two testers, you can get a good idea, even if you don’t know how skilled the testers are.”

Suppose the first tester finds 20 bugs, the second finds 15, and they find 3 in common; use PyMC to estimate the number of bugs.

Note: This exercise is more difficult that some of the previous ones. One of the challenges is that the data includes k00, which depends on N:

k00 = N - num_seen

So we have to construct the data as part of the model. To do that, we can use pm.math.stack, which makes an array:

data = pm.math.stack((k00, k01, k10, k11))

Finally, you might find it helpful to use pm.Multinomial.

I’ll use the following notation for the data:

  • k11 is the number of bugs found by both testers,

  • k10 is the number of bugs found by the first tester but not the second,

  • k01 is the number of bugs found by the second tester but not the first, and

  • k00 is the unknown number of undiscovered bugs.

Here are the values for all but k00:

k10 = 20 - 3
k01 = 15 - 3
k11 = 3

In total, 32 bugs have been discovered:

num_seen = k01 + k10 + k11
num_seen
32
Hide code cell content
# Solution

with pm.Model() as model9:
    p0 = pm.Beta('p0', alpha=1, beta=1)
    p1 = pm.Beta('p1', alpha=1, beta=1)
    N = pm.DiscreteUniform('N', num_seen, 350)
    
    q0 = 1-p0
    q1 = 1-p1
    ps = [q0*q1, q0*p1, p0*q1, p0*p1]
    
    k00 = N - num_seen
    data = pm.math.stack((k00, k01, k10, k11))
    y = pm.Multinomial('y', n=N, p=ps, observed=data)
Hide code cell content
# Solution

with model9:
    idata9 = pm.sample(1000, **options)
Multiprocess sampling (4 chains in 4 jobs)
CompoundStep
>NUTS: [p0, p1]
>Metropolis: [N]
100.00% [8000/8000 00:01<00:00 Sampling 4 chains, 0 divergences]
Sampling 4 chains for 1_000 tune and 1_000 draw iterations (4_000 + 4_000 draws total) took 2 seconds.
/home/downey/miniconda3/envs/ThinkBayes2/lib/python3.10/site-packages/pymc/backends/arviz.py:65: UserWarning: Could not extract data from symbolic observation y
  warnings.warn(f"Could not extract data from symbolic observation {obs}")
The rhat statistic is larger than 1.01 for some parameters. This indicates problems during sampling. See https://arxiv.org/abs/1903.08008 for details
The effective sample size per chain is smaller than 100 for some parameters.  A higher number is needed for reliable rhat and ess computation. See https://arxiv.org/abs/1903.08008 for details
Hide code cell content
# Solution

with model9:
    az.plot_posterior(idata9)
_images/2df13eb3c1a3976c68225d1a95c449e97010b7186ad0bfae31959355b6413f04.png

Think Bayes, Second Edition

Copyright 2020 Allen B. Downey

License: Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0)