## Toy examples

To illustrate the algorithms, we introduce two toy functions to minimize:

- simple quadratic problem:
$$
f(x_1, x_2) = \frac{1}{2} (x_1^2 +\gamma x_2^2)\,,
$$
where $\gamma$ determines the condition number.
- a non-quadratic function:
$$
f(x_1, x_2) = \log(e^{x_1 +3x_2-0.1}+e^{x_1 -3x_2-0.1}+e^{-x_1 -0.1})\,.
$$

```python
# -*- coding: utf-8 -*-
"""
Created on Tue 19 Jan 2016
Last update: Tue 14 Feb 2016

@author: Michiel Stock
michielfmstock@gmail.com

Some functions for Chapter 01: Unconstrained optimization
"""

import numpy as np
import sympy as sp
#from unconstrained import gradient_descent, newtons_method, coordinate_descent
import matplotlib.pyplot as plt
from sys import path
#path.append('../../helpers/')
#from plotting import plot_contour, add_path, blue, red, green, yellow, orange

blue = '#264653'
green = '#2a9d8f'
yellow = '#e9c46a'
orange = '#f4a261'
red = '#e76f51'
black = '#50514F'

# VISUALIZATION
# -------------

def plot_contour(f, xlim, ylim, ax, plot_f=True):
    '''
    Plots the contour of a 2D function to be minimized
    '''
    xvals = np.linspace(*xlim)
    yvals = np.linspace(*ylim)
    X, Y = np.meshgrid(xvals, yvals)
    Z = np.reshape(list(map(f, zip(X.ravel().tolist(), Y.ravel().tolist()))),
                   X.shape)
    ax.contour(X, Y, Z)
    if plot_f: ax.contourf(X, Y, Z, cmap='bone')

def add_path(ax, x_steps, col='b', label=''):
    '''
    Adds a path of an opitmization algorithm to a figure
    '''
    ax.plot([x[0] for x in x_steps], [x[1] for x in x_steps], c=col, label=label)
```

```python
# defining the quadric function, gradient and hessian

from numpy import cos, sin

def get_transformation_matrix(gamma, theta):
    rot = np.array([[cos(theta), sin(theta)],
            [sin(theta), cos(theta)]])
    scaling = np.diag([1, gamma])
    return rot.T.dot(scaling).dot(rot)

def quadratic(x, gamma=10, theta=0.0):
    x = np.array(x).reshape((-1, 1))
    C = get_transformation_matrix(gamma, theta)
    return np.sum(0.5 * x.T.dot(C).dot(x))
    #return 0.5 * (x[0]**2 + gamma * x[1]**2)

def grad_quadratic(x, gamma=10, theta=0.0):
    x = np.array(x).reshape((-1, 1))
    C = get_transformation_matrix(gamma, theta)
    return C.dot(x)
    #return np.array([x[0], gamma * x[1]])

def hessian_quadratic(x, gamma=10, theta=0.0):
    C = get_transformation_matrix(gamma, theta)
    return C
    #return np.array([[1, 0], [0, gamma]])
```

```python
# defining the non-quadric function, gradient and hessian

x1_, x2_ = sp.symbols('x1, x2')

nonquad_expr = sp.log(sp.exp(x1_ + 3 * x2_ - 0.1) + sp.exp(x1_ - 3 * x2_ - 0.1) +
                    sp.exp(-x1_ - 0.1))
#nonquad_expr /= 10
nonquadratic_f = sp.lambdify((x1_, x2_), nonquad_expr, np)
nonquadratic = lambda x : nonquadratic_f(x[0],x[1])
grad_nonquadratic_f = sp.lambdify((x1_, x2_), [nonquad_expr.diff(x1_),
                                  nonquad_expr.diff(x2_)], np)
grad_nonquadratic = lambda x : np.array(grad_nonquadratic_f(x[0], x[1]))

nqdx1dx1 = sp.lambdify((x1_, x2_), nonquad_expr.diff(x1_).diff(x1_), np)
nqdx1dx2 = sp.lambdify((x1_, x2_), nonquad_expr.diff(x1_).diff(x2_), np)
nqdx2dx2 = sp.lambdify((x1_, x2_), nonquad_expr.diff(x2_).diff(x2_), np)

def hessian_nonquadratic(x):
    return np.array([[nqdx1dx1(x[0,:], x[1,:]), nqdx1dx2(x[0,:], x[1,:])],
                     [nqdx1dx2(x[0], x[1]), nqdx2dx2(x[0], x[1])]]).reshape(2,2)
```

```python
# defining the non-quadric function, gradient and hessian

x1_, x2_ = sp.symbols('x1, x2')

nonquad_expr = sp.log(sp.exp(x1_ + 3 * x2_ - 0.1) + sp.exp(x1_ - 3 * x2_ - 0.1) +
                    sp.exp(-x1_ - 0.1))
#nonquad_expr /= 10
nonquadratic_f = sp.lambdify((x1_, x2_), nonquad_expr, np)
nonquadratic = lambda x : nonquadratic_f(x[0],x[1])
grad_nonquadratic_f = sp.lambdify((x1_, x2_), [nonquad_expr.diff(x1_),
                                  nonquad_expr.diff(x2_)], np)
grad_nonquadratic = lambda x : np.array(grad_nonquadratic_f(x[0], x[1]))

nqdx1dx1 = sp.lambdify((x1_, x2_), nonquad_expr.diff(x1_).diff(x1_), np)
nqdx1dx2 = sp.lambdify((x1_, x2_), nonquad_expr.diff(x1_).diff(x2_), np)
nqdx2dx2 = sp.lambdify((x1_, x2_), nonquad_expr.diff(x2_).diff(x2_), np)

def hessian_nonquadratic(x):
    return np.array([[nqdx1dx1(x[0,:], x[1,:]), nqdx1dx2(x[0,:], x[1,:])],
                     [nqdx1dx2(x[0], x[1]), nqdx2dx2(x[0], x[1])]]).reshape(2,2)
```

```python
def newtons_method(f, x0, grad_f, hess_f, alpha=0.3,
                beta=0.8, epsilon=1e-3, trace=False):
    '''
    Newton's method for minimizing functions.

    Inputs:
        - f: function to be minimized
        - x0: starting point
        - grad_f: gradient of the function to be minimized
        - hess_f: hessian matrix of the function to be minimized
        - alpha: parameter for btls
        - beta: parameter for btls
        - nu: parameter to determine if the algorithm is converged
        - trace: (bool) store the path that is followed?

    Outputs:
        - xstar: the found minimum
        - x_steps: path in the domain that is followed (if trace=True)
        - f_steps: image of x_steps (if trace=True)
    '''
    x = x0  # initial value
    if trace: x_steps = [x.copy()]
    if trace: f_steps = [f(x0)]
    while True:
        Dx = - np.linalg.solve(hess_f(x), grad_f(x))
        if - grad_f(x).T.dot(Dx) / 2 <= epsilon:   # stopping criterion
            break  # converged
        t = backtracking_line_search(f, x, Dx, grad_f, alpha, beta)
        x += Dx * t
        if trace: x_steps.append(x.copy())
        if trace: f_steps.append(f(x))
    if trace: return x, x_steps, f_steps
    else: return x

```

```python
def backtracking_line_search(f, x0, Dx, grad_f, alpha=0.05,
                                            beta=0.6):
#     alpha=0.1,
#                                             beta=0.7):
    '''
    Uses backtracking for finding the minimum over a line.
    Inputs:
        - f: function to be searched over a line
        - x0: initial point
        - Dx: direction to search
        - grad_f: gradient of f
        - alpha
        - beta
    Output:
        - t: suggested stepsize
    '''
    t = 1  # ...
    while (f(x0+t*Dx)>f(x0)+alpha*t*np.sum(grad_f(x0) * Dx)): # ...
        t *= beta # ...
    return t
```

```python
# defining the non-quadric function, gradient and hessian

x1_, x2_ = sp.symbols('x1, x2')

nonquad_expr = sp.log(sp.exp(x1_ + 3 * x2_ - 0.1) + sp.exp(x1_ - 3 * x2_ - 0.1) +
                    sp.exp(-x1_ - 0.1))
#nonquad_expr /= 10
nonquadratic_f = sp.lambdify((x1_, x2_), nonquad_expr, np)
nonquadratic = lambda x : nonquadratic_f(x[0],x[1])
grad_nonquadratic_f = sp.lambdify((x1_, x2_), [nonquad_expr.diff(x1_),
                                  nonquad_expr.diff(x2_)], np)
grad_nonquadratic = lambda x : np.array(grad_nonquadratic_f(x[0], x[1]))

nqdx1dx1 = sp.lambdify((x1_, x2_), nonquad_expr.diff(x1_).diff(x1_), np)
nqdx1dx2 = sp.lambdify((x1_, x2_), nonquad_expr.diff(x1_).diff(x2_), np)
nqdx2dx2 = sp.lambdify((x1_, x2_), nonquad_expr.diff(x2_).diff(x2_), np)

def hessian_nonquadratic(x):
    return np.array([[nqdx1dx1(x[0,:], x[1,:]), nqdx1dx2(x[0,:], x[1,:])],
                     [nqdx1dx2(x[0], x[1]), nqdx2dx2(x[0], x[1])]]).reshape(2,2)
```

```python
fig, (ax1, ax2) = plt.subplots(ncols=2, figsize=(10, 5))
plot_contour(quadratic, (-11, 11), (-5, 5), ax1)
plot_contour(nonquadratic, (-2, 2), (-1, 1), ax2)

xstar_q, x_steps_q, f_steps_q = newtons_method(quadratic, np.array([[10.0], [1.0]]),
                                grad_quadratic, hessian_quadratic, epsilon=1e-8, trace=True)
add_path(ax1, x_steps_q, red)

print('Number of steps quadratic function: {}'.format(len(x_steps_q) - 1))

xstar_nq, x_steps_nq, f_steps_nq = newtons_method(nonquadratic, np.array([[-0.5], [0.9]]),
                                grad_nonquadratic, hessian_nonquadratic, epsilon=1e-8, trace=True)
add_path(ax2, x_steps_nq, red)

print('Number of steps non-quadratic function: {}'.format(len(x_steps_nq) - 1))
```

___
## References

Boyd, S. and Vandenberghe, L. *'Convex Optimization'*. Cambridge University Press (2004) [link](https://stanford.edu/~boyd/cvxbook/)

AMMI Optimization/Newton feasible point.ipynb


# Определение параметров PID-регулятора из характеристики переходного процесса

Рассмторим задачу проектирования ПИД-регулятора для системы, описываемой дифференциальным уравнением второго порядка. Целью работы является подбор коэффициентов регулятора ($K_p$, $K_d$, $K_i$) для требуемых параметров переходного процесса (rise time, overshoot, steady-state error). Подбор параметров производится двумя способами: c помощью анализа графика сигнала и за счет задания требуемого положения полюсов системы.\
Рассмотрим механическую систему:

Тут $m$ = 1 кг - масса тележки, $b$ = 10 Нс/м - коэффициент дэмпфирования, $k$ = 20 Н/м - жесткость пружины, F = u(t) = 1 Н.\

Для этого рассмотрим простейшую механическую систему, определяемую ДУ:
$$ m 𝑦′′(𝑡) + b 𝑦′(𝑡) + k 𝑦(𝑡) = F(𝑡)$$

Используя преобразование Лапласа и нулевые начальные условия, получаем:
$$m 𝑠^2 𝑌(𝑠) + b 𝑠 𝑌(𝑠) + k𝑌(𝑠) = 𝑈(𝑠)$$

Передаточная функция определяется как отношение выходного сигнала(смещения) ко входному. Получаем тогда передаточную функцию:

$$ H(s) = \frac{1}{ms^2+bs+k} $$

Передаточная функция PID-регулятора имеет вид: $$ H(s) = K_{p} + \frac{K_{i}}{s} + K_{d}s $$

Структурная схема системы с управляющим устройством ($С(s)$), объектом управления ($P(s)$) и отрицательной обратной связью имеет вид:

```python
import sympy as sp
import matplotlib.pyplot as plt
import numpy as np
import control as ctrl #(pip install control если пакет control не установлен)
from __future__ import print_function
from ipywidgets import interact, interactive, fixed, interact_manual
import ipywidgets as widgets
```

```python
#Инициализация системы
M = 1  #кг
B = 10 #Нс/м
K = 20 #Н/м

num = np.array([1])
den = np.array([M, B, K])
print('Передаточная функция системы без управления в терминах оператора Лапласа:',ctrl.tf(num, den))
```

    Передаточная функция системы без управления в терминах оператора Лапласа:
           1
    ---------------
    s^2 + 10 s + 20

## P control

Пропорциональный регулятор уменьшает время нарастания сигнала (rise time), увеличивает перерегулирование (overshoot) и уменьшает (но не устраняет) статическую ошибку (steady state error).\

Передаточная функция замкнутого контура расматриваемой системы с единичной обратной связью и пропорциональным регулятором выглядит следующим образом:
$$T(s) = \frac{K_p}{m s^2 + bs + (k+K_p)}$$

```python
Kp = 300

def prop_control (Kp, num, den):
    num_arr = num.copy()
    den_arr = den.copy()

    num_arr *= Kp
    den_arr[2] += Kp
    return (num_arr, den_arr)

num_ar, den_ar = prop_control (Kp, num, den)
T_p = ctrl.tf(num_ar, den_ar)
t_p, y_p = ctrl.step_response(T_p)

print('Передаточная функция:',T_p)

output = num_ar[-1] / den_ar[-1]

plt.plot(t_p,y_p)
plt.plot(t_p,np.ones_like(y_p) * output,'--',color ='r')

plt.title("Proportional control")
plt.grid()
plt.show()

```

## PD control

Добавление контроля производной overshoot.\
Передаточная функция замкнутого контура данной системы с контроллером PD является:
$$T(s) = \frac{K_ds+K_p}{Ms^2 + (B+K_d)s + (K+K_p)}$$

```python
Kp = 300
Kd = 10

def prop_der_control (Kp,Kd, num, den):
    num_arr = num.tolist()
    num_arr[0] *= Kp
    num_arr.insert(0,Kd)
    num_arr = np.array(num_arr)

    den_arr = den.copy()
    den_arr[2] += Kp
    den_arr[1] += Kd

    return (num_arr, den_arr)

num_ar, den_ar = prop_der_control (Kp, Kd, num, den)
T_pd = ctrl.tf(num_ar, den_ar)
print('Передаточная функция:',T_pd)
t_pd, y_pd = ctrl.step_response(T_pd)

output =  num_ar[-1]/den_ar[-1]

plt.plot(t_pd,y_pd)
plt.plot(t_pd,np.ones_like(y_pd) * output,'--',color ='r')

plt.title("PD control")
plt.grid()
plt.show()

```

## PI control

Добавление интегрального управления имеет уменьшает время нарастания, увеличивает overshoot, а также уменьшает постоянную ошибку.\
Для данной системы передаточная функция с замкнутым контуром с PI-контроллером равна:
$$T(s) = \frac{K_ds+K_i}{Ms^3 + Bs^2 + (K+K_p)s + K_i}$$

```python
Kp = 30
Ki = 70

def prop_int_control (Kp,Ki, num, den):
    num_arr = num.tolist()
    num_arr[0] *= Kp
    num_arr.append(Ki)
    num_arr = np.array(num_arr)

    den_arr = den.copy()
    den_arr[2] += Kp
    den_arr = den.tolist()
    den_arr.append(Ki)
    den_arr = np.array(den_arr)

    return (num_arr, den_arr)

num_ar, den_ar = prop_int_control (Kp, Ki, num, den)
T_pi = ctrl.tf(num_ar, den_ar)
print('Передаточная функция:',T_pi)
t_pi, y_pi = ctrl.step_response(T_pi)

output =  num_ar[-1]/den_ar[-1]
plt.plot(t_pi,y_pi)
plt.plot(t_pi,np.ones_like(y_pi) * output,'--',color ='r')

plt.title("PI control")
plt.grid()
plt.show()
```

## PID control

Передаточная функция в случае PID контроля имеет вид:
$$T(s) = \frac{K_ds^2+K_ps+K_i}{Ms^3 + (B+Kd)s^2 + (K+K_p)s + K_i}$$

```python
Kp = 350
Ki = 300
Kd = 50

def prop_int_derive_control (Kp, Kd, Ki, num, den):
    num_arr = num.tolist()
    num_arr[0] *= Kp
    num_arr.insert(0,Kd)
    num_arr.append(Ki)
    num_arr = np.array(num_arr)

    den_arr = den.copy()
    den_arr[2] += Kp
    den_arr[1] += Kd
    den_arr = den_arr.tolist()
    den_arr.append(Ki)
    den_arr = np.array(den_arr)
    return (num_arr, den_arr)

num_ar, den_ar = prop_int_derive_control (Kp, Kd, Ki, num, den)

T_pid = ctrl.tf(num_ar, den_ar)
print('Передаточная функция:',T_pid)
t_pid, y_pid = ctrl.step_response(T_pid)

output =  num_ar[-1]/den_ar[-1]

plt.plot(t_pid,y_pid)
plt.plot(t_pid,np.ones_like(y_pid) * output,'--',color ='r')

plt.title("PID control")
plt.grid()
plt.show()
```

## Интерактивный подбор параметров PID-регулятора

Алгоритм подбора параметров следующий:
* Установите значения $K_i$ и $K_d$ равными нулю. Увеличить $K_p$ до значения, где амплитуда уменьшилась на четверть после первого максимума
* Увеличивать $K_i$ до тех пор, пока на бесконечности не установится требуемый режим (steady-state error станет равной нулю).
* Увеличить $K_d$, если требуется, до тех пор, пока затухание не станет быстрым и колебания от требуемого значения не уменьшаться до допустимого уровня.

```python
#Интерактивное изменение параметров регулятора
def series(Kp, Kd, Ki):
    M = 1
    B = 10
    K = 20
    num = np.array([1])
    den = np.array([M, B, K])

    num_arr, den_arr = prop_int_derive_control (Kp, Kd, Ki, num, den)
    T = ctrl.tf(num_arr, den_arr)

    t, y = ctrl.step_response(T)
    print(T)

    plt.plot(t,y)
    plt.plot(t,np.ones_like(y),'--',color ='r')
    plt.grid()
    plt.show()
    return()

interact(series, Kp=(1,500,1),Kd=(0,300,1),Ki=(0,300,1)) #Здесь можно изменить ограничения на слайдерах
#Примерный набор (Kp,Kd,Ki) = (150,13,300)
```

    interactive(children=(IntSlider(value=250, description='Kp', max=500, min=1), IntSlider(value=150, description…

    <function __main__.series(Kp, Kd, Ki)>

## Подбор параметров PID-регулятора через анализ полюсов

Достигнем асимптотической устойчивости системы с PID регулятором с анализа полюсов передаточной функции. Размещение одного полюса на расстоянии 5$\zeta\omega_n$ от мнимой оси, а двух других соответсвующим образом, приводит к следующей диаграмме:

Здесь $\zeta$ и $\omega_n$ - некоторые параметры, определяющие положения полюсов. Такое расположение обеспечивает асимптотическую устойчивость системы.

Таким образом, знаменатель можно разложить на множители,исходя их положения выбранных полюсов:
$$ s^3 + s^2(\frac{B+K_d}{M})+s(\frac{K+K_p}{M}) + \frac{K_i}{M} = (s+ 5\zeta\omega_n)(s^2+2\zeta\omega_ns +\omega_n^2)$$
Тогда после решения данной системы, можно получить выражения для коэффициентов регулятора:
$$K_d = 7M\zeta\omega_n - B$$
$$K_p = M(10\zeta^2\omega_n^2 + \omega_n^2)  - K$$
$$K_d = 5M\zeta\omega_n^3$$

Используя следующий критерий: 2% времени установки меньше 1 секунды. Получим выражения для времени установки и времени роста через параметры $\zeta$ и $\omega_n$:
$$t_s = \frac{4}{\zeta\omega_n}$$
$$t_r = \frac{\pi-\beta}{\omega_d}$$
Или при $\beta = atan(\frac{\omega_n\sqrt(1-\zeta^2)}{\zeta\omega_n})$:
$$t_r = \frac{\pi-atan(\frac{\omega_n\sqrt(1-\zeta^2)}{\zeta\omega_n})}{\omega_d}$$

```python
#Поиск параметров при известных ограничениях на время

import scipy.optimize as sp

def func(x):
    #Здесь ставятся ограничения на время установления и время роста
    tr = 0.3 #сек
    ts = 0.5 #сек
    return [4 / (x[0]*x[1]) - ts,
            (np.pi - np.arctan(np.sqrt(1-x[0]*x[0])/x[0]))/(x[1]*np.sqrt(1-x[0]*x[0])) - tr]

root = sp.fsolve(func, [0.2, 22])

zeta = root[0]
omega = root[1]

Kd_pole = 7*M*zeta*omega - B
Kp_pole = M*(10*zeta**2*omega**2+omega**2)-K
Ki_pole = 5*M*zeta*omega**3

num_arr, den_arr = prop_int_derive_control (Kp_pole, Kd_pole, Ki_pole, num, den)
T_pole = ctrl.tf(num_arr, den_arr)

t_pole, y_pole = ctrl.step_response(T_pole)
print(T_pole)

plt.plot(t_pole,y_pole)
plt.plot(t_pole,np.ones_like(y_pole),'--',color ='r')
plt.grid()
plt.show()
```

```python
#Диаграмма полюсов
print(ctrl.pzmap(T_pole))
```

Homework Problems/Lecture 4-5 PID/PID Controller Design (Mass-Spring Damper).ipynb


# Izhikevich Model

The Izhikevich model produces biologically plausible dynamics of neural spiking (as the Hodgkin-Huxley model) but with the advantage of being computationally efficient (as the Integrate-and-Fire model) [[1]](#References). It is described by the following system of ordinary differential equations:

\begin{align}
v' &= 0.04 v^2 + 5v + 140 - u + I \\
u' &= a(bv - u)
\end{align}

where $v$ denotes the membrane potential and $u$, its recovery variable. $a$, $b$, $c$ and $d$ are additional parameters accounting for:

* $a$: Time scale of recovery variable $u$.
* $b$: Sensitivity of recovery variable $u$.
* $c$: After-spike reset value of membrane potential $v$.
* $d$: After-spike reset value of recovery variable $u$.

Additionally, $u$ and $v$ are resetted when $u$ exceeds the threshold of 30mv:

$$
\begin{cases}
v \leftarrow c \\
u \leftarrow u + d
\end{cases}
$$

```python
# Add temporary path to code
import sys
sys.path.append("../")

# Import the module with the Izhikevich model
from neural_models import Izhikevich
```

Different dynamics can be accomplished by tuning the parameters appropriately:

```python
# Parameters
t = 200
current = 10
Y0 = [-65, 0]

#Regular Spiking (RS) Neuron
RS_model = Izhikevich(a=0.02, b=0.2, c=-65, d=8)
print(RS_model)
RS_model.run(t, current, Y0)
```

    Izhikevich(a=0.02, b=0.2, c=-65, d=8)

```python
# Intrinsically Bursting (RS) Neuron
IB_model = Izhikevich(a=0.02, b=0.2, c=-55, d=4)
print(IB_model)
IB_model.run(t, current, Y0)
```

    Izhikevich(a=0.02, b=0.2, c=-55, d=4)

```python
# Chattering (CH) Neuron
CH_model = Izhikevich(a=0.02, b=0.2, c=-50, d=2)
print(CH_model)
CH_model.run(t, current, Y0)
```

    Izhikevich(a=0.02, b=0.2, c=-50, d=2)

```python
# Fast Spiking (FS) Neuron
FS_model = Izhikevich(a=0.1, b=0.2, c=-65, d=2)
print(FS_model)
FS_model.run(t, current, Y0)
```

    Izhikevich(a=0.1, b=0.2, c=-65, d=2)

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

# Plots
fig, ax = plt.subplots(2, 2, figsize=(15, 8))
ax[0, 0].plot(RS_model.tvec, RS_model.V, 'r')
ax[0, 0].set_xlabel('time (s)')
ax[0, 0].set_ylabel('voltage (mv)')
ax[0, 0].set_title('Regular Spiking (RS)')

ax[0, 1].plot(IB_model.tvec, IB_model.V, 'g')
ax[0, 1].set_xlabel('time (s)')
ax[0, 1].set_ylabel('voltage (mv)')
ax[0, 1].set_title('Intrinsically Bursting (IB)')

ax[1, 0].plot(CH_model.tvec, CH_model.V, 'b')
ax[1, 0].set_xlabel('time (s)')
ax[1, 0].set_ylabel('voltage (mv)')
ax[1, 0].set_title('Chattering (CH)')

ax[1, 1].plot(FS_model.tvec, FS_model.V, 'm')
ax[1, 1].set_xlabel('time (s)')
ax[1, 1].set_ylabel('voltage (mv)')
ax[1, 1].set_title('Fast Spiking (FS)')

plt.subplots_adjust(wspace=0.2, hspace=0.4)
plt.show()
```

# References

## Bibliography
1. [Simple Model of Spiking Neurons](https://www.izhikevich.org/publications/spikes.pdf)


# Selección de modelo

El objetivo de realizar selección de modelo es, a partir de una serie de datos $(x,y)$ y cierta clase de hipótesis $\mathcal{H}$. Encontrar el $h\in \mathcal{H}$ que minimiza el error $e(h)=P_{D}[h(x) \neq y]$. Claramente la hipótesis $h$ que minimiza el error en los datos sobre una cantidad suficiente de datos, produce un error $e(h)$ pequeño. El objetivo es balancear la complejidad de $\mathcal{H}$ con el ajuste de $h\in \mathcal{H}$ a los datos de entrenamiento. Es claro que una hipótesis muy simple puede no contener una buena aproximación a la función que queremos aprender, mientras que una $h$ muy compleja puede ajustarse muy bien a los datos pero no ser tan buena en la generalización a nuevos datos. Esto se vuelve particularmente importante cuando se tienen pocos datos o estos resultan muy ruidosos.

# Estrategias para selección de modelo

Es importante notar que la complejidad de la clase de modelos es una variable a determinar por el algoritmo de aprendizaje. La selección de modelo se realiza usualmente de la siguiente manera:
- Se selecciona una función candidata $h_i$ de cierta clase $H_i$, para esto es conveniente, en general, minimizar el error empírico.
- Se utiliza algún criterio para seleccionar una de las $h\in {h_1,h_2,...}$ de manera que el error $e(h)$ sea lo suficientemente pequeño.

# 1. Validación cruzada

Cuando se evalúan diferentes configuraciones de un modelo general, lo que se hace es evaluar diferentes "hiperparámetros" para los estimadores. Tales como $\lambda$ en el caso de regresión lineal con regularización que se realizó previamente. Cuando se escoje un valor de un hiperparáemtetro escogido manuealmente, existe la posibilidad de realizar sobreajuste (overfitting) del conjunto de prueba debido a que los hiperparámetros pueden ser manipulados de manera que el estimador se comporte óptimamente, de manera que se introduce un conocimiento al modelo que invalidala idea de tener un desempeño de generalización. Para resolver este problema, es posible crear OTRO subconjunto de datos de manera que este se pueda mantener "afuera" del conjunto de prueba y funcione como un conjunto de validación durante el proceso de entrenamiento.

Para realizar validación cruzada, se realiza una estimación directa de $e(h_i)$. Para esto, se dividen los datos en conjuntos $(X_{train},Y_{train})$ y $(X_{test},Y_{test})$, donde cada conjunto contiene $1-\alpha$ y $alpha$ veces la cantidad total de datos.
Con estos datos se halla la hipótesis candidata $h_d\in \mathcal{H}_d$ minimizando el error empírico en el conjunto de datos de entrenamiento (X_{train},Y_{train}). Posteriormente, se selecciona la hipótesis candidata con el menor error empírico en $(X_{test},Y_{test})$.

\begin{equation}
h_{d^*}={argmin}_{h_i}{\hat{e}_{(X_{test},Y_{test})} (h_d)}
\end{equation}

Claramente, la selección de $\alpha$ también tiene consecuencias sobre la estimación y el modelo obtenido. Si se selecciona un $\alpha$ muy pequeño, es posible tener una estimación pobre de $e(h)$ debido a que no se tienen suficientes datos para estimar el error empírico en un conjunto de prueba. Si se selecciona un $\alpha$ muy grande, se corre el riesgo de realizar un aprendizaje pobre debido a que no se tienen los suficientes datos para entrenar el modelo. El criterio de Chernoff es útil determinar qué cantidad $n$ de datos es necesaria para estimar $e(h)$ con precisión $\epsilon$ y confianza $1-\delta$:

\begin{equation}
n \geq \frac{1}{2\epsilon^2} \ln{\frac{2}{\delta}}
\end{equation}

Sin embargo, el estimativo de $e(h_d)$ usualmente es ruidoso, por lo que es importante considerar diferentes alternativas para realizar \textit{validación cruzada}.

Sin embargo, al realizar particiones de los datos, se reduce el número de muestras que pueden ser utilizadas para aprender el modelo. Una solución a este problema se llama "validación cruzada (CV)".

```python
#EJEMPLO: SCORES DE CROSS-VALIDATION
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn import datasets
from sklearn import svm
from sklearn.model_selection import cross_val_score
from sklearn import metrics

#Se cargan los datos
iris = datasets.load_iris()

#Se crean las separaciones de train y test.
X_train, X_test, y_train, y_test = train_test_split(iris.data, iris.target, test_size=0.3, random_state=0)

#Se crea un objeto de tipo clasificador
clf = svm.SVC(kernel='linear', C=1)

#Se crea un objeto cross_val_score que recibe como parámetro el modelo,
#los datos y la cantidad de validaciones cruzadas

scores = cross_val_score(clf, X_train , y_train, cv=5)

print("Precisiones en cada subconjunto",scores)
print("Precisión media: %0.2f (+/- %0.2f)" % (scores.mean(), scores.std() * 2))
print(".......")

#Se puede modificar el tipo de scoring que se realiza, para un conjunto de datos balanceado el resultado es muy similar
scores = cross_val_score(clf, X_train, y_train, cv=5, scoring='f1_macro')

print("Precisiones en cada subconjunto",scores)
print("Precisión media: %0.2f (+/- %0.2f)" % (scores.mean(), scores.std() * 2))
```

    Precisiones en cada subconjunto [0.95454545 1.         1.         0.95238095 0.94736842]
    Precisión media: 0.97 (+/- 0.05)
    .......
    Precisiones en cada subconjunto [0.95555556 1.         1.         0.95008913 0.94747475]
    Precisión media: 0.97 (+/- 0.05)

#### Iteradores
También es posible utilizar diferentes estrategias de validación cruzada pasando un objeto de tipo iterador (por defecto scikit-learn utilizará KFold o StratifiedKFold)

```python
from sklearn.model_selection import ShuffleSplit

cv = ShuffleSplit(n_splits=5, test_size=0.3, random_state=0)

scores=cross_val_score(clf, X_train, y_train, cv=cv)

print("Precisiones en cada subconjunto",scores)
print("Precisión media: %0.2f (+/- %0.2f)" % (scores.mean(), scores.std() * 2))

```

    Precisiones en cada subconjunto [1.      0.9375  0.96875 0.9375  0.9375 ]
    Precisión media: 0.96 (+/- 0.05)

# Validación cruzada k-multiple

La idea es obtener un mejor estimativo de $e(h)$. Para esto, teniendo una clase $\mathcal{H}$, el conjunto de datos $S=(X,Y)$ se divide en $S_1,S_2,...,S_k$. Para cada $i=1,2,...k$ se halla $h_i$ minimizando el error empírico en $\cup_{j\neq i} S_j$. Luego, se estima el error calculando el error empírico $\hat{e}_{S_i}(h_i)$. Luego se realiza un promedio de los valores obtenidos

\begin{equation}
\hat{e}(h_d)=\frac{1}{k} \sum_{i=1}^{k}{\hat{e}s_i (h_i)}
\end{equation}

Para el $d^*$ que corresponde "fold" con menor valor de riesgo empírico, se halla $h$ minimizando el error empírico en $S$. Es importante mencionar que la validación cruzada k-fold es un proceso costoso computacionalmente y carece de soporte teórico, sin embargo, es ampliamente usado en la práctica.

## K fold y Leave-one-out.
En k-fold se segmentan los datos en $k$ grupos, se corren los algoritmos en $k-1$ grupos y se evalúa el riesgo del último grupo. Se repiite esto hasta que todos los grupos se hallan entrenando, por lo que se tienen $k$ números de errores que se deben promediar. Leave-one-out es el caso en el que se tiene $k=n$. La idea es que este método es más robusto, dado que se entrena en una mayor cantidad de datos y todavía se tienen promedios del riesgo estimado. el reto es que ahora se está reutilizando la información en diferentes entrenamientos y conjuntos de validación por lo que los términos en los promedios no son independientes. [1]

```python
import numpy as np
from sklearn.model_selection import KFold

X = np.random.uniform(size=100)
kf = KFold(n_splits=4)

for train, test in kf.split(X): print("%s %s" % (train, test))

```

    [25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
     49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
     73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96
     97 98 99] [ 0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
     24]
    [ 0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
     24 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
     73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96
     97 98 99] [25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
     49]
    [ 0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
     24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
     48 49 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96
     97 98 99] [50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
     74]
    [ 0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
     24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
     48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
     72 73 74] [75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98
     99]

# Comparación de comportamiento de objetos de validación cruzada

```python
#Tomado de https://scikit-learn.org/stable/auto_examples/model_selection/plot_cv_indices.html

from sklearn.model_selection import (TimeSeriesSplit, KFold, ShuffleSplit,
                                     StratifiedKFold, GroupShuffleSplit,
                                     GroupKFold, StratifiedShuffleSplit)
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Patch

np.random.seed(1338)
cmap_data = plt.cm.Paired
cmap_cv = plt.cm.coolwarm
n_splits = 4

def plot_cv_indices(cv, X, y, group, ax, n_splits, lw=10):
    """Create a sample plot for indices of a cross-validation object."""

    # Generate the training/testing visualizations for each CV split
    for ii, (tr, tt) in enumerate(cv.split(X=X, y=y, groups=group)):
        # Fill in indices with the training/test groups
        indices = np.array([np.nan] * len(X))
        indices[tt] = 1
        indices[tr] = 0

        # Visualize the results
        ax.scatter(range(len(indices)), [ii + .5] * len(indices),
                   c=indices, marker='_', lw=lw, cmap=cmap_cv,
                   vmin=-.2, vmax=1.2)

    # Plot the data classes and groups at the end
    ax.scatter(range(len(X)), [ii + 1.5] * len(X),
               c=y, marker='_', lw=lw, cmap=cmap_data)

    ax.scatter(range(len(X)), [ii + 2.5] * len(X),
               c=group, marker='_', lw=lw, cmap=cmap_data)

    # Formatting
    yticklabels = list(range(n_splits)) + ['class', 'group']
    ax.set(yticks=np.arange(n_splits+2) + .5, yticklabels=yticklabels,
           xlabel='Sample index', ylabel="CV iteration",
           ylim=[n_splits+2.2, -.2], xlim=[0, 100])
    ax.set_title('{}'.format(type(cv).__name__), fontsize=15)
    return ax

# Generate the class/group data
n_points = 100
X = np.random.randn(100, 10)

percentiles_classes = [.1, .3, .6]
y = np.hstack([[ii] * int(100 * perc)
               for ii, perc in enumerate(percentiles_classes)])

# Evenly spaced groups repeated once
groups = np.hstack([[ii] * 10 for ii in range(10)])

def visualize_groups(classes, groups, name):
    # Visualize dataset groups
    fig, ax = plt.subplots()
    ax.scatter(range(len(groups)),  [.5] * len(groups), c=groups, marker='_',
               lw=50, cmap=cmap_data)
    ax.scatter(range(len(groups)),  [3.5] * len(groups), c=classes, marker='_',
               lw=50, cmap=cmap_data)
    ax.set(ylim=[-1, 5], yticks=[.5, 3.5],
           yticklabels=['Data\ngroup', 'Data\nclass'], xlabel="Sample index")

visualize_groups(y, groups, 'no groups')
cvs = [KFold, GroupKFold, ShuffleSplit, StratifiedKFold,
       GroupShuffleSplit, StratifiedShuffleSplit, TimeSeriesSplit]

for cv in cvs:
    this_cv = cv(n_splits=n_splits)
    fig, ax = plt.subplots(figsize=(6, 3))
    plot_cv_indices(this_cv, X, y, groups, ax, n_splits)

    ax.legend([Patch(color=cmap_cv(.8)), Patch(color=cmap_cv(.02))],
              ['Testing set', 'Training set'], loc=(1.02, .8))
    # Make the legend fit
    plt.tight_layout()
    fig.subplots_adjust(right=.7)
plt.show()
```

# Aplicaciones:

## Búsqueda de parámetros utilizando gridsearch:
Scikit-learn incorpora un objeto que, dados unos datos, calcula el score durante el ajuste de cierto estimador o parámetro y escoge los parámetros que maximizan el score de validación cruzada. Este objeto toma un estimado durante la construcción y devuelve un estimador.

```python
from sklearn.model_selection import GridSearchCV, cross_val_score
import numpy as np
from sklearn.model_selection import cross_val_score
from sklearn import datasets, svm

digits = datasets.load_digits()
X = digits.data
y = digits.target
X_train, X_test, y_train, y_test = train_test_split(iris.data, iris.target, test_size=0.4, random_state=0)

svc = svm.SVC(kernel='linear')

Cs = np.logspace(-6, -1, 10)
clf = GridSearchCV(estimator=svc, param_grid=dict(C=Cs),n_jobs=-1,iid=True,cv=5)
clf.fit(X_train,y_train)

#Se puede recuperar el mejor parámetro
print(clf.best_estimator_.C)

#También se puede recuperar el score
clf.score(X_test,y_test)

```

    0.1

    0.9333333333333333

```python
#Tomado de: https://scikit-learn.org/stable/auto_examples/model_selection/plot_grid_search_digits.html#sphx-glr-auto-examples-model-selection-plot-grid-search-digits-py
from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.model_selection import GridSearchCV
from sklearn.metrics import classification_report
from sklearn.svm import SVC

# Loading the Digits dataset
digits = datasets.load_digits()

# To apply an classifier on this data, we need to flatten the image, to
# turn the data in a (samples, feature) matrix:
n_samples = len(digits.images)
X = digits.images.reshape((n_samples, -1))
y = digits.target

# Split the dataset in two equal parts
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.5, random_state=0)

# Set the parameters by cross-validation
tuned_parameters = [{'kernel': ['rbf'], 'gamma': [1e-3, 1e-4],
                     'C': [1, 10, 100, 1000]},
                    {'kernel': ['linear'], 'C': [1, 10, 100, 1000]}]

scores = ['precision', 'recall']

for score in scores:
    print("#hyper-parameters para %s" % score)
    print()

    clf = GridSearchCV(SVC(), tuned_parameters, cv=5,
                       scoring='%s_macro' % score)
    clf.fit(X_train, y_train)

    print("Mejores parámetros encontrados:")
    print()
    print(clf.best_params_)
    print()
    print("Grid scores on development set:")
    print()
    means = clf.cv_results_['mean_test_score']
    stds = clf.cv_results_['std_test_score']
    for mean, std, params in zip(means, stds, clf.cv_results_['params']):
        print("%0.3f (+/-%0.03f) for %r"
              % (mean, std * 2, params))
    print()

    print("Reporte detallado de clasificación:")
    print()
    print("The model is trained on the full development set.")
    print("The scores are computed on the full evaluation set.")
    print()
    y_true, y_pred = y_test, clf.predict(X_test)
    print(classification_report(y_true, y_pred))
    print()
```

    #hyper-parameters para precision

    Mejores parámetros encontrados:

    {'C': 10, 'gamma': 0.001, 'kernel': 'rbf'}

    Grid scores on development set:

    0.986 (+/-0.016) for {'C': 1, 'gamma': 0.001, 'kernel': 'rbf'}
    0.959 (+/-0.029) for {'C': 1, 'gamma': 0.0001, 'kernel': 'rbf'}
    0.988 (+/-0.017) for {'C': 10, 'gamma': 0.001, 'kernel': 'rbf'}
    0.982 (+/-0.026) for {'C': 10, 'gamma': 0.0001, 'kernel': 'rbf'}
    0.988 (+/-0.017) for {'C': 100, 'gamma': 0.001, 'kernel': 'rbf'}
    0.982 (+/-0.025) for {'C': 100, 'gamma': 0.0001, 'kernel': 'rbf'}
    0.988 (+/-0.017) for {'C': 1000, 'gamma': 0.001, 'kernel': 'rbf'}
    0.982 (+/-0.025) for {'C': 1000, 'gamma': 0.0001, 'kernel': 'rbf'}
    0.975 (+/-0.014) for {'C': 1, 'kernel': 'linear'}
    0.975 (+/-0.014) for {'C': 10, 'kernel': 'linear'}
    0.975 (+/-0.014) for {'C': 100, 'kernel': 'linear'}
    0.975 (+/-0.014) for {'C': 1000, 'kernel': 'linear'}

    Reporte detallado de clasificación:

    The model is trained on the full development set.
    The scores are computed on the full evaluation set.

                  precision    recall  f1-score   support

               0       1.00      1.00      1.00        89
               1       0.97      1.00      0.98        90
               2       0.99      0.98      0.98        92
               3       1.00      0.99      0.99        93
               4       1.00      1.00      1.00        76
               5       0.99      0.98      0.99       108
               6       0.99      1.00      0.99        89
               7       0.99      1.00      0.99        78
               8       1.00      0.98      0.99        92
               9       0.99      0.99      0.99        92

        accuracy                           0.99       899
       macro avg       0.99      0.99      0.99       899
    weighted avg       0.99      0.99      0.99       899

    #hyper-parameters para recall

    Mejores parámetros encontrados:

    {'C': 10, 'gamma': 0.001, 'kernel': 'rbf'}

    Grid scores on development set:

    0.986 (+/-0.019) for {'C': 1, 'gamma': 0.001, 'kernel': 'rbf'}
    0.957 (+/-0.029) for {'C': 1, 'gamma': 0.0001, 'kernel': 'rbf'}
    0.987 (+/-0.019) for {'C': 10, 'gamma': 0.001, 'kernel': 'rbf'}
    0.981 (+/-0.028) for {'C': 10, 'gamma': 0.0001, 'kernel': 'rbf'}
    0.987 (+/-0.019) for {'C': 100, 'gamma': 0.001, 'kernel': 'rbf'}
    0.981 (+/-0.026) for {'C': 100, 'gamma': 0.0001, 'kernel': 'rbf'}
    0.987 (+/-0.019) for {'C': 1000, 'gamma': 0.001, 'kernel': 'rbf'}
    0.981 (+/-0.026) for {'C': 1000, 'gamma': 0.0001, 'kernel': 'rbf'}
    0.972 (+/-0.012) for {'C': 1, 'kernel': 'linear'}
    0.972 (+/-0.012) for {'C': 10, 'kernel': 'linear'}
    0.972 (+/-0.012) for {'C': 100, 'kernel': 'linear'}
    0.972 (+/-0.012) for {'C': 1000, 'kernel': 'linear'}

    Reporte detallado de clasificación:

    The model is trained on the full development set.
    The scores are computed on the full evaluation set.

                  precision    recall  f1-score   support

               0       1.00      1.00      1.00        89
               1       0.97      1.00      0.98        90
               2       0.99      0.98      0.98        92
               3       1.00      0.99      0.99        93
               4       1.00      1.00      1.00        76
               5       0.99      0.98      0.99       108
               6       0.99      1.00      0.99        89
               7       0.99      1.00      0.99        78
               8       1.00      0.98      0.99        92
               9       0.99      0.99      0.99        92

        accuracy                           0.99       899
       macro avg       0.99      0.99      0.99       899
    weighted avg       0.99      0.99      0.99       899

## Eliminación de características:

Es posible realizar una eliminación recursiva de características con una sintonización automática del número de características seleccionadas con cross-validation. Para realizar esta aplicación primero es importante presentar algunas funciones útiles.

### Nuevo! make_classification

Genera un objeto para solucionar un problema multiclase, en general. Este objeto soluciona el problema creando clusters de puntos normalmente distribuidos sobre los vértices de un n_informativo hipercubo con lados de tamaño $2*class_sep$. Introduce interdependencia entre las características y añade diferentes tipos de ruido a los datos.
to the data.

### Nuevo! RFECV
Realiza RANKING con eliminación recursiva y validación cruzada de características de las mejores características de los modelos.

```python
import matplotlib.pyplot as plt
from sklearn.svm import SVC
from sklearn.model_selection import StratifiedKFold
from sklearn.feature_selection import RFECV
from sklearn.datasets import make_classification

# Crea un problema de clasificación, con 3 n_informativos
X, y = make_classification(n_samples=1000, n_features=25, n_informative=3,
                           n_redundant=2, n_repeated=0, n_classes=8,
                           n_clusters_per_class=1, random_state=0)

# Crea un objeto de clasificador.
svc = SVC(kernel="linear")

# Crea un objeto de ranking, el ESTIMADOR buscado es el clasificador SVC y el criterio es acuracy.
rfecv = RFECV(estimator=svc, step=1, cv=StratifiedKFold(2),
              scoring='accuracy')
rfecv.fit(X, y)

print("Número óptimo de características : %d" % rfecv.n_features_)

# Número de features en función del score de cross validation
plt.figure(figsize=(10,10))
plt.xlabel("Número de características seleccionadas")
plt.ylabel("Puntaje de validación cruzada (Número de clasificaciones correctas)")
plt.plot(range(1, len(rfecv.grid_scores_) + 1), rfecv.grid_scores_)
plt.show()
```

```python

```

```python

```


# Lecture 1 Statistical Learning Theory

## 1. 二分类问题
考虑最简单的二分类问题，给出如下基本定义：

$$\begin{alignat}{2}
&回归函数(regression function) &\qquad &\eta(x) = P(Y=1|X) \\
&分类器(classifier) &\qquad &h:\mathscr{X} \rightarrow \{0, 1\} \\
&分类误差(classification \ error) &\qquad &R(h) = P(h(X) \neq Y)=\int \mathbb{1}(h(X) \neq Y)dP(X, Y)\\
\end{alignat}$$

在决策理论有一个基本的定理，在对分类问题所有信息已知的情况下(回归函数已知)，贝叶斯分类器(Bayes classifier)是所有分类器中分类误差最小的分类器，其它分类器与贝叶斯分类的分类误差之差称为溢出风险(excess risk)。

$$\begin{alignat}{2}
&贝叶斯分类器 &\qquad& h^* = \inf_{h\in \Omega} R(h) = \mathbb{1}(\eta(X) > 1/2) \quad (其中\Omega为从\mathscr{X}到\{0, 1\}的可测函数组成的集合) \\
&溢出风险 & \qquad & \epsilon(h) = R(h) - R(h^*) \\
\end{alignat}$$

##  2. 经验风险
由于在一般问题中，数据分布$P(X, Y)$是未知的，只有观测数据集$D_n = \{(x_i, y_i)\}_{i=1,2,...,n}$</sub>(含有$P(X,Y)$的部分信息)，没法求分类器h的分类误差$R(h)$，只能求相应的经验风险，并由经验误差定义经验误差最小点(empirical risk minimizer, ERM)。

$$\begin{alignat}{2}
&经验风险(empirical \ risk)&\qquad& \hat{R}_n = \frac{1}{n}\sum_{i=1}^n \mathbb{1}(h(X) \neq Y)\\
&经验风险最小点(ERM) &\qquad& \hat{h}_n = \min_{h \in \mathscr{H}} \hat{R}_n(h)
\quad (\mathscr{H}代表分类器的假设空间) \\
\end{alignat}$$

**注意**：经验风险是一个关于观测样本集合$D_n$的统计量，假设$D_n$中样本独立同分布，则显然经验风险是分类误差的无偏估计量，且由大数定理可知，随着n的增大，经验风险以概率1趋于分类误差。

可以预料的是，经验风险最小点(ERM)不一定能泛化到训练集之外的数据。这就是大名鼎鼎的**没有免费的午餐定理**(no-free-lunch theorem)。NFL定理表明如果不对$P(X,Y)$或假设空间$\mathscr{H}$做出任何假设，那么存在一种分布$P(X, Y)$使得贝叶斯分类器$h^*$的分类误差为0(没有噪声)，而对于经验风险最小点$\hat{h_n}$，$\mathbb{E}_{D_n}[R(\hat{h_n})]$以任意精度趋于$1/2$; 而且随着$n$的增加，且存在一种分布$P(X, Y)$使得$R(h^*)=0$而$\mathbb{E}_{D_n}[R(\hat{h_n})]$收敛于0的速率任意慢。

## 3. 产生式模型和判别式模型
NFL定理告诉我们需要对$P(X,Y)$进行约束(假设)，降低分布的自由度，但目前并没有清晰的结论告诉我们应该怎样选择约束。按照约束(或者说modeling)的方式可以分为产生式模型(generative model)和判别式模型(discriminative model)，有观点认为产生式模型是统计学的方法而判别式模型是机器学习的方法。

产生式模型：对联合分布$P(X,Y)$进行约束，比如LDA，QDA，逻辑回归(虽然一般分入判别式模型，但可以给出一个产生式模型的解释)。

判别式模型：对$P(Y|X)$进行约束或者说直接对分类器的假设空间$\mathscr{H}$进行约束，比如神经网络、深度学习。

该课程主要关注**判别式模型**的问题。

## 4. 估计和逼近
假设我们已经给定了一个假设空间$\mathscr{H}$，认为在该空间中分类器的表现较好。这个假设空间可能来自对于该领域的知识，也可能仅仅是计算方便。对于从数据集获得的经验风险最小值$\hat{h_n}$,我们可以将它的溢出风险(excess risk)分解成两部分：

$$\begin{equation}
\epsilon(\hat{h}_n) = R(\hat{h}_n) - R(h^*) = \underbrace{R(\hat{h}_n) - \inf_{h\in\mathscr{H}}R(h)}_{估计误差} + \underbrace{\inf_{h\in\mathscr{H}}R(h) - R(h^*)}_{逼近误差}
\end{equation}$$

下式为逼近误差(approximation error),它的起因是因为假设空间$\mathscr{H}$和所有分类器(可测函数)组成集合$\Omega$之间的差距。但对于现在的深度学习、机器学习模型来说，逼近误差并不占主要的部分。比如神经网络的万能逼近定理可以保证，三层神经网络作为假设空间$\mathscr{H}$，可以将$\Omega$中任意一个函数逼近至任意精度。

$$逼近误差: \qquad \inf_{h\in\mathscr{H}}R(h) - R(h^*)= R(\bar{h}) - R(h^*)$$

下式称为估计误差(estimation error)，它的起因是因为训练集$D_n$中只有有限的样本，只有关于分布$P(X,Y)$的部分信息。该误差可以随着$n\rightarrow \infty$而趋于0，但在NFL定理中我们知道，如果令$\mathscr{H} = \Omega$这是不会发生的，因此我们需要把$\mathscr{H}$取得足够小，但$\mathscr{H}$太小会导致逼近误差上升，逼近误差和估计误差之间有一个权衡(trade-off)。

$$估计误差: \qquad R(\hat{h}_n) - \inf_{h\in\mathscr{H}}R(h) = R(\hat{h}_n) - R(\bar{h})$$

**注意**：统计学习理论更关注**估计误差**，探究估计误差如何随着变元$n$和$\mathscr{H}$趋于0。

## 5. 估计误差
统计学习理论主要研究**估计误差**，可以将估计误差再分解成如下两部分：
$$ \begin{aligned}
\qquad R(\hat{h}_n) - \inf_{h\in\mathscr{H}}R(h) &= R(\hat{h}_n) - R(\bar{h}) \\
&=(R(\hat{h}_n) - \hat{R}_n(\hat{h}_n)) + \underbrace{(\hat{R}_n(\hat{h}_n) - \hat{R}_n(\bar{h}))}_{\leq 0} + (\hat{R}_n(\bar{h}) -R(\bar{h})) \\
& \leq \vert \hat{R}_n(\hat{h}_n) - R(\hat{h}_n)) \vert + \vert \hat{R}_n(\bar{h}) -R(\bar{h}) \vert
\end{aligned}$$

1. 其中$\bar{h}$是确定的(deterministic)，$\vert \hat{R}_n(\bar{h}) -R(\bar{h}) \vert$关于$n$的界通过集中不等式(concentration inequalities)获得(比如Hoeffding不等式和Bernstein不等式)。

2. 而$\hat{R}_n(\hat{h}_n) = \frac{1}{n}\sum_{i=1}^n \mathbb{1}(\hat{h}_n(X_i)\neq Y_i)$并不是独立随机变量的平均值，因为$\hat{R}_n$是通过训练集$D_n$得到的，因此没法用集中不等式来给出它的一个上界。这时需要借用“sup-out”技术去掉$\hat{h}_n$，并给出一个上界：

$$\begin{equation}
\vert \hat{R}_n(\hat{h}_n) - R(\hat{h}_n)) \vert \leq \sup_{h\in \mathscr{H}}\vert \hat{R}_n(h) - R(h) \vert
\end{equation} $$

**总结**：以上就是统计学习理论的一个主线。逼近误差和估计误差的trade-off在统计中也被称为偏差和方差的trade-off。


# Unsupervised Quantum Learning with Max-cut

This notebook is an example of unsupervised machine learning on a quantum computer. Both classical and quantum algorithms are used in classifying the iris dataset.

First, we'll warm up on the classical k-means clustering algorithm. Hopefully, this quick review will refresh you on unsupervised learning. Next, we go quantum. The data are mapped to a graph; the [max-cut problem](https://en.wikipedia.org/wiki/Maximum_cut) is mapped to an Ising Hamiltonian; and QAOA solves for the ground state. If you want more details for each step check out this [kaggle tutorial](https://www.kaggle.com/efeergun96/unsupervised-learning-on-iris) on k-means, this [qiskit tutorial](https://github.com/qiskit-community/qiskit-qcgpu-provider/blob/master/examples/aqua/Max-Cut.ipynb) on max-cut, or the paper [*Unsupervised Machine Learning on a Hybrid Quantum Computer*](https://arxiv.org/abs/1712.05771) on going quantum. There are plenty of other resource out there, but these are the ones I've found useful.

As usual, we import packages and take a peak at the data we'll be playing with.

```python
import numpy as np
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.datasets import load_iris

import seaborn as sns
import matplotlib.pyplot as plt
%matplotlib inline
```

```python
# Import Iris dataset
iris_data = load_iris()
df = pd.DataFrame(iris_data.data, columns=iris_data.feature_names)
df['species'] = pd.Categorical.from_codes(iris_data.target, iris_data.target_names).astype(str)

# View data with known labels as a control to compare future clustering done by k-means and QAOA
sns.pairplot(data=df, hue="species", palette="husl")
plt.show()
```

At a glance, it looks like there are ~100+ data points grouped into three species with versicolor and virginica much closer to each other.

Since the quantum computer will be simulated on laptops and not supercomputers, **let's reduce the dataset** to ~12 for a reasonable run time. Though the details haven't been explained yet, max-cut can only split data into two clusters. (More on that later.) For now, we'll need remove one of the species. To be nice to the quantum computer, let's remove virginica. If it can't cluster setosa and versicolor, we know quantum computers are really in trouble. If it can... well, maybe they really do have a shot at unsupervised learning.

```python
# Generate subset of data with fewer data points
df_sub = df.iloc[::9,:].reset_index(drop=True)

# Constrain data to 2 species
df_sub = df_sub.loc[(df_sub['species'] == 'setosa') | (df_sub['species'] == 'versicolor')]

# View data with known labels as a control to compare future clustering done by k-means and QAOA
sns.pairplot(data=df_sub, hue="species", palette="husl")
plt.show()
```

Some dimensions of the feature space show the setosa and versicolor distributions quite close (e.g. sepal width vs. sepal length), while others have them separated (e.g. petal length vs. petal width). Those close features will make it harder to cluster, but with the more separated features, there's definitely room for accurate clustering. Let's see how k-means handles this.

## A Classical Approach
K-means groups data into k clusters by minimizing each cluster's sum-of-squares also known as inertia

$$
\sum_{i=0}^{n} \min_{\mu_j \in C}(\lvert\lvert x_i - \mu_j\rvert\rvert^2)
$$

where $\mu_j$ is the mean of the jth cluster within the set $C$ of clusters.
(See scikit-learn's [clustering user guide](https://scikit-learn.org/stable/modules/clustering.html#k-means) for more details.) The optimal number of clusters $k$ is known for this data ($k=2$) since the reduced dataset only contains two species. However, let's pretend that's unknown as that would be the case for a real unsupervised learning application. We can use the [elbow rule](https://en.wikipedia.org/wiki/Elbow_method_(clustering)) to find our "unknown" optimal $k$.

```python
# Remove species labels (otherwise it's not unsupervised learning!)
df_sub_wo_labels = df_sub.loc[:,['sepal length (cm)','sepal width (cm)','petal length (cm)','petal width (cm)']]

# Elbow rule: compute inertia for difference k's
inertia = []
K = range(1, len(df_sub_wo_labels))
for k in K:
    kmeans = KMeans(n_clusters=k, random_state=0).fit(df_sub_wo_labels)
    inertia.append(kmeans.inertia_)

# Visualize elbow rule
plt.plot(K, inertia)
plt.xlabel('Number of clusters')
plt.ylabel('Sum of squares distances about centroids')
plt.show()
```

Two is obviously the most "elbowy" and thus the optimal $k$. Let's fit and visualize the k-means algorithm with the optimal $k = 2$.

```python
# Use optimal k for final k-means model
optimal_k = 2
kmeans = KMeans(n_clusters=optimal_k, random_state=0).fit(df_sub_wo_labels)

# Add k-means labeling to dataframe for later comparison
df_sub['label'] = kmeans.labels_

# Visualize clustering done by k-means algorithm
sns.pairplot(data=df_sub, hue='label', palette="husl", vars=df_sub.columns[:-2])
plt.show()
```

The plots looks identical to the reduced dataset plot! Yay! With $k = 2$, the k-means algorithm clustering did a fantastic job on the iris dataset! The elbow rule really pulled through. Let's see how the quantum computer fares.

## A Quantum Approach

One approach to unsupervised quantum machine learning is to map the problem to a graph optimization problem (specifically max-cut in this notebook). The graph optimization problem can then be mapped to a cost Hamiltonian which can be efficiently solved by a quantum computer. (Cost Hamiltonians are the quantum computer version of cost functions.)

### Make a Graph

The first step is to map the data to a graph by calculating the pairwise "distances" between each data point. These distances will weight the edges of the graph. Let's use the $l^2\text{-norm}$ (i.e. vector magnitude). Remember, we're using a subset of the iris dataset to facilitate faster computation.

```python
# Get number of data entries
n_instances = len(df_sub_wo_labels)

# Convert dataframe into array
data_array = df_sub_wo_labels.values

# Calculate pairwise L2-norms (better performance for larger datasets can be acheived by using numba)
w = np.zeros((n_instances, n_instances))
for i in range(0, n_instances):
    for j in range(0, n_instances):
        w[i, j] = np.linalg.norm(data_array[i] - data_array[j])

print('Weight matrix size:', w.shape)
```

    Weight matrix size: (12, 12)

### Cast Max-cut as a Cost Hamiltonian

To separate the graph into clusters, the graph is cut with a [max-cut](https://en.wikipedia.org/wiki/Maximum_cut): meaning the graph is separated in two while maximizing the total weight of the 'cut' edges. This is an NP-hard problem. However, it maps to an Ising model, which can be solved efficiently on a quantum computer! By efficiently, I mean it can solve the max-cut problem **FASTER** than a classical computer (theoretically speaking). **That is why this method is so interesting and important.** Though, it's been [proposed](https://www.nature.com/articles/s41598-019-43176-9) this won't actually be the case until there are quantum computers with hundreds of qubits.

Let's max-cut!

The cost of one cut between nodes $i$ and $j$ is the edge's weight $w_{ij}$ that lies between them. In separating the graph into two sets of nodes ($S_1$ for cluster 1 and $S_2$ for cluster 2), the total weight of a cut $\delta(S)$ is

$$
w(\delta(S)) = \sum_{i\in S_1, j\in S_2} w_{ij}.
$$

Assuming a fully connected graph and accounting for the symmetry of $w_{ij}$ (i.e. $w_{ij} = w_{ji}$), the sum can be expanded to
$$
\begin{align}
w(\delta(S)) & = \frac{1}{2}\sum_{(ij) \in \delta(S_1)} w_{ij} \\
& = \frac{1}{4}\sum_{ij} w_{ij} - \frac{1}{4} \sum_{ij} w_{ij} \hat{\sigma}_i^z \hat{\sigma}_j^z \\
& = \frac{1}{4}\sum_{ij} w_{ij} (1- \hat{\sigma}_i^z \hat{\sigma}_j^z).
\end{align}
$$

We can explicitly see it's connection to the Ising Hamiltonian (external field $h = 0$ and irrelevant constant $C$)

$$
H_{ising} = -\sum_{ij}J_{ij}\hat{\sigma}_i^z\hat{\sigma}_j^z + C
$$

where $J_{ij} = \frac{w_{ij}}{4}$. We interpret the output of the Ising model as follows. The spin variables $\hat{\sigma}_i^z \in \{-1, +1\}$ take on the value $\hat{\sigma}_i^z = +1$ for data in cluster 0, and $\hat{\sigma}_i^z = -1$ for data in cluster 1. To solve the optimization problem, the quantum computer finds the ground state of $H_{ising}$ and we're done! There are a variety of algorithms that can find the ground state of a cost Hamiltonian. In this notebook, we will use QAOA.

Side note: The Ising model is conventionally written as a sum over all **nearest neighbor** pairs $\sum_{<ij>}$. Since the graph is fully connected (or can be made fully connected by adding edges of weight zero), $\sum_{<ij>}$ is identical to the double sum $\sum_{ij}$.

The ideas and derivation presented here are directly found in the paper [*Unsupervised Machine Learning on a Hybrid Quantum Computer*](https://arxiv.org/abs/1712.05771).

### Solve the Max-cut Problem with QAOA

[QAOA](https://arxiv.org/abs/1411.4028) is a quantum optimizer that will adiabatically find the ground state of a cost Hamiltonian--exactly what we need! Thankfully, [qiskit](https://qiskit.org) has a QAOA function all ready for us to use! Let's import the packages, initialize the QAOA algorithm with the weights $w$ we previously calculated and run it!!

```python
# Packages needs to access an IBM quantum computer. Not necessary for simulator.
# from qiskit import IBMQ
# IBMQ.load_account()  # Load account from disk

# Quantum Computing packages
from qiskit import BasicAer
from qiskit.aqua import QuantumInstance
from qiskit.aqua.algorithms import QAOA
from qiskit.aqua.translators.ising import max_cut
from qiskit.aqua.components.optimizers import COBYLA
```

```python
# QAOA hyperparameters and initialization
p = 1  # Number of adiabatic steps must be > 0
optimizer = COBYLA()  # Classical optimizer
qubit_ops, offset = max_cut.get_max_cut_qubitops(w)  # Compute qubit operations for QAOA algo
qaoa = QAOA(qubit_ops, optimizer, p)  # initialize QAOA algorithm

# Initialize quantum simulator
backend = BasicAer.get_backend('statevector_simulator')  # Simulate on local machine

# Initialize quantum computer
# provider = IBMQ.get_provider(group='open')  # Load provider to access IBM's cloud services
# backend = provider.get_backend('ibmq_essex')  # Compute on one of IBM's quantum computer

quantum_instance = QuantumInstance(backend, shots=1)
```

```python
# Execute QAOA on quantum simulator (or real device)
result = qaoa.run(quantum_instance)

# Extract results
x = max_cut.sample_most_likely(result['eigvecs'][0])

# Extract cluster labels and include them in df_sub
labels = pd.DataFrame(max_cut.get_graph_solution(x), columns=['label'])
df_sub['label'] = labels

# Show data by cluster
sns.pairplot(data=df_sub,hue='label', palette="husl", vars=df_sub.columns[:-2])
plt.show()
```

The plots look identical to the reduced dataset! All of setosa is in cluster 0, and all of versicolor is in cluster 1. For this simple example of solving max-cut with QAOA, the results look spectacular!

### Your turn
A great way to learn is to get your hands dirty. Mess around with this notebook and see what happens! Here are some questions to inspire your play:
* How do these results change when I execute on a real quantum computer?
* What happens if I filter out fewer data points?
* What happens if I include some data from the third species?
* What happens if I change the number of adiabatic steps (p) in the QAOA hyperparameters?
* What if I use VQE instead of QAOA to find the ground state of the cost Hamiltonian?

### Next time
**What about the third species?** We ignored it here because max-cut only classifies data into two clusters. Is there a way to use max-cut to give a 3 cluster classification? Or for that matter, any number of cluster classifications? That would make for a much more useful unsupervised learning algorithm. Hint: the answer is YES. Let's discuss it in the [next notebook](https://github.com/ajrazander/Unsupervised-QML/blob/master/Max-cut_2%2B_divisive_clustering.ipynb).