Coverage for src/bartz/bcf/_loop.py: 99%
97 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-15 10:04 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-15 10:04 +0000
1# bartz/src/bartz/bcf/_loop.py
2#
3# Copyright (c) 2026, The Bartz Contributors
4#
5# This file is part of bartz.
6#
7# Permission is hereby granted, free of charge, to any person obtaining a copy
8# of this software and associated documentation files (the "Software"), to deal
9# in the Software without restriction, including without limitation the rights
10# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11# copies of the Software, and to permit persons to whom the Software is
12# furnished to do so, subject to the following conditions:
13#
14# The above copyright notice and this permission notice shall be included in all
15# copies or substantial portions of the Software.
16#
17# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23# SOFTWARE.
25"""Implement the BCF MCMC step and traces, to be run with `run_mcmc`."""
27from dataclasses import replace
28from typing import cast
30import jax.numpy as jnp
31from equinox import tree_at
32from jax import lax, random, vmap
33from jaxtyping import Array, Float, Float32, Int32, Key, UInt
35from bartz._jaxext import field, float32_matmuls, jit, sliced_map, split
36from bartz.bcf._state import BCFState
37from bartz.mcmcloop._trace import BurninTrace, MainTrace, Trace
38from bartz.mcmcstep._axes import CHAIN_AXIS
39from bartz.mcmcstep._state import (
40 Forest,
41 State,
42 StepConfig,
43 split_key_for_chains,
44 vmap_chains,
45)
46from bartz.mcmcstep._step import step, step_leaf_prior_cov_inv, step_trees, sum_resid
49def recompute_prec_trees(
50 forest: Forest, prec_scale: Float[Array, ' n'], config: StepConfig
51) -> Float32[Array, 'num_trees tree_size']:
52 """
53 Rebuild `Forest.prec_tree` from scratch for a new `prec_scale`.
55 Like the incremental update in `step`, the result is valid at the leaves of
56 the largest version of each tree and at the node of the last move.
58 Parameters
59 ----------
60 forest
61 The forest whose per-leaf precision cache is stale.
62 prec_scale
63 The new per-datapoint precision scale.
64 config
65 The MCMC configuration, for the reduction settings.
67 Returns
68 -------
69 The per-leaf sums of `prec_scale`.
70 """
71 _, tree_size = forest.leaf_tree.shape
73 def one_tree(
74 args: tuple[UInt[Array, ' n'], Int32[Array, '']],
75 ) -> Float32[Array, ' tree_size']:
76 leaf_indices, move_node = args
77 # the sum over all bins has the shape of the residual reduction, so use
78 # its settings rather than the ones tuned for the two-bin `prec_tree` update
79 tree = sum_resid(
80 prec_scale,
81 leaf_indices,
82 tree_size,
83 config.resid_reduction_config,
84 config.data_sharded,
85 )
86 children = 2 * move_node + jnp.arange(2)
87 return tree.at[move_node].set(tree[children].sum())
89 xs = (forest.leaf_indices, forest.move_node)
91 def all_trees() -> Float32[Array, 'num_trees tree_size']:
92 return vmap(one_tree)(xs)
94 if config.prec_count_num_trees is None: 94 ↛ 99line 94 didn't jump to line 99 because the condition on line 94 was always true
95 return all_trees()
97 else:
99 def tree_batches(
100 batch_size: int = config.prec_count_num_trees,
101 ) -> Float32[Array, 'num_trees tree_size']:
102 return sliced_map(one_tree, xs, batch_size=batch_size)
104 # like `compute_prec_trees`, batch the trees on cpu to bound the reduction
105 # temporaries
106 return lax.platform_dependent(cpu=tree_batches, cuda=all_trees)
109def bcf_step_mu(key: Key[Array, ''], state: BCFState) -> BCFState:
110 """Update the prognostic forest and its leaf prior precision."""
111 # `step` rebuilds the state with `replace`, so it preserves the subclass.
112 # WORKAROUND(python<3.12): type `step` as generic over the state subclass
113 # (PEP 695) instead of casting here, since a TypeVar renders badly in the
114 # html documentation.
115 return cast(BCFState, step(key, state))
118def bcf_step_tau_0(key: Key[Array, ''], state: BCFState) -> BCFState:
119 """Update the treatment effect intercept."""
120 # `resid` is stored scaled: ``resid_unit * resid = data residual``, whereas
121 # `tau_0`, `tau_X`, `b`, `error_cov_inv` and `tau_0_prior_cov_inv` are
122 # on the data scale (matching `_bcf.predict`). Convert `resid` in and out of data
123 # units so the scalar Gibbs updates here and in `bcf_step_b` are
124 # unit-consistent for any `resid_unit` (no-op when it is 1).
126 if state.tau_0_prior_cov_inv is None:
127 return state
129 else:
130 # get coding basis, possibly adaptive so not just 0 and 1
131 b_z = state.b[state.trt.astype(int)]
133 # partial residual removing current tau_0 effect, on the data scale
134 partial_resid = state.resid * state.resid_unit + state.tau_0 * b_z
136 # determine full conditional of tau_0
137 prec = (
138 jnp.sum(jnp.square(b_z)) * state.error_cov_inv.value
139 + state.tau_0_prior_cov_inv
140 )
141 mean = jnp.sum(b_z * partial_resid) * state.error_cov_inv.value / prec
143 # sample tau_0 from full conditional
144 tau_0_new = mean + random.normal(key) * lax.rsqrt(prec)
146 # update state to reflect new tau_0
147 return replace(
148 state,
149 tau_0=tau_0_new,
150 resid=state.resid - b_z * (tau_0_new - state.tau_0) / state.resid_unit,
151 )
154def bcf_step_tau(key: Key[Array, ''], state: BCFState) -> BCFState:
155 """Update the treatment effect forest and its leaf prior precision."""
156 keys = split(key, 2)
158 # get coding basis, possibly adaptive so not just 0 and 1
159 b_z = state.b[state.trt.astype(int)]
161 # Target for tau is (Y - mu - b_z * tau_0) / b_z.
162 # Its residual is target - tau = (Y - mu - b_z*tau_0 - b_z*tau) / b_z
163 b_z_zero = jnp.abs(b_z) < 1e-10
164 b_z_safe = jnp.where(b_z_zero, 1.0, b_z)
165 mu_resid = state.resid
166 initial_resid_tau = jnp.where(b_z_zero, 0.0, mu_resid / b_z_safe)
168 # Swap the tau forest into the forest slot and run only the tree and leaf
169 # prior precision steps on it; the mu forest rides along in `forest_tau`
170 # and is swapped back at the end. The other sub-steps of `step` (latent
171 # outcome, error precision, sparsity, step counter) belong to the mu phase
172 # alone.
173 mu_prec_scale = state.prec_scale
174 state = replace(
175 state,
176 forest=state.forest_tau,
177 forest_tau=state.forest,
178 resid=initial_resid_tau,
179 prec_scale=jnp.square(b_z),
180 )
182 state = cast(BCFState, step_trees(keys.pop(), state))
183 state = cast(BCFState, step_leaf_prior_cov_inv(keys.pop(), state))
185 # Update tau_X! (the residual difference is scaled, bring it to data units)
186 if state.tau_X is not None:
187 state = replace(
188 state,
189 tau_X=state.tau_X + (initial_resid_tau - state.resid) * state.resid_unit,
190 )
192 # Swap the forests back and restore the mu-side fields
193 return replace(
194 state,
195 forest=state.forest_tau,
196 forest_tau=state.forest,
197 resid=jnp.where(b_z_zero, mu_resid, state.resid * b_z_safe),
198 prec_scale=mu_prec_scale,
199 )
202def bcf_step_b(key: Key[Array, ''], state: BCFState) -> BCFState:
203 """Update the adaptive coding weights."""
204 if state.b_prior_cov_inv is None:
205 return state
207 else:
208 assert state.tau_X is not None
210 # get coding basis
211 b_z = state.b[state.trt.astype(int)]
213 # partial residual removing current b effect, on the data scale (see
214 # `bcf_step_tau_0` about units)
215 tau_full = state.tau_0 + state.tau_X
216 partial_resid = state.resid * state.resid_unit + tau_full * b_z
218 # determine full conditional of b, one Gibbs update per treatment group
219 groups = jnp.stack([~state.trt, state.trt])
220 prec = (
221 jnp.sum(jnp.square(tau_full) * groups, axis=1) * state.error_cov_inv.value
222 + state.b_prior_cov_inv
223 )
224 mean = (
225 jnp.sum(tau_full * partial_resid * groups, axis=1)
226 * state.error_cov_inv.value
227 / prec
228 )
230 # sample b from full conditional
231 b_new = mean + random.normal(key, (2,)) * lax.rsqrt(prec)
232 b_z_new = b_new[state.trt.astype(int)]
234 # update state to reflect new b
235 state = replace(
236 state,
237 b=b_new,
238 resid=state.resid - tau_full * (b_z_new - b_z) / state.resid_unit,
239 )
241 # the tau precision scale b_z^2 changed on every datapoint, so the
242 # incrementally maintained per-leaf cache of the tau forest is stale
243 # everywhere
244 return tree_at(
245 lambda s: s.forest_tau.prec_tree,
246 state,
247 recompute_prec_trees(state.forest_tau, jnp.square(b_z_new), state.config),
248 )
251@jit(donate_argnums=(1,))
252@split_key_for_chains
253@vmap_chains
254@float32_matmuls
255def bcf_step(key: Key[Array, ''], state: BCFState) -> BCFState:
256 """
257 Do one BCF MCMC step.
259 Parameters
260 ----------
261 key
262 A jax random key.
263 state
264 A BCF mcmc state, as created by `init_bcf`.
266 Returns
267 -------
268 The new BCF mcmc state.
270 Notes
271 -----
272 The memory of the input state is re-used for the output state, so the input
273 state can not be used any more after calling `bcf_step`. All this applies
274 outside of `jax.jit`.
275 """
276 keys = split(key, 4)
277 state = bcf_step_mu(keys.pop(), state)
278 state = bcf_step_tau_0(keys.pop(), state)
279 state = bcf_step_tau(keys.pop(), state)
280 return bcf_step_b(keys.pop(), state)
283def _tau_view(state: BCFState) -> BCFState:
284 """Return the state with the tau forest in the mu forest slot."""
285 return replace(state, forest=state.forest_tau, forest_tau=state.forest)
288class BCFBurninTrace(Trace):
289 """Burn-in trace of the BCF MCMC, the per-forest diagnostics and the scalar parameters."""
291 mu: BurninTrace
292 """The trace of the prognostic forest."""
294 tau: BurninTrace
295 """The trace of the treatment forest."""
297 tau_0: Float32[Array, '*chains_and_samples'] = field(chains=CHAIN_AXIS, samples=0)
298 """The treatment effect intercept."""
300 b: Float32[Array, '*chains_and_samples 2'] = field(chains=CHAIN_AXIS, samples=0)
301 """The adaptive coding weights for untreated and treated units."""
303 @classmethod
304 def from_state(cls, state: State) -> 'BCFBurninTrace':
305 """Create a single-item burn-in trace from a BCF state."""
306 assert isinstance(state, BCFState)
307 return cls(
308 mu=BurninTrace.from_state(state),
309 tau=BurninTrace.from_state(_tau_view(state)),
310 tau_0=state.tau_0,
311 b=state.b,
312 )
315class BCFMainTrace(BCFBurninTrace):
316 """Main trace of the BCF MCMC, with the trees of both forests."""
318 mu: MainTrace
319 """The trace of the prognostic forest."""
321 tau: MainTrace
322 """The trace of the treatment forest."""
324 @classmethod
325 def from_state(cls, state: State) -> 'BCFMainTrace':
326 """Create a single-item main trace from a BCF state."""
327 assert isinstance(state, BCFState)
328 kw: dict = dict(
329 vars(BCFBurninTrace.from_state(state)),
330 mu=MainTrace.from_state(state),
331 tau=MainTrace.from_state(_tau_view(state)),
332 )
333 return cls(**kw)