Coverage for pybmc/inference_utils.py: 97%

118 statements  

« prev     ^ index     » next       coverage.py v7.10.0, created at 2026-07-20 21:03 +0000

1import numpy as np 

2 

3from .error_models import VARIANCE_FLOOR 

4from .rng import get_rng 

5 

6 

7def gibbs_sampler(y, X, iterations, prior_info=None, burn=5000, seed=None): 

8 """ 

9 Gibbs sampler for the homoscedastic (constant-variance) model. 

10 

11 The homoscedastic model is the constant-only special case of the 

12 heteroscedastic likelihood (``sigma_i^2 = sigma^2`` for every point), 

13 so this is a thin wrapper around `gibbs_sampler_heteroscedastic` 

14 with a constant-only variance basis. There is no separate 

15 homoscedastic likelihood implementation. 

16 

17 Args: 

18 y (numpy.ndarray): Response vector (centered). 

19 X (numpy.ndarray): Design matrix. 

20 iterations (int): Number of retained posterior samples. 

21 prior_info (dict, optional): Optional priors with keys 

22 ``'b_mean_prior'``, ``'b_mean_cov'`` (Gaussian prior on the 

23 coefficients) and ``'prior_spec'`` (Gamma ``(shape, scale)`` 

24 prior on ``sigma^2``). See `gibbs_sampler_heteroscedastic`. 

25 burn (int, optional): Burn-in iterations (default: 5000). 

26 seed (int | numpy.random.Generator | None, optional): Seed for 

27 the sampler. None (default) draws from the shared 

28 package-wide generator (see :mod:`pybmc.rng`). 

29 

30 Returns: 

31 numpy.ndarray: Posterior samples ``[beta_1..beta_k, sigma^2]``. 

32 """ 

33 prior_info = prior_info or {} 

34 variance_basis = np.ones((len(y), 1)) 

35 samples, _ = gibbs_sampler_heteroscedastic( 

36 y, 

37 X, 

38 variance_basis, 

39 iterations, 

40 burn=burn, 

41 prior_spec=prior_info.get("prior_spec"), 

42 b_mean_prior=prior_info.get("b_mean_prior"), 

43 b_mean_cov=prior_info.get("b_mean_cov"), 

44 seed=seed, 

45 ) 

46 return samples 

47 

48 

49def gibbs_sampler_simplex( 

50 y, X, Vt_hat, S_hat, iterations, prior_info, burn=10000, stepsize=0.001, 

51 seed=None, 

52): 

53 """ 

54 Performs Gibbs sampling with simplex constraints on model weights. 

55 

56 The likelihood is the same homoscedastic Gaussian model used by the 

57 unconstrained sampler (``y_i ~ N(X_i . b, sigma^2)``); only the 

58 weight constraint differs. 

59 

60 Args: 

61 y (numpy.ndarray): Centered response vector. 

62 X (numpy.ndarray): Design matrix of principal components. 

63 Vt_hat (numpy.ndarray): Normalized right singular vectors. 

64 S_hat (numpy.ndarray): Singular values. 

65 iterations (int): Number of sampling iterations. 

66 prior_info (list[float]): `[nu0, sigma20]` - prior parameters for variance. 

67 burn (int, optional): Burn-in iterations (default: 10000). 

68 stepsize (float, optional): Proposal step size (default: 0.001). 

69 seed (int | numpy.random.Generator | None, optional): Seed for 

70 the sampler. None (default) draws from the shared 

71 package-wide generator (see :mod:`pybmc.rng`). 

72 

73 Returns: 

74 numpy.ndarray: Posterior samples ``[beta_1..beta_k, sigma^2]``. 

75 """ 

76 rng = get_rng(seed) 

77 

78 bias0 = np.full(len(Vt_hat.T), 1 / len(Vt_hat.T)) 

79 nu0, sigma20 = prior_info 

80 cov_matrix_step = np.diag(S_hat**2 * stepsize**2) 

81 n = len(y) 

82 b_current = np.full(len(X.T), 0) 

83 supermodel_current = X.dot(b_current) 

84 residuals_current = y - supermodel_current 

85 ssr_current = np.sum(residuals_current**2) 

86 sigma2 = ssr_current / len(residuals_current) 

87 samples = [] 

88 acceptance = 0 

89 

90 # Validate inputs 

91 if burn < 0: 

92 raise ValueError("Burn-in iterations must be non-negative.") 

93 if stepsize <= 0: 

94 raise ValueError("Stepsize must be positive.") 

95 

96 for i in range(burn + iterations): 

97 b_proposed = rng.multivariate_normal(b_current, cov_matrix_step) 

98 omegas_proposed = np.dot(b_proposed, Vt_hat) + bias0 

99 

100 # Skip proposals with negative weights 

101 if not np.any(omegas_proposed < 0): 

102 supermodel_proposed = X.dot(b_proposed) 

103 residuals_proposed = y - supermodel_proposed 

104 ssr_proposed = np.sum(residuals_proposed**2) 

105 # Gaussian likelihood ratio at fixed sigma^2: 

106 # exp(-(SSR' - SSR) / (2 sigma^2)). 

107 acceptance_prob = min( 

108 1, 

109 np.exp((ssr_current - ssr_proposed) / (2 * sigma2)), 

110 ) 

111 if rng.uniform() < acceptance_prob: 

112 b_current = np.copy(b_proposed) 

113 ssr_current = ssr_proposed 

114 if i >= burn: 

115 acceptance += 1 

116 

117 # Sample sigma^2 from its inverse-gamma full conditional 

118 # (positive by construction). 

119 shape_post = (nu0 + n) / 2.0 

120 scale_post = (nu0 * sigma20 + ssr_current) / 2.0 

121 sigma2 = 1 / rng.gamma(shape_post, 1 / scale_post) 

122 if i >= burn: 

123 samples.append(np.append(b_current, sigma2)) 

124 

125 return np.array(samples) 

126 

127 

128def gibbs_sampler_heteroscedastic( 

129 y, 

130 X, 

131 variance_basis, 

132 iterations, 

133 burn=5000, 

134 proposal_scales=None, 

135 init_params=None, 

136 prior_spec=None, 

137 b_mean_prior=None, 

138 b_mean_cov=None, 

139 adapt_proposal=True, 

140 target_acceptance=0.25, 

141 seed=None, 

142): 

143 """ 

144 Gibbs-within-Metropolis sampler for the shared error-model likelihood. 

145 

146 The regression model is ``y_i ~ N(X_i . b, sigma_i^2)`` with a 

147 per-point variance that is linear in basis functions of the 

148 heteroscedasticity metrics: 

149 

150 ``sigma_i^2 = variance_basis[i] . theta`` 

151 

152 The homoscedastic model is the special case where the basis is a 

153 single column of ones, so ``theta = [sigma^2]`` is the constant 

154 variance. All error models share this one likelihood. 

155 

156 The coefficients ``b`` are updated with a conjugate (weighted 

157 least-squares) Gibbs step; the variance parameters ``theta`` with a 

158 positivity-constrained Gaussian random-walk Metropolis-Hastings step 

159 under Gamma priors. Because ``theta`` is kept strictly positive by 

160 the MH step and ``variance_basis`` is validated to be non-negative 

161 with a leading column of ones, every per-point variance satisfies 

162 ``sigma_i^2 >= theta_1 > 0`` by construction — no variance flooring 

163 is applied during sampling. The only floor is on the *initial* 

164 constant term, which is estimated from the data residuals and could 

165 otherwise be exactly zero for a perfectly fitting model. 

166 

167 Args: 

168 y (numpy.ndarray): Centered response vector, shape ``(n,)``. 

169 X (numpy.ndarray): Design matrix (principal components), shape 

170 ``(n, k)``. 

171 variance_basis (numpy.ndarray): Variance design matrix ``phi`` 

172 with a leading column of ones and no negative entries, shape 

173 ``(n, p)``. See :func:`pybmc.error_models.variance_basis`. 

174 iterations (int): Number of retained posterior samples. 

175 burn (int, optional): Burn-in iterations discarded before 

176 retention (default: 5000). 

177 proposal_scales (list[float], optional): Diagonal of the Gaussian 

178 random-walk proposal covariance for ``theta`` (length p). 

179 Defaults to ``[1e-2, 1e-3, ..., 1e-3]``. 

180 init_params (list[float], optional): Initial values for the 

181 non-constant entries of ``theta`` (length p - 1, strictly 

182 positive). The constant term starts at the OLS residual 

183 variance. Defaults to 0.01 for every term. 

184 prior_spec (list[tuple[float, float]], optional): Gamma prior 

185 ``(shape, scale)`` for each entry of ``theta`` (length p). 

186 Defaults to ``(2, 10)`` for every parameter. 

187 b_mean_prior (numpy.ndarray, optional): Prior mean for ``b`` 

188 (default zeros). 

189 b_mean_cov (numpy.ndarray, optional): Prior covariance for ``b`` 

190 (default ``1e6 * I``, i.e. weakly informative). 

191 adapt_proposal (bool, optional): If True (default), rescale the 

192 proposal covariance during burn-in toward 

193 ``target_acceptance``, so the fixed defaults work across 

194 data scales. Adaptation stops at the end of burn-in, which 

195 preserves detailed balance for the retained samples. 

196 target_acceptance (float, optional): Acceptance rate targeted by 

197 the burn-in adaptation (default: 0.25). 

198 seed (int | numpy.random.Generator | None, optional): Seed for 

199 the sampler. None (default) draws from the shared 

200 package-wide generator (see :mod:`pybmc.rng`). 

201 

202 Returns: 

203 tuple[numpy.ndarray, float]: 

204 - `samples` (numpy.ndarray): Posterior samples 

205 ``[b_1..b_k, theta_1..theta_p]``, shape 

206 ``(iterations, k + p)``. 

207 - `acceptance_rate` (float): Post-burn-in MH acceptance rate. 

208 """ 

209 if burn < 0: 

210 raise ValueError("Burn-in iterations must be non-negative.") 

211 

212 rng = get_rng(seed) 

213 

214 n_points, n_betas = X.shape 

215 n_params = variance_basis.shape[1] 

216 

217 if not np.allclose(variance_basis[:, 0], 1.0): 

218 raise ValueError( 

219 "The first column of 'variance_basis' must be ones " 

220 "(the constant variance term)." 

221 ) 

222 if np.any(variance_basis < 0): 

223 raise ValueError( 

224 "'variance_basis' must be non-negative so that positive " 

225 "variance parameters guarantee positive variances." 

226 ) 

227 

228 if proposal_scales is None: 

229 proposal_scales = [1e-2] + [1e-3] * (n_params - 1) 

230 if len(proposal_scales) != n_params: 

231 raise ValueError( 

232 f"'proposal_scales' must have length {n_params} " 

233 f"(got {len(proposal_scales)})." 

234 ) 

235 proposal_cov = np.diag(np.asarray(proposal_scales, dtype=float)) 

236 

237 if prior_spec is None: 

238 prior_spec = [(2, 10)] * n_params 

239 if len(prior_spec) != n_params: 

240 raise ValueError( 

241 f"'prior_spec' must have length {n_params} " 

242 f"(got {len(prior_spec)})." 

243 ) 

244 prior_shapes = np.array([shape for shape, _ in prior_spec], dtype=float) 

245 prior_scales = np.array([scale for _, scale in prior_spec], dtype=float) 

246 

247 def log_likelihood(residuals, sigma2): 

248 return -0.5 * float( 

249 np.sum(np.log(2.0 * np.pi * sigma2) + residuals**2 / sigma2) 

250 ) 

251 

252 def log_prior(theta): 

253 # Gamma log-density without the normalization constant, which 

254 # cancels in the Metropolis-Hastings ratio. 

255 return float( 

256 np.sum((prior_shapes - 1.0) * np.log(theta) - theta / prior_scales) 

257 ) 

258 

259 if b_mean_prior is None: 

260 b_mean_prior = np.zeros(n_betas) 

261 if b_mean_cov is None: 

262 b_mean_cov = np.eye(n_betas) * 1e6 

263 b_mean_cov_inv = np.linalg.inv(b_mean_cov) 

264 

265 b_current = np.linalg.lstsq(X, y, rcond=None)[0] 

266 if init_params is None: 

267 init_params = [0.01] * (n_params - 1) 

268 if len(init_params) != n_params - 1: 

269 raise ValueError( 

270 f"'init_params' must have length {n_params - 1} " 

271 f"(got {len(init_params)})." 

272 ) 

273 if np.any(np.asarray(init_params, dtype=float) <= 0): 

274 raise ValueError("'init_params' must be strictly positive.") 

275 theta_current = np.concatenate( 

276 [[max(np.var(y - X @ b_current), VARIANCE_FLOOR)], init_params] 

277 ) 

278 

279 samples = [] 

280 accept_count = 0 

281 

282 # Burn-in adaptation of the proposal scale (Robbins-Monro style): 

283 # every `adapt_interval` iterations the covariance is rescaled toward 

284 # the target acceptance rate, then frozen for the sampling phase. 

285 proposal_scale_factor = 1.0 

286 adapt_interval = 100 

287 accept_recent = 0 

288 

289 for i in range(burn + iterations): 

290 # --- Gibbs step: b | theta (weighted least squares) --- 

291 sigma2 = variance_basis @ theta_current 

292 Xw = X / sigma2[:, None] 

293 b_post_cov = np.linalg.inv( 

294 X.T @ Xw + b_mean_cov_inv + np.eye(n_betas) * 1e-6 

295 ) 

296 b_post_mean = b_post_cov @ ( 

297 Xw.T @ y + b_mean_cov_inv @ b_mean_prior 

298 ) 

299 b_current = rng.multivariate_normal(b_post_mean, b_post_cov) 

300 

301 # --- MH step: theta | b (positivity-constrained random walk) --- 

302 residuals = y - X @ b_current 

303 theta_proposed = np.atleast_1d( 

304 rng.multivariate_normal( 

305 theta_current, proposal_cov * proposal_scale_factor**2 

306 ) 

307 ) 

308 if np.all(theta_proposed > 0): 

309 sigma2_proposed = variance_basis @ theta_proposed 

310 log_ratio = ( 

311 log_likelihood(residuals, sigma2_proposed) 

312 + log_prior(theta_proposed) 

313 ) - ( 

314 log_likelihood(residuals, sigma2) 

315 + log_prior(theta_current) 

316 ) 

317 if np.log(rng.uniform()) < log_ratio: 

318 theta_current = theta_proposed 

319 if i >= burn: 

320 accept_count += 1 

321 else: 

322 accept_recent += 1 

323 

324 if adapt_proposal and i < burn and (i + 1) % adapt_interval == 0: 

325 recent_rate = accept_recent / adapt_interval 

326 proposal_scale_factor *= np.exp(recent_rate - target_acceptance) 

327 proposal_scale_factor = float( 

328 np.clip(proposal_scale_factor, 1e-6, 1e6) 

329 ) 

330 accept_recent = 0 

331 

332 if i >= burn: 

333 samples.append(np.concatenate([b_current, theta_current])) 

334 

335 acceptance_rate = accept_count / max(iterations, 1) 

336 return np.array(samples), acceptance_rate 

337 

338 

339def USVt_hat_extraction(U, S, Vt, components_kept): 

340 """ 

341 Extracts reduced-dimensionality matrices from SVD results. 

342 

343 Args: 

344 U (numpy.ndarray): Left singular vectors. 

345 S (numpy.ndarray): Singular values. 

346 Vt (numpy.ndarray): Right singular vectors (transposed). 

347 components_kept (int): Number of components to retain. 

348 

349 Returns: 

350 tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray]: 

351 - `U_hat` (numpy.ndarray): Reduced left singular vectors. 

352 - `S_hat` (numpy.ndarray): Retained singular values. 

353 - `Vt_hat` (numpy.ndarray): Normalized right singular vectors. 

354 - `Vt_hat_normalized` (numpy.ndarray): Original right singular vectors. 

355 """ 

356 U_hat = np.array([U.T[i] for i in range(components_kept)]).T 

357 S_hat = S[:components_kept] 

358 Vt_hat = np.array([Vt[i] / S[i] for i in range(components_kept)]) 

359 Vt_hat_normalized = np.array([Vt[i] for i in range(components_kept)]) 

360 return U_hat, S_hat, Vt_hat, Vt_hat_normalized