Perceptron mit und ohne Flux

6 Minuten zum Lesen

Dinge einzuordnen ist eine immer wiederkehrende Aufgabenstellung. Finde in einer Liste von Produkten ein Produkt, das den Kunden auch interessieren könnte? Ist diese Pflanze Bärlauch oder ein Maiglöckchen? Ist dieser Pilz eßbar oder nicht? Bei diesen Beispielen und dem folgenden Beispiel geht es um eine Entscheidung mit zwei möglichen Antworten, d.h. die Elemente einer Menge sind zwei verschiedenen Klassen, U und V, zuzuordnen.

Ein linearer Klassifizierer

Der einfachste Versuch, eine Entscheidung zu fällen ist: Finde eine Linie (im zweidimensionalen Fall) oder eine (Hyper-) Ebene, welche den Raum in zwei Bereiche einteilt. In dem einen Bereich finden sich Elemete der Klasse U, im anderen Elemente der Klasse V. Falls das möglich ist, nennt man die Klassen linear separierbar.1

Wir versuchen das Problem iterativ, also schrittweise, zu lösen: Am Anfang nehmen wir eine zufällige Linie, die durch ihre Steigung und ihren y-Achsenabschnitt bestimmt ist. Dann prüfen wir, wie gut die Linie die beiden Klassen trennt und passen und an, wenn die Linie ein Element falsch zuordnet. Im allgemeinen Fall – mit mehr als einem Attribut – lautet die lineare Gleichung:

oder mit einem zusätzlich eingeführten , das immer den Wert 1 hat,

Wendet man das lineare Modell auf einen Merkmalsvektor an, ergibt sich die Zuordnung durch das Vorzeichen des Ergebnisses.

Die Anpassungsvorschrift lautet: Bei richtiger Zuordnung werden die Parameter nicht verändert. Bei falscher Zuordnung werden die Parameter entsprechend der Werte der und der Richtung der Abweichung angepasst. Ist das Ergebnis positiv falsch, werden die Parameter vermindert, ist das Ergebnis negativ falsch, werden die Parameter erhöht. Die folgende Zuordnung setzt diese Anpassungsvorschrift um:

ist die Lernrate. Sie bestimmt die Empfindlichkeit des Modells. Für die Werte der Vorhersage und der tatsächliche Klasse erhält man.

 
0 0 0 keine Anpassung
0 1 -1 wird vermindert
1 0 1 wird erhöht
1 1 0 keine Anpassug

Eine ausführlichere Beschreibung dieser Perceptron Lernregel findet sich bei Kubat1.

Anwendung auf den Iris-Datensatz

Hier soll das Verfahren auf den Iris-Datensatz von R. A. Fisher angewendet werden. Er wird aus einer SQLite Datei in einen DataFrame geladen:

julia> using SQLite: DB, Query

julia> using DataFrames

julia> irisfile = ("../../data/Iris/iris.sqlite3")
"../../data/Iris/iris.sqlite3"

julia> idb = DB(irisfile)
SQLite.DB("../../data/Iris/iris.sqlite3")

julia> datiris = Query(idb, "select * from 'iris'", nullable = false) |> DataFrame
150×5 DataFrame. Omitted printing of 1 columns
 Row  sepal_length  sepal_width  petal_length  petal_width 
      Float64       Float64      Float64       Float64     
├─────┼──────────────┼─────────────┼──────────────┼─────────────┤
 1    5.1           3.5          1.4           0.2         
 2    4.9           3.0          1.4           0.2         
 3    4.7           3.2          1.3           0.2         
 4    4.6           3.1          1.5           0.2         
 5    5.0           3.6          1.4           0.2         

 145  6.7           3.3          5.7           2.5         
 146  6.7           3.0          5.2           2.3         
 147  6.3           2.5          5.0           1.9         
 148  6.5           3.0          5.2           2.0         
 149  6.2           3.4          5.4           2.3         
 150  5.9           3.0          5.1           1.8         

Um bei einem zweiwertigen Beispiel zu bleiben, sollen in den Daten diejenigen Merkmalssätze gefunden werden, die “Iris-setosa” zuzuordnen sind.

julia> vclass = datiris.class .== "Iris-setosa"
150-element BitArray{1}:
 1
 1
 1
 1
 1
 1
 1
 
 0
 0
 0
 0
 0
 0
 0

Das Model wird durch einen Parameter-Vektor W repräsentiert. Das Modell wird ausgewertet, durch Multiplikation mit dem Merkmalsvektor und der Prüfung, ob das Resultat positiv oder negativ ist. Um die Modelle vergleichen zu können, geben wir eine Initialisierung vor.

struct Model
    W
end

function (m::Model)(x)
    div(sign((m.W * x)[1]) + 1, 2)
end

Winit = [-0.251586  -0.0537112  0.128108  0.0789731  -0.238168]

model = Model(copy(Winit))

Die Anpassung ergibt sich aus der oben angegebenen Vorschrift:

function update!(model, x, y, eta)
    d = y - model(x)
    for (i, cx) in enumerate(x)
        model.W[i] += eta * d * cx
    end
end

Wieviele falsche Vorhersagen macht das Modell am Anfang?


julia> sum([model(x) != y for (x, y) in zip(eachcol(Mx), vclass)])
39

Im Trainingsprozess wird der Parametervektor wiederholt angepasst:

function tloop!(model, data, eta, n)
    
    hfalse(t) = model(t[1]) != t[2]
    
    cfalse = Int[]
    for epoch in 1:n
        # train
        for d in data
            update!(model, d..., eta)
        end
        push!(cfalse, sum(hfalse.(itdat)))
    end
    cfalse
end

Mit einer Lernrate von 0.005 sind 5 Trainingsschritte nötig, um die Elemente des Datensatzes alle richtig zuzuordnen.

julia> itdat = zip(eachcol(Mx), vclass);

julia> eta = 0.005;

julia> tloop!(model, itdat, eta, 12)
12-element Array{Int64,1}:
 49
 20
 48
 12
  0
  0
  0
  0
  0
  0
  0
  0

Perceptron mit Flux, „The Julia Machine Learning Library“

Das Paket Flux umfasst Typen und Methoden für maschinelles Lernen und erlaubt es, Modelle unterschiedlicher Komplexität und unterschiedlichen Typs in wenigen Zeilen Code zu formulieren und zu trainieren. Eine der Kernfunktionen besteht darin, Gradienten von Julia-Funktionen zu bilden. Für das oben beschriebene sehr einfache lineare Modell erzielt man kaum einen Gewinn, aber die Prinzipien lassen sich sehr schön nachvollziehen.

Das Modell wird über den Konstruktor Dense erzeugt, dem lediglich die Dimensionen übergeben werden, sowie eine Aktivierungsfunktion, die in unserem Fall das Vorzeichen des Ergebnisses in einen 0/1-Wert für die Klasse umwandelt.

using Flux 

fclass(y) = div(sign(y) + 1, 2)
fxmodel = Dense(4, 1, fclass)

hyp(x) = fxmodel(x)[1].data

function fxinit!(model, Winit)
    Flux.Tracker.update!(model.W, -model.W)
    Flux.Tracker.update!(model.b, -model.b)
    Flux.Tracker.update!(model.W, permutedims(Winit[2:5]))
	Flux.Tracker.update!(model.b, Winit[1:1])
	model
end

fxinit!(fxmodel, Winit)

Man beachte, dass in dem Modell der Offset b als separater Wert abgelegt ist. Entsprechend haben die Merkmalsvektoren nur eine Länge von 4. Die Modelle mit den Parametern von Winit liefern die gleichen Ergebnisse.

julia> fxMx = [datiris[i, j] for j in 1:4, i in 1:size(datiris, 1)]
4×150 Array{Float64,2}:
 5.1  4.9  4.7  4.6  5.0  5.4  4.6  5.0    6.8  6.7  6.7  6.3  6.5  6.2  5.9
 3.5  3.0  3.2  3.1  3.6  3.9  3.4  3.4     3.2  3.3  3.0  2.5  3.0  3.4  3.0
 1.4  1.4  1.3  1.5  1.4  1.7  1.4  1.5     5.9  5.7  5.2  5.0  5.2  5.4  5.1
 0.2  0.2  0.2  0.2  0.2  0.4  0.3  0.2     2.3  2.5  2.3  1.9  2.0  2.3  1.8

julia> model(Mx[:, 34]), fxmodel(fxMx[:, 34])
(1.0, Float32[1.0] (tracked))

julia> model(Mx[:, 35]), fxmodel(fxMx[:, 35])
(0.0, Float32[0.0] (tracked))

julia> sum([hyp(x) != y for (x, y) in zip(eachcol(fxMx), vclass)])
39

Für die Modellanpassung wird eine Verlustfunktion angegeben, …

function loss(model, x, y)
    d = y - model(x)[1]
    l = d * model.b + d * (model.W * x)
    # Flux liefert immmer einen Array zurück
    -l[1]
end

… deren Gradient die Korrekturwerte in jedem Anpassungsschritt liefert.

julia> pm = Flux.params(fxmodel)
Params([Float32[-0.0537112 0.128108 0.0789731 -0.238168] (tracked), Float32[-0.251586] (tracked)])

julia> g34 = Flux.Tracker.gradient(() -> loss(fxmodel, fxMx[:, 34], vclass[34]), pm)
Grads(...)


julia> g34[fxmodel.W]
Tracked 1×4 Array{Float32,2}:
 0.0  0.0  0.0  0.0

julia> g34[fxmodel.b]
Tracked 1-element Array{Float32,1}:
 0.0f0

julia> g35 = Flux.Tracker.gradient(() -> loss(fxmodel, fxMx[:, 35], vclass[35]), pm)
Grads(...)


julia> g35[fxmodel.W]
Tracked 1×4 Array{Float32,2}:
 -4.9  -3.1  -1.5  -0.1

julia> g35[fxmodel.b]
Tracked 1-element Array{Float32,1}:
 -1.0f0
 
julia> fxMx[:, 35]
4-element Array{Float64,1}:
 4.9
 3.1
 1.5
 0.1

Der Gradient in den Parametern entspricht den Werten des Merkmalsvektors.

Der Trainingsprozess lautet nun …

function fxtloop!(model, data, eta, n)
    pm = Flux.params(model)
    opt = Descent(eta)

    hfalse(t) = hyp(t[1]) != t[2]

    cfalse = Int[]
    for epoch in 1:n
        for d in data
            gs = Flux.Tracker.gradient(pm) do
                loss(model, d...)
            end
            Flux.Tracker.update!(opt, pm, gs)
        end
        push!(cfalse, sum(hfalse.(data)))
    end
    cfalse
end

… und liefert ebenfalls nach fünf Schritten die gewünschte Zuordnung.

julia> fxitdat = zip(eachcol(fxMx), vclass);

julia> eta = 0.005;

julia> fxtloop!(fxmodel, fxitdat, eta, 12)
12-element Array{Int64,1}:
 49
 20
 48
 12
  0
  0
  0
  0
  0
  0
  0
  0

Quadratische Abweichung als Verlustfunktion

Mit der Abbildung der Modellauswertung auf das Intervall [0, 1] und einer (einfacheren) quadratischen Verlustfunktion erhält man:

fxmodel = Dense(4, 1, σ)

hyp(x) = div(fxmodel(x)[1].data, 0.5)

function loss(model, x, y)
    abs2(model(x)[1] - y)
end

Wieder ordnet das Modell zunächst 39 Elemente falsch zu:

julia> fxinit!(fxmodel, Winit)
Dense(4, 1, σ)

julia> sum([hyp(x) != y for (x, y) in zip(eachcol(fxMx), vclass)])
39

In sieben Schritten, die aber jeweils sehr viel schneller berechnet werden, führt das Training zum Ziel:

julia> fxtloop!(fxmodel, fxitdat, eta, 12)
12-element Array{Int64,1}:
 50
 50
 50
 42
  6
  1
  0
  0
  0
  0
  0
  0
julia> VERSION
v"1.2.0"

Startseite

  1. Kubat: An Introduction to Machine Learning. Springer: Cham 2015, S. 65  2