Skip to content

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.

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

1import { BaseExperiment } from "../core/base-experiment";
2import 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 */
9export 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.