by Dr Kamil Erguler
Climate and One Health Group
CARE-C - The Cyprus Institute
Previous (Dungeons & Dragons) --- Index --- Next (Regression)
So, our DM swears that she has not been lying about the outcomes of the die, and that she has been using the 20-sided die all along. There must be a problem with the die itself.
All right, you say. Let's examine the chances of that.
Suppose we introduce another model, d20.unfair, in which the side probabilities are not all equal to 1/20, but are instead given by
$ \mathbf{p} = (p_1, p_2, \ldots, p_{20}), $
where
$ \sum_{i=1}^{20} p_i = 1. $
The likelihood of observing a particular outcome, say 1, is then
$ P(x = 1 \mid \mathbf{p}) = p_1. $
At the beginning, we have no reason to favour any side over another. Let's say we trust that the DM might be telling the truth. We may then use a uniform prior over the possible values of $\mathbf{p}$. Under this prior, the posterior density of the side probabilities becomes proportional to the likelihood:
$ P(\mathbf{p} \mid x) \propto P(x \mid \mathbf{p}). $
The likelihood of observing a set of rolls then becomes proportional to
$ P(x \mid \mathbf{p}) \propto p_1^{n_1}p_2^{n_2}\ldots p_{20}^{n_{20}}, $
where $n_1, n_2, \ldots, n_{20}$ represent the number of times each side is observed.
This has the same form as the unnormalised Dirichlet distribution, with one added to the number of times each side is observed:
$ p_1^{n_1}p_2^{n_2}\ldots p_{20}^{n_{20}} \propto \mathrm{Dirichlet}(\mathbf{p} \mid n_1+1,n_2+1,\ldots,n_{20}+1). $
The two-parameter version is equivalent to the beta distribution,
$ p_1^{n_1}p_2^{n_2} \propto \mathrm{Dirichlet}(\mathbf{p} \mid n_1+1,n_2+1), $
which is equivalent to
$ P(p_1 \mid x) = \mathrm{Beta}(p_1 \mid n_1+1,n_2+1), $
with
$ p_2 = 1-p_1. $
Including the normalising constant, the posterior density for the side probabilities of the 20-sided die can be written as
$ P(\mathbf{p} \mid x) = \mathrm{Dirichlet}(\mathbf{p} \mid n_1+1,n_2+1,\ldots,n_{20}+1). $
If we continue rolling the die, and the next outcome is 1, then the posterior above becomes the new prior. The new observation adds one more count to side 1, so the updated posterior density becomes
$ P(\mathbf{p} \mid \{x,1\}) = \mathrm{Dirichlet}(\mathbf{p} \mid n_1+2,n_2+1,\ldots,n_{20}+1). $
Here, the roll outcomes accumulate information about a single set of side probabilities, $\mathbf{p}$. As more rolls are observed, we learn more about each side probability.
This is different from deciding which die was chosen. In the earlier model-selection problem, the DM had chosen one die: d4, d6, or d20. Each roll gave us information about which one of these three fixed models was more plausible.
Had she chosen a new die at each roll, then we would need to learn about the frequency with which each die was being chosen. In that case, a secondary prior over those frequencies might have been appropriate. But that was not the case. Only one die was chosen.
## -----------------------------
## 1. Beta density for p1
## -----------------------------
# Let's say we observed one side of a coin 45 times and the other side 55 times.
# What is the posterior distribution of the probability of observing the first side?
n1 <- 45
n2 <- 55
alpha1 <- n1 + 1
alpha2 <- n2 + 1
p1 <- seq(0, 1, length.out = 1000)
beta_density <- function(p1, alpha1, alpha2) {
return( p1^(alpha1 - 1) * (1 - p1)^(alpha2 - 1) * gamma(alpha1 + alpha2) / (gamma(alpha1) * gamma(alpha2)) )
}
density_p1 <- beta_density(p1, alpha1, alpha2)
# or
# density_p1 <- dbeta(p1, alpha1, alpha2)
par(bg = "white")
plot(p1, density_p1, type = "l", lwd = 2, xlab = expression(p[1]), ylab = "Density", main = expression(paste("Beta density for ", p[1])))
abline(v = alpha1 / (alpha1 + alpha2), lty = 2)
text(x = alpha1 / (alpha1 + alpha2), y = max(density_p1) * 0.9, labels = "posterior mean", pos = 4, offset = 1.0)
## -----------------------------
## 2. Dirichlet density over p1 and p2
## -----------------------------
# Let's say we observed three categories with counts 34, 16, and 50.
# What is the posterior distribution over the probabilities of these categories?
n1 <- 34
n2 <- 16
n3 <- 50
alpha <- c(n1 + 1, n2 + 1, n3 + 1)
dirichlet_density <- function(p, alpha) {
return( prod(p^(alpha - 1)) * gamma(sum(alpha)) / prod(gamma(alpha)) )
}
grid_size <- 150
p1 <- seq(0.001, 0.999, length.out = grid_size)
p2 <- seq(0.001, 0.999, length.out = grid_size)
z <- matrix(NA, nrow = grid_size, ncol = grid_size)
for (i in seq_along(p1)) {
for (j in seq_along(p2)) {
p3 <- 1 - p1[i] - p2[j]
if (p3 > 0) {
z[i, j] <- dirichlet_density(
p = c(p1[i], p2[j], p3),
alpha = alpha
)
}
}
}
par(bg = "white")
persp(x = p1, y = p2, z = z, theta = 40, phi = 25, expand = 0.6, ticktype = "detailed",
xlab = expression(p[1]), ylab = expression(p[2]), zlab = "Density",
main = expression(paste("Dirichlet density over ", p[1], " and ", p[2])))
There is one more important point before we add d20.unfair to our model list.
The models we used so far had no unknown parameters. For example, under the fair d20 model, the probability of each side is fixed at 1/20. Once the model is chosen, everything else is already known.
The d20.unfair model is different. It does not specify one fixed set of side probabilities. Instead, it allows many possible versions of the die:
$ \mathbf{p} = (p_1,p_2,\ldots,p_{20}). $
So, when we ask how likely a new observation is under d20.unfair, we are not asking for the probability under one particular value of $\mathbf{p}$. We are asking for the probability of the observation after considering all possible values of $\mathbf{p}$, weighted by how plausible they are under the model.
Then, for the d20.unfair model $M$ with unknown parameters $\mathbf{p}$, the probability of the data, $x$, under the model is therefore
$ P(x \mid M) = \int_{\Delta} P(x \mid \mathbf{p}, M) P(\mathbf{p} \mid M) \,d\mathbf{p}. $
Here, $\Delta$ represents the space of all possible values of $\mathbf{p}$. Since $\mathbf{p}$ contains probabilities, not every combination of values is allowed: each value must be non-negative, and all values must add up to one.
In words, we calculate the probability of the data for each possible parameter value, then average over all parameter values allowed by the model.
For the unfair die model, $M =$ d20.unfair, the probability of the data under the model is calculated by averaging over all possible values of the side probabilities:
$ P(x \mid M) = \int_{\Delta} P(x \mid \mathbf{p}, M) P(\mathbf{p} \mid M) \,d\mathbf{p}. $
For a single new observation, side $k$, this becomes the average value of $p_k$ under the Dirichlet distribution:
$ P(x = k \mid M) = \int_{\Delta} p_k \, \mathrm{Dirichlet}(\mathbf{p} \mid \alpha_1,\alpha_2,\ldots,\alpha_{20}) \,d\mathbf{p}, $
where
$ \alpha_1 = n_1 + 1, $
$ \alpha_2 = n_2 + 1, $
$ \ldots $
$ \alpha_{20} = n_{20} + 1. $
So, we are averaging $p_k$ over all possible unfair 20-sided dice, weighted by how plausible each one is after the observations seen so far.
For the Dirichlet distribution, this average has a simple form:
$ P(x = k \mid M) = \mathbb{E}[p_k] = \frac{\alpha_k}{\sum_{j=1}^{20}\alpha_j}. $
In words, for this specific case, the probability of observing side $k$ under the d20.unfair model can be calculated directly from the observed counts.
model_d20_unfair <- function(alpha0 = rep(1, 20)) {
# If alpha0 is not provided, we start with equal initial values for all side probabilities
# (alpha0 = 1, meaning n0 = 0, for each face), so no side is favoured
# before any rolls are observed.
#
# This is a weak starting point: a few observations can tilt the inferred
# probabilities, but many observations are needed before they become strongly concentrated.
alpha <- alpha0
function(x, update=TRUE) {
if (x >= 1 && x <= 20) {
# Probability of observing x under current belief
like <- alpha[x] / sum(alpha)
if (update) {
# Update internal state after observing x
alpha[x] <<- alpha[x] + 1
} else {
# If update is FALSE, we return the likelihood and the internal state (alpha) for inspection
return(list(
like = like,
alpha = alpha
))
}
return(like)
} else {
return(0)
}
}
}
# Warning: By default, the function updates itself when called.
# To inspect the likelihood and the internal state, you should call likelihoods[["d20.unfair"]](x, update=FALSE)
# To assume that all models are equally likely a priori
uniform_prior <- function() {
prior <- c()
for (m in names(likelihoods)) {
prior[m] <- 1 / length(likelihoods)
}
return(prior)
}
# Updating a prior belief on the basis of an observation
bayesian_update <- function(observation, prior) {
unnormalised <- c()
for (model in names(likelihoods)) {
likelihood <- likelihoods[[model]]
# Probability of the new observation under each model
like <- likelihood(observation)
# Unnormalised posterior
unnormalised[model] <- like * prior[model]
}
# Normalise, with probability of observations, so that probabilities add up to 1
posterior <- unnormalised / sum(unnormalised)
return(posterior)
}
p.cheat <- 0.25
# Probability of observing x under a die model
likelihoods <- c("d4" = function(x) { return (ifelse(x >= 1 && x <= 4, 1 / 4, 0)) },
"d6" = function(x) { return (ifelse(x >= 1 && x <= 6, 1 / 6, 0)) },
"d20" = function(x) { return (ifelse(x >= 1 && x <= 20, 1 / 20, 0)) },
"d4.cheat" = function(x) { return (p.cheat * (1/20) + (1 - p.cheat) * (ifelse(x >= 1 && x <= 4, 1 / 4, 0))) },
"d6.cheat" = function(x) { return (p.cheat * (1/20) + (1 - p.cheat) * (ifelse(x >= 1 && x <= 6, 1 / 6, 0))) },
"d20.cheat" = function(x) { return (p.cheat * (1/20) + (1 - p.cheat) * (ifelse(x >= 1 && x <= 20, 1 / 20, 0))) },
#
# Here is the alternative model of an unfair 20-sided die with 20 internal parameters (side biases),
# which are updated after each observation:
#
"d20.unfair" = model_d20_unfair())
# Observed sequence
observations <- c(1, 3, 3, 2, 1, 3, 2, 4, 10)
model_probabilities <- matrix(
nrow = length(observations)+1,
ncol = length(names(likelihoods))
)
colnames(model_probabilities) <- names(likelihoods)
rownames(model_probabilities) <- c("Prior", paste("Data",1:length(observations),":",as.character(observations))
)
current_prior <- uniform_prior()
model_probabilities[1,] <- current_prior
for (m in seq_along(observations)) {
current_prior <- bayesian_update(observations[m], current_prior)
model_probabilities[m+1, ] <- current_prior
}
Before evaluating the posterior model probabilities, let's first examine what the data suggest about the side probabilities of the d4.unfair model. As expected, the alpha values correspond to an offset version of the histogram of observed side frequencies: each observed count is increased by the initial value assigned to that side in alpha0.
p <- seq(0.0001, 0.5, length.out = 1000)
# Get the current alpha values from the unfair d20 model
alpha <- likelihoods[["d20.unfair"]](1, update = FALSE)$alpha
par(bg = "white", xpd = TRUE, mar = c(8, 4, 4, 2))
barplot(alpha, names.arg = paste0("side ", 1:20), las = 2, main = "Alpha values for each side of the unfair d20 model")
The inferred side probabilities, $\mathbf{p}$, follow a Dirichlet distribution with 20 components. We cannot plot this full space directly, but we can visualise the marginal distribution of each side probability. That is, for each side $k$, we can ask how plausible different values of $p_k$ are, while grouping all other sides together.
For a Dirichlet distribution,
$ \mathbf{p} \sim \mathrm{Dirichlet}(\alpha_1,\alpha_2,\ldots,\alpha_{20}), $
the marginal distribution of $p_k$ is
$ P(p_k \mid x) = \mathrm{Beta}\left( p_k \mid \alpha_k, \sum_{j \neq k}\alpha_j \right). $
mat <- matrix(
nrow = length(p),
ncol = 20
)
colnames(mat) <- paste0("side ", 1:20)
for (k in 1:20) {
mat[, k] <- dbeta(p, alpha[k], sum(alpha) - alpha[k])
}
# Posterior mean for each selected side
posterior_mean <- alpha / sum(alpha)
# Colours and line types
mask <- posterior_mean == min(posterior_mean)
cols <- 1:20
cols[mask] <- cols[mask][1]
ltys <- 1:20
ltys[mask] <- ltys[mask][1]
par(bg = "white", xpd = TRUE)
matplot(p, mat, type = "l", lwd = 2, lty = ltys, col = cols, ylim = c(0, 50),
xlab = expression(p[k]), ylab = "Density", main = "Selected marginal distributions")
legend("topright", legend = paste0(colnames(mat), " mean = ", round(posterior_mean, 3)),
lty = ltys, lwd = 2, col = cols, bty = "n")
As the plot shows, the data do not yet contain much information about the individual side probabilities. We would need to observe many more outcomes before we could estimate all 20 side probabilities with confidence.
How many more? That depends on how precise we want the estimates to be. In the Dirichlet model, uncertainty decreases as the total count
$ \alpha_0 = \sum_{j=1}^{20}\alpha_j $
increases. The posterior variance of each side probability is
$ \mathrm{Var}(p_k \mid x) = \frac{ \alpha_k(\alpha_0-\alpha_k) }{ \alpha_0^2(\alpha_0+1) }. $
So there is no single universal number of rolls. The required amount of data depends on the desired precision, the number of sides, and how uneven the true side probabilities are.
Now, let's see which model has the highest probability given the data.
print(round(model_probabilities, 6))
steps <- 0:(nrow(model_probabilities) - 1)
par(bg = "white")
cols <- seq_len(ncol(model_probabilities))
pchs <- seq_len(ncol(model_probabilities))
ltys <- seq_len(ncol(model_probabilities))
matplot(steps, model_probabilities,
type = "b", lw = 3, pch = pchs, lty = ltys, col = cols, ylim = c(0, 1),
xlab = "Die rolls", ylab = "Model probability")
axis(side = 1, at = steps)
legend("left", legend = colnames(model_probabilities), lw = 3, lty = ltys, pch = pchs, col = cols, bty = "n")
So, with over 95% posterior probability, the 4-sided die was chosen and the DM had been occasionally lying to us about the outcomes. The unfair 20-sided die proposal is not very likely given the observed sequence, which still tells us little about the individual side probabilities.
We confront her:
Hey, you have been using the 4-sided die and lying to us. If you did not want to play the game with us, you did not need to go through all that.
I have not been lying to you. I have been using the 20-sided die, which I bought today. It feels funny in my hand, so I think it is causing all our problems. Here, look at it.
She hands it over. We weigh it and realise that, indeed, it feels heavier on one side. We roll it, and it comes up 3. The interesting thing is that, around 3, there are 1, 2, and 4. Maybe she is telling the truth, but nine observations are not enough to tell.
So, we roll the die again, and again, and many more times, recording the following outcomes:
unfair <- t(data.frame(
counts = c(233, 213, 350, 101, 2, 4, 3, 1, 4, 90, 5, 1, 2, 2, 1, 1, 1, 2, 3, 1),
row.names = 1:20
))
print(unfair)
We now have independent observations from the die itself. Could the initial observations have been made with this die? Instead of starting d20.unfair with equal alpha values, we can initialise it with alpha values derived from our own rolls of the suspicious die.
These additional rolls are not used to decide directly which die was chosen in the original sequence. Instead, they help us define a better d20.unfair model. If this new model assigns high probability to the original sequence, then the DM’s explanation becomes more plausible.
# Probability of observing x under a die model
likelihoods <- c("d4" = function(x) { return (ifelse(x >= 1 && x <= 4, 1 / 4, 0)) },
"d6" = function(x) { return (ifelse(x >= 1 && x <= 6, 1 / 6, 0)) },
"d20" = function(x) { return (ifelse(x >= 1 && x <= 20, 1 / 20, 0)) },
"d4.cheat" = function(x) { return (p.cheat * (1/20) + (1 - p.cheat) * (ifelse(x >= 1 && x <= 4, 1 / 4, 0))) },
"d6.cheat" = function(x) { return (p.cheat * (1/20) + (1 - p.cheat) * (ifelse(x >= 1 && x <= 6, 1 / 6, 0))) },
"d20.cheat" = function(x) { return (p.cheat * (1/20) + (1 - p.cheat) * (ifelse(x >= 1 && x <= 20, 1 / 20, 0))) },
"d20.unfair" = model_d20_unfair(),
#
# The alphas are the counts of each side plus 1:
#
"d20.unfair2" = model_d20_unfair(unfair+1))
model_probabilities <- matrix(
nrow = length(observations)+1,
ncol = length(names(likelihoods))
)
colnames(model_probabilities) <- names(likelihoods)
rownames(model_probabilities) <- c("Prior", paste("Data",1:length(observations),":",as.character(observations))
)
current_prior <- uniform_prior()
model_probabilities[1,] <- current_prior
for (m in seq_along(observations)) {
current_prior <- bayesian_update(observations[m], current_prior)
model_probabilities[m+1, ] <- current_prior
}
print(round(model_probabilities, 6))
steps <- 0:(nrow(model_probabilities) - 1)
par(bg = "white")
cols <- seq_len(ncol(model_probabilities))
pchs <- seq_len(ncol(model_probabilities))
ltys <- seq_len(ncol(model_probabilities))
matplot(steps, model_probabilities,
type = "b", lw = 3, pch = pchs, lty = ltys, col = cols, ylim = c(0, 1),
xlab = "Die rolls", ylab = "Model probability")
axis(side = 1, at = steps)
legend("left", legend = colnames(model_probabilities), lw = 3, lty = ltys, pch = pchs, col = cols, bty = "n")
Yes, indeed. The more informed d20.unfair2 proposal seems to be consistently more probable than most of our previous models, including the naive d20.unfair model, which started with equal and weak alpha values.
Under these circumstances, our DM thanks us and throws away the crooked die. We agree that we have played enough games for one night, and arrange to meet again for a proper journey into the fantastic world of Dungeons & Dragons.