Coverage for src/bartz/stochtree/_stochtree.py: 87%

295 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 11:03 +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. 

24 

25"""Implement class `BARTModel` that mimics the Python package stochtree.""" 

26 

27from collections.abc import Mapping, Sequence 

28from dataclasses import dataclass, field, fields 

29from functools import partial 

30 

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

32from types import MappingProxyType 

33from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload 

34 

35from jax import numpy as jnp 

36from jax.scipy.special import ndtr, ndtri 

37from jaxtyping import Array, Float, Float32, Key, Real, Shaped 

38 

39from bartz._interface import Bart, DataFrame, PredictKind, Series 

40from bartz.mcmcstep._state import ArrayLike, FloatLike 

41from bartz.prepcovars import RangeEvenBinner 

42from bartz.stochtree._preprocess import _PreprocessorBase, make_preprocessor 

43 

44if TYPE_CHECKING: 

45 from _typeshed import DataclassInstance 

46 

47_MAX_DEPTH_LIMIT = 16 

48 

49 

50@dataclass(frozen=True) 

51class OutcomeModel: 

52 """Outcome model specification, matching `stochtree.OutcomeModel`. 

53 

54 Only ``('continuous', 'identity')`` and ``('binary', 'probit')`` are 

55 supported. 

56 """ 

57 

58 outcome: Literal['continuous', 'binary'] = 'continuous' 

59 """Outcome family.""" 

60 

61 link: Literal['identity', 'probit'] | None = None 

62 """Link function. If `None`, defaults to ``'identity'`` for ``'continuous'`` and ``'probit'`` for ``'binary'``.""" 

63 

64 def __post_init__(self) -> None: 

65 if self.link is None: 

66 default_link = {'continuous': 'identity', 'binary': 'probit'}.get( 

67 self.outcome 

68 ) 

69 object.__setattr__(self, 'link', default_link) 

70 if (self.outcome, self.link) not in ( 

71 ('continuous', 'identity'), 

72 ('binary', 'probit'), 

73 ): 

74 msg = ( 

75 f'unsupported outcome_model (outcome={self.outcome!r}, ' 

76 f"link={self.link!r}); only ('continuous', 'identity') " 

77 "and ('binary', 'probit') are supported." 

78 ) 

79 raise NotImplementedError(msg) 

80 

81 

82class NotSampledError(ValueError, AttributeError): 

83 """Raised when calling a method that requires `BARTModel.sample` to have been called.""" 

84 

85 

86@dataclass(frozen=True, kw_only=True) 

87class GeneralParams: 

88 """Mirror of stochtree's ``general_params`` dict, with the keys bartz handles.""" 

89 

90 standardize: bool = True 

91 """Whether to standardize the outcome before fitting. Ignored for probit binary.""" 

92 

93 sigma2_init: FloatLike | None = None 

94 """Starting value of the global error variance. If `None` (default), uses ``var(resid_train)`` for continuous and ``1.0`` for probit.""" 

95 

96 sigma2_global_shape: FloatLike = 0.0 

97 """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.""" 

98 

99 sigma2_global_scale: FloatLike = 0.0 

100 """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.""" 

101 

102 variable_weights: Float[ArrayLike, ' p'] | None = None 

103 """Per-predictor sampling weights. Must be strictly positive; pass a small positive value to suppress a variable.""" 

104 

105 random_seed: int | Key[Array, ''] | None = None 

106 """Seed for the random number generator. Unlike stochtree, the default 

107 `None` is deterministic (equivalent to seed ``0``) rather than drawing a 

108 random seed, so repeated fits reproduce by default.""" 

109 

110 keep_every: int = 1 

111 """Thinning factor for retained MCMC samples.""" 

112 

113 num_chains: int = 1 

114 """Number of independent MCMC chains.""" 

115 

116 outcome_model: OutcomeModel = field(default_factory=OutcomeModel) 

117 """Outcome family and link specification. Defaults to continuous with 

118 identity link.""" 

119 

120 

121@dataclass(frozen=True, kw_only=True) 

122class MeanForestParams: 

123 """Mirror of stochtree's ``mean_forest_params`` dict, restricted to the keys bartz handles.""" 

124 

125 num_trees: int = 200 

126 """Number of trees in the conditional mean ensemble.""" 

127 

128 alpha: FloatLike = 0.95 

129 """Tree split prior base.""" 

130 

131 beta: FloatLike = 2.0 

132 """Tree split prior decay.""" 

133 

134 min_samples_leaf: int = 5 

135 """Minimum number of training samples at a leaf.""" 

136 

137 max_depth: int = 10 

138 """Maximum tree depth. Must be a non-negative integer at most ``16``.""" 

139 

140 sample_sigma2_leaf: bool = True 

141 """Whether to sample the leaf-variance prior. Must be set to ``False``.""" 

142 

143 sigma2_leaf_init: FloatLike | None = None 

144 """Initial leaf-variance prior (held fixed since ``sample_sigma2_leaf=False``). If `None`, matches stochtree's defaults of ``var(resid_train) / num_trees`` for continuous and ``2 / num_trees`` for probit.""" 

145 

146 def __post_init__(self) -> None: 

147 if self.sample_sigma2_leaf: 

148 msg = ( 

149 'sample_sigma2_leaf=True is not supported (bartz uses a fixed' 

150 " leaf-variance prior); pass mean_forest_params={'sample_sigma2_leaf':" 

151 ' False} to acknowledge this.' 

152 ) 

153 raise NotImplementedError(msg) 

154 if self.max_depth < 0: 154 ↛ 155line 154 didn't jump to line 155 because the condition on line 154 was never true

155 msg = ( 

156 f'max_depth={self.max_depth} is not supported; bartz stores trees' 

157 ' as heap arrays of size 2**max_depth, so the stochtree' 

158 ' convention max_depth=-1 (unbounded) is rejected. Pass a' 

159 f' non-negative integer at most {_MAX_DEPTH_LIMIT}.' 

160 ) 

161 raise NotImplementedError(msg) 

162 if self.max_depth > _MAX_DEPTH_LIMIT: 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true

163 msg = ( 

164 f'max_depth={self.max_depth} exceeds {_MAX_DEPTH_LIMIT}; bartz' 

165 ' stores trees as heap arrays of size 2**max_depth, so memory' 

166 ' grows exponentially with depth.' 

167 ) 

168 raise ValueError(msg) 

169 

170 

171if TYPE_CHECKING: 

172 # static only, beartype does not support type[DataclassInstance] 

173 T = TypeVar('T', bound='DataclassInstance') 

174else: 

175 T = TypeVar('T') 

176 

177 

178def build_dataclass(cls: type[T], params: Mapping[str, Any] | None, name: str) -> T: 

179 """Convert a user-supplied dict to a dataclass, with friendly errors.""" 

180 if params is None: 

181 params = {} 

182 allowed = {f.name for f in fields(cls)} 

183 extra = set(params) - allowed 

184 if extra: 

185 msg = ( 

186 f'{name} contains unsupported key(s) {sorted(extra)}; valid keys' 

187 f' are {sorted(allowed)}' 

188 ) 

189 raise ValueError(msg) 

190 return cls(**params) 

191 

192 

193class BARTModel: 

194 R""" 

195 BART model with a `stochtree`-compatible interface, powered by bartz. 

196 

197 This class mimics `stochtree.BARTModel` so that bartz can be used as a 

198 drop-in reference implementation for testing. The intersection of features 

199 is targeted: continuous regression (Gaussian outcome, identity link) and 

200 binary classification (probit link) on tabular covariates. 

201 

202 Use the same idiomatic pattern as `stochtree.BARTModel`:: 

203 

204 m = BARTModel() 

205 m.sample( 

206 X_train=X, y_train=y, X_test=X_test, 

207 num_gfr=0, num_mcmc=200, 

208 mean_forest_params={'sample_sigma2_leaf': False}, 

209 ) 

210 yhat = m.predict(X_new, terms='y_hat', type='mean') 

211 

212 See `GeneralParams` and `MeanForestParams` for the supported keys in the 

213 ``general_params`` and ``mean_forest_params`` dicts. 

214 

215 Notes 

216 ----- 

217 Differences from `stochtree`, by design: 

218 

219 - ``num_gfr`` has no default and must be set explicitly to ``0``. 

220 - ``mean_forest_params['sample_sigma2_leaf']`` must be ``False``. 

221 - ``mean_forest_params['max_depth']`` must be a non-negative integer at 

222 most ``16``; stochtree's ``-1`` (unbounded depth) sentinel is not 

223 accepted. 

224 - The deprecated ``general_params['probit_outcome_model']`` flag is not 

225 accepted; pass ``outcome_model=OutcomeModel('binary', 'probit')`` 

226 instead. 

227 - ``general_params['cutpoint_grid_size']`` is not accepted; bartz uses a 

228 fixed grid of 256 evenly-spaced bins per predictor. stochtree only 

229 uses this parameter for the GFR sampler, which bartz does not support. 

230 - Leaf-basis regression, random effects, heteroskedastic variance 

231 forests, and warm-starting from a previous model are not supported. 

232 - bartz uses single-precision floats, so outputs differ from stochtree 

233 at the float32 precision level. 

234 - ``general_params['random_seed']`` defaults to deterministic behavior 

235 (seed ``0``) when unset, whereas stochtree draws a random seed. This is 

236 intentional, to make repeated fits reproducible by default. 

237 

238 References 

239 ---------- 

240 Herren, A., Hahn, P. R., Murray, J., Carvalho, C. (2026). "StochTree: 

241 BART-based modeling in R and Python". arXiv:2512.12051. 

242 """ 

243 

244 # public, set by sample() 

245 sampled: bool 

246 """Whether `sample` has been called.""" 

247 

248 standardize: bool 

249 """Whether the outcome was standardized before fitting.""" 

250 

251 sample_sigma2_global: bool 

252 """Whether the global error variance is sampled (always ``True``).""" 

253 

254 probit_outcome_model: bool 

255 """Whether the model uses a binary outcome with probit link.""" 

256 

257 outcome_model: OutcomeModel 

258 """Outcome family and link specification used during fitting.""" 

259 

260 num_gfr: int 

261 """Number of grow-from-root iterations (always ``0``).""" 

262 

263 num_burnin: int 

264 """Number of MCMC burn-in iterations.""" 

265 

266 num_mcmc: int 

267 """Number of retained MCMC iterations per chain.""" 

268 

269 num_chains: int 

270 """Number of independent MCMC chains.""" 

271 

272 num_samples: int 

273 """Total number of retained posterior samples (``num_mcmc * num_chains``).""" 

274 

275 sigma2_init: FloatLike 

276 """Starting value of the global error variance actually used to seed the chain.""" 

277 

278 y_bar: Float32[Array, ''] 

279 """Mean used to standardize the outcome (``0`` if not standardized).""" 

280 

281 y_std: Float32[Array, ''] 

282 """Standard deviation used to standardize the outcome (``1`` if not standardized).""" 

283 

284 has_rfx: bool 

285 """Whether the model includes random effects (always ``False``).""" 

286 

287 include_mean_forest: bool 

288 """Whether the model includes a conditional mean forest (always ``True``).""" 

289 

290 include_variance_forest: bool 

291 """Whether the model includes a variance forest (always ``False``).""" 

292 

293 y_hat_train: Float32[Array, 'n num_samples'] 

294 """Posterior predictions at the training covariates, in the original outcome scale.""" 

295 

296 global_var_samples: Float32[Array, ' num_samples'] 

297 """Posterior samples of the global error variance. For probit binary regression, an array of ones.""" 

298 

299 y_hat_test: Float32[Array, 'm num_samples'] | None 

300 """Posterior predictions at `X_test` if it was supplied to `sample`, else `None`.""" 

301 

302 _bart: Bart 

303 _preprocessor: _PreprocessorBase | None 

304 

305 def __init__(self) -> None: 

306 self.sampled = False 

307 self._preprocessor = None 

308 

309 def is_sampled(self) -> bool: 

310 """Return whether `sample` has been called.""" 

311 return self.sampled 

312 

313 def _prepare_training_inputs( 

314 self, 

315 X_train: Real[ArrayLike, 'n p'] | DataFrame, 

316 y_train: Real[ArrayLike, ' n'] | Series, 

317 gp: GeneralParams, 

318 ) -> tuple[Real[Array, 'n p'], Real[Array, ' n'], Float32[Array, ' p'] | None]: 

319 """Coerce inputs and build variable weights, fitting the DataFrame preprocessor if any.""" 

320 y_train_arr = _coerce_response(y_train, name='y_train') 

321 

322 self._preprocessor = make_preprocessor(X_train) 

323 if self._preprocessor is None: 

324 X_train_arr = check_X(X_train, name='X_train') 

325 _, p = X_train_arr.shape 

326 varprob = check_variable_weights(gp.variable_weights, p) 

327 else: 

328 # The preprocessor decides the default weights: uniform over the 

329 # *original* columns split across each one-hot expansion (so every 

330 # original variable keeps an equal splitting budget), or `None` when 

331 # nothing expands (deferring to bartz's native uniform fast-path). 

332 weights_np = self._preprocessor.fit( 

333 X_train, variable_weights=gp.variable_weights 

334 ) 

335 X_train_np = self._preprocessor.transform(X_train) 

336 if X_train_np.shape[1] == 0: 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true

337 msg = 'X_train has no usable columns after preprocessing' 

338 raise ValueError(msg) 

339 X_train_arr = jnp.asarray(X_train_np) 

340 varprob = None if weights_np is None else jnp.asarray(weights_np) 

341 

342 n, _ = X_train_arr.shape 

343 if y_train_arr.shape[0] != n: 343 ↛ 344line 343 didn't jump to line 344 because the condition on line 343 was never true

344 msg = ( 

345 f'X_train and y_train length mismatch: X_train has {n} rows,' 

346 f' y_train has {y_train_arr.shape[0]} entries' 

347 ) 

348 raise ValueError(msg) 

349 return X_train_arr, y_train_arr, varprob 

350 

351 def sample( 

352 self, 

353 X_train: Real[ArrayLike, 'n p'] | DataFrame, 

354 y_train: Real[ArrayLike, ' n'] | Series, 

355 X_test: Real[ArrayLike, 'm p'] | DataFrame | None = None, 

356 observation_weights: Float[ArrayLike, ' n'] | Series | None = None, 

357 *, 

358 num_gfr: int, 

359 num_burnin: int = 0, 

360 num_mcmc: int = 100, 

361 general_params: Mapping[str, Any] | None = None, 

362 mean_forest_params: Mapping[str, Any] | None = None, 

363 bart_kwargs: Mapping[str, Any] = MappingProxyType({}), 

364 ) -> None: 

365 """Fit the model. 

366 

367 The signature mirrors `stochtree.BARTModel.sample`, restricted to the 

368 keyword arguments bartz supports. 

369 

370 Parameters 

371 ---------- 

372 X_train 

373 Training covariates with shape ``(n, p)``. 

374 y_train 

375 Training outcomes of length ``n``. 

376 X_test 

377 Optional test covariates; if given, predictions are cached on 

378 them in `y_hat_test`. 

379 observation_weights 

380 Optional positive per-observation weights scaling the residual 

381 variance (``y_i | - ~ N(mu(X_i), sigma^2 / w_i)``). Not supported 

382 with a probit outcome model. 

383 num_gfr 

384 Number of grow-from-root iterations. Must be ``0``. 

385 num_burnin 

386 Number of MCMC burn-in iterations. 

387 num_mcmc 

388 Number of retained MCMC iterations per chain. 

389 general_params 

390 Optional override for the keys of `GeneralParams`. 

391 mean_forest_params 

392 Override for the keys of `MeanForestParams`. Must explicitly 

393 disable ``sample_sigma2_leaf``. 

394 bart_kwargs 

395 Additional arguments forwarded to `bartz.Bart`. Use this to set 

396 ``devices`` and ``rm_const=False`` when wrapping `sample` in 

397 `jax.jit`. 

398 

399 Raises 

400 ------ 

401 NotImplementedError 

402 If ``num_gfr`` is non-zero. 

403 ValueError 

404 If `observation_weights` are set with a probit outcome model. 

405 """ 

406 if num_gfr != 0: 

407 msg = ( 

408 'num_gfr must be 0; the grow-from-root sampler is not available' 

409 ' in bartz.' 

410 ) 

411 raise NotImplementedError(msg) 

412 

413 gp = build_dataclass(GeneralParams, general_params, 'general_params') 

414 mfp = build_dataclass( 

415 MeanForestParams, mean_forest_params, 'mean_forest_params' 

416 ) 

417 

418 is_probit = gp.outcome_model.outcome == 'binary' 

419 

420 # stochtree does not support heteroskedastic probit 

421 if is_probit and observation_weights is not None: 

422 msg = ( 

423 'observation_weights are not supported with a probit outcome' 

424 ' model; stochtree does not support heteroskedastic probit.' 

425 ) 

426 raise ValueError(msg) 

427 

428 X_train_arr, y_train_arr, variable_weights = self._prepare_training_inputs( 

429 X_train, y_train, gp 

430 ) 

431 

432 y_bar, y_std, y_for_bartz = standardize_y( 

433 y_train_arr, is_probit, gp.standardize 

434 ) 

435 

436 bart_num_chains = None if gp.num_chains == 1 else gp.num_chains 

437 

438 # variance of the standardized residual, matching stochtree 

439 # (np.var(resid_train) with ddof=0). For standardize=True it is exactly 

440 # 1.0; we hardcode that so the value stays trace-time concrete. 

441 if is_probit: 

442 var_resid_train: FloatLike = 1.0 # bartz ignores σ² for binary 

443 elif gp.standardize: 443 ↛ 446line 443 didn't jump to line 446 because the condition on line 443 was always true

444 var_resid_train = 1.0 

445 else: 

446 var_resid_train = jnp.var(y_for_bartz) 

447 

448 # leaf-prior: bartz uses sigma_mu = tau_num / (k * sqrt(num_trees)); 

449 # stochtree's sigma2_leaf is the leaf-variance prior. Hold k=2 and solve 

450 # for tau_num so that the two parameterizations agree. 

451 bartz_k = 2.0 

452 sigma2_leaf_init = resolve_sigma2_leaf_init( 

453 mfp.sigma2_leaf_init, mfp.num_trees, is_probit, var_resid_train 

454 ) 

455 tau_num_arg = bartz_k * jnp.sqrt(mfp.num_trees * sigma2_leaf_init) 

456 

457 if is_probit: 

458 # stochtree pins σ²=1 for probit; bartz binary branch ignores the 

459 # variance prior, so we leave the scale/init at their 'auto' 

460 # defaults (bartz rejects explicit values for binary outcomes). 

461 sigma_df_arg: FloatLike = 3.0 

462 sigma_scale_arg: FloatLike | Literal['auto'] = 'auto' 

463 sigma_init_arg: FloatLike | Literal['auto'] = 'auto' 

464 sigma2_init_stored: FloatLike = 1.0 

465 else: 

466 sigma_df_arg, sigma_scale_arg, sigma_init_arg, sigma2_init_stored = ( 

467 resolve_variance_prior( 

468 gp.sigma2_global_shape, 

469 gp.sigma2_global_scale, 

470 gp.sigma2_init, 

471 var_resid_train, 

472 ) 

473 ) 

474 

475 binner = partial(RangeEvenBinner, max_bins=256) 

476 

477 seed = 0 if gp.random_seed is None else gp.random_seed 

478 

479 kwargs: dict = dict( 

480 x_train=X_train_arr.T, 

481 y_train=y_for_bartz, 

482 outcome_type='binary' if is_probit else 'continuous', 

483 binner=binner, 

484 varprob=variable_weights, 

485 sigma_df=sigma_df_arg, 

486 sigma_scale=sigma_scale_arg, 

487 sigma_init=sigma_init_arg, 

488 k=bartz_k, 

489 power=mfp.beta, 

490 base=mfp.alpha, 

491 tau_num=tau_num_arg, 

492 error_scale=observation_weights, 

493 num_trees=mfp.num_trees, 

494 n_save=num_mcmc, 

495 n_burn=num_burnin, 

496 n_skip=gp.keep_every, 

497 printevery=None, 

498 num_chains=bart_num_chains, 

499 seed=seed, 

500 maxdepth=mfp.max_depth + 1, 

501 precompute_predict_train=True, 

502 ) 

503 kwargs.update(bart_kwargs) 

504 # match stochtree's gating: only acceptance-time veto on 

505 # min_samples_leaf, no per-leaf affluence filter (stochtree picks 

506 # leaves uniformly over all of them). User-supplied init_kw values 

507 # win on conflicts. 

508 kwargs = dict( 

509 kwargs, 

510 init_kw=dict( 

511 { 

512 'min_points_per_leaf': mfp.min_samples_leaf, 

513 'min_points_per_decision_node': None, 

514 }, 

515 **kwargs.get('init_kw', {}), 

516 ), 

517 ) 

518 self._bart = Bart(**kwargs) 

519 self._finalize_sample( 

520 outcome_model=gp.outcome_model, 

521 num_burnin=num_burnin, 

522 num_mcmc=num_mcmc, 

523 num_chains=gp.num_chains, 

524 sigma2_init=sigma2_init_stored, 

525 y_bar=y_bar, 

526 y_std=y_std, 

527 standardize=gp.standardize, 

528 X_test=X_test, 

529 ) 

530 

531 def _finalize_sample( 

532 self, 

533 *, 

534 outcome_model: OutcomeModel, 

535 num_burnin: int, 

536 num_mcmc: int, 

537 num_chains: int, 

538 sigma2_init: FloatLike, 

539 y_bar: Float32[Array, ''], 

540 y_std: Float32[Array, ''], 

541 standardize: bool, 

542 X_test: Real[ArrayLike, 'm p'] | DataFrame | None, 

543 ) -> None: 

544 """Populate the public attributes after `_bart` has been constructed.""" 

545 is_probit = outcome_model.outcome == 'binary' 

546 self.sampled = True 

547 self.standardize = standardize 

548 self.sample_sigma2_global = True 

549 self.probit_outcome_model = is_probit 

550 self.outcome_model = outcome_model 

551 self.num_gfr = 0 

552 self.num_burnin = num_burnin 

553 self.num_mcmc = num_mcmc 

554 self.num_chains = num_chains 

555 self.num_samples = num_mcmc * num_chains 

556 self.sigma2_init = sigma2_init 

557 self.y_bar = y_bar 

558 self.y_std = y_std 

559 self.has_rfx = False 

560 self.include_mean_forest = True 

561 self.include_variance_forest = False 

562 

563 # cached outputs in stochtree's (n, num_samples) layout, original scale 

564 self.y_hat_train = self._predict_y_hat_internal('train') 

565 if X_test is not None: 

566 self.y_hat_test = self._predict_y_hat_internal(self._prepare_x(X_test).T) 

567 else: 

568 self.y_hat_test = None 

569 

570 if is_probit: 

571 self.global_var_samples = jnp.ones((self.num_samples,)) 

572 else: 

573 sigma = self._bart.get_error_sdev() 

574 self.global_var_samples = (sigma * y_std) ** 2 

575 

576 @overload 

577 def predict( 

578 self, 

579 X: Real[ArrayLike, 'm p'] | DataFrame, 

580 *, 

581 type: Literal['posterior', 'mean'] = 'posterior', 

582 terms: Literal['y_hat', 'mean_forest'], 

583 scale: Literal['linear', 'probability', 'class'] = 'linear', 

584 ) -> Shaped[Array, 'm num_samples'] | Shaped[Array, ' m']: ... 

585 

586 @overload 

587 def predict( 

588 self, 

589 X: Real[ArrayLike, 'm p'] | DataFrame, 

590 *, 

591 type: Literal['posterior', 'mean'] = 'posterior', 

592 terms: Literal['all'] = 'all', 

593 scale: Literal['linear', 'probability', 'class'] = 'linear', 

594 ) -> dict[str, Shaped[Array, 'm num_samples']] | dict[str, Shaped[Array, ' m']]: ... 

595 

596 @overload 

597 def predict( 

598 self, 

599 X: Real[ArrayLike, 'm p'] | DataFrame, 

600 *, 

601 type: Literal['posterior', 'mean'] = 'posterior', 

602 terms: Sequence[Literal['y_hat', 'mean_forest', 'all']], 

603 scale: Literal['linear', 'probability', 'class'] = 'linear', 

604 ) -> ( 

605 Shaped[Array, 'm num_samples'] 

606 | Shaped[Array, ' m'] 

607 | dict[str, Shaped[Array, 'm num_samples']] 

608 | dict[str, Shaped[Array, ' m']] 

609 ): ... 

610 

611 def predict( 

612 self, 

613 X: Real[ArrayLike, 'm p'] | DataFrame, 

614 *, 

615 type: Literal['posterior', 'mean'] = 'posterior', # noqa: A002 

616 terms: Literal['y_hat', 'mean_forest', 'all'] 

617 | Sequence[Literal['y_hat', 'mean_forest', 'all']] = 'all', 

618 scale: Literal['linear', 'probability', 'class'] = 'linear', 

619 ) -> ( 

620 Shaped[Array, 'm num_samples'] 

621 | Shaped[Array, ' m'] 

622 | dict[str, Shaped[Array, 'm num_samples']] 

623 | dict[str, Shaped[Array, ' m']] 

624 ): 

625 """Predict at new covariates. 

626 

627 Parameters 

628 ---------- 

629 X 

630 New covariates with shape ``(m, p)``. 

631 type 

632 ``'posterior'`` returns one prediction per posterior sample, with 

633 shape ``(m, num_samples)``. ``'mean'`` averages the posterior 

634 samples, returning a vector of shape ``(m,)``. 

635 terms 

636 One of ``'y_hat'``, ``'mean_forest'``, ``'all'``, or a list. Since 

637 random effects and a variance forest are not supported, ``'y_hat'`` 

638 and ``'mean_forest'`` produce the same result. 

639 scale 

640 For probit binary regression: ``'linear'`` returns the eta values, 

641 ``'probability'`` returns ``Phi(eta)``, ``'class'`` returns 0 / 1. 

642 Only ``'linear'`` is valid for continuous outcomes. 

643 

644 Returns 

645 ------- 

646 Either a single jax array (for a single requested term) or a dict keyed by term name. 

647 

648 Raises 

649 ------ 

650 NotSampledError 

651 If `sample` has not been called yet. 

652 """ 

653 if not self.sampled: 

654 msg = ( 

655 "This BARTModel instance is not fitted yet. Call 'sample' before" 

656 ' using this model.' 

657 ) 

658 raise NotSampledError(msg) 

659 terms_tuple = check_predict_args(type, scale, terms, self.probit_outcome_model) 

660 

661 pred = self._predict_y_hat_internal(self._prepare_x(X).T) 

662 

663 if self.probit_outcome_model and scale in ('probability', 'class'): 

664 prob = ndtr(pred) 

665 pred_out = jnp.where(prob < 0.5, 0, 1) if scale == 'class' else prob 

666 else: 

667 pred_out = pred 

668 

669 if type == 'mean': 

670 pred_out = jnp.mean(pred_out, axis=1) 

671 

672 wants_y_hat = ('y_hat' in terms_tuple) or ('all' in terms_tuple) 

673 wants_mean_forest = ('mean_forest' in terms_tuple) or ('all' in terms_tuple) 

674 single = sum([wants_y_hat, wants_mean_forest]) == 1 

675 if single: 

676 return pred_out 

677 result: dict[str, Shaped[Array, '...']] = {} 

678 if wants_y_hat: 678 ↛ 680line 678 didn't jump to line 680 because the condition on line 678 was always true

679 result['y_hat'] = pred_out 

680 if wants_mean_forest: 680 ↛ 682line 680 didn't jump to line 682 because the condition on line 680 was always true

681 result['mean_forest_predictions'] = pred_out 

682 return result 

683 

684 def _prepare_x(self, X: Real[ArrayLike, 'm p'] | DataFrame) -> Real[Array, 'm p']: 

685 """Convert covariates to a 2-D jax array, replaying the fitted preprocessor if any.""" 

686 if self._preprocessor is None: 

687 return check_X(X) 

688 if make_preprocessor(X) is None: 

689 msg = ( 

690 'this model was fit on a DataFrame, so prediction covariates must' 

691 ' also be a pandas/polars DataFrame with the same columns; got a' 

692 ' non-DataFrame. Passing a raw array would bypass the fitted' 

693 ' preprocessing (e.g. one-hot encoding) and silently misalign the' 

694 ' features.' 

695 ) 

696 raise TypeError(msg) 

697 return jnp.asarray(self._preprocessor.transform(X)) 

698 

699 def _predict_y_hat_internal( 

700 self, x: Real[ArrayLike, 'p m'] | Literal['train'] 

701 ) -> Float32[Array, 'm num_samples']: 

702 """Return predictions on the original outcome scale, layout ``(m, num_samples)``.""" 

703 latent = self._bart.predict(x, kind=PredictKind.latent_samples) 

704 if self.probit_outcome_model: 

705 # bartz integrates the binary offset into latent; result already on probit scale. 

706 return latent.T 

707 if self.standardize: 707 ↛ 709line 707 didn't jump to line 709 because the condition on line 707 was always true

708 return (latent * self.y_std + self.y_bar).T 

709 return latent.T 

710 

711 

712def standardize_y( 

713 y_train: Real[ArrayLike, ' n'], is_probit: bool, standardize: bool 

714) -> tuple[Float32[Array, ''], Float32[Array, ''], Float32[Array, ' n']]: 

715 """Return ``(y_bar, y_std, y_for_bartz)`` matching stochtree's standardization.""" 

716 y = jnp.asarray(y_train, jnp.float32) 

717 if is_probit: 

718 return ndtri(y.mean()), jnp.float32(1.0), (y != 0).astype(jnp.float32) 

719 if standardize: 719 ↛ 724line 719 didn't jump to line 724 because the condition on line 719 was always true

720 y_bar = y.mean() 

721 y_std_val = y.std() 

722 y_std = jnp.where(y_std_val > 0, y_std_val, 1.0) 

723 return y_bar, y_std, (y - y_bar) / y_std 

724 return jnp.float32(0.0), jnp.float32(1.0), y 

725 

726 

727def resolve_sigma2_leaf_init( 

728 sigma2_leaf_init: FloatLike | None, 

729 num_trees: int, 

730 is_probit: bool, 

731 var_resid_train: FloatLike, 

732) -> FloatLike: 

733 """Default `sigma2_leaf_init` per stochtree: probit→2/num_trees, continuous→var(resid)/num_trees.""" 

734 if sigma2_leaf_init is not None: 

735 return sigma2_leaf_init 

736 if is_probit: 

737 return 2.0 / num_trees 

738 return var_resid_train / num_trees 

739 

740 

741def resolve_variance_prior( 

742 shape: FloatLike, 

743 scale: FloatLike, 

744 sigma2_init: FloatLike | None, 

745 var_resid_train: FloatLike, 

746) -> tuple[Float32[Array, ''], Float32[Array, ''], Float32[Array, ''], FloatLike]: 

747 """Translate stochtree's IG(shape, scale) prior to bartz's error variance prior. 

748 

749 The IG(shape, scale) prior on σ² is the scaled-inverse-χ² with 

750 ``sigma_df = 2*shape`` and prior harmonic mean ``square(sigma_scale) = 

751 scale/shape``; the chain starts at `sigma2_init` (default 

752 ``var(resid_train)``), decoupled from the prior. The mapping is branchless so 

753 `shape` / `scale` may be traced; the unrepresentable IG(0, scale>0) (positive 

754 rate, zero df) yields a NaN that surfaces downstream rather than an error. 

755 

756 Parameters 

757 ---------- 

758 shape 

759 Stochtree's ``sigma2_global_shape``. 

760 scale 

761 Stochtree's ``sigma2_global_scale``. 

762 sigma2_init 

763 Stochtree's ``sigma2_init``. If `None`, defaults to `var_resid_train`. 

764 var_resid_train 

765 Variance of the residual, the default chain start for σ². 

766 

767 Returns 

768 ------- 

769 sigma_df : Float32[Array, ''] 

770 Degrees of freedom of bartz's error variance prior. 

771 sigma_scale : Float32[Array, ''] 

772 Scale of bartz's prior (sqrt of the prior harmonic mean of the variance). 

773 sigma_init : Float32[Array, ''] 

774 Initial error standard deviation seeding the chain. 

775 sigma2_init_stored : FloatLike 

776 The chain starting value of σ², suitable for ``BARTModel.sigma2_init``. 

777 """ 

778 shape = jnp.asarray(shape, jnp.float32) 

779 scale = jnp.asarray(scale, jnp.float32) 

780 sigma2_start = sigma2_init if sigma2_init is not None else var_resid_train 

781 sigma_init = jnp.sqrt(jnp.asarray(sigma2_start, jnp.float32)) 

782 # IG(shape, scale) <=> scaled-inv-chi2(df=2*shape, harmonic mean=scale/shape). 

783 # The `scale > 0` guard keeps IG(0, 0) at harmonic mean 0 (avoiding 0/0) while 

784 # letting IG(0, scale>0) overflow to inf -> NaN rate, flagging it as invalid. 

785 harmonic_mean = jnp.where(scale > 0, scale / shape, 0.0) 

786 return 2.0 * shape, jnp.sqrt(harmonic_mean), sigma_init, sigma2_start 

787 

788 

789def check_variable_weights( 

790 variable_weights: Float[ArrayLike, ' p'] | None, p: int 

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

792 """Validate `variable_weights`, returning the jax array (or None).""" 

793 if variable_weights is None: 

794 return None 

795 arr = jnp.asarray(variable_weights, jnp.float32) 

796 if arr.shape != (p,): 796 ↛ 797line 796 didn't jump to line 797 because the condition on line 796 was never true

797 msg = f'variable_weights must have shape (p,)=({p},), got {arr.shape}' 

798 raise ValueError(msg) 

799 return arr 

800 

801 

802def check_predict_args( 

803 type_: Literal['posterior', 'mean'], 

804 scale: Literal['linear', 'probability', 'class'], 

805 terms: Literal['y_hat', 'mean_forest', 'all'] 

806 | Sequence[Literal['y_hat', 'mean_forest', 'all']], 

807 probit_outcome_model: bool, 

808) -> tuple[str, ...]: 

809 """Validate `BARTModel.predict` arguments, returning the normalized terms tuple.""" 

810 if scale not in ('linear', 'probability', 'class'): 810 ↛ 811line 810 didn't jump to line 811 because the condition on line 810 was never true

811 msg = f"scale must be 'linear', 'probability', or 'class'; got {scale!r}" 

812 raise ValueError(msg) 

813 if type_ not in ('posterior', 'mean'): 813 ↛ 814line 813 didn't jump to line 814 because the condition on line 813 was never true

814 msg = f"type must be 'posterior' or 'mean'; got {type_!r}" 

815 raise ValueError(msg) 

816 if not probit_outcome_model and scale != 'linear': 816 ↛ 817line 816 didn't jump to line 817 because the condition on line 816 was never true

817 msg = ( 

818 "scale must be 'linear' for non-probit (continuous) regression;" 

819 f' got {scale!r}' 

820 ) 

821 raise ValueError(msg) 

822 if type_ == 'mean' and scale == 'class': 822 ↛ 823line 822 didn't jump to line 823 because the condition on line 822 was never true

823 msg = "scale='class' is incompatible with type='mean'" 

824 raise ValueError(msg) 

825 terms_tuple = (terms,) if isinstance(terms, str) else tuple(terms) 

826 for t in terms_tuple: 

827 if t not in ('y_hat', 'mean_forest', 'all'): 827 ↛ 828line 827 didn't jump to line 828 because the condition on line 827 was never true

828 msg = f'unknown term {t!r}; valid terms are y_hat, mean_forest, all' 

829 raise ValueError(msg) 

830 if scale == 'class' and set(terms_tuple) != {'y_hat'}: 

831 # match stochtree: 'class' converts only the single 'y_hat' term, so it 

832 # rejects 'mean_forest' and 'all' (the latter also pulls in mean_forest) 

833 msg = "scale='class' is only supported when requesting a single 'y_hat' term" 

834 raise ValueError(msg) 

835 return terms_tuple 

836 

837 

838def check_X( 

839 X: Real[ArrayLike, 'n p'] | DataFrame, *, name: str = 'X' 

840) -> Real[Array, 'n p']: 

841 """Convert a DataFrame/array-like to a 2-D jax array in ``(n, p)`` layout.""" 

842 if isinstance(X, DataFrame): 842 ↛ 843line 842 didn't jump to line 843 because the condition on line 842 was never true

843 X = X.to_numpy() 

844 arr = jnp.asarray(X) 

845 if arr.ndim == 1: 845 ↛ 846line 845 didn't jump to line 846 because the condition on line 845 was never true

846 arr = arr[:, None] 

847 if arr.ndim != 2: 847 ↛ 848line 847 didn't jump to line 848 because the condition on line 847 was never true

848 msg = f'{name} must be 2D (n, p); got shape {arr.shape}' 

849 raise ValueError(msg) 

850 return arr 

851 

852 

853def _coerce_response( 

854 y: Real[ArrayLike, ' n'] | Series, *, name: str 

855) -> Real[Array, ' n']: 

856 """Convert a Series/array-like response to a 1-D jax array.""" 

857 if isinstance(y, Series): 857 ↛ 858line 857 didn't jump to line 858 because the condition on line 857 was never true

858 y = y.to_numpy() 

859 arr = jnp.asarray(y) 

860 if arr.ndim != 1: 860 ↛ 861line 860 didn't jump to line 861 because the condition on line 860 was never true

861 msg = f'{name} must be 1D (n,); got shape {arr.shape}' 

862 raise ValueError(msg) 

863 return arr