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.
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).
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.
Σ = [[σ₁², ρσ₁σ₂],[ρσ₁σ₂, σ₂²]]. 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.
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.
Schreibe normal_2d(x, mu, Sig), das die Dichte für einen Punkt x zurückgibt.
np.linalg.det(Sig) für |Σ|, np.linalg.inv(Sig) für Σ⁻¹. Für das Matrixprodukt nimm @.
Bei 1D-numpy-Arrays ist d @ A @ d automatisch „Zeile × Matrix × Spalte“, du musst nichts transponieren. N = len(mu) macht die Funktion dimensionsunabhängig.
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)
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).
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.
Die Anzeige zählt, welcher Anteil der Punkte mit Dauer > 3 min innerhalb μ ± σ je Achse liegt. Ziel: ungefähr 66–68 %.
Trage deine Schätzungen für σ₁, σ₂ und ρ in die Markdown-Zelle ein und setze mu4, Sig4.
σ₁ ≈ 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.]])
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.
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:
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.
Leg dir zuerst die Shapes fest. Wenn die stimmen, funktioniert später auch K = 4 ohne Änderungen.
q[:,:5], also Klassen als Zeilen.init(X): μ₀ = Mittelwert + 1, μ₁ = Mittelwert − 1, beide Σ = Kovarianz der ganzen Wolke.
np.cov erwartet Variablen als Zeilen, also np.cov(X.T). Es teilt durch n−1, das passt zur erwarteten Ausgabe 1.3027…
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
Q_i(X, pi, mu, Sig): Zähler πₖ·𝒩(xᵢ|μₖ,Σₖ) für alle k und i berechnen, dann jede Spalte durch ihre Summe teilen.
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.
Nimm K = len(pi) statt 2. Dann läuft Teil B ohne Änderung.
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].
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.
Wichtig: Σₖ wird mit dem neuen μₖ berechnet. Nur so bekommst du die erwarteten Werte.
update_param(X, q, pi, mu, Sig) gibt neue pi, mu, Sig zurück.
Σᵢ wᵢ · dᵢᵀdᵢ ist dasselbe wie (w[:,None] * D).T @ D. Gewichte zuerst jede Zeile von D, dann ein einziges Matrixprodukt.
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].
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.
EM(X, nIterMax=100, saveParam=None). Erwartet: Konvergenz nach 22 Iterationen, pi ≈ [0.6441, 0.3559].
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.
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.
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.
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:
ani.save(params_path+'/animation.gif', writer='pillow') schreiben.nbiter zählt alle *.pkl im Ordner. Liefert ein neuer Lauf weniger Iterationen, zeigt die Animation Reste vom alten Lauf. Ordner vor dem Neulauf leeren.from IPython.display import Image; Image(filename='params/animation.gif').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“.
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.
π = vier gleiche Werte, μ = Mittel + [1,1], [−1,1], [1,−1], [−1,−1], Σ = Kovarianz aller Punkte für jede Klasse.
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
Dasselbe mit den Offsets [4,2], [3,4], [0,0], [−5,0].
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.
Teil C ist reine Lektüre. Die Idee in drei Sätzen:
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.