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.
218 lines
6.5 KiB
JavaScript
218 lines
6.5 KiB
JavaScript
/**
|
|
* Weight Health Indicator — ambient indicator showing network weight statistics.
|
|
*
|
|
* Analyzes a flat weight array and produces health status + compact visualization.
|
|
* Call update() periodically (not every frame) with the current flat weights.
|
|
*
|
|
* @module weight-health
|
|
*/
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Thresholds
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Weight magnitude below this is considered "dead" */
|
|
const DEAD_THRESHOLD = 0.01;
|
|
|
|
/** Weight magnitude above this drives sigmoid >99% saturated */
|
|
const SATURATING_THRESHOLD = 3.0;
|
|
|
|
/** If more than this fraction of weights are dead, status = 'dead' */
|
|
const DEAD_FRACTION_LIMIT = 0.30;
|
|
|
|
/** If more than this fraction of weights are saturating, status = 'saturating' */
|
|
const SATURATED_FRACTION_LIMIT = 0.40;
|
|
|
|
/** Number of histogram bins for weight magnitude distribution */
|
|
const HISTOGRAM_BINS = 10;
|
|
|
|
/** Histogram bin range: [0, MAX_HISTOGRAM_MAG] */
|
|
const MAX_HISTOGRAM_MAG = 5.0;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Colors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const COLOR_HEALTHY = { r: 100, g: 200, b: 120 }; // calm green
|
|
const COLOR_SATURATING = { r: 255, g: 120, b: 40 }; // hot orange
|
|
const COLOR_DEAD = { r: 120, g: 120, b: 120 }; // dim gray
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WeightHealthIndicator
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class WeightHealthIndicator {
|
|
constructor() {
|
|
this._status = null;
|
|
this._pulsePhase = 0;
|
|
}
|
|
|
|
/**
|
|
* Update from current weights. Call periodically (e.g. every 500ms).
|
|
*
|
|
* @param {number[]|Float32Array} weightsArray — flat array of all network weights
|
|
*/
|
|
update(weightsArray) {
|
|
if (!weightsArray || weightsArray.length === 0) {
|
|
this._status = null;
|
|
return;
|
|
}
|
|
|
|
const n = weightsArray.length;
|
|
let sumMag = 0;
|
|
let maxMag = 0;
|
|
let deadCount = 0;
|
|
let saturatedCount = 0;
|
|
|
|
const histogram = new Array(HISTOGRAM_BINS).fill(0);
|
|
const binWidth = MAX_HISTOGRAM_MAG / HISTOGRAM_BINS;
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const mag = Math.abs(weightsArray[i]);
|
|
sumMag += mag;
|
|
if (mag > maxMag) maxMag = mag;
|
|
|
|
if (mag < DEAD_THRESHOLD) deadCount++;
|
|
if (mag > SATURATING_THRESHOLD) saturatedCount++;
|
|
|
|
const bin = Math.min(Math.floor(mag / binWidth), HISTOGRAM_BINS - 1);
|
|
histogram[bin]++;
|
|
}
|
|
|
|
const meanMagnitude = sumMag / n;
|
|
const deadFraction = deadCount / n;
|
|
const saturatedFraction = saturatedCount / n;
|
|
|
|
// Normalize histogram to fractions
|
|
for (let i = 0; i < HISTOGRAM_BINS; i++) {
|
|
histogram[i] /= n;
|
|
}
|
|
|
|
let health;
|
|
if (deadFraction > DEAD_FRACTION_LIMIT) {
|
|
health = 'dead';
|
|
} else if (saturatedFraction > SATURATED_FRACTION_LIMIT) {
|
|
health = 'saturating';
|
|
} else {
|
|
health = 'healthy';
|
|
}
|
|
|
|
this._status = {
|
|
health,
|
|
meanMagnitude,
|
|
maxMagnitude: maxMag,
|
|
deadFraction,
|
|
saturatedFraction,
|
|
histogram,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get the current health status.
|
|
* @returns {object|null} Status object or null if never updated.
|
|
*/
|
|
getStatus() {
|
|
return this._status;
|
|
}
|
|
|
|
/**
|
|
* Draw a compact visual indicator (ambient glow bar).
|
|
*
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @param {number} x — left edge
|
|
* @param {number} y — top edge
|
|
* @param {number} width — total width
|
|
* @param {number} height — total height
|
|
*/
|
|
draw(ctx, x, y, width, height) {
|
|
if (!this._status) {
|
|
// No data — draw a dim placeholder
|
|
ctx.fillStyle = 'rgba(60, 60, 60, 0.4)';
|
|
ctx.fillRect(x, y, width, height);
|
|
return;
|
|
}
|
|
|
|
const { health, deadFraction, saturatedFraction, histogram } = this._status;
|
|
|
|
// Advance pulse phase
|
|
this._pulsePhase += 0.04;
|
|
|
|
// Determine base color + glow intensity
|
|
let color;
|
|
let glowAlpha;
|
|
let pulseAmount = 0;
|
|
|
|
if (health === 'healthy') {
|
|
color = COLOR_HEALTHY;
|
|
glowAlpha = 0.6 + 0.15 * Math.sin(this._pulsePhase * 0.3); // gentle breathing
|
|
pulseAmount = 0;
|
|
} else if (health === 'saturating') {
|
|
color = COLOR_SATURATING;
|
|
glowAlpha = 0.7;
|
|
pulseAmount = 0.25 * (0.5 + 0.5 * Math.sin(this._pulsePhase * 1.5)); // pulsing
|
|
} else {
|
|
// dead
|
|
color = COLOR_DEAD;
|
|
glowAlpha = 0.3;
|
|
pulseAmount = 0;
|
|
}
|
|
|
|
const alpha = glowAlpha + pulseAmount;
|
|
|
|
// Background
|
|
ctx.fillStyle = 'rgba(20, 20, 20, 0.6)';
|
|
ctx.fillRect(x, y, width, height);
|
|
|
|
// Draw mini histogram bars
|
|
const barWidth = width / HISTOGRAM_BINS;
|
|
const maxBinVal = Math.max(...histogram, 0.01);
|
|
|
|
for (let i = 0; i < HISTOGRAM_BINS; i++) {
|
|
const barH = (histogram[i] / maxBinVal) * height;
|
|
const bx = x + i * barWidth;
|
|
const by = y + height - barH;
|
|
|
|
// Color gradient: first bins (low magnitude) lean toward dead color,
|
|
// last bins (high magnitude) lean toward saturating color
|
|
const t = i / (HISTOGRAM_BINS - 1);
|
|
let r, g, b;
|
|
if (t < 0.3) {
|
|
// Low magnitude — blend dead/healthy
|
|
const lt = t / 0.3;
|
|
r = COLOR_DEAD.r + (color.r - COLOR_DEAD.r) * lt;
|
|
g = COLOR_DEAD.g + (color.g - COLOR_DEAD.g) * lt;
|
|
b = COLOR_DEAD.b + (color.b - COLOR_DEAD.b) * lt;
|
|
} else if (t > 0.7) {
|
|
// High magnitude — blend toward saturating
|
|
const lt = (t - 0.7) / 0.3;
|
|
r = color.r + (COLOR_SATURATING.r - color.r) * lt;
|
|
g = color.g + (COLOR_SATURATING.g - color.g) * lt;
|
|
b = color.b + (COLOR_SATURATING.b - color.b) * lt;
|
|
} else {
|
|
r = color.r;
|
|
g = color.g;
|
|
b = color.b;
|
|
}
|
|
|
|
ctx.fillStyle = `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${alpha.toFixed(2)})`;
|
|
ctx.fillRect(bx, by, barWidth - 1, barH);
|
|
}
|
|
|
|
// Top glow line
|
|
ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${(alpha * 0.8).toFixed(2)})`;
|
|
ctx.fillRect(x, y, width, 1);
|
|
|
|
// Status label
|
|
ctx.font = '8px monospace';
|
|
ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${(alpha * 0.9).toFixed(2)})`;
|
|
ctx.textBaseline = 'top';
|
|
ctx.textAlign = 'left';
|
|
|
|
const label = health === 'healthy'
|
|
? `W: ok`
|
|
: health === 'saturating'
|
|
? `W: sat ${(saturatedFraction * 100).toFixed(0)}%`
|
|
: `W: dead ${(deadFraction * 100).toFixed(0)}%`;
|
|
ctx.fillText(label, x + 2, y + 1);
|
|
}
|
|
}
|