Skip to content

Commit c2fa327

Browse files
POD via the Method of Snapshots (#73)
* option "method-of-snapshots" for src/opinf/basis/_pod, including tests for Euclidean, diagonal, and arbitrary inner product matrices * format code * update docstrings, truncate small eigs, etc. * bump version 0.5.9 -> 0.5.10, update changelog * small doc fixes --------- Co-authored-by: Shane <smcquar@sandia.gov>
1 parent 9eccfaa commit c2fa327

File tree

7 files changed

+202
-29
lines changed

7 files changed

+202
-29
lines changed

docs/_config.yml

+3-2
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,10 @@ sphinx:
122122
"bfPhi": "\\boldsymbol{\\Phi}" # left singular vectors
123123
"bfSigma": "\\boldsymbol{\\Sigma}" # singular values
124124
"bfPsi": "\\boldsymbol{\\Psi}" # right singular vectors
125+
"bfLambda": "\\boldsymbol{\\Lambda}" # eigenvalues
125126
"trp": "{^{\\mathsf{T}}}" # transpose
126-
"ddt": "\\frac{\\textrm{d}}{\\textrm{d}t}" # time derivative
127-
"ddqhat": "\\frac{\\partial}{\\partial\\qhat}" # d/dqhat
127+
"ddt": "\\frac{\\textrm{d}}{\\textrm{d}t}" # time derivative
128+
"ddqhat": "\\frac{\\partial}{\\partial\\qhat}" # d/dqhat
128129
"mean": "\\operatorname{mean}" # mean
129130
"std": "\\operatorname{std}" # standard deviation
130131
"argmin": "\\operatorname{argmin}" # argmin

docs/source/api/basis.ipynb

+2-8
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
" :nosignatures:\n",
3535
"\n",
3636
" pod_basis\n",
37+
" method_of_snapshots\n",
3738
" cumulative_energy\n",
3839
" residual_energy\n",
3940
" svdval_decay\n",
@@ -470,13 +471,6 @@
470471
"In general, using more basis vectors improves the approximation power of the basis and decreases projection error."
471472
]
472473
},
473-
{
474-
"cell_type": "code",
475-
"execution_count": null,
476-
"metadata": {},
477-
"outputs": [],
478-
"source": []
479-
},
480474
{
481475
"cell_type": "markdown",
482476
"metadata": {},
@@ -1289,7 +1283,7 @@
12891283
],
12901284
"metadata": {
12911285
"kernelspec": {
1292-
"display_name": "Python 3 (ipykernel)",
1286+
"display_name": "opinf",
12931287
"language": "python",
12941288
"name": "python3"
12951289
},

docs/source/api/missing.rst

+1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ basis.ipynb
4848
:nosignatures:
4949

5050
pod_basis
51+
method_of_snapshots
5152
cumulative_energy
5253
residual_energy
5354
svdval_decay

docs/source/opinf/changelog.md

+5
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
New versions may introduce substantial new features or API adjustments.
66
:::
77

8+
## Version 0.5.10
9+
10+
New POD basis solver option `basis.PODBasis(solver="method-of-snapshots")` (or `solver="eigh"`), which solves a symmetric eigenvalue problem instead of computing a (weighted) SVD. This method is more efficient than the SVD for snapshot matrices $\mathbf{Q}\in\mathbb{R}^{n\times k}$ where $n \gg k$ and is significantly more efficient than the SVD when a non-diagonal weight matrix is provided.
11+
Contributed by [@nicolearetz](https://github.com/nicolearetz).
12+
813
## Version 0.5.9
914

1015
Automatic regularization selection:

src/opinf/__init__.py

+1-1
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.9"
10+
__version__ = "0.5.10"
1111

1212
from . import (
1313
basis,

src/opinf/basis/_pod.py

+130-18
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
__all__ = [
55
"PODBasis",
6+
"method_of_snapshots",
67
"pod_basis",
78
"svdval_decay",
89
"cumulative_energy",
@@ -43,6 +44,89 @@ def _Wmult(W, arr):
4344
return W @ arr
4445

4546

47+
def method_of_snapshots(
48+
states,
49+
inner_product_matrix=None,
50+
minthresh: float = 1e-15,
51+
**options,
52+
):
53+
r"""Use the method of snapshots to compute the left singular values of a
54+
collection of state snapshots.
55+
56+
For a snapshot matrix :math:`\Q\in\RR^{n\times k}` (usually with
57+
:math:`n \ge k`) and a weighting matrix :math:`\W\in\RR^{n\times n}`,
58+
the method of snapshots computes the symmetric eigendecomposition
59+
60+
.. math::
61+
\Q\trp\W\Q = \bfPsi\bfLambda\bfPsi\trp.
62+
63+
The matrix :math:`\bfPsi\in\RR^{k\times k}` consists of the right singular
64+
vectors of :math:`\Q` and :math:`\bfLambda\in\RR^{k\times k}` is a diagonal
65+
matrix containing the square of the singular values of :math:`\Q`. The
66+
(weighted) left singular vectors are then given by
67+
:math:`\bfPhi = \Q\bfPsi\bfLambda^{-1/2} \in \RR^{n \times k}` and satisfy
68+
:math:`\Q = \bfPhi\bfLambda^{1/2}\bfPsi\trp` and
69+
:math:`\bfPhi\trp\W\bfPhi = \I`.
70+
71+
Parameters
72+
----------
73+
states : (n, k) ndarray,
74+
Snapshot matrix :math:`\Q` from which to compute the POD vectors.
75+
inner_product_matrix : (n, n) sparse SPD matrix or None
76+
Spatial inner product matrix :math:`\W` for measuring how different
77+
indices in the snapshot matrix interact with each other.
78+
If not provided, default to the standard Euclidean inner product
79+
(:math:`\W = \I`).
80+
minthresh : float > 0
81+
Threshold at which to truncate small eigenvalues. Singular vectors
82+
corresponding to eigenvalues that are less than this threshold are
83+
not included in the returned arrays.
84+
options : dict
85+
Additional arguments for :func:`scipy.linalg.eigh`.
86+
87+
Returns
88+
-------
89+
V : (n, k') ndarray, k' <= k
90+
Left singular vectors :math:`\bfPhi`.
91+
svals : (k',) ndarray, k' <= k
92+
Singular values :math:`\operatorname{diag}(\bfLambda^{1/2})` in
93+
descending order.
94+
eigvecsT : (k', k') ndarray
95+
Transposed right singular vectors :math:`\bfPsi\trp`.
96+
97+
Notes
98+
-----
99+
If, due to numerical precision errors, :func:`scipy.linalg.eigh` returns
100+
any negative eigenvalues, then ``minthresh`` is increased to the absolute
101+
value of the most negative eigenvalue.
102+
"""
103+
n_states = states.shape[1]
104+
if inner_product_matrix is None:
105+
gramian = states.T @ (states / n_states)
106+
else:
107+
gramian = states.T @ _Wmult(inner_product_matrix, states / n_states)
108+
109+
# Compute eigenvalue decomposition, using that the Gramian is symmetric.
110+
eigvals, eigvecs = la.eigh(gramian, **options)
111+
112+
# Re-order (largest to smallest).
113+
eigvals = eigvals[::-1]
114+
eigvecs = eigvecs[:, ::-1]
115+
116+
# NOTE: By definition the Gramian is symmetric positive semi-definite.
117+
# If any eigenvalues are smaller than zero, they are only measuring
118+
# numerical error and can be truncated.
119+
positives = eigvals > max(minthresh, abs(np.min(eigvals)))
120+
eigvecs = eigvecs[:, positives]
121+
eigvals = eigvals[positives]
122+
123+
# Rescale and square root eigenvalues to get singular values.
124+
svals = np.sqrt(eigvals * n_states)
125+
V = states @ (eigvecs / svals)
126+
127+
return V, svals, eigvecs.T
128+
129+
46130
# Main class ==================================================================
47131
class PODBasis(LinearBasis):
48132
r"""Proper othogonal decomposition basis, consisting of the principal left
@@ -60,7 +144,9 @@ class PODBasis(LinearBasis):
60144
The POD basis entries matrix :math:`\Vr = \bfPhi_{:,:r}\in\RR^{n\times r}`
61145
always has orthonormal columns, i.e., :math:`\Vr\trp\Vr = \I`. If a weight
62146
matrix :math:`\W` is specified, a weighted SVD is computed so that
63-
:math:`\Vr\trp\W\Vr = \I`.
147+
:math:`\Vr\trp\W\Vr = \I`. The columns of the basis entries are also the
148+
dominant eigenvectors of :math:`\Q\trp\W\Q` and can be computed through
149+
eigendecomposition by setting ``svdsolver="eigh"``.
64150
65151
The number of left singular vectors :math:`r` is the dimension of the
66152
reduced state and is set by specifying exactly one of the constructor
@@ -104,17 +190,27 @@ class PODBasis(LinearBasis):
104190
105191
**Options:**
106192
107-
* ``"dense"`` (default): Use :func:`scipy.linalg.svd()` to
193+
* ``"dense"`` (default): Use :func:`scipy.linalg.svd` to
108194
compute the SVD. May be inefficient for very large state matrices.
109195
* ``"randomized"``: Compute an approximate SVD with a randomized
110-
approach via :func:`sklearn.utils.extmath.randomized_svd()`.
196+
approach via :func:`sklearn.utils.extmath.randomized_svd`.
111197
May be more efficient but less accurate for very large state
112198
matrices. **NOTE**: it is highly recommended to set ``max_vectors``
113199
to limit the number of computed singular vectors. In this case,
114200
only ``max_vectors`` singular *values* are computed as well, meaning
115201
the cumulative and residual energies cannot be computed exactly.
202+
* ``"method-of-snapshots"`` or ``"eigh"``: Compute the basis through a
203+
symmetric eigenvalue decomposition, rather than through the SVD, via
204+
:func:`scipy.linalg.eigh`. This is how POD was computed when it was
205+
orginally introduced. If the state dimension is larger than the
206+
number of snapshots, this method is much more efficient than the SVD.
207+
Moreover, non-Euclidean inner products (see :attr:`weights`)
208+
are handled much more efficiently this way than with an SVD-based
209+
approach. **NOTE**: in this case, an additional keyword argument
210+
``minthresh`` defines a threshold at which small eigenvalues are
211+
truncated.
116212
* callable: If this argument is a callable function, use it for the
117-
SVD computation. The signature must match :func:`scipy.linalg.svd()`,
213+
SVD computation. The signature must match :func:`scipy.linalg.svd`,
118214
i.e., ``U, s, Vh = svdsolver(states, **svdsolver_options)``
119215
weights : (n, n) ndarray or (n,) ndarray None
120216
Weight matrix :math:`\W` or its diagonals.
@@ -133,6 +229,7 @@ class PODBasis(LinearBasis):
133229
{
134230
"dense": la.svd,
135231
"randomized": sklmath.randomized_svd,
232+
"method-of-snapshots": method_of_snapshots,
136233
# "streaming": # TODO
137234
}
138235
)
@@ -182,7 +279,10 @@ def __init__(
182279
self.__residual_energy = None
183280

184281
# Store weights (separate from LinearBasis.__weights)
185-
if weights is not None:
282+
if (
283+
weights is not None
284+
and self.__svdsolverlabel != "method-of-snapshots"
285+
):
186286
if weights.ndim == 1:
187287
self.__sqrt_weights = np.sqrt(weights)
188288
else: # (weights.ndim == 2, checked by LinearBasis)
@@ -267,6 +367,9 @@ def svdsolver(self, s):
267367
self.__svdengine = s
268368
return
269369

370+
if s == "eigh":
371+
s = "method-of-snapshots"
372+
270373
if s not in self.__SVDSOLVERS:
271374
raise AttributeError(
272375
f"invalid svdsolver '{s}', options: "
@@ -308,7 +411,9 @@ def svdvals(self):
308411

309412
@property
310413
def rightvecs(self):
311-
"""Leading *right* singular vectors of the training data."""
414+
"""Leading *right* singular vectors of the training data,
415+
if available.
416+
"""
312417
return self.__rightvecs
313418

314419
@property
@@ -572,9 +677,14 @@ def fit(self, states):
572677
options["random_state"] = None
573678
if keep < rmax:
574679
self.__energy_is_being_estimated = True
680+
elif self.__svdsolverlabel == "method-of-snapshots":
681+
options["inner_product_matrix"] = self.weights
575682

576683
# Weight the states.
577-
if self.weights is not None:
684+
if (
685+
self.weights is not None
686+
and self.__svdsolverlabel != "method-of-snapshots"
687+
):
578688
if states.shape[0] != (nW := self.__sqrt_weights.shape[0]):
579689
raise errors.DimensionalityError(
580690
f"states not aligned with weights, should have {nW:d} rows"
@@ -585,12 +695,14 @@ def fit(self, states):
585695
V, svdvals, Wt = self.__svdengine(states, **options)
586696

587697
# Unweight the basis.
588-
if self.weights is not None:
698+
if (
699+
self.weights is not None
700+
and self.__svdsolverlabel != "method-of-snapshots"
701+
):
589702
if self.__sqrt_weights.ndim == 1:
590703
V = _Wmult(1 / self.__sqrt_weights, V)
591704
else:
592705
V = la.cho_solve(self.__sqrt_weights_cho, V)
593-
# V = la.solve(sqrtW, V)
594706

595707
# Store the results.
596708
self._store_svd(
@@ -628,7 +740,7 @@ def plot_svdval_decay(
628740
Axes to plot on.
629741
If ``None`` (default), a new single-axes figure is created.
630742
options : dict
631-
Options to pass to :func:`matplotlib.pyplot.semilogy()`.
743+
Options to pass to :func:`matplotlib.pyplot.semilogy`.
632744
633745
Returns
634746
-------
@@ -666,7 +778,7 @@ def plot_cumulative_energy(
666778
Axes to plot on.
667779
If ``None`` (default), a new single-axes figure is created.
668780
kwargs : dict
669-
Options to pass to :func:`matplotlib.pyplot.semilogy()`.
781+
Options to pass to :func:`matplotlib.pyplot.semilogy`.
670782
671783
Returns
672784
-------
@@ -708,7 +820,7 @@ def plot_residual_energy(
708820
Axes to plot on.
709821
If ``None`` (default), a new single-axes figure is created.
710822
options : dict
711-
Options to pass to :func:`matplotlib.pyplot.semilogy()`.
823+
Options to pass to :func:`matplotlib.pyplot.semilogy`.
712824
713825
Returns
714826
-------
@@ -755,7 +867,7 @@ def plot_projection_error(
755867
Axes to plot on.
756868
If ``None`` (default), a new single-axes figure is created.
757869
options : dict
758-
Options to pass to :func:`matplotlib.pyplot.semilogy()`.
870+
Options to pass to :func:`matplotlib.pyplot.semilogy`.
759871
760872
Returns
761873
-------
@@ -765,7 +877,7 @@ def plot_projection_error(
765877
Notes
766878
-----
767879
This method shows the projection error of the training snapshots.
768-
See :meth:`projection_error()` to calculate the projection error for an
880+
See :meth:`projection_error` to calculate the projection error for an
769881
arbitrary snapshot or collection of snapshots.
770882
"""
771883
kwargs = dict(
@@ -869,7 +981,7 @@ def load(cls, loadfile, max_vectors=None):
869981
Parameters
870982
----------
871983
loadfile : str
872-
Path to the file where the basis was stored via :meth:`save()`.
984+
Path to the file where the basis was stored via :meth:`save`.
873985
max_vectors : int or None
874986
Maximum number of POD vectors to load.
875987
If ``None`` (default), load all stored vectors.
@@ -1003,7 +1115,7 @@ def svdval_decay(
10031115
Axes to plot the results on if ``plot=True``.
10041116
If not given, a new single-axes figure is created.
10051117
kwargs : dict
1006-
Options to pass to :func:`matplotlib.pyplot.semilogy()`.
1118+
Options to pass to :func:`matplotlib.pyplot.semilogy`.
10071119
10081120
Returns
10091121
-------
@@ -1110,7 +1222,7 @@ def cumulative_energy(
11101222
Axes to plot the results on if ``plot=True``.
11111223
If not given, a new single-axes figure is created.
11121224
kwargs : dict
1113-
Options to pass to :func:`matplotlib.pyplot.plot()`.
1225+
Options to pass to :func:`matplotlib.pyplot.plot`.
11141226
11151227
Returns
11161228
-------
@@ -1225,7 +1337,7 @@ def residual_energy(
12251337
If ``True``, square root the residual energies to get the projection
12261338
error of the training snapshots.
12271339
kwargs : dict
1228-
Options to pass to :func:`matplotlib.pyplot.semilogy()`.
1340+
Options to pass to :func:`matplotlib.pyplot.semilogy`.
12291341
12301342
Returns
12311343
-------

0 commit comments

Comments
 (0)