abril4416
Rename logistic cost labels to cost function wording
055a863
Raw
History Blame Contribute Delete
39.5 kB
const plotWrap = document.getElementById("landscapePlotWrap");
const plotEl = document.getElementById("landscapePlot");
const classifierCanvas = document.getElementById("classifierCanvas");
const classifierCtx = classifierCanvas.getContext("2d");
const controls = {
caseSelect: document.getElementById("caseSelect"),
lr: document.getElementById("lr"),
lrInput: document.getElementById("lrInput"),
iters: document.getElementById("iters"),
p1: document.getElementById("startP1"),
p2: document.getElementById("startP2"),
speed: document.getElementById("speed"),
runBtn: document.getElementById("runBtn"),
stepBtn: document.getElementById("stepBtn"),
autoBtn: document.getElementById("autoBtn"),
restartBtn: document.getElementById("restartBtn"),
regenBtn: document.getElementById("regenBtn"),
clearBtn: document.getElementById("clearBtn"),
landscapeView: document.getElementById("landscapeView"),
resetViewBtn: document.getElementById("resetViewBtn"),
plotSmallerBtn: document.getElementById("plotSmallerBtn"),
plotFitBtn: document.getElementById("plotFitBtn"),
plotLargerBtn: document.getElementById("plotLargerBtn"),
plotFullscreenBtn: document.getElementById("plotFullscreenBtn"),
toggleSurface: document.getElementById("toggleSurface"),
toggleContours: document.getElementById("toggleContours"),
toggleTrajectory: document.getElementById("toggleTrajectory"),
toggleCurrent: document.getElementById("toggleCurrent"),
stepSlider: document.getElementById("landscapeStep"),
};
const labels = {
formula: document.getElementById("formulaText"),
lr: document.getElementById("lrValue"),
iters: document.getElementById("iterValue"),
p1: document.getElementById("p1Value"),
p2: document.getElementById("p2Value"),
speed: document.getElementById("speedValue"),
stepLabel: document.getElementById("landscapeStepLabel"),
};
const modelDefinition = document.getElementById("modelDefinition");
const landscapeSummary = document.getElementById("landscapeSummary");
const stats = document.getElementById("costStats");
const clipInfo = document.getElementById("clipInfo");
const minimumInfo = document.getElementById("minimumInfo");
const stabilityWarning = document.getElementById("stabilityWarning");
const DEFAULT_CAMERA = {
eye: { x: 1.5, y: 1.5, z: 1.1 },
center: { x: 0, y: 0, z: 0 },
up: { x: 0, y: 0, z: 1 },
};
const RESIZE_EPSILON = 1;
const DEBUG_RESIZE = typeof window !== "undefined" && new URLSearchParams(window.location.search).has("debugResize");
const GRID_N = 60;
const MIN_SPAN = 2.0;
const caseMeta = {
case1: {
p1Name: "b0",
p2Name: "b1",
p1Label: "b0 (intercept)",
p2Label: "b1 (weight for x)",
model: "z = b0 + b1*x",
explain: [
"Cost landscape: J(b0, b1)",
"b0 = intercept, b1 = coefficient of x",
],
formula: "Case 1 logistic: p(y=1|x) = 1 / (1 + exp(-(b0 + b1x)))",
},
case2: {
p1Name: "w1",
p2Name: "w2",
p1Label: "w1 (weight for x1)",
p2Label: "w2 (weight for x2)",
model: "z = w1*x1 + w2*x2",
explain: [
"Cost landscape: J(w1, w2)",
"w1 = coefficient of x1, w2 = coefficient of x2",
"No intercept is used in this model.",
],
formula: "Case 2 logistic: p(y=1|x1,x2) = 1 / (1 + exp(-(w1x1 + w2x2)))",
},
};
const state = {
datasets: { case1: [], case2: [] },
dataVersion: 0,
trajectory: [],
stepIndex: 0,
timer: null,
unstableWarning: "",
gridCache: { case1: null, case2: null },
plotReady: false,
camera3d: null,
resizeRaf: null,
lastPlotSize: null,
};
function debugResizeLog(source, extra = {}) {
if (!DEBUG_RESIZE) return;
const wrapRect = plotWrap.getBoundingClientRect();
const parent = plotWrap.parentElement;
const parentRect = parent ? parent.getBoundingClientRect() : null;
const docEl = document.documentElement;
const payload = {
source,
wrapClient: `${plotWrap.clientWidth}x${plotWrap.clientHeight}`,
wrapRect: `${wrapRect.width.toFixed(2)}x${wrapRect.height.toFixed(2)}`,
parentRect: parentRect ? `${parentRect.width.toFixed(2)}x${parentRect.height.toFixed(2)}` : "n/a",
win: `${window.innerWidth}x${window.innerHeight}`,
viewport: `${docEl.clientWidth}x${docEl.clientHeight}`,
scroll: `${docEl.scrollWidth}x${docEl.scrollHeight}`,
bodyScrollY: window.scrollY,
hasVScrollbar: docEl.scrollHeight > docEl.clientHeight,
hasHScrollbar: docEl.scrollWidth > docEl.clientWidth,
...extra,
};
console.debug("[logistic-resize]", payload);
}
function hasPlotly() {
return typeof window !== "undefined" && typeof window.Plotly !== "undefined";
}
function showPlotFallback(message) {
plotEl.innerHTML = `<div class="plot-fallback">${message}</div>`;
}
function sigmoid(z) {
if (z >= 0) {
const e = Math.exp(-z);
return 1 / (1 + e);
}
const e = Math.exp(z);
return e / (1 + e);
}
function softplus(z) {
if (z > 35) return z;
if (z < -35) return Math.exp(z);
return Math.log1p(Math.exp(z));
}
function randNormal(mean = 0, std = 1) {
const u1 = Math.max(1e-12, Math.random());
const u2 = Math.random();
const z0 = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
return mean + z0 * std;
}
function percentile(sortedValues, q) {
if (sortedValues.length === 0) return NaN;
const pos = (sortedValues.length - 1) * q;
const lo = Math.floor(pos);
const hi = Math.min(sortedValues.length - 1, lo + 1);
const t = pos - lo;
return sortedValues[lo] * (1 - t) + sortedValues[hi] * t;
}
function cloneCamera(camera) {
return {
eye: { ...camera.eye },
center: { ...camera.center },
up: { ...camera.up },
};
}
function formatCost(v) {
if (!Number.isFinite(v)) return "--";
if (Math.abs(v) < 1) return v.toFixed(4);
if (Math.abs(v) < 100) return v.toFixed(3);
return v.toFixed(2);
}
function generateSyntheticData() {
const case1 = [];
const case2 = [];
const trueB0 = -0.15;
const trueB1 = 1.05;
for (let i = 0; i < 160; i += 1) {
const center = i < 80 ? -1.5 : 1.5;
const x = randNormal(center, 1.9);
const p = sigmoid(trueB0 + trueB1 * x);
const y = Math.random() < p ? 1 : 0;
case1.push({ x, y });
}
const trueW1 = 1.1;
const trueW2 = 0.95;
for (let i = 0; i < 200; i += 1) {
const center1 = i < 100 ? -1.4 : 1.4;
const center2 = i < 100 ? -1.2 : 1.2;
const x1 = randNormal(center1, 1.7);
const x2 = randNormal(center2 + 0.25 * (x1 - center1), 1.55);
const p = sigmoid(trueW1 * x1 + trueW2 * x2);
const y = Math.random() < p ? 1 : 0;
case2.push({ x1, x2, y });
}
state.datasets = { case1, case2 };
state.dataVersion += 1;
state.gridCache = { case1: null, case2: null };
}
function costFor(caseKey, params) {
const data = state.datasets[caseKey];
let total = 0;
if (caseKey === "case1") {
const [b0, b1] = params;
for (const row of data) {
const z = b0 + b1 * row.x;
total += softplus(z) - row.y * z;
}
} else {
const [w1, w2] = params;
for (const row of data) {
const z = w1 * row.x1 + w2 * row.x2;
total += softplus(z) - row.y * z;
}
}
return total / data.length;
}
function gradFor(caseKey, params) {
const data = state.datasets[caseKey];
let g1 = 0;
let g2 = 0;
if (caseKey === "case1") {
const [b0, b1] = params;
for (const row of data) {
const p = sigmoid(b0 + b1 * row.x);
const diff = p - row.y;
g1 += diff;
g2 += diff * row.x;
}
} else {
const [w1, w2] = params;
for (const row of data) {
const p = sigmoid(w1 * row.x1 + w2 * row.x2);
const diff = p - row.y;
g1 += diff * row.x1;
g2 += diff * row.x2;
}
}
const m = data.length;
return [g1 / m, g2 / m];
}
function estimateAnchorPoint(caseKey) {
let params = [0, 0];
const lr = 0.1;
for (let i = 0; i < 220; i += 1) {
const grad = gradFor(caseKey, params);
const next = [params[0] - lr * grad[0], params[1] - lr * grad[1]];
if (!Number.isFinite(next[0]) || !Number.isFinite(next[1])) break;
params = next;
}
return params;
}
function buildTrajectory(caseKey, start, lr, iters) {
const path = [];
let params = [start[0], start[1]];
let unstable = false;
path.push({ step: 0, p1: params[0], p2: params[1], cost: costFor(caseKey, params), dCost: 0 });
for (let i = 0; i < iters; i += 1) {
const grad = gradFor(caseKey, params);
const next = [params[0] - lr * grad[0], params[1] - lr * grad[1]];
if (!Number.isFinite(next[0]) || !Number.isFinite(next[1])) {
unstable = true;
break;
}
const nextCost = costFor(caseKey, next);
if (!Number.isFinite(nextCost) || nextCost > 1e6) {
unstable = true;
break;
}
const prevCost = path[path.length - 1].cost;
params = next;
path.push({ step: i + 1, p1: params[0], p2: params[1], cost: nextCost, dCost: nextCost - prevCost });
}
return { path, unstable };
}
function getTrajectoryBounds(caseKey) {
const anchor = estimateAnchorPoint(caseKey);
const p1s = [anchor[0], Number(controls.p1.value)];
const p2s = [anchor[1], Number(controls.p2.value)];
for (const s of state.trajectory) {
p1s.push(s.p1);
p2s.push(s.p2);
}
let p1Min = Math.min(...p1s);
let p1Max = Math.max(...p1s);
let p2Min = Math.min(...p2s);
let p2Max = Math.max(...p2s);
let span1 = p1Max - p1Min;
let span2 = p2Max - p2Min;
if (span1 < MIN_SPAN) {
const add = (MIN_SPAN - span1) / 2;
p1Min -= add;
p1Max += add;
span1 = MIN_SPAN;
}
if (span2 < MIN_SPAN) {
const add = (MIN_SPAN - span2) / 2;
p2Min -= add;
p2Max += add;
span2 = MIN_SPAN;
}
const pad1 = Math.max(0.4 * span1, 0.9);
const pad2 = Math.max(0.4 * span2, 0.9);
return {
p1Min: p1Min - pad1,
p1Max: p1Max + pad1,
p2Min: p2Min - pad2,
p2Max: p2Max + pad2,
};
}
function getGridCacheKey(caseKey) {
const first = state.trajectory[0];
const last = state.trajectory[state.trajectory.length - 1];
return [
state.dataVersion,
caseKey,
controls.p1.value,
controls.p2.value,
state.trajectory.length,
first ? `${first.p1.toFixed(4)}:${first.p2.toFixed(4)}` : "none",
last ? `${last.p1.toFixed(4)}:${last.p2.toFixed(4)}` : "none",
].join("|");
}
function buildGrid(caseKey) {
const bounds = getTrajectoryBounds(caseKey);
const p1Values = [];
const p2Values = [];
for (let i = 0; i < GRID_N; i += 1) {
p1Values.push(bounds.p1Min + (i / (GRID_N - 1)) * (bounds.p1Max - bounds.p1Min));
p2Values.push(bounds.p2Min + (i / (GRID_N - 1)) * (bounds.p2Max - bounds.p2Min));
}
const vals = [];
const flat = [];
let min = Infinity;
let max = -Infinity;
let minI = 0;
let minJ = 0;
for (let j = 0; j < GRID_N; j += 1) {
const row = [];
const p2 = p2Values[j];
for (let i = 0; i < GRID_N; i += 1) {
const p1 = p1Values[i];
const c = costFor(caseKey, [p1, p2]);
row.push(c);
flat.push(c);
if (c < min) {
min = c;
minI = i;
minJ = j;
}
if (c > max) max = c;
}
vals.push(row);
}
const sorted = flat.slice().sort((a, b) => a - b);
let clipMax = percentile(sorted, 0.975);
if (!Number.isFinite(clipMax)) clipMax = max;
clipMax = Math.min(max, Math.max(min + 1e-6, clipMax));
return {
key: "",
p1Values,
p2Values,
vals,
min,
max,
clipMax,
p1Min: bounds.p1Min,
p1Max: bounds.p1Max,
p2Min: bounds.p2Min,
p2Max: bounds.p2Max,
minPoint: {
p1: p1Values[minI],
p2: p2Values[minJ],
cost: min,
},
};
}
function getGrid(caseKey) {
const key = getGridCacheKey(caseKey);
const cached = state.gridCache[caseKey];
if (cached && cached.key === key) return cached;
const built = buildGrid(caseKey);
built.key = key;
state.gridCache[caseKey] = built;
return built;
}
function collectEdgeHit(bucket, a, b, level) {
const da = a.z - level;
const db = b.z - level;
if (da === 0 && db === 0) return;
if (da * db > 0) return;
const den = b.z - a.z;
const t = Math.abs(den) < 1e-12 ? 0.5 : (level - a.z) / den;
if (t < 0 || t > 1) return;
bucket.push({
x: a.x + t * (b.x - a.x),
y: a.y + t * (b.y - a.y),
});
}
function buildContourSegments3D(grid) {
const xs = [];
const ys = [];
const zs = [];
const levels = 9;
const zFloor = grid.min;
for (let levelIdx = 1; levelIdx <= levels; levelIdx += 1) {
const level = grid.min + (levelIdx / (levels + 1)) * (grid.clipMax - grid.min);
for (let j = 0; j < GRID_N - 1; j += 1) {
for (let i = 0; i < GRID_N - 1; i += 1) {
const p00 = { x: grid.p1Values[i], y: grid.p2Values[j], z: grid.vals[j][i] };
const p10 = { x: grid.p1Values[i + 1], y: grid.p2Values[j], z: grid.vals[j][i + 1] };
const p11 = { x: grid.p1Values[i + 1], y: grid.p2Values[j + 1], z: grid.vals[j + 1][i + 1] };
const p01 = { x: grid.p1Values[i], y: grid.p2Values[j + 1], z: grid.vals[j + 1][i] };
const hits = [];
collectEdgeHit(hits, p00, p10, level);
collectEdgeHit(hits, p10, p11, level);
collectEdgeHit(hits, p11, p01, level);
collectEdgeHit(hits, p01, p00, level);
if (hits.length === 2) {
xs.push(hits[0].x, hits[1].x, null);
ys.push(hits[0].y, hits[1].y, null);
zs.push(zFloor, zFloor, null);
} else if (hits.length === 4) {
xs.push(hits[0].x, hits[1].x, null, hits[2].x, hits[3].x, null);
ys.push(hits[0].y, hits[1].y, null, hits[2].y, hits[3].y, null);
zs.push(zFloor, zFloor, null, zFloor, zFloor, null);
}
}
}
}
return { x: xs, y: ys, z: zs };
}
function buildHover(meta, point) {
return [
`Iteration: ${point.step}`,
`${meta.p1Name}: ${point.p1.toFixed(4)}`,
`${meta.p2Name}: ${point.p2.toFixed(4)}`,
`Cost function: ${point.cost.toFixed(4)}`,
].join("<br>");
}
function getFitSize() {
const host = plotWrap.parentElement;
const hostW = host ? host.getBoundingClientRect().width : window.innerWidth;
const maxW = Math.max(320, Math.min(1000, hostW - 4));
if (window.innerWidth < 680) return { width: maxW, height: 390 };
if (window.innerWidth < 1024) return { width: Math.min(maxW, 920), height: 560 };
return { width: Math.min(maxW, 980), height: 640 };
}
function setPlotSize(rawW, rawH, forceResize = false) {
const host = plotWrap.parentElement;
const hostMaxW = host ? host.getBoundingClientRect().width - 4 : rawW;
const inFullscreen = document.fullscreenElement === plotWrap;
const minW = 320;
const minH = 300;
const maxW = inFullscreen ? Math.min(1800, window.innerWidth - 24) : Math.max(minW, hostMaxW);
const maxH = inFullscreen ? Math.min(1200, window.innerHeight - 24) : Math.min(900, window.innerHeight - 140);
const w = Math.round(Math.max(minW, Math.min(maxW, rawW)));
const h = Math.round(Math.max(minH, Math.min(maxH, rawH)));
const rect = plotWrap.getBoundingClientRect();
const widthChanged = Math.abs(Math.round(rect.width) - w) > RESIZE_EPSILON;
const heightChanged = Math.abs(Math.round(rect.height) - h) > RESIZE_EPSILON;
if (widthChanged || heightChanged) {
plotWrap.style.width = `${w}px`;
plotWrap.style.height = `${h}px`;
debugResizeLog("setPlotSize.apply", { width: w, height: h, forceResize });
}
if (forceResize || widthChanged || heightChanged) {
queuePlotResize(true);
}
}
function fitPlotSize(forceResize = false) {
const size = getFitSize();
setPlotSize(size.width, size.height, forceResize);
}
function queuePlotResize(force = false) {
if (!state.plotReady || !hasPlotly()) return;
const width = Math.round(plotWrap.clientWidth);
const height = Math.round(plotWrap.clientHeight);
if (width <= 0 || height <= 0) return;
if (!force && state.lastPlotSize) {
const dw = Math.abs(width - state.lastPlotSize.width);
const dh = Math.abs(height - state.lastPlotSize.height);
if (dw <= RESIZE_EPSILON && dh <= RESIZE_EPSILON) return;
}
state.lastPlotSize = { width, height };
debugResizeLog("queuePlotResize", { width, height, force });
if (state.resizeRaf) {
cancelAnimationFrame(state.resizeRaf);
}
state.resizeRaf = requestAnimationFrame(() => {
state.resizeRaf = null;
debugResizeLog("Plotly.relayout(size)", { width, height });
window.Plotly.relayout(plotEl, { width, height });
});
}
function toggleFullscreen() {
if (document.fullscreenElement === plotWrap) {
document.exitFullscreen();
return;
}
if (plotWrap.requestFullscreen) plotWrap.requestFullscreen();
}
function updateStepSlider() {
const max = Math.max(0, state.trajectory.length - 1);
controls.stepSlider.max = String(max);
controls.stepSlider.value = String(Math.min(state.stepIndex, max));
labels.stepLabel.textContent = `${Math.min(state.stepIndex, max)} / ${max}`;
}
function updateModelDefinition(caseKey) {
const meta = caseMeta[caseKey];
modelDefinition.innerHTML = `
<p><strong>Model:</strong> ${meta.model}</p>
<p>${meta.explain[0]}</p>
<p>${meta.explain[1]}</p>
${meta.explain[2] ? `<p>${meta.explain[2]}</p>` : ""}
`;
}
function updateSummary(caseKey) {
const meta = caseMeta[caseKey];
if (state.trajectory.length === 0) {
landscapeSummary.innerHTML = `
<span class="landscape-pill">Start cost: --</span>
<span class="landscape-pill">Current cost: --</span>
<span class="landscape-pill">Final cost: --</span>
<span class="landscape-pill">Reduction: --</span>
<span class="landscape-pill">Iterations: 0 / 0</span>
`;
return;
}
const idx = Math.min(state.stepIndex, state.trajectory.length - 1);
const start = state.trajectory[0];
const current = state.trajectory[idx];
const final = state.trajectory[state.trajectory.length - 1];
const reduction = ((start.cost - final.cost) / Math.max(1e-9, start.cost)) * 100;
landscapeSummary.innerHTML = `
<span class="landscape-pill">Start cost: ${start.cost.toFixed(4)}</span>
<span class="landscape-pill">Current cost: ${current.cost.toFixed(4)}</span>
<span class="landscape-pill">Final cost: ${final.cost.toFixed(4)}</span>
<span class="landscape-pill">Reduction: ${reduction.toFixed(1)}%</span>
<span class="landscape-pill">Iterations: ${idx} / ${state.trajectory.length - 1}</span>
<span class="landscape-pill">${meta.p1Name}: ${current.p1.toFixed(3)}, ${meta.p2Name}: ${current.p2.toFixed(3)}</span>
`;
}
function updateInfoChips(caseKey, grid) {
const meta = caseMeta[caseKey];
if (grid.clipMax < grid.max - 1e-9) {
clipInfo.textContent = `Surface clipping (visual only): J <= ${formatCost(grid.clipMax)} (true max ${formatCost(grid.max)})`;
} else {
clipInfo.textContent = "Surface clipping: none";
}
minimumInfo.textContent = `Lowest shown point: ${meta.p1Name}=${grid.minPoint.p1.toFixed(3)}, ${meta.p2Name}=${grid.minPoint.p2.toFixed(3)}, J=${grid.minPoint.cost.toFixed(4)}`;
if (state.unstableWarning) {
stabilityWarning.textContent = state.unstableWarning;
stabilityWarning.classList.add("warning");
} else {
stabilityWarning.textContent = "Stability: normal";
stabilityWarning.classList.remove("warning");
}
}
function renderStats(caseKey) {
const meta = caseMeta[caseKey];
const m = state.datasets[caseKey].length;
if (state.trajectory.length === 0) {
stats.innerHTML = `
<span>Model case: ${meta.p1Name}, ${meta.p2Name}</span>
<span>Samples: ${m}</span>
<span>No trajectory yet. Click Run Gradient Descent.</span>
<span>Current step: 0</span>
<span>${meta.p1Label}: --</span>
<span>${meta.p2Label}: --</span>
`;
return;
}
const idx = Math.min(state.stepIndex, state.trajectory.length - 1);
const current = state.trajectory[idx];
stats.innerHTML = `
<span>Model case: ${meta.p1Name}, ${meta.p2Name}</span>
<span>Samples: ${m}</span>
<span>Current step: ${idx}</span>
<span>Cost function: ${current.cost.toFixed(4)}</span>
<span>${meta.p1Label}: ${current.p1.toFixed(4)}</span>
<span>${meta.p2Label}: ${current.p2.toFixed(4)}</span>
`;
}
function renderLandscape(caseKey, grid) {
if (!hasPlotly()) {
showPlotFallback("Interactive 3D view unavailable (Plotly not loaded). Other page controls remain active.");
state.plotReady = false;
return;
}
const meta = caseMeta[caseKey];
const view = controls.landscapeView.value;
const idx = Math.min(state.stepIndex, Math.max(0, state.trajectory.length - 1));
const plotWidth = Math.round(plotWrap.clientWidth);
const plotHeight = Math.round(plotWrap.clientHeight);
const showSurface = controls.toggleSurface.checked;
const showContours = controls.toggleContours.checked;
const showTrajectory = controls.toggleTrajectory.checked;
const showCurrent = controls.toggleCurrent.checked;
const fullPath = state.trajectory;
const current = fullPath[idx];
const start = fullPath[0];
const final = fullPath[fullPath.length - 1];
const traces = [];
if (view === "contour2d") {
traces.push({
type: "contour",
x: grid.p1Values,
y: grid.p2Values,
z: grid.vals,
ncontours: 16,
contours: {
coloring: showSurface ? "heatmap" : "lines",
},
colorscale: "Viridis",
showscale: showSurface,
visible: showSurface || showContours,
colorbar: { title: "Cost function", thickness: 14 },
hovertemplate: `${meta.p1Name}: %{x:.4f}<br>${meta.p2Name}: %{y:.4f}<br>Cost function: %{z:.4f}<extra></extra>`,
});
if (showTrajectory && fullPath.length > 0) {
traces.push({
type: "scatter",
mode: "lines+markers",
x: fullPath.map((p) => p.p1),
y: fullPath.map((p) => p.p2),
customdata: fullPath.map((p) => p.step),
line: { color: "#d8534f", width: 3 },
marker: { size: 4, color: "#9a3412" },
text: fullPath.map((p) => buildHover(meta, p)),
hovertemplate: "%{text}<extra></extra>",
});
traces.push({
type: "scatter",
mode: "markers+text",
x: [start.p1],
y: [start.p2],
customdata: [start.step],
marker: { size: 10, color: "#0a8f7b", line: { color: "#102a32", width: 1 } },
text: ["Start"],
textposition: "top center",
hovertemplate: `${buildHover(meta, start)}<extra></extra>`,
});
traces.push({
type: "scatter",
mode: "markers+text",
x: [final.p1],
y: [final.p2],
customdata: [final.step],
marker: { size: 10, color: "#dd5e2f", line: { color: "#102a32", width: 1 } },
text: ["Current solution"],
textposition: "top center",
hovertemplate: `${buildHover(meta, final)}<extra></extra>`,
});
}
if (showCurrent && fullPath.length > 0) {
traces.push({
type: "scatter",
mode: "markers+text",
x: [current.p1],
y: [current.p2],
customdata: [current.step],
marker: { size: 10, color: "#111827", line: { color: "#f2b84b", width: 2 } },
text: [`Step ${idx}`],
textposition: "bottom center",
hovertemplate: `${buildHover(meta, current)}<extra></extra>`,
});
}
const layout2d = {
autosize: false,
width: plotWidth,
height: plotHeight,
margin: { l: 58, r: 16, t: 8, b: 50 },
xaxis: { title: meta.p1Label },
yaxis: { title: meta.p2Label },
showlegend: false,
uirevision: "logistic-contour",
paper_bgcolor: "#fff",
plot_bgcolor: "#fff",
};
const config2d = {
responsive: false,
displaylogo: false,
scrollZoom: true,
modeBarButtonsToRemove: ["lasso2d", "select2d"],
};
const p2d = state.plotReady
? window.Plotly.react(plotEl, traces, layout2d, config2d)
: window.Plotly.newPlot(plotEl, traces, layout2d, config2d);
p2d.then(() => {
if (!state.plotReady) {
state.plotReady = true;
state.lastPlotSize = null;
bindPlotClick();
queuePlotResize(true);
}
});
return;
}
const clippedSurface = grid.vals.map((row) => row.map((v) => Math.min(v, grid.clipMax)));
traces.push({
type: "surface",
x: grid.p1Values,
y: grid.p2Values,
z: clippedSurface,
opacity: 0.84,
colorscale: "Viridis",
showscale: showSurface,
visible: showSurface,
colorbar: { title: "Cost function", thickness: 14 },
hovertemplate: `${meta.p1Name}: %{x:.4f}<br>${meta.p2Name}: %{y:.4f}<br>Cost function: %{z:.4f}<extra></extra>`,
});
const contour3d = buildContourSegments3D(grid);
traces.push({
type: "scatter3d",
mode: "lines",
x: contour3d.x,
y: contour3d.y,
z: contour3d.z,
line: { color: "rgba(16,54,76,0.7)", width: 3 },
visible: showContours || view === "surface3dContours",
hoverinfo: "skip",
});
if (showTrajectory && fullPath.length > 0) {
traces.push({
type: "scatter3d",
mode: "lines+markers",
x: fullPath.map((p) => p.p1),
y: fullPath.map((p) => p.p2),
z: fullPath.map((p) => Math.min(p.cost, grid.clipMax)),
customdata: fullPath.map((p) => p.step),
line: { color: "#d8534f", width: 6 },
marker: { size: 4, color: "#9a3412" },
text: fullPath.map((p) => buildHover(meta, p)),
hovertemplate: "%{text}<extra></extra>",
});
traces.push({
type: "scatter3d",
mode: "markers+text",
x: [start.p1],
y: [start.p2],
z: [Math.min(start.cost, grid.clipMax)],
customdata: [start.step],
marker: { size: 10, color: "#0a8f7b", line: { color: "#102a32", width: 1 } },
text: ["Start"],
textposition: "top center",
hovertemplate: `${buildHover(meta, start)}<extra></extra>`,
});
traces.push({
type: "scatter3d",
mode: "markers+text",
x: [final.p1],
y: [final.p2],
z: [Math.min(final.cost, grid.clipMax)],
customdata: [final.step],
marker: { size: 10, color: "#dd5e2f", line: { color: "#102a32", width: 1 } },
text: ["Current solution"],
textposition: "top center",
hovertemplate: `${buildHover(meta, final)}<extra></extra>`,
});
}
if (showCurrent && fullPath.length > 0) {
traces.push({
type: "scatter3d",
mode: "markers+text",
x: [current.p1],
y: [current.p2],
z: [Math.min(current.cost, grid.clipMax)],
customdata: [current.step],
marker: { size: 9, color: "#111827", line: { color: "#f2b84b", width: 2 } },
text: [`Step ${idx}`],
textposition: "bottom center",
hovertemplate: `${buildHover(meta, current)}<extra></extra>`,
});
}
const layout3d = {
autosize: false,
width: plotWidth,
height: plotHeight,
margin: { l: 0, r: 0, t: 8, b: 0 },
scene: {
xaxis: { title: meta.p1Label, range: [grid.p1Min, grid.p1Max], showspikes: false },
yaxis: { title: meta.p2Label, range: [grid.p2Min, grid.p2Max], showspikes: false },
zaxis: { title: "Cost function J(theta)", range: [grid.min, grid.clipMax], showspikes: false },
aspectmode: "manual",
aspectratio: { x: 1.14, y: 1.12, z: 0.82 },
camera: state.camera3d || cloneCamera(DEFAULT_CAMERA),
dragmode: "turntable",
},
showlegend: false,
uirevision: "logistic-3d",
paper_bgcolor: "#fff",
};
const config3d = {
responsive: false,
displaylogo: false,
scrollZoom: true,
doubleClick: "reset",
modeBarButtonsToRemove: ["lasso2d", "select2d"],
};
const p3d = state.plotReady
? window.Plotly.react(plotEl, traces, layout3d, config3d)
: window.Plotly.newPlot(plotEl, traces, layout3d, config3d);
p3d.then(() => {
if (!state.plotReady) {
state.plotReady = true;
state.lastPlotSize = null;
bindPlotClick();
plotEl.on("plotly_relayout", (ev) => {
if (ev && ev["scene.camera"]) {
state.camera3d = cloneCamera(ev["scene.camera"]);
}
debugResizeLog("plotly_relayout", {
hasCamera: !!(ev && ev["scene.camera"]),
});
});
queuePlotResize(true);
}
});
}
function bindPlotClick() {
if (!plotEl || !plotEl.on) return;
plotEl.on("plotly_click", (ev) => {
if (!ev || !ev.points || ev.points.length === 0) return;
const pt = ev.points[0];
const raw = pt.customdata;
let step = null;
if (typeof raw === "number") step = raw;
if (Array.isArray(raw) && typeof raw[0] === "number") step = raw[0];
if (Number.isInteger(step) && step >= 0 && step < state.trajectory.length) {
state.stepIndex = step;
render();
}
});
}
function drawClassifier(caseKey) {
const parent = classifierCanvas.parentElement;
const width = Math.max(320, Math.round(parent.getBoundingClientRect().width));
const height = 310;
if (classifierCanvas.width !== width || classifierCanvas.height !== height) {
classifierCanvas.width = width;
classifierCanvas.height = height;
}
const ctx = classifierCtx;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, height);
const pad = 42;
if (state.trajectory.length === 0) {
ctx.fillStyle = "#2a3d46";
ctx.font = "14px 'Avenir Next', sans-serif";
ctx.fillText("Run Gradient Descent to view classifier changes by iteration.", 22, 42);
return;
}
const idx = Math.min(state.stepIndex, state.trajectory.length - 1);
const step = state.trajectory[idx];
const data = state.datasets[caseKey];
if (caseKey === "case1") {
const xs = data.map((d) => d.x);
const xMin = Math.min(...xs) - 0.6;
const xMax = Math.max(...xs) + 0.6;
const toX = (x) => pad + ((x - xMin) / (xMax - xMin || 1)) * (width - 2 * pad);
const toY = (p) => height - pad - p * (height - 2 * pad);
ctx.strokeStyle = "#7b8f98";
ctx.lineWidth = 1.1;
ctx.beginPath();
ctx.moveTo(pad, height - pad);
ctx.lineTo(width - pad, height - pad);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(pad, height - pad);
ctx.lineTo(pad, pad);
ctx.stroke();
ctx.setLineDash([5, 4]);
ctx.strokeStyle = "#8aa0ad";
ctx.beginPath();
ctx.moveTo(pad, toY(0.5));
ctx.lineTo(width - pad, toY(0.5));
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "#0a8f7b";
for (const row of data) {
const yJ = row.y === 1 ? 0.93 : 0.07;
ctx.beginPath();
ctx.arc(toX(row.x), toY(yJ), 3.4, 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = "#d8534f";
ctx.lineWidth = 2.6;
ctx.beginPath();
for (let i = 0; i <= 120; i += 1) {
const x = xMin + (i / 120) * (xMax - xMin);
const p = sigmoid(step.p1 + step.p2 * x);
const sx = toX(x);
const sy = toY(p);
if (i === 0) ctx.moveTo(sx, sy);
else ctx.lineTo(sx, sy);
}
ctx.stroke();
if (Math.abs(step.p2) > 1e-9) {
const xb = -step.p1 / step.p2;
if (xb >= xMin && xb <= xMax) {
ctx.setLineDash([4, 4]);
ctx.strokeStyle = "#111827";
ctx.beginPath();
ctx.moveTo(toX(xb), pad);
ctx.lineTo(toX(xb), height - pad);
ctx.stroke();
ctx.setLineDash([]);
}
}
ctx.fillStyle = "#334b56";
ctx.font = "13px 'Avenir Next', sans-serif";
ctx.fillText("x", width - pad - 10, height - pad + 20);
ctx.fillText("Probability", pad - 28, pad - 10);
} else {
const x1s = data.map((d) => d.x1);
const x2s = data.map((d) => d.x2);
const x1Min = Math.min(...x1s) - 0.7;
const x1Max = Math.max(...x1s) + 0.7;
const x2Min = Math.min(...x2s) - 0.7;
const x2Max = Math.max(...x2s) + 0.7;
const toX = (x) => pad + ((x - x1Min) / (x1Max - x1Min || 1)) * (width - 2 * pad);
const toY = (y) => height - pad - ((y - x2Min) / (x2Max - x2Min || 1)) * (height - 2 * pad);
ctx.strokeStyle = "#7b8f98";
ctx.lineWidth = 1.1;
ctx.beginPath();
ctx.moveTo(pad, height - pad);
ctx.lineTo(width - pad, height - pad);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(pad, height - pad);
ctx.lineTo(pad, pad);
ctx.stroke();
for (const row of data) {
ctx.fillStyle = row.y === 1 ? "#0a8f7b" : "#d8534f";
ctx.beginPath();
ctx.arc(toX(row.x1), toY(row.x2), 3.2, 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = "#111827";
ctx.lineWidth = 2.4;
if (Math.abs(step.p2) > 1e-9) {
const xa = x1Min;
const xb = x1Max;
const ya = -(step.p1 / step.p2) * xa;
const yb = -(step.p1 / step.p2) * xb;
ctx.beginPath();
ctx.moveTo(toX(xa), toY(ya));
ctx.lineTo(toX(xb), toY(yb));
ctx.stroke();
} else {
ctx.beginPath();
ctx.moveTo(toX(0), pad);
ctx.lineTo(toX(0), height - pad);
ctx.stroke();
}
ctx.fillStyle = "#334b56";
ctx.font = "13px 'Avenir Next', sans-serif";
ctx.fillText("x1", width - pad - 12, height - pad + 20);
ctx.fillText("x2", pad - 22, pad - 10);
}
}
function render() {
const caseKey = controls.caseSelect.value;
const meta = caseMeta[caseKey];
labels.formula.textContent = meta.formula;
labels.lr.textContent = Number(controls.lr.value).toFixed(3);
labels.iters.textContent = controls.iters.value;
labels.p1.textContent = Number(controls.p1.value).toFixed(2);
labels.p2.textContent = Number(controls.p2.value).toFixed(2);
labels.speed.textContent = controls.speed.value;
updateModelDefinition(caseKey);
updateStepSlider();
const grid = getGrid(caseKey);
updateInfoChips(caseKey, grid);
updateSummary(caseKey);
renderLandscape(caseKey, grid);
renderStats(caseKey);
drawClassifier(caseKey);
}
function stopAnimation() {
if (!state.timer) {
controls.autoBtn.textContent = "Automatic Run";
return;
}
clearInterval(state.timer);
state.timer = null;
controls.autoBtn.textContent = "Automatic Run";
}
function runTrajectory() {
stopAnimation();
const caseKey = controls.caseSelect.value;
const start = [Number(controls.p1.value), Number(controls.p2.value)];
const lr = Number(controls.lr.value);
const iters = Number(controls.iters.value);
const { path, unstable } = buildTrajectory(caseKey, start, lr, iters);
state.trajectory = path;
state.stepIndex = 0;
state.unstableWarning = unstable
? "The learning rate may be too large; gradient descent became unstable."
: "";
state.gridCache[caseKey] = null;
render();
}
function nextStep() {
if (state.trajectory.length === 0) {
runTrajectory();
return;
}
if (state.stepIndex < state.trajectory.length - 1) {
state.stepIndex += 1;
render();
}
}
function autoRun() {
if (state.timer) {
stopAnimation();
return;
}
if (state.trajectory.length === 0) runTrajectory();
controls.autoBtn.textContent = "Stop Auto";
state.timer = setInterval(() => {
if (state.stepIndex >= state.trajectory.length - 1) {
stopAnimation();
return;
}
state.stepIndex += 1;
render();
}, Number(controls.speed.value));
}
function restartPath() {
stopAnimation();
if (state.trajectory.length > 0) {
state.stepIndex = 0;
}
render();
}
function clearPath() {
stopAnimation();
state.trajectory = [];
state.stepIndex = 0;
state.unstableWarning = "";
state.gridCache = { case1: null, case2: null };
render();
}
function applyCaseDefaults(caseKey) {
if (caseKey === "case1") {
controls.p1.value = -4.0;
controls.p2.value = 4.0;
controls.lr.value = 0.1;
} else {
controls.p1.value = -2.2;
controls.p2.value = -2.0;
controls.lr.value = 0.1;
}
controls.lrInput.value = Number(controls.lr.value).toFixed(3);
controls.iters.value = 80;
controls.speed.value = 90;
clearPath();
}
function resetView() {
if (!hasPlotly() || !state.plotReady) return;
if (controls.landscapeView.value === "contour2d") {
window.Plotly.relayout(plotEl, {
"xaxis.autorange": true,
"yaxis.autorange": true,
});
return;
}
state.camera3d = cloneCamera(DEFAULT_CAMERA);
window.Plotly.relayout(plotEl, { "scene.camera": state.camera3d });
}
function bindEvents() {
controls.lr.addEventListener("input", () => {
controls.lrInput.value = Number(controls.lr.value).toFixed(3);
render();
});
controls.lrInput.addEventListener("change", () => {
let v = Number(controls.lrInput.value);
if (!Number.isFinite(v)) v = 0.1;
v = Math.max(0.001, Math.min(10.0, v));
controls.lr.value = v;
controls.lrInput.value = v.toFixed(3);
render();
});
for (const el of [controls.iters, controls.p1, controls.p2, controls.speed]) {
el.addEventListener("input", render);
}
controls.caseSelect.addEventListener("change", () => {
applyCaseDefaults(controls.caseSelect.value);
});
controls.runBtn.addEventListener("click", runTrajectory);
controls.stepBtn.addEventListener("click", nextStep);
controls.autoBtn.addEventListener("click", autoRun);
controls.restartBtn.addEventListener("click", restartPath);
controls.clearBtn.addEventListener("click", clearPath);
controls.regenBtn.addEventListener("click", () => {
generateSyntheticData();
clearPath();
});
controls.landscapeView.addEventListener("change", render);
controls.toggleSurface.addEventListener("change", render);
controls.toggleContours.addEventListener("change", render);
controls.toggleTrajectory.addEventListener("change", render);
controls.toggleCurrent.addEventListener("change", render);
controls.stepSlider.addEventListener("input", () => {
const next = Number(controls.stepSlider.value);
if (!Number.isFinite(next)) return;
state.stepIndex = Math.max(0, Math.min(next, Math.max(0, state.trajectory.length - 1)));
render();
});
controls.resetViewBtn.addEventListener("click", resetView);
controls.plotFitBtn.addEventListener("click", () => fitPlotSize(true));
controls.plotLargerBtn.addEventListener("click", () => setPlotSize(plotWrap.clientWidth * 1.14, plotWrap.clientHeight * 1.14, true));
controls.plotSmallerBtn.addEventListener("click", () => setPlotSize(plotWrap.clientWidth / 1.14, plotWrap.clientHeight / 1.14, true));
controls.plotFullscreenBtn.addEventListener("click", toggleFullscreen);
document.addEventListener("fullscreenchange", () => {
controls.plotFullscreenBtn.textContent = document.fullscreenElement === plotWrap ? "Exit Fullscreen" : "Fullscreen";
debugResizeLog("fullscreenchange", { inFullscreen: document.fullscreenElement === plotWrap });
if (document.fullscreenElement === plotWrap) {
setPlotSize(window.innerWidth - 24, window.innerHeight - 24, true);
return;
}
const rect = plotWrap.getBoundingClientRect();
setPlotSize(rect.width, rect.height, false);
});
window.addEventListener("resize", () => {
debugResizeLog("window.resize");
if (document.fullscreenElement === plotWrap) {
setPlotSize(window.innerWidth - 24, window.innerHeight - 24, false);
} else {
const rect = plotWrap.getBoundingClientRect();
setPlotSize(rect.width, rect.height, false);
}
drawClassifier(controls.caseSelect.value);
});
}
function init() {
fitPlotSize(true);
bindEvents();
generateSyntheticData();
applyCaseDefaults("case1");
if (!hasPlotly()) {
showPlotFallback("Interactive 3D view unavailable (Plotly script did not load).");
}
}
init();