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,395 @@
1
+ """
2
+ Append optimization results as a single row to a CSV file, and write a
3
+ per-run JSON snapshot alongside it.
4
+
5
+ CSV columns written:
6
+ timestamp, model_name, experiment_id, method, success, optimizer_message,
7
+ total_loss, aic, bic,
8
+ {param} – optimized value for each parameter
9
+ wald_SE_{param} – Wald standard error (NaN when Hessian is not PD)
10
+ wald_CI95_lower_{param} – lower bound of Wald 95% CI
11
+ wald_CI95_upper_{param} – upper bound of Wald 95% CI
12
+ wald_corr_{a}_{b} – Wald off-diagonal correlation for every pair a < b
13
+ profile_CI95_lower_{param} – lower bound of profile likelihood 95% CI
14
+ profile_CI95_upper_{param} – upper bound of profile likelihood 95% CI
15
+ profile_CI95_status_{param} – ok / flat / open / open_lower / open_upper
16
+ profile_reach_{side}_{param} – why that side stopped: "crossed" (a bound was
17
+ found), "bound" (walked to the parameter's own
18
+ limit without dNLL reaching 1.9207, so it is
19
+ unidentifiable everywhere it is allowed to go) or
20
+ "budget" (the outward extension ran out of steps,
21
+ so nothing was established either way)
22
+ profile_maxdNLL_{side}_{param} – highest dNLL reached on that side
23
+ profile_capped_{param} – profile points whose nuisance optimization hit
24
+ the iteration cap; non-zero means that CI is
25
+ too narrow
26
+ profile_points_not_converged – the same count summed over all parameters
27
+ profile_warm_improved – points the warm-started continuation pass
28
+ lowered; non-zero means the cold grid alone
29
+ would have given narrower intervals
30
+ profile_warm_nats_recovered – total NLL recovered by that pass
31
+
32
+ JSON file (one per run, timestamped to avoid overwrite):
33
+ {base}_{YYYYMMDD_HHMMSS}.json alongside the CSV, with
34
+ metadata – timestamp, model_name, experiment_id, method, success,
35
+ message, total_loss, aic, bic, n_iter, n_fev
36
+ parameters – {name: value} pairs, registry-shaped for direct copy
37
+ into Modules/utils/*_registry.py
38
+ wald_se – {name: SE}
39
+ wald_ci95 – {name: [lo, hi]}
40
+ profile_ci95 – {name: [lo, hi]}
41
+ wald_correlation – {"a|b": corr} for every off-diagonal pair (a < b)
42
+ """
43
+
44
+ import json
45
+ import os
46
+ from datetime import datetime
47
+
48
+ import numpy as np
49
+ import pandas as pd
50
+
51
+
52
+ def _finite_or_none(x):
53
+ """Convert NaN/inf to None so the value is valid strict JSON."""
54
+ try:
55
+ v = float(x)
56
+ except (TypeError, ValueError):
57
+ return None
58
+ return v if np.isfinite(v) else None
59
+
60
+
61
+ def _write_results_row(df_row, csv_path):
62
+ """Append one results row, reconciling the header instead of assuming it.
63
+
64
+ A plain ``mode="a"`` append writes the row's values in the row's own column
65
+ order under whatever header the file already carries, so the moment the two
66
+ disagree every value lands in whichever column happens to occupy its
67
+ position. That is not hypothetical: a Lecanemab CSV whose header still held
68
+ an older parameter set stored CLrecycle_Tissue's confidence interval under
69
+ ``profile_CI95_lower_CLup_Tissue``, and nothing in the file said so. Any run
70
+ that changes the parameter list -- or adds a diagnostic column, as the
71
+ profile reach columns do -- hits this.
72
+
73
+ When the columns match exactly the append is unchanged. When they do not,
74
+ the file is rewritten over the union: older rows keep their own columns,
75
+ this row keeps its, and the gaps are empty. Empty is honest; a positional
76
+ append made them wrong instead.
77
+ """
78
+ if not os.path.exists(csv_path):
79
+ df_row.to_csv(csv_path, index=False)
80
+ return
81
+
82
+ try:
83
+ df_old = pd.read_csv(csv_path)
84
+ except Exception as exc:
85
+ # Unreadable is not a reason to overwrite: whatever is in there is the
86
+ # only copy of the earlier runs, and a file this far gone cannot be
87
+ # repaired without guessing which column each orphaned value belonged
88
+ # to. Append so nothing is lost, and say plainly what is wrong -- rows
89
+ # with differing field counts are what earlier appends under a stale
90
+ # header produced, and only a fresh file gets out of that state.
91
+ print(f" [results] WARNING: {os.path.basename(csv_path)} cannot be "
92
+ f"parsed ({exc}).")
93
+ print(f" Its rows do not all have the same number of columns, which "
94
+ f"is what appending under a stale header produces. The values "
95
+ f"already in it may be filed under the wrong column names.")
96
+ print(f" This row is being appended so nothing is lost, but rename "
97
+ f"or archive that file to start a clean one.")
98
+ df_row.to_csv(csv_path, mode="a", header=False, index=False)
99
+ return
100
+
101
+ if list(df_old.columns) == list(df_row.columns):
102
+ df_row.to_csv(csv_path, mode="a", header=False, index=False)
103
+ return
104
+
105
+ added = [c for c in df_row.columns if c not in df_old.columns]
106
+ dropped = [c for c in df_old.columns if c not in df_row.columns]
107
+ print(f" [results] column set changed ({len(added)} added, "
108
+ f"{len(dropped)} no longer written); rewriting "
109
+ f"{os.path.basename(csv_path)} so earlier rows keep their columns.")
110
+ pd.concat([df_old, df_row], ignore_index=True).to_csv(csv_path, index=False)
111
+
112
+
113
+ def log_optimization_results(
114
+ opt,
115
+ param_names,
116
+ csv_path,
117
+ model_name="",
118
+ experiment_id="",
119
+ method="",
120
+ ):
121
+ """
122
+ Append one row of optimization results to *csv_path*.
123
+
124
+ Parameters
125
+ ----------
126
+ opt : dict
127
+ Return value of ``run_optimization()``. Keys used:
128
+ ``x``, ``fun``, ``success``, ``message``, ``stats``.
129
+ param_names : list[str]
130
+ Parameter names in the same order as ``opt["x"]``.
131
+ csv_path : str
132
+ Absolute path to the target CSV file. Created with a header on the
133
+ first call; subsequent calls append without writing the header again.
134
+ model_name : str, optional
135
+ experiment_id : str, optional
136
+ method : str, optional
137
+ """
138
+ stats = opt.get("stats", {})
139
+
140
+ # ------------------------------------------------------------------ #
141
+ # Build the row as an ordered dict so column order is deterministic. #
142
+ # ------------------------------------------------------------------ #
143
+ row = {}
144
+
145
+ # --- bookkeeping --------------------------------------------------- #
146
+ row["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
147
+ row["model_name"] = model_name
148
+ row["experiment_id"] = experiment_id
149
+ row["method"] = method
150
+
151
+ # --- core optimization results ------------------------------------- #
152
+ row["success"] = opt.get("success", False)
153
+ row["optimizer_message"] = str(opt.get("message", ""))
154
+ row["total_loss"] = float(opt.get("fun", float("nan")))
155
+ row["nll_proper"] = float(stats.get("nll_proper", float("nan")))
156
+
157
+ # --- information criteria ------------------------------------------ #
158
+ row["aic"] = float(stats.get("aic", float("nan")))
159
+ row["bic"] = float(stats.get("bic", float("nan")))
160
+
161
+ # --- parameter values ---------------------------------------------- #
162
+ x_opt = np.atleast_1d(opt.get("x", []))
163
+ for name, val in zip(param_names, x_opt):
164
+ row[name] = float(val)
165
+
166
+ # --- Wald standard errors ------------------------------------------ #
167
+ se = stats.get("wald_se")
168
+ se_arr = np.atleast_1d(se) if se is not None else [float("nan")] * len(param_names)
169
+ for name, s in zip(param_names, se_arr):
170
+ row[f"wald_SE_{name}"] = float(s) if s is not None and not np.isnan(float(s)) else float("nan")
171
+
172
+ # --- Wald 95% confidence intervals --------------------------------- #
173
+ ci = stats.get("wald_ci")
174
+ if ci is None:
175
+ ci = [(float("nan"), float("nan"))] * len(param_names)
176
+ for name, (lo, hi) in zip(param_names, ci):
177
+ row[f"wald_CI95_lower_{name}"] = float(lo)
178
+ row[f"wald_CI95_upper_{name}"] = float(hi)
179
+
180
+ # --- Wald off-diagonal correlations -------------------------------- #
181
+ corr = stats.get("wald_correlation")
182
+ if corr is not None:
183
+ corr = np.atleast_2d(corr)
184
+ for i, a in enumerate(param_names):
185
+ for j, b in enumerate(param_names):
186
+ if j > i:
187
+ val = corr[i, j]
188
+ row[f"wald_corr_{a}_{b}"] = float(val) if np.isfinite(val) else float("nan")
189
+ else:
190
+ for i, a in enumerate(param_names):
191
+ for j, b in enumerate(param_names):
192
+ if j > i:
193
+ row[f"wald_corr_{a}_{b}"] = float("nan")
194
+
195
+ # --- Profile likelihood 95% confidence intervals ------------------- #
196
+ profile_ci = stats.get("profile_ci")
197
+ if profile_ci is None:
198
+ profile_ci = [(float("nan"), float("nan"))] * len(param_names)
199
+ ci_status = stats.get("profile_ci_status") or ["missing"] * len(param_names)
200
+ for name, (lo, hi) in zip(param_names, profile_ci):
201
+ row[f"profile_CI95_lower_{name}"] = float(lo)
202
+ row[f"profile_CI95_upper_{name}"] = float(hi)
203
+ # A bare nan cannot distinguish "flat / non-identifiable" from "grid too
204
+ # narrow", and those call for opposite fixes.
205
+ for name, st in zip(param_names, ci_status):
206
+ row[f"profile_CI95_status_{name}"] = st
207
+
208
+ # Why each side stopped: "crossed" (a bound was found), "bound" (the profile
209
+ # walked to the parameter's own limit without dNLL reaching 1.9207, so it is
210
+ # unidentifiable everywhere it is allowed to go), or "budget" (the outward
211
+ # extension ran out of steps, so nothing has been established either way).
212
+ # The middle case is a result and the last is an unfinished run; a status of
213
+ # "open" alone cannot tell them apart, and only one of them is worth
214
+ # re-running with a wider grid.
215
+ _reach = (stats.get("profile_convergence") or {}).get("reach") or {}
216
+ for name in param_names:
217
+ sides = _reach.get(name) or {}
218
+ for key in ("lower", "upper"):
219
+ d = sides.get(key) or {}
220
+ row[f"profile_reach_{key}_{name}"] = d.get("state", "missing")
221
+ row[f"profile_maxdNLL_{key}_{name}"] = float(
222
+ d.get("max_dnll") if d.get("max_dnll") is not None else float("nan"))
223
+
224
+ # Nuisance optimizations that stopped on the iteration cap rather than
225
+ # converging. Those points overstate the profile, so the CI above them is
226
+ # too narrow -- the caveat has to travel with the interval into the CSV, not
227
+ # live only in the console log of the run that produced it.
228
+ _conv = stats.get("profile_convergence") or {}
229
+ _per_param = _conv.get("per_param", {})
230
+ row["profile_points_not_converged"] = float(_conv.get("n_not_converged", 0))
231
+
232
+ # What the warm-started continuation pass recovered. Non-zero means the
233
+ # cold-started grid alone would have reported narrower intervals than these,
234
+ # which is the bias that pass measures and removes.
235
+ _warm = _conv.get("warm") or {}
236
+ row["profile_warm_improved"] = float(_warm.get("n_improved", 0))
237
+ row["profile_warm_nats_recovered"] = float(_warm.get("nats_recovered", 0.0))
238
+ for name in param_names:
239
+ row[f"profile_capped_{name}"] = float(
240
+ (_per_param.get(name) or {}).get("n_not_converged", 0)
241
+ )
242
+
243
+ # How far below the reported optimum the profile got. With one shared
244
+ # objective anything materially negative is a fit convergence failure.
245
+ row["profile_anchor_gap"] = float(stats.get("profile_anchor_gap", 0.0))
246
+ row["k_effective"] = float(stats.get("k_effective", float("nan")))
247
+ row["n_data_points"] = float(stats.get("n_data_points", float("nan")))
248
+
249
+ # ------------------------------------------------------------------ #
250
+ # Console summary #
251
+ # ------------------------------------------------------------------ #
252
+ nit = opt.get("nit")
253
+ nfev = opt.get("nfev")
254
+ iter_str = (f" Iterations: {nit} | Func evals: {nfev}"
255
+ if nit is not None else "")
256
+ print(f'\n{"=" * 80}')
257
+ print(f'OPTIMIZATION COMPLETE [{experiment_id}]')
258
+ print(f'{"=" * 80}')
259
+ print(f' Model: {model_name}')
260
+ print(f' Method: {method}')
261
+ print(f' Success: {opt.get("success", False)}')
262
+ print(f' Message: {opt.get("message", "")}')
263
+ print(f' Final loss: {float(opt.get("fun", float("nan"))):.6e}')
264
+ if iter_str:
265
+ print(iter_str)
266
+ if param_names and len(x_opt) == len(param_names):
267
+ print(f'\n {"Parameter":<45} {"Value":>18}')
268
+ print(f' {"-" * 63}')
269
+ for name, val in zip(param_names, x_opt):
270
+ print(f' {name:<45} {float(val):>18.8e}')
271
+ print(f'{"=" * 80}\n')
272
+
273
+ # ------------------------------------------------------------------ #
274
+ # Append to CSV. #
275
+ # ------------------------------------------------------------------ #
276
+ df_row = pd.DataFrame([row])
277
+ _write_results_row(df_row, csv_path)
278
+ print(f"Optimization results appended to: {csv_path}")
279
+
280
+ # ------------------------------------------------------------------ #
281
+ # Per-run JSON snapshot. The "parameters" block is shaped like the #
282
+ # INDEPENDENT_*_REGISTRY dicts in Modules/utils/ so it can be copied #
283
+ # directly into the registry source. #
284
+ # ------------------------------------------------------------------ #
285
+ ts_compact = datetime.now().strftime("%Y%m%d_%H%M%S")
286
+ base, _ = os.path.splitext(csv_path)
287
+ json_path = f"{base}_{ts_compact}.json"
288
+
289
+ parameters_dict = {
290
+ name: _finite_or_none(val) for name, val in zip(param_names, x_opt)
291
+ }
292
+
293
+ wald_se_dict = {
294
+ name: _finite_or_none(s) for name, s in zip(param_names, se_arr)
295
+ }
296
+ wald_ci_dict = {
297
+ name: [_finite_or_none(lo), _finite_or_none(hi)]
298
+ for name, (lo, hi) in zip(param_names, ci)
299
+ }
300
+ profile_ci_dict = {
301
+ name: [_finite_or_none(lo), _finite_or_none(hi)]
302
+ for name, (lo, hi) in zip(param_names, profile_ci)
303
+ }
304
+
305
+ wald_corr_dict = {}
306
+ if corr is not None:
307
+ for i, a in enumerate(param_names):
308
+ for j, b in enumerate(param_names):
309
+ if j > i:
310
+ wald_corr_dict[f"{a}|{b}"] = _finite_or_none(corr[i, j])
311
+
312
+ snapshot = {
313
+ "metadata": {
314
+ "timestamp": row["timestamp"],
315
+ "model_name": model_name,
316
+ "experiment_id": experiment_id,
317
+ "method": method,
318
+ "success": bool(opt.get("success", False)),
319
+ "message": str(opt.get("message", "")),
320
+ # objective_kind names the function 'total_loss' and every dNLL
321
+ # refer to. Archived runs where it is absent used a weighted
322
+ # chi-square for the fit and a different frozen-sigma NLL for the
323
+ # diagnostics, so their numbers are not comparable with these.
324
+ "objective_kind": "concentrated_gaussian_nll",
325
+ "total_loss": _finite_or_none(opt.get("fun")),
326
+ # Same objective as total_loss, plus the (n/2)(1+log 2pi) constant
327
+ # the fit drops. Absolute, so AIC/BIC need it; it cancels from dNLL.
328
+ "nll_proper": _finite_or_none(stats.get("nll_proper")),
329
+ "aic": _finite_or_none(stats.get("aic")),
330
+ "bic": _finite_or_none(stats.get("bic")),
331
+ # Parameters + one profiled-out sigma per block.
332
+ "k_effective": stats.get("k_effective"),
333
+ "n_data_points": _finite_or_none(stats.get("n_data_points")),
334
+ "profile_anchor_gap": _finite_or_none(stats.get("profile_anchor_gap")),
335
+ "n_iterations": opt.get("nit"),
336
+ "n_fevals": opt.get("nfev"),
337
+ # Recorded so a run is reproducible: fit_mode says whether the
338
+ # optimizer ran at all, and x0 pins down a randomized multi-start.
339
+ "fit_mode": opt.get("fit_mode"),
340
+ # Path of the cached fit the optimum was reused from (a relaunch
341
+ # of the same problem), or null when the optimizer ran in this
342
+ # process.
343
+ "fit_source": opt.get("fit_source"),
344
+ },
345
+ "parameters": parameters_dict,
346
+ "wald_se": wald_se_dict,
347
+ "wald_ci95": wald_ci_dict,
348
+ "profile_ci95": profile_ci_dict,
349
+ "wald_correlation": wald_corr_dict,
350
+ }
351
+
352
+ if opt.get("x0") is not None:
353
+ snapshot["x0"] = {
354
+ name: _finite_or_none(val)
355
+ for name, val in zip(param_names, np.atleast_1d(opt["x0"]))
356
+ }
357
+ if opt.get("parameter_scale") is not None:
358
+ snapshot["parameter_scale"] = dict(zip(param_names, opt["parameter_scale"]))
359
+ if stats.get("curvature_se"):
360
+ snapshot["curvature_se"] = {
361
+ name: _finite_or_none(val)
362
+ for name, val in stats["curvature_se"].items()
363
+ }
364
+
365
+ if stats.get("profile_ci_status"):
366
+ snapshot["profile_ci95_status"] = dict(
367
+ zip(param_names, stats["profile_ci_status"])
368
+ )
369
+ if stats.get("block_sigmas"):
370
+ # Fitted noise level per block, with its point count. Under the
371
+ # concentrated likelihood a block's entire contribution is
372
+ # (n/2)log(sigma^2), so these two numbers say which data the fit is
373
+ # actually being driven by — and an outlying sigma is how a block the
374
+ # model cannot fit announces itself.
375
+ block_n = stats.get("block_n") or {}
376
+ snapshot["block_sigmas"] = {
377
+ k: {"sigma": _finite_or_none(v), "n": block_n.get(k)}
378
+ for k, v in stats["block_sigmas"].items()
379
+ }
380
+ if stats.get("profile_convergence"):
381
+ snapshot["profile_convergence"] = stats["profile_convergence"]
382
+ if stats.get("profile_better_point"):
383
+ snapshot["profile_better_point"] = stats["profile_better_point"]
384
+ if stats.get("fast_profile"):
385
+ # Per-side verdicts on "the interval is closed on this side", from
386
+ # one capped profile point at the slice crossing. See Engine.Fast_profile.
387
+ snapshot["fast_profile"] = stats["fast_profile"]
388
+ if "profile_traces" in stats:
389
+ snapshot["profile_traces"] = stats["profile_traces"]
390
+ if "slice_traces" in stats:
391
+ snapshot["slice_traces"] = stats["slice_traces"]
392
+
393
+ with open(json_path, "w", encoding="utf-8") as f:
394
+ json.dump(snapshot, f, indent=2, allow_nan=False)
395
+ print(f"Optimization snapshot written to: {json_path}")