diff --git a/playground/js/nisps/dataset.js b/playground/js/nisps/dataset.js index 93fc4a9..8b5c40e 100644 --- a/playground/js/nisps/dataset.js +++ b/playground/js/nisps/dataset.js @@ -37,6 +37,80 @@ export class Dataset { 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() { return this.features.length; } diff --git a/playground/js/nisps/iml.js b/playground/js/nisps/iml.js index 4e095b0..f6cd233 100644 --- a/playground/js/nisps/iml.js +++ b/playground/js/nisps/iml.js @@ -52,6 +52,9 @@ export class IML { this.lossHistory = []; this.totalTrainingIterations = 0; 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) { @@ -182,6 +185,12 @@ export class IML { return null; } + const sampleWeights = this.dataset.computeWeights(this.weightingMode, { + recencyBias: this.recencyBias, + queryInput: this.inputState, + radius: this.localRadius, + }); + this.log('Training...'); this.lastLoss = this.mlp.train( features, @@ -189,7 +198,7 @@ export class IML { this.learningRate, this.maxIterations, this.convergenceThreshold, - options + { ...options, sampleWeights } ); const latestHistory = this.mlp.lastTrainingHistory || []; diff --git a/playground/js/nisps/mlp.js b/playground/js/nisps/mlp.js index d836a5b..395a072 100644 --- a/playground/js/nisps/mlp.js +++ b/playground/js/nisps/mlp.js @@ -67,8 +67,10 @@ export class MLP { } // 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 = {}) { const sampleSizeRecip = 1 / features.length; + const sampleWeights = options.sampleWeights || null; let loss = 0; const history = []; const onIteration = typeof options.onIteration === 'function' ? options.onIteration : null; @@ -77,10 +79,11 @@ export class MLP { loss = 0; for (let s = 0; s < features.length; s++) { + const weight = sampleWeights ? sampleWeights[s] : sampleSizeRecip; const { output, activations } = this.getOutput(features[s], false); 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 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); if (onIteration) onIteration(iter, loss); diff --git a/playground/js/nisps/nisps-wasm-worker.js b/playground/js/nisps/nisps-wasm-worker.js index 4949463..45ba9b4 100644 --- a/playground/js/nisps/nisps-wasm-worker.js +++ b/playground/js/nisps/nisps-wasm-worker.js @@ -18,7 +18,7 @@ async function ensureModule() { weightCount: mod.cwrap('nisps_mlp_weight_count', 'number', ['number']), getWeights: mod.cwrap('nisps_mlp_get_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']), free: mod.cwrap('nisps_free', null, ['number']), allocInt: mod.cwrap('nisps_alloc_int', 'number', ['number']), @@ -57,7 +57,7 @@ self.onmessage = async function(e) { const { layerSizes, activationIds, weights, - features, labels, + features, labels, sampleWeights, nInputs, nOutputs, learningRate, maxIterations, convergenceThreshold, } = payload; @@ -97,16 +97,19 @@ self.onmessage = async function(e) { const featPtr = toHeapF32(featFlat); const labPtr = toHeapF32(labFlat); + const weightPtr = sampleWeights ? toHeapF32(new Float32Array(sampleWeights)) : 0; // Train const loss = w.train( mlp, featPtr, nSamples, featureDim, labPtr, nOutputs, + weightPtr, learningRate, maxIterations, convergenceThreshold ); w.free(featPtr); w.free(labPtr); + if (weightPtr) w.free(weightPtr); // Extract trained weights const outPtr = w.alloc(weightCount); diff --git a/playground/js/nisps/nisps-wasm.js b/playground/js/nisps/nisps-wasm.js index 55e8663..8cdffcc 100644 --- a/playground/js/nisps/nisps-wasm.js +++ b/playground/js/nisps/nisps-wasm.js @@ -26,7 +26,7 @@ function wrapModule(mod) { getWeights: mod.cwrap('nisps_mlp_get_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']), - 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']), moveWeightsSpread: mod.cwrap('nisps_mlp_move_weights_spread', null, ['number', 'number', 'number']), alloc: mod.cwrap('nisps_alloc', 'number', ['number']), @@ -86,6 +86,9 @@ export class WasmIML { this.maxIterations = maxIterations; this.learningRate = learningRate; 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 const BIAS = 1; @@ -221,15 +224,23 @@ export class WasmIML { this.weightsRandomised = false; } - const features = this.dataset.features; - const labels = this.dataset.labels; - if (features.length === 0) { + if (this.dataset.features.length === 0) { this.log('Empty dataset, skipping training.'); return null; } 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 const featureDim = this.nInputs + 1; // +bias const nSamples = features.length; @@ -248,15 +259,18 @@ export class WasmIML { const featPtr = toHeapF32(this._w, featFlat); const labPtr = toHeapF32(this._w, labFlat); + const weightPtr = toHeapF32(this._w, sampleWeights); const loss = this._w.train( this._mlp, featPtr, nSamples, featureDim, labPtr, this.nOutputs, + weightPtr, this.learningRate, this.maxIterations, this.convergenceThreshold ); this._w.free(featPtr); this._w.free(labPtr); + this._w.free(weightPtr); this.lastLoss = loss; // We don't have per-iteration history from WASM (single return value), @@ -374,6 +388,11 @@ export class WasmIML { const flatWeights = this._getFlatWeights(); const features = this.dataset.features; 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 if (!this._worker) { @@ -418,6 +437,7 @@ export class WasmIML { weights: flatWeights, features, labels, + sampleWeights, nInputs: this.nInputs, nOutputs: this.nOutputs, learningRate: this.learningRate,