{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**A general introduction to Jupyter Notebooks**\n",
"\n",
"Some useful shortcuts (the full list can be seen by navigating to Help > Keyboard Shortcuts):\n",
"\n",
"* Enter enters the edit mode for the selected cell.\n",
"* Shift + Enter evaluates the current cell.\n",
"* Esc allows you to navigate between cells using the arrow keys, or K (up) and J (down).\n",
"* When navigating between cells,\n",
" * A inserts a cell above, B inserts one below.\n",
" * D + D deletes the cell.\n",
" * C copies the current cell, V pastes the copied cell below the currently selected cell.\n",
" * X copies the cell and deletes it afterwards.\n",
" * Y sets the cell type to code, M switches it to Markdown.\n",
"* Ctrl + S saves the Notebook and creates a checkpoint. Going to File > Revert to Checkpoint you can go back in time to the contents at previous checkpoints."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"![NumPy logo](https://upload.wikimedia.org/wikipedia/commons/thumb/1/1a/NumPy_logo.svg/775px-NumPy_logo.svg.png)\n",
"\n",
"NumPy is a package that implements scientific computing tools directly into Python. You can start using it with"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import numpy as np"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Working with arrays\n",
"\n",
"The essential new data type that NumPy introduces is the array. On the outside it is, essentially, a list of lists, designed to represent a *$k$-dimensional matrix* $A\\in\\mathbb{R}^{d_1 \\times d_2 \\times \\cdots \\times d_k}$. Of course, NumPy includes several predetermined functions that allows one to use them in a very efficient way.\n",
"\n",
"Creating an array is simple. For instance you can easily create an array of zeros with"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"A = np.zeros((3, 4))\n",
"\n",
"print(A)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The array `A` will have shape $(3, 4)$, meaning that it lives in $\\mathbb{R}^{3\\times 4}$, or in practical terms that it is a list of three elements, which are themselves lists of four `float`s. The shape of any array is always a tuple of `int`s, and will determine whether we can perform some operations on the array (just as it happens with matrices). You can always check the shape of an array using `.shape` as follows:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"shape = (3, 4)\n",
"B = np.zeros(shape)\n",
"\n",
"print('The shape is', B.shape, '- Is this what we wanted?', B.shape == shape)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"There are other possible constructions to create new arrays: for instance, `np.ones` does the same as `np.zeros` but adding ones instead (or, in general, `np.full(shape, x)` will give you an array of shape `shape` where every element has value `x`).\n",
"\n",
"A similar function is `np.eye(d)`, which returns a two-dimensional *identity matrix* of shape $(d, d)$, like"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"print(np.eye(4))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Two other important constructors are `np.arange` and `np.linspace`. They are similar in the sense that they will return an array of evenly spaced values, but they are also different:\n",
"\n",
"- `np.arange(start, stop, step)` will return all values of `start` $ + n$ `step` in the interval $[$ `start`$, $ `stop` $)$. This is just like the `range` function we already know, but will return an array instead of a list, and does work when `step` is a `float` and not just an integer.\n",
"- `np.linspace(start, stop, num, endpoint)` creates `num` equally spaced values on the half-open interval $[$ `start`$, $ `stop`$)$ (if `endpoint` is `False`) or on the closed interval $[$ `start`$, $ `stop`$]$. So we now know the shape that will be returned, but not the step size of the increments (although they can be trivially calculated).\n",
"\n",
"In general, since both functions can be used for the same purposes, it is better to use `arange` to get integers and `linspace` to get floats. Note that you can use `dtype` to make sure that your array consists of integers:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"print('arange: ', np.arange(1, 10, 2.0))\n",
"print('arange of ints:', np.arange(1, 10, 2.0, dtype = 'int'))\n",
"print('linspace: ', np.linspace(0, 1, 9))"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"print(np.eye(3, dtype = 'int'))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"And of course, we can create an array from a standard list:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"np.array([n**2 for n in range(10)])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Random number generation\n",
"\n",
"NumPy provides functions to generate random data drawn from specific distributions. The most typical samples are:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"np.random.random(10)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"returns an array of `10` random numbers from a uniform distribution in the $[0,1)$ interval."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"np.random.randint(1, 365, 12)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"returns random integers between `1` and `365`."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"np.random.randn(10)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"returns an array of normally distributed ($\\mu = 0, \\sigma = 1$) real numbers."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"In all cases, the number of samples can be changed for the shape of an array that we want, as in"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"np.random.random((3, 4))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Another random function that can be useful is `shuffle` that will, as its name say, shuffle the elements of a list."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"N = np.arange(10)\n",
"print(N)\n",
"np.random.shuffle(N)\n",
"print(N)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"If you don't want that much randomness, you can fix a seed. This means that, although the results will be *random* (as in unpredictable), they will not change if you do it several times. Note that every time you execute a random function the seed changes, therefore you have to initialize it again (or perform the commands always in the same order). So, if you evaluate the next cell, then shuffle, then set again the seed and shuffle once more, the result should not change (you should be getting `[2 9 6 4 0 3 1 7 8 5]` for the shuffled list)."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"np.random.seed(1)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Shapes\n",
"\n",
"The shape is an essential property of the array, that can however be modified easily. Consider for instance the array"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"A = np.arange(1, 13)\n",
"print(A)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"We might want to get a matrix from this array. We can do so by using the `reshape` function as follows:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"print(A.reshape((3, 4)))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Note, however, that `A` itself has not changed, as we have only printed a *view* of `A`, and if we print it again we indeed see this."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"print(A)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"If one wants to change the shape, there are two options."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"B = np.copy(A)\n",
"B = B.reshape((3, 4))\n",
"print(B)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"C = np.copy(A)\n",
"C.shape = (3, 4)\n",
"print(C)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Slicing arrays\n",
"\n",
"The advantage of working with NumPy is the ease to create other arrays and get data from an array. Consider for instance"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"A = np.arange(1, 21).reshape((4, 5))\n",
"print(A)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"In the case of lists we could use the syntax `l[start:stop:step]`, and something similar happens here. Of course, we have more dimensions here, so whenever we want to take a slice across several dimensions we must specify each dimension and separate with commas. Let's see an example:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"A[0]"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"As one should expect, we get the first list. We can be take more than one by doing"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"A[::2]"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"which gives us the even-indexed lists. But we can slice accross the result at the same time to get, for instance, the odd-indexed elements as"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"A[::2, 1::2]"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"In general, you can use `:` to act as a placeholder for *all the elements in this dimension*, as in"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"A[:, 2]"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"that gets you the third column. You can choose the elements that you want to filter using lists too."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"print(A[[0, -1], :])\n",
"print(A[[0, -1], :][:, [0, -1]])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"As you may notice from above, list indexing may not work as expected and require additional work. For instance, one may want the following code to work and return the *inside* of the matrix (rows 1 and 2, columns 1, 2 and 3). However..."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"xs, ys = [1, 2], [1, 2, 3]\n",
"print(A[xs, ys])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Fortunately enough, this is easy to solve using the `ix_` function to turn `xs` and `ys` into the right shapes. We get"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"print(A[np.ix_(xs, ys)])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"In general you can also use `A[B]`, where `B` is a Boolean array of the same shape."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"B = (A % 5 > 2)\n",
"print('The Boolean array:\\n', B)\n",
"print('The sliced array:\\n', A[B])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Note that using Boolean slicing may result in a flattened result."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Working with slices\n",
"\n",
"Now we will apply our knowledge of slices to easily modify lists. Let us use, as an example, the Legendre polynomials. Recall that they are orthogonal in $L^2([-1, 1])$, and can be defined from the recurrence relation\n",
"$$ L_0(x) = 1,\\;\\; L_1(x) = x,\\;\\; n L_n(x) = (2n-1) x L_{n-1}(x) - (n-1) L_{n-2}(x) \\text{ for } n \\geq 2.$$\n",
"\n",
"Moreover we can define the *integrated* Legendre polynomials by\n",
"$$ \\hat{L}_n(x) = \\int_{-1}^1 L_{n-1}(x) dx = \\frac{1}{2n-1} (L_n(x) - L_{n-2}(x)) $$\n",
"as well as the *normalized* integrated Legendre polynomials as\n",
"$$ K_0(x) = \\frac{1-x}{2},\\;\\; K_1(x) = \\frac{1+x}{2}, \\;\\; K_n(x) = (-1)^n \\gamma_n \\hat{L}_n(x) $$\n",
"where $\\gamma_n = \\sqrt{(2n-3)(2n-1)(2n+1)/4}$. Let us then build them. We write `N` for the number polynomials we want to build, and `npoints` for the precision (we will calculate the values of the polynomials of course at a finite number of points)."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"N, npoints = 9, 120"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Of course we want those points to be equally spaced, hence it is a good moment to use a `linspace`."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"X = np.linspace(-1, 1, npoints)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"We now build a matrix for the values of $L_n$ to be stored simultaneously."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"L = np.zeros((N, npoints))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The initial conditions allow us to create $L_0$ and $L_1$ easily:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"L[0] = np.ones((npoints))\n",
"L[1] = X"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"For the recurrence relation, we now have a small loop:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"for n in range(2, N):\n",
" L[n] = ((2*n - 1)*X*L[n - 1] - (n - 1)*L[n - 2])/n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Let us print our results:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import matplotlib.pyplot as plt"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"for n in range(N):\n",
" plt.plot(X, L[n])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Now on to `Lhat`. The procedure will be very similar. Note that, to define $\\hat{L}_1$, we add a float `1` to a vector `X`. The dimensions are clearly not correct, however NumPy understands what we mean and performs the operation in every element of `X`. This is called *broadcasting*: NumPy takes a smaller-dimensional array (in this case even a `float`) and *augments* it by turning it into a bigger shape. Implicitely this is what happens when we multiply an array by a `float` (the difference being that this seems natural, although it isn't natural for a programming language)."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"Lhat = np.copy(L)\n",
"Lhat[1] = 0.5*(1 + X)\n",
"\n",
"for n in range(2, N):\n",
" Lhat[n] -= L[n - 2]\n",
" Lhat[n] /= 2*n - 1\n",
"\n",
"for n in range(1, N):\n",
" plt.plot(X, Lhat[n])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"We finally arrive to $K_n$. This case is surprisingly simpler once we define the appropriate functions, **we do not even need a loop!**"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"gamma = lambda n : np.sqrt(0.25*(2*n - 3)*(2*n - 1)*(2*n + 1))\n",
"scale = np.array([((-1)**n)*gamma(n) for n in range(2, N)])\n",
"\n",
"K = np.copy(Lhat)\n",
"K[0] = 0.5*(1 - X)\n",
"K[2:] *= scale[:, np.newaxis]\n",
"\n",
"for n in range(N):\n",
" plt.plot(X, K[n])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Why does this work? Recall that we mentioned before how it is possible to *broadcast* arrays. In this case, since we are only doing a scaling, it is a good idea to take advantage of this. The shapes of our objects are"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"print(K[2:].shape)\n",
"print(scale.shape)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The issue is that `scale` is missing a dimension. If it was $(7, 1)$, then NumPy would understand that it has to broadcast in that dimension, and do the multiplication a total of `npoints` times. So we artificially add a dimension using `np.newaxis`, which gives us"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"print(scale[:, np.newaxis].shape)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Note that the position of the new dimension is important! Indeed,"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"print(scale[np.newaxis, :].shape)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"will not be broadcasted correctly. While this is not so important here (it will throw an error if you try it) it could be critical if for instance `K[2:]` was a square matrix, and broadcasting could be done in both directions, since the results would be very different. Now that you know this, go back to the construction of $\\hat{L}$ and try to remove the loop. The substraction of $L_{n-2}$ should be easy by slicing appropiately, and the division by $2n-1$ can be done by broadcasting appropiately."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Boolean slices and in place modification\n",
"\n",
"Something that we did not mention was the fact that we were able to modify array slices *in place*. This means that you can alter the original array by looking at its slices instead, and you can use Boolean slicing to modify the array. Imagine we have some random data"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"R = np.random.normal(0, 1, 100).reshape((10, 10))\n",
"print(R[:3, :3])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"We instead want to make sure that all values are non-negative, by resetting to zero all negative values. We could try taking a maximum with some `np.zeros`, but a faster way is just given by slicing and correcting all negative values as"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"R[R < 0] = 0\n",
"print(R[:3, :3])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Note that one can do this for any type of slices (so doing `R[1, :] = 0` would change the first row to zero too)."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Concatenation of arrays\n",
"\n",
"Imagine you have (several) arrays that you wish to combine. For instance, the following:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"left = np.zeros((3,5))\n",
"middle = np.ones((3, 1))\n",
"right = 2*np.ones((3, 2))\n",
"print(left)\n",
"print(middle)\n",
"print(right)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Of course, you want to have a matrix with three rows and add the columns from the variables. NumPy provides a function for this:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"np.concatenate((left, middle, right))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"However, NumPy also assumes by default that you want to concatenate along the first dimension (i.e., that you want to add rows). The solution is telling NumPy to concatenate along the second axis (`axis = 1`, since everything is zero-indexed):"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"np.concatenate((left, middle, right), axis = 1)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Note that there are two special functions: `vstack((a, b))` concatenates `a` and `b` vertically (so along `axis = 0`), while `hstack` concatenates them horizontally (`axis = 1`). If you are working along any other dimension (or you want to use always the same function), `concatenate` allows an arbitrary axis."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Basic linear algebra\n",
"\n",
"Aside from the convenience of working with multidimensional data directly via arrays, the big advantage of using NumPy (and SciPy) is the fact that it provides a very efficient linear algebra implementation). Let us create some arrays first."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"A, B, x = np.random.randint(1, 10, (5, 5)), np.random.randint(1, 10, (5, 5)), np.random.randint(1, 10, 5)\n",
"\n",
"print(A)\n",
"print(B)\n",
"print(x)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The most simple operation is transposing, which is done via"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"print(A.T)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"To get the product of a matrix and a vector, one can do"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"np.dot(A, x)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Or, even shorter,"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"A.dot(x)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"This also works with two matrices (meaning, two arrays of dimension 2), but in that case it is preferred to do"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"A @ B"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Of course the result is the same:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"(A @ B) == A.dot(B)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"It is also possible to calculate the Hadamard/element-wise product, with"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"np.multiply(A, B)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"For more advanced uses, the eigenvalues can be calculated with"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"np.linalg.eigvals(A)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# The takeaway\n",
"\n",
"The most important thing you have to learn is that NumPy gives you very powerful tools to operate on arrays. So, whenever possible, you should use NumPy functions and slices instead of `for` loops. And while the saving may not be important now, once the examples start getting bigger (for instance, a $1200 \\times 50 \\times 100 \\times 3$ array for a list of 50$\\times$100 px images in color), vectorization will be the only way to perform the tasks in a reasonable time."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.6.3"
}
},
"nbformat": 4,
"nbformat_minor": 2
}