Coverage for jetgp/wdegp/optimizer.py: 80%
700 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-04-11 00:01 -0500
« prev ^ index » next coverage.py v7.10.7, created at 2026-04-11 00:01 -0500
1import numpy as np
2from scipy.linalg import cho_solve, cho_factor
3from line_profiler import profile
4import jetgp.utils as gen_utils
5from jetgp.hyperparameter_optimizers import OPTIMIZERS
6from jetgp.utils import matern_kernel_grad_builder
9class Optimizer:
10 """
11 Optimizer class for fitting the hyperparameters of a weighted derivative-enhanced GP model (wDEGP)
12 by minimizing the negative log marginal likelihood (NLL).
14 Supports DEGP, DDEGP, and GDDEGP modes.
16 Attributes
17 ----------
18 model : object
19 Instance of a weighted derivative-enhanced GP model (wDEGP) with attributes:
20 x_train, y_train, n_order, n_bases, der_indices, index, bounds, submodel_type, etc.
21 """
23 def __init__(self, model):
24 """
25 Parameters
26 ----------
27 model : object
28 An instance of a wDEGP model containing training data, hyperparameter bounds,
29 and other model-specific structures required for kernel computation.
30 """
31 self.model = model
33 # Import the appropriate utils module based on submodel_type
34 self._setup_utils()
36 # Sparse fused OTI functions only valid for DEGP (axis-aligned diffs)
37 self._sparse_safe = getattr(self.model, 'submodel_type', 'degp') == 'degp'
39 # Precompute kernel plans (structural info that never changes)
40 self._kernel_plans = None # lazily initialized on first NLL call
41 self._deriv_buf = None
42 self._deriv_buf_shape = None
43 self._deriv_buf_ndir = None
44 self._deriv_factors = None
45 self._deriv_factors_key = None
46 self._K_bufs = None # per-submodel pre-allocated K buffers
47 self._dK_bufs = None # per-submodel pre-allocated dK buffers
48 self._W_proj_buf = None
49 self._W_proj_shape = None
51 def _get_deriv_buf(self, phi, n_bases, order):
52 if self._deriv_buf_ndir is None:
53 from math import comb
54 self._deriv_buf_ndir = comb(n_bases + order, order)
55 shape = (self._deriv_buf_ndir, phi.shape[0], phi.shape[1])
56 if self._deriv_buf is None or self._deriv_buf_shape != shape:
57 self._deriv_buf = np.zeros(shape, dtype=np.float64)
58 self._deriv_buf_shape = shape
59 return self._deriv_buf
61 @profile
62 def _expand_derivs(self, phi, n_bases, deriv_order):
63 """Expand OTI derivatives, using fast struct path if available."""
64 if hasattr(phi, 'get_all_derivs_fast'):
65 buf = self._get_deriv_buf(phi, n_bases, deriv_order)
66 factors = self._get_deriv_factors(n_bases, deriv_order)
67 return phi.get_all_derivs_fast(factors, buf)
68 return phi.get_all_derivs(n_bases, deriv_order)
70 @staticmethod
71 def _enum_factors(max_basis, ordi):
72 from math import factorial
73 from collections import Counter
74 if ordi == 1:
75 for _ in range(max_basis):
76 yield 1.0
77 return
78 for last in range(1, max_basis + 1):
79 if ordi == 2:
80 for i in range(1, last + 1):
81 counts = Counter((i, last))
82 f = 1
83 for c in counts.values():
84 f *= factorial(c)
85 yield float(f)
86 else:
87 for _, prefix_counts in Optimizer._enum_factors_with_counts(last, ordi - 1):
88 counts = dict(prefix_counts)
89 counts[last] = counts.get(last, 0) + 1
90 f = 1
91 for c in counts.values():
92 f *= factorial(c)
93 yield float(f)
95 @staticmethod
96 def _enum_factors_with_counts(max_basis, ordi):
97 from math import factorial
98 from collections import Counter
99 if ordi == 1:
100 for i in range(1, max_basis + 1):
101 yield 1.0, {i: 1}
102 return
103 for last in range(1, max_basis + 1):
104 for _, prefix_counts in Optimizer._enum_factors_with_counts(last, ordi - 1):
105 counts = dict(prefix_counts)
106 counts[last] = counts.get(last, 0) + 1
107 f = 1
108 for c in counts.values():
109 f *= factorial(c)
110 yield float(f), counts
112 def _get_deriv_factors(self, n_bases, order):
113 key = (n_bases, order)
114 if self._deriv_factors is not None and self._deriv_factors_key == key:
115 return self._deriv_factors
116 factors = [1.0]
117 for ordi in range(1, order + 1):
118 factors.extend(self._enum_factors(n_bases, ordi))
119 self._deriv_factors = np.array(factors, dtype=np.float64)
120 self._deriv_factors_key = key
121 return self._deriv_factors
123 def _setup_utils(self):
124 """Set up the correct utils module based on submodel_type."""
125 submodel_type = getattr(self.model, 'submodel_type', 'degp')
127 if submodel_type == 'degp':
128 from jetgp.wdegp import wdegp_utils
129 self.utils = wdegp_utils
130 self._uses_signs = True
131 elif submodel_type == 'ddegp':
132 from jetgp.full_ddegp import wddegp_utils
133 self.utils = wddegp_utils
134 self._uses_signs = True
135 elif submodel_type == 'gddegp':
136 from jetgp.full_gddegp import wgddegp_utils
137 self.utils = wgddegp_utils
138 self._uses_signs = False
139 else:
140 # Default to degp
141 from jetgp.wdegp import wdegp_utils
142 self.utils = wdegp_utils
143 self._uses_signs = True
145 def _ensure_kernel_plans(self, n_bases):
146 """Lazily precompute kernel plans for all submodels (once per n_bases)."""
147 if self._kernel_plans is not None and self._kernel_plans_n_bases == n_bases:
148 return
149 if not hasattr(self.utils, 'precompute_kernel_plan'):
150 self._kernel_plans = None
151 return
152 plans = []
153 index = self.model.derivative_locations
154 for i in range(len(index)):
155 plan = self.utils.precompute_kernel_plan(
156 self.model.n_order, n_bases,
157 self.model.flattened_der_indices[i],
158 self.model.powers[i],
159 index[i],
160 )
161 plans.append(plan)
162 self._kernel_plans = plans
163 self._kernel_plans_n_bases = n_bases
164 # Reset buffers when plans change
165 self._K_bufs = None
166 self._dK_bufs = None
168 def _ensure_kernel_bufs(self, n_rows_func):
169 """Pre-allocate reusable K and dK buffers for each submodel."""
170 if self._kernel_plans is None:
171 return
172 if self._K_bufs is not None:
173 return # already allocated
174 self._K_bufs = []
175 self._dK_bufs = []
176 for plan in self._kernel_plans:
177 total = n_rows_func + plan['n_pts_with_derivs']
178 self._K_bufs.append(np.empty((total, total)))
179 self._dK_bufs.append(np.empty((total, total)))
180 if 'row_offsets_abs' not in plan:
181 plan['row_offsets_abs'] = plan['row_offsets'] + n_rows_func
182 plan['col_offsets_abs'] = plan['col_offsets'] + n_rows_func
184 @profile
185 def negative_log_marginal_likelihood(
186 self,
187 x0,
188 x_train,
189 y_train,
190 n_order,
191 n_bases,
192 der_indices,
193 index,
194 ):
195 """
196 Computes the negative log marginal likelihood (NLL) for a given hyperparameter vector.
198 NLL = 0.5 * y^T (K^-1) y + 0.5 * log|K| + 0.5*N*log(2*pi)
200 Parameters
201 ----------
202 x0 : ndarray
203 Log-scaled hyperparameter vector, where the last entry is log10(sigma_n).
204 x_train : list of ndarrays
205 Input training points (unused inside loop, included for general interface).
206 y_train : list of ndarrays
207 List of function and derivative training values for each submodel.
208 n_order : int
209 Maximum order of derivatives used.
210 n_bases : int
211 Number of Taylor bases used in the expansion.
212 der_indices : list
213 Multi-index derivative information.
214 index : list of lists
215 Indices partitioning the training data into submodels (derivative_locations).
217 Returns
218 -------
219 float
220 The computed negative log marginal likelihood.
221 """
222 ell = x0[:-1]
223 sigma_n = x0[-1]
224 llhood = 0
225 # ell[0] = 0
226 # ell[1] = 0
227 # ell[2] = 0
228 # sigma_n = -16
229 diffs = self.model.differences_by_dim
230 phi = self.model.kernel_func(diffs, ell)
231 if self.model.n_order == 0:
232 n_bases = 0
233 phi_exp = phi.real
234 phi_exp = phi_exp[np.newaxis, :, :]
235 else:
236 n_bases = phi.get_active_bases()[-1]
238 # Extract ALL derivative components
239 deriv_order = 2 * n_order
240 phi_exp = self._expand_derivs(phi, n_bases, deriv_order)
242 # Ensure kernel plans are precomputed
243 self._ensure_kernel_plans(n_bases)
244 use_fast = self._kernel_plans is not None
246 # Pre-reshape phi_exp to 3D once
247 if use_fast:
248 base_shape = phi.shape
249 self._ensure_kernel_bufs(base_shape[0])
250 phi_exp_3d = phi_exp.reshape(phi_exp.shape[0], base_shape[0], base_shape[1])
252 for i in range(len(index)):
253 y_train_sub = y_train[i]
255 if use_fast:
256 K = self.utils.rbf_kernel_fast(phi_exp_3d, self._kernel_plans[i], out=self._K_bufs[i])
257 else:
258 K = self.utils.rbf_kernel(
259 phi, phi_exp, n_order, n_bases,
260 self.model.flattened_der_indices[i],
261 self.model.powers[i], index=index[i]
262 )
264 K.flat[::K.shape[0] + 1] += (10 ** sigma_n) ** 2
266 try:
267 L, low = cho_factor(K, lower=True)
268 alpha = cho_solve(
269 (L, low),
270 y_train_sub
271 )
273 data_fit = 0.5 * np.dot(y_train_sub.flatten(), alpha.flatten())
274 log_det = np.sum(np.log(np.diag(L)))
275 const = 0.5 * len(y_train_sub) * np.log(2 * np.pi)
277 llhood += data_fit + log_det + const
278 except np.linalg.LinAlgError:
279 llhood += 1e6 # Penalize badly conditioned matrices
281 return llhood
283 def nll_wrapper(self, x0):
284 """
285 Wrapper for NLL function to fit PSO optimizer interface.
287 Parameters
288 ----------
289 x0 : ndarray
290 Hyperparameter vector.
292 Returns
293 -------
294 float
295 Computed NLL value.
296 """
297 return self.negative_log_marginal_likelihood(
298 x0,
299 self.model.x_train,
300 self.model.y_train_normalized,
301 self.model.n_order,
302 self.model.n_bases,
303 self.model.der_indices,
304 self.model.derivative_locations,
305 )
307 def nll_grad(self, x0):
308 """Analytic gradient of the NLL w.r.t. log10-scaled hyperparameters."""
309 ln10 = np.log(10.0)
311 kernel = self.model.kernel
312 kernel_type = self.model.kernel_type
313 D = len(self.model.differences_by_dim)
314 sigma_n_sq = (10.0 ** x0[-1]) ** 2
315 diffs = self.model.differences_by_dim
316 oti = self.model.kernel_factory.oti
317 index = self.model.derivative_locations
319 phi = self.model.kernel_func(diffs, x0[:-1])
320 if self.model.n_order == 0:
321 n_bases = 0
322 phi_exp = phi.real[np.newaxis, :, :]
323 else:
324 n_bases = phi.get_active_bases()[-1]
325 deriv_order = 2 * self.model.n_order
326 phi_exp = self._expand_derivs(phi, n_bases, deriv_order)
328 # Ensure kernel plans are precomputed
329 self._ensure_kernel_plans(n_bases)
330 use_fast = self._kernel_plans is not None
332 # Pre-reshape phi_exp to 3D once
333 if use_fast:
334 base_shape = phi.shape
335 self._ensure_kernel_bufs(base_shape[0])
336 phi_exp_3d = phi_exp.reshape(phi_exp.shape[0], base_shape[0], base_shape[1])
338 # Build per-submodel W matrices
339 W_list = []
340 for i in range(len(index)):
341 y_train_sub = self.model.y_train_normalized[i]
342 if use_fast:
343 K = self.utils.rbf_kernel_fast(phi_exp_3d, self._kernel_plans[i], out=self._K_bufs[i])
344 else:
345 K = self.utils.rbf_kernel(
346 phi, phi_exp, self.model.n_order, n_bases,
347 self.model.flattened_der_indices[i],
348 self.model.powers[i], index=index[i]
349 )
350 K.flat[::K.shape[0] + 1] += sigma_n_sq
351 try:
352 L, low = cho_factor(K, lower=True)
353 alpha_v = cho_solve((L, low), y_train_sub)
354 N = len(y_train_sub)
355 K_inv = cho_solve((L, low), np.eye(N))
356 W_list.append(K_inv - np.outer(alpha_v, alpha_v))
357 except Exception:
358 return np.zeros(len(x0))
360 grad = np.zeros(len(x0))
362 # Precompute W projected into phi_exp space (sum over submodels)
363 W_proj = None
364 if use_fast and self.model.n_order > 0:
365 from math import comb
366 ndir = comb(n_bases + deriv_order, deriv_order)
367 proj_shape = (ndir, base_shape[0], base_shape[1])
368 if self._W_proj_buf is None or self._W_proj_shape != proj_shape:
369 self._W_proj_buf = np.empty(proj_shape)
370 self._W_proj_shape = proj_shape
371 W_proj = self._W_proj_buf
372 W_proj[:] = 0.0
373 for i in range(len(index)):
374 plan = self._kernel_plans[i]
375 row_off = plan.get('row_offsets_abs', plan['row_offsets'] + base_shape[0])
376 col_off = plan.get('col_offsets_abs', plan['col_offsets'] + base_shape[1])
377 args = [
378 W_list[i], W_proj, base_shape[0], base_shape[1],
379 plan['fd_flat_indices'], plan['df_flat_indices'],
380 plan['dd_flat_indices'],
381 plan['idx_flat'], plan['idx_offsets'], plan['index_sizes'],
382 ]
383 if self._uses_signs:
384 args.append(plan['signs'])
385 args.extend([plan['n_deriv_types'], row_off, col_off])
386 self.utils._project_W_to_phi_space_accum(*args)
388 _use_vdot_fused = W_proj is not None and hasattr(phi, 'vdot_expand_fast')
389 FW_T = None
390 if _use_vdot_fused:
391 _vdot_factors = self._get_deriv_factors(n_bases, deriv_order)
392 _vdot_arr = np.asarray(_vdot_factors)
393 ndir_d = len(_vdot_arr)
394 FW_T = np.empty((base_shape[0] * base_shape[1], ndir_d))
395 np.multiply(W_proj.reshape(ndir_d, -1).T, _vdot_arr, out=FW_T)
397 def _gc(dphi):
398 if _use_vdot_fused:
399 return 0.5 * dphi.vdot_expand_fast(_vdot_factors, W_proj)
400 if self.model.n_order == 0:
401 dphi_exp = dphi.real[np.newaxis, :, :]
402 else:
403 dphi_exp = self._expand_derivs(dphi, n_bases, deriv_order)
404 if W_proj is not None:
405 dphi_3d = dphi_exp.reshape(W_proj.shape)
406 return 0.5 * np.vdot(W_proj, dphi_3d)
407 elif use_fast:
408 dphi_3d = dphi_exp.reshape(dphi_exp.shape[0], base_shape[0], base_shape[1])
409 total = 0.0
410 for i in range(len(index)):
411 dK = self.utils.rbf_kernel_fast(dphi_3d, self._kernel_plans[i], out=self._dK_bufs[i])
412 total += np.vdot(W_list[i], dK)
413 return 0.5 * total
414 else:
415 total = 0.0
416 for i in range(len(index)):
417 dK = self.utils.rbf_kernel(
418 dphi, dphi_exp,
419 self.model.n_order, n_bases,
420 self.model.flattened_der_indices[i],
421 self.model.powers[i],
422 index=index[i],
423 )
424 total += np.vdot(W_list[i], dK)
425 return 0.5 * total
427 grad[-2] = _gc(oti.mul(2.0 * ln10, phi))
428 grad[-1] = ln10 * sigma_n_sq * sum(np.trace(W) for W in W_list)
430 if kernel == 'SE':
431 if kernel_type == 'anisotropic':
432 ell = 10.0 ** x0[:D]
433 if self._sparse_safe and _use_vdot_fused and hasattr(phi, 'fused_grad_all_dims'):
434 scales = np.array([-ln10 * ell[d] ** 2 for d in range(D)])
435 grad_buf = np.zeros(D)
436 phi.fused_grad_all_dims(diffs, scales, _vdot_factors, W_proj, grad_buf, FW_T)
437 grad[:D] = grad_buf
438 elif self._sparse_safe and hasattr(phi, 'fused_scale_sq_mul_sparse'):
439 dphi_buf = oti.zeros(phi.shape)
440 for d in range(D):
441 dphi_buf.fused_scale_sq_mul_sparse(diffs[d], phi, -ln10 * ell[d] ** 2, d)
442 grad[d] = _gc(dphi_buf)
443 elif hasattr(phi, 'fused_scale_sq_mul'):
444 dphi_buf = oti.zeros(phi.shape)
445 for d in range(D):
446 dphi_buf.fused_scale_sq_mul(diffs[d], phi, -ln10 * ell[d] ** 2)
447 grad[d] = _gc(dphi_buf)
448 else:
449 for d in range(D):
450 grad[d] = _gc(oti.mul(-ln10 * ell[d] ** 2,
451 oti.mul(oti.mul(diffs[d], diffs[d]), phi)))
452 else:
453 ell = 10.0 ** float(x0[0])
454 if self._sparse_safe and hasattr(phi, 'fused_sum_sq_sparse'):
455 sum_sq = oti.zeros(phi.shape)
456 sum_sq.fused_sum_sq_sparse(diffs)
457 elif hasattr(phi, 'fused_sum_sq'):
458 sum_sq = oti.zeros(phi.shape)
459 sum_sq.fused_sum_sq(diffs)
460 else:
461 sum_sq = oti.mul(diffs[0], diffs[0])
462 for d in range(1, D):
463 sum_sq = oti.sum(sum_sq, oti.mul(diffs[d], diffs[d]))
464 grad[0] = _gc(oti.mul(-ln10 * ell ** 2, oti.mul(sum_sq, phi)))
466 elif kernel == 'RQ':
467 if kernel_type == 'anisotropic':
468 ell = 10.0 ** x0[:D]; alpha_rq = 10.0 ** float(x0[D]); alpha_idx = D
469 else:
470 ell = np.full(D, 10.0 ** float(x0[0]))
471 alpha_rq = np.exp(float(x0[1])); alpha_idx = 1
472 if self._sparse_safe and hasattr(phi, 'fused_sqdist_sparse'):
473 r2 = oti.zeros(phi.shape)
474 ell_sq = np.ascontiguousarray(ell ** 2, dtype=np.float64)
475 r2.fused_sqdist_sparse(diffs, ell_sq)
476 elif hasattr(phi, 'fused_sqdist'):
477 r2 = oti.zeros(phi.shape)
478 ell_sq = np.ascontiguousarray(ell ** 2, dtype=np.float64)
479 r2.fused_sqdist(diffs, ell_sq)
480 else:
481 r2 = oti.mul(ell[0], diffs[0]); r2 = oti.mul(r2, r2)
482 for d in range(1, D):
483 td = oti.mul(ell[d], diffs[d]); r2 = oti.sum(r2, oti.mul(td, td))
484 base = oti.sum(1.0, oti.mul(r2, 1.0 / (2.0 * alpha_rq)))
485 inv_base = oti.pow(base, -1)
486 phi_over_base = oti.mul(phi, inv_base)
487 if kernel_type == 'anisotropic':
488 if self._sparse_safe and _use_vdot_fused and hasattr(phi, 'fused_grad_all_dims'):
489 scales = np.array([-ln10 * ell[d] ** 2 for d in range(D)])
490 grad_buf = np.zeros(D)
491 phi_over_base.fused_grad_all_dims(diffs, scales, _vdot_factors, W_proj, grad_buf, FW_T)
492 grad[:D] = grad_buf
493 elif self._sparse_safe and hasattr(phi, 'fused_scale_sq_mul_sparse'):
494 dphi_buf = oti.zeros(phi.shape)
495 for d in range(D):
496 dphi_buf.fused_scale_sq_mul_sparse(diffs[d], phi_over_base, -ln10 * ell[d] ** 2, d)
497 grad[d] = _gc(dphi_buf)
498 elif hasattr(phi, 'fused_scale_sq_mul'):
499 dphi_buf = oti.zeros(phi.shape)
500 for d in range(D):
501 dphi_buf.fused_scale_sq_mul(diffs[d], phi_over_base, -ln10 * ell[d] ** 2)
502 grad[d] = _gc(dphi_buf)
503 else:
504 for d in range(D):
505 grad[d] = _gc(oti.mul(-ln10 * ell[d] ** 2,
506 oti.mul(oti.mul(diffs[d], diffs[d]), phi_over_base)))
507 else:
508 if self._sparse_safe and hasattr(phi, 'fused_sum_sq_sparse'):
509 sum_sq = oti.zeros(phi.shape)
510 sum_sq.fused_sum_sq_sparse(diffs)
511 elif hasattr(phi, 'fused_sum_sq'):
512 sum_sq = oti.zeros(phi.shape)
513 sum_sq.fused_sum_sq(diffs)
514 else:
515 sum_sq = oti.mul(diffs[0], diffs[0])
516 for d in range(1, D):
517 sum_sq = oti.sum(sum_sq, oti.mul(diffs[d], diffs[d]))
518 grad[0] = _gc(oti.mul(-ln10 * ell[0] ** 2, oti.mul(sum_sq, phi_over_base)))
519 log_base = oti.log(base)
520 term = oti.sub(oti.sub(1.0, inv_base), log_base)
521 alpha_factor = ln10 * alpha_rq if kernel_type == 'anisotropic' else alpha_rq
522 grad[alpha_idx] = _gc(oti.mul(alpha_factor, oti.mul(phi, term)))
524 elif kernel == 'SineExp':
525 if kernel_type == 'anisotropic':
526 ell = 10.0 ** x0[:D]; p = 10.0 ** x0[D:2*D]
527 pip = np.pi / p; p_start = D
528 else:
529 ell = np.full(D, 10.0 ** float(x0[0]))
530 pip = np.full(D, np.pi / 10.0 ** float(x0[1])); p_start = 1
531 sin_d = [oti.sin(oti.mul(pip[d], diffs[d])) for d in range(D)]
532 cos_d = [oti.cos(oti.mul(pip[d], diffs[d])) for d in range(D)]
533 if kernel_type == 'anisotropic':
534 if hasattr(phi, 'fused_scale_sq_mul'):
535 dphi_buf = oti.zeros(phi.shape)
536 for d in range(D):
537 dphi_buf.fused_scale_sq_mul(sin_d[d], phi, -4.0 * ln10 * ell[d] ** 2)
538 grad[d] = _gc(dphi_buf)
539 else:
540 for d in range(D):
541 grad[d] = _gc(oti.mul(-4.0 * ln10 * ell[d] ** 2,
542 oti.mul(oti.mul(sin_d[d], sin_d[d]), phi)))
543 for d in range(D):
544 sc = oti.mul(sin_d[d], oti.mul(cos_d[d], diffs[d]))
545 grad[p_start + d] = _gc(oti.mul(4.0 * ln10 * ell[d] ** 2 * pip[d],
546 oti.mul(sc, phi)))
547 else:
548 if hasattr(phi, 'fused_sum_sq'):
549 ss = oti.zeros(phi.shape)
550 ss.fused_sum_sq(sin_d)
551 else:
552 ss = oti.mul(sin_d[0], sin_d[0])
553 for d in range(1, D):
554 ss = oti.sum(ss, oti.mul(sin_d[d], sin_d[d]))
555 grad[0] = _gc(oti.mul(-4.0 * ln10 * ell[0] ** 2, oti.mul(ss, phi)))
556 scd = oti.mul(sin_d[0], oti.mul(cos_d[0], diffs[0]))
557 for d in range(1, D):
558 scd = oti.sum(scd, oti.mul(sin_d[d], oti.mul(cos_d[d], diffs[d])))
559 grad[p_start] = _gc(oti.mul(4.0 * ln10 * ell[0] ** 2 * pip[0],
560 oti.mul(scd, phi)))
562 elif kernel == 'Matern':
563 kf = self.model.kernel_factory
564 if not hasattr(kf, '_matern_grad_prebuild'):
565 kf._matern_grad_prebuild = matern_kernel_grad_builder(getattr(kf, "nu", 1.5), oti_module=oti)
566 ell = (10.0 ** x0[:D] if kernel_type == 'anisotropic'
567 else np.full(D, 10.0 ** float(x0[0])))
568 sigma_f_sq = (10.0 ** float(x0[-2])) ** 2
569 _eps = 1e-10
570 if self._sparse_safe and hasattr(phi, 'fused_sqdist_sparse'):
571 r2 = oti.zeros(phi.shape)
572 ell_sq = np.ascontiguousarray(ell ** 2, dtype=np.float64)
573 r2.fused_sqdist_sparse(diffs, ell_sq)
574 elif hasattr(phi, 'fused_sqdist'):
575 r2 = oti.zeros(phi.shape)
576 ell_sq = np.ascontiguousarray(ell ** 2, dtype=np.float64)
577 r2.fused_sqdist(diffs, ell_sq)
578 else:
579 r2 = oti.mul(ell[0], diffs[0]); r2 = oti.mul(r2, r2)
580 for d in range(1, D):
581 td = oti.mul(ell[d], diffs[d]); r2 = oti.sum(r2, oti.mul(td, td))
582 r_oti = oti.sqrt(oti.sum(r2, _eps ** 2))
583 f_prime_r = kf._matern_grad_prebuild(r_oti)
584 inv_r = oti.pow(r_oti, -1)
585 base_matern = oti.mul(sigma_f_sq, oti.mul(f_prime_r, inv_r))
586 if kernel_type == 'anisotropic':
587 if self._sparse_safe and _use_vdot_fused and hasattr(phi, 'fused_grad_all_dims'):
588 scales = np.array([ln10 * ell[d] ** 2 for d in range(D)])
589 grad_buf = np.zeros(D)
590 base_matern.fused_grad_all_dims(diffs, scales, _vdot_factors, W_proj, grad_buf, FW_T)
591 grad[:D] = grad_buf
592 elif self._sparse_safe and hasattr(phi, 'fused_scale_sq_mul_sparse'):
593 dphi_buf = oti.zeros(phi.shape)
594 for d in range(D):
595 dphi_buf.fused_scale_sq_mul_sparse(diffs[d], base_matern, ln10 * ell[d] ** 2, d)
596 grad[d] = _gc(dphi_buf)
597 elif hasattr(phi, 'fused_scale_sq_mul'):
598 dphi_buf = oti.zeros(phi.shape)
599 for d in range(D):
600 dphi_buf.fused_scale_sq_mul(diffs[d], base_matern, ln10 * ell[d] ** 2)
601 grad[d] = _gc(dphi_buf)
602 else:
603 for d in range(D):
604 d_sq = oti.mul(diffs[d], diffs[d])
605 dphi_d = oti.mul(ln10 * ell[d] ** 2, oti.mul(d_sq, base_matern))
606 grad[d] = _gc(dphi_d)
607 else:
608 if self._sparse_safe and hasattr(phi, 'fused_sum_sq_sparse'):
609 sum_dsq = oti.zeros(phi.shape)
610 sum_dsq.fused_sum_sq_sparse(diffs)
611 elif hasattr(phi, 'fused_sum_sq'):
612 sum_dsq = oti.zeros(phi.shape)
613 sum_dsq.fused_sum_sq(diffs)
614 else:
615 sum_dsq = oti.mul(diffs[0], diffs[0])
616 for d in range(1, D):
617 sum_dsq = oti.sum(sum_dsq, oti.mul(diffs[d], diffs[d]))
618 dphi_e = oti.mul(ln10 * ell[0] ** 2, oti.mul(sum_dsq, base_matern))
619 grad[0] = _gc(dphi_e)
621 return grad
623 def nll_and_grad(self, x0):
624 """Compute NLL and its gradient in a single pass, sharing one Cholesky per submodel."""
625 ln10 = np.log(10.0)
627 kernel = self.model.kernel
628 kernel_type = self.model.kernel_type
629 D = len(self.model.differences_by_dim)
630 sigma_n_sq = (10.0 ** x0[-1]) ** 2
631 diffs = self.model.differences_by_dim
632 oti = self.model.kernel_factory.oti
633 index = self.model.derivative_locations
635 # --- shared kernel computation (done ONCE) ---
636 phi = self.model.kernel_func(diffs, x0[:-1])
637 if self.model.n_order == 0:
638 n_bases = 0
639 phi_exp = phi.real[np.newaxis, :, :]
640 else:
641 n_bases = phi.get_active_bases()[-1]
642 deriv_order = 2 * self.model.n_order
643 phi_exp = self._expand_derivs(phi, n_bases, deriv_order)
645 # Ensure kernel plans are precomputed
646 self._ensure_kernel_plans(n_bases)
647 use_fast = self._kernel_plans is not None
649 # Pre-reshape phi_exp to 3D once
650 if use_fast:
651 base_shape = phi.shape
652 self._ensure_kernel_bufs(base_shape[0])
653 phi_exp_3d = phi_exp.reshape(phi_exp.shape[0], base_shape[0], base_shape[1])
655 # --- single loop: compute NLL and W_list simultaneously ---
656 llhood = 0.0
657 W_list = []
658 for i in range(len(index)):
659 y_train_sub = self.model.y_train_normalized[i]
661 if use_fast:
662 K = self.utils.rbf_kernel_fast(phi_exp_3d, self._kernel_plans[i], out=self._K_bufs[i])
663 else:
664 K = self.utils.rbf_kernel(
665 phi, phi_exp, self.model.n_order, n_bases,
666 self.model.flattened_der_indices[i],
667 self.model.powers[i], index=index[i]
668 )
669 K.flat[::K.shape[0] + 1] += sigma_n_sq
671 try:
672 L, low = cho_factor(K, lower=True)
673 alpha_v = cho_solve((L, low), y_train_sub)
674 N = len(y_train_sub)
676 # NLL contribution
677 data_fit = 0.5 * np.dot(y_train_sub.flatten(), alpha_v.flatten())
678 log_det = np.sum(np.log(np.diag(L)))
679 const = 0.5 * N * np.log(2 * np.pi)
680 llhood += data_fit + log_det + const
682 # W matrix for gradient (reuse same Cholesky)
683 K_inv = cho_solve((L, low), np.eye(N))
684 W_list.append(K_inv - np.outer(alpha_v, alpha_v))
685 except np.linalg.LinAlgError:
686 llhood += 1e6
687 return float(llhood), np.zeros(len(x0))
689 # --- gradient from W_list (no second kernel build / Cholesky) ---
690 grad = np.zeros(len(x0))
691 n_sub = len(index)
693 # Precompute W projected into phi_exp space (sum over submodels)
694 W_proj = None
695 if use_fast and self.model.n_order > 0:
696 from math import comb
697 ndir = comb(n_bases + deriv_order, deriv_order)
698 proj_shape = (ndir, base_shape[0], base_shape[1])
699 if self._W_proj_buf is None or self._W_proj_shape != proj_shape:
700 self._W_proj_buf = np.empty(proj_shape)
701 self._W_proj_shape = proj_shape
702 W_proj = self._W_proj_buf
703 W_proj[:] = 0.0
704 for i in range(n_sub):
705 plan = self._kernel_plans[i]
706 row_off = plan.get('row_offsets_abs', plan['row_offsets'] + base_shape[0])
707 col_off = plan.get('col_offsets_abs', plan['col_offsets'] + base_shape[1])
708 args = [
709 W_list[i], W_proj, base_shape[0], base_shape[1],
710 plan['fd_flat_indices'], plan['df_flat_indices'],
711 plan['dd_flat_indices'],
712 plan['idx_flat'], plan['idx_offsets'], plan['index_sizes'],
713 ]
714 if self._uses_signs:
715 args.append(plan['signs'])
716 args.extend([plan['n_deriv_types'], row_off, col_off])
717 self.utils._project_W_to_phi_space_accum(*args)
719 _use_vdot_fused = W_proj is not None and hasattr(phi, 'vdot_expand_fast')
720 FW_T = None
721 if _use_vdot_fused:
722 _vdot_factors = self._get_deriv_factors(n_bases, deriv_order)
723 _vdot_arr = np.asarray(_vdot_factors)
724 ndir_d = len(_vdot_arr)
725 FW_T = np.empty((base_shape[0] * base_shape[1], ndir_d))
726 np.multiply(W_proj.reshape(ndir_d, -1).T, _vdot_arr, out=FW_T)
728 def _gc(dphi):
729 if _use_vdot_fused:
730 return 0.5 * dphi.vdot_expand_fast(_vdot_factors, W_proj)
731 # Precompute dphi_exp ONCE, reshape to 3D
732 if self.model.n_order == 0:
733 dphi_exp = dphi.real[np.newaxis, :, :]
734 else:
735 dphi_exp = self._expand_derivs(dphi, n_bases, deriv_order)
736 if W_proj is not None:
737 dphi_3d = dphi_exp.reshape(W_proj.shape)
738 return 0.5 * np.vdot(W_proj, dphi_3d)
739 elif use_fast:
740 dphi_3d = dphi_exp.reshape(dphi_exp.shape[0], base_shape[0], base_shape[1])
741 total = 0.0
742 for i in range(n_sub):
743 dK = self.utils.rbf_kernel_fast(dphi_3d, self._kernel_plans[i], out=self._dK_bufs[i])
744 total += np.vdot(W_list[i], dK)
745 return 0.5 * total
746 else:
747 total = 0.0
748 for i in range(n_sub):
749 dK = self.utils.rbf_kernel(
750 dphi, dphi_exp,
751 self.model.n_order, n_bases,
752 self.model.flattened_der_indices[i],
753 self.model.powers[i],
754 index=index[i],
755 )
756 total += np.vdot(W_list[i], dK)
757 return 0.5 * total
759 grad[-2] = _gc(oti.mul(2.0 * ln10, phi))
760 grad[-1] = ln10 * sigma_n_sq * sum(np.trace(W) for W in W_list)
762 if kernel == 'SE':
763 if kernel_type == 'anisotropic':
764 ell = 10.0 ** x0[:D]
765 if self._sparse_safe and _use_vdot_fused and hasattr(phi, 'fused_grad_all_dims'):
766 scales = np.array([-ln10 * ell[d] ** 2 for d in range(D)])
767 grad_buf = np.zeros(D)
768 phi.fused_grad_all_dims(diffs, scales, _vdot_factors, W_proj, grad_buf, FW_T)
769 grad[:D] = grad_buf
770 elif self._sparse_safe and hasattr(phi, 'fused_scale_sq_mul_sparse'):
771 dphi_buf = oti.zeros(phi.shape)
772 for d in range(D):
773 dphi_buf.fused_scale_sq_mul_sparse(diffs[d], phi, -ln10 * ell[d] ** 2, d)
774 grad[d] = _gc(dphi_buf)
775 elif hasattr(phi, 'fused_scale_sq_mul'):
776 dphi_buf = oti.zeros(phi.shape)
777 for d in range(D):
778 dphi_buf.fused_scale_sq_mul(diffs[d], phi, -ln10 * ell[d] ** 2)
779 grad[d] = _gc(dphi_buf)
780 else:
781 for d in range(D):
782 grad[d] = _gc(oti.mul(-ln10 * ell[d] ** 2,
783 oti.mul(oti.mul(diffs[d], diffs[d]), phi)))
784 else:
785 ell = 10.0 ** float(x0[0])
786 if self._sparse_safe and hasattr(phi, 'fused_sum_sq_sparse'):
787 sum_sq = oti.zeros(phi.shape)
788 sum_sq.fused_sum_sq_sparse(diffs)
789 elif hasattr(phi, 'fused_sum_sq'):
790 sum_sq = oti.zeros(phi.shape)
791 sum_sq.fused_sum_sq(diffs)
792 else:
793 sum_sq = oti.mul(diffs[0], diffs[0])
794 for d in range(1, D):
795 sum_sq = oti.sum(sum_sq, oti.mul(diffs[d], diffs[d]))
796 grad[0] = _gc(oti.mul(-ln10 * ell ** 2, oti.mul(sum_sq, phi)))
798 elif kernel == 'RQ':
799 if kernel_type == 'anisotropic':
800 ell = 10.0 ** x0[:D]; alpha_rq = 10.0 ** float(x0[D]); alpha_idx = D
801 else:
802 ell = np.full(D, 10.0 ** float(x0[0]))
803 alpha_rq = np.exp(float(x0[1])); alpha_idx = 1
804 if self._sparse_safe and hasattr(phi, 'fused_sqdist_sparse'):
805 r2 = oti.zeros(phi.shape)
806 ell_sq = np.ascontiguousarray(ell ** 2, dtype=np.float64)
807 r2.fused_sqdist_sparse(diffs, ell_sq)
808 elif hasattr(phi, 'fused_sqdist'):
809 r2 = oti.zeros(phi.shape)
810 ell_sq = np.ascontiguousarray(ell ** 2, dtype=np.float64)
811 r2.fused_sqdist(diffs, ell_sq)
812 else:
813 r2 = oti.mul(ell[0], diffs[0]); r2 = oti.mul(r2, r2)
814 for d in range(1, D):
815 td = oti.mul(ell[d], diffs[d]); r2 = oti.sum(r2, oti.mul(td, td))
816 base = oti.sum(1.0, oti.mul(r2, 1.0 / (2.0 * alpha_rq)))
817 inv_base = oti.pow(base, -1)
818 phi_over_base = oti.mul(phi, inv_base)
819 if kernel_type == 'anisotropic':
820 if self._sparse_safe and _use_vdot_fused and hasattr(phi, 'fused_grad_all_dims'):
821 scales = np.array([-ln10 * ell[d] ** 2 for d in range(D)])
822 grad_buf = np.zeros(D)
823 phi_over_base.fused_grad_all_dims(diffs, scales, _vdot_factors, W_proj, grad_buf, FW_T)
824 grad[:D] = grad_buf
825 elif self._sparse_safe and hasattr(phi, 'fused_scale_sq_mul_sparse'):
826 dphi_buf = oti.zeros(phi.shape)
827 for d in range(D):
828 dphi_buf.fused_scale_sq_mul_sparse(diffs[d], phi_over_base, -ln10 * ell[d] ** 2, d)
829 grad[d] = _gc(dphi_buf)
830 elif hasattr(phi, 'fused_scale_sq_mul'):
831 dphi_buf = oti.zeros(phi.shape)
832 for d in range(D):
833 dphi_buf.fused_scale_sq_mul(diffs[d], phi_over_base, -ln10 * ell[d] ** 2)
834 grad[d] = _gc(dphi_buf)
835 else:
836 for d in range(D):
837 grad[d] = _gc(oti.mul(-ln10 * ell[d] ** 2,
838 oti.mul(oti.mul(diffs[d], diffs[d]), phi_over_base)))
839 else:
840 if self._sparse_safe and hasattr(phi, 'fused_sum_sq_sparse'):
841 sum_sq = oti.zeros(phi.shape)
842 sum_sq.fused_sum_sq_sparse(diffs)
843 elif hasattr(phi, 'fused_sum_sq'):
844 sum_sq = oti.zeros(phi.shape)
845 sum_sq.fused_sum_sq(diffs)
846 else:
847 sum_sq = oti.mul(diffs[0], diffs[0])
848 for d in range(1, D):
849 sum_sq = oti.sum(sum_sq, oti.mul(diffs[d], diffs[d]))
850 grad[0] = _gc(oti.mul(-ln10 * ell[0] ** 2, oti.mul(sum_sq, phi_over_base)))
851 log_base = oti.log(base)
852 term = oti.sub(oti.sub(1.0, inv_base), log_base)
853 alpha_factor = ln10 * alpha_rq if kernel_type == 'anisotropic' else alpha_rq
854 grad[alpha_idx] = _gc(oti.mul(alpha_factor, oti.mul(phi, term)))
856 elif kernel == 'SineExp':
857 if kernel_type == 'anisotropic':
858 ell = 10.0 ** x0[:D]; p = 10.0 ** x0[D:2*D]
859 pip = np.pi / p; p_start = D
860 else:
861 ell = np.full(D, 10.0 ** float(x0[0]))
862 pip = np.full(D, np.pi / 10.0 ** float(x0[1])); p_start = 1
863 sin_d = [oti.sin(oti.mul(pip[d], diffs[d])) for d in range(D)]
864 cos_d = [oti.cos(oti.mul(pip[d], diffs[d])) for d in range(D)]
865 if kernel_type == 'anisotropic':
866 if hasattr(phi, 'fused_scale_sq_mul'):
867 dphi_buf = oti.zeros(phi.shape)
868 for d in range(D):
869 dphi_buf.fused_scale_sq_mul(sin_d[d], phi, -4.0 * ln10 * ell[d] ** 2)
870 grad[d] = _gc(dphi_buf)
871 else:
872 for d in range(D):
873 grad[d] = _gc(oti.mul(-4.0 * ln10 * ell[d] ** 2,
874 oti.mul(oti.mul(sin_d[d], sin_d[d]), phi)))
875 for d in range(D):
876 sc = oti.mul(sin_d[d], oti.mul(cos_d[d], diffs[d]))
877 grad[p_start + d] = _gc(oti.mul(4.0 * ln10 * ell[d] ** 2 * pip[d],
878 oti.mul(sc, phi)))
879 else:
880 if hasattr(phi, 'fused_sum_sq'):
881 ss = oti.zeros(phi.shape)
882 ss.fused_sum_sq(sin_d)
883 else:
884 ss = oti.mul(sin_d[0], sin_d[0])
885 for d in range(1, D):
886 ss = oti.sum(ss, oti.mul(sin_d[d], sin_d[d]))
887 grad[0] = _gc(oti.mul(-4.0 * ln10 * ell[0] ** 2, oti.mul(ss, phi)))
888 scd = oti.mul(sin_d[0], oti.mul(cos_d[0], diffs[0]))
889 for d in range(1, D):
890 scd = oti.sum(scd, oti.mul(sin_d[d], oti.mul(cos_d[d], diffs[d])))
891 grad[p_start] = _gc(oti.mul(4.0 * ln10 * ell[0] ** 2 * pip[0],
892 oti.mul(scd, phi)))
894 elif kernel == 'Matern':
895 kf = self.model.kernel_factory
896 if not hasattr(kf, '_matern_grad_prebuild'):
897 kf._matern_grad_prebuild = matern_kernel_grad_builder(getattr(kf, "nu", 1.5), oti_module=oti)
898 ell = (10.0 ** x0[:D] if kernel_type == 'anisotropic'
899 else np.full(D, 10.0 ** float(x0[0])))
900 sigma_f_sq = (10.0 ** float(x0[-2])) ** 2
901 _eps = 1e-10
902 if self._sparse_safe and hasattr(phi, 'fused_sqdist_sparse'):
903 r2 = oti.zeros(phi.shape)
904 ell_sq = np.ascontiguousarray(ell ** 2, dtype=np.float64)
905 r2.fused_sqdist_sparse(diffs, ell_sq)
906 elif hasattr(phi, 'fused_sqdist'):
907 r2 = oti.zeros(phi.shape)
908 ell_sq = np.ascontiguousarray(ell ** 2, dtype=np.float64)
909 r2.fused_sqdist(diffs, ell_sq)
910 else:
911 r2 = oti.mul(ell[0], diffs[0]); r2 = oti.mul(r2, r2)
912 for d in range(1, D):
913 td = oti.mul(ell[d], diffs[d]); r2 = oti.sum(r2, oti.mul(td, td))
914 r_oti = oti.sqrt(oti.sum(r2, _eps ** 2))
915 f_prime_r = kf._matern_grad_prebuild(r_oti)
916 inv_r = oti.pow(r_oti, -1)
917 base_matern = oti.mul(sigma_f_sq, oti.mul(f_prime_r, inv_r))
918 if kernel_type == 'anisotropic':
919 if self._sparse_safe and _use_vdot_fused and hasattr(phi, 'fused_grad_all_dims'):
920 scales = np.array([ln10 * ell[d] ** 2 for d in range(D)])
921 grad_buf = np.zeros(D)
922 base_matern.fused_grad_all_dims(diffs, scales, _vdot_factors, W_proj, grad_buf, FW_T)
923 grad[:D] = grad_buf
924 elif self._sparse_safe and hasattr(phi, 'fused_scale_sq_mul_sparse'):
925 dphi_buf = oti.zeros(phi.shape)
926 for d in range(D):
927 dphi_buf.fused_scale_sq_mul_sparse(diffs[d], base_matern, ln10 * ell[d] ** 2, d)
928 grad[d] = _gc(dphi_buf)
929 elif hasattr(phi, 'fused_scale_sq_mul'):
930 dphi_buf = oti.zeros(phi.shape)
931 for d in range(D):
932 dphi_buf.fused_scale_sq_mul(diffs[d], base_matern, ln10 * ell[d] ** 2)
933 grad[d] = _gc(dphi_buf)
934 else:
935 for d in range(D):
936 d_sq = oti.mul(diffs[d], diffs[d])
937 dphi_d = oti.mul(ln10 * ell[d] ** 2, oti.mul(d_sq, base_matern))
938 grad[d] = _gc(dphi_d)
939 else:
940 if self._sparse_safe and hasattr(phi, 'fused_sum_sq_sparse'):
941 sum_dsq = oti.zeros(phi.shape)
942 sum_dsq.fused_sum_sq_sparse(diffs)
943 elif hasattr(phi, 'fused_sum_sq'):
944 sum_dsq = oti.zeros(phi.shape)
945 sum_dsq.fused_sum_sq(diffs)
946 else:
947 sum_dsq = oti.mul(diffs[0], diffs[0])
948 for d in range(1, D):
949 sum_dsq = oti.sum(sum_dsq, oti.mul(diffs[d], diffs[d]))
950 dphi_e = oti.mul(ln10 * ell[0] ** 2, oti.mul(sum_dsq, base_matern))
951 grad[0] = _gc(dphi_e)
953 return float(llhood), grad
955 def optimize_hyperparameters(
956 self,
957 optimizer="pso",
958 **kwargs
959 ):
960 """
961 Optimize the DEGP model hyperparameters using the specified optimizer.
963 Parameters:
964 ----------
965 optimizer : str or callable, default="pso"
966 Name of optimizer or callable. Available: 'pso', 'lbfgs', 'jade', etc.
967 **kwargs : dict
968 Additional arguments passed to the optimizer.
970 Returns:
971 -------
972 best_x : ndarray
973 The optimal set of hyperparameters found.
974 """
976 if isinstance(optimizer, str):
977 if optimizer not in OPTIMIZERS:
978 raise ValueError(
979 f"Unknown optimizer '{optimizer}'. Available: {list(OPTIMIZERS.keys())}"
980 )
981 optimizer_fn = OPTIMIZERS[optimizer]
982 else:
983 optimizer_fn = optimizer # allow passing a callable directly
985 bounds = self.model.bounds
986 lb = [b[0] for b in bounds]
987 ub = [b[1] for b in bounds]
989 if optimizer in ('lbfgs', 'jade', 'pso') and 'func_and_grad' not in kwargs and 'grad_func' not in kwargs:
990 kwargs['func_and_grad'] = self.nll_and_grad
992 best_x, best_val = optimizer_fn(self.nll_wrapper, lb, ub, **kwargs)
994 self.model.opt_x0 = best_x
995 self.model.opt_nll = best_val
997 return best_x