# Sympy in Colab Example

Rendering sympy equations requires MathJax to be available within each cell output. The following is a function that will make this happen.

```python
from IPython.display import Math, HTML
def enable_sympy_in_cell():
  display(HTML(""))
get_ipython().events.register('pre_run_cell', enable_sympy_in_cell)
```

After registering this hook, SymPy rendering will work correctly:

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

x, y, z = symbols('x y z')
Integral(sqrt(1/x), (x, 0, oo))
```

```python

```


# **Семинар №2. Обработка сигналов**

```python
import cv2
import numpy as np
import matplotlib.pyplot as plt
from math import sqrt,exp

%matplotlib inline
```

# **Преобразование Фурье**

Давайте поговорим немного о частотном пространстве перед тем, как углубимся в детали. Термин частота происходит из физики, как некоторое изменение во времени, описание характеристики некоторого периодического движения или поведения. Например, термин частота в компьютерном зрении обычно связан с вариацией яркости или цвета вдоль изображения, то есть является функцией пространственных координат, а не времени. Некоторые книги даже называют ее пространственной частотой (spatial frequency).

Например, если изображение, представленное в частотном пространстве, имеет высокие частоты, то это означает, что на нем присутствуют острые углы или детали. Давайте посмотрим на рисунки ниже, слева изображено исходное изображение, а справа — частотный график этой картинки.

```python
def centered_spectrum(image):
    spectrum = np.fft.fft2(image)
    spectrum = np.fft.fftshift(spectrum)
    return spectrum
```

```python
for i in range(7):
    image = cv2.imread(f'img/test_freq{i}.png', 0)
    spectrum = centered_spectrum(image)

    plt.figure(figsize=(6.4*2, 4.8*2), constrained_layout=False)
    plt.subplot(121), plt.imshow(image, "gray"), plt.title("Original Image")
    plt.axis('off')
    plt.subplot(122), plt.imshow(np.log(1+np.abs(spectrum)), "gray"), plt.title("Spectrum")
    plt.axis('off')
    plt.show()
```

Если у вас возникли проблемы с пониманием частотных графиков: члены ряда с низкой частотой находятся в центре квадрата, а члены с высокой частотой — на краях. Представьте невидимые оси с началом координат в центре квадрата. Теперь, частотное пространство на первой сверху слева картинке состоит как из высоких частот, так и из низких, поэтому исходное изображение имеет резкие границы. Однако второе изображение более нечеткое, и, конечно, график частоты для него имеет меньше высокочастотных членов.

Частотная область вносит существенные преимущества в обработку изображений (image processing). Она позволяет делать крупные операции фильтрации гораздо быстрее. Так как информация об изображении хранится в частотах, то это позволяет иногда отделить сигнал от шума, а также осуществлять различные вычисления, которые были бы очень трудоемкими в пространственной области. Кроме того Fourier Transform позволяет легко осуществлять переход из пространственной области в частотную и наоборот.

Например, допустим, у нас есть изображение с некоторым периодичным шумом, который мы хотели бы устранить. (Представьте себе отсканированную страницу с сероватыми полосами.) Если перевести данные изображения в частотное пространство, то любой периодический шум в исходной картинке будет отображаться как яркие пятна на диаграмме в частотном пространстве. Если мы устраним эти точки и применим inverse Fourier Transform, чтобы получить исходное изображение, мы сможем удалить большую часть шума и тем самым улучшить видимость изображения.

Начальное изображение:

```python
plt.figure(figsize=(6.4*5, 4.8*5), constrained_layout=False)

img_c1 = cv2.imread("img/lena_noise_image.png", 0)
img_c2 = np.fft.fft2(img_c1)
img_c3 = np.fft.fftshift(img_c2)
img_c4 = np.fft.ifftshift(img_c3)
img_c5 = np.fft.ifft2(img_c4)

plt.subplot(151), plt.imshow(img_c1, "gray"), plt.title("Original Image")
plt.subplot(152), plt.imshow(np.log(1+np.abs(img_c2)), "gray"), plt.title("Spectrum")
plt.subplot(153), plt.imshow(np.log(1+np.abs(img_c3)), "gray"), plt.title("Centered Spectrum")
plt.subplot(154), plt.imshow(np.log(1+np.abs(img_c4)), "gray"), plt.title("Decentralized")
plt.subplot(155), plt.imshow(np.abs(img_c5), "gray"), plt.title("Processed Image")

plt.show()
```

## Вспомним уравнения

Пусть $f(x)$ — непрерывная функция вещественной переменной  $x$. Преобразование Фурье (Fourier Transform) функции  $f(x)$  определяется уравнением:

$F(u)=\int^{-\infty}_{\infty}f(x)e^{-i2\pi ux}\,dx$

где  $i=\sqrt{-1}$  и  u  часто называется частотной переменной. Суммирование синусов и косинусов может быть не таким очевидным на первый взгляд, но, применяя уравнение Эйлера, получаем:

$F(u)=\int^{-\infty}_{\infty}f(x)(cos 2\pi ux -i sin 2\pi ux)\,dx$

По данному $F(u)$ мы можем вернуться назад и получить $f(x)$, используя обратное преобразование Фурье (inverse Fourier Transform):

$f(x)=\int^{-\infty}_{\infty}F(u)e^{i2\pi ux}\,dx$

Обратите внимание, что единственная разница между прямым и обратным преобразованием Фурье это знак, который легко позволяет перемещаться назад и вперед между пространственным и частотным областями; это одна из характеристик, которые делают преобразование Фурье полезным.

Некоторые могут спросить, что же такое  $F(u)$.  $F(u)$ это как раз данные в частотном пространстве, о котором мы говорили в первой секции. Даже если мы начинаем с вещественной функции $f(x)$  в пространственной области, мы обычно заканчиваем комплексными значениями  $F(u)$. Это происходит потому, что вещественное число, умноженное на комплексное, дает комплексное число.

Итак, $f(x)e^{i2\pi ux}$  — комплексное, поэтому сумма этих членов должна также дать комплексное число, то есть  $F(u)$. Поэтому,

$F(u)=R(u)+iI(u)$

где $R(u)$ является вещественной компонентой (члены, не содержащие  $i$) и  $I(u)$ есть комплексная компонента (члены, содержащие $i$); $(u)$ написано, чтобы напомнить, что члены являются функциями от $u$. Как любые другие комплексные числа, мы также можем записать их в полярной форме, дающей:

$F(u)=r(sin \theta + i cos \theta)=re^{i\theta}$

В большинстве учебников эта форма Fourier Transform записывается в виде:

$F(u)=\left|F(u)\right|e^{i\theta(u)}$

но это по сути одно и то же.

Существует несколько часто используемых слов, когда речь идет о преобразовании Фурье.  Модуль  $\left|F(u)\right|$ называется спектром Фурье (Fourier spectrum) функции  $f(x)$  и  $\theta(u)$  это фазовый угол (phase angle). Квадрат спектра,  $\left|F(u)\right|^2=R^2(u)+I^2(u)$  часто обозначается как  $P(u)$  и называется спектром мощности (power spectrum) функции  $f(x)$. Термин плотность спектра (spectral density) также широко используется для обозначения спектра мощности. Спектр Фурье часто строится от $u$.

<td>  </td>

На картинке изображена простая функция и ее спектр Фурье.

Заметьте, что сами $F(u)$  трудно построить от  $u$  в 2-D пространстве, так как они комплексные числа.

### Дискретное преобразование Фурье

Учитываются  $N$  дискретных значений  $f(x)$, отобранных с единым шагом,

$F(u)=\frac{1}{N}\sum_{x=0}^{N-1}f(x)e^{-i2\pi ux/N}$

для  $u=0,1,2,...,N-1,$  и

$f(x)=\sum_{u=0}^{N-1}F(u)e^{i2\pi ux/N}$

для  $x=0,1,2,...,N-1$.

Обратите внимание, что интеграл заменяется на суммирование, которое является простым «for loop» при программировании. Для тех, кому любопытно, расчет внутри  $\sum$  это произведение  $f(x)=R+iI$  на  $e^{i2\pi ux/N}=cos(p)-isin(p)$  где  $p=2\pi ux/N$:

$f(x)=e^{i2\pi ux/N}=(R+iI)*(cos(p)-isin(p))$

$=Rcos(p)-Risin(p)+Iicos(p)-Ii^2sin(p)$

$=Rcos(p)-Risin(p)+Iicos(p)+Isin(p)$

$=(Rcos(p)+Isin(p))+i(Icos(p)-Rsin(p)),$

где  $R$,  $I$  — вещественные числа, и  $I=0$, когда $f(x)$  — вещественное число.

## Фильтры

Разделяют понятия о **низких** и **высоких** частотах.

**Фильтр низких частот** - это фильтр, который пропускает только низкие частоты. Низкие частоты в изображениях означают медленно изменяющиеся значения пикселей. Например, гладкая область со слегка изменяющимся цветом на изображении, например, центр новой пустой белой бумаги, считается низкочастотным содержимым.
Поскольку выходной фильтр низких частот пропускает только низкие частоты, содержимое высоких частот, такое как шумы, блокируется, что делает обработанное изображение менее шумным. Поэтому низкочастотный фильтр широко используется для удаления шумов в изображениях.

**Фильтр высоких частот**, напротив, является фильтром, который пропускает только высокие частоты. Высокие частоты в изображениях означают резко изменяющиеся значения пикселей. Например, области краев изображения с огромным изменением цвета, такие как края между двумя перекрывающимися белой и черной бумагой, считаются высокочастотным содержимым.
Выходной сигнал фильтра высоких частот захватывает края в изображении, которые могут быть использованы для повышения резкости исходного изображения с правильным расчетом перекрытия. Это повышает резкость исходного изображения, делая края более четкими.

### Ideal Filter

Идея, которая лежит в основе идеального фильтра, очень проста: При значении радиуса D₀ в качестве порогового, фильтр нижних частот имеет H(u, v) равное 1 под пороговым значением, а H(u, v) равное 0 при превышении порогового значения.

<td>  </td>

<td>  </td>

```python
def distance(point1,point2):
    return sqrt((point1[0]-point2[0])**2 + (point1[1]-point2[1])**2)

def idealFilterLP(D0,imgShape):
    base = np.zeros(imgShape[:2])
    rows, cols = imgShape[:2]
    center = (rows/2,cols/2)
    for x in range(cols):
        for y in range(rows):
            if distance((y,x),center) < D0:
                base[y,x] = 1
    return base

def idealFilterHP(D0,imgShape):
    base = np.ones(imgShape[:2])
    rows, cols = imgShape[:2]
    center = (rows/2,cols/2)
    for x in range(cols):
        for y in range(rows):
            if distance((y,x),center) < D0:
                base[y,x] = 0
    return base
```

#### Low Pass Filter

```python
plt.figure(figsize=(6.4*6, 4.8*6), constrained_layout=False)

img = cv2.imread("img/lena_noise_image.png", 0)
plt.subplot(161), plt.imshow(img, "gray"), plt.title("Original Image")

original = np.fft.fft2(img)
plt.subplot(162), plt.imshow(np.log(1+np.abs(original)), "gray"), plt.title("Spectrum")

center = np.fft.fftshift(original)
plt.subplot(163), plt.imshow(np.log(1+np.abs(center)), "gray"), plt.title("Centered Spectrum")

LowPassCenter = center * idealFilterLP(50,img.shape)
plt.subplot(164), plt.imshow(np.log(1+np.abs(LowPassCenter)), "gray"), plt.title("Centered Spectrum multiply Low Pass Filter")

LowPass = np.fft.ifftshift(LowPassCenter)
plt.subplot(165), plt.imshow(np.log(1+np.abs(LowPass)), "gray"), plt.title("Decentralize")

inverse_LowPass = np.fft.ifft2(LowPass)
plt.subplot(166), plt.imshow(np.abs(inverse_LowPass), "gray"), plt.title("Processed Image")

plt.show()
```

#### High Pass Filter

```python
img = cv2.imread("img/lena_noise_image.png", 0)
original = np.fft.fft2(img)
center = np.fft.fftshift(original)

plt.figure(figsize=(6.4*5, 4.8*5), constrained_layout=False)

plt.subplot(161), plt.imshow(np.log(1+np.abs(center)), "gray"), plt.title("Spectrum")

HighPass = idealFilterHP(50,img.shape)
plt.subplot(162), plt.imshow(np.abs(HighPass), "gray"), plt.title("High Pass Filter")

HighPassCenter = center * idealFilterHP(50,img.shape)
plt.subplot(163), plt.imshow(np.log(1+np.abs(HighPassCenter)), "gray"), plt.title("Centered Spectrum multiply High Pass Filter")

HighPass = np.fft.ifftshift(HighPassCenter)
plt.subplot(164), plt.imshow(np.log(1+np.abs(HighPass)), "gray"), plt.title("Decentralize")

inverse_HighPass = np.fft.ifft2(HighPass)
plt.subplot(165), plt.imshow(np.abs(inverse_HighPass), "gray"), plt.title("Processed Image")

plt.show()
```

### Btterworth Filter

В отличие от идеального фильтра, фильтр Баттерворта не имеет резких разрывов, что дает четкое разделение между проходной и отфильтрованной частотами. Фильтр Баттерворта вводит в функцию новый параметр $n$. При работе с $n$ он влияет на чистоту среза между переданной и отфильтрованной частотами.

<td>  </td>

<td>  </td>

```python
def butterworthLP(D0,imgShape,n):
    base = np.zeros(imgShape[:2])
    rows, cols = imgShape[:2]
    center = (rows/2,cols/2)
    for x in range(cols):
        for y in range(rows):
            base[y,x] = 1/(1+(distance((y,x),center)/D0)**(2*n))
    return base

def butterworthHP(D0,imgShape,n):
    base = np.zeros(imgShape[:2])
    rows, cols = imgShape[:2]
    center = (rows/2,cols/2)
    for x in range(cols):
        for y in range(rows):
            base[y,x] = 1-1/(1+(distance((y,x),center)/D0)**(2*n))
    return base
```

```python
plt.figure(figsize=(6.4*5, 4.8*5), constrained_layout=False)

LowPass = idealFilterLP(50,img.shape)
plt.subplot(131), plt.imshow(LowPass, "gray"), plt.title("Ideal Low Pass Filter")

HighPass = idealFilterHP(50,img.shape)
plt.subplot(132), plt.imshow(HighPass, "gray"), plt.title("Ideal High Pass Filter")

plt.show()
```

```python
plt.figure(figsize=(6.4*5, 4.8*5), constrained_layout=False)

LowPass = butterworthLP(50,img.shape,20)
plt.subplot(131), plt.imshow(LowPass, "gray"), plt.title("Butterworth Low Pass Filter (n=20)")

HighPass = butterworthHP(50,img.shape,20)
plt.subplot(132), plt.imshow(HighPass, "gray"), plt.title("Butterworth High Pass Filter (n=20)")

plt.show()
```

```python
plt.figure(figsize=(6.4*5, 4.8*5), constrained_layout=False)

LowPass = butterworthLP(50,img.shape,3)
plt.subplot(131), plt.imshow(LowPass, "gray"), plt.title("Butterworth Low Pass Filter (n=3)")

HighPass = butterworthHP(50,img.shape,3)
plt.subplot(132), plt.imshow(HighPass, "gray"), plt.title("Butterworth High Pass Filter (n=3)")

plt.show()
```

### Gaussian Filter

Гауссовский фильтр - более плавная версия, чем Баттерворт. Отсечение между переданной и отфильтрованной частотами очень размыто, что приводит к более гладкой обработке изображений.

<td>  </td>

<td>  </td>

```python
def gaussianLP(D0,imgShape):
    base = np.zeros(imgShape[:2])
    rows, cols = imgShape[:2]
    center = (rows/2,cols/2)
    for x in range(cols):
        for y in range(rows):
            base[y,x] = exp(((-distance((y,x),center)**2)/(2*(D0**2))))
    return base

def gaussianHP(D0,imgShape):
    base = np.zeros(imgShape[:2])
    rows, cols = imgShape[:2]
    center = (rows/2,cols/2)
    for x in range(cols):
        for y in range(rows):
            base[y,x] = 1 - exp(((-distance((y,x),center)**2)/(2*(D0**2))))
    return base
```

```python
plt.figure(figsize=(6.4*5, 4.8*5), constrained_layout=False)

LowPass = gaussianLP(50,img.shape)
plt.subplot(131), plt.imshow(LowPass, "gray"), plt.title("Gaussian Low Pass Filter")

HighPass = gaussianHP(50,img.shape)
plt.subplot(132), plt.imshow(HighPass, "gray"), plt.title("Gaussian High Pass Filter")

plt.show()
```

### Summary

#### Только фильтры

Посмотрим сразу на все фильтры, рассмотренные ранее.

```python
plt.figure(figsize=(6.4*5, 4.8*5), constrained_layout=False)

IdealLP = idealFilterLP(50,img.shape)
plt.subplot(131), plt.imshow(IdealLP, "gray"), plt.title("Ideal Low Pass Filter")

ButterLP = butterworthLP(50,img.shape,10)
plt.subplot(132), plt.imshow(ButterLP, "gray"), plt.title("Butterworth Low Pass Filter (n=10)")

GaussianLP = gaussianLP(50,img.shape)
plt.subplot(133), plt.imshow(GaussianLP, "gray"), plt.title("Gaussian Low Pass Filter")

plt.figure(figsize=(6.4*5, 4.8*5), constrained_layout=False)
IdealHP = idealFilterHP(50,img.shape)
plt.subplot(231), plt.imshow(IdealHP, "gray"), plt.title("Ideal High Pass Filter")

ButterHP = butterworthHP(50,img.shape,10)
plt.subplot(232), plt.imshow(ButterHP, "gray"), plt.title("Butterworth High Pass Filter (n=10)")

GaussianHP = gaussianHP(50,img.shape)
plt.subplot(233), plt.imshow(GaussianHP, "gray"), plt.title("Gaussian High Pass Filter")

plt.show()
```

#### Фильтры на изображении

Теперь применим каждый из этих фильтров к исходному изображению и посмотрим на результат.

```python
img = cv2.imread("img/lena_noise_image.png", 0)
original = np.fft.fft2(img)
center = np.fft.fftshift(original)

plt.figure(figsize=(6.4*5, 4.8*5), constrained_layout=False)

LowPassCenter = center * idealFilterLP(50,img.shape)
LowPass = np.fft.ifftshift(LowPassCenter)
inverse_LowPass = np.fft.ifft2(LowPass)
plt.subplot(131), plt.imshow(np.abs(inverse_LowPass), "gray"), plt.title("Ideal Low Pass")

LowPassCenter = center * butterworthLP(50,img.shape,10)
LowPass = np.fft.ifftshift(LowPassCenter)
inverse_LowPass = np.fft.ifft2(LowPass)
plt.subplot(132), plt.imshow(np.abs(inverse_LowPass), "gray"), plt.title("Butterworth Low Pass (n=10)")

LowPassCenter = center * gaussianLP(50,img.shape)
LowPass = np.fft.ifftshift(LowPassCenter)
inverse_LowPass = np.fft.ifft2(LowPass)
plt.subplot(133), plt.imshow(np.abs(inverse_LowPass), "gray"), plt.title("Gaussian Low Pass")

plt.show()
```

```python
img = cv2.imread("img/lena_noise_image.png", 0)
original = np.fft.fft2(img)
center = np.fft.fftshift(original)

plt.figure(figsize=(6.4*5, 4.8*5), constrained_layout=False)

HighPassCenter = center * idealFilterHP(50,img.shape)
HighPass = np.fft.ifftshift(HighPassCenter)
inverse_HighPass = np.fft.ifft2(HighPass)
plt.subplot(131), plt.imshow(np.abs(inverse_HighPass), "gray"), plt.title("Ideal High Pass")

HighPassCenter = center * butterworthHP(50,img.shape,10)
HighPass = np.fft.ifftshift(HighPassCenter)
inverse_HighPass = np.fft.ifft2(HighPass)
plt.subplot(132), plt.imshow(np.abs(inverse_HighPass), "gray"), plt.title("Butterworth High Pass (n=10)")

HighPassCenter = center * gaussianHP(50,img.shape)
HighPass = np.fft.ifftshift(HighPassCenter)
inverse_HighPass = np.fft.ifft2(HighPass)
plt.subplot(133), plt.imshow(np.abs(inverse_HighPass), "gray"), plt.title("Gaussian High Pass")

plt.show()
```

```python
img = cv2.imread("img/lena_noise_image.png", 0)
original = np.fft.fft2(img)
center = np.fft.fftshift(original)

plt.figure(figsize=(6.4*5, 4.8*5), constrained_layout=False)

plt.subplot(131), plt.imshow(img, "gray"), plt.title("Original Image")

LowPassCenter = center * gaussianLP(50,img.shape)
LowPass = np.fft.ifftshift(LowPassCenter)
inverse_LowPass = np.fft.ifft2(LowPass)
plt.subplot(132), plt.imshow(np.abs(inverse_LowPass), "gray"), plt.title("Gaussian Low Pass")

HighPassCenter = center * gaussianHP(50,img.shape)
HighPass = np.fft.ifftshift(HighPassCenter)
inverse_HighPass = np.fft.ifft2(HighPass)
plt.subplot(133), plt.imshow(np.abs(inverse_HighPass), "gray"), plt.title("Gaussian High Pass")

plt.show()
```

# **Свертки (теория)**

## Основы сверток
**Свертки** $-$ это метод общей обработки сигналов. Люди, изучающие электронику, скажут вам о почти бесконечных бессонных ночах, которые были потрачены на их постижение. На эту тему написано множество книг. Но для компьютерного зрения мы просто разберемся с некоторыми простыми вещами.

## Определение
Во-первых, давайте посмотрим на математическое определение свертки в дискретной области времени. Позже мы пройдемся по тому, что говорит нам это уравнение.
${\begin{align}y[n]&=x[n]*h[n]=\sum_{k=-\infty}^\infty x[k]\cdot h[n-k]\end{align},}$

где:
* **x[n]** $-$ входой сигнал;
* **h[n]** $-$ импульсный отклик;
* **y[n]** $-$ выходной сигнал;
* **$*$** $-$ обозначает свертку.

Обратите внимание, что мы умножаем слагаемые $x[k]$ на смещенные по времени $h[n]$ и складываем их.
Краеугольный камень понимания свертки лежит в основе импульсного отклика и импульсного разложения.

## Разложение импульсной функции

Чтобы понять смысл свертки, мы собираемся начать с концепции разложения сигналов. Входной сигнал разлагается на простые аддитивные компоненты, и системный отклик входного сигнала приводит к сложению выходных данных этих компонентов, прошедших через систему.

В общем, сигнал может быть разложен как взвешенная сумма базовых сигналов. Например, в рядах Фурье любой периодический сигнал (даже прямоугольный импульсный сигнал) может быть представлен суммой функций синуса и косинуса. Но здесь мы используем импульсные (дельта) функции для базовых сигналов, а не синус и косинус.

Изучим следующий пример того, как сигнал разлагается на набор импульсных (дельта) функций:

Импульсная функция выглядит так: ${\delta(n)={\begin{cases}1, n=0\\ 0, n\ne0 \end{cases}}}$.  Тогда для данного примера $x[0]$ можно записать в виде ${x[0] = x[0] \cdot \delta[n-0] = 2 \cdot \delta[n-0]}$. Тогда $x[1]$ будет равен ${x[1] = x[1] \cdot \delta[n-1] = 3 \cdot \delta[n-1]}$, потому что ${\delta[n-1]}$ равна $1$ при $n = 1$ и ноль в других случаях. Таким же образом мы можем записать ${x[2] = x[2] \cdot \delta[n-2] = 1 \cdot \delta[n-2]}$, сдвинув ${\delta[n]}$ на 2. Следовательно, сигнал $x[n]$ может быть представлен добавлением $3$ сдвинутых и масштабированных импульсных функций.

В общем, сигнал может быть записан как сумма масштабированных и сдвинутых дельта-функций:
${\begin{align}x[n]=\sum_{k=0}^2 x[k]\cdot\delta[n-k]=x[0]\cdot\delta[n-0]+x[1]\cdot\delta[n-1]+x[2]\cdot\delta[n-2]\end{align}}$

## Импульсивный отклик
**Импульсный отклик** $-$ это выход системы, являющийся результатом импульсной функции в качестве входа.
Именно это обозначается за $h[n]$. Все волшебство именно тут. Для вас это перестанет быть магией на 2-3 курсе инстиута.

Существует огромное множество преобразования для импульсного отклика. Мы рассмотрим только конечные формулы без понимания того, как работает математика.

<td>  </td>

## Свертка в 2D
2D свертка является просто продолжением предыдущей 1D свертки путем свертки как горизонтального, так и вертикального направлений в двухмерной пространственной области. Свертка часто используется для обработки изображений, таких как сглаживание, повышение резкости и обнаружение краев изображений.

Импульсная (дельта) функция также находится в двумерном пространстве, поэтому ${\delta[m,n]={\begin{cases}1,\ m=n=0\\0,\ m\ne0,n\ne0\end{cases}}}$. Импульсный отклик в 2D обычно называют **«ядром»** или **«фильтром»** в обработке изображений.

<table><tr>
    <td>  </td>
    <td>  </td>
</tr></table>

Кроме того, выходные данные линейной и инвариантной по времени системы могут быть записаны путем свертки входного сигнала $x[m,n]$ и импульсной характеристики $h[m,n]$:
${\begin{align}y[m,n]=x[m,n]*h[m,n]=\sum_{j=-\infty}^\infty \sum_{i=-\infty}^\infty x[i,j]\cdot h[m-i,n-j]\end{align}}$

Изучим пример, чтобы уточнить, как работает свертка в **2D**-пространстве.
Допустим, что размер импульсного отклика (ядра) составляет $3\times3$, а его значения **a, b, c, d, ...**

Обратите внимание, что начало координат $(0,0)$ находится в центре ядра.

Давайте выберем простейший пример и вычислим свертку, например, результат в $(1,1)$:

${\begin{align}
y[1,1] & = \sum_{j=0}^2 \sum_{i=0}^2 x[i,j] \cdot h[1-i,1-j]  \\ &
= x[0,0] \cdot h[1,1] + x[1,0] \cdot h[0,1] + x[2,0] \cdot h[-1,1] \\ &
+ x[0,1] \cdot h[1,0] + x[1,1] \cdot h[0,0] + x[2,1] \cdot h[-1,0] \\ &
+ x[0,2] \cdot h[1,-1] + x[1,2] \cdot h[0,-1] + x[2,2] \cdot h[-1,-1]
\end{align}}$

Рассмотрим более конкртеный пример с двумерной сверткой. Предположим, у нас есть входные матрицы $3\times3$ и ядра $3\times3$ следующим образом:

Input | Kernel | Output
-|-|-
 |  |
Выходной сигнал в точке $(1,1)$ для этого примера будет:
${\begin{align}
\ y[1,1] & = \sum_{j=0}^2 \sum_{i=0}^2 x[i,j] \cdot h[1-i,1-j]  \\ &
\ = x[0,0] \cdot h[1,1] + x[1,0] \cdot h[0,1] + x[2,0] \cdot h[-1,1] \\ &
\ + x[0,1] \cdot h[1,0] + x[1,1] \cdot h[0,0] + x[2,1] \cdot h[-1,0] \\ &
\ + x[0,2] \cdot h[1,-1] + x[1,2] \cdot h[0,-1] + x[2,2] \cdot h[-1,-1] \\&
\ = 1 \cdot 1 + 2 \cdot 2 + 3 \cdot 1 \\ &
\ + 4 \cdot 0 + 5 \cdot 0 + 6 \cdot 0 \\ &
\ + 7 \cdot (-1) + 8 \cdot (-2) + 9 \cdot (-1) \\ &
\ = -24 \\
\end{align}}$

## Convolution animations

_N.B.: Blue maps are inputs, and cyan maps are outputs._

<table style="width:100%; table-layout:fixed;">
  <tr>
    <td></td>
    <td></td>
    <td></td>
    <td></td>
  </tr>
  <tr>
    <td>No padding, no strides</td>
    <td>Arbitrary padding, no strides</td>
    <td>Half padding, no strides</td>
    <td>Full padding, no strides</td>
  </tr>
  <tr>
    <td></td>
    <td></td>
    <td></td>
    <td></td>
  </tr>
  <tr>
    <td>No padding, strides</td>
    <td>Padding, strides</td>
    <td>Padding, strides (odd)</td>
    <td></td>
  </tr>
</table>

## Transposed convolution animations

_N.B.: Blue maps are inputs, and cyan maps are outputs._

<table style="width:100%; table-layout:fixed;">
  <tr>
    <td></td>
    <td></td>
    <td></td>
    <td></td>
  </tr>
  <tr>
    <td>No padding, no strides, transposed</td>
    <td>Arbitrary padding, no strides, transposed</td>
    <td>Half padding, no strides, transposed</td>
    <td>Full padding, no strides, transposed</td>
  </tr>
  <tr>
    <td></td>
    <td></td>
    <td></td>
    <td></td>
  </tr>
  <tr>
    <td>No padding, strides, transposed</td>
    <td>Padding, strides, transposed</td>
    <td>Padding, strides, transposed (odd)</td>
    <td></td>
  </tr>
</table>

## Dilated convolution animations

_N.B.: Blue maps are inputs, and cyan maps are outputs._

<table style="width:25%"; table-layout:fixed;>
  <tr>
    <td></td>
  </tr>
  <tr>
    <td>No padding, no stride, dilation</td>
  </tr>
</table>

*Источник: https://github.com/vdumoulin/conv_arithmetic*

## 2D Свертки в OpenCV (Фильтрация изображений)

Как и в одномерных сигналах, изображения также могут быть отфильтрованы с помощью различных низкочастотных фильтров (LPF), высокочастотных фильтров (HPF) и т.д.
**LPF** помогает удалять шумы, размывать изображения. Фильтры **HPF** помогают находить края в изображений.

**OpenCV** предоставляет функцию **cv2.filter2D()** для объединения ядра с изображением. В качестве примера мы попробуем усредняющий фильтр на изображении. Ядро усредняющего фильтра $5\times5$ будет выглядеть так:

${\begin{equation*}
\ K = \frac{1}{25} \begin{pmatrix}
1 & \ 1 & \ 1 & \ 1 & \ 1 \\
1 & \ 1 & \ 1 & \ 1 & \ 1 \\
1 & \ 1 & \ 1 & \ 1 & \ 1 \\
1 & \ 1 & \ 1 & \ 1 & \ 1 \\
1 & \ 1 & \ 1 & \ 1 & \ 1
\end{pmatrix}
\end{equation*}}$

Свертка выполняется согласно теоретическим примерам выше. Как результат мы получаем осредненные пиксели своими 25 соседями.

```python
img = cv2.imread('img/8_ka.jpg')
# для отрисовки в pyplot
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
```

```python
kernel = np.ones((5, 5), dtype=np.float32)
kernel /= np.sum(kernel)
dst = cv2.filter2D(img.copy(), -1, kernel)

fig, m_axs = plt.subplots(1, 2, figsize=(14, 10))
ax1, ax2 = m_axs

ax1.set_title('Исходное изображение')
ax1.imshow(img)
ax2.set_title('Результат свертки')
ax2.imshow(dst);
```

# **Фильтрация изображения**

Размытие изображения достигается путем свертывания изображения с помощью фильтра нижних частот. Это полезно для удаления шумов. Это фактически удаляет высокочастотный контент (например: шум, края) с изображения. Таким образом, края в этой операции немного размыты. (Ну, есть методы размытия, которые тоже не размывают края). **OpenCV** предоставляет в основном четыре типа техники размытия.

Более подробное описание реализации фильтров OpenCV есть в [документации](https://docs.opencv.org/3.4.2/d4/d86/group__imgproc__filter.html).

## Усреднение
Это делается путем свертки изображения с помощью нормализованного прямоугольного фильтра. Он просто берет среднее значение всех пикселей в области ядра и заменяет центральный элемент. Это выполняется функцией **cv2.blur()** или **cv2.boxFilter()**. Для более подробной информации о ядре посмотрите в документации. Мы должны указать ширину и высоту ядра. Нормализованный блочный фильтр $3\times3$ будет выглядеть следующим образом:

${\begin{equation*}K = \frac{1}{9}
{\begin{pmatrix}
1 & \ 1 & \ 1 \\
1 & \ 1 & \ 1 \\
1 & \ 1 & \ 1 \end{pmatrix}}
\end{equation*}}$

```python
blur = cv2.blur(img.copy(), (3, 3))

fig, m_axs = plt.subplots(1, 2, figsize=(14, 10))
ax1, ax2 = m_axs

ax1.set_title('Исходное изображение')
ax1.imshow(img)
ax2.set_title('Результат свертки')
ax2.imshow(blur);
```

## Размытие по Гауссу
При этом вместо box filter используется ядро Гаусса. Это делается с помощью функции **cv2.GaussianBlur()**. Мы должны указать ширину и высоту ядра, которые должны быть положительными и нечетными. Мы также должны указать стандартное отклонение в направлениях **X** и **Y**, **sigmaX** и **sigmaY** соответственно. Если указан только **sigmaX**, то **sigmaY** принимается так же, как **sigmaX**. Если оба даны в виде нулей, они рассчитываются по размеру ядра. Размытие по Гауссу очень эффективно для удаления гауссовского шума с изображения.

Если вы хотите, вы можете создать ядро Гаусса с помощью функции **cv2.getGaussianKernel()**, а затем применить его с помощью **cv2.filter2D()**.

### Гауссово окно

Гауссова функция (гауссиан, гауссиана, функция Гаусса) — вещественная функция, описываемая следующей формулой:

${\displaystyle g\left(x\right)=a*exp{-{\frac {(x-b)^{2}}{2c^{2}}}}}$

Эта функция имеет широкое приминение в математической статистике. В таком случае параметры выражаются через среднеквадратичное отклонение ${\displaystyle \sigma }$ и математическое ожидание ${\displaystyle \mu }$ :

${\displaystyle a={\frac {1}{\sigma {\sqrt {2\pi }}}}}, {\displaystyle b=\mu }, {\displaystyle c=\sigma }$

В двумерном виде она имеет такое представление:

${\displaystyle g(x,y)=A\cdot exp{\left(-\left({\frac {(x-x_{0})^{2}}{2\sigma _{x}^{2}}}+{\frac {(y-y_{0})^{2}}{2\sigma _{y}^{2}}}\right)\right)}}$

Стандартным статистическим способом мы определили ширину гауссовой формы в терминах $\sigma$. Тем не менее, когда для сглаживания используется гауссиан, для сканеров характерно описывать ширину гауссиана с помощью другой связанной меры - полной ширины на половине максимума (FWHM).

FWHM - ширина ядра, равная половине максимума высоты гауссианы.

```python
from mpl_toolkits.mplot3d import Axes3D

def fwhm2sigma(fwhm):
    return fwhm / np.sqrt(8 * np.log(2))
FWHM = 1
sigma = fwhm2sigma(FWHM)

fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
dx = 0.1
dy = 0.1
x = np.arange(-6, 6, dx)
y = np.arange(-6, 6, dy)
x2d, y2d = np.meshgrid(x, y)
kernel_2d = np.exp(-(x2d ** 2 + y2d ** 2) / (2 * sigma ** 2))
kernel_2d = kernel_2d / (2 * np.pi * sigma ** 2) # unit integral
ax.plot_surface(x2d, y2d, kernel_2d)
plt.show()
```

Таким образом, можно представить, что окно гаусса представляет из себя проекцию функции на плоскость. Проиллюстрировано на картинке:

Собственно, теперь перейдем к __threshold__.

```python
## Код для размытия по Гауссу:

blur1 = cv2.GaussianBlur(img.copy(), (9,9), 0)
blur2 = cv2.GaussianBlur(img.copy(), (9,9), 2)
blur3 = cv2.GaussianBlur(img.copy(), (9,9), 5)

fig, m_axs = plt.subplots(1, 4, figsize=(22, 8))
ax1, ax2, ax3, ax4 = m_axs

ax1.set_title('Исходное изображение')
ax1.imshow(img)
ax2.set_title(r'Результат свертки $\sigma_X = \sigma_Y = 0$')
ax2.imshow(blur1)
ax3.set_title(r'Результат свертки $\sigma_X = \sigma_Y = 2$')
ax3.imshow(blur2)
ax4.set_title(r'Результат свертки $\sigma_X = \sigma_Y = 5$')
ax4.imshow(blur3);
```

## Среднее размытие
Здесь функция **cv2.medianBlur()** принимает медиану всех пикселей в области ядра, и центральный элемент заменяется этим медианным значением. Это очень эффективно против шума соли и перца на изображениях. Интересно, что в вышеупомянутых фильтрах центральным элементом является вновь вычисленное значение, которое может быть значением пикселя в изображении или новым значением. Но при медианном размытии центральный элемент всегда заменяется некоторым пиксельным значением на изображении.

Эффективно снижает шум. Размер его ядра должен быть положительным нечетным целым числом.

В этой демонстрации я добавил шум к нашему исходному изображению и применил медианное размытие. Проверьте результат:

```python
## внесем шум
noise_img = img.copy()
mask = np.random.randint(0, 2, size=noise_img.shape).astype(np.bool)
r = np.random.rand(*noise_img.shape) * np.max(noise_img)
noise_img[mask] = r[mask]

median = cv2.medianBlur(noise_img, 7)

fig, m_axs = plt.subplots(1, 2, figsize=(14, 10))
ax1, ax2 = m_axs

ax1.set_title('Исходное изображение')
ax1.imshow(noise_img)
ax2.set_title('Результат свертки')
ax2.imshow(median);
```

## Двусторонняя фильтрация

**cv2.bilateralFilter()** очень эффективно удаляет шум, сохраняя края острыми. Но операция медленнее по сравнению с другими фильтрами. Мы уже видели, что фильтр Гаусса берет окрестность вокруг пикселя и находит его средневзвешенное значение по Гауссу. Этот гауссов фильтр является функцией только одного пространства, то есть при фильтрации учитываются соседние пиксели. Он не учитывает, имеют ли пиксели почти одинаковую интенсивность. Он не учитывает, является ли пиксель краевым или нет. Таким образом, это стирает края, что мы не хотим делать.

Двусторонний фильтр также принимает гауссов фильтр в пространстве, но еще один гауссов фильтр, который является функцией разности пикселей. Гауссова функция пространства обеспечивает размытие только соседних пикселей, а гауссова функция разности интенсивности - размытие только тех пикселей, интенсивность которых равна центральной. Таким образом, он сохраняет края, поскольку пиксели на краях будут сильно изменяться.

**Рекомендации к фильтру:**

* **Sigma values:** для простоты вы можете установить два значения сигмы одинаковыми. Если они маленькие ($<10$), фильтр не будет иметь большого эффекта, тогда как если они большие ($>150$), они будут иметь очень сильный эффект, делая изображение мультяшным.

* **Размер фильтра:** Большие фильтры (d$>5$) очень медленные, поэтому рекомендуется использовать d $=5$ для приложений реального времени и, возможно, d $=9$ для автономных приложений, которые нуждаются в сильной фильтрации шума.

Ниже приведены примеры использования двустороннего фильтра:

```python
## внесем шум
noise_img = img.copy()
mask = np.random.randint(0, 2, size=noise_img.shape).astype(np.bool)
r = np.random.rand(*noise_img.shape) * np.max(noise_img)
noise_img[mask] = r[mask]

bilateral = cv2.bilateralFilter(noise_img, d=5, sigmaColor=150, sigmaSpace=250)

fig, m_axs = plt.subplots(1, 2, figsize=(14, 10))
ax1, ax2 = m_axs

ax1.set_title('Исходное изображение')
ax1.imshow(noise_img)
ax2.set_title('Результат свертки')
ax2.imshow(bilateral);
```

## Элайзинг изображений

Размытие по Гауссу обычно используется при уменьшении размера изображения. При понижающей дискретизации изображения обычно применяют фильтр низких частот к изображению перед повторной дискретизацией. Это делается для того, чтобы ложная высокочастотная информация не появлялась на изображении с пониженной дискретизацией (aliasing). Гауссовские размытия имеют хорошие свойства, такие как отсутствие острых краев.

Проясним на примере:
<table style="width:55%"; table-layout:fixed;>
  <tr>
    <td> </td>
    <td> </td>
  </tr>
  <tr>
    <td>Изображение без сжатия</td>
    <td>Эффект alising от сжатия</td>
  </tr>
</table>

# **Кросс-корреляция**

Допустим, есть два изображения, которые выглядят похожими, но слегка смещены. Мы можем оценить, насколько и в каком направлении смещены изображения, сравнивая их преобразования Фурье.

Эта техника может быть использована для стабилизации видео на уже снятых видео. Как только мы узнали, насколько сильно смещаются два последовательных кадра видео, мы можем искусственно сдвинуть второй кадр так, чтобы он больше не смещался.

OpenCV реализует совпадение шаблонов в функции matchTemplate. Доступные методы 6:

**method=CV_TM_SQDIFF**

$R(x,y)= \sum _{x',y'} (T(x',y')-I(x+x',y+y'))^2$

**method=CV_TM_SQDIFF_NORMED**

$R(x,y)= \frac{\sum_{x',y'} (T(x',y')-I(x+x',y+y'))^2}{\sqrt{\sum_{x',y'}T(x',y')^2 \cdot \sum_{x',y'} I(x+x',y+y')^2}}$

**method=CV_TM_CCORR**

$R(x,y)= \sum _{x',y'} (T(x',y')  \cdot I(x+x',y+y'))$

**method=CV_TM_CCORR_NORMED**

$R(x,y)= \frac{\sum_{x',y'} (T(x',y') \cdot I(x+x',y+y'))}{\sqrt{\sum_{x',y'}T(x',y')^2 \cdot \sum_{x',y'} I(x+x',y+y')^2}}$

**method=CV_TM_CCOEFF**

$R(x,y)= \sum _{x',y'} (T'(x',y')  \cdot I(x+x',y+y'))$

где

$\begin{array}{l} T'(x',y')=T(x',y') - 1/(w  \cdot h)  \cdot \sum _{x'',y''} T(x'',y'') \\ I'(x+x',y+y')=I(x+x',y+y') - 1/(w  \cdot h)  \cdot \sum _{x'',y''} I(x+x'',y+y'') \end{array}$

**thod=CV_TM_CCOEFF_NORMED**

$R(x,y)= \frac{ \sum_{x',y'} (T'(x',y') \cdot I'(x+x',y+y')) }{ \sqrt{\sum_{x',y'}T'(x',y')^2 \cdot \sum_{x',y'} I'(x+x',y+y')^2} }$

```python
img_rgb = cv2.imread('img/mario_world.jpg')
img_gray = cv2.cvtColor(img_rgb, cv2.COLOR_BGR2GRAY)
template = cv2.imread('img/mario_coin.jpg', 0)
w, h = template.shape[::-1]

res = cv2.matchTemplate(img_gray,template,cv2.TM_CCOEFF_NORMED)
threshold = 0.8
loc = np.where( res >= threshold)
for pt in zip(*loc[::-1]):
    cv2.rectangle(img_rgb, pt, (pt[0] + w, pt[1] + h), (0,0,255), 3)

img_rgb = cv2.cvtColor(img_rgb, cv2.COLOR_BGR2RGB)

plt.figure(figsize=(6.4*3, 4.8*3), constrained_layout=False)
plt.subplot(131), plt.imshow(img_gray, cmap='gray'), plt.title("Original Image")
plt.axis('off')
plt.subplot(132), plt.imshow(template, cmap='gray'), plt.title("Template")
plt.axis('off')
plt.subplot(133), plt.imshow(img_rgb), plt.title("Match result")
plt.axis('off')
plt.show()
```

## Phase correlation

Использование преобразования Фурье не ограничивается фильтрацией. Если мы берем преобразование Фурье из двух последовательных кадров в видео, мы можем извлечь движение кадров. Это движение может быть использовано для коррекции дрожания камеры. Мы рассмотрим его фоновую математику и ее практическое применение в следующих постах. Этот алгоритм называется "Фазовая корреляция".

Алгоритм фазовой корреляции в основном заключается в том, чтобы просто взять преобразование Фурье двух изображений, а затем выполнить по ним некоторое вычисление.

Сначала мы сделаем преобразование Фурье из двух изображений масштаба серого 1 и 2. Мы ожидаем, что эти изображения будут двумя последовательными кадрами в видео, снятом дрожащей камерой.

```python
image1_filename = 'img/img1.jpg'
image2_filename = 'img/img2.jpg'
```

```python
image1 = cv2.imread(image1_filename, 0)
image2 = cv2.imread(image2_filename, 0)
```

```python
image1.shape, image2.shape
```

```python
f1 = cv2.dft(image1.astype(np.float32),
              flags=cv2.DFT_COMPLEX_OUTPUT)
f2 = cv2.dft(image2.astype(np.float32),
              flags=cv2.DFT_COMPLEX_OUTPUT)
f1_shf = np.fft.fftshift(f1)
f2_shf = np.fft.fftshift(f2)
```

Обратите внимание, что все f1, f2, f1_shf и s2_shf имеют глубину = 2. Одна - для реальной части преобразования Фурье, а другая - для воображаемой. Поэтому мы должны объединить их до одного комплексного значения:

```python
f1_shf_cplx = f1_shf[:,:,0] + 1j*f1_shf[:,:,1]
f2_shf_cplx = f2_shf[:,:,0] + 1j*f2_shf[:,:,1]
```

Затем мы запускаем алгоритм фазовой корреляции:

```python
f1_shf_abs = np.abs(f1_shf_cplx)
f2_shf_abs = np.abs(f2_shf_cplx)
total_abs = f1_shf_abs * f2_shf_abs

P_real = (np.real(f1_shf_cplx)*np.real(f2_shf_cplx) +
          np.imag(f1_shf_cplx)*np.imag(f2_shf_cplx))/total_abs

P_imag = (np.imag(f1_shf_cplx)*np.real(f2_shf_cplx) +
          np.real(f1_shf_cplx)*np.imag(f2_shf_cplx))/total_abs
P_complex = P_real + 1j*P_imag
```

То, что мы здесь делаем, это получаем произведение двух комплексных чисел (на самом деле, это не одно значение, а скорее изображение). P_real - это реальная часть продукта, а P_imag - воображаемая часть. Обратите внимание, что они нормализуются путем деления на их размер.

Наконец, мы применяем обратное преобразование Фурье к P_комплексу. В результате получается реальное изображение, почти черное, но имеющее только один сияющий белый пиксель. Расположение пикселя говорит о том, насколько сильно сместились два кадра.

```python
P_inverse = np.abs(np.fft.ifft2(P_complex)) # inverse FFT
max_id = [0, 0]
max_val = 0
h, w = image1.shape
for idy in range(h):
    for idx in range(w):
        if P_inverse[idy,idx] > max_val:
            max_val = P_inverse[idy,idx]
            max_id = [idy, idx]
shift_x = h - max_id[0]
shift_y = w - max_id[1]
print(shift_x, shift_y)
```

Затем мы компенсируем сдвиг, основанный на этих предсказаниях.

```python
image1_rgb = cv2.imread(image1_filename)
image1_rgb = cv2.cvtColor(image1_rgb, cv2.COLOR_BGR2RGB)
image2_rgb = cv2.imread(image2_filename)
image2_rgb = cv2.cvtColor(image2_rgb, cv2.COLOR_BGR2RGB)

cv2.rectangle(image1_rgb, (0, 0), (image1_rgb.shape[0] - shift_x, image1_rgb.shape[1] - shift_y), (0, 255, 0), 10)
cv2.rectangle(image2_rgb, (shift_x, shift_y), (image2_rgb.shape[0], image2_rgb.shape[1]), (0, 255, 0), 10)

plt.figure(figsize=(6.4*2, 4.8*2), constrained_layout=False)
plt.subplot(121), plt.imshow(image1_rgb), plt.title("Original Image")
plt.axis('off')
plt.subplot(122), plt.imshow(image2_rgb), plt.title("Spectrum")
plt.axis('off')
plt.show()
```

```python

```


# Funciones propuestas para la clase de Procesos de separación 2

En el siguiente catálogo podemos encontrar algunas funciones que serán de utilidad para la solución de problemas propuestos en la clase de procesos de separación 2, para ello cada una tendrá su respectiva documentación para convertirlo en un recurso valioso en el estudio de la asignatura.

## Librerías a utilizar

```python
# Librerias
import numpy as np
from numpy import poly1d,polyfit
import matplotlib.pyplot as plt
from sympy import Symbol

# Para imprimir en formato LaTex
from sympy.interactive import printing
printing.init_printing(use_latex=True)
```

## Funciones

```python
def Rachford_Rice_4(z,k,Li,Ls,p):
    psi = np.arange(Li,Ls+p,p)
    f_psi = []
    for value in psi:
        f_psi.append((z[0]*(1-k[0])/(1+value*(k[0]-1))) + (z[1]*(1-k[1])/(1+value*(k[1]-1))) + (z[2]*(1-k[2])/(1+value*(k[2]-1))) + (z[3]*(1-k[3])/(1+value*(k[3]-1))) )

    return psi,f_psi

def find_root_interval(given):
    i = 0
    for value in given:
        if value >=0:
            P_1 = i
            P_0 = i-1
        else:
            i += 1
    return P_0,P_1

def interp(x_1,x_2,y_1,y_2,y_n):
    x_n = x_1 -((x_1-x_2)*(y_1-y_n)/(y_1-y_2))
    return x_n , y_n
```

Todas estas funciones se encuentran dispoibles en el script function_zoo.py


# Derivation of Expectation and Variance of Power from Thermal Noise

## Preliminaries

For the general likelihood of the 2D power (or even 1D power), one needs to know the contribution to the power (and its uncertainty) from thermal noise. In fact, of course, the thermal noise is added in a non-Gaussian manner (same as everything else, really) because it is strictly positive. However, we assume that it is at least *close* to Gaussian, so that we can describe it purely by its mean and variance.

The other thing that is often assumed is that the thermal noise can be treated independently of all other signals in the chain (eg. EoR and FG). We will show that while this is a reasonable assumption when the expected power (from FG and EoR) is much less than the variance of the noise, it does not hold in general. Ignoring this fact leads to consistent under-estimation of the total variance of the power spectrum. We will first perform the calculation assuming that the FG, EoR and noise can be separated, before doing the full derivation.

Throughout the following, we assume that the variance of the *magnitude* of a visibility from thermal noise is $\sigma^2$. This requires that the variance of the real and imaginary components, $\sigma^2_\mathcal{R}, \sigma^2_\mathcal{I} \equiv \sigma^2 /2$.

## Independent Calculation

Since the thermal noise is independent of everything else, we can *just* deal with it, and neglect everything else, then add it in at the end. So let's begin.

Let every noise visibility, at frequency $\nu$, be called $V_{i,\nu}$ (we don't label it for being noise, as we won't deal with any other type of visibility in this document). Then the visibility of a $uv\nu$ grid cell is

\begin{equation}
    V_{uv\nu} = \frac{1}{n_{uv\nu}}\sum_i V_{i,\nu} \delta_{u_i,v_i \sim uv},
\end{equation}

where the $\delta$ function really just means "only count baselines in the $uv$ cell", and $n_{uv\nu}$ is just the number of baselines in the $uv$ cell at that frequency. Then of course the fourier-space visibility is

\begin{equation}
    V_{uv\eta} = \Delta\nu \sum e^{-2\pi i \eta \nu} V_{uv\nu} \phi_\nu,
\end{equation}

where $\phi_\nu$ is some applied taper and $\Delta\nu$ is the frequency channel width.

Thus we can get the expectation and variance of $V_{uv\eta}$:

\begin{align}
    \langle V_{uv\eta} \rangle &= 0, \\
    {\rm Var}(V_{uv\eta}) &= \sigma^2 \sum_\nu \frac{(\Delta\nu)^2 \phi_\nu^2}{n_{uv\nu}} \equiv \sigma^2 / \tilde{n}_{uv}.
\end{align}

With $\tilde{n}_{uv} = \left[\sum_\nu \frac{(\Delta\nu)^2 \phi_\nu^2}{n_{uv\nu}}\right]^{-1}$ an "effective" number of baselines in a $uv\eta$ cell.

Now then, we have $P_{uv\eta} = |V_{uv\eta}|^2$, and we need to evaluate the mean and variance of the power. For this, we simply use the relation that the mean and variance of a Gaussian variable squared are $\sigma^2$ and $2\sigma^4$ respectively, so:

\begin{align}
    \langle P_{uv\eta} \rangle &= \sigma^2 / \tilde{n}_{uv} \equiv {\rm Var}(V_{uv\eta}), \\
    {\rm Var}(P_{uv\eta}) &= \frac{2 \sigma^4_\mathcal{R} + 2\sigma^4_{\mathcal{I}}}{\tilde{n}^2_{uv}} \\
    &= \sigma^4 / \tilde{n}^2_{uv} \equiv \langle P_{uv\eta} \rangle ^2.
\end{align}

Now, the power spectrum is circularly averaged. When doing this, we are careful to use the weights of each grid point, which is properly the inverse of the standard deviation of each bin:

\begin{equation}
    P_{u\eta} = \frac{\sum P_{uv\eta} w_{uv\eta}}{\sum w_{uv\eta}},
\end{equation}

where the sum is over all cells within an annulus, and

\begin{equation}
    w_{uv\eta}  = \frac{\tilde{n}_{uv}}{\sigma^2}.
\end{equation}

Thus we have

\begin{equation}
    P_{u\eta} = \frac{\sum \tilde{n}_{uv} P_{uv\eta} }{\sum  \tilde{n}_{uv}}.
\end{equation}

Finally, we need to calculate the mean and variance of this quantity:

\begin{align}
    \langle P_{u\eta} \rangle &= \frac{\sum \sigma^2}{\sum \tilde{n}_{uv}} \\
    &=\sigma^2 \frac{n_u}{\sum \tilde{n}_{uv}},
\end{align}

where $n_u$ is the number of $uv$ cells in the $u$ annulus. This averages down roughly as $1/\bar{n}_{uv}$. Then the variance:

\begin{align}
    {\rm Var}(P_{u\eta}) &= \frac{\sigma^4 n_u}{(\sum \tilde{n}_{uv})^2}.
\end{align}

## General Derivation

Here we do a (more) general derivation, where we acknowledge that there is EoR signal (with non-zero expectation) that may contribute to the variance of the power. For simplicity, we attempt to reproduce the conditions under which the ``test_thermal_noise`` script is being run: i.e. zero foregrounds, and deterministic signal. **NOTE: in general neither of these assumptions is true, and this derivwation will have to be done again more generally**.

Let $V_{i,\nu} = V_{S,i,\nu} + V_{N,i,\nu}$, where $S$ stands for *signal*.

We note that every operation up to the squaring of the visibilities is linear, and therefore the equations are valid for the noise term (and other terms can be derived similarly). Thus we require only to do calculations from $\langle P_{uv\eta} \rangle onwards. We have, in particular,

\begin{align}
    P_{uv\eta} &= |V_{uv\eta}|^2 \\
    &= |V_{S,uv\eta} + V_{N, uv\eta}|^2 \\
    &= ||V_{S, uv\eta}|^2 + |V_{N, uv\eta}|^2 + 2\mathcal{Re}\left\{V_{S, uv\eta}V^*_{N, uv\eta}\right\}
\end{align}

For the expectation of the power, each term is of course treated separately, and all terms involving a single $V_N$ disappear, as $\langle X Y \rangle$ for $\langle X \rangle = 0$ and $X,Y$ independent is zero. Thus the expectation of the power is

\begin{align}
    \langle P_{uv\eta} \rangle &= {\rm Var}(V_{S, uv\eta}) + \frac{\sigma^2}{\tilde{n}_{uv}} \\
    &= \langle P_{N,uv\eta} \rangle,
\end{align}

since the *variance* of the signal is zero (as it is considered deterministic here).

However, for the variance, we have the independent parts acting in the standard manner, but recall that for independent $X,Y$,

\begin{equation}
    {\rm Var}(XY) = {\rm Var}(X){\rm Var}(Y) + {\rm Var}(X) |\langle Y \rangle|^2 + {\rm Var}(Y) |\langle X \rangle|^2.
\end{equation}

So we have
\begin{align}
    {\rm Var}(P_{uv\eta}) = &\langle P_{N, uv\eta} \rangle^2 + \langle P_{S, uv\eta} \rangle^2 + 2 {\rm Var}(V_{N,uv\eta})P_{S,uv\eta} + \\
        & 2 {\rm Cov}(|V_{S,uv\eta}|^2, \mathcal{Re}\left\{V_{N, uv\eta}V^*_{S, uv\eta}\right\}) + 2 {\rm Cov}(|V_{N,uv\eta}|^2, \mathcal{Re}\left\{V_{N, uv\eta}V^*_{S, uv\eta}\right\})
\end{align}

Notice that the last term on the first line needs reasonably careful handling of the real/imaginary components, and recalling that $S$ is deterministic.

Recall also that

\begin{align}
{\rm Cov}[XX, XY] &= E[X^3]E[Y] - E[X^2]E[X]E[Y]  \\
\end{align}

for independent $X,Y$. But this is not exactly what we have. Instead, we have
\begin{align}
{\rm Cov}[|X|^2, \mathcal{Re} (XY)] &= E[X_\mathcal{R}^3]E[Y_\mathcal{R}] - E[X_\mathcal{R}^2]E[X_\mathcal{R}]E[Y_\mathcal{R}] -  E[X_\mathcal{I}^3]E[Y_\mathcal{I}] + E[X_\mathcal{I}^2]E[X_\mathcal{I}]E[Y_\mathcal{I}].
\end{align}

Regardless, this removes all covariance terms, as its noise part  appears to the odd power in each, so we are left with

\begin{align}
    {\rm Var}(P_{uv\eta}) &= \langle P_{N, uv\eta} \rangle^2 + \langle P_{S, uv\eta} \rangle^2 + 2{\rm Var}(V_{N,uv\eta})|\langle V_{S,uv\eta} \rangle|^2 \\
    &= \langle P_{N, uv\eta} \rangle \left[ \langle P_{N, uv\eta} \rangle + 2|\langle V_{S,uv\eta} \rangle|^2 \right] + \langle P_{S, uv\eta} \rangle^2
    \end{align}

Since the signal is deterministic, we can do away with its expectation signs, and note that $|V_S|^2 \equiv P_S$:

\begin{align}
    {\rm Var}(P_{uv\eta}) &= \langle P_{N, uv\eta} \rangle \left[ \langle P_{N, uv\eta} \rangle + 2P_{S,uv\eta} \right] + P_{S, uv\eta}^2
    \end{align}

Thus, when $\langle P_N \rangle \gg 2 P_S$, the solution is the same as the above independent case. But this will not be true where the signal power is large compared to the noise (for which we must assume some bins exist).

**The question is: is this correct? At the moment, I can only see how this would add *more* power in some bins, but our numerical estimates suggest that the analytical variance is already too high**


# Python Basics

Please refer to [python 3 documentation](https://docs.python.org/3/)

<space>Table of Contents

|  |
| :--- |
| [Numpy and Linear Algebra](#Numpy-and-Linear-Algebra)   |
| [Matrices with sympy](#Matrices-with-sympy)   |
| [Scipy and Matplotlib Plotting](#Scipy-and-Matplotlib-Plotting)  |
| [Control/Conditional Statements](#Control/Conditional-Statements)   |
| [Creating Methods](#Creating-Methods)   |
| [Strings](#Strings)  |
| [Pandas - DataFrame](#Pandas-intro-using-DataFrame) |

## Numpy and Linear Algebra

```python
import numpy as np
# Creating two lists, a and b
a=[1,2,3]
b=[4,5,6]
c=np.concatenate((a,b))
print(c)
```

    [1 2 3 4 5 6]

```python
## Logial operations are element-wise ##
# Create an array
f = np.array([1,2,3,4,5])
print('This is the frist output: ',f < 4)
print('This is the second output: ',(f<4) | (f>4)) # | is logical symbol for 'or'
```

    This is the frist output:  [ True  True  True False False]
    This is the second output:  [ True  True  True False  True]

```python
## Sums and Averages of elements inside array##
g = np.array([[1,2,3],[4,5,6]])
print(g)
g.sum()
```

    [[1 2 3]
     [4 5 6]]

    21

```python
g.sum(axis=1)
```

    array([ 6, 15])

```python
g.mean()
```

    3.5

```python
g.std()
```

    1.707825127659933

```python
# If we are not sure what a function or object is, then
# we can look to the help page.
help(g.std)
```

    Help on built-in function std:

    std(...) method of numpy.ndarray instance
        a.std(axis=None, dtype=None, out=None, ddof=0, keepdims=False)

        Returns the standard deviation of the array elements along given axis.

        Refer to `numpy.std` for full documentation.

        See Also
        --------
        numpy.std : equivalent function

```python
g.std(axis=1)
```

    array([0.81649658, 0.81649658])

```python
# Take two matrices, a and b, then concatenate them.
a=np.array([[1,2,3],[4,5,6]])
b=np.array([[1,2,3],[7,8,9]])
c=np.concatenate((a,b))
print(c)
```

    [[1 2 3]
     [4 5 6]
     [1 2 3]
     [7 8 9]]

```python
d = np.transpose(c) # transpose of c
f = d[:,0:3] # first 3 columns of d
g = np.eye(3) # identity 3x3
h = np.concatenate((f,g), axis=0) # linking f and g
I = np.concatenate((f,g), axis=1) # linking f and g
print(I) # axis=1
```

    [[1. 4. 1. 1. 0. 0.]
     [2. 5. 2. 0. 1. 0.]
     [3. 6. 3. 0. 0. 1.]]

## Matrices with sympy

```python
# Lets use sympy to construct a matrix
## Matrices from docs.sympy.org ##
from sympy import *
# We can also make the output look pretty
init_printing(use_unicode=True)
M = Matrix([[1,2,3],[4,5,6],[7,8,9]])
print('Row Reduce Echelon Form of the Matrix M is:\n')
M.rref()
```

    Row Reduce Echelon Form of the Matrix M is:

$$\left ( \left[\begin{matrix}1 & 0 & -1\\0 & 1 & 2\\0 & 0 & 0\end{matrix}\right], \quad \left ( 0, \quad 1\right )\right )$$

```python
print("See, we can still print, but it doesn't look as nice\n",M)
```

    See, we can still print, but it doesn't look as nice
     Matrix([[1, 2, 3], [4, 5, 6], [7, 8, 9]])

```python
A = Matrix([[1,2,3],[4,5,6],[7,8,9]])
```

```python
# If you need an immutable version of Matrix, use ImmutableMatrix
A.shape
```

```python
# to get individual rows of columns, use:
A.row(0)
A.col(1)
```

$$\left[\begin{matrix}2\\5\\8\end{matrix}\right]$$

```python
# deleting and inserting rows and columns
A.row_del(1)
A
```

$$\left[\begin{matrix}1 & 2 & 3\\7 & 8 & 9\end{matrix}\right]$$

```python
A.T
```

$$\left[\begin{matrix}1 & 7\\2 & 8\\3 & 9\end{matrix}\right]$$

```python
A = Matrix([[2,3,4],[5,8,3],[3,3,3]])
A
```

$$\left[\begin{matrix}2 & 3 & 4\\5 & 8 & 3\\3 & 3 & 3\end{matrix}\right]$$

```python
A.rref() # (rref, pivot columns)
```

$$\left ( \left[\begin{matrix}1 & 0 & 0\\0 & 1 & 0\\0 & 0 & 1\end{matrix}\right], \quad \left ( 0, \quad 1, \quad 2\right )\right )$$

```python
A.nullspace()
```

```python
B = Matrix([[1,2,3],[4,5,6],[7,8,9]])
B
```

$$\left[\begin{matrix}1 & 2 & 3\\4 & 5 & 6\\7 & 8 & 9\end{matrix}\right]$$

```python
B.rref()
```

$$\left ( \left[\begin{matrix}1 & 0 & -1\\0 & 1 & 2\\0 & 0 & 0\end{matrix}\right], \quad \left ( 0, \quad 1\right )\right )$$

```python
B.nullspace()
```

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

```python
## Eigenvalues and Eigenvectors ##
A.eigenvals() # returns (eigenvalue:algebraic multiplicity)
```

```python
A.eigenvects()
# which returns (eigenvalue:algebraic multiplicity, [eigenvectors])
```

$$\left [ \left ( -1, \quad 1, \quad \left [ \left[\begin{matrix}- \frac{9}{4}\\\frac{11}{12}\\1\end{matrix}\right]\right ]\right ), \quad \left ( 2, \quad 1, \quad \left [ \left[\begin{matrix}1\\- \frac{4}{3}\\1\end{matrix}\right]\right ]\right ), \quad \left ( 12, \quad 1, \quad \left [ \left[\begin{matrix}1\\2\\1\end{matrix}\right]\right ]\right )\right ]$$

```python
## dagonlizable matrices ##
#  A = PDP^-1
P, D = A.diagonalize()
print(P)
print(D)
```

    Matrix([[-27, 3, 1], [11, -4, 2], [12, 3, 1]])
    Matrix([[-1, 0, 0], [0, 2, 0], [0, 0, 12]])

```python
# remember that lambda is a reserved keyword in python, so
# to create the symbol use: lamda (without the 'b')

lamda = symbols('lamda')

B = Matrix([[1, 0 , 1],[1, 1, 2], [ 1, 0, 1]])
B

b = B.charpoly(lamda)
b

factor(b)
```

## Scipy and Matplotlib Plotting

```python
x = symbols('x')
y = x**2 + 5*x + 6
print(y)
```

    x**2 + 5*x + 6

```python
y_x = integrate(y)
pprint(y_x)
```

     3      2
    x    5⋅x
    ── + ──── + 6⋅x
    3     2

```python
## Constructing a large lil_matrix and add values to it ##
from scipy.sparse import lil_matrix
from scipy.sparse.linalg import spsolve
from numpy.linalg import solve, norm
from numpy.random import rand
## Optimization ##
#Minimization (steepest-decentm conjugate gradient), curve-fitting, least squares, root finding (Newton's method), annealing, etc.
import scipy.optimize as optimize
```

```python
## Curve Fitting Example ##
def func(x,a,b,c):
    return a*np.exp(-b*x) + c
```

```python
x = np.linspace(0,4,50) # 50 values linearly in [0,4]
y = func(x,2.5,1.3,0.5)
yn = y + 0.2*np.random.normal(size=len(x))
popt,pcov = optimize.curve_fit(func,x,yn)
popt,pcov # optimized parameters and their covariance
```

    (array([2.68646283, 1.51320963, 0.53692262]),
     array([[ 0.01767912,  0.00851316, -0.00063102],
            [ 0.00851316,  0.02293804,  0.00501921],
            [-0.00063102,  0.00501921,  0.00242487]]))

```python
## Matplotlib ##
from matplotlib.pyplot import *
from pylab import *
x = np.arange(0,10,0.2)
y = np.cos(x)
figure()
```

```python
plot(x,y,'g*')
show()
```

```python
# or we can show the figure inline
%matplotlib inline
plot(x,-np.exp(2*y),'g*')
```

## Control/Conditional Statements

```python
## Contidtionsl and Flow Statements ##

## 'if' Statement
x = int(1) # Change the initial condition to alt output
if x < 0:
    x = 0
    print('Negative goes to zero')
elif x == 0:
    print('Zero')
elif x == 1:
    print('single')
else:
    print('More')
```

    single

```python
##  Examples using 'for' statement
# create a list of names
Names = ['Rob', 'Ron', 'Felish', 'George', 'Shoe']
for x in Names:
    # print the ith element of the list, followed by
    # the number of charecters the name has.
    print(x, len(x))

# Here's another way to do the same thing...
# but let x be the ith iteration.
Names = ['Rob', 'Ron', 'Felish', 'George', 'Shoe']
for i in range(len(Names)):
    print(i,Names[i])
```

    Rob 3
    Ron 3
    Felish 6
    George 6
    Shoe 4
    0 Rob
    1 Ron
    2 Felish
    3 George
    4 Shoe

```python
for x in range(5):    # range(5) = {0,1,2,3,4}
    if x == 3:        # if x=3, print out the string
        print('Three')
    elif x == 0:      # "elseif" needed after 'if' to continue with condiditions of the same statement
        print('Zero')
    else:             # otherwise, print out the remaining
        print(x)
```

    Zero
    1
    2
    Three
    4

```python
for x in Names[:]: # Loops over a slice copy of the entire list.
    if len(x) > 5:
        Names.remove(x)
        Names.append('Name')

Names
```

    ['Rob', 'Ron', 'Shoe', 'Name', 'Name']

```python
## 'break' and 'continue' Statements, and 'else' Clauses on loops
## Prime Numbers ##
for n in range(2,50):
    for x in range(2, n):
        if n % x == 0:
            print(n, '=', x, '*', n/x)
            break
    else:
        # loop fell through without finding a factor
        print(n, 'is a prime number')

```

    2 is a prime number
    3 is a prime number
    4 = 2 * 2.0
    5 is a prime number
    6 = 2 * 3.0
    7 is a prime number
    8 = 2 * 4.0
    9 = 3 * 3.0
    10 = 2 * 5.0
    11 is a prime number
    12 = 2 * 6.0
    13 is a prime number
    14 = 2 * 7.0
    15 = 3 * 5.0
    16 = 2 * 8.0
    17 is a prime number
    18 = 2 * 9.0
    19 is a prime number
    20 = 2 * 10.0
    21 = 3 * 7.0
    22 = 2 * 11.0
    23 is a prime number
    24 = 2 * 12.0
    25 = 5 * 5.0
    26 = 2 * 13.0
    27 = 3 * 9.0
    28 = 2 * 14.0
    29 is a prime number
    30 = 2 * 15.0
    31 is a prime number
    32 = 2 * 16.0
    33 = 3 * 11.0
    34 = 2 * 17.0
    35 = 5 * 7.0
    36 = 2 * 18.0
    37 is a prime number
    38 = 2 * 19.0
    39 = 3 * 13.0
    40 = 2 * 20.0
    41 is a prime number
    42 = 2 * 21.0
    43 is a prime number
    44 = 2 * 22.0
    45 = 3 * 15.0
    46 = 2 * 23.0
    47 is a prime number
    48 = 2 * 24.0
    49 = 7 * 7.0

```python
## Even and Odd example
for n in range(2, 10):
    if n % 2 == 0:
        print('Found an even number', n)
        continue
    print('Found an odd number', n)

## 'pass' Statements

# pass statement does nothin.  It can be used requires no action.
# It can be used for creaing minimal classes.
# It can also be used as a place-holder for a function
def initlog(*arguments):
    pass  # remember to implement this!
```

    Found an even number 2
    Found an odd number 3
    Found an even number 4
    Found an odd number 5
    Found an even number 6
    Found an odd number 7
    Found an even number 8
    Found an odd number 9

## Creating Methods

```python
## Defining Functions ##
def fib(n):  # write Fibonacci series up to n
    """Print a Fibonacci series up to n."""
    a,b = 0,1
    while a < n:
        print(a,)
        a,b = b,a+b

# Now, calling on the function just made
fib(2000)
```

    0
    1
    1
    2
    3
    5
    8
    13
    21
    34
    55
    89
    144
    233
    377
    610
    987
    1597

```python
## changing fib to fib2
def fib2(n):  # write Fibonacci series up to n
    """Return a list containing the Fibonacci series up to n."""
    result = []
    a,b = 0,1
    while a < n:
        result.append(a)
        a,b = b,a+b
    return result

fib2(100)
```

```python
 #The return statement returns with a value from a function.
 #return without an expression arugment returns None.
 #The statement result.append(a) calls a method of the list object result.
 #A method i a function that belongs to an object and is named obj.methodname

def f(a, L=[]):
    L.append(a)
    return L

print(f(1))
print(f(2))
print(f(3))
print(f(100))
```

    [1]
    [1, 2]
    [1, 2, 3]
    [1, 2, 3, 100]

```python
# If you dont want the default to be shared with subsequent calls...
def f(a, L=None):
    if L is None:
        L = [] # generate a new list every iteration
    L.append(a)
    return L

print(f(0))
print(f(1))
print(f(2))
print(f(100))
```

    [0]
    [1]
    [2]
    [100]

```python
## Keyword Arguments ##
# which are of the form: kwarg=value
# In general:
# def func(required, state, action, type):

# lets make one up...
food=['apple', 'organges', 'bread', 'salad']
def store(food, state='need', action='buy', type='cash'):
    if food == 'salad':
        print('This store has the groceries I', state,',' )
        print("but doesn't have", food, )
    elif food == 'organges':
        print('They have', food, 'but I have insignificant', type,)

```

```python
store('salad')
# This needs work (not finished)
```

    This store has the groceries I need ,
    but doesn't have salad

```python
## The template for writing a function ##

def my_function():
    '''Insert a short, concise summary of the object's purpose.'''
    pass

print(help(my_function)) # just showing the docstring for the function
```

    Help on function my_function in module __main__:

    my_function()
        Insert a short, concise summary of the object's purpose.

    None

## Strings

```python
name = 'Enter Name Here'  # Enter your name here
intro = 'Hello, my name is %s.'%(name)

## Let's do some string minipulation...

# First, lets separate this statement by each space
# we can put each word into a list
List = [word for word in intro.split(" ")]
print(List)
```

    ['Hello,', 'my', 'name', 'is', 'Enter', 'Name', 'Here.']

```python
# We can get rid of any punctuation marks with replace
List = [word.replace(".","").replace(",","") for word in intro.split(" ")]
print(List)
```

    ['Hello', 'my', 'name', 'is', 'Enter', 'Name', 'Here']

```python
# We can print the length of the list (number of words)
print(len(List))
```

    7

```python
# We can print the length of each element in the list (number of characters)
nChar = 0  # start a count of the number of characters
for i in range(len(List)):
    nChar += len(List[i])
    print('Word %i/%i has %i characters.'%(i+1,len(List),len(List[i])))
print('The total number of characters in the list is %i'%nChar)
```

    Word 1/7 has 5 characters.
    Word 2/7 has 2 characters.
    Word 3/7 has 4 characters.
    Word 4/7 has 2 characters.
    Word 5/7 has 5 characters.
    Word 6/7 has 4 characters.
    Word 7/7 has 4 characters.
    The total number of characters in the list is 26

## Pandas intro using `DataFrame`

```python
import pandas as pd
```

```python
m = 5  # number of participants or "members"
n = 10 # number of observations of position

observation_times = pd.Series(["01-%0.2i-2020"%i for i in range(n)])
# You can do the same thing using:  dates_index = pd.date_range("20200101", periods=m)
array = np.random.random((n,m)) # creating a bunch (n x m) of random samples
samples = pd.DataFrame(array, index=observation_times, columns=["m%i"%i for i in range(m)])
samples
```

      m0
      m1
      m2
      m3
      m4

      01-00-2020
      0.037611
      0.656845
      0.140001
      0.886104
      0.667229

      01-01-2020
      0.647753
      0.977726
      0.295408
      0.732274
      0.310976

      01-02-2020
      0.025785
      0.764663
      0.889219
      0.635461
      0.248914

      01-03-2020
      0.077782
      0.977541
      0.126935
      0.937189
      0.735266

      01-04-2020
      0.751781
      0.658810
      0.222319
      0.410867
      0.703010

      01-05-2020
      0.671324
      0.114019
      0.147262
      0.568094
      0.009472

      01-06-2020
      0.931435
      0.436208
      0.370047
      0.752514
      0.038555

      01-07-2020
      0.464833
      0.739809
      0.938925
      0.300964
      0.080938

      01-08-2020
      0.512085
      0.142006
      0.008359
      0.195520
      0.947673

      01-09-2020
      0.490614
      0.539010
      0.972949
      0.687977
      0.869855

```python
# gather specific memembers
sub_samples = samples[["m2","m4"]] # grabbing only the 2nd and 4th members
sub_samples
```

      m2
      m4

      01-00-2020
      0.140001
      0.667229

      01-01-2020
      0.295408
      0.310976

      01-02-2020
      0.889219
      0.248914

      01-03-2020
      0.126935
      0.735266

      01-04-2020
      0.222319
      0.703010

      01-05-2020
      0.147262
      0.009472

      01-06-2020
      0.370047
      0.038555

      01-07-2020
      0.938925
      0.080938

      01-08-2020
      0.008359
      0.947673

      01-09-2020
      0.972949
      0.869855

```python
sub_samples.sum()
```

    m2    4.111425
    m4    4.611888
    dtype: float64

```python
# Subtract the element one row up from the current row
sub_samples.diff(periods=1, axis=0)
```

      m2
      m4

      01-00-2020
      NaN
      NaN

      01-01-2020
      0.155408
      -0.356254

      01-02-2020
      0.593811
      -0.062061

      01-03-2020
      -0.762284
      0.486351

      01-04-2020
      0.095384
      -0.032256

      01-05-2020
      -0.075058
      -0.693538

      01-06-2020
      0.222786
      0.029083

      01-07-2020
      0.568878
      0.042384

      01-08-2020
      -0.930566
      0.866734

      01-09-2020
      0.964590
      -0.077817

```python
# Subtract one column with the other
sub_samples.diff(periods=1, axis=1)
```

      m2
      m4

      01-00-2020
      NaN
      0.527229

      01-01-2020
      NaN
      0.015567

      01-02-2020
      NaN
      -0.640305

      01-03-2020
      NaN
      0.608330

      01-04-2020
      NaN
      0.480691

      01-05-2020
      NaN
      -0.137790

      01-06-2020
      NaN
      -0.331493

      01-07-2020
      NaN
      -0.857987

      01-08-2020
      NaN
      0.939313

      01-09-2020
      NaN
      -0.103094

```python
# Another way of getting a subset...
samples[1:4]
```

      m0
      m1
      m2
      m3
      m4

      01-01-2020
      0.647753
      0.977726
      0.295408
      0.732274
      0.310976

      01-02-2020
      0.025785
      0.764663
      0.889219
      0.635461
      0.248914

      01-03-2020
      0.077782
      0.977541
      0.126935
      0.937189
      0.735266

```python

```


# Regression Discontinuity Design

## Regression Discontinuity Design

We don't stop to think about it much, but it is impressive how smooth nature is. You can't grow a tree without first getting a bud, you can't teleport from one place to another, a wound takes its time to heal. Even in the social realm, smoothness seems to be the norm. You can't grow a business in one day, consistency and hard work are required to build wealth and it takes years before you learn how linear regression works. Under normal circumstances, nature is very cohesive and doesn't jump around much.

> When the intelligent and animal souls are held together in one embrace, they can be kept from separating.

\- Tao Te Ching, Lao Tzu.

Which means that **when we do see jumps and spikes, they are probably artificial** and often man made situations. These events are usually accompanied by counterfactuals to the normal way of things: if a weird thing happens, this gives us some insight into what would have happened if nature was to work in a different way. Exploring these artificial jumps is at the core of Regression Discontinuity Design.

The basic setup goes like this. Imagine that you have a treatment variable \\(T\\) and potential outcomes \\(Y_0\\) and \\(Y_1\\). The treatment T is a discontinuous function of an observed running variable \\(R\\) such that

$
D_i = \mathcal{1}\{R_i>c\}
$

In other words, this is saying that treatment is zero when \\(R\\) is below a threshold \\(c\\) and one otherwise. This means that we get to observe \\(Y_1\\) when \\(R>c\\) and \\(Y_0\\) when \\(R<c\\). To wrap our head around this, think about the potential outcomes as 2 functions that we can't observe entirely. Both \\(Y_0(R)\\) and \\(Y_1(R)\\) are there, we just can't see that. The threshold acts as a switch that allows us to see one or the other of those function, but never both, much like in the image below:

The idea of regression discontinuity is to compare the outcome just above and just below the threshold to identify the treatment effect at the threshold. This is called a **sharp RD** design, since the probability of getting the treatment jumps from 0 to 1 at the threshold, but we could also think about a **fuzzy RD** design, where the probability also jumps, but is a less dramatic manner.

## Is Alcohol Killing You?

A very relevant public policy question is what should be the minimal drinking age. Most countries, Brazil included, set it to be 18 year, but in the US (most states) it is currently 21. So, is it the case that the US is being overly prudent and that they should lower their minimal drinking age? Or is it the case that other countries should make their legal drinking age higher?

One way to look at this question is from a [mortality rate perspective (Carpenter and Dobkin, 2009)](https://www.aeaweb.org/articles?id=10.1257/app.1.1.164). From the public policy standpoint, one could argue that we should lower the mortality rate as much as possible. If alcohol consumption increases the mortality rate by a lot, we should avoid lowering the minimum drinking age. This would be consistent with the objective of lowering deaths caused by alcohol consumption.

To estimate the impacts of alcohol, we could use the fact that legal drinking age imposes a discontinuity on nature. In the US, those just under 21 years don't drink (or drink much less) while those just older than 21 do drink. This means that the probability of drinking jumps at 21 years and that is something we can explore with an RDD.

```python
import warnings
warnings.filterwarnings('ignore')

import pandas as pd
import numpy as np
from matplotlib import style
from matplotlib import pyplot as plt
import seaborn as sns
import statsmodels.formula.api as smf

%matplotlib inline

style.use("fivethirtyeight")
```

To do so we can grab some mortality data aggregated data by age. Each row is a has the average age of a group of people and the average mortality by all causes (`all`), by moving veiche accident (`mva`) and by suicide (`suicide`).

```python
drinking = pd.read_csv("./data/drinking.csv")
drinking.head()[["agecell", "all", "mva", "suicide"]]
```

      agecell
      all
      mva
      suicide

      0
      19.068493
      92.825400
      35.829327
      11.203714

      1
      19.150684
      95.100740
      35.639256
      12.193368

      2
      19.232876
      92.144295
      34.205650
      11.715812

      3
      19.315070
      88.427760
      32.278957
      11.275010

      4
      19.397260
      88.704940
      32.650967
      10.984314

Just to aid visibility, and for another important reason we will see later, we will centralize the running variable `agecell` at the threshold 21.

```python
drinking["agecell"] -= 21
```

If we plot the multiple outcome variables (`all`, `mva`, `suicide`) with the runing variable on the x axis, we get some visual cue that there is also a jump in mortality as we cross the legal drinking age.

```python
plt.figure(figsize=(8,8))
ax = plt.subplot(3,1,1)
drinking.plot.scatter(x="agecell", y="all", ax=ax)
plt.title("Death Cause by Age (Centered at 0)")

ax = plt.subplot(3,1,2, sharex=ax)
drinking.plot.scatter(x="agecell", y="mva", ax=ax)

ax = plt.subplot(3,1,3, sharex=ax)
drinking.plot.scatter(x="agecell", y="suicide", ax=ax);

```

There are some cues, but we need more than that. What exactly is the effect of drinking on mortality at the threshold? And what is the standard error on that estimate?

## RDD Estimation

The key assumption that RDD relies on is the smoothness of the potential outcome at the threshold. Formally, the limits of the potential outcomes as the running variable approaches the threshold from the right and from the left should be the same.

$$
\lim_{r \to c^-} E[Y_{ti}|R_i=r] = \lim_{r \to c^+} E[Y_{ti}|R_i=r]
$$

If this holds true, we can find the causal effect at the threshold

\begin{align}
\lim_{r \to c^+} E[Y_{ti}|R_i=r] - \lim_{r \to c^-} E[Y_{ti}|R_i=r]=&\lim_{r \to c^+} E[Y_{1i}|R_i=r] - \lim_{r \to c^-} E[Y_{0i}|R_i=r] \\
=& E[Y_{1i}|R_i=r] - E[Y_{0i}|R_i=r] \\
=& E[Y_{1i} - Y_{0i}|R_i=r]
\end{align}

This is, in its own way, a sort of Local Average Treatment Effect (LATE), since we can only know it at the threshold. In this setting, we can think of RDD as a local randomized trial. For those at the threshold, the treatment could have gone either way and, by chance, some people fell below the threshold, and some people fell above. In our example, at the same point in time, some people are just above 21 years and some people are just below 21. What determines this is if someone was born some days later or not, which is pretty random. For this reason, RDD provides a very compelling causal story. It is not the golden standard of RCT, but it is close.

Now, to estimate the treatment effect at the threshold, all we need to do is estimate both of the limits in the formula above and compare them. The simplest way to do that is running a linear regression

To make it work, we interact a dummy for being above the threshold with the running variable

$
y_i = \beta_0 + \beta_1 r_i + \beta_2 \mathcal{1}\{r_i>c\} + \beta_3 \mathcal{1}\{r_i>c\} r_i
$

What this is doing is fitting a linear regression above the threshold and another below it. The parameter \\(\beta_0\\) is the intercept of the regression below the threshold and \\(\beta_0+\beta_2\\) is the intercept for the regression above the threshold.

Here is where the trick of centering the running variable at the threshold comes in play. After this pre-processing step, the threshold becomes zero. This causes the intercept \\(\beta_0\\) to be the predicted value at the threshold, for the regression below it. In other words, \\(\beta_0=\lim_{r \to c^-} E[Y_{ti}|R_i=r]\\). By the same reasoning, \\(\beta_0+\beta_2\\) is the limit of the outcome from above. Wich means, that

$
\lim_{r \to c^+} E[Y_{ti}|R_i=r] - \lim_{r \to c^-} E[Y_{ti}|R_i=r]=\beta_2=E[ATE|R=c]
$

Here is what this looks like in code for the case where we want to estimate the effect of alcohol consumption on death by all causes at 21 years.

```python
rdd_df = drinking.assign(threshold=(drinking["agecell"] > 0).astype(int))

model = smf.wls("all~agecell*threshold", rdd_df).fit()

model.summary().tables[1]
```

          	             coef	     std err	      t	      P>|t|	  [0.025	    0.975]

  Intercept	            93.6184	     0.932	   100.399	  0.000	    91.739	    95.498

  agecell	               0.8270	     0.819	     1.010	  0.318	    -0.823	     2.477

  threshold	             7.6627	     1.319	     5.811	  0.000	     5.005	    10.320

  agecell:threshold	    -3.6034	     1.158	    -3.111	  0.003	    -5.937	    -1.269

This model is telling us that mortality increases by 7.6627 points with the consumption of alcohol. Another way of putting this is that alcohol increases the chance of death by all causes by 8% ((7.6627+93.6184)/93.6184). Notice that this also gives us standard errors for our causal effect estimate. In this case, the effect is statistically significant, since the p-value is below 0.01.

If we want to verify this model visually, we can show the predicted values on the data that we have. You can see that it is as though we have 2 regression models: one for those above the threshold and one for below it.

```python
ax = drinking.plot.scatter(x="agecell", y="all", color="C0")
drinking.assign(predictions=model.fittedvalues).plot(x="agecell", y="predictions", ax=ax, color="C1")
plt.title("Regression Discontinuity");
```

If we do the same for the other causes, this is what we get.

```python
plt.figure(figsize=(8,8))

for p, cause in enumerate(["all", "mva", "suicide"], 1):
    ax = plt.subplot(3,1,p)
    drinking.plot.scatter(x="agecell", y=cause, ax=ax)
    m = smf.wls(f"{cause}~agecell*threshold", rdd_df).fit()
    ate_pct = 100*((m.params["threshold"] + m.params["Intercept"])/m.params["Intercept"] - 1)
    drinking.assign(predictions=m.fittedvalues).plot(x="agecell", y="predictions", ax=ax, color="C1")
    plt.title(f"Impact of Alcohol on Death: {np.round(ate_pct, 2)}%")

plt.tight_layout()
```

RDD is telling us that alcohol increases the chance of deth by suicide and car accidents by 15%, wich is a pretty significant ammount. These results are compelling arguments to not lower the drinking age, if we want to minimize mortality rates.

### Kernel Weighting

Regression Discontinuity relies heavily on the extrapolations properties of linear regression. Since we are looking at the values at the beginning and end of 2 regression lines, we better get those limits right. What can happen is that regression might focus too much on fitting the other data points at the cost of a poor fit at the threshold. If this happens, we might get the wrong measure of the treatment effect.

One way to solve this is to give higher weights for the points that are closer to the threshold. There are many ways to do this, but a popular one is to reweight the samples through the **triangular kernel**

$
K(R, c, h) = \mathcal{1}\{|R-c| \leq h\} * \bigg(1-\frac{|R-c|}{h}\bigg)
$

The first part of this kernel is an indicator function to whether we are close to the threshold. How close? This is determined by a bandwidth parameter \\(h\\). The second part of this kernel is a weighting function. As we move away from the threshold, the weights get smaller and smaller. These weights are divided by the bandwidth. If the bandwidth is large, the weights get smaller at a slow rate. If the bandwidth is small, the weights quickly go to zero.

To make it easier to understand, here is what the weights look like for this kernel applied to our problem. I've set the bandwidth to be 1 here, meaning we will only consider data from people that are no older than 22 years and no younger than 20 years.

```python
def kernel(R, c, h):
    indicator = (np.abs(R-c) <= h).astype(float)
    return indicator * (1 - np.abs(R-c)/h)
```

```python
plt.plot(drinking["agecell"], kernel(drinking["agecell"], c=0, h=1))
plt.xlabel("agecell")
plt.ylabel("Weight")
plt.title("Kernel Weight by Age");
```

If we apply these weights to our original problem, the impact of alcohol gets bigger, at least for all causes. It jumps from 7.6627 to 9.7004. The result remains very significant. Also, notice that I'm using `wls` instead of `ols`

```python
model = smf.wls("all~agecell*threshold", rdd_df,
                weights=kernel(drinking["agecell"], c=0, h=1)).fit()

model.summary().tables[1]
```

          	             coef	     std err	      t	      P>|t|	  [0.025	    0.975]

  Intercept	            93.2002	     0.731	   127.429	  0.000	    91.726	    94.674

  agecell	               0.4109	     1.789	     0.230	  0.819	    -3.196	     4.017

  threshold	             9.7004	     1.034	     9.378	  0.000	     7.616	    11.785

  agecell:threshold	    -7.1759	     2.531	    -2.835	  0.007	   -12.276	    -2.075

```python
ax = drinking.plot.scatter(x="agecell", y="all", color="C0")
drinking.assign(predictions=model.fittedvalues).plot(x="agecell", y="predictions", ax=ax, color="C1")
plt.title("Regression Discontinuity (Local Regression)");
```

And here is what it looks like for the other causes of death. Notice how the regression on the right is more negatively sloped since it disconsiders the right most points.

```python
plt.figure(figsize=(8,8))
weights = kernel(drinking["agecell"], c=0, h=1)

for p, cause in enumerate(["all", "mva", "suicide"], 1):
    ax = plt.subplot(3,1,p)
    drinking.plot.scatter(x="agecell", y=cause, ax=ax)
    m = smf.wls(f"{cause}~agecell*threshold", rdd_df, weights=weights).fit()
    ate_pct = 100*((m.params["threshold"] + m.params["Intercept"])/m.params["Intercept"] - 1)
    drinking.assign(predictions=m.fittedvalues).plot(x="agecell", y="predictions", ax=ax, color="C1")
    plt.title(f"Impact of Alcohol on Death: {np.round(ate_pct, 2)}%")

plt.tight_layout()
```

With the exception of suicide, it looks like adding the kernel weight made the negative impact on alcohol bigger. Once again, if we want to minimize the death rate, we should NOT recommend lowering the legal drinking age, since there clear impact of alcohol on the death rate.

This simple case covers what happens when regression discontinuity design works perfectly. Next, we will see some diagnostics that we should run in order to check how much we can trust RDD and talk about a topic that is very dear to our heart: the effect of education on earnings.

## Sheepskin Effect and Fuzzy RDD

When it comes to the effect of education on earnings, there are two major views in economics. The first one is the widely known argument that education increases human capital, increasing productivity and thus, earnings. In this view, education actually changes you for the better. Another view is that education is simply a signaling mechanism. It just puts you through all these hard tests and academic tasks and, if you can make it, it signals to the market that you are a good employee. In this view, education doesn't make you more productive. It only tells the market how productive you have always been. What matters here is the diploma. If you have it, you will be paid more. We refer to this as the **sheepskin effect**, since diplomas were printed in sheepskin in the past.

To test this hypothesis, [Clark and Martorell](https://faculty.smu.edu/millimet/classes/eco7321/papers/clark%20martorell%202014.pdf) used regression discontinuity to measure the effect of graduating 12th grade on earnings. In order to do that, they had to think about some running variable where students that fall above it graduate and those who fall below it, don't. They found such data in the Texas education system.

In order to graduate in Texas, one has to pass an exam. Testing starts at 10th grade and students can do it multiple times, but eventually, they face a last chance exam at the end of 12th grade. The idea was to get data from students who took those last chance exams and compare those that had barely failed it to those that barely passed it. These students will have very similar human capital, but different signaling credentials. Namely, those that barely passed it, will receive a diploma.

```python
sheepskin = pd.read_csv("./data/sheepskin.csv")[["avgearnings", "minscore", "receivehsd", "n"]]
sheepskin.head()
```

      avgearnings
      minscore
      receivehsd
      n

      0
      11845.086
      -30.0
      0.416667
      12

      1
      9205.679
      -29.0
      0.387097
      31

      2
      8407.745
      -28.0
      0.318182
      44

      3
      11114.087
      -27.0
      0.377778
      45

      4
      10814.624
      -26.0
      0.306667
      75

One again, this data is grouped by the running variable. It contains not only the running variable (`minscore`, already centered at zero) and the outcome (`avgearnings`), but it also has the probability of receiving a diploma in that score cell and the size of the call (`n`). So, for example, out of the 12 students in the cell -30 below the score threshold, only 5 were able to get the diploma.

This means that there is some slippage in the treatment assignment. Some students that are below the passing threshold managed to get the diploma anyway. Here, the regression discontinuity is **fuzzy**, rather than sharp. Notice how the probability of getting the diploma doesn't jump from zero to one at the threshold. But it does jump from something like 50% to 90%.

```python
sheepskin.plot.scatter(x="minscore", y="receivehsd", figsize=(10,5))
plt.xlabel("Test Scores Relative to Cutoff")
plt.ylabel("Fraction Receiving Diplomas")
plt.title("Last-chance Exams");
```

We can think of fuzzy RD as a sort of non compliance. Passing the threshold should make everyone receive the diploma, but some students, the never takers, don't get it. Likewise, being below the threshold should prevent you from getting a diploma, but some students, the allways takers, manage to get it anyway.

Just like we have the potential outcome, we have the potential treatment status in this situation. \\(T_1\\) is the treatment everyone would have received had they been above the threshold. \\(T_0\\) is the treatment everyone would have received had they been below the threshold. As you've might have noticed, we can think of the **threshold as an Instrumental Variable**. Just as in IV, this non compliance makes the treatment effect we can naively estimate biased to zero.

The probability of treatment being less than one, even above the threshold, makes the outcome we observe less than the true potential outcome \\(Y_1\\). By the same token, the outcome we observe below the threshold is higher than the true potential outcome \\(Y_0\\). This makes it look like the treatment effect at the threshold is smaller than it actually is and we will have to use IV techniques to correct for that.

Just like we've assumed smoothness on the potential outcome, we now assume it for the potential treatment. Also, we need to assume monotonicity, just like in IV. In case you don't remember, it states that \\(T_{i1}>T_{i0} \ \forall i\\). This means that crossing the threshold from the left to the right only increases your chance of getting a diploma (or that there are no defiers). With these 2 assumptions, we have a Wald Estimator for LATE.

$$
\dfrac{\lim_{r \to c^+} E[Y_i|R_i=r] - \lim_{r \to c^-} E[Y_i|R_i=r]}{\lim_{r \to c^+} E[T_i|R_i=r] - \lim_{r \to c^-} E[T_i|R_i=r]} = E[Y_{1i} - Y_{0i} | T_{1i} > T_{0i}, R_i=c]
$$

Notice how this is a local estimate in two senses. First, it is local because it only gives the treatment effect at the threshold \\(c\\). This is the RD locality. Second, it is local because it only estimates the treatment effect for the compliers. This is the IV locality.

To estimate this, we will use 2 linear regression. The numerator can be estimated just like we've done before. To get the denominator, we simply replace the outcome with the treatment. But first, let's talk about a sanity check we need to run to make sure we can trust our RDD estimates.

### The McCrary Test

One thing that could break our RDD argument is if people manipulate where they stand at the threshold. In the sheepskin example this could happen if students just below the threshold found a way around the system to increase their test score by just a bit. Another example is when you need to be below a certain income level to get a government benefit. Some families might lower their income on purpose, just to be just eligible for the program.

In these sorts of situations, we tend to see a phenomenon called bunching on the density of the running variable. This means that we will have a lot of entities just above or just below the threshold. To check this, we can plot the density function of the running variable and see if there are any spikes around the threshold. For our case, the density is given by the `n` column in our data.

```python
plt.figure(figsize=(8,8))

ax = plt.subplot(2,1,1)
sheepskin.plot.bar(x="minscore", y="n", ax=ax)
plt.title("McCrary Test")
plt.ylabel("Smoothness at the Threshold")

ax = plt.subplot(2,1,2, sharex=ax)
sheepskin.replace({1877:1977, 1874:2277}).plot.bar(x="minscore", y="n", ax=ax)
plt.xlabel("Test Scores Relative to Cutoff")
plt.ylabel("Spike at the Threshold");
```

The first plot shows how our data density looks like. As we can see, there are no spikes around the threshold, meaning there is no bunching. Students are not manipulating where they fall on the threshold. Just for illustrative purposes, the second plot shows what bunching would look like if students could manipulate where they fall on the threshold. We would see a spike in the density for the cells just above the threshold, since many students would be there. Getting this out of the way, we can go back to estimate the sheepskin effect.

As I've said before, the numerator of the Wald estimator can be estimated just like we did in the Sharp RD. Here, we will use as weight the kernel with a bandwidth of 15. Since we also have the cell size, we will multiply the kernel by the sample size to get a final weight for the cell.

```python
sheepsking_rdd = sheepskin.assign(threshold=(sheepskin["minscore"]>0).astype(int))
model = smf.wls("avgearnings~minscore*threshold",
                sheepsking_rdd,
                weights=kernel(sheepsking_rdd["minscore"], c=0, h=15)*sheepsking_rdd["n"]).fit()

model.summary().tables[1]
```

           	             coef	     std err	      t	      P>|t|	  [0.025	    0.975]

  Intercept	           1.399e+04	    83.678	   167.181	  0.000	  1.38e+04	  1.42e+04

  minscore	             181.6636	    16.389	    11.084	  0.000	   148.588	   214.739

  threshold	            -97.7571	   145.723	    -0.671	  0.506	  -391.839	   196.325

  minscore:threshold	    18.1955	    30.311	     0.600	  0.552	   -42.975	    79.366

This is telling us that the effect of a diploma is -97.7571, but this is not statistically significant (P-value of 0.5). If we plot these results, we get a very continuous line at the threshold. More educated people indeed make more money, but there isn't a jump at the point where they receive the 12th grade diploma. This is an argument in favor of the view that says that education increases earnings by making people more productive, since there is no sheepskin effect.

```python
ax = sheepskin.plot.scatter(x="minscore", y="avgearnings", color="C0")
sheepskin.assign(predictions=model.fittedvalues).plot(x="minscore", y="predictions", ax=ax, color="C1", figsize=(8,5))
plt.xlabel("Test Scores Relative to Cutoff")
plt.ylabel("Average Earnings")
plt.title("Last-chance Exams");
```

However, as we know from the way non compliance bias works, this result is biased towards zero. To correct for this, we need to scale it by the first stage and get the Wald estimator. Unfortunately, there isn't a good Python implementation for this, so we will have to do it manually and use bootstrap to get the standard errors.

The code below rund the numerator of the Wald estimator just like we did before and it also constructs the denominator by replacing the target variable with the treatment variable `receivehsd`. The final step just divides the numerator by the denominator.

```python
def wald_rdd(data):
    weights=kernel(data["minscore"], c=0, h=15)*data["n"]
    denominator = smf.wls("receivehsd~minscore*threshold", data, weights=weights).fit()
    numerator = smf.wls("avgearnings~minscore*threshold", data, weights=weights).fit()
    return numerator.params["threshold"]/denominator.params["threshold"]
```

```python
from joblib import Parallel, delayed

np.random.seed(45)
bootstrap_sample = 1000
ates = Parallel(n_jobs=4)(delayed(wald_rdd)(sheepsking_rdd.sample(frac=1, replace=True))
                          for _ in range(bootstrap_sample))
ates = np.array(ates)
```

With the bootstrap samples, we can plot the distribution of ATEs and see where the 95% confidence interval is.

```python
sns.distplot(ates, kde=False)
plt.vlines(np.percentile(ates, 2.5), 0, 100, linestyles="dotted")
plt.vlines(np.percentile(ates, 97.5), 0, 100, linestyles="dotted", label="95% CI")
plt.title("ATE Bootstrap Distribution")
plt.xlim([-10000, 10000])
plt.legend();
```

As you can see, even when we scale the effect by the first stage, it is still not statistically different from zero. This means that education doesn't increase earnings by a simple sheepskin effect, but rather by increasing one's productivity.

## Key Ideas

We learned how to take advantage of artificial discontinuities to estimate the causal effect. The idea is that we will have some artificial threshold that makes the probability of treatment jump. One example that we saw was how age makes the probability of drinking jump at 21 how we can use this to estimate the impact of drinking. We use the fact that very close to the threshold, we have something close to a randomized trial. Entities very close to the threshold could have gone either way and what determines where they've landed is essentially random. With this, we can compare those just above and just below to get the treatment effect. We saw how we could do that with weighted linear regression using a kernel and how this even gave us, for free, standard errors for our ATE.

Then, we look at what would happen in the fuzzy RD design, where we have non compliance. We saw how we could approach the situation much like we did with IV.

## References

I like to think of this entire book as a tribute to Joshua Angrist, Alberto Abadie and Christopher Walters for their amazing Econometrics class. Most of the ideas here are taken from their classes at the American Economic Association. Watching them is what is keeping me sane during this tough year of 2020.
* [Cross-Section Econometrics](https://www.aeaweb.org/conference/cont-ed/2017-webcasts)
* [Mastering Mostly Harmless Econometrics](https://www.aeaweb.org/conference/cont-ed/2020-webcasts)

I'll also like to reference the amazing books from Angrist. They have shown me that Econometrics, or 'Metrics as they call it, is not only extremely useful but also profoundly fun.

* [Mostly Harmless Econometrics](https://www.mostlyharmlesseconometrics.com/)
* [Mastering 'Metrics](https://www.masteringmetrics.com/)

Other important reference is Miguel Hernan and Jamie Robins' book. It has been my trustworthy companion in the most thorny causal questions I had to answer.

* [Causal Inference Book](https://www.hsph.harvard.edu/miguel-hernan/causal-inference-book/)

```python

```