feat(playground/nisps): recency & spatial weighted training in JS ML engine

Add Dataset.computeWeights() with three modes:
- global: exponential recency decay (newest examples weighted higher)
- local: spatial suppression of older examples near the current input
- combined: both applied together

IML and WasmIML now compute weights on every train() call using the
active mode. Exposes recencyBias, weightingMode, localRadius properties.
WASM worker path passes sampleWeights through to C++ via the new binding.
This commit is contained in:
w1n5t0n 2026-04-02 20:35:31 +01:00
parent c8d7779699
commit aa0ffcfd32
5 changed files with 119 additions and 9 deletions

View file

@ -37,6 +37,80 @@ export class Dataset {
return this.labels; return this.labels;
} }
/**
* Compute per-sample training weights. Returns Float32Array normalized to sum to 1.
* @param {'global'|'local'|'combined'} mode
* @param {object} params
* @param {number} params.recencyBias - 0 = uniform, 1 = strong recency (global/combined)
* @param {number[]} [params.queryInput] - current input position (local/combined)
* @param {number} [params.radius] - spatial radius in input space (local/combined), default 0.15
* @returns {Float32Array} weights summing to 1
*/
computeWeights(mode = 'global', params = {}) {
const n = this.features.length;
if (n === 0) return new Float32Array(0);
if (n === 1) return new Float32Array([1.0]);
const weights = new Float32Array(n).fill(1.0);
// Global recency: exponential decay — newest = 1, each older *= decay
if (mode === 'global' || mode === 'combined') {
const bias = params.recencyBias ?? 0.6;
if (bias > 0) {
// decay per step: at bias=1, decay=0.7 (newest ~10x oldest for 10 examples)
// at bias=0.5, decay=0.85 (gentler)
const decay = 1 - 0.3 * bias;
for (let i = n - 2; i >= 0; i--) {
weights[i] = weights[i + 1] * decay;
}
}
}
// Local recency: newer examples near the query suppress older nearby ones
if ((mode === 'local' || mode === 'combined') && params.queryInput) {
const query = params.queryInput;
const radius = params.radius ?? 0.15;
const radiusSq = radius * radius;
for (let i = 0; i < n; i++) {
const feat = this.features[i];
// Distance from this example to the query point
let distSq = 0;
for (let d = 0; d < feat.length; d++) {
const diff = feat[d] - (query[d] ?? 0);
distSq += diff * diff;
}
if (distSq < radiusSq) {
// Count newer examples also within the radius
const proximity = 1 - Math.sqrt(distSq) / radius; // 1 = on top, 0 = at edge
let newerNearby = 0;
for (let j = i + 1; j < n; j++) {
let djSq = 0;
for (let d = 0; d < feat.length; d++) {
const diff = feat[d] - this.features[j][d];
djSq += diff * diff;
}
if (djSq < radiusSq) newerNearby++;
}
// Suppress: more newer neighbors + closer to query = more suppression
if (newerNearby > 0) {
weights[i] *= Math.pow(1 - proximity, newerNearby);
}
}
}
}
// Normalize to sum to 1
let sum = 0;
for (let i = 0; i < n; i++) sum += weights[i];
if (sum > 0) {
for (let i = 0; i < n; i++) weights[i] /= sum;
}
return weights;
}
get size() { get size() {
return this.features.length; return this.features.length;
} }

View file

@ -52,6 +52,9 @@ export class IML {
this.lossHistory = []; this.lossHistory = [];
this.totalTrainingIterations = 0; this.totalTrainingIterations = 0;
this.logFn = null; this.logFn = null;
this.recencyBias = 0.6; // 0 = uniform, 1 = strong recency
this.weightingMode = 'global'; // 'global' | 'local' | 'combined'
this.localRadius = 0.15; // input-space radius for local weighting
} }
setLogger(fn) { setLogger(fn) {
@ -182,6 +185,12 @@ export class IML {
return null; return null;
} }
const sampleWeights = this.dataset.computeWeights(this.weightingMode, {
recencyBias: this.recencyBias,
queryInput: this.inputState,
radius: this.localRadius,
});
this.log('Training...'); this.log('Training...');
this.lastLoss = this.mlp.train( this.lastLoss = this.mlp.train(
features, features,
@ -189,7 +198,7 @@ export class IML {
this.learningRate, this.learningRate,
this.maxIterations, this.maxIterations,
this.convergenceThreshold, this.convergenceThreshold,
options { ...options, sampleWeights }
); );
const latestHistory = this.mlp.lastTrainingHistory || []; const latestHistory = this.mlp.lastTrainingHistory || [];

View file

@ -67,8 +67,10 @@ export class MLP {
} }
// Per-sample SGD training (Train method from C++) // Per-sample SGD training (Train method from C++)
// sampleWeights: optional Float32Array of per-sample weights (normalized, sum to 1)
train(features, labels, learningRate, maxIterations = 1000, minError = 0.00001, options = {}) { train(features, labels, learningRate, maxIterations = 1000, minError = 0.00001, options = {}) {
const sampleSizeRecip = 1 / features.length; const sampleSizeRecip = 1 / features.length;
const sampleWeights = options.sampleWeights || null;
let loss = 0; let loss = 0;
const history = []; const history = [];
const onIteration = typeof options.onIteration === 'function' ? options.onIteration : null; const onIteration = typeof options.onIteration === 'function' ? options.onIteration : null;
@ -77,10 +79,11 @@ export class MLP {
loss = 0; loss = 0;
for (let s = 0; s < features.length; s++) { for (let s = 0; s < features.length; s++) {
const weight = sampleWeights ? sampleWeights[s] : sampleSizeRecip;
const { output, activations } = this.getOutput(features[s], false); const { output, activations } = this.getOutput(features[s], false);
const derivError = new Array(output.length); const derivError = new Array(output.length);
loss += mseLoss(labels[s], output, derivError, sampleSizeRecip); loss += mseLoss(labels[s], output, derivError, weight);
// Backprop with direct weight update // Backprop with direct weight update
let tempDerivError = derivError; let tempDerivError = derivError;
@ -90,7 +93,8 @@ export class MLP {
} }
} }
loss *= sampleSizeRecip; // Custom weights already normalized — loss is already correctly scaled
if (!sampleWeights) loss *= sampleSizeRecip;
history.push(loss); history.push(loss);
if (onIteration) onIteration(iter, loss); if (onIteration) onIteration(iter, loss);

View file

@ -18,7 +18,7 @@ async function ensureModule() {
weightCount: mod.cwrap('nisps_mlp_weight_count', 'number', ['number']), weightCount: mod.cwrap('nisps_mlp_weight_count', 'number', ['number']),
getWeights: mod.cwrap('nisps_mlp_get_weights', null, ['number', 'number']), getWeights: mod.cwrap('nisps_mlp_get_weights', null, ['number', 'number']),
setWeights: mod.cwrap('nisps_mlp_set_weights', null, ['number', 'number']), setWeights: mod.cwrap('nisps_mlp_set_weights', null, ['number', 'number']),
train: mod.cwrap('nisps_mlp_train', 'number', ['number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number']), train: mod.cwrap('nisps_mlp_train', 'number', ['number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number']),
alloc: mod.cwrap('nisps_alloc', 'number', ['number']), alloc: mod.cwrap('nisps_alloc', 'number', ['number']),
free: mod.cwrap('nisps_free', null, ['number']), free: mod.cwrap('nisps_free', null, ['number']),
allocInt: mod.cwrap('nisps_alloc_int', 'number', ['number']), allocInt: mod.cwrap('nisps_alloc_int', 'number', ['number']),
@ -57,7 +57,7 @@ self.onmessage = async function(e) {
const { const {
layerSizes, activationIds, weights, layerSizes, activationIds, weights,
features, labels, features, labels, sampleWeights,
nInputs, nOutputs, nInputs, nOutputs,
learningRate, maxIterations, convergenceThreshold, learningRate, maxIterations, convergenceThreshold,
} = payload; } = payload;
@ -97,16 +97,19 @@ self.onmessage = async function(e) {
const featPtr = toHeapF32(featFlat); const featPtr = toHeapF32(featFlat);
const labPtr = toHeapF32(labFlat); const labPtr = toHeapF32(labFlat);
const weightPtr = sampleWeights ? toHeapF32(new Float32Array(sampleWeights)) : 0;
// Train // Train
const loss = w.train( const loss = w.train(
mlp, featPtr, nSamples, featureDim, mlp, featPtr, nSamples, featureDim,
labPtr, nOutputs, labPtr, nOutputs,
weightPtr,
learningRate, maxIterations, convergenceThreshold learningRate, maxIterations, convergenceThreshold
); );
w.free(featPtr); w.free(featPtr);
w.free(labPtr); w.free(labPtr);
if (weightPtr) w.free(weightPtr);
// Extract trained weights // Extract trained weights
const outPtr = w.alloc(weightCount); const outPtr = w.alloc(weightCount);

View file

@ -26,7 +26,7 @@ function wrapModule(mod) {
getWeights: mod.cwrap('nisps_mlp_get_weights', null, ['number', 'number']), getWeights: mod.cwrap('nisps_mlp_get_weights', null, ['number', 'number']),
setWeights: mod.cwrap('nisps_mlp_set_weights', null, ['number', 'number']), setWeights: mod.cwrap('nisps_mlp_set_weights', null, ['number', 'number']),
inference: mod.cwrap('nisps_mlp_inference', null, ['number', 'number', 'number', 'number', 'number']), inference: mod.cwrap('nisps_mlp_inference', null, ['number', 'number', 'number', 'number', 'number']),
train: mod.cwrap('nisps_mlp_train', 'number', ['number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number']), train: mod.cwrap('nisps_mlp_train', 'number', ['number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number']),
drawWeightsSpread: mod.cwrap('nisps_mlp_draw_weights_spread', null, ['number', 'number']), drawWeightsSpread: mod.cwrap('nisps_mlp_draw_weights_spread', null, ['number', 'number']),
moveWeightsSpread: mod.cwrap('nisps_mlp_move_weights_spread', null, ['number', 'number', 'number']), moveWeightsSpread: mod.cwrap('nisps_mlp_move_weights_spread', null, ['number', 'number', 'number']),
alloc: mod.cwrap('nisps_alloc', 'number', ['number']), alloc: mod.cwrap('nisps_alloc', 'number', ['number']),
@ -86,6 +86,9 @@ export class WasmIML {
this.maxIterations = maxIterations; this.maxIterations = maxIterations;
this.learningRate = learningRate; this.learningRate = learningRate;
this.convergenceThreshold = convergenceThreshold; this.convergenceThreshold = convergenceThreshold;
this.recencyBias = 0.6; // 0 = uniform, 1 = strong recency
this.weightingMode = 'global'; // 'global' | 'local' | 'combined'
this.localRadius = 0.15; // input-space radius for local weighting
// Layer sizes: input+bias, hidden..., output // Layer sizes: input+bias, hidden..., output
const BIAS = 1; const BIAS = 1;
@ -221,15 +224,23 @@ export class WasmIML {
this.weightsRandomised = false; this.weightsRandomised = false;
} }
const features = this.dataset.features; if (this.dataset.features.length === 0) {
const labels = this.dataset.labels;
if (features.length === 0) {
this.log('Empty dataset, skipping training.'); this.log('Empty dataset, skipping training.');
return null; return null;
} }
this.log('Training...'); this.log('Training...');
const features = this.dataset.features;
const labels = this.dataset.labels;
// Compute per-sample weights
const sampleWeights = this.dataset.computeWeights(this.weightingMode, {
recencyBias: this.recencyBias,
queryInput: this.inputState,
radius: this.localRadius,
});
// Build flat arrays with bias appended to features // Build flat arrays with bias appended to features
const featureDim = this.nInputs + 1; // +bias const featureDim = this.nInputs + 1; // +bias
const nSamples = features.length; const nSamples = features.length;
@ -248,15 +259,18 @@ export class WasmIML {
const featPtr = toHeapF32(this._w, featFlat); const featPtr = toHeapF32(this._w, featFlat);
const labPtr = toHeapF32(this._w, labFlat); const labPtr = toHeapF32(this._w, labFlat);
const weightPtr = toHeapF32(this._w, sampleWeights);
const loss = this._w.train( const loss = this._w.train(
this._mlp, featPtr, nSamples, featureDim, this._mlp, featPtr, nSamples, featureDim,
labPtr, this.nOutputs, labPtr, this.nOutputs,
weightPtr,
this.learningRate, this.maxIterations, this.convergenceThreshold this.learningRate, this.maxIterations, this.convergenceThreshold
); );
this._w.free(featPtr); this._w.free(featPtr);
this._w.free(labPtr); this._w.free(labPtr);
this._w.free(weightPtr);
this.lastLoss = loss; this.lastLoss = loss;
// We don't have per-iteration history from WASM (single return value), // We don't have per-iteration history from WASM (single return value),
@ -374,6 +388,11 @@ export class WasmIML {
const flatWeights = this._getFlatWeights(); const flatWeights = this._getFlatWeights();
const features = this.dataset.features; const features = this.dataset.features;
const labels = this.dataset.labels; const labels = this.dataset.labels;
const sampleWeights = Array.from(this.dataset.computeWeights(this.weightingMode, {
recencyBias: this.recencyBias,
queryInput: this.inputState,
radius: this.localRadius,
}));
// Lazy-init worker // Lazy-init worker
if (!this._worker) { if (!this._worker) {
@ -418,6 +437,7 @@ export class WasmIML {
weights: flatWeights, weights: flatWeights,
features, features,
labels, labels,
sampleWeights,
nInputs: this.nInputs, nInputs: this.nInputs,
nOutputs: this.nOutputs, nOutputs: this.nOutputs,
learningRate: this.learningRate, learningRate: this.learningRate,