From d1267c3192260bb43d5496e6c65c1d9f93c7a99d Mon Sep 17 00:00:00 2001 From: emil817 Date: Tue, 4 Aug 2026 18:22:26 +0700 Subject: [PATCH 1/3] minor fix --- README.md | 12 +++++++ src/deepymod/data/base.py | 7 ++-- src/deepymod/model/deepmod.py | 6 ++-- src/deepymod/model/sparse_estimators.py | 12 ++++++- src/deepymod/training/training.py | 9 +++-- src/deepymod/utils/logger.py | 46 ++++++++++++++++++++++--- 6 files changed, 75 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index bfd3fb0ff..cb9a3d175 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,18 @@ More information can be found in the following two papers: , [arXiv:2011.04336]( **What types of models can you discover?** DeepMoD can discover non-linear, multi-dimensional and/or coupled ODEs and PDEs. See our paper and the examples folder for a demonstration of each. +# PDEComp benchmark compatibility + +This fork includes a few compatibility fixes used by the PDEComp benchmark wrapper: + +* `PDEFIND` creates PySINDy `STLSQ` objects through a small compatibility helper, so it works across PySINDy versions where the `fit_intercept` argument may or may not exist. +* Tensor normalization uses `reshape(...)` instead of `view(...)`, which avoids failures on non-contiguous PyTorch tensors. +* Sparse coefficient vectors and masks are normalized to stable one-dimensional shapes, including the single-term case. +* The training and logging code computes L1 norms per coefficient vector instead of assuming that all coefficient tensors can always be concatenated along the same dimension. +* Logging can run without TensorBoard installed; in that case a no-op `SummaryWriter` is used while model saving still works. + +These changes do not alter the benchmark wrapper logic. They make the original DeePyMoD internals robust enough for fixed-library benchmark runs with different sparse estimators and small candidate sets. + # How to install ## Dependencies and CUDA diff --git a/src/deepymod/data/base.py b/src/deepymod/data/base.py index b8b98e118..80f8d5deb 100644 --- a/src/deepymod/data/base.py +++ b/src/deepymod/data/base.py @@ -158,9 +158,10 @@ def apply_normalize(X): X (torch.tensor): data to be minmax normalized Returns: (torch.tensor): minmaxed data""" - X_norm = (X - X.view(-1, X.shape[-1]).min(dim=0).values) / ( - X.view(-1, X.shape[-1]).max(dim=0).values - - X.view(-1, X.shape[-1]).min(dim=0).values + X_flat = X.reshape(-1, X.shape[-1]) + X_norm = (X - X_flat.min(dim=0).values) / ( + X_flat.max(dim=0).values + - X_flat.min(dim=0).values ) * 2 - 1 return X_norm diff --git a/src/deepymod/model/deepmod.py b/src/deepymod/model/deepmod.py index f3705bb13..491ea947a 100644 --- a/src/deepymod/model/deepmod.py +++ b/src/deepymod/model/deepmod.py @@ -137,12 +137,12 @@ def forward(self, thetas: TensorList, time_derivs: TensorList) -> TensorList: ] self.coeff_vectors = [ - self.fit(theta, time_deriv.squeeze())[:, None] + np.atleast_1d(self.fit(theta, time_deriv.squeeze()))[:, None] for theta, time_deriv in zip(normed_thetas, normed_time_derivs) ] sparsity_masks = [ torch.tensor(coeff_vector != 0.0, dtype=torch.bool) - .squeeze() + .reshape(-1) .to(thetas[0].device) # move to gpu if required for coeff_vector in self.coeff_vectors ] @@ -293,7 +293,7 @@ def constraint_coeffs(self, scaled=False, sparse=False) -> TensorList: coeff_vectors = self.constraint.coeff_vectors if scaled: # perform normalization coeff_vectors = [ - coeff / norm[:, None] + coeff / norm.reshape(-1)[:, None] for coeff, norm, mask in zip( coeff_vectors, self.library.norms, self.sparsity_masks ) diff --git a/src/deepymod/model/sparse_estimators.py b/src/deepymod/model/sparse_estimators.py index 774a16417..33c938f8b 100644 --- a/src/deepymod/model/sparse_estimators.py +++ b/src/deepymod/model/sparse_estimators.py @@ -2,6 +2,8 @@ We keep the API in line with scikit learn (mostly), so scikit learn can also be plugged in. See scikitlearn.linear_models for applicable estimators.""" +import inspect + import numpy as np from .deepmod import Estimator from sklearn.cluster import KMeans @@ -17,6 +19,14 @@ ) # To silence annoying pysindy warnings +def make_stlsq(**params): + """Create STLSQ across PySINDy versions with slightly different signatures.""" + + if "fit_intercept" not in inspect.signature(STLSQ).parameters: + params.pop("fit_intercept", None) + return STLSQ(**params) + + class Base(Estimator): def __init__(self, estimator: BaseEstimator) -> None: """Basic sparse estimator class; simply a wrapper around the supplied sk-learn compatible estimator. @@ -177,7 +187,7 @@ def TrainSTLSQ( delta_t = delta_threshold # for interal use, can be updated # Initial estimate - optimizer = STLSQ( + optimizer = make_stlsq( threshold=0, alpha=0.0, fit_intercept=False ) # Now similar to LSTSQ y_predict = optimizer.fit(X_train, y_train).predict(X_test) diff --git a/src/deepymod/training/training.py b/src/deepymod/training/training.py index edeeaa724..ade44deea 100644 --- a/src/deepymod/training/training.py +++ b/src/deepymod/training/training.py @@ -117,11 +117,10 @@ def train( ) # ================= Checking convergence - l1_norm = torch.sum( - torch.abs( - torch.cat(model.constraint_coeffs(sparse=True, scaled=True), dim=1) - ) - ) + l1_norm = torch.stack([ + torch.sum(torch.abs(coeffs)) + for coeffs in model.constraint_coeffs(sparse=True, scaled=True) + ]).sum() converged = convergence( iteration, l1_norm ) # Check if change is smaller than delta and if we've exceeded patience diff --git a/src/deepymod/utils/logger.py b/src/deepymod/utils/logger.py index 56c890685..7e41227db 100644 --- a/src/deepymod/utils/logger.py +++ b/src/deepymod/utils/logger.py @@ -2,7 +2,31 @@ import numpy as np import torch import sys, time -from torch.utils.tensorboard import SummaryWriter +from pathlib import Path + +try: + from torch.utils.tensorboard import SummaryWriter +except ModuleNotFoundError: + class SummaryWriter: # type: ignore[override] + def __init__(self, comment=None, log_dir=None, max_queue=5, flush_secs=10): + base_dir = Path(log_dir or "runs") + base_dir.mkdir(parents=True, exist_ok=True) + self._log_dir = str(base_dir.resolve()) + "/" + + def get_logdir(self): + return self._log_dir + + def add_scalar(self, *args, **kwargs): + return None + + def add_scalars(self, *args, **kwargs): + return None + + def flush(self): + return None + + def close(self): + return None class Logger: @@ -18,6 +42,12 @@ def __init__(self, exp_ID, log_dir): ) self.log_dir = self.writer.get_logdir() + @staticmethod + def coefficient_items(values): + if torch.is_tensor(values): + values = values.detach().cpu().numpy() + return np.atleast_1d(np.squeeze(values)) + def __call__( self, iteration, @@ -29,7 +59,10 @@ def __call__( estimator_coeffs, **kwargs, ): - l1_norm = torch.sum(torch.abs(torch.cat(constraint_coeffs, dim=1)), dim=0) + l1_norm = torch.stack([ + torch.sum(torch.abs(coeffs)) + for coeffs in constraint_coeffs + ]) self.update_tensorboard( iteration, @@ -94,14 +127,17 @@ def update_tensorboard( ): self.writer.add_scalars( f"coeffs/output_{output_idx}", - {f"coeff_{idx}": val for idx, val in enumerate(coeffs.squeeze())}, + { + f"coeff_{idx}": val + for idx, val in enumerate(self.coefficient_items(coeffs)) + }, iteration, ) self.writer.add_scalars( f"unscaled_coeffs/output_{output_idx}", { f"coeff_{idx}": val - for idx, val in enumerate(unscaled_coeffs.squeeze()) + for idx, val in enumerate(self.coefficient_items(unscaled_coeffs)) }, iteration, ) @@ -109,7 +145,7 @@ def update_tensorboard( f"estimator_coeffs/output_{output_idx}", { f"coeff_{idx}": val - for idx, val in enumerate(estimator_coeffs.squeeze()) + for idx, val in enumerate(self.coefficient_items(estimator_coeffs)) }, iteration, ) From 2a322c7b63c471991df6db6e5be736e3f13e50af Mon Sep 17 00:00:00 2001 From: emil817 Date: Tue, 4 Aug 2026 18:34:14 +0700 Subject: [PATCH 2/3] Update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cb9a3d175..6941c1181 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ More information can be found in the following two papers: , [arXiv:2011.04336]( **What types of models can you discover?** DeepMoD can discover non-linear, multi-dimensional and/or coupled ODEs and PDEs. See our paper and the examples folder for a demonstration of each. -# PDEComp benchmark compatibility +# Compatibility and robustness fixes -This fork includes a few compatibility fixes used by the PDEComp benchmark wrapper: +This fork includes a few compatibility fixes for external benchmark workflows, small fixed libraries, and newer dependency versions: * `PDEFIND` creates PySINDy `STLSQ` objects through a small compatibility helper, so it works across PySINDy versions where the `fit_intercept` argument may or may not exist. * Tensor normalization uses `reshape(...)` instead of `view(...)`, which avoids failures on non-contiguous PyTorch tensors. @@ -22,7 +22,7 @@ This fork includes a few compatibility fixes used by the PDEComp benchmark wrapp * The training and logging code computes L1 norms per coefficient vector instead of assuming that all coefficient tensors can always be concatenated along the same dimension. * Logging can run without TensorBoard installed; in that case a no-op `SummaryWriter` is used while model saving still works. -These changes do not alter the benchmark wrapper logic. They make the original DeePyMoD internals robust enough for fixed-library benchmark runs with different sparse estimators and small candidate sets. +These changes do not alter DeePyMoD's discovery logic. They make the internals more robust for fixed-library runs, different sparse estimators, and environments with optional logging dependencies. # How to install From 69b53c056b042c6f9687bff01835b71472c87cb3 Mon Sep 17 00:00:00 2001 From: emil817 Date: Thu, 6 Aug 2026 16:22:20 +0700 Subject: [PATCH 3/3] Fix shape handling for small libraries and recent PySINDy --- README.md | 12 --- src/deepymod/data/base.py | 7 +- src/deepymod/model/deepmod.py | 2 +- src/deepymod/model/sparse_estimators.py | 14 +--- src/deepymod/training/training.py | 10 ++- src/deepymod/utils/logger.py | 37 ++------- tests/test_compatibility.py | 106 ++++++++++++++++++++++++ 7 files changed, 123 insertions(+), 65 deletions(-) create mode 100644 tests/test_compatibility.py diff --git a/README.md b/README.md index 6941c1181..bfd3fb0ff 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,6 @@ More information can be found in the following two papers: , [arXiv:2011.04336]( **What types of models can you discover?** DeepMoD can discover non-linear, multi-dimensional and/or coupled ODEs and PDEs. See our paper and the examples folder for a demonstration of each. -# Compatibility and robustness fixes - -This fork includes a few compatibility fixes for external benchmark workflows, small fixed libraries, and newer dependency versions: - -* `PDEFIND` creates PySINDy `STLSQ` objects through a small compatibility helper, so it works across PySINDy versions where the `fit_intercept` argument may or may not exist. -* Tensor normalization uses `reshape(...)` instead of `view(...)`, which avoids failures on non-contiguous PyTorch tensors. -* Sparse coefficient vectors and masks are normalized to stable one-dimensional shapes, including the single-term case. -* The training and logging code computes L1 norms per coefficient vector instead of assuming that all coefficient tensors can always be concatenated along the same dimension. -* Logging can run without TensorBoard installed; in that case a no-op `SummaryWriter` is used while model saving still works. - -These changes do not alter DeePyMoD's discovery logic. They make the internals more robust for fixed-library runs, different sparse estimators, and environments with optional logging dependencies. - # How to install ## Dependencies and CUDA diff --git a/src/deepymod/data/base.py b/src/deepymod/data/base.py index 80f8d5deb..e839ae0c8 100644 --- a/src/deepymod/data/base.py +++ b/src/deepymod/data/base.py @@ -159,10 +159,9 @@ def apply_normalize(X): Returns: (torch.tensor): minmaxed data""" X_flat = X.reshape(-1, X.shape[-1]) - X_norm = (X - X_flat.min(dim=0).values) / ( - X_flat.max(dim=0).values - - X_flat.min(dim=0).values - ) * 2 - 1 + minimum = X_flat.min(dim=0).values + maximum = X_flat.max(dim=0).values + X_norm = (X - minimum) / (maximum - minimum) * 2 - 1 return X_norm @staticmethod diff --git a/src/deepymod/model/deepmod.py b/src/deepymod/model/deepmod.py index 491ea947a..1dd62f051 100644 --- a/src/deepymod/model/deepmod.py +++ b/src/deepymod/model/deepmod.py @@ -137,7 +137,7 @@ def forward(self, thetas: TensorList, time_derivs: TensorList) -> TensorList: ] self.coeff_vectors = [ - np.atleast_1d(self.fit(theta, time_deriv.squeeze()))[:, None] + np.asarray(self.fit(theta, time_deriv.reshape(-1))).reshape(-1, 1) for theta, time_deriv in zip(normed_thetas, normed_time_derivs) ] sparsity_masks = [ diff --git a/src/deepymod/model/sparse_estimators.py b/src/deepymod/model/sparse_estimators.py index 33c938f8b..18cee5b84 100644 --- a/src/deepymod/model/sparse_estimators.py +++ b/src/deepymod/model/sparse_estimators.py @@ -2,8 +2,6 @@ We keep the API in line with scikit learn (mostly), so scikit learn can also be plugged in. See scikitlearn.linear_models for applicable estimators.""" -import inspect - import numpy as np from .deepmod import Estimator from sklearn.cluster import KMeans @@ -19,14 +17,6 @@ ) # To silence annoying pysindy warnings -def make_stlsq(**params): - """Create STLSQ across PySINDy versions with slightly different signatures.""" - - if "fit_intercept" not in inspect.signature(STLSQ).parameters: - params.pop("fit_intercept", None) - return STLSQ(**params) - - class Base(Estimator): def __init__(self, estimator: BaseEstimator) -> None: """Basic sparse estimator class; simply a wrapper around the supplied sk-learn compatible estimator. @@ -187,9 +177,7 @@ def TrainSTLSQ( delta_t = delta_threshold # for interal use, can be updated # Initial estimate - optimizer = make_stlsq( - threshold=0, alpha=0.0, fit_intercept=False - ) # Now similar to LSTSQ + optimizer = STLSQ(threshold=0, alpha=0.0) # Now similar to LSTSQ y_predict = optimizer.fit(X_train, y_train).predict(X_test) min_loss = np.linalg.norm(y_predict - y_test, 2) + l0 * np.count_nonzero( optimizer.coef_ diff --git a/src/deepymod/training/training.py b/src/deepymod/training/training.py index ade44deea..9fb47f2bf 100644 --- a/src/deepymod/training/training.py +++ b/src/deepymod/training/training.py @@ -117,10 +117,12 @@ def train( ) # ================= Checking convergence - l1_norm = torch.stack([ - torch.sum(torch.abs(coeffs)) - for coeffs in model.constraint_coeffs(sparse=True, scaled=True) - ]).sum() + l1_norm = torch.stack( + [ + torch.sum(torch.abs(coeffs)) + for coeffs in model.constraint_coeffs(sparse=True, scaled=True) + ] + ).sum() converged = convergence( iteration, l1_norm ) # Check if change is smaller than delta and if we've exceeded patience diff --git a/src/deepymod/utils/logger.py b/src/deepymod/utils/logger.py index 7e41227db..b820c1830 100644 --- a/src/deepymod/utils/logger.py +++ b/src/deepymod/utils/logger.py @@ -2,31 +2,7 @@ import numpy as np import torch import sys, time -from pathlib import Path - -try: - from torch.utils.tensorboard import SummaryWriter -except ModuleNotFoundError: - class SummaryWriter: # type: ignore[override] - def __init__(self, comment=None, log_dir=None, max_queue=5, flush_secs=10): - base_dir = Path(log_dir or "runs") - base_dir.mkdir(parents=True, exist_ok=True) - self._log_dir = str(base_dir.resolve()) + "/" - - def get_logdir(self): - return self._log_dir - - def add_scalar(self, *args, **kwargs): - return None - - def add_scalars(self, *args, **kwargs): - return None - - def flush(self): - return None - - def close(self): - return None +from torch.utils.tensorboard import SummaryWriter class Logger: @@ -43,10 +19,10 @@ def __init__(self, exp_ID, log_dir): self.log_dir = self.writer.get_logdir() @staticmethod - def coefficient_items(values): + def coefficient_items(values) -> np.ndarray: if torch.is_tensor(values): values = values.detach().cpu().numpy() - return np.atleast_1d(np.squeeze(values)) + return np.asarray(values).reshape(-1) def __call__( self, @@ -59,10 +35,9 @@ def __call__( estimator_coeffs, **kwargs, ): - l1_norm = torch.stack([ - torch.sum(torch.abs(coeffs)) - for coeffs in constraint_coeffs - ]) + l1_norm = torch.stack( + [torch.sum(torch.abs(coeffs)) for coeffs in constraint_coeffs] + ) self.update_tensorboard( iteration, diff --git a/tests/test_compatibility.py b/tests/test_compatibility.py new file mode 100644 index 000000000..c3327878c --- /dev/null +++ b/tests/test_compatibility.py @@ -0,0 +1,106 @@ +import numpy as np +import torch + +from deepymod.data.base import Dataset +from deepymod.model.deepmod import DeepMoD, Estimator +from deepymod.model.sparse_estimators import PDEFIND +from deepymod.utils.logger import Logger + + +class ScalarEstimator(Estimator): + def fit(self, X: np.ndarray, y: np.ndarray) -> np.ndarray: + return np.float64(2.0) + + +class MatrixEstimator(Estimator): + def fit(self, X: np.ndarray, y: np.ndarray) -> np.ndarray: + return np.array([[1.0], [0.0]]) + + +def test_apply_normalize_accepts_non_contiguous_tensors(): + values = torch.arange(24, dtype=torch.float32).reshape(2, 3, 4).permute(1, 0, 2) + + normalized = Dataset.apply_normalize(values) + flat = normalized.reshape(-1, normalized.shape[-1]) + + assert normalized.shape == values.shape + assert torch.allclose(flat.min(dim=0).values, -torch.ones(values.shape[-1])) + assert torch.allclose(flat.max(dim=0).values, torch.ones(values.shape[-1])) + + +def test_estimator_keeps_single_coefficient_and_mask_one_dimensional(): + theta = torch.ones((5, 1)) + time_deriv = 2.0 * torch.ones((5, 1)) + + estimator = ScalarEstimator() + masks = estimator([theta], [time_deriv]) + + assert estimator.coeff_vectors[0].shape == (1, 1) + assert masks[0].shape == (1,) + assert masks[0].dtype == torch.bool + + +def test_estimator_flattens_matrix_coefficients_without_extra_dimension(): + theta = torch.ones((5, 2)) + time_deriv = torch.ones((5, 1)) + + estimator = MatrixEstimator() + masks = estimator([theta], [time_deriv]) + + assert estimator.coeff_vectors[0].shape == (2, 1) + assert masks[0].shape == (2,) + + +def test_constraint_coeffs_scaled_accepts_single_term_norms(): + model = DeepMoD.__new__(DeepMoD) + model.constraint = type( + "ConstraintStub", + (), + { + "coeff_vectors": [torch.tensor([[4.0]])], + "sparsity_masks": [torch.tensor([True])], + }, + )() + model.library = type("LibraryStub", (), {"norms": [torch.tensor(2.0)]})() + + coeffs = model.constraint_coeffs(scaled=True) + + assert coeffs[0].shape == (1, 1) + assert torch.allclose(coeffs[0], torch.tensor([[2.0]])) + + +def test_logger_handles_coefficients_with_single_or_heterogeneous_lengths(): + assert Logger.coefficient_items(torch.tensor([[1.0]])).shape == (1,) + assert Logger.coefficient_items(np.array([[1.0], [2.0]])).shape == (2,) + + logger = Logger.__new__(Logger) + captured = {} + + def capture_tensorboard(*args, **kwargs): + captured["l1"] = args[4] + + logger.update_tensorboard = capture_tensorboard + logger.update_terminal = lambda *args, **kwargs: None + + coeffs = [torch.ones((2, 1)), torch.ones((3, 1))] + logger( + iteration=0, + loss=torch.tensor(0.0), + MSE=torch.tensor([0.0, 0.0]), + Reg=torch.tensor([0.0, 0.0]), + constraint_coeffs=coeffs, + unscaled_constraint_coeffs=coeffs, + estimator_coeffs=coeffs, + ) + + assert torch.allclose(captured["l1"], torch.tensor([2.0, 3.0])) + + +def test_pdefind_stlsq_runs_with_installed_pysindy_signature(): + x = np.linspace(-1.0, 1.0, 30) + X = np.column_stack([x, np.ones_like(x)]) + y = (2.0 * x)[:, None] + + coeffs = PDEFIND.TrainSTLSQ(X, y, alpha=0.0, delta_threshold=0.01, max_iterations=2) + + assert np.asarray(coeffs).reshape(-1).shape == (2,)