kews 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kews/__init__.py +119 -0
- kews/_core.py +441 -0
- kews/diagnostics.py +116 -0
- kews/observables.py +254 -0
- kews-0.1.0.dist-info/METADATA +173 -0
- kews-0.1.0.dist-info/RECORD +9 -0
- kews-0.1.0.dist-info/WHEEL +5 -0
- kews-0.1.0.dist-info/licenses/LICENSE.txt +21 -0
- kews-0.1.0.dist-info/top_level.txt +1 -0
kews/__init__.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""KEWS: compact Koopman early warning signals."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal, Mapping, Optional, Union
|
|
6
|
+
|
|
7
|
+
import jax
|
|
8
|
+
import jax.numpy as jnp
|
|
9
|
+
from jax.typing import ArrayLike
|
|
10
|
+
|
|
11
|
+
from ._core import rolling_residuals
|
|
12
|
+
from .observables import _RFF, time_delay
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _window_length(n_time: int, window: Union[int, float]) -> int:
|
|
16
|
+
"""Resolve a relative or absolute window length."""
|
|
17
|
+
length = int(window * n_time) if window <= 1 else int(window)
|
|
18
|
+
if not 1 < length < n_time:
|
|
19
|
+
raise ValueError(
|
|
20
|
+
f"window resolves to {length}; expected a value in (1, {n_time})"
|
|
21
|
+
)
|
|
22
|
+
return length
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _windows(data: jax.Array, length: int) -> jax.Array:
|
|
26
|
+
"""Return ``(window, time)`` or ``(window, state, time)`` snapshots."""
|
|
27
|
+
indices = jnp.arange(data.shape[-1] - length + 1)[:, None] + jnp.arange(length)
|
|
28
|
+
if data.ndim == 1:
|
|
29
|
+
return data[indices]
|
|
30
|
+
return jnp.transpose(data[:, indices], (1, 0, 2))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resdmd(
|
|
34
|
+
data: ArrayLike,
|
|
35
|
+
window: Union[int, float] = 0.5,
|
|
36
|
+
observable: Optional[Any] = None,
|
|
37
|
+
rank: Union[int, float] = 0.9,
|
|
38
|
+
control: Optional[ArrayLike] = None,
|
|
39
|
+
*,
|
|
40
|
+
control_method: Literal["dmdc", "augmented"] = "dmdc",
|
|
41
|
+
future_input: Literal["future", "zero"] = "future",
|
|
42
|
+
fit_kwargs: Optional[Mapping[str, Any]] = None,
|
|
43
|
+
) -> jax.Array:
|
|
44
|
+
"""Compute a rolling Koopman early warning signal.
|
|
45
|
+
|
|
46
|
+
The same call covers autonomous and controlled dynamics. Leave ``control``
|
|
47
|
+
unset for autonomous ResDMD or pass an aligned control trajectory to use a
|
|
48
|
+
control-aware formulation. Each residual is aligned to the right edge of
|
|
49
|
+
its window; earlier entries are ``NaN``.
|
|
50
|
+
|
|
51
|
+
A learned observable is fitted on the first window and then held fixed.
|
|
52
|
+
The first window also determines the retained rank. Random Fourier
|
|
53
|
+
features use one seeded basis for the complete trajectory.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
data: Scalar state shaped ``(time,)`` or multivariate state shaped
|
|
57
|
+
``(state, time)``.
|
|
58
|
+
window: Fraction of the trajectory in ``(0, 1)`` or absolute sample
|
|
59
|
+
count. It must resolve to at least two samples and less than the
|
|
60
|
+
complete trajectory.
|
|
61
|
+
observable: Observable created by :mod:`kews.observables`. Defaults to
|
|
62
|
+
``time_delay()``.
|
|
63
|
+
rank: Retained SVD energy fraction in ``(0, 1)`` or an absolute rank.
|
|
64
|
+
control: Optional input shaped ``(time,)`` or ``(control, time)``.
|
|
65
|
+
control_method: ``"dmdc"`` fits DMD with raw control in lifted state
|
|
66
|
+
space. ``"augmented"`` fits ``[phi(x), phi(x)u, u]`` snapshots.
|
|
67
|
+
future_input: For the augmented method, use observed next inputs with
|
|
68
|
+
``"future"`` or replace them with zero using ``"zero"``.
|
|
69
|
+
fit_kwargs: Training arguments for a learned observable, such as
|
|
70
|
+
``epochs``, ``batch_size``, and ``lr`` for ``observables.mlp``.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
One-dimensional JAX array with the same time length as ``data``.
|
|
74
|
+
Positions before the first completed window are ``NaN``.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ValueError: If array orientation, window, rank, control alignment, or a
|
|
78
|
+
method option is invalid.
|
|
79
|
+
"""
|
|
80
|
+
data = jnp.asarray(data)
|
|
81
|
+
if data.ndim not in (1, 2):
|
|
82
|
+
raise ValueError("data must have shape (time,) or (state, time)")
|
|
83
|
+
length = _window_length(data.shape[-1], window)
|
|
84
|
+
|
|
85
|
+
control_windows = None
|
|
86
|
+
if control is not None:
|
|
87
|
+
control = jnp.asarray(control)
|
|
88
|
+
if control.ndim not in (1, 2) or control.shape[-1] != data.shape[-1]:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"control must have shape (time,) or (control, time) and match data"
|
|
91
|
+
)
|
|
92
|
+
control_windows = _windows(control, length)
|
|
93
|
+
|
|
94
|
+
observable = time_delay() if observable is None else observable
|
|
95
|
+
if not callable(getattr(observable, "transform", None)):
|
|
96
|
+
raise ValueError("observable must come from kews.observables")
|
|
97
|
+
|
|
98
|
+
if isinstance(observable, _RFF):
|
|
99
|
+
# Lift once to preserve the paper's shared basis and floating-point evaluation.
|
|
100
|
+
state_windows = _windows(observable.transform(data), length)
|
|
101
|
+
observable = None
|
|
102
|
+
else:
|
|
103
|
+
state_windows = _windows(data, length)
|
|
104
|
+
|
|
105
|
+
residuals = rolling_residuals(
|
|
106
|
+
state_windows,
|
|
107
|
+
observable,
|
|
108
|
+
rank,
|
|
109
|
+
control_windows,
|
|
110
|
+
control_method,
|
|
111
|
+
future_input,
|
|
112
|
+
fit_kwargs,
|
|
113
|
+
)
|
|
114
|
+
result = jnp.full((data.shape[-1],), jnp.nan, dtype=jnp.float32)
|
|
115
|
+
return result.at[length - 1 :].set(residuals)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
__version__ = "0.1.0"
|
|
119
|
+
__all__ = ["resdmd"]
|
kews/_core.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Koopman and ResDMD numerical kernels.
|
|
2
|
+
|
|
3
|
+
Snapshot matrices have shape ``(features, time)``; rolling evaluation adds a
|
|
4
|
+
leading window axis. Normalization and numerical tolerances follow the paper
|
|
5
|
+
reference so that refactoring does not change the plotted indicators.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from numbers import Integral, Real
|
|
11
|
+
from typing import Any, Literal, Mapping, Optional, Union
|
|
12
|
+
|
|
13
|
+
import jax
|
|
14
|
+
import jax.numpy as jnp
|
|
15
|
+
|
|
16
|
+
Rank = Union[int, float]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def delay_embed(x: jax.Array, n_delay: int) -> jax.Array:
|
|
20
|
+
"""Stack delayed state copies with time on the final axis."""
|
|
21
|
+
if not isinstance(n_delay, Integral) or isinstance(n_delay, bool) or n_delay < 1:
|
|
22
|
+
raise ValueError("n_delay must be a positive integer")
|
|
23
|
+
x = jnp.atleast_2d(x)
|
|
24
|
+
n_time = x.shape[1]
|
|
25
|
+
if n_delay > n_time:
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"n_delay must not exceed the time length ({n_time}), got {n_delay}"
|
|
28
|
+
)
|
|
29
|
+
return jnp.vstack(
|
|
30
|
+
[x[:, offset : n_time - n_delay + offset + 1] for offset in range(n_delay)]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def random_fourier_features(
|
|
35
|
+
x: jax.Array,
|
|
36
|
+
n_features: int,
|
|
37
|
+
scale: float,
|
|
38
|
+
key: jax.Array,
|
|
39
|
+
) -> jax.Array:
|
|
40
|
+
"""Map state columns to random cosine features."""
|
|
41
|
+
x = jnp.atleast_2d(x)
|
|
42
|
+
projection_key, phase_key = jax.random.split(key)
|
|
43
|
+
weights = jax.random.normal(projection_key, (n_features, x.shape[0])) / scale
|
|
44
|
+
phase = jax.random.uniform(
|
|
45
|
+
phase_key, (n_features,), maxval=2 * jnp.pi
|
|
46
|
+
)
|
|
47
|
+
return jnp.sqrt(2 / n_features) * jnp.cos(weights @ x + phase[:, None])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _rank(singular_values: jax.Array, low_rank: Rank) -> int:
|
|
51
|
+
"""Resolve an energy fraction or absolute SVD rank to a concrete integer."""
|
|
52
|
+
n_modes = singular_values.shape[0]
|
|
53
|
+
if isinstance(low_rank, Integral) and not isinstance(low_rank, bool):
|
|
54
|
+
if low_rank < 1:
|
|
55
|
+
raise ValueError("rank must be a fraction in (0, 1) or an integer >= 1")
|
|
56
|
+
return min(int(low_rank), n_modes)
|
|
57
|
+
if isinstance(low_rank, Real) and 0 < low_rank < 1:
|
|
58
|
+
energy = singular_values**2
|
|
59
|
+
cumulative = jnp.cumsum(energy) / jnp.maximum(jnp.sum(energy), 1e-12)
|
|
60
|
+
index = int(jax.device_get(jnp.searchsorted(cumulative, low_rank)))
|
|
61
|
+
return min(index + 1, n_modes)
|
|
62
|
+
raise ValueError("rank must be a fraction in (0, 1) or an integer >= 1")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _svd_inverse(singular_values: jax.Array) -> jax.Array:
|
|
66
|
+
"""Return a stable diagonal inverse for retained singular values."""
|
|
67
|
+
return jnp.diag(
|
|
68
|
+
jnp.where(singular_values > 1e-12, 1.0 / singular_values, 0.0)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _edmd_matrices(
|
|
73
|
+
current: jax.Array,
|
|
74
|
+
future: jax.Array,
|
|
75
|
+
rank: int,
|
|
76
|
+
) -> tuple[jax.Array, jax.Array, jax.Array, jax.Array]:
|
|
77
|
+
"""Build EDMD bases, Gram matrix, and cross matrix."""
|
|
78
|
+
# Preserve the paper's snapshot normalization before truncating the SVD.
|
|
79
|
+
_, singular_values, vh = jnp.linalg.svd(
|
|
80
|
+
current / current.shape[1], full_matrices=False
|
|
81
|
+
)
|
|
82
|
+
values = singular_values[:rank]
|
|
83
|
+
right = vh[:rank].T
|
|
84
|
+
inverse = _svd_inverse(values)
|
|
85
|
+
basis_current = current @ right @ inverse
|
|
86
|
+
basis_future = future @ right @ inverse
|
|
87
|
+
gram = basis_current.T @ basis_current
|
|
88
|
+
gram = (gram + gram.T) / 2
|
|
89
|
+
cross = basis_current.T @ basis_future
|
|
90
|
+
return basis_current, basis_future, gram, cross
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _mean_residual(
|
|
94
|
+
gram: jax.Array,
|
|
95
|
+
cross: jax.Array,
|
|
96
|
+
future_gram: jax.Array,
|
|
97
|
+
operator: jax.Array,
|
|
98
|
+
) -> jax.Array:
|
|
99
|
+
"""Average the ResDMD residual over Koopman eigenpairs."""
|
|
100
|
+
eigenvalues, eigenvectors = jnp.linalg.eig(operator)
|
|
101
|
+
|
|
102
|
+
def one(eigenvalue: jax.Array, eigenvector: jax.Array) -> jax.Array:
|
|
103
|
+
# Residual energy is normalized by this eigenfunction's Gram norm.
|
|
104
|
+
residual_matrix = (
|
|
105
|
+
future_gram
|
|
106
|
+
- eigenvalue * cross.conj().T
|
|
107
|
+
- eigenvalue.conj() * cross
|
|
108
|
+
+ jnp.abs(eigenvalue) ** 2 * gram
|
|
109
|
+
)
|
|
110
|
+
conjugate = eigenvector.conj()
|
|
111
|
+
numerator = (conjugate @ residual_matrix @ eigenvector).real
|
|
112
|
+
denominator = (conjugate @ gram @ eigenvector).real
|
|
113
|
+
return jnp.where(
|
|
114
|
+
jnp.abs(denominator) > 1e-10,
|
|
115
|
+
jnp.abs(numerator / denominator),
|
|
116
|
+
0.0,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return jnp.mean(jax.vmap(one)(eigenvalues, eigenvectors.T))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _edmd_residual(current: jax.Array, future: jax.Array, rank: int) -> jax.Array:
|
|
123
|
+
"""Compute one autonomous or augmented EDMD residual."""
|
|
124
|
+
_, basis_future, gram, cross = _edmd_matrices(current, future, rank)
|
|
125
|
+
future_gram = basis_future.T @ basis_future
|
|
126
|
+
future_gram = (future_gram + future_gram.T) / 2
|
|
127
|
+
operator = jnp.linalg.pinv(gram, rtol=1e-4) @ cross
|
|
128
|
+
residual = _mean_residual(gram, cross, future_gram, operator)
|
|
129
|
+
valid = jnp.all(jnp.isfinite(operator)) & jnp.isfinite(residual)
|
|
130
|
+
return jnp.where(valid, residual, 0.0)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _dmdc(
|
|
134
|
+
current: jax.Array,
|
|
135
|
+
future: jax.Array,
|
|
136
|
+
control: jax.Array,
|
|
137
|
+
input_rank: int,
|
|
138
|
+
output_rank: int,
|
|
139
|
+
) -> tuple[jax.Array, jax.Array]:
|
|
140
|
+
"""Fit DMD with control from aligned snapshot matrices."""
|
|
141
|
+
n_state = current.shape[0]
|
|
142
|
+
omega = jnp.vstack([current, control])
|
|
143
|
+
left, singular_values, vh = jnp.linalg.svd(omega, full_matrices=False)
|
|
144
|
+
left = left[:, :input_rank]
|
|
145
|
+
projection = future @ vh[:input_rank].T @ _svd_inverse(
|
|
146
|
+
singular_values[:input_rank]
|
|
147
|
+
)
|
|
148
|
+
state_operator = projection @ left[:n_state].T
|
|
149
|
+
control_operator = projection @ left[n_state:].T
|
|
150
|
+
|
|
151
|
+
# Match the reduced-order lift used by the paper reference implementation.
|
|
152
|
+
output_basis = jnp.linalg.svd(future, full_matrices=False)[0][:, :output_rank]
|
|
153
|
+
state_operator = (
|
|
154
|
+
output_basis
|
|
155
|
+
@ (output_basis.T @ state_operator @ output_basis)
|
|
156
|
+
@ output_basis.T
|
|
157
|
+
)
|
|
158
|
+
control_operator = output_basis @ (output_basis.T @ control_operator)
|
|
159
|
+
return state_operator, control_operator
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _control_residual(
|
|
163
|
+
current: jax.Array,
|
|
164
|
+
future: jax.Array,
|
|
165
|
+
control: jax.Array,
|
|
166
|
+
input_rank: int,
|
|
167
|
+
output_rank: int,
|
|
168
|
+
) -> jax.Array:
|
|
169
|
+
"""Compute one DMDc residual in lifted state space."""
|
|
170
|
+
operator, _ = _dmdc(current, future, control, input_rank, output_rank)
|
|
171
|
+
gram = current @ current.T
|
|
172
|
+
cross = current @ future.T
|
|
173
|
+
future_gram = future @ future.T
|
|
174
|
+
future_gram = (future_gram + future_gram.T) / 2
|
|
175
|
+
residual = _mean_residual(gram, cross, future_gram, operator)
|
|
176
|
+
valid = jnp.all(jnp.isfinite(operator)) & jnp.isfinite(residual)
|
|
177
|
+
return jnp.where(valid, residual, 0.0)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _augment(
|
|
181
|
+
lifted: jax.Array,
|
|
182
|
+
control: jax.Array,
|
|
183
|
+
future_input: Literal["future", "zero"],
|
|
184
|
+
) -> tuple[jax.Array, jax.Array]:
|
|
185
|
+
"""Build ``[phi(x), phi(x)u, u]`` current and future snapshots."""
|
|
186
|
+
state_current, state_future = lifted[:, :-1], lifted[:, 1:]
|
|
187
|
+
control_current = control[:, :-1]
|
|
188
|
+
if future_input == "future":
|
|
189
|
+
control_future = control[:, 1:]
|
|
190
|
+
elif future_input == "zero":
|
|
191
|
+
control_future = jnp.zeros_like(control_current)
|
|
192
|
+
else:
|
|
193
|
+
raise ValueError("future_input must be 'future' or 'zero'")
|
|
194
|
+
|
|
195
|
+
interaction_current = jnp.einsum(
|
|
196
|
+
"it,jt->ijt", state_current, control_current
|
|
197
|
+
).reshape((-1, state_current.shape[1]))
|
|
198
|
+
interaction_future = jnp.einsum(
|
|
199
|
+
"it,jt->ijt", state_future, control_future
|
|
200
|
+
).reshape((-1, state_future.shape[1]))
|
|
201
|
+
return (
|
|
202
|
+
jnp.vstack([state_current, interaction_current, control_current]),
|
|
203
|
+
jnp.vstack([state_future, interaction_future, control_future]),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _lift(observable: Any, x: jax.Array) -> jax.Array:
|
|
208
|
+
"""Apply an observable, or pass through features lifted before windowing."""
|
|
209
|
+
return x if observable is None else observable.transform(x)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def rolling_residuals(
|
|
213
|
+
windows: jax.Array,
|
|
214
|
+
observable: Any,
|
|
215
|
+
low_rank: Rank,
|
|
216
|
+
control_windows: Optional[jax.Array] = None,
|
|
217
|
+
control_method: Literal["dmdc", "augmented"] = "dmdc",
|
|
218
|
+
future_input: Literal["future", "zero"] = "future",
|
|
219
|
+
fit_kwargs: Optional[Mapping[str, Any]] = None,
|
|
220
|
+
) -> jax.Array:
|
|
221
|
+
"""Compute one residual for every pre-built rolling window."""
|
|
222
|
+
if getattr(observable, "fit_on_first_window", False):
|
|
223
|
+
# Freeze learned features after the initial window to avoid future-data leakage.
|
|
224
|
+
observable.fit(windows[0], **dict(fit_kwargs or {}))
|
|
225
|
+
|
|
226
|
+
first_lifted = _lift(observable, windows[0])
|
|
227
|
+
if first_lifted.shape[1] < 2:
|
|
228
|
+
raise ValueError("observable must produce at least two time columns per window")
|
|
229
|
+
|
|
230
|
+
if control_windows is None:
|
|
231
|
+
first_current = first_lifted[:, :-1]
|
|
232
|
+
singular_values = jnp.linalg.svd(
|
|
233
|
+
first_current / first_current.shape[1], full_matrices=False
|
|
234
|
+
)[1]
|
|
235
|
+
rank = _rank(singular_values, low_rank)
|
|
236
|
+
|
|
237
|
+
# Hold rank fixed so later windows use the same approximation dimension.
|
|
238
|
+
@jax.jit
|
|
239
|
+
def one(window: jax.Array) -> jax.Array:
|
|
240
|
+
lifted = _lift(observable, window)
|
|
241
|
+
return _edmd_residual(lifted[:, :-1], lifted[:, 1:], rank)
|
|
242
|
+
|
|
243
|
+
return jax.vmap(one)(windows)
|
|
244
|
+
|
|
245
|
+
if control_method not in ("dmdc", "augmented"):
|
|
246
|
+
raise ValueError("control_method must be 'dmdc' or 'augmented'")
|
|
247
|
+
|
|
248
|
+
first_control = control_windows[0]
|
|
249
|
+
first_control = (
|
|
250
|
+
first_control.reshape((1, -1))
|
|
251
|
+
if first_control.ndim == 1
|
|
252
|
+
else first_control
|
|
253
|
+
)
|
|
254
|
+
n_lifted = first_lifted.shape[1]
|
|
255
|
+
|
|
256
|
+
# Delay coordinates end at the latest sample; align control to those endpoints.
|
|
257
|
+
if control_method == "dmdc":
|
|
258
|
+
first_current, first_future = first_lifted[:, :-1], first_lifted[:, 1:]
|
|
259
|
+
aligned = first_control[:, first_control.shape[1] - n_lifted : -1]
|
|
260
|
+
input_rank = _rank(
|
|
261
|
+
jnp.linalg.svd(jnp.vstack([first_current, aligned]), full_matrices=False)[1],
|
|
262
|
+
low_rank,
|
|
263
|
+
)
|
|
264
|
+
output_rank = _rank(
|
|
265
|
+
jnp.linalg.svd(first_future, full_matrices=False)[1], low_rank
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
@jax.jit
|
|
269
|
+
def one(window: jax.Array, control_window: jax.Array) -> jax.Array:
|
|
270
|
+
lifted = _lift(observable, window)
|
|
271
|
+
control = (
|
|
272
|
+
control_window.reshape((1, -1))
|
|
273
|
+
if control_window.ndim == 1
|
|
274
|
+
else control_window
|
|
275
|
+
)
|
|
276
|
+
aligned = control[:, control.shape[1] - lifted.shape[1] : -1]
|
|
277
|
+
return _control_residual(
|
|
278
|
+
lifted[:, :-1],
|
|
279
|
+
lifted[:, 1:],
|
|
280
|
+
aligned,
|
|
281
|
+
input_rank,
|
|
282
|
+
output_rank,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
return jax.vmap(one)(windows, control_windows)
|
|
286
|
+
|
|
287
|
+
first_aligned = first_control[:, first_control.shape[1] - n_lifted :]
|
|
288
|
+
first_current, _ = _augment(first_lifted, first_aligned, future_input)
|
|
289
|
+
rank = _rank(
|
|
290
|
+
jnp.linalg.svd(
|
|
291
|
+
first_current / first_current.shape[1], full_matrices=False
|
|
292
|
+
)[1],
|
|
293
|
+
low_rank,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
@jax.jit
|
|
297
|
+
def one(window: jax.Array, control_window: jax.Array) -> jax.Array:
|
|
298
|
+
lifted = _lift(observable, window)
|
|
299
|
+
control = (
|
|
300
|
+
control_window.reshape((1, -1))
|
|
301
|
+
if control_window.ndim == 1
|
|
302
|
+
else control_window
|
|
303
|
+
)
|
|
304
|
+
aligned = control[:, control.shape[1] - lifted.shape[1] :]
|
|
305
|
+
current, future = _augment(lifted, aligned, future_input)
|
|
306
|
+
return _edmd_residual(current, future, rank)
|
|
307
|
+
|
|
308
|
+
return jax.vmap(one)(windows, control_windows)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def fit_koopman_edmd(
|
|
312
|
+
x: jax.Array,
|
|
313
|
+
observable: Any,
|
|
314
|
+
low_rank: Rank = 0.9,
|
|
315
|
+
fit_kwargs: Optional[Mapping[str, Any]] = None,
|
|
316
|
+
) -> dict[str, Any]:
|
|
317
|
+
"""Fit an autonomous Koopman operator in a supplied observable space."""
|
|
318
|
+
x = jnp.asarray(x)
|
|
319
|
+
if getattr(observable, "fit_on_first_window", False):
|
|
320
|
+
observable.fit(x, **dict(fit_kwargs or {}))
|
|
321
|
+
lifted = _lift(observable, x)
|
|
322
|
+
if lifted.shape[1] < 2:
|
|
323
|
+
raise ValueError("observable must produce at least two time columns")
|
|
324
|
+
current, future = lifted[:, :-1], lifted[:, 1:]
|
|
325
|
+
singular_values = jnp.linalg.svd(
|
|
326
|
+
current / current.shape[1], full_matrices=False
|
|
327
|
+
)[1]
|
|
328
|
+
rank = _rank(singular_values, low_rank)
|
|
329
|
+
basis_current, basis_future, gram, cross = _edmd_matrices(
|
|
330
|
+
current, future, rank
|
|
331
|
+
)
|
|
332
|
+
operator = jnp.linalg.lstsq(gram, cross, rcond=1e-10)[0]
|
|
333
|
+
eigenvalues, eigenvectors = jnp.linalg.eig(operator)
|
|
334
|
+
return {
|
|
335
|
+
"K": operator,
|
|
336
|
+
"eigvals": eigenvalues,
|
|
337
|
+
"eigvecs": eigenvectors.T,
|
|
338
|
+
"Psi_X": basis_current.T @ current,
|
|
339
|
+
"Psi_Y": basis_current.T @ future,
|
|
340
|
+
"r": rank,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _aligned_control(x: jax.Array, u: jax.Array) -> jax.Array:
|
|
345
|
+
"""Normalize control orientation and validate raw time alignment."""
|
|
346
|
+
u = jnp.atleast_2d(u)
|
|
347
|
+
if u.shape[1] != x.shape[-1]:
|
|
348
|
+
raise ValueError("control must have the same time length as state")
|
|
349
|
+
return u
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def compute_dmdc(
|
|
353
|
+
x: jax.Array,
|
|
354
|
+
u: jax.Array,
|
|
355
|
+
dim_delay: int = 5,
|
|
356
|
+
low_rank: Rank = 0.9,
|
|
357
|
+
) -> tuple[jax.Array, jax.Array]:
|
|
358
|
+
"""Fit DMDc in delay-coordinate state space."""
|
|
359
|
+
x = jnp.atleast_1d(x).flatten()
|
|
360
|
+
u = _aligned_control(x, u)
|
|
361
|
+
if x.size < dim_delay + 2:
|
|
362
|
+
raise ValueError(f"data needs at least {dim_delay + 2} time samples")
|
|
363
|
+
lifted = delay_embed(x, dim_delay)
|
|
364
|
+
current, future = lifted[:, :-1], lifted[:, 1:]
|
|
365
|
+
control = u[:, dim_delay - 1 : -1]
|
|
366
|
+
input_rank = _rank(
|
|
367
|
+
jnp.linalg.svd(jnp.vstack([current, control]), full_matrices=False)[1],
|
|
368
|
+
low_rank,
|
|
369
|
+
)
|
|
370
|
+
output_rank = _rank(jnp.linalg.svd(future, full_matrices=False)[1], low_rank)
|
|
371
|
+
return _dmdc(current, future, control, input_rank, output_rank)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def compute_edmdwc(
|
|
375
|
+
x: jax.Array,
|
|
376
|
+
u: jax.Array,
|
|
377
|
+
dim_delay: int = 5,
|
|
378
|
+
low_rank: Rank = 0.9,
|
|
379
|
+
) -> tuple[jax.Array, jax.Array]:
|
|
380
|
+
"""Fit reduced EDMD with control in delay-coordinate state space."""
|
|
381
|
+
x = jnp.atleast_1d(x).flatten()
|
|
382
|
+
u = _aligned_control(x, u)
|
|
383
|
+
if x.size < dim_delay + 2:
|
|
384
|
+
raise ValueError(f"data needs at least {dim_delay + 2} time samples")
|
|
385
|
+
lifted = delay_embed(x, dim_delay)
|
|
386
|
+
current, future = lifted[:, :-1], lifted[:, 1:]
|
|
387
|
+
control = u[:, dim_delay - 1 : -1]
|
|
388
|
+
left, singular_values, _ = jnp.linalg.svd(current, full_matrices=False)
|
|
389
|
+
rank = _rank(singular_values, low_rank)
|
|
390
|
+
basis = left[:, :rank]
|
|
391
|
+
current_reduced, future_reduced = basis.T @ current, basis.T @ future
|
|
392
|
+
fit = future_reduced @ jnp.linalg.pinv(
|
|
393
|
+
jnp.vstack([current_reduced, control]), rtol=1e-4
|
|
394
|
+
)
|
|
395
|
+
return (
|
|
396
|
+
basis @ fit[:, :rank] @ basis.T,
|
|
397
|
+
basis @ fit[:, rank:],
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def generator_eigenvalues(eigenvalues: jax.Array, dt: float = 1.0) -> jax.Array:
|
|
402
|
+
"""Convert discrete Koopman eigenvalues to infinitesimal-generator values."""
|
|
403
|
+
if not isinstance(dt, Real) or dt <= 0:
|
|
404
|
+
raise ValueError("dt must be positive")
|
|
405
|
+
eigenvalues = jnp.asarray(eigenvalues)
|
|
406
|
+
safe = jnp.where(jnp.abs(eigenvalues) < 1e-12, jnp.nan + 0j, eigenvalues)
|
|
407
|
+
return jnp.log(safe) / dt
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def generator_dominant_mode(
|
|
411
|
+
eigenvalues: jax.Array, dt: float = 1.0
|
|
412
|
+
) -> dict[str, Any]:
|
|
413
|
+
"""Summarize the slowest non-constant finite generator mode."""
|
|
414
|
+
if not isinstance(dt, Real) or dt <= 0:
|
|
415
|
+
raise ValueError("dt must be positive")
|
|
416
|
+
eigenvalues = jnp.asarray(eigenvalues)
|
|
417
|
+
finite = jnp.isfinite(eigenvalues.real) & jnp.isfinite(eigenvalues.imag)
|
|
418
|
+
valid = finite & (jnp.abs(eigenvalues) > 1e-10)
|
|
419
|
+
if not bool(jnp.any(valid)):
|
|
420
|
+
return {
|
|
421
|
+
"decay_rate": float("nan"),
|
|
422
|
+
"angular_freq": float("nan"),
|
|
423
|
+
"period": float("nan"),
|
|
424
|
+
"e_folding_time": float("nan"),
|
|
425
|
+
"is_oscillatory": False,
|
|
426
|
+
"dominant_idx": -1,
|
|
427
|
+
}
|
|
428
|
+
index = int(jnp.argmin(jnp.where(valid, jnp.abs(eigenvalues.real), jnp.inf)))
|
|
429
|
+
value = eigenvalues[index]
|
|
430
|
+
decay = float(value.real)
|
|
431
|
+
frequency = float(value.imag)
|
|
432
|
+
period = 2 * jnp.pi / abs(frequency) if abs(frequency) > 1e-10 else jnp.nan
|
|
433
|
+
period = float(period)
|
|
434
|
+
return {
|
|
435
|
+
"decay_rate": decay,
|
|
436
|
+
"angular_freq": frequency,
|
|
437
|
+
"period": period,
|
|
438
|
+
"e_folding_time": 1 / abs(decay) if decay < -1e-10 else float("nan"),
|
|
439
|
+
"is_oscillatory": bool(2 * dt <= period <= 1e4 * dt),
|
|
440
|
+
"dominant_idx": index,
|
|
441
|
+
}
|
kews/diagnostics.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Optional operator and spectrum diagnostics kept outside the main API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping, Optional, Union
|
|
6
|
+
|
|
7
|
+
import jax
|
|
8
|
+
|
|
9
|
+
from ._core import (
|
|
10
|
+
compute_dmdc as _dmdc,
|
|
11
|
+
compute_edmdwc as _edmdc,
|
|
12
|
+
fit_koopman_edmd as _fit_operator,
|
|
13
|
+
generator_dominant_mode as _dominant_mode,
|
|
14
|
+
generator_eigenvalues as _generator_eigenvalues,
|
|
15
|
+
)
|
|
16
|
+
from .observables import time_delay
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fit_operator(
|
|
20
|
+
data: jax.Array,
|
|
21
|
+
observable: Optional[Any] = None,
|
|
22
|
+
rank: Union[int, float] = 0.9,
|
|
23
|
+
fit_kwargs: Optional[Mapping[str, Any]] = None,
|
|
24
|
+
) -> dict[str, Any]:
|
|
25
|
+
"""Fit an autonomous EDMD Koopman operator for diagnostics.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
data: State trajectory shaped ``(time,)`` or ``(state, time)``.
|
|
29
|
+
observable: Observable from :mod:`kews.observables`; defaults to
|
|
30
|
+
``time_delay()``.
|
|
31
|
+
rank: Retained SVD energy fraction or absolute rank.
|
|
32
|
+
fit_kwargs: Optional training arguments for learned observables.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Dictionary containing ``K``, ``eigvals``, row-wise ``eigvecs``,
|
|
36
|
+
``Psi_X``, ``Psi_Y``, and the retained rank ``r``.
|
|
37
|
+
"""
|
|
38
|
+
return _fit_operator(
|
|
39
|
+
data,
|
|
40
|
+
time_delay() if observable is None else observable,
|
|
41
|
+
rank,
|
|
42
|
+
fit_kwargs,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def dmdc(
|
|
47
|
+
data: jax.Array,
|
|
48
|
+
control: jax.Array,
|
|
49
|
+
n_delay: int = 5,
|
|
50
|
+
rank: Union[int, float] = 0.9,
|
|
51
|
+
) -> tuple[jax.Array, jax.Array]:
|
|
52
|
+
"""Fit Dynamic Mode Decomposition with control.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
data: Scalar state trajectory shaped ``(time,)``.
|
|
56
|
+
control: Input trajectory shaped ``(time,)`` or ``(control, time)``.
|
|
57
|
+
n_delay: Delay-coordinate dimension for state.
|
|
58
|
+
rank: Retained SVD energy fraction or absolute rank.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Tuple ``(A, B)`` for ``x_next = A @ x + B @ u`` in delay space.
|
|
62
|
+
"""
|
|
63
|
+
return _dmdc(data, control, n_delay, rank)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def edmdc(
|
|
67
|
+
data: jax.Array,
|
|
68
|
+
control: jax.Array,
|
|
69
|
+
n_delay: int = 5,
|
|
70
|
+
rank: Union[int, float] = 0.9,
|
|
71
|
+
) -> tuple[jax.Array, jax.Array]:
|
|
72
|
+
"""Fit reduced EDMD with control in delay-coordinate space.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
data: Scalar state trajectory shaped ``(time,)``.
|
|
76
|
+
control: Input trajectory shaped ``(time,)`` or ``(control, time)``.
|
|
77
|
+
n_delay: Delay-coordinate dimension for state.
|
|
78
|
+
rank: Retained SVD energy fraction or absolute rank.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Tuple ``(K, B)`` containing lifted state and control operators.
|
|
82
|
+
"""
|
|
83
|
+
return _edmdc(data, control, n_delay, rank)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def generator_eigenvalues(
|
|
87
|
+
eigenvalues: jax.Array, dt: float = 1.0
|
|
88
|
+
) -> jax.Array:
|
|
89
|
+
"""Convert discrete Koopman eigenvalues to generator eigenvalues.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
eigenvalues: Complex eigenvalues of a discrete Koopman operator.
|
|
93
|
+
dt: Positive sampling interval.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Complex generator values ``log(eigenvalues) / dt``. Values near zero
|
|
97
|
+
map to ``NaN``.
|
|
98
|
+
"""
|
|
99
|
+
return _generator_eigenvalues(eigenvalues, dt)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def dominant_mode(eigenvalues: jax.Array, dt: float = 1.0) -> dict[str, Any]:
|
|
103
|
+
"""Summarize the slowest finite non-constant generator mode.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
eigenvalues: Complex generator eigenvalues.
|
|
107
|
+
dt: Positive sampling interval used for oscillation bounds.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Decay rate, angular frequency, period, e-folding time, oscillatory flag,
|
|
111
|
+
and selected eigenvalue index.
|
|
112
|
+
"""
|
|
113
|
+
return _dominant_mode(eigenvalues, dt)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = ["dmdc", "dominant_mode", "edmdc", "fit_operator", "generator_eigenvalues"]
|
kews/observables.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Observable factories for :func:`kews.resdmd`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from typing import Any, Callable, Optional, Sequence
|
|
7
|
+
|
|
8
|
+
import flax.linen as nn
|
|
9
|
+
import jax
|
|
10
|
+
import jax.numpy as jnp
|
|
11
|
+
import optax
|
|
12
|
+
|
|
13
|
+
from ._core import delay_embed, random_fourier_features
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _matrix(x: jax.Array) -> jax.Array:
|
|
17
|
+
"""Orient time-series input as ``(state, time)``."""
|
|
18
|
+
return jnp.atleast_2d(x)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _TimeDelay:
|
|
22
|
+
"""Time-delay observable returned by :func:`kews.observables.time_delay`."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, n_delay: int):
|
|
25
|
+
if n_delay < 1:
|
|
26
|
+
raise ValueError("n_delay must be >= 1")
|
|
27
|
+
self.n_delay = n_delay
|
|
28
|
+
|
|
29
|
+
def transform(self, x: jax.Array) -> jax.Array:
|
|
30
|
+
"""Return delayed state columns."""
|
|
31
|
+
return delay_embed(x, self.n_delay)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _RFF:
|
|
35
|
+
"""Random Fourier observable returned by :func:`kews.observables.rff`."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, n_features: int, scale: float, seed: int):
|
|
38
|
+
if n_features < 1:
|
|
39
|
+
raise ValueError("n_features must be >= 1")
|
|
40
|
+
if scale <= 0:
|
|
41
|
+
raise ValueError("scale must be positive")
|
|
42
|
+
self.n_features = n_features
|
|
43
|
+
self.scale = scale
|
|
44
|
+
self.seed = seed
|
|
45
|
+
|
|
46
|
+
def transform(self, x: jax.Array) -> jax.Array:
|
|
47
|
+
"""Return a fixed random cosine feature matrix."""
|
|
48
|
+
key = jax.random.PRNGKey(self.seed)
|
|
49
|
+
return random_fourier_features(x, self.n_features, self.scale, key)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _activation(name: str) -> Callable[[jax.Array], jax.Array]:
|
|
53
|
+
"""Resolve a supported activation name."""
|
|
54
|
+
activations = {
|
|
55
|
+
"leaky_relu": nn.leaky_relu,
|
|
56
|
+
"relu": nn.relu,
|
|
57
|
+
"sigmoid": nn.sigmoid,
|
|
58
|
+
"tanh": nn.tanh,
|
|
59
|
+
}
|
|
60
|
+
if name not in activations:
|
|
61
|
+
raise ValueError(f"activation must be one of {tuple(activations)}")
|
|
62
|
+
return activations[name]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _AutoEncoder(nn.Module):
|
|
66
|
+
"""Symmetric dense autoencoder with an exposed encoder.
|
|
67
|
+
|
|
68
|
+
Layer creation order preserves the paper's seeded parameter initialization.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
input_dim: int
|
|
72
|
+
hidden_layers: tuple[int, ...]
|
|
73
|
+
bottleneck_dim: int
|
|
74
|
+
activation: Callable[[jax.Array], jax.Array]
|
|
75
|
+
|
|
76
|
+
@nn.compact
|
|
77
|
+
def encode(self, x: jax.Array) -> jax.Array:
|
|
78
|
+
"""Return bottleneck features shaped ``(features, time)``."""
|
|
79
|
+
hidden = x.T
|
|
80
|
+
for index, width in enumerate(self.hidden_layers):
|
|
81
|
+
hidden = nn.Dense(width, use_bias=False)(hidden)
|
|
82
|
+
if index < len(self.hidden_layers) - 1:
|
|
83
|
+
hidden = self.activation(hidden)
|
|
84
|
+
return nn.Dense(self.bottleneck_dim, use_bias=False)(hidden).T
|
|
85
|
+
|
|
86
|
+
@nn.compact
|
|
87
|
+
def __call__(self, x: jax.Array) -> jax.Array:
|
|
88
|
+
"""Reconstruct input shaped ``(state, time)``."""
|
|
89
|
+
hidden = self.encode(x).T
|
|
90
|
+
for index, width in enumerate(reversed(self.hidden_layers)):
|
|
91
|
+
hidden = nn.Dense(width, use_bias=False)(hidden)
|
|
92
|
+
if index < len(self.hidden_layers) - 1:
|
|
93
|
+
hidden = self.activation(hidden)
|
|
94
|
+
return nn.Dense(self.input_dim, use_bias=False)(hidden).T
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@lru_cache(maxsize=32)
|
|
98
|
+
def _train_step(module: _AutoEncoder, lr: float):
|
|
99
|
+
"""Reuse compilation across the paper's same-architecture ensemble fits."""
|
|
100
|
+
optimizer = optax.adamw(lr)
|
|
101
|
+
|
|
102
|
+
@jax.jit
|
|
103
|
+
def step(params: Any, state: Any, batch: jax.Array):
|
|
104
|
+
def loss(candidate: Any) -> jax.Array:
|
|
105
|
+
reconstruction = module.apply(candidate, batch.T)
|
|
106
|
+
return jnp.mean((batch.T - reconstruction) ** 2)
|
|
107
|
+
|
|
108
|
+
_, gradients = jax.value_and_grad(loss)(params)
|
|
109
|
+
updates, state = optimizer.update(gradients, state, params)
|
|
110
|
+
return optax.apply_updates(params, updates), state
|
|
111
|
+
|
|
112
|
+
return step, optimizer
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _MLP:
|
|
116
|
+
"""Trainable observable returned by :func:`kews.observables.mlp`."""
|
|
117
|
+
|
|
118
|
+
fit_on_first_window = True
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
hidden_layers: Sequence[int],
|
|
123
|
+
bottleneck_dim: int,
|
|
124
|
+
activation: str,
|
|
125
|
+
seed: int,
|
|
126
|
+
):
|
|
127
|
+
if not hidden_layers or any(width < 1 for width in hidden_layers):
|
|
128
|
+
raise ValueError("hidden_layers must contain positive widths")
|
|
129
|
+
if bottleneck_dim < 1:
|
|
130
|
+
raise ValueError("bottleneck_dim must be >= 1")
|
|
131
|
+
self.hidden_layers = tuple(hidden_layers)
|
|
132
|
+
self.bottleneck_dim = bottleneck_dim
|
|
133
|
+
self.activation = _activation(activation)
|
|
134
|
+
self.seed = seed
|
|
135
|
+
self._input_dim: Optional[int] = None
|
|
136
|
+
self._module: Optional[_AutoEncoder] = None
|
|
137
|
+
self._params: Any = None
|
|
138
|
+
|
|
139
|
+
def fit(
|
|
140
|
+
self,
|
|
141
|
+
x: jax.Array,
|
|
142
|
+
*,
|
|
143
|
+
lr: float = 1e-3,
|
|
144
|
+
epochs: int = 1000,
|
|
145
|
+
batch_size: int = 32,
|
|
146
|
+
key: Optional[jax.Array] = None,
|
|
147
|
+
shuffle: bool = True,
|
|
148
|
+
) -> "_MLP":
|
|
149
|
+
"""Train on time columns using AdamW and return this observable.
|
|
150
|
+
|
|
151
|
+
The seed controls both initialization and epoch shuffles. Repeated
|
|
152
|
+
calls continue from existing parameters with a fresh optimizer state.
|
|
153
|
+
``resdmd`` supplies only the first analysis window to this method.
|
|
154
|
+
"""
|
|
155
|
+
if lr <= 0 or epochs < 1 or batch_size < 1:
|
|
156
|
+
raise ValueError("lr, epochs, and batch_size must be positive")
|
|
157
|
+
x = _matrix(x)
|
|
158
|
+
if self._input_dim not in (None, x.shape[0]):
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"MLP was fitted with {self._input_dim} states, got {x.shape[0]}"
|
|
161
|
+
)
|
|
162
|
+
self._input_dim = x.shape[0]
|
|
163
|
+
self._module = _AutoEncoder(
|
|
164
|
+
self._input_dim,
|
|
165
|
+
self.hidden_layers,
|
|
166
|
+
self.bottleneck_dim,
|
|
167
|
+
self.activation,
|
|
168
|
+
)
|
|
169
|
+
key = jax.random.PRNGKey(self.seed) if key is None else key
|
|
170
|
+
if self._params is None:
|
|
171
|
+
self._params = self._module.init(jax.random.PRNGKey(self.seed), x)
|
|
172
|
+
params = self._params
|
|
173
|
+
step, optimizer = _train_step(self._module, lr)
|
|
174
|
+
state = optimizer.init(params)
|
|
175
|
+
|
|
176
|
+
samples = x.T
|
|
177
|
+
batch_size = min(batch_size, samples.shape[0])
|
|
178
|
+
for _ in range(epochs):
|
|
179
|
+
epoch_samples = samples
|
|
180
|
+
if shuffle:
|
|
181
|
+
key, shuffle_key = jax.random.split(key)
|
|
182
|
+
# Shuffle the original sample order, preserving the seeded training path.
|
|
183
|
+
epoch_samples = samples[jax.random.permutation(shuffle_key, samples.shape[0])]
|
|
184
|
+
for start in range(0, samples.shape[0], batch_size):
|
|
185
|
+
params, state = step(params, state, epoch_samples[start : start + batch_size])
|
|
186
|
+
self._params = params
|
|
187
|
+
return self
|
|
188
|
+
|
|
189
|
+
def transform(self, x: jax.Array) -> jax.Array:
|
|
190
|
+
"""Encode a trajectory after fitting."""
|
|
191
|
+
x = _matrix(x)
|
|
192
|
+
if self._module is None or self._params is None:
|
|
193
|
+
raise RuntimeError("MLP must be fitted before transform")
|
|
194
|
+
if x.shape[0] != self._input_dim:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"MLP was fitted with {self._input_dim} states, got {x.shape[0]}"
|
|
197
|
+
)
|
|
198
|
+
return self._module.apply(self._params, x, method=self._module.encode)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def time_delay(n_delay: int = 5) -> _TimeDelay:
|
|
202
|
+
"""Create a delay-coordinate observable.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
n_delay: Number of delayed state copies in each feature vector.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
An observable accepted by :func:`kews.resdmd`.
|
|
209
|
+
"""
|
|
210
|
+
return _TimeDelay(n_delay)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# Common abbreviation for time-delay features.
|
|
214
|
+
tdf = time_delay
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def rff(
|
|
218
|
+
n_features: int = 500, scale: float = 1.0, seed: int = 0
|
|
219
|
+
) -> _RFF:
|
|
220
|
+
"""Create a random Fourier feature observable.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
n_features: Number of random cosine features.
|
|
224
|
+
scale: Positive RBF-kernel length scale.
|
|
225
|
+
seed: Random projection seed.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
An observable accepted by :func:`kews.resdmd`.
|
|
229
|
+
"""
|
|
230
|
+
return _RFF(n_features, scale, seed)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def mlp(
|
|
234
|
+
hidden_layers: Sequence[int] = (16, 32),
|
|
235
|
+
bottleneck_dim: int = 3,
|
|
236
|
+
activation: str = "sigmoid",
|
|
237
|
+
seed: int = 0,
|
|
238
|
+
) -> _MLP:
|
|
239
|
+
"""Create a learned autoencoder observable.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
hidden_layers: Encoder widths; the decoder mirrors them in reverse.
|
|
243
|
+
bottleneck_dim: Number of learned observable features.
|
|
244
|
+
activation: ``"relu"``, ``"tanh"``, ``"sigmoid"``, or
|
|
245
|
+
``"leaky_relu"``.
|
|
246
|
+
seed: Parameter initialization and training seed.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
A trainable observable accepted by :func:`kews.resdmd`.
|
|
250
|
+
"""
|
|
251
|
+
return _MLP(hidden_layers, bottleneck_dim, activation, seed)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
__all__ = ["mlp", "rff", "tdf", "time_delay"]
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kews
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Koopman operator early warning signals
|
|
5
|
+
Author-email: Juan Nathaniel <jn2808@columbia.edu>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/juannat7/kews
|
|
8
|
+
Project-URL: Issues, https://github.com/juannat7/kews/issues
|
|
9
|
+
Project-URL: Paper, https://arxiv.org/abs/2608.14716
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE.txt
|
|
16
|
+
Requires-Dist: flax==0.12.4
|
|
17
|
+
Requires-Dist: jax==0.9.0.1
|
|
18
|
+
Requires-Dist: jaxlib==0.9.0.1
|
|
19
|
+
Requires-Dist: optax==0.2.6
|
|
20
|
+
Provides-Extra: paper
|
|
21
|
+
Requires-Dist: numpy==2.3.5; extra == "paper"
|
|
22
|
+
Requires-Dist: scipy==1.16.3; extra == "paper"
|
|
23
|
+
Requires-Dist: matplotlib==3.10.8; extra == "paper"
|
|
24
|
+
Requires-Dist: scikit-learn==1.8.0; extra == "paper"
|
|
25
|
+
Requires-Dist: pandas==3.0.0; extra == "paper"
|
|
26
|
+
Requires-Dist: xarray==2026.1.0; extra == "paper"
|
|
27
|
+
Requires-Dist: netCDF4==1.7.3; extra == "paper"
|
|
28
|
+
Requires-Dist: cmocean==4.0.3; extra == "paper"
|
|
29
|
+
Requires-Dist: requests==2.32.5; extra == "paper"
|
|
30
|
+
Requires-Dist: tqdm==4.67.1; extra == "paper"
|
|
31
|
+
Requires-Dist: jupyterlab==4.5.0; extra == "paper"
|
|
32
|
+
Requires-Dist: nbconvert==7.16.6; extra == "paper"
|
|
33
|
+
Requires-Dist: ipykernel==7.1.0; extra == "paper"
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
36
|
+
Requires-Dist: twine>=6; extra == "dev"
|
|
37
|
+
Dynamic: license-file
|
|
38
|
+
|
|
39
|
+
# KEWS: Koopman Early Warning Signals
|
|
40
|
+
|
|
41
|
+
**KEWS** (pronounced “cues,” as in cues to potential tipping) is a JAX package for Koopman-based early warning signals of bifurcation- and rate-induced tipping.
|
|
42
|
+
|
|
43
|
+
<div align="center">
|
|
44
|
+
|
|
45
|
+
[](https://www.python.org/)
|
|
46
|
+
[](https://docs.jax.dev/)
|
|
47
|
+
[](https://github.com/juannat7/kews/blob/main/LICENSE.txt)
|
|
48
|
+
[](https://pypi.org/project/kews/)
|
|
49
|
+
[](https://pypi.org/project/kews/)
|
|
50
|
+
[](https://arxiv.org/abs/2608.14716)
|
|
51
|
+
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
## Abstract
|
|
55
|
+
|
|
56
|
+
Abrupt transitions in complex systems are often preceded by early warning signals. However, most indicators rely on the notion of critical slowing down and do not generally extend to rate-induced tipping where transitions can occur without local loss of stability. This is problematic in stochastic, nonautonomous systems where internal variability and time-varying variables interact to shape tipping onset.
|
|
57
|
+
|
|
58
|
+
We use Koopman operator theory to develop a unified early warning framework for both bifurcation and rate-induced tipping in stochastic systems. Our approach builds on residual Koopman mode decomposition that measures discrepancies between dynamics and their finite-dimensional approximation, and extends it to the control setting by augmenting the observable space with time-varying control variables.
|
|
59
|
+
|
|
60
|
+
In idealized examples, the resulting indicators recover expected signatures near bifurcation points and improve detection in rate-induced regimes where classical indicators fail. We further show that learned embeddings through deep learning outperform prescribed dictionaries, especially in a high-dimensional setting.
|
|
61
|
+
|
|
62
|
+
Applied to simulations of the Atlantic Meridional Overturning Circulation, our Koopman-based indicators distinguish tipping from non-tipping trajectories and reveal interpretable spectral signatures prior to critical transition.
|
|
63
|
+
|
|
64
|
+
## Installation
|
|
65
|
+
|
|
66
|
+
Python 3.11 or newer is required. Install the library from PyPI:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install kews
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
To reproduce the paper or develop the code:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
git clone https://github.com/juannat7/kews.git
|
|
76
|
+
cd kews
|
|
77
|
+
pip install -e ".[paper,dev]"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Reproduction instructions are in [examples](https://github.com/juannat7/kews/tree/main/examples) for the model systems and [experiments](https://github.com/juannat7/kews/tree/main/experiments) for the coupled AMOC and empirical cases.
|
|
81
|
+
|
|
82
|
+
## Quickstart
|
|
83
|
+
|
|
84
|
+
Compute rolling ResKMD residuals from a scalar time series:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
import jax.numpy as jnp
|
|
88
|
+
from jax import random
|
|
89
|
+
|
|
90
|
+
import kews
|
|
91
|
+
from kews.observables import time_delay
|
|
92
|
+
|
|
93
|
+
time = jnp.linspace(0, 10 * jnp.pi, 100)
|
|
94
|
+
state = jnp.sin(time) + 0.01 * random.normal(random.key(7), time.shape)
|
|
95
|
+
|
|
96
|
+
signal = kews.resdmd(
|
|
97
|
+
state,
|
|
98
|
+
window=50, # Window length in samples (or a fraction, e.g. 0.5)
|
|
99
|
+
observable=time_delay(n_delay=10), # Time-delay dictionary with 10 coordinates
|
|
100
|
+
rank=0.9, # Retain 90% of the SVD energy
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# One residual per time step; the first 49 entries are NaN.
|
|
104
|
+
print(signal.shape) # (100,)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Control-aware residuals
|
|
108
|
+
|
|
109
|
+
Pass a control time series aligned with the state:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
control = jnp.linspace(-1, 1, state.size)
|
|
113
|
+
controlled_signal = kews.resdmd(
|
|
114
|
+
state,
|
|
115
|
+
window=50,
|
|
116
|
+
observable=time_delay(10),
|
|
117
|
+
control=control,
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Observable choices
|
|
122
|
+
|
|
123
|
+
Use fixed random Fourier features instead of delay coordinates:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from kews.observables import rff
|
|
127
|
+
|
|
128
|
+
random_signal = kews.resdmd(
|
|
129
|
+
state,
|
|
130
|
+
window=50,
|
|
131
|
+
observable=rff(n_features=100, scale=0.2, seed=7),
|
|
132
|
+
)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The random basis stays fixed across windows. An autoencoder can learn the features:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from kews.observables import mlp
|
|
139
|
+
|
|
140
|
+
learned_signal = kews.resdmd(
|
|
141
|
+
state,
|
|
142
|
+
window=50,
|
|
143
|
+
observable=mlp(hidden_layers=(16, 8), bottleneck_dim=3, seed=7),
|
|
144
|
+
fit_kwargs={"epochs": 200, "batch_size": 32, "lr": 1e-3},
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Training uses only the first window; the encoder is then frozen. Create a new observable for each independent fit. Paper notebooks specify their full training settings.
|
|
149
|
+
|
|
150
|
+
## Spectral diagnostics
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from kews import diagnostics
|
|
154
|
+
|
|
155
|
+
fit = diagnostics.fit_operator(state, observable=time_delay(10), rank=0.9)
|
|
156
|
+
generator = diagnostics.generator_eigenvalues(fit["eigvals"], dt=1.0)
|
|
157
|
+
dominant = diagnostics.dominant_mode(generator, dt=1.0)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`dt` is the sampling interval in the units of the input data. The mode summary includes decay rate, oscillation period, and e-folding time.
|
|
161
|
+
|
|
162
|
+
## Citation
|
|
163
|
+
|
|
164
|
+
```bibtex
|
|
165
|
+
@article{nathaniel2026koopman,
|
|
166
|
+
title={Koopman early warning signals for bifurcation and rate-induced tipping},
|
|
167
|
+
author={Nathaniel, Juan and Roesch, Carla and DeSantis, Derek and Kooloth, Parvathi and Fan, Hang and Lucarini, Valerio and Romanou, Anastasia and Gentine, Pierre},
|
|
168
|
+
journal={arXiv preprint arXiv:2608.14716},
|
|
169
|
+
year={2026}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Code uses the [MIT license](https://github.com/juannat7/kews/blob/main/LICENSE.txt). External datasets retain their original terms.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
kews/__init__.py,sha256=WIc9QuyQNB71Iks-KQdfTOx61V5SVcbXWWv9Nvk8Fgg,4576
|
|
2
|
+
kews/_core.py,sha256=gxT-5wfqMIfSXtCG-dp2AO5m-ytdw3qVxXPXFGOlhus,16130
|
|
3
|
+
kews/diagnostics.py,sha256=sD1D6B3kO1rEwDzAZyxQb0VpD_wKvnaGI6g-RpdqR6c,3520
|
|
4
|
+
kews/observables.py,sha256=jq_HlqwVXTJULD0Zbr5VtvaOmAc2WGZ5C1UL91pv3u4,8486
|
|
5
|
+
kews-0.1.0.dist-info/licenses/LICENSE.txt,sha256=D5YkCzfzd33EgQ7LxPRC19TDIfk-K6EMCv6-feQ-6eg,1076
|
|
6
|
+
kews-0.1.0.dist-info/METADATA,sha256=F5Gf9pHOjDTZkvX0mBIIXMT9e_mCtZA-XeRctQRNK6U,6763
|
|
7
|
+
kews-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
kews-0.1.0.dist-info/top_level.txt,sha256=0ENNba47A1oAS2t06IstrNB_TvoPVKq_T6lRHGrrdDo,5
|
|
9
|
+
kews-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KEWS Developer Team
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kews
|