@seantalts/stanli 0.9.6 → 0.11.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 +40 -0
- package/index.mjs +76 -5
- package/package.json +1 -1
- package/stanli-compiler.js +5814 -5799
- package/stanli.js +1 -1
- package/stanli.wasm +0 -0
- package/worker.js +110 -14
package/README.md
CHANGED
|
@@ -23,11 +23,51 @@ const fit = await sample({
|
|
|
23
23
|
|
|
24
24
|
fit.columns["mu"]; // Float64Array, one entry per draw
|
|
25
25
|
fit.names; // every CSV column CmdStan would write
|
|
26
|
+
fit.generatedStart; // index where generated-quantity columns begin
|
|
26
27
|
fit.ms; // {stanc, lower, sample, total} in milliseconds
|
|
27
28
|
```
|
|
28
29
|
|
|
30
|
+
NUTS can take its starting point from single-path Pathfinder. The same seed
|
|
31
|
+
controls initialization and sampling, and an empty options object uses the
|
|
32
|
+
defaults:
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
const fit = await sample({
|
|
36
|
+
code,
|
|
37
|
+
data,
|
|
38
|
+
seed: 303,
|
|
39
|
+
pathfinderInit: { numIterations: 500, numElboDraws: 25 },
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`historySize` and Pathfinder's own `initRadius` are also supported. This mode
|
|
44
|
+
does not perform PSIS resampling.
|
|
45
|
+
|
|
46
|
+
For NUTS, `await diagnose(fit)` returns the same text report as the R and
|
|
47
|
+
Python bindings: divergences, maximum-treedepth saturation, E-BFMI,
|
|
48
|
+
rank-normalized R-hat, and bulk/tail ESS. Pass an array of fits from the
|
|
49
|
+
same model and configuration to diagnose all chains together:
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
import { compile, diagnose, sample } from "@seantalts/stanli";
|
|
53
|
+
const { mir } = await compile({ code });
|
|
54
|
+
const fits = await Promise.all([1, 2, 3, 4].map((seed) =>
|
|
55
|
+
sample({ mir, data, seed })));
|
|
56
|
+
console.log(await diagnose(fits));
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`fit.samplerStats` contains seven doubles per post-warmup draw, in order:
|
|
60
|
+
`lp__`, `accept_stat__`, `stepsize__`, `treedepth__`, `n_leapfrog__`,
|
|
61
|
+
`divergent__`, `energy__`. `fit.maxDepth` records the sampling limit (10).
|
|
62
|
+
WALNUTS and Pathfinder return `null` for `samplerStats`; `diagnose()` rejects
|
|
63
|
+
these methods rather than treating missing statistics as successful checks.
|
|
64
|
+
The demo displays the report after NUTS runs, including comparisons, and
|
|
65
|
+
explicitly marks WALNUTS sampler diagnostics as unavailable. Pathfinder keeps
|
|
66
|
+
its existing importance-weight k-hat diagnostic.
|
|
67
|
+
|
|
29
68
|
Columns cover the full CmdStan CSV: constrained parameters, transformed
|
|
30
69
|
parameters, and generated quantities (RNG draws stream from `seed`).
|
|
70
|
+
When there are no generated quantities, `generatedStart === fit.names.length`.
|
|
31
71
|
The heavy work runs in a worker the package owns, so the page never
|
|
32
72
|
blocks; calls queue and run one at a time.
|
|
33
73
|
|
package/index.mjs
CHANGED
|
@@ -95,6 +95,29 @@ export function compile(opts) {
|
|
|
95
95
|
return request({ cmd: "compile", code: opts.code }, opts);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
function pathfinderInitOptions(value) {
|
|
99
|
+
if (value == null) return null;
|
|
100
|
+
if (typeof value !== "object" || Array.isArray(value))
|
|
101
|
+
throw new TypeError("pathfinderInit must be an options object");
|
|
102
|
+
const defaults = { numIterations: 1000, numElboDraws: 25,
|
|
103
|
+
historySize: 5, initRadius: 2 };
|
|
104
|
+
const unknown = Object.keys(value).filter((key) => !(key in defaults));
|
|
105
|
+
if (unknown.length)
|
|
106
|
+
throw new RangeError("unknown pathfinderInit option" +
|
|
107
|
+
(unknown.length > 1 ? "s" : "") + ": " +
|
|
108
|
+
unknown.join(", "));
|
|
109
|
+
const out = { ...defaults, ...value };
|
|
110
|
+
for (const name of ["numIterations", "numElboDraws", "historySize"])
|
|
111
|
+
if (!Number.isInteger(out[name]) || out[name] <= 0 ||
|
|
112
|
+
out[name] > 2147483647)
|
|
113
|
+
throw new RangeError(`pathfinderInit ${name} must be a positive integer`);
|
|
114
|
+
if (typeof out.initRadius !== "number" || !Number.isFinite(out.initRadius) ||
|
|
115
|
+
out.initRadius < 0)
|
|
116
|
+
throw new RangeError(
|
|
117
|
+
"pathfinderInit initRadius must be finite and nonnegative");
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
98
121
|
/** Compile (unless `mir` is given) and draw from the posterior.
|
|
99
122
|
*
|
|
100
123
|
* @param {Object} opts
|
|
@@ -108,6 +131,10 @@ export function compile(opts) {
|
|
|
108
131
|
* @param {number} [opts.warmup=1000]
|
|
109
132
|
* @param {number} [opts.samples=1000]
|
|
110
133
|
* @param {number} [opts.delta=0.8] Adaptation target acceptance (NUTS).
|
|
134
|
+
* @param {Object} [opts.pathfinderInit] Generate the NUTS start with
|
|
135
|
+
* single-path Pathfinder. `{}` uses defaults; supported keys are
|
|
136
|
+
* `numIterations`, `numElboDraws`, `historySize`, and `initRadius`.
|
|
137
|
+
* The sampling seed controls both stages. NUTS only; no PSIS resampling.
|
|
111
138
|
* @param {string} [opts.sampler="nuts"] "nuts", "walnuts" (within-orbit
|
|
112
139
|
* adaptive step-length NUTS, arXiv:2506.18746), or "pathfinder"
|
|
113
140
|
* (a normal approximation fitted along an L-BFGS path). Pathfinder
|
|
@@ -123,9 +150,11 @@ export function compile(opts) {
|
|
|
123
150
|
* a transferred ArrayBuffer of constrained draws, nCon wide.
|
|
124
151
|
* Pathfinder streams {live: {phase: "path", iter, lp}} instead, one
|
|
125
152
|
* message per L-BFGS iterate.
|
|
126
|
-
* @returns {Promise<{names: string[], samples: number,
|
|
153
|
+
* @returns {Promise<{names: string[], samples: number, generatedStart: number,
|
|
127
154
|
* columns: Object<string, Float64Array>,
|
|
128
155
|
* exactLp: boolean,
|
|
156
|
+
* sampler: string, maxDepth: number|null,
|
|
157
|
+
* samplerStats: Float64Array|null,
|
|
129
158
|
* pathfinder?: {path: {iter, lp}[], khat: number,
|
|
130
159
|
* selectedIter: number,
|
|
131
160
|
* selectedElbo: number,
|
|
@@ -134,8 +163,16 @@ export function compile(opts) {
|
|
|
134
163
|
* total: number}}>}
|
|
135
164
|
* One column per CSV column CmdStan would write: constrained
|
|
136
165
|
* parameters, transformed parameters, and generated quantities.
|
|
166
|
+
* NUTS also returns post-warmup samplerStats in draw-major order:
|
|
167
|
+
* lp__, accept_stat__, stepsize__, treedepth__, n_leapfrog__,
|
|
168
|
+
* divergent__, energy__. Other methods return null for samplerStats.
|
|
137
169
|
*/
|
|
138
170
|
export function sample(opts) {
|
|
171
|
+
const sampler = opts.sampler === "walnuts" || opts.sampler === "pathfinder"
|
|
172
|
+
? opts.sampler : "nuts";
|
|
173
|
+
const pathfinderInit = pathfinderInitOptions(opts.pathfinderInit);
|
|
174
|
+
if (pathfinderInit && sampler !== "nuts")
|
|
175
|
+
throw new RangeError("pathfinderInit is available only with NUTS");
|
|
139
176
|
return request({
|
|
140
177
|
cmd: "run",
|
|
141
178
|
code: opts.code,
|
|
@@ -148,16 +185,50 @@ export function sample(opts) {
|
|
|
148
185
|
warmup: opts.warmup == null ? 1000 : opts.warmup,
|
|
149
186
|
samples: opts.samples == null ? 1000 : opts.samples,
|
|
150
187
|
delta: opts.delta == null ? 0.8 : opts.delta,
|
|
151
|
-
sampler
|
|
152
|
-
? opts.sampler : "nuts",
|
|
188
|
+
sampler,
|
|
153
189
|
maxError: opts.maxError == null ? 0 : opts.maxError,
|
|
190
|
+
pathfinderInit,
|
|
154
191
|
}, opts).then((done) => {
|
|
155
|
-
const { names, samples, ms, exactLp, pathfinder
|
|
192
|
+
const { names, samples, generatedStart, ms, exactLp, pathfinder,
|
|
193
|
+
sampler, maxDepth } = done;
|
|
156
194
|
const flat = new Float64Array(done.columns);
|
|
157
195
|
const columns = {};
|
|
158
196
|
names.forEach((name, i) => {
|
|
159
197
|
columns[name] = flat.subarray(i * samples, (i + 1) * samples);
|
|
160
198
|
});
|
|
161
|
-
|
|
199
|
+
const samplerStats = done.samplerStats
|
|
200
|
+
? new Float64Array(done.samplerStats) : null;
|
|
201
|
+
return { names, samples, generatedStart, columns, ms, exactLp, pathfinder,
|
|
202
|
+
sampler, maxDepth, samplerStats };
|
|
162
203
|
});
|
|
163
204
|
}
|
|
205
|
+
|
|
206
|
+
/** Diagnose one NUTS fit, or an array of chains from the same model and
|
|
207
|
+
* configuration. Returns the native R/Python diagnostic report as text,
|
|
208
|
+
* using only post-warmup draws. Inputs are copied, never transferred away.
|
|
209
|
+
* WALNUTS and Pathfinder do not expose the required sampler statistics.
|
|
210
|
+
* @returns {Promise<string>} */
|
|
211
|
+
export async function diagnose(fits) {
|
|
212
|
+
const chains = Array.isArray(fits) ? fits : [fits];
|
|
213
|
+
const first = chains[0];
|
|
214
|
+
if (!first || !Number.isInteger(first.samples) || first.samples < 1 ||
|
|
215
|
+
!Array.isArray(first.names) || !first.names.length)
|
|
216
|
+
throw new Error("diagnose requires nonempty NUTS draws");
|
|
217
|
+
for (const fit of chains) {
|
|
218
|
+
if (!fit || fit.sampler !== "nuts" || fit.samples !== first.samples ||
|
|
219
|
+
fit.maxDepth !== first.maxDepth || !Number.isInteger(fit.maxDepth) ||
|
|
220
|
+
fit.maxDepth < 1 || !Array.isArray(fit.names) ||
|
|
221
|
+
fit.names.length !== first.names.length ||
|
|
222
|
+
fit.names.some((name, j) => name !== first.names[j]) ||
|
|
223
|
+
!(fit.samplerStats instanceof Float64Array) ||
|
|
224
|
+
fit.samplerStats.length !== first.samples * 7 ||
|
|
225
|
+
!fit.columns || first.names.some((name) =>
|
|
226
|
+
!(fit.columns[name] instanceof Float64Array) ||
|
|
227
|
+
fit.columns[name].length !== first.samples))
|
|
228
|
+
throw new Error("diagnose requires matching NUTS chains with sampler statistics");
|
|
229
|
+
}
|
|
230
|
+
return request({ cmd: "diagnose", names: first.names, samples: first.samples,
|
|
231
|
+
maxDepth: first.maxDepth,
|
|
232
|
+
chains: chains.map((fit) => fit.columns),
|
|
233
|
+
stats: chains.map((fit) => fit.samplerStats) }, {});
|
|
234
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seantalts/stanli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Full Stan in the browser: stanc3 compiles the model in JS, a WASM runtime lowers it to an op graph and runs NUTS. No server, no C++ toolchain.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.mjs",
|