Skip to content

Commit e8235c2

Browse files
JacobianEstimator
1 parent 371389f commit e8235c2

5 files changed

Lines changed: 930 additions & 10 deletions

File tree

docs/jacobian_estimator.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Jacobian Estimator
2+
3+
## Description
4+
5+
The JacobianEstimator estimates the Jacobian and forcing inputs from a time series for a system of linear differential equations. That is, given the vector ${\bf x}(t)$, it estimates the matrix ${\bf A}$ and the forcing input vector ${\bf u}$ that is the best fit for
6+
$$
7+
\dot{\bf x}(t) = {\bf A} {\bf x}(t) + {\bf u}
8+
$$
9+
10+
``JacobianEstimator`` should be structured in a manner similar to ``SystemDiscovery``.
11+
12+
## Implementation
13+
14+
### Constructor
15+
16+
def __init__(self, timecourse_df: pd.DataFrame)
17+
``timecourse_df`` has as its index time and the column names are state variables. The constructor creates the following instance state variables:
18+
19+
* ``self.timecourse_df`` is the argument passed
20+
* ``self.dtimecourse_df`` is the derivative of the state variable for times starting at index 0. It is calculated as ``(self.timecourse_df.values[i+1, :] - self.timecourse_df.values[i,:])/(self.timecourse_df.index[i+1] - self.timecourse_df.index[i])``. Thus, if there are $N$ values in ``self.timecourse_df``, there will be $N-1$ in the derivative.
21+
22+
#### Validation
23+
24+
The constructor raises ``TypeError`` if ``timecourse_df`` is not a ``pd.DataFrame``. It raises ``ValueError`` if ``timecourse_df`` is empty, has no columns (no state variables), has an index (time) that is not strictly monotonically increasing, or contains any ``NaN`` or infinite values.
25+
26+
### fit
27+
28+
def fit(alpha: float)
29+
30+
Fit estimates ${\bf A}$ and ${\bf u}$ using lasso and its tuning parameter $alpha$.
31+
32+
#### Guard
33+
34+
``predict`` raises ``RuntimeError`` if called before ``fit`` has been called.
35+
36+
### predict
37+
38+
def predict(x: np.ndarray) -> np.ndarray
39+
40+
predicts the derivative given the state variable.

scripts/calculate_linear_prediction_scores.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
from src.score import Score # type: ignore
1616
from src.biomodels_iterator import BiomodelsIterator, getBiomodelsEndtimes # type: ignore
1717
from src.timecourse_iterator import TimecourseIterator # type: ignore
18+
from src.timecourse import Timecourse # type: ignore
19+
from src.model import Model # type: ignore
1820

1921
import argparse
2022
import math
@@ -30,6 +32,8 @@
3032
"BIOMD0000000088", # Errors "too much work"
3133

3234
"BIOMD0000000072",
35+
"BIOMD0000000566",
36+
"BIOMD0000000567",
3337
"BIOMD0000000666",
3438
"BIOMD0000000794",
3539
"BIOMD0000000866",
@@ -88,10 +92,10 @@ def processModels(first_model_num: int, last_model_num: int, process_index: int,
8892
"""Processes all the models in the range of first to last.
8993
9094
Args:
91-
first_model_num (int): _description_
92-
last_model_num (int): _description_
93-
process_index (int): _description_
94-
num_processes (int): _description_
95+
first_model_num (int):
96+
last_model_num (int):
97+
process_index (int):
98+
num_processes (int):
9599
"""
96100
""" print(f"Process {process_index}/{num_processes}: "
97101
f"models {first_model_num}–{last_model_num} "
@@ -118,6 +122,24 @@ def processModels(first_model_num: int, last_model_num: int, process_index: int,
118122
or (endtime_dct[model_name][1] != cn.ENDTIME_SOURCE_SEDML):
119123
print(f"Not a model with a SEDML endtime: {model_name} — skipping.")
120124
continue
125+
# Get the timecourse or created it if not present
126+
found_timecourse = False
127+
try:
128+
timecourse = timecourse_iterator.getTimecourse(model_name)
129+
found_timecourse = True
130+
except Exception as e:
131+
print(f"Timecourse not found. Creating it.")
132+
# Construct Timecourse if not found
133+
if not found_timecourse:
134+
try:
135+
model = Model.makeBiomodel(model_name=model_name)
136+
timecourse = Timecourse(model, num_point=100)
137+
_ = timecourse.timecourse_df # Force creation of the timecourse_df
138+
_ = timecourse.jacobian_collection_arr # Force creation of the jacobian_collection_arr
139+
timecourse.serialize()
140+
except Exception as e:
141+
print(f"Error occurred while creating timecourse for model {model_name}: {e}")
142+
continue
121143
try:
122144
timecourse = timecourse_iterator.getTimecourse(model_name)
123145
discovery = SystemDiscovery.makeBiomodel(

src/jacobian_estimator.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""
2+
Jacobian Estimator: estimates the Jacobian matrix and forcing input vector from time-series data.
3+
4+
Given a vector x(t) of state variables, this estimator finds the best-fit linear model:
5+
dx/dt = A * x + u
6+
using Lasso regression.
7+
8+
Dependencies
9+
------------
10+
pip install pandas numpy scikit-learn
11+
12+
Usage
13+
-----
14+
from src.jacobian_estimator import JacobianEstimator
15+
16+
estimator = JacobianEstimator(timecourse_df)
17+
estimator.fit(alpha=0.1)
18+
print(estimator.equations)
19+
pred = estimator.predict(np.array([1.0, 2.0]))
20+
"""
21+
22+
import numpy as np # type: ignore
23+
import pandas as pd # type: ignore
24+
from sklearn.linear_model import Lasso # type: ignore
25+
26+
27+
class JacobianEstimator:
28+
"""Estimate the Jacobian matrix and forcing input vector from time-series data.
29+
30+
Given a system of linear differential equations:
31+
dx/dt = A * x + u
32+
this class estimates the matrix ``A`` and the forcing input vector ``u`` using Lasso regression.
33+
34+
Parameters
35+
----------
36+
timecourse_df : pd.DataFrame
37+
Time series data with time as the index and state variables as columns.
38+
39+
Raises
40+
------
41+
TypeError
42+
If ``timecourse_df`` is not a ``pd.DataFrame``.
43+
ValueError
44+
If ``timecourse_df`` is empty, has no columns, has an index that is not strictly
45+
monotonically increasing, or contains any NaN or infinite values.
46+
47+
Examples
48+
--------
49+
>>> import pandas as pd
50+
>>> import numpy as np
51+
>>> df = pd.DataFrame({
52+
... 'S1': [1.0, 2.0, 3.0, 4.0],
53+
... 'S2': [0.5, 0.8, 1.1, 1.4]
54+
... }, index=[0.0, 1.0, 2.0, 3.0])
55+
>>> estimator = JacobianEstimator(df)
56+
>>> estimator.fit(alpha=0.01)
57+
>>> print(estimator.equations)
58+
"""
59+
60+
def __init__(self, timecourse_df: pd.DataFrame) -> None:
61+
# Validate type
62+
if not isinstance(timecourse_df, pd.DataFrame):
63+
raise TypeError(
64+
f"timecourse_df must be a pd.DataFrame, got {type(timecourse_df).__name__}"
65+
)
66+
67+
# Validate non-empty and has columns
68+
if timecourse_df.empty:
69+
raise ValueError("timecourse_df is empty.")
70+
if len(timecourse_df.columns) == 0:
71+
raise ValueError("timecourse_df has no columns (no state variables).")
72+
73+
# Validate index is strictly monotonically increasing
74+
idx = timecourse_df.index
75+
if not (np.diff(idx.values.astype(float)) > 0).all():
76+
raise ValueError(
77+
"timecourse_df index must be strictly monotonically increasing."
78+
)
79+
80+
# Validate no NaN or infinite values
81+
if timecourse_df.isnull().any().any():
82+
raise ValueError("timecourse_df contains NaN values.")
83+
if np.isinf(timecourse_df.values).any():
84+
raise ValueError("timecourse_df contains infinite values.")
85+
86+
self.timecourse_df = timecourse_df
87+
88+
# Compute derivatives using forward finite differences.
89+
# dtimecourse_df has one fewer row than timecourse_df (derivative at index i uses rows i and i+1).
90+
raw_values = timecourse_df.values.astype(float)
91+
idx_float = timecourse_df.index.to_numpy(dtype=float)
92+
dt_arr = np.diff(idx_float)
93+
self.dtimecourse_df = pd.DataFrame(
94+
data=(raw_values[1:, :] - raw_values[:-1, :]) / dt_arr[:, np.newaxis],
95+
index=idx_float[:-1],
96+
columns=timecourse_df.columns,
97+
)
98+
99+
# State for fitted model
100+
self._is_fitted: bool = False
101+
self.A_: np.ndarray # denormalized Jacobian matrix (n_species x n_species)
102+
self.u_: np.ndarray # denormalized forcing input vector (n_species,)
103+
104+
def _require_fitted(self) -> None:
105+
"""Raise RuntimeError if fit() has not been called."""
106+
if not self._is_fitted:
107+
raise RuntimeError("Call .fit() before using this method.")
108+
109+
def fit(self, alpha: float = 0.01) -> "JacobianEstimator":
110+
"""Fit the Jacobian matrix and forcing input vector using Lasso regression.
111+
112+
Parameters
113+
----------
114+
alpha : float, optional
115+
L1 regularization strength for Lasso. Must be non-negative. Larger values
116+
produce sparser models. Default ``0.01``.
117+
118+
Returns
119+
-------
120+
self
121+
"""
122+
if alpha < 0:
123+
raise ValueError(f"alpha must be non-negative, got {alpha}")
124+
125+
# Build the design matrix X from state variables (with intercept column)
126+
# Each row is [x_1, x_2, ..., x_n, 1]
127+
# Use only the first N-1 rows to match dtimecourse_df (derivatives at t_0..t_{N-2})
128+
X = self.timecourse_df.values[:-1].copy()
129+
n_rows = X.shape[0]
130+
intercept_col = np.ones((n_rows, 1))
131+
X_design = np.hstack([X, intercept_col])
132+
133+
# Fit one regression model per species (column of dtimecourse)
134+
n_species = X_design.shape[1] - 1 # number of state variables
135+
self.A_ = np.zeros((n_species, n_species))
136+
self.u_ = np.zeros(n_species)
137+
138+
for i in range(n_species):
139+
y = np.asarray(self.dtimecourse_df.iloc[:, i], dtype=float)
140+
if alpha == 0.0:
141+
# Use OLS when no regularization to avoid Lasso bias
142+
from sklearn.linear_model import LinearRegression # type: ignore
143+
lr = LinearRegression(fit_intercept=False)
144+
lr.fit(X_design, y)
145+
coefs: np.ndarray = np.atleast_1d(np.asarray(lr.coef_, dtype=float))
146+
else:
147+
lasso = Lasso(alpha=alpha, fit_intercept=False, max_iter=10000)
148+
lasso.fit(X_design, y)
149+
coefs = np.atleast_1d(np.asarray(lasso.coef_, dtype=float))
150+
self.A_[i, :] = coefs[0:n_species].copy()
151+
self.u_[i] = float(coefs[n_species]) # intercept term
152+
153+
self._is_fitted = True
154+
return self
155+
156+
def predict(self, x: np.ndarray) -> np.ndarray:
157+
"""Predict the derivative given a state vector.
158+
159+
Parameters
160+
----------
161+
x : np.ndarray
162+
State vector in physical units, shape ``(n_species,)``.
163+
164+
Returns
165+
-------
166+
np.ndarray
167+
Predicted derivative ``dx/dt``, shape ``(n_species,)``.
168+
169+
Raises
170+
------
171+
RuntimeError
172+
If ``fit()`` has not been called.
173+
"""
174+
self._require_fitted()
175+
176+
# Compute derivative: dx/dt = A * x + u
177+
result = self.A_.dot(x) + self.u_
178+
179+
return np.asarray(result, dtype=float)
180+
181+
@property
182+
def equations(self) -> str:
183+
"""Return a string representation of the estimated linear ODEs.
184+
185+
Each line shows the equation for one species in the form::
186+
187+
dS_i/dt = c_1*S_1 + c_2*S_2 + ... + u_i
188+
189+
where ``c_j`` are the Jacobian entries and ``u_i`` is the forcing input.
190+
191+
Returns
192+
-------
193+
str
194+
Multi-line string with one equation per species.
195+
196+
Raises
197+
------
198+
RuntimeError
199+
If ``fit()`` has not been called.
200+
"""
201+
self._require_fitted()
202+
203+
col_names = list(self.timecourse_df.columns)
204+
n_species = len(col_names)
205+
lines = []
206+
for i in range(n_species):
207+
terms = []
208+
for j in range(n_species):
209+
coef = self.A_[i, j]
210+
if abs(coef) > 1e-12:
211+
if j == 0:
212+
terms.append(f"{coef:.6g}*{col_names[j]}")
213+
else:
214+
sign = "+" if coef >= 0 else "-"
215+
terms.append(f" {sign} {abs(coef):.6g}*{col_names[j]}")
216+
217+
# Add forcing input term
218+
u_val = self.u_[i]
219+
if abs(u_val) > 1e-12:
220+
sign = "+" if u_val >= 0 else "-"
221+
terms.append(f" {sign} {abs(u_val):.6g}")
222+
223+
if not terms:
224+
terms.append("0")
225+
226+
lines.append(f"d{col_names[i]}/dt = {' '.join(terms)}")
227+
228+
return "\n".join(lines)

0 commit comments

Comments
 (0)