surrogates_interface 4.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1027 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ This module provides a model-agnostic interface to surrogate models.
4
+ """
5
+
6
+ import pickle
7
+ import warnings
8
+ from abc import abstractmethod
9
+ from enum import IntEnum, auto
10
+
11
+ import h5py
12
+ import numpy as np
13
+
14
+ from surrogates_interface.domains import * # noqa: F401,F403
15
+ from surrogates_interface.transformers import * # noqa: F401,F403
16
+
17
+ # String type for h5py.
18
+ H5_STR = h5py.special_dtype(vlen=str)
19
+
20
+
21
+ class ADMode(IntEnum):
22
+ """
23
+ Enumeration used to select if the automatic differentiation must be applied in forward or reverse mode.
24
+ """
25
+
26
+ #: Forward mode automatic differentiation.
27
+ FWD = auto()
28
+ #: Reverse mode automatic differentiation.
29
+ REV = auto()
30
+
31
+
32
+ class SurrogateModel:
33
+ """
34
+ Class that stores and evaluates an existing surrogate model.
35
+
36
+ This class cannot be used directly, because some of its functions are abstract.
37
+ For a TensorFlow model, the user should use `TensorFlowModel`.
38
+
39
+ Parameters
40
+ ----------
41
+ model : -
42
+ Surrogate model.
43
+ input_transformers : list-like of Transformer, optional
44
+ List of input transformers, which will be applied from the first till the last.
45
+ output_transformers : list-like of Transformer, optional
46
+ List of output transformers, which will be applied from the first till the last.
47
+ n_inputs : int
48
+ Number of inputs (that is, input features).
49
+ input_names : list-like of str, optional
50
+ Names of the inputs.
51
+ n_outputs : int
52
+ Number of outputs (that is, output features).
53
+ output_names : list-like of str, optional
54
+ Names of the outputs.
55
+ domain : GenericDomain
56
+ Input domain of the training data.
57
+ metadata : dict of array-like, optional
58
+ Metadata associated to the surrogate model.
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ model,
64
+ input_transformers=(),
65
+ output_transformers=(),
66
+ input_names=(),
67
+ output_names=(),
68
+ n_inputs=None,
69
+ n_outputs=None,
70
+ domain=None,
71
+ metadata={},
72
+ ):
73
+ """
74
+
75
+
76
+ Parameters
77
+ ----------
78
+ model : -
79
+ Surrogate model.
80
+ input_transformers : list-like of Transformer, optional
81
+ List of input transformers, which will be applied from the first till the last.
82
+ output_transformers : list-like of Transformer, optional
83
+ List of output transformers, which will be applied from the first till the last.
84
+ input_names : list-like of str, optional
85
+ Names of the input channels.
86
+ If not provided, the inputs will be named `x_0`, `x_1`, ..., `x_n_inputs`.
87
+ output_names : list-like of str, optional
88
+ Names of the output channels.
89
+ If not provided, the outputs will be named `y_0`, `y_1`, ..., `y_n_outputs`.
90
+ n_inputs : int, optional
91
+ Number of inputs (that is, input features). If not provided it will be inferred from `input_names`.
92
+ It is used to compute the Jacobian and by the OpenMDAO wrappers.
93
+ n_outputs : int, optional
94
+ Number of outputs (that is, output features). If not provided it will be inferred from `output_names`.
95
+ It is used to compute the Jacobian and by the OpenMDAO wrappers.
96
+ domain : GenericDomain
97
+ Input domain of the training data.
98
+ metadata : dict of array-like, optional
99
+ Metadata associated to the surrogate model.
100
+ """
101
+ self.model = model
102
+ self.input_transformers = input_transformers
103
+ self.output_transformers = output_transformers
104
+ self.domain = domain
105
+ self.metadata = {k: np.asarray(v) for k, v in metadata.items()}
106
+ if len(input_names) > 0:
107
+ self.input_names = input_names
108
+ if n_inputs is not None:
109
+ assert n_inputs == len(
110
+ input_names
111
+ ), "The length of input_names must match n_inputs."
112
+ self.n_inputs = n_inputs
113
+ else:
114
+ self.n_inputs = len(input_names)
115
+ else:
116
+ if n_inputs is not None:
117
+ self.n_inputs = n_inputs
118
+ self.input_names = [f"x_{i}" for i in range(n_inputs)]
119
+ if len(output_names) > 0:
120
+ self.output_names = output_names
121
+ if n_outputs is not None:
122
+ assert n_outputs == len(
123
+ output_names
124
+ ), "The length of output_names must match n_outputs."
125
+ self.n_outputs = n_outputs
126
+ else:
127
+ self.n_outputs = len(output_names)
128
+ else:
129
+ if n_outputs is not None:
130
+ self.n_outputs = n_outputs
131
+ self.output_names = [f"y_{i}" for i in range(n_outputs)]
132
+
133
+ def _save_model(self, file_name, *args, **kwargs):
134
+ """
135
+ Save a generic surrogate model.
136
+
137
+ Parameters
138
+ ----------
139
+ file_name : str, path-like
140
+ File path where the model will be saved.
141
+ *args : list
142
+ Additional `args` are passed to the function that saves the model.
143
+ **kwargs : dict
144
+ Additional `kwargs` are passed to the function that saves the model.
145
+
146
+ Returns
147
+ -------
148
+ None.
149
+
150
+ """
151
+ # Protected because normally the user does not want to call it.
152
+ raise NotImplementedError()
153
+
154
+ def save_h5(self, model_path, extra_data_path=None, *args, **kwargs):
155
+ """
156
+ Save the SurrogateModel object.
157
+
158
+ The model will be stored into 2 files, one for the surrogate model itself,
159
+ and the other for the extra data (input/output transformers, channel names, ...).
160
+ The file for the extra data is saved in HDF5 format.
161
+
162
+ Parameters
163
+ ----------
164
+ model_path : str
165
+ File path where the model will be saved.
166
+ extra_data_path : str, bytes, h5f.FileID, None
167
+ Name of file (bytes or str), or an instance of `h5f.FileID` to bind to an existing file identifier,
168
+ or a file-like object where the extra data will be saved.
169
+ If None (default), the extra data is appended to the model h5 file
170
+ *args : -
171
+ Additional `args` are passed to `_save_model()`, which saves the surrogate model itself.
172
+ **kwargs : -
173
+ Additional `kwargs` are passed to `_save_model()`, which saves the surrogate model itself.
174
+
175
+ Returns
176
+ -------
177
+ None.
178
+ """
179
+ # Save the surrogate model.
180
+ self._save_model(model_path, *args, **kwargs)
181
+
182
+ # Test if the model file is in HDF5 format, and therefore we can append the extra data.
183
+ # This code is not covered because it raises the exception with TensorFlow 2.15, but does not with TensorFlow 2.10.
184
+ # The associated test is: test_tensorflow_and_openmdao._test_save_h5_extra_data_to_non_h5_model_error
185
+ if extra_data_path is None and not h5py.is_hdf5(model_path): # pragma: no cover
186
+ raise ValueError( # pragma: no cover
187
+ "The extra data cannot be appended to the model file, because it is not a hdf5 file. Please specify the extra_data_path argument." # pragma: no cover
188
+ ) # pragma: no cover
189
+
190
+ extra_data_path = extra_data_path or model_path
191
+ # Save the extra data.
192
+ with h5py.File(
193
+ extra_data_path, ["x", "r+"][extra_data_path == model_path]
194
+ ) as fid:
195
+ fid.create_dataset("model_type", data=self.__class__.__name__, dtype=H5_STR)
196
+ fid.create_dataset("input_names", data=self.input_names, dtype=H5_STR)
197
+ fid.create_dataset("output_names", data=self.output_names, dtype=H5_STR)
198
+ fid.create_dataset("n_inputs", data=self.n_inputs, dtype="i8")
199
+ fid.create_dataset("n_outputs", data=self.n_outputs, dtype="i8")
200
+
201
+ grp_meta = fid.create_group("metadata")
202
+ for key, val in self.metadata.items():
203
+ grp_meta.create_dataset(key, data=val)
204
+
205
+ grp_in = fid.create_group("input_transformers")
206
+ for i in range(len(self.input_transformers)):
207
+ group = grp_in.create_group(f"transformer_{i}")
208
+ self.input_transformers[i]._save_h5(group)
209
+
210
+ grp_out = fid.create_group("output_transformers")
211
+ for i in range(len(self.output_transformers)):
212
+ group = grp_out.create_group(f"transformer_{i}")
213
+ self.output_transformers[i]._save_h5(group)
214
+
215
+ grp_domain = fid.create_group("domain")
216
+ if self.domain:
217
+ self.domain._save_h5(grp_domain)
218
+
219
+ @staticmethod
220
+ def _load_model(file_name, *args, **kwargs):
221
+ """
222
+ Load a generic surrogate model.
223
+
224
+ Parameters
225
+ ----------
226
+ file_name : str
227
+ File path where the model was saved.
228
+ *args : list
229
+ Additional `args` are passed to the function that loads the model.
230
+ **kwargs : dict
231
+ Additional `kwargs` are passed to the function that loads the model.
232
+
233
+ Returns
234
+ -------
235
+ model : -
236
+ Surrogate model.
237
+ """
238
+ # Protected because normally the user does not want to call it.
239
+ raise NotImplementedError()
240
+
241
+ @classmethod
242
+ def load_h5(cls, model_path, extra_data_path=None, *args, **kwargs):
243
+ """
244
+ Load a surrogate model.
245
+
246
+ Parameters
247
+ ----------
248
+ model_path : str
249
+ File path where the model was saved.
250
+ extra_data_path : str, None
251
+ File path where the extra data were saved.
252
+ If None, default, extra data is read from the model h5 file
253
+ *args : -
254
+ Additional `args` are passed to `_load_model()`, which loads the surrogate model itself.
255
+ **kwargs : -
256
+ Additional `kwargs` are passed to `_load_model()`, which saves the surrogate model itself.
257
+
258
+ Returns
259
+ -------
260
+ obj : SurrogateModel
261
+ The surrogate model that was saved.
262
+ """
263
+
264
+ # Load extra data.
265
+ with h5py.File(extra_data_path or model_path, "r") as fid:
266
+ model_type = fid["model_type"].asstr()[()]
267
+ if cls.__name__ != model_type:
268
+ raise ValueError(
269
+ f"The model type in the extra data file ({model_type}) does not match the current one ({cls.__name__})."
270
+ )
271
+
272
+ input_names = [s for s in fid["input_names"].asstr()[()]]
273
+ output_names = [s for s in fid["output_names"].asstr()[()]]
274
+ if "n_inputs" in fid:
275
+ n_inputs = fid["n_inputs"][()]
276
+ elif len(input_names) > 0:
277
+ n_inputs = len(input_names)
278
+ else:
279
+ # Older versions did not include the number of inputs.
280
+ n_inputs = None
281
+ warnings.warn(
282
+ "This file does not contain the number of inputs. Some features may be unavailable."
283
+ )
284
+ if "n_outputs" in fid:
285
+ n_outputs = fid["n_outputs"][()]
286
+ elif len(output_names) > 0:
287
+ n_outputs = len(output_names)
288
+ else:
289
+ # Older versions did not include the number of outputs.
290
+ n_outputs = None
291
+ warnings.warn(
292
+ "This file does not contain the number of outputs. Some features may be unavailable."
293
+ )
294
+ metadata = {k: np.array(fid["metadata"][k]) for k in fid["metadata"]}
295
+ # Look for the correct transformer class based on the input transformer type.
296
+ input_transformers = [
297
+ globals()[fid["input_transformers"][name]["type"].asstr()[()]]._load_h5(
298
+ fid["input_transformers"][name]
299
+ )
300
+ for name in fid["input_transformers"]
301
+ ]
302
+ # Look for the correct transformer class based on the output transformer type.
303
+ output_transformers = [
304
+ globals()[
305
+ fid["output_transformers"][name]["type"].asstr()[()]
306
+ ]._load_h5(fid["output_transformers"][name])
307
+ for name in fid["output_transformers"]
308
+ ]
309
+ # First, check if the domain exists, to allow loading files generated pre 2.2.0.
310
+ # Then, check if type exists, to determine whether there is actually a domain.
311
+ # Finally, look for the correct domain class based on the domain type.
312
+ if "domain" in fid and "type" in fid["domain"]:
313
+ domain = globals()[fid["domain"]["type"].asstr()[()]]._load_h5(
314
+ fid["domain"]
315
+ )
316
+ else:
317
+ domain = None
318
+
319
+ # Load model.
320
+ model = cls._load_model(model_path, *args, **kwargs)
321
+
322
+ return cls(
323
+ model,
324
+ input_transformers=input_transformers,
325
+ output_transformers=output_transformers,
326
+ input_names=input_names,
327
+ output_names=output_names,
328
+ n_inputs=n_inputs,
329
+ n_outputs=n_outputs,
330
+ metadata=metadata,
331
+ domain=domain,
332
+ )
333
+
334
+ @abstractmethod
335
+ def _predict_model_output(self, x):
336
+ """
337
+ Predict the surrogate model output. Typically, surrogates are trained on transformed input and output data,
338
+ therefore this function expects a transformed input and will return a transformed output.
339
+ """
340
+ # Protected because normally the user does not want to call it.
341
+ # Any surrogate must be evaluated. That is why this method is abstract.
342
+
343
+ def predict_output(self, x):
344
+ r"""
345
+ Predict the complete model output, including all input and output transformations.
346
+
347
+ We assume that the input :math:`x` has undergone a series of transformation :math:`f_i`
348
+
349
+ .. math::
350
+ \tilde{x} = f_n \circ \cdots \circ f_2 \circ f_1 \circ x
351
+
352
+ Similarly, also the output :math:`y` has been transformed as
353
+
354
+ .. math::
355
+ \tilde{y} = g_m \circ \cdots \circ g_2 \circ g_1 \circ y
356
+
357
+ Then, a surrogate model :math:`s` has been trained on the transformed input and output.
358
+
359
+ .. math::
360
+ \hat{\tilde{y}} = s(\tilde{x}) \simeq \tilde{y}
361
+
362
+ The output in the original space is thus reconstructed as
363
+
364
+ .. math::
365
+ \hat{y} = g_1^{-1} \circ g_2^{-1} \circ \cdots \circ g_m^{-1} \circ \hat{\tilde{y}}
366
+
367
+
368
+ Parameters
369
+ ----------
370
+ x : array-like of shape (n_points, n_inputs)
371
+ Input data.
372
+
373
+ Returns
374
+ -------
375
+ y : array-like of shape (n_points, n_outputs)
376
+ Output data.
377
+ """
378
+ # Check if the points are inside the domain of the training data.
379
+ if self.domain is not None:
380
+ x_in_domain = self.domain.in_domain(x)
381
+ if not np.all(x_in_domain):
382
+ # n_x_outside = x.shape[0] - np.count_nonzero(x_in_domain)
383
+ fraction_outside = 1.0 - np.count_nonzero(x_in_domain) / x.shape[0]
384
+ warnings.warn(
385
+ f"{fraction_outside:.1%} of points are outside of the input domain."
386
+ )
387
+
388
+ # Apply direct transformations to input.
389
+ x_transformed = x.copy()
390
+ for transformer in self.input_transformers:
391
+ # If possible do the transformation inplace.
392
+ try:
393
+ transformer.transform(x_transformed, inplace=True)
394
+ except ValueError:
395
+ # The PCA does not support inplace.
396
+ x_transformed = transformer.transform(x_transformed, inplace=False)
397
+
398
+ # Evaluate surrogate model.
399
+ y = self._predict_model_output(x_transformed)
400
+
401
+ # Apply inverse transformations to output.
402
+ for transformer in reversed(self.output_transformers):
403
+ # If possible do the inverse transformation inplace.
404
+ try:
405
+ transformer.inverse_transform(y, inplace=True)
406
+ except ValueError:
407
+ # The PCA does not support inplace.
408
+ y = transformer.inverse_transform(y, inplace=False)
409
+ return y
410
+
411
+ def _predict_model_jacobian(self, x, *args, **kwargs):
412
+ """
413
+ Predict the surrogate model output and Jacobian.
414
+
415
+ Typically, surrogates are trained on transformed input and output data,
416
+ therefore this function expects a transformed input and will return a transformed output.
417
+ """
418
+ # Protected because normally the user does not want to call it.
419
+ raise NotImplementedError()
420
+
421
+ def predict_output_and_jacobian(self, x, *args, **kwargs):
422
+ """
423
+ Predict the complete model output and Jacobian, including all input and output transformations.
424
+
425
+ Parameters
426
+ ----------
427
+ x : array-like of shape (n_points, n_inputs)
428
+ Input data.
429
+ *args : -
430
+ Additional `args` are passed to `_predict_model_jacobian()`.
431
+ **kwargs : -
432
+ Additional `kwargs` are passed to `_predict_model_jacobian()`.
433
+
434
+ Returns
435
+ -------
436
+ y : array-like of shape (n_points, n_outputs)
437
+ Output.
438
+ jacobian : array-like of shape (n_points, n_outputs, n_inputs)
439
+ Jacobian.
440
+ """
441
+ # Check if the points are inside the domain of the training data.
442
+ if self.domain is not None:
443
+ x_in_domain = self.domain.in_domain(x)
444
+ if not np.all(x_in_domain):
445
+ # n_x_outside = x.shape[0] - np.count_nonzero(x_in_domain)
446
+ fraction_outside = 1.0 - np.count_nonzero(x_in_domain) / x.shape[0]
447
+ warnings.warn(
448
+ f"{fraction_outside:.1%} of points are outside of the input domain."
449
+ )
450
+
451
+ # Apply direct transformations to input.
452
+ x_transformed = x.copy()
453
+ # The input transformation Jacobian starts as an identity matrix,
454
+ # repeated over all points.
455
+ input_transformation_jacobian = np.tile(np.eye(x.shape[1]), (x.shape[0], 1, 1))
456
+ for transformer in self.input_transformers:
457
+ # Get Jacobian of current input transformation.
458
+ in_jac = transformer.transform_gradient(x_transformed)
459
+ # Accumulate the input transformations Jacobian.
460
+ if in_jac.ndim == 2:
461
+ # If the Jacobian has shape (n_points, n_in), it means it the features are independent.
462
+ # Do the multiplication as if in_jac for each point is a diagonal matrix.
463
+ input_transformation_jacobian *= in_jac[:, np.newaxis, :]
464
+
465
+ else: # in_jac.ndim == 3:
466
+ # If the Jacobian has shape (n_points, n_out, n_in), it means that the features are dependent.
467
+ # Therefore the chain rule is a matrix multiplication.
468
+ input_transformation_jacobian = in_jac @ input_transformation_jacobian
469
+
470
+ # Apply the input transformation.
471
+ try:
472
+ transformer.transform(x_transformed, inplace=True)
473
+ except ValueError:
474
+ # The PCA does not support inplace.
475
+ x_transformed = transformer.transform(x_transformed, inplace=False)
476
+
477
+ # Predict the model output and Jacobian.
478
+ y, jacobian = self._predict_model_jacobian(x_transformed, *args, **kwargs)
479
+
480
+ # Apply the input transformation Jacobian via the chain rule.
481
+ jacobian = jacobian @ input_transformation_jacobian
482
+
483
+ # Apply the inverse output transformation Jacobian via the chain rule.
484
+ for transformer in reversed(self.output_transformers):
485
+ out_jac = transformer.inverse_transform_gradient(y)
486
+
487
+ # Accumulate the output transformations Jacobian.
488
+ if out_jac.ndim == 2:
489
+ # If the Jacobian has shape (n_points, n_out), it means it the features are independent.
490
+ # Do the multiplication as if out_jac for each point is a diagonal matrix.
491
+ jacobian *= out_jac[:, :, np.newaxis]
492
+
493
+ else: # out_jac.ndim == 3:
494
+ # If the Jacobian has shape (n_points, n_out, n_in), it means that the features are dependent.
495
+ # Therefore the chain rule is a matrix multiplication.
496
+ jacobian = out_jac @ jacobian
497
+
498
+ # Apply the inverse output transformation.
499
+ try:
500
+ transformer.inverse_transform(y, inplace=True)
501
+ except ValueError:
502
+ # The PCA does not support inplace.
503
+ y = transformer.inverse_transform(y, inplace=False)
504
+ return y, jacobian
505
+
506
+ def predict_jacobian(self, x, *args, **kwargs):
507
+ """
508
+ Predict the complete model Jacobian, including all input and output transformations.
509
+
510
+ Parameters
511
+ ----------
512
+ x : array-like of shape (n_points, n_inputs)
513
+ Input data.
514
+ *args : -
515
+ Additional `args` are passed to `_predict_model_jacobian()`.
516
+ **kwargs : -
517
+ Additional `kwargs` are passed to `_predict_model_jacobian()`.
518
+
519
+ Returns
520
+ -------
521
+ jacobian : array-like of shape (n_points, n_outputs, n_inputs)
522
+ Jacobian.
523
+ """
524
+ _, jacobian = self.predict_output_and_jacobian(x, *args, **kwargs)
525
+ return jacobian
526
+
527
+ def _predict_model_vjp(self, x, dy):
528
+ r"""
529
+ Predict the surrogate model output and vector-Jacobian product.
530
+
531
+ Computes
532
+
533
+ .. math::
534
+ \begin{align}
535
+ \mathrm{d}\boldsymbol{x} &= J(\boldsymbol{x})^{\mathsf{T}} \mathrm{d}\boldsymbol{y}
536
+ \\
537
+ \mathrm{d}\boldsymbol{x}^{\mathsf{T}} &= \mathrm{d}\boldsymbol{y}^{\mathsf{T}} J(\boldsymbol{x})^{\mathsf{T}}
538
+ \end{align}
539
+ """
540
+ # Protected because normally the user does not want to call it.
541
+ raise NotImplementedError()
542
+
543
+ def predict_vjp(self, x, dy):
544
+ """
545
+ Predict the surrogate model vector-Jacobian product, including all input and output transformations.
546
+
547
+ Parameters
548
+ ----------
549
+ x : array-like of shape (n_points, n_inputs)
550
+ Input data.
551
+ dy : array-like of shape (n_points, n_outputs)
552
+ Output variation (seed). This function is meant to work with OpenMDAO, where `dy` is a one-hot array,
553
+ which means that `dy` is 1 for a given point and output channel and 0 for all others.
554
+
555
+ Returns
556
+ -------
557
+ dx : array-like of shape (n_points, n_inputs)
558
+ Input variation.
559
+ """
560
+ # Find the hot point and input channel.
561
+ idx = dy.nonzero()
562
+ try:
563
+ i_point = idx[0][0]
564
+ # i_out = idx[1][0]
565
+ except IndexError:
566
+ # Early exit if the seed is 0.
567
+ return np.zeros((dy.shape[0], self.n_inputs), dtype=dy.dtype)
568
+
569
+ # Check if the hot point is inside the domain of the training data.
570
+ if self.domain is not None:
571
+ x_in_domain = self.domain.in_domain(x[[i_point], :])
572
+ if not x_in_domain[0]:
573
+ warnings.warn("The hot point is outside of the input domain.")
574
+
575
+ # Take one input point, apply input transformations and accumulate the Jacobian of the transformations.
576
+ x_transformed = x[[i_point], :].copy()
577
+ input_transformation_jacobian = np.ones((1, self.n_inputs), dtype=x.dtype)
578
+ for transformer in self.input_transformers:
579
+ input_transformation_jacobian *= transformer.transform_gradient(
580
+ x_transformed
581
+ )
582
+ transformer.transform(x_transformed, inplace=True)
583
+
584
+ # Compute VJP for a single point.
585
+ # y_i has shape (1, n_outputs)
586
+ # dx_i has shape (1, n_inputs)
587
+ y_i, dx_i = self._predict_model_vjp(x_transformed, dy[[i_point], :])
588
+
589
+ # Apply input transformation jacobian via chain rule.
590
+ dx_i *= input_transformation_jacobian
591
+
592
+ # Apply inverse output transformations and accumulate their VJP.
593
+ for transformer in reversed(self.output_transformers):
594
+ dx_i *= transformer.inverse_transform_gradient(y_i) @ dy[i_point, :]
595
+ transformer.inverse_transform(y_i, inplace=True)
596
+
597
+ # Preallocate and assign dx.
598
+ dx = np.zeros((dy.shape[0], self.n_inputs), dtype=dy.dtype)
599
+ dx[[i_point], :] = dx_i
600
+ return dx
601
+
602
+ @abstractmethod
603
+ def _predict_model_jvp(self, x, dx):
604
+ r"""
605
+ Predict the surrogate model output and Jacobian-vector product.
606
+
607
+ Computes
608
+
609
+ .. math::
610
+ \mathrm{d}\boldsymbol{y} = J(\boldsymbol{x}) \mathrm{d}\boldsymbol{x}
611
+ """
612
+ # Protected because normally the user does not want to call it.
613
+ raise NotImplementedError()
614
+
615
+ def predict_jvp(self, x, dx):
616
+ """
617
+ Predict the surrogate model Jacobian-vector product, including all input and output transformations.
618
+
619
+ Parameters
620
+ ----------
621
+ x : array-like of shape (n_points, n_inputs)
622
+ Input data.
623
+ dx : array-like of shape (n_points, n_inputs)
624
+ Input variation (seed). This function is meant to work with OpenMDAO, where `dx` is a one-hot array,
625
+ which means that `dx` is 1 for a given point and input channel and 0 for all others.
626
+
627
+ Returns
628
+ -------
629
+ dy : array-like of shape (n_points, n_outputs)
630
+ Output variation.
631
+ """
632
+ # Find the hot point and input channel.
633
+ idx = dx.nonzero()
634
+ try:
635
+ i_point = idx[0][0]
636
+ # i_in = idx[1][0]
637
+ except IndexError:
638
+ # Early exit if the seed is 0.
639
+ return np.zeros((dx.shape[0], self.n_outputs), dtype=x.dtype)
640
+
641
+ # Check if the hot point is inside the domain of the training data.
642
+ if self.domain is not None:
643
+ x_in_domain = self.domain.in_domain(x[[i_point], :])
644
+ if not x_in_domain[0]:
645
+ warnings.warn("The hot point is outside of the input domain.")
646
+
647
+ # Take one input point, apply input transformations and accumulate JVP of the transformations.
648
+ x_transformed = x[[i_point], :].copy()
649
+ input_transformation_jvp = np.array([1.0], dtype=x.dtype)
650
+ for transformer in self.input_transformers:
651
+ input_transformation_jvp *= (
652
+ transformer.transform_gradient(x_transformed) @ dx[i_point, :]
653
+ )
654
+ transformer.transform(x_transformed, inplace=True)
655
+
656
+ # Compute JVP for a single point.
657
+ # y_i and dy_i have shape (1, n_outputs)
658
+ y_i, dy_i = self._predict_model_jvp(x_transformed, dx[[i_point], :])
659
+
660
+ # Apply input transformation JVP via chain rule.
661
+ dy_i *= input_transformation_jvp
662
+
663
+ # Apply inverse output transformations and accumulate their Jacobians.
664
+ for transformer in reversed(self.output_transformers):
665
+ dy_i *= transformer.inverse_transform_gradient(y_i)
666
+ transformer.inverse_transform(y_i, inplace=True)
667
+
668
+ # Preallocate and assign dy.
669
+ dy = np.zeros((dx.shape[0], self.n_outputs), dtype=dx.dtype)
670
+ dy[[i_point], :] = dy_i
671
+ return dy
672
+
673
+ def __eq__(self, other):
674
+ # TODO: check also the model.
675
+ if isinstance(other, self.__class__):
676
+ test = True
677
+ try:
678
+ np.testing.assert_equal(self.metadata, other.metadata)
679
+ except AssertionError:
680
+ test = False
681
+ if test:
682
+ for a, b in zip(self.input_transformers, other.input_transformers):
683
+ test = test and (a == b)
684
+ if test:
685
+ for a, b in zip(self.output_transformers, other.output_transformers):
686
+ test = test and (a == b)
687
+ if test:
688
+ for a, b in zip(self.input_names, other.input_names):
689
+ test = test and (a == b)
690
+ if test:
691
+ for a, b in zip(self.output_names, other.output_names):
692
+ test = test and (a == b)
693
+ test = test and (self.n_inputs == other.n_inputs)
694
+ test = test and (self.n_outputs == other.n_outputs)
695
+ test = test and (self.domain == other.domain)
696
+
697
+ return test
698
+ return False
699
+
700
+
701
+ class TensorFlowModel(SurrogateModel):
702
+ """
703
+ Class that stores and evaluates an existing TensorFlow `Model`. Typically used with a `Sequential` model.
704
+ """
705
+
706
+ @staticmethod
707
+ def _load_model(filepath, custom_objects=None, compile=True, options=None):
708
+ """
709
+ Loads a TensorFlow model saved via `model.save()`.
710
+ Wrapper of `tf.keras.models.load_model`.
711
+
712
+ Parameters
713
+ ----------
714
+ filepath: str, pathlib.Path, h5py.File
715
+ Path to the model.
716
+ custom_objects: dict
717
+ Dictionary mapping names (strings) to custom classes or
718
+ functions to be considered during deserialization.
719
+ compile: bool
720
+ `True` to compile the model after loading.
721
+ options: tf.saved_model.LoadOptions
722
+ Object that specifies options for loading from SavedModel.
723
+
724
+ Returns
725
+ -------
726
+ tf.keras.Model
727
+ A Keras model instance. If the original model was compiled, and saved
728
+ with the optimizer, then the returned model will be compiled. Otherwise,
729
+ the model will be left uncompiled. In the case that an uncompiled model
730
+ is returned, a warning is displayed if the `compile` argument is set to `True`.
731
+
732
+ Raises
733
+ ------
734
+ ImportError
735
+ If loading from an hdf5 file and h5py is not available.
736
+ IOError
737
+ In case of an invalid savefile.
738
+ """
739
+ from tensorflow.keras.models import load_model
740
+
741
+ return load_model(filepath, custom_objects, compile, options)
742
+
743
+ def _save_model(self, filepath, overwrite=True, save_format="h5"):
744
+ """
745
+ Saves the model to Tensorflow SavedModel or a single HDF5 file.
746
+ Wrapper of tf.keras.Model.save`.
747
+
748
+ Parameters
749
+ ----------
750
+ filepath : str, path-like
751
+ Path to `SavedModel` or `H5` file to save the model.
752
+ overwrite : bool, optional
753
+ `True` to silently overwrite any existing file at the
754
+ target location, or provide the user with a manual prompt.
755
+ save_format : str, optional
756
+ Either `'keras'`, `'tf'` or `'h5'`, indicating whether to
757
+ save the model in the native Keras format (.keras), in the
758
+ TensorFlow SavedModel format (referred to as "SavedModel" below),
759
+ or in the legacy HDF5 format (.h5). Defaults to `'h5'`.
760
+
761
+ Returns
762
+ -------
763
+ None.
764
+
765
+ """
766
+ self.model.save(filepath, overwrite=overwrite, save_format=save_format)
767
+
768
+ def _predict_model_output(self, x):
769
+ """
770
+ Predict the model output.
771
+
772
+ Parameters
773
+ ----------
774
+ x : array-like of shape (n_points, n_inputs)
775
+ Input data.
776
+
777
+ Returns
778
+ -------
779
+ y : array-like of shape (n_points, n_outputs)
780
+ Output data.
781
+ """
782
+ # TODO: Add support also for other arguments of predict, like use_multiprocessing.
783
+ y = self.model.predict(x, batch_size=x.shape[0], verbose=0)
784
+ return y
785
+
786
+ def _predict_model_output_and_jacobian_rev(self, x):
787
+ """
788
+ Predict the model Jacobian using reverse mode automatic differentiation,
789
+ assuming that all batches (i.e. points) are independent.
790
+
791
+ Parameters
792
+ ----------
793
+ x : array-like of shape (n_points, n_inputs)
794
+ Input data.
795
+
796
+ Returns
797
+ -------
798
+ y : array-like of shape (n_points, n_outputs)
799
+ Output.
800
+ jacobian : array-like of shape (n_points, n_outputs, n_inputs)
801
+ Jacobian.
802
+ """
803
+ import tensorflow as tf
804
+
805
+ input = tf.constant(x)
806
+ with tf.GradientTape() as tape:
807
+ tape.watch(input)
808
+ output = self.model(input)
809
+ jacobian = tape.batch_jacobian(output, input).numpy()
810
+ return output.numpy(), jacobian
811
+
812
+ def _predict_model_output_and_jacobian_fwd(self, x):
813
+ """
814
+ Predict the model Jacobian using forward mode automatic differentiation,
815
+ assuming that all batches (i.e. points) are independent.
816
+
817
+ Parameters
818
+ ----------
819
+ x : array-like of shape (n_points, n_inputs)
820
+ Input data.
821
+
822
+ Returns
823
+ -------
824
+ y : array-like of shape (n_points, n_outputs)
825
+ Output.
826
+ jacobian : array-like of shape (n_points, n_outputs, n_inputs)
827
+ Jacobian.
828
+ """
829
+ import tensorflow as tf
830
+
831
+ # Preallocate jacobian array.
832
+ jacobian = np.zeros((x.shape[0], self.n_outputs, self.n_inputs))
833
+ # Transform the input into a Tensor, and convert to single precision.
834
+ input = tf.constant(x, dtype=tf.float32)
835
+ # Define the direction for the derivatives.
836
+ # The direction will be broadcasted over the points.
837
+ tangents = np.zeros((x.shape[1]), dtype=np.float32)
838
+ # We start from the first input.
839
+ tangents[0] = 1
840
+ with tf.autodiff.ForwardAccumulator(
841
+ input, tf.broadcast_to(tangents, x.shape)
842
+ ) as acc:
843
+ output = self.model(input)
844
+ jacobian[:, :, 0] = acc.jvp(output).numpy()
845
+ # And proceed with the others.
846
+ for i_in in range(1, self.n_inputs):
847
+ # Reset the direction.
848
+ tangents[i_in - 1] = 0
849
+ # Set the current one.
850
+ tangents[i_in] = 1
851
+ with tf.autodiff.ForwardAccumulator(
852
+ input, tf.broadcast_to(tangents, x.shape)
853
+ ) as acc:
854
+ output = self.model(input)
855
+ jacobian[:, :, i_in] = acc.jvp(output).numpy()
856
+ return output.numpy(), jacobian
857
+
858
+ def _predict_model_jacobian(self, x, ad_mode=None):
859
+ """
860
+ Predict the model Jacobian, assuming that all batches (i.e. points) are independent.
861
+
862
+ Parameters
863
+ ----------
864
+ x : array-like of shape (n_points, n_inputs)
865
+ Input data.
866
+ ad_mode : ADMode, optional
867
+ Automatic differentiation mode. Can be either `ADMode.FWD` or `ADMode.REV`.
868
+ If `None`, the choice depends on the number of input and output featurtes.
869
+
870
+ Returns
871
+ -------
872
+ y : array-like of shape (n_points, n_outputs)
873
+ Output.
874
+ jacobian : array-like of shape (n_points, n_outputs, n_inputs)
875
+ Jacobian.
876
+ """
877
+ if ad_mode is None:
878
+ if self.n_outputs <= self.n_inputs:
879
+ ad_mode = ADMode.REV
880
+ else:
881
+ ad_mode = ADMode.FWD
882
+ if ad_mode is ADMode.REV:
883
+ y, jacobian = self._predict_model_output_and_jacobian_rev(x)
884
+ elif ad_mode is ADMode.FWD:
885
+ y, jacobian = self._predict_model_output_and_jacobian_fwd(x)
886
+ return y, jacobian
887
+
888
+ def _predict_model_vjp(self, x, dy):
889
+ """
890
+ Compute output and vector-Jacobian product using reverse-mode
891
+ automatic differentiation, i.e. backpropagation.
892
+
893
+ Parameters
894
+ ----------
895
+ x : array-like of shape (1, n_inputs)
896
+ Input data.
897
+ dy : array-like of shape (1, n_outputs)
898
+ Output variation.
899
+
900
+ Returns
901
+ -------
902
+ y : array-like of shape (1, n_outputs)
903
+ Output.
904
+ dx : array-like of shape (1, n_inputs)
905
+ Input variation.
906
+ """
907
+ import tensorflow as tf
908
+
909
+ # Compute the Jacobian.
910
+ input = tf.constant(x)
911
+ with tf.GradientTape() as tape:
912
+ tape.watch(input)
913
+ output = self.model(input)
914
+ jacobian = tape.batch_jacobian(output, input).numpy()
915
+ # Compute the product.
916
+ dummy = jacobian.transpose(0, 2, 1) @ dy[:, :, np.newaxis]
917
+ dx = dummy[:, :, 0]
918
+ return output.numpy(), dx
919
+
920
+ def _predict_model_jvp(self, x, dx):
921
+ """
922
+ Compute output and Jacobian-vector product using forward-mode
923
+ automatic differentiation.
924
+
925
+ Parameters
926
+ ----------
927
+ x : array-like of shape (1, n_inputs)
928
+ Input data.
929
+ dx : array-like of shape (1, n_inputs)
930
+ Input variation.
931
+
932
+ Returns
933
+ -------
934
+ y : array-like of shape (1, n_outputs)
935
+ Output.
936
+ dy : array-like of shape (1, n_outputs)
937
+ Output variation.
938
+ """
939
+ import tensorflow as tf
940
+
941
+ # Take the current point, and convert to float32 or otherwise
942
+ # ForwardAccumulator will crash.
943
+ input = tf.constant(x, dtype=tf.float32)
944
+ tangents = tf.constant(dx, dtype=tf.float32)
945
+ with tf.autodiff.ForwardAccumulator(input, tangents) as acc:
946
+ output = self.model(input)
947
+ dy = acc.jvp(output).numpy()
948
+ return output.numpy(), dy
949
+
950
+
951
+ class SMTModel(SurrogateModel):
952
+ """
953
+ Class that stores and evaluates an existing SMT `SurrogateModel`.
954
+ """
955
+
956
+ @staticmethod
957
+ def _load_model(filepath):
958
+ with open(filepath, "rb") as f:
959
+ return pickle.load(f)
960
+ # TODO: deal with C++ models.
961
+
962
+ def _save_model(self, filepath):
963
+ from smt.surrogate_models import IDW, RBF, RMTB, RMTC
964
+
965
+ if isinstance(self.model, (IDW, RBF, RMTB, RMTC)):
966
+ raise NotImplementedError()
967
+ else:
968
+ # Pure Python models are saved with pickle.
969
+ with open(filepath, "wb") as fid:
970
+ pickle.dump(self.model, fid)
971
+
972
+ def _predict_model_output(self, x):
973
+ """
974
+ Predict the model output.
975
+
976
+ Parameters
977
+ ----------
978
+ x : array-like of shape (n_points, n_inputs)
979
+ Input data.
980
+
981
+ Returns
982
+ -------
983
+ y : array-like of shape (n_points, n_outputs)
984
+ Output data.
985
+ """
986
+ # Switch off printing.
987
+ print_save = self.model.options["print_prediction"]
988
+ self.model.options["print_prediction"] = False
989
+ # Predict output.
990
+ y = self.model.predict_values(x)
991
+ # Restore printing option.
992
+ self.model.options["print_prediction"] = print_save
993
+
994
+ return y
995
+
996
+ def _predict_model_jacobian(self, x):
997
+ """
998
+ Predict the model Jacobian.
999
+
1000
+ Parameters
1001
+ ----------
1002
+ x : array-like of shape (n_points, n_inputs)
1003
+ Input data.
1004
+
1005
+ Returns
1006
+ -------
1007
+ y : array-like of shape (n_points, n_outputs)
1008
+ Output.
1009
+ jacobian : array-like of shape (n_points, n_outputs, n_inputs)
1010
+ Jacobian.
1011
+ """
1012
+ # Switch off printing.
1013
+ print_save = self.model.options["print_prediction"]
1014
+ self.model.options["print_prediction"] = False
1015
+ # Predict output.
1016
+ y = self.model.predict_values(x)
1017
+ # Predict Jacobian by looping over the inputs.
1018
+ jacobian = np.empty((x.shape[0], self.n_outputs, self.n_inputs))
1019
+ for i in range(self.n_inputs):
1020
+ jacobian[:, :, i] = self.model.predict_derivatives(x, i)
1021
+ # Restore printing option.
1022
+ self.model.options["print_prediction"] = print_save
1023
+ return y, jacobian
1024
+
1025
+
1026
+ class SurrogateModelFamily:
1027
+ pass