Coverage for pybmc/error_models.py: 100%

60 statements  

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

1"""Heteroscedastic error models for Bayesian model combination. 

2 

3All error models share the mean structure of the orthogonalized BMC 

4regression and differ only in how the noise variance depends on the 

5data point: 

6 

7============================= ==================================================== 

8name sigma_i^2 

9============================= ==================================================== 

10``homoscedastic`` sigma^2 (constant) 

11``hetero_pc_dist`` alpha + beta * d_i 

12``hetero_model_var`` alpha + beta * v_i 

13``hetero_pc_dist_quad`` alpha + beta_1 d_i + beta_2 d_i^2 

14``hetero_model_var_quad`` alpha + beta_1 v_i + beta_2 v_i^2 

15``hetero_combined_linear`` alpha + beta_d d_i + beta_m v_i 

16``hetero_combined_quadratic`` alpha + beta_d1 d_i + beta_d2 d_i^2 

17 + beta_m1 v_i + beta_m2 v_i^2 

18============================= ==================================================== 

19 

20with two physics-informed, per-point metrics: 

21 

22- ``pc_dist`` (``d``): Euclidean distance from the training-data centroid 

23 in principal-component space — grows away from the fitted region. 

24- ``model_var`` (``v``): variance among the individual model predictions — 

25 grows where the models disagree. 

26 

27Both metrics are min-max normalized using the *training* points only, so 

28values on extrapolated points can exceed 1. 

29 

30Every model — the homoscedastic one included — is parametrized on the 

31variance (sigma^2) scale and shares the single likelihood 

32``y_i ~ N(X_i . b, sigma_i^2)`` sampled by 

33:func:`pybmc.inference_utils.gibbs_sampler_heteroscedastic`; the 

34homoscedastic model is simply the case where the variance basis is the 

35constant column alone. 

36""" 

37 

38import numpy as np 

39 

40#: Floor applied to a variance only where positivity is *not* guaranteed 

41#: by construction: the sampler's initial constant term (estimated from 

42#: data residuals, which can be exactly zero for a perfect fit) and 

43#: prediction-time variances (the normalized metrics of extrapolated 

44#: points can be negative, so ``phi . theta`` can dip below zero there). 

45#: During sampling no floor is needed: the variance parameters are kept 

46#: strictly positive by the Metropolis-Hastings step and the training 

47#: variance basis is non-negative with a leading column of ones, so 

48#: every per-point variance is positive by construction. 

49VARIANCE_FLOOR = 1e-9 

50 

51#: Maps each error-model name to its variance-basis terms beyond the 

52#: constant, as ``(metric_name, power)`` tuples. ``homoscedastic`` has no 

53#: terms: its variance basis is the constant column alone, and it runs 

54#: through the same likelihood and sampler as every other error model. 

55VARIANCE_MODELS = { 

56 "homoscedastic": [], 

57 "hetero_pc_dist": [("pc_dist", 1)], 

58 "hetero_model_var": [("model_var", 1)], 

59 "hetero_pc_dist_quad": [("pc_dist", 1), ("pc_dist", 2)], 

60 "hetero_model_var_quad": [("model_var", 1), ("model_var", 2)], 

61 "hetero_combined_linear": [("pc_dist", 1), ("model_var", 1)], 

62 "hetero_combined_quadratic": [ 

63 ("pc_dist", 1), 

64 ("pc_dist", 2), 

65 ("model_var", 1), 

66 ("model_var", 2), 

67 ], 

68} 

69 

70#: Metropolis-Hastings tuning defaults per error model: random-walk 

71#: proposal scales (variances of the diagonal Gaussian proposal), initial 

72#: values for the non-constant variance parameters, and Gamma prior 

73#: ``(shape, scale)`` pairs for every variance parameter (constant term 

74#: first). 

75DEFAULT_SAMPLER_SETTINGS = { 

76 "homoscedastic": { 

77 "proposal_scales": [0.05], 

78 "init_params": [], 

79 "prior_spec": [(2, 10)], 

80 }, 

81 "hetero_pc_dist": { 

82 "proposal_scales": [0.05, 0.005], 

83 "init_params": [0.01], 

84 "prior_spec": [(2, 10), (1, 1)], 

85 }, 

86 "hetero_model_var": { 

87 "proposal_scales": [0.05, 0.005], 

88 "init_params": [0.01], 

89 "prior_spec": [(2, 10), (1, 1)], 

90 }, 

91 "hetero_pc_dist_quad": { 

92 "proposal_scales": [5e-2, 5e-3, 5e-3], 

93 "init_params": [0.01, 0.001], 

94 "prior_spec": [(2, 10), (2, 10), (2, 10)], 

95 }, 

96 "hetero_model_var_quad": { 

97 "proposal_scales": [5e-2, 5e-3, 5e-3], 

98 "init_params": [0.01, 0.001], 

99 "prior_spec": [(2, 10), (2, 10), (2, 10)], 

100 }, 

101 "hetero_combined_linear": { 

102 "proposal_scales": [1e-2, 1e-3, 1e-3], 

103 "init_params": [0.01, 0.01], 

104 "prior_spec": [(2, 10), (2, 10), (2, 10)], 

105 }, 

106 "hetero_combined_quadratic": { 

107 "proposal_scales": [1e-2, 1e-3, 1e-3, 1e-3, 1e-3], 

108 "init_params": [0.01, 0.001, 0.01, 0.001], 

109 "prior_spec": [(2, 10)] * 5, 

110 }, 

111} 

112 

113 

114def required_metrics(error_model): 

115 """ 

116 Returns the metric names an error model needs. 

117 

118 Args: 

119 error_model (str): One of the keys of `VARIANCE_MODELS`. 

120 

121 Returns: 

122 list[str]: Unique metric names (empty for ``homoscedastic``). 

123 """ 

124 if error_model not in VARIANCE_MODELS: 

125 raise ValueError( 

126 f"Unknown error model '{error_model}'. " 

127 f"Must be one of {tuple(VARIANCE_MODELS)}." 

128 ) 

129 return sorted({metric for metric, _ in VARIANCE_MODELS[error_model]}) 

130 

131 

132def variance_parameter_names(error_model): 

133 """ 

134 Returns human-readable names of the variance parameters of a model. 

135 

136 Args: 

137 error_model (str): One of the keys of `VARIANCE_MODELS`. 

138 

139 Returns: 

140 list[str]: Names such as ``['alpha', 'beta_pc_dist^1', ...]``; 

141 ``['sigma^2']`` for the homoscedastic model (whose constant term 

142 is the variance itself). 

143 """ 

144 terms = VARIANCE_MODELS[error_model] if error_model in VARIANCE_MODELS else None 

145 if terms is None: 

146 raise ValueError( 

147 f"Unknown error model '{error_model}'. " 

148 f"Must be one of {tuple(VARIANCE_MODELS)}." 

149 ) 

150 if not terms: 

151 return ["sigma^2"] 

152 return ["alpha"] + [f"beta_{metric}^{power}" for metric, power in terms] 

153 

154 

155def pc_distance_metric(model_predictions, Vt_hat, pc_centroid): 

156 """ 

157 Distance of each point from a reference centroid in PC space. 

158 

159 The principal-component coordinates of a point are obtained by 

160 projecting its raw model predictions with ``Vt_hat`` (for a 

161 row-centered SVD this is identical to projecting the centered 

162 predictions, since the right singular vectors are orthogonal to the 

163 all-ones direction). 

164 

165 Args: 

166 model_predictions (numpy.ndarray): Model outputs, shape 

167 ``(n_points, n_models)``. 

168 Vt_hat (numpy.ndarray): Scaled right singular vectors, shape 

169 ``(components_kept, n_models)``. 

170 pc_centroid (numpy.ndarray): Training centroid in PC space, 

171 shape ``(components_kept,)``. 

172 

173 Returns: 

174 numpy.ndarray: Euclidean distances, shape ``(n_points,)``. 

175 """ 

176 pc_coords = model_predictions @ Vt_hat.T 

177 return np.linalg.norm(pc_coords - pc_centroid, axis=1) 

178 

179 

180def model_variance_metric(model_predictions): 

181 """ 

182 Variance among the model predictions for each point (NaN-aware). 

183 

184 Args: 

185 model_predictions (numpy.ndarray): Model outputs, shape 

186 ``(n_points, n_models)``. 

187 

188 Returns: 

189 numpy.ndarray: Per-point variance across models, shape ``(n_points,)``. 

190 """ 

191 return np.nanvar(model_predictions, axis=1) 

192 

193 

194def variance_basis(metrics, terms): 

195 """ 

196 Builds the variance design matrix ``phi`` for a heteroscedastic model. 

197 

198 The per-point variance is ``sigma_i^2 = phi[i] . theta`` where the 

199 first column of ``phi`` is ones (the constant term ``alpha``). 

200 

201 Args: 

202 metrics (dict[str, numpy.ndarray]): Metric arrays keyed by name. 

203 terms (list[tuple[str, int]]): ``(metric_name, power)`` pairs. 

204 

205 Returns: 

206 numpy.ndarray: Basis matrix, shape ``(n_points, 1 + len(terms))``. 

207 """ 

208 n_points = len(next(iter(metrics.values()))) 

209 columns = [np.ones(n_points)] 

210 for metric, power in terms: 

211 columns.append(np.asarray(metrics[metric], dtype=float) ** power) 

212 return np.column_stack(columns) 

213 

214 

215class HeteroscedasticMetrics: 

216 """ 

217 Computes and normalizes the per-point metrics of the error models. 

218 

219 The object is fit on the training model predictions: it stores the 

220 training centroid in PC space and the training min/max of every 

221 metric. Metrics for any other set of points are then computed 

222 consistently and scaled with the *training* bounds, so extrapolated 

223 points can legitimately exceed 1. 

224 """ 

225 

226 def __init__(self, metric_names): 

227 """ 

228 :param metric_names: Metric names to compute 

229 (subset of ``{'pc_dist', 'model_var'}``). 

230 """ 

231 unknown = set(metric_names) - {"pc_dist", "model_var"} 

232 if unknown: 

233 raise ValueError(f"Unknown metric names: {sorted(unknown)}") 

234 self.metric_names = list(metric_names) 

235 self.Vt_hat = None 

236 self.pc_centroid = None 

237 self.bounds = {} 

238 

239 def fit(self, train_model_predictions, Vt_hat): 

240 """ 

241 Fit the normalization on the training predictions. 

242 

243 Args: 

244 train_model_predictions (numpy.ndarray): Training model 

245 outputs, shape ``(n_train, n_models)``. 

246 Vt_hat (numpy.ndarray): Scaled right singular vectors from 

247 the orthogonalization step. 

248 

249 Returns: 

250 HeteroscedasticMetrics: ``self``, for chaining. 

251 """ 

252 self.Vt_hat = np.asarray(Vt_hat) 

253 self.pc_centroid = np.mean( 

254 train_model_predictions @ self.Vt_hat.T, axis=0 

255 ) 

256 train_metrics = self._raw_metrics(train_model_predictions) 

257 self.bounds = {} 

258 for name, values in train_metrics.items(): 

259 lo, hi = float(np.min(values)), float(np.max(values)) 

260 self.bounds[name] = (lo, hi) 

261 return self 

262 

263 def compute(self, model_predictions): 

264 """ 

265 Computes normalized metrics for a set of points. 

266 

267 Args: 

268 model_predictions (numpy.ndarray): Model outputs, shape 

269 ``(n_points, n_models)``. 

270 

271 Returns: 

272 dict[str, numpy.ndarray]: Normalized metric arrays keyed by name. 

273 """ 

274 if self.Vt_hat is None: 

275 raise ValueError("Call `fit()` before `compute()`.") 

276 metrics = self._raw_metrics(model_predictions) 

277 for name, values in metrics.items(): 

278 lo, hi = self.bounds[name] 

279 if hi > lo: 

280 metrics[name] = (values - lo) / (hi - lo) 

281 # A metric that is constant on the training set is left 

282 # unscaled; the sampler treats it like an extra constant. 

283 return metrics 

284 

285 def _raw_metrics(self, model_predictions): 

286 metrics = {} 

287 if "pc_dist" in self.metric_names: 

288 metrics["pc_dist"] = pc_distance_metric( 

289 model_predictions, self.Vt_hat, self.pc_centroid 

290 ) 

291 if "model_var" in self.metric_names: 

292 metrics["model_var"] = model_variance_metric(model_predictions) 

293 return metrics