Section 7.1 Cumulative Distribution Functions
So far we have been using probability mass functions to represent distributions. A useful alternative is the
cumulative distribution function, or CDF.
As an example, Iβll use the posterior distribution from the Euro problem, which we computed in Section 4.2.
Hereβs the uniform prior we started with.
import numpy as np
from empiricaldist import Pmf
hypos = np.linspace(0, 1, 101)
pmf = Pmf(1, hypos)
data = 140, 250
from scipy.stats import binom
def update_binomial(pmf, data):
"""Update pmf using the binomial distribution."""
k, n = data
xs = pmf.qs
likelihood = binom.pmf(k, n, xs)
pmf *= likelihood
pmf.normalize()
update_binomial(pmf, data)
The CDF is the cumulative sum of the PMF, so we can compute it like this:
cumulative = pmf.cumsum()
Hereβs what it looks like, along with the PMF.
from utils import decorate
def decorate_euro(title):
decorate(xlabel='Proportion of heads (x)',
ylabel='Probability',
title=title)
cumulative.plot(label='CDF')
pmf.plot(label='PMF')
decorate_euro(title='Posterior distribution for the Euro problem')
The range of the CDF is always from 0 to 1, in contrast with the PMF, where the maximum can be any probability.
The result from
cumsum is a Pandas
Series, so we can use the bracket operator to select an element:
0.9638303193984253
The result is about 0.96, which means that the total probability of all quantities less than or equal to 0.61 is 96%.
To go the other way β to look up a probability and get the corresponding quantile β we can use interpolation:
from scipy.interpolate import interp1d
ps = cumulative.values
qs = cumulative.index
interp = interp1d(ps, qs)
interp(0.96)
array(0.60890171)
The result is about 0.61, so that confirms that the 96th percentile of this distribution is 0.61.
empiricaldist provides a class called
Cdf that represents a cumulative distribution function. Given a
Pmf, you can compute a
Cdf like this:
make_cdf uses
np.cumsum to compute the cumulative sum of the probabilities.
You can use brackets to select an element from a
Cdf:
0.9638303193984253
But if you look up a quantity thatβs not in the distribution, you get a
KeyError.
try:
cdf[0.615]
except KeyError as e:
print(repr(e))
KeyError(0.615)
To avoid this problem, you can call a
Cdf as a function, using parentheses. If the argument does not appear in the
Cdf, it interpolates between quantities.
array(0.96383032)
Going the other way, you can use
quantile to look up a cumulative probability and get the corresponding quantity:
array(0.61)
Cdf also provides
credible_interval, which computes a credible interval that contains the given probability:
cdf.credible_interval(0.9)
array([0.51, 0.61])
CDFs and PMFs are equivalent in the sense that they contain the same information about the distribution, and you can always convert from one to the other. Given a
Cdf, you can get the equivalent
Pmf like this:
make_pmf uses
np.diff to compute differences between consecutive cumulative probabilities.
One reason
Cdf objects are useful is that they compute quantiles efficiently. Another is that they make it easy to compute the distribution of a maximum or minimum, as weβll see in the next section.
Section 7.2 Best Three of Four
In
Dungeons & Dragons, each character has six attributes: strength, intelligence, wisdom, dexterity, constitution, and charisma.
To generate a new character, players roll four 6-sided dice for each attribute and add up the best three. For example, if I roll for strength and get 1, 2, 3, 4 on the dice, my characterβs strength would be the sum of 2, 3, and 4, which is 9.
As an exercise, letβs figure out the distribution of these attributes. Then, for each character, weβll figure out the distribution of their best attribute.
Iβll import two functions from the previous chapter:
make_die, which makes a
Pmf that represents the outcome of rolling a die, and
add_dist_seq, which takes a sequence of
Pmf objects and computes the distribution of their sum.
Hereβs a
Pmf that represents a six-sided die and a sequence with three references to it.
from utils import make_die
die = make_die(6)
dice = [die] * 3
And hereβs the distribution of the sum of three dice.
from utils import add_dist_seq
pmf_3d6 = add_dist_seq(dice)
Hereβs what it looks like:
def decorate_dice(title=''):
decorate(xlabel='Outcome',
ylabel='PMF',
title=title)
pmf_3d6.plot()
decorate_dice('Distribution of attributes')
If we roll four dice and add up the best three, computing the distribution of the sum is a bit more complicated. Iβll estimate the distribution by simulating 10,000 rolls.
First Iβll create an array of random values from 1 to 6, with 10,000 rows and 4 columns:
n = 10000
a = np.random.randint(1, 7, size=(n, 4))
To find the best three outcomes in each row, Iβll use
sort with
axis=1, which sorts the rows in ascending order.
Finally, Iβll select the last three columns and add them up.
Now
t is an array with a single column and 10,000 rows. We can compute the PMF of the values in
t like this:
pmf_best3 = Pmf.from_seq(t)
The following figure shows the distribution of the sum of three dice,
pmf_3d6, and the distribution of the best three out of four,
pmf_best3.
pmf_3d6.plot(label='sum of 3 dice')
pmf_best3.plot(label='best 3 of 4', ls='--')
decorate_dice('Distribution of attributes')
As you might expect, choosing the best three out of four tends to yield higher values.
Next weβll find the distribution for the maximum of six attributes, each the sum of the best three of four dice.
Section 7.3 Maximum
To compute the distribution of a maximum or minimum, we can make good use of the cumulative distribution function. First, Iβll compute the
Cdf of the best three of four distribution:
cdf_best3 = pmf_best3.make_cdf()
Recall that
Cdf(x) is the sum of probabilities for quantities less than or equal to
x. Equivalently, it is the probability that a random value chosen from the distribution is less than or equal to
x.
Now suppose I draw 6 values from this distribution. The probability that all 6 of them are less than or equal to
x is
Cdf(x) raised to the 6th power, which we can compute like this:
If all 6 values are less than or equal to
x, that means that their maximum is less than or equal to
x. So the result is the CDF of their maximum. We can convert it to a
Cdf object, like this:
from empiricaldist import Cdf
cdf_max6 = Cdf(cdf_best3**6)
And compute the equivalent
Pmf like this:
pmf_max6 = cdf_max6.make_pmf()
The following figure shows the result.
pmf_max6.plot(label='max of 6 attributes')
decorate_dice('Distribution of attributes')
Most characters have at least one attribute greater than 12; almost 10% of them have an 18.
The following figure shows the CDFs for the three distributions we have computed.
import matplotlib.pyplot as plt
cdf_3d6 = pmf_3d6.make_cdf()
cdf_3d6.plot(label='sum of 3 dice')
cdf_best3 = pmf_best3.make_cdf()
cdf_best3.plot(label='best 3 of 4 dice', ls='--')
cdf_max6.plot(label='max of 6 attributes', ls=':')
decorate_dice('Distribution of attributes')
plt.ylabel('CDF');
Cdf provides
max_dist, which does the same computation, so we can also compute the
Cdf of the maximum like this:
cdf_max_dist6 = cdf_best3.max_dist(6)
In the next section weβll find the distribution of the minimum. The process is similar, but a little more complicated. See if you can figure it out before you go on.
Section 7.4 Minimum
In the previous section we computed the distribution of a characterβs best attribute. Now letβs compute the distribution of the worst.
To compute the distribution of the minimum, weβll use the
complementary CDF, which we can compute like this:
As the variable name suggests, the complementary CDF is the probability that a value from the distribution is greater than
x. If we draw 6 values from the distribution, the probability that all 6 exceed
x is:
If all 6 exceed
x, that means their minimum exceeds
x, so
prob_gt6 is the complementary CDF of the minimum. And that means we can compute the CDF of the minimum like this:
The result is a Pandas
Series that represents the CDF of the minimum of six attributes. We can put those values in a
Cdf object like this:
Hereβs what it looks like, along with the distribution of the maximum.
cdf_min6.plot(color='C4', label='minimum of 6')
cdf_max6.plot(color='C2', label='maximum of 6', ls=':')
decorate_dice('Minimum and maximum of six attributes')
plt.ylabel('CDF');
Cdf provides
min_dist, which does the same computation, so we can also compute the
Cdf of the minimum like this:
cdf_min_dist6 = cdf_best3.min_dist(6)
And we can confirm that the differences are small.
np.allclose(cdf_min_dist6, cdf_min6)
True
In the exercises at the end of this chapter, youβll use distributions of the minimum and maximum to do Bayesian inference. But first weβll see what happens when we mix distributions.
Section 7.5 Mixture
In this section Iβll show how we can compute a distribution which is a mixture of other distributions. Iβll explain what that means with some simple examples; then, more usefully, weβll see how these mixtures are used to make predictions.
Hereβs another example inspired by
Dungeons & Dragons:
-
Suppose your character is armed with a dagger in one hand and a short sword in the other.
-
During each round, you attack a monster with one of your two weapons, chosen at random.
-
The dagger causes one 4-sided die of damage; the short sword causes one 6-sided die of damage.
What is the distribution of damage you inflict in each round?
To answer this question, Iβll make a
Pmf to represent the 4-sided and 6-sided dice:
d4 = make_die(4)
d6 = make_die(6)
Now, letβs compute the probability you inflict 1 point of damage.
-
If you attacked with the dagger, itβs 1/4.
-
If you attacked with the short sword, itβs 1/6.
Because the probability of choosing either weapon is 1/2, the total probability is the average:
prob_1 = (d4(1) + d6(1)) / 2
prob_1
0.20833333333333331
For the outcomes 2, 3, and 4, the probability is the same, but for 5 and 6 itβs different, because those outcomes are impossible with the 4-sided die.
prob_6 = (d4(6) + d6(6)) / 2
prob_6
0.08333333333333333
To compute the distribution of the mixture, we could loop through the possible outcomes and compute their probabilities.
But we can do the same computation using the
+ operator:
Hereβs what the mixture of these distributions looks like.
mix1.bar(alpha=0.7)
decorate_dice('Mixture of one 4-sided and one 6-sided die')
Now suppose you are fighting three monsters:
-
One has a club, which causes one 4-sided die of damage.
-
One has a mace, which causes one 6-sided die.
-
And one has a quarterstaff, which also causes one 6-sided die.
Because the melee is disorganized, you are attacked by one of these monsters each round, chosen at random. To find the distribution of the damage they inflict, we can compute a weighted average of the distributions, like this:
This distribution is a mixture of one 4-sided die and two 6-sided dice. Hereβs what it looks like.
mix2.bar(alpha=0.7)
decorate_dice('Mixture of one 4-sided and two 6-sided die')
In this section we used the
+ operator, which adds the probabilities in the distributions, not to be confused with
Pmf.add_dist, which computes the distribution of the sum of the distributions.
To demonstrate the difference, Iβll use
Pmf.add_dist to compute the distribution of the total damage done per round, which is the sum of the two mixtures:
total_damage = Pmf.add_dist(mix1, mix2)
And hereβs what it looks like.
total_damage.bar(alpha=0.7)
decorate_dice('Total damage inflicted by both parties')
Section 7.6 General Mixtures
In the previous section we computed mixtures in an
ad hoc way. Now weβll see a more general solution. In future chapters, weβll use this solution to generate predictions for real-world problems, not just role-playing games. But if youβll bear with me, weβll continue the previous example for one more section.
Suppose three more monsters join the combat, each of them with a battle axe that causes one 8-sided die of damage. Still, only one monster attacks per round, chosen at random, so the damage they inflict is a mixture of:
Iβll use a
Pmf to represent a randomly chosen monster:
hypos = [4,6,8]
counts = [1,2,3]
pmf_dice = Pmf(counts, hypos)
pmf_dice.normalize()
pmf_dice
4 0.166667
6 0.333333
8 0.500000
dtype: float64
This distribution represents the number of sides on the die weβll roll and the probability of rolling each one. For example, one of the six monsters has a dagger, so the probability is
\(1/6\) that we roll a 4-sided die.
Next Iβll make a sequence of
Pmf objects to represent the dice:
dice = [make_die(sides) for sides in hypos]
To compute the distribution of the mixture, Iβll compute the weighted average of the dice, using the probabilities in
pmf_dice as the weights.
To express this computation concisely, it is convenient to put the distributions into a Pandas
DataFrame:
import pandas as pd
pd.DataFrame(dice)
1 2 3 4 5 6 7 8
0 0.250000 0.250000 0.250000 0.250000 NaN NaN NaN NaN
1 0.166667 0.166667 0.166667 0.166667 0.166667 0.166667 NaN NaN
2 0.125000 0.125000 0.125000 0.125000 0.125000 0.125000 0.125 0.125
The result is a
DataFrame with one row for each distribution and one column for each possible outcome. Not all rows are the same length, so Pandas fills the extra spaces with the special value
NaN, which stands for βnot a numberβ. We can use
fillna to replace the
NaN values with 0.
The next step is to multiply each row by the probabilities in
pmf_dice, which turns out to be easier if we transpose the matrix so the distributions run down the columns rather than across the rows:
df = pd.DataFrame(dice).fillna(0).transpose()
0 1 2
1 0.25 0.166667 0.125
2 0.25 0.166667 0.125
3 0.25 0.166667 0.125
4 0.25 0.166667 0.125
5 0.00 0.166667 0.125
6 0.00 0.166667 0.125
7 0.00 0.000000 0.125
8 0.00 0.000000 0.125
Now we can multiply by the probabilities in
pmf_dice:
0 1 2
1 0.041667 0.055556 0.0625
2 0.041667 0.055556 0.0625
3 0.041667 0.055556 0.0625
4 0.041667 0.055556 0.0625
5 0.000000 0.055556 0.0625
6 0.000000 0.055556 0.0625
7 0.000000 0.000000 0.0625
8 0.000000 0.000000 0.0625
And add up the weighted distributions:
1 0.159722
2 0.159722
3 0.159722
4 0.159722
5 0.118056
6 0.118056
7 0.062500
8 0.062500
The argument
axis=1 means we want to sum across the rows. The result is a Pandas
Series.
Putting it all together, hereβs a function that makes a weighted mixture of distributions.
def make_mixture(pmf, pmf_seq):
"""Make a mixture of distributions."""
df = pd.DataFrame(pmf_seq).fillna(0).transpose()
df *= np.array(pmf)
total = df.sum(axis=1)
return Pmf(total)
The first parameter is a
Pmf that maps from each hypothesis to a probability. The second parameter is a sequence of
Pmf objects, one for each hypothesis. We can call it like this:
mix = make_mixture(pmf_dice, dice)
And hereβs what it looks like.
mix.bar(label='mixture', alpha=0.6)
decorate_dice('Distribution of damage with three different weapons')
In this section I used Pandas so that
make_mixture is concise, efficient, and hopefully not too hard to understand. In the exercises at the end of the chapter, youβll have a chance to practice with mixtures, and we will use
make_mixture again in the next chapter.