PyAntiGen 1.0.5__tar.gz → 1.0.7__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.
- {pyantigen-1.0.5 → pyantigen-1.0.7}/PKG-INFO +1 -1
- {pyantigen-1.0.5 → pyantigen-1.0.7}/PyAntiGen.egg-info/PKG-INFO +1 -1
- {pyantigen-1.0.5 → pyantigen-1.0.7}/PyAntiGen.egg-info/SOURCES.txt +2 -0
- pyantigen-1.0.7/framework/template/Example/Engine/Evaluator.py +521 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Engine/Model_optimize.py +84 -7
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Engine/Optimize.py +1517 -290
- pyantigen-1.0.7/framework/template/Example/Engine/Profile_checkpoint.py +206 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Engine/Results.py +16 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Engine/Sensitivity_analysis.py +23 -14
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Model_run.py +4 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Optimizer_settings.py +5 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Solver_settings.py +2 -1
- {pyantigen-1.0.5 → pyantigen-1.0.7}/pyproject.toml +1 -1
- {pyantigen-1.0.5 → pyantigen-1.0.7}/setup.py +1 -1
- {pyantigen-1.0.5 → pyantigen-1.0.7}/LICENSE +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/MANIFEST.in +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/PyAntiGen.egg-info/dependency_links.txt +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/PyAntiGen.egg-info/entry_points.txt +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/PyAntiGen.egg-info/requires.txt +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/PyAntiGen.egg-info/top_level.txt +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/README.md +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/AntimonyGen.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/RxnDict_to_antimony.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/TelluriumGen.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/__init__.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/antimony_utils.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/cli.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/data_interpolation.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/isotopomer_tools.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/model_generation.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/models.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/module_base.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/pyantigen.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/rate_laws.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/reaction_creation.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/AntiGen_paths.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Engine/Model_simulate.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Engine/Nuisance_sensitivity.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Engine/Petab_export.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Engine/Simulate.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Model_generate.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Data.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Events.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Experiment.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Loss_config.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Observed_species.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Plots.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Update_opt_parameters.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Update_parameters.py +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/data/ADneg.csv +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/data/ADpos.csv +0 -0
- {pyantigen-1.0.5 → pyantigen-1.0.7}/setup.cfg +0 -0
|
@@ -26,11 +26,13 @@ framework/reaction_creation.py
|
|
|
26
26
|
framework/template/Example/AntiGen_paths.py
|
|
27
27
|
framework/template/Example/Model_generate.py
|
|
28
28
|
framework/template/Example/Model_run.py
|
|
29
|
+
framework/template/Example/Engine/Evaluator.py
|
|
29
30
|
framework/template/Example/Engine/Model_optimize.py
|
|
30
31
|
framework/template/Example/Engine/Model_simulate.py
|
|
31
32
|
framework/template/Example/Engine/Nuisance_sensitivity.py
|
|
32
33
|
framework/template/Example/Engine/Optimize.py
|
|
33
34
|
framework/template/Example/Engine/Petab_export.py
|
|
35
|
+
framework/template/Example/Engine/Profile_checkpoint.py
|
|
34
36
|
framework/template/Example/Engine/Results.py
|
|
35
37
|
framework/template/Example/Engine/Sensitivity_analysis.py
|
|
36
38
|
framework/template/Example/Engine/Simulate.py
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""Parallel evaluation service for the proper joint NLL.
|
|
2
|
+
|
|
3
|
+
Every expensive diagnostic in this Engine -- the Wald Hessian, likelihood
|
|
4
|
+
slices, Sobol sampling, and profile likelihood -- is a large batch of
|
|
5
|
+
independent ``nll_func_fixed`` evaluations. This module turns that into one
|
|
6
|
+
primitive::
|
|
7
|
+
|
|
8
|
+
with ParallelEvaluator(spec, n_workers=16) as ev:
|
|
9
|
+
losses = ev.evaluate_batch([x1, x2, x3, ...])
|
|
10
|
+
|
|
11
|
+
and every consumer above it becomes embarrassingly parallel.
|
|
12
|
+
|
|
13
|
+
Design notes
|
|
14
|
+
------------
|
|
15
|
+
|
|
16
|
+
**Windows-first, not fork.** The previous parallel path forked, so it did
|
|
17
|
+
nothing on Windows. This uses a ``spawn`` context, which behaves identically on
|
|
18
|
+
Windows, Linux and macOS. Spawn cannot inherit memory, so each worker rebuilds
|
|
19
|
+
what it needs in an initializer -- which is the right shape anyway.
|
|
20
|
+
|
|
21
|
+
**Compile once per worker, not once per task.** Compiling the model costs
|
|
22
|
+
1.7-4.9 s while a single evaluation costs ~1 s, so a naive "one task = one
|
|
23
|
+
process" pool would spend all its time in ``te.loada``. Workers are persistent
|
|
24
|
+
and compile every needed RoadRunner exactly once at startup.
|
|
25
|
+
|
|
26
|
+
**cloudpickle for the spec, plain pickle for the tasks.** Replicate dicts hold
|
|
27
|
+
callables, and some ``loss_config`` entries are closures produced by factories
|
|
28
|
+
(e.g. ``figure5_loss_config_factory``), which ``pickle`` cannot serialize.
|
|
29
|
+
``cloudpickle`` can. We serialize the spec once, pass it to the initializer as
|
|
30
|
+
*bytes* (which pickle handles fine), and thereafter send only parameter vectors.
|
|
31
|
+
That keeps per-task IPC tiny.
|
|
32
|
+
|
|
33
|
+
**Failure is data.** A worker never raises across the boundary; it returns a
|
|
34
|
+
status and the failure sentinel, so one bad integration cannot abort a batch of
|
|
35
|
+
several hundred. Callers get a count of what failed rather than silence.
|
|
36
|
+
|
|
37
|
+
**No plotting in workers.** The progress overlay writes a fixed PNG/JSON path
|
|
38
|
+
from module-level mutable state; with N workers that becomes N processes
|
|
39
|
+
fighting over one file. Workers only ever compute.
|
|
40
|
+
|
|
41
|
+
Calling scripts must guard their entry point
|
|
42
|
+
--------------------------------------------
|
|
43
|
+
|
|
44
|
+
``spawn`` re-imports the ``__main__`` module inside every worker, so any script
|
|
45
|
+
that reaches this code must do its work under a guard::
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
main()
|
|
49
|
+
|
|
50
|
+
``Model_run.py`` already does. An ad-hoc analysis script that calls
|
|
51
|
+
``setup_optimization_from_groups`` at module level will have each worker re-run
|
|
52
|
+
the whole analysis, and multiprocessing raises "an attempt has been made to start
|
|
53
|
+
a new process before the current process has finished its bootstrapping phase".
|
|
54
|
+
``evaluate_batch`` rewrites that message to name the actual cause; the caller
|
|
55
|
+
then falls back to serial rather than losing the run.
|
|
56
|
+
|
|
57
|
+
Note that even with the guard, module-level work in the main script (imports,
|
|
58
|
+
building EXPERIMENT registries) is repeated in every worker at startup, so
|
|
59
|
+
keeping that work light pays off directly in pool start-up time.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
import os
|
|
63
|
+
import sys
|
|
64
|
+
import time
|
|
65
|
+
from dataclasses import dataclass, field
|
|
66
|
+
|
|
67
|
+
import numpy as np
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
import cloudpickle as _serializer
|
|
71
|
+
_SERIALIZER_NAME = "cloudpickle"
|
|
72
|
+
except ImportError: # pragma: no cover - cloudpickle is a declared dependency
|
|
73
|
+
import pickle as _serializer
|
|
74
|
+
_SERIALIZER_NAME = "pickle"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Failure sentinel, matching Engine/Optimize.py.
|
|
78
|
+
FAILURE_VALUE = 1e10
|
|
79
|
+
|
|
80
|
+
# Windows caps ProcessPoolExecutor at 61 (WaitForMultipleObjects); leave headroom.
|
|
81
|
+
_MAX_WORKERS_WINDOWS = 60
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class EvalSpec:
|
|
86
|
+
"""Everything a worker needs to rebuild the objective from scratch.
|
|
87
|
+
|
|
88
|
+
Must be serializable by cloudpickle. It deliberately carries the *event
|
|
89
|
+
strings* rather than a way to regenerate them, so workers never re-run
|
|
90
|
+
data-dependent event generation and cannot disagree with the parent.
|
|
91
|
+
"""
|
|
92
|
+
model_text: str
|
|
93
|
+
paths: dict
|
|
94
|
+
events: dict # sim_name -> antimony event block
|
|
95
|
+
replicates: dict # sim_name -> replicate dict
|
|
96
|
+
param_names: list
|
|
97
|
+
scales: list
|
|
98
|
+
groups: dict
|
|
99
|
+
group_normalization: str
|
|
100
|
+
fixed_sigmas: dict
|
|
101
|
+
events_dynamic: bool = False
|
|
102
|
+
data_path: str = None
|
|
103
|
+
# Reserved for future use by the profile grid (Stage 2).
|
|
104
|
+
meta: dict = field(default_factory=dict)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --------------------------------------------------------------------------
|
|
108
|
+
# Worker side
|
|
109
|
+
# --------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
_WORKER = {"spec": None, "models": None, "n_evals": 0}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _init_worker(spec_blob):
|
|
115
|
+
"""Compile every model this worker needs, exactly once."""
|
|
116
|
+
# Import here: with spawn, the worker re-imports the module tree anyway, and
|
|
117
|
+
# keeping these out of module scope avoids paying for them in the parent.
|
|
118
|
+
from framework.TelluriumGen import TelluriumGen
|
|
119
|
+
from Engine.Optimize import OptRoadRunnerProxy
|
|
120
|
+
|
|
121
|
+
spec = _serializer.loads(spec_blob)
|
|
122
|
+
models = {}
|
|
123
|
+
t0 = time.time()
|
|
124
|
+
for sim_name, replicate in spec.replicates.items():
|
|
125
|
+
df_dict = replicate["Data"](replicate, spec.data_path or spec.paths["data_path"])
|
|
126
|
+
events_str = spec.events.get(sim_name, "")
|
|
127
|
+
|
|
128
|
+
r_ic = None
|
|
129
|
+
if spec.events_dynamic:
|
|
130
|
+
r_ic = TelluriumGen(spec.model_text, spec.paths)
|
|
131
|
+
replicate["Update_parameters"](
|
|
132
|
+
OptRoadRunnerProxy(r_ic, spec.param_names), replicate
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
r = TelluriumGen(spec.model_text + "\n" + events_str, spec.paths)
|
|
136
|
+
replicate["Update_parameters"](
|
|
137
|
+
OptRoadRunnerProxy(r, spec.param_names), replicate
|
|
138
|
+
)
|
|
139
|
+
models[sim_name] = {"r": r, "r_ic": r_ic, "df_dict": df_dict}
|
|
140
|
+
|
|
141
|
+
_WORKER["spec"] = spec
|
|
142
|
+
_WORKER["models"] = models
|
|
143
|
+
_WORKER["n_evals"] = 0
|
|
144
|
+
print(f" [worker {os.getpid()}] compiled {len(models)} model(s) in "
|
|
145
|
+
f"{time.time() - t0:.1f}s", flush=True)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _worker_nll(x):
|
|
149
|
+
"""The joint NLL, evaluated with this worker's own compiled models."""
|
|
150
|
+
from Engine.Optimize import evaluate_nll_fixed
|
|
151
|
+
|
|
152
|
+
spec = _WORKER["spec"]
|
|
153
|
+
return evaluate_nll_fixed(
|
|
154
|
+
np.asarray(x, dtype=float),
|
|
155
|
+
_WORKER["models"], spec.replicates, spec.param_names, spec.scales,
|
|
156
|
+
spec.groups, spec.group_normalization, spec.fixed_sigmas,
|
|
157
|
+
model_text=spec.model_text, paths=spec.paths,
|
|
158
|
+
events_dynamic=spec.events_dynamic, failure_value=FAILURE_VALUE,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _eval_task(x):
|
|
163
|
+
"""Evaluate one parameter vector. Never raises across the pool boundary."""
|
|
164
|
+
if _WORKER["spec"] is None:
|
|
165
|
+
return (FAILURE_VALUE, "worker-not-initialized", 0.0)
|
|
166
|
+
|
|
167
|
+
t0 = time.time()
|
|
168
|
+
try:
|
|
169
|
+
val = _worker_nll(x)
|
|
170
|
+
_WORKER["n_evals"] += 1
|
|
171
|
+
status = "ok" if np.isfinite(val) and val < FAILURE_VALUE else "sentinel"
|
|
172
|
+
return (float(val), status, time.time() - t0)
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
return (FAILURE_VALUE, f"error: {type(exc).__name__}: {exc}", time.time() - t0)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _profile_task(job):
|
|
178
|
+
"""Run one profile-likelihood point: minimize over the nuisance parameters
|
|
179
|
+
with parameter ``param_idx`` pinned at ``x_fixed``.
|
|
180
|
+
|
|
181
|
+
A profile point is a whole optimization, not a single evaluation, so the
|
|
182
|
+
scipy call runs *inside* the worker against its local models. That is what
|
|
183
|
+
makes the profile parallel: 2k x n_grid independent optimizations in flight,
|
|
184
|
+
instead of one adaptive walk stepping sequentially.
|
|
185
|
+
|
|
186
|
+
``job`` is a plain dict so it pickles cheaply. Returns a result dict that is
|
|
187
|
+
written straight to the checkpoint file.
|
|
188
|
+
"""
|
|
189
|
+
from Engine.Optimize import _make_nuisance_objective, _minimize_nuisance
|
|
190
|
+
|
|
191
|
+
t0 = time.time()
|
|
192
|
+
out = dict(job)
|
|
193
|
+
out.update({"nll": None, "status": "ok", "n_evals": 0, "wall_s": 0.0,
|
|
194
|
+
"worker": os.getpid()})
|
|
195
|
+
|
|
196
|
+
if _WORKER["spec"] is None:
|
|
197
|
+
out.update({"status": "worker-not-initialized", "nll": FAILURE_VALUE})
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
spec = _WORKER["spec"]
|
|
202
|
+
n_params = len(spec.param_names)
|
|
203
|
+
param_idx = int(job["param_idx"])
|
|
204
|
+
x_fixed = float(job["x_fixed"])
|
|
205
|
+
x_start = np.asarray(job["x_start"], dtype=float)
|
|
206
|
+
|
|
207
|
+
counter = {"n": 0}
|
|
208
|
+
|
|
209
|
+
def counted_nll(x):
|
|
210
|
+
counter["n"] += 1
|
|
211
|
+
return _worker_nll(x)
|
|
212
|
+
|
|
213
|
+
nuisance_objective = _make_nuisance_objective(counted_nll, param_idx, n_params)
|
|
214
|
+
|
|
215
|
+
bounds = job.get("nuisance_bounds")
|
|
216
|
+
if bounds is not None:
|
|
217
|
+
bounds = [tuple(b) if b is not None else None for b in bounds]
|
|
218
|
+
|
|
219
|
+
if x_start.size == 0:
|
|
220
|
+
# Single-parameter fit: nothing to re-optimize, so the profile value
|
|
221
|
+
# is just the objective at the fixed value.
|
|
222
|
+
nll = nuisance_objective(x_start, x_fixed)
|
|
223
|
+
x_opt = x_start
|
|
224
|
+
else:
|
|
225
|
+
res = _minimize_nuisance(
|
|
226
|
+
nuisance_objective, x_start, (x_fixed,),
|
|
227
|
+
job.get("method", "Nelder-Mead"), bounds,
|
|
228
|
+
job.get("optimizer_kwargs"),
|
|
229
|
+
)
|
|
230
|
+
nll = float(res.fun)
|
|
231
|
+
x_opt = np.asarray(res.x, dtype=float)
|
|
232
|
+
|
|
233
|
+
out.update({
|
|
234
|
+
"nll": float(nll),
|
|
235
|
+
"nuisance_x": np.asarray(x_opt, dtype=float).tolist(),
|
|
236
|
+
"n_evals": counter["n"],
|
|
237
|
+
"status": "ok" if np.isfinite(nll) and nll < FAILURE_VALUE else "sentinel",
|
|
238
|
+
})
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
out.update({"status": f"error: {type(exc).__name__}: {exc}",
|
|
241
|
+
"nll": FAILURE_VALUE})
|
|
242
|
+
|
|
243
|
+
out["wall_s"] = time.time() - t0
|
|
244
|
+
return out
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# --------------------------------------------------------------------------
|
|
248
|
+
# Parent side
|
|
249
|
+
# --------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
def default_worker_count(n_workers=None):
|
|
252
|
+
"""Workers to use: explicit value, else all cores but one, within limits."""
|
|
253
|
+
if n_workers is None:
|
|
254
|
+
n_workers = max(1, (os.cpu_count() or 2) - 1)
|
|
255
|
+
n_workers = max(1, int(n_workers))
|
|
256
|
+
if sys.platform == "win32":
|
|
257
|
+
n_workers = min(n_workers, _MAX_WORKERS_WINDOWS)
|
|
258
|
+
return n_workers
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class ParallelEvaluator:
|
|
262
|
+
"""A pool of persistent workers evaluating the joint NLL.
|
|
263
|
+
|
|
264
|
+
Use as a context manager so the pool is always shut down::
|
|
265
|
+
|
|
266
|
+
with ParallelEvaluator(spec, n_workers=16) as ev:
|
|
267
|
+
losses = ev.evaluate_batch(xs)
|
|
268
|
+
|
|
269
|
+
``evaluate_batch`` preserves input order. Failures come back as
|
|
270
|
+
``FAILURE_VALUE`` and are counted in ``ev.n_failures`` rather than raised.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
def __init__(self, spec, n_workers=None, chunk_size=None, verbose=True):
|
|
274
|
+
self.spec = spec
|
|
275
|
+
self.n_workers = default_worker_count(n_workers)
|
|
276
|
+
self.chunk_size = chunk_size
|
|
277
|
+
self.verbose = verbose
|
|
278
|
+
self._pool = None
|
|
279
|
+
self._blob = None
|
|
280
|
+
self.n_evals = 0
|
|
281
|
+
self.n_failures = 0
|
|
282
|
+
self.total_worker_seconds = 0.0
|
|
283
|
+
|
|
284
|
+
# -- lifecycle ---------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
# Calibration point: the ~370-species / 978-reaction SILK variant, whose
|
|
287
|
+
# antimony source is ~124k characters, costs ~0.35 GB per compiled model.
|
|
288
|
+
_REF_MODEL_CHARS = 124_000
|
|
289
|
+
_REF_MODEL_GB = 0.35
|
|
290
|
+
|
|
291
|
+
def memory_estimate_gb(self):
|
|
292
|
+
"""Rough resident-memory estimate for the pool, in GB.
|
|
293
|
+
|
|
294
|
+
Each worker compiles every simulation in the spec, so the footprint
|
|
295
|
+
scales as ``n_workers x n_simulations x per-model``. That product, not
|
|
296
|
+
the core count, is what usually limits how wide this can run: a
|
|
297
|
+
12-simulation spec at 40 workers would want well over 100 GB.
|
|
298
|
+
|
|
299
|
+
Per-model cost is scaled from the antimony source length against a
|
|
300
|
+
measured reference. That is a crude proxy -- it tracks model size, not
|
|
301
|
+
RoadRunner's exact allocation -- so treat the number as an order of
|
|
302
|
+
magnitude for choosing n_workers, not a budget.
|
|
303
|
+
"""
|
|
304
|
+
chars = max(len(self.spec.model_text or ""), 1)
|
|
305
|
+
per_model_gb = self._REF_MODEL_GB * (chars / self._REF_MODEL_CHARS)
|
|
306
|
+
overhead_gb = 0.15
|
|
307
|
+
n_models = max(len(self.spec.replicates), 1)
|
|
308
|
+
return self.n_workers * (n_models * per_model_gb + overhead_gb)
|
|
309
|
+
|
|
310
|
+
def start(self):
|
|
311
|
+
if self._pool is not None:
|
|
312
|
+
return self
|
|
313
|
+
import multiprocessing as mp
|
|
314
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
315
|
+
|
|
316
|
+
self._blob = _serializer.dumps(self.spec)
|
|
317
|
+
n_models = len(self.spec.replicates)
|
|
318
|
+
est_gb = self.memory_estimate_gb()
|
|
319
|
+
if self.verbose:
|
|
320
|
+
print(f"[pool] starting {self.n_workers} worker(s) "
|
|
321
|
+
f"({_SERIALIZER_NAME} spec: {len(self._blob) / 1e6:.1f} MB, "
|
|
322
|
+
f"{n_models} model(s) each, ~{est_gb:.0f} GB estimated)",
|
|
323
|
+
flush=True)
|
|
324
|
+
if est_gb > 32:
|
|
325
|
+
print(f"[pool] WARNING: {self.n_workers} workers x {n_models} models "
|
|
326
|
+
f"is an estimated ~{est_gb:.0f} GB. Memory, not core count, is "
|
|
327
|
+
f"usually the binding constraint for specs with many "
|
|
328
|
+
f"simulations — lower n_workers if the machine starts "
|
|
329
|
+
f"swapping.", flush=True)
|
|
330
|
+
self._pool = ProcessPoolExecutor(
|
|
331
|
+
max_workers=self.n_workers,
|
|
332
|
+
mp_context=mp.get_context("spawn"),
|
|
333
|
+
initializer=_init_worker,
|
|
334
|
+
initargs=(self._blob,),
|
|
335
|
+
)
|
|
336
|
+
return self
|
|
337
|
+
|
|
338
|
+
def shutdown(self):
|
|
339
|
+
if self._pool is not None:
|
|
340
|
+
self._pool.shutdown(wait=True)
|
|
341
|
+
self._pool = None
|
|
342
|
+
|
|
343
|
+
def __enter__(self):
|
|
344
|
+
return self.start()
|
|
345
|
+
|
|
346
|
+
def __exit__(self, exc_type, exc, tb):
|
|
347
|
+
self.shutdown()
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
# -- evaluation --------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
def evaluate_batch(self, xs, label=None):
|
|
353
|
+
"""Evaluate every parameter vector in *xs*; return losses in input order."""
|
|
354
|
+
xs = [np.asarray(x, dtype=float) for x in xs]
|
|
355
|
+
if not xs:
|
|
356
|
+
return []
|
|
357
|
+
if self._pool is None:
|
|
358
|
+
self.start()
|
|
359
|
+
|
|
360
|
+
chunk = self.chunk_size
|
|
361
|
+
if chunk is None:
|
|
362
|
+
# Enough chunks to keep every worker fed, few enough to avoid
|
|
363
|
+
# per-task overhead dominating.
|
|
364
|
+
chunk = max(1, len(xs) // (self.n_workers * 4) or 1)
|
|
365
|
+
|
|
366
|
+
t0 = time.time()
|
|
367
|
+
try:
|
|
368
|
+
out = list(self._pool.map(_eval_task, xs, chunksize=chunk))
|
|
369
|
+
except RuntimeError as exc:
|
|
370
|
+
if "bootstrapping phase" in str(exc):
|
|
371
|
+
# spawn re-imports the __main__ module in every worker. If the
|
|
372
|
+
# caller's script runs its work at module level, each worker
|
|
373
|
+
# re-runs the whole analysis and multiprocessing refuses. The
|
|
374
|
+
# stock message never mentions the caller's script, so say it.
|
|
375
|
+
raise RuntimeError(
|
|
376
|
+
"The parallel evaluator needs the calling script to guard "
|
|
377
|
+
"its entry point:\n\n"
|
|
378
|
+
" if __name__ == '__main__':\n"
|
|
379
|
+
" main()\n\n"
|
|
380
|
+
"Worker processes are started with 'spawn', which re-imports "
|
|
381
|
+
"the main module; without the guard each worker would re-run "
|
|
382
|
+
"your analysis from the top. Model_run.py already does this — "
|
|
383
|
+
"ad-hoc analysis scripts need it too. "
|
|
384
|
+
f"(original error: {exc})"
|
|
385
|
+
) from exc
|
|
386
|
+
raise
|
|
387
|
+
|
|
388
|
+
losses = []
|
|
389
|
+
failures = []
|
|
390
|
+
for i, (val, status, secs) in enumerate(out):
|
|
391
|
+
losses.append(val)
|
|
392
|
+
self.total_worker_seconds += secs
|
|
393
|
+
if status != "ok":
|
|
394
|
+
failures.append((i, status))
|
|
395
|
+
|
|
396
|
+
self.n_evals += len(xs)
|
|
397
|
+
self.n_failures += len(failures)
|
|
398
|
+
|
|
399
|
+
if self.verbose:
|
|
400
|
+
elapsed = max(time.time() - t0, 1e-9)
|
|
401
|
+
# Worker-seconds is the serial cost of the same work; the ratio to
|
|
402
|
+
# wall time is the speedup actually realized. On the first batch it
|
|
403
|
+
# includes worker startup, so it understates steady-state throughput
|
|
404
|
+
# -- report both numbers rather than one flattering one.
|
|
405
|
+
work = sum(o[2] for o in out)
|
|
406
|
+
tag = f" [{label}]" if label else ""
|
|
407
|
+
print(f"[pool]{tag} {len(xs)} evals in {elapsed:.1f}s wall "
|
|
408
|
+
f"({work:.1f}s of work, {work / elapsed:.1f}x, "
|
|
409
|
+
f"{len(xs) / elapsed:.1f} eval/s)", flush=True)
|
|
410
|
+
if failures:
|
|
411
|
+
shown = "; ".join(f"#{i}: {s}" for i, s in failures[:3])
|
|
412
|
+
more = f" (+{len(failures) - 3} more)" if len(failures) > 3 else ""
|
|
413
|
+
print(f"[pool]{tag} {len(failures)} failed — {shown}{more}", flush=True)
|
|
414
|
+
|
|
415
|
+
return losses
|
|
416
|
+
|
|
417
|
+
def profile_batch(self, jobs, on_result=None, label=None):
|
|
418
|
+
"""Run profile-likelihood points in parallel.
|
|
419
|
+
|
|
420
|
+
Unlike ``evaluate_batch`` this uses submit/as_completed rather than map,
|
|
421
|
+
because each job is minutes long and results must be checkpointed *as
|
|
422
|
+
they land* -- the whole point of checkpointing is that killing the run
|
|
423
|
+
halfway keeps the half that finished.
|
|
424
|
+
|
|
425
|
+
*on_result* is called with each result dict the moment it arrives.
|
|
426
|
+
Returns results in completion order; callers key off the job fields.
|
|
427
|
+
"""
|
|
428
|
+
if not jobs:
|
|
429
|
+
return []
|
|
430
|
+
if self._pool is None:
|
|
431
|
+
self.start()
|
|
432
|
+
from concurrent.futures import as_completed
|
|
433
|
+
|
|
434
|
+
t0 = time.time()
|
|
435
|
+
results = []
|
|
436
|
+
futures = {self._pool.submit(_profile_task, j): j for j in jobs}
|
|
437
|
+
done = 0
|
|
438
|
+
for fut in as_completed(futures):
|
|
439
|
+
try:
|
|
440
|
+
res = fut.result()
|
|
441
|
+
except Exception as exc:
|
|
442
|
+
job = futures[fut]
|
|
443
|
+
res = dict(job)
|
|
444
|
+
res.update({"nll": FAILURE_VALUE, "n_evals": 0, "wall_s": 0.0,
|
|
445
|
+
"status": f"error: {type(exc).__name__}: {exc}"})
|
|
446
|
+
results.append(res)
|
|
447
|
+
done += 1
|
|
448
|
+
self.n_evals += int(res.get("n_evals") or 0)
|
|
449
|
+
self.total_worker_seconds += float(res.get("wall_s") or 0.0)
|
|
450
|
+
if res.get("status") != "ok":
|
|
451
|
+
self.n_failures += 1
|
|
452
|
+
if on_result is not None:
|
|
453
|
+
on_result(res)
|
|
454
|
+
if self.verbose:
|
|
455
|
+
elapsed = time.time() - t0
|
|
456
|
+
print(f" [profile {done}/{len(jobs)}] {res.get('param_name')} "
|
|
457
|
+
f"= {res.get('x_fixed_linear', res.get('x_fixed')):.4g} "
|
|
458
|
+
f"dNLL={res.get('dnll', float('nan')):.4g} "
|
|
459
|
+
f"({res.get('n_evals')} evals, {res.get('wall_s', 0):.0f}s)"
|
|
460
|
+
f" [{elapsed:.0f}s elapsed]", flush=True)
|
|
461
|
+
|
|
462
|
+
if self.verbose:
|
|
463
|
+
elapsed = max(time.time() - t0, 1e-9)
|
|
464
|
+
work = sum(float(r.get("wall_s") or 0.0) for r in results)
|
|
465
|
+
tag = f" [{label}]" if label else ""
|
|
466
|
+
print(f"[pool]{tag} {len(jobs)} profile points in {elapsed:.0f}s wall "
|
|
467
|
+
f"({work:.0f}s of work, {work / elapsed:.1f}x)", flush=True)
|
|
468
|
+
return results
|
|
469
|
+
|
|
470
|
+
def as_scalar_func(self):
|
|
471
|
+
"""A serial-looking ``f(x) -> float`` backed by the pool.
|
|
472
|
+
|
|
473
|
+
Convenience for code that cannot batch. It gives no speedup on its own
|
|
474
|
+
(one vector at a time) -- prefer ``evaluate_batch``.
|
|
475
|
+
"""
|
|
476
|
+
def _f(x):
|
|
477
|
+
return self.evaluate_batch([x])[0]
|
|
478
|
+
return _f
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def build_eval_spec(
|
|
482
|
+
model_text, paths, events, replicates, param_names, scales, groups,
|
|
483
|
+
group_normalization, fixed_sigmas, events_dynamic=False, data_path=None,
|
|
484
|
+
):
|
|
485
|
+
"""Convenience constructor mirroring the spec-route local variables."""
|
|
486
|
+
return EvalSpec(
|
|
487
|
+
model_text=model_text,
|
|
488
|
+
paths=paths,
|
|
489
|
+
events=dict(events),
|
|
490
|
+
replicates=dict(replicates),
|
|
491
|
+
param_names=list(param_names),
|
|
492
|
+
scales=list(scales),
|
|
493
|
+
groups=groups,
|
|
494
|
+
group_normalization=group_normalization,
|
|
495
|
+
fixed_sigmas=dict(fixed_sigmas or {}),
|
|
496
|
+
events_dynamic=bool(events_dynamic),
|
|
497
|
+
data_path=data_path or paths.get("data_path"),
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def check_spec_serializable(spec, verbose=True):
|
|
502
|
+
"""Round-trip the spec so serialization problems surface in the parent.
|
|
503
|
+
|
|
504
|
+
Without this, an unpicklable ``loss_config`` closure fails inside worker
|
|
505
|
+
startup, where the traceback is far less legible.
|
|
506
|
+
"""
|
|
507
|
+
try:
|
|
508
|
+
blob = _serializer.dumps(spec)
|
|
509
|
+
_serializer.loads(blob)
|
|
510
|
+
if verbose:
|
|
511
|
+
print(f"[pool] spec serializes cleanly via {_SERIALIZER_NAME} "
|
|
512
|
+
f"({len(blob) / 1e6:.1f} MB)")
|
|
513
|
+
return True, len(blob), None
|
|
514
|
+
except Exception as exc:
|
|
515
|
+
msg = (f"{type(exc).__name__}: {exc}. The parallel path needs every "
|
|
516
|
+
f"replicate callable and loss_config to be serializable; a "
|
|
517
|
+
f"closure that captures a RoadRunner or an open file will fail "
|
|
518
|
+
f"here. Falling back to serial evaluation.")
|
|
519
|
+
if verbose:
|
|
520
|
+
print(f"[pool] spec is NOT serializable — {msg}")
|
|
521
|
+
return False, 0, msg
|