# Lecture 4: Optimization Using Python - SciPy

In this lecture / tutorial, we will learn how to solve some simple optimization problems using Python. This involves a brief introduction to the various optimization libraries available, such as ```scipy.optimize```, ```ortools```, and ```cplex```. We will solve an example optimization problem using each library.

***

## Learning goals
- Obtain an overview of optimization problems that can be easily solved using Python.
- Know about some of the popular optimization libraries which have easy to use Python interfaces.
- Learn the syntax to solve some simple optimization problems using at least a couple of the libraries discussed in this tutorial.
- Test your understanding by solving a few of the practice problems in each section.

***
# Prerequisites for running this notebook

You should have Python 3.6 installed on your computer, with all necessary packages installed.

We recommend that you install Anaconda (Python 3.6 version) from the following links depending on your OS:
- For Windows: https://www.anaconda.com/download/#windows
- For macOS: https://www.anaconda.com/download/#macos
- For Linux: https://www.anaconda.com/download/#linux

**If you are not using Anaconda, it is your responsibility to make sure that Python and all necessary packages are correctly installed and configured to be able to run this notebook.**

***

Once Anaconda is installed, open a **Terminal** (if you are using macOS / Linux), or **Anaconda Prompt** (if you are using Windows), and then create a new Python environment called **cme193**, by running the following command:

> ```conda create -n cme193 python=3.6```

Next, change to the newly created virtual environment by running the command:

On Windows
> ```activate cme193```

On macOS or Linux
> ```source activate cme193```

Next install all the necessary packages by running the following commands:

> ```conda install nb_conda```

> ```conda install -c anaconda scipy```

> ```conda install -c conda-forge matplotlib```

Now navigate to the directory containing this .ipynb file, from inside the terminal, and start jupyter notebook by typing the following command:
> ```jupyter notebook```

You should now be able to launch the .ipynb file from the browser. For more information on jupyter notebooks, read the user documentation.

***
# Introduction to scipy.optimize

In this section we will learn how to solve some simple optimization problems using ```scipy```. The ```scipy.optimize``` package already gives us a lot of basic tools to solve a wide variety of important optimization problems. For more information please read the documentation.

We can import the module as follows (henceforth to be referred to as ```sciopt```). We also import some other modules we will use in this notebook.

```python
import scipy.optimize as sciopt
import numpy as np
import matplotlib.pyplot as plt
```

***
## Solving a linear program

The first example we will look at is that of solving a **linear program (LP)**. A linear program is any optimization problem of the following form:

$$
\begin{equation}
\begin{split}
\text{minimize} \;\; & c^{T}x  \\
\text{subject to} \;\; & A_{ub}x \leq b_{ub} \\
& A_{eq}x = b_{eq}
\end{split}
\end{equation}
$$

where $c, x \in \mathbb{R}^n$, $A_{ub} \in \mathbb{R}^{m \times n}$, $A_{eq} \in \mathbb{R}^{p \times n}$, $b_{ub} \in \mathbb{R}^{m}$, and $b_{eq} \in \mathbb{R}^{p}$. It should be noted that all LP can be put in this form.

```scipy.optimize``` provides a simple function ```scipy.optimize.linprog``` to solve such problems, which is documented here. Currently, the only available algorithm that is implemented are the **simplex method**, and the **interior point method**. We will demonstrate its usage using a few examples.

***
### Example 1
Let us consider the problem

$$
\begin{equation}
\begin{split}
\text{minimize} \;\; & x_1 + 2 x_2  \\
\text{subject to} \;\; & x_1 \leq 1 \\
& 5 x_1 + x_2 \geq 0
\end{split}
\end{equation}
$$

In order to solve it, we first need to transform it to the form that ```scipy.optimize.linprog``` requires. The problem is clearly equivalent to

$$
\begin{equation}
\begin{split}
\text{minimize} \;\; & x_1 + 2 x_2  \\
\text{subject to} \;\; & x_1 \leq 1 \\
& -5 x_1 - x_2 \leq 0
\end{split}
\end{equation}
$$

The following Python code then solves this problem.

```python
# Define problem parameters
c = [1, 2]
A_ub = [[1, 0], [-5, -1]]
b_ub = [1, 0]
bounds = ((None, None), (None, None))

# Solve the LP
result = sciopt.linprog(c=c, A_ub=A_ub, b_ub=b_ub, bounds=bounds)
```

```python
# Print the result
print(result)
```

         fun: -9.000000000000004
     message: 'Optimization terminated successfully.'
         nit: 2
       slack: array([0., 0.])
      status: 0
     success: True
           x: array([ 1., -5.])

***
Notice that we must explicitly set the ```bounds``` parameter in the above problem. If we don't pass this parameter, the default assumption is that the variables are non-negative.

You can additionally pass the parameter ```options={"disp": True}``` to print convergence messages from the solver. **Solver method specific parameters can also be passed as optional parameters in** ```options```.

```python
# Solve the LP and print convergence messages
result = sciopt.linprog(c=c, A_ub=A_ub, b_ub=b_ub, bounds=bounds, options={"disp": True})
```

    Optimization terminated successfully.
             Current function value: -9.000000
             Iterations: 2

```python
# Extract the solution and print it
obj_optimal = result['fun']
x = result['x']
print("Optimal solution: x1 = ", x[0], ", x2 = ", x[1])
print("Optimal value = ", obj_optimal)
```

    Optimal solution: x1 =  1.0 , x2 =  -5.000000000000002
    Optimal value =  -9.000000000000004

***
### Example 2
Let us change the problem by adding an equality constraint

$$
\begin{equation}
\begin{split}
\text{minimize} \;\; & x_1 + 2 x_2  \\
\text{subject to} \;\; & x_1 \leq 1 \\
& 5 x_1 + x_2 \geq 0 \\
& x_1 + x_2 = 3.
\end{split}
\end{equation}
$$

In order to solve it, we first need to transform it to the form that ```scipy.optimize.linprog``` requires. The problem is clearly equivalent to

$$
\begin{equation}
\begin{split}
\text{minimize} \;\; & x_1 + 2 x_2  \\
\text{subject to} \;\; & x_1 \leq 1 \\
& -5 x_1 - x_2 \leq 0 \\
& x_1 + x_2 = 3.
\end{split}
\end{equation}
$$

The following Python code then solves this problem.

```python
# Define problem parameters
c = [1, 2]
A_ub = [[1, 0], [-5, -1]]
b_ub = [1, 0]
A_eq = [[1, 1]]
b_eq = [3]
bounds = ((None, None), (None, None))

# Solve the LP
print("Solving the LP")
result = sciopt.linprog(c=c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq=b_eq, bounds=bounds, options={"disp": True})

# Extract the solution and print it
obj_optimal = result['fun']
x = result['x']
print("\n")
print("Optimal solution: x1 = ", x[0], ", x2 = ", x[1])
print("Optimal value = ", obj_optimal)
```

    Solving the LP
    Optimization terminated successfully.
             Current function value: 5.000000
             Iterations: 2

    Optimal solution: x1 =  1.0 , x2 =  2.0
    Optimal value =  5.0

#### Alternate way of solving the problem
Notice that the inequality constraint ```x1 <= 1``` is a **bound constraint**. Hence, an alternate way to solve **Example 2** is as follows:

```python
# Define problem parameters
c = [1, 2]
A_ub = [[-5, -1]]
b_ub = [0]
A_eq = [[1, 1]]
b_eq = [3]
bounds = ((None, 1), (None, None))

# Solve the LP
print("Solving the LP")
result = sciopt.linprog(c=c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq=b_eq, bounds=bounds, options={"disp": True})

# Extract the solution and print it
obj_optimal = result['fun']
x = result['x']
print("\n")
print("Optimal solution: x1 = ", x[0], ", x2 = ", x[1])
print("Optimal value = ", obj_optimal)
```

    Solving the LP
    Optimization terminated successfully.
             Current function value: 5.000000
             Iterations: 2

    Optimal solution: x1 =  1.0 , x2 =  2.0
    Optimal value =  5.0

***
### Example 3
Some special problems can be reduced to a LP. Consider the following optimization problem

$$
\begin{equation}
\begin{split}
\text{minimize} \;\; & x_1 + 2 x_2 - 3 x_3  \\
\text{subject to} \;\; & |x_1| \leq 1 \\
& |x_2| \leq 2 \\
& |x_3| \leq 1 \\
& x_1 + x_2 + x_3 = 1.
\end{split}
\end{equation}
$$

But this is just equivalent to

$$
\begin{equation}
\begin{split}
\text{minimize} \;\; & x_1 + 2 x_2 - 3 x_3  \\
\text{subject to} \;\; & -1 \leq x_1 \leq 1 \\
& -2 \leq x_2 \leq 2 \\
& -1 \leq x_3 \leq 1 \\
& x_1 + x_2 + x_3 = 1.
\end{split}
\end{equation}
$$

The following Python code then solves this problem.

```python
# Define problem parameters
c = [1, 2, -3]
A_eq = [[1, 1, 1]]
b_eq = [1]
bounds = ((-1, 1), (-2, 2), (-1, 1))

# Solve the LP
print("Solving the LP")
result = sciopt.linprog(c=c, A_eq=A_eq, b_eq=b_eq, bounds=bounds, method="interior-point", options={"disp": True})

# Extract the solution and print it
obj_optimal = result['fun']
x = result['x']
print("\n")
print("Optimal solution: x1 = ", x[0], ", x2 = ", x[1], ", x3 = ", x[2])
print("Optimal value = ", obj_optimal)
```

    Solving the LP
    Primal Feasibility  Dual Feasibility    Duality Gap         Step             Path Parameter      Objective
    1.0                 1.0                 1.0                 -                1.0                 -2.0
    0.2580267693811     0.2580267693811     0.2580267693811     0.7472714508774  0.2580267693811     -4.874990060633
    0.02400575024644    0.02400575024644    0.02400575024644    0.9172681112386  0.02400575024644    -3.930935041508
    0.0001908759146977  0.0001908759145871  0.0001908759145884  0.9958556392802  0.0001908759146894  -4.001229925194
    9.555359004043e-09  9.555359095733e-09  9.555358282398e-09  0.9999499421812  9.555358866709e-09  -4.000000061479
    4.773407186698e-13  4.778634535547e-13  4.776179451937e-13  0.9999499968491  4.777679471601e-13  -4.000000000003
    Optimization terminated successfully.

    Optimal solution: x1 =  0.9999999999999634 , x2 =  -1.0000000000018114 , x3 =  0.9999999999998039
    Optimal value =  -4.000000000003071

***
### Example 4
Here is another interesting example. Consider the following optimization problem

$$
\begin{equation}
\begin{split}
\text{minimize} \;\; & \max \{|x_1|, |x_2|, |x_3|\}  \\
\text{subject to} \;\; & x_1 + x_2 + x_3 \geq 1.
\end{split}
\end{equation}
$$

It is easy to show that this problem is equivalent to the problem (this is called the **epigraph form** of the problem)

$$
\begin{equation}
\begin{split}
\text{minimize} \;\; & s  \\
\text{subject to} \;\; & |x_1| \leq s \\
& |x_2| \leq s \\
& |x_3| \leq s \\
& s \geq 0 \\
& x_1 + x_2 + x_3 \geq 1
\end{split}
\end{equation}
$$

where the minimization is now over the variables $x_1, x_2, x_3,$ and $s$.

As before we need to change this problem into a form that is suitable for ```scipy.optimize.linprog```. The problem can be written equivalently as

$$
\begin{equation}
\begin{split}
\text{minimize} \;\; & s  \\
\text{subject to} \;\; & x_1 - s \leq 0 \\
& x_2 - s \leq 0 \\
& x_3 - s \leq 0 \\
& - x_1 - s \leq 0 \\
& - x_2 - s \leq 0 \\
& - x_3 - s \leq 0 \\
& - x_1 - x_2 - x_3 \leq -1 \\
& s \geq 0 .
\end{split}
\end{equation}
$$

The following Python code then solves this problem.

```python
# Define problem parameters
c = [0, 0, 0, 1]
A_ub = [[1, 0, 0, -1], [0, 1, 0, -1], [0, 0, 1, -1], [-1, 0, 0, -1], [0, -1, 0, -1], [0, 0, -1, -1], [-1, -1, -1, 0]]
b_ub = [0, 0, 0, 0, 0, 0, -1]
bounds = ((None, None), (None, None),(None, None), (0, None))

# Solve the LP
print("Solving the LP")
result = sciopt.linprog(c=c, A_ub=A_ub, b_ub=b_ub, bounds=bounds, method="interior-point", options={"disp": True})

# Extract the solution and print it
obj_optimal = result['fun']
x = result['x']
print("\n")
print("Optimal solution: x1 = ", x[0], ", x2 = ", x[1], ", x3 = ", x[2], ", s = ", x[3])
print("Optimal value = ", obj_optimal)
```

    Solving the LP
    Primal Feasibility  Dual Feasibility    Duality Gap         Step             Path Parameter      Objective
    1.0                 1.0                 1.0                 -                1.0                 1.0
    0.1929160231033     0.1929160231033     0.1929160231033     0.8097074289665  0.1929160231033     0.7308257137377
    0.006450902537359   0.006450902537359   0.006450902537359   0.9797701682345  0.006450902537359   0.3336302680012
    3.775130950068e-07  3.775130944228e-07  3.775130943962e-07  0.9999416764449  3.775130944919e-07  0.3333334186219
    1.887590085209e-11  1.887565983396e-11  1.887567879777e-11  0.9999499999434  1.887566346212e-11  0.3333333333376
    Optimization terminated successfully.

    Optimal solution: x1 =  0.3333333333374885 , x2 =  0.33333333333748894 , x3 =  0.3333333333374887 , s =  0.3333333333375978
    Optimal value =  0.3333333333375978

***
### Exercise 1
Compare the efficiency of the **simplex method** and the **interior point method** at solving linear programs, by generating some random LPs, and then solving them using both options. Plot the timing results as a function of problem size.

```python
# Write your solution here
```

***
## Minimum weight matching in bipartite graphs

Given an (undirected) **complete bipartite graph** $G = (V_1, V_2, E)$, with an edge cost function $C : E \rightarrow \mathbb{R}$, the goal is to find a minimum weight **matching** $M \subset E$ that covers the smaller of the two sets $V_1$ or $V_2$. Thus $V_1$ and $V_2$ need not be of the same sizes. $G$ being complete bipartite graph means that there is an edge $e \in E$ between every pair of vertices $v_1 \in V_1$, and $v_2 \in V_2$. A matching refers to a selection of edges such that no vertex is covered more than once. This problem is also known as the **linear sum assignment** problem.

Let $|V_1| = N_1$, and $|V_2| = N_2$, and without loss of generality assume that $N_1 \leq N_2$. If we index the vertices in $V_1$ by $i$, and those in $V_2$ by $j$, then $e_{ij}$ will refer to the edge between $i$ and $j$, and similarly $C_{ij}$ will refer to the cost of the edge $e_{ij}$. Let $X_{ij}$ be a boolean $\{0,1\}$ variable that indicates whether edge $e_{ij}$ is selected or not. Then our goals can be represented by the following optimization problem:

$$
\begin{equation}
\begin{split}
\text{minimize} \;\; & \sum_{i=1}^{N_1} \sum_{j=1}^{N_2} C_{ij} X_{ij}  \\
\text{subject to} \;\; & X_{ij} \in \{0, 1\}, \;\; \forall \;\; i, j \\
& \sum_{j=1}^{N_2} X_{ij} = 1, \;\; \forall \;\; i \\
& \sum_{i=1}^{N_1} X_{ij} \leq 1, \;\; \forall \;\; j.
\end{split}
\end{equation}
$$

```scipy.optimize``` provides an inbuilt function ```scipy.optimize.linear_sum_assignment``` that solves exactly this problem, which is documented here. The algorithm used to solve this problem is the famous **Hungarian algorithm**, also known as the **Kuhn-Munkres algorithm**, although it was discovered in 2006 that Carl Gustav Jacobi had solved the problem in 1840s (published only posthumously in 1890).

***
Let us see an example.

### Example 1
Consider the following $C$ matrix

$$
C =
\begin{bmatrix}
2 & 1 & -1 & 1 \\
4 & 5 & -2 & -3 \\
1 & 2 & -1 & 5 \\
-2 & 3 & 4 & 0
\end{bmatrix}
\;\;.
$$

This problem is easily solved using the following Python code.

```python
# Define problem parameters
cost_matrix = [[2, 1, -1, 1], [4, 5, -2, -3], [1, 2, -1, 5], [-2, 3, 4, 0]]

# Solve the linear sum assignment problem
print("Solving the linear sum assignment problem")
row_ind, col_ind = sciopt.linear_sum_assignment(cost_matrix=cost_matrix)

# Print the solution
print("\n")
print("Row index : ", row_ind)
print("Col index : ", col_ind)

# Print selected edges and the costs
print("\n")
print("The selected edges in the optimal assignment and their costs are:")
cost_opt = 0
for ind, row in enumerate(row_ind):
    col = col_ind[ind]
    cost_opt += cost_matrix[row][col]
    print("Edge (" + str(row) + "," + str(col) + ") , Cost = " + str(cost_matrix[row][col]))

# Print optimal cost
print("\n")
print("The optimal cost is : ", cost_opt)
```

    Solving the linear sum assignment problem

    Row index :  [0 1 2 3]
    Col index :  [1 3 2 0]

    The selected edges in the optimal assignment and their costs are:
    Edge (0,1) , Cost = 1
    Edge (1,3) , Cost = -3
    Edge (2,2) , Cost = -1
    Edge (3,0) , Cost = -2

    The optimal cost is :  -5

***
### Example 2

Consider the following $C$ matrix

$$
C =
\begin{bmatrix}
2 & 1 & -1 & 1 \\
4 & 5 & -2 & -3 \\
1 & 2 & -1 & 5
\end{bmatrix}
\;\;.
$$

This problem is easily solved using the following Python code.

```python
# Define problem parameters
cost_matrix = [[2, 1, -1, 1], [4, 5, -2, -3], [1, 2, -1, 5]]

# Solve the linear sum assignment problem
print("Solving the linear sum assignment problem")
row_ind, col_ind = sciopt.linear_sum_assignment(cost_matrix=cost_matrix)

# Print the solution
print("\n")
print("Row index : ", row_ind)
print("Col index : ", col_ind)

# Print selected edges and the costs
print("\n")
print("The selected edges in the optimal assignment and their costs are:")
cost_opt = 0
for ind, row in enumerate(row_ind):
    col = col_ind[ind]
    cost_opt += cost_matrix[row][col]
    print("Edge (" + str(row) + "," + str(col) + ") , Cost = " + str(cost_matrix[row][col]))

# Print optimal cost
print("\n")
print("The optimal cost is : ", cost_opt)
```

    Solving the linear sum assignment problem

    Row index :  [0 1 2]
    Col index :  [2 3 0]

    The selected edges in the optimal assignment and their costs are:
    Edge (0,2) , Cost = -1
    Edge (1,3) , Cost = -3
    Edge (2,0) , Cost = 1

    The optimal cost is :  -3

***
## Root finding problems - univariate rootfinding

```scipy.optimize``` provides a bunch of functions for finding the roots of a **continuous** univariate function $f$. $x$ is a root of $f$ if and only if $f(x) = 0$. We illustrate some of the important ones with an example.

Consider the function $f(x) = x^4 - x^2$. The function has 3 roots ${-1,0,1}$. The function is graphed below.

```python
%matplotlib inline

def func(x):
    return x**4 - x**2

step = 0.01
max_x = 1.2
x = np.arange(-max_x, max_x + step, step)
y = func(x)

plt.plot(x, y, "-r")
plt.grid()
plt.xlabel("x", fontsize=16)
plt.ylabel("$x^4 - x^2$", fontsize=16)
```

The important functions in ```scipy.optimize``` for finding the roots of $f$ can be divided into two categories:
- **Root finding on an interval**: Requires that an interval $[a,b]$ be specified such that $f(a)f(b) < 0$, i.e. the function has different signs at the end points of the interval. The methods that can be used in this setting are ```scipy.optimize.brentq```, ```scipy.optimize.brenth```, ```scipy.optimize.bisect```, ```scipy.optimize.ridder```.
- **Root finding near a point**: Requires a starting point $x_0$. The method that can be used in this setting is ```scipy.optimize.newton```.

More information on these methods can be obtained by clicking on each of these functions, starting from the documentation page for ```scipy.optimize```.

***
### Root finding in an interval
Let us first try to search for a root in the interval $[-1.5, 0.5]$ using the different methods, and print some performance metrics related to convergence.

```python
# Set a, b
a = -1.5
b = 0.5

# Solve using different methods
root1, result1 = sciopt.brentq(f=func, a=a, b=b, full_output=True, disp=True)
root2, result2 = sciopt.brenth(f=func, a=a, b=b, full_output=True, disp=True)
root3, result3 = sciopt.ridder(f=func, a=a, b=b, full_output=True, disp=True)
root4, result4 = sciopt.bisect(f=func, a=a, b=b, full_output=True, disp=True)

# Print messages
print("\n\nbrentq method results\n")
print("Root detected at x = ", root1)
print("Performance parameters:")
print(result1)

print("\n\nbrenth method results\n")
print("Root detected at x = ", root2)
print("Performance parameters:")
print(result2)

print("\n\nridder method results\n")
print("Root detected at x = ", root3)
print("Performance parameters:")
print(result3)

print("\n\nbisect method results\n")
print("Root detected at x = ", root4)
print("Performance parameters:")
print(result4)
```

    brentq method results

    Root detected at x =  -1.0000000000000588
    Performance parameters:
          converged: True
               flag: 'converged'
     function_calls: 68
         iterations: 67
               root: -1.0000000000000588

    brenth method results

    Root detected at x =  -1.0
    Performance parameters:
          converged: True
               flag: 'converged'
     function_calls: 67
         iterations: 66
               root: -1.0

    ridder method results

    Root detected at x =  -1.0000000000009923
    Performance parameters:
          converged: True
               flag: 'converged'
     function_calls: 16
         iterations: 7
               root: -1.0000000000009923

    bisect method results

    Root detected at x =  -1.0
    Performance parameters:
          converged: True
               flag: 'converged'
     function_calls: 4
         iterations: 2
               root: -1.0

***
### Exercise 2
Try different values of $[a,b]$ and check the performance comparison as above.

```python
# Write your code here
```

***
### Root finding near a point
Next let us try to search for a root of the same function $f(x) = x^4 - x^2$ near a point, using the Newton algorithm. The Newton algorithm ```scipy.optimize.newton``` can take in optional parameters which are the first and second derivatives of the function. When derivatives are not provided the **secant method** is used. When the first derivative is provided, the algorithm used is called **Newton-Raphson**. When both first and second derivatives are provided, the algorithm used is called **Halley's algorithm**.

**Note: It is very important to check the result $x$ from this algorithm, i.e, if $f(x) = 0$, as convergence is only guaranteed when one starts near a zero**.

We first code up the function first and second derivatives.

```python
def func_prime(x):
    return 4 * (x ** 3) - 2 * x

def func_prime2(x):
    return 12 * (x ** 2) - 2
```

Let us see the effect of running these different algorithms for finding a root of our function, starting from the point $x_0 = 0.5$.

```python
# Define starting point
x0 = 0.5

# Solve using secant method
root_secant = sciopt.newton(func=func, x0=x0)

# Solve using Newton-Rapheson method
root_newton = sciopt.newton(func=func, x0=x0, fprime=func_prime)

# Solve using Halley's method
root_halley = sciopt.newton(func=func, x0=x0, fprime=func_prime, fprime2=func_prime2)

# Print results
print("\nPrinting the roots :")
print("Secant method : ", root_secant)
print("Newton-Rapheson method : ", root_newton)
print("Halley's method : ", root_halley)
```

    Printing the roots :
    Secant method :  1.7614525405214752e-08
    Newton-Rapheson method :  1.4586678995097246e-08
    Halley's method :  5.324162359300112e-09

***
### Exercise 3
Try different values of $x_0$ and check what happens with each root finding method. Do you see something strange for $x_0 = 0.7$? If yes, can you explain it?

```python
# Write your code here
```

***
## Root finding problems - multivariate rootfinding

We now turn to the much harder problem of finding zeros of functions of the form  $f : \mathbb{R}^m \rightarrow \mathbb{R}^n$. ```scipy.optimize``` provides a single function ```scipy.optimize.root```, through which all the other functions listed in the documentation page for multivariate root finding are accessible. All the algorithms require an initial guess (or starting point) $x_0$. The syntax for the function ```scipy.optimize.root``` can be found here.

The important parameters that this function accepts, and about which you should be aware of are:
- ```fun```: A function that implements $f$. The function can optionally return the Jacobian as well.
- ```x0```: Initial guess.
- ```method```: The type of solver to use. Options include ```hybr```, ```krylov```, ```broyden1``` etc.
- ```jac```: Either a ```bool```, or a callable function that returns the Jacobian. In this case, it must accept the same arguments as fun.
- ```options```: A dictionary with optional arguments for the solver ```method```.

**Note:** If ```jac``` is a Boolean and is True, ```fun``` is assumed to return the value of Jacobian along with the objective function. If False, the Jacobian will be estimated numerically. Also one should be aware that many methods do not need the Jacobian implemented; they approximate the Jacobian internally.

We will learn to use some of the features of ```scipy.optimize.root``` using an example.

***
### Example 1
Consider the function $f : \mathbb{R}^2 \rightarrow \mathbb{R}^2$ defined as

$$
f(x,y) = ((x - x_t)^2 - (y - y_t)^2, 2(x - x_t)(y - y_t)),
$$

for some $(x_t, y_t) \in \mathbb{R}^2$.

Alternatively you can also think of this function as $f : \mathbb{C} \rightarrow \mathbb{C}$, defined as $f(z) = (z - z_t)^2$, where $z = x + i y$, and $z_t = x_t + i y_t$. Clearly this function has only one root $z = z_t$, i.e. $(x, y) = (x_t, y_t)$.

Let us code up the function and its Jacobian. The Jacobian is given by

$$
J(x,y) =
\begin{bmatrix}
2(x - x_t) & 2(y - y_t) \\
-2(y - y_t) & 2(x - x_t)
\end{bmatrix}
.
$$

Set $x_t = 1, y_t = 1$.

```python
# Define xt, yt
xt = 1
yt = 1

# Define the function
def fun(x):
    return [(x[0] - xt) ** 2 - (x[1] - yt) ** 2, 2 * (x[0] - xt) * (x[1] - yt)]

# Define the Jacobian
def jac(x):
    return [[2 * (x[0] - xt), 2 * (x[1] - yt)], [-2 * (x[1] - yt), 2 * (x[0] - xt)]]

# Define the function that also returns the Jacobian
def fun1(x):
    return (
        [(x[0] - xt) ** 2 - (x[1] - yt) ** 2, 2 * (x[0] - xt) * (x[1] - yt)],
        [[2 * (x[0] - xt), 2 * (x[1] - yt)], [-2 * (x[1] - yt), 2 * (x[0] - xt)]]
    )
```

Define a starting guess of the root $(x_0, y_0) = (0.5, 0.5)$, and lets demonstrate how the Jacobian can be passed.

```python
# Define starting guess
x0 = [0.5, 0.5]

# Demonstrate usage using different ways to supply function and Jacobian

print("Method 1", "\n")
sol = sciopt.root(fun=fun1, x0=x0, jac=True, method='hybr')
print(sol, "\n")
print("Solution : x = ", sol.x[0], ", y = ", sol.x[1], "\n\n\n")

print("Method 2", "\n")
sol = sciopt.root(fun=fun, x0=x0, jac=False, method='hybr')
print(sol, "\n")
print("Solution : x = ", sol.x[0], ", y = ", sol.x[1], "\n\n\n")

print("Method 3", "\n")
sol = sciopt.root(fun=fun, x0=x0, jac=jac, method='hybr')
print(sol, "\n")
print("Solution : x = ", sol.x[0], ", y = ", sol.x[1], "\n\n\n")
```

    Method 1

        fjac: array([[ 0.98400183,  0.17815833],
           [-0.17815833,  0.98400183]])
         fun: array([3.09280704e-17, 1.47994165e-17])
     message: 'The solution converged.'
        nfev: 60
        njev: 3
         qtf: array([1.05477780e-16, 2.65636348e-17])
           r: array([-2.93722421e-08,  2.88725167e-08, -2.92569278e-08])
      status: 1
     success: True
           x: array([0.99999999, 1.        ])

    Solution : x =  0.9999999942897184 , y =  0.999999998704143

    Method 2

        fjac: array([[-1.00000000e+00, -1.26928271e-15],
           [ 1.26928271e-15, -1.00000000e+00]])
         fun: array([0.0000000e+00, 2.2186713e-31])
     message: 'The solution converged.'
        nfev: 75
         qtf: array([-1.12644845e-45, -8.87468518e-31])
           r: array([ 9.99999993e-01, -9.99999993e-01,  2.92304139e-15])
      status: 1
     success: True
           x: array([1., 1.])

    Solution : x =  0.9999999999999997 , y =  0.9999999999999997

    Method 3

        fjac: array([[ 0.98400183,  0.17815833],
           [-0.17815833,  0.98400183]])
         fun: array([3.09280704e-17, 1.47994165e-17])
     message: 'The solution converged.'
        nfev: 60
        njev: 3
         qtf: array([1.05477780e-16, 2.65636348e-17])
           r: array([-2.93722421e-08,  2.88725167e-08, -2.92569278e-08])
      status: 1
     success: True
           x: array([0.99999999, 1.        ])

    Solution : x =  0.9999999942897184 , y =  0.999999998704143

***
### Exercise 4
1. Try different values of the starting guess $(x_0, y_0)$, and see the impact on performance, as measured by the number of function and Jacobian evaluations.
2. Repeat the experiment with different values of $(x_t, y_t)$. What happens as you approach $x_t = 0, y_t = 0$?

```python
# Write your code here
```

***
### Example 2
Consider the following system of nonlinear equations

$$
\begin{split}
x + \frac{(x - y)^3}{2} - 1 &= 0 \\
\frac{(y - x)^3}{2} + y &= 0 \;.
\end{split}
$$

We can try to solve this system by trying to find the roots of the function $f : \mathbb{R}^2 \rightarrow \mathbb{R}^2$ defined as

$$
f(x,y) = \left( x + \frac{(x - y)^3}{2} - 1, \frac{(y - x)^3}{2} + y \right).
$$

We code up the function, its Jacobian, and solve the problem using a few different methods.

```python
# Define the function
def fun_nonlinear_eq(x):
    return [x[0] + 0.5 * ((x[0] - x[1]) ** 3) - 1, 0.5 * ((x[1] - x[0]) ** 3) + x[1]]

# Define the Jacobian
def jac_nonlinear_eq(x):
    return [
        [1 + 1.5 * ((x[0] - x[1]) ** 2), -1.5 * ((x[1] - x[0]) ** 2)],
        [-1.5 * ((x[0] - x[1]) ** 2), 1 + 1.5 * ((x[1] - x[0]) ** 2)]
    ]

# Define starting guess
x0 = [1, 1]

# Solve using method 'hybr'
name = 'hybr'
print("Method " + name, "\n")
sol = sciopt.root(fun=fun_nonlinear_eq, x0=x0, jac=jac_nonlinear_eq, method=name)
print(sol, "\n")
print("Solution : x = ", sol.x[0], ", y = ", sol.x[1], "\n\n\n")

# Solve using method 'lm'
name = 'lm'
print("Method " + name, "\n")
sol = sciopt.root(fun=fun_nonlinear_eq, x0=x0, jac=jac_nonlinear_eq, method=name)
print(sol, "\n")
print("Solution : x = ", sol.x[0], ", y = ", sol.x[1], "\n\n\n")

# Methods below do not use Jacobian -- should throw an warning if Jacobian is passed

# Solve using method 'broyden1'
name = 'broyden1'
print("Method " + name, "\n")
sol = sciopt.root(fun=fun_nonlinear_eq, x0=x0, jac=jac_nonlinear_eq, method=name)
print(sol, "\n")
print("Solution : x = ", sol.x[0], ", y = ", sol.x[1], "\n\n\n")

# Solve using method 'anderson'
name = 'anderson'
print("Method " + name, "\n")
sol = sciopt.root(fun=fun_nonlinear_eq, x0=x0, method=name)
print(sol, "\n")
print("Solution : x = ", sol.x[0], ", y = ", sol.x[1], "\n\n\n")

# Solve using method 'krylov'
name = 'krylov'
print("Method " + name, "\n")
sol = sciopt.root(fun=fun_nonlinear_eq, x0=x0, method=name)
print(sol, "\n")
print("Solution : x = ", sol.x[0], ", y = ", sol.x[1], "\n\n\n")
```

    Method hybr

        fjac: array([[ 0.95527545, -0.29571744],
           [ 0.29571744,  0.95527545]])
         fun: array([-1.11022302e-16,  2.77555756e-17])
     message: 'The solution converged.'
        nfev: 10
        njev: 1
         qtf: array([-1.11891287e-11,  5.89928428e-12])
           r: array([ 1.51616686, -1.4821053 ,  1.5807719 ])
      status: 1
     success: True
           x: array([0.8411639, 0.1588361])

    Solution : x =  0.8411639019140096 , y =  0.15883609808599033

    Method lm

       cov_x: array([[0.58704377, 0.41295623],
           [0.41295623, 0.58704377]])
        fjac: array([[-1.83633283, -0.38029971],
           [ 1.29176924, -1.30516302]])
         fun: array([-1.11022302e-16,  2.77555756e-17])
        ipvt: array([1, 2], dtype=int32)
     message: 'The relative error between two consecutive iterates is at most 0.000000'
        nfev: 7
        njev: 6
         qtf: array([-1.85329897e-10,  7.73266306e-11])
      status: 2
     success: True
           x: array([0.8411639, 0.1588361])

    Solution : x =  0.8411639019140096 , y =  0.15883609808599033

    Method broyden1

         fun: array([-2.20142724e-07,  6.78306977e-08])
     message: 'A solution was found at the specified tolerance.'
         nit: 11
      status: 1
     success: True
           x: array([0.84116377, 0.15883608])

    Solution : x =  0.841163765681265 , y =  0.15883608200670896

    Method anderson

         fun: array([ 3.39165093e-07, -3.43755319e-07])
     message: 'A solution was found at the specified tolerance.'
         nit: 9
      status: 1
     success: True
           x: array([0.84116404, 0.15883595])

    Solution : x =  0.841164042089031 , y =  0.15883595332074232

    Method krylov

    C:\Anaconda3\envs\cme193\lib\site-packages\scipy\optimize\_root.py:209: RuntimeWarning: Method broyden1 does not use the jacobian (jac).
      RuntimeWarning)

         fun: array([0.19069831, 0.08409976])
     message: 'The maximum number of iterations allowed has been reached.'
         nit: 300
      status: 2
     success: False
           x: array([1.        , 0.27479807])

    Solution : x =  1.0 , y =  0.2747980741784015

***
### Exercise 5
1. Increase the maximum number of iterations for the 'krylov' method and see if there is an impact on the solution.
2. Try different starting guesses for $(x_0, y_0)$, for e.g. try $(0.8, 0.2)$ for the 'krylov' method. Does it help?

```python
# Write your code here
```

***
## Fixed point iterations

```scipy.optimize``` provides a special function ```scipy.optimize.fixed_point``` for finding fixed points of functions of the form $f : \mathbb{R}^m \rightarrow \mathbb{R}^m$. $x \in \mathbb{R}^m$ is a fixed point of $f$ if and only if $f(x) = x$. The syntax for the function ```scipy.optimize.fixed_point``` can be found here.

There are two main algorithms which are supported by this function: ```iteration``` and ```del2```. The default method is ```del2``` which uses Steffensen’s Method with Aitken’s convergence acceleration. The ```iteration``` method simply iterates the function until convergence is detected, without attempting to accelerate the convergence.

We demonstrate the usage of this method with a few examples.

***
### Example 1
Let us consider the problem of finding a solution to the equation $\sin (\alpha x) = \beta x$, for $\alpha, \beta \in \mathbb{R}$. The roots of this equation can be expressed as fixed points of the function $f(x) = \frac{\sin (\alpha x)}{\beta}$.

Let us plot the functions $\sin(\alpha x)$ and $\beta x$ below. You can change $\alpha$ and $\beta$ and see the changes in the plots.

```python
%matplotlib inline

alpha = 1
beta = 0.5

step = 0.01
max_x = 5
x = np.arange(-max_x, max_x + step, step)
y1 = np.sin(alpha * x)
y2 = beta * x

plt.plot(x, y1, "-r", label="$\sin$(" + str(alpha) + "x)")
plt.plot(x, y2, "-b", label=str(beta) + "x")
plt.grid()
plt.xlabel("x", fontsize=16)
plt.legend(fontsize=16)
```

The following code solves the problem for $\alpha = 1, \beta = 1$, with a starting guess $x_0 = 0.5$.

```python
# Define the function
def func_sinx(x, alpha, beta):
    return np.sin(alpha * x) / beta

# Define alpha, beta
alpha = 1
beta = 1

# Define initial guess
x0 = 0.5

# Solve
fp = sciopt.fixed_point(func=func_sinx, x0=x0, args=(alpha, beta), method="del2")

# Print result
print("Fixed point detected : x = ", fp)
```

    Fixed point detected : x =  0.00012560345075422456

***
### Exercise 6
Experiment with different values of $\alpha, \beta, x_0$ in the above example.

```python
# Write your code here
```

***
### Example 2
Consider the function $f : \mathbb{R}^2 \rightarrow \mathbb{R}^2$, defined as

$$
f(x_1, x_2) = \left( \frac{a_1}{x_1 + b_1}, \frac{a_2}{x_2 + b_2} \right),
$$

for some $a_1, b_1, a_2, b_2 \in \mathbb{R}$.

The following Python code finds a fixed point of $f$ for $a_1 = 10, b_1 = 3, a_2 = 12, b_2 = 5$, and starting guess $(0,0)$. You can vary these parameters and see the changes in the solution.

```python
# Define the function
def func_fixed_point(x, a, b):
    return np.sqrt(a / (x + b))

# Define the parameters
a = [10, 12]
b = [3, 5]

# Define starting guess
x0 = [0, 0]

# Solve the problem
fp = sciopt.fixed_point(func=func_fixed_point, x0=x0, args=(a, b), method="del2")

# Print result
print("Fixed point detected : x1 = ", fp[0], ", x2 = ", fp[1])
```

    Fixed point detected : x1 =  1.4920333011718168 , x2 =  1.3722813232690143

***
### Exercise 7
1. Formulate the above example as a multivariate root finding problem and solve it.
2. Formulate the above example as a scalar root finding problem and solve it.
3. Compare the performance of the two strategies.

```python
# Write your code here
```

***
# Local optimization

```python

```


# Laboratório 4: Caso Limitado

### Referente ao capítulo 9

Vamos reexaminar o primeiro laboratório, mas com inclusão de limites inferior e superior para o controle.

$$\begin{equation}
    \max_u \int_0^1 Ax(t) - u(t)^2 dt \\
    s.a ~ x'(t) = -\frac{1}{2}x(t)^2 + Cu(t), x(0) = x_0 > -2, \\
    M_1 \leq u(t) \leq M_2, A \geq 0
\end{equation}$$

## Condições Necessárias

### Hamiltoniano

$$
H = Ax - u^2 + \lambda\left(-\frac{1}{2}x(t)^2 + Cu(t)\right)
$$

### Equação adjunta

$$
\lambda '(t) = - H_x = -A + \lambda(t)x(t)
$$

### Condição de transversalidade

$$
\lambda(T) = 0
$$

### Condição de otimalidade

$$
H_u = -2u + C\lambda
$$
Assim,

$H_u < 0 \implies u^*(t) = M_1 \implies \frac{C}{2}\lambda < M_1$

$H_u > 0 \implies u^*(t) = M_2 \implies \frac{C}{2}\lambda > M_2$

$H_u = 0 \implies M_1 \le u^*(t) = \frac{C}{2}\lambda \le M_2$

Portanto $u^*(t) = \min\left(M_2, \max\left(M_1, \frac{C}{2}\lambda(t)\right)\right)$

### Importanto as bibliotecas

```python
import numpy as np
import matplotlib.pyplot as plt
from scipy.integrate import solve_ivp

import sys
sys.path.insert(0, '../pyscripts/')

from optimal_control_class import OptimalControl
```

Precisamos definir a função que atualiza $u^*$ segundo a expressão acima mensionada, pontualmente. É importante usar a função `np.minimum` ao invés de `min` para que dados dois vetores, tenhamos o mínimo ponto a ponto, índice por índice. O mesmo para `np.maximum`.

```python
parameters = {'A': None, 'C': None, 'M1': None, 'M2': None}

diff_state = lambda t, x, u, par: -0.5*x**2 + par['C']*u
diff_lambda = lambda t, x, u, lambda_, par: -par['A'] + lambda_*x
update_u = lambda t, x, lambda_, par: np.minimum(par['M2'], np.maximum(par['M1'], 0.5*par['C']*lambda_))
```

## Aplicando a classe ao exemplo

Vamos fazer algumas exeperimentações. Sinta-se livre para variar os parâmetros. Os limites podem ser passados de duas formas. Como parâmetro padrão da classe ou como parâmetro do método `solve`.

```python
problem = OptimalControl(diff_state, diff_lambda, update_u)
```

```python
x0 = 1
T = 1
parameters['A'] = 1
parameters['C'] = 4
parameters['M1'] = -1
parameters['M2'] = 2
```

```python
t,x,u,lambda_ = problem.solve(x0, T, parameters, bounds = [(parameters['M1'], parameters['M2'])])
ax = problem.plotting(t,x,u,lambda_)
```

Observe que o resultado é equivalente àquele do laboratório 1. Se a solução ótima do problema está entre os limites que definimos, como foi o caso nesse exemplo, a solução permanece a mesma, como era esperado. Agora, se definirmos um limite menor que o máximo do controle ótimo, a solução deve mudar.

```python
parameters['M2'] = 0.5
t,x,u,lambda_ = problem.solve(x0, T, parameters, bounds = [(parameters['M1'], parameters['M2'])])
ax = problem.plotting(t,x,u,lambda_)
```

Observe que o controle ótimo ficou diferente, inclusive em todo o intervalo e, em particular, fica em seu máximo uma boa parcela do tempo. Também podemos fazer o mesmo com o limite inferior.

```python
parameters['M1'] = 0.2
t,x,u,lambda_ = problem.solve(x0, T, parameters, bounds = [(parameters['M1'], parameters['M2'])])
ax = problem.plotting(t,x,u,lambda_)
```

### Este é o final do notebook


# The McCall Search Model

Contents:

- [The McCall Search Model](#The-McCall-Search-Model)
  - [Revisiting the Finite-State McCall Model](#Revisiting-the-Finite-State-McCall-Model)
  - [Solving for the Optimal Policy](#Solving-for-the-Optimal-Policy)
  - [Implementing Value Function Iteration](#Implementing-Value-Function-Iteration)
  - [Comparative Statics](#Comparative-Statics)

This lab includes:

(1) An overview of the finite-state McCall search model;

(2) A description of the value function iteration algorithm;

(3) An implementation of value function iteration applied to the McCall model;

(4) Some simple comparative statics analysis of the McCall model.

----

## Revisiting the Finite-State McCall Model

We start by briefly reviewing the finite-state McCall model setup.

A worker wants to maximize expected lifetime utility, $\mathbb{E} \sum_{t=0}^\infty \beta^t y_t$, where $\beta \in (0,1)$ is the discount factor and $\{y_t\}$ is an income sequence.

The worker starts out being unemployed, but at each period they receive a job offer with wage $w$ drawn from a finite i.i.d. distribution (known to the worker) with outcomes $\{w_1,\ldots,w_S\}$ and corresponding probability weights $\{p_1,\ldots,p_S\}$.

At time $T$ the worker may accept the given job offer and receive the wage $w$ for all $t \geq T$.

Or the worker may reject the offer, receive the unemployment check $c > 0$ at time $T$, and repeat the job search again at $T+1$.

The worker chooses whichever option is associated with a greater expected lifetime utility. Therefore, the value function $v(w)$ of this problem satisfies the Bellman equation

\begin{align}
    v(w) = \max_{\text{accept,reject}} \left\{ \frac{w}{1-\beta} \, , \, c + \beta \sum_{i = 1}^S v(w_i ') p_i \right\}
\end{align}

In this lab we will combine some of the tools we've learned in the first two labs to solve for the value function of the above Bellman equation.

---

##  Solving for the Optimal Policy

The Bellman equation is equivalent to the following nonlinear system of equations that we need to solve with respect to the $v_i$'s:

\begin{align}
v_i = \max \left\{ \frac{w_i}{1 - \beta} \, , \, c + \beta \sum_{i=1}^S v_i p_i \right\} \text{ for } i = 1,\ldots,S \, .
\end{align}

Our solution to this system of equations should be of the form $v \in \mathbb{R}^S$.

The value function iteration algorithm allows us to solve for $v$ with the following steps:

(1) Pick some $v \in \mathbb{R}^S$ -- the initial guess for the solution;

(2) Map the chosen $v$ using the Bellman mapping to obtain $v_1 \in \mathbb{R}^S$, so that
\begin{align*}
v_i' = \max \left\{ \frac{w_i}{1-\beta} \, , \, c + \beta \sum_{i=1}^S v_i p_i \right\} \text{ for } i = 1,\ldots,S \, ;
\end{align*}

(3) Note the deviation between $v$ and $v'$ -- if the deviation is above some threshhold then stop, otherwise repeat the process.

Once the above process stops, the resulting $v$ will be an approvimation of the true solution for the value function.

Given $|\beta| < 1$, our Bellman map satisfies the assumptions of the Banach contraction mapping theorem, which implies that our generated sequence of values should converge to a unique fixed point.

---

## Implementing Value Function Iteration

First we load all necessary packages.

```julia
using LinearAlgebra
```

We create a function that carries out a single iteration of the Bellman mapping.

We can then repeatedly call on this function when applying the algorithm.

This function will be called `mccallbellmanmap`, and will take an initial value function `v`, a vector of possible wages `w`, a vector of probabilities associated with each of the wages `π`, the unemployment benefit `c`, and the discount factor `β`, as inputs.

`mccallbellmanmap()` will output a new mapping of `v`.

```julia
"""
    mccallbellmanmap(v,w,π,c,β)

Iterates the McCall search model bellman equation for with value function v.
Returns the new value function.

# Arguments
* `v` vector of values for each wage
* `w` vector of wages
* `p` vector of probabilities for each wage
* `c` unemployment benefits
* `β` time discount factor

"""

function mccallbellmanmap(v, w, p, c, β)

    # Create empty vector for value storage
    v_out = zeros(length(w))

    # Compute value given each wage in vector `w`
    for i in 1:length(w)

        # Bellman map: v = max{c + β E(v'), w/(1-β)}
        v_out[i] = max(c + β * dot(p,v), w[i]/(1-β))

    end

    # Return value function
    return v_out
end
```

    mccallbellmanmap (generic function with 1 method)

We may also avoids loops entirely by defining an alternative function `mccallbellmanmap_v2` that takes the same exact inputs and gives the same output.

```julia
"""
    mccallbellmanmap_v2(v,w,π,c,β)

Iterates the McCall search model bellman equation for with value function v.
Returns the new value function.

# Arguments
* `v` vector of values for each wage
* `w` vector of wages
* `p` vector of probabilities for each wage
* `c` unemployment benefits
* `β` time discount factor

"""
function mccallbellmanmap_v2(v, w, p, c, β)

    # Compute value of rejecting the offer
    v_reject = c + β * dot(p,v) # this is a Float

    # Compute value of accepting the wage offer
    v_accept = w / (1-β)

    # Compute v_reject to v_accept
    v_out = max.(v_reject, v_accept)

    # Return value function
    return v_out
end
```

    mccallbellmanmap_v2

Now let's iterate over the Bellman map to see what it does.

```julia
# Create discrete uniform wage distribution between 1 and 10
# where S = # of grid points (evenly-spaced points between 1 and 10)
S = 10
w = LinRange(1, 10, S)
p = ones(S)/S # each p[i] = 1/S ∀i, so that p(w[i]) = p(w[j]) ∀i,j ∈ {1,...,S}

# Set model parameters:
β = 0.95 # note that lower β causes code to converge faster
c = 3 # unemployment wage/benefits

# Initiate the value function
v0 = zeros(S)

# Iterate code J times
J = 50

# Create and initiate array containing
# iterated value function values:
V = zeros(J, S)
V[1,:] = mccallbellmanmap(v0, w, p, c, β)

# Keep applying Bellman map recursively
for j in 2:J
    V[j,:] = mccallbellmanmap_v2(V[j-1,:], w, p, c, β)
end

V
```

    50×10 Array{Float64,2}:
      20.0     40.0     60.0     80.0    …  140.0    160.0    180.0  200.0
     107.5    107.5    107.5    107.5       140.0    160.0    180.0  200.0
     130.062  130.062  130.062  130.062     140.0    160.0    180.0  200.0
     141.736  141.736  141.736  141.736     141.736  160.0    180.0  200.0
     148.554  148.554  148.554  148.554     148.554  160.0    180.0  200.0
     153.089  153.089  153.089  153.089  …  153.089  160.0    180.0  200.0
     156.104  156.104  156.104  156.104     156.104  160.0    180.0  200.0
     158.109  158.109  158.109  158.109     158.109  160.0    180.0  200.0
     159.443  159.443  159.443  159.443     159.443  160.0    180.0  200.0
     160.329  160.329  160.329  160.329     160.329  160.329  180.0  200.0
     160.95   160.95   160.95   160.95   …  160.95   160.95   180.0  200.0
     161.422  161.422  161.422  161.422     161.422  161.422  180.0  200.0
     161.781  161.781  161.781  161.781     161.781  161.781  180.0  200.0
       ⋮                                 ⋱
     162.916  162.916  162.916  162.916     162.916  162.916  180.0  200.0
     162.916  162.916  162.916  162.916     162.916  162.916  180.0  200.0
     162.916  162.916  162.916  162.916  …  162.916  162.916  180.0  200.0
     162.916  162.916  162.916  162.916     162.916  162.916  180.0  200.0
     162.916  162.916  162.916  162.916     162.916  162.916  180.0  200.0
     162.916  162.916  162.916  162.916     162.916  162.916  180.0  200.0
     162.916  162.916  162.916  162.916     162.916  162.916  180.0  200.0
     162.917  162.917  162.917  162.917  …  162.917  162.917  180.0  200.0
     162.917  162.917  162.917  162.917     162.917  162.917  180.0  200.0
     162.917  162.917  162.917  162.917     162.917  162.917  180.0  200.0
     162.917  162.917  162.917  162.917     162.917  162.917  180.0  200.0
     162.917  162.917  162.917  162.917     162.917  162.917  180.0  200.0

Let's plot the columns of `V`:

```julia
using Plots

plot(V, xlim = (0,10))
```

We can define a neat function that solves any given finite-state McCall model.

```julia
"""
    solvemccall(w,π,c,β[,ϵ])

Iterates the McCall search model bellman equation until convergence criterion
ϵ is reached

# Arguments
* `w` vector of wages
* `p` vector of probabilities for each wage
* `c` unemployment benefits
* `β` time discount factor
* `ϵ' Stopping criteria (default 1e-6)
"""

function solvemccall(w, p, c, β, ϵ=1e-6)

    # Initialize the value function.
    v = w/(1-β)

    # Initiate distance from iterated mapping
    diff = 10

    # Keep iterating on Bellman map until
    # stopping criterium is satisfied
    while diff > ϵ

        # New mapping of the value function
        v_new = mccallbellmanmap(v,w,p,c,β)

        # Use supremum norm as measure of distance between
        # old vs. new value function map
        diff = norm(v-v_new,Inf)

        # Store new value function as original
        # before loop potentially restarts
        v = v_new
    end

    # Return converged value function
    return v
end
```

    solvemccall (generic function with 2 methods)

We can use our function to solve the McCall model defined by the previously-declared parameters (which I repeat here for ease of reference).

```julia
S = 10
w = LinRange(1, 10, S)
p = ones(S)/S
β = 0.95
c = 3

# Solve the model:
v = solvemccall(w,p,c,β)
```

    10-element Array{Float64,1}:
     162.91666382521822
     162.91666382521822
     162.91666382521822
     162.91666382521822
     162.91666382521822
     162.91666382521822
     162.91666382521822
     162.91666382521822
     179.99999999999983
     199.99999999999983

---

## Comparative Statics

Let's vary $\beta$ while keeping all else constant, and check what happens to $V(w)$:

```julia
using Plots, LaTeXStrings

S = 10
w = LinRange(1, 10, S)
p = ones(S)/S
β = 0.95
c = 3

g(θ) = solvemccall(w,p,c,θ)

fig = plot(); # Limit the plot y-axis

for i in 1:length(w)
    plot!(fig, 0.85:0.01:0.95, θ -> g(θ)[i])
end

fig
```

What if we, instead, vary $c$?

```julia
using Plots, LaTeXStrings

S = 10
w = LinRange(1, 10, S)
p = ones(S)/S
β = 0.95
c = 3

g(θ) = solvemccall(w,p,θ,β)

fig = plot(); # Limit the plot y-axis

for i in 1:length(w)
    plot!(fig, 1:0.01:10, θ -> g(θ)[i])
end

fig
```

----


# Lecture 6

Using partial differentiation, we showed in the lecture that for the implicit representation of a line

$$
f(x, y) = c,
$$

where $f$ is and function and $c$ is constant, the gradient $\nabla f$  on the line is perpendicular to the line. We demonstrate this here for an ellipse.

## Equation of an ellipse

The implicit equation of an ellipse is

$$
\frac{x^{2}}{a^{2}} + \frac{y^{2}}{b^{2}} = 1
$$

where $a \ne 0$ and $b \ne 0$. The parametric representation of a ellipse is

$$
\begin{align}
    x(s) &= a \cos s
    \\
    y(s) &= b \sin s
\end{align}
$$

We can plot the parametric function with SymPy:

```python
from sympy import *
init_printing()
from IPython.display import display
from sympy.plotting import plot_parametric

# This command makes plots appear inside the browser window
%matplotlib inline

# Create independent variable and function for x and y
s = Symbol('s')
xs = Function('xs')
ys = Function('ys')

# Pick concrete values for a and b
a = 5
b = 3

# Parametric representation of ellipse
xs = a*cos(s)
ys = b*sin(s)

# Plot parametric line
plot_parametric(xs, ys)
```

## Tangent to the line

The tangent vector $\boldsymbol{t}$ to the surface is given by

$$
\boldsymbol{t} = \frac{d x}{d s} \boldsymbol{e}_{1}
 + \frac{d y}{d s} \boldsymbol{e}_{2}
$$

We can compute the tangent vector via differentiation, and plot the tangent at a given point.

```python
t = (diff(xs, s), diff(ys, s))
```

The normal direction to the surface is given by $\nabla f$

$$
\nabla f = \frac{\partial f}{\partial x} \boldsymbol{e}_{1} + \frac{\partial f}{\partial y} \boldsymbol{e}_{2}
$$

evaluated at a point on the surface.

```python
x, y = symbols('x y')
f = Eq(x**2/a**2 + y**2/b**2, 1)
display(f)
grad_f = (diff(f.args[0], x), diff(f.args[0], y))
display(grad_f)
```

We will now plot the ellipse, and the tangent vector $\boldsymbol{t}$ and the normal $\boldsymbol{n} = \nabla f$ to the surface at a point $(x(s_{0}), y(s_{0}))$. For later visualisaton, we will scale the vectors dependent on the size of the the ellipse.

```python
# Pick point to evaluate tangent and normal vector at
s0 = 0.4
x0, y0 = xs.subs(s, s0), ys.subs(s, s0)

# Evaluate tangent and normal vector, and scale according to ellipse major axis length
t0 = (t[0].subs(s, s0), t[1].subs(s, s0))
l = sqrt(t0[0]**2 + t0[1]**2)/max(a, b)
t0 = (t0[0]/l, t0[1]/l)

n0 = (grad_f[0].subs(x, x0).subs(y, y0), grad_f[1].subs(x, x0).subs(y, y0))
l = sqrt(n0[0]**2 + n0[1]**2)/max(a, b)
n0 = (n0[0]/l, n0[1]/l)
```

We will now plot the ellipse, as well as the tangent and normal vectors at the point $(x(s_{0}), y(s_{0}))$.

```python
from sympy.utilities.lambdify import lambdify
import numpy as np
import matplotlib.pyplot as plt

# Set plot limits (based on ellipse size)
plt.xlim(-(a + 1), a + 1)
plt.ylim(-(b + 1), b + 1)

# Make plot aspect ratio equal -> normal lines appear normal
ax = plt.axes()
ax.set_aspect('equal')

# Prepare the symbolix expression for x(s) and y(s) for plotting
xs = lambdify(s, xs, modules=['numpy'])
ys = lambdify(s, ys, modules=['numpy'])

# Plot ellipse
s = np.linspace(0, 7, 300)
plt.plot(xs(s), ys(s))

# Add tangent vector to plot
ax.arrow(x0, y0,  float(t0[0]),  float(t0[1]), label='tangent', color='g')
ax.arrow(x0, y0, -float(t0[0]), -float(t0[1]), color='g')

# Add normal vector to splot
ax.arrow(x0, y0,  float(n0[0]),  float(n0[1]), label='normal', color='r')

# Show plot
plt.show()
```

It can be seen from the plot that the gradient (red line) is normal to the ellipse.


# Sympy - Symbolic algebra in Python

J.R. Johansson (jrjohansson at gmail.com)

The latest version of this [IPython notebook](http://ipython.org/notebook.html) lecture is available at [http://github.com/jrjohansson/scientific-python-lectures](http://github.com/jrjohansson/scientific-python-lectures).

The other notebooks in this lecture series are indexed at [http://jrjohansson.github.io](http://jrjohansson.github.io).

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

## Introduction

There are two notable Computer Algebra Systems (CAS) for Python:

* [SymPy](http://sympy.org/en/index.html) - A python module that can be used in any Python program, or in an IPython session, that provides powerful CAS features.
* [Sage](http://www.sagemath.org/) - Sage is a full-featured and very powerful CAS enviroment that aims to provide an open source system that competes with Mathematica and Maple. Sage is not a regular Python module, but rather a CAS environment that uses Python as its programming language.

Sage is in some aspects more powerful than SymPy, but both offer very comprehensive CAS functionality. The advantage of SymPy is that it is a regular Python module and integrates well with the IPython notebook.

In this lecture we will therefore look at how to use SymPy with IPython notebooks. If you are interested in an open source CAS environment I also recommend to read more about Sage.

To get started using SymPy in a Python program or notebook, import the module `sympy`:

```python
from sympy import *
```

To get nice-looking $\LaTeX$ formatted output run:

```python
init_printing()

# or with older versions of sympy/ipython, load the IPython extension
#%load_ext sympy.interactive.ipythonprinting
# or
#%load_ext sympyprinting
```

## Symbolic variables

In SymPy we need to create symbols for the variables we want to work with. We can create a new symbol using the `Symbol` class:

```python
x = Symbol('x')
```

```python
(pi + x)**2
```

```python
# alternative way of defining symbols
a, b, c = symbols("a, b, c")
```

```python
type(a)
```

    sympy.core.symbol.Symbol

We can add assumptions to symbols when we create them:

```python
x = Symbol('x', real=True)
```

```python
x.is_imaginary
```

    False

```python
x = Symbol('x', positive=True)
```

```python
x > 0
```

### Complex numbers

The imaginary unit is denoted `I` in Sympy.

```python
1+1*I
```

```python
I**2
```

```python
(x * I + 1)**2
```

### Rational numbers

There are three different numerical types in SymPy: `Real`, `Rational`, `Integer`:

```python
r1 = Rational(4,5)
r2 = Rational(5,4)
```

```python
r1
```

```python
r1+r2
```

```python
r1/r2
```

## Numerical evaluation

SymPy uses a library for artitrary precision as numerical backend, and has predefined SymPy expressions for a number of mathematical constants, such as: `pi`, `e`, `oo` for infinity.

To evaluate an expression numerically we can use the `evalf` function (or `N`). It takes an argument `n` which specifies the number of significant digits.

```python
pi.evalf(n=50)
```

```python
y = (x + pi)**2
```

```python
N(y, 5) # same as evalf
```

When we numerically evaluate algebraic expressions we often want to substitute a symbol with a numerical value. In SymPy we do that using the `subs` function:

```python
y.subs(x, 1.5)
```

```python
N(y.subs(x, 1.5))
```

The `subs` function can of course also be used to substitute Symbols and expressions:

```python
y.subs(x, a+pi)
```

We can also combine numerical evolution of expressions with NumPy arrays:

```python
import numpy
```

```python
x_vec = numpy.arange(0, 10, 0.1)
```

```python
y_vec = numpy.array([N(((x + pi)**2).subs(x, xx)) for xx in x_vec])
```

```python
fig, ax = plt.subplots()
ax.plot(x_vec, y_vec);
```

However, this kind of numerical evolution can be very slow, and there is a much more efficient way to do it: Use the function `lambdify` to "compile" a Sympy expression into a function that is much more efficient to evaluate numerically:

```python
f = lambdify([x], (x + pi)**2, 'numpy')  # the first argument is a list of variables that
                                         # f will be a function of: in this case only x -> f(x)
```

```python
y_vec = f(x_vec)  # now we can directly pass a numpy array and f(x) is efficiently evaluated
```

The speedup when using "lambdified" functions instead of direct numerical evaluation can be significant, often several orders of magnitude. Even in this simple example we get a significant speed up:

```python
%%timeit

y_vec = numpy.array([N(((x + pi)**2).subs(x, xx)) for xx in x_vec])
```

    100 loops, best of 3: 18.4 ms per loop

```python
%%timeit

y_vec = f(x_vec)
```

    The slowest run took 22.35 times longer than the fastest. This could mean that an intermediate result is being cached.
    100000 loops, best of 3: 3.02 µs per loop

## Algebraic manipulations

One of the main uses of an CAS is to perform algebraic manipulations of expressions. For example, we might want to expand a product, factor an expression, or simply an expression. The functions for doing these basic operations in SymPy are demonstrated in this section.

### Expand and factor

The first steps in an algebraic manipulation

```python
(x+1)*(x+2)*(x+3)
```

```python
expand((x+1)*(x+2)*(x+3))
```

The `expand` function takes a number of keywords arguments which we can tell the functions what kind of expansions we want to have performed. For example, to expand trigonometric expressions, use the `trig=True` keyword argument:

```python
sin(a+b)
```

```python
expand(sin(a+b), trig=True)
```

See `help(expand)` for a detailed explanation of the various types of expansions the `expand` functions can perform.

The opposite a product expansion is of course factoring. The factor an expression in SymPy use the `factor` function:

```python
factor(x**3 + 6 * x**2 + 11*x + 6)
```

### Simplify

The `simplify` tries to simplify an expression into a nice looking expression, using various techniques. More specific alternatives to the `simplify` functions also exists: `trigsimp`, `powsimp`, `logcombine`, etc.

The basic usages of these functions are as follows:

```python
# simplify expands a product
simplify((x+1)*(x+2)*(x+3))
```

```python
# simplify uses trigonometric identities
simplify(sin(a)**2 + cos(a)**2)
```

```python
simplify(cos(x)/sin(x))
```

### apart and together

To manipulate symbolic expressions of fractions, we can use the `apart` and `together` functions:

```python
f1 = 1/((a+1)*(a+2))
```

```python
f1
```

```python
apart(f1)
```

```python
f2 = 1/(a+2) + 1/(a+3)
```

```python
f2
```

```python
together(f2)
```

Simplify usually combines fractions but does not factor:

```python
simplify(f2)
```

## Calculus

In addition to algebraic manipulations, the other main use of CAS is to do calculus, like derivatives and integrals of algebraic expressions.

### Differentiation

Differentiation is usually simple. Use the `diff` function. The first argument is the expression to take the derivative of, and the second argument is the symbol by which to take the derivative:

```python
y
```

```python
diff(y**2, x)
```

For higher order derivatives we can do:

```python
diff(y**2, x, x)
```

```python
diff(y**2, x, 2) # same as above
```

To calculate the derivative of a multivariate expression, we can do:

```python
x, y, z = symbols("x,y,z")
```

```python
f = sin(x*y) + cos(y*z)
```

$\frac{d^3f}{dxdy^2}$

```python
diff(f, x, 1, y, 2)
```

## Integration

Integration is done in a similar fashion:

```python
f
```

```python
integrate(f, x)
```

$$x \cos{\left (y z \right )} + \begin{cases} 0 & \text{for}\: y = 0 \\- \frac{1}{y} \cos{\left (x y \right )} & \text{otherwise} \end{cases}$$

By providing limits for the integration variable we can evaluate definite integrals:

```python
integrate(f, (x, -1, 1))
```

and also improper integrals

```python
integrate(exp(-x**2), (x, -oo, oo))
```

Remember, `oo` is the SymPy notation for inifinity.

### Sums and products

We can evaluate sums and products using the functions: 'Sum'

```python
n = Symbol("n")
```

```python
Sum(1/n**2, (n, 1, 10))
```

```python
Sum(1/n**2, (n,1, 10)).evalf()
```

```python
Sum(1/n**2, (n, 1, oo)).evalf()
```

Products work much the same way:

```python
Product(n, (n, 1, 10)) # 10!
```

## Limits

Limits can be evaluated using the `limit` function. For example,

```python
limit(sin(x)/x, x, 0)
```

We can use 'limit' to check the result of derivation using the `diff` function:

```python
f
```

```python
diff(f, x)
```

$\displaystyle \frac{\mathrm{d}f(x,y)}{\mathrm{d}x} = \frac{f(x+h,y)-f(x,y)}{h}$

```python
h = Symbol("h")
```

```python
limit((f.subs(x, x+h) - f)/h, h, 0)
```

OK!

We can change the direction from which we approach the limiting point using the `dir` keywork argument:

```python
limit(1/x, x, 0, dir="+")
```

```python
limit(1/x, x, 0, dir="-")
```

## Series

Series expansion is also one of the most useful features of a CAS. In SymPy we can perform a series expansion of an expression using the `series` function:

```python
series(exp(x), x)
```

By default it expands the expression around $x=0$, but we can expand around any value of $x$ by explicitly include a value in the function call:

```python
series(exp(x), x, 1)
```

And we can explicitly define to which order the series expansion should be carried out:

```python
series(exp(x), x, 1, 10)
```

The series expansion includes the order of the approximation, which is very useful for keeping track of the order of validity when we do calculations with series expansions of different order:

```python
s1 = cos(x).series(x, 0, 5)
s1
```

```python
s2 = sin(x).series(x, 0, 2)
s2
```

```python
expand(s1 * s2)
```

If we want to get rid of the order information we can use the `removeO` method:

```python
expand(s1.removeO() * s2.removeO())
```

But note that this is not the correct expansion of $\cos(x)\sin(x)$ to $5$th order:

```python
(cos(x)*sin(x)).series(x, 0, 6)
```

## Linear algebra

### Matrices

Matrices are defined using the `Matrix` class:

```python
m11, m12, m21, m22 = symbols("m11, m12, m21, m22")
b1, b2 = symbols("b1, b2")
```

```python
A = Matrix([[m11, m12],[m21, m22]])
A
```

$$\left[\begin{matrix}m_{11} & m_{12}\\m_{21} & m_{22}\end{matrix}\right]$$

```python
b = Matrix([[b1], [b2]])
b
```

$$\left[\begin{matrix}b_{1}\\b_{2}\end{matrix}\right]$$

With `Matrix` class instances we can do the usual matrix algebra operations:

```python
A**2
```

$$\left[\begin{matrix}m_{11}^{2} + m_{12} m_{21} & m_{11} m_{12} + m_{12} m_{22}\\m_{11} m_{21} + m_{21} m_{22} & m_{12} m_{21} + m_{22}^{2}\end{matrix}\right]$$

```python
A * b
```

$$\left[\begin{matrix}b_{1} m_{11} + b_{2} m_{12}\\b_{1} m_{21} + b_{2} m_{22}\end{matrix}\right]$$

And calculate determinants and inverses, and the like:

```python
A.det()
```

```python
A.inv()
```

$$\left[\begin{matrix}\frac{m_{22}}{m_{11} m_{22} - m_{12} m_{21}} & - \frac{m_{12}}{m_{11} m_{22} - m_{12} m_{21}}\\- \frac{m_{21}}{m_{11} m_{22} - m_{12} m_{21}} & \frac{m_{11}}{m_{11} m_{22} - m_{12} m_{21}}\end{matrix}\right]$$

## Solving equations

For solving equations and systems of equations we can use the `solve` function:

```python
solve(x**2 - 1, x)
```

```python
solve(x**4 - x**2 - 1, x)
```

System of equations:

```python
solve([x + y - 1, x - y - 1], [x,y])
```

In terms of other symbolic expressions:

```python
solve([x + y - a, x - y - c], [x,y])
```

## Further reading

* http://sympy.org/en/index.html - The SymPy projects web page.
* https://github.com/sympy/sympy - The source code of SymPy.
* http://live.sympy.org - Online version of SymPy for testing and demonstrations.

## Versions

```python
%reload_ext version_information

%version_information numpy, matplotlib, sympy
```


In this assignment we will practice solving differential equations using tools from scipy (rather than by implementing our own methods). First run the cell below to import the relevant libraries.

```python
import numpy as np
import matplotlib.pyplot as plt
from scipy.integrate import  solve_ivp
```

It's often helpful to read the manual. Run the cell below and skim the documentation quickly. It's very detailed, but we don't need to understand all of it.

```python
help(solve_ivp)
```

Let's start with a very simple example. In the previous assignment we looked at the initial value problem $\frac{dy}{dt} = 2t +1$; $y(0) = 0$ (which has the solution $y(t) = t^2 +t$). Note that I have changed $x$ to $t$ to be consistent with the names used by the solve_ivp function. It's very simple to solve this equation on the interval $(0,5)$ using solve_ivp. Read and then run the code below.

```python
# As with the solver that you wrote in the last assignment, the first step is to define a function that returns the derivative
# Note from the documentation in the help file that this function must have the signature f(t, y), so it needs to take
# y as an argument even if the derivative doesn't depend on y

fun = lambda t, y: 2*t + 1

sol = solve_ivp(fun, (0, 10), [0])
```

Let's examine the output of the solver:

```python
print(sol)
```

As you can see, the solver returns an object with a number of attributes. These are mostly self-explanatory. Let's extract the $y$ values and plot them. We will also plot the exact solution for comparison.

```python
tExact = np.linspace(0, 10, 100)
yExact = tExact**2 + tExact

# note that we need to write sol.y[0, :] rather than sol.y because solve_ivp is made to solve systems, and sol.y is therefor
# (in this case) a two dimensional array. This will become clearer in the later examples.
plt.plot(sol.t, sol.y[0,:] ,'x', tExact, yExact)
plt.xlabel('t')
plt.ylabel('y')
plt.legend(('Solver', 'Exact'))
plt.show()
```

As you can see, the solver is very accurate, although it only solved for a few values of $t$. This is because the goal of the solver is to get to the end of our timespan with as little computation (hence as few timesteps) as possible. This makes sense, right?

In some cases we might want to access more $t$ values. solve_ivp has appropriate options to support this, as you can see by reading the help file. For example, we can pass the optional argument t_eval to force it to evaluate at certain t values.

```python
myTs = np.linspace(0, 10, 20) # 20 evenly-spaced points between 0 and 10
sol2 = solve_ivp(fun, (0, 10), [0], t_eval = myTs)
plt.plot(sol2.t, sol2.y[0,:] ,'x', tExact, yExact)
plt.xlabel('t')
plt.ylabel('y')
plt.legend(('Solver', 'Exact'))
plt.show()
```

If we wanted to examine the approximate solution at any value of $t$, the 'dense_output' option can be used. Reading the help file, you can see that the solution will be stored in the "sol" attribute

```python
sol3 = solve_ivp(fun, (0, 10), [0], dense_output=True)
sol3.sol
```

Here's a quick check:

```python
t = 1.8875656
print("From solver: " + str(sol3.sol(t)))
print("Exact: " + str(t**2 + t))
```

solve_ivp has a few more powerful features, such as the "events" argument, which we'll look at later. For now, let's turn to solving a system of equations. The [Lotka-Volterra equations](https://en.wikipedia.org/wiki/Lotka%E2%80%93Volterra_equations) model the populations of two species, one of which is a predator, and one of which is prey (for example foxes and rabbits). The system of equations is: $\begin{align} w'(t) &= \alpha w(t) - \beta w(t) z(t)  \\ z'(t) &= \delta w(t) z(t) - \gamma z(t) \end{align}$, where $w(t)$ is the prey population and $z(t)$ is the predator population. These equations are pretty easy to understand. For example the term $\alpha z(t)$ represents that the prey species grows at a rate $\alpha$ in the absence of the predator. The other numbers $\beta$, $\delta$ and $\gamma$ represent things like how often the predators eat the prey. For more details, follow the link.

Let's try to solve this system using solve_ivp. We will define a vector (array) as $y(t) = \begin{bmatrix} w(t) \\ z(t) \end{bmatrix}$, and hence $y'(t) =  \begin{bmatrix} w'(t) \\ z'(t) \end{bmatrix} = \begin{bmatrix} \alpha z(t) - \beta w(t) z(t)  \\ \delta w(t) z(t) - \gamma z(t) \end{bmatrix}$. solve_ivp will solve the equations for the vector $Y(t)$. Let's see how this works.

The first step is to define a function that returns the derivative of $y(t)$:

```python
# for simpliticity, I am making alpha=beta=delta=gamma=1
def lvFun(t, y):
    xPrey = y[0]
    yPred = y[1]
    yder = [0, 0] # make an array

    yder[0] = xPrey - xPrey*yPred
    yder[1] = xPrey*yPred - yPred
    return yder
```

I hope you realise that `y[0] = w(t)` and `y[1] = z(t)`. I have been very careful and explicit in this code; this would also work: `lvFun = lambda t, y: [y[0] -y[0]*y[1], y[0]*y[1] - y[1]]`. Please look at the definition until it makes sense.

With this definition, it's easy to use solve_ivp. For simplicity, let's see what happens when we start with 100 predators and no prey:

```python
solLV1 = solve_ivp(lvFun, (0, 10), [0, 100])
plt.plot(solLV1.t, solLV1.y[0], solLV1.t, solLV1.y[1])
plt.xlabel('time')
plt.ylabel('Number of animals')
plt.legend(('Prey', 'Predators'))
plt.show()
```

As you might have expected, the number of prey remains zero, and the number of predators falls to zero (they have nothing to eat, so they all die).

**Your turn:** Investigate what happens when we start with 100 prey and no predators. First look at the equations and write your prediction, and then produce a graph to show how the number of predators and prey varies over time (you might realise that this model is not entirely realistic).

```python
# My predictions
# Number of predators will ....
# Number of prey will ......

```

Let's solve the system again, this time with different values of $\alpha$, $\beta$, etc., and an initial population of 2 predators and 2 prey.

```python
alpha = 2.0/3
beta = 4.0/3
delta = 1
gamma = 1

lvFun2 = lambda t, y: [alpha*y[0] - beta*y[0]*y[1], delta*y[0]*y[1] - gamma*y[1]]

solLV3 = solve_ivp(lvFun2, (0, 40), [2, 2])

plt.plot(solLV3.t, solLV3.y[0], solLV3.t, solLV3.y[1])
plt.xlabel('time')
plt.ylabel('Number of animals')
plt.legend(('Prey', 'Predators'))
plt.show()
```

As you can see the populations oscillate. The number of prey falls as they are all eaten, and then the lack of prey causes the number of predators to fall. When there are fewer predators, more prey grow, but this later leads to more predators, and the cycle repeats. Note: You might notice a problem with this model in that the number of animals can be fractional, and when the number falls below 1, the population *should* go extinct, but the model doesn't capture this. It's just  avery simple model but is has some interesting features.

**Your turn:** experiment with the initial conditions and see if you can find starting values for the populations that lead to stable populations of predators and prey.

Let's turn to solving a second-order problem. Consider and object which is thrown straight up into the air from ground level with an intial velocity of $v_0$. Writing the height of the object at a time $t$ as $Y(t)$ and neglecting air resistance, we can see that the differential equation governing the object's motion is $Y''(t) = -g$, since it experiences a constant downwards acceleration of $g$, and the initial conditions are $Y'(0) = v_0$ and $Y(0) = 0$. This is a second-order DE, which solve_ivp cannot solve. Instead we must rewrite it as a system of first-order DEs. This sounds confusing, but it's actually very easy. We do it as follows. Define the vector (array) $y(t)$ as $y(t) = \begin{bmatrix} y_1(t) \\ y_2(t) \end{bmatrix}= \begin{bmatrix} Y(t) \\ Y'(t) \end{bmatrix}$. Then we will have $y'(t) = \begin{bmatrix} y_1'(t) \\ y_2'(t) \end{bmatrix} = \begin{bmatrix} Y'(t) \\ Y''(t) \end{bmatrix} = \begin{bmatrix} Y'(t) \\ -g \end{bmatrix} = \begin{bmatrix} y_2(t) \\ -g \end{bmatrix}$

So we have rewritten our second order equation as the system $\begin{align} y_1'(t) &= y_2(t) \\ y_2'(t) &= -g\end{align}$, where $y_1(t) = Y(t)$ and $y_2(t) = Y'(t)$. It seems odd, but it will become clear when you examine the code below.

Below, I'll solve this equation and plot both the height and velocity as a function of time. Note that we also have an exact solution (this is motion with uniform acceleration): $y'(t) = v_0 - gt$ and $y(t) = v_0 t -\frac{1}{2} g t^2$  (keeping in mind that the initial height is 0.  Since I know from the exact solution that the projectile will land when $t=\frac{2v}/g$, I'll only solve until that time.

```python
g = 9.8
v0 = 10.0
tEnd = 2*v0 /g
tEval = np.linspace(0, tEnd, 20)
def projFun(t, y):
    yDer = [0, 0]
    yDer[0] = y[1]
    yDer[1] = -g
    return yDer
#Note: "projFun = lambda t, y: [y[1], -g]" would also work fine

solProj = solve_ivp(projFun, (0, tEnd), [0, v0], t_eval=tEval)

yExact = v0*tEval - 0.5*g*tEval**2
vExact = v0 - g*tEval

plt.plot(solProj.t, solProj.y[0], 'x', tEval, yExact)
plt.xlabel('t (seconds)')
plt.ylabel('height (m)')
plt.legend(('Computed', 'Exact'))
plt.title('Height vs time')
plt.show()

plt.plot(solProj.t, solProj.y[1], 'x', tEval, vExact)
plt.xlabel('t (seconds)')
plt.ylabel('velocity (m/s)')
plt.legend(('Computed', 'Exact'))
plt.title('y-velocity vs time')
plt.show()
```

That was a lot to take in, but hopefully you understood most of it. You should try some tasks. I think completing them all may be challenging, so try those that interest you. I've indicated the level of difficulty for each task.

1. An object undergoing simple harmonic motion has $a(t) = -\omega^2 x(t)$, where $x(t)$ is the displacement, and $a(t)$ is the acceleration, so it obeys the differential equation $x''(t) = -\omega^2 x(t)$.
    1. *(easiest difficulty)* Write code to solve this differential equation and plot the solution. For simplicity, you may use the initial conditions $x(0) = 1$ and $x'(0) = 0$, and set $\omega = 2\pi$. You already know from your classes that the graph of $x$ against $t$ should be sinusoidal with period $T=\frac{2\pi}{\omega}$. Does this agree with your computed solution?
    2. *(medium difficulty)* If there is damping, the differential equation is then $x''(t) =\omega^2 x(t) - \gamma x'(t)$. Pick a value of $\gamma$ and solve this equation and plot its solution alongside that of the undamped equation. Experiment with the value of $\gamma$. If you keep $\omega = 2\pi$, then the system should be underdamped when $\frac{\gamma}{4\pi} < 1$, and overdamped when $\frac{\gamma}{4\pi} > 1$. Is this what you see?

2. Copy and modify the code  for the projectile to include an *air resistance* term representing drag. The drag force is given by $F_D = \frac{1}{2} \rho v^2 C_D A$, so the acceleration term due to drag is $\frac{F_D}{m} = \frac{1}{2m} \rho v^2 C_D A$, and the differential equation should therefore be modified to $y''(t) = -g \pm  \frac{1}{2m} \rho (y'(t))^2 C_D A$. The choice of plus or minus depends on whether the object is moving up down - the drag term should always be in the opposite direction to the velocity, so we can write the equation as $y''(t) = -g - \operatorname{sign}{(y'(t))}  \frac{1}{2m} \rho (y'(t))^2 C_D A$, where $\operatorname{sign}{(y'(t))}$ simply gives the sign (+ or -) of $y'(t)$. In python you can use `np.sign()`.  You may take mass of object $m=0.5$kg, density of air $\rho = 1.225$ kg/m^3, drag coefficient $C_D = 0.25$, area $A = 0.019$ m^2, and mass $m=0.4$ kg (these correspond to a soccer ball).
    1. *(medium difficulty)* Plot both the solution without drag, and with drag and see how they differ. Do this for a few different values of initial velocity. Do your results make sense?
    2. *(easiest difficulty)* If the ball is dropped, its velocity will increase, but so will the drag force. Eventually the drag force will be equal to the force of gravity so the velocity will reach a steady value, called the terminal velocity. Solve the equations with appropriate initial conditions to find the terminal velocity. Also write a formula for the terminal velocity and compare your results (if you can't figure out the formula, it's on [wikipedia](https://en.wikipedia.org/wiki/Terminal_velocity)). *Hint: dopped means the initial velocity is zero, and it doesn't really matter what the initial height is. You could start the initial height at some large value, or even start it at zero and realise that it doesn't really matter if it becomes negative.

```python

```

```python
np.sign(-1)
```

```python

```