<!-- LDA demo — corpus display + topic word-weight bars -->
<p class="hint">Each document is a bag of colored words. Colors show which <b>topic</b> each word is currently assigned to (blue = computing, green = nature). Click <b>Run LDA</b> to let collapsed Gibbs sampling discover the hidden topics automatically.</p>
<div class="corpus" id="corpus"></div>
<div class="btns">
<button id="btn-run" type="button">Run LDA</button>
<button id="btn-step" type="button">Step</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
<span class="iter-label">Iteration: <span id="iter-count">0</span></span>
</div>
<div class="status" id="status"></div>
<div class="topics-section">
<div class="topic-block" id="topic0">
<div class="topic-title topic0-color">Topic A (computing)</div>
<div class="bars" id="bars0"></div>
</div>
<div class="topic-block" id="topic1">
<div class="topic-title topic1-color">Topic B (nature)</div>
<div class="bars" id="bars1"></div>
</div>
</div>
/* Demo styles — tokens colored by topic assignment */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
.corpus { display: flex; flex-direction: column; gap: 4px; margin-bottom: .6rem; }
.doc { display: flex; flex-wrap: wrap; gap: 3px; align-items: center; padding: 4px 6px;
background: #f0f3f6; border-radius: 6px; }
.doc-id { font-size: .75rem; color: #888; margin-right: 4px; min-width: 22px; }
/* Token colors reflect current Gibbs topic assignment */
.tok { display: inline-block; padding: 1px 5px; border-radius: 4px; font-size: .82rem;
font-weight: 600; cursor: default; transition: background .25s; }
.tok.t0 { background: #bde0ff; color: #1a4a7a; }
.tok.t1 { background: #c8f5d0; color: #1a5c2a; }
.tok.unset { background: #ddd; color: #555; }
.btns { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; margin-bottom: .4rem; }
button { font: 600 13px system-ui; padding: .38rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.iter-label { font-size: .83rem; color: #666; }
.status { font-size: .88rem; font-weight: 600; min-height: 1.2em; margin-bottom: .5rem; color: #1d3557; }
.topics-section { display: flex; gap: 12px; flex-wrap: wrap; }
.topic-block { flex: 1; min-width: 140px; background: #f8f9fa; border-radius: 8px; padding: 8px 10px; }
.topic-title { font-weight: 700; font-size: .85rem; margin-bottom: 6px; }
.topic0-color { color: #1a4a7a; }
.topic1-color { color: #1a5c2a; }
/* Bar chart for top words per topic */
.bar-row { display: flex; align-items: center; gap: 5px; margin-bottom: 3px; }
.bar-word { width: 64px; font-size: .78rem; text-align: right; color: #444; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bar-bg { flex: 1; background: #e0e4e8; border-radius: 3px; height: 10px; }
.bar-fill { height: 10px; border-radius: 3px; transition: width .3s; }
.bar-fill.t0 { background: #4a90d9; }
.bar-fill.t1 { background: #34a853; }
// Collapsed Gibbs sampling for LDA with K=2 topics
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Toy corpus: 8 short documents mixing computing and nature vocabulary
const DOCS = [
['computer','algorithm','data','memory','processor','data'],
['tree','forest','river','mountain','animal','forest'],
['algorithm','network','computer','tree','data','river'],
['mountain','animal','river','forest','mountain','animal'],
['processor','memory','network','algorithm','computer','data'],
['forest','river','tree','mountain','animal','tree'],
['computer','network','processor','memory','algorithm','network'],
['river','animal','forest','mountain','tree','river']
];
// Vocabulary = sorted unique words across all documents
const VOCAB = [...new Set(DOCS.flat())].sort();
const K = 2; // number of topics
const ALPHA = 0.5; // document-topic Dirichlet concentration
const BETA = 0.1; // topic-word Dirichlet concentration
// Sampling state arrays
let z = []; // topic assignment per token z[d][n]
let ndz = []; // count of tokens in doc d assigned to topic k
let nzw = []; // count of word w assigned to topic k
let nz = []; // total tokens assigned to topic k
let iterCount = 0;
let running = false;
let rafId = null;
function wordIdx(w) { return VOCAB.indexOf(w); }
// Initialize: assign each token a random topic and populate count tables
function init() {
z = [];
ndz = DOCS.map(() => new Array(K).fill(0));
nzw = Array.from({length: K}, () => new Array(VOCAB.length).fill(0));
nz = new Array(K).fill(0);
iterCount = 0;
document.getElementById('iter-count').textContent = '0';
document.getElementById('status').textContent = '';
for (let d = 0; d < DOCS.length; d++) {
z[d] = [];
for (let n = 0; n < DOCS[d].length; n++) {
const topic = Math.floor(Math.random() * K);
z[d][n] = topic;
ndz[d][topic]++;
nzw[topic][wordIdx(DOCS[d][n])]++;
nz[topic]++;
}
}
}
// Collapsed Gibbs: remove current assignment, recompute conditional, draw new topic
function sampleTopic(d, n) {
const w = wordIdx(DOCS[d][n]);
const oldTopic = z[d][n];
ndz[d][oldTopic]--;
nzw[oldTopic][w]--;
nz[oldTopic]--;
const probs = new Array(K);
for (let k = 0; k < K; k++) {
probs[k] = (ndz[d][k] + ALPHA) * (nzw[k][w] + BETA) / (nz[k] + VOCAB.length * BETA);
}
// Sample from the unnormalized probability vector
let sum = 0; for (let k = 0; k < K; k++) sum += probs[k];
let r = Math.random() * sum;
let newTopic = K - 1;
for (let k = 0; k < K; k++) { r -= probs[k]; if (r <= 0) { newTopic = k; break; } }
z[d][n] = newTopic;
ndz[d][newTopic]++;
nzw[newTopic][w]++;
nz[newTopic]++;
}
// One full Gibbs sweep over all tokens
function sweep() {
for (let d = 0; d < DOCS.length; d++)
for (let n = 0; n < DOCS[d].length; n++)
sampleTopic(d, n);
iterCount++;
}
// Render the corpus with tokens colored by current topic assignment
function renderCorpus() {
const el = document.getElementById('corpus');
el.innerHTML = '';
for (let d = 0; d < DOCS.length; d++) {
const row = document.createElement('div');
row.className = 'doc';
const id = document.createElement('span');
id.className = 'doc-id';
id.textContent = 'D' + (d + 1);
row.appendChild(id);
for (let n = 0; n < DOCS[d].length; n++) {
const span = document.createElement('span');
const t = (z.length && z[d]) ? z[d][n] : -1;
span.className = 'tok ' + (t >= 0 ? 't' + t : 'unset');
span.textContent = DOCS[d][n];
row.appendChild(span);
}
el.appendChild(row);
}
}
// Render top-5 words per topic as a proportional bar chart
function renderBars() {
for (let k = 0; k < K; k++) {
const container = document.getElementById('bars' + k);
container.innerHTML = '';
const total = nz[k] + VOCAB.length * BETA;
const scored = VOCAB.map((w, i) => ({ w, p: (nzw[k][i] + BETA) / total }));
scored.sort((a, b) => b.p - a.p);
const top = scored.slice(0, 5);
const maxP = top[0] ? top[0].p : 1;
for (const { w, p } of top) {
const row = document.createElement('div');
row.className = 'bar-row';
const label = document.createElement('span');
label.className = 'bar-word';
label.textContent = w;
const bg = document.createElement('div');
bg.className = 'bar-bg';
const fill = document.createElement('div');
fill.className = 'bar-fill t' + k;
fill.style.width = (100 * p / maxP).toFixed(1) + '%';
bg.appendChild(fill);
row.appendChild(label);
row.appendChild(bg);
container.appendChild(row);
}
}
}
function updateIterLabel() {
document.getElementById('iter-count').textContent = iterCount;
}
function setStatus(msg) {
document.getElementById('status').textContent = msg;
}
// Wire up button event listeners
document.getElementById('btn-step').addEventListener('click', () => {
if (!z.length) init();
sweep();
renderCorpus();
renderBars();
updateIterLabel();
setStatus(i18nFmt('Completed iteration %iter%.', { iter: iterCount }));
});
document.getElementById('btn-run').addEventListener('click', () => {
if (running) { running = false; return; }
if (!z.length) init();
running = true;
// Animate up to 60 iterations using requestAnimationFrame
function loop() {
if (!running) return;
sweep();
renderCorpus();
renderBars();
updateIterLabel();
if (iterCount < 60) {
rafId = requestAnimationFrame(loop);
} else {
running = false;
setStatus(i18nFmt('Converged after %iter% iterations. Topics are stable.', { iter: iterCount }));
}
}
loop();
});
document.getElementById('btn-reset').addEventListener('click', () => {
running = false;
if (rafId) cancelAnimationFrame(rafId);
z = [];
iterCount = 0;
document.getElementById('iter-count').textContent = '0';
document.getElementById('status').textContent = '';
document.getElementById('bars0').innerHTML = '';
document.getElementById('bars1').innerHTML = '';
renderCorpus();
});
// Initialize on load and show an initial state
init();
renderCorpus();
renderBars();
setStatus('Ready — press Run LDA or Step to begin sampling.');
<!-- LDA demo — corpus display + topic word-weight bars -->
<p class="hint">Each document is a bag of colored words. Colors show which <b>topic</b> each word is currently assigned to (blue = computing, green = nature). Click <b>Run LDA</b> to let collapsed Gibbs sampling discover the hidden topics automatically.</p>
<div class="corpus" id="corpus"></div>
<div class="btns">
<button id="btn-run" type="button">Run LDA</button>
<button id="btn-step" type="button">Step</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
<span class="iter-label">Iteration: <span id="iter-count">0</span></span>
</div>
<div class="status" id="status"></div>
<div class="topics-section">
<div class="topic-block" id="topic0">
<div class="topic-title topic0-color">Topic A (computing)</div>
<div class="bars" id="bars0"></div>
</div>
<div class="topic-block" id="topic1">
<div class="topic-title topic1-color">Topic B (nature)</div>
<div class="bars" id="bars1"></div>
</div>
</div>
/* Demo styles — tokens colored by topic assignment */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
.corpus { display: flex; flex-direction: column; gap: 4px; margin-bottom: .6rem; }
.doc { display: flex; flex-wrap: wrap; gap: 3px; align-items: center; padding: 4px 6px;
background: #f0f3f6; border-radius: 6px; }
.doc-id { font-size: .75rem; color: #888; margin-right: 4px; min-width: 22px; }
/* Token colors reflect current Gibbs topic assignment */
.tok { display: inline-block; padding: 1px 5px; border-radius: 4px; font-size: .82rem;
font-weight: 600; cursor: default; transition: background .25s; }
.tok.t0 { background: #bde0ff; color: #1a4a7a; }
.tok.t1 { background: #c8f5d0; color: #1a5c2a; }
.tok.unset { background: #ddd; color: #555; }
.btns { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; margin-bottom: .4rem; }
button { font: 600 13px system-ui; padding: .38rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.iter-label { font-size: .83rem; color: #666; }
.status { font-size: .88rem; font-weight: 600; min-height: 1.2em; margin-bottom: .5rem; color: #1d3557; }
.topics-section { display: flex; gap: 12px; flex-wrap: wrap; }
.topic-block { flex: 1; min-width: 140px; background: #f8f9fa; border-radius: 8px; padding: 8px 10px; }
.topic-title { font-weight: 700; font-size: .85rem; margin-bottom: 6px; }
.topic0-color { color: #1a4a7a; }
.topic1-color { color: #1a5c2a; }
/* Bar chart for top words per topic */
.bar-row { display: flex; align-items: center; gap: 5px; margin-bottom: 3px; }
.bar-word { width: 64px; font-size: .78rem; text-align: right; color: #444; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bar-bg { flex: 1; background: #e0e4e8; border-radius: 3px; height: 10px; }
.bar-fill { height: 10px; border-radius: 3px; transition: width .3s; }
.bar-fill.t0 { background: #4a90d9; }
.bar-fill.t1 { background: #34a853; }
// Collapsed Gibbs sampling for LDA with K=2 topics
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Toy corpus: 8 short documents mixing computing and nature vocabulary
const DOCS = [
['computer','algorithm','data','memory','processor','data'],
['tree','forest','river','mountain','animal','forest'],
['algorithm','network','computer','tree','data','river'],
['mountain','animal','river','forest','mountain','animal'],
['processor','memory','network','algorithm','computer','data'],
['forest','river','tree','mountain','animal','tree'],
['computer','network','processor','memory','algorithm','network'],
['river','animal','forest','mountain','tree','river']
];
// Vocabulary = sorted unique words across all documents
const VOCAB = [...new Set(DOCS.flat())].sort();
const K = 2; // number of topics
const ALPHA = 0.5; // document-topic Dirichlet concentration
const BETA = 0.1; // topic-word Dirichlet concentration
// Sampling state arrays
let z = []; // topic assignment per token z[d][n]
let ndz = []; // count of tokens in doc d assigned to topic k
let nzw = []; // count of word w assigned to topic k
let nz = []; // total tokens assigned to topic k
let iterCount = 0;
let running = false;
let rafId = null;
function wordIdx(w) { return VOCAB.indexOf(w); }
// Initialize: assign each token a random topic and populate count tables
function init() {
z = [];
ndz = DOCS.map(() => new Array(K).fill(0));
nzw = Array.from({length: K}, () => new Array(VOCAB.length).fill(0));
nz = new Array(K).fill(0);
iterCount = 0;
document.getElementById('iter-count').textContent = '0';
document.getElementById('status').textContent = '';
for (let d = 0; d < DOCS.length; d++) {
z[d] = [];
for (let n = 0; n < DOCS[d].length; n++) {
const topic = Math.floor(Math.random() * K);
z[d][n] = topic;
ndz[d][topic]++;
nzw[topic][wordIdx(DOCS[d][n])]++;
nz[topic]++;
}
}
}
// Collapsed Gibbs: remove current assignment, recompute conditional, draw new topic
function sampleTopic(d, n) {
const w = wordIdx(DOCS[d][n]);
const oldTopic = z[d][n];
ndz[d][oldTopic]--;
nzw[oldTopic][w]--;
nz[oldTopic]--;
const probs = new Array(K);
for (let k = 0; k < K; k++) {
probs[k] = (ndz[d][k] + ALPHA) * (nzw[k][w] + BETA) / (nz[k] + VOCAB.length * BETA);
}
// Sample from the unnormalized probability vector
let sum = 0; for (let k = 0; k < K; k++) sum += probs[k];
let r = Math.random() * sum;
let newTopic = K - 1;
for (let k = 0; k < K; k++) { r -= probs[k]; if (r <= 0) { newTopic = k; break; } }
z[d][n] = newTopic;
ndz[d][newTopic]++;
nzw[newTopic][w]++;
nz[newTopic]++;
}
// One full Gibbs sweep over all tokens
function sweep() {
for (let d = 0; d < DOCS.length; d++)
for (let n = 0; n < DOCS[d].length; n++)
sampleTopic(d, n);
iterCount++;
}
// Render the corpus with tokens colored by current topic assignment
function renderCorpus() {
const el = document.getElementById('corpus');
el.innerHTML = '';
for (let d = 0; d < DOCS.length; d++) {
const row = document.createElement('div');
row.className = 'doc';
const id = document.createElement('span');
id.className = 'doc-id';
id.textContent = 'D' + (d + 1);
row.appendChild(id);
for (let n = 0; n < DOCS[d].length; n++) {
const span = document.createElement('span');
const t = (z.length && z[d]) ? z[d][n] : -1;
span.className = 'tok ' + (t >= 0 ? 't' + t : 'unset');
span.textContent = DOCS[d][n];
row.appendChild(span);
}
el.appendChild(row);
}
}
// Render top-5 words per topic as a proportional bar chart
function renderBars() {
for (let k = 0; k < K; k++) {
const container = document.getElementById('bars' + k);
container.innerHTML = '';
const total = nz[k] + VOCAB.length * BETA;
const scored = VOCAB.map((w, i) => ({ w, p: (nzw[k][i] + BETA) / total }));
scored.sort((a, b) => b.p - a.p);
const top = scored.slice(0, 5);
const maxP = top[0] ? top[0].p : 1;
for (const { w, p } of top) {
const row = document.createElement('div');
row.className = 'bar-row';
const label = document.createElement('span');
label.className = 'bar-word';
label.textContent = w;
const bg = document.createElement('div');
bg.className = 'bar-bg';
const fill = document.createElement('div');
fill.className = 'bar-fill t' + k;
fill.style.width = (100 * p / maxP).toFixed(1) + '%';
bg.appendChild(fill);
row.appendChild(label);
row.appendChild(bg);
container.appendChild(row);
}
}
}
function updateIterLabel() {
document.getElementById('iter-count').textContent = iterCount;
}
function setStatus(msg) {
document.getElementById('status').textContent = msg;
}
// Wire up button event listeners
document.getElementById('btn-step').addEventListener('click', () => {
if (!z.length) init();
sweep();
renderCorpus();
renderBars();
updateIterLabel();
setStatus(i18nFmt('Completed iteration %iter%.', { iter: iterCount }));
});
document.getElementById('btn-run').addEventListener('click', () => {
if (running) { running = false; return; }
if (!z.length) init();
running = true;
// Animate up to 60 iterations using requestAnimationFrame
function loop() {
if (!running) return;
sweep();
renderCorpus();
renderBars();
updateIterLabel();
if (iterCount < 60) {
rafId = requestAnimationFrame(loop);
} else {
running = false;
setStatus(i18nFmt('Converged after %iter% iterations. Topics are stable.', { iter: iterCount }));
}
}
loop();
});
document.getElementById('btn-reset').addEventListener('click', () => {
running = false;
if (rafId) cancelAnimationFrame(rafId);
z = [];
iterCount = 0;
document.getElementById('iter-count').textContent = '0';
document.getElementById('status').textContent = '';
document.getElementById('bars0').innerHTML = '';
document.getElementById('bars1').innerHTML = '';
renderCorpus();
});
// Initialize on load and show an initial state
init();
renderCorpus();
renderBars();
setStatus('Ready — press Run LDA or Step to begin sampling.');
Notice how the algorithm separates words even though no document is purely about one topic. The mixing is the key: a document about a forest computer lab touches both themes, and LDA represents that as a genuine mixture rather than forcing a single label.
Comments
Loading comments...