@johnhenry/math 0.1.0 → 0.3.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/README.md +5 -4
- package/dist/Distributions.d.ts +86 -16
- package/dist/Distributions.d.ts.map +1 -1
- package/dist/Distributions.js +340 -89
- package/dist/Distributions.js.map +1 -1
- package/dist/EigenGeneral.d.ts +22 -0
- package/dist/EigenGeneral.d.ts.map +1 -0
- package/dist/EigenGeneral.js +327 -0
- package/dist/EigenGeneral.js.map +1 -0
- package/dist/Frobenius.d.ts +60 -0
- package/dist/Frobenius.d.ts.map +1 -0
- package/dist/Frobenius.js +162 -0
- package/dist/Frobenius.js.map +1 -0
- package/dist/Graph.d.ts +26 -0
- package/dist/Graph.d.ts.map +1 -1
- package/dist/Graph.js +75 -0
- package/dist/Graph.js.map +1 -1
- package/dist/MatrixMath.d.ts +10 -0
- package/dist/MatrixMath.d.ts.map +1 -1
- package/dist/MatrixMath.js +11 -0
- package/dist/MatrixMath.js.map +1 -1
- package/dist/MonteCarlo.d.ts +106 -0
- package/dist/MonteCarlo.d.ts.map +1 -0
- package/dist/MonteCarlo.js +183 -0
- package/dist/MonteCarlo.js.map +1 -0
- package/dist/Random.d.ts +31 -0
- package/dist/Random.d.ts.map +1 -0
- package/dist/Random.js +95 -0
- package/dist/Random.js.map +1 -0
- package/dist/Regression.d.ts +114 -0
- package/dist/Regression.d.ts.map +1 -0
- package/dist/Regression.js +338 -0
- package/dist/Regression.js.map +1 -0
- package/dist/Shader.d.ts +41 -0
- package/dist/Shader.d.ts.map +1 -0
- package/dist/Shader.js +758 -0
- package/dist/Shader.js.map +1 -0
- package/dist/SpecialFunctions.d.ts +3 -3
- package/dist/SpecialFunctions.d.ts.map +1 -1
- package/dist/SpecialFunctions.js +61 -51
- package/dist/SpecialFunctions.js.map +1 -1
- package/dist/Symbolic.d.ts +12 -0
- package/dist/Symbolic.d.ts.map +1 -1
- package/dist/Symbolic.js +16 -0
- package/dist/Symbolic.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Distributions.ts +391 -94
- package/src/EigenGeneral.ts +342 -0
- package/src/Frobenius.ts +203 -0
- package/src/Graph.ts +68 -0
- package/src/MatrixMath.ts +13 -0
- package/src/MonteCarlo.ts +257 -0
- package/src/Random.ts +104 -0
- package/src/Regression.ts +423 -0
- package/src/Shader.ts +837 -0
- package/src/SpecialFunctions.ts +52 -48
- package/src/Symbolic.ts +18 -0
- package/src/index.ts +34 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { MatrixMath } from "./MatrixMath.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Regression — nonlinear least squares (Levenberg-Marquardt) and robust
|
|
5
|
+
* (Huber) fitting, modelled on `scipy.optimize.least_squares` and
|
|
6
|
+
* `scipy.optimize.curve_fit`.
|
|
7
|
+
*
|
|
8
|
+
* - {@link Regression.leastSquares} minimises `0.5 * Σ ρ((r_i / f_scale)²) * f_scale²`
|
|
9
|
+
* over residuals `r(x)`, with SciPy's robust `loss` options and SciPy's
|
|
10
|
+
* robust-loss Jacobian rescaling. The solver is a damped Gauss-Newton
|
|
11
|
+
* (Levenberg-Marquardt) iteration with Marquardt scaling, so it reaches the
|
|
12
|
+
* same minimiser as SciPy's `method="lm"` / `"trf"` to the requested
|
|
13
|
+
* tolerances (iteration counts differ).
|
|
14
|
+
* - {@link Regression.curveFit} fits `y ≈ model(x, params)` and returns the
|
|
15
|
+
* parameter covariance exactly as `curve_fit` defines it.
|
|
16
|
+
* - {@link Regression.huberLinear} is the robust line fit mallory's regression
|
|
17
|
+
* panel offers (Huber loss), solved to convergence instead of by SGD.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type RobustLoss = "linear" | "huber" | "soft_l1" | "cauchy" | "arctan";
|
|
21
|
+
|
|
22
|
+
export interface LeastSquaresOptions {
|
|
23
|
+
/** Analytic Jacobian `d residual_i / d x_j` (m×n). Default: central differences. */
|
|
24
|
+
jac?: (x: number[]) => number[][];
|
|
25
|
+
/** Loss function ρ applied to scaled squared residuals. Default `"linear"` (plain least squares). */
|
|
26
|
+
loss?: RobustLoss;
|
|
27
|
+
/** Residual scale at which a robust loss turns over (SciPy's `f_scale`). Default 1. */
|
|
28
|
+
fScale?: number;
|
|
29
|
+
/** Relative cost-reduction tolerance (default 1e-8, as SciPy). */
|
|
30
|
+
ftol?: number;
|
|
31
|
+
/** Step-size tolerance (default 1e-8). */
|
|
32
|
+
xtol?: number;
|
|
33
|
+
/** Gradient infinity-norm tolerance (default 1e-8). */
|
|
34
|
+
gtol?: number;
|
|
35
|
+
/** Maximum residual-function evaluations (default `200 * n`, min 1000). */
|
|
36
|
+
maxNfev?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LeastSquaresResult {
|
|
40
|
+
x: number[];
|
|
41
|
+
/** `0.5 * Σ ρ` at the solution (SciPy's `cost`; equals `0.5 Σ r²` for the linear loss). */
|
|
42
|
+
cost: number;
|
|
43
|
+
/** Residuals at the solution. */
|
|
44
|
+
fun: number[];
|
|
45
|
+
jac: number[][];
|
|
46
|
+
/** Gradient `Jᵀ ρ'(r) r` at the solution. */
|
|
47
|
+
grad: number[];
|
|
48
|
+
/** Gradient infinity-norm. */
|
|
49
|
+
optimality: number;
|
|
50
|
+
nfev: number;
|
|
51
|
+
njev: number;
|
|
52
|
+
/** 0: max evaluations, 1: gtol, 2: ftol, 3: xtol, 4: ftol and xtol (SciPy's codes). */
|
|
53
|
+
status: 0 | 1 | 2 | 3 | 4;
|
|
54
|
+
success: boolean;
|
|
55
|
+
message: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface CurveFitOptions extends Omit<LeastSquaresOptions, "jac"> {
|
|
59
|
+
/** Analytic `d model / d params` for one point: `(x, params) => number[]`. */
|
|
60
|
+
jac?: (x: number, params: number[]) => number[];
|
|
61
|
+
/** Per-point standard deviations (weights `1/sigma`). */
|
|
62
|
+
sigma?: number[];
|
|
63
|
+
/** Treat `sigma` as absolute (don't rescale the covariance by reduced chi-square). Default false. */
|
|
64
|
+
absoluteSigma?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CurveFitResult {
|
|
68
|
+
params: number[];
|
|
69
|
+
/** Parameter covariance, as `scipy.optimize.curve_fit`'s `pcov` (`Infinity` entries when singular). */
|
|
70
|
+
covariance: number[][];
|
|
71
|
+
/** Square roots of the covariance diagonal. */
|
|
72
|
+
standardErrors: number[];
|
|
73
|
+
/** `sqrt(Σ r²)`. */
|
|
74
|
+
residualNorm: number;
|
|
75
|
+
/** Coefficient of determination `1 - SS_res / SS_tot`. */
|
|
76
|
+
rSquared: number;
|
|
77
|
+
iterations: number;
|
|
78
|
+
converged: boolean;
|
|
79
|
+
fit: LeastSquaresResult;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface HuberLinearResult {
|
|
83
|
+
slope: number;
|
|
84
|
+
intercept: number;
|
|
85
|
+
iterations: number;
|
|
86
|
+
converged: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const EPS = Number.EPSILON;
|
|
90
|
+
|
|
91
|
+
/** ρ(z), ρ'(z), ρ''(z) for the SciPy robust losses, `z` = squared scaled residual. */
|
|
92
|
+
function rho(loss: RobustLoss, z: number): [number, number, number] {
|
|
93
|
+
switch (loss) {
|
|
94
|
+
case "linear":
|
|
95
|
+
return [z, 1, 0];
|
|
96
|
+
case "huber":
|
|
97
|
+
if (z <= 1) return [z, 1, 0];
|
|
98
|
+
return [2 * Math.sqrt(z) - 1, 1 / Math.sqrt(z), -0.5 * z ** -1.5];
|
|
99
|
+
case "soft_l1": {
|
|
100
|
+
const t = 1 + z;
|
|
101
|
+
return [2 * (Math.sqrt(t) - 1), 1 / Math.sqrt(t), -0.5 * t ** -1.5];
|
|
102
|
+
}
|
|
103
|
+
case "cauchy": {
|
|
104
|
+
const t = 1 + z;
|
|
105
|
+
return [Math.log1p(z), 1 / t, -1 / (t * t)];
|
|
106
|
+
}
|
|
107
|
+
case "arctan":
|
|
108
|
+
return [Math.atan(z), 1 / (1 + z * z), (-2 * z) / (1 + z * z) ** 2];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function robustCost(loss: RobustLoss, f: number[], fScale: number): number {
|
|
113
|
+
if (loss === "linear") return 0.5 * f.reduce((s, v) => s + v * v, 0);
|
|
114
|
+
const c2 = fScale * fScale;
|
|
115
|
+
return 0.5 * c2 * f.reduce((s, v) => s + rho(loss, (v / fScale) ** 2)[0], 0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Invert a small symmetric matrix by Gauss-Jordan with partial pivoting; `null` when singular. */
|
|
119
|
+
function invert(a: number[][]): number[][] | null {
|
|
120
|
+
const n = a.length;
|
|
121
|
+
const m = a.map((row, i) => [...row, ...Array.from({ length: n }, (_, j) => (i === j ? 1 : 0))]);
|
|
122
|
+
for (let c = 0; c < n; c++) {
|
|
123
|
+
let p = c;
|
|
124
|
+
for (let r = c + 1; r < n; r++) if (Math.abs(m[r][c]) > Math.abs(m[p][c])) p = r;
|
|
125
|
+
if (Math.abs(m[p][c]) < 1e-300) return null;
|
|
126
|
+
[m[c], m[p]] = [m[p], m[c]];
|
|
127
|
+
const d = m[c][c];
|
|
128
|
+
for (let j = 0; j < 2 * n; j++) m[c][j] /= d;
|
|
129
|
+
for (let r = 0; r < n; r++) {
|
|
130
|
+
if (r === c) continue;
|
|
131
|
+
const f = m[r][c];
|
|
132
|
+
if (f !== 0) for (let j = 0; j < 2 * n; j++) m[r][j] -= f * m[c][j];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return m.map((row) => row.slice(n));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class Regression {
|
|
139
|
+
/**
|
|
140
|
+
* Nonlinear least squares by Levenberg-Marquardt -- `scipy.optimize.least_squares`
|
|
141
|
+
* (`method="lm"` for `loss: "linear"`; robust losses as `method="trf"` would
|
|
142
|
+
* minimise them). `fun(x)` returns the residual vector.
|
|
143
|
+
*/
|
|
144
|
+
static leastSquares(
|
|
145
|
+
fun: (x: number[]) => number[],
|
|
146
|
+
x0: number[],
|
|
147
|
+
options: LeastSquaresOptions = {},
|
|
148
|
+
): LeastSquaresResult {
|
|
149
|
+
const { loss = "linear", fScale = 1, ftol = 1e-8, xtol = 1e-8, gtol = 1e-8 } = options;
|
|
150
|
+
const n = x0.length;
|
|
151
|
+
const maxNfev = options.maxNfev ?? Math.max(1000, 200 * n);
|
|
152
|
+
let nfev = 0;
|
|
153
|
+
let njev = 0;
|
|
154
|
+
const evalF = (x: number[]) => {
|
|
155
|
+
nfev++;
|
|
156
|
+
return fun(x);
|
|
157
|
+
};
|
|
158
|
+
const jacobian = (x: number[], f: number[]): number[][] => {
|
|
159
|
+
njev++;
|
|
160
|
+
if (options.jac) return options.jac(x);
|
|
161
|
+
const m = f.length;
|
|
162
|
+
const J = Array.from({ length: m }, () => new Array<number>(n).fill(0));
|
|
163
|
+
for (let j = 0; j < n; j++) {
|
|
164
|
+
const h = Math.cbrt(EPS) * Math.max(Math.abs(x[j]), 1);
|
|
165
|
+
const xp = [...x];
|
|
166
|
+
const xm = [...x];
|
|
167
|
+
xp[j] += h;
|
|
168
|
+
xm[j] -= h;
|
|
169
|
+
const fp = fun(xp);
|
|
170
|
+
const fm = fun(xm);
|
|
171
|
+
for (let i = 0; i < m; i++) J[i][j] = (fp[i] - fm[i]) / (2 * h);
|
|
172
|
+
}
|
|
173
|
+
return J;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
let x = [...x0];
|
|
177
|
+
let f = evalF(x);
|
|
178
|
+
let cost = robustCost(loss, f, fScale);
|
|
179
|
+
let J = jacobian(x, f);
|
|
180
|
+
let lambda = 1e-3;
|
|
181
|
+
let status: LeastSquaresResult["status"] = 0;
|
|
182
|
+
let message = "The maximum number of function evaluations is exceeded.";
|
|
183
|
+
|
|
184
|
+
/** Robust-loss scaled residual & Jacobian (SciPy's `scale_for_robust_loss`) and true gradient. */
|
|
185
|
+
const scaled = (fv: number[], Jv: number[][]) => {
|
|
186
|
+
if (loss === "linear") return { fs: fv, Js: Jv };
|
|
187
|
+
const m = fv.length;
|
|
188
|
+
const fs = new Array<number>(m);
|
|
189
|
+
const Js = new Array<number[]>(m);
|
|
190
|
+
for (let i = 0; i < m; i++) {
|
|
191
|
+
const [, r1, r2] = rho(loss, (fv[i] / fScale) ** 2);
|
|
192
|
+
let js = r1 + 2 * r2 * (fv[i] / fScale) ** 2;
|
|
193
|
+
if (js < EPS) js = EPS;
|
|
194
|
+
js = Math.sqrt(js);
|
|
195
|
+
const fsc = r1 / js;
|
|
196
|
+
fs[i] = fv[i] * fsc;
|
|
197
|
+
Js[i] = Jv[i].map((v) => v * js);
|
|
198
|
+
}
|
|
199
|
+
return { fs, Js };
|
|
200
|
+
};
|
|
201
|
+
const gradientOf = (fv: number[], Jv: number[][]): number[] => {
|
|
202
|
+
const g = new Array<number>(n).fill(0);
|
|
203
|
+
for (let i = 0; i < fv.length; i++) {
|
|
204
|
+
const w = loss === "linear" ? 1 : rho(loss, (fv[i] / fScale) ** 2)[1];
|
|
205
|
+
for (let j = 0; j < n; j++) g[j] += Jv[i][j] * w * fv[i];
|
|
206
|
+
}
|
|
207
|
+
return g;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
let grad = gradientOf(f, J);
|
|
211
|
+
let optimality = Math.max(0, ...grad.map(Math.abs));
|
|
212
|
+
if (optimality <= gtol) {
|
|
213
|
+
status = 1;
|
|
214
|
+
message = "`gtol` termination condition is satisfied.";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
while (status === 0 && nfev < maxNfev) {
|
|
218
|
+
const { fs, Js } = scaled(f, J);
|
|
219
|
+
const JtJ = Array.from({ length: n }, () => new Array<number>(n).fill(0));
|
|
220
|
+
const Jtf = new Array<number>(n).fill(0);
|
|
221
|
+
for (let i = 0; i < fs.length; i++) {
|
|
222
|
+
for (let a = 0; a < n; a++) {
|
|
223
|
+
Jtf[a] += Js[i][a] * fs[i];
|
|
224
|
+
for (let b = a; b < n; b++) JtJ[a][b] += Js[i][a] * Js[i][b];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (let a = 0; a < n; a++) for (let b = 0; b < a; b++) JtJ[a][b] = JtJ[b][a];
|
|
228
|
+
|
|
229
|
+
let accepted = false;
|
|
230
|
+
let dxNorm = 0;
|
|
231
|
+
let reduction = 0;
|
|
232
|
+
for (let tries = 0; tries < 40 && !accepted && nfev < maxNfev; tries++) {
|
|
233
|
+
const A = JtJ.map((row, a) => row.map((v, b) => (a === b ? v + lambda * Math.max(JtJ[a][a], 1e-12) : v)));
|
|
234
|
+
let dx: number[];
|
|
235
|
+
try {
|
|
236
|
+
dx = [
|
|
237
|
+
...MatrixMath.solve(
|
|
238
|
+
A,
|
|
239
|
+
Jtf.map((v) => -v),
|
|
240
|
+
),
|
|
241
|
+
];
|
|
242
|
+
} catch {
|
|
243
|
+
lambda *= 10;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (dx.some((v) => !Number.isFinite(v))) {
|
|
247
|
+
lambda *= 10;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const xn = x.map((v, i) => v + dx[i]);
|
|
251
|
+
const fn = evalF(xn);
|
|
252
|
+
const cn = robustCost(loss, fn, fScale);
|
|
253
|
+
if (Number.isFinite(cn) && cn < cost) {
|
|
254
|
+
reduction = cost - cn;
|
|
255
|
+
dxNorm = Math.hypot(...dx);
|
|
256
|
+
const xNorm = Math.hypot(...x);
|
|
257
|
+
const prevCost = cost;
|
|
258
|
+
x = xn;
|
|
259
|
+
f = fn;
|
|
260
|
+
cost = cn;
|
|
261
|
+
lambda = Math.max(lambda / 10, 1e-14);
|
|
262
|
+
accepted = true;
|
|
263
|
+
J = jacobian(x, f);
|
|
264
|
+
grad = gradientOf(f, J);
|
|
265
|
+
optimality = Math.max(0, ...grad.map(Math.abs));
|
|
266
|
+
const fHit = reduction < ftol * prevCost;
|
|
267
|
+
const xHit = dxNorm < xtol * (xtol + xNorm);
|
|
268
|
+
if (optimality <= gtol) {
|
|
269
|
+
status = 1;
|
|
270
|
+
message = "`gtol` termination condition is satisfied.";
|
|
271
|
+
} else if (fHit && xHit) {
|
|
272
|
+
status = 4;
|
|
273
|
+
message = "Both `ftol` and `xtol` termination conditions are satisfied.";
|
|
274
|
+
} else if (fHit) {
|
|
275
|
+
status = 2;
|
|
276
|
+
message = "`ftol` termination condition is satisfied.";
|
|
277
|
+
} else if (xHit) {
|
|
278
|
+
status = 3;
|
|
279
|
+
message = "`xtol` termination condition is satisfied.";
|
|
280
|
+
}
|
|
281
|
+
} else lambda *= 10;
|
|
282
|
+
}
|
|
283
|
+
if (!accepted) {
|
|
284
|
+
// no damped step lowers the cost: a (numerical) stationary point
|
|
285
|
+
status = optimality <= Math.sqrt(gtol) || cost === 0 ? 1 : 2;
|
|
286
|
+
message = "No further improvement is possible at this precision.";
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { x, cost, fun: f, jac: J, grad, optimality, nfev, njev, status, success: status > 0, message };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Fit `y ≈ model(x, params)` by least squares -- `scipy.optimize.curve_fit`
|
|
296
|
+
* (default `method="lm"`). `covariance` follows `curve_fit`: `(JᵀJ)⁻¹`
|
|
297
|
+
* scaled by the reduced chi-square unless `absoluteSigma` is set.
|
|
298
|
+
*/
|
|
299
|
+
static curveFit(
|
|
300
|
+
model: (x: number, params: number[]) => number,
|
|
301
|
+
xs: number[],
|
|
302
|
+
ys: number[],
|
|
303
|
+
p0: number[],
|
|
304
|
+
options: CurveFitOptions = {},
|
|
305
|
+
): CurveFitResult {
|
|
306
|
+
if (xs.length !== ys.length) throw new Error("xs and ys must have the same length.");
|
|
307
|
+
const m = xs.length;
|
|
308
|
+
const n = p0.length;
|
|
309
|
+
if (m < n) throw new Error(`curveFit needs at least as many points (${m}) as parameters (${n}).`);
|
|
310
|
+
const sigma = options.sigma;
|
|
311
|
+
const w = (i: number) => (sigma ? 1 / sigma[i] : 1);
|
|
312
|
+
const { jac: pointJac, sigma: _s, absoluteSigma, ...rest } = options;
|
|
313
|
+
const fit = Regression.leastSquares((p) => xs.map((x, i) => (model(x, p) - ys[i]) * w(i)), p0, {
|
|
314
|
+
...rest,
|
|
315
|
+
jac: pointJac ? (p) => xs.map((x, i) => pointJac(x, p).map((d) => d * w(i))) : undefined,
|
|
316
|
+
});
|
|
317
|
+
const JtJ = Array.from({ length: n }, (_, a) =>
|
|
318
|
+
Array.from({ length: n }, (_, b) => fit.jac.reduce((s, row) => s + row[a] * row[b], 0)),
|
|
319
|
+
);
|
|
320
|
+
const inv = invert(JtJ);
|
|
321
|
+
let covariance: number[][];
|
|
322
|
+
if (!inv) covariance = Array.from({ length: n }, () => new Array<number>(n).fill(Number.POSITIVE_INFINITY));
|
|
323
|
+
else {
|
|
324
|
+
const sq = 2 * fit.cost; // Σ (r/σ)²
|
|
325
|
+
const scale = absoluteSigma ? 1 : m > n ? sq / (m - n) : Number.POSITIVE_INFINITY;
|
|
326
|
+
covariance = inv.map((row) => row.map((v) => v * scale));
|
|
327
|
+
}
|
|
328
|
+
const meanY = ys.reduce((a, b) => a + b, 0) / m;
|
|
329
|
+
const ssTot = ys.reduce((s, y) => s + (y - meanY) ** 2, 0);
|
|
330
|
+
const ssRes = xs.reduce((s, x, i) => s + (ys[i] - model(x, fit.x)) ** 2, 0);
|
|
331
|
+
return {
|
|
332
|
+
params: fit.x,
|
|
333
|
+
covariance,
|
|
334
|
+
standardErrors: covariance.map((row, i) => Math.sqrt(row[i])),
|
|
335
|
+
residualNorm: Math.sqrt(ssRes),
|
|
336
|
+
rSquared: ssTot === 0 ? (ssRes === 0 ? 1 : 0) : 1 - ssRes / ssTot,
|
|
337
|
+
iterations: fit.nfev,
|
|
338
|
+
converged: fit.success,
|
|
339
|
+
fit,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Robust straight-line fit `y = slope·x + intercept` minimising the Huber
|
|
345
|
+
* loss of the residuals scaled by `delta` -- the same objective as
|
|
346
|
+
* `scipy.optimize.least_squares(..., loss="huber", f_scale=delta)` on a
|
|
347
|
+
* linear model. Quadratic for residuals within `delta`, linear beyond, so an
|
|
348
|
+
* outlier pulls the line far less than ordinary least squares. Solved by
|
|
349
|
+
* iteratively reweighted least squares to convergence (the objective is convex).
|
|
350
|
+
*/
|
|
351
|
+
static huberLinear(
|
|
352
|
+
xs: number[],
|
|
353
|
+
ys: number[],
|
|
354
|
+
options: { delta?: number; maxIterations?: number; tolerance?: number } = {},
|
|
355
|
+
): HuberLinearResult {
|
|
356
|
+
const { delta = 1, maxIterations = 200, tolerance = 1e-12 } = options;
|
|
357
|
+
if (xs.length !== ys.length) throw new Error("xs and ys must have the same length.");
|
|
358
|
+
if (xs.length < 2) throw new Error("Enter at least two (x, y) rows.");
|
|
359
|
+
if (!(delta > 0)) throw new Error("delta must be positive.");
|
|
360
|
+
const m = xs.length;
|
|
361
|
+
const wls = (weights: number[]): [number, number] => {
|
|
362
|
+
let sw = 0;
|
|
363
|
+
let sx = 0;
|
|
364
|
+
let sy = 0;
|
|
365
|
+
let sxx = 0;
|
|
366
|
+
let sxy = 0;
|
|
367
|
+
for (let i = 0; i < m; i++) {
|
|
368
|
+
const w = weights[i];
|
|
369
|
+
sw += w;
|
|
370
|
+
sx += w * xs[i];
|
|
371
|
+
sy += w * ys[i];
|
|
372
|
+
sxx += w * xs[i] * xs[i];
|
|
373
|
+
sxy += w * xs[i] * ys[i];
|
|
374
|
+
}
|
|
375
|
+
const det = sw * sxx - sx * sx;
|
|
376
|
+
if (Math.abs(det) < 1e-300) return [0, sy / sw];
|
|
377
|
+
return [(sw * sxy - sx * sy) / det, (sxx * sy - sx * sxy) / det];
|
|
378
|
+
};
|
|
379
|
+
let [slope, intercept] = wls(new Array<number>(m).fill(1));
|
|
380
|
+
let iterations = 0;
|
|
381
|
+
let converged = false;
|
|
382
|
+
for (; iterations < maxIterations; iterations++) {
|
|
383
|
+
const weights = xs.map((x, i) => {
|
|
384
|
+
const r = Math.abs(ys[i] - (slope * x + intercept));
|
|
385
|
+
return r <= delta ? 1 : delta / r;
|
|
386
|
+
});
|
|
387
|
+
const [s, b] = wls(weights);
|
|
388
|
+
const change = Math.max(Math.abs(s - slope), Math.abs(b - intercept));
|
|
389
|
+
slope = s;
|
|
390
|
+
intercept = b;
|
|
391
|
+
if (change <= tolerance * (1 + Math.abs(slope) + Math.abs(intercept))) {
|
|
392
|
+
converged = true;
|
|
393
|
+
iterations++;
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return { slope, intercept, iterations, converged };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Indices of points whose residual from `y = slope·x + intercept` is more
|
|
402
|
+
* than `thresholdMads` scaled median absolute deviations from the median
|
|
403
|
+
* residual (MAD × 1.4826 estimates a Gaussian σ without being skewed by the
|
|
404
|
+
* outliers themselves). Empty when the MAD is zero.
|
|
405
|
+
*/
|
|
406
|
+
static outlierIndices(xs: number[], ys: number[], slope: number, intercept: number, thresholdMads = 2.5): number[] {
|
|
407
|
+
if (xs.length === 0) return [];
|
|
408
|
+
const median = (v: number[]) => {
|
|
409
|
+
const s = [...v].sort((a, b) => a - b);
|
|
410
|
+
return s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2;
|
|
411
|
+
};
|
|
412
|
+
const residuals = ys.map((y, i) => y - (slope * xs[i] + intercept));
|
|
413
|
+
const med = median(residuals);
|
|
414
|
+
const mad = median(residuals.map((r) => Math.abs(r - med)));
|
|
415
|
+
if (mad === 0) return [];
|
|
416
|
+
const scaled = mad * 1.4826;
|
|
417
|
+
const out: number[] = [];
|
|
418
|
+
residuals.forEach((r, i) => {
|
|
419
|
+
if (Math.abs(r - med) / scaled > thresholdMads) out.push(i);
|
|
420
|
+
});
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
}
|