Skip to content

Commit 77de218

Browse files
Merge pull request #88 from EthanJamesLew/feature/sw-edmd
Feature: State Weighted eDMD (SW-eDMD)
2 parents 8303d95 + 6a4b37b commit 77de218

File tree

12 files changed

+393
-102
lines changed

12 files changed

+393
-102
lines changed

autokoopman/autokoopman.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import numpy as np
77

8-
import autokoopman.core.observables as kobs
8+
import autokoopman.observable as kobs
99
from autokoopman.core.trajectory import (
1010
TrajectoriesData,
1111
UniformTimeTrajectoriesData,
@@ -24,7 +24,7 @@
2424
from autokoopman.tuner.gridsearch import GridSearchTuner
2525
from autokoopman.tuner.montecarlo import MonteCarloTuner
2626
from autokoopman.tuner.bayesianopt import BayesianOptTuner
27-
from autokoopman.core.observables import KoopmanObservable
27+
from autokoopman.observable.observables import KoopmanObservable
2828
from autokoopman.core.format import hide_prints
2929

3030
__all__ = ["auto_koopman"]

autokoopman/estimator/koopman.py

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ def dmdc(X, Xp, U, r):
2626
U, Sigma, V = U[:, :r], np.diag(Sigma)[:r, :r], V.conj().T[:, :r]
2727

2828
# get the transformation
29-
Atilde = Yp @ V @ np.linalg.inv(Sigma) @ U.conj().T
29+
try:
30+
Atilde = Yp @ V @ np.linalg.inv(Sigma) @ U.conj().T
31+
except:
32+
Atilde = Yp @ V @ np.linalg.pinv(Sigma) @ U.conj().T
33+
3034
return Atilde[:, :state_size], Atilde[:, state_size:]
3135

3236

@@ -48,33 +52,69 @@ def wdmdc(X, Xp, U, r, W):
4852
U, Sigma, V = U[:, :r], np.diag(Sigma)[:r, :r], V.conj().T[:, :r]
4953

5054
# get the transformation
51-
Atilde = Yp @ V @ np.linalg.inv(Sigma) @ U.conj().T
55+
# get the transformation
56+
try:
57+
Atilde = Yp @ V @ np.linalg.inv(Sigma) @ U.conj().T
58+
except:
59+
Atilde = Yp @ V @ np.linalg.pinv(Sigma) @ U.conj().T
5260
return Atilde[:, :state_size], Atilde[:, state_size:]
5361

5462

5563
def swdmdc(X, Xp, U, r, Js, W):
5664
"""State Weighted Dynamic Mode Decomposition with Control (wDMDC)"""
65+
import cvxpy as cp
66+
import warnings
67+
5768
assert len(W.shape) == 2, "weights must be 2D for snapshot x state"
5869

5970
if U is not None:
60-
Y = np.hstack((X, U)).T
71+
Y = np.hstack((X, U))
6172
else:
62-
Y = X.T
63-
Yp = Xp.T
64-
65-
# compute observables weights from state weights
66-
Wy = np.vstack([(np.abs(J) @ np.atleast_2d(w).T).T for J, w in zip(Js, W)])
67-
68-
# apply weights element-wise
69-
Y, Yp = Wy.T * Y, Wy.T * Yp
70-
state_size = Yp.shape[0]
71-
72-
# compute Atilde
73-
U, Sigma, V = np.linalg.svd(Y, False)
74-
U, Sigma, V = U[:, :r], np.diag(Sigma)[:r, :r], V.conj().T[:, :r]
75-
73+
Y = X
74+
Yp = Xp
75+
state_size = Yp.shape[1]
76+
77+
n_snap, n_obs = Yp.shape
78+
n_inps = U.shape[1] if U is not None else 0
79+
_, n_states = Js[0].shape
80+
81+
# so the objective isn't numerically unstable
82+
sf = (1.0 / n_snap)
83+
84+
# check that rank is less than the number of observations
85+
if r > n_obs:
86+
warnings.warn("Rank must be less than the number of observations. Reducing rank to n_obs.")
87+
r = n_obs
88+
89+
# koopman operator
90+
# Variables for the low-rank approximation
91+
K_U = cp.Variable((n_obs, r))
92+
K_V = cp.Variable((r, n_obs + n_inps))
93+
94+
# SW-eDMD objective
95+
weights_obj = np.vstack([(np.abs(J) @ w) for J, w in zip(Js, W)]).T
96+
P = sf * cp.multiply(weights_obj, Yp.T - (K_U @ K_V) @ Y.T)
97+
# add regularization
98+
objective = cp.Minimize(cp.sum_squares(P) + 1E-4 * 1.0 / (n_obs**2) * cp.norm(K_U @ K_V, "fro"))
99+
100+
# unconstrained problem
101+
constraints = None
102+
103+
# SW-eDMD problem
104+
prob = cp.Problem(objective, constraints)
105+
106+
# solve for the SW-eDMD Koopman operator
107+
try:
108+
_ = prob.solve(solver=cp.CLARABEL)
109+
#_ = prob.solve(solver=cp.ECOS)
110+
if K_U.value is None or K_V.value is None:
111+
raise Exception("SW-eDMD (cvxpy) Optimization failed to converge.")
112+
except:
113+
warnings.warn("SW-eDMD (cvxpy) Optimization failed to converge. Switching to unweighted DMDc.")
114+
return dmdc(X, Xp, U, r)
115+
76116
# get the transformation
77-
Atilde = Yp @ V @ np.linalg.inv(Sigma) @ U.conj().T
117+
Atilde = K_U.value @ K_V.value
78118
return Atilde[:, :state_size], Atilde[:, state_size:]
79119

80120

autokoopman/observable/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""
2+
Koopman Observables Module
3+
"""
4+
5+
from .observables import *
6+
from .rff import *
7+
from .reweighted import *

autokoopman/core/observables.py renamed to autokoopman/observable/observables.py

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
import numpy as np
55
import sympy as sp # type: ignore
66

7-
from scipy.stats import cauchy, laplace
8-
97

108
class KoopmanObservable(abc.ABC):
119
"""
@@ -163,51 +161,3 @@ def __init__(self, dimension, degree) -> None:
163161

164162
def obs_fcn(self, X: np.ndarray) -> np.ndarray:
165163
return self.poly.transform(np.atleast_2d(X)).T
166-
167-
168-
class RFFObservable(KoopmanObservable):
169-
def __init__(self, dimension, num_features, gamma, metric="rbf"):
170-
super(RFFObservable, self).__init__()
171-
self.gamma = gamma
172-
self.dimension = dimension
173-
self.metric = metric
174-
self.D = num_features
175-
# Generate D iid samples from p(w)
176-
if self.metric == "rbf":
177-
self.w = np.sqrt(2 * self.gamma) * np.random.normal(
178-
size=(self.D, self.dimension)
179-
)
180-
elif self.metric == "laplace":
181-
self.w = cauchy.rvs(scale=self.gamma, size=(self.D, self.dimension))
182-
# Generate D iid samples from Uniform(0,2*pi)
183-
self.u = 2 * np.pi * np.random.rand(self.D)
184-
185-
def obs_fcn(self, X: np.ndarray) -> np.ndarray:
186-
# modification...
187-
if len(X.shape) == 1:
188-
x = np.atleast_2d(X.flatten()).T
189-
else:
190-
x = X.T
191-
w = self.w.T
192-
u = self.u[np.newaxis, :].T
193-
s = np.sqrt(2 / self.D)
194-
Z = s * np.cos(x.T @ w + u.T)
195-
return Z.T
196-
197-
def obs_grad(self, X: np.ndarray) -> np.ndarray:
198-
if len(X.shape) == 1:
199-
x = np.atleast_2d(X.flatten()).T
200-
else:
201-
x = X.T
202-
x = np.atleast_2d(X.flatten()).T
203-
w = self.w.T
204-
u = self.u[np.newaxis, :].T
205-
s = np.sqrt(2 / self.D)
206-
# TODO: make this sparse?
207-
Z = -s * np.diag(np.sin(u + w.T @ x).flatten()) @ w.T
208-
return Z
209-
210-
def compute_kernel(self, X: np.ndarray) -> np.ndarray:
211-
Z = self.transform(X)
212-
K = Z.dot(Z.T)
213-
return K

autokoopman/observable/reweighted.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
from autokoopman.observable import SymbolicObservable, KoopmanObservable
2+
import math
3+
import numpy as np
4+
import sympy as sp
5+
from scipy.stats import cauchy, laplace
6+
from scipy.optimize import nnls
7+
8+
9+
def make_gaussian_kernel(sigma=1.0):
10+
"""the baseline"""
11+
def kernel(x, xp):
12+
return np.exp(-np.linalg.norm(x-xp)**2.0 / (2.0*sigma**2.0))
13+
return kernel
14+
15+
16+
def rff_reweighted_map(n, X, Y, wx, wy, sigma=1.0):
17+
"""build a RFF explicit feature map"""
18+
assert len(X) == len(Y)
19+
R = n
20+
D = X.shape[1]
21+
N = len(X)
22+
W = np.random.normal(loc=0, scale=(1.0/np.sqrt(sigma)), size=(R, D))
23+
b = np.random.uniform(-np.pi, np.pi, size = R)
24+
25+
# get ground truth kernel for training
26+
kernel = make_gaussian_kernel(sigma)
27+
28+
# solve NNLS problem
29+
M = np.zeros((N, R))
30+
bo = np.zeros((N,))
31+
for jdx, (xi, yi, wxi, wyi) in enumerate(zip(X, Y, wx, wy)):
32+
wi = np.sum(wxi) * np.sum(wyi)
33+
k = wi * kernel(xi, yi)
34+
if np.isclose(np.abs(wi), 0.0):
35+
continue
36+
bo[jdx] = k
37+
for idx, (omegai, bi) in enumerate(zip(W, b)):
38+
M[jdx, idx] = wi * np.cos(np.dot(omegai, xi) + bi) * np.cos(np.dot(omegai, yi) + bi)
39+
40+
a, _ = nnls(M, bo, maxiter=1000)
41+
42+
# get new values
43+
new_idxs = (np.abs(a) > 3E-5)
44+
W = W[new_idxs]
45+
b = b[new_idxs]
46+
a = a[new_idxs]
47+
48+
return a, W, b
49+
50+
51+
def subsampled_dense_grid(d, D, gamma, deg=8):
52+
"""sparse grid gaussian quadrature"""
53+
points, weights = np.polynomial.hermite.hermgauss(deg)
54+
points *= 2 * math.sqrt(gamma)
55+
weights /= math.sqrt(math.pi)
56+
# Now @weights must sum to 1.0, as the kernel value at 0 is 1.0
57+
subsampled_points = np.random.choice(points, size=(D, d), replace=True, p=weights)
58+
subsampled_weights = np.ones(D) / math.sqrt(D)
59+
return subsampled_points, subsampled_weights
60+
61+
62+
def dense_grid(d, D, gamma, deg=8):
63+
"""dense grid gaussian quadrature"""
64+
points, weights = np.polynomial.hermite.hermgauss(deg)
65+
points *= 2 * math.sqrt(gamma)
66+
weights /= math.sqrt(math.pi)
67+
points = np.atleast_2d(points).T
68+
return points, weights
69+
70+
71+
def sparse_grid_map(n, d, sigma=1.0):
72+
"""sparse GQ explicit map"""
73+
d, D = d, n
74+
gamma = 1/(2*sigma*sigma)
75+
points, weights = subsampled_dense_grid(d, D, gamma=gamma)
76+
def inner(x):
77+
prod = x @ points.T
78+
return np.hstack((weights * np.cos(prod), weights * np.sin(prod)))
79+
return inner
80+
81+
82+
def dense_grid_map(n, d, sigma=1.0):
83+
"""dense GQ explicit map"""
84+
d, D = d, n
85+
gamma = 1/(2*sigma*sigma)
86+
points, weights = dense_grid(d, D, gamma=gamma)
87+
def inner(x):
88+
prod = x @ points.T
89+
return np.hstack((weights * np.cos(prod), weights * np.sin(prod)))
90+
return inner
91+
92+
93+
def quad_reweighted_map(n, X, Y, wx, wy, sigma=1.0):
94+
"""build a RFF explicit feature map"""
95+
assert len(X) == len(Y)
96+
R = int(n / 2.0)
97+
D = X.shape[1]
98+
N = len(X)
99+
W, a = subsampled_dense_grid(D, R, gamma=1/(2*sigma*sigma))
100+
#W = np.random.normal(loc=0, scale=(1.0/np.sqrt(sigma)), size=(R, D))
101+
b = np.random.uniform(-np.pi, np.pi, size = R)
102+
#print(X.shape, W.shape)
103+
#b = np.arccos(-np.sqrt(np.cos(2*X.T.dot(W)) + 1)/np.sqrt(2.0))
104+
#print(b)
105+
106+
# get ground truth kernel for training
107+
kernel = make_gaussian_kernel(sigma)
108+
109+
# solve NNLS problem
110+
M = np.zeros((N, R))
111+
bo = np.zeros((N,))
112+
for jdx, (xi, yi, wxi, wyi) in enumerate(zip(X, Y, wx, wy)):
113+
k = kernel(xi, yi)
114+
bo[jdx] = k
115+
for idx, (omegai, ai, bi) in enumerate(zip(W, a, b)):
116+
M[jdx, idx] = np.cos(np.dot(omegai, xi - yi))
117+
118+
a, _ = nnls(M, bo, maxiter=1000)
119+
120+
# get new values
121+
new_idxs = (np.abs(a) > 1E-7)
122+
W = W[new_idxs]
123+
b = b[new_idxs]
124+
a = a[new_idxs]
125+
126+
return a, W, b
127+
128+
129+
class ReweightedRFFObservable(SymbolicObservable):
130+
def __init__(self, dimension, num_features, gamma, X, Y, wx, wy, feat_type='rff'):
131+
self.dimension, self.num_features = dimension, num_features
132+
n = num_features
133+
if feat_type == 'quad':
134+
self.a, self.W, self.b = quad_reweighted_map(n, X, Y, wx, wy, np.sqrt(1/(2*gamma)))
135+
elif feat_type == 'rff':
136+
self.a, self.W, self.b = rff_reweighted_map(n, X, Y, wx, wy, np.sqrt(1/(2*gamma)))
137+
else:
138+
raise ValueError('feat_type must be quad or rff')
139+
140+
# make sympy variables for each of the state dimensions
141+
self.variables = [f'x{i}' for i in range(self.dimension)]
142+
self._observables = sp.symbols(self.variables)
143+
X = sp.Matrix(self._observables)
144+
145+
# build observables sympy expressions from self.weights from self.weights, x.T @ points
146+
self.expressions = []
147+
for ai, wi, bi in zip(self.a, self.W, self.b):
148+
self.expressions.append(np.sqrt(ai) * sp.cos(X.dot(wi)))
149+
self.expressions.append(np.sqrt(ai) * sp.sin(X.dot(wi)))
150+
151+
super(ReweightedRFFObservable, self).__init__(self.variables, self.expressions)

autokoopman/observable/rff.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import numpy as np
2+
from scipy.stats import cauchy, laplace
3+
4+
from .observables import KoopmanObservable
5+
6+
7+
class RFFObservable(KoopmanObservable):
8+
def __init__(self, dimension, num_features, gamma, metric="rbf"):
9+
super(RFFObservable, self).__init__()
10+
self.gamma = gamma
11+
self.dimension = dimension
12+
self.metric = metric
13+
self.D = num_features
14+
# Generate D iid samples from p(w)
15+
if self.metric == "rbf":
16+
self.w = np.sqrt(2 * self.gamma) * np.random.normal(
17+
size=(self.D, self.dimension)
18+
)
19+
elif self.metric == "laplace":
20+
self.w = cauchy.rvs(scale=self.gamma, size=(self.D, self.dimension))
21+
# Generate D iid samples from Uniform(0,2*pi)
22+
self.u = 2 * np.pi * np.random.rand(self.D)
23+
24+
def obs_fcn(self, X: np.ndarray) -> np.ndarray:
25+
# modification...
26+
if len(X.shape) == 1:
27+
x = np.atleast_2d(X.flatten()).T
28+
else:
29+
x = X.T
30+
w = self.w.T
31+
u = self.u[np.newaxis, :].T
32+
s = np.sqrt(2 / self.D)
33+
Z = s * np.cos(x.T @ w + u.T)
34+
return Z.T
35+
36+
def obs_grad(self, X: np.ndarray) -> np.ndarray:
37+
if len(X.shape) == 1:
38+
x = np.atleast_2d(X.flatten()).T
39+
else:
40+
x = X.T
41+
x = np.atleast_2d(X.flatten()).T
42+
w = self.w.T
43+
u = self.u[np.newaxis, :].T
44+
s = np.sqrt(2 / self.D)
45+
# TODO: make this sparse?
46+
Z = -s * np.diag(np.sin(u + w.T @ x).flatten()) @ w.T
47+
return Z
48+
49+
def compute_kernel(self, X: np.ndarray) -> np.ndarray:
50+
Z = self.transform(X)
51+
K = Z.dot(Z.T)
52+
return K

0 commit comments

Comments
 (0)