memlnaut-nisps/playground/js/ui/gradient-flow.js
w1n5t0n 1f21494dee feat(playground): implement Phases 2-4 of control surface spec
Phase 2 — Pinning + History:
- snapshot-stack.js: ring buffer (20 max) with auto-snapshot on
  train/randomize/thumbs-down, multi-level undo, tagged entries
- ab-compare.js: A/B weight state comparison with capture/toggle/accept/revert
- region-pin.js: pin rectangular input-space regions (Approach A: example
  pinning), pinned examples always included in training
- param-pin.js: per-output pin flags, pin mask skips pinned nodes in moveWeights
- phase2-ui.js: undo button with history popup, A/B toggle, long-press region
  pin, double-tap param pin
- Modified mlp.js/iml.js/nisps-wasm.js to accept outputPinMask in moveWeights

Phase 3 — Input Refinement + Exploration:
- pressure-feedback.js: touch force + hold duration → intensity multiplier
- auto-explore.js: automated thumbs-down at configurable interval, zoom-scaled
- input-heatmap.js: 16×16 MLP sampling, 3 color modes (luminance/variance/
  divergence), zoom-aware resampling, offscreen canvas rendering
- phase3-ui.js: auto-explore toggle with progress ring, heatmap eye icon,
  pressure indicators, settings drawer section
- joy-map-enhanced.js: added setHeatmap() for background layer rendering

Phase 4 — Output Pipeline + Visualization + Polish:
- output-pipeline.js: global curve → smoothing → slew rate → freeze gate
- weight-health.js: weight magnitude histogram, dead/saturating/healthy status
- gradient-flow.js: per-layer weight-delta analysis, vanishing/exploding detection
- session-presets.js: save/load full state, URL sharing via compact params
- phase4-ui.js: freeze button, network health panel, session preset UI

All phases merged into a-app.js with proper integration: auto-snapshots,
pressure-modulated RL, heatmap triggers, output pipeline in routeOutputs,
gradient capture around training, persistence for all new state.
2026-03-26 10:48:12 +02:00

261 lines
8 KiB
JavaScript

/**
* Gradient Flow Indicator — per-layer gradient magnitude visualization.
*
* Uses the weight-delta approach: snapshot weights before training, snapshot after,
* compute per-layer L2 norm of the delta. No WASM changes needed.
*
* @module gradient-flow
*/
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Ratio threshold: if next layer's gradient < VANISHING_RATIO * previous, it's vanishing */
const VANISHING_RATIO = 0.5;
/** Ratio threshold: if next layer's gradient > EXPLODING_RATIO * previous, it's exploding */
const EXPLODING_RATIO = 2.0;
/** Absolute threshold: if all gradient norms below this, network has converged */
const CONVERGED_THRESHOLD = 1e-6;
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
const COLOR_HEALTHY = { r: 100, g: 200, b: 120 };
const COLOR_WARNING = { r: 230, g: 200, b: 60 };
const COLOR_DANGER = { r: 240, g: 80, b: 60 };
const COLOR_CONVERGED = { r: 100, g: 140, b: 200 };
// ---------------------------------------------------------------------------
// GradientFlowIndicator
// ---------------------------------------------------------------------------
export class GradientFlowIndicator {
/**
* @param {number[]} layerSizes — e.g. [3, 32, 48, 64, 126]
*/
constructor(layerSizes) {
this._layerSizes = layerSizes;
this._numLayers = layerSizes.length - 1; // number of weight matrices
// Compute per-layer weight counts: layer i has layerSizes[i] * layerSizes[i+1] weights + layerSizes[i+1] biases
this._layerWeightCounts = [];
this._layerOffsets = [];
let offset = 0;
for (let i = 0; i < this._numLayers; i++) {
const count = layerSizes[i] * layerSizes[i + 1] + layerSizes[i + 1];
this._layerWeightCounts.push(count);
this._layerOffsets.push(offset);
offset += count;
}
this._totalWeights = offset;
// Build layer labels
this._layerLabels = [];
for (let i = 0; i < this._numLayers; i++) {
if (i === this._numLayers - 1) {
this._layerLabels.push('Out');
} else {
this._layerLabels.push(`L${i + 1}`);
}
}
// Weight snapshots
this._beforeWeights = null;
this._afterWeights = null;
this._flow = null;
}
/**
* Capture weight snapshot before training.
* @param {number[]|Float32Array} weightsArray — flat array of all weights
*/
captureBeforeTrain(weightsArray) {
if (!weightsArray) return;
this._beforeWeights = weightsArray instanceof Float32Array
? new Float32Array(weightsArray)
: new Float32Array(weightsArray);
}
/**
* Capture weight snapshot after training and compute gradient flow.
* @param {number[]|Float32Array} weightsArray — flat array of all weights
*/
captureAfterTrain(weightsArray) {
if (!weightsArray || !this._beforeWeights) return;
this._afterWeights = weightsArray instanceof Float32Array
? weightsArray
: new Float32Array(weightsArray);
this._computeFlow();
}
/**
* Get per-layer gradient flow info.
* @returns {object|null}
*/
getFlow() {
return this._flow;
}
/**
* Draw per-layer gradient flow bars.
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} x
* @param {number} y
* @param {number} width
* @param {number} height
*/
draw(ctx, x, y, width, height) {
if (!this._flow) {
ctx.fillStyle = 'rgba(60, 60, 60, 0.4)';
ctx.fillRect(x, y, width, height);
ctx.font = '8px monospace';
ctx.fillStyle = 'rgba(120, 120, 120, 0.6)';
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.fillText('no grad data', x + width / 2, y + height / 2);
return;
}
const { layers, status } = this._flow;
const n = layers.length;
if (n === 0) return;
// Background
ctx.fillStyle = 'rgba(20, 20, 20, 0.6)';
ctx.fillRect(x, y, width, height);
const labelHeight = 12;
const barAreaHeight = height - labelHeight - 2;
const barWidth = Math.floor((width - 4) / n);
const startX = x + 2 + (width - 4 - barWidth * n) / 2;
for (let i = 0; i < n; i++) {
const layer = layers[i];
const barH = Math.max(1, layer.relativeFlow * barAreaHeight);
const bx = startX + i * barWidth;
const by = y + barAreaHeight - barH + 1;
// Color based on per-layer health
let color;
if (status === 'converged') {
color = COLOR_CONVERGED;
} else if (layer.relativeFlow < 0.15 && i > 0) {
color = COLOR_DANGER; // vanishing at this layer
} else if (layer.relativeFlow > 0.85 && i === n - 1 && n > 1) {
color = COLOR_WARNING; // potential explosion
} else {
color = COLOR_HEALTHY;
}
ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, 0.75)`;
ctx.fillRect(bx + 1, by, barWidth - 2, barH);
// Layer label
ctx.font = '7px monospace';
ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, 0.7)`;
ctx.textBaseline = 'top';
ctx.textAlign = 'center';
ctx.fillText(this._layerLabels[i], bx + barWidth / 2, y + barAreaHeight + 2);
}
// Status label at top
ctx.font = '8px monospace';
ctx.textBaseline = 'top';
ctx.textAlign = 'left';
let statusColor;
if (status === 'healthy') statusColor = COLOR_HEALTHY;
else if (status === 'vanishing') statusColor = COLOR_DANGER;
else if (status === 'exploding') statusColor = COLOR_WARNING;
else statusColor = COLOR_CONVERGED;
ctx.fillStyle = `rgba(${statusColor.r}, ${statusColor.g}, ${statusColor.b}, 0.8)`;
ctx.fillText(`G: ${status}`, x + 2, y + 1);
}
// -----------------------------------------------------------------------
// Internal
// -----------------------------------------------------------------------
_computeFlow() {
const before = this._beforeWeights;
const after = this._afterWeights;
if (!before || !after || before.length !== after.length) {
this._flow = null;
return;
}
const layers = [];
const norms = [];
for (let li = 0; li < this._numLayers; li++) {
const offset = this._layerOffsets[li];
const count = this._layerWeightCounts[li];
// Compute L2 norm of weight delta for this layer
let sumSq = 0;
for (let j = 0; j < count; j++) {
const idx = offset + j;
if (idx < before.length && idx < after.length) {
const delta = after[idx] - before[idx];
sumSq += delta * delta;
}
}
const norm = Math.sqrt(sumSq);
norms.push(norm);
layers.push({
name: `${this._layerLabels[li]} (${this._layerSizes[li]}\u2192${this._layerSizes[li + 1]})`,
gradientNorm: norm,
relativeFlow: 0, // computed below
});
}
// Compute relative flow (normalize to max)
const maxNorm = Math.max(...norms, 1e-12);
for (let i = 0; i < layers.length; i++) {
layers[i].relativeFlow = norms[i] / maxNorm;
}
// Detect status
let status = 'healthy';
// Check convergence: all norms very small
if (norms.every(n => n < CONVERGED_THRESHOLD)) {
status = 'converged';
} else if (norms.length >= 2) {
// Check vanishing: each successive layer < VANISHING_RATIO of previous
let vanishing = true;
let exploding = true;
for (let i = 1; i < norms.length; i++) {
const prev = norms[i - 1];
const curr = norms[i];
if (prev <= CONVERGED_THRESHOLD) {
// Can't assess ratio with near-zero denominator
vanishing = false;
exploding = false;
break;
}
const ratio = curr / prev;
if (ratio >= VANISHING_RATIO) vanishing = false;
if (ratio <= EXPLODING_RATIO) exploding = false;
}
if (vanishing) status = 'vanishing';
else if (exploding) status = 'exploding';
}
this._flow = { layers, status };
}
}