python-dice-top-k-game-prob.../README.org

446 lines
14 KiB
Org Mode

#+title: Python Programming Dice Probabilities
#+auto_tangle: t
#+PROPERTY: session *python*
#+PROPERTY: cache yes
#+PROPERTY: exports both
#+PROPERTY: header-args :tangle pydiceprob.py
* Introduction
:LOGBOOK:
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
:END:
I was given a problem as such:
#+begin_quote
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.
#+end_quote
Bonus points:
#+begin_quote
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
#+end_quote
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.
1. The pdf with the problem I was given.
2. README.org - an Emacs org-mode file, which you're reading right now.
3. 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 the
=pydiceprob.py= file.
- You can then see the outputs using =curl=, such as:
- ~curl 127.0.0.1:8000~ or
- ~curl 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.
4. 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
#+begin_src python
import sys
import json
import pprint
from fastapi import FastAPI, Header
#+end_src
** Pretty print and usage printer
#+begin_src python
pp = pp.Printer = pprint.PrettyPrinter(indent=2, compact=True)
def print(stuff):
pp.pprint(stuff)
#+end_src
And a usage printer:
#+begin_src python
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()
#+end_src
** FastAPI
#+begin_src python
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 of throwing the highest number on dice of size": result}
elif k >= 6 and k <= 100:
result = one_turn_single(k)
return {f"Probability of throwing the highest number of dice of 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."}
#+end_src
** Main
Main function for managing CLI interactions, and dispatch
#+begin_src python
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)
#+end_src
#+begin_src python
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))
#+end_src
** 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]~.
#+begin_src python
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]
#+end_src
#+RESULTS:
** Multi-turn win probability
Over many throws, the advantage of winning will decrease, approaching 50%.
We're assuming Bob goes first, and then is followed by Alice.
For each throw, the probability of winning is the same.
#+begin_src python
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
bob_wins_prob_sum = 0
r = p_lose**2
probability_win = p_win / (1 - r)
return probability_win
#+end_src
And then we want to generate a table of all the probabilities up to =k=.
#+begin_src python
def multi_turn_table(k):
result = {}
for i in range(6, int(k+1)):
result[i] = multi_turn_single(i)
return result
#+end_src
** Script
To run as a script.
#+begin_src python
if __name__ == "__main__":
main()
#+end_src
* Unit testing
** Imports
#+begin_src python :tangle test.py
import unittest
import pydiceprob
#+end_src
** 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.
#+begin_src python :tangle test.py
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}.")
#+end_src
** 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.
#+begin_src python :tangle test.py
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}.")
#+end_src
** Test main entry
#+begin_src python :tangle test.py
if __name__ == '__main__':
unittest.main()
#+end_src