diff --git a/playground/css/style.css b/playground/css/style.css index fbfca12..c88bc42 100644 --- a/playground/css/style.css +++ b/playground/css/style.css @@ -285,6 +285,38 @@ html, body { font-size: 11px; color: var(--text-dim); font-family: monospace; + margin-bottom: 8px; +} + +.metrics-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 3px 10px; + font-size: 10px; + margin-bottom: 8px; + font-family: monospace; +} + +.metric-label { + color: #707070; +} + +.metric-value { + color: #b5b5b5; + text-align: right; +} + +.loss-plot-wrap { + width: 100%; + border: 1px solid #2a2a2a; + border-radius: 8px; + overflow: hidden; +} + +#loss-plot { + width: 100%; + height: 86px; + display: block; } .hidden { display: none !important; } diff --git a/playground/js/nisps/iml.js b/playground/js/nisps/iml.js index 470e278..d03509e 100644 --- a/playground/js/nisps/iml.js +++ b/playground/js/nisps/iml.js @@ -48,6 +48,9 @@ export class IML { this.storedWeights = null; this.weightsRandomised = false; this.lastLoss = null; + this.bestLoss = null; + this.lossHistory = []; + this.totalTrainingIterations = 0; this.logFn = null; } @@ -162,7 +165,7 @@ export class IML { this.process(); } - train() { + train(options = {}) { // Restore weights if randomised if (this.weightsRandomised && this.storedWeights) { this.mlp.setWeights(this.storedWeights); @@ -183,9 +186,21 @@ export class IML { labels, this.learningRate, this.maxIterations, - this.convergenceThreshold + this.convergenceThreshold, + options ); + const latestHistory = this.mlp.lastTrainingHistory || []; + if (latestHistory.length > 0) { + this.lossHistory.push(...latestHistory); + this.totalTrainingIterations += latestHistory.length; + if (this.lossHistory.length > 1200) { + this.lossHistory = this.lossHistory.slice(this.lossHistory.length - 1200); + } + const runBest = Math.min(...latestHistory); + this.bestLoss = this.bestLoss === null ? runBest : Math.min(this.bestLoss, runBest); + } + // Run inference after training const inputWithBias = [...this.inputState, 1.0]; const { output } = this.mlp.getOutput(inputWithBias); diff --git a/playground/js/nisps/mlp.js b/playground/js/nisps/mlp.js index 361bbeb..f5bc2b7 100644 --- a/playground/js/nisps/mlp.js +++ b/playground/js/nisps/mlp.js @@ -67,9 +67,11 @@ export class MLP { } // Per-sample SGD training (Train method from C++) - train(features, labels, learningRate, maxIterations = 1000, minError = 0.00001) { + train(features, labels, learningRate, maxIterations = 1000, minError = 0.00001, options = {}) { const sampleSizeRecip = 1 / features.length; let loss = 0; + const history = []; + const onIteration = typeof options.onIteration === 'function' ? options.onIteration : null; for (let iter = 0; iter < maxIterations; iter++) { loss = 0; @@ -89,6 +91,9 @@ export class MLP { } loss *= sampleSizeRecip; + history.push(loss); + + if (onIteration) onIteration(iter, loss); if (this.progressCallback && (iter & 0x1F) === 0) { this.progressCallback(iter, loss); @@ -97,14 +102,17 @@ export class MLP { if (loss < minError) break; } + this.lastTrainingHistory = history; return loss; } // Batch training with RMSProp (TrainBatch from C++) - trainBatch(features, labels, learningRate, maxIterations = 1000, batchSize = 8, minError = 0.00001) { + trainBatch(features, labels, learningRate, maxIterations = 1000, batchSize = 8, minError = 0.00001, options = {}) { const nSamples = features.length; const nBatches = Math.ceil(nSamples / batchSize); let epochLoss = 0; + const history = []; + const onIteration = typeof options.onIteration === 'function' ? options.onIteration : null; for (let iter = 0; iter < maxIterations; iter++) { epochLoss = 0; @@ -164,6 +172,8 @@ export class MLP { } epochLoss /= nBatches; + history.push(epochLoss); + if (onIteration) onIteration(iter, epochLoss); if (this.progressCallback) { this.progressCallback(iter, epochLoss); @@ -172,6 +182,7 @@ export class MLP { if (epochLoss < minError) break; } + this.lastTrainingHistory = history; return epochLoss; } diff --git a/playground/js/ui/controls.js b/playground/js/ui/controls.js index 56dcae1..fe698d2 100644 --- a/playground/js/ui/controls.js +++ b/playground/js/ui/controls.js @@ -35,6 +35,27 @@ export class Controls { +
+ Mode + examples + Joystick + 0.50, 0.50 + Output Mean + 0.000 + Output Spread + 0.000 + Best Loss + - + Train Iters + 0 + Gamepad + Disconnected + Follow + Off +
+
+ +
`; // Mode toggle @@ -70,9 +91,97 @@ export class Controls { this.el.querySelector('#status-examples').textContent = `Examples: ${exampleCount}`; if (loss !== null && loss !== undefined) { this.el.querySelector('#status-loss').textContent = `Loss: ${loss.toFixed(5)}`; + } else { + this.el.querySelector('#status-loss').textContent = 'Loss: -'; } if (noiseLevel !== undefined) { this.el.querySelector('#status-noise').textContent = `Noise: ${noiseLevel.toFixed(3)}`; } } + + updateMetrics(metrics = {}) { + const set = (id, value) => { + const el = this.el.querySelector(id); + if (el) el.textContent = value; + }; + + if (metrics.mode) set('#metric-mode', metrics.mode); + if (metrics.joystickX !== undefined && metrics.joystickY !== undefined) { + set('#metric-joystick', `${metrics.joystickX.toFixed(2)}, ${metrics.joystickY.toFixed(2)}`); + } + if (metrics.outputMean !== undefined) set('#metric-mean', metrics.outputMean.toFixed(3)); + if (metrics.outputSpread !== undefined) set('#metric-spread', metrics.outputSpread.toFixed(3)); + if (metrics.bestLoss !== undefined && metrics.bestLoss !== null) { + set('#metric-best-loss', metrics.bestLoss.toFixed(5)); + } + if (metrics.totalTrainingIterations !== undefined) { + set('#metric-train-iters', String(metrics.totalTrainingIterations)); + } + if (metrics.gamepadConnected !== undefined) { + set('#metric-gamepad', metrics.gamepadConnected ? 'Connected' : 'Disconnected'); + } + if (metrics.followMode !== undefined) { + set('#metric-follow', metrics.followMode ? 'On' : 'Off'); + } + } + + updateLossPlot(lossHistory = []) { + const canvas = this.el.querySelector('#loss-plot'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const width = canvas.width; + const height = canvas.height; + + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = '#121212'; + ctx.fillRect(0, 0, width, height); + ctx.strokeStyle = '#2b2b2b'; + ctx.strokeRect(0.5, 0.5, width - 1, height - 1); + + if (!lossHistory.length) { + ctx.fillStyle = '#666'; + ctx.font = '11px monospace'; + ctx.fillText('Loss history appears after training.', 10, 18); + return; + } + + const points = lossHistory.slice(-420); + let min = Infinity; + let max = -Infinity; + for (const v of points) { + if (v < min) min = v; + if (v > max) max = v; + } + const range = Math.max(max - min, 1e-9); + const leftPad = 6; + const rightPad = 6; + const topPad = 6; + const bottomPad = 14; + const plotW = width - leftPad - rightPad; + const plotH = height - topPad - bottomPad; + + ctx.strokeStyle = '#243523'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(leftPad, topPad + plotH / 2); + ctx.lineTo(width - rightPad, topPad + plotH / 2); + ctx.stroke(); + + ctx.strokeStyle = '#00ff88'; + ctx.lineWidth = 1.6; + ctx.beginPath(); + for (let i = 0; i < points.length; i++) { + const x = leftPad + (i / Math.max(points.length - 1, 1)) * plotW; + const yNorm = (points[i] - min) / range; + const y = topPad + (1 - yNorm) * plotH; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + ctx.fillStyle = '#8a8a8a'; + ctx.font = '10px monospace'; + ctx.fillText(`min ${min.toFixed(5)}`, leftPad, height - 3); + ctx.fillText(`max ${max.toFixed(5)}`, width - rightPad - 78, height - 3); + } }