Skip to content

Commit 6cb936a

Browse files
authored
Regselect: accept solver without explicitly setting regularization beforehand (#78)
* fix chicken-egg problem for regselect via _fit_solver() * ensure model abstracts used for regselect * DerivativeEstimator.mask(), restructure tests * use ddt_estimator.mask() in fit_regselect_continuous() for error calc * generalize mask() to any dimensional array * fix mask() usage in fit_regselect_continuous() * regression tests, converted from tutorial notebooks * add pytest, pytest-cov to dependencies for developers * ensure correct alignment of train/test data in fit_reselect_continuous() * version 0.5.14 -> 0.5.15
1 parent e44b6da commit 6cb936a

20 files changed

+1168
-434
lines changed

docs/source/opinf/changelog.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,18 @@
55
New versions may introduce substantial new features or API adjustments.
66
:::
77

8+
## Version 0.5.15
9+
10+
- Improvement to `fit_regselect_*()` so that the regularization does not have to be initialized before fitting the model. This fixes a longstanding chicken/egg problem and makes using `fit_regselect_*()` much less cumbersome.
11+
- Ensured error computation correctly aligns training and predicted states in `fit_regselect_continuous()`. Previously, this could have been misaligned when using a time derivative estimator that truncates states.
12+
- Time derivative estimators in `opinf.ddt` now have a `mask()` method that map states to the estimation grid.
13+
- Added regression tests based on the tutorial notebooks.
14+
- Added `pytest` and `pytest-cov` to the dependencies for developers.
15+
816
## Version 0.5.14
917

1018
- Catch any errors in `fit_regselect*()` that occur when the model uses `refit()`.
11-
- Tikhonov-type least-squares solvers do not require the regularizer in the constructor but will raise an `AttributeError` in `solve()` (and other methods) if the regularizer is not set. This makes using `fit_regselect_*()` much less cumbersome.
19+
- Tikhonov-type least-squares solvers do not require the regularizer in the constructor but will raise an `AttributeError` in `solve()` (and other methods) if the regularizer is not set.
1220
- `PODBasis.fit(Q)` raises a warning when using the `"method-of-snapshots"`/`"eigh"` strategy if $n < k$ for $\mathbf{Q}\in\mathbb{R}^{n \times k}.$ In this case, calculating the $n \times k$ SVD is likely more efficient than the $k \times k$ eigenvalue problem.
1321
- Added Python 3.13 to list of tests.
1422

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ dev = [
4545
"notebook",
4646
"pandas",
4747
"pre-commit>=3.7.1",
48+
"pytest",
49+
"pytest-cov",
4850
"tox>=4",
4951
]
5052

src/opinf/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
https://github.com/Willcox-Research-Group/rom-operator-inference-Python3
88
"""
99

10-
__version__ = "0.5.14"
10+
__version__ = "0.5.15"
1111

1212
from . import (
1313
basis,

src/opinf/ddt/_base.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def _check_dimensions(self, states, inputs, check_against_time=True):
104104
raise errors.DimensionalityError("states must be two-dimensional")
105105
if check_against_time and states.shape[-1] != self.time_domain.size:
106106
raise errors.DimensionalityError(
107-
"states not aligned with time_domain"
107+
"states and time_domain not aligned"
108108
)
109109
if inputs is not None:
110110
if inputs.ndim == 1:
@@ -135,6 +135,41 @@ def estimate(self, states, inputs=None):
135135
_inputs : (m, k') ndarray or None
136136
Inputs corresponding to ``_states``, if applicable.
137137
**Only returned** if ``inputs`` is provided.
138+
139+
Raises
140+
------
141+
opinf.errors.DimensionalityError
142+
If the ``states`` or ``inputs`` are not aligned with the
143+
:attr:`time_domain`.
144+
"""
145+
raise NotImplementedError # pragma: no cover
146+
147+
@abc.abstractmethod
148+
def mask(self, arr):
149+
"""Map an array from the training time domain to the domain of the
150+
estimated time derivatives.
151+
152+
This method is used in post-hoc regularization selection routines.
153+
154+
Parameters
155+
----------
156+
arr : (..., k) ndarray
157+
Array (states, inputs, etc.) aligned with the training time domain.
158+
159+
Returns
160+
-------
161+
_arr : (..., k') ndarray
162+
Array mapped to the domain of the estimated time derivatives.
163+
164+
Examples
165+
--------
166+
>>> Q, dQ = estimator.esimate(states)
167+
>>> Q2 = estimator.mask(states)
168+
>>> np.all(Q2 == Q)
169+
True
170+
>>> Q3 = estimator.mask(other_states_on_same_time_grid)
171+
>>> Q3.shape == Q.shape
172+
True
138173
"""
139174
raise NotImplementedError # pragma: no cover
140175

src/opinf/ddt/_finite_difference.py

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -774,9 +774,8 @@ class UniformFiniteDifferencer(DerivativeEstimatorTemplate):
774774
Time domain corresponding to the snapshot data.
775775
This class requires uniformly spaced time domains, see
776776
:class:`NonuniformFiniteDifferencer` for non-uniform domains.
777-
scheme : str or callable
778-
Finite difference scheme to use.
779-
**Options:**
777+
scheme : str
778+
Finite difference scheme to use. **Options:**
780779
781780
* ``'fwd1'``: first-order forward differences, see :func:`fwd1`.
782781
* ``'fwd2'``: second-order forward differences, see :func:`fwd2`.
@@ -796,17 +795,6 @@ class UniformFiniteDifferencer(DerivativeEstimatorTemplate):
796795
* ``'ord2'``: second-order differences, see :func:`ord2`.
797796
* ``'ord4'``: fourth-order differences, see :func:`ord4`.
798797
* ``'ord6'``: sixth-order differences, see :func:`ord6`.
799-
800-
If ``scheme`` is a callable function, its signature must match the
801-
following syntax.
802-
803-
.. code-block:: python
804-
805-
_states, ddts = scheme(states, dt)
806-
_states, ddts, _inputs = scheme(states, dt, inputs)
807-
808-
Here ``dt`` is a positive float, the uniform time step.
809-
Each output should have the same number of columns.
810798
"""
811799

812800
_schemes = types.MappingProxyType(
@@ -832,7 +820,7 @@ class UniformFiniteDifferencer(DerivativeEstimatorTemplate):
832820
}
833821
)
834822

835-
def __init__(self, time_domain, scheme="ord4"):
823+
def __init__(self, time_domain, scheme: str = "ord4"):
836824
"""Store the time domain and set the finite difference scheme."""
837825
DerivativeEstimatorTemplate.__init__(self, time_domain)
838826

@@ -842,13 +830,9 @@ def __init__(self, time_domain, scheme="ord4"):
842830
raise ValueError("time domain must be uniformly spaced")
843831

844832
# Set the finite difference scheme.
845-
if not callable(scheme):
846-
if scheme not in self._schemes:
847-
raise ValueError(
848-
f"invalid finite difference scheme '{scheme}'"
849-
)
850-
scheme = self._schemes[scheme]
851-
self.__scheme = scheme
833+
if scheme not in self._schemes:
834+
raise ValueError(f"invalid finite difference scheme '{scheme}'")
835+
self.__scheme = self._schemes[scheme]
852836

853837
# Properties --------------------------------------------------------------
854838
@property
@@ -899,6 +883,47 @@ def estimate(self, states, inputs=None):
899883
states, inputs = self._check_dimensions(states, inputs, False)
900884
return self.scheme(states, self.dt, inputs)
901885

886+
def mask(self, arr):
887+
"""Map an array from the training time domain to the domain of the
888+
estimated time derivatives.
889+
890+
This method is used in post-hoc regularization selection routines.
891+
892+
Parameters
893+
----------
894+
arr : (..., k) ndarray
895+
Array (states, inputs, etc.) aligned with the training time domain.
896+
897+
Returns
898+
-------
899+
_arr : (..., k') ndarray
900+
Array mapped to the domain of the estimated time derivatives.
901+
902+
Examples
903+
--------
904+
>>> Q, dQ = estimator.esimate(states)
905+
>>> Q2 = estimator.mask(states)
906+
>>> np.all(Q2 == Q)
907+
True
908+
>>> Q3 = estimator.mask(other_states_on_same_time_grid)
909+
>>> Q3.shape == Q.shape
910+
True
911+
"""
912+
schema = self.scheme.__name__
913+
mode, order = schema[:3], int(schema[3])
914+
if mode == "ord":
915+
return arr
916+
elif mode == "fwd":
917+
cols = slice(0, -order)
918+
elif mode == "bwd":
919+
cols = slice(order, None)
920+
elif mode == "ctr":
921+
margin = order // 2
922+
cols = slice(margin, -margin)
923+
else: # pragma: no cover
924+
raise RuntimeError("invalid scheme name!")
925+
return arr[..., cols]
926+
902927

903928
class NonuniformFiniteDifferencer(DerivativeEstimatorTemplate):
904929
"""Time derivative estimation with finite differences for state snapshots
@@ -967,6 +992,35 @@ def estimate(self, states, inputs=None):
967992
return states, ddts, inputs
968993
return states, ddts
969994

995+
def mask(self, arr):
996+
"""Map an array from the training time domain to the domain of the
997+
estimated time derivatives. Since this class provides an estimate at
998+
every time step, this method simply returns ``arr``.
999+
1000+
This method is used in post-hoc regularization selection routines.
1001+
1002+
Parameters
1003+
----------
1004+
arr : (..., k) ndarray
1005+
Array (states, inputs, etc.) aligned with the training time domain.
1006+
1007+
Returns
1008+
-------
1009+
_arr : (..., k') ndarray
1010+
Array mapped to the domain of the estimated time derivatives.
1011+
1012+
Examples
1013+
--------
1014+
>>> Q, dQ = estimator.esimate(states)
1015+
>>> Q2 = estimator.mask(states)
1016+
>>> np.all(Q2 == Q)
1017+
True
1018+
>>> Q3 = estimator.mask(other_states_on_same_time_grid)
1019+
>>> Q3.shape == Q.shape
1020+
True
1021+
"""
1022+
return arr
1023+
9701024

9711025
# Old API =====================================================================
9721026
def ddt_uniform(states, dt, order=2):

src/opinf/ddt/_interpolation.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,55 @@ def estimate(self, states, inputs=None):
189189
if inputs is None:
190190
return states, ddts
191191
return states, ddts, inputs
192+
193+
def mask(self, arr):
194+
"""Map an array from the training time domain to the domain of the
195+
estimated time derivatives.
196+
197+
198+
This method is used in post-hoc regularization selection routines.
199+
200+
Parameters
201+
----------
202+
arr : (..., k) ndarray
203+
Array (states, inputs, etc.) aligned with the training time domain.
204+
205+
Returns
206+
-------
207+
_arr : (..., k) ndarray
208+
Array mapped to the domain of the estimated time derivatives.
209+
210+
Notes
211+
-----
212+
If :attr:`new_time_domain` is not the same as :attr:`time_domain`,
213+
this method interpolates the ``arr`` and evaluates the interpolant
214+
(not its derivative) over the :attr:`new_time_domain`.
215+
216+
Examples
217+
--------
218+
>>> Q, dQ = estimator.esimate(states)
219+
>>> Q2 = estimator.mask(states)
220+
>>> np.all(Q2 == Q)
221+
True
222+
>>> Q3 = estimator.mask(other_states_on_same_time_grid)
223+
>>> Q3.shape == Q.shape
224+
True
225+
"""
226+
if self.new_time_domain is None:
227+
return arr
228+
229+
# Interpolate to the new time domain.
230+
statespline = self.InterpolatorClass(
231+
self.time_domain,
232+
arr,
233+
**self.options,
234+
)
235+
return statespline(self.new_time_domain)
236+
237+
# Verification ------------------------------------------------------------
238+
def verify(self, plot=False, return_errors=False):
239+
new_time_domain = self.__t2
240+
self.__t2 = None
241+
out = super().verify(plot, return_errors)
242+
self.__t2 = new_time_domain
243+
return out

0 commit comments

Comments
 (0)