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 = `
${message}
`; } 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("
"); } 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 = `

Model: ${meta.model}

${meta.explain[0]}

${meta.explain[1]}

${meta.explain[2] ? `

${meta.explain[2]}

` : ""} `; } function updateSummary(caseKey) { const meta = caseMeta[caseKey]; if (state.trajectory.length === 0) { landscapeSummary.innerHTML = ` Start cost: -- Current cost: -- Final cost: -- Reduction: -- Iterations: 0 / 0 `; 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 = ` Start cost: ${start.cost.toFixed(4)} Current cost: ${current.cost.toFixed(4)} Final cost: ${final.cost.toFixed(4)} Reduction: ${reduction.toFixed(1)}% Iterations: ${idx} / ${state.trajectory.length - 1} ${meta.p1Name}: ${current.p1.toFixed(3)}, ${meta.p2Name}: ${current.p2.toFixed(3)} `; } 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 = ` Model case: ${meta.p1Name}, ${meta.p2Name} Samples: ${m} No trajectory yet. Click Run Gradient Descent. Current step: 0 ${meta.p1Label}: -- ${meta.p2Label}: -- `; return; } const idx = Math.min(state.stepIndex, state.trajectory.length - 1); const current = state.trajectory[idx]; stats.innerHTML = ` Model case: ${meta.p1Name}, ${meta.p2Name} Samples: ${m} Current step: ${idx} Cost function: ${current.cost.toFixed(4)} ${meta.p1Label}: ${current.p1.toFixed(4)} ${meta.p2Label}: ${current.p2.toFixed(4)} `; } 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}
${meta.p2Name}: %{y:.4f}
Cost function: %{z:.4f}`, }); 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}", }); 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)}`, }); 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)}`, }); } 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)}`, }); } 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}
${meta.p2Name}: %{y:.4f}
Cost function: %{z:.4f}`, }); 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}", }); 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)}`, }); 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)}`, }); } 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)}`, }); } 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();