# Numpy -  multidimensional data arrays

This notebook is a copy of J.R. Johansson's excellent [Numpy notebook](https://github.com/jrjohansson/scientific-python-lectures/blob/master/Lecture-2-Numpy.ipynb) with some additional examples from [Jessica B. Hamrick's Numpy notebook](https://github.com/jhamrick/nbgrader-demo/blob/master/instructor/source/ps1/3%20-%20Introduction%20to%20NumPy.ipynb)

```python
%matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
```

## Introduction

The `numpy` package (module) is used in almost all numerical computation using Python. It is a package that provide high-performance vector, matrix and higher-dimensional data structures for Python. It is implemented in C and Fortran so when calculations are vectorized (formulated with vectors and matrices), performance is very good.

To use `numpy` you need to import the module, using for example:

```python
import numpy as np
```

In the `numpy` package the terminology used for vectors, matrices and higher-dimensional data sets is *array*.

NumPy is incredibly powerful and has many features, but this can be a bit intimidating when you're first starting to use it. If you are familiar with other scientific computing languages, the following guides may be of use:

- NumPy for Matlab Users: http://mathesaurus.sourceforge.net/matlab-numpy.html
- NumPy for R (and S-Plus) Users: http://mathesaurus.sourceforge.net/r-numpy.html

## Creating `numpy` arrays

There are a number of ways to initialize new numpy arrays, for example from

* a Python list or tuples
* using functions that are dedicated to generating numpy arrays, such as `arange`, `linspace`, etc.
* reading data from files

| Numpy function | What it does ?                                                                                |
|----------------|-----------------------------------------------------------------------------------------------------------------------|
| np.array       | Convert array like python object to NumPy array                                                                       |
| np.arange      | Create arrays with regularly incrementing values.                                                                     |
| np.ones        | Create an array filled with 1 values                                                                                |
| np.zeros       | Create an array filled with 0 values                                                                                |
| np.full        | Create an array filled with the specified value                                                                       |
| np.random.rand | Generate arrays of random values in the specified shape                                                               |
| np.linspace    | Create arrays with a specified number of elements, and spaced equally between the specified beginning and end values. |

One thing that you can do with lists that you cannot do with NumPy arrays is adding and removing elements. For example, I can create a list and then add elements to it with append:

```python
mylist = []
mylist.append(7)
mylist.append(2)
mylist
```

    [7, 2]

However, you cannot do this with NumPy arrays. If you tried to run the following code, for example:

    myarray = np.array([])
    myarray.append(7)
You'd get an error like this:

    ---------------------------------------------------------------------------
    AttributeError                            Traceback (most recent call last)
    <ipython-input-25-0017a7f2667c> in <module>()
          1 myarray = np.array([])
    ----> 2 myarray.append(7)

    AttributeError: 'numpy.ndarray' object has no attribute 'append'

To create a NumPy array, you must create an array with the correct shape from the beginning. However, the array doesn't have to have all the correct values from the very beginning: these you can fill in later.

### From lists

For example, to create new vector and matrix arrays from Python lists we can use the `numpy.array` function.

```python
# a vector: the argument to the array function is a Python list
v = np.array([1,2,3,4])

v
```

    array([1, 2, 3, 4])

```python
# a matrix: the argument to the array function is a nested Python list
M = np.array([[1, 2], [3, 4]])

M
```

    array([[1, 2],
           [3, 4]])

The `v` and `M` objects are both of the type `ndarray` that the `numpy` module provides.

```python
type(v), type(M)
```

    (numpy.ndarray, numpy.ndarray)

The difference between the `v` and `M` arrays is only their shapes. We can get information about the shape of an array by using the `ndarray.shape` property.

```python
v.shape
```

    (4,)

```python
M.shape
```

    (2, 2)

The number of elements in the array is available through the `ndarray.size` property:

```python
M.size
```

    4

Equivalently, we could use the function `numpy.shape` and `numpy.size`

```python
np.shape(M)
```

    (2, 2)

```python
np.size(M)
```

    4

Some of the important attributes of a NumPy object are:

- **ndim**: displays the dimension of the array
- **shape**: returns a tuple of integers indicating the size of the array
- **size**: returns the total number of elements in the NumPy array
- **dtype**: returns the type of elements in the array, i.e., int64, character
- **itemsize**: returns the size in bytes of each item
- **reshape**: Reshapes the NumPy array

```python
print(M)
print("ndim: %s" % M.ndim)
print("shape: (%s,%s)" % M.shape)
print("size: %s" % M.size)
print("dtype: %s" % M.dtype)
print("itemsize: %s" % M.itemsize)
```

    [[1 2]
     [3 4]]
    ndim: 2
    shape: (2,2)
    size: 4
    dtype: int64
    itemsize: 8

So far the `numpy.ndarray` looks awefully much like a Python list (or nested list). Why not simply use Python lists for computations instead of creating a new array type?

There are several reasons:

* Python lists are very general. They can contain any kind of object. They are dynamically typed. They do not support mathematical functions such as matrix and dot multiplications, etc. Implementing such functions for Python lists would not be very efficient because of the dynamic typing.
* Numpy arrays are **statically typed** and **homogeneous**. The type of the elements is determined when the array is created.
* Numpy arrays are memory efficient.
* Because of the static typing, fast implementation of mathematical functions such as multiplication and addition of `numpy` arrays can be implemented in a compiled language (C and Fortran is used).

Using the `dtype` (data type) property of an `ndarray`, we can see what type the data of an array has:

```python
M.dtype
```

    dtype('int64')

We get an error if we try to assign a value of the wrong type to an element in a numpy array:

```python
M[0,0] = "hello"
```

If we want, we can explicitly define the type of the array data when we create it, using the `dtype` keyword argument:

```python
M = np.array([[1, 2], [3, 4]], dtype=complex)

M
```

    array([[1.+0.j, 2.+0.j],
           [3.+0.j, 4.+0.j]])

Common data types that can be used with `dtype` are: `int`, `float`, `complex`, `bool`, `object`, etc.

We can also explicitly define the bit size of the data types, for example: `int64`, `int16`, `float128`, `complex128`.

### Using array-generating functions

For larger arrays it is inpractical to initialize the data manually, using explicit python lists. Instead we can use one of the many functions in `numpy` that generate arrays of different forms. Some of the more common are:

#### arange

```python
# create a range

x = np.arange(0, 10, 1) # arguments: start, stop, step

x
```

    array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

```python
x = np.arange(-1, 1, 0.1)

x
```

    array([-1.00000000e+00, -9.00000000e-01, -8.00000000e-01, -7.00000000e-01,
           -6.00000000e-01, -5.00000000e-01, -4.00000000e-01, -3.00000000e-01,
           -2.00000000e-01, -1.00000000e-01, -2.22044605e-16,  1.00000000e-01,
            2.00000000e-01,  3.00000000e-01,  4.00000000e-01,  5.00000000e-01,
            6.00000000e-01,  7.00000000e-01,  8.00000000e-01,  9.00000000e-01])

#### linspace and logspace

```python
# using linspace, both end points ARE included
np.linspace(0, 10, 25)
```

    array([ 0.        ,  0.41666667,  0.83333333,  1.25      ,  1.66666667,
            2.08333333,  2.5       ,  2.91666667,  3.33333333,  3.75      ,
            4.16666667,  4.58333333,  5.        ,  5.41666667,  5.83333333,
            6.25      ,  6.66666667,  7.08333333,  7.5       ,  7.91666667,
            8.33333333,  8.75      ,  9.16666667,  9.58333333, 10.        ])

```python
np.logspace(0, 10, 10, base=np.e)
```

    array([1.00000000e+00, 3.03773178e+00, 9.22781435e+00, 2.80316249e+01,
           8.51525577e+01, 2.58670631e+02, 7.85771994e+02, 2.38696456e+03,
           7.25095809e+03, 2.20264658e+04])

#### mgrid

```python
x, y = np.mgrid[0:5, 0:5] # similar to meshgrid in MATLAB
```

```python
x
```

    array([[0, 0, 0, 0, 0],
           [1, 1, 1, 1, 1],
           [2, 2, 2, 2, 2],
           [3, 3, 3, 3, 3],
           [4, 4, 4, 4, 4]])

```python
y
```

    array([[0, 1, 2, 3, 4],
           [0, 1, 2, 3, 4],
           [0, 1, 2, 3, 4],
           [0, 1, 2, 3, 4],
           [0, 1, 2, 3, 4]])

#### random data

```python
from numpy import random
```

```python
# uniform random numbers in [0,1]
np.random.rand(5,5)
```

    array([[0.46526486, 0.33545356, 0.60485063, 0.10888525, 0.21841283],
           [0.33011199, 0.77173134, 0.07957271, 0.51510697, 0.8117858 ],
           [0.1332732 , 0.43125213, 0.61207266, 0.3399414 , 0.66002201],
           [0.17587285, 0.40338213, 0.69172738, 0.28493616, 0.41446249],
           [0.69950479, 0.69028249, 0.22255598, 0.92074117, 0.15612409]])

```python
# standard normal distributed random numbers
np.random.randn(5,5)
```

    array([[-0.49666774, -1.56162203,  0.00368648,  0.50829911,  0.95300545],
           [-1.60435585, -0.24507547,  1.9259452 ,  0.73512728,  0.1072572 ],
           [-0.02770074, -0.25417772, -1.66703327, -0.56003105,  0.70634163],
           [-2.60957982, -0.07397441,  0.8594729 , -1.05122938,  1.53023372],
           [ 1.3725573 , -1.18358119,  0.10393433,  0.65242178,  0.862934  ]])

#### diag

```python
# a diagonal matrix
np.diag([1,2,3])
```

    array([[1, 0, 0],
           [0, 2, 0],
           [0, 0, 3]])

```python
# diagonal with offset from the main diagonal
np.diag([1,2,3], k=1)
```

    array([[0, 1, 0, 0],
           [0, 0, 2, 0],
           [0, 0, 0, 3],
           [0, 0, 0, 0]])

#### zeros and ones

```python
np.zeros((3,3))
```

    array([[0., 0., 0.],
           [0., 0., 0.],
           [0., 0., 0.]])

```python
np.ones((3,3))
```

    array([[1., 1., 1.],
           [1., 1., 1.],
           [1., 1., 1.]])

## Array computations

So far, NumPy arrays seem basically the same as regular lists. What's the big deal about them?

### Working with single arrays

One advantage of using NumPy arrays over lists is the ability to do a computation over the entire array. For example, if you were using lists and wanted to add one to every element of the list, here's how you would do it:

```python
mylist = [3, 6, 1, 0, 10, 22]
mylist_plus1 = []
for x in mylist:
    mylist_plus1.append(x + 1)
mylist_plus1
```

    [4, 7, 2, 1, 11, 23]

Or, you could use a list comprehension:

```python
mylist = [3, 6, 1, 0, 10, 22]
mylist_plus1 = [x + 1 for x in mylist]
mylist_plus1
```

    [4, 7, 2, 1, 11, 23]

In contrast, adding one to every element of a NumPy array is far simpler:

```python
myarray = np.array([3, 6, 1, 0, 10, 22])
myarray_plus1 = myarray + 1
myarray_plus1
```

    array([ 4,  7,  2,  1, 11, 23])

This won't work with normal lists. For example, if you ran mylist + 1, you'd get an error like this:

    ---------------------------------------------------------------------------
    TypeError                                 Traceback (most recent call last)
    <ipython-input-19-5b3951a16990> in <module>()
    ----> 1 mylist + 1

    TypeError: can only concatenate list (not "int") to list
We can do the same thing for subtraction, multiplication, etc.:

```python
print("Subtraction: \t" + str(myarray - 2))
print("Multiplication:\t" + str(myarray * 10))
print("Squared: \t" + str(myarray ** 2))
print("Square root: \t" + str(np.sqrt(myarray)))
print("Exponential: \t" + str(np.exp(myarray)))
```

    Subtraction: 	[ 1  4 -1 -2  8 20]
    Multiplication:	[ 30  60  10   0 100 220]
    Squared: 	[  9  36   1   0 100 484]
    Square root: 	[1.73205081 2.44948974 1.         0.         3.16227766 4.69041576]
    Exponential: 	[2.00855369e+01 4.03428793e+02 2.71828183e+00 1.00000000e+00
     2.20264658e+04 3.58491285e+09]

Working with multiple arrays
We can also easily do these operations for multiple arrays. For example, let's say we want to add the corresponding elements of two lists together. Here's how we'd do it with regular lists:

```python
list_a = [1, 2, 3, 4, 5]
list_b = [6, 7, 8, 9, 10]
list_c = [list_a[i] + list_b[i] for i in range(len(list_a))]
list_c
```

    [7, 9, 11, 13, 15]

With NumPy arrays, we just have to add the arrays together:

```python
array_a = np.array(list_a) # equivalent to np.array([1, 2, 3, 4, 5])
array_b = np.array(list_b) # equivalent to np.array([6, 7, 8, 9, 10])
array_c = array_a + array_b
array_c
```

    array([ 7,  9, 11, 13, 15])

Note: make sure when adding arrays that you are actually working with arrays, because if you try to add two lists, you will *not* get an error. Instead, the lists will be concatenated:

```python
list_a + list_b
```

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

Just as when we are working with a single array, we can add, subtract, divide, multiply, etc. several arrays together:

```python
print("Subtraction: \t" + str(array_a - array_b))
print("Multiplication:\t" + str(array_a * array_b))
print("Exponent: \t" + str(array_a ** array_b))
print("Division: \t" + str(array_a / array_b))
```

    Subtraction: 	[-5 -5 -5 -5 -5]
    Multiplication:	[ 6 14 24 36 50]
    Exponent: 	[      1     128    6561  262144 9765625]
    Division: 	[0.16666667 0.28571429 0.375      0.44444444 0.5       ]

## File I/O

### Comma-separated values (CSV)

A very common file format for data files is comma-separated values (CSV), or related formats such as TSV (tab-separated values). To read data from such files into Numpy arrays we can use the `numpy.genfromtxt` function. For example,

```python
!head data/stockholm_td_adj.dat
```

    1800  1  1    -6.1    -6.1    -6.1 1
    1800  1  2   -15.4   -15.4   -15.4 1
    1800  1  3   -15.0   -15.0   -15.0 1
    1800  1  4   -19.3   -19.3   -19.3 1
    1800  1  5   -16.8   -16.8   -16.8 1
    1800  1  6   -11.4   -11.4   -11.4 1
    1800  1  7    -7.6    -7.6    -7.6 1
    1800  1  8    -7.1    -7.1    -7.1 1
    1800  1  9   -10.1   -10.1   -10.1 1
    1800  1 10    -9.5    -9.5    -9.5 1

```python
data = np.genfromtxt('data/stockholm_td_adj.dat')
```

```python
data.shape
```

    (77431, 7)

```python
fig, ax = plt.subplots(figsize=(14,4))
ax.plot(data[:,0]+data[:,1]/12.0+data[:,2]/365, data[:,5])
ax.axis('tight')
ax.set_title('tempeatures in Stockholm')
ax.set_xlabel('year')
ax.set_ylabel('temperature (C)');
```

Using `numpy.savetxt` we can store a Numpy array to a file in CSV format:

```python
M = np.random.rand(3,3)

M
```

    array([[0.66213699, 0.05990807, 0.39356681],
           [0.82267055, 0.03823638, 0.85601121],
           [0.01918591, 0.34494948, 0.16553998]])

```python
np.savetxt("data/random-matrix.csv", M)
```

```python
!cat data/random-matrix.csv
```

    6.621369884627421598e-01 5.990807076803938980e-02 3.935668104354413943e-01
    8.226705526524893397e-01 3.823637902456511473e-02 8.560112148156683309e-01
    1.918590917359441761e-02 3.449494836897697425e-01 1.655399750304711315e-01

```python
np.savetxt("data/random-matrix.csv", M, fmt='%.5f') # fmt specifies the format

!cat data/random-matrix.csv
```

    0.66214 0.05991 0.39357
    0.82267 0.03824 0.85601
    0.01919 0.34495 0.16554

### Numpy's native file format

Useful when storing and reading back numpy array data. Use the functions `numpy.save` and `numpy.load`:

```python
np.save("data/random-matrix.npy", M)

!file data/random-matrix.npy
```

    data/random-matrix.npy: NumPy array, version 1.0, header length 118

```python
np.load("data/random-matrix.npy")
```

    array([[0.66213699, 0.05990807, 0.39356681],
           [0.82267055, 0.03823638, 0.85601121],
           [0.01918591, 0.34494948, 0.16553998]])

## More properties of the numpy arrays

```python
M.itemsize # bytes per element
```

    8

```python
M.nbytes # number of bytes
```

    72

```python
M.ndim # number of dimensions
```

    2

## Manipulating arrays

### Indexing

We can index elements in an array using square brackets and indices:

```python
# v is a vector, and has only one dimension, taking one index
v[0]
```

    1

```python
# M is a matrix, or a 2 dimensional array, taking two indices
M[1,1]
```

    0.038236379024565115

If we omit an index of a multidimensional array it returns the whole row (or, in general, a N-1 dimensional array)

```python
M
```

    array([[0.66213699, 0.05990807, 0.39356681],
           [0.82267055, 0.03823638, 0.85601121],
           [0.01918591, 0.34494948, 0.16553998]])

```python
M[1]
```

    array([0.82267055, 0.03823638, 0.85601121])

The same thing can be achieved with using `:` instead of an index:

```python
M[1,:] # row 1
```

    array([0.82267055, 0.03823638, 0.85601121])

```python
M[:,1] # column 1
```

    array([0.05990807, 0.03823638, 0.34494948])

We can assign new values to elements in an array using indexing:

```python
M[0,0] = 1
```

```python
M
```

    array([[1.        , 0.05990807, 0.39356681],
           [0.82267055, 0.03823638, 0.85601121],
           [0.01918591, 0.34494948, 0.16553998]])

```python
# also works for rows and columns
M[1,:] = 0
M[:,2] = -1
```

```python
M
```

    array([[ 1.        ,  0.05990807, -1.        ],
           [ 0.        ,  0.        , -1.        ],
           [ 0.01918591,  0.34494948, -1.        ]])

### Index slicing

Index slicing is the technical name for the syntax `M[lower:upper:step]` to extract part of an array:

Slicing rules:

- arr[2:5] will print items 2 to 4. Index in NumPy arrays starts from 0
- arr[2::2] will print items 2 to end skipping 2 items
- arr[::-1] will print the array in the reverse order
- arr[1:] will print from row 1 to end

Accessing a multi-dimensional NumPy array can be done using following form:

```Array[row_start_index:row_end_index, column_start_index: column_end_index]```

Note that the exclusive slice indexing described above is different than some other languages you may be familiar with, like Matlab and R. myarray[1:2] returns only the second elment in myarray in Python, instead of the first and second element.

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

    array([1, 2, 3, 4, 5])

```python
A[1:3]
```

    array([2, 3])

Array slices are *mutable*: if they are assigned a new value the original array from which the slice was extracted is modified:

```python
A[1:3] = [-2,-3]

A
```

    array([ 1, -2, -3,  4,  5])

We can omit any of the three parameters in `M[lower:upper:step]`:

```python
A[::] # lower, upper, step all take the default values
```

    array([ 1, -2, -3,  4,  5])

```python
A[::2] # step is 2, lower and upper defaults to the beginning and end of the array
```

    array([ 1, -3,  5])

```python
A[:3] # first three elements
```

    array([ 1, -2, -3])

```python
A[3:] # elements from index 3
```

    array([4, 5])

Negative indices counts from the end of the array (positive index from the begining):

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

```python
A[-1] # the last element in the array
```

    5

```python
A[-3:] # the last three elements
```

    array([3, 4, 5])

Index slicing works exactly the same way for multidimensional arrays:

```python
A = np.array([[n+m*10 for n in range(5)] for m in range(5)])

A
```

    array([[ 0,  1,  2,  3,  4],
           [10, 11, 12, 13, 14],
           [20, 21, 22, 23, 24],
           [30, 31, 32, 33, 34],
           [40, 41, 42, 43, 44]])

```python
# a block from the original array
A[1:4, 1:4]
```

    array([[11, 12, 13],
           [21, 22, 23],
           [31, 32, 33]])

```python
# strides
A[::2, ::2]
```

    array([[ 0,  2,  4],
           [20, 22, 24],
           [40, 42, 44]])

### Fancy indexing

Sometimes, there are more advanced operations we want to do with NumPy arrays. For example, if we had an array of values and wanted to set all negative values to zero, how would we do this? The answer is called fancy indexing, and be done two ways: boolean indexing, and array indexing.

#### Boolean indexing

The idea behind boolean indexing is that for each element of the array, we know whether we want to select it or not. A boolean array is an array of the same shape as our original array which contains only True and False values. The location of the True values in our boolean array indicate the location of the element in our original array that we want to select, while the location of the False values correspond to those elements in our original array that we don't want to select.

Let's consider some experiment data:

```python
data = np.load("data/experiment_data.npy")
data
```

    array([[1668.07869346,  774.38921876, 3161.14983152, ..., 2359.05394666,
             784.36404676,  448.33416341],
           [2419.38185232,  809.18389145, 2766.62648929, ..., 1159.47379735,
            1330.44887992, 1842.3268586 ],
           [2221.02887591, 1496.00517071,  354.95889145, ..., 1355.74575912,
            1205.29137942, 1385.71283365],
           ...,
           [1654.50469248,  518.3271927 , 5127.58599224, ..., 2544.1042064 ,
             624.07607332, 1029.57386246],
           [ 480.68016502, 4690.12200498, 1520.27397139, ..., 1000.40541618,
             988.73647145,  378.43452948],
           [1823.42891807, 3680.12951133, 3522.94413167, ...,  591.4133153 ,
             383.26367525, 1768.50528483]])

Recall that these are reaction times. It is typically accepted that really low reaction times -- such as less than 100 milliseconds -- are too fast for people to have actually seen and processed the stimulus. Let's see if there are any reaction times less than 100 milliseconds in our data.

To pull out just the elements less than 100 milliseconds, we need two steps. First, we use boolean comparisons to check which are less than 100ms:

```python
too_fast = data < 100
too_fast
```

    array([[False, False, False, ..., False, False, False],
           [False, False, False, ..., False, False, False],
           [False, False, False, ..., False, False, False],
           ...,
           [False, False, False, ..., False, False, False],
           [False, False, False, ..., False, False, False],
           [False, False, False, ..., False, False, False]])

Then, using this `too_fast` array, we can index *back into* the original array, and see that there are indeed some trials which were abnormally fast:

```python
data[too_fast]
```

    array([86.28125135, 76.63231393, 68.72526177, 77.25801031, 97.065495  ,
           92.13792056, 90.05066503, 86.59892207, 96.45674184, 90.79293103,
           81.97898954, 47.59226041, 98.80537434])

What this is doing is essentially saying: for every element in `too_fast` that is `True`, give me the corresponding element in `arr`.

Bcause this is a boolean array, we can also negate it, and pull out all the elements that we consider to be valid reaction times:

```python
data[~too_fast]
```

    array([1668.07869346,  774.38921876, 3161.14983152, ...,  591.4133153 ,
            383.26367525, 1768.50528483])

Not only does this *give* you the elements, but modifying those elements will modify the original array, too. In this case, we will set our "too fast" elements to have a value of "not a number", or `NaN`:

```python
data[too_fast] = np.nan
data
```

    array([[1668.07869346,  774.38921876, 3161.14983152, ..., 2359.05394666,
             784.36404676,  448.33416341],
           [2419.38185232,  809.18389145, 2766.62648929, ..., 1159.47379735,
            1330.44887992, 1842.3268586 ],
           [2221.02887591, 1496.00517071,  354.95889145, ..., 1355.74575912,
            1205.29137942, 1385.71283365],
           ...,
           [1654.50469248,  518.3271927 , 5127.58599224, ..., 2544.1042064 ,
             624.07607332, 1029.57386246],
           [ 480.68016502, 4690.12200498, 1520.27397139, ..., 1000.40541618,
             988.73647145,  378.43452948],
           [1823.42891807, 3680.12951133, 3522.94413167, ...,  591.4133153 ,
             383.26367525, 1768.50528483]])

Now, if we try to find which elements are less than 100 milliseconds, we will not find any:

```python
data[data < 100]
```

    array([], dtype=float64)

#### Exercise: Threshold

Write a function, threshold, which takes an array and returns a new array with values thresholded by the mean of the array.

```python
def threshold(arr):
    """Computes the mean of the given array, and returns a new array which
    is 1 where values in the original array are greater than the mean, 0 where
    they are equal to the mean, and -1 where they are less than the mean.

    Remember that if you want to create a copy of an array, you need to use
    `arr.copy()`.

    Hint: your solution should use boolean indexing, and can be done in six
    lines of code (including the return statement).

    Parameters
    ----------
    arr : numpy.ndarray

    Returns
    -------
    new_arr : thresholded version of `arr`

    """
    # YOUR CODE HERE
    raise NotImplementedError()
```

```python
"""Try a few obvious threshold cases."""
from numpy.testing import assert_array_equal
assert_array_equal(threshold(np.array([1, 1, 1, 1])), np.array([0, 0, 0, 0]))
assert_array_equal(threshold(np.array([1, 0, 1, 0])), np.array([1, -1, 1, -1]))
assert_array_equal(threshold(np.array([1, 0.5, 0, 0.5])), np.array([1, 0, -1, 0]))
assert_array_equal(
    threshold(np.array([[0.5, 0.2, -0.3, 0.1], [1.7, -3.8, 0.5, 0.6]])),
    np.array([[1, 1, -1, 1], [1, -1, 1, 1]]))
print("Success!")
```

```python
"""Make sure a copy of the array is being returned, and that the original array is unmodified."""
x = np.array([[0.5, 0.2, -0.3, 0.1], [1.7, -3.8, 0.5, 0.6]])
y = threshold(x)
assert_array_equal(x, np.array([[0.5, 0.2, -0.3, 0.1], [1.7, -3.8, 0.5, 0.6]]))
assert_array_equal(y, np.array([[1, 1, -1, 1], [1, -1, 1, 1]]))
print("Success!")
```

#### Array Indexing

The other type of fancy indexing is array indexing. Let's consider our average response across participants:

```python
data = np.load("data/experiment_data.npy")
avg_responses = np.mean(data, axis=1)
avg_responses
```

    array([1698.68801725, 1888.71240023, 1796.53362098, 1879.6038851 ,
           1882.53249686, 1824.79568606, 1746.75780815, 1748.55448988,
           1655.75347639, 1740.67757826, 1854.98538242, 1720.70259522,
           1675.2006642 , 1746.52724187, 1768.64738486, 1794.45589925,
           1860.06861469, 1835.73006077, 1520.77977686, 1795.55654863,
           1794.26437533, 1716.73345285, 1740.64166499, 1704.87601852,
           1906.06514665, 1722.68258855, 1857.70131135, 1878.26245376,
           1741.26393398, 1680.21711839, 1830.55940979, 1697.03486501,
           1892.45119973, 1888.69786047, 1653.73721041, 1794.17096019,
           1779.9941148 , 1832.42610672, 1861.63504795, 1685.20108106,
           1652.29647646, 1718.43799102, 1633.30628308, 1686.72435462,
           1810.54490061, 1703.7949561 , 1747.64361845, 1670.90982655,
           1830.47925898, 1771.15425183])

And let's say we also know which element corresponds to which participant, through the following `participants` array:

```python
participants = np.load("data/experiment_participants.npy")
participants
```

    array(['p_045', 'p_039', 'p_027', 'p_023', 'p_041', 'p_008', 'p_025',
           'p_019', 'p_036', 'p_049', 'p_050', 'p_029', 'p_032', 'p_006',
           'p_028', 'p_034', 'p_044', 'p_016', 'p_010', 'p_017', 'p_022',
           'p_033', 'p_042', 'p_009', 'p_047', 'p_035', 'p_002', 'p_014',
           'p_020', 'p_043', 'p_003', 'p_012', 'p_030', 'p_015', 'p_011',
           'p_018', 'p_004', 'p_040', 'p_001', 'p_031', 'p_005', 'p_013',
           'p_046', 'p_038', 'p_021', 'p_026', 'p_024', 'p_048', 'p_007',
           'p_037'], dtype='<U5')

In other words, the first element of `avg_responses` corresponds to the first element of `participants` (so participant 45), the second element of `avg_responses` was given by participant 39, and so on.

Let's say we wanted to know what participants had the largest average response, and what participants had the smallest average response. To do this, we might try sorting the responses:

```python
np.sort(avg_responses)
```

    array([1520.77977686, 1633.30628308, 1652.29647646, 1653.73721041,
           1655.75347639, 1670.90982655, 1675.2006642 , 1680.21711839,
           1685.20108106, 1686.72435462, 1697.03486501, 1698.68801725,
           1703.7949561 , 1704.87601852, 1716.73345285, 1718.43799102,
           1720.70259522, 1722.68258855, 1740.64166499, 1740.67757826,
           1741.26393398, 1746.52724187, 1746.75780815, 1747.64361845,
           1748.55448988, 1768.64738486, 1771.15425183, 1779.9941148 ,
           1794.17096019, 1794.26437533, 1794.45589925, 1795.55654863,
           1796.53362098, 1810.54490061, 1824.79568606, 1830.47925898,
           1830.55940979, 1832.42610672, 1835.73006077, 1854.98538242,
           1857.70131135, 1860.06861469, 1861.63504795, 1878.26245376,
           1879.6038851 , 1882.53249686, 1888.69786047, 1888.71240023,
           1892.45119973, 1906.06514665])

However, we then don't know which responses correspond to which trials. A different way to do this would be to use `np.argsort`, which returns an array of indices corresponding to the sorted order of the elements, rather than the elements in sorted order:

```python
np.argsort(avg_responses)
```

    array([18, 42, 40, 34,  8, 47, 12, 29, 39, 43, 31,  0, 45, 23, 21, 41, 11,
           25, 22,  9, 28, 13,  6, 46,  7, 14, 49, 36, 35, 20, 15, 19,  2, 44,
            5, 48, 30, 37, 17, 10, 26, 16, 38, 27,  3,  4, 33,  1, 32, 24])

What this says is that element 18 is the smallest response, element 42 is the next smallest response, and so on, all the way to element 24, which is the largest response:

```python
avg_responses[18]
```

    1520.7797768567086

```python
avg_responses[42]
```

    1633.3062830758922

```python
avg_responses[24]
```

    1906.0651466520821

To use fancy indexing, we can actually use this array of integers as an index. If we use it on the original array, then we will obtain the sorted elements:

```python
avg_responses[np.argsort(avg_responses)]
```

    array([1520.77977686, 1633.30628308, 1652.29647646, 1653.73721041,
           1655.75347639, 1670.90982655, 1675.2006642 , 1680.21711839,
           1685.20108106, 1686.72435462, 1697.03486501, 1698.68801725,
           1703.7949561 , 1704.87601852, 1716.73345285, 1718.43799102,
           1720.70259522, 1722.68258855, 1740.64166499, 1740.67757826,
           1741.26393398, 1746.52724187, 1746.75780815, 1747.64361845,
           1748.55448988, 1768.64738486, 1771.15425183, 1779.9941148 ,
           1794.17096019, 1794.26437533, 1794.45589925, 1795.55654863,
           1796.53362098, 1810.54490061, 1824.79568606, 1830.47925898,
           1830.55940979, 1832.42610672, 1835.73006077, 1854.98538242,
           1857.70131135, 1860.06861469, 1861.63504795, 1878.26245376,
           1879.6038851 , 1882.53249686, 1888.69786047, 1888.71240023,
           1892.45119973, 1906.06514665])

And if we use it on our array of participants, then we can determine what participants had the largest and smallest responses:

```python
participants[np.argsort(avg_responses)]
```

    array(['p_010', 'p_046', 'p_005', 'p_011', 'p_036', 'p_048', 'p_032',
           'p_043', 'p_031', 'p_038', 'p_012', 'p_045', 'p_026', 'p_009',
           'p_033', 'p_013', 'p_029', 'p_035', 'p_042', 'p_049', 'p_020',
           'p_006', 'p_025', 'p_024', 'p_019', 'p_028', 'p_037', 'p_004',
           'p_018', 'p_022', 'p_034', 'p_017', 'p_027', 'p_021', 'p_008',
           'p_007', 'p_003', 'p_040', 'p_016', 'p_050', 'p_002', 'p_044',
           'p_001', 'p_014', 'p_023', 'p_041', 'p_015', 'p_039', 'p_030',
           'p_047'], dtype='<U5')

So, in this case, participant 10 had the smallest average response, while participant 47 had the largest average response.

#### From boolean to integer indices
Sometimes, we want to use a combination of boolean and array indexing. For example, if we wanted to pull out just the responses for participant 2, a natural approach would be to use boolean indexing:

```python
participant_2_responses = data[participants == 'p_002']
participant_2_responses
```

    array([[ 1519.95398267,  2268.97864618,  1195.65942267,   504.90066814,
             1801.02089755,   925.24286169,  1810.30761149,  1325.80705157,
             1175.54586424, 10408.41065951,  2455.08935241,   357.09813683,
             1772.0844697 ,  1196.14813706,  8314.75793716,  2675.68801506,
              730.08949799,   425.0148563 ,  2402.60428235,  1428.93884317,
              227.37915983,  1302.38963652,  1593.64880997,  1806.89076956,
             2533.22196284,  1207.73423053,  6303.25912496,  8886.54201129,
              994.92553099,  1713.41231842,   401.87479894,  2505.85572837,
             1952.73193948,   207.56620328,   603.30726209,  3616.1399997 ,
              830.73548608,  1068.04701882,  1469.02747094,  6370.22310164,
              604.55979416,  9081.14741057,  1965.26863145,  3518.87071627,
              517.41562916,  8229.56635964,   461.33763334,   780.20882914,
             1650.10585622,  1314.87174398,  1224.22622967,  2083.55702061,
             2968.86713293,  1869.42631685,  1111.82126554,  1948.83806694,
             2415.18346365,  4444.99151467,  1597.0267606 ,   499.87985072,
              957.05596729,  1203.67765498,  1664.34221178,  5003.75224736,
              967.55566474,   636.93388007,   915.99357265,  1120.80187967,
              377.40672475,  1121.26218759,  1816.92139864,  1024.03377902,
             1812.41020669,  2212.16494348,  6106.52376689,  1007.71524381,
              695.393052  ,   407.26961297,  1131.45314218,  3049.94912138,
             4876.37266626,  1128.91791729,  1141.67606093,  1135.00006133,
             6758.10849749,  1593.05943482,  1283.63625911,  2788.64115941,
              581.88359516,   543.51599043,  4434.08247551,   430.26052784,
             1070.51847351,  4064.66525915,  1549.92984649,   455.33216248,
             1972.20829979,  9066.32915959,   896.51734192,  1314.62319535,
             1296.01410683,   996.61616492,  6105.75385258,  1561.19737687,
             3383.1386535 ,  1292.54120004,  3853.30151832,   835.38429318,
              743.8276473 ,  1577.88578247,  1306.19949451,  1470.75195203,
              555.2578609 ,   217.45067187,  1256.68339959,  1186.51907184,
              489.1850599 ,  1853.43143023,  1995.60458866,   833.29382847,
             1034.45330302,  1683.02047154,   545.29081816,  1838.80743097,
              906.75426918,  1244.26630466,  1321.3404664 ,  1587.52857985,
             1410.35585247,  3105.319317  ,  1411.51416317,  5443.92972674,
              752.0169473 ,   586.37613326,   572.36658753,   957.10289119,
             1558.40600015,   372.5043349 ,   943.38369338,  4163.59377408,
              345.63171723,  2984.97324499,  1125.20342404,   919.58874102,
             3128.38041682,  1630.50710335, 13257.2010995 ,   910.32949952,
             1195.78829195,  2170.91665291,  1282.76326571,   827.92918649,
              857.79668237,   828.99037595,   934.21570992,  1665.98261859,
              897.51378789,   739.37860435,  1804.45818838,  1943.92552284,
              927.32634226,  1487.13836925,  5317.54152507,   807.78268149,
             1180.09915534,  2179.15666221,  2274.58915959,  5160.42914214,
             3252.8608973 ,  3566.97537043,   770.46953568,   389.88662928,
              281.21668416,  1641.56109359,  5993.78533537,  2193.09567337,
             4281.75189016,   857.57255182,   446.91109426,   604.31734423,
             1029.6613233 ,  2003.21326662,   555.0428431 ,  1000.38054076,
              782.61377775,   559.70248518,   465.53667091,  1508.97589903,
             1690.79653218,   868.60103515,   324.63978712,  3831.94213437,
             2389.33460775,   328.24630795,   453.09315188,   667.70706354,
              861.83882331,   449.95923305,  4161.35247677,  2838.9239933 ,
             3866.1204718 ,  2132.74921404,   456.46404904,  1444.91289822,
             2859.86453994,   625.01703188,  4696.29293529,  1706.16089941,
             2351.7652479 ,  1460.95979994,   324.1746197 ,   623.50157152,
             1320.7807782 ,  1187.47604577,  3028.69232087,  2572.00444369,
             2234.92844359,  2459.0302512 ,  4092.38986667,   749.89150303,
             1094.74299543,  2241.59126084,  3656.92713699,  2605.88444652,
             7354.29967514,  2319.71428619,  2494.45531085,   870.8280167 ,
              314.48611818,  4831.59363981,   463.49488283,  1220.11219412,
             1052.19978516,  3085.03346083,  3183.2792087 ,  1348.71168181,
             1634.84655526,   659.9016014 ,  2063.0466589 ,  4471.92194045,
             1775.15128191,  2662.54731125,  1201.29944168,   872.85026268,
              256.10174776,   660.29442355,  1598.19175661,  1674.52470932,
             1009.36470532,  3191.14929254,  2093.43620826,   673.92808951,
              442.93059594,   414.55955897,  4675.46550612,   664.7509365 ,
              693.55749027,   327.38604708,  1024.30201959,   821.20424941,
             1012.09661072,  1968.39632341,   512.52087257,   904.05253792,
              345.20284479,  1720.46736155,   864.85812872,  1294.54236543,
              841.30036722,  2494.56452101,  2126.02112408,   598.89309697,
             1177.36784728,  1475.24512926,  2260.77568058,  1753.02932524,
             1705.66756459,  1328.4368784 ,   791.14544572,   995.85046493,
             1049.73139615,   477.47972252,  2460.87899716,   747.58460743,
             1214.14766658,   609.2686128 ,   966.95494309,  1128.69125645,
              351.02567865,   855.90684706,   497.76945168,  2322.1211325 ,
             3505.39941986,   919.77372431,   532.62433677,  1071.06498294,
             1310.0555865 ,  2448.01497075,  1075.98009604,   723.05119455]])

Another way that we could do this would be to determine the *index* of participant 2, and then use that to index into `data`. To do this, we can use a function called `np.argwhere`, which returns the indices of elements that are true:

```python
np.argwhere(participants == 'p_002')
```

    array([[26]])

So in this case, we see that participant 2 corresponds to index 26.

#### Exercise: Averaging responses

Write a function that takes as arguments a participant id, the data, and the list of participant names, and computes the average response for the given participant.

```python
def participant_mean(participant, data, participants):
    """Computes the mean response for the given participant. A ValueError
    should be raised if more than one participant has the given name.

    Hint: your solution should use `np.argwhere`, and can be done in
    four lines (including the return statement).

    Parameters
    ----------
    participant: string
        The name/id of the participant
    data: numpy.ndarray with shape (n, m)
        Rows correspond to participants, columns to trials
    participants: numpy.ndarray with shape(n,)
        A string array containing participant names/ids, corresponding to
        the rows of the `data` array.

    Returns
    -------
    float: the mean response of the participant over all trials

    """
    # YOUR CODE HERE
    raise NotImplementedError()
```

```python
"""Check for correct answers with the example experiment data."""
from numpy.testing import assert_allclose
data = np.load("data/experiment_data.npy")
participants = np.load("data/experiment_participants.npy")
assert_allclose(participant_mean('p_002', data, participants), 1857.7013113499095)
assert_allclose(participant_mean('p_047', data, participants), 1906.0651466520821)
assert_allclose(participant_mean('p_013', data, participants), 1718.4379910225193)
print("Success!")
```

```python
"""Check for correct answers for some different data."""
data = np.arange(32).reshape((4, 8))
participants = np.array(['a', 'b', 'c', 'd'])
assert_allclose(participant_mean('a', data, participants), 3.5)
assert_allclose(participant_mean('b', data, participants), 11.5)
assert_allclose(participant_mean('c', data, participants), 19.5)
assert_allclose(participant_mean('d', data, participants), 27.5)
print("Success!")
```

```python
"""Check that a ValueError is raised when the participant name is not unique."""
from nose.tools import assert_raises
data = np.arange(32).reshape((4, 8))
participants = np.array(['a', 'b', 'c', 'a'])
assert_raises(ValueError, participant_mean, 'a', data, participants)
print("Success!")
```

## Functions for extracting data from arrays and creating arrays

### where

The index mask can be converted to position index using the `where` function

```python
indices = np.where(too_fast)

indices
```

    (array([ 0,  7, 12, 15, 15, 20, 27, 36, 39, 39, 42, 43, 49]),
     array([258, 145, 124, 148, 206, 161, 211, 265, 258, 273, 190, 232, 146]))

```python
data[indices] # this indexing is equivalent to the fancy indexing data[too_fast]
```

    array([86.28125135, 76.63231393, 68.72526177, 77.25801031, 97.065495  ,
           92.13792056, 90.05066503, 86.59892207, 96.45674184, 90.79293103,
           81.97898954, 47.59226041, 98.80537434])

### diag

With the diag function we can also extract the diagonal and subdiagonals of an array:

```python
np.diag(A)
```

    array([ 0, 11, 22, 33, 44])

```python
np.diag(A, -1)
```

    array([10, 21, 32, 43])

### take

The `take` function is similar to fancy indexing described above:

```python
v2 = np.arange(-3,3)
v2
```

    array([-3, -2, -1,  0,  1,  2])

```python
row_indices = [1, 3, 5]
v2[row_indices] # fancy indexing
```

    array([-2,  0,  2])

```python
v2.take(row_indices)
```

    array([-2,  0,  2])

But `take` also works on lists and other objects:

```python
np.take([-3, -2, -1,  0,  1,  2], row_indices)
```

    array([-2,  0,  2])

### choose

Constructs an array by picking elements from several arrays:

```python
which = [1, 0, 1, 0]
choices = [[-2,-2,-2,-2], [5,5,5,5]]

np.choose(which, choices)
```

    array([ 5, -2,  5, -2])

## Linear algebra

Vectorizing code is the key to writing efficient numerical calculation with Python/Numpy. That means that as much as possible of a program should be formulated in terms of matrix and vector operations, like matrix-matrix multiplication.

NumPy arrays are capable of performing all basic operations such as addition, subtraction, element-wise product, matrix dot product, element-wise division, element-wise modulo, element-wise exponents and conditional operations.

An important feature with NumPy arrays is broadcasting:

```python
# Without broadcasting
a = np.array([0, 1, 2])
b = np.array([5, 5, 5])
a + b
```

    array([5, 6, 7])

```python
# With broadcasting
a + 5
```

    array([5, 6, 7])

### Scalar-array operations

We can use the usual arithmetic operators to multiply, add, subtract, and divide arrays with scalar numbers.

```python
v1 = np.arange(0, 5)
```

```python
v1 * 2
```

    array([0, 2, 4, 6, 8])

```python
v1 + 2
```

    array([2, 3, 4, 5, 6])

```python
A * 2, A + 2
```

    (array([[ 0,  2,  4,  6,  8],
            [20, 22, 24, 26, 28],
            [40, 42, 44, 46, 48],
            [60, 62, 64, 66, 68],
            [80, 82, 84, 86, 88]]),
     array([[ 2,  3,  4,  5,  6],
            [12, 13, 14, 15, 16],
            [22, 23, 24, 25, 26],
            [32, 33, 34, 35, 36],
            [42, 43, 44, 45, 46]]))

### Element-wise array-array operations

When we add, subtract, multiply and divide arrays with each other, the default behaviour is **element-wise** operations:

```python
A * A # element-wise multiplication
```

    array([[   0,    1,    4,    9,   16],
           [ 100,  121,  144,  169,  196],
           [ 400,  441,  484,  529,  576],
           [ 900,  961, 1024, 1089, 1156],
           [1600, 1681, 1764, 1849, 1936]])

```python
v1 * v1
```

    array([ 0,  1,  4,  9, 16])

If we multiply arrays with compatible shapes, we get an element-wise multiplication of each row:

```python
A.shape, v1.shape
```

    ((5, 5), (5,))

```python
A * v1
```

    array([[  0,   1,   4,   9,  16],
           [  0,  11,  24,  39,  56],
           [  0,  21,  44,  69,  96],
           [  0,  31,  64,  99, 136],
           [  0,  41,  84, 129, 176]])

### Matrix algebra

What about matrix mutiplication? There are two ways. We can either use the `dot` function, which applies a matrix-matrix, matrix-vector, or inner vector multiplication to its two arguments:

```python
np.dot(A, A)
```

    array([[ 300,  310,  320,  330,  340],
           [1300, 1360, 1420, 1480, 1540],
           [2300, 2410, 2520, 2630, 2740],
           [3300, 3460, 3620, 3780, 3940],
           [4300, 4510, 4720, 4930, 5140]])

```python
np.dot(A, v1)
```

    array([ 30, 130, 230, 330, 430])

```python
np.dot(v1, v1)
```

    30

Alternatively, we can cast the array objects to the type `matrix`. This changes the behavior of the standard arithmetic operators `+, -, *` to use matrix algebra.

```python
M = np.matrix(A)
v = np.matrix(v1).T # make it a column vector
```

```python
v
```

    matrix([[0],
            [1],
            [2],
            [3],
            [4]])

```python
M * M
```

    matrix([[ 300,  310,  320,  330,  340],
            [1300, 1360, 1420, 1480, 1540],
            [2300, 2410, 2520, 2630, 2740],
            [3300, 3460, 3620, 3780, 3940],
            [4300, 4510, 4720, 4930, 5140]])

```python
M * v
```

    matrix([[ 30],
            [130],
            [230],
            [330],
            [430]])

```python
# inner product
v.T * v
```

    matrix([[30]])

```python
# with matrix objects, standard matrix algebra applies
v + M*v
```

    matrix([[ 30],
            [131],
            [232],
            [333],
            [434]])

If we try to add, subtract or multiply objects with incomplatible shapes we get an error:

```python
v = np.matrix([1,2,3,4,5,6]).T
```

```python
np.shape(M), np.shape(v)
```

    ((5, 5), (6, 1))

```python
M * v
```

See also the related functions: `inner`, `outer`, `cross`, `kron`, `tensordot`. Try for example `help(kron)`.

### Array/Matrix transformations

Above we have used the `.T` to transpose the matrix object `v`. We could also have used the `transpose` function to accomplish the same thing.

Other mathematical functions that transform matrix objects are:

```python
C = np.matrix([[1j, 2j], [3j, 4j]])
C
```

    matrix([[0.+1.j, 0.+2.j],
            [0.+3.j, 0.+4.j]])

```python
np.conjugate(C)
```

    matrix([[0.-1.j, 0.-2.j],
            [0.-3.j, 0.-4.j]])

Hermitian conjugate: transpose + conjugate

```python
C.H
```

    matrix([[0.-1.j, 0.-3.j],
            [0.-2.j, 0.-4.j]])

We can extract the real and imaginary parts of complex-valued arrays using `real` and `imag`:

```python
np.real(C) # same as: C.real
```

    matrix([[0., 0.],
            [0., 0.]])

```python
np.imag(C) # same as: C.imag
```

    matrix([[1., 2.],
            [3., 4.]])

Or the complex argument and absolute value

```python
np.angle(C+1) # heads up MATLAB Users, angle is used instead of arg
```

    matrix([[0.78539816, 1.10714872],
            [1.24904577, 1.32581766]])

```python
abs(C)
```

    matrix([[1., 2.],
            [3., 4.]])

### Matrix computations

#### Inverse

```python
np.linalg.inv(C) # equivalent to C.I
```

    matrix([[0.+2.j , 0.-1.j ],
            [0.-1.5j, 0.+0.5j]])

```python
C.I * C
```

    matrix([[1.00000000e+00+0.j, 0.00000000e+00+0.j],
            [1.11022302e-16+0.j, 1.00000000e+00+0.j]])

#### Determinant

```python
np.linalg.det(C)
```

    (2.0000000000000004+0j)

```python
np.linalg.det(C.I)
```

    (0.4999999999999997+0j)

### Data processing

Often it is useful to store datasets in Numpy arrays. Numpy provides a number of functions to calculate statistics of datasets in arrays.

For example, let's calculate some properties from the Stockholm temperature dataset used above.

```python
# load tempeature dataset into the data variable:
data = np.genfromtxt('data/stockholm_td_adj.dat')
np.shape(data)
```

    (77431, 7)

#### mean

```python
# the temperature data is in column 3
np.mean(data[:,3])
```

    6.197109684751585

The daily mean temperature in Stockholm over the last 200 years has been about 6.2 C.

#### standard deviations and variance

```python
np.std(data[:,3]), np.var(data[:,3])
```

    (8.282271621340573, 68.59602320966341)

#### min and max

```python
# lowest daily average temperature
data[:,3].min()
```

    -25.8

```python
# highest daily average temperature
data[:,3].max()
```

    28.3

#### sum, prod, and trace

```python
d = np.arange(0, 10)
d
```

    array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

```python
# sum up all elements
np.sum(d)
```

    45

```python
# product of all elements
np.prod(d+1)
```

    3628800

```python
# cummulative sum
np.cumsum(d)
```

    array([ 0,  1,  3,  6, 10, 15, 21, 28, 36, 45])

```python
# cummulative product
np.cumprod(d+1)
```

    array([      1,       2,       6,      24,     120,     720,    5040,
             40320,  362880, 3628800])

```python
# same as: diag(A).sum()
np.trace(A)
```

    110

## Exercise: Euclidean distance

Recall that the Euclidean distance $d$ is given by the following equation:

$$
d(a, b) = \sqrt{\sum_{i=1}^N (a_i - b_i) ^ 2}
$$

In NumPy, this is a fairly simple computation because we can rely on array computations and the `np.sum` function to do all the heavy lifting for us.

Complete the function euclidean_distance below to compute $d(a,b)$, as given by the equation above. Note that you can compute the square root using np.sqrt.

```python
def euclidean_distance(a, b):
    """Computes the Euclidean distance between a and b.

    Hint: your solution can be done in a single line of code!

    Parameters
    ----------
    a, b : numpy arrays or scalars with the same size

    Returns
    -------
    the Euclidean distance between a and b

    """
    # YOUR CODE HERE
    raise NotImplementedError()
```

Remember that you need to execute the cell above (with your definition of euclidean_distance), and then run the cell below to check your answer. If you make changes to the cell with your answer, you will need to first re-run that cell, and then re-run the test cell to check your answer again.

```python
from nose.tools import assert_equal, assert_raises

# check euclidean distance of size 3 integer arrays
a = np.array([1, 2, 3])
b = np.array([4, 5, 6])
assert_equal(euclidean_distance(a, b), 5.196152422706632)

# check euclidean distance of size 4 float arrays
x = np.array([3.6, 7., 203., 3.])
y = np.array([6., 20.2, 1., 2.])
assert_equal(euclidean_distance(x, y), 202.44752406487959)

# check euclidean distance of scalars
assert_equal(euclidean_distance(1, 0.5), 0.5)

# check that an error is thrown if the arrays are different sizes
a = np.array([1, 2, 3])
b = np.array([4, 5])
assert_raises(ValueError, euclidean_distance, a, b)
assert_raises(ValueError, euclidean_distance, b, a)

print("Success!")
```

### Computations on subsets of arrays

We can compute with subsets of the data in an array using indexing, fancy indexing, and the other methods of extracting data from an array (described above).

For example, let's go back to the temperature dataset:

```python
!head -n 3 data/stockholm_td_adj.dat
```

    1800  1  1    -6.1    -6.1    -6.1 1
    1800  1  2   -15.4   -15.4   -15.4 1
    1800  1  3   -15.0   -15.0   -15.0 1

The dataformat is: year, month, day, daily average temperature, low, high, location.

If we are interested in the average temperature only in a particular month, say February, then we can create a index mask and use it to select only the data for that month using:

```python
data = np.genfromtxt('data/stockholm_td_adj.dat')
np.unique(data[:,1]) # the month column takes values from 1 to 12
```

    array([ 1.,  2.,  3.,  4.,  5.,  6.,  7.,  8.,  9., 10., 11., 12.])

```python
mask_feb = data[:,1] == 2
```

```python
# the temperature data is in column 3
np.mean(data[mask_feb,3])
```

    -3.212109570736596

With these tools we have very powerful data processing capabilities at our disposal. For example, to extract the average monthly average temperatures for each month of the year only takes a few lines of code:

```python
months = np.arange(1,13)
monthly_mean = [np.mean(data[data[:,1] == month, 3]) for month in months]

fig, ax = plt.subplots()
ax.bar(months, monthly_mean)
ax.set_xlabel("Month")
ax.set_ylabel("Monthly avg. temp.");
```

### Calculations with higher-dimensional data

When functions such as `min`, `max`, etc. are applied to a multidimensional arrays, it is sometimes useful to apply the calculation to the entire array, and sometimes only on a row or column basis. Using the `axis` argument we can specify how these functions should behave:

```python
m = np.random.rand(3,3)
m
```

    array([[0.79729008, 0.36995048, 0.53208232],
           [0.72784289, 0.16789472, 0.36548098],
           [0.64275017, 0.82376707, 0.94225174]])

```python
# global max
m.max()
```

    0.9422517358676263

```python
# max in each column
m.max(axis=0)
```

    array([0.79729008, 0.82376707, 0.94225174])

```python
# max in each row
m.max(axis=1)
```

    array([0.79729008, 0.72784289, 0.94225174])

Many other functions and methods in the `array` and `matrix` classes accept the same (optional) `axis` keyword argument.

## Reshaping, resizing and stacking arrays

The shape of an Numpy array can be modified without copying the underlaying data, which makes it a fast operation even for large arrays.

```python
A
```

    array([[ 0,  1,  2,  3,  4],
           [10, 11, 12, 13, 14],
           [20, 21, 22, 23, 24],
           [30, 31, 32, 33, 34],
           [40, 41, 42, 43, 44]])

```python
n, m = A.shape
```

```python
B = A.reshape((1,n*m))
B
```

    array([[ 0,  1,  2,  3,  4, 10, 11, 12, 13, 14, 20, 21, 22, 23, 24, 30,
            31, 32, 33, 34, 40, 41, 42, 43, 44]])

```python
B[0,0:5] = 5 # modify the array

B
```

    array([[ 5,  5,  5,  5,  5, 10, 11, 12, 13, 14, 20, 21, 22, 23, 24, 30,
            31, 32, 33, 34, 40, 41, 42, 43, 44]])

```python
A # and the original variable is also changed. B is only a different view of the same data
```

    array([[ 5,  5,  5,  5,  5],
           [10, 11, 12, 13, 14],
           [20, 21, 22, 23, 24],
           [30, 31, 32, 33, 34],
           [40, 41, 42, 43, 44]])

We can also use the function `flatten` to make a higher-dimensional array into a vector. But this function create a copy of the data.

```python
B = A.flatten()

B
```

    array([ 5,  5,  5,  5,  5, 10, 11, 12, 13, 14, 20, 21, 22, 23, 24, 30, 31,
           32, 33, 34, 40, 41, 42, 43, 44])

```python
B[0:5] = 10

B
```

    array([10, 10, 10, 10, 10, 10, 11, 12, 13, 14, 20, 21, 22, 23, 24, 30, 31,
           32, 33, 34, 40, 41, 42, 43, 44])

```python
A # now A has not changed, because B's data is a copy of A's, not refering to the same data
```

    array([[ 5,  5,  5,  5,  5],
           [10, 11, 12, 13, 14],
           [20, 21, 22, 23, 24],
           [30, 31, 32, 33, 34],
           [40, 41, 42, 43, 44]])

## Adding a new dimension: newaxis

With `newaxis`, we can insert new dimensions in an array, for example converting a vector to a column or row matrix:

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

```python
np.shape(v)
```

    (3,)

```python
# make a column matrix of the vector v
v[:, np.newaxis]
```

    array([[1],
           [2],
           [3]])

```python
# column matrix
v[:,np.newaxis].shape
```

    (3, 1)

```python
# row matrix
v[np.newaxis,:].shape
```

    (1, 3)

## Stacking and repeating arrays

Using function `repeat`, `tile`, `vstack`, `hstack`, and `concatenate` we can create larger vectors and matrices from smaller ones:

### tile and repeat

```python
a = np.array([[1, 2], [3, 4]])
```

```python
# repeat each element 3 times
np.repeat(a, 3)
```

    array([1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4])

```python
# tile the matrix 3 times
np.tile(a, 3)
```

    array([[1, 2, 1, 2, 1, 2],
           [3, 4, 3, 4, 3, 4]])

### concatenate

```python
b = np.array([[5, 6]])
```

```python
np.concatenate((a, b), axis=0)
```

    array([[1, 2],
           [3, 4],
           [5, 6]])

```python
np.concatenate((a, b.T), axis=1)
```

    array([[1, 2, 5],
           [3, 4, 6]])

### hstack and vstack

```python
np.vstack((a,b))
```

    array([[1, 2],
           [3, 4],
           [5, 6]])

```python
np.hstack((a,b.T))
```

    array([[1, 2, 5],
           [3, 4, 6]])

## Exercise: Border

Write a function to create a 2D array of arbitrary shape. This array should have all zero values, except for the elements around the border (i.e., the first and last rows, and the first and last columns), which should have a value of one.

```python
def border(n, m):
    """Creates an array with shape (n, m) that is all zeros
    except for the border (i.e., the first and last rows and
    columns), which should be filled with ones.

    Hint: you should be able to do this in three lines
    (including the return statement)

    Parameters
    ----------
    n, m: int
        Number of rows and number of columns

    Returns
    -------
    numpy array with shape (n, m)

    """
    # YOUR CODE HERE
    raise NotImplementedError()
```

```python
from numpy.testing import assert_array_equal
from nose.tools import assert_equal

# check a few small examples explicitly
assert_array_equal(border(1, 1), [[1]])
assert_array_equal(border(2, 2), [[1, 1], [1, 1]])
assert_array_equal(border(3, 3), [[1, 1, 1], [1, 0, 1], [1, 1, 1]])
assert_array_equal(border(3, 4), [[1, 1, 1, 1], [1, 0, 0, 1], [1, 1, 1, 1]])

# check a few large and random examples
for i in range(10):
    n, m = np.random.randint(2, 1000, 2)
    result = border(n, m)

    # check dtype and array shape
    assert_equal(result.dtype, np.float)
    assert_equal(result.shape, (n, m))

    # check the borders
    assert (result[0] == 1).all()
    assert (result[-1] == 1).all()
    assert (result[:, 0] == 1).all()
    assert (result[:, -1] == 1).all()

    # check that everything else is zero
    assert np.sum(result) == (2*n + 2*m - 4)

print("Success!")
```

## Copy and "deep copy"

To achieve high performance, assignments in Python usually do not copy the underlaying objects. This is important for example when objects are passed between functions, to avoid an excessive amount of memory copying when it is not necessary (technical term: pass by reference).

```python
A = np.array([[1, 2], [3, 4]])

A
```

    array([[1, 2],
           [3, 4]])

```python
# now B is referring to the same array data as A
B = A
```

```python
# changing B affects A
B[0,0] = 10

B
```

    array([[10,  2],
           [ 3,  4]])

```python
A
```

    array([[10,  2],
           [ 3,  4]])

If we want to avoid this behavior, so that when we get a new completely independent object `B` copied from `A`, then we need to do a so-called "deep copy" using the function `copy`:

```python
B = np.copy(A)
```

```python
# now, if we modify B, A is not affected
B[0,0] = -5

B
```

    array([[-5,  2],
           [ 3,  4]])

```python
A
```

    array([[10,  2],
           [ 3,  4]])

## Iterating over array elements

Generally, we want to avoid iterating over the elements of arrays whenever we can (at all costs). The reason is that in a interpreted language like Python (or MATLAB), iterations are really slow compared to vectorized operations.

However, sometimes iterations are unavoidable. For such cases, the Python `for` loop is the most convenient way to iterate over an array:

```python
v = np.array([1,2,3,4])

for element in v:
    print(element)
```

    1
    2
    3
    4

```python
M = np.array([[1,2], [3,4]])

for row in M:
    print("row", row)

    for element in row:
        print(element)
```

    row [1 2]
    1
    2
    row [3 4]
    3
    4

When we need to iterate over each element of an array and modify its elements, it is convenient to use the `enumerate` function to obtain both the element and its index in the `for` loop:

```python
for row_idx, row in enumerate(M):
    print("row_idx", row_idx, "row", row)

    for col_idx, element in enumerate(row):
        print("col_idx", col_idx, "element", element)

        # update the matrix M: square each element
        M[row_idx, col_idx] = element ** 2
```

    row_idx 0 row [1 2]
    col_idx 0 element 1
    col_idx 1 element 2
    row_idx 1 row [3 4]
    col_idx 0 element 3
    col_idx 1 element 4

```python
# each element in M is now squared
M
```

    array([[ 1,  4],
           [ 9, 16]])

## Vectorizing functions

As mentioned several times by now, to get good performance we should try to avoid looping over elements in our vectors and matrices, and instead use vectorized algorithms. The first step in converting a scalar algorithm to a vectorized algorithm is to make sure that the functions we write work with vector inputs.

```python
def Theta(x):
    """
    Scalar implemenation of the Heaviside step function.
    """
    if x >= 0:
        return 1
    else:
        return 0
```

```python
Theta(np.array([-3,-2,-1,0,1,2,3]))
```

OK, that didn't work because we didn't write the `Theta` function so that it can handle a vector input...

To get a vectorized version of Theta we can use the Numpy function `vectorize`. In many cases it can automatically vectorize a function:

```python
Theta_vec = np.vectorize(Theta)
```

```python
Theta_vec(np.array([-3,-2,-1,0,1,2,3]))
```

    array([0, 0, 0, 1, 1, 1, 1])

We can also implement the function to accept a vector input from the beginning (requires more effort but might give better performance):

```python
def Theta(x):
    """
    Vector-aware implemenation of the Heaviside step function.
    """
    return 1 * (x >= 0)
```

```python
Theta(np.array([-3,-2,-1,0,1,2,3]))
```

    array([0, 0, 0, 1, 1, 1, 1])

```python
# still works for scalars as well
Theta(-1.2), Theta(2.6)
```

    (0, 1)

## Using arrays in conditions

When using arrays in conditions,for example `if` statements and other boolean expressions, one needs to use `any` or `all`, which requires that any or all elements in the array evalutes to `True`:

```python
M
```

    array([[ 1,  4],
           [ 9, 16]])

```python
if (M > 5).any():
    print("at least one element in M is larger than 5")
else:
    print("no element in M is larger than 5")
```

    at least one element in M is larger than 5

```python
if (M > 5).all():
    print("all elements in M are larger than 5")
else:
    print("all elements in M are not larger than 5")
```

    all elements in M are not larger than 5

## Type casting

Since Numpy arrays are *statically typed*, the type of an array does not change once created. But we can explicitly cast an array of some type to another using the `astype` functions (see also the similar `asarray` function). This always create a new array of new type:

```python
M.dtype
```

    dtype('int64')

```python
M2 = M.astype(float)

M2
```

    array([[ 1.,  4.],
           [ 9., 16.]])

```python
M2.dtype
```

    dtype('float64')

```python
M3 = M.astype(bool)

M3
```

    array([[ True,  True],
           [ True,  True]])

A particular NumPy feature of interest is solving a system of linear equations. NumPy has a function to solve linear equations.
For example these linear equations

\begin{align}
2x + 6y & =  6 \\
5x + 3y & =  -9
\end{align}

can be solved by:

```python
coeffs  = np.array([[2, 6], [5, 3]])
depvars = np.array([6, -9])
solution = np.linalg.solve(coeffs, depvars)
solution
```

    array([-3.,  2.])

## Further reading

* http://numpy.scipy.org
* http://scipy.org/Tentative_NumPy_Tutorial
* http://scipy.org/NumPy_for_Matlab_Users - A Numpy guide for MATLAB users.

---
## Exercise: Truth tables

This is a final exercise to get you comfortable working with numpy arrays. In this exercise, we're going to be programmatically constructing truth tables from boolean vectors. A *truth table* lists all possible Boolean outputs for the given inputs. For example, an AND truth table would look like this:

      $a$
      $b$
      $a\wedge b$

      0
      0
      0

      0
      1
      0

      1
      0
      0

      1
      1
      1

Each row corresponds to different values of $a$ (first column) and $b$ (second column), and the third column corresponds to whatever $a\wedge b$ is.

We can represent the different values of $a$ and $b$ using boolean arrays in numpy:

```python
# create boolean vectors a and b
a = np.array([0, 0, 1, 1], dtype=bool)
b = np.array([0, 1, 0, 1], dtype=bool)
```

You can compute the logical AND of `a` and `b` using the `&` operator (and the symbols for the Boolean operations OR and NOT are `|` and `~`, respectively).

```python
# run this cell to see what happens when we use the & operator on a and b
a & b
```

    array([False, False, False,  True])

Complete the functions below to produce truth tables for AND, OR, and NOT. Be sure to use the logical operators discussed above in your solution!

---

### Part A: AND Table

```python
def and_table(a, b):
    """
    Takes two boolean vectors, `a` and `b`, and returns an AND truth
    table consisting three columns: a, b, and (a AND b). If you forget
    how an AND operator works, see the Wikipedia page on logical
    conjunctions: https://en.wikipedia.org/wiki/Logical_conjunction

    Parameters
    ----------
    a : boolean array with shape (n,)
    b : boolean array with shape (n,)

    Returns
    -------
    boolean array with shape (n, 3)

    """
    # YOUR CODE HERE
    raise NotImplementedError()

```

After you have implemented `and_table` above, you can try running it with inputs `a` and `b`:

```python
and_table(a, b)
```

```python
"""Check whether and_table returns an array with the correct shape for random inputs."""
from nose.tools import assert_equal
for i in range(10):
    # randomize the vector length
    n = np.random.randint(1, 11)
    # randomly generate binary vectors
    v1 = np.random.randint(0, 2, n).astype(bool)
    v2 = np.random.randint(0, 2, n).astype(bool)
    # check the shape of the AND table
    assert_equal(and_table(v1, v2).shape, (n, 3))

print("Success!")
```

```python
"""Check whether and_table returns the correct answer for two particular vectors."""
from numpy.testing import assert_array_equal
# create the two vectors
v1 = np.array([0, 0, 1, 1], dtype=bool)
v2 = np.array([0, 1, 0, 1], dtype=bool)

# check (v1 AND v1)
v11 = np.array([[0, 0, 0], [0, 0, 0], [1, 1, 1], [1, 1, 1]], dtype=bool)
assert_array_equal(and_table(v1, v1), v11)

# check (v1 AND v2)
v12 = np.array([[0, 0, 0], [0, 1, 0], [1, 0, 0], [1, 1, 1]], dtype=bool)
assert_array_equal(and_table(v1, v2), v12)

# check (v2 AND v1)
v21 = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 1]], dtype=bool)
assert_array_equal(and_table(v2, v1), v21)

# check (v2 AND v2)
v22 = np.array([[0, 0, 0], [1, 1, 1], [0, 0, 0], [1, 1, 1]], dtype=bool)
assert_array_equal(and_table(v2, v2), v22)

print("Success!")
```

### Part B: OR Table

```python
def or_table(a, b):
    """
    Takes two boolean vectors, `a` and `b`, and returns an OR truth
    table consisting three columns: a, b, and (a OR b).

    Parameters
    ----------
    a : boolean array with shape (n,)
    b : boolean array with shape (n,)

    Returns
    -------
    boolean array with shape (n, 3)

    """
    # YOUR CODE HERE
    raise NotImplementedError()
```

After you have implemented `or_table` above, you can try running it with inputs `a` and `b`:

```python
or_table(a, b)
```

```python
"""Check whether or_table returns an array with the correct shape for random inputs."""
for i in range(10):
    # randomize the vector length
    n = np.random.randint(1, 11)
    # randomly generate binary vectors
    v1 = np.random.randint(0, 2, n).astype(bool)
    v2 = np.random.randint(0, 2, n).astype(bool)
    # check the shape of the OR table
    assert_equal(or_table(v1, v2).shape, (n, 3))

print("Success!")
```

```python
"""Check whether or_table returns the correct answer for two particular vectors."""
# create the two vectors
v1 = np.array([0, 0, 1, 1], dtype=bool)
v2 = np.array([0, 1, 0, 1], dtype=bool)

# check (v1 OR v1)
v11 = np.array([[0, 0, 0], [0, 0, 0], [1, 1, 1], [1, 1, 1]], dtype=bool)
assert_array_equal(or_table(v1, v1), v11)

# check (v1 OR v2)
v12 = np.array([[0, 0, 0], [0, 1, 1], [1, 0, 1], [1, 1, 1]], dtype=bool)
assert_array_equal(or_table(v1, v2), v12)

# check (v2 OR v1)
v21 = np.array([[0, 0, 0], [1, 0, 1], [0, 1, 1], [1, 1, 1]], dtype=bool)
assert_array_equal(or_table(v2, v1), v21)

# check (v2 OR v2)
v22 = np.array([[0, 0, 0], [1, 1, 1], [0, 0, 0], [1, 1, 1]], dtype=bool)
assert_array_equal(or_table(v2, v2), v22)

print("Success!")
```

---
### Part C: NOT Table

```python
def not_table(a):
    """
    Takes one boolean vector, `a`, and returns a NOT truth
    table consisting two columns: a and (NOT a).

    Parameters
    ----------
    a : boolean array with shape (n,)

    Returns
    -------
    boolean array with shape (n, 2)

    """
    # YOUR CODE HERE
    raise NotImplementedError()
```

After you have implemented `not_table` above, you can try running it with inputs `a` and `b`:

```python
not_table(a)
```

```python
"""Check whether or_table returns an array with the correct shape for random inputs."""
for i in range(10):
    # randomize the vector length
    n = np.random.randint(1, 11)
    # randomly generate a binary vector
    v = np.random.randint(0, 2, n).astype(bool)
    # check the shape of the OR table
    assert_equal(not_table(v).shape, (n, 2))

print("Success!")
```

```python
"""Check whether or_table returns the correct answer for two particular vectors."""
# create the two vectors
v1 = np.array([0, 0, 1, 1], dtype=bool)
v2 = np.array([0, 1, 0, 1], dtype=bool)

# check (NOT v1)
vn1 = np.array([[0, 1], [0, 1], [1, 0], [1, 0]], dtype=bool)
assert_array_equal(not_table(v1), vn1)

# check (NOT v2)
vn2 = np.array([[0, 1], [1, 0], [0, 1], [1, 0]], dtype=bool)
assert_array_equal(not_table(v2), vn2)

print("Success!")
```


# Vent opening probability

This notebook and associated files fully reproduce the vent opening probabilties for potential pumice cone vents around Aluto volcano, from the paper Clarke et al. (in prep). The script takes a grid of points, (UTM coordinates in the file 'vent_loc.csv'), and assigns to each a probability of vent opening given an eruption, based on the distribution and location of existing vents at Aluto volcano (UTM coordinates in the file 'aluto_vents.csv'). The methodology was developed by Connor and Hill (1995). The script is not necessarily specific to Aluto, and could be used provided the input point locations are provided in the correct format (inspect .csv for this). NB. the coordinates must be in a meter (such as UTM), rather than degree (such as decimal degree) format, and form a regular grid.

This Notebook requires the files:

- vent_loc.csv <-- UTM coordinate grid of potential vent locations to investigate. Provided in 'source_files' folder
- aluto_vents.csv <-- UTM coordinates of exisitng vents at Aluto. Provided in 'source_files' folder

### Features

What the script does:
1. Imports a grid of user-specified points over which to evaluate vent opening probability
2. Imports the location of exisitng volcanic vents
3. The probability of vent opening is calculated by assuming that there is a characteristic spacing of volcanic vents at the volcano, which can be described by a Gaussian kernel with a particular bandwidth. This bandwidth is calculated by fitting a Gaussian CDF to the cumulative nearest neighbour distances of the existing volcanic vents
4. This bandwidth parameterises a Gaussian Kernel centred on every existing vent at the volcano
5. The probability of vent opening at each point on the user specified grid is calculated based on its proximity to every existing vent (see equation in next section)
6. Exports the data as a .csv, where each point in the user-defined grid of potential vent locations has an associated probabilty. This file can be imported into GIS software such as QGIS

### Mathematical Basis

The spatial intensity of volcanism at any one point (in other words, the probability of vent opening at that point given a volcanic eruption) ($\hat{\lambda}_s(x,y)$), is given by the following equation:

\begin{equation}
    \hat{\lambda}_s(x,y) = \frac{1}{2 \pi N h^2} \sum\limits_{i=1}^N
    \exp\left(-\frac{1}{2}\left[ \frac{d_i}{h} \right]^2\right)
\end{equation}

where $N$ is the total number of existing vents, $h$ is the bandwidth of the kernel, and $d_i$ is the distance between existing vent $i$ and point $(x,y)$.

For a more in depth explanation of this methodology see:

Connor and Hill (1995) Three nonhomogeneous Poisson models for the probability of basaltic volcanism: Application to the Yucca Mountain region, Nevada. *Journal of Geophysical Research* . 100: 107-117

Weller et al. (2006) Modelling the spatial distribution of volcanoes: an example from Armenia. *In: Statistics in Volcanology* Geological Scoiety Special Publications Vol. 1: 77-87

### Notebook Author
Ben Clarke

https://github.com/GeoBen93

https://www.researchgate.net/profile/Ben_Clarke6

https://twitter.com/PyroclasticBen

```python
#-----------------Import packages

import matplotlib.pyplot as plt
import csv
import numpy as np
from scipy.special import erf
import matplotlib.mlab as mlab
import pandas as pd
from scipy.optimize import curve_fit
```

## Step 1 - Import the data

```python
#-----------------Import data as CSVs

vent = pd.read_csv('source_files/aluto_vents.csv')# existing vent locations
grid = pd.read_csv('source_files/vent_loc.csv')# grid of points to be evaluated
```

## Step 2 - Calculate the nearest neighbour distances of vents

```python
x_mv1 = vent.x_mv.values # set x location values from vent csv
y_mv1 = vent.y_mv.values # set y location values from vent csv

x_mv2 = x_mv1 # create copy of x values
y_mv2 = y_mv1 # create copy of y values

#create empty fields for dx and dy
dx=[]
dy=[]

#calculate difference in x and y coordinates between every vent. Append these to lists.
for i in range(0,len(vent)):
    for j in range(0,len(vent)):
        dx.append(np.abs(x_mv1[i] - x_mv2[j]))
        dy.append(np.abs(y_mv1[i] - y_mv2[j]))

dist = np.hypot(dx,dy) # calculate distance between vents
by_vent = np.reshape(dist,(len(vent),-1)) # split union of vent distances into distances on a vent-wise basis
m_by_vent = np.ma.masked_equal(by_vent, 0.0, copy=False) # mask zero values in array, removing the distances between vents and themselves
near = np.amin(m_by_vent, axis=1) # extract nearest (minimum) distance within each by_vent
nearkm = near*0.001 # convert to km
```

## Step 3 - Find best-fit Gaussian bandwidth based on nearest neighbours of vents

```python
#----------Define function that describes best-fit CDF to the empirical nearest neighbour CDF

def func_gauss(kernel,band):
    return erf(kernel/(np.sqrt(2)*band))

#----------Calculate cumulative density distribution of distance to nearest neighbour vent and find best fit-curve

freq = np.histogram(nearkm,bins=1000) # bin near distance data
cum_freq = np.cumsum(freq[0]) # calculate cumulative frequency of near distance bins
cum_frac = cum_freq/max(cum_freq) # calculate cumulative frequency as fraction of total vents
cum_frac = np.insert(cum_frac,0,0) # add zero value to beggining of cum_frac array to account for binning

xdata = freq[1] # define x data
ydata = cum_frac # define y data

popt, pcov = curve_fit(func_gauss, xdata, ydata) # find the function that best matches the empirical cumulative density distribution

#----------Plot result

fig = plt.figure('Kernel Bandwidth', figsize = (4,3.5))

ax1 = fig.add_subplot(111)

ax1.plot(xdata,ydata, color='darkgray', label='Nearest neighbour CDF')
ax1.plot(xdata, func_gauss(xdata, *popt), 'r-', label='Best fit Gaussian CDF')
ax1.set_xlabel("Nearest Neighbour Distance (km)")
ax1.set_ylabel("Fraction of Total Vents")
ax1.legend(loc='lower right')
bandwidth_label = 'Kernel Bandwidth =' + str(round(popt[0],3))
ax1.set_title(bandwidth_label)
plt.show()
```

## Step 4 - Calculate vent opening probability at user-defined grid points

This is where the equation at the top of the page comes in, now that we have the characteristic bandwidth $h$, we can plug the rest of the numbers into the equation. We repeat this for every one of the user defined grid points.

The results of this process are saved as 'vent_opening_probabilities' in the 'notebook_output_files' folder. This is used in the 'importance_sampling_notebook', to weight the importance of simulations from each vent.

NB. This step requires the grid spacing to be defined (meters). Assinged to variable 'spacing_m'.

```python
spacing_m = 500

#------------Extract x and y coordinates from near and input CSVs

x_mf = grid.x_mf.values
x_mv = vent.x_mv.values

y_mf = grid.y_mf.values
y_mv = vent.y_mv.values

#------------create empty lists for dx and dy
dx=[]
dy=[]

#------------calculate distance between every grid point and existing vent. Append these to lists.
for i in range(0,len(x_mf)):
    for j in range(0,len(x_mv)):
        dx.append(np.abs(x_mf[i] - x_mv[j]))
        dy.append(np.abs(y_mf[i] - y_mv[j]))

dist = np.hypot(dx,dy) # calculate distance between input and near features
neardist = dist*0.001 # convert to km

#------------calculate vent opening probability at every input feature (fishnet point)

h = popt # set fixed bandwidth as Gaussian best-fit kernel bandwidth estimated in previous step
z = np.exp(-0.5*(neardist/h)**2) # calculate z for every grid-vent distance
by_grid = np.reshape(z,(len(fishnet),-1)) # split list of z values into chunks where each chunk contains all z values associated with each grid point
sumz = np.sum(by_grid, axis = 1) # sum z values in each chunk.
intensity = (sumz*(1/(2*np.pi*len(vent)*h**2))) # calculate spatial intensity at every grid point
intensity = intensity/((1000/spacing_m)**2) # because each grid point actually represents an area, we need to recalculate the intensity based on the grid spacing
```

Now lets have a look at the results:

```python
plt.scatter(x_mf, y_mf, c=intensity, cmap='viridis', s=10, marker='o')
plt.axis('equal')
plt.colorbar(label='P(vent|eruption)')
plt.xlabel('UTMx [m]')
plt.ylabel('UTMy [m]')
plt.ticklabel_format(axis='both', style='sci')

plt.show()

print('...................')
print('Probability of opening within this grid: ', round(sum(intensity),5))
print('Residual probability: ', round(1-sum(intensity),5))
```

And now let's save the results to a .csv called 'vent_opening_probabilities', into the 'notebook_output_files' folder.

```python
#------------Export data as CSV
d = {'xUTM' :x_mf, 'yUTM' :y_mf, 'Prob' :intensity}
df_prob = pd.DataFrame(d)
df_prob.to_csv('notebook_output_files/vent_opening_probabilities.csv')

#-----------Report
print('Result Saved')
```


```python
from sympy.physics.mechanics import ReferenceFrame,Point,dynamicsymbols
from sympy.physics.mechanics import Point
from sympy import latex,pprint,symbols,init_printing
from sympy.algebras.quaternion import Quaternion
import numpy as np
init_printing() # Para visualizar símbolos
```

## 3. Cinemática de puntos

## 3.1 Velocidad y aceleración de puntos

```python
# Para el ejemplo de atracción de parque de diversiones
a=ReferenceFrame('A')
#Defina el punto O
o=Point('O')
# Parámetros del modelo
l,r=symbols('L,R')
# Variables de movimiento
q1,q2=dynamicsymbols('q1,q2')
# Marco de referencia intermedio E
e=a.orientnew('E','Axis',(q1,a.y))
# Marco de referencia intermedio B
b=e.orientnew('B','Axis',(q2,-e.z))
p=o.locatenew('P',l*b.z)
q=p.locatenew('Q',r*b.y)

#Calcule el vector desde el centro del eje de rotación del péndulo hasta el usuario
r_qo=q.pos_from(o)
#La velocidad del usuario es:
r_qo.dt(a)
#La aceleración del usuario es:
#r_qo.dt(a).dt(a).subs(q2.diff().diff(),0).simplify()
```

```python
q2.diff().diff()
```

## 3.2 Velocidad y aceleración de puntos fijos en un cuerpo

```python
# Para el ejemplo de atracción de parque de diversiones
a=ReferenceFrame('A')
#Defina el punto O
o=Point('O')
# Parámetros del modelo
l,r=symbols('L,R')
# Variables de movimiento
q1,q2=dynamicsymbols('q1,q2')
# Marco de referencia intermedio E
e=a.orientnew('E','Axis',(q1,a.y))
# Marco de referencia intermedio B
b=e.orientnew('B','Axis',(q2,-e.z))
p=o.locatenew('P',l*b.z)
q=p.locatenew('Q',r*b.y)
```

```python
#Utilice el método v2pt_theory para calcular la velocidad de un punto con la velocidad
#de otro punto fijo en el mismo marco.
#Primero defina la velocidad de O en el marco A, y de P en B como 0 ya que son puntos fijos en estos
#marcos.
o.set_vel(a,0)
p.set_vel(b,0)
#Luego utilice v2pt_theory para definir la velocidad de P en a respecto al punto O
p.v2pt_theory(o,a,b)
#Ahora utilice v2pt_theory para definir la velocidad de Q en a respecto al punto P
q.v2pt_theory(p,a,b)
#Observe que el resultado es el mismo que calculando la derivada total de r_qo
#Ahora pruebe utilizar el metodo a2pt_theory para calcular la aceleracion
```

## 3.3 Velocidad y aceleración de puntos móviles sobre un cuerpo

```python
# Modificamos el ejemplo de atracción de parque de diversiones para incluir la variable
# q3.
a=ReferenceFrame('A')
#Defina el punto O
o=Point('O')
# Parametros del modelo
l,r=symbols('L,R')
# Variables de movimiento
q1,q2,q3=dynamicsymbols('q1,q2,q3')
# Marco de referencia intermedio E
e=a.orientnew('E','Axis',(q1,a.y))
# Marco de referencia B
b=e.orientnew('B','Axis',(q2,-e.z))
p=o.locatenew('P',l*b.z)
q=p.locatenew('Q',q3*b.x)
#Esperamos que q3<R por el bien del usuario de la atracción :)

#Primero defina la velocidad de O en el marco A, y de P en B como 0 ya que son puntos fijos en estos
#marcos.
o.set_vel(a,0)
p.set_vel(b,0)
#Luego utilice v2pt_theory para definir la velocidad de P en a respecto al punto O
p.v2pt_theory(o,a,b)
#Ya no deberiamos utilizar v2pt_theory para definir la velocidad de Q puesto que
#no es un punto fijo en B.
#Dado que q fue definido en coordenadas de B, ya tiene la velocidad definida en este marco.
#Verifique
pprint(q.vel(b))
#Ahora utilizamos v1pt_theory para encontrar la velocidad de q
q.v1pt_theory(p,a,b)
```


# Gaussian process regression basics [optional]

This notebook presents basic Gaussian process (GP) regression using only the `numpy` package to keep the example as simple as possible.
Note that you do not need to know Gaussian processes in detail to be able to complete the rest of the tutorial, so this step is optional.

While a complete introduction to Gaussian proccess is beyond the scope of this tutorial, we try to describe the very basics. For more information and theoretical background, we recommend you to take a look at for example the excelent [Gaussian Processes for Machine Learning](http://www.gaussianprocess.org/gpml/) book.
The aim here is just to build some intuition about how the model works in practice.

In supervised learning we observe input-output pairs $(\mathbf{x}, y)$ and we assume $y = f(\mathbf{x})$ for some unknown function $f$, possibly corrupted by noise.
The goal of learning is to estimate $f$ as closely as possible from the observed data.
The optimal approach would be to estimate a distribution over functions given the data $p(f|\mathbf{X},\mathbf{y})$ and use it to make predictions given new inputs $p(y_*|\mathbf{x}_*,\mathbf{X},\mathbf{y}) = \int p(y_*|f,\mathbf{x}_*)p(f|\mathbf{X},\mathbf{y})df$.

A Gaussian process is a generalisation of the Gaussian distribution that describes a distribution over functions and is fully specified by its mean and covariance function:

$$
f \sim GP(m(\mathbf{x}),k(\mathbf{x},\mathbf{x'}))
$$

where $m(\mathbf{x})$ is the mean function and $k(\mathbf{x},\mathbf{x'})$ is the covariance function also known as the kernel.

## Dependencies

```python
# imports
import matplotlib.pyplot as plt
import numpy as np
```

## Example data

We start by generating some example data. To keep it simple, we have just one input variable `x` and one output variable `y`. We make `y` a nonlinear function of `x` to better illustrate the flexibility of the GP regression model.

```python
np.random.seed(0)

N = 20  # number of data points
f = lambda x: np.sin(4 * x) * np.sin (5 * x)  # true function
x = np.random.uniform(0.1, 1.5, N)  # random values of x
y = f(x) + np.random.normal(0, .05, N)  # noisy observations
x1 = np.linspace(0, 1.6, 200)  # grid of x values for plotting

plt.figure(figsize=(12,4))
plt.plot(x1, f(x1), color='k', label="true function")
plt.plot(x, y, 'o', color='k', label="noisy observations")
plt.xlabel("x"); plt.ylabel("y")
plt.legend()
plt.show()
```

## GP prior distribution

Here we define the GP prior distribution. That is the distribution of functions before we observe any data.

We will use a very simple zero mean function:

$$
m(\mathbf{x}) = 0
$$

Even with a prior with a zero-mean function, the GP is usually flexible enough to fit a wide variety of functions.

We also define the widely used squared exponential kernel (also known as RBF):

$$
k_{SE}(x, x') = \sigma_f^2 \exp \big( -\frac{(x - x')^2}{2l^2} \big)
$$

where $\sigma_f^2$ is the variance parameter and $l$ is the length scale parameter.

Finally, we define a simple noise kernel in the code below.

```python
class SquaredExponentialKernel:

    def __init__(self, variance, length_scale):
        self.variance = variance
        self.length_scale = length_scale

    def kernel_function(self, x1, x2):
        z = (x1 - x2)**2 / (2 * self.length_scale**2)
        return self.variance * np.exp(-z)

    def __call__(self, X, Z=None):
        """Compute covaraince matrix."""
        if Z is None:
            Z = X
        N, M = len(X), len(Z)
        K = np.zeros((N, M))
        # naive
        for i in range(N):
            for j in range(M):
                K[i, j] = self.kernel_function(X[i], Z[j])
        return K

class NoiseKernel:

    def __init__(self, variance):
        self.variance = variance

    def __call__(self, X):
        """Compute covaraince matrix."""
        return self.variance * np.eye(len(X))
```

Now we can draw some sample functions from the GP prior distribution and plot them. You can try to change the `variance` and `length_scale` parameters in the code below to see how they affect the sampled functions. Try for example to change the `length_scale` in factors of ten.

```python
def draw_samples_from_gp_prior(n_samples=5, variance=1.0, length_scale=0.1):
    # define kernel
    kernel = SquaredExponentialKernel(variance=variance, length_scale=length_scale)
    # draw samples
    samples = []
    for _ in range(n_samples):
        samples.append(np.random.multivariate_normal(np.zeros(len(x1)), kernel(x1)))
    std = np.sqrt(variance)
    # plot
    plt.figure(figsize=(12,4))
    plt.fill_between((0.0, 1.6), (2*std, 2*std), (-2*std, -2*std), color="C0", alpha=0.3, label="uncertainty (2*std)")
    for i,fx1 in enumerate(samples):
        plt.plot(x1, fx1, color="C0", linestyle="--", label="prior samples" if i == 0 else "")
    plt.xlabel("x"); plt.ylabel("y")
    plt.legend()
    plt.show()

draw_samples_from_gp_prior(n_samples=5, variance=1.0, length_scale=0.1)
```

## GP posterior distribution (with noise)

We now condition on the observed data to compute the GP posterior and make predictions of the mean $\mu$ and the (co)variance $\Sigma$.
For the simple GP regression model, this step can be computed in closed form with the following expressions:

\begin{align}
p(\mathbf{f}_*|\mathbf{X}_*,\mathbf{X},\mathbf{y}) &= \mathcal{N}(\mathbf{f}_*|\mathbf{\mu},\mathbf{\Sigma}) \\
\mathbf{\mu} &= \mu(\mathbf{X}_*) + \mathbf{K}^T_* \mathbf{K}^{-1} (\mathbf{y} - \mu(\mathbf{X})) = \mathbf{K}^T_* \mathbf{K}^{-1} \mathbf{y} \\
\Sigma &= \mathbf{K}_{**} - \mathbf{K}^T_* \mathbf{K}^{-1} \mathbf{K}_*
\end{align}

where $\mathbf{K} = k(\mathbf{X},\mathbf{X}) + \sigma^2 \mathbf{I}$, $\mathbf{K}_* = k(\mathbf{X},\mathbf{X}_*)$ and $\mathbf{K}_{**}=k(\mathbf{X}_*,\mathbf{X}_*)$ with covariance function (kernel) $k$ and noise level $\sigma^2$ and assuming a zero mean function $\mu(\mathbf{X})=\mathbf{0}$.

(In practice the Cholesky decomposition can be used instead of the computationally expensive matrix inversion.)

```python
def predict(variance, length_scale, noise_level, n_samples=5):
    # kernels
    kernel = SquaredExponentialKernel(variance=variance, length_scale=length_scale)
    noise = NoiseKernel(variance=noise_level)
    # inference
    K = kernel(x) + noise(x)
    K_inv = np.linalg.inv(K)
    K1 = kernel(x, x1)
    K11 = kernel(x1, x1)
    mu = K1.T @ K_inv @ y
    Sigma = K11 - K1.T @ K_inv @ K1
    # sample posterior
    samples = []
    for _ in range(n_samples):
        fx1 = np.random.multivariate_normal(mu, Sigma)
        samples.append(fx1)
    sigma = np.sqrt(Sigma.diagonal())
    return mu, sigma, samples
```

Let us plot the model predictions for various values of the length scale parameter to see how it behaves.

You can try and change the values of the `variance`, `length_scale` and `noise_level` parameters in the code below to see they affect the predictions of the model.

```python
for ls in [0.1, 0.2, 0.3, 0.4, 0.5]:
    mu, sigma, samples = predict(variance=1.0, length_scale=ls, noise_level=0.01)
    # plot
    plt.figure(figsize=(12,4))
    plt.title(f"length_scale={ls}")
    plt.plot(x1, f(x1), color="k", label="true function")
    plt.plot(x, y, 'o', color="k", label="noisy observations")
    plt.plot(x1, mu, color="C0", label="prediction")
    plt.fill_between(x1, mu + 2 * sigma, mu - 2 * sigma, color="C0", alpha=0.3, label="uncertainty (2*std)")
    for i,fx1 in enumerate(samples):
        plt.plot(x1, fx1, color="C0", linestyle="--", alpha=0.5, label="posterior samples" if i==0 else "")
    plt.legend(loc=1)
    plt.show()
```

As you can see, some parameter values make the model fit the data better than others. Thus, the problem of learning a good model of the data consists of finding suitable kernel parameters for the GP model.
In practice the kernel parameters can be optimised automatically using gradient based methods.
Fortunately there are many GP packages available so we do not have to implement this ourselves.