14 KiB
Python Programming Dice Probabilities
Introduction
CLOCK: [2024-04-20 Sat 09:05]–[2024-04-20 Sat 09:22] => 0:17 CLOCK: [2024-04-20 Sat 07:57]–[2024-04-20 Sat 09:04] => 1:07 CLOCK: [2024-04-19 Fri 10:33]–[2024-04-19 Fri 12:50] => 2:17 CLOCK: [2024-04-17 Wed 13:40]–[2024-04-17 Wed 14:14] => 0:34
I was given a problem as such:
You have two players, Bob and Alice, that take turns in rolling a fair k-sided die. Whoever rolls a k first wins the game. The Python program should output the probability that Bob wins the game for k = 6 thru 99. That is, the output will be an array of probabilities where index 0 is the probability when k = 6; index 1 when k = 7; etc.
Bonus points:
If you have 8me and interest, create a REST server rather than a console program. Flask or FastAPI can be used. The REST endpoint should be a GET, accept no Request Body, accept an op8onal Header for “k”, and return:
- The array of probabili8es in JSON format if no “k” is provided in the Header
- A single probability in JSON format if a “k” is provided in the Header
I appear to have completed the task, at a comfortable pace, in about 4 hours. I have omitted the unit tests for the API portion of this, as it would have been overkill.
I did choose to include 100 in the calculations, as I find that just a little bit more elegant. There is something special about ending a list on a nice round number.
Usage
This repository contains multiple files.
- The pdf with the problem I was given.
- README.org - an Emacs org-mode file, which you're reading right now.
-
pydiceprob.py - the main python file, which works in CLI as such:
python pydiceprob.py [dice_size] [mode]
dice_size
can be anything in the CLI, but note that excessively large numbers are pointless, as the probability of a single throw will always be 1/dice_size, while the probability of winning in the game will always be approaching 50%.-
The modes are:
single
(for single throw)single-table
(for a table of probabilities to win on a single throw across a variety of dice sizes)multi
(winning probability in the game)multi-table
(winning probabilities in the game across a variety of dice sizes)
-
Alternatively, you can start a REST API (implemented using FastAPI) using uvicorn. Run
uvicorn pydiceprob:app
from the same directory as thepydiceprob.py
file.-
You can then see the outputs using
curl
, such as:curl 127.0.0.1:8000
orcurl 127.0.0.1:8000 -H "k: [dice_size]"
- Please note that I have chosen to limit the dice size to be between 6 and 100 inclusive on the API.
-
- tests.py - simple tests for the probability calculations.
Code
The following is python code blocks, with the documentation attached. Using
org-babel
, these are tangled into pydiceprob.py
,
which is the final python script.
Imports
import sys
import json
import pprint
from fastapi import FastAPI, Header
Pretty print and usage printer
Pretty print so output is legible.
pp = pp.Printer = pprint.PrettyPrinter(indent=2, compact=True)
def print(stuff):
pp.pprint(stuff)
And a usage printer:
def usagequit():
print("""Usage: python pydiceprob.py [k] [mode]
k between 6 and 99 (higher numbers are supported, up to however much your memory can handle; given that win probabilities approach 50%, this isn't very useful past about 100.)
modes: single, single-table, multi, multi-table
single prints out the probability of a single throw with a k-sided dice yielding k.
single-table prints out a table between 6 and k with the probabilities of a single throw yielding k.
multi prints out the probability of winning (assuming we're going first) in a game where two players take turns and the person who throws k first wins.
multi-table prints out the probability of winning for a range between 6 and k if you have the first throw.""")
quit()
FastAPI
app = FastAPI()
@app.get("/")
async def api_get(k: int = Header(default=None)):
if not isinstance(k, int) and k is not None:
return {"message": "Header k must be an integer between 6 and 100 inclusive or not present."}
if k is None:
result = one_turn_table(100)
return {"Probabilities-dice": result}
elif k >= 6 and k <= 100:
result = one_turn_single(k)
return {f"Probability-dice-size-{k}": result}
else:
return {"message": "Usage: no request body, use header k to specify what probability you want, if no k, then a table is given between dice sizes 6 and 100 inclusive."}
Main
Main function for managing CLI interactions, and dispatch
def main():
global k
try:
if int(sys.argv[1]) >= 6:
k = int(sys.argv[1])
else:
usagequit()
except IndexError:
usagequit()
global mode
try:
mode = sys.argv[2]
except IndexError:
usagequit()
dispatch(mode, k)
def dispatch(mode, k):
modes = ["single", "multi", "single-table", "multi-table"]
if mode not in modes:
usagequit()
if mode == "single":
print(one_turn_single(k))
elif mode == "multi" :
print(multi_turn_single(k))
elif mode == "single-table":
print(one_turn_table(k))
elif mode == "multi-table":
print(multi_turn_table(k))
Single turn win probability
Then, we have to calculate the probability of a win on each throw given a dice
of size k
.
Both players (Alice and Bob) throw dice, alternating, and the person who throws the top number first (k
) wins. Bob always goes first for simplicity.
A dice of size k
has a 1/k probability of winning each throw.
k+1
here, because python calculates range(a, b) indices in the way of b-a, so
for a = 6 and b = 10, it'll give an array of 4 items (10-6 = 4) as such:
[6, 7, 8, 9]
, as it counts the first item as 1.
Using k+1
we can get the correct, inclusive array that we desire: [6, 7, 8, 9, 10]
.
def one_turn_table(k):
result = {}
for k in range(6, int(k)+1):
result[k] = 1/k
return result
def one_turn_single(k):
return one_turn_table(k)[k]
Multi-turn win probability
Over many throws, the advantage of going first will decrease, approaching 50%.
We're assuming Bob goes first, and then Alice. For each throw, the probability of winning is the same.
def multi_turn_single(k):
p_win = 1 / k # Winning probability on any given throw
p_lose = (k-1) / k # Losing probability on any given throw
r = p_lose**2
probability_win = p_win / (1 - r)
return probability_win
And then we want to generate a table of all the probabilities up to k
.
def multi_turn_table(k):
result = {}
for i in range(6, int(k+1)):
result[i] = multi_turn_single(i)
return result
Script
To run as a script.
if __name__ == "__main__":
main()
Unit testing
The following code blocks are tangled into test.py.
Imports
import unittest
import pydiceprob
Single-turn win probabilities
Then we test the range from 6 to 100. The script supports more, but this is the part that must be correct.
Because the function one_turn_single
pulls directly from
one_turn_table
, we can simply iterate over one_turn_single
,
ensuring that both functions work as desired at the same time.
This, however, is only because we're dealing with exceedingly trivial code, where the singular function merely narrows down the data from the plural.
class TestSingle(unittest.TestCase):
def test_single(self):
data = {6: 0.16666666666666666,
7: 0.14285714285714285,
8: 0.125,
9: 0.1111111111111111,
10: 0.1,
11: 0.09090909090909091,
12: 0.08333333333333333,
13: 0.07692307692307693,
14: 0.07142857142857142,
15: 0.06666666666666667,
16: 0.0625,
17: 0.058823529411764705,
18: 0.05555555555555555,
19: 0.05263157894736842,
20: 0.05,
21: 0.047619047619047616,
22: 0.045454545454545456,
23: 0.043478260869565216,
24: 0.041666666666666664,
25: 0.04,
26: 0.038461538461538464,
27: 0.037037037037037035,
28: 0.03571428571428571,
29: 0.034482758620689655,
30: 0.03333333333333333,
31: 0.03225806451612903,
32: 0.03125,
33: 0.030303030303030304,
34: 0.029411764705882353,
35: 0.02857142857142857,
36: 0.027777777777777776,
37: 0.02702702702702703,
38: 0.02631578947368421,
39: 0.02564102564102564,
40: 0.025,
41: 0.024390243902439025,
42: 0.023809523809523808,
43: 0.023255813953488372,
44: 0.022727272727272728,
45: 0.022222222222222223,
46: 0.021739130434782608,
47: 0.02127659574468085,
48: 0.020833333333333332,
49: 0.02040816326530612,
50: 0.02,
51: 0.0196078431372549,
52: 0.019230769230769232,
53: 0.018867924528301886,
54: 0.018518518518518517,
55: 0.01818181818181818,
56: 0.017857142857142856,
57: 0.017543859649122806,
58: 0.017241379310344827,
59: 0.01694915254237288,
60: 0.016666666666666666,
61: 0.01639344262295082,
62: 0.016129032258064516,
63: 0.015873015873015872,
64: 0.015625,
65: 0.015384615384615385,
66: 0.015151515151515152,
67: 0.014925373134328358,
68: 0.014705882352941176,
69: 0.014492753623188406,
70: 0.014285714285714285,
71: 0.014084507042253521,
72: 0.013888888888888888,
73: 0.0136986301369863,
74: 0.013513513513513514,
75: 0.013333333333333334,
76: 0.013157894736842105,
77: 0.012987012987012988,
78: 0.01282051282051282,
79: 0.012658227848101266,
80: 0.0125,
81: 0.012345679012345678,
82: 0.012195121951219513,
83: 0.012048192771084338,
84: 0.011904761904761904,
85: 0.011764705882352941,
86: 0.011627906976744186,
87: 0.011494252873563218,
88: 0.011363636363636364,
89: 0.011235955056179775,
90: 0.011111111111111112,
91: 0.01098901098901099,
92: 0.010869565217391304,
93: 0.010752688172043012,
94: 0.010638297872340425,
95: 0.010526315789473684,
96: 0.010416666666666666,
97: 0.010309278350515464,
98: 0.01020408163265306,
99: 0.010101010101010102,
100: 0.01}
for t in range(6, 100):
result = pydiceprob.one_turn_single(t)
self.assertEqual(result, data[t], f"Should be {data[t]} for dice size {t}.")
Multi-turn test
As with the other test, we're feeding the correct values, iterating over the
possible outputs of the multi_turn_single
function.
def test_multi(self):
data = { 6: 0.5454545454545455,
7: 0.5384615384615383,
8: 0.5333333333333333,
9: 0.5294117647058822,
10: 0.5263157894736844,
11: 0.5238095238095235,
12: 0.5217391304347823,
13: 0.5200000000000005,
14: 0.5185185185185185,
15: 0.5172413793103451,
16: 0.5161290322580645,
17: 0.5151515151515152,
18: 0.5142857142857141,
19: 0.5135135135135128,
20: 0.5128205128205127,
21: 0.5121951219512191,
22: 0.511627906976745,
23: 0.5111111111111115,
24: 0.5106382978723412,
25: 0.510204081632653,
26: 0.5098039215686275,
27: 0.5094339622641498,
28: 0.5090909090909096,
29: 0.5087719298245623,
30: 0.5084745762711862,
31: 0.5081967213114762,
32: 0.5079365079365079,
33: 0.5076923076923086,
34: 0.5074626865671636,
35: 0.5072463768115941,
36: 0.507042253521126,
37: 0.506849315068494,
38: 0.5066666666666677,
39: 0.5064935064935064,
40: 0.5063291139240501,
41: 0.506172839506172,
42: 0.5060240963855414,
43: 0.5058823529411758,
44: 0.5057471264367819,
45: 0.5056179775280893,
46: 0.5054945054945053,
47: 0.505376344086021,
48: 0.5052631578947361,
49: 0.5051546391752573,
50: 0.5050505050505041,
51: 0.5049504950495041,
52: 0.504854368932038,
53: 0.5047619047619043,
54: 0.5046728971962623,
55: 0.5045871559633027,
56: 0.5045045045045037,
57: 0.5044247787610603,
58: 0.5043478260869556,
59: 0.5042735042735057,
60: 0.504201680672268,
61: 0.5041322314049588,
62: 0.5040650406504076,
63: 0.5039999999999981,
64: 0.5039370078740157,
65: 0.5038759689922497,
66: 0.5038167938931295,
67: 0.5037593984962382,
68: 0.5037037037037063,
69: 0.5036496350364972,
70: 0.5035971223021601,
71: 0.5035460992907805,
72: 0.5034965034965061,
73: 0.5034482758620673,
74: 0.5034013605442197,
75: 0.5033557046979886,
76: 0.5033112582781448,
77: 0.5032679738562092,
78: 0.5032258064516143,
79: 0.5031847133757983,
80: 0.5031446540880515,
81: 0.5031055900621104,
82: 0.503067484662576,
83: 0.5030303030303014,
84: 0.502994011976049,
85: 0.5029585798816592,
86: 0.5029239766081863,
87: 0.5028901734104042,
88: 0.5028571428571439,
89: 0.5028248587570601,
90: 0.502793296089388,
91: 0.5027624309392276,
92: 0.5027322404371553,
93: 0.5027027027027039,
94: 0.5026737967914459,
95: 0.5026455026455015,
96: 0.5026178010471216,
97: 0.5025906735751302,
98: 0.502564102564102,
99: 0.5025380710659929,
100: 0.5025125628140696}
for t in range(6, 100):
result = pydiceprob.multi_turn_single(t)
self.assertEqual(result, data[t], f"Should be {data[t]} for dice size {t}.")
Test main entry
if __name__ == '__main__':
unittest.main()