Coin flip streaks in Python

Below is the wording from the problem in Automate the Boring Stuff book. The bold text leads me to believe that I should be checking for a streak and not multiple streaks within each experiment of 100 flips. With that assumption in mind, I would appreciate any feedback on my solution and comments to the problem posted below. I'm a beginner and have only included code that I have learned up to this point in the book.

Write a program to find out how often a streak of six heads or a streak of six tails comes up in a randomly generated list of heads and tails. Your program breaks up the experiment into two parts: the first part generates a list of randomly selected 'heads' and 'tails' values, and the second part checks if there is a streak in it. Put all of this code in a loop that repeats the experiment 10,000 times so we can find out what percentage of the coin flips contains a streak of six heads or tails in a row.

# importing the randint function from the random module
from random import randint

# creating variables for the number of streaks, current streak and coin flip results
numberOfStreaks = 0
streak = 0
results = []

# creating a loop that repeats the experiment 10,000 times
for experimentNumber in range(10000):

# first part of the experiment- 100 coin flips using randint
# for loop that specifies a range of 100 flips
for i in range(100):
# using the append method to add flip outcome to the results variable
results.append(randint(0, 1))

# second part of the experiment- checks flip results for a streak
# for loop where the range is equal to the length of the results variable
for i in range(len(results)):
# if statement for the first flip to start the streak
if i == 0:
# augmented assignment operator used for consistency
streak += 1
# else if statement to check for repeating values
# checks if the current indexed value is equal to the indexed value before it
elif results[i] == results[i - 1]:
# if the values are the same, the streak increases
streak += 1
# else statement for when it's not the first flip or a repeating value
else:
# resets the streak
streak = 1
# if statement for when the streak reaches 6
if streak == 6:
# registers a streak of six
numberOfStreaks += 1
# takes the string down to 0 after has hit 6
streak -= 6
# breaks the loop after a streak has come up
break

# resets the results list for the next expirementNumber
results = []

# find out what percentage contains a streak of six heads or six tails in a row
print('Chance of streak: %s%%' % (numberOfStreaks / 100))


4 Answers

The following is meant to be an interest pique and not necessarily criticism, since it's possible that you haven't seen numerical code yet.

Your code is slow. Python is slow, and the only practical way to make it fast is to not use it (at least for CPU-bound problems). Numpy is a library written in C to make fast(er) numerical operations accessible to Python. Code that uses it properly looks somewhat alien to the way we natively think about numerical operations: all of the loops are hidden away in library calls, and this process is called vectorisation. So your loops:

for experimentNumber in range(n):

for i in range(100):

for i in range(len(results)):


will all go away.

To make it even more interesting, there doesn't seem to be very good in-built support for finding sub-arrays in Numpy, needed to identify your streaks. It's possible to start with a two-dimensional array, where:

• Axis 1 is the number of experiments, in this case of length 10,000
• Axis 2 is the run length, 100

and then make a three-dimensional window view into it, where

• Axis 1 is still of length 10,000
• Axis 2 is of length 100 - 6 + 1 = 95
• Axis 3 is a window of length 6

This windowed view makes it look to Numpy as if there are 95 totally different arrays per experiment when in reality they overlap. After that nasty trick, we apply some aggregate logic to check if all of the elements in the window - or none of them - is True.

The result is indeed faster, and as a bonus is more terse:

from random import randint
from timeit import timeit

import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
from numpy.random import default_rng

def old(n: int = 10_000) -> float:
# creating variables for the number of streaks, current streak and coin flip results
numberOfStreaks = 0
streak = 0
results = []

# creating a loop that repeats the experiment 10,000 times
for experimentNumber in range(n):

# first part of the experiment- 100 coin flips using randint
# for loop that specifies a range of 100 flips
for i in range(100):
# using the append method to add flip outcome to the results variable
results.append(randint(0, 1))

# second part of the experiment- checks flip results for a streak
# for loop where the range is equal to the length of the results variable
for i in range(len(results)):
# if statement for the first flip to start the streak
if i == 0:
# augmented assignment operator used for consistency
streak += 1
# else if statement to check for repeating values
# checks if the current indexed value is equal to the indexed value before it
elif results[i] == results[i - 1]:
# if the values are the same, the streak increases
streak += 1
# else statement for when it's not the first flip or a repeating value
else:
# resets the streak
streak = 1
# if statement for when the streak reaches 6
if streak == 6:
# registers a streak of six
numberOfStreaks += 1
# takes the string down to 0 after has hit 6
streak -= 6
# breaks the loop after a streak has come up
break

# resets the results list for the next expirementNumber
results = []

# find out what percentage contains a streak of six heads or six tails in a row
return numberOfStreaks / n

def new(n: int = 10_000) -> float:
rand = default_rng()
experiments = rand.integers(
low=0, high=1, endpoint=True, size=(n, 100), dtype=bool,
)

streak_windows = sliding_window_view(
x=experiments, window_shape=6, axis=1,
)

heads = np.all(streak_windows, axis=2)
tails = np.all(np.bitwise_not(streak_windows), axis=2)
experiments_had_streak = np.any(np.bitwise_or(heads, tails), axis=1)
n_streaks = np.count_nonzero(experiments_had_streak)
return n_streaks / n

def main():
n = 10_000
for method in (old, new):
def run():
method(n)
t = timeit(run, number=1)
print(f'Method {method.__name__} took {t:.2f}s to run {n} experiments')

if __name__ == '__main__':
main()

Method old took 2.34s to run 10000 experiments
Method new took 0.14s to run 10000 experiments


It's impractical to expect this as a response to the textbook's question in context, but it's an illustration that there are many different ways to approach a seemingly simple problem.

• Thank you! I've heard of Numpy but have yet to use it. Appreciate the thorough explanation. Commented Mar 23, 2022 at 3:25

My changes to your code, in order of decreasing importance:

1. Narrow the scope of variables as much as possible. This also helps avoid having to explicitly "reset" them. (See results, streak).[Note 1]
2. Use a list comprehension to generate results, the flip list.
3. Use constants for clarity.
4. Underscored variable names instead of camelCase.

Note I stripped your comments and included only my own for explanation.

from random import randint

NUM_EXPERIMENTS = 10000
FLIPS_PER_EXPERIMENT = 100

number_of_streaks = 0

for experiment_number in range(NUM_EXPERIMENTS):
# Here I made the loop variable a single underscore.  It's common practice
# to do that when you don't use the loop variable.  (Whether to avoid
# "unused variable" warnings or to prevent accidental use of it later).
results = [randint(0,1) for _ in range(FLIPS_PER_EXPERIMENT)]

streak = 0
for i in range(len(results)):
# This is probably fine, but an alternative (since both the 'if' and
# 'elif' branches have the same logic) would be to merge them,
# e.g. with gratutious parens:
#
#   if (i == 0) or (results[i] == results[i - 1]):
#       streak += 1
#   else:
#       streak = 1
#
if i == 0:
streak += 1
elif results[i] == results[i - 1]:
streak += 1
else:
streak = 1

if streak == 6:
number_of_streaks += 1
break

print('Chance of streak: %s%%' % (100 * number_of_streaks / NUM_EXPERIMENTS))


FWIW, here's how I probably would have done it. Some of this will probably make no sense, but maybe give you paths to further explore in learning the language:

from random import randint

NUM_EXPERIMENTS = 10_000  # Underscores as thousands separators can help readability
FLIPS_PER_EXPERIMENT = 100
STREAK_THRESHOLD = 6

# A helper function that contains similar logic to your for loop with break.
# I find a helper function that can return helps resolve potential ambiguity
# that break can introduce (e.g. with nested loops).
def find_streak(flips, min_length):
streak_value = None
streak_length = 0

for flip in flips:
if flip == streak_value:
# Same flip result, increment streak length and check if it meets
# the minimum length threshold
streak_length += 1
if streak_length >= min_length:
return True
else:
# Different result, streak ended
# Initialize/reset the streak value (0,1) and length
streak_value = flip
streak_length = 1

return False

num_experiments_with_streak = 0
for experiment_number in range(NUM_EXPERIMENTS):
# If you want to inspect the flips list (e.g. print it), you'll need to
# realize the values. e.g.
#
#   flips = [randint(0,1) for _ in range(FLIPS_PER_EXPERIMENT)]
#   print(flips)
#
# But if not, you can just use a generator here, which won't build the list.
flips = (randint(0,1) for _ in range(FLIPS_PER_EXPERIMENT))

if find_streak(flips, STREAK_THRESHOLD):
num_experiments_with_streak += 1

# These are f-strings
print(f'Number of experiments with streak (>={STREAK_THRESHOLD}): {num_experiments_with_streak}')
print(f'Number of experiments: {NUM_EXPERIMENTS}')
print(f'Number of flips per experiment: {FLIPS_PER_EXPERIMENT}')
print('Chance of streak: %s%%' % (100 * num_experiments_with_streak / NUM_EXPERIMENTS))


Notes

1. By "narrowing the scope", it's first necessary to understand what a scope of a variable even is: basically the region of code where access to it is valid. To narrow the scope is to reduce the size of that region. There are many benefits to reducing the scope, but the primary one to point out here is that, by introducing a variable in a loop instead of outside of it, that line (the initializer) is called on every loop iteration. Consider streak in your code. There it effectively has global scope. It's defined way at the top and extends down to what is effectively the end of the program. And to account for this, you end up doing streak -= 6.

Now look at where I placed streak in my first example: inside the loop. Now on every loop iteration, streak is set to zero. No need to manually reset it at the end of the iteration.

There are other advantages of reducing the scope including readability (less LoC you require the user to scroll to/read/comprehend to find uses), and helping to avoid inadvertent bugs.

• Thank you! Placing the streak variable inside of the loop was something I completely overlooked. I've picked up camelCase due to its use in the book, are underscored variables more common? Commented Mar 23, 2022 at 3:34
• @a-rizzo in python, absolutely. It's not a huge deal, but it will stand out as unusual to python developers and could be he source of confusion (e.g. they wrote some_var and you wrote someVar). Commented Mar 23, 2022 at 22:31
• nice review - great advice, and probably a deal more useful than mine Commented Apr 15, 2022 at 11:48

You have some useful reviews already. Here I'll focus on some suggestions related to problem-solving strategy.

The current implementation is hierarchically algorithmic. What do I mean by that awkward phrase? The current code is arranged hierarchically in the sense that we have an outer loop over the 10000 experiments; nested under that, we iterate over the coin flips; nested under that we perform various algorithmic steps to accomplish the task of identifying streaks of size 6 or larger. The code is reasonable and not difficult to follow. However, even though this style of coding is ubiquitous, it's not very flexible or powerful, especially when building more complex programs. The problem is that all of the complexity is interwoven: the streak-counting details are tightly linked to the outer layers of iteration. In the future, if the program needs to be modified for new or different behaviors, we have to be careful not to mess anything up.

A different strategic metaphor: a data relay. A different model is to think about the program as a sequence of stages or phases. Each phase receives some input data and, after doing its computations, forwards some other data to the next stage. Each stage worries only about its own task, not the entire program.

A different strategic structure: small functions rather than hierarchical nesting. To build that data relay, will will break the stages apart using functions, each quite small and focused.

The plan: running one experiment. To run one experiment we have the following data flow: given an integer, we will flip a coin that many times, generating a collection of flips; using that collection we will create a tally of all streaks, in the form of a dict mapping each streak size to how many times the streak occurred.

from random import randint
from collections import Counter
from itertools import groupby

def flip_coins(n_flips):
# Returns a sequence of flips.
return tuple(
randint(0, 1)
for _ in range(n_flips)
)

def count_streaks(flips):
# Groups the flips into streaks.
# Returns a tally of the streak sizes.
return Counter(
len(tuple(g))
for _, g in groupby(flips)
)


The plan: running a bunch of experiments and computing a summary result. If we run multiple experiments, we can generate a collection of such streak-tallies. From that collection we can easily compute the percentage of experiments that were "successful", in the sense of having a streak of 6 or larger.

def run_experiments(n_experiments, n_flips):
return tuple(
count_streaks(flip_coins(n_flips))
for _ in range(n_experiments)
)

def count_successes(tallies, streak_size):
return sum(
max(t) >= streak_size
for t in tallies
)


Putting it all together: command-line arguments to support development. When you are working on a script like this you want to operate on a tiny scale, so that the code runs faster and the volume of debugging output can be visually scanned with ease. Running 10000 experiments with 100 flips is too much. A better approach is to build in support for command-line arguments from the beginning.

import sys

def main(args):
# Get input parameters or use defaults.
args = args or (10000, 100, 6)
n_experiments = int(args[0])
n_flips = int(args[1])
streak_size = int(args[2])

# Execute our data relay.
tallies = run_experiments(n_experiments, n_flips)
n_successes = count_successes(tallies, streak_size)

# Report to the user.
streak_pct = 100 * n_successes / n_experiments
print(f'Chance of streak: {streak_pct}%')

if __name__ == '__main__':
main(sys.argv[1:])

• Thank you! I'm unfamiliar with the collections and itertools modules, will look into those. Commented Mar 23, 2022 at 3:41

Looks good, nice logical thinking to get to the code you have. It is easy to understand how it relates to the problem and seems well thought out. Once you have some more python under your belt you might do something like this:

from random import randint

num_streaks = 0
for _ in range(10000):
flips = "".join([str(randint(0,1)) for _  in range(100)])
if "111111" in flips or "000000" in flips:
num_streaks += 1

percentage = 100.0 * num_streaks / 10000


Apologies for the magic numbers - your code is better than mine in that respect, I just quickly bashed in the above at the prompt. The point here is to make use of built in facilities of the language and libraries where you can (once you know them). As you can see it makes for very compact code and apart from the flips line (which could definitely do with a comment), very readable too.

The above doesn't look very efficient, but in fact takes about the same time to execute as your code, 1.5 seconds on my very average PC. You could spend some time optimising that as per others have suggested, but optimisation is something you should only do if needed, as it tends to make code less readable. See https://stackoverflow.com/questions/385506/when-is-optimisation-premature

Your logical thinking and the clear code you have is the most important thing. You are using the tools you have well and not wasting time "improving" things that don't need it. Well done.

• I definitely need to look into more built in functions/libraries. Appreciate the compliment as well, helps keep the confidence up! Thank you! Commented Apr 24, 2022 at 16:10