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====================================================================================
44Discovers a system of ODEs from time-series concentration data using PySINDy
55that estimate the derivatives of each species as a sparse linear combination of polynomial features of the species concentrations.
1515Input
1616-----
1717A 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
2121Usage
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"
5756# Constants
5857# ---------------------------------------------------------------------------
5958
60- MAX_SPECIES = 10
59+ MAX_SPECIES = 20
6160DifferentiationMethod = Literal ["smooth" , "finite" , "spectral" ]
6261
6362
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
486499def 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
576584def _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
605615if __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