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:
parent
c8d7779699
commit
aa0ffcfd32
5 changed files with 119 additions and 9 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || [];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue