Skip to content
EM lab, home

Expectation–Maximisation, by doing

EM, one step
at a time

Imagine knowing only how people rate films, not what kind of viewer they are, and still recovering the groups behind the ratings. That is what the EM algorithm does. This site turns a long, maths-complete explainer and its Python notebook into something you can step through, poke and break.

Watch the guided tour· three short walkthroughs

A personal project by Sunchuangyu (Rin) Huang · written 2025, revived 2026

the notebook's fitμ₁ ≈ 4.02μ₂ ≈ 7.36
component 1 component 2dotted: the true mixture · 200 ratings, iteration 15

The idea

Guess, then improve the guess. Repeat.

The explainer calls EM a smart detective. It cannot see who belongs to which group, so it alternates between two easier questions until the answers stop changing.

E-step · expectation

Which group does each rating probably belong to?

Using the current guess of each group, Bayes' theorem gives every rating a probability of coming from each group: its responsibility.

γik=πk f(xi∣μk,σk)∑jπj f(xi∣μj,σj)\gamma_{ik} = \frac{\pi_k\, f(x_i \mid \mu_k, \sigma_k)}{\sum_j \pi_j\, f(x_i \mid \mu_j, \sigma_j)}

M-step · maximisation

Given those probabilities, what does each group look like?

Each group's share, average and spread become weighted averages, with ratings counting in proportion to how much they belong.

μk=∑iγik xi∑iγik,πk=1n∑iγik\mu_k = \frac{\sum_i \gamma_{ik}\, x_i}{\sum_i \gamma_{ik}}, \quad \pi_k = \frac{1}{n}\sum_i \gamma_{ik}

What the original notebook found

200 ratings, two hidden groups, 15 iterations

The notebook draws 200 “movie ratings”: 60% from sci-fi lovers around 7.5 and 40% from romance lovers around 4.0, clipped to 1 to 10. It then hides the labels and lets EM, written from scratch, find the groups from a random start. These are its printed results, reproduced by this site's TypeScript port to within 10⁻⁶.

average rating
6.21
range 1.2 to 10.0
log-likelihood
−416.51
from −425.50 after iteration 1 (+8.98)
classified correctly
90.5%
of the 200 users the model was fitted to (in-sample), using γ > 0.5; 95% CI 85.6% to 93.8% (Wilson)
iterations
15
the cap it was given; not yet converged
Estimated parameters (true value underneath), matched to the true groups by mean
groupshare πmean μspread σ
Sci-fi lovers fitted as component 20.656true 0.67.361true 7.51.273true 1.2
Romance lovers fitted as component 10.344true 0.44.022true 4.01.249true 1.5

Note which component each group was fitted as: EM put the sci-fi lovers in component 2. That small detail is the first of the pitfalls.

Six ways in

Read it, run it, break it

Honest notes

Kept visible, not quietly fixed

Rebuilding the notebook number for number turned up three things worth knowing. The originals stay as they were; the site shows what happened and why.

  • Some hand-worked densities are off

    The explainer uses f(2∣2.5,0.5)=0.8f(2 \mid 2.5, 0.5) = 0.8 and f(3∣2.5,0.5)=0.6f(3 \mid 2.5, 0.5) = 0.6; both are 0.4839. The conclusions hold. See both side by side.

  • The labels switched

    Component 1 ended up as the low-mean group, so the notebook's final cell calls an 8.5 rater a romance lover (P = 0.001 for sci-fi). Why it happens.

  • Fifteen iterations was not convergence

    The run stopped at its cap while still improving; finishing it changes the answer. Watch it finish.

About this project

A personal explainer, rebuilt as a lab

What
Personal project, not coursework
Author
Sunchuangyu (Rin) Huang
When
September 2025 (explainer and notebook, last edited June 2026), October 2026 (this site)
Data
Synthetic ratings only; no real users
rNLKJA/EM-Algorithm

Original stack

  • Markdown explainer with LaTeX maths
  • Python 3 in a Jupyter notebook
  • NumPy for the EM loop, SciPy for densities in plots
  • pandas, Matplotlib for figures

Revived stack

  • Next.js 16 (App Router), React 19, TypeScript
  • Tailwind CSS v4, shadcn/ui, KaTeX rendered at build time
  • Hand-drawn SVG charts; EM loops in a Web Worker
  • Vitest parity tests against the notebook's own trace

Provenance

The explainer and notebook are kept unchanged in original/. A script, export_parity.py, re-executes the notebook's cells with seed 42, checks every printed line against the saved output, and exports the ratings, the random start and the per-iteration trace. The TypeScript port of EMAnalyzer is tested against that trace to 10⁻⁶, and regenerates the notebook's console output character for character. Nothing here needs a server or an account; the one optional AI feature runs in your browser with your own key, and the methods page says what it does.