PyAntiGen 1.0.9__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.
Files changed (55) hide show
  1. framework/AntimonyGen.py +48 -0
  2. framework/RxnDict_to_antimony.py +594 -0
  3. framework/TelluriumGen.py +16 -0
  4. framework/__init__.py +0 -0
  5. framework/antimony_utils.py +294 -0
  6. framework/cli.py +229 -0
  7. framework/data_interpolation.py +340 -0
  8. framework/isotopomer_tools.py +41 -0
  9. framework/model_generation.py +46 -0
  10. framework/models.py +189 -0
  11. framework/module_base.py +42 -0
  12. framework/pyantigen.py +51 -0
  13. framework/rate_laws.py +101 -0
  14. framework/reaction_creation.py +43 -0
  15. framework/template/Example/AntiGen_paths.py +23 -0
  16. framework/template/Example/Engine/Anchor_cache.py +193 -0
  17. framework/template/Example/Engine/Deadline.py +535 -0
  18. framework/template/Example/Engine/Evaluator.py +1176 -0
  19. framework/template/Example/Engine/Event_times.py +491 -0
  20. framework/template/Example/Engine/Fast_profile.py +701 -0
  21. framework/template/Example/Engine/Fit_cache.py +329 -0
  22. framework/template/Example/Engine/Identifiability.py +698 -0
  23. framework/template/Example/Engine/Model_optimize.py +1483 -0
  24. framework/template/Example/Engine/Model_simulate.py +124 -0
  25. framework/template/Example/Engine/Nuisance_sensitivity.py +298 -0
  26. framework/template/Example/Engine/Optimize.py +6862 -0
  27. framework/template/Example/Engine/Petab_export.py +398 -0
  28. framework/template/Example/Engine/Preequil_cache.py +361 -0
  29. framework/template/Example/Engine/Profile_checkpoint.py +399 -0
  30. framework/template/Example/Engine/Results.py +395 -0
  31. framework/template/Example/Engine/Sensitivity_analysis.py +320 -0
  32. framework/template/Example/Engine/Simulate.py +617 -0
  33. framework/template/Example/Flipflop_reference.py +401 -0
  34. framework/template/Example/Model_generate.py +37 -0
  35. framework/template/Example/Model_run.py +261 -0
  36. framework/template/Example/Modules/Data.py +63 -0
  37. framework/template/Example/Modules/Events.py +14 -0
  38. framework/template/Example/Modules/Experiment.py +194 -0
  39. framework/template/Example/Modules/Loss_config.py +61 -0
  40. framework/template/Example/Modules/Observed_species.py +3 -0
  41. framework/template/Example/Modules/Optimizer_settings.py +258 -0
  42. framework/template/Example/Modules/Plots.py +89 -0
  43. framework/template/Example/Modules/Solver_settings.py +16 -0
  44. framework/template/Example/Modules/Update_opt_parameters.py +24 -0
  45. framework/template/Example/Modules/Update_parameters.py +49 -0
  46. framework/template/data/ADneg.csv +27 -0
  47. framework/template/data/ADpos.csv +27 -0
  48. framework/template/data/Flipflop.csv +29 -0
  49. framework/template/data/make_flipflop_data.py +174 -0
  50. pyantigen-1.0.9.dist-info/METADATA +129 -0
  51. pyantigen-1.0.9.dist-info/RECORD +55 -0
  52. pyantigen-1.0.9.dist-info/WHEEL +5 -0
  53. pyantigen-1.0.9.dist-info/entry_points.txt +2 -0
  54. pyantigen-1.0.9.dist-info/licenses/LICENSE +21 -0
  55. pyantigen-1.0.9.dist-info/top_level.txt +1 -0
@@ -0,0 +1,361 @@
1
+ """Reuse of the pre-dose simulation segment across objective evaluations.
2
+
3
+ The PK and antibody solver settings open with a ``preequil`` block that ages the
4
+ model from birth to the first dose -- seventy-odd years of integration whose
5
+ trajectory is never fitted (``tracked: False``); only the state it leaves behind
6
+ matters. On the microglia group that block is 1.5 s of the 5.2 s each arm costs,
7
+ so across twelve arms it is roughly 28% of every objective evaluation, repeated
8
+ unchanged for every one of the thousands of evaluations a fit or a profile runs.
9
+
10
+ It is unchanged because the parameters being fitted cannot act before the first
11
+ dose. Microglial activation is gated on ``f10_dense``, which is zero while
12
+ ``Sat_Dense`` -- the antibody-bound fraction of dense plaque -- is zero;
13
+ ``Microglia_high`` starts at zero with no other production route, so the
14
+ high-state clearances and ``k_deact_high`` multiply zero; and every remaining
15
+ fitted parameter either scales an ``__Antibody`` species or is an antibody
16
+ binding rate. With no antibody in the system they are all inert.
17
+
18
+ So the block is computed once per model and its end state restored thereafter.
19
+ Two things make that safe rather than merely fast:
20
+
21
+ * **The key is the state, not a name.** The cache is keyed on the model's entire
22
+ state after ``reset()`` and ``Update_parameters`` -- before any fitted value is
23
+ applied -- together with the block and solver spec. That state is a pure
24
+ function of (model, arm), so it is identical across evaluations by
25
+ construction and no assumption about *which* parameters are inert is encoded
26
+ in the key. Anything that does change the pre-dose setup changes the key and
27
+ gets its own entry.
28
+
29
+ * **The assumption is tested, not asserted.** Using the cache means applying the
30
+ fitted parameters *after* the pre-dose block instead of before it, which is
31
+ only valid while those parameters are inert there. :func:`verify_invariance`
32
+ checks exactly that at startup by integrating the block under two different
33
+ parameter vectors and comparing the results, and the cache refuses to enable
34
+ itself if the check fails. Without it, a later model edit that let a fitted
35
+ parameter act before the first dose would silently corrupt every result with
36
+ nothing in the output to show for it.
37
+ """
38
+
39
+ import hashlib
40
+
41
+ import numpy as np
42
+
43
+ from Engine.Simulate import (
44
+ clamp_state_dust,
45
+ configure_integrator,
46
+ restore_model_state,
47
+ safe_simulate,
48
+ save_model_state,
49
+ )
50
+
51
+
52
+ def split_preequil_block(solver_settings):
53
+ """Return ``(cacheable_block, remaining_settings)``.
54
+
55
+ The candidate is the *leading* block, and only when it is untracked: an
56
+ untracked block contributes no output, so replaying its end state is
57
+ equivalent to running it. A tracked leading block (Figure8's, for instance)
58
+ contributes rows to the result and is left alone.
59
+ """
60
+ blocks = solver_settings.get("simulation_blocks")
61
+ if isinstance(blocks, dict):
62
+ items = list(blocks.items())
63
+ elif isinstance(blocks, (list, tuple)):
64
+ items = list(enumerate(blocks))
65
+ else:
66
+ return None, solver_settings
67
+
68
+ if len(items) < 2:
69
+ # Nothing would remain to simulate; not worth special-casing.
70
+ return None, solver_settings
71
+
72
+ _name, first = items[0]
73
+ if not isinstance(first, dict) or first.get("tracked", True):
74
+ return None, solver_settings
75
+
76
+ rest = dict(solver_settings)
77
+ if isinstance(blocks, dict):
78
+ rest["simulation_blocks"] = {k: v for k, v in items[1:]}
79
+ else:
80
+ rest["simulation_blocks"] = [b for _k, b in items[1:]]
81
+ return first, rest
82
+
83
+
84
+ def _digest(state, block, solver_settings):
85
+ """Identity of a pre-dose result: the starting state and how it is run."""
86
+ h = hashlib.sha256()
87
+ for k, v in zip(state["keys"], state["values"]):
88
+ h.update(str(k).encode("utf-8"))
89
+ h.update(repr(float(v)).encode("utf-8"))
90
+ for field in ("start", "end", "n_points", "variable_step_size",
91
+ "maximum_num_steps"):
92
+ h.update(f"{field}={block.get(field)!r}".encode("utf-8"))
93
+ for field in ("integrator", "absolute_tolerance", "relative_tolerance",
94
+ "stiff", "maximum_num_steps"):
95
+ h.update(f"{field}={solver_settings.get(field)!r}".encode("utf-8"))
96
+ return h.hexdigest()
97
+
98
+
99
+ class PreequilCache:
100
+ """Stores the end state of the pre-dose block for one model.
101
+
102
+ In practice this holds a single entry -- one arm per RoadRunner instance,
103
+ one pre-dose block per arm -- but it is keyed rather than a bare slot so
104
+ that anything which does alter the pre-dose setup produces a new entry
105
+ instead of silently reusing the wrong state.
106
+ """
107
+
108
+ def __init__(self, enabled=True):
109
+ self.enabled = bool(enabled)
110
+ self._store = {}
111
+ self.n_hits = 0
112
+ self.n_misses = 0
113
+
114
+ def apply(self, r, solver_settings, observed_species, label=None):
115
+ """Resolve the pre-dose block; return the settings still to simulate.
116
+
117
+ On a hit the model is left in the stored end state. On a miss the block
118
+ is integrated here, under the same solver configuration ``simulate``
119
+ would have used, and the resulting state stored.
120
+ """
121
+ if not self.enabled:
122
+ return solver_settings
123
+
124
+ block, rest = split_preequil_block(solver_settings)
125
+ if block is None:
126
+ return solver_settings
127
+
128
+ configure_integrator(r, solver_settings)
129
+ key = _digest(save_model_state(r), block, solver_settings)
130
+
131
+ hit = self._store.get(key)
132
+ if hit is not None:
133
+ restore_model_state(r, hit)
134
+ self.n_hits += 1
135
+ return rest
136
+
137
+ safe_simulate(r, block, observed_species, label=label)
138
+ # Clear the dust before snapshotting, so every later cache hit restores
139
+ # an already-clean state and the saving compounds with the cache rather
140
+ # than being re-paid on each hit.
141
+ clamp_state_dust(r)
142
+ self._store[key] = save_model_state(r)
143
+ self.n_misses += 1
144
+ return rest
145
+
146
+ def stats(self):
147
+ return {"hits": self.n_hits, "misses": self.n_misses,
148
+ "entries": len(self._store)}
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # The self-check
153
+ # ---------------------------------------------------------------------------
154
+
155
+ def _setup_and_integrate(r, replicate, param_names, p_vec, block,
156
+ solver_settings, observed_species):
157
+ """One pre-dose run at *p_vec*, in the order the uncached path uses.
158
+
159
+ Returns the state immediately after setup and the state after integrating,
160
+ so the caller can tell a value that was *set* differently from one that
161
+ *evolved* differently.
162
+ """
163
+ from Engine.Optimize import set_parameters_from_dict
164
+
165
+ r.reset()
166
+ upd = replicate.get("Update_parameters")
167
+ if upd is not None:
168
+ upd(r, replicate)
169
+
170
+ set_parameters_from_dict(r, dict(zip(param_names, np.asarray(p_vec).tolist())))
171
+ for hook in replicate.get("parameter_hooks", []):
172
+ hook(r, p_vec)
173
+ upd_opt = replicate.get("Update_opt_parameters")
174
+ if upd_opt is not None:
175
+ upd_opt(r, replicate, p_vec)
176
+
177
+ before = save_model_state(r)
178
+ configure_integrator(r, solver_settings)
179
+ _res, meta = safe_simulate(r, block, observed_species, label="preequil-check")
180
+ # Match what apply() stores, so the check is run against the state the cache
181
+ # would actually hand to the dosed block.
182
+ clamp_state_dust(r)
183
+ return before, save_model_state(r), meta
184
+
185
+
186
+ def _achieved_settings(meta, solver_settings):
187
+ """The tolerances the pre-dose block actually converged at.
188
+
189
+ ``safe_simulate`` loosens tolerances and subdivides until an integration
190
+ succeeds, then restores the original settings, so the accuracy a block was
191
+ computed at is not the accuracy that was requested. Where it subdivided, the
192
+ loosest tolerance reached anywhere is what limits the result.
193
+
194
+ Returns ``(settings_or_None, rel_tol)``; the first is None when the very
195
+ first attempt succeeded, meaning nothing needs overriding.
196
+ """
197
+ requested = float(solver_settings.get("relative_tolerance", 1e-8))
198
+
199
+ def walk(m):
200
+ found = []
201
+ if not isinstance(m, dict):
202
+ return found
203
+ if m.get("rel_tol") is not None:
204
+ found.append({k: m.get(k) for k in
205
+ ("abs_tol", "rel_tol", "max_steps", "initial_time_step")})
206
+ for key in ("meta1", "meta2"):
207
+ found.extend(walk(m.get(key)))
208
+ return found
209
+
210
+ candidates = walk(meta)
211
+ if not candidates:
212
+ return None, requested
213
+
214
+ worst = max(candidates, key=lambda d: float(d.get("rel_tol") or 0.0))
215
+ rel = float(worst.get("rel_tol") or requested)
216
+ settings = dict(solver_settings)
217
+ if worst.get("abs_tol") is not None:
218
+ settings["absolute_tolerance"] = float(worst["abs_tol"])
219
+ settings["relative_tolerance"] = rel
220
+ if worst.get("max_steps") is not None:
221
+ settings["maximum_num_steps"] = int(worst["max_steps"])
222
+ return settings, rel
223
+
224
+
225
+ def _alternate_vector(x0, bounds):
226
+ """A parameter vector far from *x0* but inside the bounds.
227
+
228
+ Multiplying by five moves every parameter far enough that any pre-dose
229
+ influence would show; where that hits a bound the value is moved the other
230
+ way instead, so no entry silently stays put and weakens the test.
231
+ """
232
+ x0 = np.asarray(x0, dtype=float)
233
+ out = np.array(x0, dtype=float, copy=True)
234
+ for i, v in enumerate(x0):
235
+ lo, hi = (bounds[i] if bounds is not None and i < len(bounds)
236
+ else (None, None))
237
+ lo = -np.inf if lo is None else float(lo)
238
+ hi = np.inf if hi is None else float(hi)
239
+ for cand in (v * 5.0, v / 5.0, (lo + hi) / 2.0 if np.isfinite(lo + hi) else v):
240
+ c = min(max(cand, lo), hi)
241
+ if not np.isclose(c, v, rtol=1e-6, atol=0.0):
242
+ out[i] = c
243
+ break
244
+ return out
245
+
246
+
247
+ def verify_invariance(r, replicate, param_names, x0, bounds, rtol=1e-8,
248
+ atol=1e-20, verbose=True):
249
+ """Is the pre-dose block independent of the fitted parameters?
250
+
251
+ Integrates it under two different parameter vectors and compares. Entries
252
+ that already differ *before* integration are excluded: those are the fitted
253
+ parameters themselves and whatever ``Update_opt_parameters`` derives from
254
+ them, which are expected to differ and say nothing about the dynamics. Any
255
+ entry that starts equal and ends different is a genuine pre-dose dependence,
256
+ and means the cache must not be used.
257
+
258
+ Returns ``(ok, report)``.
259
+ """
260
+ solver_settings = replicate["Solver_settings"](replicate)
261
+ block, _rest = split_preequil_block(solver_settings)
262
+ if block is None:
263
+ return False, {"reason": "no cacheable pre-dose block",
264
+ "offenders": []}
265
+
266
+ observed_species = replicate["Observed_species"](r)
267
+ x_alt = _alternate_vector(x0, bounds)
268
+ if np.allclose(np.asarray(x0, dtype=float), x_alt):
269
+ return False, {"reason": "could not build a distinct second parameter "
270
+ "vector inside the bounds", "offenders": []}
271
+
272
+ before_a, after_a, meta_a = _setup_and_integrate(
273
+ r, replicate, param_names, x0, block, solver_settings, observed_species)
274
+
275
+ # If the first run needed the retry ladder, it converged at looser
276
+ # tolerances than were requested. Run the second under those same settled
277
+ # settings: otherwise the two integrations differ in how they were computed
278
+ # as well as in their parameters, and the comparison cannot separate the
279
+ # two. This is what made 'Aducanumab_3mgkg' report a false dependence -- its
280
+ # pre-dose block fell back to 1e-7, and two runs at 1e-7 agreeing only to
281
+ # 5e-6 is the solver working correctly, not a parameter acting pre-dose.
282
+ settled, achieved_rel = _achieved_settings(meta_a, solver_settings)
283
+ before_b, after_b, _meta_b = _setup_and_integrate(
284
+ r, replicate, param_names, x_alt, block,
285
+ settled or solver_settings, observed_species)
286
+
287
+ # Two integrations cannot be asked to agree more closely than either was
288
+ # computed. Over seventy years the error accumulates well past the per-step
289
+ # tolerance, so allow three orders of magnitude above it -- still five
290
+ # orders below the ~0.4 relative divergence a genuinely pre-dose-active
291
+ # parameter produces.
292
+ rtol = max(rtol, 1000.0 * achieved_rel)
293
+
294
+ keys = list(after_a["keys"])
295
+ va, vb = np.asarray(after_a["values"], float), np.asarray(after_b["values"], float)
296
+ sa, sb = np.asarray(before_a["values"], float), np.asarray(before_b["values"], float)
297
+
298
+ if not (list(before_a["keys"]) == list(before_b["keys"]) == keys
299
+ and len(keys) == va.size == vb.size == sa.size):
300
+ return False, {"reason": "state layout changed between runs",
301
+ "offenders": []}
302
+
303
+ set_differently = ~np.isclose(sa, sb, rtol=0.0, atol=0.0)
304
+ diverged = ~np.isclose(va, vb, rtol=rtol, atol=atol) & ~set_differently
305
+
306
+ offenders = []
307
+ for i in np.flatnonzero(diverged):
308
+ den = max(abs(va[i]), abs(vb[i]), 1e-300)
309
+ offenders.append({"name": keys[i], "a": float(va[i]), "b": float(vb[i]),
310
+ "rel": float(abs(va[i] - vb[i]) / den)})
311
+ offenders.sort(key=lambda d: -d["rel"])
312
+
313
+ report = {
314
+ "reason": "ok" if not offenders else "pre-dose state depends on the "
315
+ "fitted parameters",
316
+ "n_compared": int((~set_differently).sum()),
317
+ "n_excluded": int(set_differently.sum()),
318
+ "offenders": offenders,
319
+ "block_end": float(block.get("end", float("nan"))),
320
+ "achieved_rel_tol": float(achieved_rel),
321
+ "requested_rel_tol": float(solver_settings.get("relative_tolerance",
322
+ achieved_rel)),
323
+ "compare_rtol": float(rtol),
324
+ "retried": settled is not None,
325
+ }
326
+ if verbose:
327
+ _print_report(replicate, report)
328
+ return (not offenders), report
329
+
330
+
331
+ def _print_report(replicate, report):
332
+ label = replicate.get("Label") or "?"
333
+ if report.get("retried"):
334
+ # Worth saying out loud regardless of the verdict: this arm's pre-dose
335
+ # state is computed at lower accuracy than the spec asks for, and that
336
+ # applies to every run of it, not just to this check.
337
+ print(f"[preequil] note: '{label}' needed the solver retry ladder for "
338
+ f"its pre-dose block and converged at rel_tol="
339
+ f"{report['achieved_rel_tol']:.0e} rather than the requested "
340
+ f"{report['requested_rel_tol']:.0e}; the invariance check was "
341
+ f"compared at {report['compare_rtol']:.0e} to match.")
342
+ if not report["offenders"]:
343
+ print(f"[preequil] invariance check passed on '{label}': "
344
+ f"{report['n_compared']} state value(s) agree after "
345
+ f"{report['block_end'] / 24 / 365:.1f} y of pre-dose integration "
346
+ f"under two different parameter vectors "
347
+ f"({report['n_excluded']} parameter value(s) excluded).")
348
+ return
349
+ print()
350
+ print(f"*** preequil cache DISABLED: the pre-dose segment of '{label}' "
351
+ f"depends on the fitted parameters.")
352
+ print(f" {len(report['offenders'])} state value(s) diverged before the "
353
+ f"first dose; the cache would have replayed a stale state for all of "
354
+ f"them. Largest differences:")
355
+ for d in report["offenders"][:8]:
356
+ print(f" {d['name']:<48} {d['a']:>15.8g} {d['b']:>15.8g} "
357
+ f"rel={d['rel']:.3e}")
358
+ if len(report["offenders"]) > 8:
359
+ print(f" ... and {len(report['offenders']) - 8} more")
360
+ print(f" Runs continue uncached and correct, just slower.")
361
+ print()