feat(playground): add spread param for sigmoid saturation control and fix randomise sync

Add ?spread=0-1 URL param that controls weight initialization scaling,
RL noise scaling per layer, noise cap, and weight decay to prevent
sigmoid output saturation. At spread=0 (original behavior) weights are
uniform [-1,1] and outputs polarise near 0/1. At spread=1 weights use
Xavier scaling (1/sqrt(fan_in)), noise is proportionally reduced, and
10% weight decay per thumbs-down prevents unbounded magnitude drift.

Also fix randomise to re-inject current joystick position and re-run
inference before routing outputs, eliminating the jump on first
joystick move after randomise.

Defaults: tame=1, spread=0.6 across all app variants.
This commit is contained in:
w1n5t0n 2026-03-22 02:10:26 +02:00
parent d9be808aaa
commit 3db90b035a
7 changed files with 94 additions and 31 deletions

View file

@ -38,6 +38,27 @@ The `playground/` directory contains a browser-based interactive demo of the NIS
Key files: `js/nisps/` (ML core port), `js/ui/` (visualizer, joystick, controls), `js/synth/` (C15 bridge, param map, arpeggiator), `js/app.js` (wiring). Key files: `js/nisps/` (ML core port), `js/ui/` (visualizer, joystick, controls), `js/synth/` (C15 bridge, param map, arpeggiator), `js/app.js` (wiring).
### URL Parameters
| Param | Range | Default | Effect |
|-------|-------|---------|--------|
| `tame` | 01 | 1 | Constrains synth output ranges toward safe limits |
| `spread` | 01 | 0.6 | Controls weight initialization, RL noise scaling, and weight decay (see below) |
#### `spread` — sigmoid saturation control
The MLP uses ReLU hidden layers with a sigmoid output layer. With uniform [-1,1] weights, the sum of many weighted inputs at each layer drives sigmoid pre-activations far from zero (std dev ≈ √fan_in), causing outputs to saturate near 0 or 1. The `spread` parameter addresses this:
- **`spread=0`** (polarised): Weights drawn from uniform [-1,1]. RL noise cap = 0.3. Noise applied uniformly across layers. Outputs cluster at extremes — good for exploration of radical mappings.
- **`spread=1`** (centered): Weights scaled by 1/√fan_in per layer (Xavier initialization). RL noise cap = 0.05. Noise also scaled per-layer. Weight decay prevents magnitude drift. Outputs spread across the full [0,1] range — better for fine-grained RL shaping.
- **Intermediate values** interpolate linearly between these two regimes.
Affects four code paths:
1. **`drawWeights(spread)`** — initial randomisation weight scale
2. **`moveWeights(speed, spread)`** — RL exploration noise scale per layer
3. **Weight decay in `moveWeights`** — each call decays weights by `10% * spread` before adding noise, preventing unbounded magnitude drift from repeated thumbs-down. At spread=0 there is no decay (original behavior). At spread=1, weights decay ~10% per call, creating a natural equilibrium where exploration noise and decay balance out rather than weights growing until sigmoid permanently saturates.
4. **Noise cap** in thumbs-down handler — `0.3*(1-spread) + 0.05*spread`
### C15 Parameter Map ### C15 Parameter Map
The 126 synth parameters in `js/synth/param-map.js` were curated from the C15's 287 total parameters. Excluded categories: The 126 synth parameters in `js/synth/param-map.js` were curated from the C15's 287 total parameters. Excluded categories:

View file

@ -360,9 +360,9 @@ function padPresetOutputs(outputs) {
function init() { function init() {
// Parse ?tame URL param // Parse ?tame URL param
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
tameLevel = parseFloat(urlParams.get('tame') ?? '0'); tameLevel = parseFloat(urlParams.get('tame') ?? '1');
spreadLevel = parseFloat(urlParams.get('spread') ?? '0'); spreadLevel = parseFloat(urlParams.get('spread') ?? '0.6');
if (isNaN(spreadLevel)) spreadLevel = 0; if (isNaN(spreadLevel)) spreadLevel = 0.6;
spreadLevel = Math.max(0, Math.min(1, spreadLevel)); spreadLevel = Math.max(0, Math.min(1, spreadLevel));
// IML — fresh random weights each boot, no state restoration // IML — fresh random weights each boot, no state restoration
@ -754,11 +754,18 @@ function wireControls() {
}); });
}); });
// Output mode toggle // Output mode toggle (sheet)
document.querySelectorAll('#output-toggle .pill-opt').forEach(btn => { document.querySelectorAll('#output-toggle .pill-opt').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
document.querySelectorAll('#output-toggle .pill-opt').forEach(b => b.classList.remove('active')); syncOutputToggles(btn.dataset.mode);
btn.classList.add('active'); setOutputMode(btn.dataset.mode);
});
});
// Output mode toggle (floating)
document.querySelectorAll('#output-toggle-float .otf-opt').forEach(btn => {
btn.addEventListener('click', () => {
syncOutputToggles(btn.dataset.mode);
setOutputMode(btn.dataset.mode); setOutputMode(btn.dataset.mode);
}); });
}); });
@ -800,6 +807,11 @@ function updateModeUI() {
} }
} }
function syncOutputToggles(mode) {
document.querySelectorAll('#output-toggle .pill-opt').forEach(b => b.classList.toggle('active', b.dataset.mode === mode));
document.querySelectorAll('#output-toggle-float .otf-opt').forEach(b => b.classList.toggle('active', b.dataset.mode === mode));
}
function setOutputMode(mode) { function setOutputMode(mode) {
outputMode = mode; outputMode = mode;
buildHeatmap(); buildHeatmap();
@ -869,6 +881,9 @@ function onTrain() {
function onRandomize() { function onRandomize() {
iml.randomiseWeights(spreadLevel); iml.randomiseWeights(spreadLevel);
iml.setInput(0, joyX);
iml.setInput(1, joyY);
iml.process();
const outputs = iml.getOutputs(); const outputs = iml.getOutputs();
routeOutputs(outputs); routeOutputs(outputs);
updateHeatmap(outputs); updateHeatmap(outputs);
@ -908,9 +923,10 @@ function onThumbsUp() {
} }
function onThumbsDown() { function onThumbsDown() {
noiseLevel = Math.min(noiseLevel * 1.5, 0.3); const noiseCap = 0.3 * (1 - spreadLevel) + 0.05 * spreadLevel;
noiseLevel = Math.min(noiseLevel * 1.5, noiseCap);
iml.moveWeights(noiseLevel); iml.moveWeights(noiseLevel, spreadLevel);
const outputs = iml.getOutputs(); const outputs = iml.getOutputs();
routeOutputs(outputs); routeOutputs(outputs);
@ -1340,9 +1356,7 @@ function loadState() {
// Restore output mode // Restore output mode
if (state.outputMode && state.outputMode !== outputMode) { if (state.outputMode && state.outputMode !== outputMode) {
setOutputMode(state.outputMode); setOutputMode(state.outputMode);
document.querySelectorAll('#output-toggle .pill-opt').forEach(b => { syncOutputToggles(outputMode);
b.classList.toggle('active', b.dataset.mode === outputMode);
});
} }
console.log(`[NISPS] Restored ${state.features?.length || 0} examples from storage`); console.log(`[NISPS] Restored ${state.features?.length || 0} examples from storage`);

View file

@ -46,10 +46,10 @@ let arpeggiator = null;
// Devmode: tame level (0 = no mitigation, 1 = strongest) // Devmode: tame level (0 = no mitigation, 1 = strongest)
// Set via URL ?tame=0.7 or window.setTameLevel(0.7) // Set via URL ?tame=0.7 or window.setTameLevel(0.7)
const _urlParams = new URLSearchParams(location.search); const _urlParams = new URLSearchParams(location.search);
let tameLevel = parseFloat(_urlParams.get('tame') ?? '0.7'); let tameLevel = parseFloat(_urlParams.get('tame') ?? '1');
if (isNaN(tameLevel)) tameLevel = 0.7; if (isNaN(tameLevel)) tameLevel = 1;
tameLevel = Math.max(0, Math.min(1, tameLevel)); tameLevel = Math.max(0, Math.min(1, tameLevel));
let spreadLevel = parseFloat(_urlParams.get('spread') ?? '0'); let spreadLevel = parseFloat(_urlParams.get('spread') ?? '0.6');
if (isNaN(spreadLevel)) spreadLevel = 0.6; if (isNaN(spreadLevel)) spreadLevel = 0.6;
spreadLevel = Math.max(0, Math.min(1, spreadLevel)); spreadLevel = Math.max(0, Math.min(1, spreadLevel));
window.setTameLevel = (v) => { tameLevel = Math.max(0, Math.min(1, v)); console.log(`[NISPS] tame=${tameLevel}`); }; window.setTameLevel = (v) => { tameLevel = Math.max(0, Math.min(1, v)); console.log(`[NISPS] tame=${tameLevel}`); };
@ -345,6 +345,9 @@ function onTrain() {
function onRandomize() { function onRandomize() {
iml.randomiseWeights(spreadLevel); iml.randomiseWeights(spreadLevel);
iml.setInput(0, joystick.x);
iml.setInput(1, joystick.y);
iml.process();
const outputs = iml.getOutputs(); const outputs = iml.getOutputs();
routeOutputs(outputs); routeOutputs(outputs);
paramDisplay.update(outputs); paramDisplay.update(outputs);
@ -387,10 +390,12 @@ function onThumbsUp() {
function onThumbsDown() { function onThumbsDown() {
// Increase noise for more exploration // Increase noise for more exploration
noiseLevel = Math.min(noiseLevel * 1.5, 0.3); // spread reduces the noise cap: at spread=1 cap is 0.05 (vs 0.3 at spread=0)
const noiseCap = 0.3 * (1 - spreadLevel) + 0.05 * spreadLevel;
noiseLevel = Math.min(noiseLevel * 1.5, noiseCap);
// Perturb weights // Perturb weights (spread scales noise per-layer by 1/sqrt(fan_in))
iml.moveWeights(noiseLevel); iml.moveWeights(noiseLevel, spreadLevel);
const outputs = iml.getOutputs(); const outputs = iml.getOutputs();
routeOutputs(outputs); routeOutputs(outputs);

View file

@ -90,8 +90,8 @@ function heatmapColor(t) {
// --- Tame URL param --- // --- Tame URL param ---
const _urlParams = new URLSearchParams(window.location.search); const _urlParams = new URLSearchParams(window.location.search);
const tameLevel = parseFloat(_urlParams.get('tame') ?? '0'); const tameLevel = parseFloat(_urlParams.get('tame') ?? '1');
const spreadLevel = Math.max(0, Math.min(1, parseFloat(_urlParams.get('spread') ?? '0') || 0)); const spreadLevel = Math.max(0, Math.min(1, parseFloat(_urlParams.get('spread') ?? '0.6') || 0.6));
// --- State --- // --- State ---
let iml; let iml;
@ -736,6 +736,9 @@ function onTrain() {
function onRandomize() { function onRandomize() {
iml.randomiseWeights(spreadLevel); iml.randomiseWeights(spreadLevel);
iml.setInput(0, joyX);
iml.setInput(1, joyY);
iml.process();
const outputs = iml.getOutputs(); const outputs = iml.getOutputs();
routeOutputs(outputs); routeOutputs(outputs);
updateAllParamBars(outputs); updateAllParamBars(outputs);
@ -772,8 +775,9 @@ function onThumbsUp() {
} }
function onThumbsDown() { function onThumbsDown() {
noiseLevel = Math.min(noiseLevel * 1.5, 0.3); const noiseCap = 0.3 * (1 - spreadLevel) + 0.05 * spreadLevel;
iml.moveWeights(noiseLevel); noiseLevel = Math.min(noiseLevel * 1.5, noiseCap);
iml.moveWeights(noiseLevel, spreadLevel);
const outputs = iml.getOutputs(); const outputs = iml.getOutputs();
routeOutputs(outputs); routeOutputs(outputs);
updateAllParamBars(outputs); updateAllParamBars(outputs);

View file

@ -48,8 +48,8 @@ const PRESETS = [
// --- Tame URL param --- // --- Tame URL param ---
const _urlParams = new URLSearchParams(window.location.search); const _urlParams = new URLSearchParams(window.location.search);
const tameLevel = parseFloat(_urlParams.get('tame') ?? '0'); const tameLevel = parseFloat(_urlParams.get('tame') ?? '1');
const spreadLevel = Math.max(0, Math.min(1, parseFloat(_urlParams.get('spread') ?? '0') || 0)); const spreadLevel = Math.max(0, Math.min(1, parseFloat(_urlParams.get('spread') ?? '0.6') || 0.6));
// ============================================================ // ============================================================
// State // State
@ -630,6 +630,10 @@ function wireTeachActions() {
document.getElementById('btn-clear')?.addEventListener('click', () => { document.getElementById('btn-clear')?.addEventListener('click', () => {
iml.clearDataset(); iml.clearDataset();
iml.randomiseWeights(spreadLevel); iml.randomiseWeights(spreadLevel);
iml.setInput(0, joystickX);
iml.setInput(1, joystickY);
iml.process();
routeOutputs(iml.getOutputs());
selectedPreset = -1; selectedPreset = -1;
document.querySelectorAll('.preset-thumb').forEach(b => b.classList.remove('selected')); document.querySelectorAll('.preset-thumb').forEach(b => b.classList.remove('selected'));
updateTeachStatus(); updateTeachStatus();
@ -688,8 +692,9 @@ function onThumbsUp() {
} }
function onThumbsDown() { function onThumbsDown() {
noiseLevel = Math.min(0.3, noiseLevel * 1.5); const noiseCap = 0.3 * (1 - spreadLevel) + 0.05 * spreadLevel;
iml.moveWeights(noiseLevel); noiseLevel = Math.min(noiseCap, noiseLevel * 1.5);
iml.moveWeights(noiseLevel, spreadLevel);
iml.setInput(0, joystickX); iml.setInput(0, joystickX);
iml.setInput(1, joystickY); iml.setInput(1, joystickY);
iml.process(); iml.process();

View file

@ -158,8 +158,9 @@ export class IML {
} }
// Add Gaussian noise to weights (for RL exploration) // Add Gaussian noise to weights (for RL exploration)
moveWeights(speed) { // spread: 0 = flat noise, 1 = Xavier-scaled per layer
this.mlp.moveWeights(speed); moveWeights(speed, spread = 0) {
this.mlp.moveWeights(speed, spread);
// Run inference to show effect // Run inference to show effect
this.inputUpdated = true; this.inputUpdated = true;
this.process(); this.process();

View file

@ -221,16 +221,29 @@ export class MLP {
} }
// MoveWeights - add Gaussian noise (port of gen_randn) // MoveWeights - add Gaussian noise (port of gen_randn)
moveWeights(speed) { // spread: 0 = flat noise across all layers (original), 1 = scale noise by 1/sqrt(fan_in)
for (const layer of this.layers) { // per layer so perturbations stay proportional to Xavier-scale weights and don't
for (const node of layer.nodes) { // saturate sigmoid outputs.
// Weight decay (proportional to spread) shrinks weights toward zero before adding noise,
// preventing unbounded magnitude drift from repeated thumbs-down. At spread=0 there is
// no decay (original behavior). At spread=1 each call decays weights by ~10%, creating
// a natural equilibrium where exploration can't permanently saturate sigmoid.
moveWeights(speed, spread = 0) {
const decay = 1 - 0.1 * spread; // spread=0 → 1.0 (no decay), spread=1 → 0.9
for (let l = 0; l < this.layers.length; l++) {
const fanIn = this.layersNodes[l];
const xavierScale = 1 / Math.sqrt(fanIn);
const layerScale = 1 * (1 - spread) + xavierScale * spread;
for (const node of this.layers[l].nodes) {
for (let j = 0; j < node.weights.length; j++) { for (let j = 0; j < node.weights.length; j++) {
// Decay toward zero to prevent magnitude drift
node.weights[j] *= decay;
// gen_randn: sum of 3 uniform randoms * kN_times * stddev + mean // gen_randn: sum of 3 uniform randoms * kN_times * stddev + mean
let accum = 0; let accum = 0;
for (let n = 0; n < 3; n++) { for (let n = 0; n < 3; n++) {
accum += Math.random() * 2 - 1; // gen_rand with range 2.0 accum += Math.random() * 2 - 1; // gen_rand with range 2.0
} }
node.weights[j] = 3 * accum * speed + node.weights[j]; node.weights[j] += 3 * accum * speed * layerScale;
} }
} }
} }