Coverage for src/bartz/_interface.py: 96%

676 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-15 10:04 +0000

1# bartz/src/bartz/_interface.py 

2# 

3# Copyright (c) 2025-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. 

24 

25"""Main high-level interface of the package.""" 

26 

27import math 

28import pickle 

29from collections.abc import Collection, Hashable, Mapping, Sequence 

30from dataclasses import replace 

31from enum import Enum 

32from functools import cached_property 

33from os import PathLike, cpu_count 

34from pathlib import Path 

35 

36# WORKAROUND(python<3.15): use frozendict instead of MappingProxyType 

37from types import MappingProxyType 

38from typing import Any, Literal, Protocol, TypedDict, cast, runtime_checkable 

39from warnings import warn 

40 

41import jax 

42import jax.numpy as jnp 

43from equinox import Module, error_if, field, tree_at 

44from jax import Device, debug_nans, device_put, lax, make_mesh, random, tree 

45from jax.scipy.linalg import solve_triangular 

46from jax.scipy.special import ndtr, ndtri 

47from jax.sharding import AxisType, Mesh, NamedSharding, PartitionSpec 

48from jax.typing import DTypeLike 

49from jaxtyping import Array, Bool, Float, Float32, Int32, Key, Real, Shaped, UInt 

50from numpy import ndarray 

51 

52from bartz._jaxext import equal_shards, is_key, jit, jit_active, project, split 

53from bartz.grove import ( 

54 TreeHeaps, 

55 TreesTrace, 

56 check_trace, 

57 evaluate_forest, 

58 forest_depth_distr, 

59 format_tree, 

60 points_per_node_distr, 

61) 

62from bartz.mcmcloop import ( 

63 BurninTrace, 

64 CallbackTuple, 

65 CheckPlatformCallback, 

66 MainTrace, 

67 MainTraceWithTrainPred, 

68 RunMCMCResult, 

69 compute_varcount, 

70 evaluate_trace, 

71 make_print_callback, 

72 make_tqdm_callback, 

73 run_mcmc, 

74) 

75from bartz.mcmcstep import DiagWishart, OutcomeType, Wishart, make_p_nonterminal 

76from bartz.mcmcstep._axes import ( 

77 chain_to_axis, 

78 chain_vmap_axes, 

79 chainful_axis, 

80 get_has_chains, 

81 trace_sample_axes, 

82) 

83from bartz.mcmcstep._state import ( 

84 ArrayLike, 

85 FloatLike, 

86 State, 

87 chol_with_gersh, 

88 init, 

89 inv_via_chol_with_gersh, 

90 leaf_partition_spec, 

91) 

92from bartz.prepcovars import Binner, BinnerFactory, UniqueQuantileBinner 

93 

94 

95class PredictKind(Enum): 

96 """Kind of output of `Bart.predict`.""" 

97 

98 mean = 'mean' 

99 """The posterior mean of the conditional mean, shape ``(m,)`` (or 

100 ``(k, m)`` for multivariate regression).""" 

101 

102 mean_samples = 'mean_samples' 

103 """Per-sample conditional mean, shape ``(num_chains * n_save, m)`` 

104 (or ``(num_chains * n_save, k, m)``). For binary regression, this is 

105 the probit-transformed sum-of-trees, divided by the error scale `w` 

106 first if the model is heteroskedastic.""" 

107 

108 outcome_samples = 'outcome_samples' 

109 """Samples of the outcome variable, shape ``(num_chains * n_save, 

110 m)`` (or ``(num_chains * n_save, k, m)``). For binary regression, 

111 these are Bernoulli draws. For continuous regression, these are 

112 Gaussian draws with the posterior noise variance.""" 

113 

114 latent_samples = 'latent_samples' 

115 """Raw sum-of-trees values, shape ``(num_chains * n_save, m)`` (or 

116 ``(num_chains * n_save, k, m)``).""" 

117 

118 

119@runtime_checkable 

120class DataFrame(Protocol): 

121 """DataFrame duck-type for `Bart`.""" 

122 

123 @property 

124 def columns(self) -> Collection[str]: 

125 """The names of the columns.""" 

126 ... 

127 

128 def to_numpy(self) -> Shaped[ndarray, '*shape']: 

129 """Convert the dataframe to a 2d numpy array with columns on the second axis.""" 

130 ... 

131 

132 

133@runtime_checkable 

134class Series(Protocol): 

135 """Series duck-type for `Bart`.""" 

136 

137 @property 

138 def name(self) -> Hashable: 

139 """The name of the series.""" 

140 ... 

141 

142 def to_numpy(self) -> Shaped[ndarray, '*shape']: 

143 """Convert the series to a 1d numpy array.""" 

144 ... 

145 

146 

147class SparseConfig(Module): 

148 R""" 

149 Configuration of a sparsity-inducing variable selection prior. 

150 

151 This is the prior of [1]_. Pass an instance to the `sparse` argument of 

152 `Bart` to activate variable selection on the predictors. The prior on the 

153 choice of predictor for each decision rule is 

154 

155 .. math:: 

156 (s_1, \ldots, s_p) \sim 

157 \operatorname{Dirichlet}(\mathtt{theta}/p, \ldots, \mathtt{theta}/p). 

158 

159 If `theta` is not specified, it's a priori distributed according to 

160 

161 .. math:: 

162 \frac{\mathtt{theta}}{\mathtt{theta} + \mathtt{rho}} \sim 

163 \operatorname{Beta}(\mathtt{a}, \mathtt{b}). 

164 

165 References 

166 ---------- 

167 .. [1] Linero, Antonio R. (2018). “Bayesian Regression Trees for 

168 High-Dimensional Prediction and Variable Selection”. In: Journal of the 

169 American Statistical Association 113.522, pp. 626-636. 

170 """ 

171 

172 theta: FloatLike | None = None 

173 """Concentration of the Dirichlet prior. If not specified, it is sampled 

174 from a Beta prior parametrized by `a`, `b` and `rho`. If set directly, it 

175 should be in the ballpark of the predictor count p or lower.""" 

176 

177 a: FloatLike = 0.5 

178 """Shape parameter of the Beta prior on ``theta / (theta + rho)``.""" 

179 

180 b: FloatLike = 1.0 

181 """Shape parameter of the Beta prior on ``theta / (theta + rho)``.""" 

182 

183 rho: FloatLike | None = None 

184 """Scale of the Beta prior on `theta`. If not specified, set to the number 

185 of predictors p. Lower values prefer more sparsity.""" 

186 

187 augment: bool = field(static=True, default=True) 

188 """Whether to account exactly for the decision rules forbidden by the 

189 ancestors of each node when updating the variable selection probabilities, 

190 using data augmentation. On by default. Setting it to `False` ignores the 

191 forbidden rules, which is faster but only approximate. This matters most 

192 with few predictors with few cutpoints each, where the same predictor 

193 cannot be re-used down a branch.""" 

194 

195 enabled: bool = field(static=True, default=True) 

196 """Whether variable selection is active.""" 

197 

198 

199class Bart(Module): 

200 R""" 

201 Nonparametric regression with Bayesian Additive Regression Trees (BART). 

202 

203 Regress `y_train` on `x_train` with a latent mean function represented as 

204 a sum of decision trees [2]_. The inference is carried out by sampling the 

205 posterior distribution of the tree ensemble with an MCMC. 

206 

207 Parameters 

208 ---------- 

209 x_train 

210 The training predictors. Missing values are not supported: NaN raises 

211 an error. 

212 y_train 

213 The training responses. For univariate regression, a 1D array of shape 

214 `(n,)`. For multivariate regression, a 2D array of shape `(k, n)` where 

215 `k` is the number of response components, as introduced in [3]_. For 

216 binary regression, the convention is that non-zero values mean 1, zero 

217 mean 0, like booleans. 

218 outcome_type 

219 The type of regression. ``'continuous'`` for continuous regression, 

220 ``'binary'`` for binary regression with probit link. For multivariate 

221 regression, a scalar value applies to all components; alternatively, a 

222 sequence of per-component types (e.g., ``['binary', 'continuous']``) 

223 specifies mixed outcome types. Binary components in multivariate 

224 outcomes follow the multivariate probit BART formulation of [4]_. 

225 sparse 

226 A `SparseConfig` for the sparsity-inducing variable selection prior of 

227 [1]_. Disabled by default; pass a `SparseConfig` to enable it. 

228 varprob 

229 The probability distribution over the `p` predictors for choosing a 

230 predictor to split on in a decision node a priori. Must be > 0. It does 

231 not need to be normalized to sum to 1. If not specified, use a uniform 

232 distribution. If `sparse` is enabled, this is used as initial value for 

233 the MCMC. 

234 binner 

235 A callable that, given the training predictors and a random key, 

236 returns a `~bartz.prepcovars.Binner` instance. The default is 

237 `~bartz.prepcovars.UniqueQuantileBinner`, which places cutpoints at 

238 the quantiles of each predictor. Other built-in options are 

239 `~bartz.prepcovars.RangeEvenBinner` (evenly-spaced cutpoints over the 

240 observed range) and `~bartz.prepcovars.GivenSplitsBinner` (R BART 

241 ``xinfo`` format). To pass options, use `functools.partial`, e.g. 

242 ``binner=partial(UniqueQuantileBinner, max_bins=128)``. 

243 rm_const 

244 How to treat predictors with no associated decision rules (i.e., there 

245 are no available cutpoints for that predictor). If `True` (default), 

246 they are ignored. If `False`, an error is raised if there are any. 

247 sigma_df 

248 The degrees of freedom of the prior on the error precision. For 

249 multivariate regression with `k` components, the Wishart degrees of 

250 freedom are set to ``sigma_df + k - 1``, such that each marginal error 

251 variance keeps ``sigma_df`` degrees of freedom; the same holds when the 

252 error covariance is restricted to diagonal. 

253 sigma_scale 

254 Sets the scale of the prior on the error precision. If 'auto' 

255 (default), the prior is scaled so that the prior harmonic mean of the 

256 error variance is ``var(y_train)``, where with `error_scale` the 

257 variance is a precision-weighted one that estimates the error variance 

258 at unit error scale. Otherwise, ``square(sigma_scale)`` is the prior 

259 harmonic mean of the error variance. For multivariate regression this 

260 applies to the marginal variance of each component, whatever `k`, and 

261 a scalar is broadcast to all components. For mixed outcome types, 

262 binary components are ignored. 

263 sigma_init 

264 The initial value of the error standard deviation in the MCMC. If 

265 'auto' (default), the initial error precision is set to ``diag(1 / 

266 var(y_train))``, with the same precision-weighted variance as 

267 `sigma_scale` when `error_scale` is given. Otherwise, the initial 

268 precision is ``diag(1 / square(sigma_init))``; for multivariate 

269 regression a scalar is broadcast to all components. For mixed outcome 

270 types, binary components are ignored. 

271 k 

272 The inverse scale of the prior standard deviation on the latent mean 

273 function, relative to half the observed range of `y_train`. If `y_train` 

274 has less than two elements, `k` is ignored and the scale is set to 1. 

275 power 

276 base 

277 Parameters of the prior on tree node generation. The probability that a 

278 node at depth `d` (0-based) is non-terminal is ``base / (1 + d) ** 

279 power``. 

280 tau_num 

281 The numerator in the expression that determines the prior standard 

282 deviation of leaves. If not specified, default to ``(max(y_train) - 

283 min(y_train)) / 2`` (or 1 if `y_train` has less than two elements) for 

284 continuous regression, and 3 for binary regression. For multivariate 

285 regression, the range is computed per component. For mixed outcome 

286 types, each component uses the default for its type. 

287 sigma_mu_df 

288 If specified, the prior precision of the leaves is sampled in the MCMC 

289 instead of being held fixed. The prior is Wishart (Gamma in the 

290 univariate case) with ``sigma_mu_df + k - 1`` degrees of freedom for 

291 `k` outcome components, such that each marginal leaf variance keeps 

292 ``sigma_mu_df`` degrees of freedom, like `sigma_df`. The prior harmonic 

293 mean of each marginal leaf variance, which is also its initial value, 

294 is the fixed value otherwise set by `tau_num` and the `k` argument (the 

295 leaf scale, not the number of components). 

296 offset 

297 The prior mean of the latent mean function. If not specified, it is set 

298 to the mean of `y_train` for continuous regression, and to 

299 ``Phi^-1(mean(y_train != 0))`` for binary regression. If `y_train` is 

300 empty, `offset` is set to 0. With binary regression, if `y_train` is 

301 all zero or all non-zero, it is set to ``Phi^-1(1/(n+1))`` or 

302 ``Phi^-1(n/(n+1))``, respectively. For multivariate regression, can be 

303 a scalar (broadcast to all components) or a `(k,)` vector. If not 

304 specified, it is set to the per-component mean of `y_train`. For mixed 

305 outcome types, each component uses the default for its type. 

306 error_scale 

307 Coefficients that rescale the error standard deviation on each 

308 datapoint ("w" in BART3). Not specifying `error_scale` is equivalent to 

309 setting it to 1 for all datapoints. Shape ``(n,)`` applies the same 

310 scalar scale to every outcome component; for multivariate regression, 

311 ``(k, n)`` instead supplies a per-component scale per datapoint. 

312 Supported with binary (probit) outcomes, where it scales the latent 

313 error so the success probability is ``Phi(latent / error_scale)``, 

314 including the binary components of a mixed regression. 

315 missing 

316 Boolean mask with the same shape as `y_train`; `True` marks entries 

317 to be ignored by the MCMC. The values of `y_train` at masked 

318 positions are ignored and may be anything, even non-finite. If 2-D, 

319 the error covariance must be diagonal. 

320 num_trees 

321 The number of trees used to represent the latent mean function. 

322 n_save 

323 The number of MCMC samples to save, after burn-in, per chain. The 

324 total trace length across all chains is ``num_chains * n_save``. 

325 n_burn 

326 The number of initial MCMC samples to discard as burn-in. This number 

327 of samples is discarded from each chain. 

328 n_skip 

329 The thinning factor for the MCMC samples, after burn-in. 

330 printevery 

331 The number of iterations (including thinned-away ones) between each log 

332 line. Set to `None` to disable progress reporting entirely (this ignores 

333 `pbar`). ^C interrupts the MCMC only every `printevery` iterations, so 

334 with reporting disabled it's impossible to kill the MCMC conveniently. 

335 pbar 

336 If `True`, show a `tqdm` progress bar instead of printing log lines. The 

337 bar advances every iteration and refreshes the acceptance statistics 

338 every `printevery` iterations. Ignored if `printevery` is `None`. 

339 num_chains 

340 The number of independent Markov chains to run. 

341 

342 The difference between ``num_chains=None`` and ``num_chains=1`` is that 

343 in the latter case in the object attributes and some methods there will 

344 be an explicit chain axis of size 1. 

345 num_chain_devices 

346 The number of devices to spread the chains across. Must be a divisor of 

347 `num_chains`. Each device will run a fraction of the chains. If 'auto' 

348 (default) and running on cpu, the number of devices is picked 

349 automatically based on the number of cores and the number of available 

350 devices (all the virtual jax cpu devices, or the `devices` list if set). 

351 num_data_devices 

352 The number of devices to split datapoints across. Must be a divisor of 

353 `n`. This is useful only with very high `n`, about > 1000_000. `predict` 

354 parallelizes across the same devices, splitting the test points; the 

355 number of test points must be a multiple of `num_data_devices` as well. 

356 

357 If both num_chain_devices and num_data_devices are specified, the total 

358 number of devices used is the product of the two. 

359 devices 

360 One or more devices used to run the MCMC on. If not specified, the 

361 computation will follow the placement of the input arrays. If a list of 

362 devices, this argument can be longer than the number of devices needed. 

363 seed 

364 The seed for the random number generator. 

365 maxdepth 

366 The maximum depth of the trees. This is 1-based, so with the default 

367 ``maxdepth=6``, the depths of the levels range from 0 to 5. 

368 precompute_predict_train 

369 If `True`, compute the predictions at the training points during the 

370 MCMC. Off by default; makes ``predict('train', ...)`` faster at the cost 

371 of more memory. 

372 init_kw 

373 Additional arguments passed to `bartz.mcmcstep.init`. 

374 run_mcmc_kw 

375 Additional arguments passed to `bartz.mcmcloop.run_mcmc`. 

376 

377 Notes 

378 ----- 

379 On gpu, the leaves of the trees are stored in float16 (see the 

380 ``leaf_dtype`` argument of `bartz.mcmcstep.init`), which limits the 

381 signal-to-noise ratio of the fit to less than about 1000, a limit that 

382 should always hold in realistic usage. 

383 

384 References 

385 ---------- 

386 .. [1] Linero, Antonio R. (2018). “Bayesian Regression Trees for 

387 High-Dimensional Prediction and Variable Selection”. In: Journal of the 

388 American Statistical Association 113.522, pp. 626-636. 

389 .. [2] Hugh A. Chipman, Edward I. George, Robert E. McCulloch "BART: 

390 Bayesian additive regression trees," The Annals of Applied Statistics, 

391 Ann. Appl. Stat. 4(1), 266-298, (March 2010). 

392 .. [3] Um, Seungha, Antonio R. Linero, Debajyoti Sinha, and Dipankar 

393 Bandyopadhyay (2023). "Bayesian additive regression trees for 

394 multivariate skewed responses". In: Statistics in Medicine 42.3, 

395 pp. 246-263. 

396 .. [4] Goh, Yong Chen, Wuu Kuang Soh, Andrew C. Parnell, and Keefe 

397 Murphy (2024). "Joint Models for Handling Non-Ignorable Missing 

398 Data using Bayesian Additive Regression Trees: Application to 

399 Leaf Photosynthetic Traits Data". arXiv:2412.14946 [stat.ME]. 

400 

401 """ 

402 

403 _main_trace: MainTrace 

404 _burnin_trace: BurninTrace 

405 _mcmc_state: State 

406 _binner: Binner 

407 _binary_mask: Bool[Array, ''] | Bool[Array, ' k'] 

408 # WORKAROUND(jax<0.9.1): use `jax.tree.static` instead of `field(static=True)` 

409 _x_train_fmt: Any = field(static=True) 

410 _device: Device | None = field(static=True) 

411 

412 def __init__( 

413 self, 

414 x_train: Real[ArrayLike, 'p n'] | DataFrame, 

415 y_train: Float32[ArrayLike, ' n'] 

416 | Float32[ArrayLike, 'k n'] 

417 | Series 

418 | DataFrame, 

419 *, 

420 outcome_type: OutcomeType | str | Sequence[OutcomeType | str] = 'continuous', 

421 sparse: SparseConfig = SparseConfig(enabled=False), 

422 varprob: Float[ArrayLike, ' p'] | None = None, 

423 binner: BinnerFactory = UniqueQuantileBinner, 

424 rm_const: bool = True, 

425 sigma_df: FloatLike = 3.0, 

426 sigma_scale: FloatLike | Float[ArrayLike, ' k'] | Literal['auto'] = 'auto', 

427 sigma_init: FloatLike | Float[ArrayLike, ' k'] | Literal['auto'] = 'auto', 

428 k: FloatLike = 2.0, 

429 power: FloatLike = 2.0, 

430 base: FloatLike = 0.95, 

431 tau_num: FloatLike | None = None, 

432 sigma_mu_df: FloatLike | None = None, 

433 offset: FloatLike | Float[ArrayLike, ' k'] | None = None, 

434 error_scale: Float[ArrayLike, ' n'] 

435 | Float[ArrayLike, 'k n'] 

436 | Series 

437 | DataFrame 

438 | None = None, 

439 missing: Bool[ArrayLike, ' n'] 

440 | Bool[ArrayLike, 'k n'] 

441 | Series 

442 | DataFrame 

443 | None = None, 

444 num_trees: int = 200, 

445 n_save: int = 1000, 

446 n_burn: int = 1000, 

447 n_skip: int = 1, 

448 printevery: int | None = 100, 

449 pbar: bool = True, 

450 num_chains: int | None = 4, 

451 num_chain_devices: int | None | Literal['auto'] = 'auto', 

452 num_data_devices: int | None = None, 

453 devices: Literal['cpu', 'gpu'] | Device | Sequence[Device] | None = None, 

454 seed: int | Key[Array, ''] = 0, 

455 maxdepth: int = 6, 

456 precompute_predict_train: bool = False, 

457 init_kw: Mapping = MappingProxyType({}), 

458 run_mcmc_kw: Mapping = MappingProxyType({}), 

459 ) -> None: 

460 # check data and put it in the right format 

461 x_train, x_train_fmt = _process_predictor_input(x_train) 

462 y_train = _process_response_input(y_train) 

463 _check_same_length(x_train, y_train) 

464 

465 if error_scale is not None: 

466 # `error_scale` is donated downstream as `init`'s `error_scale`, which 

467 # keeps it (sharded) as `State.error_scale` for prediction 

468 error_scale = _process_response_input(error_scale) 

469 _check_same_length(x_train, error_scale) 

470 

471 if missing is not None: 

472 missing = _process_response_input(missing, dtype=jnp.bool_) 

473 _check_same_length(x_train, missing) 

474 

475 # check data types are correct for continuous/binary/multivariate regression 

476 outcome_type, binary_mask = _check_type_settings( 

477 y_train, outcome_type, error_scale 

478 ) 

479 

480 # process "standardization" settings 

481 offset = _process_offset_settings( 

482 y_train, 

483 binary_mask, 

484 missing, 

485 None if offset is None else jnp.asarray(offset, jnp.float32), 

486 ) 

487 leaf_prior_cov_inv = _process_leaf_variance_settings( 

488 y_train, 

489 binary_mask, 

490 missing, 

491 k=jnp.asarray(k, jnp.float32), 

492 num_trees=num_trees, 

493 tau_num=None if tau_num is None else jnp.asarray(tau_num, jnp.float32), 

494 sigma_mu_df=None 

495 if sigma_mu_df is None 

496 else jnp.asarray(sigma_mu_df, jnp.float32), 

497 ) 

498 error_cov_inv = _process_error_variance_settings( 

499 y_train, 

500 binary_mask, 

501 missing, 

502 outcome_type=outcome_type, 

503 sigma_df=sigma_df, 

504 sigma_scale=sigma_scale, 

505 sigma_init=sigma_init, 

506 error_scale=error_scale, 

507 ) 

508 

509 # split the user-provided seed into an mcmc key and a binner key 

510 if not is_key(seed): 

511 seed = random.key(seed) 

512 keys = split(seed) 

513 

514 # construct the binner and bin x_train 

515 binner_obj = binner(x_train, key=keys.pop()) 

516 x_train = binner_obj.bin(x_train) 

517 # copy max_split because `mcmcstep.init` donates it 

518 max_split = jnp.array(binner_obj.max_split) 

519 

520 warn_byte_boundaries(x_train, max_split, maxdepth) 

521 

522 # setup and run mcmc 

523 initial_state, mcmc_key, device, check_platform = _setup_mcmc( 

524 keys.pop(), 

525 x_train, 

526 y_train, 

527 outcome_type=outcome_type, 

528 offset=offset, 

529 error_scale=error_scale, 

530 missing=missing, 

531 max_split=max_split, 

532 leaf_prior_cov_inv=leaf_prior_cov_inv, 

533 error_cov_inv=error_cov_inv, 

534 power=power, 

535 base=base, 

536 maxdepth=maxdepth, 

537 num_trees=num_trees, 

538 init_kw=init_kw, 

539 rm_const=rm_const, 

540 sparse=sparse, 

541 varprob=varprob, 

542 num_chains=num_chains, 

543 num_chain_devices=num_chain_devices, 

544 num_data_devices=num_data_devices, 

545 devices=devices, 

546 n_burn=n_burn, 

547 ) 

548 result = _run_mcmc( 

549 mcmc_key, 

550 initial_state, 

551 n_save, 

552 n_burn=n_burn, 

553 n_skip=n_skip, 

554 printevery=printevery, 

555 pbar=pbar, 

556 precompute_predict_train=precompute_predict_train, 

557 run_mcmc_kw=run_mcmc_kw, 

558 check_platform=check_platform, 

559 ) 

560 

561 # set private attributes 

562 self._main_trace = result.main_trace 

563 self._burnin_trace = result.burnin_trace 

564 self._mcmc_state = result.final_state 

565 self._binner = binner_obj 

566 self._x_train_fmt = x_train_fmt 

567 self._binary_mask = binary_mask 

568 self._device = device 

569 

570 def predict( 

571 self, 

572 x_test: Real[ArrayLike, 'p m'] | DataFrame | str, 

573 *, 

574 kind: PredictKind | str = 'mean', 

575 key: Key[Array, ''] | None = None, 

576 error_scale: Float[ArrayLike, ' m'] 

577 | Float[ArrayLike, 'k m'] 

578 | Series 

579 | DataFrame 

580 | None = None, 

581 ) -> ( 

582 Float32[Array, ' m'] 

583 | Float32[Array, 'k m'] 

584 | Float32[Array, 'ndpost m'] 

585 | Float32[Array, 'ndpost k m'] 

586 ): 

587 """ 

588 Compute predictions at `x_test`. 

589 

590 Parameters 

591 ---------- 

592 x_test 

593 The test predictors, or the string ``'train'`` to compute 

594 predictions on the training data. Missing values are not supported: 

595 NaN raises an error. 

596 kind 

597 The kind of output. See `PredictKind` for details. 

598 key 

599 Jax random key, required when ``kind='outcome_samples'``. 

600 error_scale 

601 Per-observation error scale. Used with ``kind='outcome_samples'``, 

602 and also with ``kind='mean'`` or ``'mean_samples'`` for binary 

603 outcomes (since the success probability is ``Phi(latent / 

604 error_scale)``). Required when the model was fit with `error_scale` 

605 and ``x_test`` is new data. Shape matches the shape used at 

606 fitting: ``(m,)`` for scalar scales, ``(k, m)`` for multivariate 

607 per-component scales. 

608 

609 Returns 

610 ------- 

611 Predictions at `x_test` in the requested format. 

612 

613 Raises 

614 ------ 

615 ValueError 

616 If `x_test` contains NaN or has a different format than `x_train`, 

617 or if `error_scale` is specified when it should be `None`, or if 

618 `error_scale` is not specified when it is required, or if the model 

619 splits datapoints across devices (`num_data_devices`) and the number 

620 of test points is not a multiple of the number of data devices. 

621 

622 Notes 

623 ----- 

624 If the model splits datapoints across devices (`num_data_devices`), 

625 the test points and the returned predictions are split the same way. 

626 """ 

627 # parse arguments 

628 kind = PredictKind(kind) 

629 if kind is PredictKind.outcome_samples and key is None: 629 ↛ 630line 629 didn't jump to line 630 because the condition on line 629 was never true

630 msg = '`key` not specified' 

631 raise ValueError(msg) 

632 error_scale = self._process_error_scale_test(x_test, kind, error_scale) 

633 x_test_is_train = isinstance(x_test, str) and x_test == 'train' 

634 

635 # use the predictions precomputed during the MCMC, if available 

636 if x_test_is_train and isinstance(self._main_trace, MainTraceWithTrainPred): 

637 return predict_train( 

638 key, 

639 self._main_trace, 

640 error_scale=error_scale, 

641 binary_indices=self._mcmc_state.binary_indices, 

642 has_binary=self._mcmc_state.z is not None, 

643 kind=kind, 

644 ) 

645 

646 x_test = self._process_x_test(x_test, error_scale) 

647 

648 # place new test data on the devices of the model; the training data 

649 # is already in place 

650 if not x_test_is_train: 

651 x_test, error_scale = self._device_put_test(x_test, error_scale) 

652 

653 # invoke jitted implementation 

654 return predict( 

655 key, 

656 self._main_trace, 

657 x_test, 

658 error_scale=error_scale, 

659 binary_indices=self._mcmc_state.binary_indices, 

660 has_binary=self._mcmc_state.z is not None, 

661 kind=kind, 

662 # the test points are sharded over the mesh 'data' axis (when 

663 # there is one): the training data at `init`, new test data by 

664 # `_device_put_test`. `evaluate_trace` can't detect this on its 

665 # own at trace time, so declare it. 

666 test_points='shard_and_autobatch', 

667 ) 

668 

669 def _drop_device_info(self) -> 'Bart': 

670 """Return a copy of the model without device placement metadata. 

671 

672 Clear the meshes in the MCMC state config and in the traces, and the 

673 explicitly requested device. Only this static metadata is dropped: the 

674 arrays keep their actual placement. 

675 """ 

676 config = replace(self._mcmc_state.config, mesh=None) 

677 main_trace = replace(self._main_trace, mesh=None) 

678 burnin_trace = replace(self._burnin_trace, mesh=None) 

679 obj = tree_at( 

680 lambda b: (b._mcmc_state.config, b._main_trace, b._burnin_trace), # noqa: SLF001 

681 self, 

682 (config, main_trace, burnin_trace), 

683 ) 

684 # `_device` is a static field, out of `tree_at`'s reach, so modify the 

685 # fresh copy in place 

686 object.__setattr__(obj, '_device', None) 

687 return obj 

688 

689 def dump(self, path: str | PathLike) -> None: 

690 """Serialize the fitted model to a file with `pickle`. 

691 

692 Parameters 

693 ---------- 

694 path 

695 The file to write to. 

696 

697 Notes 

698 ----- 

699 Intended for short-term storage (e.g. caching across processes), not 

700 long-term archival: the format depends on the versions of bartz, jax and 

701 equinox. The arrays are copied to host memory and all device/sharding 

702 placement is dropped; `load` reconstructs a single-device model. 

703 """ 

704 # drop all device info (`Device` objects are not picklable), then 

705 # gather any sharded arrays to host (dropping their sharding); the 

706 # reload is single-device 

707 obj = self._drop_device_info() 

708 obj = jax.device_get(obj) 

709 with Path(path).open('wb') as file: 

710 pickle.dump(obj, file, protocol=pickle.HIGHEST_PROTOCOL) 

711 

712 @classmethod 

713 def load(cls, path: str | PathLike) -> 'Bart': 

714 """Load a model saved with `dump`. 

715 

716 Parameters 

717 ---------- 

718 path 

719 The file to read from. 

720 

721 Returns 

722 ------- 

723 The deserialized model, on host memory with no device placement. 

724 

725 Raises 

726 ------ 

727 TypeError 

728 If the file does not contain a `Bart` instance. 

729 """ 

730 with Path(path).open('rb') as file: 

731 obj = pickle.load(file) # noqa: S301, the user owns the file 

732 if not isinstance(obj, cls): 

733 msg = f'unpickled a {type(obj).__name__}, not a {cls.__name__}' 

734 raise TypeError(msg) 

735 return obj 

736 

737 @property 

738 def offset(self) -> Float32[Array, ''] | Float32[Array, ' k']: 

739 """The prior mean of the latent mean function.""" 

740 return self._mcmc_state.forest.offset 

741 

742 @property 

743 def n_save(self) -> int: 

744 """The number of posterior samples after burn-in saved per chain.""" 

745 sample_axis = trace_sample_axes(self._main_trace).grow_prop_count 

746 return self._main_trace.grow_prop_count.shape[sample_axis] 

747 

748 @property 

749 def num_chains(self) -> int | None: 

750 """The number of chains, `None` if scalar.""" 

751 return self._mcmc_state.num_chains() 

752 

753 @property 

754 def ndpost(self) -> int: 

755 """The total number of posterior samples after burn-in across all chains.""" 

756 return self._main_trace.grow_prop_count.size 

757 

758 @property 

759 def num_trees(self) -> int: 

760 """Return the number of trees used in the model.""" 

761 forest = self._mcmc_state.forest 

762 chain_axis = chain_vmap_axes(forest).split_tree 

763 # chainless split_tree is (num_trees, half_tree_size); num_trees is core axis 0 

764 axis = chainful_axis(0, chain_axis) 

765 return forest.split_tree.shape[axis] 

766 

767 def get_latent_prec( 

768 self, only_continuous: bool = False 

769 ) -> ( 

770 Float32[Array, ' n_burn_plus_n_save'] 

771 | Float32[Array, 'n_burn_plus_n_save k k'] 

772 | Float32[Array, 'num_chains n_burn_plus_n_save'] 

773 | Float32[Array, 'num_chains n_burn_plus_n_save k k'] 

774 ): 

775 """Return the posterior samples of the latent error precision matrix. 

776 

777 Parameters 

778 ---------- 

779 only_continuous 

780 If `True` and the model has mixed binary-continuous outcomes, 

781 return only the submatrix for the continuous components. 

782 

783 Returns 

784 ------- 

785 MCMC samples of the error precision matrix. 

786 

787 Raises 

788 ------ 

789 ValueError 

790 If `only_continuous` is `True` but the model has only binary 

791 outcomes, so there is no continuous submatrix to return. 

792 

793 Notes 

794 ----- 

795 This method is meant to check for convergence, so it returns the full 

796 MCMC trace and does not concatenate chains together. For probit 

797 regression, this returns the precision of the latent error term, not 

798 the Bernoulli precision for the binary outcome. For heteroskedastic 

799 regression, the returned precision is the global precision parameter, 

800 that would have to be divided by a squared error scale to get the 

801 precision on a given datapoint. 

802 """ 

803 binary_indices = self._mcmc_state.binary_indices 

804 if ( 

805 only_continuous 

806 and binary_indices is None 

807 and self._mcmc_state.z is not None 

808 ): 

809 msg = 'Model has only binary outcomes, so there is no continuous submatrix to return.' 

810 raise ValueError(msg) 

811 

812 return get_latent_prec( 

813 self._burnin_trace, 

814 self._main_trace, 

815 binary_indices, 

816 only_continuous=only_continuous, 

817 ) 

818 

819 def get_error_sdev( 

820 self, mean: bool = False 

821 ) -> ( 

822 Float32[Array, ' ndpost'] 

823 | Float32[Array, 'ndpost k'] 

824 | Float32[Array, ''] 

825 | Float32[Array, ' k'] 

826 ): 

827 """Return the error standard deviation, post-burnin, chains concatenated. 

828 

829 Parameters 

830 ---------- 

831 mean 

832 If `True`, average the error covariance matrix across samples before 

833 taking the square root, returning a single scalar or vector instead 

834 of posterior samples. 

835 

836 Returns 

837 ------- 

838 Posterior samples (or single estimate) of the error standard deviation; NaN for binary outcomes. 

839 

840 Notes 

841 ----- 

842 Binary outcomes do have a standard deviation of course, but it's not 

843 returned by this method because that would require to evaluate 

844 predictions on a given X, since the Bernoulli variance is p(1-p). 

845 """ 

846 # binary outcomes are filled with NaN, so disable the NaN check 

847 with debug_nans(False): 

848 return get_error_sdev(self._main_trace, self._binary_mask, mean=mean) 

849 

850 @cached_property 

851 def accept( 

852 self, 

853 ) -> ( 

854 Float32[Array, ' n_burn_plus_n_save'] 

855 | Float32[Array, 'num_chains n_burn_plus_n_save'] 

856 ): 

857 """Fraction of trees with an accepted grow or prune move at each iteration. 

858 

859 Includes the burn-in iterations and does not concatenate the chains, to 

860 allow checking convergence. The iterations thinned away by `n_skip` are 

861 not recorded. 

862 """ 

863 return get_accept(self._burnin_trace, self._main_trace, self.num_trees) 

864 

865 @cached_property 

866 def varcount(self) -> Int32[Array, 'ndpost p']: 

867 """Histogram of predictor usage for decision rules in the trees.""" 

868 p = self._mcmc_state.forest.max_split.size 

869 return varcount(p, self._main_trace) 

870 

871 @cached_property 

872 def varcount_mean(self) -> Float32[Array, ' p']: 

873 """Average of `varcount` across MCMC iterations.""" 

874 return self.varcount.mean(axis=0) 

875 

876 @cached_property 

877 def varprob(self) -> Float32[Array, 'ndpost p']: 

878 """Posterior samples of the probability of choosing each predictor for a decision rule.""" 

879 return varprob(self._mcmc_state.forest.max_split, self._main_trace) 

880 

881 @cached_property 

882 def varprob_mean(self) -> Float32[Array, ' p']: 

883 """The marginal posterior probability of each predictor being chosen for a decision rule.""" 

884 return self.varprob.mean(axis=0) 

885 

886 def _process_error_scale_test( 

887 self, 

888 x_test: Real[ArrayLike, 'p m'] | DataFrame | str, 

889 kind: PredictKind, 

890 error_scale: Float[ArrayLike, ' m'] 

891 | Float[ArrayLike, 'k m'] 

892 | Series 

893 | DataFrame 

894 | None, 

895 ) -> Float32[Array, ' m'] | Float32[Array, 'k m'] | None: 

896 """Validate and resolve the error scales for prediction. 

897 

898 Parameters 

899 ---------- 

900 x_test 

901 The raw (not yet processed) test predictors, or ``'train'``. 

902 kind 

903 The prediction kind. 

904 error_scale 

905 User-provided per-observation error scale, or `None`. 

906 

907 Returns 

908 ------- 

909 The resolved error scale as a float32 array, or `None` if not applicable. 

910 

911 Raises 

912 ------ 

913 ValueError 

914 If `error_scale` is specified when it should be `None`, or missing 

915 when required. 

916 """ 

917 x_test_is_train = isinstance(x_test, str) and x_test == 'train' 

918 train_error_scale = self._mcmc_state.error_scale 

919 has_train_error_scale = train_error_scale is not None 

920 is_binary = self._mcmc_state.z is not None 

921 # the error scales enter the outcome samples of any outcome type, and 

922 # also the mean of binary outcomes, since P(y=1) = Phi(latent / scale) 

923 needs_error_scale = has_train_error_scale and ( 

924 kind is PredictKind.outcome_samples 

925 or (is_binary and kind in (PredictKind.mean, PredictKind.mean_samples)) 

926 ) 

927 

928 if not needs_error_scale: 

929 if error_scale is not None: 929 ↛ 930line 929 didn't jump to line 930 because the condition on line 929 was never true

930 msg = ( 

931 '`error_scale` must be `None` in this configuration (error' 

932 " scales are used with kind='outcome_samples', and with" 

933 " kind='mean' or 'mean_samples' for binary outcomes, and" 

934 ' only when the model was fit with `error_scale`)' 

935 ) 

936 raise ValueError(msg) 

937 return None 

938 

939 if x_test_is_train: 

940 if error_scale is not None: 940 ↛ 941line 940 didn't jump to line 941 because the condition on line 940 was never true

941 msg = ( 

942 "`error_scale` must be `None` when x_test='train'" 

943 ' (the training error scales are used automatically)' 

944 ) 

945 raise ValueError(msg) 

946 return train_error_scale 

947 

948 # new test data, model was fit with error scales 

949 if error_scale is None: 

950 msg = ( 

951 '`error_scale` is required because the model was fit with' 

952 ' `error_scale` and x_test is new data' 

953 ) 

954 raise ValueError(msg) 

955 error_scale_test = _process_response_input(error_scale) 

956 assert train_error_scale is not None # implied by needs_error_scale 

957 # the per-observation axis is checked separately, against x_test 

958 if error_scale_test.shape[:-1] != train_error_scale.shape[:-1]: 

959 msg = ( 

960 f'`error_scale` shape mismatch with the training error scales: ' 

961 f'got {error_scale_test.shape=}, but the leading dimensions must ' 

962 f'match the training {train_error_scale.shape=} (only the ' 

963 f'per-observation axis may differ).' 

964 ) 

965 raise ValueError(msg) 

966 return error_scale_test 

967 

968 def _process_x_test( 

969 self, 

970 x_test: Real[ArrayLike, 'p m'] | DataFrame | str, 

971 error_scale: Float32[Array, ' m'] | Float32[Array, 'k m'] | None, 

972 ) -> UInt[Array, 'p m']: 

973 """Convert x_test to binned format suitable for prediction.""" 

974 if isinstance(x_test, str): 

975 if x_test != 'train': 975 ↛ 976line 975 didn't jump to line 976 because the condition on line 975 was never true

976 msg = ( 

977 f"x_test must be an array, a DataFrame, or 'train', got {x_test!r}" 

978 ) 

979 raise ValueError(msg) 

980 return self._mcmc_state.X 

981 x_test, x_test_fmt = _process_predictor_input(x_test) 

982 if x_test_fmt != self._x_train_fmt: 

983 msg = f'Input format mismatch: {x_test_fmt=} != x_train_fmt={self._x_train_fmt!r}' 

984 raise ValueError(msg) 

985 if error_scale is not None: 

986 _check_same_length(error_scale, x_test) 

987 return self._binner.bin(x_test) 

988 

989 def _device_put_test( 

990 self, 

991 x_test: UInt[Array, 'p m'], 

992 error_scale: Float32[Array, ' m'] | Float32[Array, 'k m'] | None, 

993 ) -> tuple[UInt[Array, 'p m'], Float32[Array, ' m'] | Float32[Array, 'k m'] | None]: 

994 """Place new test data on the devices of the model. 

995 

996 Mirror the placement of the training data done at fit time: shard over 

997 the mesh if there is one (the observation axis over 'data'), else move 

998 to the device requested explicitly at construction, if any. The inputs 

999 are donated, so they must not be used elsewhere. 

1000 """ 

1001 mesh = self._mcmc_state.config.mesh 

1002 if mesh is not None: 

1003 put = lambda a: device_put( 

1004 a, 

1005 NamedSharding(mesh, leaf_partition_spec(a.ndim, None, -1, mesh)), 

1006 donate=True, 

1007 ) 

1008 elif self._device is not None: 

1009 put = lambda a: device_put(a, self._device, donate=True) 

1010 else: 

1011 return x_test, error_scale 

1012 if error_scale is None: 

1013 return put(x_test), None 

1014 else: 

1015 return put(x_test), put(error_scale) 

1016 

1017 def _check_trees( 

1018 self, error: bool = False 

1019 ) -> UInt[Array, 'num_chains n_save num_trees']: 

1020 """Apply `bartz.grove.check_trace` to all the tree draws. 

1021 

1022 Parameters 

1023 ---------- 

1024 error 

1025 If `True`, throw an error if any invalid trees are found. 

1026 

1027 Returns 

1028 ------- 

1029 An array where non-zero entries indicate invalid trees. 

1030 

1031 Raises 

1032 ------ 

1033 RuntimeError 

1034 If `error` is `True` and any invalid trees are found. 

1035 """ 

1036 out = check_trees(self._main_trace, self._mcmc_state.forest.max_split) 

1037 if error: 

1038 bad_count = jnp.count_nonzero(out).item() 

1039 if bad_count > 0: 

1040 msg = f'Found {bad_count} invalid trees in the MCMC trace.' 

1041 raise RuntimeError(msg) 

1042 return out 

1043 

1044 def _tree_goes_bad(self) -> Bool[Array, 'num_chains n_save num_trees']: 

1045 """Find iterations where a tree becomes invalid. 

1046 

1047 Returns 

1048 ------- 

1049 An array where ``(i, j, k)`` is `True` if tree `k` is invalid at iteration `j` in chain `i` but not at iteration ``j - 1``. 

1050 """ 

1051 return tree_goes_bad(self._main_trace, self._mcmc_state.forest.max_split) 

1052 

1053 def _check_replicated_trees(self) -> None: 

1054 """Check that the trees are equal across data-sharded devices. 

1055 

1056 If the data is sharded across devices, verify that the trees (which 

1057 should be replicated) are identical on all shards. 

1058 

1059 Raises 

1060 ------ 

1061 RuntimeError 

1062 If the trees differ across devices. 

1063 """ 

1064 state = self._mcmc_state 

1065 mesh = state.config.mesh 

1066 if mesh is not None and 'data' in mesh.axis_names: 

1067 # drop the data-sharded `leaf_indices` (not replicated) before the 

1068 # cross-shard equality check; `None` is a deliberately off-type 

1069 # placeholder, so use `tree_at`, which (unlike `dataclasses.replace`) 

1070 # bypasses the `__init__` type checks 

1071 replicated_forest = tree_at(lambda f: f.leaf_indices, state.forest, None) 

1072 equal = equal_shards( 

1073 replicated_forest, 'data', in_specs=PartitionSpec(), mesh=mesh 

1074 ) 

1075 equal_array = jnp.stack(tree.leaves(equal)) 

1076 all_equal = jnp.all(equal_array) 

1077 if not all_equal.item(): 1077 ↛ 1078line 1077 didn't jump to line 1078 because the condition on line 1077 was never true

1078 msg = 'The trees differ across data-sharded devices.' 

1079 raise RuntimeError(msg) 

1080 

1081 def _compare_resid( 

1082 self, 

1083 ) -> tuple[ 

1084 Float32[Array, '*num_chains n'] | Float32[Array, '*num_chains k n'], 

1085 Float32[Array, '*num_chains n'] | Float32[Array, '*num_chains k n'], 

1086 ]: 

1087 """Re-compute residuals to compare them with the updated ones. 

1088 

1089 Returns 

1090 ------- 

1091 resid1 

1092 The final state of the residuals updated during the MCMC. 

1093 resid2 

1094 The residuals computed from the final state of the trees. 

1095 """ 

1096 return compare_resid(self._mcmc_state) 

1097 

1098 def _depth_distr(self) -> Int32[Array, '*num_chains n_save d']: 

1099 """Histogram of tree depths for each state of the trees. 

1100 

1101 Returns 

1102 ------- 

1103 A matrix where each row contains a histogram of tree depths. 

1104 """ 

1105 return depth_distr(self._main_trace) 

1106 

1107 def _points_per_node_distr( 

1108 self, node_type: Literal['leaf', 'leaf-parent'] 

1109 ) -> Int32[Array, '*num_chains n_save n_plus_1']: 

1110 return points_per_node_distr_trace( 

1111 self._mcmc_state.X, self._main_trace, node_type 

1112 ) 

1113 

1114 def _points_per_decision_node_distr( 

1115 self, 

1116 ) -> Int32[Array, '*num_chains n_save n_plus_1']: 

1117 """Histogram of number of points belonging to parent-of-leaf nodes. 

1118 

1119 Returns 

1120 ------- 

1121 For each chain, a matrix where each row contains a histogram of number of points. 

1122 """ 

1123 return self._points_per_node_distr('leaf-parent') 

1124 

1125 def _points_per_leaf_distr(self) -> Int32[Array, '*num_chains n_save n_plus_1']: 

1126 """Histogram of number of points belonging to leaves. 

1127 

1128 Returns 

1129 ------- 

1130 A matrix where each row contains a histogram of number of points. 

1131 """ 

1132 return self._points_per_node_distr('leaf') 

1133 

1134 def _print_tree( 

1135 self, i_chain: int, i_sample: int, i_tree: int, print_all: bool = False 

1136 ) -> None: 

1137 """Print a single tree in human-readable format. 

1138 

1139 Parameters 

1140 ---------- 

1141 i_chain 

1142 The index of the MCMC chain. 

1143 i_sample 

1144 The index of the (post-burnin) sample in the chain. 

1145 i_tree 

1146 The index of the tree in the sample. 

1147 print_all 

1148 If `True`, also print the content of unused node slots. 

1149 """ 

1150 trace = self._main_trace 

1151 trees = _trees_chain_first(trace) 

1152 index = (i_chain, i_sample, i_tree) if trace.has_chains else (i_sample, i_tree) 

1153 # index the heap arrays, leaving the (unbatched) leaf scale alone; the 

1154 # trailing ellipsis covers the extra `k` axis of multivariate leaves 

1155 trees = tree_at( 

1156 lambda t: (t.var_tree, t.split_tree, t.leaf_tree), 

1157 trees, 

1158 replace_fn=lambda x: x[(*index, ...)], 

1159 ) 

1160 s = format_tree(trees, print_all=print_all) 

1161 print(s) # noqa: T201, this method is intended for debug 

1162 

1163 

1164def _process_predictor_input( 

1165 x: Real[ArrayLike, 'p n'] | DataFrame, 

1166) -> tuple[Shaped[Array, 'p n'], Any]: 

1167 if isinstance(x, DataFrame): 

1168 fmt = dict(kind='dataframe', columns=x.columns) 

1169 x = x.to_numpy().T 

1170 else: 

1171 fmt = dict(kind='array', num_covar=x.shape[0]) 

1172 x = jnp.asarray(x) 

1173 assert x.ndim == 2 

1174 if jnp.issubdtype(x.dtype, jnp.floating) and not jit_active() and any_nan(x): 

1175 msg = 'predictors contain NaN; missing predictor values are not supported' 

1176 raise ValueError(msg) 

1177 return x, fmt 

1178 

1179 

1180@jit 

1181def any_nan(x: Float[Array, '*shape']) -> Bool[Array, '']: 

1182 # A sum of non-negative terms is nan iff a term is nan, provided the 

1183 # accumulator has inf to overflow to, hence float32 (some float8 types lack 

1184 # inf). Unlike isnan or min/max, this fuses with the reduction and reliably 

1185 # propagates nan on cpu. 

1186 return jnp.isnan(jnp.sum(jnp.abs(x), dtype=jnp.float32)) 

1187 

1188 

1189def _process_response_input( 

1190 arr: Shaped[ArrayLike, ' n'] | Shaped[ArrayLike, 'k n'] | Series | DataFrame, 

1191 /, 

1192 *, 

1193 dtype: DTypeLike = jnp.float32, 

1194) -> Shaped[Array, ' n'] | Shaped[Array, 'k n']: 

1195 if isinstance(arr, DataFrame): 

1196 arr = arr.to_numpy().T 

1197 elif isinstance(arr, Series): 

1198 arr = arr.to_numpy() 

1199 # one unconditional copy, safe to donate downstream 

1200 arr = jnp.array(arr, dtype, copy=True) 

1201 if arr.ndim < 1 or arr.ndim > 2: 1201 ↛ 1202line 1201 didn't jump to line 1202 because the condition on line 1201 was never true

1202 msg = f'response-like input must be 1D (n,) or 2D (k, n). Got {arr.ndim=}.' 

1203 raise ValueError(msg) 

1204 return arr 

1205 

1206 

1207def _check_same_length(x1: Shaped[Array, '... n'], x2: Shaped[Array, '... n']) -> None: 

1208 get_length = lambda x: x.shape[-1] 

1209 assert get_length(x1) == get_length(x2) 

1210 

1211 

1212def warn_byte_boundaries( 

1213 x_train: UInt[Array, 'p n'], max_split: UInt[Array, ' p'], maxdepth: int 

1214) -> None: 

1215 """Warn if settings make arrays wider than one byte per element.""" 

1216 itemsize = max(x_train.dtype.itemsize, max_split.dtype.itemsize) 

1217 if itemsize > 1: 

1218 msg = ( 

1219 'More than 255 cutpoints per predictor make the binned predictors ' 

1220 f'and split points {8 * itemsize}-bit instead of 8-bit, increasing ' 

1221 'memory usage and running time. Consider using at most 255 cutpoints.' 

1222 ) 

1223 warn(msg) 

1224 if maxdepth > 8: 

1225 msg = ( 

1226 f'maxdepth={maxdepth} > 8 makes the leaf indices 16-bit instead of ' 

1227 '8-bit, increasing memory usage and running time. Consider using ' 

1228 'maxdepth <= 8. Through bartz.stochtree.BARTModel, the equivalent ' 

1229 'is mean_forest_params=dict(max_depth=7) in sample().' 

1230 ) 

1231 warn(msg) 

1232 

1233 

1234def _check_type_settings( 

1235 y_train: Float32[Array, ' n'] | Float32[Array, 'k n'], 

1236 outcome_type: OutcomeType | str | Sequence[OutcomeType | str], 

1237 error_scale: Float[Array, ' n'] | Float[Array, 'k n'] | None, 

1238) -> tuple[OutcomeType | tuple[OutcomeType, ...], Bool[Array, ''] | Bool[Array, ' k']]: 

1239 # standardize outcome_type to OutcomeType or tuple[OutcomeType, ...] 

1240 if isinstance(outcome_type, Sequence) and not isinstance(outcome_type, str): 

1241 outcome_type = tuple(OutcomeType(t) for t in outcome_type) 

1242 num_types = len(outcome_type) 

1243 if len(set(outcome_type)) == 1: 

1244 outcome_type = outcome_type[0] 

1245 else: 

1246 num_types = None 

1247 outcome_type = OutcomeType(outcome_type) 

1248 

1249 # validation 

1250 if num_types is not None and (y_train.ndim != 2 or num_types != y_train.shape[0]): 

1251 msg = ( 

1252 f'Sequence outcome_type of length {num_types}' 

1253 f' requires y_train.shape=({num_types}, n),' 

1254 f' found {y_train.shape=}.' 

1255 ) 

1256 raise ValueError(msg) 

1257 if ( 1257 ↛ 1262line 1257 didn't jump to line 1262 because the condition on line 1257 was never true

1258 error_scale is not None 

1259 and error_scale.ndim == 2 

1260 and (y_train.ndim != 2 or error_scale.shape[0] != y_train.shape[0]) 

1261 ): 

1262 msg = ( 

1263 f'2D error_scale (per-component scales) requires y_train of ' 

1264 f'shape (k, n) with matching k; got {error_scale.shape=}, ' 

1265 f'{y_train.shape=}.' 

1266 ) 

1267 raise ValueError(msg) 

1268 

1269 if isinstance(outcome_type, tuple): 

1270 binary_mask = jnp.array([t is OutcomeType.binary for t in outcome_type]) 

1271 else: 

1272 binary_mask = jnp.bool_(outcome_type is OutcomeType.binary) 

1273 binary_mask = jnp.broadcast_to(binary_mask, y_train.shape[:-1]) 

1274 

1275 return outcome_type, binary_mask 

1276 

1277 

1278def _process_sparsity_settings( 

1279 x_train: Real[Array, 'p n'], sparse: SparseConfig 

1280) -> ( 

1281 tuple[None, None, None, None] 

1282 | tuple[FloatLike, None, None, None] 

1283 | tuple[None, FloatLike, FloatLike, FloatLike] 

1284): 

1285 """Return (theta, a, b, rho).""" 

1286 if not sparse.enabled: 

1287 return None, None, None, None 

1288 elif sparse.theta is not None: 

1289 return sparse.theta, None, None, None 

1290 else: 

1291 rho = sparse.rho 

1292 if rho is None: 

1293 p, _ = x_train.shape 

1294 rho = float(p) 

1295 return None, sparse.a, sparse.b, rho 

1296 

1297 

1298@jit 

1299def _process_offset_settings( 

1300 y_train: Float32[Array, ' n'] | Float32[Array, 'k n'], 

1301 binary_mask: Bool[Array, ''] | Bool[Array, ' k'], 

1302 missing: Bool[Array, ' n'] | Bool[Array, 'k n'] | None, 

1303 offset: Float32[Array, ''] | Float32[Array, ' k'] | None, 

1304) -> Float32[Array, ''] | Float32[Array, ' k']: 

1305 """Determine and return offset.""" 

1306 if offset is not None: 

1307 return jnp.broadcast_to(offset, y_train.shape[:-1]) 

1308 if y_train.shape[-1] < 1: 

1309 return jnp.zeros(y_train.shape[:-1]) 

1310 

1311 if missing is None: 

1312 *_, n_valid = y_train.shape 

1313 prop = (y_train != 0).mean(-1) 

1314 mean = y_train.mean(-1) 

1315 else: 

1316 *_, n = y_train.shape 

1317 n_valid = n - jnp.count_nonzero(missing, axis=-1) 

1318 safe_n = jnp.maximum(n_valid, 1) 

1319 prop = jnp.where(missing, 0, y_train != 0).sum(-1) / safe_n 

1320 mean = jnp.where(missing, 0.0, y_train).sum(-1) / safe_n 

1321 

1322 bound = jnp.reciprocal(1.0 + n_valid) 

1323 binary_offset = ndtri(jnp.clip(prop, bound, 1 - bound)) 

1324 offset = jnp.where(binary_mask, binary_offset, mean) 

1325 return jnp.where(n_valid > 0, offset, 0.0) 

1326 

1327 

1328@jit(static_argnames='num_trees') 

1329def _process_leaf_variance_settings( 

1330 y_train: Float32[Array, ' n'] | Float32[Array, 'k n'], 

1331 binary_mask: Bool[Array, ''] | Bool[Array, ' k'], 

1332 missing: Bool[Array, ' n'] | Bool[Array, 'k n'] | None, 

1333 *, 

1334 k: Float[Array, ''], 

1335 num_trees: int, 

1336 tau_num: Float[Array, ''] | None, 

1337 sigma_mu_df: Float[Array, ''] | None, 

1338) -> Wishart: 

1339 """Return `leaf_prior_cov_inv`, with a prior iff `sigma_mu_df` is set.""" 

1340 # determine `tau_num` if not specified 

1341 *kshape, n = y_train.shape 

1342 if tau_num is None: 

1343 if n < 2: 

1344 continuous_tau = jnp.ones(kshape) 

1345 elif missing is None: 

1346 continuous_tau = (y_train.max(-1) - y_train.min(-1)) / 2 

1347 else: 

1348 n_valid = n - jnp.count_nonzero(missing, axis=-1) 

1349 ymax = jnp.where(missing, -jnp.inf, y_train).max(-1) 

1350 ymin = jnp.where(missing, jnp.inf, y_train).min(-1) 

1351 continuous_tau = jnp.where(n_valid >= 2, (ymax - ymin) / 2, 1.0) 

1352 tau_num = jnp.where(binary_mask, 3.0, continuous_tau) 

1353 

1354 # leaf prior standard deviation 

1355 sigma_mu = tau_num / (k * math.sqrt(num_trees)) 

1356 

1357 # leaf prior precision matrix 

1358 sigma2_mu = jnp.broadcast_to(jnp.square(sigma_mu), kshape) 

1359 leaf_prior_cov_inv = jnp.reciprocal(sigma2_mu) 

1360 if y_train.ndim == 2: 

1361 leaf_prior_cov_inv = jnp.diag(leaf_prior_cov_inv) 

1362 

1363 if sigma_mu_df is None: 

1364 return Wishart(nu=None, rate=None, value=leaf_prior_cov_inv) 

1365 else: 

1366 # each marginal variance is inverse-gamma with alpha = sigma_mu_df / 2 

1367 # and beta = rate_ii / 2, so rate_ii = sigma_mu_df * sigma2_mu makes 

1368 # sigma2_mu its harmonic mean for any k 

1369 rate = sigma_mu_df * sigma2_mu 

1370 if y_train.ndim == 2: 

1371 rate = jnp.diag(rate) 

1372 return Wishart.from_inv_wishart_marginal_nu( 

1373 sigma_mu_df, rate, leaf_prior_cov_inv 

1374 ) 

1375 

1376 

1377def _process_error_variance_settings( 

1378 y_train: Float32[Array, ' n'] | Float32[Array, 'k n'], 

1379 binary_mask: Bool[Array, ''] | Bool[Array, ' k'], 

1380 missing: Bool[Array, ' n'] | Bool[Array, 'k n'] | None, 

1381 *, 

1382 outcome_type: OutcomeType | tuple[OutcomeType, ...], 

1383 sigma_df: FloatLike, 

1384 sigma_scale: FloatLike | Float[ArrayLike, ' k'] | Literal['auto'], 

1385 sigma_init: FloatLike | Float[ArrayLike, ' k'] | Literal['auto'], 

1386 error_scale: Float32[Array, ' n'] | Float32[Array, 'k n'] | None, 

1387) -> Wishart | None: 

1388 """Build the error precision prior from the user settings.""" 

1389 if outcome_type is OutcomeType.binary: 

1390 if not isinstance(sigma_scale, str) or not isinstance(sigma_init, str): 

1391 msg = ( 

1392 'Do not set `sigma_scale` or `sigma_init` for binary regression, ' 

1393 'they are ignored' 

1394 ) 

1395 raise ValueError(msg) 

1396 return None 

1397 

1398 *kdims, _ = y_train.shape # () or (k,) 

1399 sigma_df = jnp.asarray(sigma_df, jnp.float32) 

1400 

1401 # guarded per-component variance of y_train, computed only when an 'auto' 

1402 # spec needs it (this function is not jitted, so it would not be elided) 

1403 if isinstance(sigma_scale, str) or isinstance(sigma_init, str): 

1404 vary = _guarded_response_variance(y_train, error_scale, missing) 

1405 else: 

1406 vary = None 

1407 

1408 # prior rate: each marginal variance is inverse-gamma with alpha = 

1409 # sigma_df / 2 and beta = rate_ii / 2, so rate_ii = sigma_df * var makes var 

1410 # its harmonic mean for any k 

1411 rate_diag = jnp.where( 

1412 binary_mask, 0.0, sigma_df * _resolve_error_variance(sigma_scale, vary, kdims) 

1413 ) 

1414 

1415 # initial precision = 1 / var per component (1 for binary components) 

1416 init_var = _resolve_error_variance(sigma_init, vary, kdims) 

1417 init_diag = jnp.where(binary_mask, 1.0, jnp.reciprocal(init_var)) 

1418 

1419 if y_train.ndim == 2: 

1420 rate, init = jnp.diag(rate_diag), jnp.diag(init_diag) 

1421 else: 

1422 rate, init = rate_diag, init_diag 

1423 return make_error_cov_prior(sigma_df, rate, init, outcome_type, missing) 

1424 

1425 

1426@jit 

1427def _guarded_response_variance( 

1428 y_train: Float32[Array, ' n'] | Float32[Array, 'k n'], 

1429 error_scale: Float32[Array, ' n'] | Float32[Array, 'k n'] | None, 

1430 missing: Bool[Array, ' n'] | Bool[Array, 'k n'] | None, 

1431) -> Float32[Array, '*k']: 

1432 """Per-component variance of `y_train`, used by the 'auto' error scale. 

1433 

1434 A precision-weighted variance (precision ``1 / error_scale ** 2``) estimates 

1435 ``sigma ** 2`` at unit error scale; `missing` entries are dropped. The 

1436 variance is guarded to 1 when undefined (fewer than 2 valid points) or 

1437 non-positive. 

1438 """ 

1439 if error_scale is None and missing is None: 

1440 vary = jnp.var(y_train, axis=-1) 

1441 return jnp.where(vary > 0, vary, 1.0) 

1442 else: 

1443 prec = ( 

1444 jnp.ones(()) 

1445 if error_scale is None 

1446 else jnp.reciprocal(jnp.square(error_scale)) 

1447 ) 

1448 if missing is not None: 

1449 prec = jnp.where(missing, 0.0, prec) 

1450 y_train = jnp.where(missing, 0.0, y_train) 

1451 n_valid = jnp.count_nonzero(prec, axis=-1) 

1452 wmean = jnp.sum(prec * y_train, axis=-1) / jnp.sum(prec, axis=-1) 

1453 sqdev = prec * jnp.square(y_train - wmean[..., None]) 

1454 vary = jnp.sum(sqdev, axis=-1) / n_valid 

1455 # guard on n_valid too: with a single valid point the variance is 0 in 

1456 # exact arithmetic, but float rounding in wmean can leave a tiny 

1457 # positive vary that would slip past the `vary > 0` guard 

1458 return jnp.where((n_valid > 1) & (vary > 0), vary, 1.0) 

1459 

1460 

1461def _resolve_error_variance( 

1462 spec: FloatLike | Float[ArrayLike, ' k'] | Literal['auto'], 

1463 vary: Float32[Array, '*k'] | None, 

1464 shape: Sequence[int], 

1465) -> Float32[Array, '*k']: 

1466 """Per-component error variance from a scale spec ('auto' uses var(y)).""" 

1467 if isinstance(spec, str): 

1468 if spec != 'auto': 

1469 msg = f"unrecognized value {spec!r}, expected 'auto' or a number" 

1470 raise ValueError(msg) 

1471 assert vary is not None # computed iff some spec is 'auto' 

1472 return vary 

1473 else: 

1474 return jnp.broadcast_to(jnp.square(jnp.asarray(spec, jnp.float32)), shape) 

1475 

1476 

1477def make_error_cov_prior( 

1478 marginal_nu: Float32[Array, ''], 

1479 rate: Float32[Array, ''] | Float32[Array, 'k k'], 

1480 value: Float32[Array, ''] | Float32[Array, 'k k'], 

1481 outcome_type: OutcomeType | tuple[OutcomeType, ...], 

1482 missing: Bool[Array, ' n'] | Bool[Array, 'k n'] | None, 

1483) -> Wishart: 

1484 """Build the error precision prior, diagonal-constrained where required. 

1485 

1486 Mixed binary-continuous and partial-missing (2-D mask) regression restrict 

1487 the error covariance to diagonal, so they take a `DiagWishart`; the dense 

1488 cases take a `Wishart`. `init` re-checks this choice. `marginal_nu` is the 

1489 degrees of freedom of each marginal variance, `value` the initial value of 

1490 the precision. 

1491 """ 

1492 if isinstance(outcome_type, tuple): 

1493 binary = [t is OutcomeType.binary for t in outcome_type] 

1494 is_mixed = any(binary) and not all(binary) 

1495 else: 

1496 is_mixed = False 

1497 # a 2-D missingness mask only occurs with multivariate y (checked in `init`) 

1498 partial_missing = missing is not None and missing.ndim == 2 

1499 cls = DiagWishart if is_mixed or partial_missing else Wishart 

1500 return cls.from_inv_wishart_marginal_nu(marginal_nu, rate, value) 

1501 

1502 

1503def _setup_mcmc( 

1504 mcmc_key: Key[Array, ''], 

1505 x_train: Real[Array, 'p n'], 

1506 y_train: Float32[Array, ' n'] | Float32[Array, 'k n'], 

1507 *, 

1508 outcome_type: OutcomeType | tuple[OutcomeType, ...], 

1509 offset: Float32[Array, ''] | Float32[Array, ' k'], 

1510 error_scale: Float[Array, ' n'] | Float[Array, 'k n'] | None, 

1511 missing: Bool[Array, ' n'] | Bool[Array, 'k n'] | None, 

1512 max_split: UInt[Array, ' p'], 

1513 leaf_prior_cov_inv: Wishart, 

1514 error_cov_inv: Wishart | None, 

1515 power: FloatLike, 

1516 base: FloatLike, 

1517 maxdepth: int, 

1518 num_trees: int, 

1519 init_kw: Mapping[str, Any], 

1520 rm_const: bool, 

1521 sparse: SparseConfig, 

1522 varprob: Float[ArrayLike, ' p'] | None, 

1523 num_chains: int | None, 

1524 num_chain_devices: int | None | Literal['auto'], 

1525 num_data_devices: int | None, 

1526 devices: Literal['cpu', 'gpu'] | Device | Sequence[Device] | None, 

1527 n_burn: int, 

1528) -> tuple[State, Key[Array, ''], Device | None, Literal['cpu', 'gpu'] | None]: 

1529 theta, a, b, rho = _process_sparsity_settings(x_train, sparse) 

1530 

1531 device_kw, device, check_platform = process_device_settings( 

1532 y_train, num_chains, num_chain_devices, num_data_devices, devices 

1533 ) 

1534 

1535 kw: dict = dict( 

1536 X=x_train, 

1537 y=y_train, 

1538 outcome_type=outcome_type, 

1539 offset=offset, 

1540 error_scale=error_scale, 

1541 missing=missing, 

1542 max_split=max_split, 

1543 num_trees=num_trees, 

1544 p_nonterminal=make_p_nonterminal(maxdepth, base, power), 

1545 leaf_prior_cov_inv=leaf_prior_cov_inv, 

1546 error_cov_inv=error_cov_inv, 

1547 min_points_per_decision_node=10, 

1548 filter_splitless_vars=jnp.sum(max_split == 0).item() if rm_const else 0, 

1549 log_s=process_varprob(varprob, max_split), 

1550 theta=theta, 

1551 a=a, 

1552 b=b, 

1553 rho=rho, 

1554 sparse_on_at=n_burn // 2 if sparse.enabled else None, 

1555 augment=sparse.augment, 

1556 **device_kw, 

1557 ) 

1558 

1559 kw = dict(kw, **init_kw) 

1560 

1561 state = init(**kw) 

1562 

1563 # put state and mcmc key on device if requested explicitly by the user 

1564 if device is not None: 

1565 mcmc_key, state = device_put((mcmc_key, state), device, donate=True) 

1566 

1567 return state, mcmc_key, device, check_platform 

1568 

1569 

1570def _run_mcmc( 

1571 key: Key[Array, ''], 

1572 mcmc_state: State, 

1573 n_save: int, 

1574 *, 

1575 n_burn: int, 

1576 n_skip: int, 

1577 printevery: int | None, 

1578 pbar: bool, 

1579 precompute_predict_train: bool, 

1580 run_mcmc_kw: Mapping, 

1581 check_platform: Literal['cpu', 'gpu'] | None, 

1582) -> RunMCMCResult: 

1583 # fill list of callbacks 

1584 callbacks = () 

1585 if printevery is not None: 

1586 if pbar: 

1587 callbacks += (make_tqdm_callback(mcmc_state, report_every=printevery),) 

1588 else: 

1589 callbacks += ( 

1590 make_print_callback( 

1591 mcmc_state, 

1592 dot_every=None if printevery == 1 else 1, 

1593 report_every=printevery, 

1594 ), 

1595 ) 

1596 if check_platform is not None: 

1597 callbacks += (CheckPlatformCallback(check_platform),) 

1598 

1599 # prepare arguments 

1600 kw: dict = dict( 

1601 n_burn=n_burn, 

1602 n_skip=n_skip, 

1603 inner_loop_length=printevery, 

1604 callback=CallbackTuple(callbacks), 

1605 ) 

1606 if precompute_predict_train: 

1607 kw = dict(**kw, main_trace_type=MainTraceWithTrainPred) 

1608 kw = dict(kw, **run_mcmc_kw) 

1609 

1610 return run_mcmc(key, mcmc_state, n_save, **kw) 

1611 

1612 

1613@jit(static_argnames='p') 

1614# this is jitted such that lax.collapse below does not create a copy 

1615def varcount(p: int, trace: MainTrace) -> Int32[Array, 'ndpost p']: 

1616 """Histogram of predictor usage for decision rules in the trees, squashing chains.""" 

1617 varcount: Int32[Array, '*chains samples p'] 

1618 varcount = compute_varcount(p, trace, out_chain_axis=0) 

1619 return lax.collapse(varcount, 0, -1) 

1620 

1621 

1622@jit(static_argnames='mean') 

1623def get_error_sdev( 

1624 trace: MainTrace, 

1625 binary_mask: Bool[Array, ''] | Bool[Array, ' k'], 

1626 *, 

1627 mean: bool = False, 

1628) -> ( 

1629 Float32[Array, ' ndpost'] 

1630 | Float32[Array, 'ndpost k'] 

1631 | Float32[Array, ''] 

1632 | Float32[Array, ' k'] 

1633): 

1634 """Error standard deviation, post-burnin, chains concatenated.""" 

1635 prec = trace.error_cov_inv 

1636 if trace.has_chains: 

1637 # shape (chains, samples) or (chains, samples, k, k), concatenate chains 

1638 prec = chain_to_axis(prec, chain_vmap_axes(trace).error_cov_inv) 

1639 prec = lax.collapse(prec, 0, 2) 

1640 is_uv = prec.ndim == 1 

1641 if is_uv: 

1642 # univariate case, reshape to 1x1 matrix 

1643 prec = prec[..., None, None] 

1644 

1645 # invert precision to covariance, then take diagonal variance 

1646 cov = inv_via_chol_with_gersh(prec) 

1647 var = jnp.diagonal(cov, axis1=-2, axis2=-1) 

1648 if mean: 

1649 var = var.mean(0) 

1650 sdev = jnp.sqrt(var) 

1651 if is_uv: 

1652 sdev = sdev.squeeze(-1) 

1653 return jnp.where(binary_mask, jnp.nan, sdev) 

1654 

1655 

1656@jit(static_argnames='only_continuous') 

1657def get_latent_prec( 

1658 burnin_trace: BurninTrace, 

1659 main_trace: MainTrace, 

1660 binary_indices: Int32[Array, ' kb'] | None, 

1661 *, 

1662 only_continuous: bool = False, 

1663) -> ( 

1664 Float32[Array, ' n_burn_plus_n_save'] 

1665 | Float32[Array, 'n_burn_plus_n_save k k'] 

1666 | Float32[Array, 'num_chains n_burn_plus_n_save'] 

1667 | Float32[Array, 'num_chains n_burn_plus_n_save k k'] 

1668): 

1669 """Latent error precision trace, burn-in + main concatenated.""" 

1670 burnin = burnin_trace.error_cov_inv 

1671 main = main_trace.error_cov_inv 

1672 sample_axis = trace_sample_axes(main_trace).error_cov_inv 

1673 prec = jnp.concatenate([burnin, main], axis=sample_axis) 

1674 prec = chain_to_axis(prec, chain_vmap_axes(main_trace).error_cov_inv) 

1675 if only_continuous and binary_indices is not None: 

1676 *_, k, _ = prec.shape 

1677 kc = k - binary_indices.size 

1678 mask = jnp.ones(k, dtype=bool).at[binary_indices].set(False) 

1679 (cont_indices,) = jnp.nonzero(mask, size=kc) 

1680 prec = prec[..., cont_indices[:, None], cont_indices[None, :]] 

1681 return prec 

1682 

1683 

1684@jit(static_argnames='num_trees') 

1685def get_accept( 

1686 burnin_trace: BurninTrace, main_trace: MainTrace, num_trees: int 

1687) -> ( 

1688 Float32[Array, ' n_burn_plus_n_save'] 

1689 | Float32[Array, 'num_chains n_burn_plus_n_save'] 

1690): 

1691 """Fraction of trees with an accepted move, burn-in + main concatenated.""" 

1692 sample_axis = trace_sample_axes(main_trace).grow_acc_count 

1693 acc = jnp.concatenate( 

1694 [ 

1695 burnin_trace.grow_acc_count + burnin_trace.prune_acc_count, 

1696 main_trace.grow_acc_count + main_trace.prune_acc_count, 

1697 ], 

1698 axis=sample_axis, 

1699 ) 

1700 acc = chain_to_axis(acc, chain_vmap_axes(main_trace).grow_acc_count) 

1701 return acc / num_trees 

1702 

1703 

1704@jit 

1705def varprob( 

1706 max_split: UInt[Array, ' p'], trace: MainTrace 

1707) -> Float32[Array, 'ndpost p']: 

1708 """Posterior samples of predictor selection probability, chains concatenated.""" 

1709 p = max_split.size 

1710 varprob = trace.varprob 

1711 if varprob is None: 

1712 ndpost = trace.grow_prop_count.size 

1713 peff = jnp.count_nonzero(max_split) 

1714 out = jnp.where(max_split, 1 / peff, 0) 

1715 return jnp.broadcast_to(out, (ndpost, p)) 

1716 varprob = chain_to_axis(varprob, chain_vmap_axes(trace).varprob) 

1717 return varprob.reshape(-1, p) 

1718 

1719 

1720def _trees_chain_first(obj: TreeHeaps) -> TreesTrace: 

1721 """Extract `obj`'s heap arrays, moving any chain axis to the front. 

1722 

1723 Returns a `TreesTrace` whose leading axis is the chain axis when `obj` 

1724 carries one, and the bare per-object heap arrays otherwise. 

1725 """ 

1726 trees = project(TreesTrace, obj) 

1727 if get_has_chains(obj): 

1728 axes = trees.axes_from_dataclass(chain_vmap_axes(obj)) 

1729 # WORKAROUND(python<3.14): use operator.is_none 

1730 trees = tree.map(chain_to_axis, trees, axes, is_leaf=lambda x: x is None) 

1731 return trees 

1732 

1733 

1734@jit 

1735def check_trees( 

1736 trace: MainTrace, max_split: UInt[Array, ' p'] 

1737) -> UInt[Array, 'num_chains n_save num_trees']: 

1738 """Apply `bartz.grove.check_trace` to all the tree draws.""" 

1739 trees = _trees_chain_first(trace) 

1740 out: UInt[Array, '*chains samples num_trees'] 

1741 out = check_trace(trees, max_split) 

1742 if out.ndim < 3: 

1743 out = out[None, :, :] 

1744 return out 

1745 

1746 

1747@jit 

1748def tree_goes_bad( 

1749 trace: MainTrace, max_split: UInt[Array, ' p'] 

1750) -> Bool[Array, 'num_chains n_save num_trees']: 

1751 """Find iterations where a tree becomes invalid.""" 

1752 bad = check_trees(trace, max_split).astype(bool) 

1753 bad_before = jnp.pad(bad[:, :-1, :], [(0, 0), (1, 0), (0, 0)]) 

1754 return bad & ~bad_before 

1755 

1756 

1757@jit 

1758def compare_resid( 

1759 state: State, 

1760) -> tuple[ 

1761 Float32[Array, '*num_chains n'] | Float32[Array, '*num_chains k n'], 

1762 Float32[Array, '*num_chains n'] | Float32[Array, '*num_chains k n'], 

1763]: 

1764 """Re-compute residuals to compare them with the updated ones.""" 

1765 chain_axes = chain_vmap_axes(state) 

1766 resid1 = chain_to_axis(state.resid * state.resid_unit[..., None], chain_axes.resid) 

1767 z = chain_to_axis(state.z, chain_axes.z) if state.z is not None else None 

1768 

1769 forests = _trees_chain_first(state.forest) 

1770 trees = evaluate_forest(state.X, forests, sum_batch_axis=-1) 

1771 

1772 if state.binary_indices is not None: 

1773 # mixed binary-continuous: z has only binary rows, y has all rows 

1774 assert z is not None 

1775 ref = jnp.broadcast_to(state.y, resid1.shape) 

1776 ref = ref.at[..., state.binary_indices, :].set(z) 

1777 elif z is not None: 

1778 ref = z 

1779 else: 

1780 ref = state.y 

1781 resid2 = ref - (trees + state.forest.offset[..., None]) 

1782 

1783 return resid1, resid2 

1784 

1785 

1786@jit 

1787def depth_distr(trace: MainTrace) -> Int32[Array, '*num_chains n_save d']: 

1788 """Histogram of tree depths for each state of the trees.""" 

1789 split_tree = chain_to_axis(trace.split_tree, chain_vmap_axes(trace).split_tree) 

1790 out: Int32[Array, '*chains samples d'] 

1791 out = forest_depth_distr(split_tree) 

1792 if out.ndim < 3: 1792 ↛ 1794line 1792 didn't jump to line 1794 because the condition on line 1792 was always true

1793 out = out[None, :, :] 

1794 return out 

1795 

1796 

1797@jit(static_argnames='node_type') 

1798def points_per_node_distr_trace( 

1799 X: UInt[Array, 'p n'], trace: MainTrace, node_type: Literal['leaf', 'leaf-parent'] 

1800) -> Int32[Array, '*num_chains n_save n+1']: 

1801 """Histogram of number of points per node, for every tree draw in the trace.""" 

1802 chain_axes = chain_vmap_axes(trace) 

1803 var_tree = chain_to_axis(trace.var_tree, chain_axes.var_tree) 

1804 split_tree = chain_to_axis(trace.split_tree, chain_axes.split_tree) 

1805 out: Int32[Array, '*chains samples n+1'] 

1806 out = points_per_node_distr(X, var_tree, split_tree, node_type, sum_batch_axis=-1) 

1807 if out.ndim < 3: 

1808 out = out[None, :, :] 

1809 return out 

1810 

1811 

1812class DeviceKwArgs(TypedDict): 

1813 num_chains: int | None 

1814 mesh: Mesh | None 

1815 leaf_dtype: DTypeLike 

1816 prec_scale_dtype: DTypeLike 

1817 

1818 

1819def process_device_settings( 

1820 y_train: Float32[Array, ' n'] | Float32[Array, 'k n'], 

1821 num_chains: int | None, 

1822 num_chain_devices: int | None | Literal['auto'], 

1823 num_data_devices: int | None, 

1824 devices: Literal['cpu', 'gpu'] | Device | Sequence[Device] | None, 

1825) -> tuple[DeviceKwArgs, Device | None, Literal['cpu', 'gpu'] | None]: 

1826 """Return the arguments for `mcmcstep.init` related to devices, an optional device where to put the state, and the platform to check at runtime iff it was deduced.""" 

1827 # whether the user pinned a concrete pool of devices (vs. inheriting all of 

1828 # the platform's devices); the auto chain sharding may not exceed that pool 

1829 explicit_devices = devices is not None and not isinstance(devices, str) 

1830 

1831 # the platform is deduced (rather than fixed by the user) only when neither a 

1832 # device pool nor a platform string is passed 

1833 platform_deduced = devices is None 

1834 

1835 platform, device, devices = _determine_devices(y_train, devices) 

1836 check_platform = platform if platform_deduced else None 

1837 num_chain_devices = _determine_num_chain_devices( 

1838 platform, 

1839 num_chains, 

1840 num_chain_devices, 

1841 num_data_devices=num_data_devices, 

1842 num_devices=len(devices), 

1843 explicit_devices=explicit_devices, 

1844 ) 

1845 mesh, device = _determine_mesh(num_chain_devices, num_data_devices, device, devices) 

1846 

1847 # prepare arguments to `init` 

1848 dtype = jnp.float16 if platform == 'gpu' else jnp.float32 

1849 settings = DeviceKwArgs( 

1850 num_chains=num_chains, mesh=mesh, leaf_dtype=dtype, prec_scale_dtype=dtype 

1851 ) 

1852 

1853 return settings, device, check_platform 

1854 

1855 

1856def _determine_devices( 

1857 y_train: Float32[Array, ' n'] | Float32[Array, 'k n'], 

1858 devices: Literal['cpu', 'gpu'] | Device | Sequence[Device] | None, 

1859) -> tuple[Literal['cpu', 'gpu'], Device | None, Sequence[Device]]: 

1860 """Determine the target platform and set of devices for the MCMC, and possibly a single target device.""" 

1861 if isinstance(devices, str): 

1862 platform: Literal['cpu', 'gpu'] = devices # ty:ignore[invalid-assignment] 

1863 devices = jax.devices(platform) 

1864 return platform, devices[0], devices 

1865 elif devices is not None: 

1866 if not hasattr(devices, '__len__'): 

1867 devices = (devices,) 

1868 device = devices[0] 

1869 return device.platform, device, devices 

1870 elif hasattr(y_train, 'platform'): 1870 ↛ 1877line 1870 didn't jump to line 1877 because the condition on line 1870 was always true

1871 # set device=None because if the devices were not specified explicitly 

1872 # we may be in the case where computation will follow data placement, 

1873 # do not disturb jax as the user may be playing with vmap, jit, reshard... 

1874 platform = cast(Literal['cpu', 'gpu'], y_train.platform()) 

1875 return platform, None, jax.devices(platform) 

1876 else: 

1877 msg = 'not possible to infer device from `y_train`, please set `devices`' 

1878 raise ValueError(msg) 

1879 

1880 

1881def _largest_divisor_at_most(n: int, cap: int) -> int: 

1882 """Return the largest divisor of `n` in [1, cap].""" 

1883 for d in range(cap, 0, -1): 1883 ↛ 1886line 1883 didn't jump to line 1886 because the loop on line 1883 didn't complete

1884 if n % d == 0: 

1885 return d 

1886 return 1 # unreachable: 1 always divides n 

1887 

1888 

1889def _determine_num_chain_devices( 

1890 platform: str, 

1891 num_chains: int | None, 

1892 num_chain_devices: int | None | Literal['auto'], 

1893 *, 

1894 num_data_devices: int | None, 

1895 num_devices: int, 

1896 explicit_devices: bool, 

1897) -> int | None: 

1898 """Resolve and validate `num_chain_devices`, returning the chain mesh axis size or `None`.""" 

1899 if num_chain_devices == 'auto': 

1900 num_chain_devices = _auto_num_chain_devices( 

1901 platform, num_chains, num_data_devices, num_devices, explicit_devices 

1902 ) 

1903 

1904 # an explicit value must be a positive divisor of the number of chains 

1905 if num_chain_devices is not None: 

1906 effective_chains = 1 if num_chains is None else num_chains 

1907 if num_chain_devices < 1 or effective_chains % num_chain_devices: 

1908 chains_desc = ( 

1909 'a single chain (num_chains=None)' 

1910 if num_chains is None 

1911 else f'num_chains={num_chains}' 

1912 ) 

1913 msg = ( 

1914 f'num_chain_devices={num_chain_devices} must be a positive ' 

1915 f'divisor of the number of chains ({chains_desc})' 

1916 ) 

1917 raise ValueError(msg) 

1918 

1919 # there is no chain axis to shard when the chains are scalar 

1920 if num_chains is None: 

1921 return None 

1922 return num_chain_devices 

1923 

1924 

1925def _auto_num_chain_devices( 

1926 platform: str, 

1927 num_chains: int | None, 

1928 num_data_devices: int | None, 

1929 num_devices: int, 

1930 explicit_devices: bool, 

1931) -> int | None: 

1932 """Pick `num_chain_devices` automatically for multi-chain cpu runs. 

1933 

1934 `num_data_devices` reserves devices for the data axis, so the chain axis can 

1935 only use a fraction of them; this keeps the ``chains x data`` mesh within the 

1936 `num_devices` available devices. 

1937 """ 

1938 if num_chains is None or num_chains == 1 or platform != 'cpu': 

1939 return None 

1940 data_devices = num_data_devices or 1 

1941 num_cores = cpu_count() 

1942 assert num_cores is not None, 'could not determine number of cpu cores' 

1943 

1944 # devices available for the chain axis after reserving for the data axis 

1945 core_budget = max(1, num_cores // data_devices) 

1946 num_shards = _largest_divisor_at_most(num_chains, core_budget) 

1947 

1948 if num_shards > 1: 

1949 # the mesh draws from `num_devices` devices, whether those are all the 

1950 # platform's devices or an explicit subset passed by the user 

1951 device_budget = max(1, num_devices // data_devices) 

1952 if device_budget < num_shards: 

1953 new_num_shards = _largest_divisor_at_most(num_chains, device_budget) 

1954 warn( 

1955 _auto_chain_devices_warning( 

1956 num_chains=num_chains, 

1957 desired=num_shards, 

1958 actual=new_num_shards, 

1959 device_budget=device_budget, 

1960 num_devices=num_devices, 

1961 num_data_devices=num_data_devices, 

1962 explicit_devices=explicit_devices, 

1963 ) 

1964 ) 

1965 num_shards = new_num_shards 

1966 

1967 return num_shards if num_shards > 1 else None 

1968 

1969 

1970def _auto_chain_devices_warning( 

1971 *, 

1972 num_chains: int, 

1973 desired: int, 

1974 actual: int, 

1975 device_budget: int, 

1976 num_devices: int, 

1977 num_data_devices: int | None, 

1978 explicit_devices: bool, 

1979) -> str: 

1980 """Compose the warning shown when auto chain sharding is capped by the device count.""" 

1981 if explicit_devices: 

1982 pool = f'the {num_devices} devices passed in `devices`' 

1983 few = f'only {num_devices} devices were passed in `devices`' 

1984 advice = '' 

1985 else: 

1986 pool = f'the {num_devices} jax cpu devices' 

1987 few = f'jax is set up with only {num_devices} cpu devices' 

1988 advice = ( 

1989 ' To enable more parallelization, increase the limit with ' 

1990 '`jax.config.update("jax_num_cpu_devices", <num_devices>)`.' 

1991 ) 

1992 if num_data_devices: 

1993 limit = ( 

1994 f'only {device_budget} of {pool} are free for chains ' 

1995 f'(num_data_devices={num_data_devices} reserves the rest)' 

1996 ) 

1997 else: 

1998 limit = few 

1999 return ( 

2000 f'`Bart` would like to shard {num_chains} chains across {desired} ' 

2001 f'devices, but {limit}, so it will use {actual} devices for chains ' 

2002 f'instead.{advice}' 

2003 ) 

2004 

2005 

2006def _determine_mesh( 

2007 num_chain_devices: int | None, 

2008 num_data_devices: int | None, 

2009 device: Device | None, 

2010 devices: Sequence[Device], 

2011) -> tuple[Mesh, None] | tuple[None, Device | None]: 

2012 """Create a jax device mesh for `mcmcstep.init()`.""" 

2013 if num_chain_devices is None and num_data_devices is None: 

2014 return None, device 

2015 else: 

2016 mesh = {} 

2017 if num_chain_devices is not None: 

2018 mesh.update(chains=num_chain_devices) 

2019 if num_data_devices is not None: 

2020 mesh.update(data=num_data_devices) 

2021 mesh = make_mesh( 

2022 tuple(mesh.values()), 

2023 tuple(mesh), 

2024 axis_types=(AxisType.Auto,) * len(mesh), 

2025 devices=devices, 

2026 ) 

2027 return mesh, None 

2028 # set device=None because `mcmcstep.init` will `device_put` with the 

2029 # mesh already, we don't want to undo its work 

2030 

2031 

2032def process_varprob( 

2033 varprob: Float[ArrayLike, ' p'] | None, max_split: UInt[Array, ' p'] 

2034) -> Float32[Array, ' p'] | None: 

2035 """Convert varprob to log_s.""" 

2036 if varprob is None: 

2037 return None 

2038 varprob = jnp.asarray(varprob) 

2039 assert varprob.shape == max_split.shape, 'varprob must have shape (p,)' 

2040 varprob = error_if(varprob, varprob <= 0, 'varprob must be > 0') 

2041 return jnp.log(varprob) 

2042 

2043 

2044def predict_latent( 

2045 x: UInt[Array, 'p m'], 

2046 trace: MainTrace, 

2047 test_points: Literal['none', 'autobatch', 'shard_and_autobatch'] = 'none', 

2048) -> Float32[Array, 'ndpost m'] | Float32[Array, 'ndpost k m']: 

2049 """Evaluate trees on already quantized `x`, and squash chains.""" 

2050 return evaluate_trace(x, trace, flatten_chains=True, test_points=test_points) 

2051 

2052 

2053@jit(static_argnames=('has_binary', 'kind', 'test_points')) 

2054def predict( 

2055 key: Key[Array, ''] | None, 

2056 trace: MainTrace, 

2057 x_test: UInt[Array, 'p m'], 

2058 *, 

2059 error_scale: Float[Array, ' m'] | Float[Array, 'k m'] | None, 

2060 binary_indices: Int32[Array, ' kb'] | None, 

2061 has_binary: bool, 

2062 kind: PredictKind | str, 

2063 test_points: Literal['none', 'autobatch', 'shard_and_autobatch'], 

2064) -> ( 

2065 Float32[Array, ' m'] 

2066 | Float32[Array, 'k m'] 

2067 | Float32[Array, 'ndpost m'] 

2068 | Float32[Array, 'ndpost k m'] 

2069): 

2070 """Implement `Bart.predict` by evaluating the trees on `x_test`.""" 

2071 # get latent i.e. bare sum-of-trees predictions 

2072 latent = predict_latent(x_test, trace, test_points) 

2073 return _predict_from_latent( 

2074 key, 

2075 trace, 

2076 latent, 

2077 error_scale=error_scale, 

2078 binary_indices=binary_indices, 

2079 has_binary=has_binary, 

2080 kind=kind, 

2081 ) 

2082 

2083 

2084@jit(static_argnames=('has_binary', 'kind')) 

2085def predict_train( 

2086 key: Key[Array, ''] | None, 

2087 trace: MainTraceWithTrainPred, 

2088 *, 

2089 error_scale: Float[Array, ' n'] | Float[Array, 'k n'] | None, 

2090 binary_indices: Int32[Array, ' kb'] | None, 

2091 has_binary: bool, 

2092 kind: PredictKind | str, 

2093) -> ( 

2094 Float32[Array, ' n'] 

2095 | Float32[Array, 'k n'] 

2096 | Float32[Array, 'ndpost n'] 

2097 | Float32[Array, 'ndpost k n'] 

2098): 

2099 """Implement `Bart.predict('train')` from the precomputed predictions.""" 

2100 latent = _flatten_chain_sample( 

2101 trace.train_pred, 

2102 chain_vmap_axes(trace).train_pred, 

2103 trace_sample_axes(trace).train_pred, 

2104 ) 

2105 return _predict_from_latent( 

2106 key, 

2107 trace, 

2108 latent, 

2109 error_scale=error_scale, 

2110 binary_indices=binary_indices, 

2111 has_binary=has_binary, 

2112 kind=kind, 

2113 ) 

2114 

2115 

2116def _predict_from_latent( 

2117 key: Key[Array, ''] | None, 

2118 trace: MainTrace, 

2119 latent: Float32[Array, 'ndpost m'] | Float32[Array, 'ndpost k m'], 

2120 *, 

2121 error_scale: Float[Array, ' m'] | Float[Array, 'k m'] | None, 

2122 binary_indices: Int32[Array, ' kb'] | None, 

2123 has_binary: bool, 

2124 kind: PredictKind | str, 

2125) -> ( 

2126 Float32[Array, ' m'] 

2127 | Float32[Array, 'k m'] 

2128 | Float32[Array, 'ndpost m'] 

2129 | Float32[Array, 'ndpost k m'] 

2130): 

2131 """Turn the sum-of-trees `latent` samples into the requested prediction kind.""" 

2132 if kind is PredictKind.latent_samples: 

2133 return latent 

2134 

2135 # sample posterior (uses latent directly, no probit squash needed) 

2136 if kind is PredictKind.outcome_samples: 

2137 assert key is not None 

2138 return sample_outcome( 

2139 key, 

2140 trace, 

2141 latent, 

2142 error_scale=error_scale, 

2143 binary_indices=binary_indices, 

2144 has_binary=has_binary, 

2145 ) 

2146 

2147 # squash predictions to (0, 1) if probit; with heteroskedastic error scales 

2148 # P(y=1) = Phi(latent / error_scale) 

2149 if has_binary: # self._mcmc_state.z is not None 

2150 # error_scale is (m,) or (k, m), so it broadcasts against latent 

2151 arg = latent if error_scale is None else latent / error_scale 

2152 if binary_indices is not None: 

2153 # mixed: squash only the binary rows, leaving continuous rows as-is 

2154 indexing = jnp.s_[..., binary_indices, :] 

2155 mean_samples = latent.at[indexing].set(ndtr(arg[indexing])) 

2156 else: 

2157 mean_samples = ndtr(arg) 

2158 else: 

2159 mean_samples = latent 

2160 

2161 # take mean or return samples 

2162 if kind is PredictKind.mean: 

2163 return mean_samples.mean(axis=0) 

2164 return mean_samples 

2165 

2166 

2167def _flatten_chain_sample( 

2168 arr: Float[Array, '*shape'], chain_axis: int | None, sample_axis: int 

2169) -> Float[Array, '*flat_shape']: 

2170 """Fold the chain axis into the sample axis, matching `predict_latent`'s layout.""" 

2171 if chain_axis is None: 

2172 return arr 

2173 arr = jnp.moveaxis(arr, (chain_axis, sample_axis), (0, 1)) 

2174 return lax.collapse(arr, 0, 2) 

2175 

2176 

2177@jit(static_argnames='has_binary') 

2178def sample_outcome( 

2179 key: Key[Array, ''], 

2180 trace: MainTrace, 

2181 latent: Float32[Array, 'ndpost m'] | Float32[Array, 'ndpost k m'], 

2182 *, 

2183 error_scale: Float32[Array, ' m'] | Float32[Array, 'k m'] | None, 

2184 binary_indices: Int32[Array, ' kb'] | None, 

2185 has_binary: bool, 

2186) -> Float32[Array, 'ndpost m'] | Float32[Array, 'ndpost k m']: 

2187 """Sample from the posterior predictive distribution.""" 

2188 # move error_cov_inv chain axis to 0 

2189 prec = chain_to_axis(trace.error_cov_inv, chain_vmap_axes(trace).error_cov_inv) 

2190 

2191 if latent.ndim > 2: # multivariate case 

2192 error_cov_inv = lax.collapse(prec, 0, -2) 

2193 

2194 # Cholesky of precision: error_cov_inv = L @ L^T 

2195 L = chol_with_gersh(error_cov_inv) # (ndpost, k, k) 

2196 

2197 # Sample z ~ N(0, I) and solve L^T @ error = z 

2198 # so error = L^{-T} z ~ N(0, L^{-T} L^{-1}) = N(0, Sigma) 

2199 z = random.normal(key, latent.shape) # (ndpost, k, m) 

2200 error = solve_triangular(L, z, trans='T', lower=True) # (ndpost, k, m) 

2201 if error_scale is not None: 2201 ↛ 2214line 2201 didn't jump to line 2214 because the condition on line 2201 was always true

2202 # error_scale is (m,) or (k, m) so it always broadcasts right 

2203 error *= error_scale 

2204 else: # univariate 

2205 # pure binary probit has unit-scale latent error; continuous scales it 

2206 # by `sigma`. Either way, optionally rescaled per datapoint by error_scale. 

2207 error = random.normal(key, latent.shape) 

2208 if not has_binary: 

2209 sigma = jnp.sqrt(jnp.reciprocal(prec)).reshape(-1) 

2210 error *= sigma[..., None] 

2211 if error_scale is not None: 2211 ↛ 2214line 2211 didn't jump to line 2214 because the condition on line 2211 was always true

2212 error *= error_scale[None, :] 

2213 

2214 outcome = latent + error 

2215 

2216 # convert binary outcomes via latent probit thresholding 

2217 if binary_indices is not None: 

2218 idx = jnp.s_[..., binary_indices, :] 

2219 outcome = outcome.at[idx].set(jnp.where(outcome[idx] > 0, 1.0, 0.0)) 

2220 elif has_binary: 

2221 outcome = jnp.where(outcome > 0, 1.0, 0.0) 

2222 

2223 return outcome