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:
parent
d9be808aaa
commit
3db90b035a
7 changed files with 94 additions and 31 deletions
21
CLAUDE.md
21
CLAUDE.md
|
|
@ -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).
|
||||
|
||||
### URL Parameters
|
||||
|
||||
| Param | Range | Default | Effect |
|
||||
|-------|-------|---------|--------|
|
||||
| `tame` | 0–1 | 1 | Constrains synth output ranges toward safe limits |
|
||||
| `spread` | 0–1 | 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
|
||||
|
||||
The 126 synth parameters in `js/synth/param-map.js` were curated from the C15's 287 total parameters. Excluded categories:
|
||||
|
|
|
|||
|
|
@ -360,9 +360,9 @@ function padPresetOutputs(outputs) {
|
|||
function init() {
|
||||
// Parse ?tame URL param
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
tameLevel = parseFloat(urlParams.get('tame') ?? '0');
|
||||
spreadLevel = parseFloat(urlParams.get('spread') ?? '0');
|
||||
if (isNaN(spreadLevel)) spreadLevel = 0;
|
||||
tameLevel = parseFloat(urlParams.get('tame') ?? '1');
|
||||
spreadLevel = parseFloat(urlParams.get('spread') ?? '0.6');
|
||||
if (isNaN(spreadLevel)) spreadLevel = 0.6;
|
||||
spreadLevel = Math.max(0, Math.min(1, spreadLevel));
|
||||
|
||||
// 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 => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('#output-toggle .pill-opt').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
syncOutputToggles(btn.dataset.mode);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
outputMode = mode;
|
||||
buildHeatmap();
|
||||
|
|
@ -869,6 +881,9 @@ function onTrain() {
|
|||
|
||||
function onRandomize() {
|
||||
iml.randomiseWeights(spreadLevel);
|
||||
iml.setInput(0, joyX);
|
||||
iml.setInput(1, joyY);
|
||||
iml.process();
|
||||
const outputs = iml.getOutputs();
|
||||
routeOutputs(outputs);
|
||||
updateHeatmap(outputs);
|
||||
|
|
@ -908,9 +923,10 @@ function onThumbsUp() {
|
|||
}
|
||||
|
||||
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();
|
||||
routeOutputs(outputs);
|
||||
|
|
@ -1340,9 +1356,7 @@ function loadState() {
|
|||
// Restore output mode
|
||||
if (state.outputMode && state.outputMode !== outputMode) {
|
||||
setOutputMode(state.outputMode);
|
||||
document.querySelectorAll('#output-toggle .pill-opt').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.mode === outputMode);
|
||||
});
|
||||
syncOutputToggles(outputMode);
|
||||
}
|
||||
|
||||
console.log(`[NISPS] Restored ${state.features?.length || 0} examples from storage`);
|
||||
|
|
|
|||
|
|
@ -46,10 +46,10 @@ let arpeggiator = null;
|
|||
// Devmode: tame level (0 = no mitigation, 1 = strongest)
|
||||
// Set via URL ?tame=0.7 or window.setTameLevel(0.7)
|
||||
const _urlParams = new URLSearchParams(location.search);
|
||||
let tameLevel = parseFloat(_urlParams.get('tame') ?? '0.7');
|
||||
if (isNaN(tameLevel)) tameLevel = 0.7;
|
||||
let tameLevel = parseFloat(_urlParams.get('tame') ?? '1');
|
||||
if (isNaN(tameLevel)) tameLevel = 1;
|
||||
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;
|
||||
spreadLevel = Math.max(0, Math.min(1, spreadLevel));
|
||||
window.setTameLevel = (v) => { tameLevel = Math.max(0, Math.min(1, v)); console.log(`[NISPS] tame=${tameLevel}`); };
|
||||
|
|
@ -345,6 +345,9 @@ function onTrain() {
|
|||
|
||||
function onRandomize() {
|
||||
iml.randomiseWeights(spreadLevel);
|
||||
iml.setInput(0, joystick.x);
|
||||
iml.setInput(1, joystick.y);
|
||||
iml.process();
|
||||
const outputs = iml.getOutputs();
|
||||
routeOutputs(outputs);
|
||||
paramDisplay.update(outputs);
|
||||
|
|
@ -387,10 +390,12 @@ function onThumbsUp() {
|
|||
|
||||
function onThumbsDown() {
|
||||
// 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
|
||||
iml.moveWeights(noiseLevel);
|
||||
// Perturb weights (spread scales noise per-layer by 1/sqrt(fan_in))
|
||||
iml.moveWeights(noiseLevel, spreadLevel);
|
||||
|
||||
const outputs = iml.getOutputs();
|
||||
routeOutputs(outputs);
|
||||
|
|
|
|||
|
|
@ -90,8 +90,8 @@ function heatmapColor(t) {
|
|||
|
||||
// --- Tame URL param ---
|
||||
const _urlParams = new URLSearchParams(window.location.search);
|
||||
const tameLevel = parseFloat(_urlParams.get('tame') ?? '0');
|
||||
const spreadLevel = Math.max(0, Math.min(1, parseFloat(_urlParams.get('spread') ?? '0') || 0));
|
||||
const tameLevel = parseFloat(_urlParams.get('tame') ?? '1');
|
||||
const spreadLevel = Math.max(0, Math.min(1, parseFloat(_urlParams.get('spread') ?? '0.6') || 0.6));
|
||||
|
||||
// --- State ---
|
||||
let iml;
|
||||
|
|
@ -736,6 +736,9 @@ function onTrain() {
|
|||
|
||||
function onRandomize() {
|
||||
iml.randomiseWeights(spreadLevel);
|
||||
iml.setInput(0, joyX);
|
||||
iml.setInput(1, joyY);
|
||||
iml.process();
|
||||
const outputs = iml.getOutputs();
|
||||
routeOutputs(outputs);
|
||||
updateAllParamBars(outputs);
|
||||
|
|
@ -772,8 +775,9 @@ function onThumbsUp() {
|
|||
}
|
||||
|
||||
function onThumbsDown() {
|
||||
noiseLevel = Math.min(noiseLevel * 1.5, 0.3);
|
||||
iml.moveWeights(noiseLevel);
|
||||
const noiseCap = 0.3 * (1 - spreadLevel) + 0.05 * spreadLevel;
|
||||
noiseLevel = Math.min(noiseLevel * 1.5, noiseCap);
|
||||
iml.moveWeights(noiseLevel, spreadLevel);
|
||||
const outputs = iml.getOutputs();
|
||||
routeOutputs(outputs);
|
||||
updateAllParamBars(outputs);
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ const PRESETS = [
|
|||
|
||||
// --- Tame URL param ---
|
||||
const _urlParams = new URLSearchParams(window.location.search);
|
||||
const tameLevel = parseFloat(_urlParams.get('tame') ?? '0');
|
||||
const spreadLevel = Math.max(0, Math.min(1, parseFloat(_urlParams.get('spread') ?? '0') || 0));
|
||||
const tameLevel = parseFloat(_urlParams.get('tame') ?? '1');
|
||||
const spreadLevel = Math.max(0, Math.min(1, parseFloat(_urlParams.get('spread') ?? '0.6') || 0.6));
|
||||
|
||||
// ============================================================
|
||||
// State
|
||||
|
|
@ -630,6 +630,10 @@ function wireTeachActions() {
|
|||
document.getElementById('btn-clear')?.addEventListener('click', () => {
|
||||
iml.clearDataset();
|
||||
iml.randomiseWeights(spreadLevel);
|
||||
iml.setInput(0, joystickX);
|
||||
iml.setInput(1, joystickY);
|
||||
iml.process();
|
||||
routeOutputs(iml.getOutputs());
|
||||
selectedPreset = -1;
|
||||
document.querySelectorAll('.preset-thumb').forEach(b => b.classList.remove('selected'));
|
||||
updateTeachStatus();
|
||||
|
|
@ -688,8 +692,9 @@ function onThumbsUp() {
|
|||
}
|
||||
|
||||
function onThumbsDown() {
|
||||
noiseLevel = Math.min(0.3, noiseLevel * 1.5);
|
||||
iml.moveWeights(noiseLevel);
|
||||
const noiseCap = 0.3 * (1 - spreadLevel) + 0.05 * spreadLevel;
|
||||
noiseLevel = Math.min(noiseCap, noiseLevel * 1.5);
|
||||
iml.moveWeights(noiseLevel, spreadLevel);
|
||||
iml.setInput(0, joystickX);
|
||||
iml.setInput(1, joystickY);
|
||||
iml.process();
|
||||
|
|
|
|||
|
|
@ -158,8 +158,9 @@ export class IML {
|
|||
}
|
||||
|
||||
// Add Gaussian noise to weights (for RL exploration)
|
||||
moveWeights(speed) {
|
||||
this.mlp.moveWeights(speed);
|
||||
// spread: 0 = flat noise, 1 = Xavier-scaled per layer
|
||||
moveWeights(speed, spread = 0) {
|
||||
this.mlp.moveWeights(speed, spread);
|
||||
// Run inference to show effect
|
||||
this.inputUpdated = true;
|
||||
this.process();
|
||||
|
|
|
|||
|
|
@ -221,16 +221,29 @@ export class MLP {
|
|||
}
|
||||
|
||||
// MoveWeights - add Gaussian noise (port of gen_randn)
|
||||
moveWeights(speed) {
|
||||
for (const layer of this.layers) {
|
||||
for (const node of layer.nodes) {
|
||||
// spread: 0 = flat noise across all layers (original), 1 = scale noise by 1/sqrt(fan_in)
|
||||
// per layer so perturbations stay proportional to Xavier-scale weights and don't
|
||||
// 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++) {
|
||||
// Decay toward zero to prevent magnitude drift
|
||||
node.weights[j] *= decay;
|
||||
// gen_randn: sum of 3 uniform randoms * kN_times * stddev + mean
|
||||
let accum = 0;
|
||||
for (let n = 0; n < 3; n++) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue