Coverage for src/bartz/stochtree/_stochtree.py: 88%
321 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/stochtree/_stochtree.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 class `BARTModel` that mimics the Python package stochtree."""
27from collections.abc import Mapping, Sequence
28from dataclasses import dataclass, field, fields
29from functools import partial
31# WORKAROUND(python<3.15): use frozendict instead of MappingProxyType
32from types import MappingProxyType
33from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
35from jax import lax
36from jax import numpy as jnp
37from jax.scipy.special import ndtr, ndtri
38from jaxtyping import Array, Float, Float32, Key, Real, Shaped
40from bartz._interface import Bart, DataFrame, PredictKind, Series
41from bartz._jaxext import jit
42from bartz.mcmcloop import MainTrace
43from bartz.mcmcstep import Wishart
44from bartz.mcmcstep._axes import chain_to_axis, chain_vmap_axes
45from bartz.mcmcstep._state import ArrayLike, FloatLike
46from bartz.prepcovars import RangeEvenBinner
47from bartz.stochtree._preprocess import _PreprocessorBase, make_preprocessor
49if TYPE_CHECKING:
50 from _typeshed import DataclassInstance
52_MAX_DEPTH_LIMIT = 16
55@dataclass(frozen=True)
56class OutcomeModel:
57 """Outcome model specification, matching `stochtree.OutcomeModel`.
59 Only ``('continuous', 'identity')`` and ``('binary', 'probit')`` are
60 supported.
61 """
63 outcome: Literal['continuous', 'binary'] = 'continuous'
64 """Outcome family."""
66 link: Literal['identity', 'probit'] | None = None
67 """Link function. If `None`, defaults to ``'identity'`` for ``'continuous'`` and ``'probit'`` for ``'binary'``."""
69 def __post_init__(self) -> None:
70 if self.link is None:
71 default_link = {'continuous': 'identity', 'binary': 'probit'}.get(
72 self.outcome
73 )
74 object.__setattr__(self, 'link', default_link)
75 if (self.outcome, self.link) not in (
76 ('continuous', 'identity'),
77 ('binary', 'probit'),
78 ):
79 msg = (
80 f'unsupported outcome_model (outcome={self.outcome!r}, '
81 f"link={self.link!r}); only ('continuous', 'identity') "
82 "and ('binary', 'probit') are supported."
83 )
84 raise NotImplementedError(msg)
87class NotSampledError(ValueError, AttributeError):
88 """Raised when calling a method that requires `BARTModel.sample` to have been called."""
91@dataclass(frozen=True, kw_only=True)
92class GeneralParams:
93 """Mirror of stochtree's ``general_params`` dict, with the keys bartz handles."""
95 standardize: bool = True
96 """Whether to standardize the outcome before fitting. Ignored for probit binary."""
98 sigma2_init: FloatLike | None = None
99 """Starting value of the global error variance. If `None` (default), uses ``var(resid_train)`` for continuous and ``1.0`` for probit."""
101 sigma2_global_shape: FloatLike = 0.0
102 """Shape parameter of the inverse-gamma prior on the global error variance. The default ``0`` is mapped to a near-improper prior, since bartz's scaled-inv-chi² cannot represent ``IG(0, 0)`` exactly."""
104 sigma2_global_scale: FloatLike = 0.0
105 """Scale parameter of the inverse-gamma prior on the global error variance. The default ``0`` is mapped to a near-improper prior, since bartz's scaled-inv-chi² cannot represent ``IG(0, 0)`` exactly."""
107 variable_weights: Float[ArrayLike, ' p'] | None = None
108 """Per-predictor sampling weights. Must be strictly positive; pass a small positive value to suppress a variable."""
110 random_seed: int | Key[Array, ''] | None = None
111 """Seed for the random number generator. Unlike stochtree, the default
112 `None` is deterministic (equivalent to seed ``0``) rather than drawing a
113 random seed, so repeated fits reproduce by default."""
115 keep_every: int = 1
116 """Thinning factor for retained MCMC samples."""
118 num_chains: int = 1
119 """Number of independent MCMC chains."""
121 outcome_model: OutcomeModel = field(default_factory=OutcomeModel)
122 """Outcome family and link specification. Defaults to continuous with
123 identity link."""
126@dataclass(frozen=True, kw_only=True)
127class MeanForestParams:
128 """Mirror of stochtree's ``mean_forest_params`` dict, restricted to the keys bartz handles."""
130 num_trees: int = 200
131 """Number of trees in the conditional mean ensemble."""
133 alpha: FloatLike = 0.95
134 """Tree split prior base."""
136 beta: FloatLike = 2.0
137 """Tree split prior decay."""
139 min_samples_leaf: int = 5
140 """Minimum number of training samples at a leaf."""
142 max_depth: int = 10
143 """Maximum tree depth. Must be a non-negative integer at most ``16``."""
145 sample_sigma2_leaf: bool = True
146 """Whether to sample the leaf variance with an inverse-gamma prior, or hold it fixed at `sigma2_leaf_init`."""
148 sigma2_leaf_init: FloatLike | None = None
149 """Initial leaf variance. If `None`, matches stochtree's defaults of ``var(resid_train) / num_trees`` for continuous and ``2 / num_trees`` for probit."""
151 sigma2_leaf_shape: FloatLike = 3.0
152 """Twice the shape parameter of the inverse-gamma prior on the leaf variance, i.e., the prior is ``IG(sigma2_leaf_shape / 2, sigma2_leaf_scale / 2)``."""
154 sigma2_leaf_scale: FloatLike | None = None
155 """Twice the scale parameter of the inverse-gamma prior on the leaf variance. If `None`, defaults to ``var(resid_train) / num_trees`` for continuous and ``1 / num_trees`` for probit."""
157 def __post_init__(self) -> None:
158 if self.max_depth < 0: 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true
159 msg = (
160 f'max_depth={self.max_depth} is not supported; bartz stores trees'
161 ' as heap arrays of size 2**max_depth, so the stochtree'
162 ' convention max_depth=-1 (unbounded) is rejected. Pass a'
163 f' non-negative integer at most {_MAX_DEPTH_LIMIT}.'
164 )
165 raise NotImplementedError(msg)
166 if self.max_depth > _MAX_DEPTH_LIMIT: 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true
167 msg = (
168 f'max_depth={self.max_depth} exceeds {_MAX_DEPTH_LIMIT}; bartz'
169 ' stores trees as heap arrays of size 2**max_depth, so memory'
170 ' grows exponentially with depth.'
171 )
172 raise ValueError(msg)
175if TYPE_CHECKING:
176 # static only, beartype does not support type[DataclassInstance]
177 T = TypeVar('T', bound='DataclassInstance')
178else:
179 T = TypeVar('T')
182def build_dataclass(cls: type[T], params: Mapping[str, Any] | None, name: str) -> T:
183 """Convert a user-supplied dict to a dataclass, with friendly errors."""
184 if params is None:
185 params = {}
186 allowed = {f.name for f in fields(cls)}
187 extra = set(params) - allowed
188 if extra:
189 msg = (
190 f'{name} contains unsupported key(s) {sorted(extra)}; valid keys'
191 f' are {sorted(allowed)}'
192 )
193 raise ValueError(msg)
194 return cls(**params)
197class BARTModel:
198 R"""
199 BART model with a `stochtree`-compatible interface, powered by bartz.
201 This class mimics `stochtree.BARTModel` so that bartz can be used as a
202 drop-in reference implementation for testing. The intersection of features
203 is targeted: continuous regression (Gaussian outcome, identity link) and
204 binary classification (probit link) on tabular covariates.
206 Use the same idiomatic pattern as `stochtree.BARTModel`::
208 m = BARTModel()
209 m.sample(X_train=X, y_train=y, X_test=X_test, num_gfr=0, num_mcmc=200)
210 yhat = m.predict(X_new, terms='y_hat', type='mean')
212 See `GeneralParams` and `MeanForestParams` for the supported keys in the
213 ``general_params`` and ``mean_forest_params`` dicts.
215 Notes
216 -----
217 Differences from `stochtree`, by design:
219 - ``num_gfr`` has no default and must be set explicitly to ``0``.
220 - ``mean_forest_params['max_depth']`` must be a non-negative integer at
221 most ``16``; stochtree's ``-1`` (unbounded depth) sentinel is not
222 accepted.
223 - The deprecated ``general_params['probit_outcome_model']`` flag is not
224 accepted; pass ``outcome_model=OutcomeModel('binary', 'probit')``
225 instead.
226 - ``general_params['cutpoint_grid_size']`` is not accepted; bartz uses a
227 fixed grid of 256 evenly-spaced bins per predictor. stochtree only
228 uses this parameter for the GFR sampler, which bartz does not support.
229 - Leaf-basis regression, random effects, heteroskedastic variance
230 forests, and warm-starting from a previous model are not supported.
231 - bartz uses single-precision floats, so outputs differ from stochtree
232 at the float32 precision level.
233 - ``general_params['random_seed']`` defaults to deterministic behavior
234 (seed ``0``) when unset, whereas stochtree draws a random seed. This is
235 intentional, to make repeated fits reproducible by default.
237 References
238 ----------
239 Herren, A., Hahn, P. R., Murray, J., Carvalho, C. (2026). "StochTree:
240 BART-based modeling in R and Python". arXiv:2512.12051.
241 """
243 # public, set by sample()
244 sampled: bool
245 """Whether `sample` has been called."""
247 standardize: bool
248 """Whether the outcome was standardized before fitting."""
250 sample_sigma2_global: bool
251 """Whether the global error variance is sampled (always ``True``)."""
253 probit_outcome_model: bool
254 """Whether the model uses a binary outcome with probit link."""
256 outcome_model: OutcomeModel
257 """Outcome family and link specification used during fitting."""
259 num_gfr: int
260 """Number of grow-from-root iterations (always ``0``)."""
262 num_burnin: int
263 """Number of MCMC burn-in iterations."""
265 num_mcmc: int
266 """Number of retained MCMC iterations per chain."""
268 num_chains: int
269 """Number of independent MCMC chains."""
271 num_samples: int
272 """Total number of retained posterior samples (``num_mcmc * num_chains``)."""
274 sigma2_init: FloatLike
275 """Starting value of the global error variance actually used to seed the chain."""
277 y_bar: Float32[Array, '']
278 """Mean used to standardize the outcome (``0`` if not standardized)."""
280 y_std: Float32[Array, '']
281 """Standard deviation used to standardize the outcome (``1`` if not standardized)."""
283 has_rfx: bool
284 """Whether the model includes random effects (always ``False``)."""
286 include_mean_forest: bool
287 """Whether the model includes a conditional mean forest (always ``True``)."""
289 include_variance_forest: bool
290 """Whether the model includes a variance forest (always ``False``)."""
292 y_hat_train: Float32[Array, 'n num_samples']
293 """Posterior predictions at the training covariates, in the original outcome scale."""
295 global_var_samples: Float32[Array, ' num_samples']
296 """Posterior samples of the global error variance. For probit binary regression, an array of ones."""
298 sample_sigma2_leaf: bool
299 """Whether the leaf variance is sampled."""
301 leaf_scale_samples: Float32[Array, ' num_samples'] | None
302 """Posterior samples of the leaf variance, in the standardized outcome scale, or `None` if not sampled."""
304 y_hat_test: Float32[Array, 'm num_samples'] | None
305 """Posterior predictions at `X_test` if it was supplied to `sample`, else `None`."""
307 _bart: Bart
308 _preprocessor: _PreprocessorBase | None
310 def __init__(self) -> None:
311 self.sampled = False
312 self._preprocessor = None
314 def is_sampled(self) -> bool:
315 """Return whether `sample` has been called."""
316 return self.sampled
318 def _prepare_training_inputs(
319 self,
320 X_train: Real[ArrayLike, 'n p'] | DataFrame,
321 y_train: Real[ArrayLike, ' n'] | Series,
322 gp: GeneralParams,
323 ) -> tuple[Real[Array, 'n p'], Real[Array, ' n'], Float32[Array, ' p'] | None]:
324 """Coerce inputs and build variable weights, fitting the DataFrame preprocessor if any."""
325 y_train_arr = _coerce_response(y_train, name='y_train')
327 self._preprocessor = make_preprocessor(X_train)
328 if self._preprocessor is None:
329 X_train_arr = check_X(X_train, name='X_train')
330 _, p = X_train_arr.shape
331 varprob = check_variable_weights(gp.variable_weights, p)
332 else:
333 # The preprocessor decides the default weights: uniform over the
334 # *original* columns split across each one-hot expansion (so every
335 # original variable keeps an equal splitting budget), or `None` when
336 # nothing expands (deferring to bartz's native uniform fast-path).
337 weights_np = self._preprocessor.fit(
338 X_train, variable_weights=gp.variable_weights
339 )
340 X_train_np = self._preprocessor.transform(X_train)
341 if X_train_np.shape[1] == 0: 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true
342 msg = 'X_train has no usable columns after preprocessing'
343 raise ValueError(msg)
344 X_train_arr = jnp.asarray(X_train_np)
345 varprob = None if weights_np is None else jnp.asarray(weights_np)
347 n, _ = X_train_arr.shape
348 if y_train_arr.shape[0] != n: 348 ↛ 349line 348 didn't jump to line 349 because the condition on line 348 was never true
349 msg = (
350 f'X_train and y_train length mismatch: X_train has {n} rows,'
351 f' y_train has {y_train_arr.shape[0]} entries'
352 )
353 raise ValueError(msg)
354 return X_train_arr, y_train_arr, varprob
356 def sample(
357 self,
358 X_train: Real[ArrayLike, 'n p'] | DataFrame,
359 y_train: Real[ArrayLike, ' n'] | Series,
360 X_test: Real[ArrayLike, 'm p'] | DataFrame | None = None,
361 observation_weights: Float[ArrayLike, ' n'] | Series | None = None,
362 *,
363 num_gfr: int,
364 num_burnin: int = 0,
365 num_mcmc: int = 100,
366 general_params: Mapping[str, Any] | None = None,
367 mean_forest_params: Mapping[str, Any] | None = None,
368 bart_kwargs: Mapping[str, Any] = MappingProxyType({}),
369 ) -> None:
370 """Fit the model.
372 The signature mirrors `stochtree.BARTModel.sample`, restricted to the
373 keyword arguments bartz supports.
375 Parameters
376 ----------
377 X_train
378 Training covariates with shape ``(n, p)``.
379 y_train
380 Training outcomes of length ``n``.
381 X_test
382 Optional test covariates; if given, predictions are cached on
383 them in `y_hat_test`.
384 observation_weights
385 Optional positive per-observation weights scaling the residual
386 variance (``y_i | - ~ N(mu(X_i), sigma^2 / w_i)``). Not supported
387 with a probit outcome model.
388 num_gfr
389 Number of grow-from-root iterations. Must be ``0``.
390 num_burnin
391 Number of MCMC burn-in iterations.
392 num_mcmc
393 Number of retained MCMC iterations per chain.
394 general_params
395 Optional override for the keys of `GeneralParams`.
396 mean_forest_params
397 Optional override for the keys of `MeanForestParams`.
398 bart_kwargs
399 Additional arguments forwarded to `bartz.Bart`. Use this to set
400 ``devices`` and ``rm_const=False`` when wrapping `sample` in
401 `jax.jit`.
403 Raises
404 ------
405 NotImplementedError
406 If ``num_gfr`` is non-zero.
407 ValueError
408 If `observation_weights` are set with a probit outcome model.
409 """
410 if num_gfr != 0:
411 msg = (
412 'num_gfr must be 0; the grow-from-root sampler is not available'
413 ' in bartz.'
414 )
415 raise NotImplementedError(msg)
417 gp = build_dataclass(GeneralParams, general_params, 'general_params')
418 mfp = build_dataclass(
419 MeanForestParams, mean_forest_params, 'mean_forest_params'
420 )
422 is_probit = gp.outcome_model.outcome == 'binary'
424 # stochtree does not support heteroskedastic probit
425 if is_probit and observation_weights is not None:
426 msg = (
427 'observation_weights are not supported with a probit outcome'
428 ' model; stochtree does not support heteroskedastic probit.'
429 )
430 raise ValueError(msg)
432 X_train_arr, y_train_arr, variable_weights = self._prepare_training_inputs(
433 X_train, y_train, gp
434 )
436 y_bar, y_std, y_for_bartz = standardize_y(
437 y_train_arr, is_probit, gp.standardize
438 )
440 bart_num_chains = None if gp.num_chains == 1 else gp.num_chains
442 # variance of the standardized residual, matching stochtree
443 # (np.var(resid_train) with ddof=0). For standardize=True it is exactly
444 # 1.0; we hardcode that so the value stays trace-time concrete.
445 if is_probit:
446 var_resid_train: FloatLike = 1.0 # bartz ignores σ² for binary
447 elif gp.standardize: 447 ↛ 450line 447 didn't jump to line 450 because the condition on line 447 was always true
448 var_resid_train = 1.0
449 else:
450 var_resid_train = jnp.var(y_for_bartz)
452 # leaf-prior: bartz uses sigma_mu = tau_num / (k * sqrt(num_trees));
453 # stochtree's sigma2_leaf is the leaf-variance prior. Hold k=2 and solve
454 # for tau_num so that the two parameterizations agree.
455 bartz_k = 2.0
456 sigma2_leaf_init = resolve_sigma2_leaf_init(
457 mfp.sigma2_leaf_init, mfp.num_trees, is_probit, var_resid_train
458 )
459 tau_num_arg = bartz_k * jnp.sqrt(mfp.num_trees * sigma2_leaf_init)
461 if mfp.sample_sigma2_leaf:
462 leaf_init_kw: dict = {
463 'leaf_prior_cov_inv': resolve_leaf_variance_prior(
464 mfp.sigma2_leaf_shape,
465 mfp.sigma2_leaf_scale,
466 sigma2_leaf_init,
467 num_trees=mfp.num_trees,
468 is_probit=is_probit,
469 var_resid_train=var_resid_train,
470 )
471 }
472 else:
473 leaf_init_kw = {}
475 if is_probit:
476 # stochtree pins σ²=1 for probit; bartz binary branch ignores the
477 # variance prior, so we leave the scale/init at their 'auto'
478 # defaults (bartz rejects explicit values for binary outcomes).
479 sigma_df_arg: FloatLike = 3.0
480 sigma_scale_arg: FloatLike | Literal['auto'] = 'auto'
481 sigma_init_arg: FloatLike | Literal['auto'] = 'auto'
482 sigma2_init_stored: FloatLike = 1.0
483 else:
484 sigma_df_arg, sigma_scale_arg, sigma_init_arg, sigma2_init_stored = (
485 resolve_variance_prior(
486 gp.sigma2_global_shape,
487 gp.sigma2_global_scale,
488 gp.sigma2_init,
489 var_resid_train,
490 )
491 )
493 binner = partial(RangeEvenBinner, max_bins=256)
495 seed = 0 if gp.random_seed is None else gp.random_seed
497 kwargs: dict = dict(
498 x_train=X_train_arr.T,
499 y_train=y_for_bartz,
500 outcome_type='binary' if is_probit else 'continuous',
501 binner=binner,
502 varprob=variable_weights,
503 sigma_df=sigma_df_arg,
504 sigma_scale=sigma_scale_arg,
505 sigma_init=sigma_init_arg,
506 k=bartz_k,
507 power=mfp.beta,
508 base=mfp.alpha,
509 tau_num=tau_num_arg,
510 error_scale=observation_weights,
511 num_trees=mfp.num_trees,
512 n_save=num_mcmc,
513 n_burn=num_burnin,
514 n_skip=gp.keep_every,
515 printevery=None,
516 num_chains=bart_num_chains,
517 seed=seed,
518 maxdepth=mfp.max_depth + 1,
519 precompute_predict_train=True,
520 )
521 kwargs.update(bart_kwargs)
522 # min_samples_leaf maps to bartz's acceptance-time veto.
523 # min_points_per_decision_node = 2 * min_samples_leaf makes the
524 # proposal skip leaves too small to split, without changing the target
525 # posterior, which thus matches stochtree. The leaf variance prior goes
526 # through init_kw because Bart's sigma_mu_df ties the prior scale to
527 # the initial value, while stochtree sets them independently.
528 # User-supplied init_kw values win on conflicts.
529 kwargs = dict(
530 kwargs,
531 init_kw=dict(
532 {
533 'min_points_per_leaf': mfp.min_samples_leaf,
534 'min_points_per_decision_node': 2 * mfp.min_samples_leaf,
535 **leaf_init_kw,
536 },
537 **kwargs.get('init_kw', {}),
538 ),
539 )
540 self._bart = Bart(**kwargs)
541 self._finalize_sample(
542 outcome_model=gp.outcome_model,
543 num_burnin=num_burnin,
544 num_mcmc=num_mcmc,
545 num_chains=gp.num_chains,
546 sample_sigma2_leaf=mfp.sample_sigma2_leaf,
547 sigma2_init=sigma2_init_stored,
548 y_bar=y_bar,
549 y_std=y_std,
550 standardize=gp.standardize,
551 X_test=X_test,
552 )
554 def _finalize_sample(
555 self,
556 *,
557 outcome_model: OutcomeModel,
558 num_burnin: int,
559 num_mcmc: int,
560 num_chains: int,
561 sample_sigma2_leaf: bool,
562 sigma2_init: FloatLike,
563 y_bar: Float32[Array, ''],
564 y_std: Float32[Array, ''],
565 standardize: bool,
566 X_test: Real[ArrayLike, 'm p'] | DataFrame | None,
567 ) -> None:
568 """Populate the public attributes after `_bart` has been constructed."""
569 is_probit = outcome_model.outcome == 'binary'
570 self.sampled = True
571 self.standardize = standardize
572 self.sample_sigma2_global = True
573 self.sample_sigma2_leaf = sample_sigma2_leaf
574 self.probit_outcome_model = is_probit
575 self.outcome_model = outcome_model
576 self.num_gfr = 0
577 self.num_burnin = num_burnin
578 self.num_mcmc = num_mcmc
579 self.num_chains = num_chains
580 self.num_samples = num_mcmc * num_chains
581 self.sigma2_init = sigma2_init
582 self.y_bar = y_bar
583 self.y_std = y_std
584 self.has_rfx = False
585 self.include_mean_forest = True
586 self.include_variance_forest = False
588 # cached outputs in stochtree's (n, num_samples) layout, original scale
589 self.y_hat_train = self._predict_y_hat_internal('train')
590 if X_test is not None:
591 self.y_hat_test = self._predict_y_hat_internal(self._prepare_x(X_test).T)
592 else:
593 self.y_hat_test = None
595 if is_probit:
596 self.global_var_samples = jnp.ones((self.num_samples,))
597 else:
598 sigma = self._bart.get_error_sdev()
599 self.global_var_samples = (sigma * y_std) ** 2
601 if sample_sigma2_leaf:
602 self.leaf_scale_samples = get_leaf_scale_samples(
603 self._bart._main_trace # noqa: SLF001
604 )
605 else:
606 self.leaf_scale_samples = None
608 @overload
609 def predict(
610 self,
611 X: Real[ArrayLike, 'm p'] | DataFrame,
612 *,
613 type: Literal['posterior', 'mean'] = 'posterior',
614 terms: Literal['y_hat', 'mean_forest'],
615 scale: Literal['linear', 'probability', 'class'] = 'linear',
616 ) -> Shaped[Array, 'm num_samples'] | Shaped[Array, ' m']: ...
618 @overload
619 def predict(
620 self,
621 X: Real[ArrayLike, 'm p'] | DataFrame,
622 *,
623 type: Literal['posterior', 'mean'] = 'posterior',
624 terms: Literal['all'] = 'all',
625 scale: Literal['linear', 'probability', 'class'] = 'linear',
626 ) -> dict[str, Shaped[Array, 'm num_samples']] | dict[str, Shaped[Array, ' m']]: ...
628 @overload
629 def predict(
630 self,
631 X: Real[ArrayLike, 'm p'] | DataFrame,
632 *,
633 type: Literal['posterior', 'mean'] = 'posterior',
634 terms: Sequence[Literal['y_hat', 'mean_forest', 'all']],
635 scale: Literal['linear', 'probability', 'class'] = 'linear',
636 ) -> (
637 Shaped[Array, 'm num_samples']
638 | Shaped[Array, ' m']
639 | dict[str, Shaped[Array, 'm num_samples']]
640 | dict[str, Shaped[Array, ' m']]
641 ): ...
643 def predict(
644 self,
645 X: Real[ArrayLike, 'm p'] | DataFrame,
646 *,
647 type: Literal['posterior', 'mean'] = 'posterior', # noqa: A002
648 terms: Literal['y_hat', 'mean_forest', 'all']
649 | Sequence[Literal['y_hat', 'mean_forest', 'all']] = 'all',
650 scale: Literal['linear', 'probability', 'class'] = 'linear',
651 ) -> (
652 Shaped[Array, 'm num_samples']
653 | Shaped[Array, ' m']
654 | dict[str, Shaped[Array, 'm num_samples']]
655 | dict[str, Shaped[Array, ' m']]
656 ):
657 """Predict at new covariates.
659 Parameters
660 ----------
661 X
662 New covariates with shape ``(m, p)``.
663 type
664 ``'posterior'`` returns one prediction per posterior sample, with
665 shape ``(m, num_samples)``. ``'mean'`` averages the posterior
666 samples, returning a vector of shape ``(m,)``.
667 terms
668 One of ``'y_hat'``, ``'mean_forest'``, ``'all'``, or a list. Since
669 random effects and a variance forest are not supported, ``'y_hat'``
670 and ``'mean_forest'`` produce the same result.
671 scale
672 For probit binary regression: ``'linear'`` returns the eta values,
673 ``'probability'`` returns ``Phi(eta)``, ``'class'`` returns 0 / 1.
674 Only ``'linear'`` is valid for continuous outcomes.
676 Returns
677 -------
678 Either a single jax array (for a single requested term) or a dict keyed by term name.
680 Raises
681 ------
682 NotSampledError
683 If `sample` has not been called yet.
684 """
685 if not self.sampled:
686 msg = (
687 "This BARTModel instance is not fitted yet. Call 'sample' before"
688 ' using this model.'
689 )
690 raise NotSampledError(msg)
691 terms_tuple = check_predict_args(type, scale, terms, self.probit_outcome_model)
693 pred = self._predict_y_hat_internal(self._prepare_x(X).T)
695 if self.probit_outcome_model and scale in ('probability', 'class'):
696 prob = ndtr(pred)
697 pred_out = jnp.where(prob < 0.5, 0, 1) if scale == 'class' else prob
698 else:
699 pred_out = pred
701 if type == 'mean':
702 pred_out = jnp.mean(pred_out, axis=1)
704 wants_y_hat = ('y_hat' in terms_tuple) or ('all' in terms_tuple)
705 wants_mean_forest = ('mean_forest' in terms_tuple) or ('all' in terms_tuple)
706 single = sum([wants_y_hat, wants_mean_forest]) == 1
707 if single:
708 return pred_out
709 result: dict[str, Shaped[Array, '...']] = {}
710 if wants_y_hat: 710 ↛ 712line 710 didn't jump to line 712 because the condition on line 710 was always true
711 result['y_hat'] = pred_out
712 if wants_mean_forest: 712 ↛ 714line 712 didn't jump to line 714 because the condition on line 712 was always true
713 result['mean_forest_predictions'] = pred_out
714 return result
716 def _prepare_x(self, X: Real[ArrayLike, 'm p'] | DataFrame) -> Real[Array, 'm p']:
717 """Convert covariates to a 2-D jax array, replaying the fitted preprocessor if any."""
718 if self._preprocessor is None:
719 return check_X(X)
720 if make_preprocessor(X) is None:
721 msg = (
722 'this model was fit on a DataFrame, so prediction covariates must'
723 ' also be a pandas/polars DataFrame with the same columns; got a'
724 ' non-DataFrame. Passing a raw array would bypass the fitted'
725 ' preprocessing (e.g. one-hot encoding) and silently misalign the'
726 ' features.'
727 )
728 raise TypeError(msg)
729 return jnp.asarray(self._preprocessor.transform(X))
731 def _predict_y_hat_internal(
732 self, x: Real[ArrayLike, 'p m'] | Literal['train']
733 ) -> Float32[Array, 'm num_samples']:
734 """Return predictions on the original outcome scale, layout ``(m, num_samples)``."""
735 latent = self._bart.predict(x, kind=PredictKind.latent_samples)
736 if self.probit_outcome_model:
737 # bartz integrates the binary offset into latent; result already on probit scale.
738 return latent.T
739 if self.standardize: 739 ↛ 741line 739 didn't jump to line 741 because the condition on line 739 was always true
740 return (latent * self.y_std + self.y_bar).T
741 return latent.T
744def standardize_y(
745 y_train: Real[ArrayLike, ' n'], is_probit: bool, standardize: bool
746) -> tuple[Float32[Array, ''], Float32[Array, ''], Float32[Array, ' n']]:
747 """Return ``(y_bar, y_std, y_for_bartz)`` matching stochtree's standardization."""
748 y = jnp.asarray(y_train, jnp.float32)
749 if is_probit:
750 return ndtri(y.mean()), jnp.float32(1.0), (y != 0).astype(jnp.float32)
751 if standardize: 751 ↛ 756line 751 didn't jump to line 756 because the condition on line 751 was always true
752 y_bar = y.mean()
753 y_std_val = y.std()
754 y_std = jnp.where(y_std_val > 0, y_std_val, 1.0)
755 return y_bar, y_std, (y - y_bar) / y_std
756 return jnp.float32(0.0), jnp.float32(1.0), y
759def resolve_sigma2_leaf_init(
760 sigma2_leaf_init: FloatLike | None,
761 num_trees: int,
762 is_probit: bool,
763 var_resid_train: FloatLike,
764) -> FloatLike:
765 """Default `sigma2_leaf_init` per stochtree: probit→2/num_trees, continuous→var(resid)/num_trees."""
766 if sigma2_leaf_init is not None:
767 return sigma2_leaf_init
768 if is_probit:
769 return 2.0 / num_trees
770 return var_resid_train / num_trees
773def resolve_leaf_variance_prior(
774 shape: FloatLike,
775 scale: FloatLike | None,
776 sigma2_leaf_init: FloatLike,
777 *,
778 num_trees: int,
779 is_probit: bool,
780 var_resid_train: FloatLike,
781) -> Wishart:
782 """Translate stochtree's leaf variance prior to a `Wishart` on the leaf precision.
784 Despite the documented ``IG(shape, scale)``, stochtree's sampler uses the
785 posterior ``IG(shape / 2 + L / 2, scale / 2 + S / 2)``, i.e., the prior is
786 ``IG(shape / 2, scale / 2)``, which is `Wishart` with ``nu = shape`` and
787 ``rate = scale``.
788 """
789 if scale is None:
790 scale = 1.0 / num_trees if is_probit else var_resid_train / num_trees
791 return Wishart(nu=shape, rate=scale, value=jnp.reciprocal(sigma2_leaf_init))
794@jit
795def get_leaf_scale_samples(trace: MainTrace) -> Float32[Array, ' num_samples']:
796 """Return the leaf variance samples, chains concatenated."""
797 prec = trace.leaf_prior_cov_inv
798 if trace.has_chains:
799 prec = chain_to_axis(prec, chain_vmap_axes(trace).leaf_prior_cov_inv)
800 prec = lax.collapse(prec, 0, 2)
801 return jnp.reciprocal(prec)
804def resolve_variance_prior(
805 shape: FloatLike,
806 scale: FloatLike,
807 sigma2_init: FloatLike | None,
808 var_resid_train: FloatLike,
809) -> tuple[Float32[Array, ''], Float32[Array, ''], Float32[Array, ''], FloatLike]:
810 """Translate stochtree's IG(shape, scale) prior to bartz's error variance prior.
812 The IG(shape, scale) prior on σ² is the scaled-inverse-χ² with
813 ``sigma_df = 2*shape`` and prior harmonic mean ``square(sigma_scale) =
814 scale/shape``; the chain starts at `sigma2_init` (default
815 ``var(resid_train)``), decoupled from the prior. The mapping is branchless so
816 `shape` / `scale` may be traced; the unrepresentable IG(0, scale>0) (positive
817 rate, zero df) yields a NaN that surfaces downstream rather than an error.
819 Parameters
820 ----------
821 shape
822 Stochtree's ``sigma2_global_shape``.
823 scale
824 Stochtree's ``sigma2_global_scale``.
825 sigma2_init
826 Stochtree's ``sigma2_init``. If `None`, defaults to `var_resid_train`.
827 var_resid_train
828 Variance of the residual, the default chain start for σ².
830 Returns
831 -------
832 sigma_df : Float32[Array, '']
833 Degrees of freedom of bartz's error variance prior.
834 sigma_scale : Float32[Array, '']
835 Scale of bartz's prior (sqrt of the prior harmonic mean of the variance).
836 sigma_init : Float32[Array, '']
837 Initial error standard deviation seeding the chain.
838 sigma2_init_stored : FloatLike
839 The chain starting value of σ², suitable for ``BARTModel.sigma2_init``.
840 """
841 shape = jnp.asarray(shape, jnp.float32)
842 scale = jnp.asarray(scale, jnp.float32)
843 sigma2_start = sigma2_init if sigma2_init is not None else var_resid_train
844 sigma_init = jnp.sqrt(jnp.asarray(sigma2_start, jnp.float32))
845 # IG(shape, scale) <=> scaled-inv-chi2(df=2*shape, harmonic mean=scale/shape).
846 # The `scale > 0` guard keeps IG(0, 0) at harmonic mean 0 (avoiding 0/0) while
847 # letting IG(0, scale>0) overflow to inf -> NaN rate, flagging it as invalid.
848 harmonic_mean = jnp.where(scale > 0, scale / shape, 0.0)
849 return 2.0 * shape, jnp.sqrt(harmonic_mean), sigma_init, sigma2_start
852def check_variable_weights(
853 variable_weights: Float[ArrayLike, ' p'] | None, p: int
854) -> Float32[Array, ' p'] | None:
855 """Validate `variable_weights`, returning the jax array (or None)."""
856 if variable_weights is None:
857 return None
858 arr = jnp.asarray(variable_weights, jnp.float32)
859 if arr.shape != (p,): 859 ↛ 860line 859 didn't jump to line 860 because the condition on line 859 was never true
860 msg = f'variable_weights must have shape (p,)=({p},), got {arr.shape}'
861 raise ValueError(msg)
862 return arr
865def check_predict_args(
866 type_: Literal['posterior', 'mean'],
867 scale: Literal['linear', 'probability', 'class'],
868 terms: Literal['y_hat', 'mean_forest', 'all']
869 | Sequence[Literal['y_hat', 'mean_forest', 'all']],
870 probit_outcome_model: bool,
871) -> tuple[str, ...]:
872 """Validate `BARTModel.predict` arguments, returning the normalized terms tuple."""
873 if scale not in ('linear', 'probability', 'class'): 873 ↛ 874line 873 didn't jump to line 874 because the condition on line 873 was never true
874 msg = f"scale must be 'linear', 'probability', or 'class'; got {scale!r}"
875 raise ValueError(msg)
876 if type_ not in ('posterior', 'mean'): 876 ↛ 877line 876 didn't jump to line 877 because the condition on line 876 was never true
877 msg = f"type must be 'posterior' or 'mean'; got {type_!r}"
878 raise ValueError(msg)
879 if not probit_outcome_model and scale != 'linear': 879 ↛ 880line 879 didn't jump to line 880 because the condition on line 879 was never true
880 msg = (
881 "scale must be 'linear' for non-probit (continuous) regression;"
882 f' got {scale!r}'
883 )
884 raise ValueError(msg)
885 if type_ == 'mean' and scale == 'class': 885 ↛ 886line 885 didn't jump to line 886 because the condition on line 885 was never true
886 msg = "scale='class' is incompatible with type='mean'"
887 raise ValueError(msg)
888 terms_tuple = (terms,) if isinstance(terms, str) else tuple(terms)
889 for t in terms_tuple:
890 if t not in ('y_hat', 'mean_forest', 'all'): 890 ↛ 891line 890 didn't jump to line 891 because the condition on line 890 was never true
891 msg = f'unknown term {t!r}; valid terms are y_hat, mean_forest, all'
892 raise ValueError(msg)
893 if scale == 'class' and set(terms_tuple) != {'y_hat'}:
894 # match stochtree: 'class' converts only the single 'y_hat' term, so it
895 # rejects 'mean_forest' and 'all' (the latter also pulls in mean_forest)
896 msg = "scale='class' is only supported when requesting a single 'y_hat' term"
897 raise ValueError(msg)
898 return terms_tuple
901def check_X(
902 X: Real[ArrayLike, 'n p'] | DataFrame, *, name: str = 'X'
903) -> Real[Array, 'n p']:
904 """Convert a DataFrame/array-like to a 2-D jax array in ``(n, p)`` layout."""
905 if isinstance(X, DataFrame): 905 ↛ 906line 905 didn't jump to line 906 because the condition on line 905 was never true
906 X = X.to_numpy()
907 arr = jnp.asarray(X)
908 if arr.ndim == 1: 908 ↛ 909line 908 didn't jump to line 909 because the condition on line 908 was never true
909 arr = arr[:, None]
910 if arr.ndim != 2: 910 ↛ 911line 910 didn't jump to line 911 because the condition on line 910 was never true
911 msg = f'{name} must be 2D (n, p); got shape {arr.shape}'
912 raise ValueError(msg)
913 return arr
916def _coerce_response(
917 y: Real[ArrayLike, ' n'] | Series, *, name: str
918) -> Real[Array, ' n']:
919 """Convert a Series/array-like response to a 1-D jax array."""
920 if isinstance(y, Series): 920 ↛ 921line 920 didn't jump to line 921 because the condition on line 920 was never true
921 y = y.to_numpy()
922 arr = jnp.asarray(y)
923 if arr.ndim != 1: 923 ↛ 924line 923 didn't jump to line 924 because the condition on line 923 was never true
924 msg = f'{name} must be 1D (n,); got shape {arr.shape}'
925 raise ValueError(msg)
926 return arr