<p class="hint">These points secretly come from <b>two overlapping clusters</b>, but the labels are hidden. The orange and blue curves start with a bad guess. Each <b>Step</b> runs one round of EM: shade every point by which curve likely produced it (E-step), then recompute each curve's center and width (M-step). The log-likelihood at the top never goes down.</p>
<div class="ll">log-likelihood: <span id="ll">—</span> · iteration <span id="it">0</span></div>
<canvas id="cv" width="520" height="220"></canvas>
<div class="btns">
<button id="step" type="button">Step</button>
<button id="run" type="button">Run</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
.ll { font: 700 14px ui-monospace, monospace; color: #1d3557; margin: .2rem 0 .5rem; }
canvas { width: 100%; max-width: 520px; height: auto; background: #f5f7fa;
border: 1px solid #cdd9e3; border-radius: 8px; display: block; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .6rem; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: default; }
// 1-D Gaussian mixture fit by Expectation-Maximization.
// Two hidden clusters generated the data; EM recovers their means and widths
// by alternating: E-step = responsibility of each curve for each point,
// M-step = re-fit mean/variance/weight as a weighted average. The data
// log-likelihood provably never decreases (Dempster, Laird & Rubin, 1977).
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const W = cv.width, H = cv.height, X0 = 0, X1 = 100;
const ORANGE = '#e8853a', BLUE = '#1d6fb8';
// Deterministic "random" points from two true clusters (~30 and ~70).
function makeData(){
let s = 12345; const rnd = () => (s = (s*1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
const gauss = (m,sd) => { let u=0,v=0; while(!u)u=rnd(); while(!v)v=rnd();
return m + sd*Math.sqrt(-2*Math.log(u))*Math.cos(2*Math.PI*v); };
const pts=[]; for(let i=0;i<30;i++) pts.push(gauss(32,7));
for(let i=0;i<30;i++) pts.push(gauss(68,8));
return pts.map(x=>Math.max(X0+2,Math.min(X1-2,x)));
}
let data = makeData();
// Two components: {mu, sigma, w}. Deliberately bad start (both near center).
let comp, iter, lastLL;
function init(){
comp = [ {mu:45, sigma:10, w:0.5}, {mu:55, sigma:10, w:0.5} ];
iter = 0; lastLL = null; updateLabels(null); draw(null);
}
const norm = (x,m,sd) => Math.exp(-((x-m)*(x-m))/(2*sd*sd)) / (Math.sqrt(2*Math.PI)*sd);
// One EM iteration. Returns the responsibilities used (for shading) + log-likelihood.
function emStep(){
const n = data.length;
const resp = data.map(()=>[0,0]);
let ll = 0;
// E-step: responsibility r_ik = w_k N(x|k) / sum_k ...
for(let i=0;i<n;i++){
const a = comp[0].w*norm(data[i],comp[0].mu,comp[0].sigma);
const b = comp[1].w*norm(data[i],comp[1].mu,comp[1].sigma);
const s = a+b || 1e-12;
resp[i][0] = a/s; resp[i][1] = b/s;
ll += Math.log(s);
}
// M-step: weighted re-fit of w, mu, sigma for each component.
for(let k=0;k<2;k++){
let nk=0, sum=0; for(let i=0;i<n;i++){ nk+=resp[i][k]; sum+=resp[i][k]*data[i]; }
nk = nk || 1e-9;
const mu = sum/nk;
let varsum=0; for(let i=0;i<n;i++) varsum += resp[i][k]*(data[i]-mu)*(data[i]-mu);
comp[k] = { mu, sigma: Math.max(2, Math.sqrt(varsum/nk)), w: nk/n };
}
iter++; lastLL = ll; updateLabels(ll); draw(resp);
return ll;
}
function updateLabels(ll){
document.getElementById('ll').textContent = ll===null ? '—' : ll.toFixed(2);
document.getElementById('it').textContent = iter;
}
const sx = x => (x-X0)/(X1-X0)*W;
function draw(resp){
ctx.clearRect(0,0,W,H);
// curves (scaled so the taller peak fills most of the height)
let peak = 0;
for(let px=0;px<=W;px+=2){ const x = X0+(px/W)*(X1-X0);
peak = Math.max(peak, comp[0].w*norm(x,comp[0].mu,comp[0].sigma)+comp[1].w*norm(x,comp[1].mu,comp[1].sigma)); }
peak = peak || 1;
const drawCurve = (k,color) => { ctx.strokeStyle=color; ctx.lineWidth=2.2; ctx.beginPath();
for(let px=0;px<=W;px+=2){ const x=X0+(px/W)*(X1-X0);
const y=H-20 - (comp[k].w*norm(x,comp[k].mu,comp[k].sigma)/peak)*(H-40);
px===0?ctx.moveTo(px,y):ctx.lineTo(px,y); } ctx.stroke(); };
drawCurve(0,ORANGE); drawCurve(1,BLUE);
// points, shaded by responsibility (orange vs blue), on the baseline
for(let i=0;i<data.length;i++){
const r = resp ? resp[i][0] : 0.5;
const col = mix(BLUE, ORANGE, r);
ctx.fillStyle = col; ctx.beginPath();
ctx.arc(sx(data[i]), H-12, 4, 0, 2*Math.PI); ctx.fill();
}
ctx.strokeStyle='#cdd9e3'; ctx.beginPath(); ctx.moveTo(0,H-6); ctx.lineTo(W,H-6); ctx.stroke();
}
function mix(c1,c2,t){ // t=1 -> c2
const p=h=>[parseInt(h.slice(1,3),16),parseInt(h.slice(3,5),16),parseInt(h.slice(5,7),16)];
const a=p(c1), b=p(c2); const r=a.map((v,i)=>Math.round(v+(b[i]-v)*t));
return 'rgb('+r.join(',')+')';
}
let timer=null;
function stop(){ if(timer){ clearInterval(timer); timer=null;
document.getElementById('run').textContent='Run'; } }
document.getElementById('step').addEventListener('click',()=>{ stop(); emStep();
console.log('iter',iter,'logL',lastLL.toFixed(3),'means',comp[0].mu.toFixed(1),comp[1].mu.toFixed(1)); });
document.getElementById('run').addEventListener('click',function(){
if(timer){ stop(); return; }
this.textContent='Stop';
timer=setInterval(()=>{ const prev=lastLL; emStep();
if(prev!==null && Math.abs(lastLL-prev)<1e-4) stop(); }, 500);
});
document.getElementById('reset').addEventListener('click',()=>{ stop(); init(); });
init();
<p class="hint">These points secretly come from <b>two overlapping clusters</b>, but the labels are hidden. The orange and blue curves start with a bad guess. Each <b>Step</b> runs one round of EM: shade every point by which curve likely produced it (E-step), then recompute each curve's center and width (M-step). The log-likelihood at the top never goes down.</p>
<div class="ll">log-likelihood: <span id="ll">—</span> · iteration <span id="it">0</span></div>
<canvas id="cv" width="520" height="220"></canvas>
<div class="btns">
<button id="step" type="button">Step</button>
<button id="run" type="button">Run</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
.ll { font: 700 14px ui-monospace, monospace; color: #1d3557; margin: .2rem 0 .5rem; }
canvas { width: 100%; max-width: 520px; height: auto; background: #f5f7fa;
border: 1px solid #cdd9e3; border-radius: 8px; display: block; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .6rem; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: default; }
// 1-D Gaussian mixture fit by Expectation-Maximization.
// Two hidden clusters generated the data; EM recovers their means and widths
// by alternating: E-step = responsibility of each curve for each point,
// M-step = re-fit mean/variance/weight as a weighted average. The data
// log-likelihood provably never decreases (Dempster, Laird & Rubin, 1977).
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const W = cv.width, H = cv.height, X0 = 0, X1 = 100;
const ORANGE = '#e8853a', BLUE = '#1d6fb8';
// Deterministic "random" points from two true clusters (~30 and ~70).
function makeData(){
let s = 12345; const rnd = () => (s = (s*1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
const gauss = (m,sd) => { let u=0,v=0; while(!u)u=rnd(); while(!v)v=rnd();
return m + sd*Math.sqrt(-2*Math.log(u))*Math.cos(2*Math.PI*v); };
const pts=[]; for(let i=0;i<30;i++) pts.push(gauss(32,7));
for(let i=0;i<30;i++) pts.push(gauss(68,8));
return pts.map(x=>Math.max(X0+2,Math.min(X1-2,x)));
}
let data = makeData();
// Two components: {mu, sigma, w}. Deliberately bad start (both near center).
let comp, iter, lastLL;
function init(){
comp = [ {mu:45, sigma:10, w:0.5}, {mu:55, sigma:10, w:0.5} ];
iter = 0; lastLL = null; updateLabels(null); draw(null);
}
const norm = (x,m,sd) => Math.exp(-((x-m)*(x-m))/(2*sd*sd)) / (Math.sqrt(2*Math.PI)*sd);
// One EM iteration. Returns the responsibilities used (for shading) + log-likelihood.
function emStep(){
const n = data.length;
const resp = data.map(()=>[0,0]);
let ll = 0;
// E-step: responsibility r_ik = w_k N(x|k) / sum_k ...
for(let i=0;i<n;i++){
const a = comp[0].w*norm(data[i],comp[0].mu,comp[0].sigma);
const b = comp[1].w*norm(data[i],comp[1].mu,comp[1].sigma);
const s = a+b || 1e-12;
resp[i][0] = a/s; resp[i][1] = b/s;
ll += Math.log(s);
}
// M-step: weighted re-fit of w, mu, sigma for each component.
for(let k=0;k<2;k++){
let nk=0, sum=0; for(let i=0;i<n;i++){ nk+=resp[i][k]; sum+=resp[i][k]*data[i]; }
nk = nk || 1e-9;
const mu = sum/nk;
let varsum=0; for(let i=0;i<n;i++) varsum += resp[i][k]*(data[i]-mu)*(data[i]-mu);
comp[k] = { mu, sigma: Math.max(2, Math.sqrt(varsum/nk)), w: nk/n };
}
iter++; lastLL = ll; updateLabels(ll); draw(resp);
return ll;
}
function updateLabels(ll){
document.getElementById('ll').textContent = ll===null ? '—' : ll.toFixed(2);
document.getElementById('it').textContent = iter;
}
const sx = x => (x-X0)/(X1-X0)*W;
function draw(resp){
ctx.clearRect(0,0,W,H);
// curves (scaled so the taller peak fills most of the height)
let peak = 0;
for(let px=0;px<=W;px+=2){ const x = X0+(px/W)*(X1-X0);
peak = Math.max(peak, comp[0].w*norm(x,comp[0].mu,comp[0].sigma)+comp[1].w*norm(x,comp[1].mu,comp[1].sigma)); }
peak = peak || 1;
const drawCurve = (k,color) => { ctx.strokeStyle=color; ctx.lineWidth=2.2; ctx.beginPath();
for(let px=0;px<=W;px+=2){ const x=X0+(px/W)*(X1-X0);
const y=H-20 - (comp[k].w*norm(x,comp[k].mu,comp[k].sigma)/peak)*(H-40);
px===0?ctx.moveTo(px,y):ctx.lineTo(px,y); } ctx.stroke(); };
drawCurve(0,ORANGE); drawCurve(1,BLUE);
// points, shaded by responsibility (orange vs blue), on the baseline
for(let i=0;i<data.length;i++){
const r = resp ? resp[i][0] : 0.5;
const col = mix(BLUE, ORANGE, r);
ctx.fillStyle = col; ctx.beginPath();
ctx.arc(sx(data[i]), H-12, 4, 0, 2*Math.PI); ctx.fill();
}
ctx.strokeStyle='#cdd9e3'; ctx.beginPath(); ctx.moveTo(0,H-6); ctx.lineTo(W,H-6); ctx.stroke();
}
function mix(c1,c2,t){ // t=1 -> c2
const p=h=>[parseInt(h.slice(1,3),16),parseInt(h.slice(3,5),16),parseInt(h.slice(5,7),16)];
const a=p(c1), b=p(c2); const r=a.map((v,i)=>Math.round(v+(b[i]-v)*t));
return 'rgb('+r.join(',')+')';
}
let timer=null;
function stop(){ if(timer){ clearInterval(timer); timer=null;
document.getElementById('run').textContent='Run'; } }
document.getElementById('step').addEventListener('click',()=>{ stop(); emStep();
console.log('iter',iter,'logL',lastLL.toFixed(3),'means',comp[0].mu.toFixed(1),comp[1].mu.toFixed(1)); });
document.getElementById('run').addEventListener('click',function(){
if(timer){ stop(); return; }
this.textContent='Stop';
timer=setInterval(()=>{ const prev=lastLL; emStep();
if(prev!==null && Math.abs(lastLL-prev)<1e-4) stop(); }, 500);
});
document.getElementById('reset').addEventListener('click',()=>{ stop(); init(); });
init();
Comments
Loading comments...