7 Guess the Number: AI takes a turn

Let us program Guess the Number game again19 but reverse the roles. Now you will pick a number and the computer will guess. Think about the algorithm that a computer could use for this before reading the next paragraph20.

The optimal way to do this is to use the middle of the interval for a guess. This way you rule out half the numbers that are either greater or smaller than your guess (or you guess the number correctly, of course). So, if you know that the number is between 1 and 10, you should split things in the middle, that is picking 5 or 6, as you cannot pick 5.5 (we assume that you can use only integers). If your opponent tells that their number is greater than your pick, you know that it must be somewhere between your guess and the original upper limit, e.g., between 5 and 10. Conversely, if the opponent responds “lower”, the number is the lower limit and your guess, e.g., between 1 and 5. On your next attempt, you pick split the new interval and repeat this until you either guess the number correctly or end up with an interval that contains just one number. Then you do not need to guess anymore.

To implement this program, you will need to learn about functions, how to document them, and how to use your own libraries. Grab the exercise notebook before we start!

7.1 Chapter concepts.

7.2 Player’s response

Let us warm up by writing a code that will allow a player to respond to computer’s guess. Recall that there are just three options: your number is greater, smaller, or equal to a computer’s guess. I would suggest using >, <, and = symbols to communicate this. You need to write the code that will prompt a player for their response until they enter one of these symbols. I.e., the prompt for input should be repeated if they enter anything else. Thus, you definitely need to use the input([prompt]) and a while loop. Think of a useful and informative prompt message for this. Test that it works. Using breakpoints might be very useful here.

Put your code into code01.py.

7.3 Functions

You already now how to use functions, now it is turn for you to learn more about why you should care. The purpose of a function is to isolate certain code that performs a single computation making it testable and reusable. Let us go through the last sentence bit by bit using examples.

7.3.1 Function performs a single computation

I already told you that reading code is easy because every action has to be spelled-out for computers in a simple and clear way. However, a lot of simple things can be very overwhelming and confusing. Think about the final code for the previous seminar: we had two loops with conditional statements nested inside. Add a few more of those and you have so many branches to trace, you never be quite sure what will happen. This is because our cognition and working memory, which you use to trace all branches, are limited to just about four items21.

Thus, a function should perform one computation / action that is conceptually clear and those purpose should be understood directly from its name or, at most, from a single sentence that describes it22. The name of a function should typically be a verb because function is about performing an action. If you need more than once sentence to explain what function does, you should consider splitting the code further. This does not mean that entire description / documentation must fit into a single sentence. The full description can be lengthy, particularly if underlying computation is complex and there are many parameters to consider. However, these are optional details that tell the reader how the function is doing its job or how its behavior can be modified. Still, they should be able to understand what the job is just from the name or from a single sentence. I am repeating myself and stressing this so much because conceptually simple single-job functions are a foundation of a clear robust reusable code. And future-you will be very grateful that it has to work with easy-to-understand isolated reliable code you wrote.

7.3.2 Function isolates code from the rest of the program

Isolation means that your code runs in a separate scope where the only things that exist are function arguments (limited number of values you pass to it from outside with fixed meaning) and local variables that you define inside the function. You have no access to variables defined in the outside script23 or to variables defined inside of other functions. Conversely, neither global script nor other functions have access to variables and values that you use inside. This means that you only need to study the code inside the function to understand how it works. Accordingly, when you write the code it should be independent of any global context the function can be used in. The isolation is both practical (no run-time access to variables from outside means fewer chances that things go terribly wrong) and conceptual (no further context is required to understand the code).

7.3.3 Function makes code easier to test

You can build even moderately complex programs only if you can be certain what individual chunks of code are doing under every possible condition. Do they produce the correct results? Do the fail clearly and raise a correct error, if the inputs are wrong? Do they use defaults when required? However, testing all chunks together means running extreme number of runs as you need to test all possible combinations of conditions for one chunk given all possible conditions for other chunk, etc. Functions make your life much easier. Because they have a single point of entry, fixed number of parameters, a single return value, and are isolated (see above), you can test them one at a time independent of other functions and the rest of the code. This is called unit testing and it is a heavy use of automatic unit testing24 that ensures reliable code for absolute majority of programs and apps that you use25.

7.3.4 Function makes code reusable

Sometimes, this is given as a primary reason to use functions. Turning code into a function means that you can call the function instead of copy-pasting the code. The latter approach is a terrible idea as it means that you have to maintain the same code at many places and you might not be even sure in just how many. This is a problem even if a code is extremely simple. Here, we define a standard way to compute an initial by taking the first symbol from a string (you will learn about indexing and slicing later). The code is as simple as it gets.

...
initial = "test"[0]
...
initial_for_file = filename[0]
...
initial_for_website = first_name[0]
...

Imagine that you decided to change it and use first two symbols. Again, the computation is not complicated, use just replace [0] with [:2]. But you have to do it for all the code that does this computation. And you cannot use Replace All option because sometimes you might use the first element for some other purposes. And when you edit the code, you are bound to forget about some locations (I do it all the time) making things even less consistent and more confusing. Turning code into a function means you need to modify and test at just one location. Here is the original code implemented via a function.

def generate_initial(full_string):
    """Generate an initial using first symbol.
    
    Parameters
    ----------
    full_string : str
    
    Returns
    ----------
    str : single symbol
    """
    return full_string[0]

...
initial = generate_initial("test")
...
initial_for_file = generate_initial(filename)
...
initial_for_website = generate_initial(first_name)
...

and here is the “alternative” initial computation. Note that the code that uses the function stays the same

def generate_initial(full_string):
    """Generate an initial using first TWO symbols.
    
    Parameters
    ----------
    full_string : str
    
    Returns
    ----------
    str : two symbols long
    """
    return full_string[:2]

...
initial = generate_initial("test")
...
initial_for_file = generate_initial(filename)
...
initial_for_website = generate_initial(first_name)
...

Thus, turning the code into a function is particularly useful when the reused code is complex but it pays off even if computation is as simple and trivial as in example above. With a function you have a single code chunk to worry about and you can be sure that the same computation is performed whenever you call the function (and that these are not several copies of the code that might or might not be identical).

Note that I put reusable code as the last and the least reason to use functions. This is because the other three reasons are far more important. Having a conceptually clear isolated and testable code is advantageous even if you call this function only once. It still makes code easier to understand and to test and helps you to reduce its complexity by replacing chunks of code with its meaning. Take a look at the example below. The first code takes the first symbol but this action (taking the first symbol) does not mean anything by itself, it is just a mechanical computation. It is only the original context initial_for_file = filename[0] or additional comments that give it its meaning. In contrast, calling a function called compute_initial tells you what is happening, as it disambiguates the purpose. I suspect that future-you is very pro-disambiguation and anti-confusion.

if filename[0] == "A":
    ...
    
if compute_initial(filename) == "A":
    ...

7.4 Functions in Python

7.4.1 Defining a function in Python

A function in Python looks like this (note the indentation and : at the end of the first line)

def <function name>(param1, param2, ...):
    some internal computation
    if somecondition:
        return some value
    return some other value

The parameters are optional, so is the return value. Thus, the minimal function would be

def minimal_function():
    pass # pass means "do nothing"

You must define your function (once!) before calling it (one or more times). Thus, you should create functions before the code that uses it.

def do_something():
    """
    This is a function called "do_something". It actually does nothing.
    It requires no input and returns no value.
    """
    return
    
def another_function():
    ...
    # We call it in another function.
    do_something()
    ...

# This is a function call (we use this function)
do_something()

# And we use it again!
do_something()

# And again but via another_function call
another_function()

Do exercise #1.

You must also keep in mind that redefining a function (or defining a technically different function that has the same name) overwrites the original definition, so that only the latest version of it is retained and can be used.

Do exercise #2.

Although example in the exercise makes the problem easy to spot, in a large code that spans multiple files and uses various libraries, solving the same problem may not be so straightforward!

7.4.2 Function arguments

Some functions may not need arguments (also called parameters), as they perform a fixed action:

def ping():
    """
    Machine that goes "ping!"
    """
    print("ping!")

However, you may need to pass information to the function via arguments in order to influence how the function performs its action. In Python, you simply list arguments within the round brackets after the function name (there are more bells and whistles but we will keep it simple for now). For example, we could write a function that computes and prints person’s age given two parameters 1) their birth year, 2) current year:

def print_age(birth_year, current_year):
    """
    Print age given birth year and current year.
    
    Parameters
    ----------
    birth_year : int
    current_year : int
    """
    print(current_year - birth_year)

It is a very good idea to give meaningful names to functions, parameters, and variables. The following code will produce exactly the same result but understanding why and what for it is doing what it is doing would be much harder (so always use meaningful names!):

def x(a, b):
    print(b - a)

When calling a function, you must pass the correct number of parameters and pass them in a correct order, another reason for a function arguments to have meaningful names26.

Do exercise #3.

When you call a function, values you pass to the function are assigned to the parameters and they are used as local variables (more on local bit later). However, it does not matter how you came up with this values, whether they were in a variable, hard-coded, or returned by another function. If you are using numeric, logical, or string values (immutable types), you can assume that any link to the original variable or function that produced it is gone (we’ll deal with mutable types, like lists, later). Thus, when writing a function or reading its code, you just assume that it has been set to some value during the call and you can ignore the context in which this call was made

# hardcoded
print_age(1976, 2020)

# using values from variables
i_was_born = 1976
today_is = 2023
print_age(i_was_born, today_is)

# using value from a function
def get_current_year():
    return 2023

print_age(1976, get_current_year())

7.4.3 Functions’ returned value (output)

Your function may perform an action without returning any value to the caller (this is what out print_age function was doing). However, you may need to return the value instead. For example, to make things more general, we might want write a new function called compute_age that returns the age instead of printing it (we can always print it ourselves).

def compute_age(birth_year, current_year):
    """
    Computes age given birth year and current year.

    Parameters
    ----------
    birth_year : int
    current_year : int
    
    Returns
    ----------
    int : age
    """
    return current_year - birth_year

Note that even if a function returns the value, it is retained only if it is actually used (stored in a variable, used as a value, etc.). Thus, just calling it will not by itself store the returned value anywhere!

Do exercise #4.

7.4.4 Scopes (for immutable values)

As we have discussed above, turning code into a function isolates it, so makes it run in it own scope. In Python, each variable exists in a scope it has been defined in. If it was defined in the global script, it exists in that global scope as a global variable. However, it is not accessible (at least not without special effort via a global operator) from within a function. Conversely, function’s parameters and any variables defined inside a function, exists and are accessible only inside that function. It is fully invisible for the outside world and cannot be accessed from a global script or from another function. Conversely, any changes you make to the function parameter or local variable have no effect on the outside world.

The purpose of scopes is to isolate individual code segments from each other, so that modifying variables within one scope has no effect on all other scopes. This means that when writing or debugging the code, you do not need to worry about code in other scopes and concentrate only on the code you working on. Because scopes are isolated, they may have identically named variables that, however, have no relationship to each other as they exists in their own parallel universes27. Thus, if you want to know which value a variable has, you must look only within the scope and ignore all other scopes (even if the names match!).

# this is variable `x` in the global scope
x  = 5 

def f1():
  # This is variable `x` in the scope of function f1
  # It has the same name as the global variable but
  # has no relation to it: many people are called Sasha 
  # but they are still different people. Whatever
  # happens to `x` in f1, stays in f1's scope.
  x = 3
  
  
def f2(x):
  # This is parameter `x` in the scope of function f2.
  # Again, no relation to other global or local variables.
  # It is a completely separate object, it just happens to 
  # have the same name (again, just namesakes)
  print(x)

Do exercise #5.

7.5 Player’s response as a function

Let us put all that theory about functions into practice. Use the code that you created to acquire player’s response and turn it into function. It should have no parameters (for now) and should return player’s response. I suggest that you call it input_response (or something along these lines). Test that the code works by calling this function for the main script.

Put your code into code02.py.

7.6 Debugging a function

Now that you have your first function, you can make sense of three step over/step in/step out buttons that the debugger offers you. Copy-paste the following code in a separate file (call it test01.py, for example).

def f1(x, y):
  return x / y
  
def f2(x, y):
  x = x + 5
  y = y * 2
  return f1(x, y)
  
z = f2(4, 2)
print(z)

First, put a break point on the line in the main script that calls function f2(). Run the debugger via F5 and the program will pause at that line. If you now press F10 (step over), the program will go to the next line print(z). However, if you are to press F11 (step into) instead, the program will step into the function and go to x = x + 5 line. When inside the function, you have the same two choices we just looked at but also, you can press Shift+F11 to step out of the function. Here, the program will run all the code until you reach the next line outside of the function (you should end up at print(z) again). Experiment with putting breakpoints at various lines and stepping over/in/out to get a hang of these useful debugging tools.

Now, put the breakpoint inside of f1() function and run the code via F5. Take a look at the left pane, you will see a Call Stack tab. While yellow highlighted line in the editor shows you where you currently are (should be inside the f1() function), the Call Stack shows you how did you get where. In this case it should show:

f1 test01.py 2:1
f2 test01.py 7:1
<module> test01.py 9:1

The calls are stacked from bottom to top, so this means that a function was called in the main module in line 9, you ended up in function f2 in line 7, and then in function f1 and in line 2. Experiment with stepping in and out of functions while keeping an eye on this. You might not need this information frequently but could be useful in our later projects with multiple nested function calls.

7.7 Documenting your function

Writing a function is only half the job. You need to document it! Remember, this is a good habit that makes your code easy to use and reuse. There are different ways to document the code but we will use NumPy docstring convention. Here is an example of such documented function

def generate_initial(full_string):
    """Generate an initial using first symbol.
    
    Parameters
    ----------
    full_string : str
    
    Returns
    ----------
    str : single symbol
    """
    return full_string[0]

Take the look at the manual and document the input_response function. You will not need the Parameters section as it currently accepts no inputs.

Update your code in code02.py.

7.8 Using prompt

In the future, we will be asking about a specific number that is a current guess by the computer, thus we cannot use a fixed prompt message. Modify the input_response function by adding a guess parameter. Then, modify the prompt that you used for the input() to include the value in that parameter. Update functions’ documentation. Test it by calling with different values for the guess parameter and seeing a different prompt for response.

Put your code into code03.py.

7.9 Splitting interval in the middle

Let us practice writing functions a bit more. Recall that the computer should use the middle of the interval as a guess. Create a function (let us call it split_interval() or something like that) that takes two parameters — lower_limit and upper_limit — and returns an integer that is closest to the middle of the interval. The only tricky part is how you convert a potentially float number (e.g, when you are trying to find it for the interval 1..10) to an integer. You can use function int() for that. However, read the documentation carefully, as it does not perform a proper rounding (what does it do? read the docs!). Thus, you should round() the number to the closest integer before converting it.

Write a function, document it, and test it by checking that numbers are correct.

Put you split_interval() function and the testing code into code04.py.

7.10 Single round

You have both functions that you need, so let us write the code to initialize the game and play a single round. The initialization boils down to creating two variables that correspond to the lower and upper limits of the game range (we used 1 to 10 so far, but you can always change that). Next, the computer should generate a guess (you have your split_interval() function for that) and ask the player about the guess (that is the input_response() function). Once you have the response (stored in a separate variable, think of the name yourself), update either upper or lower limit using an if..elif..else statement based on player’s response (if the player said that their number is higher, that means the new interval is from guess to upper_limit, and vice versa for when it is lower). Print out a joyous message, if computer’s guess was correct.

Put both functions and the script code into code05.py.

7.11 Multiple rounds

Extend the game, so that the computer keeps guessing until it finally wins. You already know how to use the while loop, just think how you can use participant’s response as a loop condition variable. Also, think about the initial value of that variable and how to use it so you call input_response() only at one location.

Put the updated code into code06.py.

7.12 Playing again

Modify the code, so that you can play this game several times. You already know how to do this and the only thing you need to consider is where exactly should you perform initialization before each game. As you already implemented that for the last game, you might be tempted to look how you did it or, even, copy-paste the code. However, I would recommend writing it from scratch. Remember, your aim is not to write a program but to learn how to do this and, therefore, the journey is more important than a destination.

Put the updated code into code07.py.

7.13 Best score

Add the code to count the number of attempts that the computer required in each round and report the best score (fewest number of attempts) after the game is over. You will need one variable to count the number of attempts and one to keep the best score. Again, try writing it without looking at your previous game.

Put the updated code into code08.py.

7.14 Using you own libraries

You already know how to use existing libraries but you can also create and use your own. Take the two functions that you developed and put them into a new file called utils.py (do not forget to put a multiline comment at the top of the file to remind you what is inside!) . Copy the remaining code (the global script) into code09.py. It will not work in its current state as it won’t find the two functions (try it to see the error message), so you need to import from your own utils module. Importing works exactly the same way as for other libraries. Note that even though your file is utils.py, the module name is utils (without the extension).

Put function into utils.py, the remaining code into code09.py.

7.15 Ordnung muss sein!

So far, you only imported one library at most. However, as Python is highly modular, it is very common to have many imports in a single file. There are several rules that make it easier to track the imports. When you import libraries, all import statements should be at the top of your file and you should avoid putting them in random order. The recommended order is 1) system libraries, like os or random; 2) third-party libraries, like psychopy; 3) your project modules. And, within each section you should put the libraries alphabetically, so

import os
import random

This may not look particularly useful for our simple code but as your projects will grow, you will need to include more and more libraries. Keeping them in that order makes it easy to understand which libraries you use and which are non-standard. Alphabetic order means that you can quickly check whether a library is included, as you can quickly find the location where its import statement should appear.

7.16 Putting video into videogames

Submit your files and be ready for more excitement as we are moving onto “proper” videogames with PsychoPy.