Coverage for jetgp/full_degp_sparse/optimizer.py: 51%

1029 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-04-11 00:01 -0500

1import numpy as np 

2import numba 

3from scipy.linalg import cho_solve, cho_factor, blas 

4from jetgp.full_degp_sparse import degp_utils as utils 

5from jetgp.full_degp_sparse.sparse_cholesky import ( 

6 build_U, build_U_supernodes, nlml_from_U, alpha_from_U 

7) 

8from line_profiler import profile 

9import jetgp.utils as gen_utils 

10from jetgp.hyperparameter_optimizers import OPTIMIZERS 

11from jetgp.utils import matern_kernel_grad_builder 

12 

13 

14@numba.jit(nopython=True, cache=True) 

15def _symmetrise_upper(A): 

16 """Copy upper triangle to lower triangle in-place.""" 

17 n = A.shape[0] 

18 for i in range(n): 

19 for j in range(i + 1, n): 

20 A[j, i] = A[i, j] 

21 

22 

23@numba.jit(nopython=True, parallel=True, cache=True) 

24def _permute_and_subtract_outer(K_inv_ord, alpha_v, P_full, W): 

25 """Fused: W[P[i], P[j]] = K_inv_ord[i, j] - alpha_v[P[i]] * alpha_v[P[j]] 

26 Reads only the lower triangle of K_inv_ord (as produced by dsyrk with lower=1 

27 on a Fortran-order buffer). In column-major layout, lower-triangle entries 

28 within each column are contiguous → cache-friendly sequential reads. 

29 W is symmetric, so we write both (pi,pj) and (pj,pi). 

30 """ 

31 N = len(P_full) 

32 for i in numba.prange(N): 

33 pi = P_full[i] 

34 ai = alpha_v[pi] 

35 # Diagonal 

36 W[pi, pi] = K_inv_ord[i, i] - ai * ai 

37 # j > i → read lower triangle: K_inv_ord[j, i] (row > col) 

38 for j in range(i + 1, N): 

39 pj = P_full[j] 

40 val = K_inv_ord[j, i] - ai * alpha_v[pj] 

41 W[pi, pj] = val 

42 W[pj, pi] = val 

43 

44 

45def _build_k_index_map(plan, n_rows_func): 

46 """ 

47 Build arrays that map each K-matrix index to (deriv_type, physical_point). 

48 

49 Returns 

50 ------- 

51 k_type : int64 array of shape (N_total,) 

52 Derivative type for each K index (0 = function value, 1.. = derivatives). 

53 k_phys : int64 array of shape (N_total,) 

54 Physical training point index for each K index. 

55 deriv_lookup : int64 array of shape (n_types, n_types) 

56 phi_exp_3d derivative-dimension index for block (type_i, type_j). 

57 sign_lookup : float64 array of shape (n_types,) 

58 Sign multiplier for each derivative type. 

59 """ 

60 n_dt = plan['n_deriv_types'] 

61 n_types = n_dt + 1 

62 N_total = n_rows_func + plan['n_pts_with_derivs'] 

63 

64 k_type = np.empty(N_total, dtype=np.int64) 

65 k_phys = np.empty(N_total, dtype=np.int64) 

66 

67 # Function-value rows: type 0, phys = row index 

68 k_type[:n_rows_func] = 0 

69 k_phys[:n_rows_func] = np.arange(n_rows_func) 

70 

71 # Derivative rows 

72 idx_flat = plan['idx_flat'] 

73 idx_offsets = plan['idx_offsets'] 

74 idx_sizes = plan['index_sizes'] 

75 row_offsets = plan.get('row_offsets_abs', plan['row_offsets'] + n_rows_func) 

76 for j in range(n_dt): 

77 ro = row_offsets[j] 

78 sz = idx_sizes[j] 

79 off = idx_offsets[j] 

80 k_type[ro:ro + sz] = j + 1 

81 k_phys[ro:ro + sz] = idx_flat[off:off + sz] 

82 

83 # Derivative lookup: which phi_exp_3d dimension for (type_i, type_j) 

84 deriv_lookup = np.empty((n_types, n_types), dtype=np.int64) 

85 deriv_lookup[0, 0] = 0 

86 fd = plan['fd_flat_indices'] 

87 df = plan['df_flat_indices'] 

88 dd = plan['dd_flat_indices'] 

89 for j in range(n_dt): 

90 deriv_lookup[0, j + 1] = fd[j] 

91 deriv_lookup[j + 1, 0] = df[j] 

92 for i in range(n_dt): 

93 for j in range(n_dt): 

94 deriv_lookup[i + 1, j + 1] = dd[i, j] 

95 

96 # Sign lookup 

97 signs = plan['signs'] 

98 sign_lookup = np.empty(n_types, dtype=np.float64) 

99 sign_lookup[0] = signs[0] 

100 for j in range(n_dt): 

101 sign_lookup[j + 1] = signs[j + 1] 

102 

103 return k_type, k_phys, deriv_lookup, sign_lookup 

104 

105 

106@numba.jit(nopython=True, cache=True) 

107def _extract_K_sub(phi_exp_3d, nb_type, nb_phys, deriv_lookup, sign_lookup, 

108 sigma_n_sq, sigma_data_diag, m, K_sub): 

109 """ 

110 Assemble K_sub directly from phi_exp_3d for a neighbourhood `nb`. 

111 

112 nb_type[a], nb_phys[a] give the derivative type and physical point 

113 for the a-th row/column of K_sub. 

114 """ 

115 for a in range(m): 

116 ta = nb_type[a] 

117 pa = nb_phys[a] 

118 for b in range(m): 

119 tb = nb_type[b] 

120 pb = nb_phys[b] 

121 d = deriv_lookup[ta, tb] 

122 K_sub[a, b] = phi_exp_3d[d, pa, pb] * sign_lookup[tb] 

123 # Add noise to diagonal 

124 K_sub[a, a] += sigma_n_sq + sigma_data_diag[a] 

125 

126 

127@numba.jit(nopython=True, cache=True) 

128def _extract_dK_sub(dphi_exp_3d, nb_type, nb_phys, deriv_lookup, sign_lookup, m, dK_sub): 

129 """ 

130 Assemble dK_sub/dtheta from dphi_exp_3d for a neighbourhood. 

131 

132 Same as _extract_K_sub but without noise diagonal (noise doesn't 

133 depend on kernel hyperparameters). 

134 """ 

135 for a in range(m): 

136 ta = nb_type[a] 

137 pa = nb_phys[a] 

138 for b in range(m): 

139 tb = nb_type[b] 

140 pb = nb_phys[b] 

141 d = deriv_lookup[ta, tb] 

142 dK_sub[a, b] = dphi_exp_3d[d, pa, pb] * sign_lookup[tb] 

143 

144 

145@numba.jit(nopython=True, cache=True) 

146def _trace_term_all_blocks(dphi_exp_3d, deriv_lookup, sign_lookup, 

147 block_nb_type, block_nb_phys, block_m, 

148 block_V_flat, block_V_offsets, block_n_cols, 

149 n_blocks): 

150 """ 

151 Compute sum_blocks trace(V^T dK_sub V) in a single numba pass. 

152 

153 Avoids Python-level block loop and per-block np.empty/matmul overhead. 

154 """ 

155 result = 0.0 

156 for b in range(n_blocks): 

157 m = block_m[b] 

158 n_cols = block_n_cols[b] 

159 off = block_V_offsets[b] 

160 

161 # For each column pair (i, j) of V, compute V[:,i]^T dK_sub V[:,j] 

162 # trace = sum_i V[:,i]^T dK_sub V[:,i] 

163 nb_type = block_nb_type[b] 

164 nb_phys = block_nb_phys[b] 

165 

166 for col in range(n_cols): 

167 # Compute V[:,col]^T @ dK_sub @ V[:,col] 

168 # = sum_a sum_b V[a,col] * dK_sub[a,b] * V[b,col] 

169 for a in range(m): 

170 ta = nb_type[a] 

171 pa = nb_phys[a] 

172 va = block_V_flat[off + col * m + a] 

173 for bb in range(m): 

174 tb = nb_type[bb] 

175 pb = nb_phys[bb] 

176 d = deriv_lookup[ta, tb] 

177 dk_ab = dphi_exp_3d[d, pa, pb] * sign_lookup[tb] 

178 vb = block_V_flat[off + col * m + bb] 

179 result += va * dk_ab * vb 

180 return result 

181 

182 

183@numba.jit(nopython=True, cache=True) 

184def _build_K_inv_proj_from_blocks(K_inv_proj, 

185 block_nb_type, block_nb_phys, block_m, 

186 block_V_flat, block_V_offsets, block_n_cols, 

187 deriv_lookup, sign_lookup, n_blocks): 

188 """ 

189 Project K_inv = U U^T into phi-space directly from U's block structure. 

190 

191 For each block, computes V[a,:] @ V[bb,:] (dot over columns) and 

192 accumulates into K_inv_proj[d, pa, pb] with sign correction. 

193 

194 K_inv_proj must be pre-zeroed. Cost: O(Σ_blocks m² · n_cols). 

195 """ 

196 max_m = block_nb_type.shape[1] 

197 for b in range(n_blocks): 

198 m = block_m[b] 

199 nc = block_n_cols[b] 

200 off = block_V_offsets[b] 

201 

202 for a in range(m): 

203 ta = block_nb_type[b, a] 

204 pa = block_nb_phys[b, a] 

205 for bb in range(m): 

206 tb = block_nb_type[b, bb] 

207 pb = block_nb_phys[b, bb] 

208 

209 # V[a,:] @ V[bb,:] — dot product over block columns 

210 vv = 0.0 

211 for col in range(nc): 

212 vv += (block_V_flat[off + col * m + a] 

213 * block_V_flat[off + col * m + bb]) 

214 

215 d = deriv_lookup[ta, tb] 

216 K_inv_proj[d, pa, pb] += sign_lookup[tb] * vv 

217 

218 

219@numba.jit(nopython=True, cache=True) 

220def _build_alpha_proj(alpha_proj, alpha_vecs, signed_vecs, deriv_lookup): 

221 """ 

222 Project alpha*alpha^T into phi-space using per-type alpha vectors. 

223 

224 alpha_proj must be pre-zeroed. signed_vecs = sign_lookup[:, None] * alpha_vecs. 

225 alpha_proj[d, i, j] += Σ_{ta,tb: deriv_lookup[ta,tb]==d} alpha_vecs[ta,i] * signed_vecs[tb,j] 

226 """ 

227 n_types = alpha_vecs.shape[0] 

228 n_func = alpha_vecs.shape[1] 

229 for ta in range(n_types): 

230 for tb in range(n_types): 

231 d = deriv_lookup[ta, tb] 

232 for i in range(n_func): 

233 ai = alpha_vecs[ta, i] 

234 if ai == 0.0: 

235 continue 

236 for j in range(n_func): 

237 alpha_proj[d, i, j] += ai * signed_vecs[tb, j] 

238 

239 

240def _alpha_quadratic_form(dphi_3d, alpha_v, k_type, k_phys, 

241 deriv_lookup, sign_lookup, n_types, n_func): 

242 """ 

243 Compute α^T dK α via BLAS, grouping by derivative type. 

244 

245 Builds per-type alpha vectors (n_types, n_func), then for each 

246 type pair (ta, tb) computes sign[tb] * a_ta @ dphi_3d[d] @ a_tb 

247 using BLAS gemv + ddot. Avoids forming the full alpha projection. 

248 """ 

249 # Build per-type alpha vectors 

250 alpha_vecs = np.zeros((n_types, n_func)) 

251 N = len(alpha_v) 

252 for i in range(N): 

253 alpha_vecs[k_type[i], k_phys[i]] = alpha_v[i] 

254 

255 result = 0.0 

256 for ta in range(n_types): 

257 a_vec = alpha_vecs[ta] 

258 for tb in range(n_types): 

259 b_vec = alpha_vecs[tb] 

260 d = deriv_lookup[ta, tb] 

261 s = sign_lookup[tb] 

262 # a_vec @ dphi_3d[d] @ b_vec — uses BLAS dgemv + ddot 

263 result += s * a_vec @ dphi_3d[d] @ b_vec 

264 return result 

265 

266 

267@numba.jit(nopython=True, cache=True) 

268def _project_G_to_W_proj(W_proj, G_b, nb_type, nb_phys, 

269 deriv_lookup, sign_lookup, m): 

270 """ 

271 Project per-block sensitivity G_b (m × m) into W_proj (ndir × n_func × n_func). 

272 

273 W_proj[d, pa, pb] += sign_lookup[tb] * G_b[a, b] 

274 

275 where d = deriv_lookup[ta, tb], pa = nb_phys[a], pb = nb_phys[b]. 

276 """ 

277 n_func = W_proj.shape[1] 

278 plane = n_func * W_proj.shape[2] 

279 wptr = W_proj.ravel() 

280 for a_i in range(m): 

281 ta = nb_type[a_i] 

282 pa = nb_phys[a_i] 

283 for bb_i in range(m): 

284 tb = nb_type[bb_i] 

285 pb = nb_phys[bb_i] 

286 d = deriv_lookup[ta, tb] 

287 wptr[d * plane + pa * W_proj.shape[2] + pb] += sign_lookup[tb] * G_b[a_i, bb_i] 

288 

289 

290class Optimizer: 

291 """ 

292 Optimizer class to perform hyperparameter tuning for derivative-enhanced Gaussian Process models 

293 by minimizing the negative log marginal likelihood (NLL). 

294 

295 Parameters 

296 ---------- 

297 model : object 

298 An instance of a model (e.g., ddegp) containing the necessary training data 

299 and kernel configuration. 

300 """ 

301 

302 def __init__(self, model): 

303 self.model = model 

304 self._kernel_plan = None 

305 self._deriv_buf = None 

306 self._deriv_buf_shape = None 

307 self._deriv_factors = None 

308 self._deriv_factors_key = None 

309 self._ndir = None 

310 self._K_buf = None 

311 self._dK_buf = None 

312 self._kernel_buf_size = None 

313 self._W_proj_buf = None 

314 self._W_proj_shape = None 

315 self._U_buf = None 

316 self._K_inv_buf = None 

317 self._P_ix = None 

318 # Direct phi extraction maps (built lazily) 

319 self._k_index_map = None 

320 self._inv_P = None 

321 self._sigma_data_diag_mmd = None 

322 self._block_metadata = None 

323 

324 def _get_deriv_buf(self, phi, n_bases, order): 

325 """Return a pre-allocated buffer for get_all_derivs, reusing if shape matches.""" 

326 if self._ndir is None: 

327 from math import comb 

328 self._ndir = comb(n_bases + order, order) 

329 shape = (self._ndir, phi.shape[0], phi.shape[1]) 

330 if self._deriv_buf is None or self._deriv_buf_shape != shape: 

331 self._deriv_buf = np.zeros(shape, dtype=np.float64) 

332 self._deriv_buf_shape = shape 

333 return self._deriv_buf 

334 

335 def _expand_derivs(self, phi, n_bases, deriv_order): 

336 """Expand OTI derivatives, using fast struct path if available.""" 

337 if hasattr(phi, 'get_all_derivs_fast'): 

338 buf = self._get_deriv_buf(phi, n_bases, deriv_order) 

339 factors = self._get_deriv_factors(n_bases, deriv_order) 

340 return phi.get_all_derivs_fast(factors, buf) 

341 return phi.get_all_derivs(n_bases, deriv_order) 

342 

343 @staticmethod 

344 def _enum_factors(max_basis, ordi): 

345 """Enumerate derivative factors in struct memory order for a given order. 

346 

347 Yields the factorial factor prod(count_b!) for each multi-index of 

348 the given order, enumerated in the same order as the OTI struct layout 

349 (last-index-major: for last=1..max_basis, recurse prefix with max=last). 

350 """ 

351 from math import factorial 

352 from collections import Counter 

353 if ordi == 1: 

354 for _ in range(max_basis): 

355 yield 1.0 

356 return 

357 for last in range(1, max_basis + 1): 

358 if ordi == 2: 

359 for i in range(1, last + 1): 

360 counts = Counter((i, last)) 

361 f = 1 

362 for c in counts.values(): 

363 f *= factorial(c) 

364 yield float(f) 

365 else: 

366 for prefix_factor, prefix_counts in Optimizer._enum_factors_with_counts(last, ordi - 1): 

367 counts = dict(prefix_counts) 

368 counts[last] = counts.get(last, 0) + 1 

369 f = 1 

370 for c in counts.values(): 

371 f *= factorial(c) 

372 yield float(f) 

373 

374 @staticmethod 

375 def _enum_factors_with_counts(max_basis, ordi): 

376 """Enumerate (factor, counts_dict) pairs in struct order.""" 

377 from math import factorial 

378 from collections import Counter 

379 if ordi == 1: 

380 for i in range(1, max_basis + 1): 

381 yield 1.0, {i: 1} 

382 return 

383 for last in range(1, max_basis + 1): 

384 for _, prefix_counts in Optimizer._enum_factors_with_counts(last, ordi - 1): 

385 counts = dict(prefix_counts) 

386 counts[last] = counts.get(last, 0) + 1 

387 f = 1 

388 for c in counts.values(): 

389 f *= factorial(c) 

390 yield float(f), counts 

391 

392 def _get_deriv_factors(self, n_bases, order): 

393 """Return cached precomputed derivative factorial factors.""" 

394 from math import comb 

395 key = (n_bases, order) 

396 if self._deriv_factors is not None and self._deriv_factors_key == key: 

397 return self._deriv_factors 

398 factors = [1.0] # order 0: real part 

399 for ordi in range(1, order + 1): 

400 factors.extend(self._enum_factors(n_bases, ordi)) 

401 self._deriv_factors = np.array(factors, dtype=np.float64) 

402 self._deriv_factors_key = key 

403 self._ndir = comb(n_bases + order, order) 

404 return self._deriv_factors 

405 

406 def _ensure_kernel_plan(self, n_bases): 

407 """Lazily precompute kernel plan (once per n_bases).""" 

408 if self._kernel_plan is not None and self._kernel_plan_n_bases == n_bases: 

409 return 

410 if not hasattr(utils, 'precompute_kernel_plan'): 

411 self._kernel_plan = None 

412 return 

413 self._kernel_plan = utils.precompute_kernel_plan( 

414 self.model.n_order, n_bases, 

415 self.model.flattened_der_indices, 

416 self.model.powers, 

417 self.model.derivative_locations, 

418 ) 

419 self._kernel_plan_n_bases = n_bases 

420 # Reset kernel buffers when plan changes 

421 self._K_buf = None 

422 self._dK_buf = None 

423 self._kernel_buf_size = None 

424 

425 def _ensure_kernel_bufs(self, n_rows_func): 

426 """Pre-allocate reusable K and dK buffers (avoids repeated malloc).""" 

427 if self._kernel_plan is None: 

428 return 

429 total = n_rows_func + self._kernel_plan['n_pts_with_derivs'] 

430 if self._kernel_buf_size != total: 

431 self._K_buf = np.empty((total, total)) 

432 self._dK_buf = np.empty((total, total)) 

433 self._kernel_buf_size = total 

434 # Cache absolute offsets in plan so rbf_kernel_fast doesn't recompute 

435 if 'row_offsets_abs' not in self._kernel_plan: 

436 self._kernel_plan['row_offsets_abs'] = self._kernel_plan['row_offsets'] + n_rows_func 

437 self._kernel_plan['col_offsets_abs'] = self._kernel_plan['col_offsets'] + n_rows_func 

438 

439 def _ensure_phi_index_maps(self, n_rows_func): 

440 """Lazily build the K-index-to-phi maps and inverse permutation.""" 

441 if self._k_index_map is not None: 

442 return 

443 plan = self._kernel_plan 

444 k_type, k_phys, deriv_lookup, sign_lookup = _build_k_index_map( 

445 plan, n_rows_func) 

446 self._k_index_map = (k_type, k_phys, deriv_lookup, sign_lookup) 

447 

448 P_full = self.model.mmd_P_full 

449 inv_P = np.empty_like(P_full) 

450 inv_P[P_full] = np.arange(len(P_full)) 

451 self._inv_P = inv_P 

452 

453 # sigma_data diagonal in MMD order 

454 sd = self.model.sigma_data 

455 if sd.ndim == 2: 

456 sd_diag_orig = np.diag(sd) ** 2 if np.any(sd) else np.zeros(len(P_full)) 

457 else: 

458 sd_diag_orig = np.zeros(len(P_full)) 

459 self._sigma_data_diag_mmd = sd_diag_orig[P_full] 

460 

461 # Precompute flat index arrays for phi_exp_3d gather. 

462 # At runtime, K_sub = phi_exp_3d.ravel()[flat_idx] * sign_mat + noise. 

463 stride_d = n_rows_func * n_rows_func 

464 stride_row = n_rows_func 

465 

466 if (self.model.use_supernodes 

467 and self.model.sparse_supernodes_full is not None): 

468 for sn in self.model.sparse_supernodes_full: 

469 ch = sn.get('children_arr') 

470 if ch is None: 

471 ch = np.asarray(sn['children']) 

472 orig_ch = P_full[ch] 

473 ch_type = k_type[orig_ch] 

474 ch_phys = k_phys[orig_ch] 

475 m = len(ch) 

476 

477 d_mat = deriv_lookup[ch_type[:, None], ch_type[None, :]] 

478 pa_mat = np.broadcast_to(ch_phys[:, None], (m, m)) 

479 pb_mat = np.broadcast_to(ch_phys[None, :], (m, m)) 

480 sn['phi_flat_idx'] = np.ascontiguousarray( 

481 d_mat * stride_d + pa_mat * stride_row + pb_mat 

482 ) 

483 sn['phi_sign_mat'] = np.ascontiguousarray( 

484 sign_lookup[ch_type[None, :]] * np.ones((m, 1)) 

485 ) 

486 sn['phi_sd_diag'] = self._sigma_data_diag_mmd[ch] 

487 

488 # Same precomputation for non-supernode block path 

489 if (not self.model.use_supernodes 

490 and self.model.n_order > 0): 

491 N_total = len(P_full) 

492 block_size = self.model.n_bases + 1 

493 S = self.model.sparse_S_full_arr 

494 self._block_phi_maps = [] 

495 for start in range(0, N_total, block_size): 

496 end = min(start + block_size, N_total) 

497 nb = S[end - 1] if isinstance(S[end - 1], np.ndarray) else np.asarray(S[end - 1]) 

498 m = len(nb) 

499 

500 orig_nb = P_full[nb] 

501 nb_type = k_type[orig_nb] 

502 nb_phys = k_phys[orig_nb] 

503 

504 d_mat = deriv_lookup[nb_type[:, None], nb_type[None, :]] 

505 pa_mat = np.broadcast_to(nb_phys[:, None], (m, m)) 

506 pb_mat = np.broadcast_to(nb_phys[None, :], (m, m)) 

507 

508 self._block_phi_maps.append({ 

509 'nb': nb, 

510 'start': start, 

511 'flat_idx': np.ascontiguousarray( 

512 d_mat * stride_d + pa_mat * stride_row + pb_mat 

513 ), 

514 'sign_mat': np.ascontiguousarray( 

515 sign_lookup[nb_type[None, :]] * np.ones((m, 1)) 

516 ), 

517 'sd_diag': self._sigma_data_diag_mmd[nb], 

518 'positions': np.searchsorted(nb, np.arange(start, end)), 

519 }) 

520 

521 

522 def negative_log_marginal_likelihood(self, x0): 

523 """ 

524 Compute the negative log marginal likelihood (NLL) via sparse U. 

525 

526 NLL = 0.5 * ||U^T y||^2 - sum(log|diag(U)|) + 0.5 * N * log(2π) 

527 

528 Parameters 

529 ---------- 

530 x0 : ndarray 

531 Vector of log-scaled hyperparameters (length scales and noise). 

532 

533 Returns 

534 ------- 

535 float 

536 Value of the negative log marginal likelihood. 

537 """ 

538 try: 

539 if self.model._use_dense_factor: 

540 # Dense path: single Cholesky, no sparse U 

541 W, alpha, nll, *_ = self._dense_nll_and_W(x0) 

542 if nll > 1e6: 

543 return 1e6 

544 return nll 

545 

546 # Use direct phi path (skip full K construction) when possible 

547 if self.model.n_order > 0: 

548 alpha, U, nlml, *_ = self._sparse_nlml_direct(x0) 

549 else: 

550 K, _, _, _, _ = self._build_K_and_phi(x0) 

551 alpha, U, nlml = self._sparse_U_alpha_nll(K) 

552 

553 # Sparse U can silently produce bad factors when K is 

554 # ill-conditioned (e.g. very small noise). Clamp to 1e6 

555 # to match the dense fallback behaviour. 

556 if nlml > 1e6: 

557 return 1e6 

558 

559 self.model._cached_U = U 

560 self.model._cached_P = self.model.mmd_P_full 

561 self.model._cached_alpha = alpha 

562 self.model._cached_L = None 

563 self.model._cached_low = None 

564 self.model._cached_params = x0.copy() 

565 

566 return nlml 

567 except Exception: 

568 return 1e6 

569 

570 def nll_wrapper(self, x0): 

571 """ 

572 Wrapper function to compute NLL for optimizer. 

573 

574 Parameters 

575 ---------- 

576 x0 : ndarray 

577 Hyperparameter vector. 

578 

579 Returns 

580 ------- 

581 float 

582 NLL evaluated at x0. 

583 """ 

584 return self.negative_log_marginal_likelihood(x0) 

585 

586 @profile 

587 def _compute_grad(self, x0, W, phi, n_bases, oti, diffs): 

588 """ 

589 Compute the NLL gradient given pre-factorised W = K^{-1} - α α^T. 

590 

591 Factoring this out allows nll_grad and nll_and_grad to share the 

592 expensive Cholesky decomposition instead of each rebuilding it. 

593 """ 

594 ln10 = np.log(10.0) 

595 kernel = self.model.kernel 

596 kernel_type = self.model.kernel_type 

597 D = len(diffs) 

598 sigma_n_sq = (10.0 ** x0[-1]) ** 2 

599 

600 grad = np.zeros(len(x0)) 

601 use_fast = self._kernel_plan is not None 

602 base_shape = (W.shape[0] - self._kernel_plan['n_pts_with_derivs'],) * 2 if use_fast else None 

603 

604 deriv_order = 2 * self.model.n_order 

605 

606 # Precompute W projected into phi_exp space to avoid assembling 

607 # the full dK matrix for each hyperparameter dimension. 

608 W_proj = None 

609 if use_fast and self.model.n_order > 0: 

610 ndir = self._ndir 

611 proj_shape = (ndir, base_shape[0], base_shape[1]) 

612 if self._W_proj_buf is None or self._W_proj_shape != proj_shape: 

613 self._W_proj_buf = np.empty(proj_shape) 

614 self._W_proj_shape = proj_shape 

615 W_proj = self._W_proj_buf 

616 

617 plan = self._kernel_plan 

618 row_off = plan.get('row_offsets_abs', plan['row_offsets'] + base_shape[0]) 

619 col_off = plan.get('col_offsets_abs', plan['col_offsets'] + base_shape[1]) 

620 

621 utils._project_W_to_phi_space( 

622 W, W_proj, base_shape[0], base_shape[1], 

623 plan['fd_flat_indices'], plan['df_flat_indices'], 

624 plan['dd_flat_indices'], 

625 plan['idx_flat'], plan['idx_offsets'], plan['index_sizes'], 

626 plan['signs'], plan['n_deriv_types'], row_off, col_off, 

627 ) 

628 

629 _use_vdot_fused = W_proj is not None and hasattr(phi, 'vdot_expand_fast') 

630 FW_T = None 

631 if _use_vdot_fused: 

632 _vdot_factors = self._get_deriv_factors(n_bases, deriv_order) 

633 _vdot_arr = np.asarray(_vdot_factors) 

634 ndir_d = len(_vdot_arr) 

635 FW_T = np.empty((base_shape[0] * base_shape[1], ndir_d)) 

636 np.multiply(W_proj.reshape(ndir_d, -1).T, _vdot_arr, out=FW_T) 

637 

638 @profile 

639 def _gc(dphi): 

640 if _use_vdot_fused: 

641 return 0.5 * dphi.vdot_expand_fast(_vdot_factors, W_proj) 

642 if self.model.n_order == 0: 

643 dphi_exp = dphi.real[np.newaxis, :, :] 

644 else: 

645 dphi_exp = self._expand_derivs(dphi, n_bases, deriv_order) 

646 if W_proj is not None: 

647 dphi_3d = dphi_exp.reshape(W_proj.shape) 

648 return 0.5 * np.vdot(W_proj, dphi_3d) 

649 elif use_fast: 

650 dphi_3d = dphi_exp.reshape(dphi_exp.shape[0], base_shape[0], base_shape[1]) 

651 dK = utils.rbf_kernel_fast(dphi_3d, self._kernel_plan, out=self._dK_buf) 

652 return 0.5 * np.vdot(W, dK) 

653 else: 

654 dK = utils.rbf_kernel( 

655 dphi, dphi_exp, 

656 self.model.n_order, n_bases, 

657 self.model.flattened_der_indices, self.model.powers, 

658 index=self.model.derivative_locations, 

659 ) 

660 return 0.5 * np.vdot(W, dK) 

661 

662 # ── signal variance (common: d phi/d log_sf = 2*ln10 * phi) ────── 

663 if _use_vdot_fused: 

664 grad[-2] = ln10 * phi.vdot_expand_fast(_vdot_factors, W_proj) 

665 else: 

666 grad[-2] = _gc(oti.mul(2.0 * ln10, phi)) 

667 

668 # ── noise variance (common: dK/d log_sn = diag(2*ln10*σ_n²)) ──── 

669 grad[-1] = ln10 * sigma_n_sq * np.trace(W) 

670 

671 # ── kernel-specific hyperparameter gradients ────────────────────── 

672 

673 if kernel == 'SE': 

674 # phi = sf² * exp(-0.5 * Σ_d ell_d² * diff_d²) 

675 # d phi/d log_ell_d = -ln10 * ell_d² * diff_d² * phi 

676 if kernel_type == 'anisotropic': 

677 ell = 10.0 ** x0[:D] 

678 if _use_vdot_fused and hasattr(phi, 'fused_grad_all_dims'): 

679 scales = np.array([-ln10 * ell[d] ** 2 for d in range(D)]) 

680 grad_buf = np.zeros(D) 

681 phi.fused_grad_all_dims(diffs, scales, _vdot_factors, W_proj, grad_buf, FW_T) 

682 grad[:D] = grad_buf 

683 elif hasattr(phi, 'fused_scale_sq_mul_sparse'): 

684 dphi_buf = oti.zeros(phi.shape) 

685 for d in range(D): 

686 dphi_buf.fused_scale_sq_mul_sparse(diffs[d], phi, -ln10 * ell[d] ** 2, d) 

687 grad[d] = _gc(dphi_buf) 

688 elif hasattr(phi, 'fused_scale_sq_mul'): 

689 dphi_buf = oti.zeros(phi.shape) 

690 for d in range(D): 

691 dphi_buf.fused_scale_sq_mul(diffs[d], phi, -ln10 * ell[d] ** 2) 

692 grad[d] = _gc(dphi_buf) 

693 else: 

694 for d in range(D): 

695 d_sq = oti.mul(diffs[d], diffs[d]) 

696 dphi_d = oti.mul(-ln10 * ell[d] ** 2, oti.mul(d_sq, phi)) 

697 grad[d] = _gc(dphi_d) 

698 else: # isotropic: single ell 

699 ell = 10.0 ** float(x0[0]) 

700 if hasattr(phi, 'fused_sum_sq_sparse'): 

701 sum_sq = oti.zeros(phi.shape) 

702 sum_sq.fused_sum_sq_sparse(diffs) 

703 elif hasattr(phi, 'fused_sum_sq'): 

704 sum_sq = oti.zeros(phi.shape) 

705 sum_sq.fused_sum_sq(diffs) 

706 else: 

707 sum_sq = oti.mul(diffs[0], diffs[0]) 

708 for d in range(1, D): 

709 sum_sq = oti.sum(sum_sq, oti.mul(diffs[d], diffs[d])) 

710 grad[0] = _gc(oti.mul(-ln10 * ell ** 2, oti.mul(sum_sq, phi))) 

711 

712 elif kernel == 'RQ': 

713 # phi = sf² * (1 + r²/(2α))^(-α), r² = Σ_d (ell_d * diff_d)² 

714 # d phi/d log_ell_d = -ln10 * ell_d² * diff_d² * phi / base 

715 # d phi/d log_α = ln10 * α * phi * [-log(base) + (1 - 1/base)] 

716 if kernel_type == 'anisotropic': 

717 ell = 10.0 ** x0[:D] 

718 alpha_rq = 10.0 ** float(x0[D]) 

719 alpha_idx = D 

720 else: 

721 ell_val = 10.0 ** float(x0[0]) 

722 ell = np.full(D, ell_val) 

723 alpha_rq = np.exp(float(x0[1])) # iso uses exp(x), not 10^x 

724 alpha_idx = 1 

725 

726 # Recompute r² and base in OTI 

727 if hasattr(phi, 'fused_sqdist_sparse'): 

728 r2 = oti.zeros(phi.shape) 

729 ell_sq = np.ascontiguousarray(ell ** 2, dtype=np.float64) 

730 r2.fused_sqdist_sparse(diffs, ell_sq) 

731 elif hasattr(phi, 'fused_sqdist'): 

732 r2 = oti.zeros(phi.shape) 

733 ell_sq = np.ascontiguousarray(ell ** 2, dtype=np.float64) 

734 r2.fused_sqdist(diffs, ell_sq) 

735 else: 

736 r2 = oti.mul(ell[0], diffs[0]) 

737 r2 = oti.mul(r2, r2) 

738 for d in range(1, D): 

739 td = oti.mul(ell[d], diffs[d]) 

740 r2 = oti.sum(r2, oti.mul(td, td)) 

741 base = oti.sum(1.0, oti.mul(r2, 1.0 / (2.0 * alpha_rq))) 

742 inv_base = oti.pow(base, -1) 

743 phi_over_base = oti.mul(phi, inv_base) 

744 

745 if kernel_type == 'anisotropic': 

746 if _use_vdot_fused and hasattr(phi, 'fused_grad_all_dims'): 

747 scales = np.array([-ln10 * ell[d] ** 2 for d in range(D)]) 

748 grad_buf = np.zeros(D) 

749 phi_over_base.fused_grad_all_dims(diffs, scales, _vdot_factors, W_proj, grad_buf, FW_T) 

750 grad[:D] = grad_buf 

751 elif hasattr(phi, 'fused_scale_sq_mul_sparse'): 

752 dphi_buf = oti.zeros(phi.shape) 

753 for d in range(D): 

754 dphi_buf.fused_scale_sq_mul_sparse(diffs[d], phi_over_base, -ln10 * ell[d] ** 2, d) 

755 grad[d] = _gc(dphi_buf) 

756 elif hasattr(phi, 'fused_scale_sq_mul'): 

757 dphi_buf = oti.zeros(phi.shape) 

758 for d in range(D): 

759 dphi_buf.fused_scale_sq_mul(diffs[d], phi_over_base, -ln10 * ell[d] ** 2) 

760 grad[d] = _gc(dphi_buf) 

761 else: 

762 for d in range(D): 

763 d_sq = oti.mul(diffs[d], diffs[d]) 

764 dphi_d = oti.mul(-ln10 * ell[d] ** 2, oti.mul(d_sq, phi_over_base)) 

765 grad[d] = _gc(dphi_d) 

766 else: 

767 if hasattr(phi, 'fused_sum_sq_sparse'): 

768 sum_sq = oti.zeros(phi.shape) 

769 sum_sq.fused_sum_sq_sparse(diffs) 

770 elif hasattr(phi, 'fused_sum_sq'): 

771 sum_sq = oti.zeros(phi.shape) 

772 sum_sq.fused_sum_sq(diffs) 

773 else: 

774 sum_sq = oti.mul(diffs[0], diffs[0]) 

775 for d in range(1, D): 

776 sum_sq = oti.sum(sum_sq, oti.mul(diffs[d], diffs[d])) 

777 grad[0] = _gc(oti.mul(-ln10 * ell[0] ** 2, oti.mul(sum_sq, phi_over_base))) 

778 

779 # alpha gradient: phi * α_factor * [-log(base) + (1 - 1/base)] 

780 # aniso: alpha = 10^x → d alpha/dx = ln10 * alpha 

781 # iso: alpha = exp(x) → d alpha/dx = alpha 

782 log_base = oti.log(base) 

783 term = oti.sub(oti.sub(1.0, inv_base), log_base) 

784 alpha_factor = ln10 * alpha_rq if kernel_type == 'anisotropic' else alpha_rq 

785 grad[alpha_idx] = _gc(oti.mul(alpha_factor, oti.mul(phi, term))) 

786 

787 elif kernel == 'SineExp': 

788 # phi = sf² * exp(-2 * Σ_d (ell_d * sin(π/p_d * diff_d))²) 

789 # d phi/d log_ell_d = -4*ln10 * ell_d² * sin_d² * phi 

790 # d phi/d log_p_d = 4*ln10 * ell_d² * (π/p_d) * sin_d * cos_d * diff_d * phi 

791 if kernel_type == 'anisotropic': 

792 ell = 10.0 ** x0[:D] 

793 p = 10.0 ** x0[D:2 * D] 

794 pip = np.pi / p # π/p_d per dimension 

795 p_start = D # index of first log_p in x0 

796 else: 

797 ell_val = 10.0 ** float(x0[0]) 

798 p_val = 10.0 ** float(x0[1]) 

799 pip_val = np.pi / p_val 

800 ell = np.full(D, ell_val) 

801 pip = np.full(D, pip_val) 

802 p_start = 1 

803 

804 # Precompute sin and cos for each dimension 

805 sin_d = [] 

806 cos_d = [] 

807 for d in range(D): 

808 arg = oti.mul(pip[d], diffs[d]) 

809 sin_d.append(oti.sin(arg)) 

810 cos_d.append(oti.cos(arg)) 

811 

812 # Length-scale gradients: d phi/d log_ell_d = -4*ln10 * ell_d² * sin_d² * phi 

813 if kernel_type == 'anisotropic': 

814 if hasattr(phi, 'fused_scale_sq_mul'): 

815 dphi_buf = oti.zeros(phi.shape) 

816 for d in range(D): 

817 dphi_buf.fused_scale_sq_mul(sin_d[d], phi, -4.0 * ln10 * ell[d] ** 2) 

818 grad[d] = _gc(dphi_buf) 

819 else: 

820 for d in range(D): 

821 sin_sq = oti.mul(sin_d[d], sin_d[d]) 

822 grad[d] = _gc(oti.mul(-4.0 * ln10 * ell[d] ** 2, 

823 oti.mul(sin_sq, phi))) 

824 else: 

825 if hasattr(phi, 'fused_sum_sq'): 

826 sum_sin_sq = oti.zeros(phi.shape) 

827 sum_sin_sq.fused_sum_sq(sin_d) 

828 else: 

829 sum_sin_sq = oti.mul(sin_d[0], sin_d[0]) 

830 for d in range(1, D): 

831 sum_sin_sq = oti.sum(sum_sin_sq, oti.mul(sin_d[d], sin_d[d])) 

832 grad[0] = _gc(oti.mul(-4.0 * ln10 * ell[0] ** 2, 

833 oti.mul(sum_sin_sq, phi))) 

834 

835 # Period gradients 

836 if kernel_type == 'anisotropic': 

837 for d in range(D): 

838 # d phi/d log_p_d = 4*ln10*ell_d²*(π/p_d)*sin_d*cos_d*diff_d * phi 

839 sc_diff = oti.mul(sin_d[d], oti.mul(cos_d[d], diffs[d])) 

840 scale = 4.0 * ln10 * ell[d] ** 2 * pip[d] 

841 grad[p_start + d] = _gc(oti.mul(scale, oti.mul(sc_diff, phi))) 

842 else: 

843 # d phi/d log_p = 4*ln10*ell²*(π/p) * Σ_d(sin_d*cos_d*diff_d) * phi 

844 sum_scd = oti.mul(sin_d[0], oti.mul(cos_d[0], diffs[0])) 

845 for d in range(1, D): 

846 sum_scd = oti.sum(sum_scd, 

847 oti.mul(sin_d[d], oti.mul(cos_d[d], diffs[d]))) 

848 scale = 4.0 * ln10 * ell[0] ** 2 * pip[0] 

849 grad[p_start] = _gc(oti.mul(scale, oti.mul(sum_scd, phi))) 

850 

851 elif kernel == 'Matern': 

852 # phi = sf² * f(r), r = sqrt(Σ_d (ell_d*(diff_d+ε))²) 

853 # d phi/d log_ell_d = sf² * f'(r) * ln10 * ell_d² * (diff_d+ε)² / r 

854 kf = self.model.kernel_factory 

855 

856 # Build/cache the Matern derivative function 

857 if not hasattr(kf, '_matern_grad_prebuild'): 

858 kf._matern_grad_prebuild = matern_kernel_grad_builder( 

859 kf.nu, oti_module=oti) 

860 

861 if kernel_type == 'anisotropic': 

862 ell = 10.0 ** x0[:D] 

863 else: 

864 ell = np.full(D, 10.0 ** float(x0[0])) 

865 

866 sigma_f_sq = (10.0 ** float(x0[-2])) ** 2 

867 _eps = 1e-10 # regularise r, not each diff (matches kernel_funcs.py) 

868 

869 # Recompute r in OTI (matches matern_kernel_anisotropic/isotropic) 

870 if hasattr(phi, 'fused_sqdist_sparse'): 

871 r2 = oti.zeros(phi.shape) 

872 ell_sq = np.ascontiguousarray(ell ** 2, dtype=np.float64) 

873 r2.fused_sqdist_sparse(diffs, ell_sq) 

874 elif hasattr(phi, 'fused_sqdist'): 

875 r2 = oti.zeros(phi.shape) 

876 ell_sq = np.ascontiguousarray(ell ** 2, dtype=np.float64) 

877 r2.fused_sqdist(diffs, ell_sq) 

878 else: 

879 r2 = oti.mul(ell[0], diffs[0]) 

880 r2 = oti.mul(r2, r2) 

881 for d in range(1, D): 

882 td = oti.mul(ell[d], diffs[d]) 

883 r2 = oti.sum(r2, oti.mul(td, td)) 

884 r_oti = oti.sqrt(oti.sum(r2, _eps ** 2)) 

885 f_prime_r = kf._matern_grad_prebuild(r_oti) # df/dr (OTI) 

886 inv_r = oti.pow(r_oti, -1) 

887 

888 # Precompute base = sigma_f² * f'(r) * 1/r for length-scale gradients 

889 # grad[d] = _gc(base * ln10 * ell_d² * diff_d²) 

890 base_matern = oti.mul(sigma_f_sq, oti.mul(f_prime_r, inv_r)) 

891 if kernel_type == 'anisotropic': 

892 if _use_vdot_fused and hasattr(phi, 'fused_grad_all_dims'): 

893 scales = np.array([ln10 * ell[d] ** 2 for d in range(D)]) 

894 grad_buf = np.zeros(D) 

895 base_matern.fused_grad_all_dims(diffs, scales, _vdot_factors, W_proj, grad_buf, FW_T) 

896 grad[:D] = grad_buf 

897 elif hasattr(phi, 'fused_scale_sq_mul_sparse'): 

898 dphi_buf = oti.zeros(phi.shape) 

899 for d in range(D): 

900 dphi_buf.fused_scale_sq_mul_sparse(diffs[d], base_matern, ln10 * ell[d] ** 2, d) 

901 grad[d] = _gc(dphi_buf) 

902 elif hasattr(phi, 'fused_scale_sq_mul'): 

903 dphi_buf = oti.zeros(phi.shape) 

904 for d in range(D): 

905 dphi_buf.fused_scale_sq_mul(diffs[d], base_matern, ln10 * ell[d] ** 2) 

906 grad[d] = _gc(dphi_buf) 

907 else: 

908 for d in range(D): 

909 d_sq = oti.mul(diffs[d], diffs[d]) 

910 dphi_d = oti.mul(ln10 * ell[d] ** 2, oti.mul(d_sq, base_matern)) 

911 grad[d] = _gc(dphi_d) 

912 else: 

913 ell_val = ell[0] 

914 if hasattr(phi, 'fused_sum_sq_sparse'): 

915 sum_dsq = oti.zeros(phi.shape) 

916 sum_dsq.fused_sum_sq_sparse(diffs) 

917 elif hasattr(phi, 'fused_sum_sq'): 

918 sum_dsq = oti.zeros(phi.shape) 

919 sum_dsq.fused_sum_sq(diffs) 

920 else: 

921 sum_dsq = oti.mul(diffs[0], diffs[0]) 

922 for d in range(1, D): 

923 sum_dsq = oti.sum(sum_dsq, oti.mul(diffs[d], diffs[d])) 

924 dphi_e = oti.mul(ln10 * ell_val ** 2, oti.mul(sum_dsq, base_matern)) 

925 grad[0] = _gc(dphi_e) 

926 

927 elif kernel == 'SI': 

928 # phi = sf² * Π_d (1 + ell_d * B(diff_d)) 

929 # d phi/d log_ell_d = ln10 * ell_d * B(diff_d) / (1 + ell_d*B(diff_d)) * phi 

930 kf = self.model.kernel_factory 

931 si_prebuild = kf.SI_kernel_prebuild 

932 

933 if kernel_type == 'anisotropic': 

934 ell = 10.0 ** x0[:D] 

935 else: 

936 ell = np.full(D, 10.0 ** float(x0[0])) 

937 

938 # Precompute SI values and factor terms for each dimension 

939 si_vals = [si_prebuild(diffs[d]) for d in range(D)] 

940 term_vals = [oti.sum(1.0, oti.mul(ell[d], si_vals[d])) for d in range(D)] 

941 

942 if kernel_type == 'anisotropic': 

943 for d in range(D): 

944 phi_over_term = oti.div(phi, term_vals[d]) 

945 dphi_d = oti.mul(ln10 * ell[d], 

946 oti.mul(si_vals[d], phi_over_term)) 

947 grad[d] = _gc(dphi_d) 

948 else: 

949 ell_val = ell[0] 

950 # d phi/d log_ell = ln10 * ell * Σ_d [B(diff_d)/(1+ell*B(diff_d))] * phi 

951 acc = oti.mul(si_vals[0], oti.div(phi, term_vals[0])) 

952 for d in range(1, D): 

953 acc = oti.sum(acc, oti.mul(si_vals[d], 

954 oti.div(phi, term_vals[d]))) 

955 grad[0] = _gc(oti.mul(ln10 * ell_val, acc)) 

956 

957 return grad 

958 

959 @profile 

960 def _compute_grad_blockwise(self, x0, U, alpha_v, phi, n_bases, oti, diffs): 

961 """ 

962 Compute the NLL gradient by differentiating through each block's 

963 Cholesky in the Vecchia decomposition. 

964 

965 For each block b the Vecchia NLL contribution is: 

966 NLL_b = 0.5 * Σ_j [ α_b[p_j]² / s_j - log(s_j) ] 

967 

968 where α_b = K_sub⁻¹ y_nb, s_j = (K_sub⁻¹)[p_j, p_j]. 

969 

970 Differentiating through K_sub⁻¹ gives the per-block sensitivity: 

971 G_b = 0.5 * M V^T 

972 

973 where V = K_sub⁻¹ E (un-normalised U block), and 

974 M[:, j] = γ_j V[:, j] - 2 β_j α_b 

975 β_j = α_b[p_j] / s_j, γ_j = β_j² + 1/s_j 

976 

977 The gradient is dNLL/dθ = Σ_b tr(G_b dK_sub_b/dθ), 

978 which is projected into phi-space as W_proj for vdot. 

979 """ 

980 ln10 = np.log(10.0) 

981 kernel = self.model.kernel 

982 kernel_type = self.model.kernel_type 

983 D = len(diffs) 

984 sigma_n_sq = (10.0 ** x0[-1]) ** 2 

985 

986 grad = np.zeros(len(x0)) 

987 deriv_order = 2 * self.model.n_order 

988 plan = self._kernel_plan 

989 P_full = self.model.mmd_P_full 

990 N_total = len(P_full) 

991 n_func = phi.shape[0] 

992 

993 ndir = self._ndir 

994 k_type, k_phys, deriv_lookup, sign_lookup = self._k_index_map 

995 

996 # ── phi_exp for K_sub reconstruction ────────────────────── 

997 phi_exp = self._expand_derivs(phi, n_bases, deriv_order) 

998 phi_3d = phi_exp.reshape(phi_exp.shape[0], n_func, n_func) 

999 phi_flat = phi_3d.ravel() 

1000 

1001 block_maps = self._block_phi_maps 

1002 y_ord = self.model.y_train[P_full] 

1003 

1004 # ── accumulate W_proj and noise trace from per-block G_b ── 

1005 proj_shape = (ndir, n_func, n_func) 

1006 W_proj = np.zeros(proj_shape) 

1007 w_flat = W_proj.ravel() 

1008 noise_trace = 0.0 

1009 

1010 for bm in block_maps: 

1011 nb = bm['nb'] 

1012 m = len(nb) 

1013 positions = bm['positions'] 

1014 n_cols = len(positions) 

1015 

1016 # Reconstruct K_sub for this block 

1017 K_sub = phi_flat[bm['flat_idx']].reshape(m, m) * bm['sign_mat'] 

1018 diag_idx = np.arange(m) 

1019 K_sub[diag_idx, diag_idx] += sigma_n_sq + bm['sd_diag'] 

1020 

1021 # Cholesky factor 

1022 L_u, low_u = cho_factor(K_sub, lower=True) 

1023 

1024 # V = K_sub⁻¹ E (un-normalised U block, m × n_cols) 

1025 E = np.zeros((m, n_cols)) 

1026 E[positions, np.arange(n_cols)] = 1.0 

1027 V = cho_solve((L_u, low_u), E) 

1028 

1029 # α_b = K_sub⁻¹ y_nb (m,) 

1030 y_nb = y_ord[nb] 

1031 alpha_b = cho_solve((L_u, low_u), y_nb) 

1032 

1033 # Per-parent scalars 

1034 s = V[positions, np.arange(n_cols)] # s_j = S[p_j, p_j] 

1035 a = alpha_b[positions] # α_b[p_j] 

1036 beta = a / s # β_j 

1037 gamma = beta ** 2 + 1.0 / s # γ_j 

1038 

1039 # M[:, j] = γ_j V[:, j] - 2 β_j α_b (m × n_cols) 

1040 M = V * gamma[np.newaxis, :] - 2.0 * alpha_b[:, np.newaxis] * beta[np.newaxis, :] 

1041 

1042 # Noise trace: tr(G_b) = 0.5 Σ_{a,k} M[a,k] V[a,k] 

1043 noise_trace += np.sum(M * V) 

1044 

1045 # Project G_b into W_proj using precomputed flat indices 

1046 G_b = M @ V.T # m × m 

1047 np.add.at(w_flat, bm['flat_idx'].ravel(), (G_b * bm['sign_mat']).ravel()) 

1048 

1049 # ── noise gradient ─────────────────────────────────────── 

1050 # dK_sub/d(sigma_n_sq) = I ⇒ dNLL/d(sigma_n_sq) = Σ_b 0.5 tr(M V^T) 

1051 # x[-1] = log10(sigma_n), sigma_n_sq = 10^(2x[-1]) 

1052 # chain rule: d(sigma_n_sq)/d(x[-1]) = 2 ln10 sigma_n_sq 

1053 grad[-1] = ln10 * sigma_n_sq * noise_trace 

1054 

1055 # ── deriv factors + fast vdot path ─────────────────────── 

1056 _vdot_factors = self._get_deriv_factors(n_bases, deriv_order) 

1057 

1058 # Precompute transposed factor-weighted W_proj for cache-friendly access 

1059 # FW_T[kk, c] = factors[c] * W_proj[c, kk] — shape (size, ndir) 

1060 _vdot_arr = np.asarray(_vdot_factors) 

1061 FW_T = np.empty((n_func * n_func, ndir)) 

1062 np.multiply(W_proj.reshape(ndir, -1).T, _vdot_arr, out=FW_T) 

1063 

1064 @profile 

1065 def _gc_block(dphi): 

1066 return 0.5 * dphi.vdot_expand_fast(_vdot_factors, W_proj) 

1067 

1068 # ── signal variance ────────────────────────────────────────── 

1069 grad[-2] = ln10 * phi.vdot_expand_fast(_vdot_factors, W_proj) 

1070 

1071 # ── kernel-specific hyperparameter gradients ───────────────── 

1072 

1073 if kernel == 'SE': 

1074 if kernel_type == 'anisotropic': 

1075 ell = 10.0 ** x0[:D] 

1076 if hasattr(phi, 'fused_grad_all_dims'): 

1077 scales = np.array([-ln10 * ell[d] ** 2 for d in range(D)]) 

1078 grad_buf = np.zeros(D) 

1079 phi.fused_grad_all_dims(diffs, scales, _vdot_factors, W_proj, grad_buf, FW_T) 

1080 grad[:D] = grad_buf 

1081 elif hasattr(phi, 'fused_scale_sq_mul_sparse'): 

1082 dphi_buf = oti.zeros(phi.shape) 

1083 for d in range(D): 

1084 dphi_buf.fused_scale_sq_mul_sparse(diffs[d], phi, -ln10 * ell[d] ** 2, d) 

1085 grad[d] = _gc_block(dphi_buf) 

1086 elif hasattr(phi, 'fused_scale_sq_mul'): 

1087 dphi_buf = oti.zeros(phi.shape) 

1088 for d in range(D): 

1089 dphi_buf.fused_scale_sq_mul(diffs[d], phi, -ln10 * ell[d] ** 2) 

1090 grad[d] = _gc_block(dphi_buf) 

1091 else: 

1092 for d in range(D): 

1093 d_sq = oti.mul(diffs[d], diffs[d]) 

1094 dphi_d = oti.mul(-ln10 * ell[d] ** 2, oti.mul(d_sq, phi)) 

1095 grad[d] = _gc_block(dphi_d) 

1096 else: 

1097 ell = 10.0 ** float(x0[0]) 

1098 if hasattr(phi, 'fused_sum_sq_sparse'): 

1099 sum_sq = oti.zeros(phi.shape) 

1100 sum_sq.fused_sum_sq_sparse(diffs) 

1101 elif hasattr(phi, 'fused_sum_sq'): 

1102 sum_sq = oti.zeros(phi.shape) 

1103 sum_sq.fused_sum_sq(diffs) 

1104 else: 

1105 sum_sq = oti.mul(diffs[0], diffs[0]) 

1106 for d in range(1, D): 

1107 sum_sq = oti.sum(sum_sq, oti.mul(diffs[d], diffs[d])) 

1108 grad[0] = _gc_block(oti.mul(-ln10 * ell ** 2, oti.mul(sum_sq, phi))) 

1109 

1110 elif kernel == 'RQ': 

1111 if kernel_type == 'anisotropic': 

1112 ell = 10.0 ** x0[:D] 

1113 alpha_rq = 10.0 ** float(x0[D]) 

1114 alpha_idx = D 

1115 else: 

1116 ell_val = 10.0 ** float(x0[0]) 

1117 ell = np.full(D, ell_val) 

1118 alpha_rq = np.exp(float(x0[1])) 

1119 alpha_idx = 1 

1120 

1121 if hasattr(phi, 'fused_sqdist_sparse'): 

1122 r2 = oti.zeros(phi.shape) 

1123 ell_sq = np.ascontiguousarray(ell ** 2, dtype=np.float64) 

1124 r2.fused_sqdist_sparse(diffs, ell_sq) 

1125 elif hasattr(phi, 'fused_sqdist'): 

1126 r2 = oti.zeros(phi.shape) 

1127 ell_sq = np.ascontiguousarray(ell ** 2, dtype=np.float64) 

1128 r2.fused_sqdist(diffs, ell_sq) 

1129 else: 

1130 r2 = oti.mul(ell[0], diffs[0]) 

1131 r2 = oti.mul(r2, r2) 

1132 for d in range(1, D): 

1133 td = oti.mul(ell[d], diffs[d]) 

1134 r2 = oti.sum(r2, oti.mul(td, td)) 

1135 base = oti.sum(1.0, oti.mul(r2, 1.0 / (2.0 * alpha_rq))) 

1136 inv_base = oti.pow(base, -1) 

1137 phi_over_base = oti.mul(phi, inv_base) 

1138 

1139 if kernel_type == 'anisotropic': 

1140 if hasattr(phi, 'fused_grad_all_dims'): 

1141 scales = np.array([-ln10 * ell[d] ** 2 for d in range(D)]) 

1142 grad_buf = np.zeros(D) 

1143 phi_over_base.fused_grad_all_dims(diffs, scales, _vdot_factors, W_proj, grad_buf, FW_T) 

1144 grad[:D] = grad_buf 

1145 elif hasattr(phi, 'fused_scale_sq_mul_sparse'): 

1146 dphi_buf = oti.zeros(phi.shape) 

1147 for d in range(D): 

1148 dphi_buf.fused_scale_sq_mul_sparse(diffs[d], phi_over_base, -ln10 * ell[d] ** 2, d) 

1149 grad[d] = _gc_block(dphi_buf) 

1150 elif hasattr(phi, 'fused_scale_sq_mul'): 

1151 dphi_buf = oti.zeros(phi.shape) 

1152 for d in range(D): 

1153 dphi_buf.fused_scale_sq_mul(diffs[d], phi_over_base, -ln10 * ell[d] ** 2) 

1154 grad[d] = _gc_block(dphi_buf) 

1155 else: 

1156 for d in range(D): 

1157 d_sq = oti.mul(diffs[d], diffs[d]) 

1158 dphi_d = oti.mul(-ln10 * ell[d] ** 2, oti.mul(d_sq, phi_over_base)) 

1159 grad[d] = _gc_block(dphi_d) 

1160 else: 

1161 if hasattr(phi, 'fused_sum_sq_sparse'): 

1162 sum_sq = oti.zeros(phi.shape) 

1163 sum_sq.fused_sum_sq_sparse(diffs) 

1164 elif hasattr(phi, 'fused_sum_sq'): 

1165 sum_sq = oti.zeros(phi.shape) 

1166 sum_sq.fused_sum_sq(diffs) 

1167 else: 

1168 sum_sq = oti.mul(diffs[0], diffs[0]) 

1169 for d in range(1, D): 

1170 sum_sq = oti.sum(sum_sq, oti.mul(diffs[d], diffs[d])) 

1171 grad[0] = _gc_block(oti.mul(-ln10 * ell[0] ** 2, oti.mul(sum_sq, phi_over_base))) 

1172 

1173 log_base = oti.log(base) 

1174 term = oti.sub(oti.sub(1.0, inv_base), log_base) 

1175 alpha_factor = ln10 * alpha_rq if kernel_type == 'anisotropic' else alpha_rq 

1176 grad[alpha_idx] = _gc_block(oti.mul(alpha_factor, oti.mul(phi, term))) 

1177 

1178 elif kernel == 'SineExp': 

1179 if kernel_type == 'anisotropic': 

1180 ell = 10.0 ** x0[:D] 

1181 p = 10.0 ** x0[D:2 * D] 

1182 pip = np.pi / p 

1183 p_start = D 

1184 else: 

1185 ell_val = 10.0 ** float(x0[0]) 

1186 p_val = 10.0 ** float(x0[1]) 

1187 pip_val = np.pi / p_val 

1188 ell = np.full(D, ell_val) 

1189 pip = np.full(D, pip_val) 

1190 p_start = 1 

1191 

1192 sin_d = [] 

1193 cos_d = [] 

1194 for d in range(D): 

1195 arg = oti.mul(pip[d], diffs[d]) 

1196 sin_d.append(oti.sin(arg)) 

1197 cos_d.append(oti.cos(arg)) 

1198 

1199 if kernel_type == 'anisotropic': 

1200 if hasattr(phi, 'fused_scale_sq_mul'): 

1201 dphi_buf = oti.zeros(phi.shape) 

1202 for d in range(D): 

1203 dphi_buf.fused_scale_sq_mul(sin_d[d], phi, -4.0 * ln10 * ell[d] ** 2) 

1204 grad[d] = _gc_block(dphi_buf) 

1205 else: 

1206 for d in range(D): 

1207 sin_sq = oti.mul(sin_d[d], sin_d[d]) 

1208 grad[d] = _gc_block(oti.mul(-4.0 * ln10 * ell[d] ** 2, 

1209 oti.mul(sin_sq, phi))) 

1210 else: 

1211 if hasattr(phi, 'fused_sum_sq'): 

1212 sum_sin_sq = oti.zeros(phi.shape) 

1213 sum_sin_sq.fused_sum_sq(sin_d) 

1214 else: 

1215 sum_sin_sq = oti.mul(sin_d[0], sin_d[0]) 

1216 for d in range(1, D): 

1217 sum_sin_sq = oti.sum(sum_sin_sq, oti.mul(sin_d[d], sin_d[d])) 

1218 grad[0] = _gc_block(oti.mul(-4.0 * ln10 * ell[0] ** 2, 

1219 oti.mul(sum_sin_sq, phi))) 

1220 

1221 if kernel_type == 'anisotropic': 

1222 for d in range(D): 

1223 sc_diff = oti.mul(sin_d[d], oti.mul(cos_d[d], diffs[d])) 

1224 scale = 4.0 * ln10 * ell[d] ** 2 * pip[d] 

1225 grad[p_start + d] = _gc_block(oti.mul(scale, oti.mul(sc_diff, phi))) 

1226 else: 

1227 sum_scd = oti.mul(sin_d[0], oti.mul(cos_d[0], diffs[0])) 

1228 for d in range(1, D): 

1229 sum_scd = oti.sum(sum_scd, 

1230 oti.mul(sin_d[d], oti.mul(cos_d[d], diffs[d]))) 

1231 scale = 4.0 * ln10 * ell[0] ** 2 * pip[0] 

1232 grad[p_start] = _gc_block(oti.mul(scale, oti.mul(sum_scd, phi))) 

1233 

1234 elif kernel == 'Matern': 

1235 kf = self.model.kernel_factory 

1236 if not hasattr(kf, '_matern_grad_prebuild'): 

1237 from jetgp.kernel_funcs.kernel_funcs import matern_kernel_grad_builder 

1238 kf._matern_grad_prebuild = matern_kernel_grad_builder( 

1239 kf.nu, oti_module=oti) 

1240 

1241 if kernel_type == 'anisotropic': 

1242 ell = 10.0 ** x0[:D] 

1243 else: 

1244 ell = np.full(D, 10.0 ** float(x0[0])) 

1245 

1246 sigma_f_sq = (10.0 ** float(x0[-2])) ** 2 

1247 _eps = 1e-10 

1248 

1249 if hasattr(phi, 'fused_sqdist_sparse'): 

1250 r2 = oti.zeros(phi.shape) 

1251 ell_sq = np.ascontiguousarray(ell ** 2, dtype=np.float64) 

1252 r2.fused_sqdist_sparse(diffs, ell_sq) 

1253 elif hasattr(phi, 'fused_sqdist'): 

1254 r2 = oti.zeros(phi.shape) 

1255 ell_sq = np.ascontiguousarray(ell ** 2, dtype=np.float64) 

1256 r2.fused_sqdist(diffs, ell_sq) 

1257 else: 

1258 r2 = oti.mul(ell[0], diffs[0]) 

1259 r2 = oti.mul(r2, r2) 

1260 for d in range(1, D): 

1261 td = oti.mul(ell[d], diffs[d]) 

1262 r2 = oti.sum(r2, oti.mul(td, td)) 

1263 r_oti = oti.sqrt(oti.sum(r2, _eps ** 2)) 

1264 f_prime_r = kf._matern_grad_prebuild(r_oti) 

1265 inv_r = oti.pow(r_oti, -1) 

1266 

1267 base_matern = oti.mul(sigma_f_sq, oti.mul(f_prime_r, inv_r)) 

1268 if kernel_type == 'anisotropic': 

1269 if hasattr(phi, 'fused_grad_all_dims'): 

1270 scales = np.array([ln10 * ell[d] ** 2 for d in range(D)]) 

1271 grad_buf = np.zeros(D) 

1272 base_matern.fused_grad_all_dims(diffs, scales, _vdot_factors, W_proj, grad_buf, FW_T) 

1273 grad[:D] = grad_buf 

1274 elif hasattr(phi, 'fused_scale_sq_mul_sparse'): 

1275 dphi_buf = oti.zeros(phi.shape) 

1276 for d in range(D): 

1277 dphi_buf.fused_scale_sq_mul_sparse(diffs[d], base_matern, ln10 * ell[d] ** 2, d) 

1278 grad[d] = _gc_block(dphi_buf) 

1279 elif hasattr(phi, 'fused_scale_sq_mul'): 

1280 dphi_buf = oti.zeros(phi.shape) 

1281 for d in range(D): 

1282 dphi_buf.fused_scale_sq_mul(diffs[d], base_matern, ln10 * ell[d] ** 2) 

1283 grad[d] = _gc_block(dphi_buf) 

1284 else: 

1285 for d in range(D): 

1286 d_sq = oti.mul(diffs[d], diffs[d]) 

1287 dphi_d = oti.mul(ln10 * ell[d] ** 2, oti.mul(d_sq, base_matern)) 

1288 grad[d] = _gc_block(dphi_d) 

1289 else: 

1290 ell_val = ell[0] 

1291 if hasattr(phi, 'fused_sum_sq_sparse'): 

1292 sum_dsq = oti.zeros(phi.shape) 

1293 sum_dsq.fused_sum_sq_sparse(diffs) 

1294 elif hasattr(phi, 'fused_sum_sq'): 

1295 sum_dsq = oti.zeros(phi.shape) 

1296 sum_dsq.fused_sum_sq(diffs) 

1297 else: 

1298 sum_dsq = oti.mul(diffs[0], diffs[0]) 

1299 for d in range(1, D): 

1300 sum_dsq = oti.sum(sum_dsq, oti.mul(diffs[d], diffs[d])) 

1301 dphi_e = oti.mul(ln10 * ell_val ** 2, oti.mul(sum_dsq, base_matern)) 

1302 grad[0] = _gc_block(dphi_e) 

1303 

1304 elif kernel == 'SI': 

1305 kf = self.model.kernel_factory 

1306 si_prebuild = kf.SI_kernel_prebuild 

1307 

1308 if kernel_type == 'anisotropic': 

1309 ell = 10.0 ** x0[:D] 

1310 else: 

1311 ell = np.full(D, 10.0 ** float(x0[0])) 

1312 

1313 si_vals = [si_prebuild(diffs[d]) for d in range(D)] 

1314 term_vals = [oti.sum(1.0, oti.mul(ell[d], si_vals[d])) for d in range(D)] 

1315 

1316 if kernel_type == 'anisotropic': 

1317 for d in range(D): 

1318 phi_over_term = oti.div(phi, term_vals[d]) 

1319 dphi_d = oti.mul(ln10 * ell[d], 

1320 oti.mul(si_vals[d], phi_over_term)) 

1321 grad[d] = _gc_block(dphi_d) 

1322 else: 

1323 ell_val = ell[0] 

1324 acc = oti.mul(si_vals[0], oti.div(phi, term_vals[0])) 

1325 for d in range(1, D): 

1326 acc = oti.sum(acc, oti.mul(si_vals[d], 

1327 oti.div(phi, term_vals[d]))) 

1328 grad[0] = _gc_block(oti.mul(ln10 * ell_val, acc)) 

1329 

1330 return grad 

1331 

1332 def _build_K_and_phi(self, x0): 

1333 """ 

1334 Shared helper: build K (with noise), phi, n_bases, oti, diffs. 

1335 

1336 Returns (K, phi, n_bases, oti, diffs). 

1337 """ 

1338 diffs = self.model.differences_by_dim 

1339 oti = self.model.kernel_factory.oti 

1340 sigma_n_sq = (10.0 ** x0[-1]) ** 2 

1341 

1342 phi = self.model.kernel_func(diffs, x0[:-1]) 

1343 if self.model.n_order == 0: 

1344 n_bases = 0 

1345 phi_exp = phi.real[np.newaxis, :, :] 

1346 else: 

1347 n_bases = phi.get_active_bases()[-1] 

1348 deriv_order = 2 * self.model.n_order 

1349 phi_exp = self._expand_derivs(phi, n_bases, deriv_order) 

1350 

1351 self._ensure_kernel_plan(n_bases) 

1352 if self._kernel_plan is not None: 

1353 base_shape = phi.shape 

1354 self._ensure_kernel_bufs(base_shape[0]) 

1355 phi_3d = phi_exp.reshape(phi_exp.shape[0], base_shape[0], base_shape[1]) 

1356 K = utils.rbf_kernel_fast(phi_3d, self._kernel_plan, out=self._K_buf) 

1357 else: 

1358 K = utils.rbf_kernel( 

1359 phi, phi_exp, self.model.n_order, n_bases, 

1360 self.model.flattened_der_indices, self.model.powers, 

1361 index=self.model.derivative_locations, 

1362 ) 

1363 K.flat[::K.shape[0] + 1] += sigma_n_sq 

1364 K.flat[::K.shape[0] + 1] += self.model.sigma_data_sq_diag 

1365 return K, phi, n_bases, oti, diffs 

1366 

1367 

1368 def _sparse_nlml_direct(self, x0): 

1369 """ 

1370 Compute sparse NLML directly from phi_exp_3d, skipping full K 

1371 construction and permutation. 

1372 

1373 Returns (alpha_v, U, nll, phi, n_bases, oti, diffs) where the 

1374 last four are needed by _compute_grad. 

1375 """ 

1376 from jetgp.full_degp_sparse.sparse_cholesky import ( 

1377 build_U_from_phi, build_U_from_phi_flat, 

1378 build_U_supernodes_from_phi, 

1379 ) 

1380 

1381 diffs = self.model.differences_by_dim 

1382 oti = self.model.kernel_factory.oti 

1383 sigma_n_sq = (10.0 ** x0[-1]) ** 2 

1384 

1385 phi = self.model.kernel_func(diffs, x0[:-1]) 

1386 n_bases = phi.get_active_bases()[-1] 

1387 deriv_order = 2 * self.model.n_order 

1388 phi_exp = self._expand_derivs(phi, n_bases, deriv_order) 

1389 

1390 self._ensure_kernel_plan(n_bases) 

1391 base_shape = phi.shape 

1392 phi_3d = phi_exp.reshape(phi_exp.shape[0], base_shape[0], base_shape[1]) 

1393 

1394 # Build index maps (once) 

1395 self._ensure_phi_index_maps(base_shape[0]) 

1396 k_type, k_phys, deriv_lookup, sign_lookup = self._k_index_map 

1397 

1398 P_full = self.model.mmd_P_full 

1399 N_total = len(P_full) 

1400 

1401 if self.model.use_supernodes and self.model.sparse_supernodes_full is not None: 

1402 U, _ = build_U_supernodes_from_phi( 

1403 phi_3d, self.model.sparse_supernodes_full, N_total, 

1404 sigma_n_sq, 

1405 ) 

1406 else: 

1407 if self._U_buf is None or self._U_buf.shape[0] != N_total: 

1408 self._U_buf = np.zeros((N_total, N_total), order='F') 

1409 

1410 U = build_U_from_phi_flat( 

1411 phi_3d, self._block_phi_maps, N_total, 

1412 sigma_n_sq, out=self._U_buf, 

1413 ) 

1414 

1415 y_ord = self.model.y_train[P_full] 

1416 nll = nlml_from_U(U, y_ord) 

1417 

1418 alpha_ord = alpha_from_U(U, y_ord) 

1419 alpha_v = np.empty_like(alpha_ord) 

1420 alpha_v[P_full] = alpha_ord 

1421 

1422 return alpha_v, U, nll, phi, n_bases, oti, diffs 

1423 

1424 def _dense_nll_and_W(self, x0): 

1425 """ 

1426 Dense Cholesky path: build full K, factor once, compute NLL and W. 

1427 

1428 Used as a fallback when the sparsity pattern is too full for the 

1429 block-wise sparse path to be efficient. 

1430 

1431 Returns (W, alpha_v, nll, phi, n_bases, oti, diffs). 

1432 """ 

1433 K, phi, n_bases, oti, diffs = self._build_K_and_phi(x0) 

1434 N = K.shape[0] 

1435 

1436 L, low = cho_factor(K, lower=True) 

1437 alpha_v = cho_solve((L, low), self.model.y_train) 

1438 

1439 nll = (0.5 * np.dot(self.model.y_train, alpha_v) 

1440 + np.sum(np.log(np.diag(L))) 

1441 + 0.5 * N * np.log(2 * np.pi)) 

1442 

1443 K_inv = cho_solve((L, low), np.eye(N)) 

1444 W = K_inv - np.outer(alpha_v, alpha_v) 

1445 

1446 # Cache dense factors for prediction 

1447 self.model._cached_L = L 

1448 self.model._cached_low = low 

1449 self.model._cached_alpha = alpha_v 

1450 self.model._cached_U = None 

1451 self.model._cached_P = None 

1452 self.model._cached_params = x0.copy() 

1453 

1454 return W, alpha_v, nll, phi, n_bases, oti, diffs 

1455 def _sparse_U_alpha_nll(self, K): 

1456 """ 

1457 Build sparse U, compute alpha and NLML. Does NOT form K^{-1}. 

1458 

1459 Returns (alpha_v, U, nll) all in original index space. 

1460 """ 

1461 P_full = self.model.mmd_P_full 

1462 N_total = len(P_full) 

1463 

1464 if self._P_ix is None: 

1465 self._P_ix = np.ix_(P_full, P_full) 

1466 K_ord = K[self._P_ix] 

1467 y_ord = self.model.y_train[P_full] 

1468 

1469 if self.model.use_supernodes and self.model.sparse_supernodes_full is not None: 

1470 U, _ = build_U_supernodes(K_ord, self.model.sparse_supernodes_full, N_total) 

1471 else: 

1472 if self._U_buf is None or self._U_buf.shape[0] != N_total: 

1473 self._U_buf = np.zeros((N_total, N_total), order='F') 

1474 U = build_U(K_ord, self.model.sparse_S_full_arr, N_total, 

1475 block_size=self.model.n_bases + 1, out=self._U_buf) 

1476 

1477 nll = nlml_from_U(U, y_ord) 

1478 

1479 # alpha in original space 

1480 alpha_ord = alpha_from_U(U, y_ord) 

1481 alpha_v = np.empty_like(alpha_ord) 

1482 alpha_v[P_full] = alpha_ord 

1483 

1484 return alpha_v, U, nll 

1485 

1486 

1487 def _W_from_U(self, U, alpha_v): 

1488 """ 

1489 Compute W = K^{-1} - αα^T from a pre-built sparse U. 

1490 

1491 U is in MMD order, alpha_v is in original index space. 

1492 Returns W in original index space. 

1493 """ 

1494 P_full = self.model.mmd_P_full 

1495 N_total = len(P_full) 

1496 

1497 # K^{-1} = U @ U.T — exploit symmetry of result via dsyrk 

1498 # (computes upper triangle only, ~2x fewer FLOPs than dgemm) 

1499 if self._K_inv_buf is None or self._K_inv_buf.shape[0] != N_total: 

1500 self._K_inv_buf = np.empty((N_total, N_total), order='F') 

1501 K_inv_ord = blas.dsyrk(1.0, U, lower=1, 

1502 c=self._K_inv_buf, overwrite_c=1) 

1503 # No symmetrisation needed — _permute_and_subtract_outer reads 

1504 # the lower triangle directly (contiguous in Fortran-order). 

1505 

1506 W = np.empty((N_total, N_total)) 

1507 _permute_and_subtract_outer(K_inv_ord, alpha_v, P_full, W) 

1508 return W 

1509 

1510 def _sparse_W_and_alpha(self, K): 

1511 """ 

1512 Compute W = K^{-1} - αα^T and alpha using the sparse U factor. 

1513 

1514 K is in the ORIGINAL index space (size N_total × N_total). 

1515 Returns (W, alpha_v, U, nll) all in original index space. 

1516 """ 

1517 alpha_v, U, nll = self._sparse_U_alpha_nll(K) 

1518 

1519 P_full = self.model.mmd_P_full 

1520 N_total = len(P_full) 

1521 

1522 # K^{-1} in original space: K_inv_ord = U @ U^T, then permute back. 

1523 K_inv_ord = U @ U.T 

1524 K_inv = np.empty_like(K_inv_ord) 

1525 K_inv[np.ix_(P_full, P_full)] = K_inv_ord 

1526 

1527 W = K_inv - np.outer(alpha_v, alpha_v) 

1528 return W, alpha_v, U, nll 

1529 

1530 def nll_grad(self, x0): 

1531 """Analytic gradient of the NLL.""" 

1532 try: 

1533 if self.model._use_dense_factor: 

1534 W, alpha_v, nll, phi, n_bases, oti, diffs = self._dense_nll_and_W(x0) 

1535 return self._compute_grad(x0, W, phi, n_bases, oti, diffs) 

1536 else: 

1537 alpha_v, U, nll, phi, n_bases, oti, diffs = self._sparse_nlml_direct(x0) 

1538 return self._compute_grad_blockwise(x0, U, alpha_v, phi, n_bases, oti, diffs) 

1539 except Exception: 

1540 return np.zeros(len(x0)) 

1541 

1542 @profile 

1543 def nll_and_grad(self, x0): 

1544 """ 

1545 Compute NLL and its gradient in a single pass. 

1546 

1547 Routes to either the dense Cholesky path or the sparse U path 

1548 based on the sparsity pattern fill fraction. 

1549 

1550 Returns 

1551 ------- 

1552 nll : float 

1553 grad : ndarray 

1554 """ 

1555 try: 

1556 if self.model._use_dense_factor: 

1557 W, alpha_v, nll, phi, n_bases, oti, diffs = self._dense_nll_and_W(x0) 

1558 grad = self._compute_grad(x0, W, phi, n_bases, oti, diffs) 

1559 elif self.model.n_order > 0: 

1560 alpha_v, U, nll, phi, n_bases, oti, diffs = self._sparse_nlml_direct(x0) 

1561 

1562 # Cache for fast prediction (reused by degp.predict) 

1563 self.model._cached_U = U 

1564 self.model._cached_P = self.model.mmd_P_full 

1565 self.model._cached_alpha = alpha_v 

1566 self.model._cached_L = None 

1567 self.model._cached_low = None 

1568 self.model._cached_params = x0.copy() 

1569 

1570 grad = self._compute_grad_blockwise(x0, U, alpha_v, phi, n_bases, oti, diffs) 

1571 else: 

1572 K, phi, n_bases, oti, diffs = self._build_K_and_phi(x0) 

1573 alpha_v, U, nll = self._sparse_U_alpha_nll(K) 

1574 

1575 self.model._cached_U = U 

1576 self.model._cached_P = self.model.mmd_P_full 

1577 self.model._cached_alpha = alpha_v 

1578 self.model._cached_L = None 

1579 self.model._cached_low = None 

1580 self.model._cached_params = x0.copy() 

1581 

1582 W = self._W_from_U(U, alpha_v) 

1583 grad = self._compute_grad(x0, W, phi, n_bases, oti, diffs) 

1584 except Exception: 

1585 return 1e6, np.zeros(len(x0)) 

1586 

1587 if nll > 1e6: 

1588 return 1e6, np.zeros(len(x0)) 

1589 return float(nll), grad 

1590 

1591 def optimize_hyperparameters(self, 

1592 optimizer="pso", 

1593 **kwargs): 

1594 """ 

1595 Optimize the DEGP model hyperparameters using Particle Swarm Optimization (PSO). 

1596 

1597 Parameters: 

1598 ---------- 

1599 n_restart_optimizer : int, default=20 

1600 Maximum number of iterations for PSO. 

1601 swarm_size : int, default=20 

1602 Number of particles in the swarm. 

1603 verbose : bool, default=True 

1604 Controls verbosity of PSO output. 

1605 

1606 Returns: 

1607 ------- 

1608 best_x : ndarray 

1609 The optimal set of hyperparameters found. 

1610 """ 

1611 

1612 if isinstance(optimizer, str): 

1613 if optimizer not in OPTIMIZERS: 

1614 raise ValueError( 

1615 f"Unknown optimizer '{optimizer}'. Available: {list(OPTIMIZERS.keys())}" 

1616 ) 

1617 optimizer_fn = OPTIMIZERS[optimizer] 

1618 else: 

1619 optimizer_fn = optimizer # allow passing a callable directly 

1620 

1621 bounds = self.model.bounds 

1622 lb = [b[0] for b in bounds] 

1623 ub = [b[1] for b in bounds] 

1624 

1625 # Inject nll_and_grad (single Cholesky per step) for all gradient-aware optimizers. 

1626 if optimizer in ('lbfgs', 'jade', 'pso') and 'func_and_grad' not in kwargs and 'grad_func' not in kwargs: 

1627 kwargs['func_and_grad'] = self.nll_and_grad 

1628 

1629 best_x, best_val = optimizer_fn(self.nll_wrapper, lb, ub, **kwargs) 

1630 

1631 self.model.opt_x0 = best_x 

1632 self.model.opt_nll = best_val 

1633 

1634 

1635 return best_x