3
3
4
4
__all__ = [
5
5
"PODBasis" ,
6
+ "method_of_snapshots" ,
6
7
"pod_basis" ,
7
8
"svdval_decay" ,
8
9
"cumulative_energy" ,
@@ -43,6 +44,89 @@ def _Wmult(W, arr):
43
44
return W @ arr
44
45
45
46
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
+
46
130
# Main class ==================================================================
47
131
class PODBasis (LinearBasis ):
48
132
r"""Proper othogonal decomposition basis, consisting of the principal left
@@ -60,7 +144,9 @@ class PODBasis(LinearBasis):
60
144
The POD basis entries matrix :math:`\Vr = \bfPhi_{:,:r}\in\RR^{n\times r}`
61
145
always has orthonormal columns, i.e., :math:`\Vr\trp\Vr = \I`. If a weight
62
146
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"``.
64
150
65
151
The number of left singular vectors :math:`r` is the dimension of the
66
152
reduced state and is set by specifying exactly one of the constructor
@@ -104,17 +190,27 @@ class PODBasis(LinearBasis):
104
190
105
191
**Options:**
106
192
107
- * ``"dense"`` (default): Use :func:`scipy.linalg.svd() ` to
193
+ * ``"dense"`` (default): Use :func:`scipy.linalg.svd` to
108
194
compute the SVD. May be inefficient for very large state matrices.
109
195
* ``"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`.
111
197
May be more efficient but less accurate for very large state
112
198
matrices. **NOTE**: it is highly recommended to set ``max_vectors``
113
199
to limit the number of computed singular vectors. In this case,
114
200
only ``max_vectors`` singular *values* are computed as well, meaning
115
201
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.
116
212
* 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`,
118
214
i.e., ``U, s, Vh = svdsolver(states, **svdsolver_options)``
119
215
weights : (n, n) ndarray or (n,) ndarray None
120
216
Weight matrix :math:`\W` or its diagonals.
@@ -133,6 +229,7 @@ class PODBasis(LinearBasis):
133
229
{
134
230
"dense" : la .svd ,
135
231
"randomized" : sklmath .randomized_svd ,
232
+ "method-of-snapshots" : method_of_snapshots ,
136
233
# "streaming": # TODO
137
234
}
138
235
)
@@ -182,7 +279,10 @@ def __init__(
182
279
self .__residual_energy = None
183
280
184
281
# 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
+ ):
186
286
if weights .ndim == 1 :
187
287
self .__sqrt_weights = np .sqrt (weights )
188
288
else : # (weights.ndim == 2, checked by LinearBasis)
@@ -267,6 +367,9 @@ def svdsolver(self, s):
267
367
self .__svdengine = s
268
368
return
269
369
370
+ if s == "eigh" :
371
+ s = "method-of-snapshots"
372
+
270
373
if s not in self .__SVDSOLVERS :
271
374
raise AttributeError (
272
375
f"invalid svdsolver '{ s } ', options: "
@@ -308,7 +411,9 @@ def svdvals(self):
308
411
309
412
@property
310
413
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
+ """
312
417
return self .__rightvecs
313
418
314
419
@property
@@ -572,9 +677,14 @@ def fit(self, states):
572
677
options ["random_state" ] = None
573
678
if keep < rmax :
574
679
self .__energy_is_being_estimated = True
680
+ elif self .__svdsolverlabel == "method-of-snapshots" :
681
+ options ["inner_product_matrix" ] = self .weights
575
682
576
683
# 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
+ ):
578
688
if states .shape [0 ] != (nW := self .__sqrt_weights .shape [0 ]):
579
689
raise errors .DimensionalityError (
580
690
f"states not aligned with weights, should have { nW :d} rows"
@@ -585,12 +695,14 @@ def fit(self, states):
585
695
V , svdvals , Wt = self .__svdengine (states , ** options )
586
696
587
697
# 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
+ ):
589
702
if self .__sqrt_weights .ndim == 1 :
590
703
V = _Wmult (1 / self .__sqrt_weights , V )
591
704
else :
592
705
V = la .cho_solve (self .__sqrt_weights_cho , V )
593
- # V = la.solve(sqrtW, V)
594
706
595
707
# Store the results.
596
708
self ._store_svd (
@@ -628,7 +740,7 @@ def plot_svdval_decay(
628
740
Axes to plot on.
629
741
If ``None`` (default), a new single-axes figure is created.
630
742
options : dict
631
- Options to pass to :func:`matplotlib.pyplot.semilogy() `.
743
+ Options to pass to :func:`matplotlib.pyplot.semilogy`.
632
744
633
745
Returns
634
746
-------
@@ -666,7 +778,7 @@ def plot_cumulative_energy(
666
778
Axes to plot on.
667
779
If ``None`` (default), a new single-axes figure is created.
668
780
kwargs : dict
669
- Options to pass to :func:`matplotlib.pyplot.semilogy() `.
781
+ Options to pass to :func:`matplotlib.pyplot.semilogy`.
670
782
671
783
Returns
672
784
-------
@@ -708,7 +820,7 @@ def plot_residual_energy(
708
820
Axes to plot on.
709
821
If ``None`` (default), a new single-axes figure is created.
710
822
options : dict
711
- Options to pass to :func:`matplotlib.pyplot.semilogy() `.
823
+ Options to pass to :func:`matplotlib.pyplot.semilogy`.
712
824
713
825
Returns
714
826
-------
@@ -755,7 +867,7 @@ def plot_projection_error(
755
867
Axes to plot on.
756
868
If ``None`` (default), a new single-axes figure is created.
757
869
options : dict
758
- Options to pass to :func:`matplotlib.pyplot.semilogy() `.
870
+ Options to pass to :func:`matplotlib.pyplot.semilogy`.
759
871
760
872
Returns
761
873
-------
@@ -765,7 +877,7 @@ def plot_projection_error(
765
877
Notes
766
878
-----
767
879
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
769
881
arbitrary snapshot or collection of snapshots.
770
882
"""
771
883
kwargs = dict (
@@ -869,7 +981,7 @@ def load(cls, loadfile, max_vectors=None):
869
981
Parameters
870
982
----------
871
983
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`.
873
985
max_vectors : int or None
874
986
Maximum number of POD vectors to load.
875
987
If ``None`` (default), load all stored vectors.
@@ -1003,7 +1115,7 @@ def svdval_decay(
1003
1115
Axes to plot the results on if ``plot=True``.
1004
1116
If not given, a new single-axes figure is created.
1005
1117
kwargs : dict
1006
- Options to pass to :func:`matplotlib.pyplot.semilogy() `.
1118
+ Options to pass to :func:`matplotlib.pyplot.semilogy`.
1007
1119
1008
1120
Returns
1009
1121
-------
@@ -1110,7 +1222,7 @@ def cumulative_energy(
1110
1222
Axes to plot the results on if ``plot=True``.
1111
1223
If not given, a new single-axes figure is created.
1112
1224
kwargs : dict
1113
- Options to pass to :func:`matplotlib.pyplot.plot() `.
1225
+ Options to pass to :func:`matplotlib.pyplot.plot`.
1114
1226
1115
1227
Returns
1116
1228
-------
@@ -1225,7 +1337,7 @@ def residual_energy(
1225
1337
If ``True``, square root the residual energies to get the projection
1226
1338
error of the training snapshots.
1227
1339
kwargs : dict
1228
- Options to pass to :func:`matplotlib.pyplot.semilogy() `.
1340
+ Options to pass to :func:`matplotlib.pyplot.semilogy`.
1229
1341
1230
1342
Returns
1231
1343
-------
0 commit comments