APML · Projekt 2 · Geyser-EM-etu_en.ipynb

EM am Old Faithful

Ein geführter Pfad durch dein Notebook: erst das Konzept, dann ein kleines Labor zum Anfassen, dann die Aufgabe mit gestuften Hinweisen. Lösungen sind eingeklappt. Probier es zuerst selbst, die erwarteten Ausgaben aus dem Notebook helfen dir beim Prüfen.

0 / 9 erledigt
A.1 · Zellen 4–12 · ca. 6 min

Die 2D-Normalverteilung

Eine Gaußverteilung in 2D wird durch zwei Dinge festgelegt: den Mittelwert μ (wo der Hügel steht) und die Kovarianzmatrix Σ (wie breit er in jede Richtung ist und ob er schräg liegt).

p(x | μ, Σ) = 1 / ( (2π)^(N/2) · |Σ|^(1/2) ) · exp( −½ · (x−μ) Σ⁻¹ (x−μ)ᵀ )

Der Kern ist der Ausdruck im Exponenten, die quadrierte Mahalanobis-Distanz d² = (x−μ) Σ⁻¹ (x−μ)ᵀ. Sie misst den Abstand zum Zentrum in „Standardabweichungen“, also relativ zur Form von Σ. Punkte mit gleichem d² liegen auf einer Ellipse. Genau diese Ellipsen siehst du später als Konturlinien.

(x−μ)
1×2 (Zeilenvektor)
Σ⁻¹
2×2
(x−μ)ᵀ
2×1
Ergebnis
(1×2)(2×2)(2×1) = 1×1, also ein Skalar. Das ist der „Dimension-Check auf Papier“ aus dem Notebook.

Σ = [[σ₁², ρσ₁σ₂],[ρσ₁σ₂, σ₂²]]. Auf der Diagonale stehen Varianzen (nicht Standardabweichungen!), neben der Diagonale die Kovarianz. Σ muss symmetrisch und positiv definit sein (|ρ| < 1), sonst ist |Σ| ≤ 0 und die Formel bricht.

Labor · Klick in die Fläche setzt den Punkt x

Mit „Test aus Zelle 6“ steht x auf (0,0): die Anzeige muss 0.023056… zeigen. Das ist genau der Wert, den deine Funktion liefern soll. Ellipsen: d = 1, 2, 3.

Aufgabe · Zelle 5

Schreibe normal_2d(x, mu, Sig), das die Dichte für einen Punkt x zurückgibt.

Hinweis 1: welche numpy-Funktionen?

np.linalg.det(Sig) für |Σ|, np.linalg.inv(Sig) für Σ⁻¹. Für das Matrixprodukt nimm @.

Hinweis 2: Zeilen- vs. Spaltenvektor

Bei 1D-numpy-Arrays ist d @ A @ d automatisch „Zeile × Matrix × Spalte“, du musst nichts transponieren. N = len(mu) macht die Funktion dimensionsunabhängig.

Lösung anzeigen
def normal_2d(x, mu, Sig):
    d = x - mu                       # (2,)
    N = len(mu)
    norm = 1.0 / ((2*np.pi)**(N/2) * np.sqrt(np.linalg.det(Sig)))
    return norm * np.exp(-0.5 * d @ np.linalg.inv(Sig) @ d)

Frage aus Zelle 7: Passt die Reihenfolge der zwei Werte?

Schreib die Antwort in die Markdown-Zelle: Am Mittelwert ist der Exponent 0, also maximal; jeder andere Punkt hat d² > 0 und damit eine kleinere Dichte. Zelle 11 zeigt eine Ellipse, die entlang der zweiten Achse gestreckt ist (Varianz 4 vs. 1), Zelle 12 eine nach rechts oben gekippte Ellipse (positive Kovarianz).

A.1 · Zellen 13–14 · ca. 3 min

Eine Gaußverteilung per Hand anpassen

Du schätzt für die Wolke oben rechts (lange Eruptionen) μ und Σ mit dem Auge. Faustregel: Etwa 2/3 der Punkte liegen innerhalb μ ± σ. Achtung, in Σ gehören dann σ² hinein.

Labor · echte Old-Faithful-Daten (272 Eruptionen)

Die Anzeige zählt, welcher Anteil der Punkte mit Dauer > 3 min innerhalb μ ± σ je Achse liegt. Ziel: ungefähr 66–68 %.

Aufgabe · Zelle 13 (Markdown) und 14

Trage deine Schätzungen für σ₁, σ₂ und ρ in die Markdown-Zelle ein und setze mu4, Sig4.

Lösung anzeigen

σ₁ ≈ 0.4 (Varianz ≈ 0.16), σ₂ ≈ 6 (Varianz ≈ 36), ρ ≈ 0. Das Referenzbild zeigt eine fast achsenparallele Ellipse. Die echte Korrelation der Wolke liegt bei etwa 0.37 (Kovarianz ≈ 0.9), beides wird akzeptiert.

mu4  = np.array([4.25, 80.])
Sig4 = np.array([[0.16, 0. ],
                 [0.  , 36.]])
A.2 · Zellen 15, 18, 20 · ca. 5 min

Mischmodelle und die Idee von EM

Ein Gaußsches Mischmodell sagt: Jeder Punkt wurde erzeugt, indem man zuerst eine Klasse Z = k mit Wahrscheinlichkeit πk zieht und dann x aus 𝒩(μk, Σk). Z ist latent, wir sehen es nicht.

p(x) = Σₖ πₖ · 𝒩(x | μₖ, Σₖ)

Das Henne-Ei-Problem: Kennten wir Z, wären μ und Σ einfach Mittelwert und Kovarianz pro Klasse. Kennten wir μ, Σ, π, könnten wir Z per Bayes schätzen. EM löst das, indem es beides abwechselnd tut:

  1. E-Schritt: Für jeden Punkt i und jede Klasse k die „Verantwortung“ Qi(k) = P(Z=k | xi, Θ) per Bayes. Das ist eine weiche Zuordnung, z. B. 97 % Klasse 1, 3 % Klasse 0.
  2. M-Schritt: π, μ, Σ neu schätzen, wobei jeder Punkt mit seinem Q gewichtet zählt. Das ist ein gewichteter Mittelwert und eine gewichtete Kovarianz.
  3. Wiederholen, bis sich μ kaum noch ändert. Die Log-Likelihood steigt dabei nie.
E: Q_i(k) = πₖ 𝒩(xᵢ|μₖ,Σₖ) / Σⱼ πⱼ 𝒩(xᵢ|μⱼ,Σⱼ)
M: Nₖ = Σᵢ Q_i(k) πₖ = Nₖ / n μₖ = Σᵢ Q_i(k) xᵢ / Nₖ Σₖ = Σᵢ Q_i(k)(xᵢ−μₖ)ᵀ(xᵢ−μₖ) / Nₖ
Labor · EM Schritt für Schritt

Punktfarbe = Mischung der Verantwortungen Q. Probiere die schlechte Initialisierung: Bei Old Faithful dauert es nur länger, bei vier Wolken kann EM in einem lokalen Optimum hängen bleiben. Das ist genau der Inhalt von A-4 und B.

A.2 · Zellen 17 und 19 · ca. 5 min

Initialisierung und E-Schritt

Leg dir zuerst die Shapes fest. Wenn die stimmen, funktioniert später auch K = 4 ohne Änderungen.

X
(n, 2), n = 272
pi
(K,)
mu
(K, 2), Zeile k = μₖ
Sig
(K, 2, 2), Sig[k] = Σₖ
q
(K, n). Das Notebook druckt q[:,:5], also Klassen als Zeilen.
Aufgabe · Zelle 17

init(X): μ₀ = Mittelwert + 1, μ₁ = Mittelwert − 1, beide Σ = Kovarianz der ganzen Wolke.

Hinweis: Kovarianz in numpy

np.cov erwartet Variablen als Zeilen, also np.cov(X.T). Es teilt durch n−1, das passt zur erwarteten Ausgabe 1.3027…

Lösung anzeigen
def init(X):
    pi = np.array([0.5, 0.5])
    m = X.mean(0)
    mu = np.vstack((m + 1, m - 1))          # (2, 2)
    Sig = np.array([np.cov(X.T)] * 2)       # (2, 2, 2)
    return pi, mu, Sig
Aufgabe · Zelle 19

Q_i(X, pi, mu, Sig): Zähler πₖ·𝒩(xᵢ|μₖ,Σₖ) für alle k und i berechnen, dann jede Spalte durch ihre Summe teilen.

Hinweis 1: Aufbau

Baue eine Matrix p der Form (K, n), fülle sie mit zwei Schleifen über k und über die Punkte. Danach normierst du mit p / p.sum(axis=0); Broadcasting teilt jede Spalte durch ihre Summe.

Hinweis 2: K nicht hart kodieren

Nimm K = len(pi) statt 2. Dann läuft Teil B ohne Änderung.

Lösung anzeigen
def Q_i(X, pi, mu, Sig):
    K = len(pi)
    p = np.zeros((K, len(X)))
    for k in range(K):
        for i, x in enumerate(X):
            p[k, i] = pi[k] * normal_2d(x, mu[k], Sig[k])
    return p / p.sum(axis=0)                # jede Spalte summiert zu 1

Check: jede Spalte von q summiert zu 1, und die erste Spalte ist [0.0246, 0.9754].

A.2 · Zelle 21 · ca. 5 min

M-Schritt: gewichtet neu schätzen

Stell dir vor, jeder Punkt wird auf die Klassen „aufgeteilt“: Mit Anteil Qi(k) gehört er zu Klasse k. Nk = Σᵢ Qi(k) ist dann die effektive Anzahl Punkte in Klasse k. Alle Formeln sind die gewohnten Schätzer, nur gewichtet.

Nk = q.sum(1)
(K,)
q @ X
(K,n)(n,2) = (K,2), Zeile k = Σᵢ Q_i(k)·xᵢ
D = X − μₖ
(n,2)
(q[k][:,None]*D).T @ D
(2,n)(n,2) = (2,2) = Σᵢ Q_i(k)(xᵢ−μₖ)ᵀ(xᵢ−μₖ)

Wichtig: Σₖ wird mit dem neuen μₖ berechnet. Nur so bekommst du die erwarteten Werte.

Aufgabe · Zelle 21

update_param(X, q, pi, mu, Sig) gibt neue pi, mu, Sig zurück.

Hinweis: Summe von Außenprodukten als Matrixprodukt

Σᵢ wᵢ · dᵢᵀdᵢ ist dasselbe wie (w[:,None] * D).T @ D. Gewichte zuerst jede Zeile von D, dann ein einziges Matrixprodukt.

Lösung anzeigen
def update_param(X, q, pi, mu, Sig):
    K = q.shape[0]
    Nk = q.sum(axis=1)                      # (K,)
    pi_u = Nk / Nk.sum()
    mu_u = (q @ X) / Nk[:, None]            # (K, 2)
    Sig_u = np.zeros((K, X.shape[1], X.shape[1]))
    for k in range(K):
        D = X - mu_u[k]                     # (n, 2)
        Sig_u[k] = (q[k][:, None] * D).T @ D / Nk[k]
    return pi_u, mu_u, Sig_u

Check: pi_u = [0.5113, 0.4887], mu_u[0] = [3.8836, 71.3887].

A.2 · Zellen 23 und 25 · ca. 3 min

Die EM-Schleife

Eine for-Schleife mit nIterMax als Notbremse. In jeder Iteration E, dann M, dann Konvergenztest auf Σ|μt+1 − μt| < 1e-3. Gleich mit einbauen: das Speichern der Parameter für die Animation (A-3), dann musst du die Funktion nicht zweimal anfassen.

Aufgabe · Zelle 23

EM(X, nIterMax=100, saveParam=None). Erwartet: Konvergenz nach 22 Iterationen, pi ≈ [0.6441, 0.3559].

Hinweis: welche Parameter zurückgeben?

Die Referenzausgabe 0.64411944 entsteht, wenn du bei Konvergenz break machst, bevor du die neuen Werte übernimmst, also die Parameter der vorletzten Iteration zurückgibst. Gibst du die neuesten zurück, bekommst du 0.644125. Beides ist korrekt, der Unterschied liegt unter der Toleranz.

Hinweis: Dateinamen für die Animation

create_animation lädt faithful0.pkl, faithful1.pkl, … bis nbiter−1. Deine Nummerierung muss also bei 0 beginnen und lückenlos sein. Nutze das Schleifen-i.

Lösung anzeigen
import os

def EM(X, nIterMax=100, saveParam=None, eps=1e-3):
    pi, mu, Sig = init(X)
    if saveParam is not None:
        d = saveParam[:saveParam.rfind('/')]
        if not os.path.exists(d):
            os.makedirs(d)
    for i in range(nIterMax):
        q = Q_i(X, pi, mu, Sig)                          # E
        pi_u, mu_u, Sig_u = update_param(X, q, pi, mu, Sig)  # M
        if saveParam is not None:
            pkl.dump({'pi': pi_u, 'mu': mu_u, 'Sig': Sig_u},
                     open(saveParam + str(i) + ".pkl", 'wb'))
        if np.abs(mu_u - mu).sum() < eps:
            print("Konvergenz nach", i, "Iterationen")
            break
        pi, mu, Sig = pi_u, mu_u, Sig_u
    return pi, mu, Sig

Zelle 25 läuft dann ohne Änderung und speichert res_EM.png: zwei Ellipsenfamilien, eine je Wolke.

A-3 und A-4 · Zellen 26–32 · ca. 3 min

Animation und schlechte Initialisierung

Mit dem saveParam-Teil oben ist A-3 fertig: Zelle 28 schreibt params/faithful0.pkl …, Zelle 30 baut daraus ein GIF. Das Notebook warnt, dass das „nicht immer sofort klappt“. Die üblichen Stolpersteine:

A-4 braucht keinen eigenen Code: Zelle 32 überschreibt init mit μ = Mittel ± [0.1, −15], also eine Trennung quer zur natürlichen Richtung. Weil EM intern init aufruft, nutzt es automatisch die neue Version. Erwartung: Es konvergiert trotzdem, aber deutlich langsamer. Grund: Die Log-Likelihood eines Mischmodells ist nicht konvex, und EM findet nur ein lokales Maximum, das vom Startpunkt abhängt. Teste es im Labor oben mit „schlecht“.

B · Zellen 34–38 · ca. 3 min

Vier Klassen

Wenn Q_i und update_param überall K = len(pi) bzw. q.shape[0] verwenden, musst du nur init anpassen. create_animation plottet bereits len(data['mu']) Komponenten.

Aufgabe · Zelle 36

π = vier gleiche Werte, μ = Mittel + [1,1], [−1,1], [1,−1], [−1,−1], Σ = Kovarianz aller Punkte für jede Klasse.

Lösung anzeigen
def init(X):
    K = 4
    pi = np.ones(K) / K
    offsets = np.array([[1, 1], [-1, 1], [1, -1], [-1, -1]])
    mu = X.mean(0) + offsets                # (4, 2) per Broadcasting
    Sig = np.array([np.cov(X.T)] * K)       # (4, 2, 2)
    return pi, mu, Sig
Aufgabe · Zelle 38

Dasselbe mit den Offsets [4,2], [3,4], [0,0], [−5,0].

Lösung anzeigen
def init(X):
    K = 4
    pi = np.ones(K) / K
    offsets = np.array([[4, 2], [3, 4], [0, 0], [-5, 0]])
    mu = X.mean(0) + offsets
    Sig = np.array([np.cov(X.T)] * K)
    return pi, mu, Sig

pi, mu, Sig = EM(Xg, saveParam="params_gauss_bad/gauss")
create_animation(Xg, "params_gauss_bad", "gauss")   # im Notebook steht "creer_animation"

Achtung, in Zelle 38 steht ein Tippfehler aus der französischen Vorlage: creer_animation gibt einen NameError, ersetze es durch create_animation. Die Daten sind zufällig erzeugt (kein Seed). Für reproduzierbare Ergebnisse setze vor generating_4_gaussians() z. B. np.random.seed(0). Bei schlechter Initialisierung teilen sich oft zwei Komponenten eine Wolke, während eine andere zwei Wolken abdeckt.

C · Zelle 39 · ca. 2 min

Herleitung des M-Schritts überfliegen

Teil C ist reine Lektüre. Die Idee in drei Sätzen:

  1. Im M-Schritt maximiert man Σᵢ Σₖ Qi(k) · log(πₖ · fₖ(xᵢ)), wobei Q fest ist.
  2. Ableitung nach π₀ (mit π₁ = 1 − π₀) gleich 0 gesetzt ergibt π₀ = N₀ / (N₀ + N₁).
  3. Ableitungen nach μ, σ, ρ gleich 0 liefern genau die gewichteten Mittelwerte und Kovarianzen, die du implementiert hast. Es ist dieselbe Herleitung wie beim normalen Maximum-Likelihood-Schätzer, nur mit Gewichten Q.

Kleiner Fehler in der Vorlage: Die Σ-Matrix in der Notiz enthält σ statt σ² auf der Diagonale und ρ statt ρσₓσ_z daneben. Für die Rechnung im Text ist das egal, im Code gilt die Version aus Schritt 1.