Naive Bayes Classifier
Bayes' rule, applied one feature at a time.
Beginner / classification / probability / generative
Each class gets an axis-aligned Gaussian over the features; Bayes' rule with the class frequencies as priors turns any point into a posterior probability. The shaded field is P(class A | x) - recomputed from scratch every time you add a labeled point, so you can watch one click move the decision boundary.
Tagged: Gaussian NB, posterior, decision boundary, Bayes
Live simulation
Click to add labeled points; hover anywhere to read the posterior P(A | x). Keyboard: Space runs or pauses, . steps, r resets.
Speed
Click to add labeled points; hover anywhere to read the posterior P(A | x).
Mathematics
Bayes' rule turns a generative model into a classifier: model each class's features as an axis-aligned Gaussian, combine with the class frequencies as priors, and the posterior P(class | x) follows in closed form. 'Naive' is the independence assumption between features - which is exactly what the elliptical contours show.
P(A | x) = π_A N(x; μ_A, Σ_A) / (π_A N_A + π_B N_B)
log-posterior: log π − ½ (x − μ)ᵀ Σ⁻¹ (x − μ) − ½ log |Σ|
decision boundary: P(A | x) = 0.5
Everything is recomputed from scratch whenever the data changes: means, variances (with a variance floor so single points cannot claim zero width), priors, the full posterior field, and the training accuracy. Adding one point on the wrong side of the boundary drags its class mean and visibly bends the 0.5 contour - generative classifiers answer to their data directly.
How it works
The posterior field renders on a 96×48 grid through a token-built diverging colormap (class A toward accent, class B toward sky) at 55% opacity so points stay dominant; the 1σ ellipses are drawn per class from the live statistics. The pointer's own posterior P(A | x) is a live metric - hover anywhere and read the classifier's mind.
The tool selector adds class A, adds class B, or erases the nearest point; class counts and priors update with every click.
Implementation
src/engine/experiments/naive-bayes.ts · 253 lines · the module running above
TypeScript · fixed-timestep engine
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import { sampleInto } from "../render/colormaps"; |
| 3 | import type { ParameterDef, PointerState, ThemeColors } from "../core/types"; |
| 4 | import { divergingFor } from "../render/diverging-cache"; |
| 5 | |
| 6 | const CAPACITY = 240; |
| 7 | const FIELD_W = 96; |
| 8 | |
| 9 | /** |
| 10 | * Gaussian Naive Bayes: per class, an axis-aligned Gaussian over the two |
| 11 | * features; priors are the class frequencies. The posterior field P(A | x) |
| 12 | * is recomputed from Bayes' rule every time the data changes - adding a |
| 13 | * single labeled point moves the decision boundary on the next tick. |
| 14 | */ |
| 15 | export class NaiveBayesExperiment extends BaseExperiment { |
| 16 | readonly id = "naive-bayes"; |
| 17 | |
| 18 | private xs = new Float32Array(CAPACITY); |
| 19 | private ys = new Float32Array(CAPACITY); |
| 20 | private labels = new Float32Array(CAPACITY); |
| 21 | private count = 0; |
| 22 | private stats = { |
| 23 | meanAx: 0.5, meanAy: 0.5, varAx: 1e-4, varAy: 1e-4, |
| 24 | meanBx: 0.5, meanBy: 0.5, varBx: 1e-4, varBy: 1e-4, |
| 25 | priorA: 0.5, |
| 26 | }; |
| 27 | private accuracy = 0; |
| 28 | private pointerPosterior = 0.5; |
| 29 | private pointer = { x: 0, y: 0, down: false, inside: false }; |
| 30 | private lastCell = { x: -1, y: -1 }; |
| 31 | private image: ImageData | null = null; |
| 32 | private scratch: HTMLCanvasElement | null = null; |
| 33 | |
| 34 | protected params(): ParameterDef[] { |
| 35 | return [ |
| 36 | { |
| 37 | key: "tool", |
| 38 | label: "Pointer tool", |
| 39 | options: [ |
| 40 | { value: "classA", label: "Add class A (+)" }, |
| 41 | { value: "classB", label: "Add class B (-)" }, |
| 42 | { value: "erase", label: "Erase nearest" }, |
| 43 | ], |
| 44 | defaultValue: "classA", |
| 45 | }, |
| 46 | ]; |
| 47 | } |
| 48 | |
| 49 | protected onReset(): void { |
| 50 | this.count = 0; |
| 51 | // Two seeded, clearly separated blobs to start from. |
| 52 | for (let i = 0; i < 20; i++) { |
| 53 | this.addPoint(0.3 + (this.rng() - 0.5) * 0.22, 0.35 + (this.rng() - 0.5) * 0.22, 1, true); |
| 54 | } |
| 55 | for (let i = 0; i < 20; i++) { |
| 56 | this.addPoint(0.7 + (this.rng() - 0.5) * 0.22, 0.65 + (this.rng() - 0.5) * 0.22, -1, true); |
| 57 | } |
| 58 | this.updateModel(); |
| 59 | } |
| 60 | |
| 61 | private addPoint(x: number, y: number, label: number, skipUpdate = false): void { |
| 62 | if (this.count >= CAPACITY) return; |
| 63 | this.xs[this.count] = Math.min(1, Math.max(0, x)); |
| 64 | this.ys[this.count] = Math.min(1, Math.max(0, y)); |
| 65 | this.labels[this.count] = label; |
| 66 | this.count += 1; |
| 67 | if (!skipUpdate) this.updateModel(); |
| 68 | } |
| 69 | |
| 70 | /** Static field: recomputes on data and parameter changes. */ |
| 71 | protected onUpdate(): void {} |
| 72 | |
| 73 | resize(width: number, height: number): void { |
| 74 | this.width = width; |
| 75 | this.height = height; |
| 76 | } |
| 77 | |
| 78 | onPointer(state: PointerState): void { |
| 79 | this.pointer = state; |
| 80 | if (state.inside) { |
| 81 | this.pointerPosterior = this.posteriorA(state.x / Math.max(1, this.width), state.y / Math.max(1, this.height)); |
| 82 | } |
| 83 | if (!state.inside || !state.down) { |
| 84 | this.lastCell = { x: -1, y: -1 }; |
| 85 | return; |
| 86 | } |
| 87 | const x = state.x / Math.max(1, this.width); |
| 88 | const y = state.y / Math.max(1, this.height); |
| 89 | const cell = { x: Math.round(x * 50), y: Math.round(y * 50) }; |
| 90 | if (cell.x === this.lastCell.x && cell.y === this.lastCell.y) return; |
| 91 | this.lastCell = cell; |
| 92 | const tool = this.str("tool"); |
| 93 | if (tool === "erase") { |
| 94 | let best = -1; |
| 95 | let bestDist = 0.06; |
| 96 | for (let i = 0; i < this.count; i++) { |
| 97 | const d = Math.hypot(this.xs[i] - x, this.ys[i] - y); |
| 98 | if (d < bestDist) { |
| 99 | bestDist = d; |
| 100 | best = i; |
| 101 | } |
| 102 | } |
| 103 | if (best >= 0) { |
| 104 | this.count -= 1; |
| 105 | this.xs[best] = this.xs[this.count]; |
| 106 | this.ys[best] = this.ys[this.count]; |
| 107 | this.labels[best] = this.labels[this.count]; |
| 108 | this.updateModel(); |
| 109 | } |
| 110 | } else { |
| 111 | this.addPoint(x, y, tool === "classA" ? 1 : -1); |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | /** Bayes' rule with axis-aligned Gaussian class-conditionals. */ |
| 116 | private posteriorA(x: number, y: number): number { |
| 117 | const s = this.stats; |
| 118 | const logA = |
| 119 | Math.log(Math.max(1e-9, s.priorA)) - |
| 120 | (0.5 * (x - s.meanAx) ** 2) / s.varAx - |
| 121 | (0.5 * (y - s.meanAy) ** 2) / s.varAy - |
| 122 | 0.5 * Math.log(s.varAx * s.varAy); |
| 123 | const logB = |
| 124 | Math.log(Math.max(1e-9, 1 - s.priorA)) - |
| 125 | (0.5 * (x - s.meanBx) ** 2) / s.varBx - |
| 126 | (0.5 * (y - s.meanBy) ** 2) / s.varBy - |
| 127 | 0.5 * Math.log(s.varBx * s.varBy); |
| 128 | // Softmax of the two log posteriors. |
| 129 | const max = Math.max(logA, logB); |
| 130 | const expA = Math.exp(logA - max); |
| 131 | const expB = Math.exp(logB - max); |
| 132 | return expA / (expA + expB); |
| 133 | } |
| 134 | |
| 135 | private updateModel(): void { |
| 136 | let ax = 0; |
| 137 | let ay = 0; |
| 138 | let bx = 0; |
| 139 | let by = 0; |
| 140 | let nA = 0; |
| 141 | let nB = 0; |
| 142 | for (let i = 0; i < this.count; i++) { |
| 143 | if (this.labels[i] > 0) { |
| 144 | ax += this.xs[i]; |
| 145 | ay += this.ys[i]; |
| 146 | nA += 1; |
| 147 | } else { |
| 148 | bx += this.xs[i]; |
| 149 | by += this.ys[i]; |
| 150 | nB += 1; |
| 151 | } |
| 152 | } |
| 153 | const varFloor = 1e-4; |
| 154 | const stats = { |
| 155 | meanAx: nA > 0 ? ax / nA : 0.5, |
| 156 | meanAy: nA > 0 ? ay / nA : 0.5, |
| 157 | meanBx: nB > 0 ? bx / nB : 0.5, |
| 158 | meanBy: nB > 0 ? by / nB : 0.5, |
| 159 | priorA: this.count > 0 ? nA / this.count : 0.5, |
| 160 | varAx: varFloor, |
| 161 | varAy: varFloor, |
| 162 | varBx: varFloor, |
| 163 | varBy: varFloor, |
| 164 | }; |
| 165 | for (let i = 0; i < this.count; i++) { |
| 166 | if (this.labels[i] > 0) { |
| 167 | stats.varAx = Math.max(varFloor, stats.varAx + (this.xs[i] - stats.meanAx) ** 2 / Math.max(1, nA)); |
| 168 | stats.varAy = Math.max(varFloor, stats.varAy + (this.ys[i] - stats.meanAy) ** 2 / Math.max(1, nA)); |
| 169 | } else { |
| 170 | stats.varBx = Math.max(varFloor, stats.varBx + (this.xs[i] - stats.meanBx) ** 2 / Math.max(1, nB)); |
| 171 | stats.varBy = Math.max(varFloor, stats.varBy + (this.ys[i] - stats.meanBy) ** 2 / Math.max(1, nB)); |
| 172 | } |
| 173 | } |
| 174 | this.stats = stats; |
| 175 | // Training accuracy |
| 176 | let correct = 0; |
| 177 | for (let i = 0; i < this.count; i++) { |
| 178 | const p = this.posteriorA(this.xs[i], this.ys[i]); |
| 179 | const prediction = p >= 0.5 ? 1 : -1; |
| 180 | if (prediction === this.labels[i]) correct += 1; |
| 181 | } |
| 182 | this.accuracy = this.count > 0 ? correct / this.count : 0; |
| 183 | } |
| 184 | |
| 185 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 186 | ctx.fillStyle = theme.bg; |
| 187 | ctx.fillRect(0, 0, this.width, this.height); |
| 188 | const diverging = divergingFor(theme); |
| 189 | const fieldH = Math.floor(FIELD_W / 2); |
| 190 | if (!this.image || this.image.width !== FIELD_W) { |
| 191 | this.image = new ImageData(FIELD_W, fieldH); |
| 192 | this.scratch = document.createElement("canvas"); |
| 193 | this.scratch.width = FIELD_W; |
| 194 | this.scratch.height = fieldH; |
| 195 | } |
| 196 | const data = this.image.data; |
| 197 | for (let gy = 0; gy < fieldH; gy++) { |
| 198 | for (let gx = 0; gx < FIELD_W; gx++) { |
| 199 | const p = this.posteriorA((gx + 0.5) / FIELD_W, (gy + 0.5) / fieldH); |
| 200 | const t = Math.min(1, Math.max(0, 0.5 + (p - 0.5) * 1.1)); |
| 201 | sampleInto(diverging, t, data, (gy * FIELD_W + gx) * 4, 140); |
| 202 | } |
| 203 | } |
| 204 | this.scratch!.getContext("2d")!.putImageData(this.image, 0, 0); |
| 205 | ctx.imageSmoothingEnabled = true; |
| 206 | ctx.drawImage(this.scratch!, 0, 0, this.width, this.height); |
| 207 | // 1-sigma ellipses per class |
| 208 | const s = this.stats; |
| 209 | const scale = Math.min(this.width, this.height); |
| 210 | ctx.strokeStyle = theme.fgSecondary; |
| 211 | ctx.lineWidth = 1; |
| 212 | for (const cls of [ |
| 213 | { cx: s.meanAx, cy: s.meanAy, sx: Math.sqrt(s.varAx), sy: Math.sqrt(s.varAy) }, |
| 214 | { cx: s.meanBx, cy: s.meanBy, sx: Math.sqrt(s.varBx), sy: Math.sqrt(s.varBy) }, |
| 215 | ]) { |
| 216 | ctx.beginPath(); |
| 217 | ctx.ellipse(cls.cx * this.width, cls.cy * this.height, cls.sx * scale, cls.sy * scale, 0, 0, Math.PI * 2); |
| 218 | ctx.stroke(); |
| 219 | } |
| 220 | // Points |
| 221 | for (let i = 0; i < this.count; i++) { |
| 222 | ctx.fillStyle = this.labels[i] > 0 ? theme.accent : theme.viz[1]; |
| 223 | ctx.beginPath(); |
| 224 | ctx.arc(this.xs[i] * this.width, this.ys[i] * this.height, 3.4, 0, Math.PI * 2); |
| 225 | ctx.fill(); |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | getMetrics() { |
| 230 | return { |
| 231 | classA: this.count > 0 ? this.countClass(1) : 0, |
| 232 | classB: this.count > 0 ? this.countClass(-1) : 0, |
| 233 | priorA: `${(this.stats.priorA * 100).toFixed(0)} %`, |
| 234 | accuracy: `${(this.accuracy * 100).toFixed(1)} %`, |
| 235 | pointerPA: this.pointer.inside ? this.pointerPosterior.toFixed(3) : "n/a", |
| 236 | }; |
| 237 | } |
| 238 | |
| 239 | private countClass(label: number): number { |
| 240 | let sum = 0; |
| 241 | for (let i = 0; i < this.count; i++) if (this.labels[i] === label) sum += 1; |
| 242 | return sum; |
| 243 | } |
| 244 | |
| 245 | describe(): string { |
| 246 | return `Gaussian Naive Bayes trained on ${this.count} labeled points (${this.countClass(1)} class A, ${this.countClass(-1)} class B), ${(this.accuracy * 100).toFixed(0)}% training accuracy. Click to add points and watch the boundary move.`; |
| 247 | } |
| 248 | |
| 249 | entities(): number { |
| 250 | return this.count; |
| 251 | } |
| 252 | } |
| 253 | |
Performance
- Time per tick
- O(points) per model update; O(grid) per field render
- Space
- Eight numbers of class statistics
- Notes
- Compare with the SVM: generative model versus discriminative margin, Gaussian boundary versus kernelized hinge - same data, two philosophies.
Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.