Skip to content

Commit ea319dc

Browse files
Poly operator (#83)
* Parameterized test selection via `{posargs}` so that specific tests can be executed via `tox -- my/favorite/tests.py` * fixed typo in documentation * added operator_dimension for arbitrary polynomial operators and tests * added _poly_operator to __init__.py * renamed PolyOperator to PolynomialOperator * added datablock to PolynomialOperator * added apply method to PolynomialOperator * start docstring for PolynomialOperator * some additional tests, not passing yet * Basis Backend Maintenance (#82) * Basis.fit_compress() method * update basis tests * pragma: no cover to verify() methods * time() -> process_time() in TimedBlock * PODBasis handle sparse weights (w/ warning) if svdsolver='dense' * models ok inferring multiple PolynomialOperators if they have different orders * operator_dimension() now takes in r and m, not p, and not static * fixed issue with test_lifting, test_05: changed dimension of PolynomialOperator.apply for p=0 * added .idea to gitignore, fixed failing test for polynomial operator * Delete .idea directory --------- Co-authored-by: Shane <smcquar@sandia.gov>
1 parent 091074c commit ea319dc

File tree

10 files changed

+492
-3
lines changed

10 files changed

+492
-3
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ dist/
2121
.vscode/
2222
.pypirc
2323
.tox/
24+
.idea/
2425

2526
# Extra Matlab files
2627
*.mat

src/opinf/models/mono/_nonparametric.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,25 @@ def _check_operator_types_unique(ops):
5757
"""Raise a ValueError if any two operators represent the same kind
5858
of operation (e.g., two constant operators).
5959
"""
60-
if len({type(op) for op in ops}) != len(ops):
60+
optypes = []
61+
for op in ops:
62+
if isinstance(op, _operators.PolynomialOperator):
63+
p = op.polynomial_order
64+
if p == 0:
65+
optypes.append(_operators.ConstantOperator)
66+
elif p == 1:
67+
optypes.append(_operators.LinearOperator)
68+
elif p == 2:
69+
optypes.append(_operators.QuadraticOperator)
70+
elif p == 3:
71+
optypes.append(_operators.CubicOperator)
72+
elif p == 4:
73+
optypes.append(_operators.QuarticOperator)
74+
else:
75+
optypes.append(f"PolynomialOperator_ord{p}")
76+
else:
77+
optypes.append(type(op))
78+
if len(set(optypes)) != len(optypes):
6179
raise ValueError("duplicate type in list of operators to infer")
6280

6381
def _get_operator_of_type(self, OpClass):

src/opinf/operators/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
from ._nonparametric import *
66
from ._affine import *
77
from ._interpolate import *
8+
from ._polynomial_operator import *

src/opinf/operators/_base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -753,7 +753,7 @@ def datablock(states: np.ndarray, inputs=None) -> np.ndarray:
753753
\end{array}\right]
754754
\in \RR^{d \times k}.
755755
756-
Here, ``states`` is the snapshot matrix
756+
Here, ``states`` is the projected snapshot matrix
757757
:math:`[~\qhat_0~~\cdots~~\qhat_{k-1}~]`
758758
and ``inputs`` is the (optional) input matrix
759759
:math:`[~\u_0~~\cdots~~\u_{k-1}~]`.
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
# from .. import utils
2+
from ._base import OpInfOperator
3+
4+
import numpy as np
5+
6+
import scipy.linalg as la
7+
8+
# import scipy.sparse as sparse
9+
from scipy.special import comb
10+
11+
__all__ = ["PolynomialOperator"]
12+
13+
14+
class PolynomialOperator(OpInfOperator):
15+
r"""Polynomial state operator
16+
:math:`\Ophat_{\ell}(\qhat,\u) = \Phat[\qhat^{\otimes p}]`
17+
where :math:`\otimes p` indicates the Kronecker product of a vector with
18+
itself :math:`p` times. The matrix :math:`\Phat` is
19+
:math:`r \times \binom{r+p-1}{p}`.
20+
21+
This class is equivalent to the following.
22+
23+
* :math:`p = 1`: :class:`ConstantOperator`
24+
* :math:`p = 2`: :class:`QuadraticOperator`
25+
* :math:`p = 3`: :class:`CubicOperator`
26+
* :math:`p = 4`: :class:`QuarticOperator`
27+
28+
Parameters
29+
----------
30+
polynomial_order : int
31+
Order of the Kronecker product.
32+
entries : (r, (r + p - 1 choose p)) ndarray or None
33+
Operator matrix :math:`\Phat`.
34+
35+
Examples
36+
--------
37+
>>> import numpy as np
38+
>>> H = opinf.operators.QuadraticOperator()
39+
>>> P = opinf.operators.PolynomialOperator(2)
40+
>>> entries = np.random.random((10, 100))
41+
>>> H.set_entries(entries)
42+
>>> H.shape
43+
(10, 55)
44+
>>> P.set_entries(H.entries)
45+
>>> q = np.random.random(10)
46+
>>> outH = H.apply(q)
47+
>>> out = P.apply(q)
48+
>>> np.allclose(out, outH)
49+
True
50+
"""
51+
52+
def __init__(self, polynomial_order: int, entries=None):
53+
"""Initialize an empty operator."""
54+
if polynomial_order < 0 or (
55+
not np.isclose(polynomial_order, p := int(polynomial_order))
56+
):
57+
raise ValueError(
58+
"expected non-negative integer polynomial order"
59+
+ f" polynomial_order. Got p={polynomial_order}"
60+
)
61+
62+
self.polynomial_order = p
63+
64+
super().__init__(entries=entries)
65+
66+
def operator_dimension(self, r: int, m=None) -> int:
67+
"""
68+
computes the number of non-redundant terms in a vector of length r
69+
that is taken to the power p with the Kronecker product
70+
71+
Parameters
72+
----------
73+
r : int
74+
State dimension.
75+
m : int or None
76+
Input dimension -- currently not used
77+
"""
78+
if r < 0 or (not np.isclose(r, int(r))):
79+
raise ValueError(
80+
f"expected non-negative integer reduced dimension r. Got r={r}"
81+
)
82+
83+
# for constant operators the dimension does not matter
84+
if (p := self.polynomial_order) == 0:
85+
return 1
86+
87+
return comb(r, p, repetition=True, exact=True)
88+
89+
def datablock(self, states: np.ndarray, inputs=None) -> np.ndarray:
90+
r"""Return the data matrix block corresponding to
91+
this operator's polynomial order,
92+
with ``states`` being the projected snapshots.
93+
94+
Parameters
95+
----------
96+
states : (r, k) or (k,) ndarray
97+
State vectors. Each column is a single state vector.
98+
If one dimensional, it is assumed that :math:`r = 1`.
99+
inputs : (m, k) or (k,) ndarray or None
100+
Input vectors (not used).
101+
102+
Returns
103+
-------
104+
datablock : (self.operator_dimension(r), k) ndarray
105+
where p is the polynomial order for this operator.
106+
"""
107+
# if constant, we just return an array containing ones
108+
# of shape 1 x <number of data points>
109+
if self.polynomial_order == 0:
110+
return np.ones((1, np.atleast_1d(states).shape[-1]))
111+
112+
# make sure data is in 2D
113+
states = np.atleast_2d(states)
114+
115+
if states.shape[0] == 0:
116+
return np.empty(shape=(0, states.shape[1]))
117+
118+
# compute data matrix
119+
return PolynomialOperator.exp_p(states, self.polynomial_order)
120+
121+
@staticmethod
122+
def keptIndices_p(r, p):
123+
"""
124+
returns the non-redundant indices in a kronecker-product with
125+
exponent p when the dimension of the vector is r
126+
"""
127+
if p == 0:
128+
return np.array([0])
129+
130+
dim_if_p_was_one_smaller = PolynomialOperator(
131+
polynomial_order=(p - 1)
132+
).operator_dimension(r=r)
133+
indexmatrix = np.reshape(
134+
np.arange(r * dim_if_p_was_one_smaller),
135+
(r, dim_if_p_was_one_smaller),
136+
)
137+
return np.hstack(
138+
[
139+
indexmatrix[
140+
i,
141+
: PolynomialOperator(
142+
polynomial_order=(p - 1)
143+
).operator_dimension(i + 1),
144+
]
145+
for i in range(r)
146+
]
147+
)
148+
149+
@staticmethod
150+
def exp_p(x, p, kept=None):
151+
"""
152+
recursively computes x^p without the redundant terms
153+
(it still computes them but then takes them out)
154+
the result has shape
155+
156+
if x is 1-dimensional:
157+
(PolynomialOperator.operator_dimension(x.shape[0]),)
158+
159+
otherwise:
160+
(PolynomialOperator.operator_dimension(x.shape[0]), x.shape[1])
161+
"""
162+
# for a constant operator, we just return 1 (x^0 = 1)
163+
if p == 0:
164+
return np.ones([1])
165+
166+
# for a linear operator, x^1 = 1
167+
if p == 1:
168+
return x
169+
170+
# identify kept entries in condensed Kronecker product for
171+
# this reduced dimension
172+
# for all polynomial orders up to self.polynomial order
173+
if kept is None:
174+
r = x.shape[0]
175+
kept = [
176+
PolynomialOperator.keptIndices_p(r=r, p=i)
177+
for i in range(p + 1)
178+
]
179+
180+
# distinguish between the shapes of the input
181+
if len(x.shape) == 1:
182+
# this gets called when the ROM is run
183+
return np.kron(x, PolynomialOperator.exp_p(x, p - 1, kept))[
184+
kept[p]
185+
]
186+
else:
187+
# this gets called for constructing the data matrix
188+
return la.khatri_rao(x, PolynomialOperator.exp_p(x, p - 1, kept))[
189+
kept[p]
190+
]
191+
192+
def apply(self, state: np.ndarray, input_=None) -> np.ndarray:
193+
r"""Apply the operator to the given state. Input is not used.
194+
See OpInfOperator.apply for description.
195+
"""
196+
if state.shape[0] != self.state_dimension:
197+
raise ValueError(
198+
f"Expected state of dimension r={self.state_dimension}."
199+
+ f"Got state.shape={state.shape}"
200+
)
201+
202+
# constant
203+
if self.polynomial_order == 0:
204+
if np.ndim(state) == 2: # r, k > 1.
205+
return np.outer(self.entries, np.ones(state.shape[-1]))
206+
if np.ndim(self.entries) == 2:
207+
return self.entries[:, 0]
208+
return self.entries
209+
# note: no need to go through the trouble of identifying the
210+
# non-redundant indices
211+
212+
# linear
213+
if self.polynomial_order == 1:
214+
return self.entries @ state
215+
# note: no need to go through the trouble of identifying the
216+
# non-redundant indices
217+
218+
# higher-order
219+
restricted_kronecker_product = PolynomialOperator.exp_p(
220+
x=state, p=self.polynomial_order, kept=self.nonredudant_entries
221+
)
222+
return self.entries @ restricted_kronecker_product
223+
224+
# Properties --------------------------------------------------------------
225+
@property
226+
def nonredudant_entries(self) -> list:
227+
r"""list containing at index i a list of the indices that are kept
228+
when restricting the i-times Kronecker product of a vector of
229+
shape self.state_dimension() with itself.
230+
"""
231+
# return self.__nonredudant_entries
232+
return [
233+
PolynomialOperator.keptIndices_p(r=self.state_dimension, p=i)
234+
for i in range(self.polynomial_order + 1)
235+
]

tests/operators/test_nonparametric.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ def get_entries(self, r, m=None):
3333
def test_set_entries(self):
3434
raise NotImplementedError
3535

36+
def test_in_model(self, r=3, k=100, m=2):
37+
"""See if we can fit a model with this operator."""
38+
model = opinf.models.ContinuousModel(operators=[self.get_operator()])
39+
40+
Q = np.random.random((r, k))
41+
dQ = np.random.random((r, k))
42+
43+
if self.has_inputs:
44+
U = np.random.random((m, k))
45+
model.fit(states=Q, ddts=dQ, inputs=U)
46+
else:
47+
model.fit(states=Q, ddts=dQ, inputs=None)
48+
3649

3750
# No dependence on state or input =============================================
3851
class TestConstantOperator(_TestNonparametricOperator):

0 commit comments

Comments
 (0)