Perceptron
The 1958 neuron that guarantees convergence.
Beginner / classification / linear-models
Rosenblatt's perceptron adjusts its weights once per misclassified point and provably converges on separable data - the ancestor of everything in this category. The boundary line walks to a solution in full view; click to add points and it keeps learning. It is the SVM's great-grandparent with a cleaner guarantee and a louder failure mode on non-separated data.
Tagged: Rosenblatt, weight update, separability, convergence theorem
Live simulation
Click to add points; ringed ones are the mistakes it is still fixing. Keyboard: Space runs or pauses, . steps, r resets.
Speed
Click to add points; ringed ones are the mistakes it is still fixing.
Mathematics
The Rosenblatt perceptron predicts sign(w . x + b) and, on every mistake, adds y * x to the weights: w <- w + lr * y x. The perceptron convergence theorem guarantees that if the data is separable, the updates stop within 1/gamma^2 epochs, where gamma is the margin.
prediction: y_hat = sign(w . x + b)
update on mistake: w <- w + lr y x; b <- b + lr y
convergence: updates <= (R / gamma)^2 for R = max |x|
The theorem's guarantee is conditional - separable data - and the failure mode on overlapping data is loud: the update counter never rests and accuracy plateaus below 100%. The ringed points in the render are the current mistakes, so the guarantee's premise is visible, not assumed.
How it works
One epoch per tick over a seeded shuffle; the boundary line is the live decision surface, and each click adds a labeled point that the perceptron starts correcting immediately (convergence flag resets).
The dataset is constructed separable with a noisy margin: tight enough to be interesting, loose enough to need some updates.
Implementation
src/engine/experiments/perceptron.ts · 192 lines · the module running above
TypeScript · fixed-timestep engine
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import type { ParameterDef, PointerState, ThemeColors } from "../core/types"; |
| 3 | |
| 4 | /** |
| 5 | * The Rosenblatt perceptron (1958): one weight update per misclassified |
| 6 | * point, guaranteed to converge on separable data. The boundary line walks |
| 7 | * to a solution in full view; the update counter is the epoch history. |
| 8 | */ |
| 9 | export class PerceptronExperiment extends BaseExperiment { |
| 10 | readonly id = "perceptron"; |
| 11 | |
| 12 | private xs = new Float32Array(0); |
| 13 | private ys = new Float32Array(0); |
| 14 | private labels = new Float32Array(0); |
| 15 | private count = 0; |
| 16 | private w1 = 0; |
| 17 | private w2 = 0; |
| 18 | private bias = 0; |
| 19 | private epochs = 0; |
| 20 | private updates = 0; |
| 21 | private updatesThisEpoch = 0; |
| 22 | private lastEpochUpdates = 0; |
| 23 | private accuracy = 0; |
| 24 | private converged = false; |
| 25 | private pointer = { x: 0, y: 0, down: false, inside: false }; |
| 26 | private lastCell = { x: -1, y: -1 }; |
| 27 | |
| 28 | protected params(): ParameterDef[] { |
| 29 | return [ |
| 30 | { key: "rate", label: "Learning rate", min: 0.01, max: 0.5, step: 0.01, defaultValue: 0.1 }, |
| 31 | { |
| 32 | key: "tool", |
| 33 | label: "Pointer tool", |
| 34 | options: [ |
| 35 | { value: "classA", label: "Add class A (+)" }, |
| 36 | { value: "classB", label: "Add class B (-)" }, |
| 37 | ], |
| 38 | defaultValue: "classA", |
| 39 | }, |
| 40 | ]; |
| 41 | } |
| 42 | |
| 43 | protected onReset(): void { |
| 44 | this.count = 0; |
| 45 | this.xs = new Float32Array(200); |
| 46 | this.ys = new Float32Array(200); |
| 47 | this.labels = new Float32Array(200); |
| 48 | // A linearly separable dataset with a noisy margin. |
| 49 | for (let i = 0; i < 30; i++) { |
| 50 | const x = 0.15 + this.rng() * 0.35; |
| 51 | const y = 0.75 - x * 0.4 + (this.rng() - 0.5) * 0.18 + 0.1; |
| 52 | this.push(x, Math.min(1, y), 1); |
| 53 | } |
| 54 | for (let i = 0; i < 30; i++) { |
| 55 | const x = 0.5 + this.rng() * 0.35; |
| 56 | const y = 0.25 - x * 0.4 + (this.rng() - 0.5) * 0.18 + 0.45; |
| 57 | this.push(x, Math.max(0, y), -1); |
| 58 | } |
| 59 | this.w1 = this.rng() - 0.5; |
| 60 | this.w2 = this.rng() - 0.5; |
| 61 | this.bias = this.rng() - 0.5; |
| 62 | this.epochs = 0; |
| 63 | this.updates = 0; |
| 64 | this.updatesThisEpoch = 0; |
| 65 | this.lastEpochUpdates = 0; |
| 66 | this.converged = false; |
| 67 | this.measure(); |
| 68 | } |
| 69 | |
| 70 | private push(x: number, y: number, label: number): void { |
| 71 | if (this.count >= this.xs.length) return; |
| 72 | this.xs[this.count] = Math.min(1, Math.max(0, x)); |
| 73 | this.ys[this.count] = Math.min(1, Math.max(0, y)); |
| 74 | this.labels[this.count] = label; |
| 75 | this.count += 1; |
| 76 | } |
| 77 | |
| 78 | resize(width: number, height: number): void { |
| 79 | this.width = width; |
| 80 | this.height = height; |
| 81 | } |
| 82 | |
| 83 | onPointer(state: PointerState): void { |
| 84 | this.pointer = state; |
| 85 | if (!state.inside || !state.down) { |
| 86 | this.lastCell = { x: -1, y: -1 }; |
| 87 | return; |
| 88 | } |
| 89 | const x = state.x / Math.max(1, this.width); |
| 90 | const y = 1 - state.y / Math.max(1, this.height); |
| 91 | const cell = { x: Math.round(x * 50), y: Math.round(y * 50) }; |
| 92 | if (cell.x === this.lastCell.x && cell.y === this.lastCell.y) return; |
| 93 | this.lastCell = cell; |
| 94 | this.push(x, y, this.str("tool") === "classA" ? 1 : -1); |
| 95 | this.converged = false; |
| 96 | } |
| 97 | |
| 98 | private output(i: number): number { |
| 99 | return this.w1 * this.xs[i] + this.w2 * this.ys[i] + this.bias >= 0 ? 1 : -1; |
| 100 | } |
| 101 | |
| 102 | protected onUpdate(): void { |
| 103 | if (this.count === 0) return; |
| 104 | const rate = this.num("rate"); |
| 105 | // One epoch: a seeded-shuffle pass, updating on every mistake. |
| 106 | const order = Array.from({ length: this.count }, (_, i) => i); |
| 107 | for (let i = this.count - 1; i > 0; i--) { |
| 108 | const j = Math.floor(this.rng() * (i + 1)); |
| 109 | [order[i], order[j]] = [order[j], order[i]]; |
| 110 | } |
| 111 | this.updatesThisEpoch = 0; |
| 112 | for (const i of order) { |
| 113 | const prediction = this.output(i); |
| 114 | if (prediction !== this.labels[i]) { |
| 115 | this.w1 += rate * this.labels[i] * this.xs[i]; |
| 116 | this.w2 += rate * this.labels[i] * this.ys[i]; |
| 117 | this.bias += rate * this.labels[i]; |
| 118 | this.updates += 1; |
| 119 | this.updatesThisEpoch += 1; |
| 120 | } |
| 121 | } |
| 122 | this.epochs += 1; |
| 123 | if (this.updatesThisEpoch === 0 && !this.converged) { |
| 124 | this.converged = true; |
| 125 | } |
| 126 | this.measure(); |
| 127 | } |
| 128 | |
| 129 | private measure(): void { |
| 130 | let correct = 0; |
| 131 | for (let i = 0; i < this.count; i++) { |
| 132 | if (this.output(i) === this.labels[i]) correct += 1; |
| 133 | } |
| 134 | this.accuracy = this.count > 0 ? correct / this.count : 0; |
| 135 | } |
| 136 | |
| 137 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 138 | ctx.fillStyle = theme.bg; |
| 139 | ctx.fillRect(0, 0, this.width, this.height); |
| 140 | // Decision regions: sign(w1 x + w2 y + b) with y up. |
| 141 | const region = (sign: number) => { |
| 142 | ctx.fillStyle = sign > 0 ? theme.accent : theme.viz[1]; |
| 143 | ctx.globalAlpha = 0.1; |
| 144 | ctx.fillRect(0, 0, this.width, this.height); |
| 145 | ctx.globalAlpha = 1; |
| 146 | }; |
| 147 | void region; |
| 148 | // Boundary line w1 x + w2 y + b = 0 with y up. |
| 149 | if (Math.abs(this.w2) > 1e-9) { |
| 150 | const yAt = (x: number) => -(this.w1 * x + this.bias) / this.w2; |
| 151 | ctx.strokeStyle = theme.accent; |
| 152 | ctx.lineWidth = 2; |
| 153 | ctx.beginPath(); |
| 154 | ctx.moveTo(0, this.height - yAt(0) * this.height); |
| 155 | ctx.lineTo(this.width, this.height - yAt(1) * this.height); |
| 156 | ctx.stroke(); |
| 157 | ctx.lineWidth = 1; |
| 158 | } |
| 159 | for (let i = 0; i < this.count; i++) { |
| 160 | const misclassified = this.output(i) !== this.labels[i]; |
| 161 | ctx.fillStyle = this.labels[i] > 0 ? theme.accent : theme.viz[1]; |
| 162 | ctx.beginPath(); |
| 163 | ctx.arc(this.xs[i] * this.width, this.height - this.ys[i] * this.height, 3.4, 0, Math.PI * 2); |
| 164 | ctx.fill(); |
| 165 | if (misclassified) { |
| 166 | ctx.strokeStyle = theme.fg; |
| 167 | ctx.beginPath(); |
| 168 | ctx.arc(this.xs[i] * this.width, this.height - this.ys[i] * this.height, 5.5, 0, Math.PI * 2); |
| 169 | ctx.stroke(); |
| 170 | } |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | getMetrics() { |
| 175 | return { |
| 176 | epochs: this.epochs, |
| 177 | updates: this.updates, |
| 178 | lastEpochUpdates: this.lastEpochUpdates, |
| 179 | accuracy: `${(this.accuracy * 100).toFixed(1)} %`, |
| 180 | status: this.converged ? "Converged" : "Learning", |
| 181 | }; |
| 182 | } |
| 183 | |
| 184 | describe(): string { |
| 185 | return `A perceptron at epoch ${this.epochs}: ${(this.accuracy * 100).toFixed(0)}% accuracy, ${this.updates} weight updates so far${this.converged ? ", converged - zero updates last epoch" : ""}.`; |
| 186 | } |
| 187 | |
| 188 | entities(): number { |
| 189 | return this.count; |
| 190 | } |
| 191 | } |
| 192 | |
Performance
- Time per tick
- O(points) per epoch
- Space
- Three weights
- Notes
- The perceptron is the hinge-loss SVM without a margin objective - compare the two pages on the same kind of data.
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.