Skip to content

Commit b7558be

Browse files
Merge pull request #674 from brianhjh/feature/evidence-greedy-optimizer
Feature: Adding BINDy (based on Greedy search that maximise evidence) to PySINDy
2 parents 1edf312 + 32488a1 commit b7558be

File tree

7 files changed

+1355
-0
lines changed

7 files changed

+1355
-0
lines changed

pysindy/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from ._core import SINDy
1616
from ._core import DiscreteSINDy
17+
from ._core import BINDy
1718
from ._core import AxesArray
1819
from .differentiation import BaseDifferentiation
1920
from .differentiation import FiniteDifference
@@ -68,6 +69,7 @@
6869
__all__ = [
6970
"SINDy",
7071
"DiscreteSINDy",
72+
"BINDy",
7173
"differentiation",
7274
"feature_library",
7375
"optimizers",

pysindy/_core.py

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
except ImportError:
2929
sindy_pi_flag = False
3030
from .optimizers import STLSQ
31+
from .optimizers import EvidenceGreedy
3132
from .optimizers.base import _BaseOptimizer
3233
from .optimizers.base import BaseOptimizer
3334
from .utils import AxesArray
@@ -37,6 +38,7 @@
3738
from .utils import SampleConcatter
3839
from .utils import validate_control_variables
3940
from .utils import validate_no_reshape
41+
from .utils.bindy import TemporalNoisePropagation
4042

4143

4244
TrajectoryType = TypeVar("TrajectoryType", list[np.ndarray], np.ndarray)
@@ -1054,6 +1056,250 @@ def check_stop_condition(xi):
10541056
return x
10551057

10561058

1059+
class BINDy(SINDy):
1060+
"""
1061+
Bayesian Identification of Nonlinear Dynamical Systems (BINDy)
1062+
1063+
Learns a dynamical model with corrections for measurement noise
1064+
passing through differentiation and feature library. Maximizes a Gaussian
1065+
(Laplace) approximation of the posterior with a weakly informed hyper
1066+
prior on the feature coefficients, then greedily eliminates model features to
1067+
maximize Bayesian evidence.
1068+
1069+
For more information, see this paper: https://doi.org/10.1098/rspa.2024.0200 .
1070+
1071+
.. seealso::
1072+
1073+
`SBR`
1074+
A Bayesian optimizer that uses a more sophisticated sparsifying prior
1075+
and Monte Carlo sampling. Slower but more accurate.
1076+
1077+
`EnsembleOptimizer`
1078+
Model sparsification by b(r)agging.
1079+
Empirically approximating Bayesian method.
1080+
1081+
Parameters
1082+
----------
1083+
sigma_x (required): float
1084+
Measurement noise standard deviation (std) for the state measurements
1085+
``x``. If ``x_dot`` is provided, ``sigma_x`` is used to set the noise
1086+
variance ``optimizer._sigma2 = sigma_x**2``.
1087+
1088+
Otherwise, ``sigma_x`` is propagated through the
1089+
``differentiation_method`` to estimate the derivative noise variance::
1090+
1091+
_sigma2 = TemporalNoisePropagation(
1092+
differentiation_method, t_grid, sigma_x
1093+
)
1094+
1095+
For multiple trajectories, ``_sigma2`` is computed per trajectory and
1096+
averaged.
1097+
1098+
1099+
optimizer
1100+
Optimization method used to fit the SINDy model. This must be a class
1101+
extending :class:`pysindy.optimizers.BaseOptimizer`.
1102+
The default is :class:`pysindy.optimizers.EvidenceGreedy`.
1103+
1104+
feature_library
1105+
Feature library object used to specify candidate right-hand side features.
1106+
This must be a class extending
1107+
:class:`pysindy.feature_library.base.BaseFeatureLibrary`.
1108+
The default option is :class:`pysindy.feature_library.PolynomialLibrary`.
1109+
1110+
differentiation_method
1111+
Method for differentiating the data. This must be a class extending
1112+
:class:`pysindy.differentiation.base.BaseDifferentiation` class.
1113+
It must also be a linear method.
1114+
The default option is centered finite differences.
1115+
1116+
Attributes
1117+
----------
1118+
model : ``sklearn.pipeline.Pipeline``
1119+
The fitted SINDy model pipeline.
1120+
1121+
n_input_features_ : int
1122+
The total number of input features.
1123+
1124+
n_output_features_ : int
1125+
The total number of output features.
1126+
1127+
n_control_features_ : int
1128+
The total number of control input features.
1129+
1130+
feature_names : list of str or None
1131+
Names for the input features.
1132+
1133+
Notes
1134+
-----
1135+
Noise propagation from measurement noise ``sigma_x`` to the derivative
1136+
noise variance used by the regression (``optimizer._sigma2``) is only
1137+
performed when:
1138+
1139+
1. ``x_dot`` is not provided, and derivatives are estimated internally.
1140+
2. ``differentiation_method`` is linear (e.g.,
1141+
:class:`~pysindy.differentiation.FiniteDifference` or
1142+
:class:`~pysindy.differentiation.SmoothedFiniteDifference`).
1143+
1144+
Spectral differentiation is currently not supported for noise
1145+
propagation.
1146+
1147+
1148+
1149+
- FiniteDifference is strongly recommended for EvidenceGreedy because the
1150+
noise propagation algorithm assumes a linear differential operator. If you
1151+
use a different differentiator, set ``optimizer._sigma2`` manually.
1152+
1153+
Examples
1154+
--------
1155+
>>> import numpy as np
1156+
>>> from scipy.integrate import odeint
1157+
>>> import pysindy as ps
1158+
>>> from pysindy.differentiation import FiniteDifference
1159+
>>> from pysindy.optimizers import EvidenceGreedy
1160+
>>>
1161+
>>> def lorenz(z, t):
1162+
... x, y, z_ = z
1163+
... return [10.0 * (y - x), x * (28.0 - z_) - y, x * y - 8.0 / 3.0 * z_]
1164+
>>>
1165+
>>> t = np.arange(0, 10, 0.01)
1166+
>>> x0 = np.array([-8.0, 8.0, 27.0])
1167+
>>> sigma_x = 0.01
1168+
>>> x = odeint(lorenz, x0, t)
1169+
>>> x = x + sigma_x * np.random.normal(size=x.shape) # add noise
1170+
>>> dt = t[1] - t[0]
1171+
>>>
1172+
>>> model = ps.BINDy(sigma_x=sigma_x) # You MUST specify sigma_x
1173+
>>> model.fit(x, t=dt)
1174+
>>> model.print()
1175+
1176+
"""
1177+
1178+
def __init__(
1179+
self,
1180+
sigma_x: float,
1181+
optimizer: Optional[EvidenceGreedy] = None,
1182+
feature_library: Optional[BaseFeatureLibrary] = None,
1183+
differentiation_method: Optional[FiniteDifference] = None,
1184+
# Only support differentiation methods that are linear.
1185+
# # TODO: FiniteDifference and SmoothedFiniteDifference are included
1186+
# as it's under FiniteDifference, but what about SpectralDerivative?
1187+
):
1188+
if optimizer is None:
1189+
optimizer = EvidenceGreedy(
1190+
alpha=1.0,
1191+
_sigma2=sigma_x**2,
1192+
max_iter=None,
1193+
normalize_columns=True,
1194+
unbias=False,
1195+
verbose=False,
1196+
)
1197+
self.optimizer = optimizer
1198+
1199+
if feature_library is None:
1200+
feature_library = PolynomialLibrary()
1201+
self.feature_library = feature_library
1202+
1203+
# Match the continuous-time convention used by SINDy:
1204+
if differentiation_method is None:
1205+
differentiation_method = FiniteDifference(axis=-2)
1206+
self.differentiation_method = differentiation_method
1207+
1208+
self.sigma_x = sigma_x
1209+
1210+
def fit(
1211+
self,
1212+
x,
1213+
t,
1214+
x_dot=None,
1215+
u=None,
1216+
feature_names: Optional[list[str]] = None,
1217+
):
1218+
"""
1219+
Fit an BINDy model.
1220+
1221+
See :meth:`pysindy.SINDy.fit` for full parameter documentation.
1222+
"""
1223+
1224+
# If derivatives are supplied, sigma_x does not define derivative-noise.
1225+
if x_dot is not None:
1226+
self.optimizer._sigma2 = self.sigma_x**2
1227+
msg = (
1228+
"BINDy: Noise is not propagated through the differentiation method. "
1229+
"Assuming noise variance from specified sigma_x. "
1230+
f"_sigma2 value used: {self.optimizer._sigma2}"
1231+
)
1232+
warnings.warn(msg, UserWarning)
1233+
1234+
return super().fit(x, t, x_dot=x_dot, u=u, feature_names=feature_names)
1235+
1236+
# Ensure we treat everything as multiple trajectories for
1237+
# _sigma2 calculation.
1238+
if not _check_multiple_trajectories(x, x_dot, u):
1239+
x_list, t_list, _, _ = _adapt_to_multiple_trajectories(x, t, x_dot, u)
1240+
else:
1241+
x_list, t_list = x, t
1242+
1243+
_sigma2_vals = []
1244+
eps = float(np.finfo(float).eps)
1245+
1246+
for xi, ti in _zip_like_sequence(x_list, t_list):
1247+
xi_arr = np.asarray(xi)
1248+
if xi_arr.ndim == 1:
1249+
n_samples = xi_arr.shape[0]
1250+
else:
1251+
n_samples = xi_arr.shape[-2]
1252+
1253+
# Build a time grid for _sigma2 mapping
1254+
if np.isscalar(ti):
1255+
dt = float(ti)
1256+
if dt <= 0:
1257+
raise ValueError("t (dt) must be positive.")
1258+
t_grid = np.arange(n_samples, dtype=float) * dt
1259+
else:
1260+
t_grid = np.asarray(ti, dtype=float)
1261+
if t_grid.ndim != 1:
1262+
raise ValueError("t must be a 1D array of time points.")
1263+
if len(t_grid) != n_samples:
1264+
raise ValueError(
1265+
f"Length of t ({len(t_grid)}) does not match "
1266+
f"number of samples ({n_samples})."
1267+
)
1268+
# Call TemporalNoisePropagation to compute an averaged _sigma2
1269+
_sigma2_i = TemporalNoisePropagation(
1270+
self.differentiation_method,
1271+
t_grid,
1272+
float(self.sigma_x),
1273+
)
1274+
_sigma2_vals.append(float(_sigma2_i))
1275+
1276+
_sigma2_mean = float(np.mean(_sigma2_vals))
1277+
_sigma2_mean = max(_sigma2_mean, eps) # must be positive
1278+
1279+
# If user provided a non-Bayesian optimizer, _sigma2
1280+
# attribute may not exist. In that case,
1281+
# we raise an error to avoid mathematically inconsistency
1282+
# with the expectation of a Bayesian optimization.
1283+
# NOTE: This is current commented out because checks are done
1284+
# in the BINDy constructor has ensured that the optimizer is
1285+
# an EvidenceGreedy instance.
1286+
# If we later allow users to pass in other optimizers,
1287+
# we should re-enable this check.
1288+
#
1289+
# if not hasattr(self.optimizer, "_sigma2"):
1290+
# raise AttributeError(
1291+
# "BINDy requires an optimizer with a "
1292+
# "'_sigma2' attribute. Got optimizer of type: "
1293+
# + type(self.optimizer).__name__
1294+
# )
1295+
1296+
# Set _sigma2 on the underlying optimizer
1297+
self.optimizer._sigma2 = _sigma2_mean
1298+
1299+
# Now run the standard SINDy fitting pipeline.
1300+
return super().fit(x, t, x_dot=x_dot, u=u, feature_names=feature_names)
1301+
1302+
10571303
def _zip_like_sequence(x, t):
10581304
"""Create an iterable like zip(x, t), but works if t is scalar.
10591305

pysindy/optimizers/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from .base import BaseOptimizer
22
from .base import EnsembleOptimizer
3+
from .evidence_greedy import EvidenceGreedy
34
from .frols import FROLS
45
from .sr3 import SR3
56
from .ssr import SSR
67
from .stlsq import STLSQ
78
from .wrapped_optimizer import WrappedOptimizer
89

10+
911
try:
1012
from ._constrained_sr3 import ConstrainedSR3
1113
except (ImportError, NameError):
@@ -46,4 +48,5 @@
4648
"SINDyPI",
4749
"MIOSR",
4850
"SBR",
51+
"EvidenceGreedy",
4952
]

0 commit comments

Comments
 (0)