<p class="hint">
<b>Click</b> to place a <span class="dot-blue">blue</span> point (+1).
<b>Shift-click</b> to place a <span class="dot-red">red</span> point (−1).
Then hit <b>Train</b> to watch the perceptron find the boundary.
</p>
<canvas id="canvas" width="380" height="280"></canvas>
<div class="info" id="info">Add at least one blue and one red point, then Train.</div>
<div class="btns">
<button id="trainBtn" type="button">Train</button>
<button id="stepBtn" type="button">Step</button>
<button id="resetBtn" type="button" class="ghost">Clear</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.5; }
.dot-blue { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #3a86ff; vertical-align: middle; }
.dot-red { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #e63946; vertical-align: middle; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 8px; cursor: crosshair; touch-action: none; }
.info { font-size: .9rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; color: #1d3557; }
.info.converged { color: #0a7d33; }
.info.cycling { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Perceptron learning algorithm (Rosenblatt 1958).
// Convergence theorem: if data is linearly separable with margin γ and
// all points have ‖x‖ ≤ R, the algorithm makes at most (R/γ)² mistakes.
// We work in (x, y, bias=1) space so the threshold is absorbed into w[2].
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
let points = []; // { x, y, label: +1 | -1 }
let w = [0, 0, 0]; // weights [w0, w1, bias]
let steps = 0;
let animId = null;
let converged = false;
// Map canvas pixels to [-1, 1] feature space
function px2feat(px, py) {
return [(px / W) * 2 - 1, 1 - (py / H) * 2];
}
function predict(fx, fy) {
return (w[0] * fx + w[1] * fy + w[2]) >= 0 ? 1 : -1;
}
function perceptronStep() {
// Scan points for a mistake; update on first found (online rule)
for (let i = 0; i < points.length; i++) {
const p = points[i];
const [fx, fy] = px2feat(p.x, p.y);
const pred = predict(fx, fy);
if (pred !== p.label) {
w[0] += p.label * fx;
w[1] += p.label * fy;
w[2] += p.label * 1; // bias input is always 1
steps++;
return false; // not yet converged
}
}
return true; // all correct
}
function draw() {
ctx.clearRect(0, 0, W, H);
// Grid
ctx.strokeStyle = '#e8eef3';
ctx.lineWidth = 1;
for (let x = 0; x <= W; x += 40) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
for (let y = 0; y <= H; y += 40) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
// Decision boundary w0*fx + w1*fy + w2 = 0
// fy = -(w0*fx + w2) / w1 → py = H * (1 - fy) / 2
if (points.length >= 2 && (w[0] !== 0 || w[1] !== 0)) {
const fxLeft = -1, fxRight = 1;
function featToPy(fx) {
const fy = -(w[0] * fx + w[2]) / w[1];
return H * (1 - fy) / 2;
}
ctx.beginPath();
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2;
if (Math.abs(w[1]) > 1e-9) {
ctx.moveTo(0, featToPy(fxLeft));
ctx.lineTo(W, featToPy(fxRight));
} else {
// Vertical line: w0*fx + w2 = 0 → fx = -w2/w0
const fx0 = -w[2] / w[0];
const px0 = W * (fx0 + 1) / 2;
ctx.moveTo(px0, 0);
ctx.lineTo(px0, H);
}
ctx.stroke();
}
// Points
points.forEach(p => {
const [fx, fy] = px2feat(p.x, p.y);
const pred = predict(fx, fy);
const correct = pred === p.label;
ctx.beginPath();
ctx.arc(p.x, p.y, 7, 0, Math.PI * 2);
ctx.fillStyle = p.label === 1 ? '#3a86ff' : '#e63946';
ctx.fill();
if (!correct) {
// Ring around misclassified point
ctx.beginPath();
ctx.arc(p.x, p.y, 10, 0, Math.PI * 2);
ctx.strokeStyle = '#ff9f1c';
ctx.lineWidth = 2;
ctx.stroke();
}
});
}
function setInfo(msg, cls) {
const el = document.getElementById('info');
el.textContent = msg;
el.className = 'info' + (cls ? ' ' + cls : '');
}
function checkConverged() {
if (points.length === 0) return false;
const plus = points.some(p => p.label === 1);
const minus = points.some(p => p.label === -1);
if (!plus || !minus) return false;
return points.every(p => {
const [fx, fy] = px2feat(p.x, p.y);
return predict(fx, fy) === p.label;
});
}
// Animated training: runs one step per animation frame
function trainAnimate() {
if (animId) cancelAnimationFrame(animId);
let maxSteps = 10000;
function frame() {
if (maxSteps-- <= 0) {
setInfo('Stopped after 10 000 steps — data may not be linearly separable.', 'cycling');
draw();
return;
}
const done = perceptronStep();
draw();
if (done) {
converged = true;
setInfo(fmt('Converged in %steps% update%v%. All points classified correctly.', { steps: steps, v: steps !== 1 ? 's' : '' }), 'converged');
console.log('Perceptron converged. Steps:', steps, 'Weights:', w.map(x => x.toFixed(4)));
} else {
setInfo(fmt('Training… %steps% update%v% so far.', { steps: steps, v: steps !== 1 ? 's' : '' }));
animId = requestAnimationFrame(frame);
}
}
animId = requestAnimationFrame(frame);
}
function reset() {
if (animId) { cancelAnimationFrame(animId); animId = null; }
points = []; w = [0, 0, 0]; steps = 0; converged = false;
draw();
setInfo('Add at least one blue and one red point, then Train.');
}
canvas.addEventListener('click', e => {
const rect = canvas.getBoundingClientRect();
const px = (e.clientX - rect.left) * (W / rect.width);
const py = (e.clientY - rect.top) * (H / rect.height);
const label = e.shiftKey ? -1 : 1;
points.push({ x: px, y: py, label });
converged = false;
w = [0, 0, 0]; steps = 0;
draw();
setInfo('Point added. Hit Train to learn the boundary.');
});
document.getElementById('trainBtn').addEventListener('click', () => {
const plus = points.some(p => p.label === 1);
const minus = points.some(p => p.label === -1);
if (!plus || !minus) { setInfo('Need at least one blue AND one red point.'); return; }
if (animId) { cancelAnimationFrame(animId); animId = null; }
w = [0, 0, 0]; steps = 0; converged = false;
trainAnimate();
});
document.getElementById('stepBtn').addEventListener('click', () => {
if (animId) { cancelAnimationFrame(animId); animId = null; }
const plus = points.some(p => p.label === 1);
const minus = points.some(p => p.label === -1);
if (!plus || !minus) { setInfo('Need at least one blue AND one red point.'); return; }
const done = perceptronStep();
draw();
if (done) {
converged = true;
setInfo(fmt('Converged in %steps% update%v%. All points classified correctly.', { steps: steps, v: steps !== 1 ? 's' : '' }), 'converged');
} else {
setInfo(fmt('Step: %steps% update%v% so far.', { steps: steps, v: steps !== 1 ? 's' : '' }));
}
});
document.getElementById('resetBtn').addEventListener('click', reset);
draw();
setInfo('Add at least one blue and one red point, then Train.');
<p class="hint">
<b>Click</b> to place a <span class="dot-blue">blue</span> point (+1).
<b>Shift-click</b> to place a <span class="dot-red">red</span> point (−1).
Then hit <b>Train</b> to watch the perceptron find the boundary.
</p>
<canvas id="canvas" width="380" height="280"></canvas>
<div class="info" id="info">Add at least one blue and one red point, then Train.</div>
<div class="btns">
<button id="trainBtn" type="button">Train</button>
<button id="stepBtn" type="button">Step</button>
<button id="resetBtn" type="button" class="ghost">Clear</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.5; }
.dot-blue { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #3a86ff; vertical-align: middle; }
.dot-red { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #e63946; vertical-align: middle; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 8px; cursor: crosshair; touch-action: none; }
.info { font-size: .9rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; color: #1d3557; }
.info.converged { color: #0a7d33; }
.info.cycling { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Perceptron learning algorithm (Rosenblatt 1958).
// Convergence theorem: if data is linearly separable with margin γ and
// all points have ‖x‖ ≤ R, the algorithm makes at most (R/γ)² mistakes.
// We work in (x, y, bias=1) space so the threshold is absorbed into w[2].
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
let points = []; // { x, y, label: +1 | -1 }
let w = [0, 0, 0]; // weights [w0, w1, bias]
let steps = 0;
let animId = null;
let converged = false;
// Map canvas pixels to [-1, 1] feature space
function px2feat(px, py) {
return [(px / W) * 2 - 1, 1 - (py / H) * 2];
}
function predict(fx, fy) {
return (w[0] * fx + w[1] * fy + w[2]) >= 0 ? 1 : -1;
}
function perceptronStep() {
// Scan points for a mistake; update on first found (online rule)
for (let i = 0; i < points.length; i++) {
const p = points[i];
const [fx, fy] = px2feat(p.x, p.y);
const pred = predict(fx, fy);
if (pred !== p.label) {
w[0] += p.label * fx;
w[1] += p.label * fy;
w[2] += p.label * 1; // bias input is always 1
steps++;
return false; // not yet converged
}
}
return true; // all correct
}
function draw() {
ctx.clearRect(0, 0, W, H);
// Grid
ctx.strokeStyle = '#e8eef3';
ctx.lineWidth = 1;
for (let x = 0; x <= W; x += 40) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
for (let y = 0; y <= H; y += 40) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
// Decision boundary w0*fx + w1*fy + w2 = 0
// fy = -(w0*fx + w2) / w1 → py = H * (1 - fy) / 2
if (points.length >= 2 && (w[0] !== 0 || w[1] !== 0)) {
const fxLeft = -1, fxRight = 1;
function featToPy(fx) {
const fy = -(w[0] * fx + w[2]) / w[1];
return H * (1 - fy) / 2;
}
ctx.beginPath();
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2;
if (Math.abs(w[1]) > 1e-9) {
ctx.moveTo(0, featToPy(fxLeft));
ctx.lineTo(W, featToPy(fxRight));
} else {
// Vertical line: w0*fx + w2 = 0 → fx = -w2/w0
const fx0 = -w[2] / w[0];
const px0 = W * (fx0 + 1) / 2;
ctx.moveTo(px0, 0);
ctx.lineTo(px0, H);
}
ctx.stroke();
}
// Points
points.forEach(p => {
const [fx, fy] = px2feat(p.x, p.y);
const pred = predict(fx, fy);
const correct = pred === p.label;
ctx.beginPath();
ctx.arc(p.x, p.y, 7, 0, Math.PI * 2);
ctx.fillStyle = p.label === 1 ? '#3a86ff' : '#e63946';
ctx.fill();
if (!correct) {
// Ring around misclassified point
ctx.beginPath();
ctx.arc(p.x, p.y, 10, 0, Math.PI * 2);
ctx.strokeStyle = '#ff9f1c';
ctx.lineWidth = 2;
ctx.stroke();
}
});
}
function setInfo(msg, cls) {
const el = document.getElementById('info');
el.textContent = msg;
el.className = 'info' + (cls ? ' ' + cls : '');
}
function checkConverged() {
if (points.length === 0) return false;
const plus = points.some(p => p.label === 1);
const minus = points.some(p => p.label === -1);
if (!plus || !minus) return false;
return points.every(p => {
const [fx, fy] = px2feat(p.x, p.y);
return predict(fx, fy) === p.label;
});
}
// Animated training: runs one step per animation frame
function trainAnimate() {
if (animId) cancelAnimationFrame(animId);
let maxSteps = 10000;
function frame() {
if (maxSteps-- <= 0) {
setInfo('Stopped after 10 000 steps — data may not be linearly separable.', 'cycling');
draw();
return;
}
const done = perceptronStep();
draw();
if (done) {
converged = true;
setInfo(fmt('Converged in %steps% update%v%. All points classified correctly.', { steps: steps, v: steps !== 1 ? 's' : '' }), 'converged');
console.log('Perceptron converged. Steps:', steps, 'Weights:', w.map(x => x.toFixed(4)));
} else {
setInfo(fmt('Training… %steps% update%v% so far.', { steps: steps, v: steps !== 1 ? 's' : '' }));
animId = requestAnimationFrame(frame);
}
}
animId = requestAnimationFrame(frame);
}
function reset() {
if (animId) { cancelAnimationFrame(animId); animId = null; }
points = []; w = [0, 0, 0]; steps = 0; converged = false;
draw();
setInfo('Add at least one blue and one red point, then Train.');
}
canvas.addEventListener('click', e => {
const rect = canvas.getBoundingClientRect();
const px = (e.clientX - rect.left) * (W / rect.width);
const py = (e.clientY - rect.top) * (H / rect.height);
const label = e.shiftKey ? -1 : 1;
points.push({ x: px, y: py, label });
converged = false;
w = [0, 0, 0]; steps = 0;
draw();
setInfo('Point added. Hit Train to learn the boundary.');
});
document.getElementById('trainBtn').addEventListener('click', () => {
const plus = points.some(p => p.label === 1);
const minus = points.some(p => p.label === -1);
if (!plus || !minus) { setInfo('Need at least one blue AND one red point.'); return; }
if (animId) { cancelAnimationFrame(animId); animId = null; }
w = [0, 0, 0]; steps = 0; converged = false;
trainAnimate();
});
document.getElementById('stepBtn').addEventListener('click', () => {
if (animId) { cancelAnimationFrame(animId); animId = null; }
const plus = points.some(p => p.label === 1);
const minus = points.some(p => p.label === -1);
if (!plus || !minus) { setInfo('Need at least one blue AND one red point.'); return; }
const done = perceptronStep();
draw();
if (done) {
converged = true;
setInfo(fmt('Converged in %steps% update%v%. All points classified correctly.', { steps: steps, v: steps !== 1 ? 's' : '' }), 'converged');
} else {
setInfo(fmt('Step: %steps% update%v% so far.', { steps: steps, v: steps !== 1 ? 's' : '' }));
}
});
document.getElementById('resetBtn').addEventListener('click', reset);
draw();
setInfo('Add at least one blue and one red point, then Train.');
Notice how the boundary swings toward each misclassified point as the algorithm updates. The step counter shows the total weight updates made. When the classes are linearly separable the algorithm always stops; when they are not, it loops — try mixing the two colors and see what happens.
Comments
Loading comments...