# ICA Example

The goal of this script is to illustrate independent component analysis as a method
suitable for blind source separation. This is done by creating several non-gaussian
signals and mixing them - the unmixing is then done by Hyvarinens standard Fast ICA
algorithm. More details can be found in the publication "Independent component analysis
: algorithms and applications" by A. Hyvärinen, E. Oja, Neural Networks, Volume 13,
Issues 4–5, 2000,Pages 411-430.

For this, do the following:

    * 1. Definitions and imports
    * 2. Simulate measurements
    * 3. Whiten the data
    * 4. Execute FastICA
    * 5. Plot results

The following entries may be changed to explore the script:

    * sigma_noise         : Quantifies the average magnitude of the noise
    * period              : Determines the amount of signal repetitions
    * n_timesteps         : Determines the number of measurements per signal

This script is given out as part of the Machine Learning Tutorial during IV2020, Munich. Please consult the slides for some background information regarding motivation and a possible physical setup for this example.

Written by Jemil Butt, Zan Gojcic, ETH Zurich.

 # 1. Definitions and imports ------------------------------------------------

Define the average magnitude of the noise by adjusting $\sigma_{noise}$ and the period of the simulated data by changing the vector $$period=[p_{sine},p_{sawtooth},p_{box}]=[p_1,p_2,p_3].$$ The three different signals $[x_1,x_2,x_3]$ playing a role in this task are sinewaves, sawtooths and squarewaves (see image below).

Sinewave $x_1(t)=\sin(2\pi p_{1}t)$ $\hspace{4cm}$ Sawtooth $x_2(t)=\operatorname{sawtooth}(2\pi p_{2}t)$ $\hspace{2cm}$ Squarewave $x_3(t)=\operatorname{box}(2\pi p_{3}t)$

The goal of Blind source separation (and this specific implmentation of it via the Independent Component Analysis algorithm) is to separate mixtures of non-Gaussian signals into the original underlying signals. The latter ones are also called 'sources' in the BSS-literature. In this example, the sources are the signals recorded in the vector $x_{true}=[x_1,x_2,x_3]^T$ whose components have been illustrated in the image above. The vector $x_{true}$ has only been observed in terms of mixtures $$x_{observed}=A x_{true}+x_{noise}$$
where $A$ is a Matrix of appropriate sizes and $x_{noise}$ is uncorrelated noise. If $A$ were known, $x_{true}$ could be trivially guessed via $\hat{x}_{true}=A^{-1}x_{observed}.$  If however, as typical in BSS, the mixing matrix $A$ is considered to be unknown, it needs to be estimated. If the sources are known to be originating from independent processes, a guess for $A$ might be produced by finding those coefficients $(A)_{ij}=a_{ij}, i,j=1, ... m$ that maximize a measure of stochastic independence of the vector $\hat{x}_{sources}=A^{-1}x_{observed}$.

```

# 1.1 Import numerical and plotting libraries
import numpy as np
import numpy.matlib as mb
import scipy.signal as signal
import matplotlib.pyplot as plt

# 1.2 Define elementary quantities
n_timesteps=200                         # Discretization density
time=np.linspace(0,1,n_timesteps)       # Independent variable interpreted as time
m=3                                     # Nr of independent components

# 1.3 Set the parameters necessary for simulation
sigma_noise=0.001                       # Quantifies the average magnitude of the noise
period=2*np.pi*np.array([3,4,5])        # Quantifies the amount of signal repetitions

print('Packages imported. The parameters for simulation have been defined and are now \
ready for later use.')

```

    Packages imported. The parameters for simulation have been defined and are now ready for later use.

# 2. Simulate measurements --------------------------------------------------

To arrive at the measurement vectors $x_{observed}$, the simulation of 3 quantities is necessary: The signals $x_{true}$, the noise $x_{noise}$ and the mixing matrix $A$ that are combined according to the equation $$x_{observed}=Ax_{true} + x_{noise}.$$ Whereas for $x_{true}$  the deterministic sine, sawtooth and square signals are chosen, the noise $x_{noise}$ is entirely stochastic and uncorrelated with $(x_{noise})_{ij}\coprod (x_{noise})_{kl}$ for $(i,j)\neq(k,l)$ and $(x_{noise})_{ij}\sim \mathcal{N}(0,\sigma_{noise}^2)$. The mixing matrix $A$ is chosen randomly as well with its entries white noise although a subsequent column-wise scaling is applied that avoids the rows of $x_{observed}$ being of too different magnitudes for illustration purposes. An example of the mixed signals $x_{observed}$ to be split into independent components can be found in the images below. In this example, the randomly chosen mixing matrix is approximately

$$A\approx\begin{bmatrix} 0.933 &  0.397 & -0.239 \\       -0.320&  -0.885 & -0.952\\ -0.165&  0.245&  0.193 \end{bmatrix}.$$

Notice, how non-obvious the presence of sines, sawtooths and squarewaves is and how difficult the intuitive visual estimation of the different source signals would be event by employing this prior knowledge.

```

# 2.1 Create covariance matrix of noise

# Define covariance function
def cov_fun_noise(time_1,time_2,sigma_noise):
    covariance=np.power(sigma_noise,2)*int(time_1==time_2)
    return covariance

# Assemble covariance matrices
Cov_mat_noise=np.zeros((n_timesteps,n_timesteps))
for i in range(n_timesteps):
    for j in range(n_timesteps):
        Cov_mat_noise[i,j]=cov_fun_noise(time[i],time[j],sigma_noise)

# 2.3 Simulate independent timeseries

# Define the signals as sines, sawtooths and squares of different repetititon rate
x_sine=np.sin(time*period[0])
x_triangle=signal.sawtooth(period[1]*time)
x_box=signal.square(time*period[2])

# Simulate some noise to superimpose onto the measurements
x_noise=np.zeros([m,n_timesteps])
for k in range(m):
    x_noise[k,:]=np.random.multivariate_normal(np.zeros([n_timesteps]),Cov_mat_noise)

# 2.4 Mix the timeseries together and add noise

# Generate mixing matrix at random
A=np.random.normal(0,1,size=[m,m])
for k in range(m):
    A[:,k]=A[:,k]/np.linalg.norm(A[:,k])
x_true=np.stack((x_sine,x_triangle,x_box),axis=0);

# Mix the signals
x_o=A@(x_true)+x_noise

print('Sine, sawtooth and square signals have been generated, subjected to noise and mixed randomly.')

```

    Sine, sawtooth and square signals have been generated, subjected to noise and mixed randomly.

# 3. Whiten the data --------------------------------------------------------

As a preprocessing step to ICA the data $x_{observed}$ has to be whitened. This consists in two steps, one in which the data is centered and another one in which the data is decorrelated by diagonalizing the the empirical covariance matrix.
$$\begin{align} 1&.\text{Centering  }  &&x_{centered}=x_{observed}-\sum_{k=1}^{n_{timesteps}} x_{observed}(t_k) \\
2&. \text{Decorrelation  }  &&x_{whitened}=S^{-1/2}x_{observed} ~~~\text{where } S=\frac{1}{n_{timesteps}}x_{centered}x_{centered}^T\end{align}$$
The whitened data has mean $0$ and its empirical covariance matrix is the unit matrix $I$. This is due to the fact that $$\frac{1}{n_{timesteps}}S^{-1/2}x_{centered} (S^{-1/2}x_{centered})^T=S^{-1/2} SS^{-1/2}=I.$$
The whitened data $x_{whitened}$ consists of the principal components of the observations $x_{observed}$. Although the principal components are uncorrelated and therefore (in a weak sense) stochastically independent, they are bad estimators for the underlying sources $x_{true}$ as the image below attests.

Notice that the decorrelation process is not unique and any unitary transformation $W$ applied to $x_{whitened}$ produces another centered and decorrelated process since $WW^T=I$. It is therefore essential that the underlying processes are not Gaussian - otherwise there are infinitely many indistinguishable signal splits that all optimize the objectively correct measure of independence (the correlation).

```

# 3.1 Subtract the mean
mu=np.mean(x_o,axis=1)
x_o_centered=x_o-np.transpose(mb.repmat(mu,n_timesteps,1))

# 3.2 Diagonalize the covariances
Sigma_emp=(1/n_timesteps)*(x_o_centered@np.transpose(x_o_centered))
U,S,V=np.linalg.svd(Sigma_emp)

Sigma_sqrt=U@np.diag(np.sqrt(S))@np.transpose(V)
Whitening=np.linalg.pinv(Sigma_sqrt)
Dewhitening=Sigma_sqrt

# 3.3 Whiten the data and rename
x_o_whitened=Whitening@x_o_centered
z=x_o_whitened

print('Whitening of the data sucessful. The whitened components correspond to the ones \
identified via PCA')
```

    Whitening of the data sucessful. The whitened components correspond to the ones identified via PCA

# 4. Execute FastICA --------------------------------------------------------

ICA now tries to find that unitary matrix $W$ that minimizes some additional measure of dependence on $x_{sources~guess}=Wx_{whitened}$. Since $W$ is unitary, $x_{sources ~ guess}$ is uncorrelated. There are several different measures of dependence whose minimization corresponds to different distributional assumptions on the data. One of the most common ones is the  used in the Fast ICA algorithm proposed by Hyvärinen and Oja.

They approximate the negentropy (a entropy-based distance measure on probability distributions) by the function $G=\log \cosh (u)$ and maximize it in an iteration that involves its first two derivates $$g=\tanh(u) \text{ and } \partial_u g = 1-\tanh^2(u).$$

The iteration for $W$ consists in a sequence of intermittent maximizations of $G$ with respect to $W$ and orthogonalizations that are listed in the pseudocode below.

* 1. Initialize $W$ randomly
* 2. Calculate $zw=Wx_{whitened}$
* 3. Update $W=\frac{1}{n_{timesteps}}g(zw)x^T_{whitened} -\operatorname{diag}\left(\sum_{k=1}^{n_{timesteps}}\partial_{u}g(zw)\right)W$

where steps 2 and 3 are to be repeated until convergence. The result of this processing are guesses for the independent components that are calculated as $x_{source ~ guess}=Wx_{whitened}$.

```

# 4.1 Define function for decorrelating a Matrix
def Decorrelate(Matrix):
    U,S,V=np.linalg.svd(Matrix)
    Decorr_mat=(U@np.linalg.pinv(np.diag(S))@U.T)@Matrix
    return Decorr_mat

# 4.2 Initialize W and define derivatives of contrast function
k=1
delta=1
n_iter=100                      # Maximum number of iterations
W=np.random.normal(0,1,[m,m])   # Random initialization

g=lambda zw:np.tanh(zw)                         # Derivative of contrast function
g_prime=lambda zw: 1-np.power(np.tanh(zw),2)    # Second derivative

# 4.3 Iterate over W to minimize entropy
while delta >10**-6 and k<n_iter:
    W_old=W
    zw_prod=W@z
    W=(1/n_timesteps)*(g(zw_prod)@z.T)-np.diag(np.mean(g_prime(zw_prod),1))@W
    W=Decorrelate(W)

    # Update optimization progress
    delta=np.max(W-W_old)
    k+=1
    print('Iteration nr {} with a parameter change of max {}' .format(k,delta))

# 4.4 Reassemble signal guess
x_sources_guess=W@z

print('Independent component analysis carried out. The resulting components are maximally \
independent as measured by (some quantity related to) the entropy.')
```

    Iteration nr 2 with a parameter change of max 0.85900710173818
    Iteration nr 3 with a parameter change of max 0.11982026327395662
    Iteration nr 4 with a parameter change of max 0.008146156032170948
    Iteration nr 5 with a parameter change of max 0.0005149906871976251
    Iteration nr 6 with a parameter change of max 3.4351529427323335e-05
    Iteration nr 7 with a parameter change of max 2.2218410415941747e-06
    Iteration nr 8 with a parameter change of max 1.4557734917541865e-07
    Independent component analysis carried out. The resulting components are maximally independent as measured by (some quantity related to) the entropy.

# 5. Plot results -----------------------------------------------------------

Illustrate the ground, truth $x_{true}$, measured data $x_{observed}$, and the inference process delivering estimates for $x_{source}$ via a whitening step and subsequent iterative maximization of joint entropy. Compare the PCA and ICA results.

```

# 5.1 Figures showing the mixture process
# Ground truth
plt.figure(1)
plt.plot(time,x_true.T)
plt.title('True signals')
plt.legend(['Signal 1', 'Signal 2', 'Signal 3'])
plt.xlabel('Time')
plt.ylabel('Function value')

# Mixed components
plt.figure(2)
plt.plot(time,x_o.T)
plt.title('Randomly mixed signals')
plt.legend(['Observation 1', 'Observation 2', 'Observation 3'])
plt.xlabel('Time')
plt.ylabel('Function value')

# 5.2 Figure 3 showing the result of PCA
plt.figure(3)
plt.plot(time,z.T)
plt.title('Signal separation using PCA')
plt.legend(['Guess for signal 1', 'Guess for signal 2', 'Guess for signal 3'])
plt.xlabel('Time')
plt.ylabel('Function value')

# 5.3 Figure 4 showing the demixed components
plt.figure(4)
plt.plot(time,x_sources_guess.T)
plt.title('Signal separation using ICA')
plt.legend(['Guess for signal 1', 'Guess for signal 2', 'Guess for signal 3'])
plt.xlabel('Time')
plt.ylabel('Function value')

```


# Adaptive Market Planning

## Narrative:

There is a broad class of problems that involves allocating some resources to meet an uncertain (
sometimes unobservable ) demand . Example:

**Stocking a perishable inventory (e.g. fresh fish) to meet a demand where leftover inventory cannot be held for the future.** \
**We have to allocate annual budgets for activities such as marketing. Left-over funds are returned to the company**

"newsvendor problem", as canonical problem in optimization under uncertainity.

### Newsvendor problem

$$
\max_{x} \; \mathbf{E} \; F(x, W) =  \mathbf{E} \{pmin\{x,W\}-cx\} \\
$$

* where $x$ is our decision variable that detrmines the amount of resources to meet task
* where $W$ is uncertain demand for the resource
* We assume that we "purchase" our resource at a unit cost of $c$
* we sell the smaller of $x$ and $W$ at a price $p$
* $p$ is assumed greater than than $c$

If $W$ was deterministic (and if $p$>$c$), then the solution is easily verified to be $x$=$W$

Now imagine that $W$ is a random variable with:

* probality distribution: $f^W(W)$

* cumulative distribution: $F^W(w) = Prob[W<w]$

* Then we can compute:

* $F(x) = \mathbf{E} \; F(x, W)$

Then, the optimal solution $x^{*}$ would satisfy:

$$
\frac{dF(x)}{dx} = 0 \\
x=x^*
$$

Now consider the stochastic gradient, where we take the derivative of $F(x, W)$ assuming we know
$W$, which is given by:

$$
\begin{equation}
  \frac{dF(x,W)}{dx} =\left\{
  \begin{array}{@{}ll@{}}
    p-c, & x\leq  W\  \\
    -c, & x > W
  \end{array}\right.
\end{equation}
$$

* Taking Expectation:

$$
\begin{equation}
\mathbf{E}\frac{dF(x,W)}{dx} = (p-c)Prob[x \leq W] - cProb[x>W] \\
= (p-c)(1-F^W(x))-cF^W(x) \\
= (p-c) - pF^W
= 0 \; For \; x = x^*
\end{equation}
$$

We can now solve for $F^W(x^*)$ giving:

$$
F^W(x^*) = \frac{p-c}{p}
$$

**This Chapter**: we are going to addressing the single-dimensional newsvendor, where: \
we assume that the demand distribution $W$ is unknown, but can be observed.

## Basic Model

We are going to solve problem using a classical method, called a stochastic gradient algorithm. The sequence is like:

we going to pick $x^n$, then observe $W^{n+1}$ and then compute $\nabla F (x^n, W^{n+1})$- As:

$$
x^{n+1} = x^{n} + \alpha_{n} \nabla F(x^n, W^{n+1)}
$$

whre $\alpha_n$ is known as a stepsize. Where it has been shown that we obtain asymptotic optimality:

$$
\lim_{n \to +\infty} x^n = x^*,
$$

If the stepsize $\alpha_n$ satisfie:

$$
\alpha_n \ > \ 0, \\
\sum_{n=1}^{\infty}\alpha_n = \infty, \\
\sum_{n=1}^{\infty}(\alpha_n)^2 < \infty,
$$

The second equation ensures that the stepsizes do not shrink so quickly that we stall out on the way to the optimum, the last equation has a effect ofinsuring that the variance of our estimate $x^*$ does shrink to zero.

```python

```

### State variable:

For our stochastic gradient algorithm, our state variable is given by:

$$
S^n = (x^n)
$$

### Decision Variable:

### harmonic stepsize rule:

***Step size Policy***

As with all of our sequential decision problems, the decision ( that is, the stepsize) is determined by what is typically referred to as a stepsize rule, but is sometimes called a stepzie policy that we denote by $\alpha^{\pi}(S^n)$

$$
\alpha^{harmonic} (S^n|\theta^{step}) = \frac{\theta^{step}}{\theta^{step} + n -1}
$$

### Exogenous information

The exogenous information is the random demand $W^{n+1}$ for the resource (product, time or money) that we are trying to meet with our supply of product $x^n$.
 We may assume that we observe $W^{n+1}$ directly.

### Transition Function

$$
x^{n+1} = x^n + \alpha_n \nabla_xF(x^n, W^{n+1})
$$

### Objective Function

***Net benefit at each iteration***

$$
F(x^n, W^{n+1}) = p \ min \{x^n, W^{n+1} \} -cx
$$

***Maximize the total reward over some horizon***

$$
max \ \mathbf{E} \{ \sum_{n=0}^{N-1}F(X^{\pi}(S^n), W^{n+1})| S^0)\} \\
\pi
$$

$$
S^{n+1} = S^M(S^n, X^{\pi}(S^n), W^{n+1})
$$

## Coding Part

```python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
```

### Chnaging the path to function folder

```python
import os
os.chdir("/home/peyman/Documents/PhD_UiS/seqdec_powell_repo/Chap3_Adap_Mar_Plan/functions")
```

```python
from AdaptiveMarketPlanningModel import AdaptiveMarketPlanningModel
from AdaptiveMarketPlanningPolicy import AdaptiveMarketPlanningPolicy
```

```python
os.chdir("/home/peyman/Documents/PhD_UiS/seqdec_powell_repo/Chap3_Adap_Mar_Plan/data")
```

```python
raw_data= pd.read_excel("Base_parameters.xlsx", sheet_name="parameters", usecols=["Parameter", "Value"])
```

```python
raw_data
```

      Parameter
      Value

      0
      cost
      20

      1
      trial_size
      100

      2
      price
      26

      3
      theta_step
      50

      4
      T
      24

      5
      reward_type
      Terminal

```python
cost=raw_data["Value"][0]
trial_size = raw_data["Value"][1]
price = raw_data["Value"][2]
theta_step = raw_data["Value"][3]
T = raw_data["Value"][4]
reward_type = raw_data["Value"][5]
```

```python
if __name__ == "__main__":
# this is an example of creating a model and running a simulation for a certain trial size

# define state variables
    state_names = ['order_quantity', 'counter']
    init_state = {'order_quantity': 0, 'counter': 0}
    decision_names = ['step_size']

    # read in variables from excel file
    #file = 'Base parameters.xlsx'
    #raw_data = pd.ExcelFile(file)
    raw_data= pd.read_excel("Base_parameters.xlsx", sheet_name="parameters", usecols=["Parameter", "Value"])
    cost=raw_data["Value"][0]
    trial_size = raw_data["Value"][1]
    price = raw_data["Value"][2]
    theta_step = raw_data["Value"][3]
    T = raw_data["Value"][4]
    reward_type = raw_data["Value"][5]

    # initialize model and store ordered quantities in an array
    M = AdaptiveMarketPlanningModel(state_names, decision_names, init_state, T,reward_type, price, cost)
    P = AdaptiveMarketPlanningPolicy(M, theta_step)

    rewards_per_iteration = []
    learning_list_per_iteration = []
    for ite in list(range(trial_size)):
        print("Starting iteration ", ite)
        reward,learning_list = P.run_policy()
        M.learning_list=[]
        #print(learning_list)
        rewards_per_iteration.append(reward)
        learning_list_per_iteration.append(learning_list)
        print("Ending iteration ", ite," Reward ",reward)

    nElem = np.arange(1,trial_size+1)

    rewards_per_iteration = np.array(rewards_per_iteration)
    rewards_per_iteration_sum = rewards_per_iteration.cumsum()
    rewards_per_iteration_cum_avg = rewards_per_iteration_sum/nElem

    if (reward_type=="Cumulative"):
        rewards_per_iteration_cum_avg = rewards_per_iteration_cum_avg/T
        rewards_per_iteration = rewards_per_iteration/T

    optimal_order_quantity = -np.log(cost/price) * 100
    print("Optimal order_quantity for price {} and cost {} is {}".format(price,cost,optimal_order_quantity))
    print("Reward type: {}, theta_step: {}, T: {} - Average reward over {} iteratios is: {}".format(reward_type,theta_step,T,trial_size,rewards_per_iteration_cum_avg[-1]))

    ite = np.random.randint(0,trial_size)
    order_quantity = learning_list_per_iteration[ite]
    print("Order quantity for iteration {}".format(ite))
    print(order_quantity)

    #Ploting the reward
    fig1, axsubs = plt.subplots(1,2,sharex=True,sharey=True)
    fig1.suptitle("Reward type: {}, theta_step: {}, T: {}".format(reward_type,theta_step,T) )

    axsubs[0].plot(nElem, rewards_per_iteration_cum_avg, 'g')
    axsubs[0].set_title('Cum_average reward')

    axsubs[1].plot(nElem, rewards_per_iteration, 'g')
    axsubs[1].set_title('Reward per iteration')
    #Create a big subplot
    ax = fig1.add_subplot(111, frameon=False)
    # hide tick and tick label of the big axes
    plt.tick_params(labelcolor='none', top=False, bottom=False, left=False, right=False)
    ax.set_ylabel('USD', labelpad=0) # Use argument `labelpad` to move label downwards.
    ax.set_xlabel('Iterations', labelpad=10)
    plt.show()

    # ploting the analytical sol
    plt.xlabel("Time")
    plt.ylabel("Order quantity")
    plt.title("Analytical vs learned ordered quantity - (iteration {})".format(ite))
    time = np.arange(0, len(order_quantity))
    plt.plot(time, time * 0 - np.log(cost/price) * 100, label = "Analytical solution")
    plt.plot(time, order_quantity, label = "Kesten's Rule for theta_step {}".format(theta_step))
    plt.legend()
    plt.show()
```


# Linear Algebra, Handling of Arrays and more Python Features

## Introduction

The aim of this set of lectures is to review some central linear algebra algorithms that we will need in our
data analysis part and in the construction of Machine Learning algorithms (ML).
This will allow us to introduce some central programming features of high-level languages like Python and
compiled languages like C++ and/or Fortran.

As discussed in the introductory notes, these series of lectures focuses both on using
central Python packages like **tensorflow** and **scikit-learn** as well
as writing your own codes for some central ML algorithms. The
latter can be written in a language of your choice, be it Python, Julia, R,
Rust, C++, Fortran etc. In order to avoid confusion however, in these lectures we will limit our
attention to Python, C++ and Fortran.

## Important Matrix and vector handling packages

There are several central software packages for linear algebra and eigenvalue problems. Several of the more
popular ones have been wrapped into ofter software packages like those from the widely used text **Numerical Recipes**. The original source codes in many of the available packages are often taken from the widely used
software package LAPACK, which follows two other popular packages
developed in the 1970s, namely EISPACK and LINPACK.  We describe them shortly here.

  * LINPACK: package for linear equations and least square problems.

  * LAPACK:package for solving symmetric, unsymmetric and generalized eigenvalue problems. From LAPACK's website <http://www.netlib.org> it is possible to download for free all source codes from this library. Both C/C++ and Fortran versions are available.

  * BLAS (I, II and III): (Basic Linear Algebra Subprograms) are routines that provide standard building blocks for performing basic vector and matrix operations. Blas I is vector operations, II vector-matrix operations and III matrix-matrix operations. Highly parallelized and efficient codes, all available for download from <http://www.netlib.org>.

When dealing with matrices and vectors a central issue is memory
handling and allocation. If our code is written in Python the way we
declare these objects and the way they are handled, interpreted and
used by say a linear algebra library, requires codes that interface
our Python program with such libraries. For Python programmers,
**Numpy** is by now the standard Python package for numerical arrays in
Python as well as the source of functions which act on these
arrays. These functions span from eigenvalue solvers to functions that
compute the mean value, variance or the covariance matrix. If you are
not familiar with how arrays are handled in say Python or compiled
languages like C++ and Fortran, the sections in this chapter may be
useful. For C++ programmer, **Armadillo** is widely used library for
linear algebra and eigenvalue problems. In addition it offers a
convenient way to handle and organize arrays. We discuss this library
as well.   Before we proceed we believe  it may be convenient to repeat some basic features of
 matrices and vectors.

## Basic Matrix Features

Matrix properties reminder

$$
\mathbf{A} =
      \begin{bmatrix} a_{11} & a_{12} & a_{13} & a_{14} \\
                                 a_{21} & a_{22} & a_{23} & a_{24} \\
                                   a_{31} & a_{32} & a_{33} & a_{34} \\
                                  a_{41} & a_{42} & a_{43} & a_{44}
             \end{bmatrix}\qquad
\mathbf{I} =
      \begin{bmatrix} 1 & 0 & 0 & 0 \\
                                 0 & 1 & 0 & 0 \\
                                 0 & 0 & 1 & 0 \\
                                 0 & 0 & 0 & 1
             \end{bmatrix}
$$

The inverse of a matrix is defined by

$$
\mathbf{A}^{-1} \cdot \mathbf{A} = I
$$

              Relations               	       Name     	                             matrix elements

   $A = A^{T}$                               	    symmetric          	    $a_{ij} = a_{ji}$

   $A = \left (A^{T} \right )^{-1}$          	    real orthogonal    	    $\sum_k a_{ik} a_{jk} = \sum_k a_{ki} a_{kj} = \delta_{ij}$

   $A = A^{ * }$                             	    real matrix        	    $a_{ij} = a_{ij}^{ * }$

   $A = A^{\dagger}$                         	    hermitian          	    $a_{ij} = a_{ji}^{ * }$

   $A = \left (A^{\dagger} \right )^{-1}$    	    unitary            	    $\sum_k a_{ik} a_{jk}^{ * } = \sum_k a_{ki}^{ * } a_{kj} = \delta_{ij}$

### Some famous Matrices

  * Diagonal if $a_{ij}=0$ for $i\ne j$

  * Upper triangular if $a_{ij}=0$ for $i > j$

  * Lower triangular if $a_{ij}=0$ for $i < j$

  * Upper Hessenberg if $a_{ij}=0$ for $i > j+1$

  * Lower Hessenberg if $a_{ij}=0$ for $i < j+1$

  * Tridiagonal if $a_{ij}=0$ for $|i -j| > 1$

  * Lower banded with bandwidth $p$: $a_{ij}=0$ for $i > j+p$

  * Upper banded with bandwidth $p$: $a_{ij}=0$ for $i < j+p$

  * Banded, block upper triangular, block lower triangular....

Some Equivalent Statements. For an $N\times N$ matrix  $\mathbf{A}$ the following properties are all equivalent

  * If the inverse of $\mathbf{A}$ exists, $\mathbf{A}$ is nonsingular.

  * The equation $\mathbf{Ax}=0$ implies $\mathbf{x}=0$.

  * The rows of $\mathbf{A}$ form a basis of $R^N$.

  * The columns of $\mathbf{A}$ form a basis of $R^N$.

  * $\mathbf{A}$ is a product of elementary matrices.

  * $0$ is not eigenvalue of $\mathbf{A}$.

## Numpy and arrays
[Numpy](http://www.numpy.org/) provides an easy way to handle arrays in Python. The standard way to import this library is as

```
import numpy as np
n = 10
x = np.random.normal(size=n)
print(x)
```

Here we have defined a vector $x$ with $n=10$ elements with its values given by the Normal distribution $N(0,1)$.
Another alternative is to declare a vector as follows

```
import numpy as np
x = np.array([1, 2, 3])
print(x)
```

Here we have defined a vector with three elements, with $x_0=1$, $x_1=2$ and $x_2=3$. Note that both Python and C++
start numbering array elements from $0$ and on. This means that a vector with $n$ elements has a sequence of entities $x_0, x_1, x_2, \dots, x_{n-1}$. We could also let (recommended) Numpy to compute the logarithms of a specific array as

```
import numpy as np
x = np.log(np.array([4, 7, 8]))
print(x)
```

Here we have used Numpy's unary function $np.log$. This function is
highly tuned to compute array elements since the code is vectorized
and does not require looping. We normaly recommend that you use the
Numpy intrinsic functions instead of the corresponding **log** function
from Python's **math** module. The looping is done explicitely by the
**np.log** function. The alternative, and slower way to compute the
logarithms of a vector would be to write

```
import numpy as np
from math import log
x = np.array([4, 7, 8])
for i in range(0, len(x)):
    x[i] = log(x[i])
print(x)
```

We note that our code is much longer already and we need to import the **log** function from the **math** module.
The attentive reader will also notice that the output is $[1, 1, 2]$. Python interprets automacally our numbers as integers (like the **automatic** keyword in C++). To change this we could define our array elements to be double precision numbers as

```
import numpy as np
x = np.log(np.array([4, 7, 8], dtype = np.float64))
print(x)
```

or simply write them as double precision numbers (Python uses 64 bits as default for floating point type variables), that is

```
import numpy as np
x = np.log(np.array([4.0, 7.0, 8.0])
print(x)
```

To check the number of bytes (remember that one byte contains eight bits for double precision variables), you can use simple use the **itemsize** functionality (the array $x$ is actually an object which inherits the functionalities defined in Numpy) as

```
import numpy as np
x = np.log(np.array([4.0, 7.0, 8.0])
print(x.itemsize)
```

Having defined vectors, we are now ready to try out matrices. We can define a $3 \times 3 $ real matrix $\hat{A}$
as (recall that we user lowercase letters for vectors and uppercase letters for matrices)

```
import numpy as np
A = np.log(np.array([ [4.0, 7.0, 8.0], [3.0, 10.0, 11.0], [4.0, 5.0, 7.0] ]))
print(A)
```

If we use the **shape** function we would get $(3, 3)$ as output, that is verifying that our matrix is a $3\times 3$ matrix. We can slice the matrix and print for example the first column (Python organized matrix elements in a row-major order, see below) as

```
import numpy as np
A = np.log(np.array([ [4.0, 7.0, 8.0], [3.0, 10.0, 11.0], [4.0, 5.0, 7.0] ]))
# print the first column, row-major order and elements start with 0
print(A[:,0])
```

We can continue this was by printing out other columns or rows. The example here prints out the second column

```
import numpy as np
A = np.log(np.array([ [4.0, 7.0, 8.0], [3.0, 10.0, 11.0], [4.0, 5.0, 7.0] ]))
# print the first column, row-major order and elements start with 0
print(A[1,:])
```

Numpy contains many other functionalities that allow us to slice, subdivide etc etc arrays. We strongly recommend that you look up the [Numpy website for more details](http://www.numpy.org/). Useful functions when defining a matrix are the **np.zeros** function which declares a matrix of a given dimension and sets all elements to zero

```
import numpy as np
n = 10
# define a matrix of dimension 10 x 10 and set all elements to zero
A = np.zeros( (n, n) )
print(A)
```

or initializing all elements to

```
import numpy as np
n = 10
# define a matrix of dimension 10 x 10 and set all elements to one
A = np.ones( (n, n) )
print(A)
```

or as unitarily distributed random numbers (see the material on random number generators in the statistics part)

```
import numpy as np
n = 10
# define a matrix of dimension 10 x 10 and set all elements to random numbers with x \in [0, 1]
A = np.random.rand(n, n)
print(A)
```

As we will see throughout these lectures, there are several extremely useful functionalities in Numpy.
As an example, consider the discussion of the covariance matrix. Suppose we have defined three vectors
$\hat{x}, \hat{y}, \hat{z}$ with $n$ elements each. The covariance matrix is defined as

$$
\hat{\Sigma} = \begin{bmatrix} \sigma_{xx} & \sigma_{xy} & \sigma_{xz} \\
                              \sigma_{yx} & \sigma_{yy} & \sigma_{yz} \\
                              \sigma_{zx} & \sigma_{zy} & \sigma_{zz}
             \end{bmatrix},
$$

where for example

$$
\sigma_{xy} =\frac{1}{n} \sum_{i=0}^{n-1}(x_i- \overline{x})(y_i- \overline{y}).
$$

The Numpy function **np.cov** calculates the covariance elements using the factor $1/(n-1)$ instead of $1/n$ since it assumes we do not have the exact mean values. For a more in-depth discussion of the covariance and covariance matrix and its meaning, we refer you to the lectures on statistics.
The following simple function uses the **np.vstack** function which takes each vector of dimension $1\times n$ and produces a $ 3\times n$ matrix $\hat{W}$

$$
\hat{W} = \begin{bmatrix} x_0 & y_0 & z_0 \\
                          x_1 & y_1 & z_1 \\
                          x_2 & y_2 & z_2 \\
                          \dots & \dots & \dots \\
                          x_{n-2} & y_{n-2} & z_{n-2} \\
                          x_{n-1} & y_{n-1} & z_{n-1}
             \end{bmatrix},
$$

which in turn is converted into into the $3 times 3$ covariance matrix
$\hat{\Sigma}$ via the Numpy function **np.cov()**. In our review of
statistical functions and quantities we will discuss more about the
meaning of the covariance matrix. Here we note that we can calculate
the mean value of each set of samples $\hat{x}$ etc using the Numpy
function **np.mean(x)**. We can also extract the eigenvalues of the
covariance matrix through the **np.linalg.eig()** function.

```
# Importing various packages
import numpy as np

n = 100
x = np.random.normal(size=n)
print(np.mean(x))
y = 4+3*x+np.random.normal(size=n)
print(np.mean(y))
z = x**3+np.random.normal(size=n)
print(np.mean(z))
W = np.vstack((x, y, z))
Sigma = np.cov(W)
print(Sigma)
Eigvals, Eigvecs = np.linalg.eig(Sigma)
print(Eigvals)
```

```
%matplotlib inline

import numpy as np
import matplotlib.pyplot as plt
from scipy import sparse
eye = np.eye(4)
print(eye)
sparse_mtx = sparse.csr_matrix(eye)
print(sparse_mtx)
x = np.linspace(-10,10,100)
y = np.sin(x)
plt.plot(x,y,marker='x')
plt.show()
```

## Gaussian Elimination

We start with the linear set of equations

$$
\mathbf{A}\mathbf{x} = \mathbf{w}.
$$

We assume also that the matrix $\mathbf{A}$ is non-singular and that the
matrix elements along the diagonal satisfy $a_{ii} \ne 0$. Simple $4\times 4 $ example

$$
\begin{bmatrix}
                           a_{11}& a_{12} &a_{13}& a_{14}\\
                           a_{21}& a_{22} &a_{23}& a_{24}\\
                           a_{31}& a_{32} &a_{33}& a_{34}\\
                           a_{41}& a_{42} &a_{43}& a_{44}\\
                      \end{bmatrix} \begin{bmatrix}
                           x_1\\
                           x_2\\
                           x_3 \\
                           x_4  \\
                      \end{bmatrix}
  =\begin{bmatrix}
                           w_1\\
                           w_2\\
                           w_3 \\
                           w_4\\
                      \end{bmatrix}.
$$

or

$$
a_{11}x_1 +a_{12}x_2 +a_{13}x_3 + a_{14}x_4=w_1 \nonumber
$$

$$
a_{21}x_1 + a_{22}x_2 + a_{23}x_3 + a_{24}x_4=w_2 \nonumber
$$

$$
a_{31}x_1 + a_{32}x_2 + a_{33}x_3 + a_{34}x_4=w_3 \nonumber
$$

$$
a_{41}x_1 + a_{42}x_2 + a_{43}x_3 + a_{44}x_4=w_4. \nonumber
$$

The basic idea of Gaussian elimination is to use the first equation to eliminate the first unknown $x_1$
from the remaining $n-1$ equations. Then we use the new second equation to eliminate the second unknown
$x_2$ from the remaining $n-2$ equations. With $n-1$ such eliminations
we obtain a so-called upper triangular set of equations of the form

$$
b_{11}x_1 +b_{12}x_2 +b_{13}x_3 + b_{14}x_4=y_1 \nonumber
$$

$$
b_{22}x_2 + b_{23}x_3 + b_{24}x_4=y_2 \nonumber
$$

$$
b_{33}x_3 + b_{34}x_4=y_3 \nonumber
$$

$$
b_{44}x_4=y_4. \nonumber
\label{eq:gaussbacksub} \tag{1}
$$

We can solve this system of equations recursively starting from $x_n$ (in our case $x_4$) and proceed with
what is called a backward substitution.

This process can be expressed mathematically as

$$
\begin{equation}
   x_m = \frac{1}{b_{mm}}\left(y_m-\sum_{k=m+1}^nb_{mk}x_k\right)\quad m=n-1,n-2,\dots,1.
\label{_auto1} \tag{2}
\end{equation}
$$

To arrive at such an upper triangular system of equations, we start by eliminating
the unknown $x_1$ for $j=2,n$. We achieve this by multiplying the first equation by $a_{j1}/a_{11}$ and then subtract
the result from the $j$th equation. We assume obviously that $a_{11}\ne 0$ and that
$\mathbf{A}$ is not singular.

Our actual $4\times 4$ example reads after the first operation

$$
\begin{bmatrix}
                           a_{11}& a_{12} &a_{13}& a_{14}\\
                           0& (a_{22}-\frac{a_{21}a_{12}}{a_{11}}) &(a_{23}-\frac{a_{21}a_{13}}{a_{11}}) & (a_{24}-\frac{a_{21}a_{14}}{a_{11}})\\
0& (a_{32}-\frac{a_{31}a_{12}}{a_{11}})& (a_{33}-\frac{a_{31}a_{13}}{a_{11}})& (a_{34}-\frac{a_{31}a_{14}}{a_{11}})\\
0&(a_{42}-\frac{a_{41}a_{12}}{a_{11}}) &(a_{43}-\frac{a_{41}a_{13}}{a_{11}}) & (a_{44}-\frac{a_{41}a_{14}}{a_{11}}) \\
                      \end{bmatrix} \begin{bmatrix}
                           x_1\\
                           x_2\\
                           x_3 \\
                           x_4  \\
                      \end{bmatrix}
  =\begin{bmatrix}
                           y_1\\
                           w_2^{(2)}\\
                           w_3^{(2)} \\
                           w_4^{(2)}\\
                      \end{bmatrix},
$$

or

$$
b_{11}x_1 +b_{12}x_2 +b_{13}x_3 + b_{14}x_4=y_1 \nonumber
$$

$$
a^{(2)}_{22}x_2 + a^{(2)}_{23}x_3 + a^{(2)}_{24}x_4=w^{(2)}_2 \nonumber
$$

$$
a^{(2)}_{32}x_2 + a^{(2)}_{33}x_3 + a^{(2)}_{34}x_4=w^{(2)}_3 \nonumber
$$

$$
a^{(2)}_{42}x_2 + a^{(2)}_{43}x_3 + a^{(2)}_{44}x_4=w^{(2)}_4, \nonumber
$$

$$
\begin{equation}
\label{_auto2} \tag{3}
\end{equation}
$$

The new coefficients are

$$
\begin{equation}
   b_{1k} = a_{1k}^{(1)} \quad k=1,\dots,n,
\label{_auto3} \tag{4}
\end{equation}
$$

where each $a_{1k}^{(1)}$ is equal to the original $a_{1k}$ element. The other coefficients are

$$
\begin{equation}
a_{jk}^{(2)} = a_{jk}^{(1)}-\frac{a_{j1}^{(1)}a_{1k}^{(1)}}{a_{11}^{(1)}} \quad j,k=2,\dots,n,
\label{_auto4} \tag{5}
\end{equation}
$$

with a new right-hand side given by

$$
\begin{equation}
y_{1}=w_1^{(1)}, \quad w_j^{(2)} =w_j^{(1)}-\frac{a_{j1}^{(1)}w_1^{(1)}}{a_{11}^{(1)}} \quad j=2,\dots,n.
\label{_auto5} \tag{6}
\end{equation}
$$

We have also set $w_1^{(1)}=w_1$, the original vector element.
We see that the system of unknowns $x_1,\dots,x_n$ is transformed into an $(n-1)\times (n-1)$ problem.

This step is called forward substitution.
Proceeding with these substitutions, we obtain the
general expressions for the new coefficients

$$
\begin{equation}
   a_{jk}^{(m+1)} = a_{jk}^{(m)}-\frac{a_{jm}^{(m)}a_{mk}^{(m)}}{a_{mm}^{(m)}} \quad j,k=m+1,\dots,n,
\label{_auto6} \tag{7}
\end{equation}
$$

with $m=1,\dots,n-1$ and a
right-hand side given by

$$
\begin{equation}
   w_j^{(m+1)} =w_j^{(m)}-\frac{a_{jm}^{(m)}w_m^{(m)}}{a_{mm}^{(m)}}\quad j=m+1,\dots,n.
\label{_auto7} \tag{8}
\end{equation}
$$

This set of $n-1$ elimations leads us to an equations which is solved by back substitution.
If the arithmetics is exact and the matrix $\mathbf{A}$ is not singular, then the computed answer will be exact.

Even though the matrix elements along the diagonal are not zero,
numerically small numbers may appear and subsequent divisions may lead to large numbers, which, if added
to a small number may yield losses of precision. Suppose for example that our first division in $(a_{22}-a_{21}a_{12}/a_{11})$
results in $-10^{-7}$ and that $a_{22}$ is one.
one. We are then
adding $10^7+1$. With single precision this results in $10^7$.

  * Gaussian elimination, $O(2/3n^3)$ flops, general matrix

  * LU decomposition, upper triangular and lower tridiagonal matrices, $O(2/3n^3)$ flops, general matrix. Get easily the inverse, determinant and can solve linear equations with back-substitution only, $O(n^2)$ flops

  * Cholesky decomposition. Real symmetric or hermitian positive definite matrix, $O(1/3n^3)$ flops.

  * Tridiagonal linear systems, important for differential equations. Normally positive definite and non-singular. $O(8n)$ flops for symmetric. Special case of banded matrices.

  * Singular value decomposition

  * the QR method will be discussed in chapter 7 in connection with eigenvalue systems. $O(4/3n^3)$ flops.

The LU decomposition method means that we can rewrite
this matrix as the product of two matrices $\mathbf{L}$ and $\mathbf{U}$
where

$$
\begin{bmatrix}
                          a_{11} & a_{12} & a_{13} & a_{14} \\
                          a_{21} & a_{22} & a_{23} & a_{24} \\
                          a_{31} & a_{32} & a_{33} & a_{34} \\
                          a_{41} & a_{42} & a_{43} & a_{44}
                      \end{bmatrix}
                      = \begin{bmatrix}
                              1  & 0      & 0      & 0 \\
                          l_{21} & 1      & 0      & 0 \\
                          l_{31} & l_{32} & 1      & 0 \\
                          l_{41} & l_{42} & l_{43} & 1
                      \end{bmatrix}
                        \begin{bmatrix}
                          u_{11} & u_{12} & u_{13} & u_{14} \\
                               0 & u_{22} & u_{23} & u_{24} \\
                               0 & 0      & u_{33} & u_{34} \\
                               0 & 0      &  0     & u_{44}
             \end{bmatrix}.
$$

LU decomposition forms the backbone of other algorithms in linear algebra, such as the
solution of linear equations given by

$$
a_{11}x_1 +a_{12}x_2 +a_{13}x_3 + a_{14}x_4=w_1 \nonumber
$$

$$
a_{21}x_1 + a_{22}x_2 + a_{23}x_3 + a_{24}x_4=w_2 \nonumber
$$

$$
a_{31}x_1 + a_{32}x_2 + a_{33}x_3 + a_{34}x_4=w_3 \nonumber
$$

$$
a_{41}x_1 + a_{42}x_2 + a_{43}x_3 + a_{44}x_4=w_4.  \nonumber
$$

The above set of equations is conveniently solved by using LU decomposition as an intermediate step.

The matrix $\mathbf{A}\in \mathbb{R}^{n\times n}$ has an LU factorization if the determinant
is different from zero. If the LU factorization exists and $\mathbf{A}$ is non-singular, then the LU factorization
is unique and the determinant is given by

$$
det\{\mathbf{A}\}=det\{\mathbf{LU}\}= det\{\mathbf{L}\}det\{\mathbf{U}\}=u_{11}u_{22}\dots u_{nn}.
$$

There are at least three main advantages with LU decomposition compared with standard Gaussian elimination:

  * It is straightforward to compute the determinant of a matrix

  * If we have to solve sets of linear equations with the same matrix but with different vectors $\mathbf{y}$, the number of FLOPS is of the order $n^3$.

  * The inverse is such an operation

With the LU decomposition it is rather
simple to solve a system of linear equations

$$
a_{11}x_1 +a_{12}x_2 +a_{13}x_3 + a_{14}x_4=w_1 \nonumber
$$

$$
a_{21}x_1 + a_{22}x_2 + a_{23}x_3 + a_{24}x_4=w_2 \nonumber
$$

$$
a_{31}x_1 + a_{32}x_2 + a_{33}x_3 + a_{34}x_4=w_3 \nonumber
$$

$$
a_{41}x_1 + a_{42}x_2 + a_{43}x_3 + a_{44}x_4=w_4. \nonumber
$$

This can be written in matrix form as

$$
\mathbf{Ax}=\mathbf{w}.
$$

where $\mathbf{A}$ and $\mathbf{w}$ are known and we have to solve for
$\mathbf{x}$. Using the LU dcomposition we write

$$
\mathbf{A} \mathbf{x} \equiv \mathbf{L} \mathbf{U} \mathbf{x} =\mathbf{w}.
$$

The previous equation can be calculated in two steps

$$
\mathbf{L} \mathbf{y} = \mathbf{w};\qquad \mathbf{Ux}=\mathbf{y}.
$$

To show that this is correct we use to the LU decomposition
to rewrite our system of linear equations as

$$
\mathbf{LUx}=\mathbf{w},
$$

and since the determinant of $\mathbf{L}$ is equal to 1 (by construction
since the diagonals of $\mathbf{L}$ equal 1) we can use the inverse of
$\mathbf{L}$ to obtain

$$
\mathbf{Ux}=\mathbf{L^{-1}w}=\mathbf{y},
$$

which yields the intermediate step

$$
\mathbf{L^{-1}w}=\mathbf{y}
$$

and as soon as we have $\mathbf{y}$ we can obtain $\mathbf{x}$
through $\mathbf{Ux}=\mathbf{y}$.

For our four-dimentional example this takes the form

$$
y_1=w_1 \nonumber
$$

$$
l_{21}y_1 + y_2=w_2\nonumber
$$

$$
l_{31}y_1 + l_{32}y_2 + y_3 =w_3\nonumber
$$

$$
l_{41}y_1 + l_{42}y_2 + l_{43}y_3 + y_4=w_4. \nonumber
$$

and

$$
u_{11}x_1 +u_{12}x_2 +u_{13}x_3 + u_{14}x_4=y_1 \nonumber
$$

$$
u_{22}x_2 + u_{23}x_3 + u_{24}x_4=y_2\nonumber
$$

$$
u_{33}x_3 + u_{34}x_4=y_3\nonumber
$$

$$
u_{44}x_4=y_4  \nonumber
$$

This example shows the basis for the algorithm
needed to solve the set of $n$ linear equations.

The algorithm goes as follows

  * Set up the matrix $\bf A$ and the vector $\bf w$ with their correct dimensions. This determines the dimensionality of the unknown vector $\bf x$.

  * Then LU decompose the matrix $\bf A$ through a call to the function `ludcmp(double a, int n, int indx, double &d)`. This functions returns the LU decomposed matrix $\bf A$, its determinant and the vector indx which keeps track of the number of interchanges of rows. If the determinant is zero, the solution is malconditioned.

  * Thereafter you call the function  `lubksb(double a, int n, int indx, double w)` which uses the LU decomposed matrix $\bf A$ and the vector $\bf w$ and returns $\bf x$ in the same place as $\bf w$. Upon exit the original content in $\bf w$ is destroyed. If you wish to keep this information, you should make a backup of it in your calling function.

### LU Decomposition, the inverse of a matrix

If the inverse exists then

$$
\mathbf{A}^{-1}\mathbf{A}=\mathbf{I},
$$

the identity matrix. With an LU decomposed matrix we can rewrite the last equation as

$$
\mathbf{LU}\mathbf{A}^{-1}=\mathbf{I}.
$$

If we assume that the first column (that is column 1) of the inverse matrix
can be written as a vector with unknown entries

$$
\mathbf{A}_1^{-1}= \begin{bmatrix}
                              a_{11}^{-1} \\
                              a_{21}^{-1} \\
                              \dots \\
                              a_{n1}^{-1} \\
                    \end{bmatrix},
$$

then we have a linear set of equations

$$
\mathbf{LU}\begin{bmatrix}
                              a_{11}^{-1} \\
                              a_{21}^{-1} \\
                              \dots \\
                              a_{n1}^{-1} \\
                    \end{bmatrix} =\begin{bmatrix}
                               1 \\
                              0 \\
                              \dots \\
                              0 \\
                    \end{bmatrix}.
$$

In a similar way we can compute the unknow entries of the second column,

$$
\mathbf{LU}\begin{bmatrix}
                              a_{12}^{-1} \\
                              a_{22}^{-1} \\
                              \dots \\
                              a_{n2}^{-1} \\
                    \end{bmatrix}=\begin{bmatrix}
                                0 \\
                              1 \\
                              \dots \\
                              0 \\
                    \end{bmatrix},
$$

and continue till we have solved all $n$ sets of linear equations.


## Accleration from motion
Calcualtion of 6 DOF acceleration in some point based on 6 DOF motion in some other point

```
import warnings
#warnings.filterwarnings('ignore')
%matplotlib inline
%load_ext autoreload
%autoreload 2
```

```
import sympy as sp
import sympy.physics.mechanics as me
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

from signal_lab.substitute_dynamic_symbols import substitute_dynamic_symbols, find_name, find_derivative_name, lambdify, find_derivatives

```

```
x0,y0,z0 = me.dynamicsymbols('x0 y0 z0')
x01d,y01d,z01d = me.dynamicsymbols('x0 y0 z0',1)
x02d,y02d,z02d = me.dynamicsymbols('x0 y0 z0',2)

phi,theta,psi = me.dynamicsymbols('phi theta psi')
p,q,r = me.dynamicsymbols('phi theta psi',1)
p1d,q1d,r1d = me.dynamicsymbols('phi theta psi',2)

p_x = sp.Symbol('p_x')
p_y = sp.Symbol('p_y')
p_z = sp.Symbol('p_z')

```

```
x02d
```

```
N = me.ReferenceFrame('N')
```

```
#S = N.orientnew('S', 'Body', [phi,theta,psi],'321')
#M = N.orientnew('M','Axis', [psi,N.z])

S = N.orientnew('S', 'Body', [psi,theta,phi],'ZYX')

```

```
#S.set_ang_vel(N, q * N.y)
#S.set_ang_acc(N, q1d * N.y)
```

```
S.ang_vel_in(N)
```

```
S.ang_acc_in(N)
```

```
M = me.Point('M')  # Origo of measurement system
M.set_vel(N, 0)

O = M.locatenew('O', x0*N.x + y0*N.y + z0*N.z) # Origo of ship
O.set_vel(N, x01d*N.x + y01d*N.y + z01d*N.z)
O.set_acc(N, x02d*N.x + y02d*N.y + z02d*N.z)

```

```
P = O.locatenew('P', p_x*S.x + p_y*S.y + p_z*S.z)
P.set_vel(S, 0)

```

```
S.dcm(N)
```

```
vector = P.pos_from(M)
motion_P = vector.express(N)
motion_P
```

```
motion_matrix = vector.to_matrix(N)
motion_matrix
```

```
motion_lambda = lambdify(motion_matrix)
```

```
motion_lambda
```

```
number_of_points = 100
t = np.linspace(0,2*np.pi,number_of_points)
df = pd.DataFrame(index = t)
df['phi'] = 0
df['theta'] = np.sin(t)
df['psi'] = 0

df['p_x'] = 20
df['p_y'] = 0
df['p_z'] = 1

df['x0'] = np.sin(t)
df['y0'] = np.sin(t)
df['z0'] = -np.sin(t)
motions = motion_lambda(**df)

df_result = df.copy()
df_result['x'] = motions[0][0]
df_result['y'] = motions[1][0]
df_result['z'] = motions[2][0]

fig,ax=plt.subplots()
df_result.plot(y='x', ax=ax)

fig,ax=plt.subplots()
df_result.plot(y='z', ax=ax)

```

## Velocity

```
P.v2pt_theory(O,N,S)
```

```
velocity_matrix = P.vel(N).express(N).to_matrix(N)
velocity_matrix
```

```
velocity_lambda = lambdify(velocity_matrix)
```

```
velocity_lambda
```

```
number_of_points = 100
t = np.linspace(0,2*np.pi,number_of_points)
df = pd.DataFrame(index = t)
df['phi'] = 0
df['theta'] = np.sin(t)
df['psi'] = 0

df['phi1d'] = 0
df['theta1d'] = np.cos(t)
df['psi1d'] = 0

df['p_x'] = 20
df['p_y'] = 0
df['p_z'] = 1

df['x01d'] = 10
df['y01d'] = 0
df['z01d'] = 0

velocities = velocity_lambda(**df)

df_result = df.copy()
df_result['x01d'] = velocities[0][0]
df_result['y01d'] = velocities[1][0]
df_result['z01d'] = velocities[2][0]

fig,ax=plt.subplots()
df_result.plot(y='x01d', ax=ax)

fig,ax=plt.subplots()
df_result.plot(y='z01d', ax=ax)
```

## Acceleration

```
P.a2pt_theory(O,N,S)
```

### Acceleration in earth fixed reference frame N expressed in ship Reference frame S

```
#acceleration_matrix = P.acc(N).express(N).to_matrix(N)
acceleration_matrix = P.acc(N).express(S).to_matrix(S)

acceleration_matrix
```

```
substitute_dynamic_symbols(acceleration_matrix)
```

```
acceleration_lambda = lambdify(acceleration_matrix)
```

```
acceleration_lambda
```

### Centrifugal acceleration
Absolute acceleration can be calcualted as:

$ |a| = r\cdot\dot{\theta}^2 $

With the components:

$ \ddot{x_0} = -a $

$ \ddot{z_0} = 0 $

```
number_of_points = 100
t = np.linspace(0,2*np.pi,number_of_points)
r_ = 10
w_ = 1
df = pd.DataFrame(index = t)

df['phi'] = 0
df['theta'] = w_*t
df['psi'] = 0

df['phi1d'] = 0
df['theta1d'] = w_
df['psi1d'] = 0

df['phi2d'] = 0
df['theta2d'] = 0
df['psi2d'] = 0

df['p_x'] = r_
df['p_y'] = 0
df['p_z'] = 0

df['x02d'] = 0
df['y02d'] = 0
df['z02d'] = 0

accelerations = acceleration_lambda(**df)

df_result = df.copy()
df_result['x02d'] = accelerations[0,0]
df_result['y02d'] = accelerations[1,0]
df_result['z02d'] = accelerations[2,0]

a = r_* w_**2
df_result['x02d_check'] = -a
df_result['z02d_check'] = 0

fig,ax=plt.subplots()
df_result.plot(y=['x02d','x02d_check'], ax=ax, alpha=0.7)

fig,ax=plt.subplots()
df_result.plot(y=['z02d','z02d_check'], ax=ax, alpha=0.7)
```

## Save lambdas

```
import dill
import signal_lab
dill.settings['recurse'] = True
```

```
import os.path
with open(os.path.join(signal_lab.directory,'point_acceleration.dill'),mode='wb') as file:
    dill.dump(acceleration_lambda, file=file)
```

```

```


## Math constants - Irrational Genius Numbers

https://docs.sympy.org/0.7.1/modules/mpmath/functions/constants.html

```python
#from sympy import *
#init_printing()

# import mpmath as mp
from mpmath import *
```

```python
mp.dps = 10
```

### Pi

```python
pi
```

    <pi: 3.14159~>

```python
mp.pi
```

    <pi: 3.14159~>

```python
v = mp.pi()
```

```python
type(v)
```

    mpmath.ctx_mp_python.mpf

```python
v.real
```

    mpf('3.141592653585')

```python
mp.dps = 15
mp.pi()
```

    mpf('3.1415926535897931')

```python
print(+mp.pi)
```

    3.14159265358979

```python
float(mp.pi())
```

    3.141592653589793

```python
mp.dps = 50; mp.pretty = True
```

```python
+pi
```

    3.1415926535897932384626433832795028841971693993751

```python
mp.dps = 100000
str(pi)[:100]
```

    '3.14159265358979323846264338327950288419716939937510582097494459230781640628620899862803482534211706'

```python
mp.dps = 10
sin(pi)
```

    5.126688303e-12

```python
sinpi(1)
```

    0.0

### Base of the natural logarithm

```python
+e
```

    2.718281828

Use `exp(x)` instead; this is both faster and more accurate

```python
exp(1.0)
```

    2.718281828

### Golden ratio (phi)

Ratio of two neighboring Fibonacci numbers

$ \phi = \frac{1+\sqrt{5}}{2} $

```python
mp.dps = 20; mp.pretty = True
```

```python
+phi
```

    1.6180339887498948482

```python
(1+sqrt(5))/2
```

    1.6180339887498948482

```python
limit(lambda n: fib(n+1)/fib(n), inf)
```

    1.6180339887498948482

### Euler's constant

$\gamma = 0.577 $

```python
mp.dps = 50; mp.pretty = True
+euler
```

    0.57721566490153286060651209008240243104215933593992

```python
# compute from definition
limit(lambda n: harmonic(n)-log(n), inf)
```

    0.57721566490153286060651209008240243104215933593992

```python
γ = lambda n: harmonic(n)-log(n)
```

```python
γ(1000)     # converge very slowly
```

    0.57771558156820786060254384028080755371837470382248

Integrals, series, and representations for γ in terms of special functions include the following (there are many others):

```python
-quad(lambda x: exp(-x)*log(x), [0,inf])
```

    0.57721566490153286060651209008240243104215933593992

```python
nsum(lambda k: 1/k-log(1+1/k), [1,inf])
```

    0.57721566490153286060651209008240243104215933593992

```python
nsum(lambda k: (-1)**k*zeta(k)/k, [2,inf])
```

    0.57721566490153286060651209008240243104215933593992

```python
-diff(gamma, 1)
```

    0.57721566490153286060651209008240243104215933593992

```python
limit(lambda x: 1/x-gamma(x), 0)
```

    0.57721566490153286060651209008240243104215933593992

```python
limit(lambda x: zeta(x)-1/(x-1), 1)
```

    0.57721566490153286060651209008240243104215933593992

### Apery’s constant (apery)

an irrational number approximately equal to 1.2020569

$$ \zeta(3) = \sum_{k=1}^{\infty} \frac{1}{k^3} $$

see https://www.overleaf.com/learn/latex/Integrals,_sums_and_limits for type-setting summation

```python
+apery
```

    1.2020569031595942853997381615114499907649862923405

```python
zeta(3)
```

    1.2020569031595942853997381615114499907649862923405

```python
-psi(2,1)/2
```

    1.2020569031595942853997381615114499907649862923405

```python
8*nsum(lambda k: 1/(2*k+1)**3, [0,inf])/7
```

    1.2020569031595942853997381615114499907649862923405

```python
f = lambda k: 2/k**3/(exp(2*pi*k)-1)
7*pi**3/180 - nsum(f, [1,inf])
```

    1.2020569031595942853997381615114499907649862923405

### Twin prime constant (twinprime)

http://mathworld.wolfram.com/TwinPrimesConstant.html

Represents the twin prime constant, which is the factor C2 featuring in the Hardy-Littlewood conjecture for the growth of the twin prime counting function

$$ \pi_2(n) \approx 2C_2 \frac{n}{log^2 n}$$

It is given by the product over primes

$$ C_2 = \prod_{p=3}^{\infty} \frac{p(p-2)}{(p-1)^2} \approx 0.66016 $$

```python
twinprime()
```

    0.66016181584686957392781211001455577843262336028473

```python
+twinprime
```

    0.66016181584686957392781211001455577843262336028473

```python

```


```
from sympy import *
```

```
n_u, n_b, m_p, m_r = symbols("n_u n_b m_p m_r")
F = MatrixSymbol("F", n_u, n_u)
M = MatrixSymbol("M", n_b, n_b)
C = MatrixSymbol("C", n_b, n_u)
B = MatrixSymbol("B", m_p, n_u)
D = MatrixSymbol("D", m_r, n_b)
```

```
B.T.shape
```

    (n_u, m_p)

```
A = BlockMatrix([[F,B.T,C.T,ZeroMatrix(n_u,m_r)],
                 [B,ZeroMatrix(m_p,m_p),ZeroMatrix(m_p,n_b),ZeroMatrix(m_p,m_r)],
                 [-C,ZeroMatrix(n_b,m_p),M,D.T],
                 [ZeroMatrix(m_r,n_u),ZeroMatrix(m_r,m_p),D,ZeroMatrix(m_r,m_r)]])
A
```

    [     F, B', C',  0]
    [     B,  0,  0,  0]
    [(-1)*C,  0,  M, D']
    [     0,  0,  D,  0]

```
n_u, n_b, m_p, m_r = symbols("n_u n_b m_p m_r")
F = Symbol("F")
M = Symbol("M")
C = Symbol("C")
Ct = Symbol("Ct")
B = Symbol("B")
Bt = Symbol("Bt")
D = Symbol("D")
Dt = Symbol("Dt")

```

```
A = Matrix([[F,B.T,C.T,0],[B,0,0,0],[-C,0,M,D.T],[0,0,D,0]])

```


# 13 Root Finding

An important tool in the computational tool box is to find roots of equations for which no closed form solutions exist:

We want to find the roots $x_0$ of

$$
f(x_0) = 0
$$

## Problem: Projectile range
The equations of motion for the projectile with linear air resistance (see *12 ODE applications*) can be solved exactly.

As a reminder: the linear drag force is
$$
\mathbf{F}_1 = -b_1 \mathbf{v}\\
b := \frac{b_1}{m}
$$

Equations of motion with force due to gravity $\mathbf{g} = -g \hat{\mathbf{e}}_y$

\begin{align}
\frac{d\mathbf{r}}{dt} &= \mathbf{v}\\
\frac{d\mathbf{v}}{dt} &= - g \hat{\mathbf{e}}_y -b \mathbf{v}
\end{align}

### Analytical solution of the equations of motion
(Following Wang Ch 3.3.2)

Solve $x$ component of the velocity

$$
\frac{dv_x}{dt} = -b v_x
$$

by integration:

$$
v_x(t) = v_{0x} \exp(-bt)
$$

The drag force reduces the forward velocity to 0.

Integrate again to get the $x(t)$ component

$$
x(t) = x_0 + \left[1 - \frac{v_{0x}}{b} \exp(-bt)\right]
$$

Integrating the $y$ component of the velocity

$$
\frac{dv_y}{dt} = -g - b v_y
$$

gives

$$
v_y = \left(v_{0y} + \frac{g}{b}\right) \exp(-bt) - \frac{g}{b}
$$

and integrating again

$$
y(t) = y_0 + \frac{v_{0y} + \frac{g}{b}}{b} \left[1 - \exp(-bt)\right] - \frac{g}{b} t
$$

(Note: This shows immediately that the *terminal velocity* is

$$
\lim_{t\rightarrow\infty} v_y(t) = - \frac{g}{b},
$$

i.e., the force of gravity is balanced by the drag force.)

#### Analytical trajectory

To obtain the **trajectory $y(x)$** eliminate time (and for convenience, using the origin as the initial starting point, $x_0 = 0$ and $y_0 = 0$. Solve $x(t)$ for $t$

$$
t = -\frac{1}{b} \ln \left(1 - \frac{bx}{v_{0x}}\right)
$$

and insert into $y(t)$:

$$
y(x) = \frac{x}{v_{0x}} \left( v_{0y} + \frac{g}{b} \right) + \frac{g}{b^2} \ln \left(1 - \frac{bx}{v_{0x}}\right)
$$

#### Plot

Plot the analytical solution for $\theta = 30^\circ$ and $v_0 = 100$~m/s.

```python
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
plt.style.use('ggplot')
```

```python
def y_lindrag(x, v0, b1=0.2, g=9.81, m=0.5):
    b = b1/m
    v0x, v0y = v0
    return x/v0x * (v0y + g/b) + g/(b*b) * np.log(1 - b*x/v0x)

def initial_v(v, theta):
    x = np.deg2rad(theta)
    return v * np.array([np.cos(x), np.sin(x)])
```

```python
X = np.concatenate([np.linspace(0, 42, 100), np.linspace(42, 45, 1000)])
```

```python
Y = y_lindrag(X, initial_v(100, 30), b1=1)
```

    /Users/oliver/anaconda3/lib/python3.5/site-packages/ipykernel/__main__.py:4: RuntimeWarning: invalid value encountered in log

```python
plt.plot(X, Y)
```

Compare to the numerical solution (from 12 ODE Applications):

```python
import ode

def simulate(v0, h=0.01, b1=0.2, g=9.81, m=0.5):
    def f(t, y):
        # y = [x, y, vx, vy]
        return np.array([y[2], y[3], -b1/m * y[2], -g - b1/m * y[3]])

    vx, vy = v0
    t = 0
    positions = []
    y = np.array([0, 0, vx, vy], dtype=np.float64)

    while y[1] >= 0:
        positions.append([t, y[0], y[1]])  # record t, x and y
        y[:] = ode.rk4(y, f, t, h)
        t += h

    return np.array(positions)
```

```python
r = simulate(initial_v(100, 30), h=0.01, b1=1)
```

```python
plt.plot(X, Y, lw=2, label="analytical")
plt.plot(r[:, 1], r[:, 2], '--', label="RK4")
plt.legend(loc="best")
plt.xlabel("$x$ (m)"); plt.ylabel("$y$ (m)")
```

### Predict the range $R$
How far does the ball or projectile fly, i.e., that value $x=R$ where $y(R) = 0$:

$$
\frac{R}{v_{0x}} \left( v_{0y} + \frac{g}{b} \right) + \frac{g}{b^2} \ln \left(1 - \frac{bR}{v_{0x}}\right) = 0
$$

This *transcendental equation* can not be solved in terms of elementary functions.

Use a **root finding** algorithm.

## Root-finding with the Bisection algorithm
**Bisection** is the simplest (but very robust) root finding algorithm that uses trial-and-error:

* bracket the root
* refine the brackets
* see [13_Root-finding-algorithms (PDF)](13_Root-finding-algorithms.pdf)

More specifically
1. determine a bracket that contains the root: $x_{-} < x_0 < x_{+}$
2. cut bracket in half: $x' = \frac{1}{2}(x_{+} + x_{-})$
3. determine in which half the root lies: $f(x_{-}) f(x') < 0$ then the root lies in the left half, otherwise the right half.
4. repeat until $x_{+} - x_{-} < \epsilon$.

### Implementation of Bisection

- Test that the initial bracket contains a root; if not, return `None` (and possibly print a warning).
- If either of the bracket points is a root then return the bracket point.
- Allow `Nmax` iterations or until the convergence criterion `eps` is reached.
- Bonus: print a message if no root was found after `Nmax` iterations, but print the best guess and the error (but return `None`).

```python
def bisection(f, xminus, xplus, Nmax=100, eps=1e-14):
    fa, fb = f(xminus), f(xplus)
    if (fa*fb) > 0:
        print("bisect: Initial bracket [{0}, {1}] "
              "does not contain a single root".format(xminus, xplus))
        return None
    if fa == 0:
        return xminus
    if fb == 0:
        return xplus

    for iteration in range(Nmax):
        x = (xplus + xminus)/2
        fx = f(x)
        if f(xplus) * fx > 0:
            # root is not between xplus and x
            xplus = x
        else:
            xminus = x
        if np.abs(fx) < eps:
            break
    else:
        print("bisect: no root found after {0} iterations (eps={1}); "
              "best guess is {2} with error {3}".format(Nmax, eps, x, fx))
        x = None
    return x
```

### Finding the range with the bisection algorithm

Define the trial function:

```python
v = initial_v(100, 30)

def f(x):
    return y_lindrag(x, v, b1=b1)
```

The initial bracket is a little bit difficult for this function: choose the right bracket near the point where the argument of the logarithm becomes 0:

$$
x_{+} = \frac{v_{0x}}{b} - \epsilon
$$

```python
b1 = 1.
m = 0.5
b = b1/m
bisection(f, 0.1, v[0]/b - 1e-12, eps=1e-6)
```

    43.300674233470772

### Find the range as a function of the initial angle

```python
b1 = 1.
m = 0.5
b = b1/m
v0 = 100
u = []
for theta in np.arange(1, 90):
    v = initial_v(v0, theta)
    def f(x):
        return y_lindrag(x, v, b1=b1)
    R = bisection(f, 0.1, v[0]/b - 1e-16, eps=1e-5)
    if R is not None:
        u.append((theta, R))
u = np.array(u)
```

    /Users/oliver/anaconda3/lib/python3.5/site-packages/ipykernel/__main__.py:4: RuntimeWarning: divide by zero encountered in log

```python
plt.plot(u[:, 0], u[:, 1])
```

Write a function `find_range()` to calculate the range for a given initial velocity $v_0$ and plot $R(\theta)$ for $10\,\text{m/s} ≤ v_0 ≤ 100\,\text{m/s}$.

```python
def find_range(v0, b1=1, m=0.5):
    b = b1/m
    u = []
    for theta in np.arange(1, 90):
        v = initial_v(v0, theta)
        def f(x):
            return y_lindrag(x, v, b1=b1)
        R = bisection(f, 0.1, v[0]/b - 1e-16, eps=1e-5)
        if R is not None:
            u.append((theta, R))
    return np.array(u)
```

```python
for v0 in (10, 25, 50, 75, 100):
    u = find_range(v0)
    plt.plot(u[:, 0], u[:, 1], label="{} m/s".format(v0))
plt.legend(loc="best")
plt.xlabel(r"$\theta$ (degrees)")
plt.ylabel(r"range $R$ (m)")
```

## Newton-Raphson algorithm
(see derivation in class and in the PDF or [Newton's Method](http://mathworld.wolfram.com/NewtonsMethod.html) on MathWorld)

### Activity: Implement Newton-Raphson
1. Implement the Newton-Raphson algorithm
2. Test with $g(x)$.

   $$
   g(x) = 2 \cos x - x
   $$

3. Bonus: test performance of `newton_raphson()` against `bisection()`.

```python
def g(x):
    return 2*np.cos(x) - x
```

```python
xvals = np.linspace(0, 7, 30)
plt.plot(xvals, np.zeros_like(xvals), 'k--')
plt.plot(xvals, g(xvals))
```

```python
def newton_raphson(f, x, h=3e-1, Nmax=100, eps=1e-14):
    for iteration in range(Nmax):
        fx = f(x)
        if np.abs(fx) < eps:
            break
        df = (f(x + h/2) - f(x - h/2))/h
        Delta_x = -fx/df
        x += Delta_x
    else:
        print("Newton-Raphson: no root found after {0} iterations (eps={1}); "
              "best guess is {2} with error {3}".format(Nmax, eps, x, fx))
        x = None
    return x
```

```python
newton_raphson(g, 0)
```

    1.0298665293222589

```python

```


```python
import sympy as sp
from sympy import symbols
import networkx as nx
from math import inf

sp.init_printing()

%matplotlib inline
```

# Problem 1

**Use the $\alpha-\beta$ algorithm to show the minimax value to `MAX` in the following game tree. Show the branches that are pruned during the search, and the values for `MAX` and `MIN` at each level in the tree.**

```python
from IPython.display import display, Image
Image('./tree.png')
```

```python
names = ['A']

class Node(object):
    def __init__(self, state, value=None):
        self.state = state
        self.value = value
        self.alpha = -inf
        self.beta = inf
        self.left = None
        self.right = None
        self.pruned = False
        self._name = None

    @property
    def is_empty(self):
        return self.left is None and self.right is None

    @property
    def children(self):
        return [x for x in [self.left, self.right] if x is not None]

    @property
    def name(self):
        global names
        if self._name is None:
            n = chr(ord(names[-1]) + 1)
            self._name = n
            names.append(n)
        return self._name

    def __str__(self):
        if not self.pruned:
            return (('({} , {})' if self.state == 'MIN' else '[{} , {}]').format(
                    -sp.oo if self.alpha == -inf else self.alpha,
                    sp.oo if self.beta == inf else self.beta) +
                ('' if self.is_empty else ' , <{}>'.format(', '.join([str(x) for x in self.children]))))
        else:
            return ''

    def nx(self):
        g = nx.DiGraph()
        nodes = []
        edges = []
        def add(cnode):
            if cnode.left and not cnode.left.pruned:
                nodes.append((cnode.left.name, cnode.left.alpha, cnode.left.beta))
                edges.append(((cnode.name, cnode.alpha, cnode.beta),
                              (cnode.left.name, cnode.left.alpha, cnode.left.beta)))
                add(cnode.left)
            if cnode.right and not cnode.right.pruned:
                nodes.append((cnode.right.name, cnode.right.alpha, cnode.right.beta))
                edges.append(((cnode.name, cnode.alpha, cnode.beta),
                              (cnode.right.name, cnode.right.alpha, cnode.right.beta)))
                add(cnode.right)
        g.add_node((self.name, self.alpha, self.beta))
        add(self)
        g.add_nodes_from(nodes)
        g.add_edges_from(edges)
        return g

    def show(self, title=''):
        g = self.nx()
        plt.figure()
        pos = nx.drawing.nx_agraph.graphviz_layout(g,prog='dot')
        nodes = nx.draw_networkx_nodes(g, pos=pos, node_size=100, alpha=0.2)
        edges = nx.draw_networkx_edges(g, pos=pos)
        labels = nx.draw_networkx_labels(g, pos=pos, font_size=6)
        plt.axis('off')
        if title:
            plt.title(title)
        plt.show()

root = Node('MAX')

root.left = Node('MIN')
root.right = Node('MIN')

root.left.left = Node('MAX')
root.left.right = Node('MAX')
root.right.left = Node('MAX')
root.right.right = Node('MAX')

root.left.left.left = Node('MIN')
root.left.left.right = Node('MIN')
root.left.right.left = Node('MIN')
root.left.right.right = Node('MIN')
root.right.left.left = Node('MIN')
root.right.left.right = Node('MIN')
root.right.right.left = Node('MIN')

root.left.left.left.left = Node('MAX', value=5)
root.left.left.left.right = Node('MAX', value=10)
root.left.left.right.left = Node('MAX', value=6)
root.left.left.right.right = Node('MAX', value=5)
root.left.right.left.left = Node('MAX', value=4)
root.left.right.right.left = Node('MAX', value=0)
root.left.right.right.right = Node('MAX', value=5)
root.right.left.left.left = Node('MAX', value=5)
root.right.left.left.right = Node('MAX', value=10)
root.right.left.right.left = Node('MAX', value=3)
root.right.right.left.left = Node('MAX', value=2)
root.right.right.left.right = Node('MAX', value=14)

root.show(title='Initial')

i = 0

def alpha_beta_pruning(root, depth, player, alpha, beta):
    global i
    if depth == 0 or root.is_empty:
        return root.value
    if player == 'MAX':
        best = -inf
        pruned = False
        for child in root.children:
            if pruned:
                child.pruned = True
            else:
                best = max(best,
                           alpha_beta_pruning(child, depth - 1, 'MIN',
                                              alpha, beta))
                alpha = max(alpha, best)
                root.alpha = alpha
                if beta <= alpha:
                    pruned = True
        i += 1
        root.show(title='Step {}'.format(i))
        return best
    else:
        best = inf
        pruned = False
        for child in root.children:
            if pruned:
                child.pruned = True
            else:
                best = min(best,
                           alpha_beta_pruning(child, depth - 1, 'MAX',
                                              alpha, beta))
                beta = min(beta, best)
                root.beta = beta
                if beta <= alpha:
                    pruned = True
        i += 1
        root.show(title='Step {}'.format(i))
        return best
alpha_beta_pruning(root, 4, 'MAX', -inf, inf)
```

# Problem 2

**Show that the following assertion is true using an example: For every game tree, the utility obtained by `MAX` using minimax decisions against a suboptimal `MIN` will never be lower than the utility obtained playing against an optimal `MIN`. Your example should also include an explanation of what an optimal utility is for `MAX` and `MIN` and what it would mean for either `MAX` or `MIN` to play a sub-optimal game.**

This is evidently true, as the `MAX` value is the theoretical upper bound, and should never be lower than `MIN`.

We can use induction to prove this.

Let $T$ be some theoretical tree, with $T_i$ any given node from this tree.

$$
\max(T_0) \ge \min(T_0)
$$

For some tree with depth 1.

$$
\max(T_i) \ge \min(T_{j})
$$

For any two nodes $i$ and $j$.

# Problem 3

**The following pseudo-code for the minimax algorithm is similar to the code included in the lecture notes. This code shows additional print commands that are not in the lecture notes code.**

```
minimax(node, depth, mexPlayer)
    if depth == 0 or terminal(node) //terminal test is true
        return f(node)
    if maxPlayer // Player(s) = MAX
        bestValue = -MAX_INT  // system property, max negative integer
        for each child in node.adjacent
            eval = minimax(child, depth - 1, FALSE)
            print eval
            bestValue = max(bestValue, eval)
        return bestValue
    else // Player(s) = MIN
        bestValue = MAX_INT
        for each child in node.adjacent
            eval = minimax(child, depth - 1, TRUE)
            print eval
            bestValue = min(bestValue, eval)
        return bestValue

    minimax(origin, depth, TRUE) // call from root for MAX player
```

**For the following tre, what numbers are displayed when the algorithm executes with an initial depth $=4$. You can assume that the root of the tree only has one child.**

```python
Image('./tree1.png')
```

```python
names = ['A']

root = Node('MAX')

root.left = Node('MIN')

root.left.left = Node('MAX')
root.left.right = Node('MAX')

root.left.left.left = Node('MIN')
root.left.left.right = Node('MIN')
root.left.right.left = Node('MIN')
root.left.right.right = Node('MIN')

root.left.left.left.left = Node('MAX', value=5)
root.left.left.left.right = Node('MAX', value=10)
root.left.left.right.left = Node('MAX', value=6)
root.left.left.right.right = Node('MAX', value=5)
root.left.right.left.left = Node('MAX', value=4)
root.left.right.right.left = Node('MAX', value=0)
root.left.right.right.right = Node('MAX', value=5)

root.show(title='initial')

step = 1

def alpha_beta_pruning(root, depth, player):
    global step
    if depth == 0 or root.is_empty:
        return root.value
    if player == 'MAX':
        best = -inf
        for child in root.children:
            x = alpha_beta_pruning(child, depth - 1, 'MIN')
            print(x)
            best = max(x, best)
        return best
    else:
        best = inf
        for child in root.children:
            x = alpha_beta_pruning(child, depth - 1, 'MAX')
            print(x)
            beta = min(x, best)
        return best
alpha_beta_pruning(root, 4, 'MAX')
```

```python

```