# Python, numpy, and Nifti-images

This tutorial is about the programming language Python and how to use it for psychology/neuroimaging analysis. Part 2 of this tutorial is a quite extensive recap of basic Python syntax and constructs; this should be doable for students who are comfortable with programming (e.g., from previous experience with other programming languages). If you're *not* comfortable with programming, or you notice during this tutorial that you get stuck often, we recommend working through the [Codecademy](https://www.codecademy.com/learn/python) up to and including unit 8 (`loops`). You *don't* have to do the quizzes (because you need to pay for that).

Given that you have learned the basics of Python's syntax, this tutorial will recap some of the most important aspects of Python (section 2.: General Python). Then, 'numpy' (Python's scientific computing package) is discussed and explained and followed by a section on 'Matplotlib' (Python's plotting package). Finally, this tutorial ends with a section on how to interact with Nifti-files - the file format used by most (MRI) neuroimaging data.

## What this tutorial is about
This tutorial will recap some of the most important aspects of Python (section 2: General Python). Then, 'numpy' (Python's numeric computing package) is discussed and explained and followed by a section on 'Matplotlib' (Python's plotting package). Finally, this tutorial ends with a section on how to work with Nifti-files - the file format used by most (MRI) neuroimaging software packages.

At the end of this tutorial, you are able to implement any computation/routine necessary for complex fMRI analysis using a combination of basic Python and Numpy.

### Contents:

1. Jupyter notebooks
2. General Python
3. Numpy
4. Matplotlib / plotting
5. Interacting with Nifti-files

But before anything else, let's get more familiar with the Jupyter notebook environment!

## 1. 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. 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.

### 1.1. 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!

#### 1.1.1. 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!

You can also try it with the cell way at the top where you entered your name and student number...

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

    I'm printing Python code
    6

(You might be confused because we use the `print` statement with brackets -- `print()` -- while you've learned in Codecademy that you should use `print` without brackets. This is because Codecademy uses an old version of Python, Python2.7, while we use a newer version, Python3.5. In the new version, `print()` should be used with brackets!)

#### 1.1.2. 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!

#### 1.1.3. 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).

#### 1.1.3. 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 ctr+B 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).

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

```python
10/5
```

    2.0

#### 1.1.4. 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.

```python
# 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!

# This command makes sure that the figure is plotted in the notebook instead of in a separate window!
%matplotlib inline

# Now, let's plot something
plt.plot(range(10))
plt.show()
```

### 1.2. 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 heading

\#\# Two hashes creates a slightly smaller heading (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](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) 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 ctr+B). 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 ctr+m; "m" refers to "markdown"). You should see the prompt (the **`In [ ]`** thingie) disappear. Voilà, now it's a text cell!

Try it below! Insert a new markdown cell and try to write the following:

"**OMG**, this is th emost awesome Python tutorial *ever*."

"**OMG**, this is the most... *ever*"

### 1.3. 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.

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

```python
len?
```

If this method (i.e. appending with "?") does not give you enough information, try to google 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()'](http://lmgtfy.com/?q=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'.

** REMEMBER: Google is your friend! Please try to google things first, before you ask your classmates and your tutors! **

The online python community is huge, so someone definitely already wondered about something you might be struggling with.

### 1.4. Saving your work & closing 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 the server (that you're accessing through Jupyterhub). 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). If you want to close your notebook, simply close the browser.

*However, when you close the browser, the notebook is still "running" in the background in your terminal where you started it!*

To stop the notebook from running after you've closed it, go to the `Running` tab, and click the `Shutdown` button next to the notebook that you want to stop.

Alright. Now that you've had your first contact with Jupyter notebooks - let's talk about about exercises and assignments.

## A note on exercises (ToDo/ToThink) and assignments
You'll encounter different types of exercises throughout the notebook, being:

- ToDos : short programming exercises, either zero or for a few points
- ToThinks: questions about the (preceding) text/material, either zero or for a few points
- Assignments: a larger programming exercise (sometimes with additional questions), usually for a larger amount of points

We highly recommend doing/answering the ToDos/ToThinks, not only because they are worth points, but also because they are designed to improve your understanding of the material!

Anyway, a "ToDo" would look something like this:

ToDo (0 points)

In the code-cell below, set the variable `x` to 5 and remove the `raise AssertionError` statement below.

```python
x = 5
x
```

    5

Then, *after* each code-cell corresponding to the ToDo, there are one or more code-cell(s) with "tests" written by us. These tests may provide you with feedback about whether you implemented the "ToDo" correctly, and optionally gives you some hints on how to solve your error(s). If you run this cell and you don't get any errors, you did it correctly and you'll receive the point(s) for that ToDo. If it gives any errors, try to use the information from the error and/or print statements to solve the ToDo.

These tests are usually implemented as a set of `assert` statements, which 'assert' whether a specific statement is true. If it evaluates to `False`, then that means you've made an error. 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:

```python
''' 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!")
```

**Note**: each *ToDo* is explicitly tested, so you can always check your answer (and continue trying until you get it right).

Now, let's try another one, but this time for real! It's a super simple one, so use this to earn a free point. Note that it is followed by two test-cells, which test different aspects of your implementation (each passed test gives you 0.5 points).

ToDo (1 point)

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).

```python
x = 2
y = x * 2
```

```python
''' 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("Well done!")
```

    Well done!

```python
''' 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("Well done!")
```

    Well done!

Throughout the notebook you'll encounter several 'ToDos' - some are worth points, some don't. In addition to these 'ToDos', you'll also encounter 'ToThinks', which are short questions about the material, meant to make you think about what you're doing. Like the 'ToDos', some of these are worth points, others don't. Each 'ToThink' cell is followed by a markdown-cell in which you can write your answer. Obviously, these answers cannot be automatically tested, so there won't be a test-cell with `asserts`. These 'ToThinks' are later (by us) graded manually.

A typical 'ToThink' may look like this:

ToThink (1 point)

What's your name? Please write it in the text-cell below.

Tomas Knapen

Make sure you actually write your name in the cell above for another free point! (You'll have to double-click the text-cell to go into 'edit mode' and replace 'YOUR ANSWER HERE' with your name.)

Next to 'ToDos' and 'ToThinks', there are Assignments. These are basically just a collection of programming exercises and/or open questions, each with a certain amount of points associated with it. You'll encounter assignments usually at the end of a specific section. Usually, altogether these assignments are worth substantially more points than 'ToDos' or 'ToThinks'.

An assigment may look like this:

Assignment (5 points)

This is an example assignment; it's not actually worth any points. In a real assignment, you would see one or more coding assignments or open questions below.

Alright, by now you should known enough to start with section 2: a recap of basic Python syntax! (As we mentioned earlier, if you find this section too difficult, it might help to do the Codecademy Python course, up to and including section 8, before you go on!)

## 2. General Python
This section will provide short tutorial on basic Python syntax and concepts (if you've done Codecademy, this should all be familiar).

Here, we will cover:

* Python data types (integers, floats, lists, dictionaries)
* Python functions
* Python classes
* Conditionals (if-then-else)
* Loops (for-loop, list-comprehensions)
* Imports

### 2.1. Structure of Python

Python is a multipurpose programming language, meaning it can be used for almost anything. While R is mostly used for statistics, and php is used for web programming only, Python is a general language, specified by the packages you add on to it (using import statements). So, "pure" Python provides some basic functionality, but Python's versatility comes from specialized packages for almost any purpose.

For example:
* the [scipy](https://www.scipy.org/) package provides functionality for scientific computing (e.g. statistics, signal processing);
* the [numpy](http://www.numpy.org/) package provides data structures and functionality for (very fast) numeric computing (e.g. multidimensional numeric array computations, some linear algebra);
* the [matplotlib](http://matplotlib.org/) package provides plotting functions;
* and various specialied neuroimaging packages provide functionality to work and analyze (f)MRI (e.g. [nibabel](http://nipy.org/nibabel/) and [nipype](http://nipy.org/nipype)) and MEG/EEG (e.g. [MNE](http://www.martinos.org/mne/stable/index.html)).

Basically, there are packages for everything you can think of (also: creating websites, game programming, etc.)! In this course, we will mostly use basic Python in combination with the scientific computing packages ('numpy', 'scipy') and specialized neuroimaging packages ('nibabel', 'nipype').

#### Import statements
As explained above, Python ships with some default functionality. This means that it's already available upon starting a notebook (or any other Python environment) and doesn't need to be imported. An example is the function `len()`.

```python
my_list = [1, 2, 3]
print(len(my_list))
```

    3

However, non-built-in packages - such as `numpy` - need to be explicitly imported to access their functionality. After importing, their functions are accessible as: `{package}.{function}`.

For example:

```python
import numpy

# Now you can access the numpy function `add()` as numpy.add()
print(numpy.add(5, 3))
```

    8

However, writing `numpy` in front of every function you access from it becomes annoying very quickly. Therefore, we usually abbreviate the package name by two or three characters, which can be achieved through:

`import {package} as {abbreviation}`

For example, people usually abbreviate the numpy import as:

```python
import numpy as np

# Now you can access numpy functions such as 'add()' as:
print(np.add(5, 3))
```

Throughout the tutorials, you'll see different packages (e.g. nibabel and scipy) being imported using abbreviations.

Also, you don't need to import an *entire* package, but you can also import a specific function or class. This is done as follows:

`from {package} import {function1}, {function2}, {etc}`

An example:

```python
from numpy import add, subtract

# Now I can simply call add() and subtract()
print(add(5, 3))
```

    8

Note that some packages have a hierarchical structure with subpackages (also called modules). For example, scipy has a subpackage `ndimage` (with functions for n-dimensional arrays). To import *only* this subpackage, do the following:

```python
from scipy import ndimage
# Now you can call functions from the ndimage subpackage,
# e.g. gaussian_filter

print(ndimage.gaussian_filter([10, 5, 4], 2))
```

Note that you can mix and match all of these operations to customize the import to your own liking (see cell below for such a fancy import). In this course, we'll usually just import entire packages (e.g. `import numpy as np`) or specific functions/subpackages (e.g. `from scipy import stats`).

```python
# a fancy import
from scipy.stats import binom_test as omg_binomial_testing_so_cool

print(omg_binomial_testing_so_cool(0.5, 10))
```

ToDo (1 point)

Import the function "randn" from the numpy subpackage "random" and rename it "random_normal_generator".

```python
from numpy.random import randn as random_normal_generator
```

```python
''' Tests the above ToDo. '''
try:
    assert('random_normal_generator' in dir())
except AssertionError as e:
    print("I couldn't find the function 'random_normal_generator'; did you spell it correctly?")
    raise(e)
else:
    print("Well done!")
```

    Well done!

```python
''' Another test for the above ToDo. '''
try:
    assert(random_normal_generator.__name__ == 'randn')
except AssertionError as e:
    print("Your 'random_normal_generator' function does not point to the 'randn' numpy.random subpackage!")
    raise(e)
else:
    print("Well done!")
```

    Well done!

#### Whitespace for indentation
In most programming languages, code blocks (e.g., if-else blocks, or for-loops) are delineated by dedicated symbols (often curly brackets, `{}`). For example, an if-else block in R is written like this:

```
if (x > 0) {
   y = x + 5
} else {
   y = x - 5
}
```

While in languages like R and MATLAB whitespace/indentation is used for readability, it is not necessary! The above if-else statement in R can also be written as:

```
if (x > 0) { y = x + 5 } else { y = x - 5 }
```

However, in Python, whitespace and indentation is important! In Python, indendation - instead of curly braces - delineates code blocks, and if code is incorrectly indented, Python will give an error! For example, an if-else statement in Python must be indented (always with 4 spaces or a tab). If you don't do this, it will give an error, as show below:

```python
x = 0
if x < 0:
    y = x + 5
else:
    y = x - 5
```

If you make sure the two statements beginning with `y = ...` are indented with 4 spaces/a tab, the error disappears (try it yourself!).

#### Python versions
As a side note: there are currently two different supported versions of Python, 2.7 and 3.6. Somewhat confusingly, Python 3.0 introduced many backwards-incompatible changes to the language, so code written for 2.7 may not work under 3.x and vice versa. For this class all code will use Python **3.6** (but 90% of this notebook should be compatible with Python 2.7., we think ...). So if you want to use code from this class on your own computer, make sure you use Python 3.6!

### 2.2 Basic data types
"Pure" (i.e. built-in) Python has mostly the same data types as you might know from MATLAB or R, such as numbers (integers/floats), strings, and lists (cells in MATLAB; lists in R). Also, Python has to data types that might be unknown to MATLAB/R users, such as "dictionaries" and "tuples", which are explained later.

#### Numbers
Numbers are represented either as integers ("whole" numbers) or floats (numbers with decimals, basically).

```python
x = 3
print(x, type(x)) # use type(variable) to find out of what data-type something is!

y = 3.15
print(y, type(y))
```

    3 <class 'int'>
    3.15 <class 'float'>

Let's try to apply arithmetic to x as defined above with some basic operations:

```python
print(x + 1)   # Addition;
print(x - 1)   # Subtraction;
print(x / 2)   # Division;
print(x * 2)   # Multiplication;
print(x ** 2)  # Exponentiation;
```

    4
    2
    1.5
    6
    9

The above commands apply operations to x, but do not *change* x itself. To permanently change x, you have to store the results of the operation (e.g. `x + 1`) into a variable (e.g. `x2 = x + 1`), as shown in the cell below:

```python
x = 3
x_new = x + 2

# If you simply want to update an existing variable, you can do this in two ways:
x = x + 1

# ... or:
x += 1

print(x)

x *= 2 # This is the same as: x = x * 2
print(x)
```

    5
    10

ToDo (1 point)

In the cell below, make a new variable, `y`, which should contain x minus 5 and subsequently raised to the 4th power.

```python
x = 8
# your solution here
y = (x - 5) ** 4
```

```python
''' Tests the above ToDo.'''

# Check if there exists a variable 'y'
try:
    assert('y' in dir())
except AssertionError as e:
    print("The variable 'y' doesn't seem to exist! Did you name it correctly?")
    raise(e)
else:
    print("Well done! 1 out of tests 2 passed")

# Check if it has the correct number
try:
    assert(y == 81)
except AssertionError as e:
    print("The variable y does not seem to equal x minus 5, raise to the power 4.")
    raise(e)
else:
    print("Well done! 2 out of tests 2 passed")
```

    Well done! 1 out of tests 2 passed
    Well done! 2 out of tests 2 passed

#### Booleans
Python implements all of the usual operators for comparisons. Similar to what you might know from other languages, '==' tests equivalence, '!=' for not equivalent, and '<' and '>' for larger/smaller than.

Check out some examples below:

```python
a = 3
b = 5
is_a_equal_to_b = a == b

print(is_a_equal_to_b)
print(type(is_a_equal_to_b))
```

    False
    <class 'bool'>

However, for Boolean logic, python doesn't use operators (such as && for "and" and | for "or") but uses special (regular English) **words**:

```python
bool_1 = 3 > 5 # False, because 3 is not greater than 5
bool_2 = 5 == 5 # True, because, well, 5 is 5

print(bool_1 and bool_2)  # Logical AND, both have to be True
print(bool_1 or bool_2)   # Logical OR, either one of them has to be True
print(not bool_1)         # Logical NOT, the inverse of bool_1
print(bool_1 != bool_2)   # Logical XOR, yields True when bool_1 and bool_2 are not equal
```

    False
    True
    True
    True

ToDo (0 points)

Mess around with booleans in the cell below. Try some more complex things, like: `not ((3 > 5) and not (5 < 2))`.
Do you understand why the result is the way it is? Try to follow the logic in the sequence of statements (not graded, so no tests follow the code block).

```python
# Do your ToDo here:
[1, 3, 4] == [1, 3, 4]
```

    True

#### Strings
Strings in Python are largely the same as in other languages.

```python
h = 'hello'   # String literals can use single quotes
w = "world"   # or double quotes; it does not matter.

print(h)
print(len(h))  # see how many characters in this string
```

    hello
    5

A very nice feature of Python strings is that they are easy to concatenate: just use '+'!

```python
hw = h + ', ' + w + '!' # String concatenation
print(hw)
```

    hello, world!

You can also create and combine strings with what is called 'string formatting'. This is accomplished by inserting a placeholder in a string, that you can fill with variables. An example is given below:

```python
# Here, we have a string with a placeholder '%s' (the 's' refers to 'string' placeholder)
my_string = 'My favorite programming language is: %s'
print('Before formatting:')
print(my_string)

# Now, to 'fill' the placeholder, do the following:
my_fav_language = 'Python'
my_string = 'My favorite programming language is: %s' % my_fav_language

print('After formatting')
print(my_string)
```

    Before formatting:
    My favorite programming language is: %s
    After formatting
    My favorite programming language is: Python

You can also use specific placeholders for different data types:

```python
week_no = 1 # integer
string1 = 'This is week %i of neuroimaging' % week_no # the %i expects an integer!
print(string1)

project_score = 99.50 # float
string2 = 'I will get a %f on my midterm exam!' % project_score
print(string2)

# You can also combine different types in a string:
string3 = 'In week %i of neuroimaging, %s will get a %f for my lab-assignment' % (week_no, "I", 95.00)
print(string3)
```

For a full list of placeholders see https://docs.python.org/2/library/stdtypes.html#string-formatting-operations

ToDo (1 point)

Using the variable `to_print` defined below, print the string:

"Neuroimaging will be my favorite course 4ever"

So you'll have to "fill" the "%" placeholders using string formatting.
(This is a manually graded answer, so no tests!)

```python
to_print = "%s will be my favorite course %iever"
# print the to_print variable, with the appropriate string formatters!
print(to_print % ("Neuroimaging", 4))

```

    Neuroimaging will be my favorite course 4ever

#### Lists
A list is the Python equivalent of an array, but can be resized and can contain elements of different types. It is similar to a list in R and a cell in MATLAB. Note that indices in python start with 0! This means that the 3rd element of the list below is accessed through `[2]`.

Let's check out some lists and how to index them!

```python
# Note that list may contain numbers ...
list1 = [3, 1, 2]

# ... or strings
list2 = ['hello', 'world']

# ... or, actually, anything at all! List lists themselves
list3 = ['hello', [3, 1, 2], 'world', 5.3, -999]
list3[1][0]
```

    3

Whatever the contents of a list, they are indexed the same way: using square brackets with an integer, e.g. `[0]`:

```python
print('The first element of list1 is: %i' % list1[0])
print('The second element of list2 is: %s' % list2[1])
print('The last element of list3 is: %i' % list3[-1])
print('The second-to-last element of list3 is: %f' % list3[-2])
```

    The first element of list1 is: 3
    The second element of list2 is: world
    The last element of list3 is: -999
    The second-to-last element of list3 is: 5.300000

Note that you can also use negative indices! Negative indices start indexing from the end of the list, so `[-1]` indexes the last element, `[-2]` indexes the second-to-last element, etc.

We cannot only 'extract' element from lists using indexing, but we can also replace them! This works as follows:

```python
some_list = [1, 2, 3, ['A', 'B', 'C']]

# Let's set the first element of some_list to 100:
some_list[0] = 100
print(some_list)

# Note that indexing a list within a list is done with sequential square brackets,
# so if we want to index the element 'A' in some_list, we do:
some_list[-1][0] = 'ANOTHER STRING'
print(some_list)
```

    [100, 2, 3, ['A', 'B', 'C']]
    [100, 2, 3, ['ANOTHER STRING', 'B', 'C']]

ToDo (1 point)

In the cell below, replace the element 'TO_REPLACE_1' with 'REPLACED' and the element 'TO_REPLACE_2' also with 'REPLACED' in the list `todo_list`.

```python
todo_list = [1, 'B', 'TO_REPLACE_1', [5, 3, 1038, 'C'], [1, 3, 5, [9, 3, 1, 'TO_REPLACE_2']]]

# your solution here
todo_list[2] = 'REPLACED'
todo_list[-1][-1][-1] = 'REPLACED'
todo_list
```

    [1, 'B', 'REPLACED', [5, 3, 1038, 'C'], [1, 3, 5, [9, 3, 1, 'REPLACED']]]

**Note**: the code-cell below as usual tests your ToDo, but we haven't written out the tests in the cell itself. Instead, we wrote the tests in a separate Python module (a file with a collection of functions, objects, etc.), which we import here. (We do this, because writing out the tests here would give you the answer rightaway!)

```python
''' Tests the above ToDo with a custom function. '''
# Below, we import all our tests (*)
from tests import *
test_list_indexing(todo_list)
```

In addition to accessing list elements one at a time, Python provides concise syntax to access specific parts of a list (sublists); this is known as **slicing**.

Let's look at some slice operations:

```python
nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(nums)         # Our original list

# Get a slice form index 2 to 4 (exclusive); prints "[2, 3]"
print(nums[2:4])

# Get a slice from index 2 to the end; prints "[2, 3, 4, 5, 6, 7, 8, 9]"
print(nums[2:])

# Get a slice from the start to index 2 (exclusive); prints "[0, 1]"
print(nums[:2])

# Get a slice of the whole list; prints ["0, 1, 2, 3, 4, 5, 6, 7, 8, 9]"
print(nums[:])

# Slice indices can be negative; prints ["0, 1, 2, 3, 4, 5, 6, 7, 8]",
# so everything up to (but not including) the last element
print(nums[:-1])
```

Apart from the syntax `[from:to]`, you can also specify a "stride" (sort of step-size) of your slice using the syntax `[from:to:stride]`:

```python
# Return values in steps of 2
print(nums[::2])

# Returns values in steps of 3, but starting from the second element
print(nums[1::3])
```

With 'normal' indexing of lists, you can only index a subsequently set/replace one element at the time. With slices, however, you can set multiple elements at the same time:

```python
nums[2:4] = [100, 200] # Assign a new sublist to a slice
print(nums)         # Prints "[0, 1, 100, 200, 4, 5, 6, 7, 8, 9]"
```

Importantly, slicing in Python is "end exclusive", which means that the last index in your slice is not returned. Thus ...

`nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
nums[0:5]`

... returns 0 up till and including 4 (not 5!).

Check it out below:

```python
nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(nums[0:5])
```

    [0, 1, 2, 3, 4]

Tip: instead of creating sequential lists like:

`num = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]`

... we can also create a list using the syntax:

`num = list(range(starting_point, exclusive_end_point))`

For example, to create a list from 5 to 15, use the following:

`num = list(range(5, 16))`

We'll use this construction (`list(range(x, y))`, or without the `list`) quite often in this course!

ToDo (1 point)

From the list (`my_list`) below, extract the numbers 2, 3, 4, 5, and 6 using a slice and store it in a new variable named `my_new_list`!

```python
my_list = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

# your solution here
my_new_list = my_list[1:6]
my_new_list
```

    [2, 3, 4, 5, 6]

```python
available_vars = dir()
if 'my_new_list' not in available_vars:
    raise ValueError("You did not store the results in a new variable caleld 'my_new_list'!")

test_slicing_1(my_new_list)
```

ToDo (1 point)

From the list below (`my_list_2`), extract the values `[5, 7, 9, 11]` using a slice (i.e., in a single operation!) and store it in a new variable named `my_new_list_2`.

```python
my_list_2 = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21]

# your solution here
my_new_list_2 = my_list_2[2:6]
my_new_list_2
```

    [5, 7, 9, 11]

```python
available_vars = dir()

if 'my_new_list_2' not in available_vars:
    raise ValueError("You didn't define the variable 'my_new_list_2'!")

test_slicing_2(my_new_list_2)
```

**NOTE**: you can index *strings* the same way as you index lists! Try to see it this way: a string is, quite literally, a *string* ("list") of characters. So, to get the first letter of some string s (e.g, 'this is a string'), you simply write: `s[0]`. To get first 5 characters, you write `s[:5]`, etc etc. Remember this!

#### Dictionaries
Dictionaries might be new for those who are used to MATLAB or R. Basically, a dictionary is an **unordered** list in which list entries have a name (which is also referred to as a "key"). To get a value from a dictionary, you have to use the "key" as index instead of using an integer.

Let's check out such a dictionary and how to index it. We build a dictionary using the following syntax:

`{some_key: value, another_key: another_value, etc: etc}`

The keys can be anything! Strings, integers, lists ... doesn't matter! Mostly, though, strings are used as keys.
So, let's look at an example:

```python
my_dict = {'cat': 'cute', 'dog': 'furry'}  # Create a new dictionary with some data
```

To index a dictionary, we'll use square brackets `[]` again, just like with lists. But now, we can index using the key!

```python
indexed_value = my_dict['cat']
print(indexed_value)
```

    cute

Adding new key-value pairs to dictionaries is easy! Just index it with a new key, and assign the value to it:

```python
my_dict['fish'] = 'wet'     # Set an entry in a dictionary
print(my_dict['fish'])      # Prints "wet"
```

    wet

Like a list, an entry in a dictionary can be of any data type:

```python
my_dict['rabbit'] = ['omg', 'so', 'cute']
print(my_dict['rabbit'])
```

    ['omg', 'so', 'cute']

If you try to 'index' a dictionary with a key that doesn't exist, it raises a "KeyError", which means you're trying to index something that doesn't exist:

```python
print(my_dict['monkey'])
```

ToDo (1 point)

In the code cell below, add a new key to the dictionary `my_dict` named `"rat"` and with the value `"nasty"`.

```python
# Add the key-value pair here!

# your solution here
my_dict['rat'] = 'nasty'
```

```python
''' Tests the above ToDo. '''

try:
    assert('rat' in my_dict)
except AssertionError as e:
    print("There exists no key 'rat' in my_dict!")
    raise(e)

try:
    assert(my_dict['rat'] == 'nasty')
except AssertionError as e:
    print("The value of key 'rat' is '%s' and NOT 'nasty'" % my_dict['rat'])

print('Well done!')
```

    Well done!

ToDo (1 point)

Values of dictionaries can be any type of object, even dictionaries themselves! So, add a new key to the dictionary `my_dict` named `"another_dict"` with the value of *another* dictionary with the keys `"a"` and `"b"` and the corresponding values `1` and `2`. Also, try to figure out how to index the value `1` from the 'nested' dictionary (this is not graded, but try it nonetheless!).

```python
# Do the ToDo here

my_dict["another_dict"] = {'a':1, 'b':2}
```

```python
''' Tests the above ToDo. '''

try:
    assert('another_dict' in my_dict)
except AssertionError as e:
    print("There exists no key 'another_dict' in my_dict!")
    raise(e)

try:
    assert(my_dict['another_dict']['a'] == 1)
    assert(my_dict['another_dict']['b'] == 2)
except AssertionError as e:
    print("The key 'another_dictionary' should contain a dictionary with keys 'a' and 'b', corresponding"
          "to values 1 and 2, respectively.")
    raise(e)

print('Well done!')
```

    Well done!

#### tuples
Tuples are very much like lists, but the main difference is that they are immutable. In other words, after creating them, they cannot be modified (their values cannot be replaced/altered):

```python
# A list can be modified ...
my_list = [1, 2, 3]
my_list[0] = 0
print(my_list)
```

```python
# ... but a tuple cannot.
my_tuple = (1, 2, 3)
print(my_tuple[0]) # you can print parts of tuple ...
my_tuple[0] = 0   # but you cannot modify it!
```

You probably won't use tuples a lot, but you might come across them when using and writing functions (but more about that in the next section!).

```python
def my_epic_function(integer):

    return integer, integer * 2

outputs = my_epic_function(10)
print(outputs)
print(type(outputs))

# also, you can unpack tuples (and also lists) as follows:
output1, output2 = outputs
print(output2)
```

    (10, 20)
    <class 'tuple'>
    20

### 2.3 Functions and methods

If you followed the Codecademy tutorial, you are familiar with the basic syntax of functions in Python; if you're familiar with other programming languages, you'll see that the syntax of Python functions is quite similar to what you're used to.

A function definition in Python starts with the keyword `def`, followed by the function name and round brackets with the arguments to the function, and finally the contents of the function, like so (note the indentation with four spaces/tab!!!):

```
def my_awesome_function(arg_1, arg_2):
    print("Argument 1: %s" % arg_1)
    print("Argument 2: %s" % arg_2)
```

This dummy-function above prints some stuff, but does not **return** something. Similar to R (but unlike MATLAB), you have to explicitly state what you want to **return** from the function by the "return" statement.

So, suppose you have a function that adds 2 to any number. Let's define it as follows (you have to run the cell to let Python know you've defined this function):

```python
def add_2_to_a_number(some_number):
    new_number = some_number + 2
```

Here, we omitted a **return** statement to return the value of `new_number`. This is a problem, because in Python (like most languages) you cannot 'peek' inside the function after using it! You can only access whatever is returned.

So, in the function defined above, we cannot access the value of `new_number`, because we didn't return it:

```python
# This will give an error!
add_2_to_a_number(5)
print(new_number)
```

So, to access the *value* of `new_number` (that is, *not* `new_number` itself, but its associated value), we need to return it:

```python
def add_2_to_a_number_fixed(some_number):
    new_number = some_number + 2
    return new_number
```

```python
value_contained_in_new_number = add_2_to_a_number_fixed(5)
print("Results of function 'add_2_to_a_number' with argument '5': %i" % value_contained_in_new_number)
```

    Results of function 'add_2_to_a_number' with argument '5': 7

Importantly, you can name the variable to which you assign the return value *anyway you like*. This doesn't have to be `new_number`! Like above, we named it `value_contained_in_new_number`, but it really doesn't matter.

ToDo (1 points)

In the code cell below, we've started writing a function named `extract_last_element` that takes one input-argument - a list - and returns the last element of the list. Some parts of the function are missing, though, which you need to write! When you're done, run the test-cell below it to check if it's right!

```python
def extract_last_element(input_list):

    # your solution here
    return input_list[-1]

```

```python
try:
    assert(extract_last_element(input_list=[0, 1, 2]) == 2)
except AssertionError as e:
    print("Your function fails for input [0, 1, 2]")
    raise(e)

try:
    assert(extract_last_element(input_list=[0]) == 0)
except AssertionError as e:
    print("Your function fails for input [0]")
    raise(e)

try:
    assert(extract_last_element(input_list=['string1', 'string2', 'string3']) == 'string3')
except AssertionError as e:
    print("Your function fails for input ['string1', 'string2', 'string3']")
    raise(e)

print("Well done!")
```

    Well done!

Alright, that was probably relatively easy. Let's do a slightly harder one.

ToDo (1 point)

Write a completely new function named `get_values_from_odd_indices` (so you have to write the `def ...` part!) that takes one input-argument - a list - and returns all values from the odd indices of that list. So, suppose you have a list:

`[2, 100, 25, 48, 92, -5, 12]`

... your function should return:

`[100, 48, -5]`

... i.e, the values from odd indices (here: 1, 3, 5; we exclude index zero!)

Hint: slices might be useful here!

When you're done, run the test-cell below it to check if it's right!

```python
# Implement your function (called get_values_from_odd_indices) here:

def get_values_from_odd_indices(input_list):
    return input_list[1:-1:2]

```

```python
''' Tests the ToDo above. '''
try:
    assert('get_values_from_odd_indices' in dir())
    assert(callable(get_values_from_odd_indices))
except AssertionError as e:
    print("Your function 'get_values_from_odd_indices' does not seem to exist!")

try:
    out = get_values_from_odd_indices([0, 1, 2])
    if out is None:
        msg = "ERROR: did you forget the Return statement?"
        raise ValueError(msg)
except ValueError as e:
    raise(e)

print("Well done (also run the next cell with tests)!")
```

    Well done (also run the next cell with tests)!

```python
''' Some other tests for the ToDo above. '''
inp = [0, 1, 2]
outp = get_values_from_odd_indices(inp)
ans = [1]
try:
    assert(outp == ans)
except AssertionError as e:
    print("Your function returned '%r' but I expected '%r'" % (outp, ans))
    raise(e)

inp = [5, 7, 9, 11, 13, 15, 18, 20, 21]
outp = get_values_from_odd_indices(inp)
ans = [7, 11, 15, 20]
try:
    assert(outp == ans)
except AssertionError as e:
    print("Your function returned '%r' but I expected '%r'" % (outp, ans))
    raise(e)

print("Well done!")
```

    Well done!

**IMPORTANT**: it is possible to return *multiple things* from a function. The function, then, returns these things as a tuple, which can subsequently be "unpacked". Let's check out an example using a custom function called `minmax_of_list` which returns both the minimum and maximum of a list:

```python
def minmax_of_list(some_list):
    ''' Returns both the minimum and maximum of a list.

    Parameters
    ----------
    some_list : a Python list

    Returns
    -------
    min_value : a float or int
        The minimum of a list
    max_value : a float or int
        The maximum of a list
    '''
    min_value = min(some_list)
    max_value = max(some_list)

    return min_value, max_value
```

As you can see, returning multiple things is a simple as adding more variables after the `return` statement, separated by commas. If we now call the function with a particular list, it gives us back a tuple of size 2 (one value for the minimum, one value for the maximum):

```python
output_from_function = minmax_of_list([0, 1, 2, 3])
print(output_from_function)
print(type(output_from_function))
```

    (0, 3)
    <class 'tuple'>

We can now "unpack" the tuple (i.e., extract the separate values) in several ways. One way is to simply index the values:

```python
output_from_function = minmax_of_list([0, 1, 2, 3])
minimum = output_from_function[0]
print("Minimum: %i" % minimum)

maximum = output_from_function[1]
print("Maximum: %i" % maximum)
```

    Minimum: 0
    Maximum: 3

Alternatively, we can already "extract" one value, let's say the maximum (index 1 of the tuple) right after calling the function, so we can skip dealing with the tuple altogether:

```python
maximum = minmax_of_list([0, 1, 2, 3])[1]  # The [1] extracts the maximum from the output of the function immediately!
print("Maximum: %i" % maximum)

```

    Maximum: 3

Keep this feature of returning multiple things and tuple unpacking in mind for the rest of the course (you'll definitely encounter it more often!).

#### Methods
However, in Python, functions are not the only things that allow you to 'do' things with data. In others' code, you'll often see function-like expressions written with periods, like this: `some_variable.function()`. These `.function()` parts are called 'methods', which are like functions 'inherent' to a specific type of object. In other words, it is a function that is applied to the object it belongs to.

Different type of objects in Python, such as stings and lists, have their own set of methods. For example, the function you defined above (`extract_last_element()`) also exists as a method each list has, called `pop()`! (This is a builtin, standard, method that each list in Python has.) See for yourself in the block below.

```python
my_list = [0, 5, 10, 15]
print(my_list.pop())

# You can also just do the following (i.e. no need to define a variable first!):
print([0, 5, 10, 15].pop())

# ... which is the same as:
print(extract_last_element([0, 5, 10, 15]))
```

    15
    15
    15

Not only lists, but also other data-types (such as strings, dictionaries, and, as we'll see later, numpy arrays) have their own methods. How methods work exactly is not really important (this belongs to the topic of 'object-oriented programming'), but it is necessary to know **what** it does, as you'll see them a lot throughout this course.

We'll show you a couple of (often-used) examples:

```python
# For lists, we have .append()
x = [0, 10, 15]
x.append(20) # Add a new element to the end of the list using the append() method!
print(x)
```

**Note**: sometimes, methods modify the object (above: `x`) *in-place*, which means that the method does not return anything, so you don't have to assign it to a new variable. If you accidentally do this, the new object's value is `None`, as you see below:

```python
x = [0, 10, 15]
x_new = x.append(20)
print(x_new)
```

    None

Some often-used methods for dictionaries:

```python
my_dict = {'a': 0, 'b': 1, 'c': 2}

# The .values() method returns all the values of the dictionary
print(list(my_dict.values()))

# And the .keys() method returns all the keys of the dictionary
print(list(my_dict.keys()))
```

    [2, 1, 0]
    ['c', 'b', 'a']

**Note**: these dictionary-methods actually *do* return values!

Some often-used methods for strings:

```python
my_string = 'neuroimaging is fun!'

# The .upper() method returns the string in uppercase!
print(my_string.upper())

# The .count(substring) method returns the number of times a substring occurs in a string
print(my_string.count('n'))

# The .replace(old, new) method replaces substrings
print(my_string.replace('fun', 'awesome'))

# The .split(separator) splits a string into subparts (returned as a list)
print(my_string.split(' '))  # split by whitespace
```

    NEUROIMAGING IS FUN!
    3
    neuroimaging is awesome!
    ['neuroimaging', 'is', 'fun!']

#### Default arguments in functions/methods
Importantly, and unlike most (scientific) programming languages, Python supports the use of 'default' arguments in functions. Basically, if you don't specify an optional argument, it uses the default:

```python
def exponentiate_number(number, power=2):
    return number ** power

print(exponentiate_number(2)) # now it uses the default!
print(exponentiate_number(2, 10)) # now it "overwrites" the default and uses power=10
print(exponentiate_number(number=2, power=10)) # also note that you can 'name' arguments
```

    4
    1024
    1024

### 2.4 If-statements
If-elif-else statements in Python are quite straightforward. An example:

```python
x = 5

if x > 0:
    print('x is larger than 0')
elif x < 0:
    print('x is smaller than 0')
else:
    print('x must be exactly 0!')
```

    x is larger than 0

If-statements contain at least an `if` keyword, but optionally also one or more `elif` ("else if") statements and an optional `else` statement. We'll practice this (in a `ToDo`) after the section on Loops.

### 2.4 Loops
Loops in Python (for- and while-loops) are largely similar to MATLAB and R loops, with some minor differences in  their syntax:

```python
animals = ['cat', 'dog', 'monkey']
for animal in animals:
    print(animal)
```

    cat
    dog
    monkey

Basically, each data type that is also an "iterable" (something that you can iterate over) can be used in loops, including lists, dictionaries, and tuples.

```python
# An example of looping over a list
my_list = [1, 2, 3]
for x in my_list:
    print(x)
```

    1
    2
    3

MATLAB users might be used to looping over indices instead of the actual list values, like the following:

```
for i=1:100
    disp(some_list(i));
end```

In Python, however, you loop (by default) over the contents of a list:

```
for entry in some_list:
    print(entry)
```

If you want to access for the value **AND** the index, you can use the built-in `enumerate` function:

```python
my_list = ['a', 'b', 'c']
for index, value in enumerate(my_list):

    print('Loop iteration number (index) = %i, value = %s' % (index, value))

# Don't forget that Python indexing starts at zero!
```

    Loop iteration number (index) = 0, value = a
    Loop iteration number (index) = 1, value = b
    Loop iteration number (index) = 2, value = c

```python
# Looping over a tuple (exactly the same as looping over a list)
my_tuple = (1, 2, 3)
for x in my_tuple:
    print(x)

len(my_tuple)
```

    1
    2
    3

    3

```python
# Iterating over a dictionary can be done in a couple of ways!
my_dict = {'a': 1, 'b': 2, 'c': 3}

# Looping over the keys ONLY
for key in my_dict:
    print(key)
```

    c
    b
    a

```python
# Looping over both the keys and the entries
for key, entry in my_dict.items():
    print(key, entry)
```

    c 3
    b 2
    a 1

ToDo (2 points)

Complete the function below - named `extract_values_smaller_than_0` - that takes a single list with numbers as input and returns a new list with *only the values smaller than 0* from the input-list. For example, suppose our input-list is:

```
[2, -5.3, 1.8, 0.0, -205.1, 6029]
```

... the function should return:

```
[-5.3, -205.1]
```

Hint: use an if-statement in combination with the `.append()` method of the empty list we initialized below (`list_to_return`) to fill the `list_to_return` variable in a for-loop. In other words, the function should contain an if-statement in a for-loop (in which you need to use the `.append()` method).

```python
# Complete the function below (make sure to remove raise NotImplementedError!)
def extract_values_smaller_than_0(input_list):

    # We initialize an empty list here (which you need to fill using a for-loop)
    list_to_return = []

    # your solution here
    for i in input_list:
        if i < 0:
            list_to_return.append(i)

    return list_to_return
```

```python
''' Tests the ToDo above. '''
inp = [-5, 2, 3, -8]
outp = extract_values_smaller_than_0(inp)
ans = [-5, -8]
try:
    assert(outp == ans)
except AssertionError as e:
    print("Your function  with input '%r' returned '%r', but I expected '%r'" % (inp, outp, ans))
    raise(e)

inp = [0, 2, -3]
outp = extract_values_smaller_than_0(inp)
ans = [-3]
try:
    assert(outp == ans)
except AssertionError as e:
    print("Your function  with input '%r' returned '%r', but I expected '%r'" % (inp, outp, ans))
    raise(e)

inp = [0, 0, 0]
outp = extract_values_smaller_than_0(inp)
ans = []
try:
    assert(outp == ans)
except AssertionError as e:
    print("Your function  with input '%r' returned '%r', but I expected '%r'" % (inp, outp, ans))
    raise(e)

print("Well done!")
```

    Well done!

#### Advanced loops: list comprehensions
Sometimes, writing (and reading!) for-loops can be confusing and lead to "ugly" code. Wouldn't it be nice to represent (small) for-loops on a single line? Python has a way to do this: using what is called `list comprehensions`. It does exactly the same thing as a for-loop: it takes a list, iterates over its entries (and does something with each entry), and (optionally) returns a (modified) list.

Let's look at an arbitrary example of a for-loop over a list:

```python
nums = [0, 1, 2, 3, 4]

# Also, check out the way 'enumerate' is used here!
for index, x in enumerate(nums):
    nums[index] = x ** 2

print(nums)
```

    [0, 1, 4, 9, 16]

You can make this code simpler using a list comprehension:

```python
nums = [0, 1, 2, 3, 4]
squares = [x ** 2 for x in nums] # importantly, a list comprehension always returns a (modified) list!
print(squares)
```

    [0, 1, 4, 9, 16]

Also, list comprehensions may contain if-statements!

```python
string_nums = ['one', 'two', 'three']
starts_with_t = ['yes' if s[0] == 't' else 'no' for s in string_nums]
print(starts_with_t)
```

    ['no', 'yes', 'yes']

ToDo (0 points; optional)

Write a list comprehension that adds the string '\_check' to each value in the list `my_list` below, except if the value is 'B'. (This is an optional ToDo to practice list comprehensions.)

```python
my_list = ['A', 'B', 'C', 'D']
# Implement your list comprehension here!

my_list = [x if x == 'B' else x + "_check" for x in my_list]
my_list

```

    ['A_check', 'B', 'C_check', 'D_check']

List comprehensions are somewhat of a more advanced Python concept, so if you don't feel comfortable using them (correctly) in your future assignments, use regular for-loops by all means! In the upcoming tutorials, though, we'll definitely use them, so make sure you understand what they do!

## 2. Numpy
This section is about Numpy, Python's core library for numeric computing. While the syntax of basic Python (as we've gone over in the previous section) is important to do neuroimaging analysis in Python, knowing numpy is **essential**. Basicially, Numpy is the go-to package whenever you want to do (anything related to) data analysis in Python. Make sure you understand what is going on in this section, as the rest of the course will feature a lot of Numpy-code!

The most important feature of Numpy is it's core data structure: the numpy *ndarray* (which stands for *n*-dimensional array, referring to the fact that the array may be of any dimension: 1D, 2D, 3D, 180D ... *n*D). This is, just like basic lists/dictionaries/integers/tuples, a data structure with its own syntax, operations, and methods, as we will explain below.

### Python lists vs. numpy arrays
Basically, numpy arrays are a lot like Python lists. The major difference, however, is that **numpy arrays may contain only a single data-type**, while Python lists may contain different data-types within the same list.

Let check this out:

```python
# Python lists may contain mixed data-types: an integer, a float, a string, a list
python_list = [1, 2.5, "whatever", [3, 4, 5]]

for value in python_list:

    print("%s is a: %s" % (str(value), type(value)))
```

    1 is a: <class 'int'>
    2.5 is a: <class 'float'>
    whatever is a: <class 'str'>
    [3, 4, 5] is a: <class 'list'>

Unlike Python lists, numpy only allows entries of the same data-type. This difference between Python lists and numpy arrays is basically the same as R lists (allow multiple data-types) versus R matrices/arrays (only allow one data type), and is also the same as MATLAB cells (allow multiple data-types) versus MATLAB matrices (only allow one data type).

In fact, if you try to make a numpy array with different data-types, numpy will force the entries into the same data-type (in a smart way), as is shown in the example below:

```python
import numpy as np
# Importantly, you often specify your arrays as Python lists first, and then convert them to numpy
to_convert_to_numpy = [1, 2, 3.5]               # specify python list ...
numpy_array = np.array(to_convert_to_numpy)     # ... and convert ('cast') it to numpy

for entry in numpy_array:

    print(entry)
    print('this is a: %s \n' % type(entry))
```

    1.0
    this is a: <class 'numpy.float64'>

    2.0
    this is a: <class 'numpy.float64'>

    3.5
    this is a: <class 'numpy.float64'>

```python

```

As you can see, Numpy converted our original list (to_convert_to_numpy), which contained both integers and floats, to an array with only floats! You might think that such a data structure that only allows one single data type is not ideal. However, the very fact that it only contains a single data-type makes operations on numpy arrays extremely fast. For example, loops over numpy arrays are often way faster than loops over python lists. This is because, internally, Python has to check the data-type of each loop entry before doing something with that entry. Because numpy arrays one allow a single data-type, it only has to check for the entries' data type **once**. If you imagine looping over an array or list of length 100,000, you probably understand that the numpy loop is way faster.

Let's check out the speed difference between Python list operations and numpy array operations:

```python
# timeit is a cool 'feature' that you can use in Notebooks (no need to understand how it works)
# it basically performs a computation that you specify a couple of times and prints how long it took on average
results_python = %timeit -o [x * 2 for x in range(0, 100000)]
```

    7.53 ms ± 768 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

And now let's do the same with numpy:

```python
results_numpy = %timeit -o np.arange(0, 10000) * 2

ratio = results_python.average / results_numpy.average
print("Numpy is %i times faster than Python!" % ratio)
```

    19.4 µs ± 2.67 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
    Numpy is 388 times faster than Python!

You see that Numpy 10 times as fast! This really matters when you start doing more complex operations, on, let's say, multidimensional brain images!

### Numpy arrays: creation
As shown an earlier example, numpy arrays can be created as follows:

1. Define a Python list (e.g. `my_list = [0, 1, 2]`)
2. Convert the list to a numpy array (`numpy_array = np.array(my_list)`)

Importantly, a simple Python list will be converted to a 1D numpy array, but a nested Python list will be converted to a 2D (or even higher-dimensional array). Nesting is simply combining different lists, separated by commans, as is shown here:

```python
my_list = [1, 2, 3]
my_array = np.array(my_list)

print("A 1D (or 'flat') array:")
print(my_array, '\n')

my_nested_list = [[1, 2, 3],
                  [4, 5, 6],
                  [7, 8, 9]]

my_2D_array = np.array(my_nested_list)
print("A 2D array:")
print(my_2D_array)
```

    A 1D (or 'flat') array:
    [1 2 3]

    A 2D array:
    [[1 2 3]
     [4 5 6]
     [7 8 9]]

As you can imagine, creating numpy arrays from nested lists becomes cumbersome if you want to create (large) arrays with more than 2 dimensions. There are, fortunately, a lot of other ways to create ('initialize') large, high-dimensional numpy arrays. One often-used method is to create an array with zeros using the numpy function `np.zeros`. This function takes one (mandatory) argument, which is a tuple with the dimensions of your desired array:

```python
my_desired_dimensions = (2, 5) # suppose I want to create a matrix with zeros of size 2 by 5
my_array = np.zeros(my_desired_dimensions)

print(my_array)
```

    [[0. 0. 0. 0. 0.]
     [0. 0. 0. 0. 0.]]

ToDo (1 point)

using the `np.zeros` function, create a five dimensional array of shape $2 \times 3 \times 5 \times 3 \times 7$ and store it in a variable named `array_5d`.

```python
# Implement your ToDo here

# your solution here
dims = (2, 3, 5, 3, 7)
array_5d = np.zeros(dims)
print(array_5d)

```

    [[[[[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]]

      [[[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]]

      [[[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]]]

     [[[[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]]

      [[[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]]

      [[[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]

       [[0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]
        [0. 0. 0. 0. 0. 0. 0.]]]]]

```python
''' Tests the above ToDo. '''
available_vars = dir()
if 'array_5d' not in available_vars:
    raise ValueError("You didn't define the variable 'array_5d'!")

test_create_array_with_zeros(array_5d)
```

Using arrays with zeros is often used in what is called 'pre-allocation', in which you create an 'empty' array with only zeros and for example, 'fill' that array in a loop.

Below, we show you an example in which we pre-allocate an array with 5 zeros, and fill that in a for-loop with the squares of 1 - 5. Try to understand how it works! (You have to do something similar in the next ToDo!)

```python
my_array = np.zeros(5)

print('Original zeros-array')
print(my_array)

for i in range(5):  # notice the range function here! This loop now iterates over [0, 1, 2, 3, 4]
    number_to_calculate_the_square_of = i + 1
    my_array[i] = number_to_calculate_the_square_of ** 2

print('\nFilled array')
print(my_array)
```

    Original zeros-array
    [0. 0. 0. 0. 0.]

    Filled array
    [ 1.  4.  9. 16. 25.]

**Important**: realize that loops (not shown above), if-statements and other boolean logic is the same for numpy and python!

ToDo (2 points)

Write a loop in which you fill each value in the variable `my_array2` with the value 1 divided by the current index plus one. So, suppose the index (usually `i`, like the last example) is 3, then you should fill `my_array2` at index `i` with `1 / (3 + 1)`.

```python
import numpy as np
my_array2 = np.zeros(8)

# your solution here
for i in range(len(my_array2)):
    my_array2[i] = 1 / (i + 1)
my_array2
```

    array([1.        , 0.5       , 0.33333333, 0.25      , 0.2       ,
           0.16666667, 0.14285714, 0.125     ])

```python
''' Tests the above ToDo. '''
available_vars = dir()
if 'my_array2' not in available_vars:
    raise ValueError("The variable my_array2 does not exist!")

test_fill_array_with_complement(my_array2)
```

In addition to `np.zeros`, you can create numpy arrays using other functions, like `np.ones` and `random` from the `np.random` module:

```python
ones = np.ones((5, 10)) # create an array with ones
print(ones, '\n')

rndom = np.random.random((5, 10)) # Create an array filled with random values (0 - 1 uniform)
print(rndom)
```

    [[1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]
     [1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]
     [1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]
     [1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]
     [1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]]

    [[0.58225787 0.43376404 0.96060657 0.33026376 0.69259438 0.99373706
      0.25255857 0.47050424 0.41794689 0.15479334]
     [0.72601987 0.06520818 0.95751049 0.40067078 0.74343008 0.59082416
      0.4996278  0.59197313 0.36211512 0.19820634]
     [0.56748169 0.5800225  0.05323937 0.62624096 0.22568656 0.6317539
      0.05626455 0.39777624 0.86300044 0.58308046]
     [0.02028157 0.28217899 0.39147311 0.13873879 0.8198226  0.33758146
      0.43110568 0.90999583 0.06693565 0.13153269]
     [0.60902323 0.05956734 0.75681971 0.63561485 0.18279177 0.47373918
      0.72369829 0.28068653 0.50107507 0.79094778]]

### Numpy: indexing
Indexing (extracting a single value of an array) and slicing (extracting multiple values - a subset - from an array) of numpy arrays is largely the same as with regular Python lists and data structures from other scientific computing languages such as R and MATLAB. Let's check out a couple of examples of a 1D array:

```python
my_array = np.arange(10, 21)  # numpy equivalent of list(range(10, 21))
print('Full array:')
print(my_array, '\n')

print("Index the first element:")
print(my_array[0], '\n')

print("Index the second-to-last element:")
print(my_array[-2], '\n')

print("Slice from 5 until (not including!) 8")
print(my_array[5:8], '\n')

print("Slice from beginning until 4")
print(my_array[:4])

```

    Full array:
    [10 11 12 13 14 15 16 17 18 19 20]

    Index the first element:
    10

    Index the second-to-last element:
    19

    Slice from 5 until (not including!) 8
    [15 16 17]

    Slice from beginning until 4
    [10 11 12 13]

    range(0, 3)

Setting values in numpy arrays works the same way as lists:

```python
my_array = np.arange(10, 21)
my_array[0] = 10000
print(my_array)

my_array[5:7] = 0
print(my_array)
```

    [10000    11    12    13    14    15    16    17    18    19    20]
    [10000    11    12    13    14     0     0    17    18    19    20]

ToDo (1 point)

In the array `my_array` below, set all odd indices (i.e., the first element, the third element, the fifth element, etc.) to the value `0.0` *in a single statement using a slice* (i.e., not a for-loop).

```python
# Implement the ToDo here
my_array = np.arange(3, 25)

# your solution here
my_array[1:-1:2] = 0.0
my_array
```

    array([ 3,  0,  5,  0,  7,  0,  9,  0, 11,  0, 13,  0, 15,  0, 17,  0, 19,
            0, 21,  0, 23, 24])

```python
''' Tests the above ToDo. '''
test_set_odd_indices_to_zero(my_array)
```

#### Multidimensional indexing
Often, instead of working on and indexing 1D array, we'll work with multi-dimensional (>1D) arrays. Indexing multi-dimensional arrays is, again, quite similar to other scientific computing languages.

Like indexing Python lists, indexing multidimensional numpy arrays is done with square brackets `[]`, in which you can put as many comma-delimited numbers as there are dimensions in your array.

For example, suppose you have a 2D array of shape $3 \times 3$ and you want to index the value in the first row and first column. You would do this as follows:

```python
my_array = np.zeros((3, 3)) # 3 by 3 array with zeros
indexed_value = my_array[0, 0]
print("Value of first row and first column: %.1f" % indexed_value)
```

    Value of first row and first column: 0.0

ToDo (1 point)

Using multidimensional indexing, set the value in the last row and last column of the 2D array `my_array` to `1.0`. In other words, set the value in the lower-right "corner" of the 2D array to 1.0.

```python
import numpy as np
my_array = np.zeros((3, 3))

print(my_array)

# ToDo: set the value in the lower right corner to 1

# your solution here
my_array[-1, -1] = 1.0
print(my_array)

```

    [[0. 0. 0.]
     [0. 0. 0.]
     [0. 0. 0.]]
    [[0. 0. 0.]
     [0. 0. 0.]
     [0. 0. 1.]]

```python
''' Tests the ToDo above. '''
test_set_lower_right_value_to_one(my_array)
```

In addition to setting specific slices to specific values, you can also extract sub-arrays using slicing/indexing. An important construct here is that you can use a single colon `:` to select all values from a particular dimension. For example, if you want to select all column-values (second dimension) from only the first row (first dimension), do this:

```
some_2d_arr[0, :]
```

We'll show you some examples below:

```python
my_array = np.array([[1, 2, 3],
                     [4, 5, 6],
                     [7, 8, 9]])

print(my_array, '\n')

all_column_values_from_first_row = my_array[0, :]
print('First row')
print(all_column_values_from_first_row, '\n')

all_row_values_from_first_col = my_array[:, 0]
print('First column')
print(all_row_values_from_first_col)
```

    [[1 2 3]
     [4 5 6]
     [7 8 9]]

    First row
    [1 2 3]

    First column
    [1 4 7]

So far, we only talked about 2D arrays, which are relatively easy to 'understand'. In neuroimaging, however, we usually work with 3D, 4D, or even higher-dimensional arrays. These arrays are great for organizing data (like 3D brain scans!), but they are somewhat unintuitive. This takes some time to get used to!

To get you used to thinking in more than 2 (or 3) dimensions, consider the following scenario. A researcher wants to test the efficacy of a particular medicine against high blood pressure. To do so, he/she measures the (average systolic) blood pressure of a group of twenty subjects every hour for 30 days when they're not on the medication. The same subjects are then again measured for another 30 days, but then when they're on medication.

After this period of data collection, the researcher has $20\ (subjects)\ \times\ 24\ (hours)\ \times\ 30\ (days)\ \times\ 2\ (conditions: off/on) = 28800$ measurements! We can then organize the data in a 4D array, in which each factor (subjects/hours/days/conditions) represents a separate dimension (also called "axis") of our array!

So, let's generate some random data (from a normal distribution to generate 'realistic' blood pressure data) that could represent this blood pressure dataset:

```python
np.random.seed(42)  # this is not important for now
bp_data = np.random.normal(loc=100, scale=5, size=(20, 24, 30, 2))
```

(Note that we're not printing the `bp_data` variable here, because it's way too much to visualize/interpret at once anyway!)

Now, suppose I would want to extract the blood pressure of subject 5 at 09.00 (AM) in the morning at day 21 when he/she was *not* on medication. In that case, I would do:

```python
# Note that I count midnight as the first measurement
this_particular_datapoint = bp_data[4, 8, 20, 0]
print("Blood pressure of participant 5 at 09.00 (AM) on day 21 in "
      "the no-medication (off) condition is %.3f" % this_particular_datapoint)

# Also, remember that Python is zero-based, so e.g. participant 5 is indexed by index 4!
```

    Blood pressure of participant 5 at 09.00 (AM) on day 21 in the no-medication (off) condition is 100.567

Try to remember this: **in multidimensional arrays, each dimension (axis) represents a different attribute of your data (e.g. subjects, time, condition, etc.)**. This concept will become very important to understand later when we're going to analyze 4D MRI-datasets (with 3 spatial dimensions and 1 time dimension)!

ToDo (1 point)

Consider the blood-pressure dataset again. In the code-cell below, extract from all participants all data from day 18 in the medication ("on") condition. You'll have to use slices with the single `:`! Store the results of your index-operation in the variable `day_18_condition_on`.

```python
# Implement your ToDo here (index the bp_data variable)

# your solution here

```

```python
''' Tests the above ToDo. '''
if 'day_18_condition_on' not in dir():
    raise ValueError("The variable 'day_18_condition_on' is not defined!")

test_bloodpressure_index(day_18_condition_on)
```

Multidimensional indexing using slices (especially the single colon `:` slice, i.e., selecting everything) is very common in scientific programming such as neuroimaging analyses. There is, however, yet another way of (multidimensional) indexing called "boolean indexing".

In this type of indexing, you index an array with a boolean array (i.e. array with True and False values) of the same shape. Basically, when you're indexing the array `my_array` with boolean array `bool_array`, you're saying: "give me all values in `my_array` that are `True` at the same location in `bool_array`!"

Let's look at an example:

```python
my_array = np.array([[1, 2, 3],
                     [4, 5, 6],
                     [7, 8, 9]])

print("The original array:\n")
print(my_array, '\n')

bool_array = np.array([[True, False, True],
                       [False, True, False],
                       [True, False, True]])

print("The boolean array:\n")
print(bool_array, '\n')

print('Result of indexing my_array with bool_array:\n')
print(my_array[bool_array])
```

    The original array:

    [[1 2 3]
     [4 5 6]
     [7 8 9]]

    The boolean array:

    [[ True False  True]
     [False  True False]
     [ True False  True]]

    Result of indexing my_array with bool_array:

    [1 3 5 7 9]

Usually, you do not write out the boolean array in full (as we did above), but you base it on the data itself to "filter" it according to some criterion formalized as a logical statement (i.e., using the boolean operators >, <, ==, or !=). For example, suppose I want to extract only the values above 6 from the `my_array` variable in the above example.

To do so, I could do the following:

```python
my_array = np.array([[1, 2, 3],
                     [4, 5, 6],
                     [7, 8, 9]])

print("The original array:\n")
print(my_array, '\n')

bool_array = my_array > 6

print("The boolean array:\n")
print(bool_array, '\n')

print('Result of indexing my_array with bool_array:\n')
print(my_array[bool_array])
```

    The original array:

    [[1 2 3]
     [4 5 6]
     [7 8 9]]

    The boolean array:

    [[False False False]
     [False False False]
     [ True  True  True]]

    Result of indexing my_array with bool_array:

    [7 8 9]

Easy, right? Now try it yourself in the next ToDo!

ToDo (1 point)

Use a boolean index to extract all values whose square (i.e. $value^2$) is larger than 4 from the array (`my_array`) below. Store the results in a variable with the name `square_is_larger_than_4`.

```python
my_array = np.array([[0, 1, -1, -2],
                     [2, -5, 1, 4],
                     [10, -2, -4, 20]])

# Make a new boolean array below (name it whatever you want) ...

# And use it to index my_array and store it in a variable `square_is_larger_than_4`.
bol_ar = my_array**2 > 4
# your solution here
square_is_larger_than_4 = my_array[bol_ar]
square_is_larger_than_4
```

    array([-5,  4, 10, -4, 20])

```python
if 'square_is_larger_than_4' not in dir():
    raise ValueError("Could not find the variable 'square_is_larger_than_4'; did you name it correctly?")

test_boolean_indexing(square_is_larger_than_4)
```

Again, it's very important to understand how to (effectively) index multidimensional numpy arrays using slices and boolean indexing, as we'll use it a lot in this course!

### Numpy: data-types
Every numpy array is a grid of values of the same type. Numpy provides a large set of numeric datatypes that you can use to construct arrays. Numpy guesses the datatype when you create an array, but functions that construct arrays usually also include an optional argument to explicitly specify the datatype.

Here are a couple of examples:

```python
x1 = np.array([1, 2])  # Let numpy choose the datatype (here: int)
x2 = np.array([1.0, 2.0])  # Let numpy choose the datatype (here: float)
x3 = np.array([1, 2], dtype=np.float64)  # Force a particular datatype (input: int, but converted to 64bit float)
x4 = np.array([-1, 0, 1, 2], dtype=bool)  # Convert ints to booleans! 0 -> False, everthing else -> True

print('%r (%s)' % (x1, type(x1[0])))
print('%r (%s)' % (x2, type(x2[0])))
print('%r (%s)' % (x3, type(x3[0])))
print('%r (%s)' % (x4, type(x4[0])))
```

    array([1, 2]) (<class 'numpy.int64'>)
    array([1., 2.]) (<class 'numpy.float64'>)
    array([1., 2.]) (<class 'numpy.float64'>)
    array([ True, False,  True,  True]) (<class 'numpy.bool_'>)

### Numpy: methods vs. functions
In the previous section (Basic Python), you've learned that, in addition to functions, 'methods' exist that are like functions of an object. In other words, methods are functions that are applied to the object itself. You've seen examples of list methods, e.g. `my_list.append(1)`, and string methods, e.g. `my_string.replace('a', 'b')`.

Like lists and strings, numpy arrays have a lot of convenient methods that you can call. Again, this is just like a function, but then applied to itself. Often, numpy provides both a function and method for simple operations.

Let's look at an example:

```python
my_array = np.arange(10)  # creates a numpy array from 0 until (excluding!) 10
print(my_array, '\n')

mean_array = np.mean(my_array)
print('The mean of the array is: %f' % mean_array, '\n')

mean_array2 = my_array.mean()
print('The mean of the array (computed by its corresponding method) is: %f' % mean_array2, '\n')

print('Is the results from the numpy function the same as '
      'the corresponding method? Answer: %s' % str(mean_array == mean_array2))
```

    [0 1 2 3 4 5 6 7 8 9]

    The mean of the array is: 4.500000

    The mean of the array (computed by its corresponding method) is: 4.500000

    Is the results from the numpy function the same as the corresponding method? Answer: True

If there is both a function and a method for the operation you want to apply to the array, it really doesn't matter what you choose! Let's look at some more (often used) methods of numpy ndarrays:

```python
my_array = np.array([[1, 2, 3],
                     [4, 5, 6],
                     [7, 8, 9]])

std_my_array = my_array.std()  # same as np.std(array)
print("Standard deviation of my_array: %.3f" % std_my_array, '\n')

transpose_my_array = my_array.T  # same as np.transpose(array)
print("Transpose of my_array:\n%r" % transpose_my_array, '\n')

min_my_array = my_array.min()  # same as np.min(array)
print("Minimum of my_array: %i" % my_array.min(), '\n')

max_my_array = my_array.max()  # same as np.max(array)
print("Maximum of my_array: %i" % max_my_array, '\n')

sum_my_array = my_array.sum()  # same as np.sum(array)
print("Sum of my_array: %i" % sum_my_array, '\n')
```

    Standard deviation of my_array: 2.582

    Transpose of my_array:
    array([[1, 4, 7],
           [2, 5, 8],
           [3, 6, 9]])

    Minimum of my_array: 1

    Maximum of my_array: 9

    Sum of my_array: 45

Importantly, a method may or may not take arguments (input).
If no arguments are given, it just looks like "object.method()", i.e. two enclosing brackets with nothing in between.
However, a method may take one or more arguments (like the my_list.append(1) method)!
This argument may be named or unnamed - doesn't matter. An example:

```python
my_array2 = np.random.random((3, 3))
print('Original array:')
print(my_array2, '\n')

print('Use the round() method with the argument 3:')
print(my_array2.round(3), '\n')

print('Use the round() method with the named argument 5:')
print(my_array2.round(decimals=5), '\n')
```

    Original array:
    [[0.32710922 0.30512889 0.84403235]
     [0.6239239  0.29343832 0.71361229]
     [0.34459281 0.58655561 0.99263403]]

    Use the round() method with the argument 3:
    [[0.327 0.305 0.844]
     [0.624 0.293 0.714]
     [0.345 0.587 0.993]]

    Use the round() method with the named argument 5:
    [[0.32711 0.30513 0.84403]
     [0.62392 0.29344 0.71361]
     [0.34459 0.58656 0.99263]]

**Some methods that you'll see a lot in the upcoming tutorials**. In addition to the methods listed above, you'll probably see the following methods a lot in the rest of this course (make sure you understand them!):

Reshaping arrays:

```python
my_array = np.arange(10)
print(my_array.reshape((5, 2))) # reshape to desired shape
```

    [[0 1]
     [2 3]
     [4 5]
     [6 7]
     [8 9]]

```python

```

Ravel ("flatten") an array:

```python
temporary = my_array.reshape((5, 2))
print("Initial shape: %s" % (temporary.shape,))
print(temporary.ravel()) # unroll multi-dimensional array to single 1D array
print("Shape after ravel(): %s" % (temporary.ravel().shape,))
```

    Initial shape: (5, 2)
    [0 1 2 3 4 5 6 7 8 9]
    Shape after ravel(): (10,)

```python
# .dot() does matrix multiplication (dot product: https://en.wikipedia.org/wiki/Dot_product)
# This linear algebra operation is used very often in neuroimaging research
# (which depends heavily on the General Linear Model!)
array1 = np.array([0, 1, 2, 3])
array2 = np.array([4, 5, 6, 7])

dot_product = array1.dot(array2)
print(dot_product)
```

    38

ToDo (1 point)

Let's practice writing functions some more. Complete the function below, named `calculate_range`. This function takes  a single input-argument - a 1D numpy array - and subsequently calculates the [range](https://en.wikipedia.org/wiki/Range_(statistics) of the array. The range is the difference between the maximum and minimum of any given array (vector) $x$:

\begin{align}
range(x) = max(x) - min(x)
\end{align}

You may use the corresponding numpy min/max methods or functions in your custom function, doesn't matter. Don't forget to explicitly return the value of the range!

Note: this custom function that implements the *mathematical* formula for a vector's range is completely unrelated to the *Python function* `range` that is often used in for-loops!

```python
# Complete the function below
def calculate_range(arr):
    ''' Calculate the range of an array.

    Parameters
    ----------
    arr : a 1D numpy array

    Returns
    -------
    The range of the input arr
    '''

    # your solution here
    return arr.max() - arr.min()

```

```python
''' Tests the above ToDo. '''

outp = calculate_range(np.array([0, 1, 2, 3]))
if outp is None:
    raise ValueError("Didn't get any output! Did you explicitly return the range?")

assert(outp == 3)
assert(calculate_range(np.array([-1, 0, 1, 2])) == 3)
assert(calculate_range(np.array([0, 0, 0, 0])) == 0)

print("Well done!")
```

    Well done!

### Numpy: methods vs. attributes?
Alright, by now, if you see a variable followed by a word ending with enclosed brackets, e.g. `my_array.mean()`, you'll know that it's a method! But sometimes you might see something similar, but **without** the brackets, such as `my_array.size`. This `.size` is called an **attribute** of the variable `my_array`. Like a method, it's an integral part of an object (such as a numpy ndarray). The attribute may be of any data-type, like a string, integer, tuple, an array itself. Let's look at an example:

```python
my_array = np.array([[1, 2, 3],
                     [4, 5, 6],
                     [7, 8, 9]])

print(my_array, '\n')
print('The size (number of element) in the array is:')
print(my_array.size, '\n')
print('The .size attribute is of data-type: %s' % type(my_array.size))
len(my_array)
```

    [[1 2 3]
     [4 5 6]
     [7 8 9]]

    The size (number of element) in the array is:
    9

    The .size attribute is of data-type: <class 'int'>

    3

Alright, so by now you might be wondering what the difference between a method and an attribute is. Superficially, you can recognize a method by the form `object.method()` (note the brackets!), like `my_array.round()`; an attribute is virtually the same **but without brackets**, in the form of `object.attribute`, like `my_array.size`.

Conceptually, you may think of methods as things that **do** something with the array, while attributes **say** something about the array.

For example, `my_array.size` **does nothing** with the array - it only **says** something about the array (it gives information about its size), while `my_array.mean()` really **does** something (i.e. calculates the mean of the array).

Again, you might not use attributes a lot during this course, but you'll definitely see them around in the code of the tutorials. Below, some of the common ndarray attributes are listed:

```python
my_array = np.array([[1, 2, 3],
                     [4, 5, 6],
                     [7, 8, 9]])

print('Size (number of elements) of array:')
print(my_array.size, '\n') # returns an integer

print('Shape of array:')
print(my_array.shape, '\n') # this is a tuple!

print('Number of dimensions:')
print(my_array.ndim) # this is an integer
```

    Size (number of elements) of array:
    9

    Shape of array:
    (3, 3)

    Number of dimensions:
    2

ToDo (2 points)

Let's try another one. Complete the function below, named `compute_one_sample_ttest`. This function takes two input arguments:

- arr : a 1D numpy array
- h0 : a scalar value (single number) that represents the value representing the null-hypothesis

The function should compute the one-sample t-test, which tests whether the mean of an array is significantly different from a given value representing the null-hypothesis. Formally, for any given array $x$ and null-hypothesis $h_{0}$:

\begin{align}
t = \frac{\bar{x} - h_{0}}{s\ / \sqrt{N}}
\end{align}

Here: $\bar{x}$ represents the mean of $x$, $s$ represents the standard deviation of $x$, and $N$ represents the length ('size') of x. So, in slightly less mathematical notation:

\begin{align}
t = \frac{mean(x) - h_{0}}{std(x)\ / \sqrt{length(x)}}
\end{align}

Make sure to return the t-value!

**Hint 1**: to compute $N$, you can use the `.size` attribute of an array ...

**Hint 2**: use the function `np.sqrt(some_number)` to calculate the square root ...

```python
# Complete the function below!
def compute_one_sample_ttest(arr, h0):
    ''' Computes the one-sample t-test for any array and h0.

    Parameters
    ----------
    arr : a 1D numpy array
    h0 : an int or float

    Returns
    -------
    A single value representing the t-value
    '''

    # your solution here
    return (arr.mean() - h0) / (arr.std() / np.sqrt(arr.size))

```

```python
''' Tests the ToDo above. '''
arr = np.random.randn(100)
outp = compute_one_sample_ttest(arr , 0)

if outp is None:
    raise ValueError("Your function didn't return anything! Did you forget the return statement?")

test_tvalue_computation(arr, 0, outp)

outp = compute_one_sample_ttest(arr, 5)
test_tvalue_computation(arr, 5, outp)

outp = compute_one_sample_ttest(arr, -3)
test_tvalue_computation(arr, -3, outp)
```

### Numpy: array math
Now you know all the numpy basics necessary to do neuroimaging analysis! As you'll see in the last section (Working with nifti-images), we'll work with 3D (structural MRI images) or 4D (functional MRI images) numpy arrays a lot. Given that you know how the basics about numpy in general and numpy ndarrays in particular, we can utilize some of numpy's best features: (very fast) array math.

Basic mathematical functions operate elementwise on arrays, which means that the operation (e.g. addition) is applied onto each element in the array.

So, let's initialize a 1D array with ten zeros and let's add 1 to it:

```python
x = np.zeros(10)
print(x, '\n')
x += 1 # remember: this the same as x = x + 1
print(x)
```

    [0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]

    [1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]

Additionally, you can also sum two arrays together in an elementwise manner by simply writing: `array_1 + array_2`, given that these two (or more) arrays are of the same shape! Let's look at an example:

```python
x = np.array([[1,2],[3,4]], dtype=np.float64)
y = np.array([[5,6],[7,8]], dtype=np.float64)

print("x: \n%r" % x, '\n')
print("y: \n%r" % y, '\n')
print("x+y: \n%r" % (x + y), '\n')
```

    x:
    array([[1., 2.],
           [3., 4.]])

    y:
    array([[5., 6.],
           [7., 8.]])

    x+y:
    array([[ 6.,  8.],
           [10., 12.]])

Often, there exist function-equivalents of the mathematical operators. For example, `x + y` is the same as `np.add(x, y)`. However, it is recommended to use the operators wherever possible to improve readability of your code. See below for an example:

```python
print(x + y, '\n')
print(np.add(x, y))
```

    [[ 6.  8.]
     [10. 12.]]

    [[ 6.  8.]
     [10. 12.]]

Next to addition, we can also do elementwise subtraction, multiplication, divison, square root, and exponentiation:

```python
# Elementwise difference; both produce the array
print(x - y, '\n')
print(np.subtract(x, y))  # function-equivalent of above
```

    [[-4. -4.]
     [-4. -4.]]

    [[-4. -4.]
     [-4. -4.]]

```python
# Elementwise product; both produce the array
print(x * y, '\n')
print(np.multiply(x, y))
```

    [[ 5. 12.]
     [21. 32.]]

    [[ 5. 12.]
     [21. 32.]]

```python
# Elementwise division; both produce the array
# [[ 0.2         0.33333333]
#  [ 0.42857143  0.5       ]]
print(x / y, '\n')
print(np.divide(x, y))
```

    [[0.2        0.33333333]
     [0.42857143 0.5       ]]

    [[0.2        0.33333333]
     [0.42857143 0.5       ]]

```python
# Elementwise square root; there is no operator-equivalent!
print(np.sqrt(x))
```

    [[1.         1.41421356]
     [1.73205081 2.        ]]

```python
# Elementwise exponentiation
print(x ** y, '\n')
print(np.power(x, y))
```

ToDo (1 point)

Do an elementwise product between the two variables defined below (`arr_A` and `arr_B`) and subsequently add 5 to each element; store the result in a new variable called `result_product_and_sum`.

```python
# Implement the ToDo!
arr_A = np.arange(10).reshape((5, 2))
arr_B = np.arange(10, 20).reshape((5, 2))

# your solution here
result_product_and_sum = arr_A * arr_B + 5

```

```python
''' Tests the above ToDo. '''
if 'result_product_and_sum' not in dir():
    raise ValueError("The variable 'result_product_and_sum' does not seem to exist!")

test_array_product_and_sum(result_product_and_sum)
```

Note that unlike MATLAB, `*` is elementwise multiplication, not matrix multiplication. We instead use the dot function (or method!) to  compute matrix operations like inner products of vectors, multiplication of a vector by a matrix, and multiplication of two matrices.

These matrix operations are quite important in this course, because they are used a lot in neuroimaging methods (such as the General Linear Model, the topic of next week!). You're not expected to fully understand these matrix operations, but you'll see them several times in this course, so make sure you're familiar with its implementation in Python/Numpy.

An example of the inner product ("dot product") of two vectors in both the function-format and the method-format:

```python
v = np.array([9,10])
w = np.array([11, 12])

# Inner product of vectors; both produce 219
print(v.dot(w))
print(np.dot(v, w))
```

    219
    219

Probably the most used functions in numpy are the sum() and mean() fuctions (or equivalent methods!). A nice feature is that they can operate on the entire array (this is the default) or they can be applied per dimension (or, in numpy lingo, per "axis").

Applying functions along axes is very common in scientific computing! Let's look at an example in which we apply the `sum` function/method across rows and columns:

```python
x = np.array([[1, 2],[3, 4], [5, 6]])

print('Original array:')
print(x, '\n')

print('Sum over ALL elements of x:')
print(np.sum(x), '\n')

print('Sum across rows of x:')
print(np.sum(x, axis=0), '\n')

print('Sum across columns of x:')
print(x.sum(axis=1)) # this is the method form! Is exactly the same as np.sum(x, axis=1)
```

    Original array:
    [[1 2]
     [3 4]
     [5 6]]

    Sum over ALL elements of x:
    21

    Sum across rows of x:
    [ 9 12]

    Sum across columns of x:
    [ 3  7 11]

Importantly, application of functions across axes is much quicker (and more concise) than writing for-loops! Let's look at the speed difference between a for-loop (implemented as a list comprehension) and the numpy-style application of the `sum` function across axes:

```python
arr = np.random.random((1000, 100))
loop_style = %timeit -o [arr[i, :].sum() for i in range(arr.shape[0])]
axis_style = %timeit -o arr.sum(axis=1)
print("Using the axis-argument is %.3f times faster than a for-loop!" % (loop_style.average / axis_style.average))
```

    1.76 ms ± 279 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
    60.6 µs ± 6.24 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
    Using the axis-argument is 28.995 times faster than a for-loop!

This application of functions on entire arrays (in an elementwise manner, like `arr_1 + arr_2`) and application of functions across a certain axis (like `np.sum(arr_1, axis=1)`) is often called **vectorization**. This is an incredibly useful concept and something we'll use a lot in this course! Make sure you understand this.

Let's practice this a bit.

ToDo (1 point)

Remember the "range" function from before? Below, we started writing a function, called `calculate_range_vectorized`, that calculates, for any 2D array, the range of each column (i.e. calculates range **across rows** for each column) in a *vectorized manner* (so without any for loops!).

Complete the function, and make sure to return the result, which should be a 1D vector which values represents the ranges for all columns. So, if the input array would be of shape `(100, 10)`, then the shape of your returned array should be `(10,)`.

```python
def compute_range_vectorized(arr):
    ''' Compute the range across rows for each column in a 2D array.

    Parameters
    ----------
    arr : a 2D numpy array

    Returns
    -------
    A 1D vector with the ranges for each column
    '''

    # your solution here
    return arr.max(axis = 0) - arr.min(axis = 0)

```

```python

```

```python
np.random.seed(42)
test_arr = np.random.random((100, 10))
outp = compute_range_vectorized(test_arr)
outp

```

    array([0.97835067, 0.99508565, 0.99111472, 0.94810375, 0.97019384,
           0.98376774, 0.97464742, 0.9860366 , 0.98221455, 0.94601832])

### Broadcasting
Broadcasting is a powerful mechanism that allows numpy to work with arrays of different shapes when performing arithmetic operations. Frequently we have a smaller array and a larger array, and we want to use the smaller array multiple times to perform some operation on the larger array.

For example, suppose that we want to add a vector to each row of a matrix. We could do it like this:

```python
# We will add the vector v to each row of the matrix x,
# storing the result in the matrix y
x = np.array([[1,2,3], [4,5,6], [7,8,9], [10, 11, 12]])

print('x array is of shape: %r' % (x.shape,))
print(x, '\n')

v = np.array([1, 0, 1])
print('v vector is of shape: %r (different shape than x!)' % (v.shape,))
print(v, '\n')

y = np.zeros(x.shape)   # Create an empty (zeros) matrix with the same shape as x
print('Shape of (pre-allocated) y-matrix: %r' % (y.shape,))

# Add the vector v to each row of the matrix x with an explicit loop
for i in range(x.shape[0]): # see how the shape attributes comes in handy in creating loops?
    y[i, :] = x[i, :] + v

print('The result of adding v to each row of x, as stored in y:')
print(y)
```

This works; however when the matrix `x` is very large, computing an explicit for-loop in Python could be slow. Note that adding the vector v to each row of the matrix `x` is equivalent to forming a matrix `vv` by stacking multiple copies of `v` vertically, like this `[[1 0 1], [1 0 1], [1 0 1], [1 0 1]]`, and subsequently elementwise addition of `x + vv`:

```python
vv = np.tile(v, (4, 1)) # i.e. expand vector 'v' 4 times along the row dimension (similar to MATLAB's repmat function)
y = x + vv  # Add x and vv elementwise
print(y)
```

Numpy **broadcasting** allows us to perform this computation without actually creating multiple copies of v. Consider this version, using broadcasting:

```python
# We will add the vector v to each row of the matrix x,
# storing the result in the matrix y
x = np.array([[1,2,3], [4,5,6], [7,8,9], [10, 11, 12]])
v = np.array([1, 0, 1])
y = x + v  # Add v to each row of x using broadcasting
print(y)
```

The line `y = x + v` works even though `x` has shape `(4, 3)` and `v` has shape `(3,)` due to broadcasting; this line works as if v actually had shape `(4, 3)`, where each row was a copy of `v`, and the sum was performed elementwise.

This broadcasting function is really useful, as it prevents us from writing unnessary and by definition slower explicit for-loops. Additionally, it's way easier to read and write than explicit for-loops (which need pre-allocation). Functions that support broadcasting are known as universal functions. You can find the list of all universal functions in the [documentation](http://docs.scipy.org/doc/numpy/reference/ufuncs.html#available-ufuncs).

Here are some applications of broadcasting using different functions:

```python
x = np.array([[1, 2],[3, 4], [5, 6]], dtype=np.float)
x_sum = x.sum(axis=0)

print(x / x_sum, '\n')

x_mean = x.mean(axis=0)
print(x / x_mean)
```

ToDo (2 points)

Below, we started writing a function called `standardize_columns`, which takes a single input-argument - a 2D numpy array - and should subsequently *standardize* each column such that each column has a mean of zero and a standard deviation of 1. In other words, standardization subtracts the mean (across rows) of each column from the values in that column, and then divides those value with the standard deviation of that column. Formally, for each column $j$ in any 2D array, standardization entails:

\begin{align}
x_{j}\ standardized = \frac{(x_{j} - \bar{x_{j}})}{std(x_{j})}
\end{align}

Standardization, which is also oftend called "z-transformation", is often done in statistics. We'll also see this next week.

So, in the function below, make sure that it is able to standardize each column in any 2D array. Make sure to use vectorized array computations (i.e, the `np.mean` and `np.std` functions/methods across rows) and broadcasting, so no for-loops!

```python
def standardize_columns(arr):
    ''' Standardize each column of a 2D input-array.

    Parameters
    ----------
    arr : a 2D numpy array

    Returns
    -------
    The input-array with standardized columns (should have the same shape as the input-array!)
    '''

    # your solution here

```

```python
''' Tests the above ToDo. '''

test_arr = np.random.normal(5, 2, size=(100, 10))
outp = standardize_columns(test_arr)

if outp is None:
    raise ValueError("The output from your function is None; did you forget the Return statement?")

try:
    np.testing.assert_array_almost_equal(outp.mean(axis=0), np.zeros(test_arr.shape[1]))
except AssertionError as e:
    print("The mean of the columns of your standardized array are not 0!")
    raise(e)

try:
    np.testing.assert_array_almost_equal(outp.std(axis=0), np.ones(test_arr.shape[1]))
except AssertionError as e:
    print("The std of the columns of your standardized array are not 1!")
    raise(e)

print("Well done!")
```

Now, you know the most important numpy concepts and functionality that is necessary to do neuroimaging analysis. Surely, there is a lot more to the numpy package that what we've covered here! But for now, let's continue with plotting using Matplotlib!

## 3. Matplotlib
Matplotlib is Python's main plotting library. In this section give a brief introduction to the `matplotlib.pyplot` module, which provides a plotting system similar to that of MATLAB.

```python
import matplotlib.pyplot as plt # this is how matplotlib.pyplot is usually imported
import numpy as np
```

By running this special iPython command, we will be displaying plots inline (instead of in a new window; this is only relevant in Jupyter notebooks!):

```python
%matplotlib inline
```

### Plotting
The most important function in `matplotlib` is **`plot`**, which allows you to plot 1 dimensional data. Here is a simple example:

```python
# Draw a line with coordinates from vectors x and y
x = np.arange(0, 11)  # 0 - 10
y = np.arange(0, 11)  # 0 - 10, same as x

# Plot the points using matplotlib
plt.plot(x, y)
plt.show()
```

With just a little bit of extra work we can easily plot multiple lines at once, and add a title, legend, and axis labels:

```python
y2 = np.ones(x.size)  # vector of ones, i.e., straight line

# Plot the points using matplotlib
plt.plot(x, y)
plt.plot(x, y2)
plt.xlabel('x axis label')
plt.ylabel('y axis label')
plt.title('Two lines')
plt.legend(['Linear increasing line', 'Constant line'])
plt.show()
```

### Subplots
You can plot different subplots in a single figure using the subplot function. Here is an example:

```python
# Compute the x and y coordinates for points on sine and cosine curves
x = np.arange(0, 3 * np.pi, 0.1)
y_sin = np.sin(x)
y_cos = np.cos(x)

# Set up a subplot grid that has 2 'rows' and 1 'column',
# and set the first such subplot as active.
plt.subplot(2, 1, 1)  # the third argument here sets the 'active' plot

# Make the first plot
plt.plot(x, y_sin)
plt.title('Sine')

# Set the second subplot as active, and make the second plot.
plt.subplot(2, 1, 2)
plt.plot(x, y_cos)
plt.title('Cosine')

# Show the figure.
plt.tight_layout() # this prevents plots from overlapping
plt.show()
```

And we can add text to plots as such:

```python
x = np.arange(-1, 1, 0.01) # array from -1 to 1 in steps of 0.01

# Set up a subplot grid that has 2 'rows' and 1 'column',
# and set the first such subplot as active.
plt.subplot(2, 1, 1)
plt.plot(x, x ** 2)
plt.title('x squared')
plt.text(-0.5, 0.4, r"$y=x^2$", fontsize=20, color="blue")

# Set the second subplot as active, and make the second plot.
plt.subplot(2, 1, 2)
plt.plot(x, x ** 3)
plt.title('x cubed')
plt.text(-0.5, 0.4, r"$y=x^3$", fontsize=20, color="green")

plt.tight_layout()
plt.show()
```

You can read much more about the `subplot` function in the [documentation](http://matplotlib.org/api/pyplot_api.html#matplotlib.pyplot.subplot).

ToDo (1 point)

Below, we initialized a 2D array of shape `(10, 100)` named `arr_2D`. In a figure with two subplots (across two columns), plot in the left subplot the mean across columns (i.e., 10 values) and in the right subplot plot the standard deviation across columns (also 10 values). **Hint**: these values represent the values on the `y-axis`. You can use `x = np.arange(10)` for the `x-axis`.

Also, give the subplots labels for the x- and y-axis, and give it a title.

Note: this ToDo is manually graded, so there is no test-cell!

```python
arr_2D = np.random.normal(0, 1, size=(10, 100))

# Make your plot below!

# your solution here
x = np.arange(10)

plt.subplot(1, 2, 1)
plt.plot(x, arr_2D.mean(axis = 1))
plt.title("col means")

plt.subplot(1, 2, 2)
plt.plot(x, arr_2D.std(axis = 1))
plt.title("col stds")
plt.show

```

```python

```

### Histograms
Now let's generate some random data using the np.random functionality, and get some histograms of their distributions

```python
rand_uniform = np.random.uniform(size = (1000)) # uniform distribution
rand_normal = np.random.normal(size = (1000))   # normal distribution
rand_exponential = np.random.exponential(size = (1000)) # exponential distribution
```

```python
# Set up a subplot grid that has rows=2 and columns=3,
# and set the first such subplot as active.
plt.subplot(1, 3, 1)

# Make the first plot
plt.hist(rand_uniform, color='g')
plt.title('uniform')

# Set the second subplot as active, and make the second plot.
plt.subplot(1, 3, 2)
plt.hist(rand_normal, color='b')

plt.title('normal')

# Set the second subplot as active, and make the second plot.
plt.subplot(1, 3, 3)
plt.hist(rand_exponential, color='r')
plt.title('exponential')

plt.tight_layout()
plt.show()
```

### Images
So far, we've been plotting lines. Matplotlib also has easy functionality for plotting two-dimensional data-arrays, such as pictures. This is done using the command `plt.imshow()`. We'll use this feature a lot, because it allows us to visualize 2D slices of MRI-data as well! (Remember, MRI data is essentially just a 3D "picture" of the brain.)

```python
# the scipy module includes some pictures, so we can use those:
from scipy import misc
bw_photo = misc.face(gray=True)

# now we can use the imshow command to plot 2d data:
plt.imshow(bw_photo,cmap='gray')
plt.show()
```

Importanty, photos are just 2D arrays with numbers representing the luminance (in black-and-white pictures) of each pixel. You can see this by printing the loaded image:

```python
print("Shape of photo object: %r" % (bw_photo.shape,), '\n')
print(bw_photo)
```

    Shape of photo object: (768, 1024)

    [[114 130 145 ... 119 129 137]
     [ 83 104 123 ... 118 134 146]
     [ 68  88 109 ... 119 134 145]
     ...
     [ 98 103 116 ... 144 143 143]
     [ 94 104 120 ... 143 142 142]
     [ 94 106 119 ... 142 141 140]]

You can see that it's actually just a bunch of numbers (one number per pixel), and when we plot colorbar with the original picture, we see that high numbers convert to bright shades, and low numbers convert to dark shades.

```python
plt.imshow(bw_photo,cmap='gray')
plt.colorbar()
plt.show()
```

Using the knowledge that black and white images are nothing more than just 2d-arrays of values, we can also generate our own images:

```python
rand_img = np.random.random((200,200)) # Create an array filled with random values
print(rand_img, '\n')
plt.imshow(rand_img, cmap='gray')
plt.colorbar()
plt.show()
```

ToDo (0 points, no test-cell)

Below, we loaded in a 2D array of shape (91, 108) named `mystery_data`. Visualize it using `plt.imshow()` (using the argument `cmap='gray'`) to check out what the data entails!

```python
mystery_data = np.load('mystery_data.npy')

# Visualize it using plt.imshow!
plt.imshow(mystery_data, cmap = 'gray')
plt.colorbar()
plt.show()
```

Now that you know some basics of data types, arrays and plotting, it's time to get to work with some fMRI data!

## 3. Neuroimaging analysis with Python
Although (f)MRI data can come in many formats, by far the most used format is **nifti** (nifti has been developed by the **n**euroimaging **i**n**f**ormatics **t**echnology **i**nitiative). Throughout these tutorials you'll work with these nifti-files (also called nifti images), which you can recognize by their extension of **.nii** or its compressed version **.nii.gz**. This file-format is also used by some of the main neuroimaging software packages such as AFNI, nipype, and FSL. (You'll use FSL later in this course!).

However, we'd like to inspect and analyze nifti images in Python as well! **Nibabel** is a Python package that allows us to read and load nifti images as numpy arrays in a straightforward manner. Let's look at an example:

```python
# If you haven't done so already, load some other packages we need
import numpy as np
import matplotlib.pyplot as plt
import nibabel as nib # common way of importing nibabel
```

    /opt/conda/lib/python3.5/site-packages/h5py/__init__.py:36: FutureWarning: Conversion of the second argument of issubdtype from `float` to `np.floating` is deprecated. In future, it will be treated as `np.float64 == np.dtype(float).type`.
      from ._conv import register_converters as _register_converters

We'll load in an fMRI-scan using nibabel below:

```python
data_path = 'func_data.nii.gz'
data = nib.load(data_path)
```

Right now, we haven't loaded the *actual* data **in memory** yet, but only the meta-data (information **about** the data, such as its shape, dimensions, and some technical MRI acquisition parameters). So, what we've loaded now (in the variable `data`) is **not** a numpy array (yet), but a different object: a `Nifti-object`.

Like a numpy array, this object has some attributes and methods that we can use.
Let's check out some of this meta-data that is already available through its methods/attributes.

For example, `Nifti-objects`, like numpy-arrays, also have a `shape` attribute:

```python
print('Shape of the data:')
print(data.shape) # this is one of the attributes of a Nifti-object in Python, just like a numpy array shape attribute!
```

    Shape of the data:
    (80, 80, 37, 200)

From the shape of the data, we can already infer some things about the file we're working with. Clearly, it has 4 dimensions, which means that it is a functional file: 3 spatial dimensions, and 1 time dimension! Remember the blood-pressure data? This 4D representation of fMRI files is very similar. Here, each axis represents a different feature/factor of the data:

1. the first axis represents the first spatial dimension (the "saggital" dimension, usually referred to as $X$)
2. the second axis represents the second spatial dimension (the "coronal" dimension, usually referred to as $Y$)
3. the third axis represents the third spatial dimension (the "axial" dimension, usually referred to as $Z$)
4. the fourth axis represents the time dimension (usually referred to as $T$, or $N$, "volumes", or "dynamics")

We'll practice with this data-organization later.

Another attribute of this Nifti-object that you might check is the **header** attribute. Perhaps confusingly, the header attribute is another object itself, also with its own methods and attributes. Let's check out some of them:

```python
header = data.header
print('Get the measurements of our dimensions:')
print(header.get_zooms())
```

    Get the measurements of our dimensions:
    (3.0, 3.0, 3.3, 2.0)

Above, the four numbers represent the measurements of the data's four dimensions (3 space dims, 1 time dim). But what the hell do these numbers represent? Millimeters, liters, km/hr?

Actually, the units of these dimensions can be accessed through the `header.get_xyzt_units()` (x, y, and z = spatial dims, t = time dims):

```python
print('Units of our measurements:')
print(header.get_xyzt_units())
# so space is in mm (voxels are 3 x 3 x 3.3 mm in size!) and time is in seconds (time resolution = 2 seconds)
```

    Units of our measurements:
    ('mm', 'sec')

So now we know that our data reflects activity from 3 x 3 x 3.3 mm voxels across 200 timepoints sampled at a time resolution of 2 seconds. Now, let's dig a little deeper into the data! But remember, we still have to load the actual data in memory. We can do that by the `get_data()` method. This method actually returns a numpy 4D-array we can work with!

```python
func_data_in_memory = data.get_data()
print(type(func_data_in_memory))  # this is in fact a numpy array!
```

    <class 'numpy.ndarray'>

ToDo (1 point)

From the variable `func_data_in_memory`, extract (i.e., index) all values (across time) from the voxel located at saggital slice number 30, coronal slice number 50, and axial slice number 23. Store these values in the variable `voxel_timeseries`, which should be of shape `(100,)`.

```python
# Implement the ToDo here (remember: Python's indexing is zero-based!)

# your solution here

```

```python
''' Tests the above ToDo. '''
if 'voxel_timeseries' not in dir():
    raise ValueError("Could not find any variable named 'voxel_timeseries'; did you spell it correctly?")

test_extract_voxel_timeseries(func_data_in_memory, voxel_timeseries)
```

Let's look at some of the data! Because we have $80\ (X) \times\ 80\ (Y)\times\ 37\ (Z)\times\ 100\ (T) = 23680000$ values in total, let's not look at all data at once. We'll select a [axial](https://en.wikipedia.org/wiki/Anatomical_plane) slice somewhere in the middle of the brain of the first recorded volume (i.e. t = 1).

```python
axial_slice_no = 18
slice_1 = func_data_in_memory[:, :, axial_slice_no, 0] # the 0 refers to the first timepoint (t = 1)
print(slice_1, '\n')
print('Shape of slice_1: %r' % list(slice_1.shape))
```

    [[0. 0. 0. ... 0. 0. 0.]
     [0. 0. 0. ... 0. 0. 0.]
     [0. 0. 0. ... 0. 0. 0.]
     ...
     [0. 0. 0. ... 0. 0. 0.]
     [0. 0. 0. ... 0. 0. 0.]
     [0. 0. 0. ... 0. 0. 0.]]

    Shape of slice_1: [80, 80]

Hmm, seems like nothing is going on! This is because Python doesn't print out the entire array, because that would clutter the notebook. Here, it is only plotting the first couple of rows and the last couple of rows from slice_1. The fact that we're only seeing zeros is because not all 23680000 values contain brain data! If a data point has value 0, it is very likely that this data point falls outside the recording of the brain.

Let's check whether this is indeed the case. To visualize the entire slice, let's plot is using `imshow`:

```python
plt.imshow(slice_1, cmap='gray')
plt.axis('off')  # Do not plot the tickmarks of x-axis and y-axis
plt.show()
```

As you can see, only a part of the image is brain data, which is centered in the middle of the image. All completely black locations in the image are represented as zeros in the slice-data. Knowing this, let's select (slice) only a small part of some actual brain data, as visualized below:

```python
import matplotlib.patches as patches

print('We will select (index) the region indicated by red square:')
rect = patches.Rectangle((35, 35), 5, 5, linewidth=2, edgecolor='r', facecolor='none')
plt.imshow(slice_1, cmap='gray')
ax = plt.gca()
ax.add_patch(rect)
plt.axis('off')
plt.show()
```

```python
# The data corresponding to the red square above
print(slice_1[35:41, 35:41])
```

    [[18910.754 19797.008 19496.709 18136.812 16348.341 14807.348]
     [18662.031 19416.506 19315.932 18834.494 17968.053 16776.805]
     [16337.761 18613.24  19266.787 18997.826 18172.816 17015.344]
     [13643.852 16853.826 18110.594 17964.703 17332.225 16250.853]
     [13439.482 16374.295 16968.223 16023.93  15165.015 14135.762]
     [14196.337 16780.568 16897.617 15501.976 14583.225 13987.994]]

What does this tell you? Well, fMRI data is simply a lot of numbers, with (relatively) higher numbers indicating (relatively) higher activity in functional MRI images.

So far, we have only looked at some (static) representation of voxel activity of some voxels at one particular timepoint (i.e, $t = 1$, the first volume of the data), ignoring the time-dimension of our data. We can also look at the activity of a single voxel (e.g., $X = 35$, $Y = 35$, $Z=18$) across time.

This basically amounts to indexing at these X/Y/Z coordinates! We'll show you what this entails conceptually in the code-block below, in which we show you that particular voxel at location X/Y/Z for the first 20 timepoints (not all 100, because that clutters the notebook too much):

```python
volumes_to_plot = 20
plt.figure(figsize=(30, 30))

# Loop over the first 20 volumes/timepoints
for t in range(volumes_to_plot):

    plt.subplot(5, 5, (t+1))
    plt.imshow(func_data_in_memory[:, :, axial_slice_no, t], cmap='gray')  # index with t!
    rect = patches.Rectangle((35, 35), 1, 1, linewidth=1, edgecolor='r', facecolor='none')
    ax = plt.gca()
    ax.add_patch(rect)
    plt.axis('off')
    plt.title('t = %i' % (t + 1), fontsize=20)
plt.show()
```

It is really hard to spot whether the image of the slice is actually different between different time points! To the naked eye, it just seems like the same picture. This is because activity fluctations (over time) in fMRI are **very** small - most of the time just 1-3% compared to baseline (average) activity. That's why it's hard to spot activity differences by looking at the pictures alone.

The activity fluctuations become clearer when you plot the activity of voxels across time in a linegraph (which allows you to see *relative* activity fluctuations better). So, let's plot the activity over time (t = 1:100) for the voxel within the red square in the figure above:

```python
plt.plot(func_data_in_memory[35, 35, axial_slice_no, :])
plt.title('Activity over time for voxel (35, 35, %i)' % axial_slice_no, fontsize=15)
plt.xlabel('Time (t)', fontsize=15)
plt.ylabel('Activity (arbitrary units)', fontsize=15)

plt.show()
```

ToDo (3 points)

Let's practice some of the skills that you learned in this tutorial! For this ToDo, you goal is to **plot an histogram of the average activity value across time for all non-zero voxels in the brain**. So, what you have to do is the following:

1. Average the `func_data_in_memory` variable across time (resulting array should by of shape (80, 80, 37)
2. Remove all voxels which are zero (use boolean indexing!)
3. Make a histogram of all the resulting non-zero voxels (use the argument `bins=100` in `plt.hist`)

Do this in the code-cell below. Also, *give the plot sensible labels for the axes*. This is a manually graded ToDo, so there is no test-cell.

The resulting histogram should show a "bimodal distribution" of average activity values - roughly with a peak around $x = 1000$ and a peak round $x = 10,000$.

```python
# Implement to ToDo here

# your solution here

```

Alright, that's it!