Below is a tiny email dataset. The classifier has already learned word frequencies from the training examples. Type a new message and hit Classify — the demo shows you the per-word log-probability contributions and the final verdict.
<div class="nb-wrap">
<div class="training-panel">
<div class="panel-title">Training data</div>
<div id="training-list" class="training-list"></div>
</div>
<div class="classify-panel">
<div class="panel-title">Classify a message</div>
<textarea id="msg-input" rows="2" placeholder='e.g. "win free money now"'></textarea>
<div class="btn-row">
<button id="classify-btn" type="button">Classify</button>
<button id="reset-btn" type="button" class="ghost">Clear</button>
</div>
<div id="result-box" class="result-box hidden"></div>
<div id="breakdown" class="breakdown hidden"></div>
</div>
</div>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; font-size: 14px; color: #222; padding: 8px; }
.nb-wrap { display: flex; gap: 10px; flex-wrap: wrap; }
.training-panel, .classify-panel { flex: 1 1 200px; min-width: 180px; }
.panel-title { font-weight: 700; font-size: .8rem; text-transform: uppercase;
letter-spacing: .05em; color: #556; margin-bottom: 6px; }
.training-list { display: flex; flex-direction: column; gap: 4px; }
.train-item { border-radius: 6px; padding: 5px 8px; font-size: .82rem; line-height: 1.4; }
.train-item.spam { background: #fde8e8; border-left: 3px solid #e63946; }
.train-item.ham { background: #e8f5e9; border-left: 3px solid #2a9d55; }
.train-label { font-weight: 700; font-size: .75rem; text-transform: uppercase;
letter-spacing: .04em; margin-right: 4px; }
.train-item.spam .train-label { color: #c92f3c; }
.train-item.ham .train-label { color: #1a7a40; }
textarea { width: 100%; border: 1px solid #ccd; border-radius: 6px; padding: 6px 8px;
font: 14px system-ui, sans-serif; resize: vertical; }
.btn-row { display: flex; gap: 6px; margin: 6px 0; flex-wrap: wrap; }
button { font: 600 13px system-ui, sans-serif; padding: .38rem .8rem; border-radius: 6px;
cursor: pointer; border: 1px solid #1d3557; background: #1d3557; color: #fff; }
button.ghost { background: #fff; color: #1d3557; }
.result-box { padding: 8px 10px; border-radius: 6px; font-weight: 700; font-size: .95rem; }
.result-box.spam { background: #fde8e8; color: #c92f3c; border: 1px solid #f5b5b8; }
.result-box.ham { background: #e8f5e9; color: #1a7a40; border: 1px solid #a8d5b5; }
.breakdown { margin-top: 8px; }
.bk-title { font-size: .78rem; font-weight: 700; color: #556; text-transform: uppercase;
letter-spacing: .04em; margin-bottom: 4px; }
.bk-row { display: flex; align-items: center; gap: 6px; margin: 3px 0; font-size: .8rem; }
.bk-word { min-width: 80px; font-family: ui-monospace, monospace; }
.bk-bar-wrap { flex: 1; background: #e8eef3; border-radius: 4px; height: 10px; overflow: hidden; }
.bk-bar { height: 100%; border-radius: 4px; transition: width .3s; }
.bk-bar.spam { background: #e63946; }
.bk-bar.ham { background: #2a9d55; }
.bk-val { min-width: 54px; text-align: right; font-family: ui-monospace, monospace; font-size: .75rem; color: #556; }
.hidden { display: none; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Naive Bayes text classifier (multinomial, log-space, Laplace smoothing alpha=1).
// Training data: small spam/ham corpus.
const TRAIN = [
{ label: 'spam', text: 'win free money now click here' },
{ label: 'spam', text: 'cheap pills free offer limited time' },
{ label: 'spam', text: 'congratulations you won free prize claim now' },
{ label: 'spam', text: 'urgent free cash reward click link' },
{ label: 'spam', text: 'buy cheap meds online free shipping' },
{ label: 'ham', text: 'hi can we meet tomorrow for lunch' },
{ label: 'ham', text: 'please review the report and send feedback' },
{ label: 'ham', text: 'meeting is scheduled for monday at ten' },
{ label: 'ham', text: 'the project deadline is next friday morning' },
{ label: 'ham', text: 'thanks for the update see you soon' },
];
function tokenize(text) {
return text.toLowerCase().replace(/[^a-z\s]/g, '').split(/\s+/).filter(Boolean);
}
// Build model
const classes = ['spam', 'ham'];
const counts = {}; // counts[cls][word]
const totals = {}; // totals[cls] = total word tokens
const vocab = new Set();
const classDocs = { spam: 0, ham: 0 };
classes.forEach(c => { counts[c] = {}; totals[c] = 0; });
TRAIN.forEach(({ label, text }) => {
classDocs[label]++;
tokenize(text).forEach(w => {
vocab.add(w);
counts[label][w] = (counts[label][w] || 0) + 1;
totals[label]++;
});
});
const V = vocab.size;
const N = TRAIN.length;
// Log prior
const logPrior = {};
classes.forEach(c => { logPrior[c] = Math.log(classDocs[c] / N); });
// Log likelihood with Laplace smoothing (alpha=1)
function logLik(word, cls) {
const cnt = counts[cls][word] || 0;
return Math.log((cnt + 1) / (totals[cls] + V));
}
function classify(text) {
const words = tokenize(text);
if (words.length === 0) return null;
const scores = {};
const perWord = {};
classes.forEach(c => { scores[c] = logPrior[c]; });
words.forEach(w => {
perWord[w] = {};
classes.forEach(c => {
const ll = logLik(w, c);
scores[c] += ll;
perWord[w][c] = ll;
});
});
const winner = scores.spam > scores.ham ? 'spam' : 'ham';
return { winner, scores, words, perWord };
}
// Render training list
function renderTraining() {
const el = document.getElementById('training-list');
TRAIN.forEach(({ label, text }) => {
const div = document.createElement('div');
div.className = 'train-item ' + label;
div.innerHTML = '<span class="train-label">' + label + '</span>' + text;
el.appendChild(div);
});
}
function renderResult(res) {
const rb = document.getElementById('result-box');
const bd = document.getElementById('breakdown');
rb.className = 'result-box ' + res.winner;
const pct = (Math.exp(res.scores[res.winner]) * 100).toFixed(1);
rb.textContent = res.winner === 'spam'
? fmt('🚫 SPAM — log-score: spam %spam% vs ham %ham%', { spam: res.scores.spam.toFixed(2), ham: res.scores.ham.toFixed(2) })
: fmt('✓ HAM — log-score: ham %ham% vs spam %spam%', { ham: res.scores.ham.toFixed(2), spam: res.scores.spam.toFixed(2) });
rb.classList.remove('hidden');
// Breakdown per word
bd.innerHTML = '<div class="bk-title">Word contributions (log-likelihood spam − ham)</div>';
res.words.forEach(w => {
const diff = res.perWord[w].spam - res.perWord[w].ham;
const maxDiff = 3; // visual scale
const pctFill = Math.min(Math.abs(diff) / maxDiff * 100, 100);
const dir = diff > 0 ? 'spam' : 'ham';
const row = document.createElement('div');
row.className = 'bk-row';
row.innerHTML =
'<span class="bk-word">' + w + '</span>' +
'<div class="bk-bar-wrap"><div class="bk-bar ' + dir + '" style="width:' + pctFill + '%"></div></div>' +
'<span class="bk-val">' + (diff > 0 ? '+' : '') + diff.toFixed(2) + '</span>';
bd.appendChild(row);
});
bd.classList.remove('hidden');
console.log('Classification:', res.winner, '| | scores:', res.scores, '| | words:', res.words);
}
document.getElementById('classify-btn').addEventListener('click', () => {
const text = document.getElementById('msg-input').value.trim();
if (!text) return;
const res = classify(text);
if (res) renderResult(res);
});
document.getElementById('reset-btn').addEventListener('click', () => {
document.getElementById('msg-input').value = '';
document.getElementById('result-box').className = 'result-box hidden';
document.getElementById('breakdown').className = 'breakdown hidden';
});
document.getElementById('msg-input').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); document.getElementById('classify-btn').click(); }
});
renderTraining();
<div class="nb-wrap">
<div class="training-panel">
<div class="panel-title">Training data</div>
<div id="training-list" class="training-list"></div>
</div>
<div class="classify-panel">
<div class="panel-title">Classify a message</div>
<textarea id="msg-input" rows="2" placeholder='e.g. "win free money now"'></textarea>
<div class="btn-row">
<button id="classify-btn" type="button">Classify</button>
<button id="reset-btn" type="button" class="ghost">Clear</button>
</div>
<div id="result-box" class="result-box hidden"></div>
<div id="breakdown" class="breakdown hidden"></div>
</div>
</div>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; font-size: 14px; color: #222; padding: 8px; }
.nb-wrap { display: flex; gap: 10px; flex-wrap: wrap; }
.training-panel, .classify-panel { flex: 1 1 200px; min-width: 180px; }
.panel-title { font-weight: 700; font-size: .8rem; text-transform: uppercase;
letter-spacing: .05em; color: #556; margin-bottom: 6px; }
.training-list { display: flex; flex-direction: column; gap: 4px; }
.train-item { border-radius: 6px; padding: 5px 8px; font-size: .82rem; line-height: 1.4; }
.train-item.spam { background: #fde8e8; border-left: 3px solid #e63946; }
.train-item.ham { background: #e8f5e9; border-left: 3px solid #2a9d55; }
.train-label { font-weight: 700; font-size: .75rem; text-transform: uppercase;
letter-spacing: .04em; margin-right: 4px; }
.train-item.spam .train-label { color: #c92f3c; }
.train-item.ham .train-label { color: #1a7a40; }
textarea { width: 100%; border: 1px solid #ccd; border-radius: 6px; padding: 6px 8px;
font: 14px system-ui, sans-serif; resize: vertical; }
.btn-row { display: flex; gap: 6px; margin: 6px 0; flex-wrap: wrap; }
button { font: 600 13px system-ui, sans-serif; padding: .38rem .8rem; border-radius: 6px;
cursor: pointer; border: 1px solid #1d3557; background: #1d3557; color: #fff; }
button.ghost { background: #fff; color: #1d3557; }
.result-box { padding: 8px 10px; border-radius: 6px; font-weight: 700; font-size: .95rem; }
.result-box.spam { background: #fde8e8; color: #c92f3c; border: 1px solid #f5b5b8; }
.result-box.ham { background: #e8f5e9; color: #1a7a40; border: 1px solid #a8d5b5; }
.breakdown { margin-top: 8px; }
.bk-title { font-size: .78rem; font-weight: 700; color: #556; text-transform: uppercase;
letter-spacing: .04em; margin-bottom: 4px; }
.bk-row { display: flex; align-items: center; gap: 6px; margin: 3px 0; font-size: .8rem; }
.bk-word { min-width: 80px; font-family: ui-monospace, monospace; }
.bk-bar-wrap { flex: 1; background: #e8eef3; border-radius: 4px; height: 10px; overflow: hidden; }
.bk-bar { height: 100%; border-radius: 4px; transition: width .3s; }
.bk-bar.spam { background: #e63946; }
.bk-bar.ham { background: #2a9d55; }
.bk-val { min-width: 54px; text-align: right; font-family: ui-monospace, monospace; font-size: .75rem; color: #556; }
.hidden { display: none; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Naive Bayes text classifier (multinomial, log-space, Laplace smoothing alpha=1).
// Training data: small spam/ham corpus.
const TRAIN = [
{ label: 'spam', text: 'win free money now click here' },
{ label: 'spam', text: 'cheap pills free offer limited time' },
{ label: 'spam', text: 'congratulations you won free prize claim now' },
{ label: 'spam', text: 'urgent free cash reward click link' },
{ label: 'spam', text: 'buy cheap meds online free shipping' },
{ label: 'ham', text: 'hi can we meet tomorrow for lunch' },
{ label: 'ham', text: 'please review the report and send feedback' },
{ label: 'ham', text: 'meeting is scheduled for monday at ten' },
{ label: 'ham', text: 'the project deadline is next friday morning' },
{ label: 'ham', text: 'thanks for the update see you soon' },
];
function tokenize(text) {
return text.toLowerCase().replace(/[^a-z\s]/g, '').split(/\s+/).filter(Boolean);
}
// Build model
const classes = ['spam', 'ham'];
const counts = {}; // counts[cls][word]
const totals = {}; // totals[cls] = total word tokens
const vocab = new Set();
const classDocs = { spam: 0, ham: 0 };
classes.forEach(c => { counts[c] = {}; totals[c] = 0; });
TRAIN.forEach(({ label, text }) => {
classDocs[label]++;
tokenize(text).forEach(w => {
vocab.add(w);
counts[label][w] = (counts[label][w] || 0) + 1;
totals[label]++;
});
});
const V = vocab.size;
const N = TRAIN.length;
// Log prior
const logPrior = {};
classes.forEach(c => { logPrior[c] = Math.log(classDocs[c] / N); });
// Log likelihood with Laplace smoothing (alpha=1)
function logLik(word, cls) {
const cnt = counts[cls][word] || 0;
return Math.log((cnt + 1) / (totals[cls] + V));
}
function classify(text) {
const words = tokenize(text);
if (words.length === 0) return null;
const scores = {};
const perWord = {};
classes.forEach(c => { scores[c] = logPrior[c]; });
words.forEach(w => {
perWord[w] = {};
classes.forEach(c => {
const ll = logLik(w, c);
scores[c] += ll;
perWord[w][c] = ll;
});
});
const winner = scores.spam > scores.ham ? 'spam' : 'ham';
return { winner, scores, words, perWord };
}
// Render training list
function renderTraining() {
const el = document.getElementById('training-list');
TRAIN.forEach(({ label, text }) => {
const div = document.createElement('div');
div.className = 'train-item ' + label;
div.innerHTML = '<span class="train-label">' + label + '</span>' + text;
el.appendChild(div);
});
}
function renderResult(res) {
const rb = document.getElementById('result-box');
const bd = document.getElementById('breakdown');
rb.className = 'result-box ' + res.winner;
const pct = (Math.exp(res.scores[res.winner]) * 100).toFixed(1);
rb.textContent = res.winner === 'spam'
? fmt('🚫 SPAM — log-score: spam %spam% vs ham %ham%', { spam: res.scores.spam.toFixed(2), ham: res.scores.ham.toFixed(2) })
: fmt('✓ HAM — log-score: ham %ham% vs spam %spam%', { ham: res.scores.ham.toFixed(2), spam: res.scores.spam.toFixed(2) });
rb.classList.remove('hidden');
// Breakdown per word
bd.innerHTML = '<div class="bk-title">Word contributions (log-likelihood spam − ham)</div>';
res.words.forEach(w => {
const diff = res.perWord[w].spam - res.perWord[w].ham;
const maxDiff = 3; // visual scale
const pctFill = Math.min(Math.abs(diff) / maxDiff * 100, 100);
const dir = diff > 0 ? 'spam' : 'ham';
const row = document.createElement('div');
row.className = 'bk-row';
row.innerHTML =
'<span class="bk-word">' + w + '</span>' +
'<div class="bk-bar-wrap"><div class="bk-bar ' + dir + '" style="width:' + pctFill + '%"></div></div>' +
'<span class="bk-val">' + (diff > 0 ? '+' : '') + diff.toFixed(2) + '</span>';
bd.appendChild(row);
});
bd.classList.remove('hidden');
console.log('Classification:', res.winner, '| | scores:', res.scores, '| | words:', res.words);
}
document.getElementById('classify-btn').addEventListener('click', () => {
const text = document.getElementById('msg-input').value.trim();
if (!text) return;
const res = classify(text);
if (res) renderResult(res);
});
document.getElementById('reset-btn').addEventListener('click', () => {
document.getElementById('msg-input').value = '';
document.getElementById('result-box').className = 'result-box hidden';
document.getElementById('breakdown').className = 'breakdown hidden';
});
document.getElementById('msg-input').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); document.getElementById('classify-btn').click(); }
});
renderTraining();
Notice how each word shifts the score up (toward spam) or down (toward ham). Words the model has never seen get a tiny floor probability so one unknown word cannot silence everything else — that trick is called Laplace smoothing. The whole classification runs in time proportional to the number of words: scaling from ten words to ten million words is just ten million multiplications.
Comments
Loading comments...