@seanhogg/builderforce-memory-engine 2026.10.1 → 2026.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/evermind/delta_wire.d.ts +16 -1
- package/dist/evermind/delta_wire.d.ts.map +1 -1
- package/dist/evermind/delta_wire.js +22 -2
- package/dist/evermind/delta_wire.js.map +1 -1
- package/dist/evermind/index.d.ts +2 -2
- package/dist/evermind/index.d.ts.map +1 -1
- package/dist/evermind/index.js +1 -1
- package/dist/evermind/index.js.map +1 -1
- package/dist/evermind/merge.d.ts +5 -2
- package/dist/evermind/merge.d.ts.map +1 -1
- package/dist/evermind/merge.js +5 -2
- package/dist/evermind/merge.js.map +1 -1
- package/dist/evermind/ml_head.d.ts +28 -0
- package/dist/evermind/ml_head.d.ts.map +1 -0
- package/dist/evermind/ml_head.js +47 -0
- package/dist/evermind/ml_head.js.map +1 -0
- package/dist/evermind/package.d.ts +2 -2
- package/dist/evermind/package.d.ts.map +1 -1
- package/dist/evermind/package.js +1 -1
- package/dist/evermind/package.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/limbic/limbic_model.d.ts.map +1 -1
- package/dist/limbic/limbic_model.js +1 -3
- package/dist/limbic/limbic_model.js.map +1 -1
- package/dist/ml/classical/anomaly_composite.d.ts +32 -0
- package/dist/ml/classical/anomaly_composite.d.ts.map +1 -0
- package/dist/ml/classical/anomaly_composite.js +29 -0
- package/dist/ml/classical/anomaly_composite.js.map +1 -0
- package/dist/ml/classical/ewma.d.ts +6 -1
- package/dist/ml/classical/ewma.d.ts.map +1 -1
- package/dist/ml/classical/ewma.js +5 -3
- package/dist/ml/classical/ewma.js.map +1 -1
- package/dist/ml/classical/gbm.d.ts.map +1 -1
- package/dist/ml/classical/gbm.js +2 -1
- package/dist/ml/classical/gbm.js.map +1 -1
- package/dist/ml/classical/index.d.ts +4 -0
- package/dist/ml/classical/index.d.ts.map +1 -1
- package/dist/ml/classical/index.js +4 -0
- package/dist/ml/classical/index.js.map +1 -1
- package/dist/ml/classical/kmeans.d.ts.map +1 -1
- package/dist/ml/classical/kmeans.js +23 -4
- package/dist/ml/classical/kmeans.js.map +1 -1
- package/dist/ml/classical/knn.d.ts +4 -1
- package/dist/ml/classical/knn.d.ts.map +1 -1
- package/dist/ml/classical/knn.js +2 -1
- package/dist/ml/classical/knn.js.map +1 -1
- package/dist/ml/classical/logistic.d.ts +1 -9
- package/dist/ml/classical/logistic.d.ts.map +1 -1
- package/dist/ml/classical/logistic.js +17 -9
- package/dist/ml/classical/logistic.js.map +1 -1
- package/dist/ml/classical/naive_bayes.d.ts +5 -1
- package/dist/ml/classical/naive_bayes.d.ts.map +1 -1
- package/dist/ml/classical/naive_bayes.js +8 -3
- package/dist/ml/classical/naive_bayes.js.map +1 -1
- package/dist/ml/classical/robust_z.d.ts +9 -1
- package/dist/ml/classical/robust_z.d.ts.map +1 -1
- package/dist/ml/classical/robust_z.js +2 -1
- package/dist/ml/classical/robust_z.js.map +1 -1
- package/dist/ml/eval_loss.d.ts +15 -0
- package/dist/ml/eval_loss.d.ts.map +1 -0
- package/dist/ml/eval_loss.js +20 -0
- package/dist/ml/eval_loss.js.map +1 -0
- package/dist/ml/federate.d.ts +11 -25
- package/dist/ml/federate.d.ts.map +1 -1
- package/dist/ml/federate.js +15 -38
- package/dist/ml/federate.js.map +1 -1
- package/dist/ml/head_trainer.d.ts +4 -2
- package/dist/ml/head_trainer.d.ts.map +1 -1
- package/dist/ml/head_trainer.js +19 -19
- package/dist/ml/head_trainer.js.map +1 -1
- package/dist/ml/run.d.ts +11 -2
- package/dist/ml/run.d.ts.map +1 -1
- package/dist/ml/run.js +56 -17
- package/dist/ml/run.js.map +1 -1
- package/dist/ml/tasks/experience.d.ts +42 -5
- package/dist/ml/tasks/experience.d.ts.map +1 -1
- package/dist/ml/tasks/experience.js +76 -19
- package/dist/ml/tasks/experience.js.map +1 -1
- package/dist/ml/tasks/run_anomaly.d.ts +20 -19
- package/dist/ml/tasks/run_anomaly.d.ts.map +1 -1
- package/dist/ml/tasks/run_anomaly.js +20 -30
- package/dist/ml/tasks/run_anomaly.js.map +1 -1
- package/dist/ml/train.d.ts +2 -7
- package/dist/ml/train.d.ts.map +1 -1
- package/dist/ml/train.js +2 -19
- package/dist/ml/train.js.map +1 -1
- package/dist/ml/types.d.ts +6 -3
- package/dist/ml/types.d.ts.map +1 -1
- package/dist/ml/types.js +3 -2
- package/dist/ml/types.js.map +1 -1
- package/package.json +1 -1
- package/src/evermind/delta_wire.ts +173 -148
- package/src/evermind/index.ts +36 -35
- package/src/evermind/merge.ts +10 -4
- package/src/evermind/ml_head.ts +63 -0
- package/src/evermind/package.ts +488 -488
- package/src/index.ts +2 -0
- package/src/limbic/limbic_model.ts +1 -4
- package/src/limbic/limbic_trainer.ts +91 -91
- package/src/ml/classical/anomaly_composite.ts +46 -0
- package/src/ml/classical/ewma.ts +7 -1
- package/src/ml/classical/gbm.ts +144 -143
- package/src/ml/classical/index.ts +6 -0
- package/src/ml/classical/kmeans.ts +22 -4
- package/src/ml/classical/knn.ts +6 -2
- package/src/ml/classical/logistic.ts +9 -9
- package/src/ml/classical/math.ts +93 -93
- package/src/ml/classical/naive_bayes.ts +10 -3
- package/src/ml/classical/robust_z.ts +11 -2
- package/src/ml/eval_loss.ts +27 -0
- package/src/ml/federate.ts +22 -52
- package/src/ml/head_trainer.ts +21 -42
- package/src/ml/registry.ts +44 -44
- package/src/ml/run.ts +57 -13
- package/src/ml/tasks/experience.ts +98 -19
- package/src/ml/tasks/run_anomaly.ts +30 -36
- package/src/ml/train.ts +3 -19
- package/src/ml/types.ts +135 -132
- package/src/training/cpu_ops.ts +141 -141
package/src/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ export {
|
|
|
43
43
|
buildDeltaLearnPayload,
|
|
44
44
|
decodeDeltaPayload,
|
|
45
45
|
deltaUnusableReason,
|
|
46
|
+
deltaTargetMismatch,
|
|
46
47
|
MAX_DELTA_B64_CHARS,
|
|
47
48
|
mergeCheckpointDiffs,
|
|
48
49
|
mergeCheckpointDiffsGated,
|
|
@@ -63,6 +64,7 @@ export type {
|
|
|
63
64
|
AdaptTokenizer,
|
|
64
65
|
DeltaLearnPayload,
|
|
65
66
|
DeltaParseResult,
|
|
67
|
+
DeltaParseOptions,
|
|
66
68
|
MergeResult,
|
|
67
69
|
GatedMergeResult,
|
|
68
70
|
MergeGateOptions,
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* (experience, state) → affect pair is close to an independent regression.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { sigmoid } from "../training/cpu_ops.js";
|
|
31
32
|
import { SeededRng } from "../utils/rng.js";
|
|
32
33
|
import { quantizeFp16, dequantizeFp16 } from "../utils/quantization.js";
|
|
33
34
|
import { LIMBIC_STATE_DIM } from "./regions.js";
|
|
@@ -86,10 +87,6 @@ const MAGIC = 0x4c4d4243; // "LMBC"
|
|
|
86
87
|
/** Fixed default init seed — reproducible byte-identical cold start across machines. */
|
|
87
88
|
export const DEFAULT_LIMBIC_SEED = 0x11b1c5ee;
|
|
88
89
|
|
|
89
|
-
function sigmoid(x: number): number {
|
|
90
|
-
return 1 / (1 + Math.exp(-x));
|
|
91
|
-
}
|
|
92
|
-
|
|
93
90
|
export class LimbicModel {
|
|
94
91
|
readonly config: Required<Omit<LimbicModelConfig, "seed">>;
|
|
95
92
|
|
|
@@ -1,91 +1,91 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* limbic_trainer.ts – LimbicTrainer: gradient-based training for the LimbicModel.
|
|
3
|
-
*
|
|
4
|
-
* Trains the affect head to predict affect deltas and reward from
|
|
5
|
-
* (experience embedding, current state) pairs, using full-batch gradient
|
|
6
|
-
* descent with AdamW — through the shared {@link HeadTrainer} (`ml/head_trainer.ts`),
|
|
7
|
-
* which runs the optimiser step on the GPU via WEIGHT_UPDATE_WGSL when a device is
|
|
8
|
-
* supplied and on a numerically-equivalent CPU AdamW otherwise. This file is only
|
|
9
|
-
* the limbic registration: the head adapter and the public trainer surface.
|
|
10
|
-
*
|
|
11
|
-
* The objective is MSE (regression), not the cross-entropy used by the
|
|
12
|
-
* language-model {@link MambaTrainer} — a limbic experience has continuous
|
|
13
|
-
* targets, not a next-token distribution.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { HeadTrainer, type HeadTrainOptions, type TrainableHead } from "../ml/head_trainer.js";
|
|
17
|
-
import type { LimbicModel, LimbicParam } from "./limbic_model.js";
|
|
18
|
-
|
|
19
|
-
/** One training example: an experience and the affect change it should produce. */
|
|
20
|
-
export interface LimbicSample {
|
|
21
|
-
/** Experience embedding (length = model.inputDim). */
|
|
22
|
-
input: ArrayLike<number>;
|
|
23
|
-
/** Affective state at the time of the experience (length = model.stateDim). */
|
|
24
|
-
state: ArrayLike<number>;
|
|
25
|
-
/** Observed affect delta target in (-1, 1) per state dim (length = model.stateDim). */
|
|
26
|
-
deltaTarget: ArrayLike<number>;
|
|
27
|
-
/** Observed scalar reward for the experience. */
|
|
28
|
-
reward: number;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** The limbic trainer's options are the shared head trainer's. */
|
|
32
|
-
export type LimbicTrainOptions = HeadTrainOptions;
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* The limbic model as a {@link TrainableHead}. Each sample is one experience
|
|
36
|
-
* appraisal: cross-experience memory flows through the affective state `s` (fed
|
|
37
|
-
* back by the runtime), not through the hidden scratch `h`, so the hidden state
|
|
38
|
-
* resets per sample. This also makes BPTT(1) exact — there is no truncated carry.
|
|
39
|
-
*/
|
|
40
|
-
export function limbicHead(model: LimbicModel): TrainableHead<LimbicSample> {
|
|
41
|
-
return {
|
|
42
|
-
parameters: (): LimbicParam[] => model.parameters(),
|
|
43
|
-
gradients: (): LimbicParam[] => model.gradients(),
|
|
44
|
-
zeroGrad: () => model.zeroGrad(),
|
|
45
|
-
accumulate: (s) =>
|
|
46
|
-
model.backwardStep(s.input, model.initHidden(), s.state, s.deltaTarget, s.reward).loss,
|
|
47
|
-
loss: (s) => {
|
|
48
|
-
const { rewardWeight } = model.config;
|
|
49
|
-
const f = model.forward(s.input, model.initHidden(), s.state);
|
|
50
|
-
let loss = 0;
|
|
51
|
-
for (let k = 0; k < f.delta.length; k++) {
|
|
52
|
-
const d = f.delta[k]! - (s.deltaTarget[k] ?? 0);
|
|
53
|
-
loss += 0.5 * d * d;
|
|
54
|
-
}
|
|
55
|
-
const rd = f.reward - s.reward;
|
|
56
|
-
loss += 0.5 * rewardWeight * rd * rd;
|
|
57
|
-
return loss;
|
|
58
|
-
},
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Thin registration of the limbic head over the shared {@link HeadTrainer}. */
|
|
63
|
-
export class LimbicTrainer {
|
|
64
|
-
readonly model: LimbicModel;
|
|
65
|
-
readonly device: GPUDevice | null;
|
|
66
|
-
private readonly _trainer: HeadTrainer<LimbicSample>;
|
|
67
|
-
|
|
68
|
-
constructor(model: LimbicModel, device: GPUDevice | null = null) {
|
|
69
|
-
this.model = model;
|
|
70
|
-
this.device = device;
|
|
71
|
-
this._trainer = new HeadTrainer(limbicHead(model), device, "LimbicTrainer");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Whether the optimiser step runs on the GPU. */
|
|
75
|
-
get gpuTraining(): boolean {
|
|
76
|
-
return this._trainer.gpuTraining;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Train on a batch of samples for `epochs` passes. Returns the per-epoch mean
|
|
81
|
-
* loss (monotonically decreasing on a learnable mapping).
|
|
82
|
-
*/
|
|
83
|
-
train(samples: LimbicSample[], opts: LimbicTrainOptions = {}): Promise<number[]> {
|
|
84
|
-
return this._trainer.train(samples, opts);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Mean MSE loss over samples (no weight update). Hidden resets per sample. */
|
|
88
|
-
evaluate(samples: LimbicSample[]): number {
|
|
89
|
-
return this._trainer.evaluate(samples);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* limbic_trainer.ts – LimbicTrainer: gradient-based training for the LimbicModel.
|
|
3
|
+
*
|
|
4
|
+
* Trains the affect head to predict affect deltas and reward from
|
|
5
|
+
* (experience embedding, current state) pairs, using full-batch gradient
|
|
6
|
+
* descent with AdamW — through the shared {@link HeadTrainer} (`ml/head_trainer.ts`),
|
|
7
|
+
* which runs the optimiser step on the GPU via WEIGHT_UPDATE_WGSL when a device is
|
|
8
|
+
* supplied and on a numerically-equivalent CPU AdamW otherwise. This file is only
|
|
9
|
+
* the limbic registration: the head adapter and the public trainer surface.
|
|
10
|
+
*
|
|
11
|
+
* The objective is MSE (regression), not the cross-entropy used by the
|
|
12
|
+
* language-model {@link MambaTrainer} — a limbic experience has continuous
|
|
13
|
+
* targets, not a next-token distribution.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { HeadTrainer, type HeadTrainOptions, type TrainableHead } from "../ml/head_trainer.js";
|
|
17
|
+
import type { LimbicModel, LimbicParam } from "./limbic_model.js";
|
|
18
|
+
|
|
19
|
+
/** One training example: an experience and the affect change it should produce. */
|
|
20
|
+
export interface LimbicSample {
|
|
21
|
+
/** Experience embedding (length = model.inputDim). */
|
|
22
|
+
input: ArrayLike<number>;
|
|
23
|
+
/** Affective state at the time of the experience (length = model.stateDim). */
|
|
24
|
+
state: ArrayLike<number>;
|
|
25
|
+
/** Observed affect delta target in (-1, 1) per state dim (length = model.stateDim). */
|
|
26
|
+
deltaTarget: ArrayLike<number>;
|
|
27
|
+
/** Observed scalar reward for the experience. */
|
|
28
|
+
reward: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The limbic trainer's options are the shared head trainer's. */
|
|
32
|
+
export type LimbicTrainOptions = HeadTrainOptions;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The limbic model as a {@link TrainableHead}. Each sample is one experience
|
|
36
|
+
* appraisal: cross-experience memory flows through the affective state `s` (fed
|
|
37
|
+
* back by the runtime), not through the hidden scratch `h`, so the hidden state
|
|
38
|
+
* resets per sample. This also makes BPTT(1) exact — there is no truncated carry.
|
|
39
|
+
*/
|
|
40
|
+
export function limbicHead(model: LimbicModel): TrainableHead<LimbicSample> {
|
|
41
|
+
return {
|
|
42
|
+
parameters: (): LimbicParam[] => model.parameters(),
|
|
43
|
+
gradients: (): LimbicParam[] => model.gradients(),
|
|
44
|
+
zeroGrad: () => model.zeroGrad(),
|
|
45
|
+
accumulate: (s) =>
|
|
46
|
+
model.backwardStep(s.input, model.initHidden(), s.state, s.deltaTarget, s.reward).loss,
|
|
47
|
+
loss: (s) => {
|
|
48
|
+
const { rewardWeight } = model.config;
|
|
49
|
+
const f = model.forward(s.input, model.initHidden(), s.state);
|
|
50
|
+
let loss = 0;
|
|
51
|
+
for (let k = 0; k < f.delta.length; k++) {
|
|
52
|
+
const d = f.delta[k]! - (s.deltaTarget[k] ?? 0);
|
|
53
|
+
loss += 0.5 * d * d;
|
|
54
|
+
}
|
|
55
|
+
const rd = f.reward - s.reward;
|
|
56
|
+
loss += 0.5 * rewardWeight * rd * rd;
|
|
57
|
+
return loss;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Thin registration of the limbic head over the shared {@link HeadTrainer}. */
|
|
63
|
+
export class LimbicTrainer {
|
|
64
|
+
readonly model: LimbicModel;
|
|
65
|
+
readonly device: GPUDevice | null;
|
|
66
|
+
private readonly _trainer: HeadTrainer<LimbicSample>;
|
|
67
|
+
|
|
68
|
+
constructor(model: LimbicModel, device: GPUDevice | null = null) {
|
|
69
|
+
this.model = model;
|
|
70
|
+
this.device = device;
|
|
71
|
+
this._trainer = new HeadTrainer(limbicHead(model), device, "LimbicTrainer");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether the optimiser step runs on the GPU. */
|
|
75
|
+
get gpuTraining(): boolean {
|
|
76
|
+
return this._trainer.gpuTraining;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Train on a batch of samples for `epochs` passes. Returns the per-epoch mean
|
|
81
|
+
* loss (monotonically decreasing on a learnable mapping).
|
|
82
|
+
*/
|
|
83
|
+
train(samples: LimbicSample[], opts: LimbicTrainOptions = {}): Promise<number[]> {
|
|
84
|
+
return this._trainer.train(samples, opts);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Mean MSE loss over samples (no weight update). Hidden resets per sample. */
|
|
88
|
+
evaluate(samples: LimbicSample[]): number {
|
|
89
|
+
return this._trainer.evaluate(samples);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ml/classical/anomaly_composite.ts — robust z and EWMA as ONE anomaly engine.
|
|
3
|
+
*
|
|
4
|
+
* Two detectors, one answer: robust z (median/MAD over the whole history — "far
|
|
5
|
+
* from normal") and EWMA (the recent trend — "drifting away from lately"). The
|
|
6
|
+
* score is the larger of the two normalised scores, so either one can flag. It is
|
|
7
|
+
* one engine because it is one fitted thing: a task that lists it gets back exactly
|
|
8
|
+
* what it stores under `local:classical:anomaly-composite`.
|
|
9
|
+
*/
|
|
10
|
+
import type { MlPrediction, MlPredictor, MlSample } from "../types.js";
|
|
11
|
+
import { EwmaModel, fitEwma, type EwmaOptions } from "./ewma.js";
|
|
12
|
+
import { anomalyConfidence, fitRobustZ, RobustZModel, type RobustZOptions } from "./robust_z.js";
|
|
13
|
+
|
|
14
|
+
export interface AnomalyCompositeOptions {
|
|
15
|
+
robustZ?: RobustZOptions;
|
|
16
|
+
ewma?: EwmaOptions;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class AnomalyCompositeModel implements MlPredictor {
|
|
20
|
+
constructor(
|
|
21
|
+
readonly population: RobustZModel,
|
|
22
|
+
readonly trend: EwmaModel,
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
/** Per feature, per detector: z / threshold (≥ 1 = out of range). */
|
|
26
|
+
featureScores(x: ArrayLike<number>): { population: number; trend: number }[] {
|
|
27
|
+
const pz = this.population.zScores(x);
|
|
28
|
+
const tz = this.trend.zScores(x);
|
|
29
|
+
return pz.map((z, i) => ({ population: z / this.population.threshold, trend: tz[i]! / this.trend.threshold }));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Fold a finished, non-anomalous observation into the trend. */
|
|
33
|
+
observe(x: ArrayLike<number>): void {
|
|
34
|
+
this.trend.update(x);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
predict(x: Float32Array): MlPrediction {
|
|
38
|
+
const score = Math.max(this.population.predict(x).scores[0]!, this.trend.predict(x).scores[0]!);
|
|
39
|
+
return { scores: [score], confidence: anomalyConfidence(score) };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Fit both detectors on a chronological history (feature vectors, oldest first). */
|
|
44
|
+
export function fitAnomalyComposite(samples: readonly MlSample[], opts: AnomalyCompositeOptions = {}): AnomalyCompositeModel {
|
|
45
|
+
return new AnomalyCompositeModel(fitRobustZ(samples, opts.robustZ), fitEwma(samples, opts.ewma));
|
|
46
|
+
}
|
package/src/ml/classical/ewma.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* Per feature, over the history IN ORDER: diff = x − μ; μ += α·diff;
|
|
6
6
|
* σ² = (1 − α)(σ² + α·diff²). A new point scores z = |x − μ| / σ against the
|
|
7
7
|
* state BEFORE it is folded in. Prediction: `scores[0] = max_i z_i / threshold`.
|
|
8
|
+
* σ is floored like robust z's scale: a fraction of |μ|, and `minScale` in absolute
|
|
9
|
+
* units — σ² starts at 0, so a constant history would otherwise flag any change.
|
|
8
10
|
*/
|
|
9
11
|
import type { MlPrediction, MlPredictor, MlSample } from "../types.js";
|
|
10
12
|
import { featureDim } from "./math.js";
|
|
@@ -17,6 +19,8 @@ export interface EwmaOptions {
|
|
|
17
19
|
threshold?: number;
|
|
18
20
|
/** Smallest σ per feature, as a fraction of |μ| (plus 1e-9). Default 0.01. */
|
|
19
21
|
minScaleFraction?: number;
|
|
22
|
+
/** Smallest σ per feature in absolute feature units. Default 0 (see `RobustZOptions.minScale`). */
|
|
23
|
+
minScale?: number;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
export class EwmaModel implements MlPredictor {
|
|
@@ -26,13 +30,14 @@ export class EwmaModel implements MlPredictor {
|
|
|
26
30
|
readonly alpha: number,
|
|
27
31
|
readonly threshold: number,
|
|
28
32
|
readonly minScaleFraction: number,
|
|
33
|
+
readonly minScale: number = 0,
|
|
29
34
|
) {
|
|
30
35
|
if (!(alpha > 0 && alpha <= 1)) throw new Error(`EwmaModel: alpha must be in (0, 1] (got ${alpha})`);
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
zScores(x: ArrayLike<number>): number[] {
|
|
34
39
|
return Array.from(this.mean, (m, i) => {
|
|
35
|
-
const sd = Math.max(Math.sqrt(this.variance[i]!), this.minScaleFraction * Math.abs(m) + 1e-9);
|
|
40
|
+
const sd = Math.max(Math.sqrt(this.variance[i]!), this.minScaleFraction * Math.abs(m) + 1e-9, this.minScale);
|
|
36
41
|
return Math.abs((x[i] ?? 0) - m) / sd;
|
|
37
42
|
});
|
|
38
43
|
}
|
|
@@ -62,6 +67,7 @@ export function fitEwma(samples: readonly MlSample[], opts: EwmaOptions = {}): E
|
|
|
62
67
|
opts.alpha ?? 0.3,
|
|
63
68
|
opts.threshold ?? 3,
|
|
64
69
|
opts.minScaleFraction ?? 0.01,
|
|
70
|
+
opts.minScale ?? 0,
|
|
65
71
|
);
|
|
66
72
|
for (let i = 1; i < samples.length; i++) model.update(samples[i]!.x);
|
|
67
73
|
return model;
|
package/src/ml/classical/gbm.ts
CHANGED
|
@@ -1,143 +1,144 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ml/classical/gbm.ts — a small gradient-boosted tree ensemble.
|
|
3
|
-
*
|
|
4
|
-
* Depth-limited regression trees fitted to the loss gradient, one after another,
|
|
5
|
-
* shrunk by a learning rate. Objectives: `binary` (log-loss, Newton leaf values)
|
|
6
|
-
* and `regression` (squared error). Exhaustive split search over midpoints —
|
|
7
|
-
* fine for the catalog's small numeric feature sets, not for wide embeddings.
|
|
8
|
-
*/
|
|
9
|
-
import type { MlPrediction, MlPredictor, MlSample } from "../types.js";
|
|
10
|
-
import { featureDim, sigmoid, type Vec } from "./math.js";
|
|
11
|
-
|
|
12
|
-
export type GbmObjective = "binary" | "regression";
|
|
13
|
-
|
|
14
|
-
export interface GbmOptions {
|
|
15
|
-
objective?: GbmObjective;
|
|
16
|
-
trees?: number;
|
|
17
|
-
maxDepth?: number;
|
|
18
|
-
learningRate?: number;
|
|
19
|
-
/** Minimum summed sample weight in a leaf. Default 1. */
|
|
20
|
-
minLeafWeight?: number;
|
|
21
|
-
/** L2 on leaf values (Newton denominator). Default 1. */
|
|
22
|
-
lambda?: number;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
type RTree = { value: number } | { feature: number; split: number; left: RTree; right: RTree };
|
|
26
|
-
|
|
27
|
-
interface Row {
|
|
28
|
-
x: Vec;
|
|
29
|
-
g: number; // gradient
|
|
30
|
-
h: number; // hessian
|
|
31
|
-
w: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function leafValue(rows: readonly Row[], lambda: number): number {
|
|
35
|
-
let g = 0;
|
|
36
|
-
let h = 0;
|
|
37
|
-
for (const r of rows) {
|
|
38
|
-
g += r.w * r.g;
|
|
39
|
-
h += r.w * r.h;
|
|
40
|
-
}
|
|
41
|
-
return -g / (h + lambda);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** XGBoost-style structure score G²/(H+λ) of a node. */
|
|
45
|
-
function gain(g: number, h: number, lambda: number): number {
|
|
46
|
-
return (g * g) / (h + lambda);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function buildTree(rows: Row[], depth: number, opts: Required<Omit<GbmOptions, "objective" | "trees" | "learningRate">>): RTree {
|
|
50
|
-
if (depth >= opts.maxDepth || rows.length < 2) return { value: leafValue(rows, opts.lambda) };
|
|
51
|
-
let G = 0;
|
|
52
|
-
let H = 0;
|
|
53
|
-
let W = 0;
|
|
54
|
-
for (const r of rows) {
|
|
55
|
-
G += r.w * r.g;
|
|
56
|
-
H += r.w * r.h;
|
|
57
|
-
W += r.w;
|
|
58
|
-
}
|
|
59
|
-
const parent = gain(G, H, opts.lambda);
|
|
60
|
-
let best: { feature: number; split: number; score: number } | null = null;
|
|
61
|
-
const d = rows[0]!.x.length;
|
|
62
|
-
for (let f = 0; f < d; f++) {
|
|
63
|
-
const sorted = [...rows].sort((a, b) => a.x[f]! - b.x[f]!);
|
|
64
|
-
let gl = 0;
|
|
65
|
-
let hl = 0;
|
|
66
|
-
let wl = 0;
|
|
67
|
-
for (let i = 0; i < sorted.length - 1; i++) {
|
|
68
|
-
const r = sorted[i]!;
|
|
69
|
-
gl += r.w * r.g;
|
|
70
|
-
hl += r.w * r.h;
|
|
71
|
-
wl += r.w;
|
|
72
|
-
const a = r.x[f]!;
|
|
73
|
-
const b = sorted[i + 1]!.x[f]!;
|
|
74
|
-
if (a === b) continue;
|
|
75
|
-
if (wl < opts.minLeafWeight || W - wl < opts.minLeafWeight) continue;
|
|
76
|
-
const score = gain(gl, hl, opts.lambda) + gain(G - gl, H - hl, opts.lambda) - parent;
|
|
77
|
-
if (score > 1e-12 && (!best || score > best.score)) best = { feature: f, split: (a + b) / 2, score };
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
if (!best) return { value: leafValue(rows, opts.lambda) };
|
|
81
|
-
const left = rows.filter((r) => r.x[best!.feature]! < best!.split);
|
|
82
|
-
const right = rows.filter((r) => r.x[best!.feature]! >= best!.split);
|
|
83
|
-
return { feature: best.feature, split: best.split, left: buildTree(left, depth + 1, opts), right: buildTree(right, depth + 1, opts) };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function treeValue(t: RTree, x: Vec): number {
|
|
87
|
-
while (!("value" in t)) t = x[t.feature]! < t.split ? t.left : t.right;
|
|
88
|
-
return t.value;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export class GbmModel implements MlPredictor {
|
|
92
|
-
constructor(
|
|
93
|
-
readonly objective: GbmObjective,
|
|
94
|
-
readonly base: number,
|
|
95
|
-
readonly trees: readonly RTree[],
|
|
96
|
-
readonly learningRate: number,
|
|
97
|
-
) {}
|
|
98
|
-
|
|
99
|
-
/** The raw additive score (log-odds for `binary`). */
|
|
100
|
-
margin(x: Vec): number {
|
|
101
|
-
let s = this.base;
|
|
102
|
-
for (const t of this.trees) s += this.learningRate * treeValue(t, x);
|
|
103
|
-
return s;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
predict(x: Float32Array): MlPrediction {
|
|
107
|
-
const m = this.margin(x);
|
|
108
|
-
if (this.objective === "regression") return { scores: [m], confidence: 1 };
|
|
109
|
-
const p = sigmoid(m);
|
|
110
|
-
return { scores: [1 - p, p], confidence: Math.max(p, 1 - p) };
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function fitGbm(samples: readonly MlSample[], opts: GbmOptions = {}): GbmModel {
|
|
115
|
-
featureDim(samples.map((s) => s.x));
|
|
116
|
-
const objective = opts.objective ?? "binary";
|
|
117
|
-
const lr = opts.learningRate ?? 0.3;
|
|
118
|
-
const treeOpts = { maxDepth: opts.maxDepth ?? 2, minLeafWeight: opts.minLeafWeight ?? 1, lambda: opts.lambda ?? 1 };
|
|
119
|
-
|
|
120
|
-
let wSum = 0;
|
|
121
|
-
let ySum = 0;
|
|
122
|
-
for (const s of samples) {
|
|
123
|
-
wSum += s.weight ?? 1;
|
|
124
|
-
ySum += (s.weight ?? 1) * s.y;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
1
|
+
/**
|
|
2
|
+
* ml/classical/gbm.ts — a small gradient-boosted tree ensemble.
|
|
3
|
+
*
|
|
4
|
+
* Depth-limited regression trees fitted to the loss gradient, one after another,
|
|
5
|
+
* shrunk by a learning rate. Objectives: `binary` (log-loss, Newton leaf values)
|
|
6
|
+
* and `regression` (squared error). Exhaustive split search over midpoints —
|
|
7
|
+
* fine for the catalog's small numeric feature sets, not for wide embeddings.
|
|
8
|
+
*/
|
|
9
|
+
import type { MlPrediction, MlPredictor, MlSample } from "../types.js";
|
|
10
|
+
import { featureDim, sigmoid, type Vec } from "./math.js";
|
|
11
|
+
|
|
12
|
+
export type GbmObjective = "binary" | "regression";
|
|
13
|
+
|
|
14
|
+
export interface GbmOptions {
|
|
15
|
+
objective?: GbmObjective;
|
|
16
|
+
trees?: number;
|
|
17
|
+
maxDepth?: number;
|
|
18
|
+
learningRate?: number;
|
|
19
|
+
/** Minimum summed sample weight in a leaf. Default 1. */
|
|
20
|
+
minLeafWeight?: number;
|
|
21
|
+
/** L2 on leaf values (Newton denominator). Default 1. */
|
|
22
|
+
lambda?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type RTree = { value: number } | { feature: number; split: number; left: RTree; right: RTree };
|
|
26
|
+
|
|
27
|
+
interface Row {
|
|
28
|
+
x: Vec;
|
|
29
|
+
g: number; // gradient
|
|
30
|
+
h: number; // hessian
|
|
31
|
+
w: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function leafValue(rows: readonly Row[], lambda: number): number {
|
|
35
|
+
let g = 0;
|
|
36
|
+
let h = 0;
|
|
37
|
+
for (const r of rows) {
|
|
38
|
+
g += r.w * r.g;
|
|
39
|
+
h += r.w * r.h;
|
|
40
|
+
}
|
|
41
|
+
return -g / (h + lambda);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** XGBoost-style structure score G²/(H+λ) of a node. */
|
|
45
|
+
function gain(g: number, h: number, lambda: number): number {
|
|
46
|
+
return (g * g) / (h + lambda);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildTree(rows: Row[], depth: number, opts: Required<Omit<GbmOptions, "objective" | "trees" | "learningRate">>): RTree {
|
|
50
|
+
if (depth >= opts.maxDepth || rows.length < 2) return { value: leafValue(rows, opts.lambda) };
|
|
51
|
+
let G = 0;
|
|
52
|
+
let H = 0;
|
|
53
|
+
let W = 0;
|
|
54
|
+
for (const r of rows) {
|
|
55
|
+
G += r.w * r.g;
|
|
56
|
+
H += r.w * r.h;
|
|
57
|
+
W += r.w;
|
|
58
|
+
}
|
|
59
|
+
const parent = gain(G, H, opts.lambda);
|
|
60
|
+
let best: { feature: number; split: number; score: number } | null = null;
|
|
61
|
+
const d = rows[0]!.x.length;
|
|
62
|
+
for (let f = 0; f < d; f++) {
|
|
63
|
+
const sorted = [...rows].sort((a, b) => a.x[f]! - b.x[f]!);
|
|
64
|
+
let gl = 0;
|
|
65
|
+
let hl = 0;
|
|
66
|
+
let wl = 0;
|
|
67
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
68
|
+
const r = sorted[i]!;
|
|
69
|
+
gl += r.w * r.g;
|
|
70
|
+
hl += r.w * r.h;
|
|
71
|
+
wl += r.w;
|
|
72
|
+
const a = r.x[f]!;
|
|
73
|
+
const b = sorted[i + 1]!.x[f]!;
|
|
74
|
+
if (a === b) continue;
|
|
75
|
+
if (wl < opts.minLeafWeight || W - wl < opts.minLeafWeight) continue;
|
|
76
|
+
const score = gain(gl, hl, opts.lambda) + gain(G - gl, H - hl, opts.lambda) - parent;
|
|
77
|
+
if (score > 1e-12 && (!best || score > best.score)) best = { feature: f, split: (a + b) / 2, score };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!best) return { value: leafValue(rows, opts.lambda) };
|
|
81
|
+
const left = rows.filter((r) => r.x[best!.feature]! < best!.split);
|
|
82
|
+
const right = rows.filter((r) => r.x[best!.feature]! >= best!.split);
|
|
83
|
+
return { feature: best.feature, split: best.split, left: buildTree(left, depth + 1, opts), right: buildTree(right, depth + 1, opts) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function treeValue(t: RTree, x: Vec): number {
|
|
87
|
+
while (!("value" in t)) t = x[t.feature]! < t.split ? t.left : t.right;
|
|
88
|
+
return t.value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class GbmModel implements MlPredictor {
|
|
92
|
+
constructor(
|
|
93
|
+
readonly objective: GbmObjective,
|
|
94
|
+
readonly base: number,
|
|
95
|
+
readonly trees: readonly RTree[],
|
|
96
|
+
readonly learningRate: number,
|
|
97
|
+
) {}
|
|
98
|
+
|
|
99
|
+
/** The raw additive score (log-odds for `binary`). */
|
|
100
|
+
margin(x: Vec): number {
|
|
101
|
+
let s = this.base;
|
|
102
|
+
for (const t of this.trees) s += this.learningRate * treeValue(t, x);
|
|
103
|
+
return s;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
predict(x: Float32Array): MlPrediction {
|
|
107
|
+
const m = this.margin(x);
|
|
108
|
+
if (this.objective === "regression") return { scores: [m], confidence: 1 };
|
|
109
|
+
const p = sigmoid(m);
|
|
110
|
+
return { scores: [1 - p, p], confidence: Math.max(p, 1 - p) };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function fitGbm(samples: readonly MlSample[], opts: GbmOptions = {}): GbmModel {
|
|
115
|
+
featureDim(samples.map((s) => s.x));
|
|
116
|
+
const objective = opts.objective ?? "binary";
|
|
117
|
+
const lr = opts.learningRate ?? 0.3;
|
|
118
|
+
const treeOpts = { maxDepth: opts.maxDepth ?? 2, minLeafWeight: opts.minLeafWeight ?? 1, lambda: opts.lambda ?? 1 };
|
|
119
|
+
|
|
120
|
+
let wSum = 0;
|
|
121
|
+
let ySum = 0;
|
|
122
|
+
for (const s of samples) {
|
|
123
|
+
wSum += s.weight ?? 1;
|
|
124
|
+
ySum += (s.weight ?? 1) * s.y;
|
|
125
|
+
}
|
|
126
|
+
// All-zero weights would make this 0/0: fall back to the unweighted mean.
|
|
127
|
+
const yMean = wSum > 0 ? ySum / wSum : samples.reduce((a, s) => a + s.y, 0) / samples.length;
|
|
128
|
+
const base = objective === "binary" ? Math.log(Math.max(yMean, 1e-6) / Math.max(1 - yMean, 1e-6)) : yMean;
|
|
129
|
+
const margins = samples.map(() => base);
|
|
130
|
+
const trees: RTree[] = [];
|
|
131
|
+
for (let t = 0; t < (opts.trees ?? 50); t++) {
|
|
132
|
+
const rows: Row[] = samples.map((s, i) => {
|
|
133
|
+
if (objective === "binary") {
|
|
134
|
+
const p = sigmoid(margins[i]!);
|
|
135
|
+
return { x: s.x, g: p - s.y, h: Math.max(p * (1 - p), 1e-6), w: s.weight ?? 1 };
|
|
136
|
+
}
|
|
137
|
+
return { x: s.x, g: margins[i]! - s.y, h: 1, w: s.weight ?? 1 };
|
|
138
|
+
});
|
|
139
|
+
const tree = buildTree(rows, 0, treeOpts);
|
|
140
|
+
trees.push(tree);
|
|
141
|
+
samples.forEach((s, i) => (margins[i] = margins[i]! + lr * treeValue(tree, s.x)));
|
|
142
|
+
}
|
|
143
|
+
return new GbmModel(objective, base, trees, lr);
|
|
144
|
+
}
|
|
@@ -10,6 +10,7 @@ import { fitLinear, type LinearOptions } from "./linear.js";
|
|
|
10
10
|
import { fitKMeans, type KMeansOptions } from "./kmeans.js";
|
|
11
11
|
import { fitRobustZ, type RobustZOptions } from "./robust_z.js";
|
|
12
12
|
import { fitEwma, type EwmaOptions } from "./ewma.js";
|
|
13
|
+
import { fitAnomalyComposite, type AnomalyCompositeOptions } from "./anomaly_composite.js";
|
|
13
14
|
import { fitIsolationForest, type IsolationForestOptions } from "./isolation_forest.js";
|
|
14
15
|
import { fitGbm, type GbmOptions } from "./gbm.js";
|
|
15
16
|
|
|
@@ -27,6 +28,8 @@ export { RobustZModel, fitRobustZ, anomalyConfidence } from "./robust_z.js";
|
|
|
27
28
|
export type { RobustZOptions } from "./robust_z.js";
|
|
28
29
|
export { EwmaModel, fitEwma } from "./ewma.js";
|
|
29
30
|
export type { EwmaOptions } from "./ewma.js";
|
|
31
|
+
export { AnomalyCompositeModel, fitAnomalyComposite } from "./anomaly_composite.js";
|
|
32
|
+
export type { AnomalyCompositeOptions } from "./anomaly_composite.js";
|
|
30
33
|
export { IsolationForestModel, fitIsolationForest } from "./isolation_forest.js";
|
|
31
34
|
export type { IsolationForestOptions } from "./isolation_forest.js";
|
|
32
35
|
export { GbmModel, fitGbm } from "./gbm.js";
|
|
@@ -42,6 +45,7 @@ export interface ClassicalOptions {
|
|
|
42
45
|
kmeans?: KMeansOptions;
|
|
43
46
|
"robust-z"?: RobustZOptions;
|
|
44
47
|
ewma?: EwmaOptions;
|
|
48
|
+
"anomaly-composite"?: AnomalyCompositeOptions;
|
|
45
49
|
"isolation-forest"?: IsolationForestOptions;
|
|
46
50
|
"gbm-small"?: GbmOptions;
|
|
47
51
|
}
|
|
@@ -68,6 +72,8 @@ export function fitClassical(
|
|
|
68
72
|
return fitRobustZ(samples, opts["robust-z"]);
|
|
69
73
|
case "ewma":
|
|
70
74
|
return fitEwma(samples, opts.ewma);
|
|
75
|
+
case "anomaly-composite":
|
|
76
|
+
return fitAnomalyComposite(samples, opts["anomaly-composite"]);
|
|
71
77
|
case "isolation-forest":
|
|
72
78
|
return fitIsolationForest(samples, opts["isolation-forest"]);
|
|
73
79
|
case "gbm-small":
|