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