Spaces:
Running
Running
| const sampleCountInput = document.getElementById("sampleCount"); | |
| const learningRateInput = document.getElementById("learningRate"); | |
| const iterationCountInput = document.getElementById("iterationCount"); | |
| const noiseLevelInput = document.getElementById("noiseLevel"); | |
| const runGdBtn = document.getElementById("runGdBtn"); | |
| const gdStep = document.getElementById("gdStep"); | |
| const gdStepLabel = document.getElementById("gdStepLabel"); | |
| const playGdBtn = document.getElementById("playGdBtn"); | |
| const resetGdBtn = document.getElementById("resetGdBtn"); | |
| const fitCanvas = document.getElementById("fitCanvas"); | |
| const costCanvas = document.getElementById("costCanvas"); | |
| const landscapePlotWrap = document.getElementById("landscapePlotWrap"); | |
| const landscapePlot = document.getElementById("landscapePlot"); | |
| const landscapeSummary = document.getElementById("landscapeSummary"); | |
| const resetViewBtn = document.getElementById("resetViewBtn"); | |
| const plotSmallerBtn = document.getElementById("plotSmallerBtn"); | |
| const plotFitBtn = document.getElementById("plotFitBtn"); | |
| const plotLargerBtn = document.getElementById("plotLargerBtn"); | |
| const plotFullscreenBtn = document.getElementById("plotFullscreenBtn"); | |
| const toggleSurface = document.getElementById("toggleSurface"); | |
| const toggleContours = document.getElementById("toggleContours"); | |
| const toggleTrajectory = document.getElementById("toggleTrajectory"); | |
| const costSummary = document.getElementById("costSummary"); | |
| const stepSummary = document.getElementById("stepSummary"); | |
| const gdLogBody = document.getElementById("gdLogBody"); | |
| const fitCtx = fitCanvas.getContext("2d"); | |
| const costCtx = costCanvas.getContext("2d"); | |
| 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 state = { | |
| data: [], | |
| steps: [], | |
| currentStep: 0, | |
| timer: null, | |
| landscapeCache: null, | |
| landscapeCamera: null, | |
| landscapePlotReady: false, | |
| landscapeResizeRaf: null, | |
| lastLandscapeSize: null, | |
| }; | |
| function debugResizeLog(source, extra = {}) { | |
| if (!DEBUG_RESIZE) return; | |
| const wrapRect = landscapePlotWrap.getBoundingClientRect(); | |
| const parent = landscapePlotWrap.parentElement; | |
| const parentRect = parent ? parent.getBoundingClientRect() : null; | |
| const docEl = document.documentElement; | |
| const payload = { | |
| source, | |
| wrapClient: `${landscapePlotWrap.clientWidth}x${landscapePlotWrap.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("[linear-resize]", payload); | |
| } | |
| function init() { | |
| resizeCanvases(); | |
| fitLandscapeSize(); | |
| initLandscapeInteractions(); | |
| runGdBtn.addEventListener("click", runGradientDescent); | |
| window.addEventListener("resize", handleResize); | |
| gdStep.addEventListener("input", () => { | |
| stopPlayback(); | |
| state.currentStep = Number(gdStep.value); | |
| renderAll(); | |
| }); | |
| playGdBtn.addEventListener("click", () => { | |
| if (!state.steps.length) return; | |
| if (state.timer) stopPlayback(); | |
| else startPlayback(); | |
| }); | |
| resetGdBtn.addEventListener("click", () => { | |
| stopPlayback(); | |
| if (!state.steps.length) return; | |
| state.currentStep = 0; | |
| gdStep.value = "0"; | |
| renderAll(); | |
| }); | |
| runGradientDescent(); | |
| } | |
| function initLandscapeInteractions() { | |
| resetViewBtn.addEventListener("click", resetLandscapeView); | |
| plotFitBtn.addEventListener("click", () => fitLandscapeSize(true)); | |
| plotLargerBtn.addEventListener("click", () => scaleLandscapeSize(1.14)); | |
| plotSmallerBtn.addEventListener("click", () => scaleLandscapeSize(1 / 1.14)); | |
| plotFullscreenBtn.addEventListener("click", toggleLandscapeFullscreen); | |
| for (const cb of [toggleSurface, toggleContours, toggleTrajectory]) { | |
| cb.addEventListener("change", renderCostLandscape); | |
| } | |
| document.addEventListener("fullscreenchange", () => { | |
| plotFullscreenBtn.textContent = document.fullscreenElement === landscapePlotWrap ? "Exit Fullscreen" : "Fullscreen"; | |
| debugResizeLog("fullscreenchange", { inFullscreen: document.fullscreenElement === landscapePlotWrap }); | |
| if (document.fullscreenElement === landscapePlotWrap) { | |
| setLandscapeSize(window.innerWidth - 24, window.innerHeight - 24, true); | |
| return; | |
| } | |
| constrainLandscapeSizeToContainer(); | |
| }); | |
| } | |
| function handleResize() { | |
| debugResizeLog("window.resize"); | |
| resizeCanvases(); | |
| if (document.fullscreenElement === landscapePlotWrap) { | |
| setLandscapeSize(window.innerWidth - 24, window.innerHeight - 24, false); | |
| } else { | |
| constrainLandscapeSizeToContainer(); | |
| } | |
| if (state.steps.length) renderAll(); | |
| } | |
| function resizeCanvases() { | |
| resizeCanvasToContainer(fitCanvas, 0.46, 240, 480); | |
| resizeCanvasToContainer(costCanvas, 0.28, 200, 320); | |
| } | |
| function resizeCanvasToContainer(canvas, ratio, minHeight, maxHeight) { | |
| const parent = canvas.parentElement; | |
| if (!parent) return; | |
| const width = Math.max(320, Math.round(parent.getBoundingClientRect().width)); | |
| const targetHeight = Math.floor(width * ratio); | |
| const height = Math.max(minHeight, Math.min(maxHeight, targetHeight)); | |
| if (canvas.width !== width || canvas.height !== height) { | |
| canvas.width = width; | |
| canvas.height = height; | |
| } | |
| } | |
| function getLandscapeFitSize() { | |
| const host = landscapePlotWrap.parentElement; | |
| const hostWidth = host ? host.getBoundingClientRect().width : window.innerWidth; | |
| const maxW = Math.max(320, Math.min(1000, hostWidth - 4)); | |
| if (window.innerWidth < 680) { | |
| return { width: maxW, height: 380 }; | |
| } | |
| if (window.innerWidth < 1024) { | |
| return { width: Math.min(maxW, 920), height: 560 }; | |
| } | |
| return { width: Math.min(maxW, 980), height: 640 }; | |
| } | |
| function fitLandscapeSize(forceResize = false) { | |
| const size = getLandscapeFitSize(); | |
| setLandscapeSize(size.width, size.height, forceResize); | |
| } | |
| function setLandscapeSize(rawW, rawH, forceResize = false) { | |
| const host = landscapePlotWrap.parentElement; | |
| const hostMaxW = host ? host.getBoundingClientRect().width - 4 : rawW; | |
| const isFullscreen = document.fullscreenElement === landscapePlotWrap; | |
| const minW = 320; | |
| const minH = 280; | |
| const maxW = isFullscreen ? Math.min(1800, window.innerWidth - 24) : Math.max(minW, hostMaxW); | |
| const maxH = isFullscreen ? Math.min(1200, window.innerHeight - 24) : Math.min(900, window.innerHeight - 150); | |
| const width = Math.round(Math.max(minW, Math.min(maxW, rawW))); | |
| const height = Math.round(Math.max(minH, Math.min(maxH, rawH))); | |
| const rect = landscapePlotWrap.getBoundingClientRect(); | |
| const widthChanged = Math.abs(Math.round(rect.width) - width) > RESIZE_EPSILON; | |
| const heightChanged = Math.abs(Math.round(rect.height) - height) > RESIZE_EPSILON; | |
| if (widthChanged || heightChanged) { | |
| landscapePlotWrap.style.width = `${width}px`; | |
| landscapePlotWrap.style.height = `${height}px`; | |
| debugResizeLog("setLandscapeSize.apply", { width, height, forceResize }); | |
| } | |
| if (forceResize || widthChanged || heightChanged) { | |
| queueLandscapeResize(true); | |
| } | |
| } | |
| function constrainLandscapeSizeToContainer() { | |
| const rect = landscapePlotWrap.getBoundingClientRect(); | |
| setLandscapeSize(rect.width, rect.height, false); | |
| } | |
| function scaleLandscapeSize(factor) { | |
| const rect = landscapePlotWrap.getBoundingClientRect(); | |
| setLandscapeSize(rect.width * factor, rect.height * factor, false); | |
| } | |
| function queueLandscapeResize(force = false) { | |
| if (!state.landscapePlotReady || typeof Plotly === "undefined") return; | |
| const width = Math.round(landscapePlotWrap.clientWidth); | |
| const height = Math.round(landscapePlotWrap.clientHeight); | |
| if (width <= 0 || height <= 0) return; | |
| if (!force && state.lastLandscapeSize) { | |
| const dw = Math.abs(width - state.lastLandscapeSize.width); | |
| const dh = Math.abs(height - state.lastLandscapeSize.height); | |
| if (dw <= RESIZE_EPSILON && dh <= RESIZE_EPSILON) return; | |
| } | |
| state.lastLandscapeSize = { width, height }; | |
| debugResizeLog("queueLandscapeResize", { width, height, force }); | |
| if (state.landscapeResizeRaf) { | |
| cancelAnimationFrame(state.landscapeResizeRaf); | |
| } | |
| state.landscapeResizeRaf = requestAnimationFrame(() => { | |
| state.landscapeResizeRaf = null; | |
| debugResizeLog("Plotly.relayout(size)", { width, height }); | |
| Plotly.relayout(landscapePlot, { width, height }); | |
| }); | |
| } | |
| function toggleLandscapeFullscreen() { | |
| if (document.fullscreenElement === landscapePlotWrap) { | |
| document.exitFullscreen(); | |
| return; | |
| } | |
| if (landscapePlotWrap.requestFullscreen) { | |
| landscapePlotWrap.requestFullscreen(); | |
| } | |
| } | |
| function resetLandscapeView() { | |
| state.landscapeCamera = cloneCamera(DEFAULT_CAMERA); | |
| if (!state.landscapePlotReady || typeof Plotly === "undefined") return; | |
| Plotly.relayout(landscapePlot, { "scene.camera": state.landscapeCamera }); | |
| } | |
| function runGradientDescent() { | |
| stopPlayback(); | |
| const config = readConfig(); | |
| state.data = generateData(config); | |
| state.steps = computeGradientDescent(state.data, config).steps; | |
| state.currentStep = 0; | |
| state.landscapeCache = null; | |
| gdStep.max = String(state.steps.length - 1); | |
| gdStep.value = "0"; | |
| renderAll(); | |
| } | |
| function readConfig() { | |
| const sampleCount = clampInt(Number(sampleCountInput.value), 10, 120, 50); | |
| const learningRate = clampNum(Number(learningRateInput.value), 0.001, 0.5, 0.02); | |
| const iterations = clampInt(Number(iterationCountInput.value), 5, 300, 120); | |
| const noise = clampNum(Number(noiseLevelInput.value), 0, 5, 0.35); | |
| sampleCountInput.value = String(sampleCount); | |
| learningRateInput.value = String(learningRate); | |
| iterationCountInput.value = String(iterations); | |
| noiseLevelInput.value = String(noise); | |
| return { sampleCount, learningRate, iterations, noise }; | |
| } | |
| function generateData(config) { | |
| const rows = []; | |
| for (let i = 0; i < config.sampleCount; i += 1) { | |
| const x1 = rand(-5, 5); | |
| const y = 8 + 4.2 * x1 + rand(-config.noise, config.noise); | |
| rows.push({ x: [x1], y }); | |
| } | |
| return rows; | |
| } | |
| function computeGradientDescent(data, config) { | |
| let theta = [-12, 12]; | |
| const steps = []; | |
| for (let step = 0; step <= config.iterations; step += 1) { | |
| const { cost, grad } = costAndGradient(data, theta); | |
| steps.push({ step, theta: theta.slice(), grad: grad.slice(), cost }); | |
| if (step === config.iterations) break; | |
| theta = theta.map((t, j) => t - config.learningRate * grad[j]); | |
| } | |
| return { steps }; | |
| } | |
| function costAndGradient(data, theta) { | |
| const m = data.length; | |
| let sumSq = 0; | |
| const grad = [0, 0]; | |
| for (const row of data) { | |
| const yHat = theta[0] + theta[1] * row.x[0]; | |
| const err = yHat - row.y; | |
| sumSq += err * err; | |
| grad[0] += err; | |
| grad[1] += err * row.x[0]; | |
| } | |
| grad[0] /= m; | |
| grad[1] /= m; | |
| return { cost: sumSq / (2 * m), grad }; | |
| } | |
| function renderAll() { | |
| if (!state.steps.length) return; | |
| gdStepLabel.textContent = `${state.currentStep} / ${state.steps.length - 1}`; | |
| renderFitPlot(); | |
| renderCostPlot(); | |
| renderCostLandscape(); | |
| renderCostSummary(); | |
| renderStepSummary(); | |
| renderLogTable(); | |
| } | |
| function renderFitPlot() { | |
| const ctx = fitCtx; | |
| const { width, height } = fitCanvas; | |
| const pad = 52; | |
| const s = state.steps[state.currentStep]; | |
| ctx.clearRect(0, 0, width, height); | |
| const xs = state.data.map((d) => d.x[0]); | |
| const ys = state.data.map((d) => d.y); | |
| const xMin = Math.min(...xs) - 1; | |
| const xMax = Math.max(...xs) + 1; | |
| const yMin = Math.min(...ys) - 2; | |
| const yMax = Math.max(...ys) + 2; | |
| const toX = (x) => pad + ((x - xMin) / (xMax - xMin)) * (width - 2 * pad); | |
| const toY = (y) => height - pad - ((y - yMin) / (yMax - yMin)) * (height - 2 * pad); | |
| drawAxes2D(ctx, width, height, pad, "x", "y"); | |
| ctx.fillStyle = "#0c7b73"; | |
| for (const row of state.data) { | |
| ctx.beginPath(); | |
| ctx.arc(toX(row.x[0]), toY(row.y), 4, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| const yL = s.theta[0] + s.theta[1] * xMin; | |
| const yR = s.theta[0] + s.theta[1] * xMax; | |
| ctx.strokeStyle = "#dd5e2f"; | |
| ctx.lineWidth = 3; | |
| ctx.beginPath(); | |
| ctx.moveTo(toX(xMin), toY(yL)); | |
| ctx.lineTo(toX(xMax), toY(yR)); | |
| ctx.stroke(); | |
| } | |
| function renderCostPlot() { | |
| const ctx = costCtx; | |
| const { width, height } = costCanvas; | |
| const pad = 40; | |
| const costs = state.steps.map((s) => s.cost); | |
| ctx.clearRect(0, 0, width, height); | |
| const minC = Math.min(...costs); | |
| const maxC = Math.max(...costs); | |
| const toX = (i) => pad + (i / (costs.length - 1 || 1)) * (width - 2 * pad); | |
| const toY = (c) => | |
| height - pad - ((c - minC) / (maxC - minC || 1)) * (height - 2 * pad); | |
| drawAxes2D(ctx, width, height, pad, "iteration", "cost"); | |
| ctx.strokeStyle = "#0c7b73"; | |
| ctx.lineWidth = 2.2; | |
| ctx.beginPath(); | |
| costs.forEach((cost, i) => { | |
| const x = toX(i); | |
| const y = toY(cost); | |
| if (i === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| }); | |
| ctx.stroke(); | |
| ctx.fillStyle = "#dd5e2f"; | |
| ctx.beginPath(); | |
| ctx.arc(toX(state.currentStep), toY(costs[state.currentStep]), 5, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| function renderCostLandscape() { | |
| const data = getLandscapeData(); | |
| renderLandscapeSummary(); | |
| renderLandscapePlotly(data); | |
| } | |
| function getLandscapeData() { | |
| if (state.landscapeCache) return state.landscapeCache; | |
| const pathTheta0 = state.steps.map((s) => s.theta[0]); | |
| const pathTheta1 = state.steps.map((s) => s.theta[1]); | |
| const t0MinRaw = Math.min(...pathTheta0); | |
| const t0MaxRaw = Math.max(...pathTheta0); | |
| const t1MinRaw = Math.min(...pathTheta1); | |
| const t1MaxRaw = Math.max(...pathTheta1); | |
| const t0Pad = (t0MaxRaw - t0MinRaw || 1) * 0.35; | |
| const t1Pad = (t1MaxRaw - t1MinRaw || 1) * 0.35; | |
| const t0Min = t0MinRaw - t0Pad; | |
| const t0Max = t0MaxRaw + t0Pad; | |
| const t1Min = t1MinRaw - t1Pad; | |
| const t1Max = t1MaxRaw + t1Pad; | |
| const gridN = 64; | |
| const theta0Values = []; | |
| const theta1Values = []; | |
| for (let i = 0; i < gridN; i += 1) { | |
| theta0Values.push(t0Min + (i / (gridN - 1)) * (t0Max - t0Min)); | |
| theta1Values.push(t1Min + (i / (gridN - 1)) * (t1Max - t1Min)); | |
| } | |
| const grid = []; | |
| let zMin = Infinity; | |
| let zMax = -Infinity; | |
| for (let gy = 0; gy < gridN; gy += 1) { | |
| const t1 = theta1Values[gy]; | |
| const row = []; | |
| for (let gx = 0; gx < gridN; gx += 1) { | |
| const t0 = theta0Values[gx]; | |
| const cost = costAndGradient(state.data, [t0, t1]).cost; | |
| row.push(cost); | |
| zMin = Math.min(zMin, cost); | |
| zMax = Math.max(zMax, cost); | |
| } | |
| grid.push(row); | |
| } | |
| const path = state.steps.map((s) => ({ | |
| step: s.step, | |
| t0: s.theta[0], | |
| t1: s.theta[1], | |
| z: s.cost, | |
| g0: s.grad[0], | |
| g1: s.grad[1], | |
| })); | |
| const contourLines = buildContourLines(theta0Values, theta1Values, grid, zMin, zMax, 9); | |
| state.landscapeCache = { | |
| grid, | |
| gridN, | |
| theta0Values, | |
| theta1Values, | |
| t0Min, | |
| t0Max, | |
| t1Min, | |
| t1Max, | |
| zMin, | |
| zMax, | |
| path, | |
| contourLines, | |
| }; | |
| return state.landscapeCache; | |
| } | |
| function buildContourLines(theta0Values, theta1Values, grid, zMin, zMax, levelsCount) { | |
| const xs = []; | |
| const ys = []; | |
| const zs = []; | |
| const zBase = zMin; | |
| for (let levelIdx = 1; levelIdx <= levelsCount; levelIdx += 1) { | |
| const level = zMin + (levelIdx / (levelsCount + 1)) * (zMax - zMin); | |
| for (let y = 0; y < theta1Values.length - 1; y += 1) { | |
| for (let x = 0; x < theta0Values.length - 1; x += 1) { | |
| const p00 = { x: theta0Values[x], y: theta1Values[y], z: grid[y][x] }; | |
| const p10 = { x: theta0Values[x + 1], y: theta1Values[y], z: grid[y][x + 1] }; | |
| const p11 = { x: theta0Values[x + 1], y: theta1Values[y + 1], z: grid[y + 1][x + 1] }; | |
| const p01 = { x: theta0Values[x], y: theta1Values[y + 1], z: grid[y + 1][x] }; | |
| const intersections = []; | |
| addContourIntersection(intersections, p00, p10, level); | |
| addContourIntersection(intersections, p10, p11, level); | |
| addContourIntersection(intersections, p11, p01, level); | |
| addContourIntersection(intersections, p01, p00, level); | |
| if (intersections.length === 2) { | |
| xs.push(intersections[0].x, intersections[1].x, null); | |
| ys.push(intersections[0].y, intersections[1].y, null); | |
| zs.push(zBase, zBase, null); | |
| } else if (intersections.length === 4) { | |
| xs.push(intersections[0].x, intersections[1].x, null, intersections[2].x, intersections[3].x, null); | |
| ys.push(intersections[0].y, intersections[1].y, null, intersections[2].y, intersections[3].y, null); | |
| zs.push(zBase, zBase, null, zBase, zBase, null); | |
| } | |
| } | |
| } | |
| } | |
| return { x: xs, y: ys, z: zs }; | |
| } | |
| function addContourIntersection(bucket, a, b, level) { | |
| const aSide = a.z - level; | |
| const bSide = b.z - level; | |
| if (aSide === 0 && bSide === 0) return; | |
| if (aSide * bSide > 0) return; | |
| const denom = b.z - a.z; | |
| const t = Math.abs(denom) < 1e-12 ? 0.5 : (level - a.z) / denom; | |
| 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 renderLandscapeSummary() { | |
| if (!state.steps.length) { | |
| landscapeSummary.innerHTML = ""; | |
| return; | |
| } | |
| const first = state.steps[0]; | |
| const final = state.steps[state.steps.length - 1]; | |
| const current = state.steps[state.currentStep]; | |
| const reduction = ((first.cost - final.cost) / Math.max(1e-9, first.cost)) * 100; | |
| landscapeSummary.innerHTML = ` | |
| <span class="landscape-pill">Start cost: ${first.cost.toFixed(2)}</span> | |
| <span class="landscape-pill">Final cost: ${final.cost.toFixed(2)}</span> | |
| <span class="landscape-pill">Reduction: ${reduction.toFixed(1)}%</span> | |
| <span class="landscape-pill">Iterations: ${state.steps.length - 1}</span> | |
| <span class="landscape-pill">Current step: ${state.currentStep}</span> | |
| <span class="landscape-pill">Current cost: ${current.cost.toFixed(2)}</span> | |
| `; | |
| } | |
| function renderLandscapePlotly(data) { | |
| if (typeof Plotly === "undefined") return; | |
| const trajectoryPath = data.path; | |
| const current = data.path[state.currentStep]; | |
| const start = data.path[0]; | |
| const final = data.path[data.path.length - 1]; | |
| const baseHover = | |
| "Iteration: %{customdata[0]}<br>theta0: %{x:.4f}<br>theta1: %{y:.4f}<br>Cost J(theta): %{z:.4f}<extra></extra>"; | |
| const traces = [ | |
| { | |
| type: "surface", | |
| name: "Cost surface", | |
| x: data.theta0Values, | |
| y: data.theta1Values, | |
| z: data.grid, | |
| opacity: 0.82, | |
| colorscale: "Viridis", | |
| showscale: true, | |
| colorbar: { | |
| title: "Cost J", | |
| thickness: 16, | |
| }, | |
| contours: { | |
| z: { | |
| show: false, | |
| }, | |
| }, | |
| visible: toggleSurface.checked, | |
| hovertemplate: "theta0: %{x:.3f}<br>theta1: %{y:.3f}<br>Cost J(theta): %{z:.4f}<extra></extra>", | |
| }, | |
| { | |
| type: "scatter3d", | |
| mode: "lines", | |
| name: "Contours", | |
| x: data.contourLines.x, | |
| y: data.contourLines.y, | |
| z: data.contourLines.z, | |
| line: { color: "rgba(25,45,58,0.65)", width: 3 }, | |
| visible: toggleContours.checked, | |
| hoverinfo: "skip", | |
| }, | |
| { | |
| type: "scatter3d", | |
| mode: "lines+markers", | |
| name: "Gradient descent trajectory", | |
| x: trajectoryPath.map((p) => p.t0), | |
| y: trajectoryPath.map((p) => p.t1), | |
| z: trajectoryPath.map((p) => p.z), | |
| customdata: trajectoryPath.map((p) => [p.step]), | |
| line: { color: "#f97316", width: 7 }, | |
| marker: { size: 3.8, color: "#b42318", opacity: 0.95 }, | |
| visible: toggleTrajectory.checked, | |
| hovertemplate: baseHover, | |
| }, | |
| { | |
| type: "scatter3d", | |
| mode: "markers+text", | |
| name: "Start", | |
| x: [start.t0], | |
| y: [start.t1], | |
| z: [start.z], | |
| customdata: [[start.step]], | |
| marker: { size: 10, color: "#0c7b73", line: { color: "#0b2230", width: 1.2 } }, | |
| text: ["Start"], | |
| textposition: "top center", | |
| visible: toggleTrajectory.checked, | |
| hovertemplate: baseHover, | |
| }, | |
| { | |
| type: "scatter3d", | |
| mode: "markers+text", | |
| name: "Final", | |
| x: [final.t0], | |
| y: [final.t1], | |
| z: [final.z], | |
| customdata: [[final.step]], | |
| marker: { size: 10, color: "#dd5e2f", line: { color: "#0b2230", width: 1.2 } }, | |
| text: ["Final"], | |
| textposition: "top center", | |
| visible: toggleTrajectory.checked, | |
| hovertemplate: baseHover, | |
| }, | |
| { | |
| type: "scatter3d", | |
| mode: "markers+text", | |
| name: "Current", | |
| x: [current.t0], | |
| y: [current.t1], | |
| z: [current.z], | |
| customdata: [[current.step]], | |
| marker: { size: 9, color: "#111827", line: { color: "#f59e0b", width: 2 } }, | |
| text: [state.currentStep === data.path.length - 1 ? "Final" : `Step ${state.currentStep}`], | |
| textposition: "bottom center", | |
| visible: toggleTrajectory.checked, | |
| hovertemplate: baseHover, | |
| }, | |
| ]; | |
| const sceneCamera = state.landscapeCamera || cloneCamera(DEFAULT_CAMERA); | |
| const plotWidth = Math.round(landscapePlotWrap.clientWidth); | |
| const plotHeight = Math.round(landscapePlotWrap.clientHeight); | |
| const layout = { | |
| autosize: false, | |
| width: plotWidth, | |
| height: plotHeight, | |
| margin: { l: 0, r: 0, t: 8, b: 0 }, | |
| paper_bgcolor: "#ffffff", | |
| scene: { | |
| xaxis: { | |
| title: "theta0", | |
| range: [data.t0Min, data.t0Max], | |
| showspikes: false, | |
| backgroundcolor: "rgba(247, 248, 252, 0.85)", | |
| }, | |
| yaxis: { | |
| title: "theta1", | |
| range: [data.t1Min, data.t1Max], | |
| showspikes: false, | |
| backgroundcolor: "rgba(247, 248, 252, 0.85)", | |
| }, | |
| zaxis: { | |
| title: "Cost J(theta)", | |
| range: [data.zMin, data.zMax], | |
| showspikes: false, | |
| backgroundcolor: "rgba(249, 250, 252, 0.9)", | |
| }, | |
| aspectmode: "manual", | |
| aspectratio: { x: 1.12, y: 1.12, z: 0.78 }, | |
| camera: sceneCamera, | |
| dragmode: "turntable", | |
| }, | |
| showlegend: false, | |
| uirevision: "landscape-ui", | |
| }; | |
| const config = { | |
| responsive: false, | |
| displaylogo: false, | |
| scrollZoom: true, | |
| doubleClick: "reset", | |
| modeBarButtonsToRemove: ["lasso2d", "select2d"], | |
| }; | |
| const plotPromise = state.landscapePlotReady | |
| ? Plotly.react(landscapePlot, traces, layout, config) | |
| : Plotly.newPlot(landscapePlot, traces, layout, config); | |
| plotPromise.then(() => { | |
| if (!state.landscapePlotReady) { | |
| state.landscapePlotReady = true; | |
| state.lastLandscapeSize = null; | |
| landscapePlot.on("plotly_relayout", (ev) => { | |
| if (ev && ev["scene.camera"]) { | |
| state.landscapeCamera = cloneCamera(ev["scene.camera"]); | |
| } | |
| debugResizeLog("plotly_relayout", { | |
| hasCamera: !!(ev && ev["scene.camera"]), | |
| }); | |
| }); | |
| queueLandscapeResize(true); | |
| } | |
| }); | |
| } | |
| function cloneCamera(camera) { | |
| return { | |
| eye: { ...camera.eye }, | |
| center: { ...camera.center }, | |
| up: { ...camera.up }, | |
| }; | |
| } | |
| function renderCostSummary() { | |
| const first = state.steps[0]; | |
| const last = state.steps[state.steps.length - 1]; | |
| costSummary.innerHTML = ` | |
| <article class="matrix-card"> | |
| <p class="matrix-title">Cost Function</p> | |
| <p class="formula-line">J(theta) = (1/2m) sum((theta0 + theta1*x1 - y)^2)</p> | |
| </article> | |
| <article class="matrix-card"> | |
| <p class="matrix-title">Result</p> | |
| <p class="formula-line">y_hat = ${fmt(last.theta[0])} + ${fmt(last.theta[1])}*x1</p> | |
| <p class="slice-label">parameters: 2 (theta0, theta1)</p> | |
| <p class="slice-label">initial cost: ${first.cost.toFixed(6)}</p> | |
| <p class="slice-label">final cost: ${last.cost.toFixed(6)}</p> | |
| </article> | |
| `; | |
| } | |
| function renderStepSummary() { | |
| const s = state.steps[state.currentStep]; | |
| stepSummary.innerHTML = ` | |
| <article class="matrix-card"> | |
| <p class="matrix-title">Current Step ${s.step}</p> | |
| <p class="formula-line">cost = ${s.cost.toFixed(6)}</p> | |
| <p class="formula-line">theta = [${s.theta.map(fmt).join(", ")}]</p> | |
| <p class="formula-line">gradient = [${s.grad.map(fmt).join(", ")}]</p> | |
| </article> | |
| `; | |
| } | |
| function renderLogTable() { | |
| gdLogBody.innerHTML = ""; | |
| for (const s of state.steps) { | |
| const tr = document.createElement("tr"); | |
| if (s.step === state.currentStep) tr.classList.add("active-log-row"); | |
| const cells = [ | |
| String(s.step), | |
| s.cost.toFixed(6), | |
| `[${s.theta.map(fmt).join(", ")}]`, | |
| `[${s.grad.map(fmt).join(", ")}]`, | |
| ]; | |
| cells.forEach((txt) => { | |
| const td = document.createElement("td"); | |
| td.textContent = txt; | |
| tr.appendChild(td); | |
| }); | |
| gdLogBody.appendChild(tr); | |
| } | |
| } | |
| function drawAxes2D(ctx, width, height, pad, xLabel, yLabel) { | |
| ctx.strokeStyle = "#8f9ba1"; | |
| ctx.lineWidth = 1.2; | |
| 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.fillStyle = "#4c5961"; | |
| ctx.font = "13px 'Courier New', monospace"; | |
| ctx.fillText(xLabel, width - pad - 50, height - pad + 22); | |
| ctx.fillText(yLabel, pad - 30, pad - 10); | |
| } | |
| function startPlayback() { | |
| if (state.timer || !state.steps.length) return; | |
| playGdBtn.textContent = "Pause"; | |
| state.timer = setInterval(() => { | |
| if (!document.body.contains(gdStep)) { | |
| stopPlayback(); | |
| return; | |
| } | |
| if (state.currentStep >= state.steps.length - 1) { | |
| stopPlayback(); | |
| return; | |
| } | |
| state.currentStep += 1; | |
| gdStep.value = String(state.currentStep); | |
| renderAll(); | |
| }, 220); | |
| } | |
| function stopPlayback() { | |
| if (!state.timer) { | |
| playGdBtn.textContent = "Play"; | |
| return; | |
| } | |
| clearInterval(state.timer); | |
| state.timer = null; | |
| playGdBtn.textContent = "Play"; | |
| } | |
| function fmt(n) { | |
| return Number(n).toFixed(4); | |
| } | |
| function rand(min, max) { | |
| return min + Math.random() * (max - min); | |
| } | |
| function clampInt(v, min, max, fallback) { | |
| if (!Number.isFinite(v)) return fallback; | |
| return Math.max(min, Math.min(max, Math.round(v))); | |
| } | |
| function clampNum(v, min, max, fallback) { | |
| if (!Number.isFinite(v)) return fallback; | |
| return Math.max(min, Math.min(max, v)); | |
| } | |
| init(); | |