Skip to content

Commit 13238ef

Browse files
Refining SystemDiscovery
1 parent e997ed7 commit 13238ef

4 files changed

Lines changed: 158 additions & 163 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
* Accuracy of reproducing a non-linear simulation
88
* Identify "linearity bottleneck" reactions, those that must limit the viability of a linear approximation
99

10+
# Tuning SystemDiscovery
11+
12+
* Need sufficient data, maybe in the 1000s.
13+
* Reduce threshold (sensitivity) to get more sparse coefficients
14+
* Set includebias = True
15+
* Don't include boundary species
16+
1017
## Analyses
1118

1219

Lines changed: 79 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
Chemical Network Rate Discovery via SINDy (Sparse Identification of Nonlinear Dynamics)
2+
Discovery of a system of differential equations from data using PySINDy, tailored for chemical reaction networks.
33
====================================================================================
44
Discovers a system of ODEs from time-series concentration data using PySINDy
55
that estimate the derivatives of each species as a sparse linear combination of polynomial features of the species concentrations.
@@ -15,16 +15,15 @@
1515
Input
1616
-----
1717
A pandas DataFrame with:
18-
- One column named 'time' (or passed separately as the `time_col` argument)
19-
- One column per species (up to 10)
18+
- Index is time
19+
- One column per species (up to 20)
2020
2121
Usage
2222
-----
2323
from chemical_network_sindy import NetworkRateDiscovery
2424
2525
discovery = NetworkRateDiscovery(
2626
df,
27-
time_col="time",
2827
threshold=0.05, # STLSQ sparsity threshold
2928
alpha=0.05, # L2 regularisation
3029
differentiation="smooth" # "smooth" | "finite" | "spectral"
@@ -57,7 +56,7 @@
5756
# Constants
5857
# ---------------------------------------------------------------------------
5958

60-
MAX_SPECIES = 10
59+
MAX_SPECIES = 20
6160
DifferentiationMethod = Literal["smooth", "finite", "spectral"]
6261

6362

@@ -66,16 +65,15 @@
6665
# ---------------------------------------------------------------------------
6766

6867

69-
class NetworkRateDiscovery:
68+
class SystemDiscovery:
7069
"""Discover a chemical reaction network from concentration time-series data.
7170
7271
Parameters
7372
----------
7473
df : pd.DataFrame
7574
Time-series data. Must contain a time column and one column per
7675
chemical species (concentrations must be non-negative).
77-
time_col : str
78-
Name of the column holding time values. Default ``"time"``.
76+
Index is time
7977
threshold : float
8078
STLSQ sparsity threshold. Terms whose coefficient magnitude falls
8179
below this value are pruned. Tune this to trade sparsity for fit.
@@ -110,39 +108,35 @@ class NetworkRateDiscovery:
110108
def __init__(
111109
self,
112110
df: pd.DataFrame,
113-
*,
114-
time_col: str = "time",
115-
threshold: float = 0.05,
111+
threshold: float = 0.01,
116112
alpha: float = 0.05,
117-
differentiation: DifferentiationMethod = "smooth",
113+
#differentiation: DifferentiationMethod = "smooth",
114+
differentiation: DifferentiationMethod = "spectral",
118115
poly_degree: int = 2,
119116
include_bias: bool = True,
120117
species_names: list[str] | None = None,
121118
bias_species: list[str] | None = None,
122119
) -> None:
123-
if poly_degree not in (1, 2):
124-
raise ValueError("`poly_degree` must be 1 (linear) or 2 (quadratic).")
125120

126-
self._validate_dataframe(df, time_col)
121+
self._validate_dataframe(df)
127122

128123
self.df = df.copy()
129-
self.time_col = time_col
130124
self.threshold = threshold
131125
self.alpha = alpha
132126
self.differentiation = differentiation
133127
self.poly_degree = poly_degree
134128
self.include_bias = include_bias
135129

136130
# Extract time and concentration arrays
137-
species_cols = [c for c in df.columns if c != time_col]
131+
species_cols = list(df.columns)
138132
if len(species_cols) > MAX_SPECIES:
139133
raise ValueError(
140134
f"DataFrame contains {len(species_cols)} species columns; "
141135
f"maximum supported is {MAX_SPECIES}."
142136
)
143137

144138
self.species_cols = species_cols
145-
self.t: np.ndarray = df[time_col].to_numpy(dtype=float)
139+
self.time_arr: np.ndarray = df.index.to_numpy(dtype=float)
146140
self.X: np.ndarray = df[species_cols].to_numpy(dtype=float)
147141

148142
if species_names is not None:
@@ -186,16 +180,22 @@ def __init__(
186180
# Public interface
187181
# ------------------------------------------------------------------
188182

189-
def fit(self) -> "NetworkRateDiscovery":
183+
def fit(self) -> "SystemDiscovery":
190184
"""Fit the SINDy model to the data.
191185
192186
Returns
193187
-------
194188
self
195189
"""
196190

197-
dt = float(np.median(np.diff(self.t)))
198-
self.model.fit(self.X, t=dt, feature_names=self.species_names)
191+
dt = float(np.median(np.diff(self.time_arr)))
192+
with warnings.catch_warnings(record=True) as _caught:
193+
warnings.simplefilter("always")
194+
self.model.fit(self.X, t=dt, feature_names=self.species_names)
195+
if _caught:
196+
print("Warnings from model.fit():")
197+
for w in _caught:
198+
print(f" {w.category.__name__}: {w.message}")
199199
if self.bias_species is not None:
200200
allowed = set(self.bias_species)
201201
for i, name in enumerate(self.species_names):
@@ -224,9 +224,9 @@ def predict(self) -> pd.DataFrame:
224224
"""
225225
self._require_fitted()
226226
X_sim = self._simulate()
227-
return pd.DataFrame(X_sim, index=self.t, columns=self.species_names)
227+
return pd.DataFrame(X_sim, index=self.time_arr, columns=self.species_names)
228228

229-
def r_squared(self, method: str = "derivative") -> dict[str, float]:
229+
def r_squared(self, method: str = "simulation") -> dict[str, float]:
230230
"""Compute R² for each species.
231231
232232
Parameters
@@ -243,19 +243,26 @@ def r_squared(self, method: str = "derivative") -> dict[str, float]:
243243
dict mapping species name → R²
244244
"""
245245
self._require_fitted()
246-
if method == "simulation":
247-
return self._r_squared_simulation()
248-
return self._r_squared_derivative()
246+
try:
247+
if method == "simulation":
248+
result = self._r_squared_simulation()
249+
else:
250+
result = self._r_squared_derivative()
251+
except Exception as exc:
252+
warnings.warn(f"R² computation failed: {exc}")
253+
result = self._r_squared_derivative()
254+
return result
249255

250256
def _r_squared_derivative(self) -> dict[str, float]:
251257
"""R² on predicted vs numerical derivatives."""
252-
dt = float(np.median(np.diff(self.t)))
258+
dt = float(np.median(np.diff(self.time_arr)))
253259
X_dot_pred = self.model.predict(self.X) # predicted derivatives
254-
X_dot_num = self.model.differentiation_method(self.X, self.t) # numerical
260+
X_dot_num = self.model.differentiation_method(self.X, self.time_arr) # type: ignore # numerical derivatives
255261
r2 = {}
262+
X_dot_pred_arr = np.array(X_dot_pred)
256263
for i, name in enumerate(self.species_names):
257264
y_true = X_dot_num[:, i]
258-
y_pred = X_dot_pred[:, i]
265+
y_pred = X_dot_pred_arr[:, i]
259266
ss_res = np.sum((y_true - y_pred) ** 2)
260267
ss_tot = np.sum((y_true - y_true.mean()) ** 2)
261268
r2[name] = 1.0 - ss_res / ss_tot if ss_tot > 0 else float("nan")
@@ -294,13 +301,14 @@ def summary(self) -> pd.DataFrame:
294301
)
295302
# Keep only rows where at least one coefficient is non-zero
296303
non_zero_mask = (df_coef.abs() > 0).any(axis=1)
297-
return df_coef[non_zero_mask]
304+
return df_coef[non_zero_mask] # type: ignore
298305

299306
def plotResult(
300307
self,
301308
figsize: tuple[float, float] | None = None,
302309
show: bool = True,
303-
) -> plt.Figure:
310+
num_skip_point: int = 5,
311+
) -> plt.Figure: # type: ignore
304312
"""Plot observed vs. model-simulated trajectories for each species.
305313
306314
Parameters
@@ -310,6 +318,8 @@ def plotResult(
310318
show : bool
311319
Call ``plt.show()`` at the end. Set to ``False`` when embedding
312320
in a larger figure or saving manually.
321+
num_skip_point : int
322+
Plot only every N-th point from the original data to reduce clutter.
313323
314324
Returns
315325
-------
@@ -346,7 +356,7 @@ def plotResult(
346356
row, col = divmod(idx, ncols)
347357
ax = axes[row][col]
348358
color = f"C{idx}"
349-
ax.plot(self.t, self.X[:, idx], "-", lw=2, color=color, label=f"{name} (observed)")
359+
ax.scatter(self.time_arr[::num_skip_point], self.X[::num_skip_point, idx], s=20, color=color, label=f"{name} (observed)")
350360
if prediction_ok and pred_df is not None:
351361
ax.plot(pred_df.index, pred_df[name], "--", lw=2, color=color, label=f"{name} (predicted)")
352362
r2 = r2_vals.get(name, float("nan"))
@@ -373,7 +383,7 @@ def plot_coefficient_heatmap(
373383
self,
374384
figsize: tuple[float, float] | None = None,
375385
show: bool = True,
376-
) -> plt.Figure:
386+
) -> plt.Figure: # type: ignore
377387
"""Visualise the coefficient matrix as a heatmap.
378388
379389
Each row is a library feature; each column is a species.
@@ -407,11 +417,11 @@ def plot_coefficient_heatmap(
407417
for i in range(len(df_coef.index)):
408418
for j in range(len(df_coef.columns)):
409419
val = df_coef.iloc[i, j]
410-
if abs(val) > 1e-10:
420+
if abs(val) > 1e-10: # type: ignore
411421
ax.text(
412422
j, i, f"{val:.3f}",
413423
ha="center", va="center", fontsize=7,
414-
color="white" if abs(val) > df_coef.values.max() * 0.5 else "black",
424+
color="white" if abs(val) > df_coef.values.max() * 0.5 else "black", # type: ignore
415425
)
416426

417427
fig.tight_layout()
@@ -445,37 +455,40 @@ def _simulate(self) -> np.ndarray:
445455
x0 = self.X[0, :]
446456

447457
def rhs(t, x):
448-
return self.model.predict(x.reshape(1, -1))[0]
449-
450-
sol = solve_ivp(
451-
rhs,
452-
t_span=(self.t[0], self.t[-1]),
453-
y0=x0,
454-
t_eval=self.t,
455-
method="RK45",
456-
rtol=1e-6,
457-
atol=1e-8,
458-
)
458+
ans = self.model.predict(x.reshape(1, -1))[0]
459+
return np.array(ans, dtype=float)
460+
461+
462+
try:
463+
sol = solve_ivp(
464+
rhs,
465+
t_span=(self.time_arr[0], self.time_arr[-1]),
466+
y0=x0,
467+
t_eval=self.time_arr,
468+
method="LSODA",
469+
rtol=1e-6,
470+
atol=1e-8,
471+
#max_step=0.01
472+
)
473+
except Exception as exc:
474+
raise RuntimeError(f"ODE integration failed: {exc}") from exc
459475
if not sol.success:
460476
raise RuntimeError(f"ODE integration failed: {sol.message}")
461477
return sol.y.T # shape (n_timepoints, n_species)
462478

463479
@staticmethod
464-
def _validate_dataframe(df: pd.DataFrame, time_col: str) -> None:
480+
def _validate_dataframe(df: pd.DataFrame) -> None:
465481
if not isinstance(df, pd.DataFrame):
466482
raise TypeError("`df` must be a pandas DataFrame.")
467-
if time_col not in df.columns:
468-
raise ValueError(f"Time column '{time_col}' not found in DataFrame.")
469-
species_cols = [c for c in df.columns if c != time_col]
470-
if len(species_cols) == 0:
483+
if len(df.columns) == 0:
471484
raise ValueError("DataFrame must contain at least one species column.")
472-
if len(species_cols) > MAX_SPECIES:
485+
if len(df.columns) > MAX_SPECIES:
473486
raise ValueError(
474-
f"DataFrame has {len(species_cols)} species columns; "
487+
f"DataFrame has {len(df.columns)} species columns; "
475488
f"maximum is {MAX_SPECIES}."
476489
)
477-
if df[time_col].is_monotonic_increasing is False:
478-
raise ValueError("Time column must be strictly increasing.")
490+
if not df.index.is_monotonic_increasing:
491+
raise ValueError("DataFrame index (time) must be strictly increasing.")
479492

480493

481494
# ---------------------------------------------------------------------------
@@ -485,25 +498,21 @@ def _validate_dataframe(df: pd.DataFrame, time_col: str) -> None:
485498

486499
def discover_network(
487500
df: pd.DataFrame,
488-
*,
489-
time_col: str = "time",
490-
threshold: float = 0.05,
501+
threshold: float = 0.01,
491502
alpha: float = 0.05,
492503
differentiation: DifferentiationMethod = "smooth",
493504
poly_degree: int = 2,
494505
include_bias: bool = True,
495506
species_names: list[str] | None = None,
496507
plot: bool = True,
497508
heatmap: bool = True,
498-
) -> NetworkRateDiscovery:
509+
) -> SystemDiscovery:
499510
"""One-shot helper: construct, fit, print, and optionally plot.
500511
501512
Parameters
502513
----------
503514
df : pd.DataFrame
504515
Input data (see :class:`NetworkRateDiscovery`).
505-
time_col : str
506-
Name of the time column.
507516
threshold : float
508517
STLSQ sparsity threshold.
509518
alpha : float
@@ -528,13 +537,12 @@ def discover_network(
528537
529538
Example
530539
-------
531-
>>> disc = discover_network(df, time_col="time", threshold=0.02)
540+
>>> disc = discover_network(df, threshold=0.02)
532541
>>> disc.print_equations()
533542
>>> summary = disc.summary()
534543
"""
535-
disc = NetworkRateDiscovery(
544+
disc = SystemDiscovery(
536545
df,
537-
time_col=time_col,
538546
threshold=threshold,
539547
alpha=alpha,
540548
differentiation=differentiation,
@@ -545,7 +553,7 @@ def discover_network(
545553
disc.fit()
546554
disc.print_equations()
547555

548-
r2 = disc.r_squared(method="derivative")
556+
r2 = disc.r_squared()
549557
print("R² on time derivatives per species:")
550558
for name, val in r2.items():
551559
print(f" {name}: {val:.6f}")
@@ -575,7 +583,7 @@ def discover_network(
575583

576584
def _generate_brusselator(
577585
t_end: float = 20.0,
578-
n_points: int = 400,
586+
n_points: int = 4000,
579587
noise_std: float = 0.02,
580588
seed: int = 42,
581589
) -> pd.DataFrame:
@@ -599,7 +607,9 @@ def brusselator(t, z):
599607
X_data = sol.y[0] + rng.normal(0, noise_std, n_points)
600608
Y_data = sol.y[1] + rng.normal(0, noise_std, n_points)
601609

602-
return pd.DataFrame({"time": t_eval, "X": X_data, "Y": Y_data})
610+
df = pd.DataFrame({"time": t_eval, "X": X_data, "Y": Y_data})
611+
df = df.set_index("time")
612+
return df
603613

604614

605615
if __name__ == "__main__":
@@ -612,11 +622,10 @@ def brusselator(t, z):
612622

613623
disc = discover_network(
614624
df_demo,
615-
time_col="time",
616-
threshold=0.1,
625+
threshold=0.01,
617626
alpha=0.01,
618627
differentiation="smooth",
619-
poly_degree=2,
628+
poly_degree=3,
620629
include_bias=True,
621630
plot=True,
622631
heatmap=True,

0 commit comments

Comments
 (0)