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.
Files changed (52) hide show
  1. {pyantigen-1.0.5 → pyantigen-1.0.7}/PKG-INFO +1 -1
  2. {pyantigen-1.0.5 → pyantigen-1.0.7}/PyAntiGen.egg-info/PKG-INFO +1 -1
  3. {pyantigen-1.0.5 → pyantigen-1.0.7}/PyAntiGen.egg-info/SOURCES.txt +2 -0
  4. pyantigen-1.0.7/framework/template/Example/Engine/Evaluator.py +521 -0
  5. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Engine/Model_optimize.py +84 -7
  6. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Engine/Optimize.py +1517 -290
  7. pyantigen-1.0.7/framework/template/Example/Engine/Profile_checkpoint.py +206 -0
  8. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Engine/Results.py +16 -0
  9. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Engine/Sensitivity_analysis.py +23 -14
  10. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Model_run.py +4 -0
  11. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Optimizer_settings.py +5 -0
  12. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Solver_settings.py +2 -1
  13. {pyantigen-1.0.5 → pyantigen-1.0.7}/pyproject.toml +1 -1
  14. {pyantigen-1.0.5 → pyantigen-1.0.7}/setup.py +1 -1
  15. {pyantigen-1.0.5 → pyantigen-1.0.7}/LICENSE +0 -0
  16. {pyantigen-1.0.5 → pyantigen-1.0.7}/MANIFEST.in +0 -0
  17. {pyantigen-1.0.5 → pyantigen-1.0.7}/PyAntiGen.egg-info/dependency_links.txt +0 -0
  18. {pyantigen-1.0.5 → pyantigen-1.0.7}/PyAntiGen.egg-info/entry_points.txt +0 -0
  19. {pyantigen-1.0.5 → pyantigen-1.0.7}/PyAntiGen.egg-info/requires.txt +0 -0
  20. {pyantigen-1.0.5 → pyantigen-1.0.7}/PyAntiGen.egg-info/top_level.txt +0 -0
  21. {pyantigen-1.0.5 → pyantigen-1.0.7}/README.md +0 -0
  22. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/AntimonyGen.py +0 -0
  23. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/RxnDict_to_antimony.py +0 -0
  24. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/TelluriumGen.py +0 -0
  25. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/__init__.py +0 -0
  26. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/antimony_utils.py +0 -0
  27. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/cli.py +0 -0
  28. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/data_interpolation.py +0 -0
  29. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/isotopomer_tools.py +0 -0
  30. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/model_generation.py +0 -0
  31. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/models.py +0 -0
  32. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/module_base.py +0 -0
  33. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/pyantigen.py +0 -0
  34. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/rate_laws.py +0 -0
  35. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/reaction_creation.py +0 -0
  36. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/AntiGen_paths.py +0 -0
  37. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Engine/Model_simulate.py +0 -0
  38. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Engine/Nuisance_sensitivity.py +0 -0
  39. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Engine/Petab_export.py +0 -0
  40. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Engine/Simulate.py +0 -0
  41. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Model_generate.py +0 -0
  42. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Data.py +0 -0
  43. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Events.py +0 -0
  44. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Experiment.py +0 -0
  45. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Loss_config.py +0 -0
  46. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Observed_species.py +0 -0
  47. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Plots.py +0 -0
  48. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Update_opt_parameters.py +0 -0
  49. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/Example/Modules/Update_parameters.py +0 -0
  50. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/data/ADneg.csv +0 -0
  51. {pyantigen-1.0.5 → pyantigen-1.0.7}/framework/template/data/ADpos.csv +0 -0
  52. {pyantigen-1.0.5 → pyantigen-1.0.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyAntiGen
3
- Version: 1.0.5
3
+ Version: 1.0.7
4
4
  Summary: Short one-line description of what PyAntiGen does
5
5
  Author: Open Source Contributor
6
6
  Author-email: Don <you@example.com>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyAntiGen
3
- Version: 1.0.5
3
+ Version: 1.0.7
4
4
  Summary: Short one-line description of what PyAntiGen does
5
5
  Author: Open Source Contributor
6
6
  Author-email: Don <you@example.com>
@@ -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