Introduction to Python (tutorial)#

In this notebook, we will introduce you to the basics of programming in Python. We will explain how to work with Jupyter notebooks, contrast “interactive programming” with “scripting”, and introduce you to the concept of object-oriented programming. Importantly, we believe that programming is best learned by doing it, so this notebook will feature a lot of (ungraded) exercises. Because students often differ in how much experience they have with programming, the exercises are often relatively easy. However, to challenge those more familiar with programming, we also include (optional!) more difficult exercises. The concepts discussed in these exercises will not be featured on the exam and/or graded assignments.

Contents#

  1. Jupyter notebooks

  2. Interactive vs. script mode

  3. Object-oriented programming

Estimated time to complete: 1-3 hours

Jupyter notebooks#

So, what are Jupyter notebooks, actually? Basically, using Jupyter notebooks is like using the web-browser as a kind of editor from which you can run Python code, similar to the MATLAB interactive editor or RStudio. Just like any editor, code in Jupyter notebooks is interpreted and executed by Python on your computer (or on a remote server) and their results are returned to the notebook for display.

The cool thing about these notebooks is that they allow you to mix code “cells” (see below) and text “cells” (such as this one). The (printed) output from code blocks are displayed right below the code blocks themselves.

Jupyter notebooks have two modes: edit mode and command mode.

  • Command mode is indicated by a grey cell border with a blue left margin (as is the case now!): When you are in command mode, you are able to edit the notebook as a whole, but not type into individual cells. Most importantly, in command mode, the keyboard is mapped to a set of shortcuts that let you perform notebook and cell actions efficiently (some shortcuts in command mode will be discussed later!). Enter command mode by pressing Esc or using the mouse to click outside a cell’s editor area;

  • Edit mode is indicated by a green cell border and a prompt showing in the editor area: When a cell is in edit mode, you can type into the cell, like a normal text editor. Enter edit mode by pressing Enter or using the mouse to double-click on a cell’s editor area.

When you’re reading and scrolling through the tutorials, you’ll be in the command mode mostly. But once you have to program (or write) stuff yourself, you have to switch to edit mode. But we’ll get to that. First, we’ll explain something about the two types of cells: code cells and text cells.

Code cells#

Code cells are the place to write your Python code, similar to MATLAB ‘code sections’ (which are usually deliniated by %%). Importantly, unlike the interactive editors in RStudio and MATLAB, a code cell in Jupyter notebooks can only be run all at once. This means you cannot run it line-by-line, but you have to run the entire cell!

Running cells#

Let’s look at an example. Below, you see a code-cell with two print-statements. To run the cell, select it (i.e., the cell should have a green or blue frame around it; doesn’t matter whether you’re in edit or command mode), and click on the “▶ Run” icon or press ctr+Enter). Try it with the cell below!

print("I'm printing Python code")
print(3 + 3)
I'm printing Python code
6

Note that you cannot run the two print statements separately! (Unless you put them in two separate cells.) Also, in the above cell, we’re explicitly printing the results of the code. By default, code cells always print the last line (if it’s not a variable assignment), even if you don’t explicitly call print:

first_line = "This is not printed"
last_line = "But this is!"
last_line
'But this is!'

In your own code, if you want to see the results of your code, we recommend always using print statements!

Stop running/execution of cells#

Sometimes, you might want to quit the execution of a code-cell because it’s taking too long (or worse, you created an infinite loop!). To do so, click the stop icon ■ in the top menu!

Restarting the kernel#

Sometimes, you accidentally ‘crash’ the notebook, for example when creating an infinite loop or when loading in too much data. You know your notebook ‘crashed’ when stopping the cell (■) does not work and your cell continues its execution, as evident by the In [*]: prompt next to the cell. In those cases, you need to completely restart the notebook, or in programming lingo: you need to “restart the kernel”. To do so, click Kernel and Restart.

Importantly, when you restart the kernel, it will keep all text/code that you’ve written, but it will not remember all the variables that you defined before restarting the kernel, including the imports. So if you restart the kernel, you will have to re-import everything (e.g. run import numpy as np again).

Inserting cells#

As you can see in the code cell above, you can only run the entire cell (i.e. both print statements). Sometimes, of course, you’d like to organise code across multiple cells. To do this, you can simply add new blocks (cells) by selecting “Insert → Insert Cell Below” on the toolbar (or use the shortcut by pressing the “B” key when you’re in command mode; “B” refers to “below”). This will insert a new code cell below the cell you have currently highlighted (the currently highlighted cell has a blue box around it).

ToDo: Try inserting a cell below and write some code, e.g., print(10 * 10).

Inline plotting#

Another cool feature of Jupyter notebooks is that you can display figures in the same notebook! You simply define some plots in a code cell and it’ll output the plot below it.

Check it out by executing (click the “play” button or ctr+Enter) the next cell.

# We'll get to what the code means later in the tutorial
import matplotlib.pyplot as plt # The plotting package 'Matplotlib' is discussed in section 3!

# Now, let's plot something
plt.plot(range(10))
plt.show()
../../_images/684458d2990778e9765f6ec009645ea36342accf56cbd5ef12db6a4470444554.png

We’ll discuss plotting in detail in a future tutorial!

Text (“markdown”) cells#

Next to code cells, jupyter notebooks allow you to write text in so-called “markdown cells” (the cell this text is written in is, obviously, also a markdown cell). Markdown cells accept plain text and can be formatted by special markdown-syntax. A couple of examples:

# One hash creates a large header
## Two hashes creates a slightly smaller header (this goes up to four hashes)

Bold text can be created by enclosing text in **double asterisks** and italicized text can be created by enclosing text in *single asterisks*. You can even include URLs and insert images from the web; check this link for a cheatsheet with markdown syntax and options! All the special markdown-syntax and options will be converted to readable text after running the text cell (again, by pressing the “play” icon in the toolbar or by ctr+Enter).

To insert a text (markdown) cell, insert a new cell (“Insert → Insert Cell Below” or “B” in command mode). Then, while highlighting the new cell, press “Cell → Cell Type → Markdown” on the toolbar on top of the notebook (or, while in command mode, press the “m” key; “m” refers to “markdown”). You should see the prompt (the In [ ] thingie) disappear. Voilà, now it’s a text cell!

ToDo: Try it out yourself! Insert a new markdown cell and try to write the following (without peeking at this cell!):

OMG, this is the most awesome Python tutorial ever.

Changing cell type#

Sometimes you might accidentally change the cell type from “code” to “markdown”. To change it back, you can click “Cell” → “Cell Type” → “Code” or “Markdown” (or use the shortcuts, in command mode, “m” for markdown or “y” for code).

ToDo: Try it out yourself! Change the code cell, which contains markdown, into a markdown cell!
This is a code cell, but it contains markdown - **oh no**!
  Cell In[4], line 1
    This is a code cell, but it contains markdown - **oh no**!
              ^
SyntaxError: invalid syntax

Getting help#

Throughout this course, you’ll encounter situations in which you have to use functions that you’d like to get some more information on, e.g. which inputs it expects. To get help on any python function you’d like to use, you can simply write the function-name appended with a “?” and run the cell. This will open a window below with the “docstring” (explanation of the function). Take for example the built-in function len(). To get some more information, simply type len? in a code cell and run the cell.

ToDo: Try it out yourself: create a code cell below, type len? and check the output!

If this method (i.e. appending with “?”) does not give you enough information, try to google (or just whatever search engine) the name of the function together with ‘python’ and, if you know from which package the function comes, the name of that package. For instance, for len() you could google: ‘python len()’, or later when you’ll use the numpy package, and you’d like to know how the numpy.arange function works you could google: “python numpy arange”.

Tip: Google is your friend! Googling things is an integral aspect of programming. If you're stuck, try to figure it out yourself by trying to find the solution online. At first, this might seem frustrating, but in the long run, it will make you a better programmer.

Saving your work & closing/shutting down the notebook#

You’re running and actively editing the notebook in a browser, but remember that it’s still just a file located on your account on your laptop (or server). Therefore, for your work to persist, you need to save the notebook once in a while (and definitely before closing the notebook). To save, simply press the floppy-disk image in the top-left (or do ctr+s while in command mode).

If you saved your notebook and want to close it, click “File” → “Close and halt”. This will stop your notebook from running and close it.

Executing order and overwriting variables#

Jupyter notebooks are meant to be run from top to bottom. You can, of course, go back and work on a previous section. But beware: variables from later sections (i.e., more towards the bottom of the notebook) might overwrite previously defined ones! For example, suppose that we define a particular variable y to be 5 (note that assignment in Python is done using the = operator):

y = 5
print("Is y equal to 5?", (y == 5))
Is y equal to 5? True

Now, in a later part we might again define a variable y, but this time we define it to be 6.

y = 6

If we would rerun (i.e., “go back”) to the cell with the print statement (try this!), suddenly, it will print:

Is y equal to 5? False

This is why the order of executing the cells matter! The notebooks are designed to be run from top to bottom. So, if you encounter situations where you get errors because your variable suddenly seemed to have changed (where it used to work just fine), it might be due to variables being overwritten by code in later sections.

If this happens, we recommend clearing and restarting your kernel (the thing that executes your code) by Kernel → Restart & Clear Output (in the toolbar on top), and rerun all your cells from top to bottom.

Exercises (ToDo/ToThink)#

We believe that the best way to learn how to do neuroimaging analysis is by actually programming the analyses yourself. You have already seen some of these (ungraded) exercises, which we call “ToDos” (formatted as orange boxes): short programming exercises, which you get immediate feedback for using “tests” (more on this later). We highly recommend doing/answering the ToDos/ToThinks, because they are designed to improve your understanding of the material!

Sometimes, you also encounter Tips and Tricks, which may contain advice, more information on a specific topic, or links to relevant websites or material.

For example, a “ToDo” would look something like this:

ToDo: In the code-cell below, set the variable x to 5 (without quotes!) and remove the raise NotImplementedError statement.
Hide code cell content
""" Implement the ToDo here. """

x = 'not yet 5'
### BEGIN SOLUTION
x = 5
### END SOLUTION

Each answer-cell will contain the statement raise NotImplementedError. When implementing your ToDo, you always have to remove this statement, otherwise it will give an error when you run it.

Then, after each code-cell corresponding to the ToDo, there are one or more code cell(s) with “tests” written by us. These tests provide you with feedback about whether you implemented the “ToDo” correctly (“Well done!”), or, if it give an error or the wrong answer, will give you some hints on how to get it right. You can keep trying these ToDo exercises until your implementation passes the test cell(s)! If you don’t see any errors when running the test-cells, you did the ToDo correctly!

The tests associated with the ToDo exercises are usually implemented as a set of assert statements (or np.testing.assert_*, which ‘assert’ whether a specific statement is true. If it evaluates to False, then that means you’ve made an error and it will “crash” by raising a specific error (with optional hints on how to proceed). For example, for the above ToDo, we simply evaluated the statement assert(x == 5) to check whether you set x to 5 correctly, as is shown in the cell below:

""" Checks whether the above ToDo is correct. """
try:
    assert(x == 5)
except AssertionError as e:
    print("x does not evaluate to 5! Did you replace 'not yet 5' with 5?")
    raise(e)
else:
    print("Well done!")
Well done!

In contrast to ToDos, you don’t get any immediate feedback on ToThinks (because this cannot be automated). These ToThinks are designed to make you reflect on the study material.

Now, let’s try another ToDo. Note that it is followed by two test-cells, which test different aspects of your implementation.

ToDo: In the code-cell below, set the variable y to x * 2, so you have to replace None with x * 2 (yes, this is as easy as it sounds). Don't forget to remove the raise NotImplementedError.
Hide code cell content
""" Implement the ToDo here. """
x = 2
y = None
### BEGIN SOLUTION
y = x * 2
### END SOLUTION
""" This tests the above ToDo. """
try:
    assert(y == x * 2)
except AssertionError as e:
    print("y does not evaluate to x * 2 (i.e., 4)! Did you replace y with x * 2?")
    raise(e)
else:
    print("Correct!")
Correct!
""" This tests the above ToDo as well. """
try:
    assert(isinstance(y, int))
except AssertionError as e:
    print("y doesn't seem to be an integer (it is of type %s; make sure it is!" % type(y))
    raise(e)
else:
    print("Epic!")
Epic!

ToThink exercises need a written answer in the associated markdown cell. A typical ‘ToThink’ may look like this:

ToThink (1 point): What's your name? Please write it in the text-cell below.

Lukas Snoek

Make sure you actually write your name in the cell above! (You’ll have to double-click the text-cell to go into ‘edit mode’ and replace ‘YOUR ANSWER HERE’ with your name, and render the cell again by running it.)

Interactive vs. script mode#

Some programming languages, including Python, can be used interactively. Here, interactive means that subsets of code from a single file can be run one by one. Jupyter notebooks enable this way of programming by allowing users to split code across multiple cells (as you’ve seen in the previous section). Importantly, because all variables defined will be kept in memory until you close the notebook (i.e., when you stop the Python process), you can inspect the result of each step in your code.

To peek at all variables defined thus far, we can use the %whos “magic command”:

%whos
Variable     Type      Data/Info
--------------------------------
first_line   str       This is not printed
last_line    str       But this is!
plt          module    <module 'matplotlib.pyplo<...>es/matplotlib/pyplot.py'>
x            int       2
y            int       4

Note that magic commands are Jupyter notebook-specific functionality, so it won’t work in environments other than Jupyter notebooks!

Interactive programming is common in languages such as R and Matlab, whose popular editors (like RStudio and the default Matlab editor) by design encourage users to run their scripts line by line. This way of programming is great for teaching, as it allows for explanation of concepts in a step by step fashion. Similarly, interactive programming is great when troubleshooting (“debugging”) your code, exploring your data, or trying out different data visualizations.

However, this way of programming is not necessarily always the best way to write, organize, and run your code. For example, if you want to reuse your code across multiple projects or applications, you’d have to copy your code from one notebook to the other. Also, notebooks tend to become large and messy when the scope and complexity of projects increase. Fortunately, you can also store your code in regular Python files, i.e., any plain text file with the extension .py. These are analogous to R scripts (.R) and Matlab scripts (.m). Writing Python code in Python files is actually the way most people use Python!

Like Jupyter notebooks, Python files accept any valid Python code. We included an example script, example_file.py, in the current directory.

ToDo: Locate the example_file.py file in the file browser from the Jupyter interface. Click on it to open it and inspect its contents.

As you can see, this file contains a couple of comments (prefixed by #) and two lines of code: one defining a variable (message) pointing to a string ("Hi, welcome to introPy!") and one printing the variable.

Now, you might ask yourself, “how should these files by run?”. Usually, Python files are run (or “executed”) in a terminal. A terminal, or sometimes called a command line, is a tool to perform tasks on a computer without a graphical interface. Instead of a graphical interface, you type commands in the terminal to tell it what to do. Although each operating system has its own type of terminal (and way to open it), the Jupyter environment also comes with its own terminal implementation.

ToDo Open a terminal by clicking on FileNewTerminal in the upper right corner (in the classic interface) or upper left corner (in the JupyterLab interface).

This should open a new tab with a terminal. In the terminal, you can enter different commands. For example, to print something to the terminal window (similar to the Python print function), you can run the following command:

echo "Hey! I'm running stuff in the terminal. #lit"
ToDo Run the command above in the terminal!

In the above example, echo is a built-in command on most Linux and Mac systems. In addition to built-in commands, you can also enter Python commands to run Python files! To do so, your command should look something like:

python path/to/your/python_file.py

The path to your Python file should be specified relative to your location in the terminal. By default in this course, Jupyter terminals are openened at the root of the directory with course materials. So, to run the example_file.py file, we cannot enter python example_file.py, but we should instead enter the full path to the file:

python week_1/example_file.py
Note: While Mac and Linux systems (including JupyterHub environments) use forward slashes (/) as a "separator" symbol, Windows uses backslashes (\). So, on Windows, you'd need to run python week_1\example_file.py.
ToDo: Run the file example_file.py in your terminal!

Importantly, when running Python files, it is run as a whole; unlike R and Matlab scripts, Python files cannot be run line by line!

As you can see, this non-interactive way of programming — which we’ll call “script mode” for a lack of a better term — is quite different from interactive programming in Jupyter notebooks. Unlike in Jupyter notebooks, script mode strictly separates the process of writing code and running code. In this course, you will write code in both “interactive mode” (mostly week 1) and in “script mode” (mostly week 2).

Scripts vs. modules#

One last concept related to script mode we want to discuss is the distinction between Python “scripts” and Python “modules”. In short, any Python file (i.e., a file with the extension .py) can be either a script or a module, and whether it is one or the other depends on how you use the file. In short, scripts are Python files that contain code that actually does something (e.g., compute the average of a series of numbers, clean up a messy excel file, etc.) while modules only contain code that define things.

In the course materials, we have included a very simple script (example_script.py) and a simple module (example_module.py). The script computes the average of two (predefined) numbers (i.e., it does something). The module file only defines a function (average). Indeed, if you’d run the script, several things will be done (i.e., the average of a and b will be computed and the result will be printed), but if you’d run the module, nothing will happen.

ToDo: Run both files separately and confirm for yourself that nothing happens when running the module file (example_module.py).

It is important to realize that this distinction between scripts and modules is a conceptual difference, not a technical one. Python doesn’t care whether you think a file is a script or a module — it just sees and runs a .py file. Why would you care, then? Well, because it allows you to organize your code in a sensible and efficient manner, where modules may define operations that may be used across different scripts. In the example script and module discussed previously, we actually did something similar by “importing” functionality from the module in the script file! (Imports will be discussed at length in the next notebook.)

To give a more practical example, in my own research projects I often have a single analysis.py script which I run when I want to (re)run my analysis. This script uses functionality from different modules, such as preprocessing.py and visualization.py. This way, the analysis script stays relatively short and readable, abstracting away from the different operations defined in the modules.

Again, this distinction between scripts and modules is only conceptual. Nothing is stopping you from writing all your code in one single file! For example, the average function definition from the example_module.py module could also have been directly included in the example_script.py script; nothing wrong with that. But understanding the difference between these two types of files and organizing your code accordingly will undoubtely lead to cleaner, better readable code in the long run, especially for large projects!

Object-oriented programming#

Before you are going to delve more into actual programming in the next notebook, we need to discuss one more concept: object-oriented programming. Here, we will discuss the essentials of what you need to know for this course.

Object-oriented programming (OOP) is a particular programming paradigm. In such paradigms, “objects” are things that may contain data (called attributes) and functionality (called methods). Python is such an object-oriented programming language, as opposed to for example R and Matlab (although both feature some OOP functionality). Now, you may ask, “what are these objects, then?”

The answer is everything.

Everything in Python is an object. Every variable you define is pointing to a particular object in memory. For example, consider the following variable definition:

x = 'hello'

Here, x is an object! If we define a particular function (which will be explained in detail in the next notebook) …

def some_function(arg):
    return arg + 1

… then some_function is also an object!

Importantly, each object is of a certain class. An object’s class defines which attributes and which methods an object has. To find out an object’s class, you can use the function type:

type(x)
str

So, it appears that the class of x is str, which stands for “string”!

ToDo: find out the class of the function some_function
# Find out the class of `some_function` below!

To further explain the relation between objects and classes, consider the following analogy. One way to think about the relationship between an object and a class is as the relationship between a building and it’s (architectural) plan.

In other words, just like a building plan outlines how a building should be constructed, the class specifies how an object should be created. In programmer lingo, an object is always an instance of a class. Classes in Python define, basically, the attributes (i.e., things that an object is or has) and the methods (i.e., what each object can do). Importantly, all built-in data types in Python (like strings, integers, and lists) are in fact classes! In addition to built-in classes, you can also define your own custom classes! We will see an example of a custom class shortly.

Both attributes and methods can be accessed by the object appended with a period (.) and the name of the attribute/method. The only difference between attributes and methods is that methods are followed by round brackets, (), with optional arguments. For example, to access the __class__ attribute of the string variable x, we would run the following:

x.__class__
str

As you can see, this attribute of x returns the name of its class. (Note that you could achieve the same by running type(x)!) Think of attributes as things that describe properties of objects.

As mentioned before, methods represent things that objects can do. For example, all string objects (like x) have the method capitalize, which can be run as follows:

x.capitalize()
'Hello'

As you can see, this method does something, i.e., it capitalizes the first character of the string! Note the round brackets in the previous line of code! This shows us that capitalize is in fact a method, and not an attribute.

ToDo: In the cell below, try out the string method isdigit using the object x. Do you understand what it does?
# Try out the `isdigit` method below!

In the previous two examples (capitalize and isdigit), the methods do not take any inputs (or “arguments”). Some methods, however, need inputs. For example, the replace method of string objects takes two arguments: the first with the substring that should be replaced and the second with the substring that should replace the first one. An example:

x.replace('h', 'c')
'cello'

As you can see in the example above, methods are very much like functions in the sense that they (may) take some inputs and (may) return something. Importantly, often (but not always, as we’ll see in a minute) the results from the method operation are not automatically stored! So, if you want to store the result of a method call, assign the result to a (new) variable!

x_h2c = x.replace('h', 'c')
print("The variable x_h2c contains:", x_h2c)
The variable x_h2c contains: cello

Importantly, the x_h2c variable is now a new object, again from the string class!

type(x_h2c)
str

This is highlighting the fact about Python we mentioned earlier: everything is an object!

ToDo: In the code cell below, using the variable x, call the method islower and save the result in a new variable is_x_lowercase.
Hide code cell content
# Implement the ToDo here
### BEGIN SOLUTION
is_x_lowercase = x.islower()
### END SOLUTION
""" This cell tests your implementation above. """
# Test if variable is defined
if 'is_x_lowercase' not in locals():
    raise ValueError("Couldn't find the variable 'is_x_lowercase'; did you spell it correctly?")
else:
    # Test if the right method has been called
    if type(is_x_lowercase) != bool:
        raise ValueError("Hmmm, the output type is not what I expected (a boolean); did you "
                         "call the right method (islower)?")
    else:  # probably correct implementation then
        print("Yay! Well done.")
Yay! Well done.

Alright, although you now know how to access and use attributes and methods of existing objects, you might wonder how you can create objects. Creating objects actually looks a lot like calling a function (or method!):

  • you call a particular “class” (Someclass in the image below) …

  • … with, optionally, one or more arguments (arg1, arg2 in the image below) …

  • … which you assign to a new variable (my_var in the image below)

The new variable then represents the new object!

obj_class_diff

Most object are initialized in this way. To showcase this, we created a custom class, Person, which we defined in the utils.py file. We import the class below:

from utils import Person

Here, Person is like the Someclass element in the image above. Often, the class documentation tells us if the class should be initialized with any variables, and if so, which ones. One neat trick to peek at the code documentation of a particular class/function in Jupyter notebooks is to run the class/function appended with a question mark:

Person?

As you can see, the Person class needs two variables upon initialization: a name (a string) and an age (an integer or a float). Now, let’s initialize a Person object and store it in a variable named random_person:

random_person = Person('Lukas', 29)

Now, the random_person represents an object (from the class Person) with its own attributes and methods! Often, the names of the input arguments (here, “name” and “age”) are also bound to the object as attributes:

print(random_person.name)
print(random_person.age)
Lukas
29

To find out which (other) attributes and methods a particular object has, you can either look at the source code (i.e., looking at the utils.py file in this case) or using the following Jupyter trick: in a code cell, type the name of the object followed by a period (.) and press TAB. This will create a pop-up with a list of the possible attributes (called “instances” in the Jupyter interface) and methods (called simply “functions” in Jupyter).

ToDo: In the cell below, use the Jupyter notebook trick just discussed to check out the attributes and methods of the random_person object.
# Use the Jupyter notebook trick here!

As you’ve seen, the random_person object has three methods: introduce, is_older_than_30, and increase_age. The introduce method is a simple method that only prints something:

random_person.introduce()
Hi, I am Lukas!

Other methods, like the is_older_than_30 method, actually also return something:

older_than_30 = random_person.is_older_than_30()
print(f"Is {random_person.name} older than 30?", older_than_30)  # thank God
Is Lukas older than 30? False

Finally, some methods may not return anything but instead modify its own attributes! For example, the increase_age method takes a single input (an integer) that will increase the age attribute of the Person object by that number! For example:

random_person.increase_age(1)
print(f"{random_person.name} is now this old:", random_person.age)  # oh no!
Lukas is now this old: 30

As you can see, no need to save the result from increase_age in a new variable, because it is saved “internally” in the age attribute!

Warning! If you save the "result" from a method call in a new variable even though that method doesn't return something, Python won't crash but save the result as None! So if you ever notice that one of your variables is None, this might have happened!
ToDo: Lukas doesn't like to be 30 years old. Can you make him 29 again? Watch out: if you run the command increase_age multiple times, the age attribute will also be modified multiple times!
Hide code cell content
""" Implement the ToDo here. """
### BEGIN SOLUTION
random_person.increase_age(-1)
### END SOLUTION
""" Tests the ToDo implementation above. """
if random_person.age != 29:
    raise ValueError(f"Oh no, {random_person.name} is not 29 but {random_person.age}!")
else:
    print("Yes! Well done. I feel younger already.")
Yes! Well done. I feel younger already.
ToDo: Create a new Person object with your own name and age and save it in a variable named my_self. Then, introduce yourself (using the introduce method) and pretend its your birthday and increase your age by 1 (using the increase_age method).
Hide code cell content
""" Implement your ToDo here. """
### BEGIN SOLUTION
my_self = Person("Lukas", 29)
my_self.introduce()
my_self.increase_age(1)
### END SOLUTION
Hi, I am Lukas!
""" Tests the above ToDo. """
if not 'my_self' in locals():
    raise ValueError("Oh no! I couldn't find the variable 'my_self'! Did you spell it correctly?")
else:
    if type(my_self.age) not in (int, float):
        raise ValueError("Oh no! Did you enter a valid number for age upon initialization?")
    else:
        print(f"Great work, {my_self.name}! You're on a roll!")
Great work, Lukas! You're on a roll!

Now, some of you may wonder about those built-in classes like strings, integers, and lists — objects from those classes seem to be initialized differently than we discussed! This is true! This is because some classes are used so often, Python introduced a shorter sytax (sometimes called “syntactic sugar”) to initialize objects from such classes. For example, as we have seen, string objects can be initialized by just wrapping characters in quotes:

this_is_a_string = "a random sequence of characters in quotes"  # single quotes are fine too

A more elaborate, but equivalent, way to initialize strings also exists, however:

this_is_another_string = str("another random sequence of characters")

The initialization process underlying this_is_a_string and this_is_another_string is equivalent! The same applies to integers:

# Integers can be initialized using "syntactic sugar":
my_integer = 5

# ... or this way:
my_integer = int(5)

… and lists:

# The "syntactic sugar" way:
my_list = [0, 1, 2]

# ... or this way:
my_list = list([1, 2, 3])

All this is to show that everything in Python is an object! Objects may be initialized from built-in classes (like strings, integers, and lists) or from custom classes (like the Person class). We want you to understand that all objects may have certain attributes (which describe properties of the object) and methods (which represent things an object can do). You will encounter this idea a lot when we start working with the different data visualization (Matplotlib) and analysis (Pandas, Numpy) packages. Also, Psychopy features a lot of object-oriented programming.

For those that want a challenge can continue with the optional next section about creating custom classes yourself! If not, please continue with the next notebook: 1_python_basics.ipynb.

Custom classes (optional)#

In the previous section, you have seen how to instantiate objects from (custom) classes. In this (optional) section, you will learn how to write your own classes! This won’t cover everything of object-oriented programming, but it will teach you enough to get started. Although it is not strictly necessary, familiarity with functions helps a lot to understand this section.

Now, suppose that you are running an experiment for your MSc thesis. Each participant in your study performs a simple reaction time task. Half of the subjects are given a cup of coffee beforehand (condition: caffeine) while the other half are given a cup of water beforehand (condition: water). You think it would be nice to create a custom class that stores some information about a participant and their results. We will call this class Participant. In what follows, we will create the class step by step.

To create a custom class in Python, you use the keyword class followed by the name of your class, which is customarily capitalized:

class Participant:
    pass

Here, we leave the “body” of the class empty and just use the filler keyword pass (without pass, the cell would raise an error).

The __init__ method#

The next step is to define the class constructor, which is a method which outlines what needs to happen upon initialization. To create a method, you just need to define a function inside the class. Functions in Python are defined using the keyword def, followed by the name of the function, its (optional) arguments, and the function body (we will discuss functions in detail in the next notebook). The name of the constructor method is __init__ (short for “initialize”) and takes at least one argument, which is customarily named self (which we’ll explain in a bit):

class Participant:
    """ Usually, right underneath the class name, some documentation about the class is added
    across a multiline string using triple quotes. For example, we might add 'A class to represent
    participants in our study'. """
    def __init__(self):
        pass

Note that any code within the class definition is indented with four spaces (or a single tab; both are okay)! This tells Python which code belongs (and doesn’t belong) to the class definition. Right now, the __init__ method doesn’t do anything, but it does allow us to create an object with the class Participant by calling Participant() (note the round brackets):

participant_1 = Participant()
ToDo: Let's make the __init__ method actually do something! In the class definition below, add a print statement within the __init__ function which prints out "Constructing a Participant object!". Make sure to add another indent (four spaces or a tab) for your print statement (just like the pass keyword in the __init__ method above).
Hide code cell content
""" Implement your ToDo here. """

class Participant:
    """ Usually, right underneath the class name, some documentation about the class is added
    across a multiline string using triple quotes. For example, we might add 'A class to represent
    participants in our study'. """
    def __init__(self):
        ### BEGIN SOLUTION
        print("Constructing a Participant object!")
        ### END SOLUTION
""" Tests the above ToDo. """
import inspect
testp = Participant()
if not any('print(' in l for l in inspect.getsourcelines(testp.__init__)[0]):
    raise ValueError("Could not find a print statement in the __init__ method!")
else:
    print("Awesome! You're doing great.")
Constructing a Participant object!
Awesome! You're doing great.

As you can see in the test cell, whenver a Participant object is initialized, the __init__ method is triggered (under the hood)! You can do anything you like in the __init__ method, but usually this method is used to “bind” attributes to itself, such as arguments given to the __init__ method. For example, let’s add a single argument (in addition to self) to the constructor, a subject number (an integer):

""" Implement your ToDo here. """

class Participant:
    """ A class to represent participants in our study. """
    def __init__(self, sub_nr):
        self.sub_nr = sub_nr
ToDo: Below, initialize a new Participant object with a particular participant number (e.g., `1`). Then, try to access that attribute (sub_nr) from your new object! What happens if you don't supply a participant number when calling Participant?
Hide code cell content
""" Implement your ToDo here (no test cell). """
### BEGIN SOLUTION
participant_x = Participant(1)
print(participant_x.sub_nr)
### END SOLUTION
1

As you can see, whenever you construct a new Participant object (and thus implicitly call the __init__ method) the self argument is actually ignored. The self argument in classes function as a sort of placeholder (or template) in the class that will be “filled in” by the specific object once it is initialized. In other words, anything that is referenced by self in the class definition is referenced by the object name after initialization. This concept will become more clear when we’ll discuss other class methods later.

ToDo: Below, rewrite the __init__ method to accept (apart from self) two arguments: sub_nr and condition, a string (which should be either a "caffeine" or "water"). Then, make sure the value of the condition argument is bound to self such that the object will have a condition attribute. Bonus for those with more programming experience: raise a ValueError whenever the condition is something else than "caffeine" and "water".
Hide code cell content
""" Implement your ToDo here. """
class Participant:
    """ A class to represent participants in our study. """
    ### BEGIN SOLUTION
    def __init__(self, sub_nr, condition):
        self.sub_nr = sub_nr
        self.condition = condition
        
        if self.condition not in ['caffeine', 'water']:
            raise ValueError("Please choose 'condition' from ['caffeine', 'water']!")
    ### END SOLUTION
""" Tests the above ToDo. """
try:
    testp = Participant(1, 'water')
except Exception as e:
    print("Something went wrong during initialization ... Is your syntax correct?")
    raise(e)
else:
    if not hasattr(testp, 'condition'):
        raise ValueError("Your class does not have an attribute called 'condition'!")
    else:
        print("Yeah! Well done!")
Yeah! Well done!
""" Tests bonus part. If you haven't done this, you may ignore this. """
try:
    testp = Participant(1, 'tea')
except ValueError:
    # Yes! It's raising an error, great!
    print("Well done!")
else:
    raise ValueError("It should raise an error, but it isn't doing so ... ")
Well done!

Other methods#

The __init__ method is almost always included in custom Python classes. But you can of course add more custom methods (like the introduce method in the previously discussed Person class)! For example, suppose that you want to know whether the participant number is larger than 10. I know, not a very realistic use case, but we’ll keep it simple. Just like the __init__ method, you can add a method by defining a function inside the class definition:

class Participant:
    """ A class to represent participants in our study. """
    def __init__(self, sub_nr, condition):
        self.sub_nr = sub_nr
        self.condition = condition
    
    def larger_than_10(self):
        """ Checks if sub_nr is larger than 10. """
        if self.sub_nr > 10:
            ans = 'yes'
        else:
            ans = 'no'

        return ans

As you can see, like the __init__ method, the larger_than_10 method also takes self as input. This way, the object can access attributes of itself. This happens in the larger_than_10 method when it accesses the sub_nr attribute through self.sub_nr! Without self as the first argument, this would not have been possible. Let’s check out whether it works:

participant_15 = Participant(15, 'water')
answer = participant_15.larger_than_10()
print(answer)
yes

One last thing that is important to know is that you can also create attributes outside the __init__ method! For example, we could store the result of the larger_than_10 call as an attribute by “binding” it to self inside the method:

class Participant:
    """ A class to represent participants in our study. """
    def __init__(self, sub_nr, condition):
        self.sub_nr = sub_nr
        self.condition = condition
        self.ltt = None  # to be filled in later
    
    def larger_than_10(self):
        """ Checks if sub_nr is larger than 10. """
        if self.sub_nr > 10:
            ans = 'yes'
        else:
            ans = 'no'
        
        # Here, we bind `ans` to `self`! Note that we can call the attribute
        # whatever we want! Here, we use `ltt` (Larger Than Ten)
        self.ltt = ans
        
        return ans

Note that, if you expect attributes to be created after initialization (i.e., outside the __init__ method), it is good practice to pre-set this attribute to None inside the __init__ function (as we did above as well). Now, let’s see whether it works as expected:

participant_x = Participant(10, 'caffeine')

# After initialization, `ltt` is still None
print(participant_x.ltt)

# But after calling `larger_than_10`, it is correctly set!
participant_x.larger_than_10()
print(participant_x.ltt)
None
no

Now, let’s try a slightly harder ToDo!

ToDo: Create a new method, add_data, that takes as a list of numbers as a single (apart from self) input argument named data and checks whether its length is larger than 4. You can use the built-in function len for this. If the list of numbers is larger than 4, add it as an attribute named data. If it isn't, do not add it as an attribute. Make sure to pre-set the data attribute!
Hide code cell content
""" Implement your ToDo here. """
class Participant:
    """ A class to represent participants in our study. """
    
    def __init__(self, sub_nr, condition):
        ### BEGIN SOLUTION
        self.sub_nr = sub_nr
        self.condition = condition
        self.data = None
        
    def add_data(self, data):

        if len(data) > 4:
            self.data = data
    ### END SOLUTION
""" Tests the ToDo above. """
testp = Participant(1, 'water')
if not hasattr(testp, 'data'):
    raise ValueError("It looks like you didn't pre-set the data attribute!")
else:
    if testp.data is not None:
        raise ValueError("Make sure to pre-set the data attribute with None!")
    
testp.add_data([1, 2, 3])
if testp.data is not None:
    raise ValueError("The data attribute should not be updated when the length of the list is smaller than 5!")

testp.add_data([1, 2, 3, 4, 5, 6])
if testp.data is None:
    raise ValueError("The data attribute has not been updated ...")

print("EPIC! This wasn't easy, but everything works as expected!")
EPIC! This wasn't easy, but everything works as expected!

Okay then, let’s finish with an even harder ToDo for those that already have some experience with Python. Its solution features quite some more advanced concepts that will be discussed in the upcoming notebooks, so no worries if you can’t figure this out!

ToDo: Modify the add_data method such that it only stores data when the input is a list of exactly 10 numbers. Whenever the data (of length 10) is added for the first time, store it as an attribute with the name data as before. However, whenever the add_data method is called when the data attribute already contains a list of 10 numbers, add the 10 new numbers to the corresponding existing numbers: the first number from the new list should be added to the first number from the existing list in the data attribute, etc. etc. Also, add a new method called average_data that computes the average of each number in the data attribute and stores this list of numbers in a new attribute called current_average.

A couple of hints:
  • Make sure to pre-set all attributes upon initialization!
  • Do not cheat by using external packages such as Numpy ;-)
Hide code cell content
""" Implement your ToDo here. """
class Participant:
    """ A class to represent participants in our study. """

    ### BEGIN SOLUTION
    def __init__(self, sub_nr, condition):
        self.sub_nr = sub_nr
        self.condition = condition
        self.data = None
        self.current_average = None
        self.counter = 0
        
    def add_data(self, data):

        if len(data) == 10:
            if self.data is None:
                self.data = copy(data)
            else:
                i = 0
                for d in data:
                    self.data[i] += d
                    i += 1

            self.counter += 1

    def average_data(self):
        """ Note: this implementation is not completely correct! Because it won't
        average the numbers correctly whenever this method is called for more than once!
        Technically, a running average should be used. """
        self.current_average = []
        i = 0
        for d in self.data:
            av = d / self.counter
            self.current_average.append(av)
    ### END SOLUTION
""" Tests the above ToDo (part 1). """
from copy import copy

testp = Participant(1, 'water')
testp.add_data([1, 2, 3])  # should not add it because != length 10
if testp.data is not None:
    raise ValueError("It should only store data with the list contains exactly 10 numbers!")

inp1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
testp.add_data(copy(inp1))
if not isinstance(getattr(testp, 'data'), list):
    raise ValueError("Hmm, the data was not stored although it was exactly of length 10!")
    
# Let's add some numbers
inp2 = [2, 1, 5, 8, 9, 10, 3, 3, 1, 2]
testp.add_data(inp2)
for i, (x, y) in enumerate(zip(inp1, inp2)):
    if (x + y) != testp.data[i]:
        raise ValueError("Hmm, the number in position {i+1} is not added correctly ...")
        
print("YES! Well done so far! Let's see how the next test cell goes ...")
YES! Well done so far! Let's see how the next test cell goes ...
""" Tests the above ToDo (part 2). """
testp = Participant(1, 'water')
testp.add_data(inp1)
testp.add_data(inp2)
testp.average_data()

av = [(i1 + i2) / 2 for i1, i2 in zip(inp1, inp2)]
if all(testp.current_average[i] == av[i] for i in range(10)):
    print("OMG. You're amazing!")
OMG. You're amazing!

If you’re done with this notebook, please continue with the next one, 1_python_basics.ipynb (but treat yourself to a nice cup of tea or coffee, first — you deserved it).