When you roll a pair of fair dice, the most likely outcome is 7 (which occurs 1/6 of the time) and the least likely outcomes are 2 and 12 (which each occur 1/36 of the time). Annoyed by the variance of these probabilities, I set out to create a pair of “uniform dice.” These dice still have sides that are uniquely numbered from 1 to 6, and they are identical to each other. However, they are weighted so that their sum is more uniformly distributed between 2 and 12 than that of fair dice. Unfortunately, it is impossible to create a pair of such dice so that the probabilities of all 11 sums from 2 to 12 are identical (i.e., they are all 1/11), but I bet we can get pretty close.
So how should I make my dice as uniform as possible? In other words, which specific weighting of the dice minimizes the variance among the 11 probabilities?
The canonical setup assumes that I am making two identical dice with weighted distributions such that the face i will land with probability pi, for i=1,…,6. However, I will set up the generic problem where I can have weights p=(p1,…,p6) for one die and q=(q1,…,q6) for the other.
Given p and q, the probability of rolling s for some s∈{2,…,12} is P{S=s∣p,q}=∑i=max So the variance of the probabilities is given by V(p, q) = \frac{1}{11} \sum_{s=2}^12 \left( \mathbb{P} \{S = s \mid p, q \} - \frac{1}{11} \right)^2. Obviously the choice of weights p and q must satisfy p^T \mathbb{1} = q^T \mathbb{1} = 1 and p, q \geq 0 in order for them to be true probabhility weights.
Therefore, the optimal weightings to obtain as uniform as possible a sum of the two dice rolls is therefore given by the optimization problem \begin{align*} \text{minimize} \,\,\, & \,\, V(p, q) \\ \text{such that} & \,\, p^T \mathbb{1} = q^T \mathbb{1} = 1 \\ & \,\, p, q \in \mathbb{R}^6_+.\end{align*} In the canonical version of this problem, the additional linear constraint that p = q is added.
I set up a small Python script (see below) to do the optimization using the SLSQP solver method for scipy.optimize.minimize. For the canonical problem, the solution is given by \hat{p} = \hat{q} = (0.2439, 0.1375, 0.1186, 0.1186, 0.1375, 0.2439), which gives minimal probability variance of V(\hat{p}, \hat{p}) = 0.00121758. In the general problem, however, we can do quite a bit better, reducing the probability variance by about a factor of 6. In the general case, the solution is \begin{align*}\hat{p} &= (0.1250, 0.1875, 0.1875, 0.1875, 0.1875, 0.1250) \,\,\text{and}\\ \hat{q} &= (0.5000, 0.0000, 0.0000, 0.0000, 0.0000, 0.5000),\end{align*} (or vice versa), with minimal probability variance of V(\hat{p}, \hat{q}) = 0.00025826.
import numpy as np
from scipy.optimize import Bounds, minimize, LinearConstraint
def F(x):
p = x[:6].copy()
q = x[6:].copy()
prob = np.zeros((11,))
jac = np.zeros((12,))
for s in range(1,12):
innerj = np.zeros((12,))
lower = max(0,s-6)
upper = min(6,s)
for i in range(lower, upper):
prob[s-1] += p[i] * q[s-i-1]
innerj[i] += q[s-i-1]
innerj[6+s-i-1] += p[i]
jac += 2.0 / 11.0 * (prob[s-1] - 1.0/11.0) * innerj
var = (prob - 1.0/11.0).dot(prob - 1.0/11.0) / 11.0
return var, jac, prob
def objective(x):
o, _, _ = F(x)
return o
def jac(x):
_, j, _ = F(x)
return j
def probs(x):
_, _, p = F(x)
return p
A = np.concatenate([np.concatenate([np.ones((1,6)), np.zeros((1,6))], axis=1),
np.concatenate([np.zeros((1,6)), np.ones((1,6))], axis=1)], axis=0)
bounds = Bounds(np.zeros((12,)), np.ones((12,)))
r = np.random.uniform(size=(12,))
x0 = np.concatenate([r[:6]/r[:6].sum(), r[6:]/r[6:].sum()])
soln = minimize(objective, x0, jac=jac, method='SLSQP', bounds=bounds, options={'ftol': 1e-16, 'disp': True},
constraints=[{'type' : 'eq', 'fun' : lambda x: A.dot(x) - np.ones((2,)), 'jac' : lambda x : A}],
)
print('The solution for the general problem is given by p=(%0.4f, %0.4f, %0.4f, %0.4f, %0.4f, %0.4f) and q=(%0.4f, %0.4f, %0.4f, %0.4f, %0.4f, %0.4f) (or vice versa!).\nThese optimizers yield optimal value of V(p,q)=%0.8f.' % (*soln.x, soln.fun))
def Fc(x):
xx = np.concatenate([x,x])
return F(xx)
def objectivec(x):
oc, _, _ = Fc(x)
return oc
def jacc(x):
_, jc, _ = Fc(x)
return jc[:6]
def probsc(x):
_, _, pc = Fc(x)
return pc
x0c = np.ones((6,))/6.0
boundsc = Bounds(np.zeros((6,)), np.ones((6,)))
solnc = minimize(objectivec, x0c, jac=jacc, method='SLSQP', bounds=boundsc, options={'ftol': 1e-16, 'disp': True},
constraints=[{'type' : 'eq', 'fun' : lambda x : np.ones((1,6)).dot(x) - 1.0, 'jac' : lambda x : np.ones((1,6))}],
)
print('The solution for the canonical problem is given by p=q=(%0.4f, %0.4f, %0.4f, %0.4f, %0.4f, %0.4f).\nThis optimizer yields optimal value of V(p,p)=%0.8f.' % (*solnc.x, solnc.fun))