8
\$\begingroup\$

This is a practice task from Automate the Boring Stuff with Python. I imagine many others have asked for their version of the solution to be checked, so I apologise beforehand for boring you yet again.

In brief, the task entails writing a code that carries out an experiment of checking if there is a streak of 6 'heads' or 'tails' in 100 coin tosses, then replicates it 10,000 times and gives a percentage of the success rate.

import random
numberOfStreaks = 0
listOf100 = []
streak = 0


def toss():
    flip = random.randint(0, 1)
    if flip == 0:
        return 'H'
    else:
        return 'T'


for experimentNumber in range(10000):
    # Code that creates a list of 100 'heads' or 'tails' values.
    for flipCoin in range(100):
        listOf100.append(toss())

    # Code that checks if there is a streak of 6 'heads' or 'tails' in a row.
    for listItem in range(len(listOf100) - 1):
        if listOf100[listItem] == listOf100[listItem + 1]:
            streak += 1
            if streak == 5:
                numberOfStreaks += 1
                streak = 0
                break
        else:
            streak = 0
    listOf100 = []

print('Chance of streak: %s%%' % (numberOfStreaks / 10000))

My question is, am I correct in setting the condition if streak == 5?

My reasoning is that there are 5 pairs to be checked for similarities, if the actual streak is to be 6, for example:
if listOf100[0] == listOf100[1]
if listOf100[1] == listOf100[2]
if listOf100[2] == listOf100[3]
if listOf100[3] == listOf100[4]
if listOf100[4] == listOf100[5]

So, if all 5 such pairs increase streak with 1, it means that there are 6 list items in a row that are either 'heads' or 'tails'.

Thank you!

\$\endgroup\$

4 Answers 4

7
\$\begingroup\$

PEP 8

The Style Guide for Python Code "gives coding conventions for the Python code ... intended to improve the readability of code and make it consistent across the wide spectrum of Python code."

Since a large majority of Python projects follow PEP-8 guideline, it behooves you to follow those conventions as much as possible (except when you shouldn't, as outlined in section 2 of the document).

These conventions include:

  • using snake_case for variable names, instead of mixedCase. Eg, numberOfStreaks should be named number_of_streaks.
  • imports should be followed by a blank line
  • functions should appear after import and before main code. Eg) number_of_streaks = 0, list_of_100 = [], and streaks = 0 should appear after def toss():
  • the mainline code should be inside a "main-guard" (if __name__ == '__main__':) statement.

Correctness

Task

I fear you have interpreted the practice task incorrectly, or at least, implemented it wrong.

The task is to check "if there is a streak of 6 'heads' or 'tails' in 100 coin tosses, not "how many" streaks occurred. It asks for a percentage success rate. If you had an unfair coin, with your code you may find several dozen streaks in each experiment, and well over 10,000 streaks in the course of the 10,000 experiments, which would lead to a "percentage success rate" that exceeds 100%, which is suspect.

(Incorrect, but left in to support Heap Overflow's answer)

Math

print('Chance of streak: %s%%' % (numberOfStreaks / 10000))

Simply dividing a count by the total possible does not yield a percentage; 95 / 100 = 0.95 ... you must multiply by 100 to compute the result as a percentage.

WET -vs- DRY and locality of reference.

Your code reads (roughly):

listOf100 = []

# ...

for experiment ...:

    for flipCoin in range(100):
        listOf100.append(toss())

    ...

    listOf100 = []

You see the listOf100 = []? WET stands for "Write Everything Twice". In contrast, DRY stands for "Don't Repeat Yourself". In general, with less code, the code is easier to understand and maintain. If variables are defined near where they are used, the code is also easier to understand and maintain.

Let's DRY this code up.

# ...

for experiment ...:

    listOf100 = []
    for flipCoin in range(100):
        listOf100.append(toss())

    ...

Now, listOf100 = [] exists only once, and it exists right before it is being used.

Now, as demonstrated in the other two answers, you can replace the initialization and repeated .append() with a more concise list comprehension.

Magic Numbers

I see several numbers in the code: 10000, 100, listOf100, 1, 5, 0. What do these numbers mean?

If you wanted to change the number of experiments from 10000 to 20000 how many changes would you have to make? Two?

If you wanted to changed the number of tosses per experiment from 100 to 200, how many changes do you have to make? Change a number once, and a variable name 6 times??? That seems awkward and unmaintainable. And wrong, because there is also the comment.

Named constants go a long way to improving maintainability.

NUM_EXPERIMENTS = 10_000

...

for experiementNumber in range(NUM_EXPERIMENTS):
   ...

print('Change of streak: %s%%' % (numberOfStreaks / NUM_EXPERIMENTS))

Finally, 5 is the length of the streak. No, wait, 6 is the length of the streak. Uh. It would be nice to have a STREAK_LENGTH = 6 named constant, and then the algorithm could use if streak == STREAK_LENGTH - 1:, with perhaps a comment explaining the "why".

Unused variables

The variable created in this statement:

for experimentNumber in range(10000):

is never used anywhere. It only serves two purposes.

  1. to make a syntactically valid for statement.
  2. indicate this loop is executed once per experiment.

The second reason is obsoleted by changing the magic number 10000 into the named constant NUM_EXPERIMENTS. By convention, _ is used as the throw-away variable, used only to satisfy syntactical reasons. So this for statement could become:

for _ in range(NUM_EXPERIMENTS):

Ditto for the for flipCoin in range(100): statement; it could become (say):

    for _ in range(COIN_TOSSES_PER_EXPERIMENT):

Formatting numbers

Using the %s format code for a number is not a good habit to get into. It may produce ok results here; you are dividing by 10,000 so likely will get a number with only 4 decimal points. But if you were asked to perform a different number of experiments, such as 7, you could get a lot of digits after the decimal point.

Using the format code %.4f produces four digits after the decimal point, regardless of the actual number of experiments.

Improved Code

Others have answered with advanced -- or at best, tricky, and at worst, confusing -- methods of detecting the streaks including:

  • string concatenation and substring searching
  • functional programming
  • converting head/tail coin values into same/different values

In the spirit of the tag, let's investigate a clearer way.

You are currently testing listOf100[listItem] == listOf100[listItem + 1] to check if a coin face is the same as the next. The [listItem + 1] is the awkward part here, necessitating stopping our loop one element before the end of the list. Let's rethink this. Instead of comparing two coins at a time, how about examining only one coin at a time? Simply remember the whether the streak is currently heads or tails, and ask if the current coin matches that streak:

    for coin_face in coin_tosses:
        if coin_face == current_streak_face:
            streak_length += 1

When we find a coin that doesn't match the current streak, we have to start the streak with one instance of the new face.

        else:
            current_streak_face = coin_face
            streak_length = 1

Of course, we have to initialize our state variables. The first coin won't match any previous value, so we should start off with some value which is neither heads nor tails.

    current_streak_face = None
    streak_length = 0

Using this, we can create a simple coin streak detector function:

def contains_a_streak(coin_tosses, minimum_length):

    current_streak_face = None
    streak_length = 0

    for coin_face in coin_tosses:
        if coin_face == current_streak_face:
            streak_length += 1
        else:
            current_streak_face = coin_face
            streak_length = 1

        if streak_length >= minimum_length:
            return True

    return False

Notice that since we are initialize the streak_length to 1 when we find a different coin face, and adding 1 when we find a matching face, our streak_length counter is actually the length of the streak, and not one less. No more 5 -vs- 6, confusion, which is a huge win for clarity.

Actually, there is nothing about this detector that is specific to coin tosses. We could use it for dice rolls, win-loss streaks, and so on. Just need to change some variable names ... and change the initial value from None to a different sentinel, so it could even properly detect a streak of None values at the start of a sequence of values.

def contains_a_streak(iterable, minimum_length):

    current = object()   # a unique value that can't possibly match this first
    streak_length = 0

    for value in iterable:
        if current == value:
            streak_length += 1
        else:
            current = value
            streak_length = 1

        if streak_length >= minimum_length:
            return True

    return False

Now, our code for one experiment could become:

def coin_toss_experiment(number_of_tosses, streak_length):

    tosses = []
    for _ in range(number_of_tosses):
        tosses.append(toss())

    return contains_a_streak(tosses, streak_length)

As noted elsewhere, the list initialization and repeated appending could be replaced with list comprehension:

def coin_toss_experiment(number_of_tosses, streak_length):

    tosses = [toss() for _ in range(number_of_tosses)]

    return contains_a_streak(tosses, streak_length)

(Actually, a generator expression might be even better, but since we're focusing at the level, we'll just note it in passing. When you're a bit more comfortable with Python, look up what it is and what it would do for you, and why you might want to use one.)

We need to run multiple experiments to compute the streak success rate:

def repeated_coin_toss_experiment(num_experiments, num_tosses, streak_length):
    successes = 0
    for _ in range(num_experiments):
        if coin_toss_experiment():
            successes += 1

    print(f"Chance of streak: {successes/num_experiments*100:.2f}%")

Finally, we need to run our experiment:

if __name__ == '__main__':
    repeated_coin_toss_experiment(10_000, 100, 6)

If you want to change the number of tosses, you only have to change one number. If you want to change the number of experiments, again, you just have to change one number. Change the streak length? Well, you get the idea.

\$\endgroup\$
1
  • \$\begingroup\$ A few comments: 1) "Of course, we have to initialize our state variables" - You don't need to initialize streak_length there [*]. 2) "object() [...] can't possibly match" - It can. 3) For minimum_length=0 the result should always be True, but for an empty iterable you produce False. 4) Better do 100*successes/num_experiments to get the most accurate result. \$\endgroup\$ Commented Aug 18, 2020 at 23:20
2
\$\begingroup\$

numberOfStreaks is a misleading variable name. You even managed to make @AJNeufeld claim that you're not checking whether a streak occurred but that you're counting the number of streaks (possibly multiple per experiment) and thus compute the wrong thing. But you stop at the first streak in each experiment, so you're doing the right thing. A better name would be experiments_with_streak, as that's what you're really counting.

As AJNeufeld pointed out, you misrepresent the result, showing about 0.8% instead of about 80%. Now the 80% means that most experiments have streaks. Probably on average somewhere in the middle. So it's wasteful to compute 100 tosses if you actually don't use the last few dozen. Also, you don't always need to follow the letter of the task (though that is advantageous for clarity) as long as you get the right result. In this case, instead of 100 tosses of heads/tails, you could look at 99 tosses of same/different (as the coin before). It can make the code a bit simpler. Only 99 because the first coin doesn't have one before.

Putting these observations into code (also incorporating some of AJNeufeld's points):

import random

NUM_EXPERIMENTS = 10_000

experiments_with_streak = 0

for _ in range(NUM_EXPERIMENTS):
    streak = 0
    for _ in range(99):
        same = random.choice((True, False))
        streak = streak + 1 if same else 0
        if streak == 5:
            experiments_with_streak += 1
            break

print('Chance of streak: %.2f%%' % (100 * experiments_with_streak / NUM_EXPERIMENTS))

Finally let me have some fun with a for-loop-less solution that even allows me to use statistics.mean so I don't have to repeat the number of experiments:

from random import choices
from statistics import mean

chance = mean('s' * 5 in ''.join(choices('sd', k=99))
              for _ in range(10000))

print('Chance of streak: %.2f%%' % (100 * chance))
\$\endgroup\$
2
\$\begingroup\$

After the many hints @AJNeufeld already gave you (PEP-8, conventions for naming, constants in UPPERCASE etc.), here is advice targeted on a different level.

Programming in Python often benefits from the work of others; in other words, you do not have to reinvent the wheel. If you choose the right data format for your problem, very often there is either a built-in method or a module which you can import to do the work. This has several benefits:

  • It's faster and/or much more optimized than freshly written code.
  • While not important for each and every program, with fast code you can scale more easily.
  • Re-used code has been debugged a lot of times before, by different people, so there is a high chance that it will work as expected (esp. with regards to corner cases).
  • Your program becomes more compact, for better overview and maintainability.
import random

def main():
    # declare constants
    NUM_EXPERIMENTS = 10000
    SEQLEN = 100
    STREAKLEN = 6

    streaks = 0
    for _ in range(NUM_EXPERIMENTS):
        # create a random sequence of length SEQLEN
        # this IS the experiment of coin tosses
        seqlist = [random.choice('HT') for _ in range(SEQLEN)]

        # convert list to string for easier searching
        seq = ''.join(seqlist)

        # if a streak of H's or T's occurs, the experiment is positive...
        if seq.count('H'*STREAKLEN) > 0 or seq.count('T'*STREAKLEN) > 0:
            streaks += 1
            # ... and we can stop searching & continue with the next
            continue

    print('probability: {0:.2f} %'.format(100.0*streaks/NUM_EXPERIMENTS))


if __name__ == '__main__':
    main()

Remarks:

  1. As you already make use of the random module, why not check the other module functions to see if one of them can generate a random sequence of characters of length seqlen directly? random.choice does that.

  2. The right data format: looking for subsequences lends itself to string comparison. Your random sequence is a list. The next line converts a list to a string. As the 2 values are characters already, and we want to search for substrings, having a method string.count() is very convenient. It counts the number of occurrences of a substring within a string.

  3. Now we only need to check if a streak is found, increment the streak counter and continue with the next experiment.

  4. To print the percentage, we have to multiply the division by 100.

What is gained? Using built-in functions is nearly always much faster than using an explicit loop, especially as Python is an interpreted language. Sometimes, choosing a different data format may offer you one of those built-in methods which would not be applicable with the original format.

So converting from the original list to string in your code enables you to use the built-in str.count() method which takes care of scanning the sequence, keeping a count on the match length etc., all within an embedded loop.

\$\endgroup\$
8
  • 1
    \$\begingroup\$ This is an alternate solution, but does not even superficially review the OP's code, and is therefore not a valid answer by the rules of this site, and is subject to deletion. \$\endgroup\$
    – AJNeufeld
    Commented Aug 15, 2020 at 20:48
  • 1
    \$\begingroup\$ seqlist always has exactly 50 heads & 50 tails, which is not correct for a random sequence of 100 flips. \$\endgroup\$
    – AJNeufeld
    Commented Aug 15, 2020 at 20:50
  • 1
    \$\begingroup\$ @AJNeufeld: I can't really follow your first remark, as I've worked closely along OP's code in the sense of refactoring it. Pointing out alternative programming principles were meant as useful hints for a learner. Your second remark IMHO is fully valid and is an oversight on my side. My code will produce wrong results for sure. Shall I edit it? \$\endgroup\$ Commented Aug 15, 2020 at 21:31
  • 1
    \$\begingroup\$ "Here is a different approach to your problem..." doesn't describe why the OP's code needs a different approach. The OP is left to look at the two approaches and guess at the reasoning behind the differences. Yes, you described things that you did, and why you did them, but not what was wrong with what they did. Compare your answer with mine. (I'll leave you to figure out the differences, instead of spelling it out.). But yes, edit your answer, and actually add a REVIEW, and I'll retract my downvote. \$\endgroup\$
    – AJNeufeld
    Commented Aug 16, 2020 at 0:48
  • 1
    \$\begingroup\$ Why are you still using Python 2? \$\endgroup\$ Commented Aug 16, 2020 at 13:47
1
\$\begingroup\$

You are correct.

However, your code is not very pythonic and the number of trials you want to do is hardcoded causing you to change it in multiple places whenever you want to change it.

for flipCoin in range(100):
        listOf100.append(toss())

Can be replaced with a list comprehension.

listOf100 = [toss() for _ in range(100)]

from there you could use a functional approach to the problem, thus making your script:

from functools import reduce
import random

numberOfStreaks = 0
trialCount = 1000


def toss():
    flip = random.randint(0, 1)
    if flip == 0:
        return 'H'
    else:
        return 'T'


def updateStreak(streakState, nextValue):
    currentStreak, currentMaxStreak, lastValue = streakState
    if nextValue == lastValue:
        return (currentStreak + 1, currentMaxStreak, nextValue)
    else:
        return (1, max(currentStreak, currentMaxStreak), nextValue)


for experiment in range(trialCount):
    l = [toss() for _ in range(100)]
    currentStreak, maxStreak, _ = reduce(updateStreak, l, (0, 0, ''))
    if max(currentStreak, maxStreak) >= 6:
        numberOfStreaks += 1
print('Chance of streak: %s%%' % (numberOfStreaks / trialCount))

Google 'funcitonal programming in python' to learn more about each of the new functions I've shown you

\$\endgroup\$
1
  • \$\begingroup\$ @AJNeufeld - My bad, it's now edited and tested so will run :) \$\endgroup\$
    – Edward
    Commented Aug 16, 2020 at 7:34

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.