Coverage for src/lsqfitgp/bayestree/_bcf.py: 79%

357 statements  

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

1# lsqfitgp/bayestree/_bcf.py 

2# 

3# Copyright (c) 2023, 2024, 2026, Giacomo Petrillo 

4# 

5# This file is part of lsqfitgp. 

6# 

7# lsqfitgp is free software: you can redistribute it and/or modify 

8# it under the terms of the GNU General Public License as published by 

9# the Free Software Foundation, either version 3 of the License, or 

10# (at your option) any later version. 

11# 

12# lsqfitgp is distributed in the hope that it will be useful, 

13# but WITHOUT ANY WARRANTY; without even the implied warranty of 

14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

15# GNU General Public License for more details. 

16# 

17# You should have received a copy of the GNU General Public License 

18# along with lsqfitgp. If not, see <http://www.gnu.org/licenses/>. 

19 

20import functools 1cab

21import warnings 1cab

22 

23import gvar 1cab

24import numpy 1cab

25from jax import jit 1cab

26from jax import numpy as jnp 1cab

27 

28from lsqfitgp import ( 1cab

29 _GP, 

30 _array, 

31 _fastraniter, 

32 _fit, 

33 _gvarext, 

34 _jaxext, 

35 _kernels, 

36 _utils, 

37 copula, 

38) 

39 

40 

41def _recursive_cast(dtype, default, mapping): 1cab

42 if dtype in mapping: 42 ↛ 43line 42 didn't jump to line 43 because the condition on line 42 was never true1ab

43 return mapping[dtype] 

44 elif dtype.names is not None: 1ab

45 return numpy.dtype( 1ab

46 [ 

47 (name, _recursive_cast(dtype[name], default, mapping)) 

48 for name in dtype.names 

49 ] 

50 ) 

51 elif dtype.subdtype is not None: 51 ↛ 53line 51 didn't jump to line 53 because the condition on line 51 was never true1ab

52 # note: has names => does not have subdtype 

53 return numpy.dtype((_recursive_cast(dtype.base, default, mapping), dtype.shape)) 

54 elif default is None: 54 ↛ 55line 54 didn't jump to line 55 because the condition on line 54 was never true1ab

55 return dtype 

56 else: 

57 return default 1ab

58 

59 

60def cast(dtype, default, mapping={}): # noqa: B006, read only 1cab

61 """ 

62 Recursively cast a numpy data type. 

63 

64 Parameters 

65 ---------- 

66 dtype : dtype 

67 The data type to cast. 

68 default : dtype or None 

69 The leaf fields of `dtype` are casted to `default`, which can be 

70 structured, unless they appear in `mapping`. If None, dtypes not in 

71 `mapping` are left unchanged. 

72 mapping : dict 

73 A dictionary from dtypes to dtypes, indicating specific casting rules. 

74 The dtypes can be structured, a match of a structured dtype takes 

75 precedence over matches in its leaves, and the converted dtype is not 

76 further searched for matches. 

77 

78 Returns 

79 ------- 

80 casted_dtype : dtype 

81 The casted version of `dtype`. May not have the same structure if 

82 `mapping` contains structured dtypes. 

83 """ 

84 mapping = {numpy.dtype(k): numpy.dtype(v) for k, v in mapping.items()} 1ab

85 default = None if default is None else numpy.dtype(default) 1ab

86 return _recursive_cast(numpy.dtype(dtype), default, mapping) 1ab

87 

88 

89class bcf: 1cab

90 def __init__( # noqa: C901, PLR0915 1cab

91 self, 

92 *, 

93 y, 

94 z, 

95 x_mu, 

96 x_tau=None, 

97 pihat, 

98 include_pi='mu', 

99 weights=None, 

100 fitkw={}, # noqa: B006, read only 

101 kernelkw_mu={}, # noqa: B006, read only 

102 kernelkw_tau={}, # noqa: B006, read only 

103 marginalize_mean=True, 

104 gpaux=None, 

105 x_aux=None, 

106 otherhp={}, # noqa: B006, read only 

107 transf='standardize', 

108 ): 

109 r""" 

110 Nonparametric Bayesian regression with a GP version of BCF. 

111 

112 BCF (Bayesian Causal Forests) is a regression method for observational 

113 causal inference studies introduced in [1]_ based on a pair of BART 

114 models. 

115 

116 This class evaluates a Gaussian process regression with a kernel which 

117 accurately approximates BCF in the infinite trees limit of each BART 

118 model. The hyperparameters are optimized to their marginal MAP. 

119 

120 The model is (loosely, see notes below) :math:`y = \mu(x) + z\tau(x)`, 

121 so :math:`\tau(x)` is the expected causal effect of :math:`z` on 

122 :math:`y` at location :math:`x`. 

123 

124 Parameters 

125 ---------- 

126 y : (n,) array, series or dataframe 

127 Outcome. 

128 z : (n,) array, series or dataframe 

129 Binary treatment status: 0 control group, 1 treatment group. 

130 x_mu : (n, p) array, series or dataframe 

131 Covariates for the :math:`\mu` model. 

132 x_tau : (n, q) array, series or dataframe, optional 

133 Covariates for the :math:`\tau` model. If not specified, use `x_mu`. 

134 pihat : (n,) array, series or dataframe 

135 Estimated propensity score, i.e., P(Z=1|X). 

136 include_pi : {'mu', 'tau', 'both'}, optional 

137 Whether to include the propensity score in the :math:`\mu` model, 

138 the :math:`\tau` model, or both. Default is ``'mu'``. 

139 weights : (n,) array, series or dataframe 

140 Weights used to rescale the error variance (as 1 / weight). 

141 fitkw : dict 

142 Additional arguments passed to `~lsqfitgp.empbayes_fit`, overrides 

143 the defaults. 

144 kernelkw_mu, kernelkw_tau : dict 

145 Additional arguments passed to `~lsqfitgp.BART` for each model, 

146 overrides the defaults. 

147 marginalize_mean : bool 

148 If True (default), marginalize the intercept of the model. 

149 gpaux : callable, optional 

150 If specified, this function is called with a pair ``(hp, gp)``, 

151 where ``hp`` is a dictionary of hyperparameters, and ``gp`` is a 

152 `~lsqfitgp.GP` object under construction, and is expected to return 

153 a modified ``gp`` with a new process named ``'aux'`` defined with 

154 `~lsqfitgp.GP.defproc` or similar. The process is added to the 

155 regression model. The input to the process is a structured array 

156 with fields: 

157 

158 'train' : bool 

159 Indicates whether the data is training set (the one passed on 

160 initialization) or test set (the one passed to `pred` or `gp`). 

161 'i' : int 

162 Index of the flattened array. 

163 'z' : int 

164 Treatment status. 

165 'mu', 'tau' : structured 

166 The values in `x_mu` and `x_tau`, converted to indices according 

167 to the BART grids. Where `pihat` has been added, there are two 

168 subfields: ``'x'`` which contains the covariates, and 

169 ``'pihat'``, the latter expressed in indices as well. 

170 'pihat' : float 

171 The `pihat` argument. Contrary to the subfield included under 

172 ``'mu'`` and/or ``'tau'``, this field contains the original 

173 values. 

174 'aux' : structured 

175 The values in `x_aux`, if specified. 

176 

177 x_aux : (n, k) array, series or dataframe, optional 

178 Additional covariates for the ``'aux'`` process. 

179 otherhp : dictionary of gvar 

180 A dictionary with the prior of arbitrary additional hyperpameters, 

181 intended to be used by ``gpaux`` or ``transf``. 

182 transf : (list of) str or pair of callable 

183 Data transformation. Either a string indicating a pre-defined 

184 transformation, or a pair ``(from_data, to_data)``, two functions 

185 with signatures ``from_data(hp, y) -> eta`` and ``to_data(hp, eta) 

186 -> y``, where ``eta`` is the value to which the model is fit, and 

187 ``hp`` is the dictionary of hyperparameters. The functions must be 

188 ufuncs and one the inverse of the other w.r.t. the second parameter. 

189 ``from_data`` must be derivable with `jax` w.r.t. ``y``. 

190 

191 If a list of such specifications is provided, the transformations 

192 are applied in order, with the first one being the outermost, i.e., 

193 the one applied first to the data. 

194 

195 If a transformation uses additional hyperparameters, either 

196 predefined automatically or passed by the user through `otherhp`, 

197 they are inferred with the rest of the hyperparameters. 

198 

199 The pre-defined transformations are: 

200 

201 'standardize' (default) 

202 eta = (y - mean(train_y)) / sdev(train_y) 

203 'yeojohnson' 

204 The Yeo-Johnson transformation [2]_ to reduce skewness. The 

205 :math:`\lambda` parameter is bounded in :math:`(0, 2)` 

206 for implementation convenience, this restriction may be lifted 

207 in future versions. 

208 

209 Notes 

210 ----- 

211 The regression model is: 

212 

213 .. math:: 

214 \eta_i = g(y_i; \ldots) &= m + {} \\ 

215 &\phantom{{}={}} + 

216 \lambda_\mu 

217 \mu(\mathbf x^\mu_i, \hat\pi_i?) + {} \\ 

218 &\phantom{{}={}} + 

219 \lambda_\tau 

220 \tau(\mathbf x^\tau_i, \hat\pi_i?) (z_i - z_0) + {} \\ 

221 &\phantom{{}={}} + 

222 \mathrm{aux}(i, z_i, \mathbf x^\mu_i, \mathbf x^\tau_i, 

223 \hat\pi_i, \mathbf x^\text{aux}_i) + {} \\ 

224 &\phantom{{}={}} + 

225 \varepsilon_i, \\ 

226 \varepsilon_i &\sim 

227 N(0, \sigma^2 / w_i), \\ 

228 m &\sim N(0, 1), \\ 

229 \log \sigma^2 &\sim N(\log\bar w, 4), \\ 

230 \lambda_\mu 

231 &\sim \mathrm{HalfCauchy}(2), \\ 

232 \lambda_\tau 

233 &\sim \mathrm{HalfNormal}(1.48), \\ 

234 \mu &\sim \mathrm{GP}(0, 

235 \mathrm{BART}(\alpha_\mu, \beta_\mu) ), \\ 

236 \tau &\sim \mathrm{GP}(0, 

237 \mathrm{BART}(\alpha_\tau, \beta_\tau) ), \\ 

238 \mathrm{aux} & \sim \mathrm{GP}(0, \text{<user defined>}), \\ 

239 \alpha_\mu, \alpha_\tau &\sim \mathrm{Beta}(2, 1), \\ 

240 \beta_\mu, \beta_\tau &\sim \mathrm{InvGamma}(1, 1), \\ 

241 z_0 &\sim U(0, 1), 

242 

243 To make the inference, :math:`(\mu, \tau, \boldsymbol\varepsilon, m, 

244 \mathrm{aux})` are marginalized analytically, and the marginal posterior 

245 mode of 

246 :math:`(\sigma, \lambda_*, \alpha_*, \beta_*, z_0, \ldots)` is found by 

247 numerical minimization, after transforming them to express their prior 

248 as a Gaussian copula. Their marginal posterior covariance matrix is 

249 estimated with an approximation of the hessian inverse. See 

250 `~lsqfitgp.empbayes_fit` and use the parameter ``fitkw`` to customize 

251 this procedure. 

252 

253 The tree splitting grid of the BART kernel is set using quantiles of the 

254 observed covariates. This corresponds to settings ``usequants=True``, 

255 ``numcut=inf`` in the R packages BayesTree and BART. Use the parameters 

256 `kernelkw_mu` and `kernelkw_tau` to customize the grids. 

257 

258 The difference between the regression model evaluated at :math:`Z=1` vs. 

259 :math:`Z=0` can be interpreted as the causal effect :math:`Z \rightarrow 

260 Y` if the unconfoundedness assumption is made: 

261 

262 .. math:: 

263 \{Y(Z=0), Y(Z=1)\} \perp\!\!\!\perp Z \mid X. 

264 

265 In practical terms, this holds when: 

266 

267 1) :math:`X` are pre-treatment variables, i.e., they represent 

268 quantities causally upstream of :math:`Z`. 

269 

270 2) :math:`X` are sufficient to adjust for all common causes of 

271 :math:`Z` and :math:`Y`, such that the only remaining difference 

272 is the causal effect and not just a correlation. 

273 

274 Here :math:`X` consists in `x_tau`, `x_mu` and `x_aux`. However these 

275 arrays may also be used to pass "technical" values used to set up the 

276 model, that do not satisfy the uncounfoundedness assumption, if you know 

277 what you are doing. 

278 

279 Attributes 

280 ---------- 

281 m : float or gvar 

282 The prior mean :math:`m`. 

283 sigma : gvar 

284 The error term standard deviation :math:`\sigma`. If there are 

285 weights, the sdev for each unit is obtained dividing ``sigma`` by 

286 sqrt(weight). 

287 alpha_mu, alpha_tau : gvar 

288 The numerator of the tree spawn probability :math:`\alpha_*` (named 

289 ``base`` in R bcf). 

290 beta_mu, beta_tau : gvar 

291 The depth exponent of the tree spawn probability :math:`\beta_*` 

292 (named ``power`` in R bcf). 

293 lambda_mu, lambda_tau : gvar 

294 The prior standard deviation :math:`\lambda_*`. 

295 z_0 : gvar 

296 The treatment coding parameter. 

297 fit : empbayes_fit 

298 The hyperparameters fit object. 

299 

300 Methods 

301 ------- 

302 gp : 

303 Create a GP object. 

304 data : 

305 Creates the dictionary to be passed to `GP.pred` to represent data. 

306 pred : 

307 Evaluate the regression function at given locations. 

308 from_data : 

309 Convert :math:`y` to :math:`\eta`. 

310 to_data : 

311 Convert :math:`\eta` to :math:`y`. 

312 

313 See Also 

314 -------- 

315 lsqfitgp.BART 

316 

317 References 

318 ---------- 

319 .. [1] P. Richard Hahn, Jared S. Murray, Carlos M. Carvalho "Bayesian 

320 Regression Tree Models for Causal Inference: Regularization, 

321 Confounding, and Heterogeneous Effects (with Discussion)," Bayesian 

322 Analysis 15(3), 965-1056, September 2020, 

323 https://doi.org/10.1214/19-BA1195 

324 .. [2] Yeo, In-Kwon; Johnson, Richard A. (2000). "A New Family of Power 

325 Transformations to Improve Normality or Symmetry". Biometrika. 87 

326 (4): 954–959. https://doi.org/10.1093/biomet/87.4.954 

327 """ # noqa: DOC001 

328 # convert covariates to StructuredArray 

329 x_mu = self._to_structured(x_mu) 1ab

330 if x_tau is not None: 1ab

331 x_tau = self._to_structured(x_tau) 1b

332 assert x_tau.shape == x_mu.shape 1b

333 if x_aux is not None: 1ab

334 x_aux = self._to_structured(x_aux) 1b

335 assert x_aux.shape == x_mu.shape 1b

336 

337 # convert outcomes, treatment, propensity score, weights to 1d arrays 

338 y = self._to_vector(y) 1ab

339 z = self._to_vector(z) 1ab

340 pihat = self._to_vector(pihat) 1ab

341 assert y.shape == z.shape == pihat.shape == x_mu.shape 1ab

342 if weights is not None: 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true1ab

343 weights = self._to_vector(weights) 

344 assert weights.shape == x_mu.shape 

345 

346 # check include_pi 

347 if include_pi not in ('mu', 'tau', 'both'): 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true1ab

348 msg = f'invalid value include_pi={include_pi!r}' 

349 raise KeyError(msg) 

350 self._include_pi = include_pi 1ab

351 

352 # add pihat to covariates 

353 x_mu, x_tau = self._append_pihat(x_mu, x_tau, pihat) 1ab

354 

355 # grid and indices 

356 splits_mu = _kernels.BART.splits_from_coord(x_mu) 1ab

357 i_mu = self._toindices(x_mu, splits_mu) 1ab

358 if x_tau is None: 1ab

359 splits_tau = splits_mu 1ab

360 i_tau = None 1ab

361 else: 

362 splits_tau = _kernels.BART.splits_from_coord(x_tau) 1b

363 i_tau = self._toindices(x_tau, splits_tau) 1b

364 

365 # get functions for data transformation 

366 from_data, to_data, transfloss, transfhp = self._get_transf( 1ab

367 transf=transf, weights=weights, y=y 

368 ) 

369 

370 # scale of error variance 

371 logsigma2_loc = 0 if weights is None else numpy.log(jnp.mean(weights)) 1ab

372 

373 # prior on hyperparams 

374 hyperprior = copula.makedict( 1ab

375 { 

376 'm': gvar.gvar(0, 1), 

377 'sigma^2': copula.lognorm(logsigma2_loc, 2), 

378 'lambda_mu': copula.halfcauchy(2), 

379 'lambda_tau': copula.halfnorm(1.48), 

380 'alpha_mu': copula.beta(2, 1), 

381 'alpha_tau': copula.beta(2, 1), 

382 'beta_mu': copula.invgamma(1, 1), 

383 'beta_tau': copula.invgamma(1, 1), 

384 'z_0': copula.uniform(0, 1), 

385 } 

386 ) 

387 

388 # remove explicit mean parameter if it's baked into the Gaussian process 

389 if marginalize_mean: 1ab

390 hyperprior.pop('m') 1ab

391 

392 # add data transformation and user hyperparameters 

393 def update_hyperparams(new, newname, raises): 1ab

394 new = gvar.BufferDict(new) 1ab

395 for key in new.all_keys(): 1ab

396 if hyperprior.has_dictkey(key): 396 ↛ 397line 396 didn't jump to line 397 because the condition on line 396 was never true1ab

397 message = f'{newname} hyperparameter {key!r} overrides existing one' 

398 if raises: 

399 raise ValueError(message) 

400 else: 

401 warnings.warn(message) 

402 hyperprior.update(new) 1ab

403 

404 update_hyperparams(transfhp, 'data transformation', True) 1ab

405 # the hypers handed by _get_transf are not allowed to override 

406 update_hyperparams(otherhp, 'user', False) 1ab

407 

408 # GP factory 

409 def gpfactory( 1ab

410 hp, *, z, i_mu, i_tau, pihat, x_aux, weights, splits_mu, splits_tau, **_ 

411 ): 

412 

413 kw_overridable = dict(maxd=10, reset=[2, 4, 6, 8], intercept=False) 1ab

414 kw_not_overridable = dict(indices=True) 1ab

415 

416 gp = _GP.GP(checkpos=False, checksym=False, solver='chol') 1ab

417 

418 splits = dict(mu=splits_mu, tau=splits_tau) 1ab

419 for name, kernelkw in dict(mu=kernelkw_mu, tau=kernelkw_tau).items(): 1ab

420 kw = dict( 1ab

421 alpha=hp[f'alpha_{name}'], 

422 beta=hp[f'beta_{name}'], 

423 dim=name, 

424 splits=splits[name], 

425 **kw_overridable, 

426 ) 

427 kw.update(kernelkw) 1ab

428 kernel = _kernels.BART(**kw, **kw_not_overridable) 1ab

429 kernel *= hp[f'lambda_{name}'] ** 2 1ab

430 

431 gp = gp.defproc(name, kernel) 1ab

432 

433 if 'm' in hp: 1ab

434 kernel_mean = 0 * _kernels.Constant() 1b

435 else: 

436 kernel_mean = _kernels.Constant() 1ab

437 gp = gp.defproc('m', kernel_mean) 1ab

438 

439 if gpaux is None: 1ab

440 gp = gp.defproc('aux', 0 * _kernels.Constant()) 1ab

441 else: 

442 gp = gpaux(hp, gp) 1b

443 

444 gp = gp.deflintransf( 1ab

445 gp.DefaultProcess, 

446 lambda m, mu, tau, aux: ( 

447 lambda x: m(x) + mu(x) + tau(x) * (x['z'] - hp['z_0']) + aux(x) 

448 ), 

449 ['m', 'mu', 'tau', 'aux'], 

450 ) 

451 

452 x = self._join_points(True, z, i_mu, i_tau, pihat, x_aux) 1ab

453 gp = gp.addx(x, 'trainmean') 1ab

454 errcov = self._error_cov(hp, weights, x) 1ab

455 return gp.addcov(errcov, 'trainnoise').addtransf( 1ab

456 {'trainmean': 1, 'trainnoise': 1}, 'train' 

457 ) 

458 

459 # data factory 

460 def data(hp, *, y, **_): 1ab

461 return {'train': from_data(hp, y) - hp.get('m', 0)} 1ab

462 

463 # fit hyperparameters 

464 options = dict( 1ab

465 verbosity=3, 

466 minkw=dict(method='l-bfgs-b', options=dict(maxls=4, maxiter=100)), 

467 mlkw=dict(epsrel=0), 

468 forward=True, 

469 gpfactorykw=dict( 

470 y=y, 

471 z=z, 

472 i_mu=i_mu, 

473 i_tau=i_tau, 

474 pihat=pihat, 

475 x_aux=x_aux, 

476 weights=weights, 

477 splits_mu=splits_mu, 

478 splits_tau=splits_tau, 

479 ), 

480 additional_loss=transfloss, 

481 ) 

482 options.update(fitkw) 1ab

483 fit = _fit.empbayes_fit(hyperprior, gpfactory, data, **options) 1ab

484 

485 # extract hyperparameters from minimization result 

486 self.m = fit.p.get('m', 0) 1ab

487 self.sigma = gvar.sqrt(fit.p['sigma^2']) 1ab

488 self.lambda_mu = fit.p['lambda_mu'] 1ab

489 self.lambda_tau = fit.p['lambda_tau'] 1ab

490 self.alpha_mu = fit.p['alpha_mu'] 1ab

491 self.alpha_tau = fit.p['alpha_tau'] 1ab

492 self.beta_mu = fit.p['beta_mu'] 1ab

493 self.beta_tau = fit.p['beta_tau'] 1ab

494 self.z_0 = fit.p['z_0'] 1ab

495 

496 # save other attributes 

497 self.fit = fit 1ab

498 self._from_data = from_data 1ab

499 self._to_data = to_data 1ab

500 

501 def _append_pihat(self, x_mu, x_tau, pihat): 1cab

502 ip = self._include_pi 1ab

503 if ip in {'mu', 'both'}: 1ab

504 x_mu = _array.StructuredArray.from_dict(dict(x=x_mu, pihat=pihat)) 1ab

505 if x_tau is not None and ip in {'tau', 'both'}: 1ab

506 x_tau = _array.StructuredArray.from_dict(dict(x=x_tau, pihat=pihat)) 1b

507 return x_mu, x_tau 1ab

508 

509 @staticmethod 1cab

510 def _join_points(train, z, i_mu, i_tau, pihat, x_aux): 1cab

511 """Join covariates into a single StructuredArray.""" 

512 columns = dict( 1ab

513 train=jnp.broadcast_to(bool(train), z.shape), 

514 i=jnp.arange(z.size).reshape(z.shape), 

515 z=z, 

516 mu=i_mu, 

517 tau=i_mu if i_tau is None else i_tau, 

518 pihat=pihat, 

519 ) 

520 if x_aux is not None: 1ab

521 columns.update(aux=x_aux) 1b

522 return _array.StructuredArray.from_dict(columns) 1ab

523 

524 @staticmethod 1cab

525 def _error_cov(hp, weights, x): 1cab

526 """Fill error covariance matrix.""" 

527 if weights is None: 527 ↛ 530line 527 didn't jump to line 530 because the condition on line 527 was always true1ab

528 error_var = jnp.broadcast_to(hp['sigma^2'], len(x)) 1ab

529 else: 

530 error_var = hp['sigma^2'] / weights 

531 return jnp.diag(error_var) 1ab

532 

533 def _gethp(self, hp, rng): 1cab

534 if not isinstance(hp, str): 534 ↛ 535line 534 didn't jump to line 535 because the condition on line 534 was never true1ab

535 return hp 

536 elif hp == 'map': 1ab

537 return self.fit.pmean 1ab

538 elif hp == 'sample': 538 ↛ 541line 538 didn't jump to line 541 because the condition on line 538 was always true1ab

539 return _fastraniter.sample(self.fit.pmean, self.fit.pcov, rng=rng) 1ab

540 else: 

541 raise KeyError(hp) 

542 

543 def gp( 1cab

544 self, 

545 *, 

546 hp='map', 

547 z=None, 

548 x_mu=None, 

549 x_tau=None, 

550 pihat=None, 

551 x_aux=None, 

552 weights=None, 

553 rng=None, 

554 ): 

555 """ 

556 Create a Gaussian process with the fitted hyperparameters. 

557 

558 Parameters 

559 ---------- 

560 hp : str or dict 

561 The hyperparameters to use. If ``'map'`` (default), use the marginal 

562 maximum a posteriori. If ``'sample'``, sample hyperparameters from 

563 the posterior. If a dict, use the given hyperparameters. 

564 z : (m,) array, series or dataframe, optional 

565 Treatment status at test points. If specified, also `x_mu`, `pihat`, 

566 `x_tau` and `x_aux` (the latter two if and only also specified at 

567 initialization) must be specified. 

568 x_mu : (m, p) array, series or dataframe, optional 

569 Control model covariates at test points. 

570 x_tau : (m, q) array, series or dataframe, optional 

571 Moderating model covariates at test points. 

572 pihat : (m,) array, series or dataframe, optional 

573 Estimated propensity score at test points. 

574 x_aux : (m, k) array, series or dataframe, optional 

575 Additional covariates for the ``'aux'`` process. 

576 weights : (m,) array, series or dataframe, optional 

577 Weights for the error variance on the test points. 

578 rng : numpy.random.Generator, optional 

579 Random number generator, used if ``hp == 'sample'``. 

580 

581 Returns 

582 ------- 

583 gp : GP 

584 A centered Gaussian process object. To add the mean, use the `m` 

585 attribute of the `bcf` object. The keys of the GP are ``'@mean'``, 

586 ``'@noise'``, and ``'@'``, where the "@" stands either for 'train' 

587 or 'test', and @ = @mean + @noise. 

588 

589 This Gaussian process is defined on the transformed data ``eta``. 

590 """ 

591 hp = self._gethp(hp, rng) 

592 return self._gp(hp, z, x_mu, x_tau, pihat, x_aux, weights, self.fit.gpfactorykw) 

593 

594 def _gp(self, hp, z, x_mu, x_tau, pihat, x_aux, weights, gpfactorykw): 1cab

595 """ 

596 Create the GP object. 

597 

598 This function must work both if the arguments are user-provided and 

599 need to be checked and converted to standard format, or if they are 

600 traced jax values. 

601 """ 

602 # create GP object 

603 gp = self.fit.gpfactory(hp, **gpfactorykw) 1ab

604 

605 # add test points 

606 if z is not None: 1ab

607 # check presence/absence of arguments is coherent 

608 self._check_coherent_covariates(z, x_mu, x_tau, pihat, x_aux) 1a

609 

610 # check treatment and propensity score 

611 z = self._to_vector(z) 1a

612 pihat = self._to_vector(pihat) 1a

613 assert pihat.shape == z.shape 1a

614 

615 # check weights 

616 if weights is not None: 616 ↛ 617line 616 didn't jump to line 617 because the condition on line 616 was never true1a

617 weights = self._to_vector(weights) 

618 assert weights.shape == z.shape 

619 

620 # add propensity score to covariates 

621 x_mu = self._to_structured(x_mu) 1a

622 assert x_mu.shape == z.shape 1a

623 if x_tau is not None: 623 ↛ 624line 623 didn't jump to line 624 because the condition on line 623 was never true1a

624 x_tau = self._to_structured(x_tau) 

625 assert x_tau.shape == z.shape 

626 x_mu, x_tau = self._append_pihat(x_mu, x_tau, pihat) 1a

627 

628 # convert covariates to indices 

629 i_mu = self._toindices(x_mu, gpfactorykw['splits_mu']) 1a

630 assert i_mu.dtype == gpfactorykw['i_mu'].dtype 1a

631 if x_tau is not None: 631 ↛ 632line 631 didn't jump to line 632 because the condition on line 631 was never true1a

632 i_tau = self._toindices(x_tau, gpfactorykw['splits_tau']) 

633 assert i_tau.dtype == gpfactorykw['i_tau'].dtype 

634 else: 

635 i_tau = None 1a

636 

637 # check auxiliary points 

638 if x_aux is not None: 638 ↛ 639line 638 didn't jump to line 639 because the condition on line 638 was never true1a

639 x_aux = self._to_structured(x_aux) 

640 

641 # add test points 

642 x = self._join_points(False, z, i_mu, i_tau, pihat, x_aux) 1a

643 gp = gp.addx(x, 'testmean') 1a

644 errcov = self._error_cov(hp, weights, x) 1a

645 gp = gp.addcov(errcov, 'testnoise').addtransf( 1a

646 {'testmean': 1, 'testnoise': 1}, 'test' 

647 ) 

648 

649 return gp 1ab

650 

651 def _check_coherent_covariates(self, z, x_mu, x_tau, pihat, x_aux): 1cab

652 if z is None: 1ab

653 assert x_mu is None 1b

654 assert x_tau is None 1b

655 assert pihat is None 1b

656 assert x_aux is None 1b

657 else: 

658 assert x_mu is not None 1a

659 assert pihat is not None 1a

660 train_tau = self.fit.gpfactorykw['i_tau'] 1a

661 if x_tau is None: 661 ↛ 664line 661 didn't jump to line 664 because the condition on line 661 was always true1a

662 assert train_tau is None 1a

663 else: 

664 assert train_tau is not None 

665 train_aux = self.fit.gpfactorykw['x_aux'] 1a

666 if x_aux is None: 666 ↛ 669line 666 didn't jump to line 669 because the condition on line 666 was always true1a

667 assert train_aux is None 1a

668 else: 

669 assert train_aux is not None 

670 

671 def data(self, *, hp='map', rng=None): 1cab

672 """ 

673 Get the data to be passed to `GP.pred` on a GP object returned by `gp`. 

674 

675 Parameters 

676 ---------- 

677 hp : str or dict 

678 The hyperparameters to use. If ``'map'`` (default), use the marginal 

679 maximum a posteriori. If ``'sample'``, sample hyperparameters from 

680 the posterior. If a dict, use the given hyperparameters. 

681 rng : numpy.random.Generator, optional 

682 Random number generator, used if ``hp == 'sample'``. 

683 

684 Returns 

685 ------- 

686 data : dict 

687 A dictionary representing ``eta`` in the format required by the 

688 `GP.pred` method. 

689 """ 

690 hp = self._gethp(hp, rng) 

691 return self.fit.data(hp, **self.fit.gpfactorykw) 

692 

693 def pred( # noqa: C901 1cab

694 self, 

695 *, 

696 hp='map', 

697 error=False, 

698 z=None, 

699 x_mu=None, 

700 x_tau=None, 

701 pihat=None, 

702 x_aux=None, 

703 weights=None, 

704 transformed=True, 

705 samples=None, 

706 gvars=False, 

707 rng=None, 

708 ): 

709 r""" 

710 Predict the transformed outcome at given locations. 

711 

712 Parameters 

713 ---------- 

714 hp : str or dict 

715 The hyperparameters to use. If ``'map'`` (default), use the marginal 

716 maximum a posteriori. If ``'sample'``, sample hyperparameters from 

717 the posterior. If a dict, use the given hyperparameters. 

718 error : bool, default False 

719 If ``False``, make a prediction for the latent mean. If ``True``, 

720 add the error term. 

721 z : (m,) array, series or dataframe, optional 

722 Treatment status at test points. If specified, also `x_mu`, `pihat`, 

723 `x_tau` and `x_aux` (the latter two if and only also specified at 

724 initialization) must be specified. 

725 x_mu : (m, p) array, series or dataframe, optional 

726 :math:`\mu` model covariates at test points. 

727 x_tau : (m, q) array, series or dataframe, optional 

728 :math:`\tau` model covariates at test points. 

729 pihat : (m,) array, series or dataframe, optional 

730 Estimated propensity score at test points. 

731 x_aux : (m, k) array, series or dataframe, optional 

732 Additional covariates for the ``'aux'`` process at test points. 

733 weights : (m,) array, series or dataframe, optional 

734 Weights for the error variance on the test points. 

735 transformed : bool, default True 

736 If ``True``, return the prediction on the transformed outcome 

737 :math:`\eta`, else the observable outcome :math:`y`. 

738 samples : int, optional 

739 If specified, indicates the number of samples to take from the 

740 posterior. If not, return the mean and covariance matrix of the 

741 posterior. 

742 gvars : bool, default False 

743 If ``True``, return the mean and covariance matrix of the posterior 

744 as an array of `GVar` variables. 

745 rng : numpy.random.Generator, optional 

746 Random number generator, used if ``hp == 'sample'`` or ``samples`` 

747 is not `None`. 

748 

749 Returns 

750 ------- 

751 mean, cov : (m,) and (m, m) arrays 

752 If ``samples`` is `None` and ``gvars`` is `False` (default), the 

753 mean and covariance matrix of the Normal posterior distribution 

754 over the regression function or :math:`\eta` at the specified 

755 locations. 

756 out : (m,) array of gvars 

757 If ``samples`` is `None` and ``gvars`` is `True`, the same 

758 distribution represented as an array of `~gvar.GVar` objects. 

759 sample : (samples, m) array 

760 If ``samples`` is an integer, posterior samples over either the 

761 regression function, :math:`\eta`, or :math:`y`. 

762 """ 

763 # check consistency of output choice 

764 if samples is None: 764 ↛ 765line 764 didn't jump to line 765 because the condition on line 764 was never true1ab

765 if not transformed: 

766 msg = ( 

767 'Posterior is required in analytical form ' 

768 '(samples=None) and in data space ' 

769 '(transformed=False), this is not possible as ' 

770 'the transformation model space -> data space ' 

771 'is arbitrary. Either sample the posterior or ' 

772 'get the result in model space.' 

773 ) 

774 raise ValueError(msg) 

775 else: 

776 if not transformed and not error: 776 ↛ 777line 776 didn't jump to line 777 because the condition on line 776 was never true1ab

777 msg = ( 

778 'Posterior is required in data space ' 

779 '(transformed=False) and without error term ' 

780 '(error=False), this is not possible as the ' 

781 'transformation model space -> data space ' 

782 'applies after adding the error.' 

783 ) 

784 raise ValueError(msg) 

785 assert not gvars, 'can not represent posterior samples as gvars' 1ab

786 

787 # get hyperparameters 

788 hp = self._gethp(hp, rng) 1ab

789 

790 # check presence of covariates is coherent 

791 self._check_coherent_covariates(z, x_mu, x_tau, pihat, x_aux) 1ab

792 

793 # convert all inputs to arrays compatible with jax to pass them to the 

794 # compiled implementation 

795 if z is not None: 1ab

796 z = self._to_vector(z) 1a

797 pihat = self._to_vector(pihat) 1a

798 x_mu = self._to_structured(x_mu) 1a

799 if x_tau is not None: 799 ↛ 800line 799 didn't jump to line 800 because the condition on line 799 was never true1a

800 x_tau = self._to_structured(x_tau) 

801 if x_aux is not None: 801 ↛ 802line 801 didn't jump to line 802 because the condition on line 801 was never true1a

802 x_aux = self._to_structured(x_aux) 

803 if weights is not None: 803 ↛ 804line 803 didn't jump to line 804 because the condition on line 803 was never true1ab

804 weights = self._to_vector(weights) 

805 

806 # GP regression 

807 mean, cov = self._pred( 1ab

808 hp, z, x_mu, x_tau, pihat, x_aux, weights, self.fit.gpfactorykw, bool(error) 

809 ) 

810 

811 # return Normal posterior moments 

812 if samples is None: 812 ↛ 813line 812 didn't jump to line 813 because the condition on line 812 was never true1ab

813 if gvars: 

814 return gvar.gvar(mean, cov, fast=True) 

815 else: 

816 return mean, cov 

817 

818 # sample from posterior 

819 sample = jnp.stack(list(_fastraniter.raniter(mean, cov, n=samples, rng=rng))) 1ab

820 if not transformed: 820 ↛ 822line 820 didn't jump to line 822 because the condition on line 820 was always true1ab

821 sample = self._to_data(hp, sample) 1ab

822 return sample 1ab

823 

824 @functools.cached_property 1cab

825 def _pred(self): 1cab

826 

827 @functools.partial(jit, static_argnums=(8,)) 1ab

828 def _pred(hp, z, x_mu, x_tau, pihat, x_aux, weights, gpfactorykw, error): 1ab

829 gp = self._gp(hp, z, x_mu, x_tau, pihat, x_aux, weights, gpfactorykw) 1ab

830 data = self.fit.data(hp, **gpfactorykw) 1ab

831 if z is None: 1ab

832 label = 'train' 1b

833 else: 

834 label = 'test' 1a

835 if not error: 835 ↛ 836line 835 didn't jump to line 836 because the condition on line 835 was never true1ab

836 label += 'mean' 

837 outmean, outcov = gp.predfromdata(data, label, raw=True) 1ab

838 return outmean + hp.get('m', 0), outcov 1ab

839 

840 return _pred 1ab

841 

842 def from_data(self, y, *, hp='map', rng=None): 1cab

843 r""" 

844 Transform outcomes :math:`y` to the regression variable :math:`\eta`. 

845 

846 Parameters 

847 ---------- 

848 y : (n,) array 

849 Outcomes. 

850 hp : str or dict 

851 The hyperparameters to use. If ``'map'`` (default), use the marginal 

852 maximum a posteriori. If ``'sample'``, sample hyperparameters from 

853 the posterior. If a dict, use the given hyperparameters. 

854 rng : numpy.random.Generator, optional 

855 Random number generator, used if ``hp == 'sample'``. 

856 

857 Returns 

858 ------- 

859 eta : (n,) array 

860 Transformed outcomes. 

861 """ 

862 hp = self._gethp(hp, rng) 1b

863 return self._from_data(hp, y) 1b

864 

865 def to_data(self, eta, *, hp='map', rng=None): 1cab

866 r""" 

867 Convert the regression variable :math:`\eta` to outcomes :math:`y`. 

868 

869 Parameters 

870 ---------- 

871 eta : (n,) array 

872 Transformed outcomes. 

873 hp : str or dict 

874 The hyperparameters to use. If ``'map'`` (default), use the marginal 

875 maximum a posteriori. If ``'sample'``, sample hyperparameters from 

876 the posterior. If a dict, use the given hyperparameters. 

877 rng : numpy.random.Generator, optional 

878 Random number generator, used if ``hp == 'sample'``. 

879 

880 Returns 

881 ------- 

882 y : (n,) array 

883 Outcomes. 

884 """ 

885 hp = self._gethp(hp, rng) 1b

886 return self._to_data(hp, eta) 1b

887 

888 @classmethod 1cab

889 def _to_structured(cls, x, *, check_numerical=True): 1cab

890 

891 # convert to StructuredArray 

892 if hasattr(x, 'columns'): 1ab

893 x = _array.StructuredArray.from_dataframe(x) 1a

894 elif hasattr(x, 'to_numpy'): 894 ↛ 895line 894 didn't jump to line 895 because the condition on line 894 was never true1ab

895 x = _array.StructuredArray.from_dict( 

896 {'f0' if x.name is None else x.name: x.to_numpy()} 

897 ) 

898 elif x.dtype.names is None: 1ab

899 x = _array.unstructured_to_structured(x) 1b

900 else: 

901 x = _array.StructuredArray(x) 1a

902 

903 # check fields are numerical, for BART 

904 if check_numerical: 904 ↛ 915line 904 didn't jump to line 915 because the condition on line 904 was always true1ab

905 assert x.ndim == 1 1ab

906 assert x.size > len(x.dtype) 1ab

907 

908 def check_numerical(path, dtype): 1ab

909 if not numpy.issubdtype(dtype, numpy.number): 909 ↛ 910line 909 didn't jump to line 910 because the condition on line 909 was never true1ab

910 msg = f'covariate `{path}` is not numerical' 

911 raise TypeError(msg) 

912 

913 cls._walk_dtype(x.dtype, check_numerical) 1ab

914 

915 return x 1ab

916 

917 @staticmethod 1cab

918 def _to_vector(x): 1cab

919 if hasattr(x, 'columns'): # dataframe 919 ↛ 920line 919 didn't jump to line 920 because the condition on line 919 was never true1ab

920 x = x.to_numpy().squeeze(axis=1) 

921 elif hasattr(x, 'to_numpy'): # series (dataframe column) 1ab

922 x = x.to_numpy() 1a

923 x = jnp.asarray(x) 1ab

924 if x.ndim != 1: 924 ↛ 925line 924 didn't jump to line 925 because the condition on line 924 was never true1ab

925 msg = f'array is not 1d vector, ndim={x.ndim}' 

926 raise ValueError(msg) 

927 return x 1ab

928 

929 @classmethod 1cab

930 def _walk_dtype(cls, dtype, task, path=None): 1cab

931 if dtype.names is None: 1ab

932 task(path, dtype) 1ab

933 else: 

934 for name in dtype.names: 1ab

935 subpath = name if path is None else path + ':' + name 1ab

936 cls._walk_dtype(dtype[name], task, subpath) 1ab

937 

938 @staticmethod 1cab

939 def _toindices(x, splits): 1cab

940 ix = _kernels.BART.indices_from_coord(x, splits) 1ab

941 dtype = cast(x.dtype, ix.dtype) 1ab

942 return _array.unstructured_to_structured(ix, dtype=dtype) 1ab

943 

944 def __repr__(self): 1cab

945 

946 with _gvarext.gvar_format(): 1a

947 if hasattr(self.m, 'sdev'): 947 ↛ 948line 947 didn't jump to line 948 because the condition on line 947 was never true1a

948 m = str(self.m) 

949 else: 

950 m = f'{self.m:.3g}' 1a

951 

952 n = self.fit.gpfactorykw['y'].size 1a

953 p_mu = _array._nd(self.fit.gpfactorykw['i_mu']['x'].dtype) 1a

954 x_tau = self.fit.gpfactorykw['i_tau'] 1a

955 x_aux = self.fit.gpfactorykw['x_aux'] 1a

956 

957 out = f"""\ 1a

958Data: 

959 n = {n}""" 

960 

961 if x_tau is None: 961 ↛ 965line 961 didn't jump to line 965 because the condition on line 961 was always true1a

962 out += f""" 1a

963 p = {p_mu}""" 

964 else: 

965 p_tau = _array._nd(x_tau['x'].dtype) 

966 out += f""" 

967 p_mu/tau = {p_mu}, {p_tau}""" 

968 

969 if x_aux is not None: 969 ↛ 970line 969 didn't jump to line 970 because the condition on line 969 was never true1a

970 p_aux = _array._nd(x_aux['x'].dtype) 

971 out += f""" 

972 p_aux = {p_aux}""" 

973 

974 out += f""" 1a

975Hyperparameter posterior: 

976 m = {m} 

977 z_0 = {self.z_0} 

978 alpha_mu/tau = {self.alpha_mu} {self.alpha_tau} 

979 beta_mu/tau = {self.beta_mu} {self.beta_tau} 

980 lambda_mu/tau = {self.lambda_mu} {self.lambda_tau}""" 

981 

982 weights = self.fit.gpfactorykw['weights'] 1a

983 if weights is None: 983 ↛ 988line 983 didn't jump to line 988 because the condition on line 983 was always true1a

984 out += f""" 1a

985 sigma = {self.sigma}""" 

986 

987 else: 

988 weights = numpy.array(weights) # to avoid jax taking over the ops 

989 avgsigma = numpy.sqrt(numpy.mean(self.sigma**2 / weights)) 

990 out += f""" 

991 sqrt(mean(sigma^2/w)) = {avgsigma} 

992 sigma = {self.sigma}""" 

993 

994 out += """ 1a

995Meaning of hyperparameters: 

996 mu(x) = reference outcome level 

997 tau(x) = effect of the treatment 

998 z_0 in (0, 1): reference treatment level 

999 z_0 -> 0: mu is the model of the untreated 

1000 z_0 -> 1: mu is the model of the treated 

1001 alpha in (0, 1) 

1002 alpha -> 0: constant function 

1003 alpha -> 1: no constraints on the function 

1004 beta in (0, ∞) 

1005 beta -> 0: no constraints on the function 

1006 beta -> ∞: no interactions, f(x) = f1(x1) + f2(x2) + ... 

1007 lambda in (0, ∞): standard deviation of function 

1008 lambda small: confident extrapolation 

1009 lambda large: conservative extrapolation 

1010 sigma in (0, ∞): standard deviation of i.i.d. error""" 

1011 

1012 return _utils.top_bottom_rule('BCF', out) 1a

1013 

1014 def _get_transf(self, *, transf, y, weights): # noqa: C901 1cab

1015 

1016 from_datas = [] 1ab

1017 to_datas = [] 1ab

1018 hypers = {} 1ab

1019 

1020 if transf is None: 1020 ↛ 1021line 1020 didn't jump to line 1021 because the condition on line 1020 was never true1ab

1021 transf = [] 

1022 elif isinstance(transf, list): 1ab

1023 name = lambda n: f'transf{i}_{n}' 1ab

1024 else: 

1025 name = lambda n: n 1b

1026 transf = [transf] 1b

1027 

1028 for i, tr in enumerate(transf): 1ab

1029 hyper = {} 1ab

1030 

1031 if not isinstance(tr, str): 1031 ↛ 1032line 1031 didn't jump to line 1032 because the condition on line 1031 was never true1ab

1032 from_data, to_data = tr 

1033 

1034 elif tr == 'standardize': 1ab

1035 if i > 0: 1035 ↛ 1036line 1035 didn't jump to line 1036 because the condition on line 1035 was never true1ab

1036 warnings.warn( 

1037 'standardization applied after other ' 

1038 'transformations: standardization always uses the ' 

1039 'initial data mean and standard deviation, so it may ' 

1040 'not work as intended' 

1041 ) 

1042 

1043 # It's not possible to overcome this limitation if one wants 

1044 # to stick to transformations that act on one point at a 

1045 # time to make them generalizable out of sample. 

1046 

1047 if weights is None: 1047 ↛ 1051line 1047 didn't jump to line 1051 because the condition on line 1047 was always true1ab

1048 loc = jnp.mean(y) 1ab

1049 scale = jnp.std(y) 1ab

1050 else: 

1051 loc = jnp.average(y, weights=weights) 

1052 scale = jnp.sqrt(jnp.average((y - loc) ** 2, weights=weights)) 

1053 

1054 def from_data(_hp, y, loc=loc, scale=scale): 1ab

1055 return (y - loc) / scale 1ab

1056 

1057 def to_data(_hp, eta, loc=loc, scale=scale): 1ab

1058 return loc + scale * eta 1ab

1059 

1060 elif tr == 'yeojohnson': 1060 ↛ 1073line 1060 didn't jump to line 1073 because the condition on line 1060 was always true1ab

1061 # bind the key now, `name` depends on the loop variable `i` 

1062 key = name('lambda_yj') 1ab

1063 

1064 def from_data(hp, y, key=key): 1ab

1065 return yeojohnson(y, hp[key]) 1ab

1066 

1067 def to_data(hp, eta, key=key): 1ab

1068 return yeojohnson_inverse(eta, hp[key]) 1ab

1069 

1070 hyper[key] = 2 * copula.beta(2, 2) 1ab

1071 

1072 else: 

1073 raise KeyError(tr) 

1074 

1075 from_datas.append(from_data) 1ab

1076 to_datas.append(to_data) 1ab

1077 hypers.update(hyper) 1ab

1078 

1079 if transf: 1079 ↛ 1091line 1079 didn't jump to line 1091 because the condition on line 1079 was always true1ab

1080 

1081 def from_data(hp, y): 1ab

1082 for fd in from_datas: 1ab

1083 y = fd(hp, y) 1ab

1084 return y 1ab

1085 

1086 def to_data(hp, eta): 1ab

1087 for td in reversed(to_datas): 1ab

1088 eta = td(hp, eta) 1ab

1089 return eta 1ab

1090 else: 

1091 from_data = lambda _hp, y: y # ty: ignore[conflicting-declarations] 

1092 to_data = lambda _hp, eta: eta # ty: ignore[conflicting-declarations] 

1093 

1094 from_data_grad = _jaxext.elementwise_grad(from_data, 1) 1ab

1095 

1096 def loss(hp): 1ab

1097 return -jnp.sum(jnp.log(from_data_grad(hp, y))) 1ab

1098 

1099 hypers = copula.makedict(hypers) 1ab

1100 

1101 return from_data, to_data, loss, hypers 1ab

1102 

1103 

1104def yeojohnson(x, lmbda): 1cab

1105 """Yeo-Johnson transformation with lamda != 0, 2.""" 

1106 return jnp.where( 1ab

1107 x >= 0, 

1108 (jnp.power(x + 1, lmbda) - 1) / lmbda, 

1109 -((jnp.power(-x + 1, 2 - lmbda) - 1) / (2 - lmbda)), 

1110 ) 

1111 

1112 

1113def yeojohnson_inverse(y, lmbda): 1cab

1114 return jnp.where( 1ab

1115 y >= 0, 

1116 jnp.power(y * lmbda + 1, 1 / lmbda) - 1, 

1117 -jnp.power(-(2 - lmbda) * y + 1, 1 / (2 - lmbda)) + 1, 

1118 )