Skip to content

Commit a62bfea

Browse files
authored
Make it easy to estimate linear parameters with multitones (#32)
1 parent 80ffa98 commit a62bfea

5 files changed

Lines changed: 118 additions & 34 deletions

File tree

dynax/estimation.py

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -457,57 +457,68 @@ def H(s: complex):
457457

458458

459459
def estimate_spectra(
460-
u: np.ndarray, y: np.ndarray, sr: float, nperseg: int
460+
u: ArrayLike,
461+
y: ArrayLike,
462+
sr: float,
463+
with_dc: bool = False,
464+
**kwargs,
461465
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
462466
"""Estimate cross- and autospectral densities using Welch's method.
463467
464468
Args:
465469
u: Input signal.
466470
y: Output signal.
467471
sr: Sampling rate.
468-
nperseg: Number of samples per segment.
472+
with_dc: Wether or not to include zero frequency term.
473+
kwargs: Passed to `scipy.signal.csd`.
469474
470475
Returns:
471476
Tuple `(f, S_yu, S_uu)` of frequencies, cross- and autospectral densities.
472477
473478
"""
474479
u_ = np.asarray(u)
475480
y_ = np.asarray(y)
481+
476482
# Prep for correct broadcasting in sig.csd
477483
if u_.ndim == 1:
478484
u_ = u_[:, None]
479485
if y_.ndim == 1:
480486
y_ = y_[:, None]
481-
f, S_yu = sig.csd(u_[:, None, :], y_[:, :, None], fs=sr, nperseg=nperseg, axis=0)
482-
_, S_uu = sig.welch(u, fs=sr, nperseg=nperseg, axis=0)
487+
f, Syu = sig.csd(u_[:, None, :], y_[:, :, None], fs=sr, **kwargs, axis=0)
488+
_, Suu = sig.welch(u, fs=sr, **kwargs, axis=0)
489+
483490
# Reshape back with dimensions of arguments
484-
S_yu = S_yu.reshape((nperseg // 2 + 1,) + y.shape[1:] + u.shape[1:])
485-
S_uu = S_uu.reshape((nperseg // 2 + 1,) + u.shape[1:])
486-
return f, S_yu, S_uu
491+
Syu = Syu.reshape((Syu.shape[0],) + y.shape[1:] + u.shape[1:])
492+
Suu = Suu.reshape((Suu.shape[0],) + u.shape[1:])
493+
494+
if not with_dc:
495+
# remove dc term
496+
f = f[1:]
497+
Syu = Syu[1:]
498+
Suu = Suu[1:]
499+
500+
return f, Syu, Suu
487501

488502

489503
def fit_csd_matching(
490504
sys: AbstractSystem,
491-
u: ArrayLike,
492-
y: ArrayLike,
493-
sr: float = 1.0,
494-
nperseg: int = 1024,
505+
f: ArrayLike,
506+
Syu: ArrayLike,
507+
Suu: ArrayLike,
495508
reg: float = 0,
496509
x_scale: bool = True,
497510
verbose_mse: bool = True,
498511
absolute_sigma: bool = False,
499-
fit_dc: bool = False,
500512
linearize_kwargs: dict | None = None,
501513
**kwargs,
502514
) -> OptimizeResult:
503515
"""Estimate parameters of linearized system by matching cross-spectral densities.
504516
505517
Args:
506518
sys: Concrete dynamical system.
507-
u: Input signal.
508-
y: Output signal.
509-
sr: Sampling rate.
510-
nperseg: Number of samples per segment.
519+
f: Frequencies.
520+
S_yu: Cross-spectral density.
521+
S_uu: Auto-spectral density.
511522
reg: Weight of the :math:`L_2` regularization.
512523
x_scale: Whether parameters are scaled by the initial values.
513524
verbose_mse: Whether the cost is scaled to the mean-squared error during logging
@@ -516,7 +527,6 @@ def fit_csd_matching(
516527
parameter covariance reflects these absolute values. Otherwise, only
517528
the relative magnitudes of the sigma values matter. See also
518529
:func:`scipy.optimize.curve_fit`.
519-
fit_dc: Whether to fit the DC term.
520530
linearize_kwargs: Arguments passed to
521531
:py:meth:`~dynax.system.AbstractSystem.linearize`.
522532
kwargs: Optional parameters for `scipy.optimize.least_squares`.
@@ -536,14 +546,6 @@ def fit_csd_matching(
536546
if linearize_kwargs is None:
537547
linearize_kwargs = {}
538548

539-
f, Syu, Suu = estimate_spectra(u, y, sr=sr, nperseg=nperseg)
540-
541-
if not fit_dc:
542-
# remove dc term
543-
f = f[1:]
544-
Syu = Syu[1:]
545-
Suu = Suu[1:]
546-
547549
s = 2 * np.pi * f * 1j
548550
weight = 1 / np.std(Syu, axis=0)
549551
init_params, bounds, unravel = ravel_and_bounds(sys)

dynax/util.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
import equinox
55
import jax
66
import jax.numpy as jnp
7+
import numpy as np
78

8-
from .custom_types import Array, Scalar
9+
from .custom_types import Array, ArrayLike, Scalar
910

1011

1112
def value_and_jacfwd(fun: Callable, x: Array) -> tuple[Array, Callable]:
@@ -38,17 +39,17 @@ def value_and_jacrev(fun: Callable, x: Array) -> tuple[Array, Callable]:
3839
return y, jac
3940

4041

41-
def mse(target: Array, prediction: Array, axis: int = 0) -> Scalar:
42+
def mse(target: ArrayLike, prediction: ArrayLike, axis: int = 0) -> Scalar:
4243
"""Compute mean-squared error."""
4344
return jnp.mean(jnp.abs(target - prediction) ** 2, axis=axis)
4445

4546

46-
def nmse(target: Array, prediction: Array, axis: int = 0) -> Scalar:
47+
def nmse(target: ArrayLike, prediction: ArrayLike, axis: int = 0) -> Scalar:
4748
"""Compute normalized mean-squared error."""
4849
return mse(target, prediction, axis) / jnp.mean(jnp.abs(target) ** 2, axis=axis)
4950

5051

51-
def nrmse(target: Array, prediction: Array, axis: int = 0) -> Scalar:
52+
def nrmse(target: ArrayLike, prediction: ArrayLike, axis: int = 0) -> Scalar:
5253
"""Compute normalized root mean-squared error."""
5354
return jnp.sqrt(nmse(target, prediction, axis))
5455

@@ -96,3 +97,33 @@ def broadcast_right(arr, target):
9697

9798
def dim2shape(x: int | Literal["scalar"]) -> tuple:
9899
return () if x == "scalar" else (x,)
100+
101+
102+
def multitone(length: int, num_tones: int, first_tone: int = 0) -> Array:
103+
"""Create a periodic bandpassed multitone signal.
104+
105+
Has flat flat spectrum and crest factor <= 2.
106+
107+
From: S. Boyd, "Multitone Signals with Low Crest Factor"
108+
IEEE Transactions on Circuits and Systems, CAS-33(10):1018-1022, October 1986
109+
"""
110+
if np.log2(num_tones) % 1 != 0:
111+
raise ValueError("Number of tones must be power of 2")
112+
113+
def rudin_sign(k: int) -> int:
114+
previous_bit, sign = 1, 1
115+
k = k - 1
116+
while k > 0:
117+
previous_bit = k % 2
118+
if (k := k // 2) == 0:
119+
break
120+
if previous_bit == 1 and k % 2 == 1:
121+
sign = -sign
122+
return sign
123+
124+
t = np.linspace(0, 2 * np.pi, length, endpoint=False)
125+
u = 0
126+
for k in range(1, num_tones + 1):
127+
u += rudin_sign(k) * np.sin((k + first_tone) * t)
128+
u *= np.sqrt(2 / num_tones)
129+
return u

examples/fit_long_input.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import numpy as np
55

66
from dynax import fit_csd_matching, fit_least_squares, Flow, pretty
7+
from dynax.estimation import estimate_spectra
78
from dynax.example_models import NonlinearDrag
89

910

@@ -27,9 +28,8 @@
2728
# If we have long-duration, wide-band input data we can fit the linear
2829
# parameters first by matching the transfer-functions. In this example the result is
2930
# not very good.
30-
initial_sys = fit_csd_matching(
31-
initial_sys, u_train, y_train, samplerate, nperseg=500
32-
).result
31+
f, Syu, Suu = estimate_spectra(u_train, y_train, samplerate, nperseg=512)
32+
initial_sys = fit_csd_matching(initial_sys, f, Syu, Suu).result
3333
print("linear params fitted:", pretty(initial_sys))
3434

3535
# Combine the fitted ODE with an ODE solver

tests/test_estimation.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
non_negative_field,
1818
transfer_function,
1919
)
20+
from dynax.estimation import estimate_spectra
2021
from dynax.example_models import LotkaVolterra, NonlinearDrag, SpringMassDamper
22+
from dynax.util import multitone
2123

2224

2325
tols = {"rtol": 1e-02, "atol": 1e-04}
@@ -222,9 +224,10 @@ def test_csd_matching():
222224
# output
223225
_, y = model(t, u)
224226
# fit
225-
init_sys = SpringMassDamper(1.0, 1.0, 1.0)
226-
fitted_sys = fit_csd_matching(init_sys, u, y, sr, nperseg=1024, verbose=1).result
227+
f, Syu, Suu = estimate_spectra(u, y, sr, nperseg=1024)
228+
fitted_sys = fit_csd_matching(sys, f, Syu, Suu, verbose=1).result
227229

230+
# poor fit due to random input signal that requires windowing
228231
assert eqx.tree_equal(
229232
fitted_sys,
230233
sys,
@@ -233,6 +236,37 @@ def test_csd_matching():
233236
)
234237

235238

239+
def test_csd_matching_multitone():
240+
np.random.seed(123)
241+
# model
242+
sys = SpringMassDamper(1.0, 1.0, 1.0)
243+
model = Flow(sys, stepsize_controller=PIDController(rtol=1e-6, atol=1e-6))
244+
# input
245+
nperseg = 1024
246+
segs = 10
247+
num_total_samples = nperseg * segs
248+
sr = 50
249+
t = np.arange(num_total_samples) / sr
250+
u = np.tile(multitone(nperseg, num_tones=nperseg // 2), segs)
251+
# output
252+
_, y = model(t, u)
253+
# fit
254+
warmup_seps = 5
255+
warmup = warmup_seps * nperseg
256+
f, Syu, Suu = estimate_spectra(
257+
u[warmup:], y[warmup:], sr, nperseg=nperseg, window="boxcar", noverlap=0
258+
)
259+
fitted_sys = fit_csd_matching(sys, f, Syu, Suu, verbose=1).result
260+
261+
# good fit with much less data!
262+
assert eqx.tree_equal(
263+
fitted_sys,
264+
sys,
265+
rtol=1e-2,
266+
atol=1e-2,
267+
)
268+
269+
236270
def test_estimate_initial_state():
237271
class NonlinearDragFreeInitialState(NonlinearDrag):
238272
initial_state: Array = field(init=False)

tests/test_util.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import numpy as np
2+
3+
from dynax.util import multitone
4+
5+
6+
def test_multitone():
7+
def crest_factor(sig):
8+
return (
9+
np.linalg.norm(sig, np.inf) / (np.linalg.norm(sig, 2) / np.sqrt(len(sig)))
10+
).item()
11+
12+
length = 1000
13+
for num_tones in np.pow(2, np.arange(8)):
14+
for first_tone in [0, 1, 10]:
15+
u = multitone(length, num_tones, first_tone)
16+
cr = crest_factor(u)
17+
assert cr < 2 or np.allclose(cr, 2)

0 commit comments

Comments
 (0)