nomass 1.0.0__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.
nomass/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """
2
+ NoMaSS: Non-Markovian Stochastic Simulation.
3
+
4
+ A Gillespie-based framework to simulate reaction networks whose inter-event
5
+ times follow arbitrary distributions, mixing three exact methods per reaction
6
+ channel: MOSAIC (rejection), Laplace Gillespie and DelaySSA.
7
+
8
+ Quick start::
9
+
10
+ import nomass
11
+
12
+ r1 = nomass.Reaction_channel('A -> B', rate=1, shape_param=20, distribution='Gamma')
13
+ sim = nomass.Gillespie_simulation({'A': 300, 'B': 0}, [r1], Tend=10, N_simulations=20, unit='h')
14
+ populations = sim.run_simulations()
15
+ sim.plot_populations()
16
+
17
+ Reference: Pelissier A., Phan M., Beerenwinkel N., Rodriguez Martinez M.
18
+ "Gillespie-based simulation and inference for non-Markovian stochastic reaction
19
+ networks", Briefings in Bioinformatics 27(5), bbag486 (2026). doi:10.1093/bib/bbag486
20
+ """
21
+
22
+ from .simulation import Reaction_channel, Reactant, Gillespie_simulation, parse_reaction, PALETTE
23
+ from .distributions import DISTRIBUTIONS, METHODS
24
+ from . import distributions
25
+
26
+ __version__ = '1.0.0'
27
+
28
+ __all__ = ['Reaction_channel', 'Reactant', 'Gillespie_simulation', 'parse_reaction',
29
+ 'distributions', 'DISTRIBUTIONS', 'METHODS', 'PALETTE', '__version__']
@@ -0,0 +1,437 @@
1
+ """
2
+ Inter-event time distributions supported by NoMaSS.
3
+
4
+ Every distribution is parametrised by a nominal ``rate`` (the inverse of the
5
+ mean waiting time, or of the median when the mean is undefined) and a
6
+ dimensionless ``shape`` parameter. This module provides, for each distribution:
7
+
8
+ * ``hazard(t, rate, shape)`` instantaneous rate at time ``t`` since the
9
+ reactant entered the channel (used by MOSAIC)
10
+ * ``pdf(t, rate, shape)`` theoretical density (used for plotting)
11
+ * ``sample(rate, shape, size)`` random waiting times (used by DelaySSA)
12
+ * ``laplace_rate(rate, shape)`` a random Poisson rate drawn from the mixing
13
+ density of the Laplace Gillespie algorithm
14
+ (only for completely monotone survival
15
+ functions)
16
+
17
+ See Table S1 of Pelissier et al., Brief. Bioinform. 27(5), 2026 for the
18
+ parametrisations.
19
+ """
20
+
21
+ from math import log, gamma as gamma_fn, exp, pi, sqrt, erf, atan, sin, inf, isfinite
22
+
23
+ import numpy as np
24
+ import scipy.stats as stat
25
+ from scipy.special import gammainc
26
+
27
+ # --------------------------------------------------------------------------
28
+ # Names
29
+ # --------------------------------------------------------------------------
30
+
31
+ _ALIASES = {
32
+ 'exponential': 'Exponential', 'exp': 'Exponential', 'poisson': 'Exponential',
33
+ 'gamma': 'Gamma', 'gam': 'Gamma',
34
+ 'weibull': 'Weibull', 'weib': 'Weibull',
35
+ 'gaussian': 'Gaussian', 'normal': 'Gaussian', 'norm': 'Gaussian',
36
+ 'lognormal': 'Lognormal', 'lognorm': 'Lognormal', 'log-normal': 'Lognormal',
37
+ 'cauchy': 'Cauchy', 'cau': 'Cauchy',
38
+ 'pareto': 'Pareto', 'pareto1': 'Pareto', 'power-law': 'Pareto', 'powerlaw': 'Pareto',
39
+ 'lomax': 'Lomax', 'pareto2': 'Lomax',
40
+ }
41
+
42
+ DISTRIBUTIONS = ('Exponential', 'Gamma', 'Weibull', 'Gaussian', 'Lognormal',
43
+ 'Cauchy', 'Pareto', 'Lomax')
44
+
45
+ METHODS = ('MOSAIC', 'Laplace', 'Delay')
46
+
47
+ _METHOD_ALIASES = {
48
+ 'mosaic': 'MOSAIC', 'rejection': 'MOSAIC', 'regir': 'MOSAIC',
49
+ 'laplace': 'Laplace', 'laplace gillespie': 'Laplace', 'lga': 'Laplace',
50
+ 'delay': 'Delay', 'delayssa': 'Delay', 'delay-ssa': 'Delay', 'nrm': 'Delay',
51
+ }
52
+
53
+
54
+ def canonical_distribution(name):
55
+ """Return the canonical distribution name for any accepted alias."""
56
+ key = str(name).strip().lower()
57
+ if key not in _ALIASES:
58
+ raise ValueError(
59
+ "Unsupported distribution '%s'. Supported: %s" % (name, ', '.join(DISTRIBUTIONS)))
60
+ return _ALIASES[key]
61
+
62
+
63
+ def canonical_method(name):
64
+ """Return the canonical method name for any accepted alias."""
65
+ key = str(name).strip().lower()
66
+ if key not in _METHOD_ALIASES:
67
+ raise ValueError(
68
+ "Unsupported method '%s'. Supported: %s" % (name, ', '.join(METHODS)))
69
+ return _METHOD_ALIASES[key]
70
+
71
+
72
+ # --------------------------------------------------------------------------
73
+ # Parameter conversions (rate, shape) -> native scipy parameters
74
+ # --------------------------------------------------------------------------
75
+
76
+ def _gamma_params(rate, shape):
77
+ """Gamma with mean 1/rate and shape alpha: returns (alpha, scale)."""
78
+ return shape, 1.0 / (shape * rate)
79
+
80
+
81
+ def _weibull_params(rate, shape):
82
+ """Weibull with mean 1/rate and shape k: returns (k, scale)."""
83
+ return shape, 1.0 / (rate * gamma_fn(1.0 + 1.0 / shape))
84
+
85
+
86
+ def _gaussian_params(rate, shape):
87
+ """Normal with mean 1/rate and std = shape * mean: returns (mu, sigma)."""
88
+ mu = 1.0 / rate
89
+ return mu, shape * mu
90
+
91
+
92
+ def _lognormal_params(rate, shape):
93
+ """Lognormal with mean 1/rate and std = shape * mean: returns (mu, sigma) of log."""
94
+ sigma2 = log(1.0 + shape ** 2)
95
+ mu = log(1.0 / rate) - sigma2 / 2.0
96
+ return mu, sqrt(sigma2)
97
+
98
+
99
+ def _cauchy_params(rate, shape):
100
+ """Cauchy with median 1/rate and scale = shape * median: returns (x0, gamma)."""
101
+ x0 = 1.0 / rate
102
+ return x0, shape * x0
103
+
104
+
105
+ def _pareto_params(rate, shape):
106
+ """Pareto type I with median 1/rate and tail index alpha: returns (alpha, x_min)."""
107
+ return shape, 1.0 / (rate * 2.0 ** (1.0 / shape))
108
+
109
+
110
+ def _lomax_params(rate, shape):
111
+ """Lomax (Pareto II) with median 1/rate and tail index alpha: returns (alpha, kappa)."""
112
+ return shape, 1.0 / (rate * (2.0 ** (1.0 / shape) - 1.0))
113
+
114
+
115
+ def scipy_distribution(distribution, rate, shape):
116
+ """Return a frozen ``scipy.stats`` distribution for (distribution, rate, shape).
117
+
118
+ Gaussian and Cauchy are truncated to non-negative waiting times, which is
119
+ the distribution actually produced by a hazard-based simulation.
120
+ """
121
+ d = canonical_distribution(distribution)
122
+ if d == 'Exponential':
123
+ return stat.expon(scale=1.0 / rate)
124
+ if d == 'Gamma':
125
+ a, s = _gamma_params(rate, shape)
126
+ return stat.gamma(a, scale=s)
127
+ if d == 'Weibull':
128
+ k, s = _weibull_params(rate, shape)
129
+ return stat.weibull_min(k, scale=s)
130
+ if d == 'Gaussian':
131
+ mu, sigma = _gaussian_params(rate, shape)
132
+ return stat.truncnorm(-mu / sigma, inf, loc=mu, scale=sigma)
133
+ if d == 'Lognormal':
134
+ mu, sigma = _lognormal_params(rate, shape)
135
+ return stat.lognorm(sigma, scale=exp(mu))
136
+ if d == 'Cauchy':
137
+ x0, g = _cauchy_params(rate, shape)
138
+ return _TruncatedCauchy(x0, g)
139
+ if d == 'Pareto':
140
+ a, xm = _pareto_params(rate, shape)
141
+ return stat.pareto(a, scale=xm)
142
+ if d == 'Lomax':
143
+ a, kappa = _lomax_params(rate, shape)
144
+ return stat.lomax(a, scale=kappa)
145
+ raise ValueError(d)
146
+
147
+
148
+ class _TruncatedCauchy:
149
+ """Cauchy distribution conditioned on t >= 0 (minimal frozen-distribution API)."""
150
+
151
+ def __init__(self, x0, g):
152
+ self.x0, self.g = x0, g
153
+ self._F0 = stat.cauchy.cdf(0.0, loc=x0, scale=g)
154
+ self._norm = 1.0 - self._F0
155
+
156
+ def pdf(self, t):
157
+ t = np.asarray(t, dtype=float)
158
+ return np.where(t >= 0, stat.cauchy.pdf(t, self.x0, self.g) / self._norm, 0.0)
159
+
160
+ def sf(self, t):
161
+ t = np.asarray(t, dtype=float)
162
+ return np.where(t >= 0, stat.cauchy.sf(t, self.x0, self.g) / self._norm, 1.0)
163
+
164
+ def rvs(self, size=None, random_state=None):
165
+ rng = np.random if random_state is None else random_state
166
+ u = rng.uniform(self._F0, 1.0, size=size)
167
+ return stat.cauchy.ppf(u, self.x0, self.g)
168
+
169
+ def mean(self):
170
+ return inf
171
+
172
+
173
+ # --------------------------------------------------------------------------
174
+ # Hazard functions h(t) = pdf(t) / survival(t)
175
+ # (fast closed forms; used at every MOSAIC step)
176
+ # --------------------------------------------------------------------------
177
+
178
+ def exponential_hazard(t, rate, shape=None):
179
+ return rate
180
+
181
+
182
+ def gamma_hazard(t, rate, shape):
183
+ alpha = shape
184
+ if t <= 0:
185
+ return 0.0 if alpha > 1 else (rate if alpha == 1 else inf)
186
+ beta = alpha * rate
187
+ pdf = (beta ** alpha) * (t ** (alpha - 1)) * exp(-beta * t) / gamma_fn(alpha)
188
+ sf = 1.0 - gammainc(alpha, beta * t)
189
+ return pdf / sf if sf > 0 else inf
190
+
191
+
192
+ def weibull_hazard(t, rate, shape):
193
+ k, s = _weibull_params(rate, shape)
194
+ if t <= 0:
195
+ return 0.0 if k > 1 else (rate if k == 1 else inf)
196
+ return (k / s) * (t / s) ** (k - 1)
197
+
198
+
199
+ def gaussian_hazard(t, rate, shape):
200
+ mu, sigma = _gaussian_params(rate, shape)
201
+ if t > mu + 7 * sigma:
202
+ # asymptotic form: the cdf is numerically 1 in this region
203
+ return (t - mu) / sigma ** 2
204
+ pdf = exp(-0.5 * ((t - mu) / sigma) ** 2) / (sigma * sqrt(2 * pi))
205
+ sf = 0.5 * (1.0 - erf((t - mu) / (sigma * sqrt(2))))
206
+ return pdf / sf if sf > 0 else inf
207
+
208
+
209
+ def lognormal_hazard(t, rate, shape):
210
+ if t <= 0:
211
+ return 0.0
212
+ mu, sigma = _lognormal_params(rate, shape)
213
+ z = (log(t) - mu) / sigma
214
+ pdf = exp(-0.5 * z * z) / (t * sigma * sqrt(2 * pi))
215
+ sf = 0.5 * (1.0 - erf(z / sqrt(2)))
216
+ return pdf / sf if sf > 0 else inf
217
+
218
+
219
+ def cauchy_hazard(t, rate, shape):
220
+ x0, g = _cauchy_params(rate, shape)
221
+ pdf = 1.0 / (pi * g * (1.0 + ((t - x0) / g) ** 2))
222
+ sf = 0.5 - atan((t - x0) / g) / pi
223
+ return pdf / sf if sf > 0 else inf
224
+
225
+
226
+ def pareto_hazard(t, rate, shape):
227
+ a, xm = _pareto_params(rate, shape)
228
+ return a / t if t >= xm else 0.0
229
+
230
+
231
+ def lomax_hazard(t, rate, shape):
232
+ a, kappa = _lomax_params(rate, shape)
233
+ return a / (kappa + max(t, 0.0))
234
+
235
+
236
+ _HAZARDS = {
237
+ 'Exponential': exponential_hazard,
238
+ 'Gamma': gamma_hazard,
239
+ 'Weibull': weibull_hazard,
240
+ 'Gaussian': gaussian_hazard,
241
+ 'Lognormal': lognormal_hazard,
242
+ 'Cauchy': cauchy_hazard,
243
+ 'Pareto': pareto_hazard,
244
+ 'Lomax': lomax_hazard,
245
+ }
246
+
247
+
248
+ def hazard_function(distribution):
249
+ """Return the hazard function ``h(t, rate, shape)`` of a distribution."""
250
+ return _HAZARDS[canonical_distribution(distribution)]
251
+
252
+
253
+ def hazard(t, distribution, rate, shape):
254
+ """Evaluate the hazard with a slow but robust fallback for numerical overflow."""
255
+ h = _HAZARDS[canonical_distribution(distribution)](t, rate, shape)
256
+ if isfinite(h):
257
+ return h
258
+ frozen = scipy_distribution(distribution, rate, shape)
259
+ try:
260
+ h = float(np.exp(frozen.logpdf(t) - frozen.logsf(t)))
261
+ except AttributeError:
262
+ h = float(frozen.pdf(t) / frozen.sf(t))
263
+ return h
264
+
265
+
266
+ # --------------------------------------------------------------------------
267
+ # Hazard shape information used by MOSAIC to bound the rate
268
+ # --------------------------------------------------------------------------
269
+
270
+ def hazard_is_bounded(distribution, shape):
271
+ """True if the hazard is finite for all t >= 0 (requirement of MOSAIC)."""
272
+ d = canonical_distribution(distribution)
273
+ if d in ('Gamma', 'Weibull'):
274
+ return shape >= 1
275
+ return True
276
+
277
+
278
+ def hazard_is_increasing(distribution, shape):
279
+ """True if the hazard is monotonically non-decreasing in t.
280
+
281
+ For such distributions the maximum rate over a population is the hazard of
282
+ the reactant that has been waiting the longest. Lognormal hazards are not
283
+ monotone, but for a small relative standard deviation the maximum lies far
284
+ in the tail, so they are treated as increasing (see Pelissier et al.).
285
+ """
286
+ d = canonical_distribution(distribution)
287
+ if d in ('Gamma', 'Weibull'):
288
+ return shape >= 1
289
+ if d == 'Gaussian':
290
+ return True
291
+ if d == 'Lognormal':
292
+ return shape < 0.25
293
+ return False
294
+
295
+
296
+ def hazard_upper_bound(distribution, rate, shape, Tend):
297
+ """A fixed upper bound of the hazard on [0, Tend] for non-increasing hazards."""
298
+ d = canonical_distribution(distribution)
299
+ if d == 'Exponential':
300
+ return rate
301
+ if d == 'Pareto':
302
+ a, xm = _pareto_params(rate, shape)
303
+ return a / xm
304
+ if d == 'Lomax':
305
+ a, kappa = _lomax_params(rate, shape)
306
+ return a / kappa
307
+ # Cauchy, Lognormal with large shape: scan numerically
308
+ f = _HAZARDS[d]
309
+ t = np.linspace(0.0, Tend, num=1000)
310
+ values = np.array([f(ti, rate, shape) for ti in t])
311
+ values = values[np.isfinite(values)]
312
+ return float(np.max(values)) if len(values) else rate
313
+
314
+
315
+ # --------------------------------------------------------------------------
316
+ # Laplace Gillespie: completely monotone survival functions as mixtures of
317
+ # exponentials. psi(tau) = int p(lambda) exp(-lambda tau) dlambda
318
+ # --------------------------------------------------------------------------
319
+
320
+ def laplace_supported(distribution, shape):
321
+ """True if the distribution can be simulated with the Laplace Gillespie algorithm."""
322
+ d = canonical_distribution(distribution)
323
+ if d == 'Exponential' or d == 'Lomax':
324
+ return True
325
+ if d in ('Gamma', 'Weibull'):
326
+ return shape <= 1
327
+ return False
328
+
329
+
330
+ def _positive_stable(alpha, rng):
331
+ """Kanter's sampler of a positive stable variable S with E[exp(-u S)] = exp(-u^alpha)."""
332
+ u = rng.uniform(0.0, pi)
333
+ w = rng.exponential(1.0)
334
+ a = (sin((1.0 - alpha) * u) * sin(alpha * u) ** (alpha / (1.0 - alpha))
335
+ / sin(u) ** (1.0 / (1.0 - alpha)))
336
+ return (a / w) ** ((1.0 - alpha) / alpha)
337
+
338
+
339
+ def laplace_rate(distribution, rate, shape, rng=None):
340
+ """Draw a Poisson rate from the mixing density of the survival function.
341
+
342
+ * Exponential: deterministic ``rate``
343
+ * Gamma (alpha <= 1): lambda = beta / B, B ~ Beta(alpha, 1 - alpha), beta = alpha * rate
344
+ * Weibull (alpha <= 1): lambda = S / scale, S positive alpha-stable
345
+ * Lomax: lambda ~ Gamma(alpha, scale = 1 / kappa)
346
+ """
347
+ rng = np.random if rng is None else rng
348
+ d = canonical_distribution(distribution)
349
+ if d == 'Exponential' or ((d in ('Gamma', 'Weibull')) and shape == 1):
350
+ return float(rate)
351
+ if d == 'Gamma':
352
+ if shape > 1:
353
+ raise ValueError('Laplace Gillespie requires a Gamma shape parameter <= 1')
354
+ beta = shape * rate
355
+ return beta / rng.beta(shape, 1.0 - shape)
356
+ if d == 'Weibull':
357
+ if shape > 1:
358
+ raise ValueError('Laplace Gillespie requires a Weibull shape parameter <= 1')
359
+ _, s = _weibull_params(rate, shape)
360
+ return _positive_stable(shape, rng) / s
361
+ if d == 'Lomax':
362
+ a, kappa = _lomax_params(rate, shape)
363
+ return rng.gamma(a, 1.0 / kappa)
364
+ raise ValueError("The Laplace Gillespie method does not support the %s distribution" % d)
365
+
366
+
367
+ # --------------------------------------------------------------------------
368
+ # Sampling and theoretical density
369
+ # --------------------------------------------------------------------------
370
+
371
+ def sample(distribution, rate, shape, size=None, rng=None):
372
+ """Draw ``size`` waiting times (a float if ``size`` is None)."""
373
+ frozen = scipy_distribution(distribution, rate, shape)
374
+ out = frozen.rvs(size=1 if size is None else size, random_state=rng)
375
+ return float(out[0]) if size is None else np.asarray(out, dtype=float)
376
+
377
+
378
+ def pdf(t, distribution, rate, shape):
379
+ """Theoretical probability density of the waiting time at ``t``."""
380
+ return scipy_distribution(distribution, rate, shape).pdf(np.asarray(t, dtype=float))
381
+
382
+
383
+ def mean(distribution, rate, shape):
384
+ """Theoretical mean waiting time (inf when undefined)."""
385
+ return float(scipy_distribution(distribution, rate, shape).mean())
386
+
387
+
388
+ def fit(distribution, samples):
389
+ """Fit the native scipy parameters to observed waiting times (for plotting)."""
390
+ d = canonical_distribution(distribution)
391
+ samples = np.asarray(samples, dtype=float)
392
+ if d == 'Exponential':
393
+ P = stat.expon.fit(samples, floc=0)
394
+ return P, lambda t: stat.expon.pdf(t, *P)
395
+ if d == 'Gamma':
396
+ P = stat.gamma.fit(samples, floc=0)
397
+ return P, lambda t: stat.gamma.pdf(t, *P)
398
+ if d == 'Weibull':
399
+ P = stat.weibull_min.fit(samples, floc=0)
400
+ return P, lambda t: stat.weibull_min.pdf(t, *P)
401
+ if d == 'Gaussian':
402
+ P = stat.norm.fit(samples)
403
+ return P, lambda t: stat.norm.pdf(t, *P)
404
+ if d == 'Lognormal':
405
+ P = stat.lognorm.fit(samples, floc=0)
406
+ return P, lambda t: stat.lognorm.pdf(t, *P)
407
+ if d == 'Cauchy':
408
+ P = stat.cauchy.fit(samples)
409
+ return P, lambda t: stat.cauchy.pdf(t, *P)
410
+ if d == 'Pareto':
411
+ P = stat.pareto.fit(samples, floc=0)
412
+ return P, lambda t: stat.pareto.pdf(t, *P)
413
+ if d == 'Lomax':
414
+ P = stat.lomax.fit(samples, floc=0)
415
+ return P, lambda t: stat.lomax.pdf(t, *P)
416
+ raise ValueError(d)
417
+
418
+
419
+ def default_method(distribution, shape):
420
+ """Pick the recommended simulation method for a distribution.
421
+
422
+ MOSAIC whenever the hazard is bounded, Laplace Gillespie for the heavy
423
+ early-time hazards of Gamma/Weibull with shape < 1.
424
+ """
425
+ if hazard_is_bounded(distribution, shape):
426
+ return 'MOSAIC'
427
+ return 'Laplace'
428
+
429
+
430
+ def method_supported(method, distribution, shape):
431
+ """Check whether ``method`` can simulate ``distribution`` with this shape."""
432
+ m = canonical_method(method)
433
+ if m == 'MOSAIC':
434
+ return hazard_is_bounded(distribution, shape)
435
+ if m == 'Laplace':
436
+ return laplace_supported(distribution, shape)
437
+ return True # Delay works for every distribution