# Lecture 06: Examples and overview

[Download on GitHub](https://github.com/NumEconCopenhagen/lectures-2022)

[](https://mybinder.org/v2/gh/NumEconCopenhagen/lectures-2022/master?urlpath=lab/tree/06/Examples_and_overview.ipynb)

1. [Recap](#Recap)
2. [The consumer problem](#The-consumer-problem)
3. [A worker-capitalist production economy](#A-worker-capitalist-production-economy)
4. [Inaugural project from last year (labor supply and taxation)](#Inaugural-project-from-last-year-(labor-supply-and-taxation))
5. [Summary](#Summary)

You now have all the basic tools to solve interesting economic models. The trick is to be able to combine what you know to solve problems in practice. We firstly briefly recap, with a focus solving optimization problems and non-linear equations. Afterwards, we consider a number of examples.

1. The consumer problem
2. A worker-capitalist production economy
3. The inaugurual project from 2020 (labor supply and taxation)

```python
# magic to reload modules automatically
%load_ext autoreload
%autoreload 2

# standard imports
from types import SimpleNamespace # new? explained below
import numpy as np
from scipy import optimize
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
```

# 1. Recap

2. **Primitives:** types, operators, copy vs. view, conditionals, loops, functions, classes
3. **Optimize, print and plot:** mathematics (numpy), printing, figures (matplotlib), solving optimization problems and equations (scipy.optimize)
4. **Random numbers and simulation:** random numbers (numpy.random), save/load (pickle), interactive figures (ipywidgets)
5. **Workflow and debugging:** structuring, naming, commenting, debugging (assert, try-except), modules

**Sum up:** Lots and lots of information. The important thing is not to remember it all, but to know where to look for answers.

## 1.1 Optimize, optimize, optimize

**The two most important tools:**

1. Solving optimization problems with `scipy.optimize.minimize` and `scipy.optimize.minimize_scalar`
2. Solving equations with `scipy.optimize.root` and `scipy.optimize.root_scalar`

**Problem:** A bit of a black box...

* **Lecture 10:** Details on solving equations.
* **Lecture 11:** Details on numerical optimization.
* **Now:** Compare with a) a *loop search* and b) a *hand-written optimizer*.

### Loops vs. optimizer

**Define function:** Simple polynomial with maximum at $x = 2.0$

```python
def f_func(x):
    return -3*(x-2)**2 + 1
```

**Rough solution with loop:**

```python
N = 100
x_vec = np.linspace(-10,10,N)
f_vec = np.empty(N)

f_best = -np.inf # initial maximum
x_best = np.nan # not-a-number

for i,x in enumerate(x_vec):
    f_now = f_vec[i] = f_func(x)
    if f_now > f_best:
        x_best = x
        f_best = f_now

print(f'best with loop is {f_best:.8f} at x = {x_best:.8f}')
```

**Question:** Not quite right, how to improve?

**Plot:**

```python
fig = plt.figure()
ax = fig.add_subplot(1,1,1)

ax.plot(x_vec,f_vec,ls='--',lw=2,color='black',label='$f(x)$')
ax.plot(x_best,f_best,ls='',marker='s',label='best')

ax.set_xlabel('x')
ax.set_ylabel('f')
ax.legend(loc='lower center',frameon=True);
```

**Solution with** `scipy.optimize.minimize_scalar` ([documentation](https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize_scalar.html#scipy.optimize.minimize_scalar)):

```python
obj = lambda x: -f_func(x)
res = optimize.minimize_scalar(obj,bracket=(-10,10),method='brent')
x = res.x
f = -res.fun

print(f'best is {f:.8f} at x = {x:.8f}')
```

**Solution with** `scipy.optimize.minimize` ([documentation](https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html#scipy.optimize.minimize)):

```python
x_guess = [0]
obj = lambda x: -f_func(x[0])
res = optimize.minimize(obj, x_guess, method='Nelder-Mead')
x = res.x[0]
f = -res.fun

print(f'best is {f:.8f} at x = {x:.8f}')
```

**Solution with** `scipy.optimize.root_scalar` ([documentation](https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.root_scalar.html)):

Find derivative and solve via FOC:

```python
def fp_func(x):
    return -6*(x-2)
```

```python
x_guess = [0]
obj = lambda x: fp_func(x[0])
res = optimize.root(obj,x_guess,method='hybr')
x = res.x[0]
f = f_func(x)

print(f'best is {f:.8f} at x = {x:.8f}')
```

**Solution with** `scipy.optimize.root` ([documentation](https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.root.html)):

```python
obj = lambda x: fp_func(x)
res = optimize.root_scalar(obj,bracket=(-10,10),method='bisect')
x = res.root
f = f_func(res.root)

print(f'best is {f:.8f} at x = {x:.8f}')
```

### Gradient descent optimizer

**Algorithm:** `minimize_gradient_descent()`

1. Choose tolerance $\epsilon>0$, step size $\alpha > 0$, and guess on $x_0$, set $n=0$.
2. Compute  $f(x_n)$ and $f^\prime(x_n) \approx \frac{f(\boldsymbol{x}_{n}+\Delta)-f(\boldsymbol{x}_{n})}{\Delta}$.
3. If $|f^\prime(x_n)| < \epsilon$ then stop.
4. Compute new guess "down the hill":

  $$
  x_{n+1} = x_{n} - \alpha f^\prime(x_n)
  $$

5. Set $n = n + 1$ and return to step 2.

**Code for algorithm:**

```python
def gradient_descent(f,x0,alpha=1,Delta=1e-8,max_iter=500,eps=1e-8):
    """ minimize function with gradient descent

    Args:

        f (callable): function
        x0 (float): initial value
        alpha (float,optional): step size factor in search
        Delta (float,optional): step size in numerical derivative
        max_iter (int,optional): maximum number of iterations
        eps (float,optional): tolerance

    Returns:

        x (float): minimum
        fx (float): funciton value at minimum
        trials (list): list with tuple (x,value,derivative)

    """

    # step 1: initialize
    x = x0
    n = 0
    trials = []

    # step 2-4:
    while n < max_iter:

        # step 2: compute function value and derivative
        fx = f(x)
        fp = (f(x+Delta)-fx)/Delta

        trials.append({'x':x,'fx':fx,'fp':fp})

        # step 3: check convergence
        print(f'n = {n:3d}: x = {x:12.8f}, f = {fx:12.8f}, fp = {fp:12.8f}')
        if np.abs(fp) < eps:
            break

        # step 4: update x
        x -= alpha*fp

        # step 5: update n
        n += 1

    return x,fx,trials
```

**Call the optimizer:**

```python
x0 = 0
alpha = 0.5
f = lambda x: -np.sin(x)+0.05*x**2

x,fx,trials = gradient_descent(f,x0,alpha)

print(f'best with gradient_descent is {fx:.8f} at x = {x:.8f}')
```

**Illusstration:**

```python
fig = plt.figure(figsize=(10,10))

# a. main figure
ax = fig.add_subplot(2,2,(1,2))

trial_x_vec = [trial['x'] for trial in trials]
trial_f_vec = [trial['fx'] for trial in trials]
trial_fp_vec = [trial['fp'] for trial in trials]

ax.plot(x_vec,f(x_vec),ls='--',lw=2,color='black',label='$f(x)$')
ax.plot(trial_x_vec,trial_f_vec,ls='',marker='s',ms=4,color='blue',label='iterations')

ax.set_xlabel('$x$')
ax.set_ylabel('$f$')
ax.legend(loc='upper center',frameon=True)

# sub figure 1
ax = fig.add_subplot(2,2,3)
ax.plot(np.arange(len(trials)),trial_x_vec)
ax.set_xlabel('iteration')
ax.set_ylabel('x')

# sub figure 2
ax = fig.add_subplot(2,2,4)
ax.plot(np.arange(len(trials)),trial_fp_vec)
ax.set_xlabel('iteration')
ax.set_ylabel('derivative of f');
```

**Question:** Can we guess on any initial value of $x_0$?

# 2. The consumer problem

$$
\begin{aligned}
V(p_{1},p_{2},I) & = \max_{x_{1},x_{2}} \left(\alpha^{\frac{1}{\sigma}}x_{1}^{\frac{\sigma-1}{\sigma}}+(1-\alpha)^{\frac{1}{\sigma}}x_{2}^{\frac{\sigma-1}{\sigma}}\right)^{\frac{\sigma}{\sigma-1}}\\
 \text{s.t.}\\
p_{1}x_{1}+p_{2}x_{2} & \leq I,\,\,\,p_{1},p_{2},I>0\\
x_{1},x_{2} & \geq 0
\end{aligned}
$$

**Goal:** Create a model-class to solve this problem.

**Utility function:**

```python
def u_func(model,x1,x2):

    u_x1 = model.alpha**(1/model.sigma)*x1**((model.sigma-1)/model.sigma)
    u_x2 = (1-model.alpha)**(1/model.sigma)*x2**((model.sigma-1)/model.sigma)

    return (u_x1+u_x2)**(model.sigma/(model.sigma-1))
```

**Solution function:**

```python
def solve(model):

    # a. objective function (to minimize)
    obj = lambda x: -model.u_func(x[0],x[1]) # minimize -> negtive of utility

    # b. constraints and bounds
    budget_constraint = lambda x: model.I-model.p1*x[0]-model.p2*x[1] # violated if negative
    constraints = ({'type':'ineq','fun':budget_constraint})
    bounds = ((1e-8,model.I/model.p1-1e-8),(1e-8,model.I/model.p2-1e-8))

    # why all these 1e-8? To avoid ever having x1 = 0 or x2 = 0

    # c. call solver
    x0 = [(model.I/model.p1)/2,(model.I/model.p2)/2]
    sol = optimize.minimize(obj,x0,method='SLSQP',bounds=bounds,constraints=constraints)

    # d. save
    model.x1 = sol.x[0]
    model.x2 = sol.x[1]
    model.u = model.u_func(model.x1,model.x2)
```

**Create consumer class:**

```python
class ConsumerClass:

    def __init__(self):

        self.alpha = 0.5
        self.sigma = 0.1
        self.p1 = 1
        self.p2 = 2
        self.I = 10

    u_func = u_func
    solve = solve
```

**Solve consumer problem**:

```python
jeppe = ConsumerClass() # calls __init__()
jeppe.solve()
print(f'(x1,x2) = ({jeppe.x1:.3f},{jeppe.x2:.3f}), u = {jeppe.u:.3f}')
```

Easy to loop over:

```python
for alpha in np.linspace(0.1,0.9,9):
    jeppe.alpha = alpha
    jeppe.solve()
    print(f'alpha = {alpha:.3f} -> (x1,x2) = ({jeppe.x1:.3f},{jeppe.x2:.3f}), u = {jeppe.u:.3f}')
```

**Question:** Anything you want to test?

# 3. A worker-capitalist production economy

Consider an economy consisting of $N_w$ **workers**, and $N_c$ **capitalists** and a single **firm** owned equally by the capitalists.

**Workers:** Consume, $c_w$, at a price $p$, and supply labor, $\ell_w$, at a wage of $w$. Maximize utility:

$$\max_{c_w\geq0,\ell_w\in[0,1]} \log (c_w+\kappa)- \omega \ell_w^{\eta} \text{ s.t } pc_w \leq w \ell_w,\,\,\,\omega,\kappa > 0, \eta \geq 1$$

Equivalently, substituting in the budget constraint with equality:

$$\max_{\ell_w\in[0,1]} \log \left( \frac{w \ell_w}{p}+\kappa \right)- \omega \ell_w^{\eta}$$

Denote ***optimal behavior*** $c_w^{\star}(p,w)$ and $\ell_w^{\star}(p,w)$.

**Capitalists:** Consume, $c_c$, at a price $p$, supply labor, $\ell_c$, at a wage $w$, and receives profits $\pi$. Maximize utility:

$$\max_{c_c\geq0,\ell_c\in[0,1]} \log (c_c+\kappa) - \omega \ell_c^{\eta} \text{ s.t } pc_c = w \ell_c + \pi, ,\,\,\,\omega,\kappa > 0, \eta \geq 1$$

Equivalently, substituting in the budget constraint with equality:

$$\max_{\ell_c\in[0,1]} \log \left( \frac{w \ell_c + \pi}{p}+\kappa \right)- \omega \ell_c^{\eta}$$

Denote ***optimal behavior*** $c_c^{\star}(p,w,\pi)$ and $\ell_c^{\star}(p,w,\pi)$.

**Firm:** Use the production function $f(\ell) = \ell^\alpha, \alpha \in (0,1)$. Maximize profits:

$$\max_{\ell\geq0} p f(\ell) - w\ell $$

Denote ***optional behavior*** by $\ell^{\star}(p,w)$.

Implied ***production*** is $y^{\star}(p,w) = f(\ell^{\star}(p,w))$ and implied ***total profits*** are $\Pi^\star(p,w) = py^{\star}(p,w) - w\ell^{\star}(p,w)$

**Equilibrium:** A set of prices $(p,w)$ such that workers, capitalists and firms act optimally given prices and profit, and

1. **Goods market clears**: $N_w c_w^{\star}(p,w) + N_c c_c^{\star}(p,w,\pi) = y^\star(p,w)$
2. **Labor market clears**: $N_w \ell_w^{\star}(p,w) + N_c \ell_c^{\star}(p,w,\pi) = \ell^\star(p,w)$
3. **Profits received equal profits distributed**: $\pi = \frac{py^{\star}(p,w) - w\ell^{\star}(p,w)}{N_c}$

**Note I:** We can use $p=1$ as numeraire.

**Note II:** *Walras' Law* imply that if one of the markets clear, then the other one does too.

## 3.1 Parameters

Choose parameters:

```python
par = SimpleNamespace()
par.kappa = 0.1
par.omega = 10
par.eta = 1.50
par.alpha = 0.50
par.Nw = 99
par.Nc = 1
```

**SimpleNamespace():** Like a dictionary, but e.g. `par.kappa` instead of `par['kappa']`.

Can always be interfaced as a dictionary with `__dict__`:

```python
for k,v in par.__dict__.items():
    print(f'{k:6s} = {v:6.3f}')
```

## 3.2 Workers

```python
def utility_w(c,l,par):
    """ utility of workers """

    return np.log(c+par.kappa)-par.omega*l**par.eta

def workers(p,w,par):
    """ maximize utility for workers """

    # a. solve
    obj = lambda l: -utility_w((w*l)/p,l,par)
    res = optimize.minimize_scalar(obj,bounds=(0,1),method='bounded')

    # b. save
    l_w_star = res.x
    c_w_star = (w*l_w_star)/p

    return c_w_star,l_w_star
```

**Small test:**

```python
p = 1
for w in [0.5,1,1.5]:
    c,l = workers(p,w,par)
    print(f'w = {w:.2f} -> c = {c:.2f}, l = {l:.2f}')
```

## 3.3 Capitalists

```python
def utility_c(c,l,par):
    """ utility of capitalists """

    return np.log(c+par.kappa)-par.omega*l**par.eta

def capitalists(p,w,pi,par):
    """ maximize utility of capitalists """

    # a. solve
    obj = lambda l: -utility_c((w*l+pi)/p,l,par) # subsittute in the budget constraint
    res = optimize.minimize_scalar(obj,bounds=(0,1),method='bounded')

    # b. save
    l_c_star = res.x
    c_c_star = (w*l_c_star+pi)/p

    return c_c_star,l_c_star
```

**Small test:**

```python
p = 1
pi = 0.1
for w in [0.5,1,1.5]:
    c,l = capitalists(p,w,pi,par)
    print(f'w = {w:.2f} -> c = {c:.2f}, l = {l:.2f}')
```

**Question:** Any idea for another test?

## 3.4 Firm

```python
def firm(p,w,par):
    """ maximize firm profits """

    # a. solve
    f = lambda l: l**par.alpha
    obj = lambda l: -(p*f(l)-w*l)
    x0 = [0.0]
    res = optimize.minimize(obj,x0,bounds=((0,None),),method='L-BFGS-B')

    # b. save
    l_star = res.x[0]
    y_star = f(l_star)
    Pi = p*y_star - w*l_star

    return y_star,l_star,Pi
```

**Small test:**

```python
p = 1
for w in [0.5,1,1.5]:
    y,l,Pi = firm(p,w,par)
    print(f'w = {w:.2f} -> y = {y:.2f}, l = {l:.2f}, Pi = {Pi:.2f}')
```

## 3.5 Equilibrium

```python
def evaluate_equilibrium(w,par,p=None,do_print=False):
    """ evaluate equilirium """

    # a. normalize output price
    p = 1 if p is None else p

    # b. optimal behavior of firm
    y_star,l_star,Pi = firm(p,w,par)
    pi = Pi/par.Nc

    # c. optimal behavior of households
    c_w_star,l_w_star = workers(p,w,par)
    c_c_star,l_c_star = capitalists(p,w,pi,par)

    # d. market clearing
    goods_mkt_clearing = par.Nw*c_w_star + par.Nc*c_c_star - y_star
    labor_mkt_clearing = par.Nw*l_w_star + par.Nc*l_c_star - l_star

    if do_print:

        u_w = utility_w(c_w_star,l_w_star,par)
        print(f'workers      : c = {c_w_star:6.4f}, l = {l_w_star:6.4f}, u = {u_w:7.4f}')
        u_c = utility_c(c_c_star,l_c_star,par)
        print(f'capitalists  : c = {c_c_star:6.4f}, l = {l_c_star:6.4f}, u = {u_c:7.4f}')
        print(f'goods market : {goods_mkt_clearing:.8f}')
        print(f'labor market : {labor_mkt_clearing:.8f}')

    else:

        return goods_mkt_clearing

```

**Step 1:** Perform rough grid search to check when the goods market clears.

```python
num_w = 10
grid_w = np.linspace(0.1,1.5,num_w)
grid_mkt_clearing = np.zeros(num_w)

for i,w in enumerate(grid_w):
    grid_mkt_clearing[i] = evaluate_equilibrium(w,par)
    print(f'w = {w:.2f} -> excess demand = {grid_mkt_clearing[i]:12.8f}')
```

**Step 2:** Find where *excess demand* changes sign - the equilibrium price must be within this range

```python
left = np.max(grid_w[grid_mkt_clearing < 0])
right = np.min(grid_w[grid_mkt_clearing > 0])
print(f'equilibrium price must be in [{left:.2f},{right:.2f}]')
```

**Step 3:** Use equation-solver / root-finder

```python
res = optimize.root_scalar(evaluate_equilibrium,bracket=[left,right],method='bisect',args=(par,))
w_eq = res.root
print(f'the equilibrium wage is {w_eq:.4f}')
```

**Show details:**

```python
evaluate_equilibrium(w_eq,par,do_print=True)
```

**Check I:** Does both markets clear?

**Check II:** Can we multiply both prices with the same factor? I.e. can we change the numeraire?

```python
fac = 100
p_eq_ = fac*1.0
w_eq_ = fac*w_eq
evaluate_equilibrium(w_eq_,par,p=p_eq_,do_print=True)
```

## 3.6 Experiments

It is easy to extend this model in many directions:

1. Should workers and capitalists have different tastes or producitvity?
2. Should workers differ wrt. tastes or producitvity?
3. Should there be government redistribution?
4. Other ideas?

## 3.7 Using a class

```python
from WorkerCapitalistEconomy import WorkerCapitalistEconomyClass
```

**Look at `WorkerCapitalistEconomy.py`:** Same code, but written as a class!

```python
model = WorkerCapitalistEconomyClass()
print(model.par.kappa) # excess the class data with "".property"
```

```python
model.find_equilibrium()
```

**Benefit I:** Fewer inputs and outputs, less risk of wrong ordering.

**Benefit II of class-based solution:** Easy access to all data.
E.g. capitalists share of total consumption.

```python
C_w = model.par.Nw*model.c_w_star
C_c = model.par.Nc*model.c_c_star
print(f'capitalists share of total consumption is: {C_c/(C_c+C_w):.2f}')
```

**Benefit III of class-based solution:** Easy to experiment with different parameters.

```python
model.par.kappa = model.par.kappa/100 # lower kappa
model.find_equilibrium()
```

# 4. Inaugural project from last year (labor supply and taxation)

Consider a consumer solving the following maximization problem

$$\begin{eqnarray}
c^{\star},\ell^{\star} & = & \arg\max_{c,\ell}\log(c)-\nu\frac{\ell^{1+\frac{1}{\varepsilon}}}{1+\frac{1}{\varepsilon}}\\
 & \text{s.t.} \\
x & = & m+w\ell-\left[\tau_{0}w\ell+\tau_{1}\max\{w\ell-\kappa,0\}\right] \\
c & \in & [0,x] \\
\ell & \in & [0,1]
\end{eqnarray}$$

where $c$ is consumption, $\ell$ is labor supply, $m$ is cash-on-hand,
$w$ is the wage rate, $\tau_{0}$ is the standard labor income tax,
$\tau_{1}$ is the top bracket labor income tax, $\kappa$ is the
cut-off for the top labor income bracket, $x$ is total resources,
$\nu$ scales the disutility of labor, and $\varepsilon$ is the Frisch
elasticity of labor supply.

Note that utility is monotonically increasing in consumption. This implies that
$$\begin{equation}
c^{\star}=x
\end{equation}$$

**Question 1:** Construct a function which solves the consumer given the parameters.

We choose the following parameter values

$$
m=1,\,\nu=10,\,\varepsilon=0.3,\,\tau_{0}=0.4,\,\tau_{1}=0.1,\,\kappa=0.4
$$

**Question 2:** Plot $\ell^{\star}$ and $c^{\star}$ as functions of $w$ in
the range $0.5$ to $1.5$.

Consider a population with $N=1,000$ individuals indexed by $i$.

Assume the distribution of wages is uniform such that

$$w_{i}\sim\mathcal{U}(0.5,1.5).$$

Denote the optimal choices of individual $i$ by $\ell_{i}^{\star}$ and $c_{i}^{\star}$.

**Question 3:** Calculate the total tax revenue given by $T=\sum_{i=1}^{N}\left[\tau_{0}w_{i}\ell_{i}^{\star}+\tau_{1}\max\{w_{i}\ell_{i}^{\star}-\kappa,0\}\right].$

**Question 4:** What would the tax revenue be if instead $\varepsilon=0.1$?

Consider a politician who wishes to maximize the tax revenue.

**Question 5:** Which $\tau_{0}$, $\tau_{1}$ and $\kappa$ would you suggest her to implement? Report the tax revenue you expect to obtain.

## 4.1 Solution of question 1+2

All the basic functions are written in `LaborSupplyModel.py`.

```python
import LaborSupplyModel as LSM
```

Define all **parameters**:

```python
m = 1
nu = 10
frisch = 0.3
tau0 = 0.4
tau1 = 0.1
kappa = 0.4
```

**Allocate** arrays for solutions:

```python
N = 1_000
w_vec = np.linspace(0.5,1.5,N)
l_vec = np.zeros(N)
c_vec = np.zeros(N)
```

**Solve:**

```python
for i in range(N):
    l_vec[i] = LSM.find_optimal_labor_supply(nu,frisch,m,w_vec[i],tau0,tau1,kappa)
    c_vec[i] = LSM.implied_c(l_vec[i],m,w_vec[i],tau0,tau1,kappa)
```

**Plot results:**

```python
fig = plt.figure(figsize=(12,4))

ax = fig.add_subplot(1,2,1)
ax.plot(w_vec,l_vec,'-')
ax.set_ylabel('labor supply, $\ell$')
ax.set_xlabel('wage, $w$')
ax.set_title('Labor suppply')

ax = fig.add_subplot(1,2,2)
ax.plot(w_vec,c_vec,'-')
ax.set_ylabel('consumption, $c$')
ax.set_xlabel('consumption, $c$')
ax.set_title('Consumption');
```

## 4.2 Solution of question 3

Calculate **tax revnue** using that a equally spaced vector approximates a uniform distribution:

```python
T = np.sum(LSM.implied_tax(l_vec,w_vec,tau0,tau1,kappa))
print(f'total tax revenue is: {T:.4f}')
```

Using **random sampling** is also a possibility:

```python
# a. set seed
np.random.seed(1917)

# b. run replications
reps = 50
T_vec = np.zeros(reps)
for rep in range(reps):

    # i. draw randow wages
    w_vec_ = np.random.uniform(0.5,1.5,size=N)

    # ii. find labor supply
    l_vec_ = np.zeros(N)
    for i in range(N):
        l_vec_[i] = LSM.find_optimal_labor_supply(nu,frisch,m,w_vec_[i],tau0,tau1,kappa)

    # iii. find tax revenue
    T_vec[rep] = np.sum(LSM.implied_tax(l_vec_,w_vec_,tau0,tau1,kappa))

    if rep < 10 or rep%10 == 0:
        print(f'{rep:2d}: {T_vec[rep]:.4f}')

# c. mean
print(f'mean: {np.mean(T_vec):.4f} [{np.min(T_vec):.4f} {np.max(T_vec):.4f}]')
```

## 4.3 Question 4

**Re-solve** with $\epsilon = 0.1$:

```python
frisch_low = 0.1
l_vec_frisch_low = np.zeros(N)
for i in range(N):
    l_vec_frisch_low[i] = LSM.find_optimal_labor_supply(nu,frisch_low,m,w_vec[i],tau0,tau1,kappa)
```

Re-calculate **tax revenue**:

```python
T_frisch_low = np.sum(LSM.implied_tax(l_vec_frisch_low,w_vec,tau0,tau1,kappa))
print(f'total tax revenue is: {T_frisch_low:.4f}')
```

**Conclusion:** Higher tax revenue because of lower Frish elasticity.

## 4.4 Question 5

Define function to calculate **tax revenue for guess of tax parameters**:

```python
def tax_revenue(nu,frisch,m,w_vec,tau0,tau1,kappa):
    """ find total tax revenue and labor and consumpty

    Args:

        nu (float): disutility of labor supply
        frisch (float): frisch elasticity of labor supply
        m (float): cash-on-hand
        w_vec (np.array): wage
        tau0 (float): standard labor tax
        tau1 (float): top bracket labor income tax
        kappa (float): cut-off for the top labor income bracket

    Returns:

        (float): total tax revenue

    """

    # a. optimal labor supply
    N = w_vec.size
    l_vec = np.zeros(N)
    for i in range(N):
        l_vec[i] = LSM.find_optimal_labor_supply(nu,frisch,m,w_vec[i],tau0,tau1,kappa)

    # b. taxes
    T = np.sum(LSM.implied_tax(l_vec,w_vec,tau0,tau1,kappa))

    return T
```

Define **objective function for optimizer**:

```python
def obj(x,nu,frisch_low,m,w_vec):
    """ find negative of total tax revenue

    Args:

        x (np.array): tax parameters
        nu (float): disutility of labor supply
        frisch (float): frisch elasticity of labor supply
        m (float): cash-on-hand
        w_vec (np.array): wage

    Returns:

        (float): minus total tax revenue

    """

    global it

    # a. determine parameters
    tau0 = x[0]
    if x.size > 1:
        tau1 = x[1]
        kappa = x[2]
    else:
        tau1 = 0.0
        kappa = 0.0

    # b. calculate tax revnue
    T = tax_revenue(nu,frisch_low,m,w_vec,tau0,tau1,kappa)

    # c. print
    print(f'{it:3d}: tau0 = {tau0:10.8f}, tau1 = {tau1:10.8f}, kappa = {kappa:10.8f} -> T = {T:12.8f},')

    it += 1

    return -T
```

**Solve:**

```python
# a. initial guess and bounds
x0 = np.array([tau0,tau1,kappa])
bounds = ((0,0.99),(0,0.99),(0,1.5))

# b. call solver
it = 0
result = optimize.minimize(obj,x0,
    method='SLSQP',bounds=bounds,
    args=(nu,frisch,m,w_vec)
)
```

**Have we found the global optimum?**

**Same result with another initial guess?**

```python
# a. initial guess and bounds
x0 = np.array([0.1,0.1,0.1])
bounds = ((0,0.99),(0,0.99),(0,1.5))

# b. call solver
it = 0
result = optimize.minimize(obj,x0,
    method='SLSQP',bounds=bounds,
    args=(nu,frisch,m,w_vec)
)
```

**Can we improve if we force $\tau_1 = \kappa = 0$?**

```python
# a. initial guess and bounds
x0 = np.array([result.x[0]])
bounds = ((0,0.99),)

# b. call solver
it = 0
result = optimize.minimize(obj,x0,
    method='SLSQP',bounds=bounds,
    args=(nu,frisch,m,w_vec)
)
```

**Can we improve if fix $\kappa$ to some value?**

```python
def obj_kappa(x,nu,frisch_low,m,w_vec,kappa):
    """ find negative of total tax revenue

    Args:

        x (np.array): tax parameters
        nu (float): disutility of labor supply
        frisch (float): frisch elasticity of labor supply
        m (float): cash-on-hand
        w_vec (np.array): wage
        kappa (float): cut-off for the top labor income bracket

    Returns:

        (float): minus total tax revenue

    """

    global it

    # a. determine parameters
    tau0 = x[0]
    tau1 = x[1]

    # b. calculate tax revnue
    T = tax_revenue(nu,frisch_low,m,w_vec,tau0,tau1,kappa)

    # c. print
    print(f' {it:3d}: tau0 = {tau0:10.8f}, tau1 = {tau1:10.8f} -> T = {T:12.8f},')

    it += 1

    return -T
```

```python
# a. initial guess and bounds
x0 = np.array([0.1,0.1])
bounds = ((0,0.99),(0,0.99))

# b. call solver
for kappa in [0.05,0.1,0.15]:

    print(f'kappa = {kappa:.3f}')
    it = 0
    result = optimize.minimize(obj_kappa,x0,
        method='SLSQP',bounds=bounds,
        args=(nu,frisch,m,w_vec,kappa)
    )
    print('')

```

**Suggestions for other tests?**

# 5. Summary

1. **Main takeway:** You are actually already equipped to solve a lot of interesting economic models.
2. **Next time:** Pandas, the central Python package for working with data.


# Quantum codes do not fix qubit independent errors

#### J. Lacalle(1), L.M. Pozo-Coronado(2), A.L. Fonseca de Oliveira(3), R. Martín-Cuevas(4)

(1) Universidad Politécnica de Madrid, email: jesus.glopezdelacalle@upm.es

(2) Universidad Politécnica de Madrid, email: lm.pozo@upm.es

(3) Universidad ORT, Montevideo, Uruguay, email: fonseca@ort.edu.uy

(4) Universidad Politécnica de Madrid, email: r.martin-cuevas@alumnos.upm.es

January 5, 2021

This notebook complements the homonymous article written by the authors, by proving several claims made in the article with the assistance of a symbolic calculus library for Python.

First of all, we will import the required libraries and set up a few basic variables and tools.

```python
from sympy import symbols, Symbol, Matrix, I, pprint, diff, re, im, expand, degree
from sympy.core.expr import Expr
from sympy.physics.quantum import Dagger
from sympy.physics.quantum.tensorproduct import TensorProduct
from itertools import combinations

num_qubits = 5

def intersection(a: list, b: list) -> list:
    """
    Returns the intersection of two lists: a new list containing the elements
    that appeared in both input lists.

    :param a: First list to consider.
    :param b: Second list to consider.
    :result: Resulting list.
    """
    result = []

    for a_i in a:
        if a_i in b:
            result.append(a_i)

    return result
```

## 1. Finding the basis associated with the 5-qubit quantum code

We will start this section by defining the generators used by the 5-qubit quantum code $C$. As we will be using the string themselves to perform calculations on the quantum states, the Dirac notation is avoided when defining these variables.

```python
L0 = Symbol('00000') - Symbol('00011') + Symbol('00101') - Symbol('00110') + \
     Symbol('01001') + Symbol('01010') - Symbol('01100') - Symbol('01111') - \
     Symbol('10001') + Symbol('10010') + Symbol('10100') - Symbol('10111') - \
     Symbol('11000') - Symbol('11011') - Symbol('11101') - Symbol('11110')

L1 = - Symbol('00001') - Symbol('00010') - Symbol('00100') - Symbol('00111') - \
     Symbol('01000') + Symbol('01011') + Symbol('01101') - Symbol('01110') - \
     Symbol('10000') - Symbol('10011') + Symbol('10101') + Symbol('10110') - \
     Symbol('11001') + Symbol('11010') - Symbol('11100') + Symbol('11111')
```

We will now define the Pauli matrices that will be used later on to construct the different discrete errors.

```python
X = Matrix([[0, 1], [1, 0]])
Y = Matrix([[0, -I], [I, 0]])
Z = Matrix([[1, 0], [0, -1]])
```

The computational basis ($B_c$) can be defined as follows. We will also define a method to build any binary string, from the non-negative integer in decimal basis that it should represent.

```python
def convert_to_binary_string(i: int, num_qubits: int) -> Symbol:
    """
    This method returns the given non-negative integer in decimal basis, expressed as
    a binary string with as many digits as required by the second parameter.

    :param i: Non-negative integer in decimal basis.
    :param num_bits: Length of the resulting binary string.
    :return: Sympy symbol containing the desired string as name.
    """
    binary_string = str(bin(i)[2:])
    binary_string = ''.join(['0' for _ in range(num_qubits - len(binary_string))]) + binary_string
    return Symbol(binary_string)

B_c = []  # Computational basis: [00000, 00001, ..., 11111].
for i in range(2 ** num_qubits):
    B_c.append(convert_to_binary_string(i, num_qubits))
print('B_c:', B_c)
```

    B_c: [00000, 00001, 00010, 00011, 00100, 00101, 00110, 00111, 01000, 01001, 01010, 01011, 01100, 01101, 01110, 01111, 10000, 10001, 10010, 10011, 10100, 10101, 10110, 10111, 11000, 11001, 11010, 11011, 11100, 11101, 11110, 11111]

The basis associated with the quantum code $C$ ($B$) can be defined as follows, using the Pauli matrices, and aplying them on all the different qubits of each of the quantum states defined as generators, to obtain:
$B = [|0_L\rangle, |1_L\rangle, X_0|0_L\rangle, X_0|1_L\rangle, X_1|0_L\rangle, X_1|1_L\rangle, ..., Z_4|0_L\rangle, Z_4|1_L\rangle]$

We need to define an auxiliary method to apply 1-qubit operation to quantum states, using its string representation to perform the calculation. We will also need another method to obtain which new states could be obtained if we alter one bit of an incoming binary string.

```python
def apply_1qubit_operation(operation: Matrix, target_qubit: int, initial_state: Expr, num_qubits: int) -> Expr:
    """
    This method applies the specified 1-qubit operation to the initial state, on the desired
    target qubit, and returns the resulting quantum state.

    :param operation: 2x2 matrix to be applied.
    :param target_qubit: Qubit on which it should be applied, one of {0, 1, ..., num_qubits - 1}.
    :param initial_state: Initial quantum state, as an expression in terms of elements of the
        computational basis, expressed as binary strings.
    :param num_qubits: Number of qubits of the quantum state.
    :return: New quantum state after applying the operation.
    """

    final_state = 0

    for i in range(2 ** num_qubits):
        index = convert_to_binary_string(i, num_qubits)

        alpha = initial_state.coeff(index)
        state_0, state_1 = get_basis_states_one_bit_apart(index, target_qubit)

        initial_qubit_state = Matrix([[1], [0]]) if index.name[target_qubit] == '0' else Matrix([[0], [1]])
        final_qubit_state = operation * initial_qubit_state

        final_state += alpha * (final_qubit_state[0] * state_0 + final_qubit_state[1] * state_1)

    return final_state

def get_basis_states_one_bit_apart(reference_state: Symbol, target: int) -> tuple:
    """
    Method used to get the two binary strings that would be obtained if we modify any
    one bit of an incoming binary string. E.g.: if we receive the string 00000 and the
    integer '1' as target, the two possible outputs will be 00000 and 01000.

    :param reference_state: Incoming binary string.
    :param target: Bit that would be altered, one of {0, 1, ..., length - 1}.
    :return: Tuple with the new two binary strings.
    """
    reference_state = reference_state.name

    return Symbol(reference_state[:target] + '0' + reference_state[target + 1:]), \
           Symbol(reference_state[:target] + '1' + reference_state[target + 1:])

B = [L0, L1]  # Basis associated with the code C.
for operator in [X, Y, Z]:
    for u in range(num_qubits):
        for generator in [L0, L1]:
            B.append(
                apply_1qubit_operation(operator, u, generator, num_qubits)
            )
print('B:', B)
```

    B: [00000 - 00011 + 00101 - 00110 + 01001 + 01010 - 01100 - 01111 - 10001 + 10010 + 10100 - 10111 - 11000 - 11011 - 11101 - 11110, -00001 - 00010 - 00100 - 00111 - 01000 + 01011 + 01101 - 01110 - 10000 - 10011 + 10101 + 10110 - 11001 + 11010 - 11100 + 11111, -00001 + 00010 + 00100 - 00111 - 01000 - 01011 - 01101 - 01110 + 10000 - 10011 + 10101 - 10110 + 11001 + 11010 - 11100 - 11111, -00000 - 00011 + 00101 + 00110 - 01001 + 01010 - 01100 + 01111 - 10001 - 10010 - 10100 - 10111 - 11000 + 11011 + 11101 - 11110, 00001 + 00010 - 00100 - 00111 + 01000 - 01011 + 01101 - 01110 - 10000 - 10011 - 10101 - 10110 - 11001 + 11010 + 11100 - 11111, -00000 + 00011 + 00101 - 00110 - 01001 - 01010 - 01100 - 01111 - 10001 + 10010 - 10100 + 10111 - 11000 - 11011 + 11101 + 11110, 00001 - 00010 + 00100 - 00111 - 01000 - 01011 + 01101 + 01110 + 10000 - 10011 - 10101 + 10110 - 11001 - 11010 - 11100 - 11111, -00000 - 00011 - 00101 - 00110 + 01001 - 01010 - 01100 + 01111 + 10001 + 10010 - 10100 - 10111 - 11000 + 11011 - 11101 + 11110, -00001 + 00010 - 00100 + 00111 + 01000 + 01011 - 01101 - 01110 + 10000 - 10011 - 10101 + 10110 - 11001 - 11010 - 11100 - 11111, -00000 - 00011 - 00101 - 00110 + 01001 - 01010 - 01100 + 01111 - 10001 - 10010 + 10100 + 10111 + 11000 - 11011 + 11101 - 11110, 00001 - 00010 + 00100 - 00111 + 01000 + 01011 - 01101 - 01110 - 10000 + 10011 + 10101 - 10110 - 11001 - 11010 - 11100 - 11111, -00000 - 00011 - 00101 - 00110 - 01001 + 01010 + 01100 - 01111 - 10001 - 10010 + 10100 + 10111 - 11000 + 11011 - 11101 + 11110, I*00001 - I*00010 - I*00100 + I*00111 + I*01000 + I*01011 + I*01101 + I*01110 + I*10000 - I*10011 + I*10101 - I*10110 + I*11001 + I*11010 - I*11100 - I*11111, I*00000 + I*00011 - I*00101 - I*00110 + I*01001 - I*01010 + I*01100 - I*01111 - I*10001 - I*10010 - I*10100 - I*10111 - I*11000 + I*11011 + I*11101 - I*11110, -I*00001 - I*00010 + I*00100 + I*00111 + I*01000 - I*01011 + I*01101 - I*01110 + I*10000 + I*10011 + I*10101 + I*10110 - I*11001 + I*11010 + I*11100 - I*11111, I*00000 - I*00011 - I*00101 + I*00110 - I*01001 - I*01010 - I*01100 - I*01111 + I*10001 - I*10010 + I*10100 - I*10111 - I*11000 - I*11011 + I*11101 + I*11110, -I*00001 + I*00010 + I*00100 - I*00111 + I*01000 + I*01011 + I*01101 + I*01110 - I*10000 + I*10011 - I*10101 + I*10110 + I*11001 + I*11010 - I*11100 - I*11111, I*00000 + I*00011 - I*00101 - I*00110 - I*01001 + I*01010 - I*01100 + I*01111 - I*10001 - I*10010 - I*10100 - I*10111 + I*11000 - I*11011 - I*11101 + I*11110, I*00001 + I*00010 + I*00100 + I*00111 - I*01000 + I*01011 + I*01101 - I*01110 - I*10000 - I*10011 + I*10101 + I*10110 + I*11001 - I*11010 + I*11100 - I*11111, I*00000 - I*00011 + I*00101 - I*00110 - I*01001 - I*01010 + I*01100 + I*01111 + I*10001 - I*10010 - I*10100 + I*10111 - I*11000 - I*11011 - I*11101 - I*11110, I*00001 + I*00010 - I*00100 - I*00111 - I*01000 + I*01011 - I*01101 + I*01110 + I*10000 + I*10011 + I*10101 + I*10110 - I*11001 + I*11010 + I*11100 - I*11111, I*00000 - I*00011 - I*00101 + I*00110 - I*01001 - I*01010 - I*01100 - I*01111 - I*10001 + I*10010 - I*10100 + I*10111 + I*11000 + I*11011 - I*11101 - I*11110, 00000 - 00011 + 00101 - 00110 + 01001 + 01010 - 01100 - 01111 + 10001 - 10010 - 10100 + 10111 + 11000 + 11011 + 11101 + 11110, -00001 - 00010 - 00100 - 00111 - 01000 + 01011 + 01101 - 01110 + 10000 + 10011 - 10101 - 10110 + 11001 - 11010 + 11100 - 11111, 00000 - 00011 + 00101 - 00110 - 01001 - 01010 + 01100 + 01111 - 10001 + 10010 + 10100 - 10111 + 11000 + 11011 + 11101 + 11110, -00001 - 00010 - 00100 - 00111 + 01000 - 01011 - 01101 + 01110 - 10000 - 10011 + 10101 + 10110 + 11001 - 11010 + 11100 - 11111, 00000 - 00011 - 00101 + 00110 + 01001 + 01010 + 01100 + 01111 - 10001 + 10010 - 10100 + 10111 - 11000 - 11011 + 11101 + 11110, -00001 - 00010 + 00100 + 00111 - 01000 + 01011 - 01101 + 01110 - 10000 - 10011 - 10101 - 10110 - 11001 + 11010 + 11100 - 11111, 00000 + 00011 + 00101 + 00110 + 01001 - 01010 - 01100 + 01111 - 10001 - 10010 + 10100 + 10111 - 11000 + 11011 - 11101 + 11110, -00001 + 00010 - 00100 + 00111 - 01000 - 01011 + 01101 + 01110 - 10000 + 10011 + 10101 - 10110 - 11001 - 11010 - 11100 - 11111, 00000 + 00011 - 00101 - 00110 - 01001 + 01010 - 01100 + 01111 + 10001 + 10010 + 10100 + 10111 - 11000 + 11011 + 11101 - 11110, 00001 - 00010 - 00100 + 00111 - 01000 - 01011 - 01101 - 01110 - 10000 + 10011 - 10101 + 10110 + 11001 + 11010 - 11100 - 11111]

## 2. Finding the change of basis matrices

Now we will define the matrix $M$ used to transform from basis $B$ to basis $B_c$. We will define it in two ways, firstly by putting the vectors from $B$, expressed in tearms of $B_c$, as columns. Then, we will define it by using derivatives. After that, we will ensure that we can get all vectors of the base $B$ expressed in the computational basis $B_c$ and that matches the expression of the basis $B$ obtained earlier, and also that both matrices $M$ are equal, as confirmations.

```python
M = Matrix([
    [
        B[column].coeff(convert_to_binary_string(row, num_qubits))
        for column in range(2 ** num_qubits)
    ] for row in range(2 ** num_qubits)
])
```

```python
M2 = Matrix([
    [
        diff(B[j], B_c[i]) for j in range(2 ** num_qubits)
    ] for i in range(2 ** num_qubits)
])
```

```python
def get_computational_basis_vector_state(i: int, num_qubits: int) -> Matrix:
    """
    This methods returns a column vector that expresses the quantum state from the
    computational basis expressed by the integer 'i', for a quantum state with a total
    of 'num_qubits' qubits.

    :param i: Non-negative integer in decimal basis.
    :param num_qubits: Number of qubits of the resulting quantum state.
    :return: Sympy matrix containing the quantum state as a column vector.
    """
    return Matrix([
        [
            1 if i == j else 0
        ] for j in range(2 ** num_qubits)
    ])

def transform_vector_state_to_expression_state(state: Matrix, num_qubits: int) -> Expr:
    """
    This method converts a column vector to an expression in terms of the states of
    the corresponding basis, as binary strings, to be used in methods that use these
    binary strings to perform calculations.

    :param state: Matrix containing a quantum state expressed as a column vector.
    :param num_qubits: Number of qubits of the quantum state.
    :return: Quantum state, as expression with its variables as binary strings.
    """

    result = 0

    for j in range(state.shape[0]):
        binary_string = convert_to_binary_string(j, num_qubits)
        result += state[j, 0] * binary_string

    return result

assert 0 == sum(M - M2)
for i in range(2 ** num_qubits):
    state_B_vector = M * get_computational_basis_vector_state(i, num_qubits)
    state_B_c_expression = transform_vector_state_to_expression_state(state_B_vector, num_qubits)
    assert state_B_c_expression == B[i]  # The program will fail if the assertion is false.
print('Successfully proven that M transforms vectors from B to B_c.')

print('Shape of M == M2:', M.shape)
#pprint(M)
```

    Successfully proven that M transforms vectors from B to B_c.
    Shape of M == M2: (32, 32)

## 3. Finding the unitary error operators

Now we will define the qubit errors $W_u$ in terms of the coefficients $A_u$, $B_u$, $C_u$ and $D_u$.

```python
A = symbols('A_0:%d' % num_qubits)  # Define A_0 through A_4.
B = symbols('B_0:%d' % num_qubits)  # Define B_0 through B_4.
C = symbols('C_0:%d' % num_qubits)  # Define C_0 through C_4.
D = symbols('D_0:%d' % num_qubits)  # Define D_0 through D_4.
V = A + B + C + D

W_u = []
for u in range(num_qubits):  # Define W_0 through W_4.
    W_u.append(Matrix([
        [A[u] + I * B[u], -C[u] + I * D[u]],
        [C[u] + I * D[u], A[u] - I * B[u]]
    ]))
    print('W[%d]:' % u, W_u[u])
```

    W[0]: Matrix([[A_0 + I*B_0, -C_0 + I*D_0], [C_0 + I*D_0, A_0 - I*B_0]])
    W[1]: Matrix([[A_1 + I*B_1, -C_1 + I*D_1], [C_1 + I*D_1, A_1 - I*B_1]])
    W[2]: Matrix([[A_2 + I*B_2, -C_2 + I*D_2], [C_2 + I*D_2, A_2 - I*B_2]])
    W[3]: Matrix([[A_3 + I*B_3, -C_3 + I*D_3], [C_3 + I*D_3, A_3 - I*B_3]])
    W[4]: Matrix([[A_4 + I*B_4, -C_4 + I*D_4], [C_4 + I*D_4, A_4 - I*B_4]])

With this, we can express the unitary error operator for independent qubit errors $W$, and that same operator in the base $B$, $W_B$:

```python
W = TensorProduct(W_u[0], W_u[1], W_u[2], W_u[3], W_u[4])
print('Shape of W:', W.shape)
#pprint(W)
```

    Shape of W: (32, 32)

```python
W_B = Dagger(M) * W * M
print('Shape of W_B:', W.shape)
print('W[0][0] =', expand(W_B[0, 0]))
```

    Shape of W_B: (32, 32)
    W[0][0] = 16*A_0*A_1*A_2*A_3*A_4 + 16*I*A_0*A_1*B_3*C_2*C_4 + 16*I*A_0*A_2*B_1*D_3*D_4 + 16*I*A_0*A_3*B_4*D_1*D_2 + 16*I*A_0*A_4*B_2*C_1*C_3 + 16*A_0*B_1*B_4*C_2*C_3 + 16*A_0*B_2*B_3*D_1*D_4 + 16*A_0*C_1*C_4*D_2*D_3 + 16*I*A_1*A_2*B_4*C_0*C_3 + 16*I*A_1*A_3*B_2*D_0*D_4 + 16*I*A_1*A_4*B_0*D_2*D_3 + 16*A_1*B_0*B_2*C_3*C_4 + 16*A_1*B_3*B_4*D_0*D_2 + 16*A_1*C_0*C_2*D_3*D_4 + 16*I*A_2*A_3*B_0*C_1*C_4 + 16*I*A_2*A_4*B_3*D_0*D_1 + 16*A_2*B_0*B_4*D_1*D_3 + 16*A_2*B_1*B_3*C_0*C_4 + 16*A_2*C_1*C_3*D_0*D_4 + 16*I*A_3*A_4*B_1*C_0*C_2 + 16*A_3*B_0*B_1*D_2*D_4 + 16*A_3*B_2*B_4*C_0*C_1 + 16*A_3*C_2*C_4*D_0*D_1 + 16*A_4*B_0*B_3*C_1*C_2 + 16*A_4*B_1*B_2*D_0*D_3 + 16*A_4*C_0*C_3*D_1*D_2 + 16*I*B_0*B_1*B_2*B_3*B_4 + 16*I*B_0*C_2*C_3*D_1*D_4 + 16*I*B_1*C_3*C_4*D_0*D_2 + 16*I*B_2*C_0*C_4*D_1*D_3 + 16*I*B_3*C_0*C_1*D_2*D_4 + 16*I*B_4*C_1*C_2*D_0*D_3

As discussed in the article, each of the entries in matrix $W_B$ is a homogeneous polynomial of degree 5 in the set of variables $V = \{A_u, B_u, C_u, D_u | 0 \le u \lt 5\}$, and degree 1 in the subsets of variables of $V$ with subscript $u$ for all $0 \le u \lt 5$. Each of this polynomials includes exactly 32 monomials.

## 4. Disturbing a quantum state

We can define a generic quantum state in basis $B$, $\phi_B$ (phi_B), as follows:

```python
w = symbols('w_0:%d' % 4)  # Define w_0 through w_3.

phi_B = Matrix([
    [w[0] + I * w[1]],
    [w[2] + I * w[3]]
] + [
    [0]
    for _ in range(2 ** num_qubits - 2)
])
print('Shape of phi_B:', phi_B.shape)
print(phi_B)
```

    Shape of phi_B: (32, 1)
    Matrix([[w_0 + I*w_1], [w_2 + I*w_3], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0], [0]])

The disturbed quantum state $\psi_B$ (psi_B) is obtained by multiplying $\phi_B$ by the error operator $W_B$:

```python
psi_B = W_B * phi_B
print('Shape of psi_B:', psi_B.shape)
print('psi_B[0] = beta_0 =', expand(psi_B[0, 0]))
```

    Shape of psi_B: (32, 1)
    psi_B[0] = beta_0 = 16*A_0*A_1*A_2*A_3*A_4*w_0 + 16*I*A_0*A_1*A_2*A_3*A_4*w_1 + 16*I*A_0*A_1*B_2*B_4*D_3*w_2 - 16*A_0*A_1*B_2*B_4*D_3*w_3 + 16*I*A_0*A_1*B_3*C_2*C_4*w_0 - 16*A_0*A_1*B_3*C_2*C_4*w_1 - 16*A_0*A_1*C_3*D_2*D_4*w_2 - 16*I*A_0*A_1*C_3*D_2*D_4*w_3 + 16*I*A_0*A_2*B_1*D_3*D_4*w_0 - 16*A_0*A_2*B_1*D_3*D_4*w_1 - 16*A_0*A_2*B_3*B_4*C_1*w_2 - 16*I*A_0*A_2*B_3*B_4*C_1*w_3 + 16*I*A_0*A_2*C_3*C_4*D_1*w_2 - 16*A_0*A_2*C_3*C_4*D_1*w_3 - 16*A_0*A_3*B_1*B_2*C_4*w_2 - 16*I*A_0*A_3*B_1*B_2*C_4*w_3 + 16*I*A_0*A_3*B_4*D_1*D_2*w_0 - 16*A_0*A_3*B_4*D_1*D_2*w_1 + 16*I*A_0*A_3*C_1*C_2*D_4*w_2 - 16*A_0*A_3*C_1*C_2*D_4*w_3 + 16*I*A_0*A_4*B_1*B_3*D_2*w_2 - 16*A_0*A_4*B_1*B_3*D_2*w_3 + 16*I*A_0*A_4*B_2*C_1*C_3*w_0 - 16*A_0*A_4*B_2*C_1*C_3*w_1 - 16*A_0*A_4*C_2*D_1*D_3*w_2 - 16*I*A_0*A_4*C_2*D_1*D_3*w_3 + 16*A_0*B_1*B_4*C_2*C_3*w_0 + 16*I*A_0*B_1*B_4*C_2*C_3*w_1 + 16*A_0*B_2*B_3*D_1*D_4*w_0 + 16*I*A_0*B_2*B_3*D_1*D_4*w_1 + 16*A_0*C_1*C_4*D_2*D_3*w_0 + 16*I*A_0*C_1*C_4*D_2*D_3*w_1 + 16*I*A_1*A_2*B_0*B_3*D_4*w_2 - 16*A_1*A_2*B_0*B_3*D_4*w_3 + 16*I*A_1*A_2*B_4*C_0*C_3*w_0 - 16*A_1*A_2*B_4*C_0*C_3*w_1 - 16*A_1*A_2*C_4*D_0*D_3*w_2 - 16*I*A_1*A_2*C_4*D_0*D_3*w_3 - 16*A_1*A_3*B_0*B_4*C_2*w_2 - 16*I*A_1*A_3*B_0*B_4*C_2*w_3 + 16*I*A_1*A_3*B_2*D_0*D_4*w_0 - 16*A_1*A_3*B_2*D_0*D_4*w_1 + 16*I*A_1*A_3*C_0*C_4*D_2*w_2 - 16*A_1*A_3*C_0*C_4*D_2*w_3 + 16*I*A_1*A_4*B_0*D_2*D_3*w_0 - 16*A_1*A_4*B_0*D_2*D_3*w_1 - 16*A_1*A_4*B_2*B_3*C_0*w_2 - 16*I*A_1*A_4*B_2*B_3*C_0*w_3 + 16*I*A_1*A_4*C_2*C_3*D_0*w_2 - 16*A_1*A_4*C_2*C_3*D_0*w_3 + 16*A_1*B_0*B_2*C_3*C_4*w_0 + 16*I*A_1*B_0*B_2*C_3*C_4*w_1 + 16*A_1*B_3*B_4*D_0*D_2*w_0 + 16*I*A_1*B_3*B_4*D_0*D_2*w_1 + 16*A_1*C_0*C_2*D_3*D_4*w_0 + 16*I*A_1*C_0*C_2*D_3*D_4*w_1 + 16*I*A_2*A_3*B_0*C_1*C_4*w_0 - 16*A_2*A_3*B_0*C_1*C_4*w_1 + 16*I*A_2*A_3*B_1*B_4*D_0*w_2 - 16*A_2*A_3*B_1*B_4*D_0*w_3 - 16*A_2*A_3*C_0*D_1*D_4*w_2 - 16*I*A_2*A_3*C_0*D_1*D_4*w_3 - 16*A_2*A_4*B_0*B_1*C_3*w_2 - 16*I*A_2*A_4*B_0*B_1*C_3*w_3 + 16*I*A_2*A_4*B_3*D_0*D_1*w_0 - 16*A_2*A_4*B_3*D_0*D_1*w_1 + 16*I*A_2*A_4*C_0*C_1*D_3*w_2 - 16*A_2*A_4*C_0*C_1*D_3*w_3 + 16*A_2*B_0*B_4*D_1*D_3*w_0 + 16*I*A_2*B_0*B_4*D_1*D_3*w_1 + 16*A_2*B_1*B_3*C_0*C_4*w_0 + 16*I*A_2*B_1*B_3*C_0*C_4*w_1 + 16*A_2*C_1*C_3*D_0*D_4*w_0 + 16*I*A_2*C_1*C_3*D_0*D_4*w_1 + 16*I*A_3*A_4*B_0*B_2*D_1*w_2 - 16*A_3*A_4*B_0*B_2*D_1*w_3 + 16*I*A_3*A_4*B_1*C_0*C_2*w_0 - 16*A_3*A_4*B_1*C_0*C_2*w_1 - 16*A_3*A_4*C_1*D_0*D_2*w_2 - 16*I*A_3*A_4*C_1*D_0*D_2*w_3 + 16*A_3*B_0*B_1*D_2*D_4*w_0 + 16*I*A_3*B_0*B_1*D_2*D_4*w_1 + 16*A_3*B_2*B_4*C_0*C_1*w_0 + 16*I*A_3*B_2*B_4*C_0*C_1*w_1 + 16*A_3*C_2*C_4*D_0*D_1*w_0 + 16*I*A_3*C_2*C_4*D_0*D_1*w_1 + 16*A_4*B_0*B_3*C_1*C_2*w_0 + 16*I*A_4*B_0*B_3*C_1*C_2*w_1 + 16*A_4*B_1*B_2*D_0*D_3*w_0 + 16*I*A_4*B_1*B_2*D_0*D_3*w_1 + 16*A_4*C_0*C_3*D_1*D_2*w_0 + 16*I*A_4*C_0*C_3*D_1*D_2*w_1 + 16*I*B_0*B_1*B_2*B_3*B_4*w_0 - 16*B_0*B_1*B_2*B_3*B_4*w_1 + 16*I*B_0*B_1*C_2*C_4*D_3*w_2 - 16*B_0*B_1*C_2*C_4*D_3*w_3 - 16*B_0*B_2*C_1*D_3*D_4*w_2 - 16*I*B_0*B_2*C_1*D_3*D_4*w_3 - 16*B_0*B_3*C_4*D_1*D_2*w_2 - 16*I*B_0*B_3*C_4*D_1*D_2*w_3 + 16*I*B_0*B_4*C_1*C_3*D_2*w_2 - 16*B_0*B_4*C_1*C_3*D_2*w_3 + 16*I*B_0*C_2*C_3*D_1*D_4*w_0 - 16*B_0*C_2*C_3*D_1*D_4*w_1 + 16*I*B_1*B_2*C_0*C_3*D_4*w_2 - 16*B_1*B_2*C_0*C_3*D_4*w_3 - 16*B_1*B_3*C_2*D_0*D_4*w_2 - 16*I*B_1*B_3*C_2*D_0*D_4*w_3 - 16*B_1*B_4*C_0*D_2*D_3*w_2 - 16*I*B_1*B_4*C_0*D_2*D_3*w_3 + 16*I*B_1*C_3*C_4*D_0*D_2*w_0 - 16*B_1*C_3*C_4*D_0*D_2*w_1 + 16*I*B_2*B_3*C_1*C_4*D_0*w_2 - 16*B_2*B_3*C_1*C_4*D_0*w_3 - 16*B_2*B_4*C_3*D_0*D_1*w_2 - 16*I*B_2*B_4*C_3*D_0*D_1*w_3 + 16*I*B_2*C_0*C_4*D_1*D_3*w_0 - 16*B_2*C_0*C_4*D_1*D_3*w_1 + 16*I*B_3*B_4*C_0*C_2*D_1*w_2 - 16*B_3*B_4*C_0*C_2*D_1*w_3 + 16*I*B_3*C_0*C_1*D_2*D_4*w_0 - 16*B_3*C_0*C_1*D_2*D_4*w_1 + 16*I*B_4*C_1*C_2*D_0*D_3*w_0 - 16*B_4*C_1*C_2*D_0*D_3*w_1 - 16*C_0*C_1*C_2*C_3*C_4*w_2 - 16*I*C_0*C_1*C_2*C_3*C_4*w_3 + 16*I*D_0*D_1*D_2*D_3*D_4*w_2 - 16*D_0*D_1*D_2*D_3*D_4*w_3

The coordinates of $\psi_B$ are homogeneous polynomials of degree 5 in the set of variables $V$, degree 1 in the subsets of variables of $V$ with subscript $u$ for all $0 \le u \lt 5$, and degree 1 in the variables $w_0$, $w_1$, $w_2$ and $w_3$. It can be expressed as $\psi_B = (\beta_0, \beta_1, ..., \beta_{31})_B$.

### 4.1 Proving Lemma 1

Lemma 1 states that the coordinates of the disturbed state $\psi_B = (\beta_0, \beta_1, ..., \beta_{31})_B$ satisfy:

$$ \beta_{2s} = (a_s + ib_s)w_0 + (-b_s + ia_s)w_1 + (c_s + id_s)w_2 + (-d_s + ic_s)w_3, 0 \le s \lt 16 $$
$$ \beta_{2s+1} = (-c_s + id_s)w_0 + (-d_s - ic_s)w_1 + (a_s - ib_s)w_2 + (b_s + ia_s)w_3, s = 0 $$
$$ \beta_{2s+1} = (c_s - id_s)w_0 + (d_s + ic_s)w_1 + (-a_s + ib_s)w_2 + (-b_s - ia_s)w_3, 0 \lt s \lt 16  $$

where

$$ a_s = Re(\beta_{2s}(1,0,0,0)), b_s = Im(\beta_{2s}(1,0,0,0)), $$
$$ c_s = Re(\beta_{2s}(0,0,1,0)), d_s = Im(\beta_{2s}(0,0,1,0)), $$
$$ 0 \le s \lt 16 $$

```python
def separate_real_from_imaginary(expression: Expr) -> tuple:

    polynomial = expand(expression)
    monomials = expand(expression).args

    real = polynomial  # We will substract the imaginary terms from this polynomial.
    imaginary = 0      # We will add the imaginary terms from this polynomial.

    for monomial in monomials:

        i = 0
        monomial_has_imaginary_unit = False
        variables = monomial.args
        while i < len(variables) and not monomial_has_imaginary_unit:
            if variables[i] == I:
                monomial_has_imaginary_unit = True
            i += 1

        if monomial_has_imaginary_unit:
            real -= monomial
            imaginary += monomial

    return expand(real), expand(imaginary / I)

def get_beta_2s(s: int, a_s: Expr, b_s: Expr, c_s: Expr, d_s: Expr, w: list) -> Expr:

    return expand(
        (a_s + I*b_s) * w[0] + (-b_s + I*a_s) * w[1] +
        (c_s + I*d_s) * w[2] + (-d_s + I*c_s) * w[3]
    )

def get_beta_2s_1(s: int, a_s: Expr, b_s: Expr, c_s: Expr, d_s: Expr, w: list) -> Expr:

    if s == 0:
        return expand(  # When s = 0.
            (-c_s + I*d_s) * w[0] + (-d_s - I*c_s) * w[1] +
            (a_s - I*b_s) * w[2] + (b_s + I*a_s) * w[3]
        )
    else:
        return expand(  # When 0 < s < 16.
            (c_s - I*d_s) * w[0] + (d_s + I*c_s) * w[1] +
            (-a_s + I*b_s) * w[2] + (-b_s - I*a_s) * w[3]
        )

a = []
b = []
c = []
d = []
for s in range(16):

    beta_2s_1000 = psi_B[2 * s].subs(w[0], 1).subs(w[1], 0).subs(w[2], 0).subs(w[3], 0)
    real, imaginary = separate_real_from_imaginary(beta_2s_1000)
    a.append(real)       # Save a_s.
    b.append(imaginary)  # Save b_s.

    beta_2s_0010 = psi_B[2 * s].subs(w[0], 0).subs(w[1], 0).subs(w[2], 1).subs(w[3], 0)
    real, imaginary = separate_real_from_imaginary(beta_2s_0010)
    c.append(real)       # Save c_s.
    d.append(imaginary)  # Save d_s.

    # Test beta_2s.
    beta_2s = get_beta_2s(s, a[s], b[s], c[s], d[s], w)
    psi_B_2s = expand(psi_B[2 * s, 0])
    assert beta_2s == psi_B_2s  # The program will fail if the assertion is false.
    print('Lemma 1 successfully proven for beta_2s with s = %d' % s)

    # Test beta_2s+1.
    beta_2s_1 = get_beta_2s_1(s, a[s], b[s], c[s], d[s], w)
    psi_B_2s_1 = expand(psi_B[2 * s + 1, 0])
    assert beta_2s_1 == psi_B_2s_1  # The program will fail if the assertion is false.
    print('Lemma 1 successfully proven for beta_2s+1 with s = %d' % s)
```

    Lemma 1 successfully proven for beta_2s with s = 0
    Lemma 1 successfully proven for beta_2s+1 with s = 0
    Lemma 1 successfully proven for beta_2s with s = 1
    Lemma 1 successfully proven for beta_2s+1 with s = 1
    Lemma 1 successfully proven for beta_2s with s = 2
    Lemma 1 successfully proven for beta_2s+1 with s = 2
    Lemma 1 successfully proven for beta_2s with s = 3
    Lemma 1 successfully proven for beta_2s+1 with s = 3
    Lemma 1 successfully proven for beta_2s with s = 4
    Lemma 1 successfully proven for beta_2s+1 with s = 4
    Lemma 1 successfully proven for beta_2s with s = 5
    Lemma 1 successfully proven for beta_2s+1 with s = 5
    Lemma 1 successfully proven for beta_2s with s = 6
    Lemma 1 successfully proven for beta_2s+1 with s = 6
    Lemma 1 successfully proven for beta_2s with s = 7
    Lemma 1 successfully proven for beta_2s+1 with s = 7
    Lemma 1 successfully proven for beta_2s with s = 8
    Lemma 1 successfully proven for beta_2s+1 with s = 8
    Lemma 1 successfully proven for beta_2s with s = 9
    Lemma 1 successfully proven for beta_2s+1 with s = 9
    Lemma 1 successfully proven for beta_2s with s = 10
    Lemma 1 successfully proven for beta_2s+1 with s = 10
    Lemma 1 successfully proven for beta_2s with s = 11
    Lemma 1 successfully proven for beta_2s+1 with s = 11
    Lemma 1 successfully proven for beta_2s with s = 12
    Lemma 1 successfully proven for beta_2s+1 with s = 12
    Lemma 1 successfully proven for beta_2s with s = 13
    Lemma 1 successfully proven for beta_2s+1 with s = 13
    Lemma 1 successfully proven for beta_2s with s = 14
    Lemma 1 successfully proven for beta_2s+1 with s = 14
    Lemma 1 successfully proven for beta_2s with s = 15
    Lemma 1 successfully proven for beta_2s+1 with s = 15

### 4.2 Proving Lemma 3

Lemma 3 states that

$$ a_s = Re(\beta_{2s}(1,0,0,0)), b_s = Im(\beta_{2s}(1,0,0,0)), $$
$$ c_s = Re(\beta_{2s}(0,0,1,0)), d_s = Im(\beta_{2s}(0,0,1,0)), $$
$$ 0 \le s \lt 16 $$

Satisfy the following properties:

1) They are homogeneous polynomials of degree 5 in the set of variables $V = \{A_u, B_u, C_u, D_u | 0 \le u \lt 5\}$, degree 1 in the subsets of variables of $V$, and they are made up of 16 monomials.

```python
for x, name in [(a, 'a'), (b, 'b'), (c, 'c'), (d, 'd')]:
    for s in range(16):

        monomials = expand(x[s]).args

        # Polynomials made up of 16 monomials.
        assert len(monomials) == 16

        for monomial in monomials:

            monomial_degree = 0
            variables = monomial.args

            for variable in variables:

                if variable in V:
                    monomial_degree += 1

                    # Degree 1 in the subsets of variables of 𝑉.
                    assert degree(monomial, gen=variable) == 1

            # Degree 5 in the set
            assert monomial_degree == 5

    print('Lemma 3, first property, successfully proven for %s[0] to %s[%d]' % (name, name, s))
```

    Lemma 3, first property, successfully proven for a[0] to a[15]
    Lemma 3, first property, successfully proven for b[0] to b[15]
    Lemma 3, first property, successfully proven for c[0] to c[15]
    Lemma 3, first property, successfully proven for d[0] to d[15]

2) The monomials of $a_s$, $b_s$, $c_s$ and $d_s$ for all $0 \le s \lt 16$ are different two by two.

```python
all_monomials = {}  # Dictionary used as hash table to keep track of monomials seen.

for x, name in [(a, 'a'), (b, 'b'), (c, 'c'), (d, 'd')]:
    for s in range(16):

        monomials = expand(x[s]).args

        for monomial in monomials:

            monomial = str(monomial)
            assert monomial not in all_monomials.keys()
            all_monomials[monomial] = True

    print('Lemma 3, second property, successfully proven for %s[0] to %s[%d]' % (name, name, s))
```

    Lemma 3, second property, successfully proven for a[0] to a[15]
    Lemma 3, second property, successfully proven for b[0] to b[15]
    Lemma 3, second property, successfully proven for c[0] to c[15]
    Lemma 3, second property, successfully proven for d[0] to d[15]

3) For all $0 \le s \lt 16$, in each of $a_s$, $b_s$, $c_s$ or $d_s$, every pair of different monomials share exactly one variable.

```python
for x, name in [(a, 'a'), (b, 'b'), (c, 'c'), (d, 'd')]:
    for s in range(16):

        monomials = expand(x[s]).args

        i = 0
        while i < len(monomials):
            monomial_1 = monomials[i]
            variables_monomial_1 = monomial_1.args

            j = i + 1
            while j < len(monomials):
                monomial_2 = monomials[j]
                variables_monomial_2 = monomial_2.args

                shared_variables = 0
                for variable in variables_monomial_2:
                    if variable in V and variable in variables_monomial_1:
                        shared_variables += 1

                # Every pair of different monomials within the same polynomial share exactly one variable.
                assert shared_variables == 1

                j += 1
            i += 1

    print('Lemma 3, third property, successfully proven for %s[0] to %s[%d]' % (name, name, s))
```

    Lemma 3, third property, successfully proven for a[0] to a[15]
    Lemma 3, third property, successfully proven for b[0] to b[15]
    Lemma 3, third property, successfully proven for c[0] to c[15]
    Lemma 3, third property, successfully proven for d[0] to d[15]

## 5. Classification of monomials by type

```python
def count_As_in_monomial(monomial: Expr, A: list) -> int:

    result = 0

    for variable in monomial.args:
        if variable in A:
            result += 1

    return result

for s in range(16):

    # Create the list that will keep track of how many instances of each type of monomial are there.
    # E.g.: if type_counter[5] == 1, that means that there is one monomial with 5 A's.
    # The array goes from counter[0] (0 A's), to counter[5] (5 A's).
    type_counter = [0 for _ in range(len(A) + 1)]

    for x, name in [(a, 'a'), (b, 'b'), (c, 'c'), (d, 'd')]:

        monomials = expand(x[s]).args

        for monomial in monomials:

            num_As = count_As_in_monomial(monomial, A)
            type_counter[num_As] += 1

    if s == 0:
        assert type_counter == [18, 15, 30, 0, 0, 1]
    elif 0 < s < 16:
        assert type_counter == [15, 26, 16, 6, 1, 0]
    print('Monomial type count proven for s = %d' % (s))
```

    Monomial type count proven for s = 0
    Monomial type count proven for s = 1
    Monomial type count proven for s = 2
    Monomial type count proven for s = 3
    Monomial type count proven for s = 4
    Monomial type count proven for s = 5
    Monomial type count proven for s = 6
    Monomial type count proven for s = 7
    Monomial type count proven for s = 8
    Monomial type count proven for s = 9
    Monomial type count proven for s = 10
    Monomial type count proven for s = 11
    Monomial type count proven for s = 12
    Monomial type count proven for s = 13
    Monomial type count proven for s = 14
    Monomial type count proven for s = 15

## 6. Search of subsets of variables $V_s$

The reader may find an algorithm for the identification of subsets $V_s$ in the file "aux_functions.py", included in this very repository. The following code imports and runs the algorithm from that file.

```python
import aux_functions
import importlib
importlib.reload(aux_functions)

# If this parameter is marked as False, for each 0 ≤ s < 16, the program will look
# for just one valid value of V_s.
get_all_combinations = False

# If this parameter is marked as True, for 0 < s < 16, the program will look for
# solutions that do not include any variable A_u, for all 0 ≤ u < 5.
avoid_As_where_possible = True

# Run:
for s in range(0, 16):

    if avoid_As_where_possible and s > 0:
        print('\ns =', s, '(avoiding any variable A_u, for all 0 ≤ u < 5)')
        valid_variables = B + C + D
    else:
        print('\ns =', s)
        valid_variables = A + B + C + D

    result = []
    monomials = aux_functions.get_all_monomials(a[s], b[s], c[s], d[s], valid_variables)

    aux_functions.search_configurations_recursively(s, {}, monomials, result, get_all_combinations)
    print('  ', len(result), 'possible V_' + str(s) + '(\'s) found.')
```

    s = 0
       Valid V_0 found: ['A_0', 'A_1', 'A_2', 'B_1', 'B_2', 'B_3', 'C_3', 'D_0']
       1 possible V_0('s) found.

    s = 1 (avoiding any variable A_u, for all 0 ≤ u < 5)
       Valid V_1 found: ['B_0', 'B_1', 'B_2', 'B_3', 'C_1', 'C_2', 'D_0', 'D_3']
       1 possible V_1('s) found.

    s = 2 (avoiding any variable A_u, for all 0 ≤ u < 5)
       Valid V_2 found: ['B_1', 'B_2', 'B_3', 'B_4', 'C_2', 'C_3', 'D_1', 'D_4']
       1 possible V_2('s) found.

    s = 3 (avoiding any variable A_u, for all 0 ≤ u < 5)
       Valid V_3 found: ['B_0', 'B_1', 'B_2', 'B_4', 'C_0', 'C_1', 'D_2', 'D_4']
       1 possible V_3('s) found.

    s = 4 (avoiding any variable A_u, for all 0 ≤ u < 5)
       Valid V_4 found: ['B_0', 'B_1', 'B_3', 'B_4', 'C_0', 'C_4', 'D_1', 'D_3']
       1 possible V_4('s) found.

    s = 5 (avoiding any variable A_u, for all 0 ≤ u < 5)
       Valid V_5 found: ['B_0', 'B_1', 'B_2', 'B_4', 'C_0', 'C_1', 'D_2', 'D_4']
       1 possible V_5('s) found.

    s = 6 (avoiding any variable A_u, for all 0 ≤ u < 5)
       Valid V_6 found: ['B_0', 'B_2', 'C_0', 'C_2', 'C_3', 'C_4', 'D_3', 'D_4']
       1 possible V_6('s) found.

    s = 7 (avoiding any variable A_u, for all 0 ≤ u < 5)
       Valid V_7 found: ['B_1', 'B_3', 'C_0', 'C_1', 'C_3', 'C_4', 'D_0', 'D_4']
       1 possible V_7('s) found.

    s = 8 (avoiding any variable A_u, for all 0 ≤ u < 5)
       Valid V_8 found: ['B_0', 'B_2', 'C_0', 'C_2', 'C_3', 'C_4', 'D_3', 'D_4']
       1 possible V_8('s) found.

    s = 9 (avoiding any variable A_u, for all 0 ≤ u < 5)
       Valid V_9 found: ['B_0', 'B_3', 'C_0', 'C_1', 'C_2', 'C_3', 'D_1', 'D_2']
       1 possible V_9('s) found.

    s = 10 (avoiding any variable A_u, for all 0 ≤ u < 5)
       Valid V_10 found: ['B_1', 'B_4', 'C_1', 'C_2', 'C_3', 'C_4', 'D_2', 'D_3']
       1 possible V_10('s) found.

    s = 11 (avoiding any variable A_u, for all 0 ≤ u < 5)
       Valid V_11 found: ['B_0', 'B_1', 'B_2', 'B_4', 'C_0', 'C_1', 'D_2', 'D_4']
       1 possible V_11('s) found.

    s = 12 (avoiding any variable A_u, for all 0 ≤ u < 5)
       Valid V_12 found: ['B_0', 'B_1', 'B_2', 'B_4', 'C_0', 'C_1', 'D_2', 'D_4']
       1 possible V_12('s) found.

    s = 13 (avoiding any variable A_u, for all 0 ≤ u < 5)
       Valid V_13 found: ['B_0', 'B_1', 'B_2', 'B_3', 'C_1', 'C_2', 'D_0', 'D_3']
       1 possible V_13('s) found.

    s = 14 (avoiding any variable A_u, for all 0 ≤ u < 5)
       Valid V_14 found: ['B_1', 'B_2', 'B_3', 'B_4', 'C_2', 'C_3', 'D_1', 'D_4']
       1 possible V_14('s) found.

    s = 15 (avoiding any variable A_u, for all 0 ≤ u < 5)
       Valid V_15 found: ['B_0', 'B_1', 'B_3', 'B_4', 'C_0', 'C_4', 'D_1', 'D_3']
       1 possible V_15('s) found.

We will test a few sets of length 8 obtained during the development of the article, to ensure they are valid.

```python
V_1  = [B[3], B[4], C[0], C[2], D[0], D[2], D[3], D[4]]
V_2  = [B[2], B[3], C[1], C[4], D[1], D[2], D[3], D[4]]
V_3  = [B[2], B[3], C[1], C[4], D[1], D[2], D[3], D[4]]
V_4  = [B[2], B[3], C[1], C[4], D[1], D[2], D[3], D[4]]
V_5  = [B[2], B[3], C[1], C[4], D[1], D[2], D[3], D[4]]
V_6  = [B[3], B[4], C[0], C[2], D[0], D[2], D[3], D[4]]
V_7  = [B[2], B[3], C[1], C[4], D[1], D[2], D[3], D[4]]
V_8  = [B[3], B[4], C[0], C[2], D[0], D[2], D[3], D[4]]
V_9  = [B[0], B[4], C[1], C[3], D[0], D[1], D[3], D[4]]
V_10 = [B[2], B[3], C[1], C[4], D[1], D[2], D[3], D[4]]
V_11 = [B[0], B[4], C[1], C[3], D[0], D[1], D[3], D[4]]
V_12 = [B[0], B[1], C[2], C[4], D[0], D[1], D[2], D[4]]
V_13 = [B[2], B[3], C[1], C[4], D[1], D[2], D[3], D[4]]
V_14 = [B[2], B[3], C[1], C[4], D[1], D[2], D[3], D[4]]
V_15 = [B[3], B[4], C[0], C[2], D[0], D[2], D[3], D[4]]
valid_variables = A + B + C + D

sets_to_test = [V_1, V_2, V_3, V_4, V_5, V_6, V_7, V_8, V_9, V_10, V_11, V_12, V_13, V_14, V_15]
set_index = 0
while set_index < len(sets_to_test):
    set_under_test = sets_to_test[set_index]

    s = set_index + 1
    for polynomial in [a[s], b[s], c[s], d[s]]:
        for monomial in expand(polynomial).args:
            variables = [var for var in monomial.args if var in valid_variables]

            # The intersection of each of the previous sets with the set of variables of
            # any monomial of a_0, b_0, c_0 or d_0 has an odd cardinal.
            assert len(intersection(set_under_test, variables)) % 2 == 1

    print('Assertion tested for V_' + str(s))
    set_index += 1
```

    Assertion tested for V_1
    Assertion tested for V_2
    Assertion tested for V_3
    Assertion tested for V_4
    Assertion tested for V_5
    Assertion tested for V_6
    Assertion tested for V_7
    Assertion tested for V_8
    Assertion tested for V_9
    Assertion tested for V_10
    Assertion tested for V_11
    Assertion tested for V_12
    Assertion tested for V_13
    Assertion tested for V_14
    Assertion tested for V_15

## 7. Checking the intersection of $V'_0$, $V'_1$, $V'_2$, $V'_3$ and $V'_4$ with different monomials

Some valid definitions of $V_s$ will now be used to test several properties.

```python
V_prime_0 = [A[0], B[0], B[3], B[4], C[2], D[2], D[3], D[4]]
V_prime_1 = [A[1], B[1], B[2], B[3], C[4], D[2], D[3], D[4]]
V_prime_2 = [A[2], B[0], C[0], C[3], C[4], D[2], D[3], D[4]]
V_prime_3 = [A[3], B[1], B[2], B[4], C[2], D[1], D[3], D[4]]
V_prime_4 = [A[4], B[1], C[1], C[2], C[3], D[2], D[3], D[4]]
valid_variables = A + B + C + D

sets_to_test = [V_prime_0, V_prime_1, V_prime_2, V_prime_3, V_prime_4]
set_index = 0
while set_index < len(sets_to_test):
    set_under_test = sets_to_test[set_index]

    for polynomial in [a[0], b[0], c[0], d[0]]:
        for monomial in expand(polynomial).args:
            variables = [var for var in monomial.args if var in valid_variables]

            # The intersection of each of the previous sets with the set of variables of
            # any monomial of a_0, b_0, c_0 or d_0 has an odd cardinal.
            assert len(intersection(set_under_test, variables)) % 2 == 1

    # The intersection of each of the previous sets with the set of variables of the
    # monomial A_0*A_1*A_2*A_3*A_4 is {A_0}, {A_1}, {A_2}, {A_3} and {A_4} respectively.
    assert intersection(set_under_test, A) == [A[set_index]]

    print('Assertion tested for V_prime_' + str(set_index))
    set_index += 1
```

    Assertion tested for V_prime_0
    Assertion tested for V_prime_1
    Assertion tested for V_prime_2
    Assertion tested for V_prime_3
    Assertion tested for V_prime_4

## 8. Checking the intersection of $V''_1$ to $V''_{16}$ with different monomials

```python
V_doubleprime_2  = [A[1], B[1], B[2], B[3], C[4], D[2], D[3], D[4]]
V_doubleprime_3  = [A[1], B[2], C[0], C[1], C[3], D[0], D[2], D[3]]
V_doubleprime_4  = [A[1], B[2], B[3], B[4], C[1], C[2], C[3], D[4]]
V_doubleprime_5  = [A[0], B[0], B[3], B[4], C[2], D[2], D[3], D[4]]
V_doubleprime_6  = [A[0], B[0], B[1], B[3], C[1], C[3], C[4], D[4]]
V_doubleprime_7  = [A[0], B[2], B[3], B[4], C[0], C[3], C[4], D[2]]
V_doubleprime_8  = [A[0], B[0], B[1], B[3], C[1], C[3], C[4], D[4]]
V_doubleprime_9  = [A[0], B[1], B[2], B[4], C[1], D[0], D[2], D[4]]
V_doubleprime_10 = [A[0], B[1], B[2], B[3], C[0], C[1], C[2], D[3]]
V_doubleprime_11 = [A[0], B[0], B[2], B[4], C[1], C[2], C[4], D[1]]
V_doubleprime_12 = [A[0], B[2], C[2], C[3], C[4], D[0], D[3], D[4]]
V_doubleprime_13 = [A[0], B[0], B[3], B[4], C[2], D[2], D[3], D[4]]
V_doubleprime_14 = [A[0], B[0], B[1], B[2], C[3], D[1], D[2], D[3]]
V_doubleprime_15 = [A[0], B[1], C[0], C[2], C[4], D[1], D[2], D[4]]
V_doubleprime_16 = [A[0], B[1], B[2], B[3], C[0], C[1], C[2], D[3]]
valid_variables = A + B + C + D

sets_to_test = [V_doubleprime_2, V_doubleprime_3, V_doubleprime_4, V_doubleprime_5, V_doubleprime_6,
                V_doubleprime_7, V_doubleprime_8, V_doubleprime_9, V_doubleprime_10, V_doubleprime_11,
                V_doubleprime_12, V_doubleprime_13, V_doubleprime_14, V_doubleprime_15, V_doubleprime_16]

polynomials = [expand(polynomial) for polynomial in [a[0], b[0], c[0], d[0]]]
monomials_from_a0 = expand(a[0]).args

set_index = 0
while set_index < len(sets_to_test):
    set_under_test = sets_to_test[set_index]

    for polynomial in polynomials:
        for monomial in polynomial.args:
            variables = [var for var in monomial.args if var in valid_variables]

            # The intersection of each of the previous sets with the set of variables of
            # any monomial of a_0, b_0, c_0 or d_0 has an odd cardinal.
            assert len(intersection(set_under_test, variables)) % 2 == 1

    # The intersection of each of the previous sets with the corresponding monomial from
    # a_0 equals the second variable from the monomial (the first one different from A_u).
    target_monomial_object = monomials_from_a0[set_index + 1].args
    target_monomial_list = [var for var in target_monomial_object if var in valid_variables]
    expected_variable = target_monomial_list[1]
    assert intersection(set_under_test, target_monomial_list) == [expected_variable]

    print('Assertion tested for V_doubleprime_' + str(set_index + 2))
    set_index += 1
```

    Assertion tested for V_doubleprime_2
    Assertion tested for V_doubleprime_3
    Assertion tested for V_doubleprime_4
    Assertion tested for V_doubleprime_5
    Assertion tested for V_doubleprime_6
    Assertion tested for V_doubleprime_7
    Assertion tested for V_doubleprime_8
    Assertion tested for V_doubleprime_9
    Assertion tested for V_doubleprime_10
    Assertion tested for V_doubleprime_11
    Assertion tested for V_doubleprime_12
    Assertion tested for V_doubleprime_13
    Assertion tested for V_doubleprime_14
    Assertion tested for V_doubleprime_15
    Assertion tested for V_doubleprime_16


# Neuromatch Academy: Week 1, Day 3, Tutorial 2
# Model Fitting: Linear regression with MLE

#Tutorial Objectives

This is Tutorial 2 of a series on fitting models to data. We start with simple linear regression, using least squares optimization (Tutorial 1) and Maximum Likelihood Estimation (Tutorial 2). We will use bootstrapping to build confidence intervals around the inferred linear model parameters (Tutorial 3). We'll finish our exploration of linear models by  generalizing to multiple linear regression (Tutorial 4). We then move on to polynomial regression (Tutorial 5). We end by learning how to choose between these various models. We discuss the bias-variance trade-off (Tutorial 6) and two common methods for model selection, AIC and Cross Validation (Tutorial 7).

In this tutorial, we will use a different approach to fit linear models that incorporates the random 'noise' in our data.
- Learn about probability distributions and probabilistic models
- Learn how to calculate the likelihood of our model parameters
- Learn how to implement the maximum likelihood estimator, to find the model parameter with the maximum likelihood

# Setup

```python
# @title  Imports
import matplotlib.pyplot as plt
import numpy as np
import scipy.stats as stats
import ipywidgets as widgets
```

```python
#@title Figure Settings
%matplotlib inline
fig_w, fig_h = (8, 6)
plt.rcParams.update({'figure.figsize': (fig_w, fig_h)})
%config InlineBackend.figure_format = 'retina'
```

```python
#@title Helper Functions
def plot_density_image(x, y, theta, sigma=1, ax=None):
  # plot the probability density of p(y|x,theta)
  if ax is None:
    fig, ax = plt.subplots()

  xmin, xmax = np.floor(np.min(x)), np.ceil(np.max(x))
  ymin, ymax = np.floor(np.min(y)), np.ceil(np.max(y))
  xx = np.linspace(xmin, xmax, 50)
  yy = np.linspace(ymin, ymax, 50)

  surface = np.zeros((len(yy), len(xx)))
  for i, x_i in enumerate(xx):
    surface[:, i] = stats.norm(theta*x_i, sigma).pdf(yy)

  ax.imshow(surface, origin='lower', aspect='auto', vmin=0, vmax=None,
            cmap=plt.get_cmap('Wistia'),
            extent=[xmin, xmax, ymin, ymax])
  ax.set(xlabel='x', ylabel='y')

def solve_normal_eqn(x, y):
  """Solve the normal equations to produce the value of theta_hat that minimizes
    MSE.

    Args:
    x (ndarray): An array of shape (samples,) that contains the input values.
    y (ndarray): An array of shape (samples,) that contains the corresponding
      measurement values to the inputs.
    thata_hat (float): An estimate of the slope parameter.

  Returns:
    float: The mean squared error of the data with the estimated parameter.
  """
  theta_hat = (x.T @ y) / (x.T @ x)
  return theta_hat
```

# Maximum Likelihood Estimation (MLE)

```python
#@title Video: Maximum Likelihood Estimation
from IPython.display import YouTubeVideo
video = YouTubeVideo(id="iBtB9kgloJ4", width=854, height=480, fs=1)
print("Video available at https://youtube.com/watch?v=" + video.id)
video
```

    Video available at https://youtube.com/watch?v=iBtB9kgloJ4

In the previous tutorial we took a first approximation assumption, that the data was drawn from a linear relationship with noise added, and found an effective approach for estimating model paramters based on minimizing the mean squared error.

In that case we treated the noise as simply a nuisance, but what if we factored it directly into our model?

Recall our linear model:

\begin{align}
y = \theta_0 + \theta_1 x + \eta.
\end{align}

The noise component $\eta$ is often modeled as a random variable drawn from a Gaussian distribution (also called the normal distribution).

The Gaussian distribution is described by its [probability density function](https://en.wikipedia.org/wiki/Probability_density_function) (pdf)
\begin{align}
\mathcal{N}(x; \mu, \sigma^2) = \frac{1}{\sqrt{2\pi\sigma^2}}e^{-\frac{1}{2\sigma^2}(x-\mu)^2}
\end{align}

and is dependent on two parameters: the mean $\mu$ and the variance $\sigma^2$. We often consider the noise signal to be "white noise" which is to say that it is Gaussian with zero mean and unit variance:

\begin{align}
\eta \sim \mathcal{N}(0, 1).
\end{align}

Use the explorer widget below to see how varying the $\mu$ and $\sigma$ parameters change the location and shape of the samples.

```python
#@title Gaussian Distribution Explorer
@widgets.interact(mu=widgets.FloatSlider(0.0, min=-2.0, max=2.0),
                  sigma=widgets.FloatSlider(1.0, min=0.5, max=2.0))
def plot_normal_dist(mu=0, sigma=1):
  rv = stats.norm(mu, sigma)
  x = np.linspace(-5, 5, 100)
  y = rv.pdf(x)
  samples = rv.rvs(1000)

  fig, ax = plt.subplots()
  ax.hist(samples, 20, density=True, histtype='stepfilled', alpha=0.8,
          label='samples')
  ax.plot(x, y, color='limegreen', linewidth=2, label='pdf')
  ax.vlines(mu, 0, rv.pdf(mu), colors='orange', label='$\mu$')
  ax.vlines([mu-sigma, mu+sigma], 0, rv.pdf([mu-sigma, mu+sigma]), colors='red',
            label='$\sigma$')
  ax.set(xlabel='x', ylabel='probability density', xlim=[-5, 5], ylim=[0, 1.0])
  ax.legend()
```

### Probabilistic Models

Now that we have a model of our noise component $\eta$ as random variable, how do we incorporate this back into our original linear model from before? Consider again our simplified model $y = \theta x + \eta$ where the noise has zero mean and unit variance $\eta \sim \mathcal{N}(0, 1)$. We can now also treat $y$ as a random variable drawn from a Gaussian distribution where $\mu = \theta x$ and $\sigma^2 = 1$:

\begin{align}
y \sim \mathcal{N}(\theta x, 1)
\end{align}

which is to say that the probability of observing $y$ given $x$ and parameter $\theta$ is
\begin{align}
p(y|x,\theta) = \frac{1}{\sqrt{2\pi}}e^{-\frac{1}{2}(y-\theta x)^2}.
\end{align}

---

Let's revist our original sample dataset where the true underlying model has $\theta = 1.2$.

```python
# setting a fixed seed to our random number generator ensures we will always
# get the same psuedorandom number sequence
np.random.seed(121)
theta = 1.2
n_samples = 30
x = 10*np.random.rand(n_samples) # sample from a uniform distribution over [0,10)
noise = np.random.randn(n_samples) # sample from a standard normal distribution
y = theta*x + noise
```

This time we can plot the density of $p(y|x,\theta=1.2)$ and see how $p(y)$ changes for different values of $x$.

```python
fig, (ax1, ax2) = plt.subplots(ncols=2, figsize=(10, 4))
# helper function to generate density image plots from data and parameters
plot_density_image(x, y, 1.2, ax=ax1)
ax1.axvline(8, color='k')

ylim = ax1.get_ylim()
yy = np.linspace(ylim[0], ylim[1], 50)
ax2.plot(yy, stats.norm(theta*8, 1).pdf(yy))
ax2.set(
    title='p(y|x=8,theta=1.2)',
    xlabel='y',
    ylabel='probability density');
```

### Likelihood Estimation

Now that we have our probabilistic model, we turn back to our original challenge of finding a good estimate for $\theta$ that fits our data. Given the inherent uncertainty when dealing in probabilities, we talk about the [likelihood](https://en.wikipedia.org/wiki/Likelihood_function) that some estimate $\hat \theta$ fits our data. The likelihood function $\mathcal{L(\theta)}$ is equal to the probability density function parameterized by that $\theta$:

\begin{align}
\mathcal{L}(\theta|x,y) = p(y|x,\theta) = \frac{1}{\sqrt{2\pi\sigma^2}}e^{-\frac{1}{2\sigma^2}(y-\theta x)^2}.
\end{align}

### Exercise: Likelihood Function

In this exercise you will implement the likelihood function $\mathcal{L}(\theta|x,y)$ for our linear model where $\sigma = 1$.

TIP: Use `np.exp` and `np.sqrt` for the exponential and square root functions, respectively.

```python
def likelihood(theta_hat, x, y):
  """The likelihood function for a linear model with noise sampled from a
    Gaussian distribution with zero mean and unit variance.

  Args:
    theta_hat (float): An estimate of the slope parameter.
    x (ndarray): An array of shape (samples,) that contains the input values.
    y (ndarray): An array of shape (samples,) that contains the corresponding
      measurement values to the inputs.

  Returns:
    ndarray: the likelihood values for the theta_hat estimate
  """
  sigma = 1
  ######################################################################
  ## TODO for students: implement the likelihood function
  ######################################################################
  # pdf =
  # comment this out when you've filled
  raise NotImplementedError("Student excercise: implement the likelihood function")
  return pdf
```

```python
# to_remove solution
def likelihood(theta_hat, x, y):
  """The likelihood function for a linear model with noise sampled from a
    Gaussian distribution with zero mean and unit variance.

  Args:
    theta_hat (float): An estimate of the slope parameter.
    x (ndarray): An array of shape (samples,) that contains the input values.
    y (ndarray): An array of shape (samples,) that contains the corresponding
      measurement values to the inputs.

  Returns:
    float: the likelihood value for the theta_hat estimate
  """
  sigma = 1
  pdf = 1 / np.sqrt(2*np.pi*sigma**2) * np.exp(-(y - theta_hat*x)**2 / (2*sigma**2))
  return pdf
```

Now that we have our likelihood function, we can produce probabilities that our estimate $\hat{\theta}$ generated the provided observations. Let's try with one of the samples from our dataset.

```python
likelihood(1.0, x[1], y[1])
```

    0.11344443599846923

We should see that $\mathcal{L}(\theta=1.0|x=2.1,y=3.7) \approx 0.11$. So far so good, but how does this tell us how this estimate is better than any others?

When dealing with a set of data points, as we are with our dataset, we are concerned with their joint probability -- the likelihood that all data points are explained by our parameterization. Since we assume the noise affects each output independently, we can represent this as

\begin{align}
\mathcal{L}(\theta|x,y) = \prod_{i=1}^N \mathcal{L}(\theta|x_i,y_i).
\end{align}

Such a product is numerically unstable (multiplying small values together leads to underflow), so taking the logarithm of the likelihood helps by transforming products into sums:

\begin{align}
\operatorname{log}\mathcal{L}(\theta|x,y) = \sum_{i=1}^N \operatorname{log}\mathcal{L}(\theta|x_i,y_i)
\end{align}

We can take the sum of the log of the output of our `likelihood` method applied to the full dataset to get a better idea of how different $\hat\theta$ compare. We can also plot the different distribution densities over our dataset and see how they line up qualitatively.

```python
theta_hats = [0.5, 1.0, 2.2]
fig, axes = plt.subplots(ncols=3, figsize=(16, 4))
for theta_hat, ax in zip(theta_hats, axes):
  ll = np.sum(np.log(likelihood(theta_hat, x, y))) # log likelihood
  plot_density_image(x, y, theta_hat, ax=ax)
  ax.scatter(x, y)
  ax.set(title=f'theta_hat = {theta_hat}, log likelihood: {ll:.2f}')
```

Using the log likelihood calculation, we see that $\mathcal{L}(\theta=1.0) > \mathcal{L}(\theta=0.5) > \mathcal{L}(\theta=2.2)$.

This is great, now we have a way to compare estimators based on likelihood. But like with the MSE approach, we want an analytic solution to find the best estimator. In this case, we want to find the estimator that maximizes the likelihood.

### Finding the Maximum Likelihood Estimator

Our aim is to find some $\hat\theta$ that maximizes the likelihood function:

\begin{align}
\hat{\theta}_{\textrm{MLE}} = \underset{\theta}{\operatorname{argmax}} \mathcal{L}(\theta|x,y) = \underset{\theta}{\operatorname{argmax}}\frac{1}{\sqrt{2\pi\sigma^2}}e^{-\frac{1}{2\sigma^2}(y-\theta x)^2}.
\end{align}

We know from before that it's much nicer to work with the log likelihood:

\begin{align}
\hat{\theta}_{\textrm{MLE}} = \underset{\theta}{\operatorname{argmax}}\sum_{i=1}^N \operatorname{log}\mathcal{L}(\theta|x_i,y_i).
\end{align}

Substituting in our model and taking its logarithm, we get
\begin{align}
\hat{\theta}_{\textrm{MLE}} = \underset{\theta}{\operatorname{argmax}}-\frac{N}{2} \operatorname{log} 2\pi\sigma^2 - \frac{1}{2\sigma^2}\sum_{i=1}^N (y_i-\theta x_i)^2.
\end{align}

Now, we want to maximize the log likelihood, which is the same as minimizing the negative log likelihood. Just like our solution to minimizing MSE, we can take the derivative of our negative log likelihhood, set it to 0, and solve.

\begin{align}
\frac{\partial\operatorname{log}\mathcal{L}(\theta|x,y)}{\partial\theta}=\frac{1}{\sigma^2}\sum_{i=1}^N(y_i-\theta x_i)x_i = 0
\end{align}

This looks remarkably like the equation we had to solve for the optimal MSE estimator, and, in fact, we arrive to the exact same solution!

\begin{align}
\hat{\theta}_{\textrm{MLE}} = \hat{\theta}_{\textrm{MSE}} = \sum_{i=1}^N \frac{x_i y_i}{x_i^2}
\end{align}

```python
theta_hat_mle = (x @ y) / (x @ x)
fig, ax = plt.subplots()
ll = np.sum(np.log(likelihood(theta_hat_mle, x, y))) # log likelihood
plot_density_image(x, y, theta_hat_mle, ax=ax)
ax.scatter(x, y)
ax.set(title=f'theta_hat = {theta_hat_mle:.2f}, log likelihood: {ll:.2f}')
```

# Summary

- Likelihood vs probability
    - $\mathcal{L}(\theta|x, y) = p(y|\theta, x)$
    - $p(y|\theta, x)$ -> "probability of observing the response $y$ given parameter $\theta$ and input $x$"
    - $\mathcal{L}(\theta|x, y)$ -> "likelihood that parameters $\theta$ produced response $y$ from input $x$"
- Log-likelihood maximization
    - We take the $\textrm{log}$ of the likelihood function for computational convenience
    - The parameters $\theta$ that maximize $\textrm{log}\mathcal{L}(\theta|x, y)$ are the model parameters that maximize the probability of observing the data.
- **Key point**:
  - the log-likelihood is a flexible cost function, and is often used to find model parameters that best fit the data.

# Appendix

### General approach to maximum likelihood estimation

We want to find the parameter value that makes our data set most likely. We have $N$ data points $X = \{x_1,...,x_N\}$ and $Y = \{y_1,...,y_N\}$ and want:

$$\hat{\theta}_{\textrm{ML}} = \underset{\theta}{\operatorname{argmax}} p(Y |X, \theta)$$

We have assumed that the noise affects each output independently, we can therefore factorize as follows:
$$\hat{\theta}_{\textrm{ML}} = \underset{\theta}{\operatorname{argmax}}\prod_{i=1}^N p(y_i |x_i, \theta).$$

Optimizing such a product is numerically unstable (multiplying small values together leads to underflow), so taking the logarithm of the likelihood helps by transforming products into sums:

$$ \textrm{log} \prod_{i=1}^N p(y_i |x_i, \theta) = \sum_{i=1}^m \textrm{log} p(y_i |x_i, \theta)$$

The $\textrm{log}()$ function is *monotonically increasing*, which means that it preserves the order of its inputs. In practice optimiaztion routines are usually developed to solve minimization problem, we therefore rewrite our objective as a minimization of the negative log likelihood. Finally we obtain a problem that is equivalent to the original and more practical to solve:
$$\hat{\theta}_{\textrm{ML}} = \underset{\theta}{\operatorname{argmax}}\prod_{i=1}^m P_{\textrm{model}}(x_i | \theta) = \underset{\theta}{\operatorname{argmin}} -\sum_{i=1}^m \textrm{log}P_{\textrm{model}}(x_i | \theta).$$

---

### Probability vs likelihood

The expression $\mathrm{p}(\mathrm{y} | \mathrm{x}, \theta)$ can be considered as a function of $y$ or of $x$:

1. as a function of $y$, it is the probability distribution of the response conditioned on the input $x$ and the parameter $\theta$ (aka "encoding distribution")
  - Think of **probability** relating to possible **responses**

2. as a function of $\theta$, it is the probability of the data given the model parameter (aka likelihood function, which is not a proabbility distribution, meaning it usually does not sum to one).
  - Think of **likelihood** relating to **parameters**

In order to keep things clean, we can write the encoding distribution as $\mathrm{p}(\mathrm{y} | \mathrm{x}, \theta)$ and the likelihood function as $\mathcal{L}(\theta;x, y)$.

We can also see $\mathrm{p}(\mathrm{y} | \mathrm{x}, \theta)$ as a function of $x$. This is the stimulus likelihood function, and it is useful in case we want to decode the input $x$ from observed responses $y$. This is what is relevant from the point of view of a neuron that does not have access to the outside world and tries to infer what's out there from the responses of other neurons!

---