|
28 | 28 | except ImportError: |
29 | 29 | sindy_pi_flag = False |
30 | 30 | from .optimizers import STLSQ |
| 31 | +from .optimizers import EvidenceGreedy |
31 | 32 | from .optimizers.base import _BaseOptimizer |
32 | 33 | from .optimizers.base import BaseOptimizer |
33 | 34 | from .utils import AxesArray |
|
37 | 38 | from .utils import SampleConcatter |
38 | 39 | from .utils import validate_control_variables |
39 | 40 | from .utils import validate_no_reshape |
| 41 | +from .utils.bindy import TemporalNoisePropagation |
40 | 42 |
|
41 | 43 |
|
42 | 44 | TrajectoryType = TypeVar("TrajectoryType", list[np.ndarray], np.ndarray) |
@@ -1054,6 +1056,250 @@ def check_stop_condition(xi): |
1054 | 1056 | return x |
1055 | 1057 |
|
1056 | 1058 |
|
| 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 | + |
1057 | 1303 | def _zip_like_sequence(x, t): |
1058 | 1304 | """Create an iterable like zip(x, t), but works if t is scalar. |
1059 | 1305 |
|
|
0 commit comments