Spaces:
Running
Running
File size: 25,790 Bytes
1b19efc 7bb6780 4685944 7bb6780 4685944 7bb6780 1b19efc 3f21703 1b19efc 3f21703 1b19efc dac4940 1b19efc dac4940 1b19efc 3f21703 dac4940 1b19efc dac4940 1b19efc fb5193e 1b19efc dac4940 1b19efc dac4940 1b19efc 3f21703 1b19efc 3f21703 1b19efc 3f21703 1b19efc 3f21703 1b19efc 3f21703 1b19efc 3f21703 1b19efc 3f21703 1b19efc 3f21703 1b19efc 3f21703 1b19efc 3f21703 1b19efc dac4940 1b19efc 3f21703 1b19efc ef4c571 1b19efc 7bb6780 1b19efc 7bb6780 1b19efc 69f60de 7bb6780 69f60de 1b19efc 69f60de 7043795 1b19efc 69f60de 7043795 69f60de 1b19efc dac4940 1b19efc 3f21703 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 | const PYODIDE_URL = "https://cdn.jsdelivr.net/pyodide/v0.26.1/full/pyodide.mjs";
const RANGE = { min: -5, max: 5 };
const COLORS = { A: "#2d6cdf", B: "#e86f32" };
const REGION = { A: "rgba(45,108,223,.12)", B: "rgba(232,111,50,.12)" };
const BUILTIN_DATASETS = {
"easy-linear": [
{ x: -3.2, y: -2.0, label: "A" }, { x: -2.2, y: 0.1, label: "A" }, { x: -3.0, y: 2.3, label: "A" },
{ x: 3.1, y: -2.1, label: "B" }, { x: 2.1, y: 0.2, label: "B" }, { x: 3.2, y: 2.2, label: "B" },
],
"hard-linear": [
{ x: -3.4, y: -1.08, label: "A" }, { x: -0.2, y: 0.80, label: "A" }, { x: 3.2, y: 2.95, label: "A" },
{ x: -3.2, y: -2.30, label: "B" }, { x: 0.2, y: -0.16, label: "B" }, { x: 3.4, y: 1.77, label: "B" },
],
nonlinear: [
{ x: -0.7, y: -0.5, label: "A" }, { x: 0.7, y: -0.5, label: "A" }, { x: 0.0, y: 0.8, label: "A" },
{ x: -3.2, y: -2.4, label: "B" }, { x: 3.2, y: -2.4, label: "B" }, { x: 0.0, y: 3.3, label: "B" },
],
};
const state = { points: structuredClone(BUILTIN_DATASETS['easy-linear']), datasetMode: "easy-linear", redoPoints: [], hoveredPoint: -1, label: "A", model: null, pendingUpdate: null, metrics: null, step: 0, permutation: [], cursor: 0, nextBatch: [], rngState: 42, history: [], viewIndex: -1, previewVersion: 0, previewPromise: null, busy: false, animating: false, ready: false };
const canvas = document.querySelector("#plot");
const ctx = canvas.getContext("2d");
const metricsCanvas = document.querySelector("#metrics-chart");
const metricsCtx = metricsCanvas.getContext("2d");
const ui = Object.fromEntries([...document.querySelectorAll("[id]")].map(el => [el.id, el]));
function modelOptions() {
return {
loss: ui.loss.value,
learning_rate: Number(ui["learning-rate"].value),
batch_size: Math.max(1, Number.parseInt(ui["batch-size"].value, 10) || 1),
batch_selection: ui["batch-selection"].value,
random_state: randomSeed(),
};
}
function randomSeed() { return (Number.parseInt(ui["random-seed"].value, 10) || 0) >>> 0; }
function resetModel(message = "Model reset. Take a step to begin training.") {
state.model = null; state.pendingUpdate = null; state.metrics = null; state.step = 0; state.permutation = []; state.cursor = 0; state.nextBatch = []; state.history = []; state.viewIndex = -1;
state.rngState = randomSeed();
queueNextBatch();
ui["step-count"].textContent = "Step 0";
renderView(); setMessage(message); updateControls(); refreshPreview();
}
function setMessage(text, kind = "") { ui.message.textContent = text; ui.message.className = `message ${kind}`; }
function hasBothClasses() { return state.points.some(p => p.label === "A") && state.points.some(p => p.label === "B"); }
function updateControls() {
ui.undo.disabled = state.points.length === 0 || state.busy || state.animating;
ui.redo.disabled = state.redoPoints.length === 0 || state.busy || state.animating;
ui.clear.disabled = state.points.length === 0 || state.busy || state.animating;
ui["label-a"].disabled = state.busy || state.animating; ui["label-b"].disabled = state.busy || state.animating;
for (const id of ["dataset-select", "loss", "learning-rate", "batch-size", "random-seed", "animation-speed", "batch-selection"]) ui[id].disabled = state.busy || state.animating;
ui["history-prev"].disabled = state.viewIndex <= 0 || state.busy || state.animating;
ui["history-reset"].disabled = state.viewIndex <= 0 || state.busy || state.animating;
ui["history-next"].disabled = !state.ready || !hasBothClasses() || state.busy || state.animating;
ui.animate.disabled = !state.ready || !hasBothClasses() || state.busy || state.animating;
ui.pause.disabled = !state.animating;
ui["point-count"].textContent = `${state.points.length} point${state.points.length === 1 ? "" : "s"}`;
ui["empty-hint"].hidden = state.points.length > 0;
//ui["plot-instructions"].textContent = state.datasetMode === "custom" ? "Click inside the plot to add a point with the selected label." : "Hover over a point to inspect its coordinates.";
if (!hasBothClasses()) setMessage("Add at least one point from each class to begin.");
}
function shuffleIndices(count) {
const values = Array.from({ length: count }, (_, i) => i);
for (let i = values.length - 1; i > 0; i--) { const j = Math.floor(seededRandom() * (i + 1)); [values[i], values[j]] = [values[j], values[i]]; }
return values;
}
function seededRandom() {
state.rngState = (Math.imul(1664525, state.rngState) + 1013904223) >>> 0;
return state.rngState / 4294967296;
}
function nextBatch() {
const { batch_size, batch_selection } = modelOptions();
const size = Math.min(batch_size, state.points.length);
if (batch_selection === "random") return shuffleIndices(state.points.length).slice(0, size);
if (!state.permutation.length || state.cursor >= state.permutation.length) { state.permutation = shuffleIndices(state.points.length); state.cursor = 0; }
const batch = state.permutation.slice(state.cursor, state.cursor + size); state.cursor += size;
return batch;
}
function queueNextBatch() {
state.nextBatch = hasBothClasses() ? nextBatch() : [];
const count = state.nextBatch.length;
ui["batch-summary"].textContent = count ? `Next batch: ${count} point${count === 1 ? "" : "s"}` : "No batch selected";
}
function formatNumber(value) {
if (Math.abs(value) < 0.0000005) return "0.0000";
if (Math.abs(value) >= 1000 || Math.abs(value) < 0.001) return value.toExponential(3);
return value.toFixed(4);
}
function formatVector(vector) { return `[${vector.map(formatNumber).join(", ")}]`; }
function visibleSnapshot() {
return state.history[state.viewIndex] || { model: state.model, pendingUpdate: state.pendingUpdate, metrics: state.metrics, nextBatch: state.nextBatch, step: state.step };
}
function renderParameters(snapshot = visibleSnapshot()) {
ui["model-parameters"].textContent = snapshot.model
? `w_A = ${formatVector(snapshot.model.coef[0])}\nb_A = ${formatNumber(snapshot.model.intercept[0])}`
: "w_A = —\nb_A = —";
ui["model-update"].textContent = snapshot.pendingUpdate
? `Δw_A = ${formatVector(snapshot.pendingUpdate.coef[0])}\nΔb_A = ${formatNumber(snapshot.pendingUpdate.intercept[0])}`
: "Δw_A = —\nΔb_A = —";
}
function captureSnapshot() {
const snapshot = structuredClone({ model: state.model, pendingUpdate: state.pendingUpdate, metrics: state.metrics, nextBatch: state.nextBatch, step: state.step });
if (state.history.at(-1)?.step === state.step) state.history[state.history.length - 1] = snapshot;
else state.history.push(snapshot);
state.viewIndex = state.history.length - 1;
renderView();
}
function renderView() {
const snapshot = visibleSnapshot();
renderParameters(snapshot);
ui["step-count"].textContent = `Step ${snapshot.step}`;
ui["history-position"].textContent = `Step ${snapshot.step} of ${Math.max(0, state.history.length - 1)}`;
const count = snapshot.nextBatch.length;
ui["batch-summary"].textContent = count ? `Next batch: ${count} point${count === 1 ? "" : "s"}` : "No batch selected";
updateControls(); draw(); drawMetrics();
}
async function refreshPreview(reportError = true) {
const version = ++state.previewVersion;
if (!state.ready || !hasBothClasses() || !state.nextBatch.length) { renderView(); return; }
state.previewPromise = (async () => {
try {
if (!state.model) {
const pyOptions = state.pyodide.toPy(modelOptions());
state.pyodide.globals.set("model_options", pyOptions);
try { state.pyodide.runPython("model = SGDClassifier(**model_options)"); }
finally { pyOptions.destroy(); state.pyodide.globals.delete("model_options"); }
}
const X = state.nextBatch.map(i => [state.points[i].x, state.points[i].y]);
const y = state.nextBatch.map(i => state.points[i].label);
const allX = state.points.map(point => [point.x, point.y]);
const allY = state.points.map(point => point.label);
const pyX = state.pyodide.toPy(X), pyY = state.pyodide.toPy(y);
const pyAllX = state.pyodide.toPy(allX), pyAllY = state.pyodide.toPy(allY);
state.pyodide.globals.set("preview_X", pyX); state.pyodide.globals.set("preview_y", pyY);
state.pyodide.globals.set("all_X", pyAllX); state.pyodide.globals.set("all_y", pyAllY);
try {
const result = state.pyodide.runPython(`
preview_coef, preview_intercept = model.get_update(preview_X, preview_y, classes=["A", "B"])
metrics = model.evaluate(all_X, all_y)
{"coef": model.coef_.tolist(), "intercept": model.intercept_.tolist(),
"update_coef": preview_coef.tolist(), "update_intercept": preview_intercept.tolist(),
"loss": metrics["loss"], "accuracy": metrics["accuracy"]}
`);
const values = result.toJs({ dict_converter: Object.fromEntries }); result.destroy();
if (version !== state.previewVersion) return;
state.model = { coef: values.coef, intercept: values.intercept };
state.pendingUpdate = { coef: values.update_coef, intercept: values.update_intercept };
state.metrics = { loss: values.loss, accuracy: values.accuracy };
captureSnapshot();
} finally {
pyX.destroy(); pyY.destroy(); pyAllX.destroy(); pyAllY.destroy();
for (const name of ["preview_X", "preview_y", "all_X", "all_y"]) state.pyodide.globals.delete(name);
}
} catch (error) {
if (version === state.previewVersion) { state.pendingUpdate = null; renderView(); if (reportError) setMessage(error instanceof Error ? error.message : String(error), "error"); }
}
})();
return state.previewPromise;
}
async function initializeRuntime() {
try {
const { loadPyodide } = await import(PYODIDE_URL);
const pyodide = await loadPyodide({ indexURL: "https://cdn.jsdelivr.net/pyodide/v0.26.1/full/" });
await pyodide.loadPackage("numpy");
const classifierUrl = new URL("sgd_classifier.py", window.location.href);
classifierUrl.searchParams.set("v", Date.now().toString());
const source = await fetch(classifierUrl, { cache: "no-store" }).then(response => { if (!response.ok) throw new Error("Classifier source could not be loaded."); return response.text(); });
pyodide.FS.writeFile("sgd_classifier.py", source);
pyodide.runPython("from sgd_classifier import SGDClassifier");
state.pyodide = pyodide; state.ready = true;
ui["runtime-status"].className = "status ready"; ui["runtime-status"].innerHTML = "<span></span>Python runtime ready";
updateControls(); refreshPreview();
} catch (error) {
ui["runtime-status"].className = "status error"; ui["runtime-status"].innerHTML = "<span></span>Runtime failed";
setMessage(error instanceof Error ? error.message : String(error), "error");
}
}
async function trainOneStep(fromAnimation = false) {
if (!fromAnimation && ui["history-next"].disabled) return false;
if (state.viewIndex < state.history.length - 1) { state.viewIndex += 1; renderView(); return true; }
state.busy = true; updateControls(); ui["history-next"].querySelector(".button-text").textContent = "Training…";
try {
if (state.previewPromise) await state.previewPromise;
if (!state.model || !state.pendingUpdate) throw new Error("The parameter update is not ready yet.");
const indices = state.nextBatch.slice();
const X = indices.map(i => [state.points[i].x, state.points[i].y]);
const y = indices.map(i => state.points[i].label);
const pyX = state.pyodide.toPy(X), pyY = state.pyodide.toPy(y);
state.pyodide.globals.set("batch_X", pyX); state.pyodide.globals.set("batch_y", pyY);
try {
const result = state.pyodide.runPython(`
model.train_step(batch_X, batch_y, classes=["A", "B"])
{"coef": model.coef_.tolist(), "intercept": model.intercept_.tolist()}
`);
state.model = result.toJs({ dict_converter: Object.fromEntries }); result.destroy();
} finally { pyX.destroy(); pyY.destroy(); state.pyodide.globals.delete("batch_X"); state.pyodide.globals.delete("batch_y"); }
state.step += 1;
queueNextBatch();
await refreshPreview(false);
setMessage(`Completed SGD step ${state.step}. The next update is shown.`, "success"); renderView();
return true;
} catch (error) { setMessage(error instanceof Error ? error.message : String(error), "error"); return false; }
finally { state.busy = false; ui["history-next"].querySelector(".button-text").textContent = "Next"; updateControls(); }
}
async function startAnimation() {
if (ui.animate.disabled) return;
state.animating = true; updateControls(); setMessage("Animation running…", "success");
while (state.animating) {
const succeeded = await trainOneStep(true);
if (!succeeded) { state.animating = false; break; }
await new Promise(resolve => setTimeout(resolve, Number(ui["animation-speed"].value)));
}
updateControls();
if (!state.animating && !ui.message.classList.contains("error")) setMessage("Animation paused.");
}
function pauseAnimation() { state.animating = false; updateControls(); }
function drawMetrics() {
const rect = metricsCanvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const ratio = window.devicePixelRatio || 1;
metricsCanvas.width = Math.round(rect.width * ratio); metricsCanvas.height = Math.round(rect.height * ratio);
metricsCtx.setTransform(ratio, 0, 0, ratio, 0, 0); metricsCtx.clearRect(0, 0, rect.width, rect.height);
const data = state.history.filter(snapshot => snapshot.metrics);
const pad = { left: 34, right: 34, top: 10, bottom: 22 };
const width = rect.width - pad.left - pad.right, height = rect.height - pad.top - pad.bottom;
const bottom = pad.top + height;
metricsCtx.font = "9px system-ui"; metricsCtx.fillStyle = "#77847d"; metricsCtx.strokeStyle = "#e4e9e5"; metricsCtx.lineWidth = 1;
for (let fraction = 0; fraction <= 1; fraction += 0.5) {
const y = pad.top + height * fraction; metricsCtx.beginPath(); metricsCtx.moveTo(pad.left,y); metricsCtx.lineTo(pad.left+width,y); metricsCtx.stroke();
}
if (!data.length) { metricsCtx.textAlign="center"; metricsCtx.fillText("Metrics appear after initialization",rect.width/2,rect.height/2); return; }
const maxLoss = Math.max(...data.map(snapshot => snapshot.metrics.loss), 1e-9) * 1.08;
const pointX = index => data.length === 1 ? pad.left + width / 2 : pad.left + index / (data.length - 1) * width;
const lossY = value => bottom - value / maxLoss * height;
const accuracyY = value => bottom - value * height;
metricsCtx.textAlign="right"; metricsCtx.fillStyle="#8b5cf6"; metricsCtx.fillText(maxLoss.toFixed(2),pad.left-5,pad.top+3); metricsCtx.fillText("0",pad.left-5,bottom+3);
metricsCtx.textAlign="left"; metricsCtx.fillStyle="#159267"; metricsCtx.fillText("100%",pad.left+width+5,pad.top+3); metricsCtx.fillText("0%",pad.left+width+5,bottom+3);
metricsCtx.textAlign="center"; metricsCtx.fillStyle="#77847d"; metricsCtx.fillText("step",pad.left+width/2,rect.height-3);
const drawSeries = (color, getY) => {
metricsCtx.strokeStyle=color; metricsCtx.lineWidth=2; metricsCtx.beginPath();
data.forEach((snapshot,index)=>{const x=pointX(index),y=getY(snapshot.metrics);if(index)metricsCtx.lineTo(x,y);else metricsCtx.moveTo(x,y);}); metricsCtx.stroke();
data.forEach((snapshot,index)=>{const x=pointX(index),y=getY(snapshot.metrics),active=index===state.viewIndex;metricsCtx.beginPath();metricsCtx.arc(x,y,active?5:2.5,0,Math.PI*2);metricsCtx.fillStyle=active?"white":color;metricsCtx.fill();metricsCtx.strokeStyle=color;metricsCtx.lineWidth=active?2.5:1;metricsCtx.stroke();});
};
drawSeries("#8b5cf6", metrics => lossY(metrics.loss));
drawSeries("#159267", metrics => accuracyY(metrics.accuracy));
}
function dimensions() {
const rect = canvas.getBoundingClientRect(), ratio = window.devicePixelRatio || 1;
if (canvas.width !== Math.round(rect.width * ratio) || canvas.height !== Math.round(rect.height * ratio)) { canvas.width = Math.round(rect.width * ratio); canvas.height = Math.round(rect.height * ratio); }
ctx.setTransform(ratio, 0, 0, ratio, 0, 0); return { width: rect.width, height: rect.height, pad: 40 };
}
function toPixel(x, y, d) { const sizeX = d.width - d.pad * 2, sizeY = d.height - d.pad * 2; return [d.pad + (x - RANGE.min) / 10 * sizeX, d.height - d.pad - (y - RANGE.min) / 10 * sizeY]; }
function fromPixel(px, py, d) { return [RANGE.min + (px - d.pad) / (d.width - d.pad * 2) * 10, RANGE.min + (d.height - d.pad - py) / (d.height - d.pad * 2) * 10]; }
function predictedClass(x, y, model) {
if (!model) return null;
const scoreA = model.coef[0][0] * x + model.coef[0][1] * y + model.intercept[0];
const scoreB = model.coef[1][0] * x + model.coef[1][1] * y + model.intercept[1];
return scoreA >= scoreB ? "A" : "B";
}
function draw() {
const snapshot = visibleSnapshot(), model = snapshot.model, step = snapshot.step;
const d = dimensions(); ctx.clearRect(0, 0, d.width, d.height);
const [left, bottom] = toPixel(RANGE.min, RANGE.min, d), [right, top] = toPixel(RANGE.max, RANGE.max, d);
ctx.fillStyle = "#fbfcfa"; ctx.fillRect(left, top, right - left, bottom - top);
if (model && step > 0) { const cells = 55, cellW = (right-left)/cells, cellH = (bottom-top)/cells; for (let row=0; row<cells; row++) for (let col=0; col<cells; col++) { const [x,y] = fromPixel(left+(col+.5)*cellW, top+(row+.5)*cellH,d); ctx.fillStyle=REGION[predictedClass(x,y,model)]; ctx.fillRect(left+col*cellW,top+row*cellH,cellW+1,cellH+1); } }
ctx.strokeStyle = "#e3e8e4"; ctx.lineWidth = 1; ctx.fillStyle = "#7b8881"; ctx.font = "10px system-ui";
for (let value=-4; value<=4; value+=2) { const [x] = toPixel(value,0,d), [,y]=toPixel(0,value,d); ctx.beginPath(); ctx.moveTo(x,top);ctx.lineTo(x,bottom);ctx.moveTo(left,y);ctx.lineTo(right,y);ctx.stroke(); ctx.fillText(String(value),x-5,bottom+17); ctx.fillText(String(value),left-23,y+3); }
const [axisX, axisY] = toPixel(0, 0, d);
ctx.strokeStyle="#718078"; ctx.fillStyle="#526159"; ctx.lineWidth=1.4;
ctx.beginPath(); ctx.moveTo(left,axisY); ctx.lineTo(right-1,axisY); ctx.moveTo(axisX,bottom); ctx.lineTo(axisX,top+1); ctx.stroke();
ctx.beginPath(); ctx.moveTo(right-1,axisY); ctx.lineTo(right-8,axisY-4); ctx.lineTo(right-8,axisY+4); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(axisX,top+1); ctx.lineTo(axisX-4,top+8); ctx.lineTo(axisX+4,top+8); ctx.closePath(); ctx.fill();
ctx.font="italic 12px Georgia, serif"; ctx.fillText("x₁",right-13,axisY-9); ctx.fillText("x₂",axisX+9,top+14);
ctx.strokeStyle="#aebbb3"; ctx.strokeRect(left,top,right-left,bottom-top);
if (step === 0 && hasBothClasses()) {
// Zero initialization has no unique separating hyperplane; use x = 0 as
// the visible reference boundary until the first gradient update.
const [x] = toPixel(0, 0, d);
ctx.save(); ctx.setLineDash([6, 6]); ctx.strokeStyle="#68766e"; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(x,top); ctx.lineTo(x,bottom); ctx.stroke(); ctx.restore();
}
else if (model) { const w0=model.coef[0][0]-model.coef[1][0], w1=model.coef[0][1]-model.coef[1][1], b=model.intercept[0]-model.intercept[1]; if (Math.abs(w1)>1e-10) { const y1=-(w0*RANGE.min+b)/w1,y2=-(w0*RANGE.max+b)/w1,[px1,py1]=toPixel(RANGE.min,y1,d),[px2,py2]=toPixel(RANGE.max,y2,d); ctx.save();ctx.beginPath();ctx.rect(left,top,right-left,bottom-top);ctx.clip();ctx.strokeStyle="#26342c";ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(px1,py1);ctx.lineTo(px2,py2);ctx.stroke();ctx.restore(); } else if(Math.abs(w0)>1e-10) { const x=-b/w0,[px]=toPixel(x,0,d);ctx.strokeStyle="#26342c";ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(px,top);ctx.lineTo(px,bottom);ctx.stroke(); } }
const selected = new Set(snapshot.nextBatch);
state.points.forEach((point, index) => {
const [x,y]=toPixel(point.x,point.y,d);
if (selected.has(index)) {
ctx.beginPath(); ctx.arc(x,y,11,0,Math.PI*2);
ctx.fillStyle="rgba(255, 202, 58, .28)"; ctx.fill();
ctx.strokeStyle="#d29400"; ctx.lineWidth=2; ctx.stroke();
}
ctx.beginPath();ctx.arc(x,y,6,0,Math.PI*2);ctx.fillStyle=COLORS[point.label];ctx.fill();ctx.strokeStyle="white";ctx.lineWidth=2;ctx.stroke();
});
if (state.hoveredPoint >= 0 && state.points[state.hoveredPoint]) {
const point = state.points[state.hoveredPoint], [x,y] = toPixel(point.x,point.y,d);
const text = `Class ${point.label} · (${point.x.toFixed(2)}, ${point.y.toFixed(2)})`;
ctx.font = "11px system-ui"; const width = ctx.measureText(text).width + 16;
const boxX = Math.min(Math.max(left + 4, x + 12), right - width - 4);
const boxY = y - 35 < top ? y + 13 : y - 35;
ctx.fillStyle = "rgba(31, 43, 36, .94)"; ctx.beginPath(); ctx.roundRect(boxX, boxY, width, 27, 5); ctx.fill();
ctx.fillStyle = "white"; ctx.fillText(text, boxX + 8, boxY + 18);
}
}
canvas.addEventListener("mousemove", event => {
const d=dimensions(), rect=canvas.getBoundingClientRect(), px=event.clientX-rect.left, py=event.clientY-rect.top;
let nearest=-1, distance=10;
state.points.forEach((point,index)=>{const [x,y]=toPixel(point.x,point.y,d), current=Math.hypot(px-x,py-y);if(current<distance){nearest=index;distance=current;}});
if (nearest !== state.hoveredPoint) { state.hoveredPoint=nearest; canvas.style.cursor=nearest>=0?"pointer":state.datasetMode==="custom"?"crosshair":"default"; draw(); }
});
canvas.addEventListener("mouseleave",()=>{if(state.hoveredPoint>=0){state.hoveredPoint=-1;canvas.style.cursor=state.datasetMode==="custom"?"crosshair":"default";draw();}});
canvas.addEventListener("click", event => { if (state.busy || state.animating || state.datasetMode !== "custom") return; const d=dimensions(), rect=canvas.getBoundingClientRect(), px=event.clientX-rect.left,py=event.clientY-rect.top; if(px<d.pad||px>d.width-d.pad||py<d.pad||py>d.height-d.pad)return; const [x,y]=fromPixel(px,py,d);state.points.push({x,y,label:state.label});state.redoPoints=[];state.hoveredPoint=-1;resetModel("Dataset changed. The model was reset."); });
ui["label-a"].addEventListener("click",()=>{state.label="A";ui["label-a"].classList.add("active");ui["label-b"].classList.remove("active");});
ui["label-b"].addEventListener("click",()=>{state.label="B";ui["label-b"].classList.add("active");ui["label-a"].classList.remove("active");});
ui.undo.addEventListener("click",()=>{const point=state.points.pop();if(point)state.redoPoints.push(point);state.hoveredPoint=-1;resetModel("Dataset changed. The model was reset.");});
ui.redo.addEventListener("click",()=>{const point=state.redoPoints.pop();if(point)state.points.push(point);resetModel("Point restored. The model was reset.");});
ui.clear.addEventListener("click",()=>{state.points=[];state.redoPoints=[];state.hoveredPoint=-1;resetModel("Dataset cleared.");});
function loadDataset(mode, message) {
ui["dataset-select"].value = mode;
state.datasetMode = mode;
ui["custom-data-controls"].hidden = state.datasetMode !== "custom";
state.points = state.datasetMode === "custom" ? [] : structuredClone(BUILTIN_DATASETS[state.datasetMode]);
state.redoPoints = []; state.hoveredPoint = -1;
canvas.style.cursor = state.datasetMode === "custom" ? "crosshair" : "default";
resetModel(message || (state.datasetMode === "custom" ? "Click the plot to create a custom dataset." : "Builtin dataset loaded. The model was reset."));
}
ui["dataset-select"].addEventListener("change", () => loadDataset(ui["dataset-select"].value));
function selectTab(name) {
for (const tab of ["configure", "train", "examples", "usage"]) {
const active = tab === name;
ui[`${tab}-tab`].classList.toggle("active", active);
ui[`${tab}-panel`].classList.toggle("active", active);
}
if (name === "train") requestAnimationFrame(drawMetrics);
}
ui["configure-tab"].addEventListener("click", () => selectTab("configure"));
ui["train-tab"].addEventListener("click", () => selectTab("train"));
ui["examples-tab"].addEventListener("click", () => selectTab("examples"));
ui["usage-tab"].addEventListener("click", () => selectTab("usage"));
for (const button of document.querySelectorAll(".example-button")) {
button.addEventListener("click", () => {
pauseAnimation();
ui.loss.value = button.dataset.loss || "perceptron";
ui["learning-rate"].value = button.dataset.learningRate || "1";
ui["batch-size"].value = "1";
ui["batch-selection"].value = "random"; ui["random-seed"].value = "42"; ui["animation-speed"].value = "200";
ui["sampling-help"].textContent = "Each point is used once before the dataset is shuffled again.";
loadDataset(button.dataset.dataset, `${button.textContent.trim()} example loaded.`);
selectTab("train");
});
}
ui["history-next"].addEventListener("click", trainOneStep);
ui["history-prev"].addEventListener("click", () => { if (state.viewIndex > 0) { state.viewIndex -= 1; renderView(); } });
ui["history-reset"].addEventListener("click", () => { if (state.history.length) { state.viewIndex = 0; renderView(); } });
ui.animate.addEventListener("click", startAnimation); ui.pause.addEventListener("click", pauseAnimation);
for (const id of ["loss","learning-rate","batch-size","random-seed","batch-selection"]) ui[id].addEventListener("change",()=>{if(id==="batch-selection")ui["sampling-help"].textContent=ui[id].value==="random"?"Each step independently samples a subset without replacement.":"Each point is used once before the dataset is shuffled again.";resetModel("Configuration changed. The model was reset.");});
window.addEventListener("keydown",event=>{if(event.code==="Space"&&!event.repeat&&!/INPUT|SELECT|TEXTAREA/.test(document.activeElement.tagName)){event.preventDefault();trainOneStep();}});
new ResizeObserver(draw).observe(canvas.parentElement); new ResizeObserver(drawMetrics).observe(metricsCanvas); queueNextBatch(); updateControls(); draw(); initializeRuntime();
|