Gradientenabstieg

Hat man eine Menge von Testpunkten die man mit einer geraden Linie approximieren will so hat man mehrere Möglichkeiten. In Python ist es am einfachsten, die Daten in ein Array einzugeben und damit die Funktion lingress() aus dem scipy.stats-Paket aufzurufen. Dadurch bekommt man immerhin neben der Steigung und dem Offset der Regressionsgeraden auch noch die Statistik-Werte p und r geschenkt. Doch was für Mathematiker, wenn nicht selbst wagen…

Regressionsgerade durch Gradientenabstiegsverfahren

Die Methode der kleinsten Quadrate

Man kann die Aufgabe umformulieren: Gesucht sind a_{0} und a_{1} so, dass die Summe aller Distanzen der Messpunkte zu den entsprechenden Punkten auf der Gerade y = a_{0}x^0 + a_{1}x^1 so klein wie möglich wird. Wenn man diese Gerade unter Verwendung dieser Distanzen sucht, hat man die Methode der kleinsten Quadrate eingesetzt. Es gibt einige Erläuterungen im Internet über dieses Verfahren. Die kürzeste Schreibeweise der ausformulierten, analytischen Closed Form mit der a_{1} berechnet werden kann ist:

a_{1}=\frac{\sum\limits_{i=1}^{n}(x_{i}y_{i}) - n\tilde{x}\tilde{y}}{(\sum\limits_{i=1}^{n}x_{i}^{2})-n\tilde{x}^{2}}

Dabei sind \tilde{x} und \tilde{y} sind die Durchschnitte aller Messpunkte.

Um a_{0} auszurechnen muss man das eben berechnete a_{1} in folgende Formel für a_{0} einsetzen und umformen:

na_{0} + (\sum\limits_{i=1}^{n})a_{1} = \sum\limits_{i=1}^ny_{i}

Ja und viel Spaß dabei! Aktuelle Problemstellungen können mehrere Millionen Messpunkte enthalten. Die notwendigen Berechnungen in dieser expliziten Methode sind nichts für ungeduldige.

Gradientenabstiegsverfahren

An dieser Stelle kann uns das Gradientenabstiegsverfahren helfen. Gretchenhaft gesagt^{(1)}, zeichnet man mit beliebig geschätzen Startwerten für a_{0} und a_{1} die Gerade in die Messpunkte ein, berechnet den Gradienten (die Ableitung) einer sogenannten Fehler-Funktion^{(2)}. Dann verändert man die Steigung der Geraden um einen Bruchteil ^{(3)} entgegengesetzt zum Gradienten der Fehlerfunktion, damit die nächste Hypothese \hat{y} näher an y liegt.

Mathematisch anschaulich sieht die Fehlerfunktion so aus:

MSE=\frac{1}{2n}\sum\limits_{i}(\hat{y}_{i}-y_{i})^{2}=\frac{1}{2n}\sum\limits_{i}(w^{T}x_{i}-y_{i})^2

Und die Ableitung davon nach \frac{\partial}{\partial{x}}:

\frac{\partial}{\partial{w_{j}}}MSE = \frac{1}{n}(w_{j}x_{i} - y_{i})x_{ij}

Uff, wer bis hier noch dabei ist hat es geschafft. Falls nicht – macht nichts – vielleicht hilft ein Blick in den Code.

^{(1)} Ja, es ist gretchenhaft, denn mäxchenhaft ist ja lächerlich.

^{(2)} Eine Fehlerfunktion ist abhängig von einem X und liefert einen Fehler. Das gute an dieser Darstellung ist, dass man die Funktion ableiten kann und dadurch einen Fehler-Gradienten erhält, der wiederum hilft die ursprüngliche Hypothese (Regressionsgerade) zu verbessern.

^{(3)} Diesen Bruchteil nennt man für gewöhnlich Lernrate. Der Buchstabe dazu ist \eta (sprich: eta)

import numpy as np
import matplotlib.pyplot as plt;

X = 5 * np.random.rand(1000, 1)
y = (np.random.rand()*10-5) + (np.random.rand()*10-5)*X +  np.random.randn(1000, 1)

class LinearRegressionGD():

    def __init__(self,  η = 0.0001, epochen = 50):

        self.η = η # Lernrate
        self.epochen = Epochen

    def fit(self, X, y):

        # gesucht ist ein w so dass ∀ x ∈ X: ŷ = w[1]∙x + w[0] liegt möglichst nahe an y
        self.w = np.zeros(1 + X.shape[1])

        for i in range(self.epochen):
            ŷ = self.predict(X)

            err = y.flatten() - ŷ  # Fehler

            #mse = (err ** 2).sum() / 2.0 / len(X)  # aufsammeln und ausgeben, wird in Rechnung nicht gebraucht

            self.w[1:] += self.η * X.T.dot(err)  # Anpassen des Gewichts Beta (und falls es sie gäbe: aller weiteren Gewichte)
            self.w[0] += self.η * err.sum()      # Anpassen des Gewichts Alpha

        return self

    def predict(self, X):
        return np.dot(X, self.w[1:]) + self.w[0] # y = x * Beta + Alpha

x_std = X
y_std = y
# Zum Probieren mit μ=0 und σ=1
#x_std = (X - X.mean()) / X.std()
#y_std = (y - y.mean()) / y.std()

lrGD = LinearRegressionGD()  # Instanz erzeugen
lrGD.fit(x_std, y_std)


plt.figure(figsize = (15, 15))
plt.plot(x_std, y_std, "b.") # Scatter
plt.plot(x_std, lrGD.predict(x_std), "r-", linewidth = 5)   # Regressionsgerade als Linie
plt.show()

Alternative Implementierung

Nicht ganz so effizient, aber doch lehrreich ist die nächste Implementierung. (In Wirklichkeit war das mein erster Versuch :-))

Manuelle Regression

Es werden insgesamt viel weniger Punkte mit mehr Rechenaufwand approximiert. Aber ohne die Matrizen-Tricks wie beim Verfahren oben, kann man viel besser zusehen, wie die Regressionsgerade langsam zum Optimum hingedreht wird.

import numpy as np
import matplotlib.pyplot as plt

# Definiere n Punkte (x, y) ∈ ([0, maxx], [0, maxy])
n = 6
maxx = 10
maxy = 2
x = np.random.rand(n)*maxx
y = np.random.rand(n)*maxy

# Plot der n Punkte
plt.scatter(x, y)

η = 0.001 # lernrate
m = 0.0  # Zufällige Steigung
b = 0.0  # Zufälliger Offset
ϝ = lambda x: m*x + b # f(x) = mx + b

print("---------------------")

previous_sdm = 123

j = 0
for j in range(2000):
    cost = 0.
    sdm = 0.
    sdb = 0.
    # gehe alle Punkte durch und summiere die Fehlerquadrate
    for i in range(len(x)):
        δ = y[i]-ϝ(x[i]) # δ ist der Unterschied zwischen der Vorhersage und dem i-ten Messwert 
        loss =  δ**2     # loss ist das Quadrat
        db = -δ          # Ableitung der cost-Funktion nach b 
        dm = -δ * x[i]   # Ableitung der cost-Funktion nach m
        cost = cost + loss
        sdm = sdm + dm
        sdb = sdb + db

    m = m - η * sdm # korrigiere m in entgegengesetzer Richtung der Steigung (downhill)
    b = b - η * sdb # dito mit b

    # Vielleicht haben wir ja ein ausreichend gutes Ergebnis
    if np.abs(previous_sdm - sdm) < 0.00000001:
        print("end after {} steps".format(j))
        break
    previous_sdm = sdm
    #print(sdm)

print("Total cost: {:.3f}, {:.3f}, {:.3f} in {} steps".format(cost, sdm, sdb, j))

p = np.linspace(0, maxx, 2) # wir brauchen nur 2 Punkte für die Gerade
plt.plot(p, ϝ(p))
plt.show()