Coverage for src/bartz/bcf/_bcf.py: 98%
333 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/_bcf.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"""Bayesian Causal Forests (BCF) interface."""
27import dataclasses
28import json
29from operator import attrgetter
30from pathlib import Path
31from typing import Any, Literal, cast
33import equinox as eqx
34import jax
35import jax.numpy as jnp
36import numpy as np
37from jax import device_put, lax, random, tree
38from jax.scipy import special
39from jaxtyping import Array, Float32, Key, Real, Shaped
41from bartz._interface import (
42 ArrayLike,
43 DataFrame,
44 FloatLike,
45 Series,
46 _flatten_chain_sample,
47 _guarded_response_variance,
48 _process_error_variance_settings,
49 _process_leaf_variance_settings,
50 _process_offset_settings,
51 _process_predictor_input,
52 _process_response_input,
53 predict_latent,
54)
55from bartz._jaxext import split
56from bartz.bcf._loop import BCFBurninTrace, BCFMainTrace, bcf_step
57from bartz.bcf._state import init_bcf
58from bartz.mcmcloop import MainTrace, run_mcmc
59from bartz.mcmcloop._trace import Trace
60from bartz.mcmcstep import OutcomeType, Wishart
61from bartz.mcmcstep._axes import chain_vmap_axes, trace_sample_axes
62from bartz.mcmcstep._state import make_p_nonterminal
63from bartz.prepcovars import RangeEvenBinner, UniqueQuantileBinner
66def _process_bcf_predictor_input(
67 x: Real[ArrayLike, 'n p'] | DataFrame,
68) -> tuple[Shaped[Array, 'p n'], Any]:
69 """
70 Process predictors (one predictor per column) to bartz layout (p, n).
72 Parameters
73 ----------
74 x
75 The predictor data.
77 Returns
78 -------
79 tuple[Shaped[Array, "p n"], Any]
80 A tuple containing the predictors transposed to (p, n) shape and their
81 original format metadata.
82 """
83 if not isinstance(x, DataFrame):
84 x = jnp.asarray(x).T
85 return _process_predictor_input(x)
88def _fold_chains(trace: Trace, path: str) -> Float32[Array, 'num_samples ...']:
89 """Fold the chain axis of a trace field into its sample axis, like `predict_latent`."""
90 get = attrgetter(path)
91 return _flatten_chain_sample(
92 get(trace), get(chain_vmap_axes(trace)), get(trace_sample_axes(trace))
93 )
96def _serialize_binner(binner: Any, max_split: Any = None) -> dict[str, Any]: # noqa: ANN401
97 """
98 Serialize binner state to a dictionary of NumPy arrays.
100 Parameters
101 ----------
102 binner
103 The binner object to serialize.
104 max_split
105 Optional pre-extracted max_split array.
107 Returns
108 -------
109 dict[str, Any]
110 A dictionary containing the serialized binner attributes.
112 Raises
113 ------
114 RuntimeError
115 If max_split was donated to JAX and not provided explicitly.
116 """
117 binner_dict = {}
118 binner_dict['class'] = binner.__class__.__name__
120 if max_split is None: # pragma: no cover
121 try:
122 max_split = np.asarray(binner.max_split)
123 except RuntimeError as exc:
124 msg = 'max_split array was donated to JAX. Pass max_split explicitly.'
125 raise RuntimeError(msg) from exc
126 binner_dict['max_split'] = np.asarray(max_split)
128 if hasattr(binner, '_splits'):
129 binner_dict['_splits'] = np.asarray(binner._splits) # noqa: SLF001
131 if hasattr(binner, '_low'): # pragma: no cover
132 binner_dict['_low'] = np.asarray(binner._low) # noqa: SLF001
133 binner_dict['_high'] = np.asarray(binner._high) # noqa: SLF001
134 binner_dict['_max_bins'] = np.asarray(binner._max_bins) # noqa: SLF001
136 return binner_dict
139def _deserialize_binner(data: Any) -> Any: # noqa: ANN401
140 """
141 Reconstruct binner instance from serialized NPZ data dictionary.
143 Parameters
144 ----------
145 data
146 A mapping containing the serialized binner fields from NPZ archive.
148 Returns
149 -------
150 binner : Any
151 A reconstituted binner instance.
152 """
153 binner_cls_name = str(data.get('binner.class', 'UniqueQuantileBinner'))
154 if binner_cls_name == 'UniqueQuantileBinner':
155 binner = object.__new__(UniqueQuantileBinner)
156 object.__setattr__(binner, '_splits', jnp.asarray(data['binner._splits']))
157 object.__setattr__(binner, 'max_split', jnp.asarray(data['binner.max_split']))
158 else: # pragma: no cover
159 binner = object.__new__(RangeEvenBinner)
160 object.__setattr__(binner, '_low', jnp.asarray(data['binner._low']))
161 object.__setattr__(binner, '_high', jnp.asarray(data['binner._high']))
162 object.__setattr__(binner, '_max_bins', int(data['binner._max_bins']))
163 object.__setattr__(binner, 'max_split', jnp.asarray(data['binner.max_split']))
164 return binner
167def make_leaf_prior_cov_inv(
168 value: FloatLike, sample: bool, shape: FloatLike, scale: FloatLike
169) -> Wishart:
170 """Build the leaf precision prior from the inverse-gamma prior on the variance."""
171 if sample:
172 return Wishart(nu=2 * shape, rate=2 * scale, value=value)
173 else:
174 return Wishart(nu=None, rate=None, value=value)
177class bcf(eqx.Module):
178 R"""
179 Bayesian Causal Forests (BCF).
181 Regress `y_train` on `x_train` and `z_train` (treatment) with two latent
182 mean functions represented as sums of decision trees:
183 Y = mu(X, pihat) + tau(X, pihat) * Z + error
185 For continuous outcomes, the hyperparameters with units are on the scale
186 of the standardized response, see `standardize`, while the outputs are on
187 the scale of `y_train`.
189 Parameters
190 ----------
191 x_train
192 The training predictors (confounders/modifiers).
193 y_train
194 The training responses.
195 z_train
196 The binary treatment assignment (0 or 1).
197 pihat_train
198 The estimated propensity scores. If provided, appended to `x_train`.
199 x_test
200 The test predictors. If provided, predictions at these points are
201 computed and stored in `mu_test` and `tau_test`.
202 z_test
203 The test treatment assignment (0 or 1). If provided together with
204 `x_test`, the outcome prediction is stored in `yhat_test`.
205 pihat_test
206 The test propensity scores. Must be passed together with `pihat_train`.
207 include_pihat_in_mu
208 Whether to include propensity scores in the prognostic forest.
209 include_pihat_in_tau
210 Whether to include propensity scores in the treatment effect forest.
211 num_trees_mu
212 The number of trees used for the prognostic forest `mu`.
213 num_trees_tau
214 The number of trees used for the treatment effect forest `tau`.
215 ndpost
216 The number of MCMC samples to save, after burn-in, per chain. The
217 posterior samples of all chains are concatenated, for a total of
218 ``num_chains * ndpost``.
219 nskip
220 The number of initial MCMC samples to discard as burn-in, per chain.
221 num_chains
222 The number of independent MCMC chains. `None` for a single chain
223 without an explicit chain axis, which is equivalent to 1.
224 k_mu
225 Prior parameter k for prognostic forest.
226 k_tau
227 Prior parameter k for treatment forest.
228 sigma_df
229 Prior degrees of freedom for error variance.
230 sigma_scale
231 Prior scale of the error standard deviation.
232 sigma_init
233 Initial value of the error standard deviation.
234 leaf_prior_cov_inv_mu
235 Custom leaf prior precision for the prognostic forest.
236 leaf_prior_cov_inv_tau
237 Custom leaf prior precision for the treatment effect forest.
238 min_points_per_leaf_mu
239 Minimum data points per leaf for prognostic forest.
240 min_points_per_leaf_tau
241 Minimum data points per leaf for treatment forest.
242 tau_0_prior_var
243 Prior variance for global intercept tau_0.
244 sample_intercept
245 Whether to sample a global treatment intercept `tau_0`.
246 adaptive_coding
247 Whether to use adaptive coding for the treatment effect.
248 sample_sigma2_leaf_mu
249 Whether to sample the leaf parameter variance for the prognostic forest.
250 sigma2_leaf_shape_mu
251 The shape parameter for the Inverse-Gamma prior on the prognostic forest leaf variance.
252 sigma2_leaf_scale_mu
253 The scale parameter for the Inverse-Gamma prior on the prognostic forest leaf variance.
254 sample_sigma2_leaf_tau
255 Whether to sample the leaf parameter variance for the treatment effect forest.
256 sigma2_leaf_shape_tau
257 The shape parameter for the Inverse-Gamma prior on the treatment effect forest leaf variance.
258 sigma2_leaf_scale_tau
259 The scale parameter for the Inverse-Gamma prior on the treatment effect forest leaf variance.
260 standardize
261 Whether to standardize `y_train` internally, ignored for binary
262 outcomes. If `False`, the hyperparameters with units (`sigma_scale`,
263 `sigma_init`, `leaf_prior_cov_inv_*`, `tau_0_prior_var`,
264 `sigma2_leaf_scale_*`) are on the scale of `y_train` instead of the
265 standardized one.
266 outcome_type
267 Either 'continuous' or 'binary' (probit link).
268 delta_max
269 Maximum plausible treatment effect on the probability scale for binary probit models.
270 seed
271 The seed for the random number generator.
273 Raises
274 ------
275 ValueError
276 If binary outcome is specified but `y_train` contains values other
277 than 0 or 1, or if the format of `x_test` does not match `x_train`
278 format, or if `z_test` or `pihat_test` is passed without `x_test`, or
279 if only one of `pihat_train` and `pihat_test` is passed, or if
280 `z_test` or `pihat_test` does not match the length of `x_test`.
281 """
283 _mcmc_state: Any
284 _binner: Any
285 _main_trace: Any
286 _burnin_trace: Any
287 _tau_0_trace: Any
288 _b_trace: Any
289 _leaf_prior_cov_inv_mu_trace: Any
290 _leaf_prior_cov_inv_tau_trace: Any
291 _x_train_fmt: Any = eqx.field(static=True, default=None)
292 _standardize: bool = eqx.field(static=True, default=False)
293 _y_mean: Float32[ArrayLike, ''] | float = eqx.field(default=0.0)
294 _y_std: Float32[ArrayLike, ''] | float = eqx.field(default=1.0)
295 _outcome_type: str = eqx.field(static=True, default='continuous')
296 _offset: Float32[ArrayLike, ''] | float = eqx.field(default=0.0)
297 _mu_test: Float32[Array, 'num_samples m'] | None = eqx.field(default=None)
298 _tau_test: Float32[Array, 'num_samples m'] | None = eqx.field(default=None)
299 _yhat_test: Float32[Array, 'num_samples m'] | None = eqx.field(default=None)
301 def __init__( # noqa: C901, PLR0915
302 self,
303 x_train: Real[ArrayLike, 'n p'] | DataFrame,
304 y_train: Float32[ArrayLike, ' n'] | Series,
305 z_train: Float32[ArrayLike, ' n'] | Series,
306 *,
307 pihat_train: Float32[ArrayLike, ' n'] | Series | None = None,
308 x_test: Real[ArrayLike, 'm p'] | DataFrame | None = None,
309 z_test: Float32[ArrayLike, ' m'] | Series | None = None,
310 pihat_test: Float32[ArrayLike, ' m'] | Series | None = None,
311 include_pihat_in_mu: bool = True,
312 include_pihat_in_tau: bool = False,
313 num_trees_mu: int = 250,
314 num_trees_tau: int = 100,
315 ndpost: int = 1000,
316 nskip: int = 100,
317 num_chains: int | None = None,
318 k_mu: float = 2.0,
319 k_tau: float = 10.0,
320 sigma_df: float = 3.0,
321 sigma_scale: float | Literal['auto'] = 'auto',
322 sigma_init: float | Literal['auto'] = 'auto',
323 leaf_prior_cov_inv_mu: FloatLike | None = None,
324 leaf_prior_cov_inv_tau: FloatLike | None = None,
325 min_points_per_leaf_mu: int = 5,
326 min_points_per_leaf_tau: int = 5,
327 tau_0_prior_var: float | None = None,
328 sample_intercept: bool = True,
329 adaptive_coding: bool = False,
330 sample_sigma2_leaf_mu: bool = True,
331 sigma2_leaf_shape_mu: float = 3.0,
332 sigma2_leaf_scale_mu: FloatLike | None = None,
333 sample_sigma2_leaf_tau: bool = False,
334 sigma2_leaf_shape_tau: float = 3.0,
335 sigma2_leaf_scale_tau: FloatLike | None = None,
336 standardize: bool = True,
337 outcome_type: Literal['continuous', 'binary'] = 'continuous',
338 delta_max: float = 0.9,
339 seed: int | Key[Array, ''] = 0,
340 ) -> None:
342 # 1. Pre-process the data (convert to arrays and transpose X to (p, n))
343 x_train, self._x_train_fmt = _process_bcf_predictor_input(x_train)
344 y_train = _process_response_input(y_train)
345 z_train = _process_response_input(z_train)
346 z_train = eqx.error_if(
347 z_train,
348 jnp.any((z_train != 0) & (z_train != 1)),
349 'Values in `z_train` must be 0 or 1.',
350 ).astype(bool)
352 self._outcome_type = outcome_type
354 if outcome_type == 'binary':
355 if not jnp.all((y_train == 0) | (y_train == 1)):
356 msg = 'Values in `y_train` must be strictly 0 or 1 for binary outcomes.'
357 raise ValueError(msg)
358 standardize = False
360 if standardize:
361 y_mean = jnp.mean(y_train)
362 y_std = jnp.std(y_train)
363 y_std = jnp.where(y_std == 0, 1.0, y_std)
364 y_train_internal = (y_train - y_mean) / y_std
365 else:
366 y_mean = jnp.float32(0.0)
367 y_std = jnp.float32(1.0)
368 y_train_internal = y_train
370 self._standardize = standardize
371 self._y_mean = y_mean
372 self._y_std = y_std
374 if pihat_train is not None:
375 pihat_train = _process_response_input(pihat_train)
377 if x_test is None:
378 if z_test is not None or pihat_test is not None:
379 msg = '`z_test` and `pihat_test` require `x_test`.'
380 raise ValueError(msg)
381 else:
382 x_test_preprocessed, x_test_fmt = _process_bcf_predictor_input(x_test)
383 _, m = x_test_preprocessed.shape
384 del x_test_preprocessed
385 if x_test_fmt != self._x_train_fmt:
386 msg = (
387 f'Format of x_test {x_test_fmt} does not match x_train'
388 f' {self._x_train_fmt}'
389 )
390 raise ValueError(msg)
391 if (pihat_train is None) != (pihat_test is None):
392 msg = '`pihat_train` and `pihat_test` must be passed together.'
393 raise ValueError(msg)
394 if z_test is not None:
395 z_test = _process_response_input(z_test)
396 (len_z,) = z_test.shape
397 if len_z != m:
398 msg = f'`z_test` has length {len_z}, but `x_test` has {m} rows.'
399 raise ValueError(msg)
400 z_test = eqx.error_if(
401 z_test,
402 jnp.any((z_test != 0) & (z_test != 1)),
403 'Values in `z_test` must be 0 or 1.',
404 ).astype(bool)
405 if pihat_test is not None: 405 ↛ 416line 405 didn't jump to line 416 because the condition on line 405 was always true
406 pihat_test = _process_response_input(pihat_test)
407 (len_pihat,) = pihat_test.shape
408 if len_pihat != m:
409 msg = (
410 f'`pihat_test` has length {len_pihat}, but `x_test` has'
411 f' {m} rows.'
412 )
413 raise ValueError(msg)
415 # 2. Append pihat to X to create unified predictor matrix
416 x_train_unified = x_train
417 pihat_index = None
419 if pihat_train is not None: 419 ↛ 427line 419 didn't jump to line 427 because the condition on line 419 was always true
420 # x_train is (p, n), pihat_train is (n,). Add a channel dim to pihat to
421 # make it (1, n)
422 pihat_row = pihat_train[jnp.newaxis, :]
423 x_train_unified = jnp.concatenate([x_train_unified, pihat_row], axis=0)
424 pihat_index = x_train_unified.shape[0] - 1
426 # 3. Resolve priors for both mu and tau forests
427 binary_mask = (
428 jnp.ones((), dtype=bool)
429 if outcome_type == 'binary'
430 else jnp.zeros((), dtype=bool)
431 )
433 offset_val = _process_offset_settings(y_train_internal, binary_mask, None, None)
434 self._offset = offset_val
436 if leaf_prior_cov_inv_mu is None:
437 if outcome_type == 'binary':
438 leaf_prior_cov_inv_mu = jnp.array(num_trees_mu, jnp.float32)
439 else:
440 leaf_prior_cov_inv_mu = _process_leaf_variance_settings(
441 y_train_internal,
442 binary_mask,
443 missing=None,
444 k=jnp.array(k_mu),
445 num_trees=num_trees_mu,
446 tau_num=None,
447 sigma_mu_df=None,
448 ).value
449 if leaf_prior_cov_inv_tau is None:
450 if outcome_type == 'binary':
451 p_val = 0.6827
452 q_quantile = special.ndtri((p_val + 1) / 2.0)
453 phi_0 = 1.0 / jnp.sqrt(2 * jnp.pi)
454 sigma2_tau = ((delta_max / (q_quantile * phi_0)) ** 2) / num_trees_tau
455 leaf_prior_cov_inv_tau = jnp.reciprocal(sigma2_tau)
456 else:
457 leaf_prior_cov_inv_tau = _process_leaf_variance_settings(
458 y_train_internal,
459 binary_mask,
460 missing=None,
461 k=jnp.array(k_tau),
462 num_trees=num_trees_tau,
463 tau_num=None,
464 sigma_mu_df=None,
465 ).value
467 error_cov_inv = _process_error_variance_settings(
468 y_train_internal,
469 binary_mask,
470 None,
471 outcome_type=OutcomeType(outcome_type),
472 sigma_df=sigma_df,
473 sigma_scale=sigma_scale,
474 sigma_init=sigma_init,
475 error_scale=None,
476 )
478 p_nonterminal_mu = make_p_nonterminal(d=10, alpha=0.95, beta=2.0)
479 p_nonterminal_tau = make_p_nonterminal(d=5, alpha=0.25, beta=3.0)
481 if outcome_type == 'binary':
482 var_y = 1.0
483 else:
484 var_y = _guarded_response_variance(y_train_internal, None, None)
485 if sigma2_leaf_scale_mu is None:
486 sigma2_leaf_scale_mu = var_y / num_trees_mu
487 if sigma2_leaf_scale_tau is None:
488 sigma2_leaf_scale_tau = 0.5 * var_y / num_trees_tau
490 # 3.5 Bin the unified data
491 rng = random.key(seed) if not isinstance(seed, jax.Array) else seed
492 keys = split(rng)
494 binner = UniqueQuantileBinner(x_train_unified, key=keys.pop())
495 x_train_binned = binner.bin(x_train_unified)
496 # copies because `init_bcf` may donate them
497 max_split_mu = jnp.copy(binner.max_split)
498 max_split_tau = jnp.copy(binner.max_split)
500 if pihat_index is not None: 500 ↛ 508line 500 didn't jump to line 508 because the condition on line 500 was always true
501 if not include_pihat_in_mu:
502 max_split_mu = max_split_mu.at[pihat_index].set(0)
503 if not include_pihat_in_tau: 503 ↛ 508line 503 didn't jump to line 508 because the condition on line 503 was always true
504 # Block splits on propensity score for tau
505 max_split_tau = max_split_tau.at[pihat_index].set(0)
507 # 4. Initialize BCFState
508 initial_state = init_bcf(
509 X_unified=x_train_binned,
510 trt=z_train,
511 y=y_train_internal,
512 outcome_type=outcome_type,
513 offset=offset_val,
514 max_split_mu=max_split_mu,
515 max_split_tau=max_split_tau,
516 num_trees_mu=num_trees_mu,
517 num_trees_tau=num_trees_tau,
518 p_nonterminal_mu=p_nonterminal_mu,
519 p_nonterminal_tau=p_nonterminal_tau,
520 leaf_prior_cov_inv_mu=make_leaf_prior_cov_inv(
521 leaf_prior_cov_inv_mu,
522 sample_sigma2_leaf_mu,
523 sigma2_leaf_shape_mu,
524 sigma2_leaf_scale_mu,
525 ),
526 leaf_prior_cov_inv_tau=make_leaf_prior_cov_inv(
527 leaf_prior_cov_inv_tau,
528 sample_sigma2_leaf_tau,
529 sigma2_leaf_shape_tau,
530 sigma2_leaf_scale_tau,
531 ),
532 min_points_per_leaf_mu=min_points_per_leaf_mu,
533 min_points_per_leaf_tau=min_points_per_leaf_tau,
534 # ignore all predictors without splits, like `Bart(..., rm_const=True)`
535 filter_splitless_vars_mu=jnp.sum(max_split_mu == 0).item(),
536 filter_splitless_vars_tau=jnp.sum(max_split_tau == 0).item(),
537 tau_0_prior_var=tau_0_prior_var,
538 sample_intercept=sample_intercept,
539 adaptive_coding=adaptive_coding,
540 error_cov_inv=error_cov_inv,
541 num_chains=num_chains,
542 )
544 # 5. Run the MCMC loop
545 # WORKAROUND(python<3.12): once `run_mcmc` is generic over the state
546 # subclass (PEP 695), `bcf_step` will type-check as its `step` and the
547 # traces will come out typed, dropping the ignore and the casts.
548 final_state, burnin_trace, main_trace = run_mcmc(
549 keys.pop(),
550 initial_state,
551 ndpost,
552 n_burn=nskip,
553 step=bcf_step, # ty: ignore[invalid-argument-type]
554 burnin_trace_type=BCFBurninTrace,
555 main_trace_type=BCFMainTrace,
556 )
557 burnin_trace = cast(BCFBurninTrace, burnin_trace)
558 main_trace = cast(BCFMainTrace, main_trace)
559 self._mcmc_state = final_state
560 self._binner = binner
561 # fold the chains like `predict_latent` does to align the samples
562 self._tau_0_trace = _fold_chains(main_trace, 'tau_0')
563 self._b_trace = _fold_chains(main_trace, 'b')
564 self._leaf_prior_cov_inv_mu_trace = _fold_chains(
565 main_trace, 'mu.leaf_prior_cov_inv'
566 )
567 self._leaf_prior_cov_inv_tau_trace = _fold_chains(
568 main_trace, 'tau.leaf_prior_cov_inv'
569 )
570 self._main_trace = {'mu': main_trace.mu, 'tau': main_trace.tau}
571 self._burnin_trace = {'mu': burnin_trace.mu, 'tau': burnin_trace.tau}
573 # 6. Predict at the test points, now that the traces are available
574 if x_test is not None:
575 test_pred = self.predict(x_test, pihat_test=pihat_test)
576 self._mu_test = test_pred['mu']
577 self._tau_test = test_pred['tau']
578 if z_test is not None: 578 ↛ exitline 578 didn't return from function '__init__' because the condition on line 578 was always true
579 self._yhat_test = self._mu_test + z_test * self._tau_test
581 @classmethod
582 def _from_saved_state(
583 cls,
584 *,
585 binner: Any, # noqa: ANN401
586 tau_0_trace: Any, # noqa: ANN401
587 b_trace: Any, # noqa: ANN401
588 main_trace: Any, # noqa: ANN401
589 burnin_trace: Any = None, # noqa: ANN401
590 mcmc_state: Any = None, # noqa: ANN401
591 leaf_prior_cov_inv_mu_trace: Any = None, # noqa: ANN401
592 leaf_prior_cov_inv_tau_trace: Any = None, # noqa: ANN401
593 x_train_fmt: Any = None, # noqa: ANN401
594 standardize: bool = False,
595 y_mean: Float32[ArrayLike, ''] | float = 0.0,
596 y_std: Float32[ArrayLike, ''] | float = 1.0,
597 outcome_type: str = 'continuous',
598 offset: Float32[ArrayLike, ''] | float = 0.0,
599 mu_test: Float32[Array, 'num_samples m'] | None = None,
600 tau_test: Float32[Array, 'num_samples m'] | None = None,
601 yhat_test: Float32[Array, 'num_samples m'] | None = None,
602 ) -> 'bcf':
603 """
604 Private factory constructor to initialize bcf instance from restored state.
606 Parameters
607 ----------
608 binner
609 The binner instance for continuous predictor transforms.
610 tau_0_trace
611 Posterior trace of the tau_0 intercept.
612 b_trace
613 Posterior trace of the adaptive coding weights, columns b0 and b1.
614 main_trace
615 Posterior traces for mu and tau forests.
616 burnin_trace
617 Optional burn-in trace data.
618 mcmc_state
619 Optional final MCMC state.
620 leaf_prior_cov_inv_mu_trace
621 Optional prior variance trace for mu forest.
622 leaf_prior_cov_inv_tau_trace
623 Optional prior variance trace for tau forest.
624 x_train_fmt
625 Formatting metadata of training predictors.
626 standardize
627 Whether predictions should be unscaled back to original outcome units.
628 y_mean
629 Original training outcome mean for unscaling.
630 y_std
631 Original training outcome standard deviation for unscaling.
632 outcome_type
633 The regression target type ('continuous' or 'binary').
634 offset
635 Probit latent scale offset (0.0 for continuous).
636 mu_test
637 Optional control mean at the test points.
638 tau_test
639 Optional treatment effect at the test points.
640 yhat_test
641 Optional outcome at the test points.
643 Returns
644 -------
645 bcf
646 A reconstituted `bcf` model ready for prediction.
647 """
648 model = object.__new__(cls)
649 object.__setattr__(model, '_mcmc_state', mcmc_state)
650 object.__setattr__(model, '_binner', binner)
651 object.__setattr__(model, '_main_trace', main_trace)
652 object.__setattr__(model, '_burnin_trace', burnin_trace)
653 object.__setattr__(model, '_tau_0_trace', tau_0_trace)
654 object.__setattr__(model, '_b_trace', b_trace)
655 object.__setattr__(
656 model, '_leaf_prior_cov_inv_mu_trace', leaf_prior_cov_inv_mu_trace
657 )
658 object.__setattr__(
659 model, '_leaf_prior_cov_inv_tau_trace', leaf_prior_cov_inv_tau_trace
660 )
661 object.__setattr__(model, '_x_train_fmt', x_train_fmt)
662 object.__setattr__(model, '_standardize', standardize)
663 object.__setattr__(model, '_y_mean', jnp.asarray(y_mean))
664 object.__setattr__(model, '_y_std', jnp.asarray(y_std))
665 object.__setattr__(model, '_outcome_type', outcome_type)
666 object.__setattr__(model, '_offset', jnp.asarray(offset))
667 object.__setattr__(model, '_mu_test', mu_test)
668 object.__setattr__(model, '_tau_test', tau_test)
669 object.__setattr__(model, '_yhat_test', yhat_test)
670 return model
672 def save_npz(self, path: str | Path) -> None:
673 """
674 Save the loaded BCF traces to an NPZ archive.
676 Parameters
677 ----------
678 path
679 The file path to save the NPZ archive.
680 """
681 state = {}
683 # Explicit schema versioning
684 state['schema_version'] = np.array(1)
686 # Serialize binner attributes
687 try:
688 max_split = np.asarray(self._binner.max_split)
689 except RuntimeError: # pragma: no cover
690 # Array was donated to JAX during fit(), recover from state
691 max_split = (
692 np.asarray(self._mcmc_state.forest_tau.max_split)
693 if self._mcmc_state is not None
694 else None
695 )
697 for k, v in _serialize_binner(self._binner, max_split=max_split).items():
698 state[f'binner.{k}'] = v
700 # Save scalar traces
701 state['tau_0_trace'] = np.asarray(self._tau_0_trace)
702 state['b0_trace'] = np.asarray(self._b_trace[:, 0])
703 state['b1_trace'] = np.asarray(self._b_trace[:, 1])
705 # Save standardization metadata
706 state['standardize'] = np.array(self._standardize)
707 state['_y_mean'] = np.asarray(self._y_mean)
708 state['_y_std'] = np.asarray(self._y_std)
710 # Save binary / probit metadata
711 state['_outcome_type'] = np.array(self._outcome_type)
712 state['_offset'] = np.asarray(self._offset)
714 # Save main_trace (mu and tau forests) based on dataclass fields
715 for forest_name, trace in self._main_trace.items():
716 state[f'main_trace.{forest_name}.class'] = trace.__class__.__name__
717 for field_name in trace.__dataclass_fields__:
718 # Skip mesh because we cannot easily serialize JAX mesh obj
719 if field_name == 'mesh':
720 continue
721 val = getattr(trace, field_name)
722 if val is not None:
723 state[f'main_trace.{forest_name}.{field_name}'] = np.asarray(val)
725 # Save format string if any
726 if self._x_train_fmt is not None: 726 ↛ 730line 726 didn't jump to line 730 because the condition on line 726 was always true
727 state['x_train_fmt'] = json.dumps(self._x_train_fmt)
729 # Save test predictions, present only if `x_test` was passed
730 for key in ('_mu_test', '_tau_test', '_yhat_test'):
731 val = getattr(self, key)
732 if val is not None:
733 state[key] = np.asarray(val)
735 np.savez_compressed(path, allow_pickle=True, **state)
737 @classmethod
738 def load_npz(cls, path: str | Path) -> 'bcf':
739 """
740 Load BCF traces from an NPZ archive, bypassing __init__ MCMC.
742 Parameters
743 ----------
744 path
745 The file path to the saved NPZ archive.
747 Returns
748 -------
749 bcf
750 A reconstituted `bcf` object ready for prediction.
752 Raises
753 ------
754 ValueError
755 If the schema version in the archive is unsupported.
756 """
757 with np.load(path, allow_pickle=False) as data:
758 # Inspect schema version
759 schema_version = int(data.get('schema_version', 1))
760 if schema_version != 1:
761 msg = f'Unsupported schema version: {schema_version}'
762 raise ValueError(msg)
764 # Reconstruct binner
765 binner = _deserialize_binner(data)
767 # Scalar traces
768 tau_0_trace = jnp.asarray(data['tau_0_trace'])
769 b_trace = jnp.stack([data['b0_trace'], data['b1_trace']], axis=1)
771 # Standardization metadata
772 standardize = bool(data.get('standardize', False))
773 y_mean = data.get('_y_mean', 0.0)
774 y_std = data.get('_y_std', 1.0)
775 outcome_type = str(data.get('_outcome_type', 'continuous'))
776 offset = data.get('_offset', 0.0)
778 # Reconstruct main_trace respecting dataclass field defaults
779 main_trace = {}
780 for forest_name in ['mu', 'tau']:
781 trace = object.__new__(MainTrace)
782 for field_name, field_def in MainTrace.__dataclass_fields__.items():
783 key = f'main_trace.{forest_name}.{field_name}'
784 if key in data:
785 val = data[key]
786 if field_name == 'has_chains':
787 val = bool(val)
788 else:
789 val = jnp.asarray(val)
790 object.__setattr__(trace, field_name, val)
791 else:
792 # Respect dataclass default or default_factory if defined
793 if field_def.default is not dataclasses.MISSING:
794 default_val = field_def.default # pragma: no cover
795 elif field_def.default_factory is not dataclasses.MISSING:
796 default_val = (
797 field_def.default_factory()
798 ) # pragma: no cover
799 else:
800 default_val = None
801 object.__setattr__(trace, field_name, default_val)
802 main_trace[forest_name] = trace
804 fmt_str = str(data.get('x_train_fmt', 'None'))
805 x_train_fmt = None if fmt_str == 'None' else json.loads(fmt_str)
807 mu_test, tau_test, yhat_test = (
808 jnp.asarray(data[key]) if key in data else None
809 for key in ('_mu_test', '_tau_test', '_yhat_test')
810 )
812 model = cls._from_saved_state(
813 binner=binner,
814 tau_0_trace=tau_0_trace,
815 b_trace=b_trace,
816 main_trace=main_trace,
817 x_train_fmt=x_train_fmt,
818 standardize=standardize,
819 y_mean=y_mean,
820 y_std=y_std,
821 outcome_type=outcome_type,
822 offset=offset,
823 mu_test=mu_test,
824 tau_test=tau_test,
825 yhat_test=yhat_test,
826 )
828 # Push loaded dictionary of arrays back into accelerator memory
829 return tree.map(device_put, model)
831 def predict(
832 self,
833 x_test: Real[ArrayLike, 'm p'] | DataFrame,
834 *,
835 pihat_test: Float32[ArrayLike, ' m'] | Series | None = None,
836 include_pihat_in_mu: bool = True, # noqa: ARG002
837 include_pihat_in_tau: bool = False, # noqa: ARG002
838 ) -> dict[str, Float32[Array, 'num_samples m']]:
839 """
840 Compute predictions for both mu and tau forests at `x_test`.
842 Parameters
843 ----------
844 x_test
845 The test predictors.
846 pihat_test
847 The test propensity scores.
848 include_pihat_in_mu
849 Whether to include propensity scores in prognostic forest prediction.
850 include_pihat_in_tau
851 Whether to include propensity scores in treatment effect forest prediction.
853 Returns
854 -------
855 dict
856 A dictionary with "mu" and "tau" containing the posterior samples
857 of the respective forests evaluated at x_test. Shapes are
858 (num_chains * ndpost, m).
860 Raises
861 ------
862 ValueError
863 If the format of `x_test` does not match `x_train` format.
864 """
865 x_test, x_test_fmt = _process_bcf_predictor_input(x_test)
866 if x_test_fmt != self._x_train_fmt:
867 msg = (
868 f'Format of x_test {x_test_fmt} does not match x_train'
869 f' {self._x_train_fmt}'
870 )
871 raise ValueError(msg)
873 if pihat_test is not None: 873 ↛ 876line 873 didn't jump to line 876 because the condition on line 873 was always true
874 pihat_test = _process_response_input(pihat_test)
876 x_test_unified = x_test
877 if pihat_test is not None: 877 ↛ 882line 877 didn't jump to line 882 because the condition on line 877 was always true
878 pihat_row = pihat_test[jnp.newaxis, :]
879 x_test_unified = jnp.concatenate([x_test_unified, pihat_row], axis=0)
881 # Bin the test data
882 x_test_binned = self._binner.bin(x_test_unified)
884 # Evaluate the sum-of-trees (both forests walk the same unified test matrix)
885 mu_latent = predict_latent(x_test_binned, self._main_trace['mu'], 'none')
886 tau_latent = predict_latent(x_test_binned, self._main_trace['tau'], 'none')
888 # Add the global tau_0 intercept
889 tau_latent = tau_latent + self._tau_0_trace[:, jnp.newaxis]
891 b0_expanded = self._b_trace[:, 0, jnp.newaxis]
892 b1_expanded = self._b_trace[:, 1, jnp.newaxis]
893 # Control mean: mu(X) + b_0 * (tau(X) + tau_0)
894 mu_adjusted = mu_latent + b0_expanded * tau_latent
895 # Compute CATE via adaptive coding difference
896 cate = (b1_expanded - b0_expanded) * tau_latent
898 if getattr(self, '_outcome_type', 'continuous') == 'binary':
899 p1 = special.ndtr(mu_latent + tau_latent * b1_expanded)
900 p0 = special.ndtr(mu_latent + tau_latent * b0_expanded)
901 cate_prob = p1 - p0
902 return {
903 'mu': mu_adjusted,
904 'tau': cate,
905 'tau_prob': cate_prob,
906 'p1': p1,
907 'p0': p0,
908 }
910 if self._standardize:
911 mu_adjusted = mu_adjusted * self._y_std + self._y_mean
912 cate = cate * self._y_std
914 return {'mu': mu_adjusted, 'tau': cate}
916 @property
917 def sigma_trace(self) -> Float32[Array, ' num_samples']:
918 """The posterior trace of residual standard deviation on the outcome scale, chains concatenated."""
919 error_cov_inv = _fold_chains(self._main_trace['mu'], 'error_cov_inv')
920 sigma_internal = lax.rsqrt(error_cov_inv)
921 if self._standardize:
922 return sigma_internal * self._y_std
923 return sigma_internal
925 @property
926 def mu_test(self) -> Float32[Array, 'num_samples m'] | None:
927 """The control mean at `x_test` for each MCMC iteration.
929 On the latent probit scale for binary outcomes.
930 """
931 return self._mu_test
933 @property
934 def tau_test(self) -> Float32[Array, 'num_samples m'] | None:
935 """The treatment effect at `x_test` for each MCMC iteration.
937 On the latent probit scale for binary outcomes.
938 """
939 return self._tau_test
941 @property
942 def yhat_test(self) -> Float32[Array, 'num_samples m'] | None:
943 """The outcome at `x_test` under `z_test` for each MCMC iteration.
945 On the latent probit scale for binary outcomes; see `prob_test`.
946 """
947 return self._yhat_test
949 @property
950 def prob_test(self) -> Float32[Array, 'num_samples m'] | None:
951 """The probability of y being True at `x_test` under `z_test`.
953 `None` unless the outcome is binary and `x_test` and `z_test` were
954 passed to the constructor.
955 """
956 if self._yhat_test is None or self._outcome_type != 'binary':
957 return None
958 return special.ndtr(self._yhat_test)
960 def predict_potential_outcomes(
961 self,
962 x_test: Real[ArrayLike, 'm p'] | DataFrame,
963 *,
964 pihat_test: Float32[ArrayLike, ' m'] | Series | None = None,
965 rho: float = 0.5,
966 key: Key[Array, ''] | int | None = None,
967 include_pihat_in_mu: bool = True,
968 include_pihat_in_tau: bool = False,
969 ) -> dict[str, Float32[Array, 'num_samples m']]:
970 """
971 Sample joint posterior predictive potential outcomes Y(0), Y(1), and lift.
973 Parameters
974 ----------
975 x_test
976 The test predictors.
977 pihat_test
978 Optional test propensity scores.
979 rho
980 Cross-world counterfactual noise correlation in [0, 1].
981 key
982 JAX PRNG key or integer seed for stochastic noise sampling.
983 include_pihat_in_mu
984 Whether to include propensity scores in prognostic forest prediction.
985 include_pihat_in_tau
986 Whether to include propensity scores in treatment effect forest prediction.
988 Returns
989 -------
990 dict
991 A dictionary mapping outcome names to posterior predictive arrays.
993 Raises
994 ------
995 ValueError
996 If rho is not within [0, 1].
997 """
998 if not 0.0 <= rho <= 1.0:
999 msg = f'rho must be in [0, 1], got {rho}'
1000 raise ValueError(msg)
1002 if key is None:
1003 key = random.key(0)
1004 elif isinstance(key, int):
1005 key = random.key(key)
1007 preds = self.predict(
1008 x_test=x_test,
1009 pihat_test=pihat_test,
1010 include_pihat_in_mu=include_pihat_in_mu,
1011 include_pihat_in_tau=include_pihat_in_tau,
1012 )
1013 mu = preds['mu']
1014 tau = preds['tau']
1015 num_samples, m = mu.shape
1017 sigma = self.sigma_trace[:, jnp.newaxis]
1019 keys = split(key)
1020 u0 = random.normal(keys.pop(), shape=(num_samples, m), dtype=jnp.float32)
1021 u1 = random.normal(keys.pop(), shape=(num_samples, m), dtype=jnp.float32)
1023 rho_f = jnp.float32(rho)
1024 eps0 = sigma * u0
1025 eps1 = sigma * (rho_f * u0 + jnp.sqrt(jnp.maximum(0.0, 1.0 - rho_f**2)) * u1)
1027 y0_latent = mu + eps0
1028 y1_latent = mu + tau + eps1
1030 if getattr(self, '_outcome_type', 'continuous') == 'binary':
1031 y0 = (y0_latent > 0.0).astype(jnp.float32)
1032 y1 = (y1_latent > 0.0).astype(jnp.float32)
1033 delta = y1 - y0
1034 return {
1035 'y0': y0,
1036 'y1': y1,
1037 'delta': delta,
1038 'mu': mu,
1039 'tau': tau,
1040 'p0': preds['p0'],
1041 'p1': preds['p1'],
1042 'tau_prob': preds['tau_prob'],
1043 }
1045 y0 = y0_latent
1046 y1 = y1_latent
1047 delta = y1 - y0
1049 return {'y0': y0, 'y1': y1, 'delta': delta, 'mu': mu, 'tau': tau}