@energy8platform/stake-math-tools 0.1.0 → 0.2.0
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/package.json +1 -1
- package/src/nnls.ts +42 -28
- package/test/optimize-lookup.integration.test.ts +35 -0
package/package.json
CHANGED
package/src/nnls.ts
CHANGED
|
@@ -17,8 +17,9 @@ export interface NNLSOptions {
|
|
|
17
17
|
* A is m×n (rows = features, cols = variables). m ≪ n is permitted thanks to ε > 0.
|
|
18
18
|
*
|
|
19
19
|
* Algorithm: classical active-set NNLS as in Lawson & Hanson §23.3. The Tikhonov term
|
|
20
|
-
* is
|
|
21
|
-
*
|
|
20
|
+
* is applied *implicitly* — we never materialize the √ε · I block. Folding it into
|
|
21
|
+
* the gradient and the normal equations keeps the storage at O(m · n) instead of
|
|
22
|
+
* O(n²), which matters when n can reach 10⁵.
|
|
22
23
|
*/
|
|
23
24
|
export function solveNNLS(
|
|
24
25
|
A: ReadonlyArray<ReadonlyArray<number>>,
|
|
@@ -32,34 +33,25 @@ export function solveNNLS(
|
|
|
32
33
|
const tol = options.tolerance ?? 1e-12;
|
|
33
34
|
const maxIter = options.maxIterations ?? 3 * Math.max(1, n);
|
|
34
35
|
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const Ah: number[][] = new Array(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
Ah[i] = A[i].slice();
|
|
42
|
-
bh[i] = b[i];
|
|
43
|
-
}
|
|
44
|
-
if (epsilon > 0) {
|
|
45
|
-
for (let j = 0; j < n; j++) {
|
|
46
|
-
const row = new Array(n).fill(0);
|
|
47
|
-
row[j] = sqrtEps;
|
|
48
|
-
Ah[m + j] = row;
|
|
49
|
-
bh[m + j] = sqrtEps * prior[j];
|
|
50
|
-
}
|
|
51
|
-
}
|
|
36
|
+
// No augmentation: A stays m×n. Tikhonov enters only via the gradient
|
|
37
|
+
// (in lawsonHansonNNLS) and the normal equations (in solveLS).
|
|
38
|
+
// Shallow-copy A to a mutable number[][] for the inner routine.
|
|
39
|
+
const Ah: number[][] = new Array(m);
|
|
40
|
+
for (let i = 0; i < m; i++) Ah[i] = A[i].slice();
|
|
41
|
+
const bh: number[] = b.slice();
|
|
52
42
|
|
|
53
|
-
return lawsonHansonNNLS(Ah, bh, n, tol, maxIter);
|
|
43
|
+
return lawsonHansonNNLS(Ah, bh, n, tol, maxIter, epsilon, prior);
|
|
54
44
|
}
|
|
55
45
|
|
|
56
46
|
/**
|
|
57
|
-
* Lawson–Hanson active-set NNLS, matrix form. Returns x ≥ 0 minimizing
|
|
47
|
+
* Lawson–Hanson active-set NNLS, matrix form. Returns x ≥ 0 minimizing
|
|
48
|
+
* ||A x − b||² + ε ||x − prior||².
|
|
58
49
|
*
|
|
59
50
|
* Variables:
|
|
60
51
|
* P (passive set): indices where x_i > 0, x_i is "free"
|
|
61
52
|
* Z (active set): indices where x_i = 0, x_i is "constrained"
|
|
62
|
-
* w =
|
|
53
|
+
* w = A_augᵀ(b_aug − A_aug x) — gradient of the augmented residual squared (negated).
|
|
54
|
+
* Split as w_j = (Aᵀ(b − A x))_j + ε · (prior_j − x_j).
|
|
63
55
|
*
|
|
64
56
|
* Outer loop: pick the most negative-gradient index from Z, move it to P.
|
|
65
57
|
* Inner loop: solve unconstrained LS on P; if any x_i ≤ 0, perform an interpolation
|
|
@@ -71,6 +63,8 @@ function lawsonHansonNNLS(
|
|
|
71
63
|
n: number,
|
|
72
64
|
tol: number,
|
|
73
65
|
maxIter: number,
|
|
66
|
+
epsilon: number,
|
|
67
|
+
prior: ReadonlyArray<number>,
|
|
74
68
|
): number[] {
|
|
75
69
|
const m = A.length;
|
|
76
70
|
const x = new Array(n).fill(0);
|
|
@@ -78,18 +72,19 @@ function lawsonHansonNNLS(
|
|
|
78
72
|
let iter = 0;
|
|
79
73
|
|
|
80
74
|
while (iter++ < maxIter) {
|
|
81
|
-
// residual r = b − A x
|
|
75
|
+
// residual r = b − A x (against the un-augmented A only)
|
|
82
76
|
const r = b.slice();
|
|
83
77
|
for (let i = 0; i < m; i++) {
|
|
84
78
|
let s = 0;
|
|
85
79
|
for (let j = 0; j < n; j++) s += A[i][j] * x[j];
|
|
86
80
|
r[i] -= s;
|
|
87
81
|
}
|
|
88
|
-
// w = Aᵀ r
|
|
82
|
+
// w = Aᵀ r + ε · (prior − x) ← implicit Tikhonov in the gradient
|
|
89
83
|
const w = new Array(n).fill(0);
|
|
90
84
|
for (let j = 0; j < n; j++) {
|
|
91
85
|
let s = 0;
|
|
92
86
|
for (let i = 0; i < m; i++) s += A[i][j] * r[i];
|
|
87
|
+
if (epsilon > 0) s += epsilon * (prior[j] - x[j]);
|
|
93
88
|
w[j] = s;
|
|
94
89
|
}
|
|
95
90
|
|
|
@@ -112,7 +107,7 @@ function lawsonHansonNNLS(
|
|
|
112
107
|
// Solve LS over P only
|
|
113
108
|
const pIdx: number[] = [];
|
|
114
109
|
for (let j = 0; j < n; j++) if (inP[j]) pIdx.push(j);
|
|
115
|
-
const sP = solveLS(A, b, pIdx);
|
|
110
|
+
const sP = solveLS(A, b, pIdx, epsilon, prior);
|
|
116
111
|
// Build full s
|
|
117
112
|
const s = new Array(n).fill(0);
|
|
118
113
|
for (let k = 0; k < pIdx.length; k++) s[pIdx[k]] = sP[k];
|
|
@@ -153,12 +148,23 @@ function lawsonHansonNNLS(
|
|
|
153
148
|
}
|
|
154
149
|
|
|
155
150
|
/**
|
|
156
|
-
* Solve unconstrained LS for the passive subset: argmin ‖A_P x_P − b‖²
|
|
157
|
-
* is the columns of A indexed by `pIdx`. Uses normal equations
|
|
151
|
+
* Solve unconstrained LS for the passive subset: argmin ‖A_P x_P − b‖² + ε ‖x_P − prior_P‖²
|
|
152
|
+
* where A_P is the columns of A indexed by `pIdx`. Uses normal equations
|
|
153
|
+
* (A_Pᵀ A_P + ε I) x = A_Pᵀ b + ε · prior_P
|
|
158
154
|
* with Gaussian elimination — adequate for the small passive sets that arise in
|
|
159
155
|
* Tikhonov-regularized NNLS (|P| ≤ m + a few extras at convergence).
|
|
156
|
+
*
|
|
157
|
+
* The Tikhonov term enters as +ε on the Gram diagonal and +ε·prior on the RHS,
|
|
158
|
+
* which is exactly what augmenting A with √ε · I would produce — without the
|
|
159
|
+
* O(n²) storage.
|
|
160
160
|
*/
|
|
161
|
-
function solveLS(
|
|
161
|
+
function solveLS(
|
|
162
|
+
A: number[][],
|
|
163
|
+
b: number[],
|
|
164
|
+
pIdx: ReadonlyArray<number>,
|
|
165
|
+
epsilon = 0,
|
|
166
|
+
prior?: ReadonlyArray<number>,
|
|
167
|
+
): number[] {
|
|
162
168
|
const m = A.length;
|
|
163
169
|
const k = pIdx.length;
|
|
164
170
|
if (k === 0) return [];
|
|
@@ -177,6 +183,14 @@ function solveLS(A: number[][], b: number[], pIdx: ReadonlyArray<number>): numbe
|
|
|
177
183
|
G[a][k] = s;
|
|
178
184
|
}
|
|
179
185
|
|
|
186
|
+
// Implicit Tikhonov: add ε to the Gram diagonal and ε·prior to the RHS.
|
|
187
|
+
if (epsilon > 0) {
|
|
188
|
+
for (let col = 0; col < k; col++) {
|
|
189
|
+
G[col][col] += epsilon;
|
|
190
|
+
if (prior !== undefined) G[col][k] += epsilon * prior[pIdx[col]];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
180
194
|
// Gaussian elimination with partial pivoting
|
|
181
195
|
for (let col = 0; col < k; col++) {
|
|
182
196
|
let pivot = col;
|
|
@@ -138,4 +138,39 @@ describe('integration', () => {
|
|
|
138
138
|
for (const r of result.rows) sum += r.weight;
|
|
139
139
|
expect(sum).toBe(1000 * 1_000_000);
|
|
140
140
|
});
|
|
141
|
+
|
|
142
|
+
it('6. handles nRowsOut=5000 without n² memory blowup', () => {
|
|
143
|
+
// Pre-fix this would allocate a 5000×5000 dense matrix (200 MB Float64);
|
|
144
|
+
// after the implicit-Tikhonov fix it should fit in well under 100 MB and
|
|
145
|
+
// complete in a few seconds.
|
|
146
|
+
const rng = makeRng(6);
|
|
147
|
+
const rows: LookupRow[] = new Array(200_000);
|
|
148
|
+
for (let i = 0; i < 200_000; i++) {
|
|
149
|
+
const u = rng();
|
|
150
|
+
let p = 0;
|
|
151
|
+
if (u > 0.7) p = Math.floor(rng() * 200);
|
|
152
|
+
if (u > 0.97) p = Math.floor(rng() * 5_000);
|
|
153
|
+
if (u > 0.999) p = Math.floor(rng() * 50_000);
|
|
154
|
+
rows[i] = { sim: i, weight: 1 + Math.floor(rng() * 10), payoutCents: p };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const t0 = performance.now();
|
|
158
|
+
const result = optimizeLookupTable(rows, {
|
|
159
|
+
targetRTP: 0.5, toleranceRTP: 0.2,
|
|
160
|
+
targetCV: 3, toleranceCV: 5,
|
|
161
|
+
targetHitRate: 0.30, toleranceHitRate: 0.1,
|
|
162
|
+
capMaxWin: 50_000,
|
|
163
|
+
nRowsOut: 5_000,
|
|
164
|
+
requireMaxReached: false,
|
|
165
|
+
maxIterations: 1, // single pass — we're testing memory, not convergence
|
|
166
|
+
});
|
|
167
|
+
const elapsed = performance.now() - t0;
|
|
168
|
+
|
|
169
|
+
expect(result.rows).toHaveLength(5_000);
|
|
170
|
+
// Should be well under the testTimeout (30s). 60s as a generous upper bound.
|
|
171
|
+
expect(elapsed).toBeLessThan(60_000);
|
|
172
|
+
let sum = 0;
|
|
173
|
+
for (const r of result.rows) sum += r.weight;
|
|
174
|
+
expect(sum).toBe(5_000 * 1_000_000);
|
|
175
|
+
});
|
|
141
176
|
});
|