import numpy as np
import matplotlib.pyplot as plt
import seaborn

%matplotlib inline

x = np.linspace(-3, 3, 100)
plt.plot(x, x**2, label='f(x)')  # optymalizowana funkcja
plt.plot(x, 2 * x, label='pochodna -- f\'(x)')  # pochodna
plt.legend()
plt.show()

"""
Explanation: Optymalizacja i propagacja wsteczna (backprop)
Zaczniemy od prostego przykładu. Funkcji kwadratowej: $f(x) = x^2$
Spróbujemy ją zoptymalizować (znaleźć minimum) przy pomocy metody zwanej gradient descent. Polega ona na wykorzystaniu gradientu (wartości pierwszej pochodnej) przy wykonywaniu kroku optymalizacji.
End of explanation
"""


learning_rate = 0.1  # przykładowa wartość
nb_steps = 10
x_ = 1

steps = [x_]
for _ in range(nb_steps):
    x_ -= learning_rate * (2 * x_)  # learning_rate * pochodna
    steps += [x_]
    
plt.plot(x, x**2, alpha=0.7)
plt.plot(steps, np.array(steps)**2, 'r-', alpha=0.7)
plt.xlim(-3, 3)
plt.ylim(-1, 10)
plt.show()

"""
Explanation: Funkcja ta ma swoje minimum w punkcie $x = 0$. Jak widać na powyższym rysunku, gdy pochodna jest dodatnia (co oznacza, że funkcja jest rosnąca) lub gdy pochodna jest ujemna (gdy funkcja jest malejąca), żeby zminimalizować wartość funkcji potrzebujemy wykonywać krok optymalizacji w kierunku przeciwnym do tego wyznaczanego przez gradient.
Przykładowo gdybyśmy byli w punkcie $x = 2$, gradient wynosiłby $4$. Ponieważ jest dodatni, żeby zbliżyć się do minimum potrzebujemy przesunąć naszą pozycje w kierunku przeciwnym czyli w stonę ujemną.
Ponieważ gradient nie mówi nam dokładnie jaki krok powinniśmy wykonać, żeby dotrzeć do minimum a raczej wskazuje kierunek. Żeby nie "przeskoczyć" minimum zwykle skaluje się krok o pewną wartość $\alpha$ nazywaną krokiem uczenia (ang. learning rate).
Prosty przykład optymalizacji $f(x) = x^2$ przy użyciu gradient descent.
Sprawdź różne wartości learning_step, w szczególności [0.1, 1.0, 1.1].
End of explanation
"""


def sigmoid(x):
    return 1. / (1. + np.exp(-x))

def forward_pass(x):
    t1 = sigmoid(x)  # 1
    t2 = t1 * x      # 2
    
    b1 = x**2        # 3
    b2 = b1 + 1.     # 4
    b3 = 1. / b2     # 5
    
    y = t2 * b3      # 6
    return y

"""
Explanation: Backprop - propagacja wsteczna - metoda liczenia gradientów przy pomocy reguły łańcuchowej (ang. chain rule)
Rozpatrzymy przykład minimalizacji troche bardziej skomplikowanej jednowymiarowej funkcji
$$f(x) = \frac{x \cdot \sigma(x)}{x^2 + 1}$$
Do optymalizacji potrzebujemy gradientu funkcji. Do jego wyliczenia skorzystamy z chain rule oraz grafu obliczeniowego.
Chain rule mówi, że:
$$ \frac{\partial f}{\partial x} = \frac{\partial f}{\partial y} \cdot \frac{\partial y}{\partial x}$$
Żeby łatwiej zastosować chain rule stworzymy z funkcji graf obliczeniowy, którego wykonanie zwróci nam wynik funkcji.
End of explanation
"""


def backward_pass(x):
    # kopia z forward_pass, ponieważ potrzebujemy wartości
    # pośrednich by wyliczyć pochodne
    # >>>
    t1 = sigmoid(x)  # 1
    t2 = t1 * x      # 2
    
    b1 = x**2        # 3
    b2 = b1 + 1.     # 4
    b3 = 1. / b2     # 5
    
    y = t2 * b3      # 6
    # <<<
    
    # backprop: y = t2 * b3
    dt2 = b3                    # 6
    db3 = t2                    # 6
    
    # backprop: b3 = 1. / b2
    db2 = (-1. / b2**2) * db3   # 5
    
    # backprop: b2 = b1 + 1.
    db1 = 1. * db2              # 4
    
    # backprop: b1 = x**2
    dx = 2 * x * db1            # 3
    
    # backprop: t2 = t1 * x
    dt1 = x * dt2               # 2
    dx += t1 * dt2              # 2 -- uwaga! pochodna dx zależy od kilku węzłów dlatego sumujemy jej gradienty
    
    # backprop: t1 = sigmoid(x)
    dx += t1 * (1. - t1) * dt1  # 1
    
    return dx

x = np.linspace(-10, 10, 200)

plt.plot(x, forward_pass(x), label='f(x)')
plt.plot(x, backward_pass(x), label='poochodna -- f\'(x)')
plt.legend()
plt.show()

"""
Explanation: Jeśli do węzła przychodzi więcej niż jedna krawędź (np. węzeł x), gradienty sumujemy.
End of explanation
"""


learning_rate = 1
nb_steps = 100
x_ = 1.  # przykładowa wartość

steps = [x_]
for _ in range(nb_steps):
    x_ -= learning_rate * backward_pass(x_)
    steps += [x_]

plt.plot(x, forward_pass(x), alpha=0.7)
plt.plot(steps, forward_pass(np.array(steps)), 'r-', alpha=0.7)
plt.show()

"""
Explanation: Posiadając gradient możemy próbować optymalizować funkcję, podobnie jak poprzenio.
Sprawdź różne wartości parametru x_, który oznacza punkt startowy optymalizacji. W szczególności zwróć uwagę na wartości [-5.0, 1.3306, 1.3307, 1.330696146306314].
End of explanation
"""


