@mcmcjs/julia 0.14.0 → 0.14.1

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/fitlib.jl DELETED
@@ -1,869 +0,0 @@
1
- # MCMC.js fit library, shared by the one-shot driver (driver.jl) and the
2
- # persistent worker (worker.jl). handle_request runs one fit or predict request
3
- # (Turing or JuliaBUGS) and writes the draws as the @mcmcjs/core samples
4
- # artifact (a flat JSON wire). This is the only place that touches the backend
5
- # chain type (FlexiChains), so a chain-type change is contained here.
6
- using JSON
7
- using Logging
8
- import Distributed
9
- using Random
10
- using StableRNGs
11
- using Turing
12
- using Turing: VarName
13
- using SHA
14
- using Pkg
15
- # Imported (not `using`) so they do not pull `@model` etc. into Main and clash
16
- # with Turing's exports, which would break Turing model files on include.
17
- import JuliaBUGS
18
- import AdvancedHMC
19
- import ForwardDiff
20
- # Loading Mooncake activates AbstractPPL's native extension for AutoMooncake.
21
- import Mooncake
22
- # Loading ReverseDiff activates the DynamicPPL extension for AutoReverseDiff.
23
- import ReverseDiff
24
- import FlexiChains
25
- import DimensionalData
26
-
27
- # Workers include this file to mirror the master's definitions (ModelData, the
28
- # helpers a deserialized model may reference) when sampling MCMCDistributed.
29
- const FITLIB_FILE = @__FILE__
30
-
31
- # AbstractMCMC reports sampling progress through ProgressLogging (reachable via
32
- # Turing's dependency, no extra package in the managed env).
33
- const ProgressLogging = Turing.AbstractMCMC.ProgressLogging
34
-
35
- # Where progress JSON lines go: the driver leaves this on stderr; the worker
36
- # points it at the client socket for the duration of a request.
37
- const PROGRESS_IO = Ref{IO}(stderr)
38
-
39
- # Translates ProgressLogging records into one JSON line per update
40
- # ({"mcmcjs":"progress",...}). The TypeScript side filters these out of the
41
- # failure protocol's stderr buffer. Info-and-above records pass through to a
42
- # plain console logger; Debug records (AdvancedHMC step-size search) are dropped.
43
- struct JSONProgressLogger <: Logging.AbstractLogger
44
- fallback::Logging.AbstractLogger
45
- last::Dict{Int,Float64}
46
- end
47
- JSONProgressLogger() =
48
- JSONProgressLogger(Logging.ConsoleLogger(stderr, Logging.Info), Dict{Int,Float64}())
49
-
50
- Logging.min_enabled_level(::JSONProgressLogger) = Logging.BelowMinLevel
51
- Logging.shouldlog(::JSONProgressLogger, args...) = true
52
- Logging.catch_exceptions(::JSONProgressLogger) = true
53
-
54
- function emit_progress(logger::JSONProgressLogger, name, fraction, done)
55
- m = match(r"Chain (\d+) of (\d+)", String(name))
56
- chain = m === nothing ? 1 : parse(Int, m.captures[1])
57
- of = m === nothing ? 1 : parse(Int, m.captures[2])
58
- f = done ? 1.0 : fraction === nothing ? 0.0 : Float64(fraction)
59
- # AbstractMCMC already steps by 1%; the dedupe guards chattier emitters.
60
- if done || f - get(logger.last, chain, -1.0) >= 0.01
61
- logger.last[chain] = f
62
- println(
63
- PROGRESS_IO[],
64
- JSON.json(
65
- Dict(
66
- "mcmcjs" => "progress",
67
- "chain" => chain,
68
- "of" => of,
69
- "fraction" => round(f; digits = 4),
70
- "done" => done,
71
- ),
72
- ),
73
- )
74
- flush(PROGRESS_IO[])
75
- end
76
- end
77
-
78
- function Logging.handle_message(
79
- logger::JSONProgressLogger, level, message, _module, group, id, file, line; kwargs...
80
- )
81
- progress = message isa ProgressLogging.ProgressString ? message.progress :
82
- message isa ProgressLogging.Progress ? message : nothing
83
- if progress !== nothing
84
- emit_progress(logger, progress.name, progress.fraction, progress.done)
85
- return
86
- end
87
- if haskey(kwargs, :progress) && (kwargs[:progress] isa Real || kwargs[:progress] == "done")
88
- value = kwargs[:progress]
89
- emit_progress(logger, string(message), value == "done" ? nothing : value, value == "done")
90
- return
91
- end
92
- level >= Logging.Info || return
93
- Logging.handle_message(logger.fallback, level, message, _module, group, id, file, line; kwargs...)
94
- end
95
-
96
- # A Turing model file may declare the AD backend it needs by exporting, e.g.,
97
- # `const MCMC_DEFAULTS = (; adtype = "mooncake")`. A spec or flag adtype wins;
98
- # the model default fills in only when the request leaves adtype unset.
99
- function apply_model_defaults(sampler, mod)
100
- haskey(sampler, "adtype") && return sampler
101
- # Only the gradient samplers take an AD backend.
102
- get(sampler, "algorithm", "NUTS") in ("NUTS", "HMC", "HMCDA", "Gibbs", "External") ||
103
- return sampler
104
- Base.invokelatest(isdefined, mod, :MCMC_DEFAULTS) || return sampler
105
- defaults = Base.invokelatest(getfield, mod, :MCMC_DEFAULTS)
106
- hasproperty(defaults, :adtype) || return sampler
107
- merged = copy(sampler)
108
- merged["adtype"] = String(defaults.adtype)
109
- return merged
110
- end
111
-
112
- function build_adtype(name)
113
- name == "forwarddiff" && return Turing.ADTypes.AutoForwardDiff()
114
- name == "reversediff" && return Turing.ADTypes.AutoReverseDiff()
115
- name == "mooncake" && return Turing.ADTypes.AutoMooncake(; config = nothing)
116
- error("unsupported adtype: $name")
117
- end
118
-
119
- function build_sampler(sampler, backend, modelmod = nothing)
120
- algorithm = get(sampler, "algorithm", "NUTS")
121
- # Prior draws are iid: no warmup, no adaptation. The JuliaBUGS path samples
122
- # ancestrally (sample_bugs_prior) instead of going through a sampler object.
123
- algorithm == "Prior" && return Turing.Prior(), 0
124
- warmup = Int(get(sampler, "warmup", 1000))
125
- adapt_delta = Float64(get(sampler, "adapt_delta", 0.8))
126
- if backend == "juliabugs"
127
- algorithm == "NUTS" || error("the juliabugs backend supports NUTS and Prior only")
128
- return AdvancedHMC.NUTS(adapt_delta), warmup
129
- end
130
- algorithm == "MH" && return Turing.MH(), warmup
131
- algorithm == "ESS" && return Turing.ESS(), warmup
132
- algorithm == "SMC" && return Turing.SMC(), warmup
133
- algorithm == "PG" && return Turing.PG(Int(sampler["particles"])), warmup
134
- adkw = haskey(sampler, "adtype") ? (; adtype = build_adtype(sampler["adtype"])) : (;)
135
- algorithm == "NUTS" && return Turing.NUTS(warmup, adapt_delta; adkw...), warmup
136
- algorithm == "HMC" &&
137
- return Turing.HMC(Float64(sampler["step_size"]), Int(sampler["leapfrog_steps"]); adkw...),
138
- warmup
139
- algorithm == "HMCDA" &&
140
- return Turing.HMCDA(warmup, adapt_delta, Float64(sampler["lambda"]); adkw...), warmup
141
- algorithm == "Gibbs" && return build_gibbs(sampler, adkw, warmup), warmup
142
- if algorithm == "External"
143
- modelmod === nothing && error("the External algorithm applies to Turing models only")
144
- Base.invokelatest(isdefined, modelmod, :MCMC_SAMPLER) ||
145
- error("algorithm External needs the model file to define MCMC_SAMPLER")
146
- ext = Base.invokelatest(getfield, modelmod, :MCMC_SAMPLER)
147
- return Turing.externalsampler(ext; adkw...), warmup
148
- end
149
- error("unsupported sampler algorithm: $algorithm")
150
- end
151
-
152
- # One Gibbs component: the block's variables get the block's sampler. A single
153
- # variable keys by symbol, several by tuple, matching Turing's constructor.
154
- function block_sampler(block, adkw, warmup)
155
- algorithm = get(block, "algorithm", "NUTS")
156
- adapt_delta = Float64(get(block, "adapt_delta", 0.8))
157
- algorithm == "MH" && return Turing.MH()
158
- algorithm == "ESS" && return Turing.ESS()
159
- algorithm == "PG" && return Turing.PG(Int(block["particles"]))
160
- algorithm == "NUTS" && return Turing.NUTS(warmup, adapt_delta; adkw...)
161
- algorithm == "HMC" &&
162
- return Turing.HMC(Float64(block["step_size"]), Int(block["leapfrog_steps"]); adkw...)
163
- algorithm == "HMCDA" &&
164
- return Turing.HMCDA(warmup, adapt_delta, Float64(block["lambda"]); adkw...)
165
- error("unsupported Gibbs block algorithm: $algorithm")
166
- end
167
-
168
- function build_gibbs(sampler, adkw, warmup)
169
- pairs = map(sampler["blocks"]) do block
170
- vars = Symbol.(block["variables"])
171
- key = length(vars) == 1 ? vars[1] : Tuple(vars)
172
- key => block_sampler(block, adkw, warmup)
173
- end
174
- return Turing.Gibbs(pairs...)
175
- end
176
-
177
- # Extra keyword arguments for AbstractMCMC.sample, from the request's sampler
178
- # table: thinning, burn-in for the non-adaptive samplers (NUTS and HMCDA discard
179
- # their own adaptation), and named starting values replicated across chains.
180
- function sampling_kwargs(sampler, warmup, chains)
181
- kw = (;)
182
- thin = Int(get(sampler, "thin", 1))
183
- thin > 1 && (kw = merge(kw, (; thinning = thin)))
184
- algorithm = get(sampler, "algorithm", "NUTS")
185
- if algorithm in ("MH", "HMC", "ESS", "PG", "Gibbs", "External") && warmup > 0
186
- kw = merge(kw, (; discard_initial = warmup))
187
- end
188
- # AdvancedHMC-style external samplers adapt only when told how many steps to
189
- # use; without n_adapts a chain can freeze at its initial point.
190
- if algorithm == "External" && warmup > 0
191
- kw = merge(kw, (; n_adapts = warmup))
192
- end
193
- # NUTS/HMCDA blocks inside Gibbs adapt only through the sample-level nadapts
194
- # keyword (the constructor's count is ignored there); without it the block
195
- # never tunes its step size and can freeze.
196
- if algorithm == "Gibbs" && warmup > 0
197
- blocks = get(sampler, "blocks", Any[])
198
- if any(get(b, "algorithm", "NUTS") in ("NUTS", "HMCDA") for b in blocks)
199
- kw = merge(kw, (; nadapts = warmup))
200
- end
201
- end
202
- if haskey(sampler, "initial_params")
203
- nt = (; (Symbol(k) => narrow(v) for (k, v) in sampler["initial_params"])...)
204
- strategy = Turing.DynamicPPL.InitFromParams(nt)
205
- kw = merge(kw, (; initial_params = fill(strategy, chains)))
206
- end
207
- return kw
208
- end
209
-
210
- to_namedtuple(data) = (; (Symbol(k) => v for (k, v) in data)...)
211
-
212
- # Binds the data so a model can read a variable as a property (`data.y`) or by
213
- # index (`data["y"]` / `data[:y]`), with `haskey`/`keys` supported, so models
214
- # written in either idiom run unchanged. The underlying NamedTuple is reached
215
- # via getfield to avoid colliding with a data variable also named `nt`.
216
- struct ModelData
217
- nt::NamedTuple
218
- end
219
- Base.getproperty(d::ModelData, k::Symbol) = getproperty(getfield(d, :nt), k)
220
- Base.getindex(d::ModelData, k::Symbol) = getproperty(getfield(d, :nt), k)
221
- Base.getindex(d::ModelData, k::AbstractString) = getproperty(getfield(d, :nt), Symbol(k))
222
- Base.haskey(d::ModelData, k::Symbol) = haskey(getfield(d, :nt), k)
223
- Base.haskey(d::ModelData, k::AbstractString) = haskey(getfield(d, :nt), Symbol(k))
224
- Base.keys(d::ModelData) = keys(getfield(d, :nt))
225
- Base.propertynames(d::ModelData) = propertynames(getfield(d, :nt))
226
-
227
- # JuliaBUGS requires dense arrays with a concrete numeric eltype, but JSON parses
228
- # them as Vector{Any} (and nested arrays as vectors of vectors). narrow promotes a
229
- # numeric array to a concrete eltype and stacks equal-sized nested arrays into a
230
- # dense array; an empty array becomes Float64[].
231
- function narrow(v::AbstractVector)
232
- isempty(v) && return Float64[]
233
- elems = [narrow(x) for x in v]
234
- if all(e -> e isa AbstractArray, elems) && allequal(size.(elems))
235
- return stack(elems; dims = 1)
236
- end
237
- return eltype(elems) <: Real && !isconcretetype(eltype(elems)) ? float.(elems) : elems
238
- end
239
- narrow(x) = x
240
- bugs_namedtuple(data) = (; (Symbol(k) => narrow(v) for (k, v) in data)...)
241
-
242
- # JSON null arrives as `nothing`; map it to `missing` so a blanked outcome becomes
243
- # a sampling (predictive) statement in the model.
244
- to_missing(x) = x === nothing ? missing : (x isa AbstractVector ? map(to_missing, x) : x)
245
- predict_namedtuple(data) = (; (Symbol(k) => to_missing(v) for (k, v) in data)...)
246
-
247
- # A blanked target compiles to a latent node; JuliaBUGS drops scalar missings.
248
- bugs_predict_namedtuple(data) = (; (Symbol(k) => narrow(to_missing(v)) for (k, v) in data)...)
249
-
250
- # Rebuild a posterior VNChain (latents only) from our samples wire so it can feed
251
- # Turing's predict. Scalar leaves are regrouped into their array-valued VarName
252
- # (theta[1..8] -> @varname(theta) holding an 8-vector), so predict matches the
253
- # model's parameters exactly instead of relying on leaf reconstruction. Target
254
- # outcome columns are excluded.
255
- function wire_to_vnchain(wire, targets)
256
- sz = wire["size"]
257
- nIter, total, nChains = Int(sz[1]), Int(sz[2]), Int(sz[3])
258
- flat = wire["value_flat"]
259
- index = Dict(n => i for (i, n) in enumerate(wire["parameters"]))
260
- is_target(n) = any(t -> n == t || startswith(n, t * "["), targets)
261
- keep = [n for n in wire["name_map"]["parameters"] if !is_target(n)]
262
- cell(name, i, c) = begin
263
- p = index[name]
264
- v = flat[i + (p - 1) * nIter + (c - 1) * nIter * total]
265
- v === nothing ? NaN : Float64(v)
266
- end
267
- base_of(n) = (b = findfirst('[', n); b === nothing ? n : n[1:prevind(n, b)])
268
- idx_of(n) = begin
269
- b = findfirst('[', n)
270
- b === nothing && return Int[]
271
- inner = n[nextind(n, b):prevind(n, findlast(']', n))]
272
- parse.(Int, split(inner, ','))
273
- end
274
- groups = Dict{String,Vector{String}}()
275
- for n in keep
276
- push!(get!(groups, base_of(n), String[]), n)
277
- end
278
- dict = Dict{FlexiChains.ParameterOrExtra{<:VarName},Array}()
279
- for (base, names) in groups
280
- vn = eval(:(Turing.@varname($(Meta.parse(base)))))
281
- if length(names) == 1 && isempty(idx_of(names[1]))
282
- m = Matrix{Float64}(undef, nIter, nChains)
283
- for c in 1:nChains, i in 1:nIter
284
- m[i, c] = cell(names[1], i, c)
285
- end
286
- dict[FlexiChains.Parameter(vn)] = m
287
- else
288
- idxs = [idx_of(n) for n in names]
289
- D = length(idxs[1])
290
- dims = ntuple(d -> maximum(ix[d] for ix in idxs), D)
291
- m = Matrix{Array{Float64,D}}(undef, nIter, nChains)
292
- for c in 1:nChains, i in 1:nIter
293
- a = Array{Float64,D}(undef, dims...)
294
- for (n, ix) in zip(names, idxs)
295
- a[ix...] = cell(n, i, c)
296
- end
297
- m[i, c] = a
298
- end
299
- dict[FlexiChains.Parameter(vn)] = m
300
- end
301
- end
302
- return FlexiChains.FlexiChain{VarName}(nIter, nChains, dict)
303
- end
304
-
305
- # Expand one sampler transition to canonical scalar leaves (theta -> theta[1], ...),
306
- # the same names the final samples file uses, so streamed batches reconstruct it.
307
- # Sampler statistics (acceptance rate, tree depth, ...) ride along under the same
308
- # names the samples file records as internals, so consumers get per-draw
309
- # diagnostics without a second channel; Bool stats become 0/1.
310
- function flatten_draw(transition)
311
- out = Pair{String,Float64}[]
312
- for (vn, val) in pairs(transition.params)
313
- for (leaf, leafval) in Turing.DynamicPPL.varname_and_value_leaves(vn, val)
314
- push!(out, string(leaf) => Float64(leafval))
315
- end
316
- end
317
- if hasproperty(transition, :stats)
318
- for (name, value) in pairs(transition.stats)
319
- value isa Real && push!(out, string(name) => Float64(value))
320
- end
321
- end
322
- return out
323
- end
324
-
325
- # A per-draw callback that emits {"mcmcjs":"draws",...} batches on PROGRESS_IO as
326
- # sampling proceeds. AbstractMCMC passes the 1-based chain index as `chain_number`;
327
- # each batch carries a per-chain monotonic seq, and a chain's batches concatenate
328
- # (by leaf name) to the final samples file. Assumes the callback is not invoked
329
- # concurrently across chains (true for the MCMCSerial path build_and_sample uses).
330
- function draw_streamer(draws_per_chain::Int, batch_size::Int)
331
- chain = Ref(-1)
332
- seq = Ref(0)
333
- names = String[]
334
- buffer = Dict{String,Vector{Float64}}()
335
- filled = Ref(0)
336
- function emit(iteration)
337
- filled[] == 0 && return
338
- cols = Dict{String,Vector{Float64}}(n => copy(buffer[n]) for n in names)
339
- println(
340
- PROGRESS_IO[],
341
- JSON.json(
342
- Dict(
343
- "mcmcjs" => "draws",
344
- "chain" => chain[],
345
- "seq" => seq[],
346
- "iteration" => iteration,
347
- "draws" => cols,
348
- ),
349
- ),
350
- )
351
- flush(PROGRESS_IO[])
352
- seq[] += 1
353
- for n in names
354
- empty!(buffer[n])
355
- end
356
- filled[] = 0
357
- end
358
- return function (_rng, _model, _sampler, transition, _state, iteration; chain_number = 1, _kwargs...)
359
- c = Int(chain_number) - 1
360
- if c != chain[]
361
- chain[] = c
362
- seq[] = 0
363
- end
364
- leaves = flatten_draw(transition)
365
- if isempty(names)
366
- for (n, _) in leaves
367
- push!(names, n)
368
- buffer[n] = Float64[]
369
- end
370
- end
371
- for (n, v) in leaves
372
- push!(get!(buffer, n, Float64[]), v)
373
- end
374
- filled[] += 1
375
- if filled[] >= batch_size || iteration >= draws_per_chain
376
- emit(iteration)
377
- end
378
- end
379
- end
380
-
381
- # Turing observes a variable only when its name is a model argument. A dict-reading
382
- # model (`y = data["y"]; y[i] ~ dist`) would otherwise SAMPLE its outcome instead of
383
- # observing it, so condition the built model on the data columns to turn each such
384
- # `~` into an observation. Columns that are already model arguments are skipped: they
385
- # are observed by construction, and conditioning one that is also an argument errors.
386
- # Do not probe the model to find latent columns (e.g. via VarInfo): evaluating a
387
- # dict-reading model assigns prior draws into the array the data is read from,
388
- # corrupting it before we condition. Predictor columns carry no `~`, so conditioning
389
- # on them is a harmless no-op.
390
- function condition_on_data(model, data)
391
- nt = data isa ModelData ? getfield(data, :nt) : data
392
- nt isa NamedTuple || return model
393
- argnames = keys(model.args)
394
- pairs = [k => getproperty(nt, k) for k in keys(nt) if Base.isidentifier(string(k)) && !(k in argnames)]
395
- isempty(pairs) && return model
396
- return Turing.DynamicPPL.condition(model, (; pairs...))
397
- end
398
-
399
- # Build the model and sample in one call so both run in the latest world age:
400
- # the model methods are defined by `include` at runtime, so a plain call from
401
- # this (older) world would fail with "method too new". invokelatest fixes that.
402
- # Returns Turing's default chain type, a FlexiChain. With a callback, draws stream.
403
- function build_and_sample(
404
- entry, data, sampler, draws, chains, rng; callback = nothing, extra = (;), parallel = "serial"
405
- )
406
- model = condition_on_data(entry(data), data)
407
- kw = callback === nothing ? extra : merge(extra, (; callback))
408
- ensemble = parallel == "threads" ? MCMCThreads() :
409
- parallel == "distributed" ? MCMCDistributed() : MCMCSerial()
410
- return Logging.with_logger(JSONProgressLogger()) do
411
- sample(rng, model, sampler, ensemble, draws, chains; progress = true, kw...)
412
- end
413
- end
414
-
415
- # Distributed chains need the model's definitions on every process: workers get
416
- # this file (ModelData and friends), then all processes include the model file
417
- # into Main so the serialized model's references resolve identically everywhere.
418
- function setup_distributed(chains, model_file)
419
- missing_workers = chains - Distributed.nworkers()
420
- if Distributed.nprocs() == 1 || missing_workers > 0
421
- Distributed.addprocs(
422
- Distributed.nprocs() == 1 ? chains : missing_workers;
423
- exeflags = ["--project=$(Base.active_project())", "--startup-file=no"],
424
- )
425
- Distributed.remotecall_eval(
426
- Main, Distributed.workers(), :(Base.include(Main, $FITLIB_FILE))
427
- )
428
- end
429
- Distributed.remotecall_eval(Main, Distributed.procs(), :(Base.include(Main, $model_file)))
430
- end
431
-
432
- # FlexiChains-native wire writer. DimArray(chn) splits array-valued parameters
433
- # into scalar leaves (theta -> theta[1], theta[2]) in the (iter, chain, param)
434
- # orientation; Real-valued extras become the internals section.
435
- function vnchain_to_wire(chn)
436
- da = DimensionalData.DimArray(chn)
437
- pnames = string.(collect(DimensionalData.lookup(da, :param)))
438
- arr = parent(da)
439
- nIter, nChains, nParams = size(arr)
440
-
441
- extras = [e for e in FlexiChains.extras(chn) if eltype(chn[e]) <: Union{Missing,Real}]
442
- enames = [string(Symbol(e)) for e in extras]
443
- total = nParams + length(extras)
444
-
445
- flat = Vector{Union{Float64,Nothing}}(undef, nIter * total * nChains)
446
- # JSON has no Inf/NaN; a non-finite draw (e.g. 1/sqrt(tau) under a diffuse
447
- # prior) becomes null, which the samples parser reads back as NaN.
448
- cell(v) = v === missing || !isfinite(v) ? nothing : Float64(v)
449
- for c in 1:nChains, p in 1:nParams, i in 1:nIter
450
- flat[i + (p - 1) * nIter + (c - 1) * nIter * total] = cell(arr[i, c, p])
451
- end
452
- for (k, e) in enumerate(extras)
453
- m = chn[e]
454
- p = nParams + k
455
- for c in 1:nChains, i in 1:nIter
456
- flat[i + (p - 1) * nIter + (c - 1) * nIter * total] = cell(m[i, c])
457
- end
458
- end
459
-
460
- return Dict(
461
- "size" => [nIter, total, nChains],
462
- "value_flat" => flat,
463
- "parameters" => vcat(pnames, enames),
464
- "name_map" => Dict("parameters" => pnames, "internals" => enames),
465
- )
466
- end
467
-
468
- # JuliaBUGS compiles the model at runtime, so building and sampling must run in
469
- # separate latest-world frames; merging them reintroduces a world-age error.
470
- # Returns a FlexiChain like the Turing path; gen_chains recovers generated
471
- # quantities (unobserved nodes with no observed descendants) after sampling.
472
- # With a callback, draws stream (see bugs_draw_streamer).
473
- function sample_bugs(
474
- model, sampler, warmup, draws, chains, rng;
475
- callback = nothing, thin = 1, threads = false,
476
- )
477
- kw = callback === nothing ? (;) : (; callback)
478
- thin > 1 && (kw = merge(kw, (; thinning = thin)))
479
- ensemble = threads ? JuliaBUGS.AbstractMCMC.MCMCThreads() : JuliaBUGS.AbstractMCMC.MCMCSerial()
480
- return Logging.with_logger(JSONProgressLogger()) do
481
- JuliaBUGS.AbstractMCMC.sample(
482
- rng, model, sampler, ensemble, draws, chains;
483
- chain_type = FlexiChains.VNChain, n_adapts = warmup, discard_initial = warmup,
484
- progress = true, kw...,
485
- )
486
- end
487
- end
488
-
489
- # JuliaBUGS prior sampling: each draw is one ancestral pass (evaluate!! with an
490
- # rng samples every unobserved stochastic node from its prior and recomputes the
491
- # deterministic ones; observed data stays fixed). The collected variables are the
492
- # model parameters plus generated quantities, the same columns a posterior fit
493
- # produces through gen_chains.
494
- function sample_bugs_prior(model, draws, chains, rng)
495
- model isa JuliaBUGS.BUGSModelWithGradient && (model = model.base_model)
496
- APPL = JuliaBUGS.AbstractPPL
497
- vars = vcat(JuliaBUGS.model_parameters(model), JuliaBUGS.generated_quantities(model))
498
- isempty(vars) && error("the model has no parameters or generated quantities to sample")
499
- grab(env, vn) = (v = APPL.getvalue(env, vn); v isa AbstractArray ? copy(v) : v)
500
- columns = Dict(vn => Vector{Any}(undef, draws * chains) for vn in vars)
501
- for c in 1:chains, i in 1:draws
502
- env, _ = APPL.evaluate!!(rng, model)
503
- for vn in vars
504
- columns[vn][i + (c - 1) * draws] = grab(env, vn)
505
- end
506
- end
507
- dict = Dict{FlexiChains.ParameterOrExtra{<:VarName},Array}(
508
- FlexiChains.Parameter(vn) =>
509
- [columns[vn][i + (c - 1) * draws] for i in 1:draws, c in 1:chains] for vn in vars
510
- )
511
- return FlexiChains.FlexiChain{VarName}(draws, chains, dict)
512
- end
513
-
514
- # The JuliaBUGS counterpart to draw_streamer. A JuliaBUGS transition is a raw
515
- # AdvancedHMC.Transition whose params are an unconstrained vector, so (unlike the
516
- # Turing path) draws cannot be read off it directly. Per batch we rebuild a small
517
- # FlexiChain through the same gen_chains + vnchain_to_wire path the final file
518
- # uses, so the streamed leaf names (parameters, generated quantities, and sampler
519
- # stats) match the file exactly. Reconstruction runs on a copy of the model so it
520
- # never perturbs the sampler's state. AbstractMCMC passes the 1-based chain index
521
- # as chain_number; each chain's batches carry a per-chain monotonic seq.
522
- # TODO: once the pinned JuliaBUGS carries TuringLang/JuliaBUGS.jl#517 (with
523
- # AbstractMCMC#212 and AbstractPPL#178), a transition exposes named constrained
524
- # params (a VarNamedTuple) plus generated quantities, so this collapses to a
525
- # per-draw flatten like draw_streamer, dropping the deepcopy and reconstruction.
526
- function bugs_draw_streamer(model, draws_per_chain::Int, batch_size::Int)
527
- recon = deepcopy(model)
528
- side_rng = StableRNG(0)
529
- chain = Ref(-1)
530
- seq = Ref(0)
531
- θs = Vector{Vector{Float64}}()
532
- lps = Float64[]
533
- stats = Any[]
534
- function emit(iteration)
535
- isempty(θs) && return
536
- stats_names = collect(keys(merge((; lp = lps[1]), stats[1])))
537
- stats_values = [vcat(lps[i], collect(values(stats[i]))) for i in eachindex(θs)]
538
- chn = JuliaBUGS.gen_chains(FlexiChains.VNChain, recon, θs, stats_names, stats_values; rng = side_rng)
539
- wire = vnchain_to_wire(chn)
540
- nIter, total, _ = wire["size"]
541
- flat = wire["value_flat"]
542
- params = wire["parameters"]
543
- cols = Dict(params[p] => [flat[i + (p - 1) * nIter] for i in 1:nIter] for p in 1:total)
544
- println(
545
- PROGRESS_IO[],
546
- JSON.json(
547
- Dict(
548
- "mcmcjs" => "draws",
549
- "chain" => chain[],
550
- "seq" => seq[],
551
- "iteration" => iteration,
552
- "draws" => cols,
553
- ),
554
- ),
555
- )
556
- flush(PROGRESS_IO[])
557
- seq[] += 1
558
- empty!(θs)
559
- empty!(lps)
560
- empty!(stats)
561
- end
562
- return function (_rng, _model, _sampler, transition, _state, iteration; chain_number = 1, _kwargs...)
563
- c = Int(chain_number) - 1
564
- if c != chain[]
565
- chain[] = c
566
- seq[] = 0
567
- empty!(θs)
568
- empty!(lps)
569
- empty!(stats)
570
- end
571
- push!(θs, copy(transition.z.θ))
572
- push!(lps, transition.z.ℓπ.value)
573
- push!(stats, AdvancedHMC.stat(transition))
574
- if length(θs) >= batch_size || iteration >= draws_per_chain
575
- emit(iteration)
576
- end
577
- end
578
- end
579
-
580
- # JuliaBUGS pointwise log-likelihood: restore each posterior draw into the
581
- # evaluation environment, recompute the deterministic nodes in topological
582
- # order, and record logpdf per observed node. Every unobserved stochastic node
583
- # must be covered by the posterior columns, or log p(y | theta) would not be a
584
- # function of the draw.
585
- function loglik_bugs(model, posterior)
586
- model isa JuliaBUGS.BUGSModelWithGradient && (model = model.base_model)
587
- APPL = JuliaBUGS.AbstractPPL
588
- gd = model.graph_evaluation_data
589
-
590
- sz = posterior["size"]
591
- nIter, nCols, nChains = Int(sz[1]), Int(sz[2]), Int(sz[3])
592
- flat = posterior["value_flat"]
593
- col = Dict(n => i for (i, n) in enumerate(posterior["parameters"]))
594
- posterior_names = Set(posterior["name_map"]["parameters"])
595
- cell(i, p, c) = begin
596
- v = flat[i + (p - 1) * nIter + (c - 1) * nIter * nCols]
597
- v === nothing ? NaN : Float64(v)
598
- end
599
-
600
- restore = Tuple{APPL.VarName,Int}[]
601
- for vn in JuliaBUGS.model_parameters(model)
602
- leaves = collect(APPL.varname_leaves(vn, APPL.getvalue(model.evaluation_env, vn)))
603
- found = count(l -> string(l) in posterior_names, leaves)
604
- found == length(leaves) ||
605
- error("the posterior samples do not cover $(vn); were they produced by fitting this spec?")
606
- append!(restore, [(l, col[string(l)]) for l in leaves])
607
- end
608
- observed = [
609
- (vn, i) for (i, vn) in enumerate(gd.sorted_nodes) if
610
- gd.is_stochastic_vals[i] && gd.is_observed_vals[i]
611
- ]
612
- isempty(observed) && error("the model has no observed variables to compute a log-likelihood for")
613
-
614
- pnames = string.(first.(observed))
615
- n = length(pnames)
616
- flat_out = Vector{Union{Float64,Nothing}}(undef, nIter * n * nChains)
617
- for c in 1:nChains, i in 1:nIter
618
- env = model.evaluation_env
619
- for (leaf, p) in restore
620
- env = JuliaBUGS.BangBang.setindex!!(env, cell(i, p, c), leaf)
621
- end
622
- for (k, node) in enumerate(gd.sorted_nodes)
623
- if !gd.is_stochastic_vals[k]
624
- env = JuliaBUGS.BangBang.setindex!!(
625
- env, gd.node_function_vals[k](env, gd.loop_vars_vals[k]), node,
626
- )
627
- end
628
- end
629
- for (k, (vn, idx)) in enumerate(observed)
630
- dist = gd.node_function_vals[idx](env, gd.loop_vars_vals[idx])
631
- v = logpdf(dist, APPL.getvalue(env, vn))
632
- flat_out[i + (k - 1) * nIter + (c - 1) * nIter * n] = isfinite(v) ? Float64(v) : nothing
633
- end
634
- end
635
- return Dict(
636
- "size" => [nIter, n, nChains],
637
- "value_flat" => flat_out,
638
- "parameters" => pnames,
639
- "name_map" => Dict("parameters" => pnames, "internals" => String[]),
640
- )
641
- end
642
-
643
- # JuliaBUGS posterior prediction: condition the (target-blanked) model on the
644
- # posterior parameter columns, then evaluate!! ancestral-samples the remaining
645
- # latents once per posterior draw. No sampler or gradient runs.
646
- function predict_bugs(model, posterior, targets, rng)
647
- model isa JuliaBUGS.BUGSModelWithGradient && (model = model.base_model)
648
- APPL = JuliaBUGS.AbstractPPL
649
- target_syms = Set(Symbol.(targets))
650
- is_target(vn) = APPL.getsym(vn) in target_syms
651
-
652
- sz = posterior["size"]
653
- nIter, nCols, nChains = Int(sz[1]), Int(sz[2]), Int(sz[3])
654
- flat = posterior["value_flat"]
655
- col = Dict(n => i for (i, n) in enumerate(posterior["parameters"]))
656
- posterior_names = Set(posterior["name_map"]["parameters"])
657
- cell(i, p, c) = begin
658
- v = flat[i + (p - 1) * nIter + (c - 1) * nIter * nCols]
659
- v === nothing ? NaN : Float64(v)
660
- end
661
-
662
- # Nodes fully covered by posterior columns are conditioned; anything
663
- # uncovered stays latent and is forward-sampled, like Turing.predict.
664
- gd = model.graph_evaluation_data
665
- conditioned = APPL.VarName[]
666
- restore = Tuple{APPL.VarName,Int}[]
667
- for (i, vn) in enumerate(gd.sorted_nodes)
668
- (gd.is_stochastic_vals[i] && !gd.is_observed_vals[i] && !is_target(vn)) || continue
669
- leaves = collect(APPL.varname_leaves(vn, APPL.getvalue(model.evaluation_env, vn)))
670
- found = count(l -> string(l) in posterior_names, leaves)
671
- if found == length(leaves)
672
- push!(conditioned, vn)
673
- append!(restore, [(l, col[string(l)]) for l in leaves])
674
- elseif found > 0
675
- error("the posterior samples cover $(vn) only partially; refit before predicting")
676
- end
677
- end
678
- isempty(conditioned) &&
679
- error("the posterior samples share no parameters with the model; were they produced by fitting this spec?")
680
- cond = APPL.condition(model, conditioned)
681
-
682
- any(is_target, cond.graph_evaluation_data.sorted_nodes) ||
683
- error("no variables predicted; check that [predict].targets name model variables")
684
-
685
- out_leaves = APPL.VarName[]
686
- arr = Array{Float64,3}(undef, 0, 0, 0)
687
- for c in 1:nChains, i in 1:nIter
688
- env = cond.evaluation_env
689
- for (leaf, p) in restore
690
- env = JuliaBUGS.BangBang.setindex!!(env, cell(i, p, c), leaf)
691
- end
692
- cond = JuliaBUGS.BangBang.setproperty!!(cond, :evaluation_env, env)
693
- drawn, _ = APPL.evaluate!!(rng, cond)
694
- if isempty(out_leaves)
695
- out_leaves = [
696
- l for vn in cond.graph_evaluation_data.sorted_nodes if is_target(vn)
697
- for l in APPL.varname_leaves(vn, APPL.getvalue(drawn, vn))
698
- ]
699
- arr = Array{Float64,3}(undef, nIter, length(out_leaves), nChains)
700
- end
701
- for (k, leaf) in enumerate(out_leaves)
702
- arr[i, k, c] = Float64(APPL.getvalue(drawn, leaf))
703
- end
704
- end
705
-
706
- pnames = string.(out_leaves)
707
- n = length(pnames)
708
- flat_out = Vector{Union{Float64,Nothing}}(undef, nIter * n * nChains)
709
- for c in 1:nChains, p in 1:n, i in 1:nIter
710
- v = arr[i, p, c]
711
- flat_out[i + (p - 1) * nIter + (c - 1) * nIter * n] = isfinite(v) ? v : nothing
712
- end
713
- return Dict(
714
- "size" => [nIter, n, nChains],
715
- "value_flat" => flat_out,
716
- "parameters" => pnames,
717
- "name_map" => Dict("parameters" => pnames, "internals" => String[]),
718
- )
719
- end
720
-
721
- function provenance()
722
- packages = Dict{String,String}()
723
- for (_, info) in Pkg.dependencies()
724
- if info.name in
725
- ("Turing", "FlexiChains", "DynamicPPL", "AbstractMCMC", "JuliaBUGS", "AdvancedHMC", "ForwardDiff", "Mooncake", "ADTypes") &&
726
- info.version !== nothing
727
- packages[info.name] = string(info.version)
728
- end
729
- end
730
- manifest = joinpath(dirname(Base.active_project()), "Manifest.toml")
731
- return Dict(
732
- "julia_version" => string(VERSION),
733
- "packages" => packages,
734
- "manifest_sha256" => isfile(manifest) ? bytes2hex(sha256(read(manifest))) : "",
735
- )
736
- end
737
-
738
- # Resolve the model entry function: use the requested name, falling back to the
739
- # conventional build_model. This lets a request leave the entry implicit, or name a
740
- # custom entry that a given model file does not define (then build_model is used).
741
- # Load the user's model file into a throwaway module. Isolation keeps repeated
742
- # requests in the persistent worker from colliding on names (e.g. two models each
743
- # defining `const model_def`), and confines the model's globals to their own scope.
744
- function load_model_module(path)
745
- mod = Module(gensym(:UserModel))
746
- Base.include(mod, abspath(path))
747
- return mod
748
- end
749
-
750
- function resolve_entry(mod, requested)
751
- for name in (requested, "build_model")
752
- name === nothing && continue
753
- sym = Symbol(name)
754
- # The model was just include'd, so its bindings live in a newer world than
755
- # this frame; resolve them in the latest world to avoid a Julia 1.12
756
- # world-age warning on the access.
757
- Base.invokelatest(isdefined, mod, sym) && return Base.invokelatest(getfield, mod, sym)
758
- end
759
- error("model file defines no entry function (looked for $(requested) and build_model)")
760
- end
761
-
762
- # Runs one request end to end. Returns {"ok": true, "provenance": ...} or
763
- # {"ok": false, "error": ..., "stage": ...}; never throws.
764
- function handle_request(request)
765
- out = request["out"]
766
- mode = get(request, "mode", "fit")
767
- backend = get(get(request, "backend", Dict()), "id", "turing")
768
- stage = "compile"
769
- try
770
- Random.seed!(Int(request["seed"]))
771
- rng = StableRNG(Int(request["seed"]))
772
- modelmod = load_model_module(request["model"]["file"])
773
- entry = resolve_entry(modelmod, get(request["model"], "entry", nothing))
774
-
775
- wire = if mode == "loglik"
776
- if backend == "juliabugs"
777
- model = Base.invokelatest(entry, bugs_namedtuple(request["data"]))
778
- stage = "load_samples"
779
- posterior = JSON.parsefile(request["samples"])
780
- stage = "loglik"
781
- Base.invokelatest(loglik_bugs, model, posterior)
782
- else
783
- data = ModelData(to_namedtuple(request["data"]))
784
- model = Base.invokelatest(entry, data)
785
- model = Base.invokelatest(condition_on_data, model, data)
786
- stage = "load_samples"
787
- chn = wire_to_vnchain(JSON.parsefile(request["samples"]), String[])
788
- stage = "loglik"
789
- pll = Base.invokelatest(Turing.DynamicPPL.pointwise_loglikelihoods, model, chn)
790
- isempty(FlexiChains.parameters(pll)) &&
791
- error("the model has no observed variables to compute a log-likelihood for")
792
- vnchain_to_wire(pll)
793
- end
794
- elseif mode == "predict"
795
- if backend == "juliabugs"
796
- model = Base.invokelatest(entry, bugs_predict_namedtuple(request["data"]))
797
- stage = "load_samples"
798
- posterior = JSON.parsefile(request["samples"])
799
- stage = "predict"
800
- Base.invokelatest(predict_bugs, model, posterior, request["predict"]["targets"], rng)
801
- else
802
- model = Base.invokelatest(entry, ModelData(predict_namedtuple(request["data"])))
803
- stage = "load_samples"
804
- rchn = wire_to_vnchain(JSON.parsefile(request["samples"]), request["predict"]["targets"])
805
- stage = "predict"
806
- pp = Base.invokelatest(predict, rng, model, rchn; include_all = false)
807
- isempty(FlexiChains.parameters(pp)) &&
808
- error("no variables predicted; check that [predict].targets name unconditioned outcomes")
809
- vnchain_to_wire(pp)
810
- end
811
- else
812
- data = backend == "juliabugs" ? bugs_namedtuple(request["data"]) : ModelData(to_namedtuple(request["data"]))
813
- parallel = get(request["sampler"], "parallel", "serial")
814
- chains = Int(get(request["sampler"], "chains", 4))
815
- if parallel == "distributed" && backend != "juliabugs"
816
- # Every process needs the model's definitions under the same
817
- # names; the module-per-request isolation gives way to Main.
818
- stage = "distribute"
819
- Base.invokelatest(setup_distributed, chains, request["model"]["file"])
820
- modelmod = Main
821
- entry = resolve_entry(Main, get(request["model"], "entry", nothing))
822
- end
823
- sampler_conf = backend == "juliabugs" ? request["sampler"] :
824
- apply_model_defaults(request["sampler"], modelmod)
825
- sampler, warmup = build_sampler(sampler_conf, backend, modelmod)
826
- draws = Int(request["sampler"]["draws"])
827
- stage = "sample"
828
- if backend == "juliabugs"
829
- model = Base.invokelatest(entry, data)
830
- chn = if get(request["sampler"], "algorithm", "NUTS") == "Prior"
831
- Base.invokelatest(sample_bugs_prior, model, draws, chains, rng)
832
- else
833
- # Reconstruction reads the plain BUGSModel; entry may return a gradient wrapper.
834
- base = hasproperty(model, :base_model) ? model.base_model : model
835
- threads = get(request["sampler"], "parallel", "serial") == "threads"
836
- cb = get(request, "stream_draws", false) && !threads ?
837
- bugs_draw_streamer(base, draws, Int(get(request, "draw_batch_size", 25))) : nothing
838
- Base.invokelatest(
839
- sample_bugs, model, sampler, warmup, draws, chains, rng;
840
- callback = cb, thin = Int(get(request["sampler"], "thin", 1)), threads,
841
- )
842
- end
843
- vnchain_to_wire(chn)
844
- else
845
- # The draw streamer assumes chains arrive one at a time; with
846
- # concurrent chains the caller must not request streaming.
847
- cb = get(request, "stream_draws", false) && parallel == "serial" ?
848
- draw_streamer(draws, Int(get(request, "draw_batch_size", 25))) : nothing
849
- extra = sampling_kwargs(request["sampler"], warmup, chains)
850
- chn = Base.invokelatest(
851
- build_and_sample, entry, data, sampler, draws, chains, rng;
852
- callback = cb, extra, parallel,
853
- )
854
- vnchain_to_wire(chn)
855
- end
856
- end
857
-
858
- stage = "write"
859
- tmp = out * ".tmp"
860
- open(tmp, "w") do io
861
- JSON.print(io, wire)
862
- end
863
- mv(tmp, out; force = true)
864
-
865
- return Dict("ok" => true, "provenance" => provenance())
866
- catch err
867
- return Dict("ok" => false, "error" => sprint(showerror, err), "stage" => stage)
868
- end
869
- end