This is the part 3 of a 3 part series analyzing a betting game. If you'd like to jump to another part here are direct links:
- Intro Page
- Part 1 - A Simpler Problem
- Part 2 - Analysis and Simulations
- Part 3 - Mathematical Restructuring (You are here)
- Conclusion
Simulating is Slow
In Part 2 we were able to simulate the coin flips in Python. We simulated all possible permutations of flips to calculate the expected value after a specified number of rounds.
This works, but is very slow. The code's runtime is approximately and on my machine it takes nearly 8 seconds to simulate 20 rounds. 25 rounds takes about 5 minutes. 50 rounds would take well over 250 years to simulate.
There are some neat tricks you can use to greatly speed up the computation. Implementing a dynamic programming algorithm can cut the run time down to . This is a huge improvement, making large calculations feasible.
However, by using some clever math there is a way to cut the runtime down to . You can simulate 1000 rounds in less than 100 milliseconds.
Betting Wagers as Functions
Consider the amount that you should wager
where is a constant and .
We can show that this is equivalent to
Let , so . Then
Now we define
representing the change of the stake from a bet. We have to be careful here since is winning the bet and is losing the bet. The magnitude of is . The sign is determined by both whether or and the result of the flip.
In other words, flipping heads when results in multiplying the stake by , but flipping tails in the same situation results .
We can show that
The proof is tedious and algebraic so it will be provided on a separate page.
Proving the Order of Flips is Invariant
We will show that, for any permutation of flips with heads and tails, the resulting stake will be
for .
Consider any permutation with heads and tails, where is the element in the permutation.
Let be equal to the cumulative total of heads seen minus the cumulative total of tails seen before position . Formally
, then, is the amount wagered on every turn .
Let
and
so
Define a dominating streak from to where and and .
We can write our permutation as a series of dominating streaks , where is the final sub-sequence of the permutation after the last dominating streak. Note that could have length .
Consider any such dominating streak of length . Assume, without loss of generality, that so .
Let
the number of elements equal to in the sequence .
Since and , and the presence of increments the value of by and the presence of decrements the value of by .
Since , . Because we know that . So over , there are values of where and values of where .
Consider the set of where in . Pick one such and let . Because we know that every increment has a corresponding decrement, we know that there must be an index in such that . Choose the corresponding such that . Then every will have a unique corresponding which together form .
Because .
Using the fact that ,
Now consider the sequence of length . As before, assume, without loss of generality, that so .
since .
Then since and or else this would be a dominating sequence of length 2. So call the current index and value .
We will begin the process of finding offset streaks.
If terminate the process.
Let be the value such that where and , if it exists.
If exists then the sequence is an offset streak . Because the boundary conditions and underlying functions are the same this offset streak has the same properties of the dominating streak. and a there exists a one-to-one correspondence between negative and non-negative , where, when , there is an such that .
If then since otherwise it would be part of an offset streak with an earlier . So . Update the value of and check if this is part of an offset streak.
Otherwise, if , terminate the process.
If does not exist then since, if this would form an offset streak of length 2. So . Update the value of and and continue the search for offset streaks.
Once this process is finished we have created the sequence
We can see that all elements are contained within an offset streak . Further, let be the set of values such that and is not contained within any sequence . The values of for since we have shown that the increases by if there is an not captured by an offset streak.
So, in with , we see that
Because , we find that
for .
Because all orderings of a combination of heads and tails result in the same value, we find that the resulting value is independent of the ordering.
Calculating Via Our Findings
Using this method we can dramatically speed up our calculations.
Given our new betting fraction function
def f(p: float, n: int) -> float:
q = 1-p
frac = (p ** n - q ** n) / (p ** n + q ** n)
return (p - q) * frac
we can write a new function for
def F(p: float, n: int) -> float:
return 1 + f(p, n)
We can now write a short function to implement our math for Equation
def get_new_stake(p: float, h: int, t: int) -> float:
c = min(h, t)
d = max(h, t) - c
result = F(p, -1) ** c
for i in range(d):
result *= F(p, i)
return result
and finally a short loop to calculate the expected value
expected_value = 0
for h in range(rounds + 1):
t = rounds - h
probability = get_prob_outcome(bias, h, t)
expected_value += get_new_stake(bias, h, t) * probability
c = min(h, t)
d = max(h, t) - c
print(get_new_stake(bias, h, t), c, d)
print(expected_value)
This can now calculate the expected value for 1000 rounds in 0.2 seconds.
It doesn't end there though. Going just a bit farther, we can take advantage of the fact that the payoffs are symmetric across the number of heads and tails (the value after 60 heads and 40 tails is the same as 60 tails and 40 heads, despite the probabilities possibly being vastly different). This is fairly intuitive seeing that depends only on .
We can also rewrite our method to take better advantage of saved values for the multiplication sequence and we arrive, finally, at the memory efficient
def get_expected_value(bias: float, rounds: int) -> float:
expected_value = 0
f_neg1 = F(bias, -1)
pi_d = 1
# Prevent double-counting the center of the distribution
if rounds % 2 == 0:
probability = get_prob_outcome(bias, int(rounds/2), int(rounds/2))
expected_value -= f_neg1 ** int(rounds/2) * probability
for d in range(int(rounds % 2 != 0), rounds+1, 2):
c = int((rounds - d)/2)
probability = get_prob_outcome(bias, c, c+d) + get_prob_outcome(bias, c+d, c)
expected_value += log(f_neg1 ** c * pi_d) * probability
pi_d *= F(bias, d) * F(bias, d+1)
return expected_value
which runs in , bringing our 1000 round calculation time down to less than 100 milliseconds.
If we need to get the most probable value we can use the prior function and call
expected_heads = round(BIAS * rounds)
expected_tails = rounds - expected_heads
get_new_stake(BIAS, expected_heads, expected_tails)
Very fast and a large improvement over prior methods.
Continue to the Conclusion, which will provide some insight into simulating games and wrap up the series.