stopro 0.3.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stopro/__init__.py +34 -0
- stopro/_utils.py +171 -0
- stopro/colored_geometric_brownian_motion.py +81 -0
- stopro/colored_replicator.py +110 -0
- stopro/competitive_lotka_volterra.py +154 -0
- stopro/exponential_ornstein_uhlenbeck.py +83 -0
- stopro/geometric_brownian_motion.py +81 -0
- stopro/gillespie_replicator.py +89 -0
- stopro/integrated_ornstein_uhlenbeck.py +76 -0
- stopro/kimura_replicator.py +116 -0
- stopro/moran.py +153 -0
- stopro/ornstein_uhlenbeck.py +183 -0
- stopro/white_replicator.py +45 -0
- stopro/wiener.py +85 -0
- stopro-0.3.3.dist-info/METADATA +92 -0
- stopro-0.3.3.dist-info/RECORD +17 -0
- stopro-0.3.3.dist-info/WHEEL +4 -0
stopro/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
__version__ = version("stopro")
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"wiener",
|
|
7
|
+
"ornstein_uhlenbeck",
|
|
8
|
+
"kimura_replicator",
|
|
9
|
+
"geometric_brownian_motion",
|
|
10
|
+
"exponential_ornstein_uhlenbeck",
|
|
11
|
+
"integrated_ornstein_uhlenbeck",
|
|
12
|
+
"colored_geometric_brownian_motion",
|
|
13
|
+
"gillespie_replicator",
|
|
14
|
+
"white_replicator",
|
|
15
|
+
"colored_stochastic_replicator",
|
|
16
|
+
"colored_replicator",
|
|
17
|
+
"moran",
|
|
18
|
+
"competitive_lotka_volterra",
|
|
19
|
+
"__version__",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
from .wiener import wiener
|
|
23
|
+
from .ornstein_uhlenbeck import ornstein_uhlenbeck
|
|
24
|
+
from .kimura_replicator import kimura_replicator
|
|
25
|
+
from .geometric_brownian_motion import geometric_brownian_motion
|
|
26
|
+
from .exponential_ornstein_uhlenbeck import exponential_ornstein_uhlenbeck
|
|
27
|
+
from .integrated_ornstein_uhlenbeck import integrated_ornstein_uhlenbeck
|
|
28
|
+
from .colored_geometric_brownian_motion import colored_geometric_brownian_motion
|
|
29
|
+
from .gillespie_replicator import gillespie_replicator
|
|
30
|
+
from .white_replicator import white_replicator
|
|
31
|
+
from .colored_replicator import colored_replicator as colored_stochastic_replicator
|
|
32
|
+
from .colored_replicator import colored_replicator
|
|
33
|
+
from .moran import moran
|
|
34
|
+
from .competitive_lotka_volterra import competitive_lotka_volterra
|
stopro/_utils.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
# Build a uniform time grid on [0, T] from either dt or steps (exactly one must be given).
|
|
4
|
+
# Returns (dt, steps, t) with t including both endpoints 0 and T.
|
|
5
|
+
def _time_grid(T, dt=None, steps=None):
|
|
6
|
+
"""
|
|
7
|
+
Build a uniform grid on [0, T].
|
|
8
|
+
|
|
9
|
+
Provide exactly one of dt or steps.
|
|
10
|
+
- If steps is provided: dt = T/steps.
|
|
11
|
+
- If dt is provided: choose integer steps close to T/dt, then use dt = T/steps
|
|
12
|
+
so the grid ends exactly at T.
|
|
13
|
+
"""
|
|
14
|
+
if (dt is None) == (steps is None):
|
|
15
|
+
raise ValueError("Provide exactly one of dt or steps (not both, not neither).")
|
|
16
|
+
|
|
17
|
+
T = float(T)
|
|
18
|
+
if T <= 0:
|
|
19
|
+
raise ValueError("T must be > 0.")
|
|
20
|
+
|
|
21
|
+
if steps is not None:
|
|
22
|
+
steps = int(steps)
|
|
23
|
+
if steps <= 0:
|
|
24
|
+
raise ValueError("steps must be a positive integer.")
|
|
25
|
+
dt = T / steps
|
|
26
|
+
t = np.linspace(0.0, T, steps + 1)
|
|
27
|
+
return dt, steps, t
|
|
28
|
+
|
|
29
|
+
dt = float(dt)
|
|
30
|
+
if dt <= 0:
|
|
31
|
+
raise ValueError("dt must be > 0.")
|
|
32
|
+
|
|
33
|
+
steps = int(np.round(T / dt))
|
|
34
|
+
steps = max(1, steps)
|
|
35
|
+
|
|
36
|
+
dt = T / steps
|
|
37
|
+
t = np.linspace(0.0, T, steps + 1)
|
|
38
|
+
return dt, steps, t
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Robustly factor a symmetric PSD matrix C into L such that C ≈ L @ L.T.
|
|
42
|
+
# Uses Cholesky when possible, otherwise falls back to eigen-decomposition (clipping small negatives).
|
|
43
|
+
def _psd_factor(C, *, jitter=0.0, tol=1e-12):
|
|
44
|
+
|
|
45
|
+
C = np.asarray(C, dtype=float)
|
|
46
|
+
C = 0.5 * (C + C.T)
|
|
47
|
+
|
|
48
|
+
if jitter and jitter > 0:
|
|
49
|
+
Cj = C + jitter * np.eye(C.shape[0])
|
|
50
|
+
else:
|
|
51
|
+
Cj = C
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
return np.linalg.cholesky(Cj)
|
|
55
|
+
except np.linalg.LinAlgError:
|
|
56
|
+
w, V = np.linalg.eigh(C)
|
|
57
|
+
w = np.where(w > tol, w, 0.0)
|
|
58
|
+
return V @ np.diag(np.sqrt(w))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Normalize "noise specification" into a mixing matrix S with covariance C = S @ S.T.
|
|
62
|
+
# Accepts either covariance (must be symmetric PSD) or a mixing_matrix; otherwise defaults to identity.
|
|
63
|
+
def _mixing(*, N=None, covariance=None, mixing_matrix=None, jitter=1e-12, psd_tol=1e-12, sym_tol=1e-12):
|
|
64
|
+
if covariance is not None and mixing_matrix is not None:
|
|
65
|
+
raise ValueError("Provide only one of covariance or mixing_matrix (not both).")
|
|
66
|
+
|
|
67
|
+
if mixing_matrix is not None:
|
|
68
|
+
S = np.asarray(mixing_matrix, dtype=float)
|
|
69
|
+
if S.ndim != 2:
|
|
70
|
+
raise ValueError("mixing_matrix must be a 2D array.")
|
|
71
|
+
N_res, M = S.shape
|
|
72
|
+
if N is not None and int(N) != N_res:
|
|
73
|
+
raise ValueError(f"N={N} is inconsistent with mixing_matrix.shape[0]={N_res}.")
|
|
74
|
+
C = S @ S.T
|
|
75
|
+
C = 0.5 * (C + C.T)
|
|
76
|
+
return S, C, N_res, M
|
|
77
|
+
|
|
78
|
+
if covariance is not None:
|
|
79
|
+
C = np.asarray(covariance, dtype=float)
|
|
80
|
+
if C.ndim != 2 or C.shape[0] != C.shape[1]:
|
|
81
|
+
raise ValueError("covariance must be a square (N,N) array.")
|
|
82
|
+
|
|
83
|
+
# Require symmetry (within tolerance) for user-provided covariance
|
|
84
|
+
max_asym = float(np.max(np.abs(C - C.T)))
|
|
85
|
+
if max_asym > sym_tol:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"covariance must be symmetric; max |C - C.T| = {max_asym:g} exceeds sym_tol={sym_tol:g}."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
C = 0.5 * (C + C.T)
|
|
91
|
+
|
|
92
|
+
N_res = C.shape[0]
|
|
93
|
+
if N is not None and int(N) != N_res:
|
|
94
|
+
raise ValueError(f"N={N} is inconsistent with covariance.shape[0]={N_res}.")
|
|
95
|
+
|
|
96
|
+
lam_min = float(np.min(np.linalg.eigvalsh(C)))
|
|
97
|
+
if lam_min < -psd_tol:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"covariance must be positive semidefinite; min eigenvalue = {lam_min:g} < -psd_tol={psd_tol:g}."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
S = _psd_factor(C, jitter=jitter, tol=psd_tol)
|
|
103
|
+
M = N_res
|
|
104
|
+
return S, C, N_res, M
|
|
105
|
+
# Neither covariance nor mixing_matrix: default to independent Wiener components
|
|
106
|
+
if N is None:
|
|
107
|
+
raise ValueError("N must be provided when neither covariance nor mixing_matrix is given.")
|
|
108
|
+
N_res = int(N)
|
|
109
|
+
if N_res <= 0:
|
|
110
|
+
raise ValueError("N must be a positive integer.")
|
|
111
|
+
S = np.eye(N_res, dtype=float)
|
|
112
|
+
C = np.eye(N_res, dtype=float)
|
|
113
|
+
M = N_res
|
|
114
|
+
return S, C, N_res, M
|
|
115
|
+
|
|
116
|
+
# Coerce a parameter into a length-N float vector (broadcast scalar; validate 1D shape).
|
|
117
|
+
def _as_vector(x, N, name):
|
|
118
|
+
arr = np.asarray(x, dtype=float)
|
|
119
|
+
if arr.ndim == 0:
|
|
120
|
+
return np.full(int(N), float(arr))
|
|
121
|
+
if arr.shape == (int(N),):
|
|
122
|
+
return arr
|
|
123
|
+
raise ValueError(f"{name} must be a scalar or an array of shape ({N},), got shape {arr.shape}.")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Parse initial_condition into either ("stationary") or an explicit x0 vector of length N.
|
|
127
|
+
# Used by OU/Wiener-like processes to support a convenient stationary start.
|
|
128
|
+
def _parse_initial_condition(initial_condition, *, N):
|
|
129
|
+
if isinstance(initial_condition, str):
|
|
130
|
+
if initial_condition.lower() == "stationary":
|
|
131
|
+
return True, None
|
|
132
|
+
raise ValueError(f"unknown initial_condition '{initial_condition}'")
|
|
133
|
+
|
|
134
|
+
if initial_condition is None:
|
|
135
|
+
return False, np.zeros(int(N), dtype=float)
|
|
136
|
+
|
|
137
|
+
# scalar or vector
|
|
138
|
+
x0 = _as_vector(initial_condition, N, "initial_condition")
|
|
139
|
+
return False, x0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# Create an initial condition on the probability simplex (nonnegative entries summing to 1).
|
|
143
|
+
# Accepts None (uniform), scalar (broadcast then normalize), or a length-N vector (validate+normalize).
|
|
144
|
+
def _simplex_initial_condition(initial_condition, *, N, name="initial_condition"):
|
|
145
|
+
|
|
146
|
+
N = int(N)
|
|
147
|
+
if N <= 0:
|
|
148
|
+
raise ValueError("N must be a positive integer.")
|
|
149
|
+
|
|
150
|
+
if initial_condition is None:
|
|
151
|
+
return np.full(N, 1.0 / N, dtype=float)
|
|
152
|
+
|
|
153
|
+
x0 = np.asarray(initial_condition, dtype=float)
|
|
154
|
+
|
|
155
|
+
if x0.ndim == 0:
|
|
156
|
+
c = float(x0)
|
|
157
|
+
if c <= 0:
|
|
158
|
+
raise ValueError(f"{name} scalar must be > 0 to normalize, got {c}.")
|
|
159
|
+
x0 = np.full(N, c, dtype=float)
|
|
160
|
+
|
|
161
|
+
if x0.shape != (N,):
|
|
162
|
+
raise ValueError(f"{name} must be None, a scalar, or an array of shape ({N},), got shape {x0.shape}.")
|
|
163
|
+
|
|
164
|
+
if np.any(x0 < 0):
|
|
165
|
+
raise ValueError(f"{name} must be nonnegative.")
|
|
166
|
+
|
|
167
|
+
s = float(np.sum(x0))
|
|
168
|
+
if s <= 0:
|
|
169
|
+
raise ValueError(f"{name} must have positive sum to normalize.")
|
|
170
|
+
|
|
171
|
+
return x0 / s
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from ._utils import _as_vector
|
|
4
|
+
from .integrated_ornstein_uhlenbeck import integrated_ornstein_uhlenbeck
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def colored_geometric_brownian_motion(
|
|
8
|
+
T,
|
|
9
|
+
dt=None,
|
|
10
|
+
*,
|
|
11
|
+
steps=None,
|
|
12
|
+
gap=1,
|
|
13
|
+
N=1,
|
|
14
|
+
samples=1,
|
|
15
|
+
mu=1.0,
|
|
16
|
+
sigma=1.0,
|
|
17
|
+
tau=1.0,
|
|
18
|
+
initial_condition=None,
|
|
19
|
+
covariance=None,
|
|
20
|
+
mixing_matrix=None,
|
|
21
|
+
):
|
|
22
|
+
"""
|
|
23
|
+
Simulate multivariate colored geometric Brownian motion (cGBM) on [0, T].
|
|
24
|
+
|
|
25
|
+
dX_i(t) = mu_i X_i(t) dt + sigma_i X_i(t) Z_i(t) dt
|
|
26
|
+
tau * dZ_i(t) = -Z_i(t) dt + dW_i(t)
|
|
27
|
+
|
|
28
|
+
Hence,
|
|
29
|
+
X_i(t) = X_i(0) * exp(mu_i t + sigma_i * ∫_0^t Z_i(s) ds).
|
|
30
|
+
|
|
31
|
+
Provide exactly one of `dt` or `steps`. Noise correlation can be specified via
|
|
32
|
+
`covariance` or `mixing_matrix` (mutually exclusive). Use `gap>1` to subsample.
|
|
33
|
+
"""
|
|
34
|
+
tau = float(tau)
|
|
35
|
+
if tau <= 0:
|
|
36
|
+
raise ValueError("tau must be > 0.")
|
|
37
|
+
|
|
38
|
+
N = int(N)
|
|
39
|
+
if N <= 0:
|
|
40
|
+
raise ValueError("N must be a positive integer.")
|
|
41
|
+
|
|
42
|
+
# Choose OU stdev so that: tau*dZ = -Z dt + dW <=> dZ = -(1/tau)Z dt + (1/tau)dW
|
|
43
|
+
# which implies stationary stdev(Z) = 1/sqrt(2*tau).
|
|
44
|
+
stdev_z = 1.0 / np.sqrt(2.0 * tau)
|
|
45
|
+
|
|
46
|
+
res_I = integrated_ornstein_uhlenbeck(
|
|
47
|
+
T,
|
|
48
|
+
dt,
|
|
49
|
+
steps=steps,
|
|
50
|
+
gap=gap,
|
|
51
|
+
N=N,
|
|
52
|
+
samples=samples,
|
|
53
|
+
stdev=stdev_z,
|
|
54
|
+
timescale=tau,
|
|
55
|
+
initial_condition="stationary",
|
|
56
|
+
covariance=covariance,
|
|
57
|
+
mixing_matrix=mixing_matrix,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
I = res_I["X"] # (samples, N, K) = ∫_0^t Z(s) ds (approx.)
|
|
61
|
+
t = res_I["t"] # (K,)
|
|
62
|
+
N_res = res_I["N"]
|
|
63
|
+
|
|
64
|
+
mu = _as_vector(mu, N_res, "mu")
|
|
65
|
+
sigma = _as_vector(sigma, N_res, "sigma")
|
|
66
|
+
|
|
67
|
+
if initial_condition is None:
|
|
68
|
+
x0 = np.ones(N_res, dtype=float)
|
|
69
|
+
else:
|
|
70
|
+
x0 = _as_vector(initial_condition, N_res, "initial_condition")
|
|
71
|
+
|
|
72
|
+
# X_i(t) = x0_i * exp(mu_i t + sigma_i I_i(t))
|
|
73
|
+
tt = t[None, None, :] # (1, 1, K)
|
|
74
|
+
X = x0[None, :, None] * np.exp(mu[None, :, None] * tt + sigma[None, :, None] * I)
|
|
75
|
+
|
|
76
|
+
res_I["X"] = X
|
|
77
|
+
res_I["mu"] = mu
|
|
78
|
+
res_I["sigma"] = sigma
|
|
79
|
+
res_I["tau"] = tau
|
|
80
|
+
res_I["initial_condition"] = x0
|
|
81
|
+
return res_I
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy.special import logsumexp
|
|
3
|
+
|
|
4
|
+
from ._utils import _as_vector, _simplex_initial_condition
|
|
5
|
+
from .integrated_ornstein_uhlenbeck import integrated_ornstein_uhlenbeck
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def colored_replicator(
|
|
9
|
+
T,
|
|
10
|
+
dt=None,
|
|
11
|
+
*,
|
|
12
|
+
steps=None,
|
|
13
|
+
N=2,
|
|
14
|
+
mu=1.0,
|
|
15
|
+
sigma=1.0,
|
|
16
|
+
tau=1.0,
|
|
17
|
+
initial_condition=None,
|
|
18
|
+
gap=1,
|
|
19
|
+
samples=1,
|
|
20
|
+
covariance=None,
|
|
21
|
+
mixing_matrix=None,
|
|
22
|
+
):
|
|
23
|
+
r"""
|
|
24
|
+
Simulate the colored stochastic replicator (softmax-normalized colored log-process).
|
|
25
|
+
|
|
26
|
+
Model:
|
|
27
|
+
dX_i(t) = mu_i X_i(t) dt + sigma_i X_i(t) Z_i(t) dt
|
|
28
|
+
tau_i dZ_i(t) = -Z_i(t) dt + dW_i(t)
|
|
29
|
+
|
|
30
|
+
With I_i(t) = ∫_0^t Z_i(s) ds:
|
|
31
|
+
X_i(t) = X_i(0) * exp(mu_i t + sigma_i I_i(t))
|
|
32
|
+
Y_i(t) = X_i(t) / sum_j X_j(t)
|
|
33
|
+
|
|
34
|
+
Notes
|
|
35
|
+
-----
|
|
36
|
+
The underlying OU Z is started at zero (not stationary) to make comparisons to
|
|
37
|
+
Wiener-driven models consistent as tau -> 0.
|
|
38
|
+
|
|
39
|
+
Returns
|
|
40
|
+
-------
|
|
41
|
+
dict with keys including:
|
|
42
|
+
- "X": (samples, N, K) replicator trajectory on the saved grid
|
|
43
|
+
- "t": (K,) time grid (subsampled by gap)
|
|
44
|
+
- plus metadata from integrated_ornstein_uhlenbeck, and "mu","sigma","tau","initial_condition"
|
|
45
|
+
"""
|
|
46
|
+
N = int(N)
|
|
47
|
+
if N < 2:
|
|
48
|
+
raise ValueError(f"colored_replicator requires N>=2, got N={N}.")
|
|
49
|
+
|
|
50
|
+
gap = int(gap)
|
|
51
|
+
if gap <= 0:
|
|
52
|
+
raise ValueError("gap must be a positive integer.")
|
|
53
|
+
|
|
54
|
+
samples = int(samples)
|
|
55
|
+
if samples <= 0:
|
|
56
|
+
raise ValueError("samples must be a positive integer.")
|
|
57
|
+
|
|
58
|
+
mu = _as_vector(mu, N, "mu")
|
|
59
|
+
sigma = _as_vector(sigma, N, "sigma")
|
|
60
|
+
tau = _as_vector(tau, N, "tau")
|
|
61
|
+
if np.any(tau <= 0):
|
|
62
|
+
raise ValueError("tau must be > 0 (component-wise).")
|
|
63
|
+
|
|
64
|
+
# Replicator starts on simplex
|
|
65
|
+
x0 = _simplex_initial_condition(initial_condition, N=N)
|
|
66
|
+
|
|
67
|
+
# Choose OU stdev so that tau dZ = -Z dt + dW <=> stationary stdev(Z) = 1/sqrt(2*tau)
|
|
68
|
+
# (even though we start Z(0)=0, this keeps the intended tau-scaling of the colored noise)
|
|
69
|
+
stdev_z = 1.0 / np.sqrt(2.0 * tau)
|
|
70
|
+
|
|
71
|
+
# I(t) = ∫ Z ds (computed on fine grid internally; returned on gap-grid)
|
|
72
|
+
# We intentionally start OU at zero for comparability (initial_condition=None).
|
|
73
|
+
res_I = integrated_ornstein_uhlenbeck(
|
|
74
|
+
T,
|
|
75
|
+
dt,
|
|
76
|
+
steps=steps,
|
|
77
|
+
gap=gap,
|
|
78
|
+
N=N,
|
|
79
|
+
samples=samples,
|
|
80
|
+
stdev=stdev_z,
|
|
81
|
+
timescale=tau,
|
|
82
|
+
initial_condition=None,
|
|
83
|
+
covariance=covariance,
|
|
84
|
+
mixing_matrix=mixing_matrix,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
I = res_I["X"] # (samples, N, K)
|
|
88
|
+
t = res_I["t"] # (K,)
|
|
89
|
+
|
|
90
|
+
# log X_i(t) = log x0_i + mu_i t + sigma_i I_i(t)
|
|
91
|
+
# Normalize with logsumexp for numerical stability.
|
|
92
|
+
with np.errstate(divide="ignore"):
|
|
93
|
+
logx0 = np.where(x0 > 0, np.log(x0), -np.inf) # (N,)
|
|
94
|
+
|
|
95
|
+
logX = (
|
|
96
|
+
logx0[None, :, None]
|
|
97
|
+
+ mu[None, :, None] * t[None, None, :]
|
|
98
|
+
+ sigma[None, :, None] * I
|
|
99
|
+
) # (samples, N, K)
|
|
100
|
+
|
|
101
|
+
log_denom = logsumexp(logX, axis=1, keepdims=True) # (samples, 1, K)
|
|
102
|
+
Y = np.exp(logX - log_denom) # (samples, N, K)
|
|
103
|
+
|
|
104
|
+
res = dict(res_I)
|
|
105
|
+
res["X"] = Y
|
|
106
|
+
res["mu"] = mu
|
|
107
|
+
res["sigma"] = sigma
|
|
108
|
+
res["tau"] = tau
|
|
109
|
+
res["initial_condition"] = x0
|
|
110
|
+
return res
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from math import inf
|
|
3
|
+
from math import isinf
|
|
4
|
+
from scipy.integrate import odeint
|
|
5
|
+
from scipy.special import logsumexp
|
|
6
|
+
|
|
7
|
+
def clv_particle_dynamics(n0,T,alpha,beta,omega,
|
|
8
|
+
dt = 1,
|
|
9
|
+
sigma = False,
|
|
10
|
+
A = None,
|
|
11
|
+
timescale = 1):
|
|
12
|
+
|
|
13
|
+
N = len(n0)
|
|
14
|
+
t = [0]
|
|
15
|
+
X = [tuple(n0)]
|
|
16
|
+
n = n0.copy()
|
|
17
|
+
|
|
18
|
+
if sigma is not False:
|
|
19
|
+
alpha = exponential_ornstein_uhlenbeck(T = T, dt = dt, coeff_var = sigma, mixing_matrix = A, mean = alpha, timescale = timescale)['X'][0]
|
|
20
|
+
|
|
21
|
+
while t[-1] < T:
|
|
22
|
+
|
|
23
|
+
beta_i = n * (beta@n)/omega
|
|
24
|
+
|
|
25
|
+
if np.ndim(alpha)>1:
|
|
26
|
+
|
|
27
|
+
alpha_i = n[:,None] * alpha
|
|
28
|
+
rtot = np.sum(alpha_i, axis = 0) + np.sum(beta_i)
|
|
29
|
+
if len(rtot) == 1: break
|
|
30
|
+
tau = stochastic_tau(rtot, dt)
|
|
31
|
+
t.append(t[-1]+(1+tau)*dt)
|
|
32
|
+
alpha_t = alpha_i[:,tau]
|
|
33
|
+
rtot = rtot[tau]
|
|
34
|
+
alpha = alpha[:,(1+tau):]
|
|
35
|
+
|
|
36
|
+
else:
|
|
37
|
+
|
|
38
|
+
alpha_i = n * alpha
|
|
39
|
+
rtot = np.sum(alpha_i) + np.sum(beta_i)
|
|
40
|
+
tau = np.random.exponential(1.0/rtot)
|
|
41
|
+
t.append(t[-1]+tau)
|
|
42
|
+
alpha_t = alpha_i
|
|
43
|
+
|
|
44
|
+
P1 = (alpha_t+beta_i)/rtot
|
|
45
|
+
selected_species = np.random.choice(range(N),p = P1)
|
|
46
|
+
P2 = alpha_t[selected_species]/(alpha_t[selected_species]+beta_i[selected_species])
|
|
47
|
+
|
|
48
|
+
if (np.random.rand() < P2):
|
|
49
|
+
n[selected_species]+=1
|
|
50
|
+
else:
|
|
51
|
+
n[selected_species]-=1
|
|
52
|
+
|
|
53
|
+
X.append(tuple(n))
|
|
54
|
+
|
|
55
|
+
return (t,X)
|
|
56
|
+
|
|
57
|
+
def clv_diffusion_approximation(n0,T,alpha,beta,omega,dt):
|
|
58
|
+
|
|
59
|
+
N = len(n0)
|
|
60
|
+
t = [0]
|
|
61
|
+
X = [tuple(n0)]
|
|
62
|
+
n = list(n0)
|
|
63
|
+
|
|
64
|
+
while t[-1] < T:
|
|
65
|
+
beta_i = (beta@n)/omega
|
|
66
|
+
alpha_mean = np.sum(n*alpha)/omega
|
|
67
|
+
|
|
68
|
+
W = np.random.normal(size=N)
|
|
69
|
+
dn = n*(alpha-beta_i)*dt + np.sqrt(n*(alpha+beta_i)*dt)*W;
|
|
70
|
+
n += dn;
|
|
71
|
+
n[n<0] = 0
|
|
72
|
+
t.append(t[-1]+dt)
|
|
73
|
+
X.append(tuple(n))
|
|
74
|
+
|
|
75
|
+
return (t,X)
|
|
76
|
+
|
|
77
|
+
def competitive_lotka_volterra(T,x0,alpha,beta,system_size,
|
|
78
|
+
diffusion_approximation=False,
|
|
79
|
+
normalize=False,
|
|
80
|
+
samples=1,
|
|
81
|
+
dt = 1,
|
|
82
|
+
sigma = False,
|
|
83
|
+
A = None,
|
|
84
|
+
timescale = 1
|
|
85
|
+
):
|
|
86
|
+
|
|
87
|
+
"""
|
|
88
|
+
Generates realizations of the multispecies, stochasic competitve Lokta-Volterra Model
|
|
89
|
+
of a population of individuals of M different species
|
|
90
|
+
that replicate and compete to the following reaction scheme:
|
|
91
|
+
.. math::
|
|
92
|
+
X_i \rightarrow 2X_i \quad \text{at rate} \quad \alpha_i
|
|
93
|
+
|
|
94
|
+
.. math::
|
|
95
|
+
X_i + X_j \rightarrow 2X_j \quad \text{at rate} \quad \beta_{ij}/Omega
|
|
96
|
+
|
|
97
|
+
The parameter Omega is the system size
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
T : float
|
|
101
|
+
Time interval.
|
|
102
|
+
x0: numpy.ndarray of shape (``M``,)
|
|
103
|
+
Initial state, depending on the state_variable keyword (below) this is abundance,
|
|
104
|
+
density, fraction, or capacitance
|
|
105
|
+
alpha : numpy.ndarray of shape (``N``,)
|
|
106
|
+
Replication rates of species
|
|
107
|
+
beta : numpy.ndarray of shape (``N``,``N``)
|
|
108
|
+
Competition rate between species
|
|
109
|
+
system_size : int or inf
|
|
110
|
+
System size, i.e. a measure for the approximate total abundance of species,
|
|
111
|
+
it's the normalization of the competitive rate that makes sure beta is of the order of unity
|
|
112
|
+
If inf deterministic solution is computed, required additional keyword dt to be set
|
|
113
|
+
diffusion_approximation: True/False, default is False
|
|
114
|
+
If True computes the realizations of the diffusion approximation process for the system
|
|
115
|
+
requires to set keyword dt
|
|
116
|
+
normalize : boolean, default = False
|
|
117
|
+
If True normalizes state variable with system_size parameter Omega
|
|
118
|
+
samples : int, default = 1
|
|
119
|
+
The number of samples generated, ignored if system_size = inf (deterministic system)
|
|
120
|
+
dt: float
|
|
121
|
+
Needs to be specified if the diffusion approximation is used. This is the time increment in that
|
|
122
|
+
case
|
|
123
|
+
|
|
124
|
+
Returns
|
|
125
|
+
-------
|
|
126
|
+
result : numpy.ndarray:
|
|
127
|
+
each array element is a sample, each sample is a tuple (t,X) or time array t and array of state vectors X.
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
"""
|
|
131
|
+
if isinf(system_size):
|
|
132
|
+
t = np.linspace(0,T,int(T/dt))
|
|
133
|
+
ode1 = lambda x,t,a,b : x*(a-b@x)
|
|
134
|
+
sol = odeint(lambda x,t,a,b : x*(a-b@x),x0,t,args=(alpha,beta))
|
|
135
|
+
X = [tuple(v) for v in sol]
|
|
136
|
+
return (t,X)
|
|
137
|
+
|
|
138
|
+
if normalize:
|
|
139
|
+
n0 = system_size*x0
|
|
140
|
+
n0 = n0.astype(int)
|
|
141
|
+
else:
|
|
142
|
+
n0 = x0
|
|
143
|
+
|
|
144
|
+
if diffusion_approximation:
|
|
145
|
+
res = [clv_diffusion_approximation(n0,T,alpha,beta,system_size,dt) for i in range(samples)]
|
|
146
|
+
else:
|
|
147
|
+
res = [clv_particle_dynamics(n0,T,alpha,beta,system_size, dt, sigma, A, timescale) for i in range(samples)]
|
|
148
|
+
|
|
149
|
+
if normalize:
|
|
150
|
+
for i in range(len(res)):
|
|
151
|
+
res[i]=(res[i][0],[tuple(np.array(v)/system_size) for v in res[i][1]])
|
|
152
|
+
|
|
153
|
+
return res
|
|
154
|
+
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from ._utils import _as_vector
|
|
4
|
+
from .ornstein_uhlenbeck import ornstein_uhlenbeck
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def exponential_ornstein_uhlenbeck(
|
|
8
|
+
T,
|
|
9
|
+
dt=None,
|
|
10
|
+
*,
|
|
11
|
+
steps=None,
|
|
12
|
+
gap=1,
|
|
13
|
+
N=1,
|
|
14
|
+
samples=1,
|
|
15
|
+
mean=1.0,
|
|
16
|
+
coeff_var=1.0,
|
|
17
|
+
timescale=1.0,
|
|
18
|
+
initial_condition=None,
|
|
19
|
+
covariance=None,
|
|
20
|
+
mixing_matrix=None,
|
|
21
|
+
):
|
|
22
|
+
"""
|
|
23
|
+
Simulate an exponential Ornstein–Uhlenbeck (lognormal OU) process on [0, T].
|
|
24
|
+
|
|
25
|
+
Constructs X from an OU process Z via
|
|
26
|
+
|
|
27
|
+
X_i(t) = A_i * exp(B_i * Z_i(t)),
|
|
28
|
+
|
|
29
|
+
where Z has stationary mean 0 and stationary stdev 1 (OU timescale set by
|
|
30
|
+
`timescale`). A and B are chosen so that X has the specified `mean` and
|
|
31
|
+
coefficient of variation `coeff_var` (component-wise).
|
|
32
|
+
|
|
33
|
+
Provide exactly one of `dt` or `steps`. Noise correlation can be specified via
|
|
34
|
+
`covariance` or `mixing_matrix` (mutually exclusive). Use `gap>1` to subsample.
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
dict
|
|
39
|
+
The return dict from `ornstein_uhlenbeck`, with 'X' replaced by the
|
|
40
|
+
exponential transform and added keys: 'mean', 'coeff_var', 'A', 'B'.
|
|
41
|
+
"""
|
|
42
|
+
N = int(N)
|
|
43
|
+
if N <= 0:
|
|
44
|
+
raise ValueError("N must be a positive integer.")
|
|
45
|
+
|
|
46
|
+
mean = _as_vector(mean, N, "mean")
|
|
47
|
+
coeff_var = _as_vector(coeff_var, N, "coeff_var")
|
|
48
|
+
|
|
49
|
+
if np.any(mean <= 0):
|
|
50
|
+
raise ValueError("mean must be positive.")
|
|
51
|
+
if np.any(coeff_var < 0):
|
|
52
|
+
raise ValueError("coeff_var must be >= 0.")
|
|
53
|
+
if np.any(np.asarray(timescale, dtype=float) <= 0):
|
|
54
|
+
raise ValueError("timescale must be > 0.")
|
|
55
|
+
|
|
56
|
+
# For Z ~ N(0,1): CV^2 = exp(B^2) - 1 => B = sqrt(log(1 + CV^2))
|
|
57
|
+
# E[X] = A exp(B^2/2) => A = mean / exp(B^2/2) = mean / sqrt(1 + CV^2)
|
|
58
|
+
A = mean / np.sqrt(1.0 + coeff_var**2)
|
|
59
|
+
B = np.sqrt(np.log(1.0 + coeff_var**2))
|
|
60
|
+
|
|
61
|
+
# Underlying OU: keep stationary stdev=1 so A/B calibration is correct.
|
|
62
|
+
res = ornstein_uhlenbeck(
|
|
63
|
+
T,
|
|
64
|
+
dt,
|
|
65
|
+
steps=steps,
|
|
66
|
+
gap=gap,
|
|
67
|
+
N=N,
|
|
68
|
+
samples=samples,
|
|
69
|
+
initial_condition=initial_condition,
|
|
70
|
+
stdev=1.0,
|
|
71
|
+
timescale=timescale,
|
|
72
|
+
covariance=covariance,
|
|
73
|
+
mixing_matrix=mixing_matrix,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
Z = res["X"] # (samples, N, K)
|
|
77
|
+
res["X"] = A[None, :, None] * np.exp(B[None, :, None] * Z)
|
|
78
|
+
|
|
79
|
+
res["mean"] = mean
|
|
80
|
+
res["coeff_var"] = coeff_var
|
|
81
|
+
res["A"] = A
|
|
82
|
+
res["B"] = B
|
|
83
|
+
return res
|