pymodest 0.1.0__tar.gz

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.
pymodest-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Song Feng
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,398 @@
1
+ Metadata-Version: 2.4
2
+ Name: pymodest
3
+ Version: 0.1.0
4
+ Summary: Divide-and-conquer, module-wise parameter estimation for biological models written in Antimony
5
+ Author: Song Feng
6
+ License: MIT
7
+ Keywords: systems biology,parameter estimation,antimony,sbml,roadrunner
8
+ Classifier: Intended Audience :: Science/Research
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: numpy>=1.22
15
+ Requires-Dist: scipy>=1.9
16
+ Requires-Dist: pandas>=1.4
17
+ Requires-Dist: antimony>=2.13
18
+ Requires-Dist: libroadrunner>=2.4
19
+ Dynamic: license-file
20
+
21
+ # pyModEst
22
+
23
+ Divide-and-conquer parameter estimation for biological models.
24
+
25
+ pyModEst reads models written in [Antimony](https://tellurium.readthedocs.io/en/latest/antimony.html),
26
+ a declaration of which parameters to fit, and one or more experimental datasets,
27
+ then estimates the parameters **module by module**: each module's parameters are
28
+ optimized against only that module's measured variables, while every other
29
+ parameter is held fixed. The procedure cycles through the modules for a finite
30
+ number of loops.
31
+
32
+ ```
33
+ theta <- initial values
34
+ repeat up to max_loops times:
35
+ for each module m:
36
+ theta[m] <- argmin cost_m(theta[m] ; theta[not m] held fixed)
37
+ stop when the total cost stops improving
38
+ ```
39
+
40
+ One shared parameter set can be constrained by **several models** and **several
41
+ datasets** at once, which is the usual situation when wild-type and mutant
42
+ strains, or different experimental conditions, are described by related models.
43
+
44
+ ---
45
+
46
+ ## Documentation
47
+
48
+ Full reference in [`docs/`](docs/):
49
+
50
+ | | |
51
+ | --- | --- |
52
+ | [Concepts](docs/concepts.md) | what module-wise fitting is, when it helps, how it fails |
53
+ | [Configuration](docs/configuration.md) | the complete TOML reference |
54
+ | [Capabilities](docs/capabilities.md) | what the package does, each pinned by a test |
55
+ | [Optimizers](docs/optimizers.md) | the five backends, choosing one, adding one |
56
+ | [Python API](docs/api.md) | using pyModEst as a library |
57
+ | [Troubleshooting](docs/troubleshooting.md) | what goes wrong and what it means |
58
+ | [Examples](docs/examples/) | runnable scripts behind the documented output |
59
+
60
+ ## Why fit in modules
61
+
62
+ A joint fit over thirty parameters searches a thirty-dimensional space; a
63
+ module-wise fit searches several small ones in turn. Each module fit is small
64
+ enough for a global optimizer to solve reliably, and the biology usually
65
+ suggests the partition already: uptake parameters are constrained by upstream
66
+ metabolites, feedback constants by the species that does the inhibiting.
67
+
68
+ The trade-off is real and worth stating plainly. Because each module minimizes
69
+ its own objective rather than the joint one, the total cost is **not guaranteed
70
+ to decrease every loop** — modules are coupled through the shared model. pyModEst
71
+ handles this by tracking the best parameter set across the whole run and
72
+ returning that, not whatever the last loop happened to produce. If you want a
73
+ strictly monotone total, set `accept = "total"`, but read the warning under
74
+ [Acceptance policy](#acceptance-policy) first.
75
+
76
+ ## Installation
77
+
78
+ The project is managed with [uv](https://docs.astral.sh/uv/). From a clone:
79
+
80
+ ```bash
81
+ uv sync # create .venv and install from uv.lock, dev group included
82
+ ```
83
+
84
+ `uv sync` installs the exact versions recorded in `uv.lock`, so everyone gets
85
+ the same environment. Commands then run through `uv run`, which keeps the
86
+ environment up to date for you — no manual activation needed:
87
+
88
+ ```bash
89
+ uv run pymodest --version
90
+ uv run pytest
91
+ ```
92
+
93
+ To add or change a dependency, edit `pyproject.toml` and run `uv lock` (or
94
+ `uv add <package>`, which does both), then commit the updated `uv.lock`.
95
+
96
+ Plain pip works too, if you would rather not use uv:
97
+
98
+ ```bash
99
+ pip install -e . # runtime only
100
+ pip install -e . --group dev # with pytest
101
+ ```
102
+
103
+ Requires Python 3.11+ (the oldest version `libroadrunner` publishes wheels
104
+ for), and pulls in `antimony`, `libroadrunner`, `numpy`, `scipy` and `pandas`.
105
+
106
+ ## Quick start
107
+
108
+ ```bash
109
+ uv run pymodest template --out study.toml # a commented starter configuration
110
+ uv run pymodest validate study.toml # check models, data and modules agree
111
+ uv run pymodest fit study.toml # run the estimation
112
+ ```
113
+
114
+ Drop the `uv run` prefix if you have activated the environment yourself
115
+ (`source .venv/bin/activate`) or installed with pip.
116
+
117
+ Or from Python:
118
+
119
+ ```python
120
+ from pymodest import load_config, fit
121
+
122
+ config = load_config("study.toml")
123
+ result = fit(config)
124
+
125
+ print(result.parameter_table())
126
+ print(result.loop_summary())
127
+ result.save(config.output_dir)
128
+ ```
129
+
130
+ ## The configuration file
131
+
132
+ One TOML file describes the whole study. Every path in it is resolved relative
133
+ to the file itself.
134
+
135
+ ### Models
136
+
137
+ Several models may share one fitted parameter set. A parameter that exists in
138
+ only some of them is written only to those, and is therefore identified only by
139
+ their datasets.
140
+
141
+ ```toml
142
+ [[models]]
143
+ id = "wt"
144
+ antimony_file = "models/wt.ant"
145
+
146
+ [models.observables] # derived quantities, usable as module variables
147
+ Total = "A + B + C"
148
+
149
+ [[models]]
150
+ id = "feedback"
151
+ antimony_file = "models/feedback.ant" # adds Ki; shares everything else
152
+ ```
153
+
154
+ `antimony = """..."""` may be given inline instead of `antimony_file`.
155
+ `[models.overrides]` pins values for one model only.
156
+
157
+ ### Datasets
158
+
159
+ Each dataset is measured on one model, under its own conditions.
160
+
161
+ ```toml
162
+ [[datasets]]
163
+ id = "wt_low"
164
+ model = "wt"
165
+ file = "data/wt_low.csv"
166
+ format = "wide" # wide: time,A,B,... long: time,variable,value
167
+ weight = 1.0
168
+
169
+ [datasets.conditions] # parameters set for this experiment
170
+ inducer = 0.0
171
+
172
+ [datasets.initial_conditions] # species starting values
173
+ S = 3.0
174
+ ```
175
+
176
+ **Wide** files have one column per variable; a matching `A_sigma` (or `_sd`,
177
+ `_std`, `_err`) column supplies measurement errors. **Long** files have
178
+ `time, variable, value` and an optional `sigma`. Missing values are dropped per
179
+ variable, so variables need not share a measurement schedule. Data may also be
180
+ given inline under `[datasets.data]`.
181
+
182
+ ### Modules
183
+
184
+ A module declares which parameters are fitted together and which measured
185
+ variables score them. **The modules must partition the parameter set** — every
186
+ fitted parameter belongs to exactly one module — which pyModEst checks at load
187
+ time.
188
+
189
+ ```toml
190
+ [[modules]]
191
+ id = "upstream"
192
+ variables = ["A", "B"] # what this module is scored on
193
+ # datasets = ["wt_low"] # optional: restrict to particular experiments
194
+ # [modules.weights] # optional: per-variable weights
195
+ # A = 2.0
196
+
197
+ [[modules.parameters]]
198
+ name = "Vmax1"
199
+ lower = 0.05
200
+ upper = 50.0
201
+ init = 1.0
202
+ scale = "log" # log | linear; log searches log10(value)
203
+ # fixed = true # hold at init and exclude from the search
204
+
205
+ [modules.optimizer] # optional per-module optimizer
206
+ name = "scatter_search"
207
+ maxiter = 25
208
+ ```
209
+
210
+ Use `scale = "log"` for rate constants and affinities spanning orders of
211
+ magnitude — it is usually the difference between a fit that converges and one
212
+ that does not.
213
+
214
+ ### Fitting
215
+
216
+ ```toml
217
+ [fitting]
218
+ max_loops = 8
219
+ module_order = "as_listed" # or a list of ids, "random", "round_robin_reversed"
220
+ tol = 1e-3 # relative improvement counting as progress
221
+ atol = 1e-12 # absolute floor, so a cost heading to zero terminates
222
+ patience = 2 # loops without progress before stopping
223
+ accept = "module" # module | total
224
+ seed = 7
225
+
226
+ [fitting.optimizer]
227
+ name = "differential_evolution"
228
+ maxiter = 40
229
+ popsize = 12
230
+
231
+ [fitting.objective]
232
+ scaling = "relative" # relative | absolute | sigma | max_normalized
233
+ aggregation = "mean" # mean | sum
234
+ epsilon = 1e-3
235
+
236
+ [fitting.simulation]
237
+ integrator = "cvode"
238
+ relative_tolerance = 1e-8
239
+ absolute_tolerance = 1e-10
240
+ ```
241
+
242
+ ## Residual scaling
243
+
244
+ Every residual is `simulated - observed`, then scaled:
245
+
246
+ | `scaling` | residual | use when |
247
+ | ----------------- | ---------------------------- | -------- |
248
+ | `relative` | `diff / (abs(obs) + epsilon)` | variables differ in magnitude (**default**) |
249
+ | `absolute` | `diff` | all variables share units and scale |
250
+ | `sigma` | `diff / sigma` | you measured errors — gives chi-square residuals |
251
+ | `max_normalized` | `diff / max(abs(obs))` | relative scaling is unstable near zero |
252
+
253
+ With `relative`, raise `epsilon` above the noise level of near-zero
254
+ measurements; otherwise an observation of 0.001 dominates the objective.
255
+ Residuals are then multiplied by `sqrt(dataset weight x variable weight)` and
256
+ aggregated by mean (default) or sum of squares.
257
+
258
+ ## Optimizers
259
+
260
+ Named in `[fitting.optimizer]` or per module in `[modules.optimizer]`. Any key
261
+ other than `name` is passed straight to the backend.
262
+
263
+ | name | what it is | notable options |
264
+ | ---- | ---------- | --------------- |
265
+ | `differential_evolution` | bounded global search (SciPy) — robust default | `maxiter`, `popsize`, `polish`, `workers` |
266
+ | `scatter_search` | population search with reference-set update and local refinement | `refset_size`, `maxiter`, `max_nfev`, `local_search` |
267
+ | `particle_swarm` | swarm with inertia damping and reflecting bounds | `n_particles`, `maxiter`, `inertia`, `cognitive`, `social` |
268
+ | `least_squares` | local Trust Region Reflective on the residual vector | `max_nfev`, `loss`, `diff_step` |
269
+ | `minimize` | SciPy local minimizers | `method` (`L-BFGS-B`, `Nelder-Mead`, `Powell`, ...) |
270
+
271
+ Aliases (`de`, `pso`, `ss`, `trf`, `nelder_mead`) work too. Register your own:
272
+
273
+ ```python
274
+ from pymodest.optimizers import register, OptimizerResult
275
+
276
+ @register("my_method")
277
+ def my_method(objective, x0, bounds, rng, **options):
278
+ ... # objective(x) -> cost; objective.residuals(x) -> vector
279
+ return OptimizerResult(x=best_x, fun=best_cost, nfev=n)
280
+ ```
281
+
282
+ The local methods (`least_squares`, `minimize`) only refine where they start.
283
+ Use them for a module whose parameters are already close, or after a global
284
+ pass; on their own they will sit in whatever basin the initial values fall in.
285
+
286
+ ### Acceptance policy
287
+
288
+ `accept = "module"` (default) keeps a module fit whenever that module's own cost
289
+ improved — the plain divide-and-conquer rule. `accept = "total"` additionally
290
+ requires that the total cost across all modules did not rise.
291
+
292
+ Monotone sounds safer, but it can stall the search: an early step that improves
293
+ one module while temporarily worsening another is often exactly the step needed
294
+ to escape a poor starting region. In the shipped example, `"total"` gets stuck
295
+ at a cost of 0.86 while `"module"` reaches 0.060. Prefer the default unless you
296
+ have a specific reason.
297
+
298
+ ## What a run produces
299
+
300
+ `result.save(directory)` writes:
301
+
302
+ | file | contents |
303
+ | ---- | -------- |
304
+ | `best_parameters.toml` | the estimates, ready to feed back to `pymodest simulate` |
305
+ | `parameters.csv` | initial value beside final estimate |
306
+ | `history.csv` | one row per module fit: costs before/after, evaluations, timing |
307
+ | `loop_summary.csv` | one row per loop: total and per-module costs |
308
+ | `fit_report.json` | the complete record, including every step |
309
+ | `predictions_<id>.csv` | simulated trace beside the observations, per dataset |
310
+
311
+ In Python, `FitResult` exposes `parameters`, `cost`, `module_costs`, `loops`,
312
+ `converged`, `stop_reason`, and the DataFrames `history()`, `loop_summary()`
313
+ and `parameter_table()`.
314
+
315
+ ## Worked example
316
+
317
+ `examples/two_module_pathway/` fits a linear pathway
318
+
319
+ ```
320
+ S --J1--> A --J2--> B --J3--> C --J4--> out
321
+ ```
322
+
323
+ with **two models sharing one parameter set** — the wild type, and a strain in
324
+ which C inhibits its own uptake (adding `Ki`, which only that strain's data can
325
+ identify) — against **three datasets** at two substrate concentrations.
326
+ Parameters split into an upstream module (`Vmax1, Km1, k2`, scored on A and B)
327
+ and a downstream module (`Vmax3, Km3, k4, Ki`, scored on C).
328
+
329
+ ```bash
330
+ cd examples/two_module_pathway
331
+ uv run python generate_data.py # regenerate the synthetic measurements
332
+ uv run pymodest fit config.toml
333
+ ```
334
+
335
+ The data carry 4% proportional noise. Evaluated at the generating parameters the
336
+ total cost is **0.0469** — the noise floor, the best any fit could do. Starting
337
+ from cost 237, the run converges in about 7 seconds to:
338
+
339
+ | | cost | mean abs. log10 error |
340
+ | --- | --- | --- |
341
+ | `differential_evolution` | 0.0604 | 0.066 |
342
+ | `scatter_search` | 0.0604 | 0.067 |
343
+ | `particle_swarm` | 0.0605 | 0.066 |
344
+
345
+ All three independent optimizers land on the same answer, which is good evidence
346
+ the estimator finds the true optimum of the module-wise objective. The small gap
347
+ to the noise floor is inherent to the method, not a failure to converge: each
348
+ module minimizes its own objective, so the fixed point differs slightly from the
349
+ joint least-squares optimum. `Ki` and `k4` are recovered closely; the `Vmax`/`Km`
350
+ pairs remain correlated at this noise level, as they would in any fit.
351
+
352
+ ## Practical notes
353
+
354
+ - **Choose modules by what the data constrain.** A module's variables should
355
+ respond to its parameters more strongly than to the rest. If two parameters
356
+ are only identifiable together, put them in the same module.
357
+ - **Coupled modules need more loops.** The example's feedback strain makes the
358
+ upstream variables depend on a downstream parameter; that coupling is what the
359
+ repeated loops resolve.
360
+ - **Watch `loop_summary.csv`.** A total cost that oscillates rather than settles
361
+ means the partition is fighting itself — merge the modules that trade against
362
+ each other, or widen the variables scoring one of them.
363
+ - **A failed integration is scored, not raised.** Parameter sets that make the
364
+ ODEs unsolvable get a large finite cost, so a global search can step over them.
365
+ - **Set `seed`** in `[fitting]` for reproducible runs; every backend derives its
366
+ randomness from it.
367
+
368
+ ## Development
369
+
370
+ ```bash
371
+ uv sync
372
+ uv run pytest # 118 tests
373
+ ```
374
+
375
+ `uv.lock` is committed so results are reproducible. It resolves for every
376
+ platform at once; the pinned `antimony` and `libroadrunner` wheels cover macOS
377
+ (Intel and Apple silicon), Windows, and Linux on x86-64. Linux on arm64 is not
378
+ covered by the current `antimony` release, so on that platform install without
379
+ the lock (`uv pip install -e .`), which falls back to the newest version that
380
+ does publish an arm64 wheel.
381
+
382
+ Layout:
383
+
384
+ ```
385
+ src/pymodest/
386
+ config.py TOML schema, dataclasses, validation
387
+ model.py Antimony -> SBML -> roadrunner, with result caching
388
+ data.py wide/long experiment tables
389
+ objective.py residual assembly, scaling, per-module objectives
390
+ estimator.py the divide-and-conquer loop
391
+ result.py records, DataFrames, on-disk report
392
+ cli.py the pymodest command
393
+ optimizers/ registry, SciPy backends, particle swarm, scatter search
394
+ ```
395
+
396
+ ## License
397
+
398
+ MIT.