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.
- framework/AntimonyGen.py +48 -0
- framework/RxnDict_to_antimony.py +594 -0
- framework/TelluriumGen.py +16 -0
- framework/__init__.py +0 -0
- framework/antimony_utils.py +294 -0
- framework/cli.py +229 -0
- framework/data_interpolation.py +340 -0
- framework/isotopomer_tools.py +41 -0
- framework/model_generation.py +46 -0
- framework/models.py +189 -0
- framework/module_base.py +42 -0
- framework/pyantigen.py +51 -0
- framework/rate_laws.py +101 -0
- framework/reaction_creation.py +43 -0
- framework/template/Example/AntiGen_paths.py +23 -0
- framework/template/Example/Engine/Anchor_cache.py +193 -0
- framework/template/Example/Engine/Deadline.py +535 -0
- framework/template/Example/Engine/Evaluator.py +1176 -0
- framework/template/Example/Engine/Event_times.py +491 -0
- framework/template/Example/Engine/Fast_profile.py +701 -0
- framework/template/Example/Engine/Fit_cache.py +329 -0
- framework/template/Example/Engine/Identifiability.py +698 -0
- framework/template/Example/Engine/Model_optimize.py +1483 -0
- framework/template/Example/Engine/Model_simulate.py +124 -0
- framework/template/Example/Engine/Nuisance_sensitivity.py +298 -0
- framework/template/Example/Engine/Optimize.py +6862 -0
- framework/template/Example/Engine/Petab_export.py +398 -0
- framework/template/Example/Engine/Preequil_cache.py +361 -0
- framework/template/Example/Engine/Profile_checkpoint.py +399 -0
- framework/template/Example/Engine/Results.py +395 -0
- framework/template/Example/Engine/Sensitivity_analysis.py +320 -0
- framework/template/Example/Engine/Simulate.py +617 -0
- framework/template/Example/Flipflop_reference.py +401 -0
- framework/template/Example/Model_generate.py +37 -0
- framework/template/Example/Model_run.py +261 -0
- framework/template/Example/Modules/Data.py +63 -0
- framework/template/Example/Modules/Events.py +14 -0
- framework/template/Example/Modules/Experiment.py +194 -0
- framework/template/Example/Modules/Loss_config.py +61 -0
- framework/template/Example/Modules/Observed_species.py +3 -0
- framework/template/Example/Modules/Optimizer_settings.py +258 -0
- framework/template/Example/Modules/Plots.py +89 -0
- framework/template/Example/Modules/Solver_settings.py +16 -0
- framework/template/Example/Modules/Update_opt_parameters.py +24 -0
- framework/template/Example/Modules/Update_parameters.py +49 -0
- framework/template/data/ADneg.csv +27 -0
- framework/template/data/ADpos.csv +27 -0
- framework/template/data/Flipflop.csv +29 -0
- framework/template/data/make_flipflop_data.py +174 -0
- pyantigen-1.0.9.dist-info/METADATA +129 -0
- pyantigen-1.0.9.dist-info/RECORD +55 -0
- pyantigen-1.0.9.dist-info/WHEEL +5 -0
- pyantigen-1.0.9.dist-info/entry_points.txt +2 -0
- pyantigen-1.0.9.dist-info/licenses/LICENSE +21 -0
- pyantigen-1.0.9.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1483 @@
|
|
|
1
|
+
"""
|
|
2
|
+
High-level optimization setup. Two entry points:
|
|
3
|
+
|
|
4
|
+
setup_optimization(settings, optimization_settings, experiment_dict)
|
|
5
|
+
Original flat-dict path: experiments is a plain dict of treatment dicts.
|
|
6
|
+
|
|
7
|
+
setup_optimization_from_groups(settings, optimization_settings, EXPERIMENT_dict)
|
|
8
|
+
Group-aware path: uses Experiment.opt_groups (derived from each replicate's
|
|
9
|
+
Opt_group key) to define which replicates contribute to the objective.
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import pandas as pd
|
|
13
|
+
import AntiGen_paths
|
|
14
|
+
|
|
15
|
+
REPO_ROOT = AntiGen_paths.REPO_ROOT
|
|
16
|
+
|
|
17
|
+
from framework.AntimonyGen import AntimonyGen
|
|
18
|
+
from framework.TelluriumGen import TelluriumGen # used by _run_steady_state
|
|
19
|
+
from Modules.Experiment import *
|
|
20
|
+
from Engine.Optimize import (
|
|
21
|
+
run_optimization,
|
|
22
|
+
run_optimization_from_groups,
|
|
23
|
+
_extract_profile_ci,
|
|
24
|
+
profile_ci_status,
|
|
25
|
+
)
|
|
26
|
+
from Engine.Identifiability import UnidentifiableParameters
|
|
27
|
+
from Modules.Plots import *
|
|
28
|
+
from Engine.Results import log_optimization_results
|
|
29
|
+
from Engine.Petab_export import export_petab
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Diagnostics Presets
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
_FULL_DIAGNOSTICS = {
|
|
37
|
+
"wald_analysis": True,
|
|
38
|
+
"slice_analysis": True,
|
|
39
|
+
"profile_likelihood_analysis": True,
|
|
40
|
+
"sobol_analysis": True,
|
|
41
|
+
"sobol_N": 128,
|
|
42
|
+
"profile_se_span": 10,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_SLICE_ONLY = {
|
|
46
|
+
"wald_analysis": False,
|
|
47
|
+
"slice_analysis": True,
|
|
48
|
+
"profile_likelihood_analysis": False,
|
|
49
|
+
"sobol_analysis": False,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_PROFILE_ONLY = {
|
|
53
|
+
"wald_analysis": True,
|
|
54
|
+
"slice_analysis": False,
|
|
55
|
+
"profile_likelihood_analysis": True,
|
|
56
|
+
"sobol_analysis": False,
|
|
57
|
+
# 4 SE, which is run_parallel_profile's own default; this preset used to
|
|
58
|
+
# override it to 10. The crossing being looked for sits at 1.96 SE for a
|
|
59
|
+
# locally quadratic profile, so a span of 10 spread across n_grid=5 puts
|
|
60
|
+
# points at 2, 4, 6, 8 and 10 SE -- dNLL of roughly 2, 8, 18, 32 and 50,
|
|
61
|
+
# with four of the five deep in a tail whose height nobody needs. At 4 the
|
|
62
|
+
# same five points land at 0.8, 1.6, 2.4, 3.2 and 4.0 SE (dNLL 0.32, 1.28,
|
|
63
|
+
# 2.88, 5.12, 8.0), straddling the threshold with two below and three
|
|
64
|
+
# above, which is what pass 3 refines between.
|
|
65
|
+
#
|
|
66
|
+
# Widening the span was never the right lever for a parameter whose
|
|
67
|
+
# crossing lies further out than Wald predicts: it spends points on every
|
|
68
|
+
# parameter at once, including the ones already answered. max_extend walks
|
|
69
|
+
# outward only where the threshold has not been reached, which is why it
|
|
70
|
+
# exists and why tightening the span here is safe.
|
|
71
|
+
"profile_se_span": 4,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_SOBOL_ONLY = {
|
|
75
|
+
"wald_analysis": False,
|
|
76
|
+
"slice_analysis": False,
|
|
77
|
+
"profile_likelihood_analysis": False,
|
|
78
|
+
"sobol_analysis": True,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# The Hessian alone: O(k^2) evaluations, minutes rather than days. Worth running
|
|
82
|
+
# before any profile, because a Wald SE that is None says the fit is singular in
|
|
83
|
+
# that direction and the profile will spend its whole budget discovering the
|
|
84
|
+
# same thing -- and an SE wider than the distance to the parameter's bound says
|
|
85
|
+
# the box will truncate the interval before the data does.
|
|
86
|
+
_WALD_ONLY = {
|
|
87
|
+
"wald_analysis": True,
|
|
88
|
+
"slice_analysis": False,
|
|
89
|
+
"profile_likelihood_analysis": False,
|
|
90
|
+
"sobol_analysis": False,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# One capped profile point per side at the slice crossing, classified into
|
|
94
|
+
# "proven true / likely true / unlikely true / proven false" on the statement
|
|
95
|
+
# "the interval is closed on this side". Hours rather than days, and every
|
|
96
|
+
# point lands in the checkpoint the full profile reads. See Engine.Fast_profile.
|
|
97
|
+
# The Hessian is needed: the screen places its ladder from the Wald SE, and
|
|
98
|
+
# the local compensation factor in the table comes from the covariance.
|
|
99
|
+
_FAST_PROFILE_ONLY = {
|
|
100
|
+
"wald_analysis": True,
|
|
101
|
+
"slice_analysis": False,
|
|
102
|
+
"profile_likelihood_analysis": False,
|
|
103
|
+
"fast_profile_likelihood_analysis": True,
|
|
104
|
+
"sobol_analysis": False,
|
|
105
|
+
# Evaluations per round and rounds per point; None takes the module
|
|
106
|
+
# defaults (150 x 3). A point stops early once it has a verdict.
|
|
107
|
+
"fast_profile_round_evals": None,
|
|
108
|
+
"fast_profile_rounds": None,
|
|
109
|
+
# dNLL at or below this fraction of the threshold is "near zero".
|
|
110
|
+
"fast_profile_near_zero_frac": None,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_NO_DIAGNOSTICS = {
|
|
114
|
+
"wald_analysis": False,
|
|
115
|
+
"slice_analysis": False,
|
|
116
|
+
"profile_likelihood_analysis": False,
|
|
117
|
+
"sobol_analysis": False,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
DIAGNOSTICS_PRESETS = {
|
|
121
|
+
"_NO_DIAGNOSTICS": _NO_DIAGNOSTICS,
|
|
122
|
+
"_FULL_DIAGNOSTICS": _FULL_DIAGNOSTICS,
|
|
123
|
+
"_SLICE_ONLY": _SLICE_ONLY,
|
|
124
|
+
"_PROFILE_ONLY": _PROFILE_ONLY,
|
|
125
|
+
"_SOBOL_ONLY": _SOBOL_ONLY,
|
|
126
|
+
"_WALD_ONLY": _WALD_ONLY,
|
|
127
|
+
"WALD_ONLY": _WALD_ONLY,
|
|
128
|
+
"WALD": _WALD_ONLY,
|
|
129
|
+
"_FAST_PROFILE_ONLY": _FAST_PROFILE_ONLY,
|
|
130
|
+
"NO_DIAGNOSTICS": _NO_DIAGNOSTICS,
|
|
131
|
+
"FULL_DIAGNOSTICS": _FULL_DIAGNOSTICS,
|
|
132
|
+
"SLICE_ONLY": _SLICE_ONLY,
|
|
133
|
+
"PROFILE_ONLY": _PROFILE_ONLY,
|
|
134
|
+
"SOBOL_ONLY": _SOBOL_ONLY,
|
|
135
|
+
"FAST_PROFILE_ONLY": _FAST_PROFILE_ONLY,
|
|
136
|
+
"FULL": _FULL_DIAGNOSTICS,
|
|
137
|
+
"SLICE": _SLICE_ONLY,
|
|
138
|
+
"PROFILE": _PROFILE_ONLY,
|
|
139
|
+
"SOBOL": _SOBOL_ONLY,
|
|
140
|
+
"FAST_PROFILE": _FAST_PROFILE_ONLY,
|
|
141
|
+
"NO": _NO_DIAGNOSTICS,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# Fork-safe worker (module-level so it is accessible in forked child memory)
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def _profile_fork_worker(result_queue, param_idx, profile_func):
|
|
151
|
+
"""Worker target for fork-based parallel profile on Linux/Mac."""
|
|
152
|
+
try:
|
|
153
|
+
pv, nr = profile_func(param_idx)
|
|
154
|
+
result_queue.put((param_idx, pv, nr, None))
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
result_queue.put((param_idx, None, None, str(exc)))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Shared steady-state helper
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def _run_steady_state(model_text, paths, settings):
|
|
164
|
+
"""Compute steady state and update InitialConditions CSV."""
|
|
165
|
+
ic_path = os.path.join(
|
|
166
|
+
paths["repo_root"], "antimony_models",
|
|
167
|
+
paths["MODEL_NAME"], f"{paths['MODEL_NAME']}_InitialConditions.csv",
|
|
168
|
+
)
|
|
169
|
+
rss = TelluriumGen(model_text, paths, settings)
|
|
170
|
+
print("Steady state:", rss.steadyState())
|
|
171
|
+
if os.path.exists(ic_path):
|
|
172
|
+
df_ic = pd.read_csv(ic_path)
|
|
173
|
+
if 'Species' in df_ic.columns:
|
|
174
|
+
max_val, vals = 0.0, {}
|
|
175
|
+
for idx, row in df_ic.iterrows():
|
|
176
|
+
try:
|
|
177
|
+
val = rss[row['Species']]
|
|
178
|
+
vals[idx] = val
|
|
179
|
+
if val > max_val:
|
|
180
|
+
max_val = val
|
|
181
|
+
except RuntimeError:
|
|
182
|
+
pass
|
|
183
|
+
for idx, val in vals.items():
|
|
184
|
+
df_ic.at[idx, 'InitialCondition'] = 0.0 if val < 1e-10 * max_val else val
|
|
185
|
+
df_ic.to_csv(ic_path, index=False)
|
|
186
|
+
print(f"Updated InitialConditions in {ic_path}")
|
|
187
|
+
return rss
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
_PROFILE_CI_THRESHOLD = 1.9207 # chi2(df=1, p=0.95) / 2
|
|
191
|
+
|
|
192
|
+
# A profile flatter than this over its whole grid carries no information about
|
|
193
|
+
# the parameter: it is structurally non-identifiable, not merely poorly bounded.
|
|
194
|
+
# Well below the threshold, well above nuisance-optimizer noise.
|
|
195
|
+
_PROFILE_FLAT_TOL = 1e-3
|
|
196
|
+
|
|
197
|
+
# How far a slice may sit from zero at the fitted point, or below zero anywhere,
|
|
198
|
+
# before it is called out. Same magnitude as the profile's anchor rule, so the
|
|
199
|
+
# two diagnostics agree on what counts as "below the reported optimum", and far
|
|
200
|
+
# enough above solver noise that a converged fit stays quiet.
|
|
201
|
+
_SLICE_DNLL_TOL = 1e-3
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _shutdown_evaluator(opt):
|
|
205
|
+
"""Close the worker pool once the diagnostic closures have been consumed.
|
|
206
|
+
|
|
207
|
+
run_optimization_from_groups cannot close it itself: the profile/slice
|
|
208
|
+
closures it returns are called from here, after it returns.
|
|
209
|
+
"""
|
|
210
|
+
if not isinstance(opt, dict):
|
|
211
|
+
return
|
|
212
|
+
ev = opt.get("stats", {}).pop("_evaluator", None)
|
|
213
|
+
if ev is None:
|
|
214
|
+
return
|
|
215
|
+
try:
|
|
216
|
+
print(f"[pool] shutting down after {ev.n_evals} evaluation(s)"
|
|
217
|
+
+ (f", {ev.n_failures} failed" if ev.n_failures else ""))
|
|
218
|
+
ev.shutdown()
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
print(f"[pool] shutdown warning: {exc}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# Grid controls for the parallel profile, and the settings key that reaches each.
|
|
224
|
+
# The engine holds the defaults; only keys a run actually sets are forwarded, so
|
|
225
|
+
# there is one place to change a default rather than two.
|
|
226
|
+
_PROFILE_GRID_SETTINGS = {
|
|
227
|
+
"profile_se_span": "se_span",
|
|
228
|
+
"profile_n_grid": "n_grid",
|
|
229
|
+
"profile_n_refine": "n_refine",
|
|
230
|
+
"profile_range_factor": "range_factor",
|
|
231
|
+
"profile_warm_passes": "warm_passes",
|
|
232
|
+
"profile_max_extend": "max_extend",
|
|
233
|
+
"profile_extend_growth": "extend_growth",
|
|
234
|
+
"profile_bracket_rtol": "bracket_rtol",
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _profile_kwargs(settings, optimization_spec=None):
|
|
239
|
+
"""Profile grid arguments for this run, from the settings and the spec.
|
|
240
|
+
|
|
241
|
+
Two sources, and the spec wins. A run's diagnostics preset says what the
|
|
242
|
+
operator asked for today; grid density is a property of how expensive one
|
|
243
|
+
spec's evaluations are, and the two specs sharing ``_PROFILE_ONLY`` differ
|
|
244
|
+
by an order of magnitude in that cost. A spec states its own under
|
|
245
|
+
``optimizer_kwargs["profile_grid"]``, keyed by the engine's own argument
|
|
246
|
+
names (``n_grid``, ``se_span``, ...). An unknown key is an error rather
|
|
247
|
+
than a silent no-op: a typo there would otherwise look exactly like a
|
|
248
|
+
setting that did not help.
|
|
249
|
+
|
|
250
|
+
``max_extend`` is the one to reach for when a CI comes back open. The
|
|
251
|
+
opening grid is placed from the Wald SE (``se_span``) or, when the Hessian
|
|
252
|
+
gave no SE, from ``range_factor``; whichever it is, it is a guess about
|
|
253
|
+
where dNLL reaches 1.9207, and ``max_extend`` bounds how many times a side
|
|
254
|
+
that guessed short may step further out before giving up. Raising
|
|
255
|
+
``se_span`` widens the opening guess for *every* parameter at once, which
|
|
256
|
+
spends points on the ones already answered; raising ``max_extend`` spends
|
|
257
|
+
them only where the answer is still missing.
|
|
258
|
+
|
|
259
|
+
``bracket_rtol`` is the opposite control: how precisely a crossing that has
|
|
260
|
+
been bracketed needs to be located before probing stops. It is a relative
|
|
261
|
+
precision on the confidence bound, so 0.05 means "to within 5%" -- far finer
|
|
262
|
+
than identifiability needs, and the budget it releases goes to the sides
|
|
263
|
+
that have not reached the threshold at all.
|
|
264
|
+
"""
|
|
265
|
+
out = {}
|
|
266
|
+
for key, arg in _PROFILE_GRID_SETTINGS.items():
|
|
267
|
+
value = (settings or {}).get(key)
|
|
268
|
+
if value is not None:
|
|
269
|
+
out[arg] = value
|
|
270
|
+
|
|
271
|
+
spec_kwargs = getattr(optimization_spec, "optimizer_kwargs", None) or {}
|
|
272
|
+
known = set(_PROFILE_GRID_SETTINGS.values())
|
|
273
|
+
for arg, value in (spec_kwargs.get("profile_grid") or {}).items():
|
|
274
|
+
if arg not in known:
|
|
275
|
+
raise ValueError(
|
|
276
|
+
f"profile_grid key {arg!r} is not a profile grid argument; "
|
|
277
|
+
f"expected one of {sorted(known)}"
|
|
278
|
+
)
|
|
279
|
+
if value is not None:
|
|
280
|
+
out[arg] = value
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
_FAST_PROFILE_SETTINGS = {
|
|
285
|
+
"fast_profile_round_evals": "round_evals",
|
|
286
|
+
"fast_profile_rounds": "n_rounds",
|
|
287
|
+
"fast_profile_near_zero_frac": "near_zero_frac",
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _fast_profile_kwargs(settings, optimization_spec=None):
|
|
292
|
+
"""Arguments for the fast profile pass, from the settings and the spec.
|
|
293
|
+
|
|
294
|
+
The round sizes come from the run settings only. The screen's reach comes
|
|
295
|
+
from the spec's ``profile_grid`` where it states one, so the fast pass and
|
|
296
|
+
the full profile walk the same distance and their screens are the same
|
|
297
|
+
file.
|
|
298
|
+
"""
|
|
299
|
+
out = {}
|
|
300
|
+
for key, arg in _FAST_PROFILE_SETTINGS.items():
|
|
301
|
+
value = (settings or {}).get(key)
|
|
302
|
+
if value is not None:
|
|
303
|
+
out[arg] = value
|
|
304
|
+
spec_kwargs = getattr(optimization_spec, "optimizer_kwargs", None) or {}
|
|
305
|
+
grid = spec_kwargs.get("profile_grid") or {}
|
|
306
|
+
for arg in ("screen_span_decades", "screen_min_reach_decades"):
|
|
307
|
+
if grid.get(arg) is not None:
|
|
308
|
+
out[arg] = grid[arg]
|
|
309
|
+
return out
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _run_fast_profile_report(opt, settings, optimization_spec=None):
|
|
313
|
+
"""Run the fast pass, print its summary table, keep it for the snapshot."""
|
|
314
|
+
from Engine.Fast_profile import fast_profile_summary, print_fast_profile_report
|
|
315
|
+
|
|
316
|
+
fast_all = opt.get("stats", {}).get("fast_profile_all")
|
|
317
|
+
if fast_all is None:
|
|
318
|
+
return None
|
|
319
|
+
try:
|
|
320
|
+
report = fast_all(**_fast_profile_kwargs(settings, optimization_spec))
|
|
321
|
+
except Exception as exc:
|
|
322
|
+
import traceback
|
|
323
|
+
print(f" fast profile failed ({exc}).")
|
|
324
|
+
traceback.print_exc()
|
|
325
|
+
return None
|
|
326
|
+
print_fast_profile_report(report)
|
|
327
|
+
opt["stats"]["fast_profile"] = fast_profile_summary(report)
|
|
328
|
+
return report
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# How far past the threshold crossing the profile figure extends, as a fraction
|
|
332
|
+
# of the crossing's own distance from the optimum. Enough to show the curve
|
|
333
|
+
# continuing past the bound without letting a grid that was clipped to a
|
|
334
|
+
# far-away parameter bound dictate the scale.
|
|
335
|
+
_PROFILE_PLOT_MARGIN = 0.25
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _profile_plot_x(cache, params_estimated, profile_ci=None,
|
|
339
|
+
margin=_PROFILE_PLOT_MARGIN):
|
|
340
|
+
"""The x window for the profile figure, and whether a log axis is usable.
|
|
341
|
+
|
|
342
|
+
Returns ``((lo, hi), use_log)``, or ``(None, use_log)`` when there is
|
|
343
|
+
nothing to plot.
|
|
344
|
+
|
|
345
|
+
The window is anchored on the threshold crossings -- the confidence bounds --
|
|
346
|
+
and extends *margin* beyond them, measured as a fraction of each crossing's
|
|
347
|
+
own distance from the optimum. That distance is multiplicative on a log
|
|
348
|
+
axis, so the margin is too.
|
|
349
|
+
|
|
350
|
+
Anchoring on the crossing rather than on the data is what keeps the figure
|
|
351
|
+
readable. Two earlier rules both failed, in opposite directions. The
|
|
352
|
+
original fixed window of [0.2, 3.5] was too narrow: it pre-dated the outward
|
|
353
|
+
extension pass, so a crossing at a fifth of the optimum fell off the left
|
|
354
|
+
edge. Spanning every computed point instead was too wide: a grid clipped to
|
|
355
|
+
a parameter bound a thousand-fold below the optimum -- which is what the
|
|
356
|
+
GantenerumabIV and Donanemab specs produce -- pushed the whole informative
|
|
357
|
+
region, crossings included, into a sliver at the right-hand edge.
|
|
358
|
+
|
|
359
|
+
A side with no crossing has no anchor, so it falls back to how far it was
|
|
360
|
+
actually walked: that a profile ran three decades without reaching the
|
|
361
|
+
threshold is the finding for that parameter, and cropping it would hide the
|
|
362
|
+
one thing worth seeing. The window never extends past the computed data
|
|
363
|
+
either, so it cannot imply points that were never evaluated.
|
|
364
|
+
|
|
365
|
+
A log axis is used whenever every plotted ratio is positive, which is nearly
|
|
366
|
+
always -- these are rate constants, volumes and clearances, and extension
|
|
367
|
+
walks them over decades, which a linear axis cannot show at all.
|
|
368
|
+
"""
|
|
369
|
+
import numpy as np
|
|
370
|
+
|
|
371
|
+
ci = list(profile_ci or [])
|
|
372
|
+
lo_all, hi_all = np.inf, -np.inf
|
|
373
|
+
all_positive = True
|
|
374
|
+
ratios = {}
|
|
375
|
+
for idx, cached in cache.items():
|
|
376
|
+
pv, _nr = cached
|
|
377
|
+
r = np.asarray(pv, dtype=float) / params_estimated[idx]
|
|
378
|
+
r = r[np.isfinite(r)]
|
|
379
|
+
if r.size == 0:
|
|
380
|
+
continue
|
|
381
|
+
ratios[idx] = r
|
|
382
|
+
if np.any(r <= 0):
|
|
383
|
+
all_positive = False
|
|
384
|
+
lo_all, hi_all = min(lo_all, r.min()), max(hi_all, r.max())
|
|
385
|
+
|
|
386
|
+
if not ratios or not (np.isfinite(lo_all) and np.isfinite(hi_all)):
|
|
387
|
+
return None, False
|
|
388
|
+
use_log = all_positive and lo_all > 0
|
|
389
|
+
|
|
390
|
+
def dist(ratio, sign):
|
|
391
|
+
"""How far *ratio* lies from the optimum on side *sign*, or None."""
|
|
392
|
+
if use_log:
|
|
393
|
+
if ratio <= 0:
|
|
394
|
+
return None
|
|
395
|
+
d = -np.log10(ratio) if sign < 0 else np.log10(ratio)
|
|
396
|
+
else:
|
|
397
|
+
d = (1.0 - ratio) if sign < 0 else (ratio - 1.0)
|
|
398
|
+
return float(d) if d > 0 else None
|
|
399
|
+
|
|
400
|
+
reach = {-1: 0.0, +1: 0.0}
|
|
401
|
+
for idx, r in ratios.items():
|
|
402
|
+
opt_v = float(params_estimated[idx])
|
|
403
|
+
bounds = ci[idx] if idx < len(ci) else (np.nan, np.nan)
|
|
404
|
+
for sign, ci_val in ((-1, bounds[0]), (+1, bounds[1])):
|
|
405
|
+
side = r[r < 1.0] if sign < 0 else r[r > 1.0]
|
|
406
|
+
d_side = [dist(v, sign) for v in side]
|
|
407
|
+
d_data = max([d for d in d_side if d is not None], default=0.0)
|
|
408
|
+
d_ci = None
|
|
409
|
+
try:
|
|
410
|
+
if opt_v != 0 and np.isfinite(float(ci_val)):
|
|
411
|
+
d_ci = dist(float(ci_val) / opt_v, sign)
|
|
412
|
+
except (TypeError, ValueError):
|
|
413
|
+
d_ci = None
|
|
414
|
+
# Never draw further out than the profile was actually computed.
|
|
415
|
+
d = min(d_ci * (1.0 + margin), d_data) if d_ci else d_data
|
|
416
|
+
reach[sign] = max(reach[sign], d)
|
|
417
|
+
|
|
418
|
+
if reach[-1] <= 0 and reach[+1] <= 0:
|
|
419
|
+
# Everything sits on the optimum: give it an interval to be drawn in.
|
|
420
|
+
return ((lo_all / 1.5, hi_all * 1.5), True) if use_log \
|
|
421
|
+
else ((lo_all - 0.5, hi_all + 0.5), False)
|
|
422
|
+
|
|
423
|
+
if use_log:
|
|
424
|
+
lo, hi = 10.0 ** -reach[-1], 10.0 ** reach[+1]
|
|
425
|
+
pad = max((hi / lo) ** 0.03, 1.05)
|
|
426
|
+
return (lo / pad, hi * pad), True
|
|
427
|
+
lo, hi = 1.0 - reach[-1], 1.0 + reach[+1]
|
|
428
|
+
span = max(hi - lo, 1e-12)
|
|
429
|
+
return (lo - 0.03 * span, hi + 0.03 * span), False
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _fmt_g(x, width=0, prec=6):
|
|
433
|
+
"""Format a number for the report, or "n/a" when it is missing.
|
|
434
|
+
|
|
435
|
+
The report is printed to a Windows console as well as written to a UTF-8
|
|
436
|
+
file, and that console is routinely on a codepage where en/em dashes come
|
|
437
|
+
out as replacement characters, so this whole report stays ASCII.
|
|
438
|
+
"""
|
|
439
|
+
import numpy as np
|
|
440
|
+
try:
|
|
441
|
+
v = float(x)
|
|
442
|
+
except (TypeError, ValueError):
|
|
443
|
+
return "n/a".rjust(width)
|
|
444
|
+
if not np.isfinite(v):
|
|
445
|
+
return "n/a".rjust(width)
|
|
446
|
+
return f"{v:.{prec}g}".rjust(width)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _profile_report_text(opt, param_names, params_estimated, profile_ci,
|
|
450
|
+
profile_ci_state, cache, model_name, tag, stamp,
|
|
451
|
+
plot_files=()):
|
|
452
|
+
"""Consolidated profile-likelihood summary: the intervals and what backs them.
|
|
453
|
+
|
|
454
|
+
The console reports each interval as it is extracted, interleaved with that
|
|
455
|
+
parameter's advice. That is the right shape while a run is in progress and
|
|
456
|
+
the wrong shape afterwards: the question a profile exists to answer -- which
|
|
457
|
+
parameters are identifiable, and how far each bound can be trusted -- has to
|
|
458
|
+
be reassembled by eye from lines scattered through thousands of solver
|
|
459
|
+
messages. This gathers it in one place and writes it next to the figures, so
|
|
460
|
+
a run's conclusion outlives its terminal scrollback.
|
|
461
|
+
|
|
462
|
+
Every number here is one the reader would otherwise have to hunt for or
|
|
463
|
+
recompute: the interval as a multiple of the optimum (these are rate
|
|
464
|
+
constants and clearances, where a factor is the meaningful unit, not a
|
|
465
|
+
difference), how far each side was actually walked and how high it got,
|
|
466
|
+
whether a missing bound is a result or an unfinished search, and how much of
|
|
467
|
+
the curve rests on nuisance optimizations that never converged.
|
|
468
|
+
"""
|
|
469
|
+
import numpy as np
|
|
470
|
+
|
|
471
|
+
stats = opt.get("stats", {}) or {}
|
|
472
|
+
conv = stats.get("profile_convergence") or {}
|
|
473
|
+
reach = conv.get("reach") or {}
|
|
474
|
+
per_param = conv.get("per_param") or {}
|
|
475
|
+
warm = conv.get("warm") or {}
|
|
476
|
+
anchor = float(stats.get("profile_anchor_gap", 0.0) or 0.0)
|
|
477
|
+
|
|
478
|
+
se_raw = stats.get("wald_se")
|
|
479
|
+
se_arr = (np.atleast_1d(se_raw) if se_raw is not None
|
|
480
|
+
else np.full(len(param_names), np.nan))
|
|
481
|
+
wald_ci = stats.get("wald_ci") or [(np.nan, np.nan)] * len(param_names)
|
|
482
|
+
|
|
483
|
+
_reach_word = {"crossed": "crossed the threshold",
|
|
484
|
+
"bound": "stopped at the parameter bound",
|
|
485
|
+
"budget": "ran out of extension steps",
|
|
486
|
+
"empty": "no points",
|
|
487
|
+
"missing": "not computed"}
|
|
488
|
+
|
|
489
|
+
L = []
|
|
490
|
+
rule = "=" * 78
|
|
491
|
+
L.append(rule)
|
|
492
|
+
L.append(f"PROFILE LIKELIHOOD SUMMARY - {model_name} [{tag}]")
|
|
493
|
+
L.append(f"generated {stamp}")
|
|
494
|
+
L.append(rule)
|
|
495
|
+
L.append("")
|
|
496
|
+
L.append(f"Threshold dNLL = {_PROFILE_CI_THRESHOLD:.4f} "
|
|
497
|
+
f"(chi2(df=1, p=0.95) / 2)")
|
|
498
|
+
_running = conv.get("n_interrupted", 0)
|
|
499
|
+
L.append(f"Profile points {conv.get('n_points', 0)} total, "
|
|
500
|
+
f"{conv.get('n_not_converged', 0)} hit the optimizer cap, "
|
|
501
|
+
f"{conv.get('n_unknown', 0)} unknown"
|
|
502
|
+
+ (f", {_running} still in progress" if _running else ""))
|
|
503
|
+
if _running:
|
|
504
|
+
L.append(f"*** INCOMPLETE: {_running} point(s) were stopped by the "
|
|
505
|
+
f"wall clock with their state saved, not by the optimizer.")
|
|
506
|
+
L.append(" Their dNLL is an upper bound, so every interval below is "
|
|
507
|
+
"provisional and too narrow. Relaunch to continue.")
|
|
508
|
+
if warm.get("n_attempted"):
|
|
509
|
+
L.append(f"Warm continuation {warm.get('n_improved', 0)} of "
|
|
510
|
+
f"{warm['n_attempted']} point(s) improved, "
|
|
511
|
+
f"{warm.get('nats_recovered', 0.0):.4g} nats recovered")
|
|
512
|
+
if anchor < -1e-3:
|
|
513
|
+
L.append(f"*** The profile found a point {abs(anchor):.4g} nats BELOW "
|
|
514
|
+
f"the reported optimum: the fit has not converged, and the")
|
|
515
|
+
L.append(f" parameter values above are not the MLE. Refit before "
|
|
516
|
+
f"quoting anything here.")
|
|
517
|
+
bp = conv.get("better_point") or {}
|
|
518
|
+
if bp:
|
|
519
|
+
L.append("")
|
|
520
|
+
L.append(f" lowest NLL found {bp['nll']:.10g}")
|
|
521
|
+
L.append(f" found while profiling {bp['parameter']} "
|
|
522
|
+
f"= {bp['value']:.8g}")
|
|
523
|
+
xs, names = bp.get("x"), bp.get("param_names")
|
|
524
|
+
if xs and names and len(xs) == len(names):
|
|
525
|
+
L.append(" restart the fit from these values:")
|
|
526
|
+
width = max(len(n) for n in names)
|
|
527
|
+
for n, v in zip(names, xs):
|
|
528
|
+
L.append(f" {n:<{width}} {v:.8e}")
|
|
529
|
+
else:
|
|
530
|
+
L.append("Anchor the fit sits at the profile minimum")
|
|
531
|
+
L.append("")
|
|
532
|
+
|
|
533
|
+
L.append("-" * 78)
|
|
534
|
+
L.append("PARAMETERS")
|
|
535
|
+
L.append("-" * 78)
|
|
536
|
+
for i, pname in enumerate(param_names):
|
|
537
|
+
opt_val = float(params_estimated[i])
|
|
538
|
+
lo, hi = profile_ci[i] if i < len(profile_ci) else (np.nan, np.nan)
|
|
539
|
+
status = (profile_ci_state[i] if i < len(profile_ci_state)
|
|
540
|
+
else "missing")
|
|
541
|
+
sides = reach.get(pname) or {}
|
|
542
|
+
pp = per_param.get(pname) or {}
|
|
543
|
+
|
|
544
|
+
L.append("")
|
|
545
|
+
L.append(f"{pname}")
|
|
546
|
+
L.append(f" optimum {_fmt_g(opt_val)}")
|
|
547
|
+
L.append(f" profile 95% CI [{_fmt_g(lo)}, {_fmt_g(hi)}]"
|
|
548
|
+
f" status: {status}")
|
|
549
|
+
# A multiplicative read of the interval. For rate constants and
|
|
550
|
+
# clearances "between 0.76x and 1.7x of the fitted value" is the
|
|
551
|
+
# statement a modeller can act on; the absolute bounds above are not.
|
|
552
|
+
if opt_val > 0 and np.isfinite(lo) and np.isfinite(hi) and lo > 0:
|
|
553
|
+
L.append(f" as a factor [{lo / opt_val:.4g}x, "
|
|
554
|
+
f"{hi / opt_val:.4g}x] of the optimum "
|
|
555
|
+
f"(spans {hi / lo:.4g}x)")
|
|
556
|
+
for key in ("lower", "upper"):
|
|
557
|
+
d = sides.get(key)
|
|
558
|
+
if not d:
|
|
559
|
+
continue
|
|
560
|
+
word = _reach_word.get(d["state"], d["state"])
|
|
561
|
+
L.append(f" {key + ' side':<16} {word}; walked to "
|
|
562
|
+
f"{_fmt_g(d.get('reach'))}, highest dNLL "
|
|
563
|
+
f"{_fmt_g(d.get('max_dnll'), prec=4)}")
|
|
564
|
+
se_i = float(se_arr[i]) if i < len(se_arr) else np.nan
|
|
565
|
+
wlo, whi = (wald_ci[i] if i < len(wald_ci) else (np.nan, np.nan))
|
|
566
|
+
if np.isfinite(se_i):
|
|
567
|
+
L.append(f" Wald SE {_fmt_g(se_i)} "
|
|
568
|
+
f"Wald 95% CI [{_fmt_g(wlo)}, {_fmt_g(whi)}]")
|
|
569
|
+
else:
|
|
570
|
+
# Worth saying explicitly: no SE means the Hessian was singular in
|
|
571
|
+
# this direction, which is also why the opening grid for this
|
|
572
|
+
# parameter came from range_factor rather than from a curvature
|
|
573
|
+
# estimate.
|
|
574
|
+
L.append(f" Wald SE none - the Hessian was singular in "
|
|
575
|
+
f"this direction")
|
|
576
|
+
if pp:
|
|
577
|
+
capped = pp.get("n_not_converged", 0)
|
|
578
|
+
running = pp.get("n_interrupted", 0)
|
|
579
|
+
parts = []
|
|
580
|
+
if capped:
|
|
581
|
+
# Hitting the evaluation cap is not by itself evidence of
|
|
582
|
+
# anything. Measured on the engine's own 15-parameter
|
|
583
|
+
# ill-conditioned quadratic, 79% of points stop on a cap of
|
|
584
|
+
# 1500 while the intervals they produce are within 0.02% of the
|
|
585
|
+
# analytic answer: the search had reached the minimum and was
|
|
586
|
+
# polishing. Reporting "this interval is too narrow" on every
|
|
587
|
+
# such point cried wolf on healthy runs and buried the real
|
|
588
|
+
# ones.
|
|
589
|
+
#
|
|
590
|
+
# What does measure it is how far the warm pass moved the
|
|
591
|
+
# point. That is a direct observation of how much the earlier
|
|
592
|
+
# value was above the profile, so it is what the verdict is
|
|
593
|
+
# based on when it is available.
|
|
594
|
+
near = pp.get("warm_gain_near")
|
|
595
|
+
measured = pp.get("n_warm_measured", 0)
|
|
596
|
+
if not measured:
|
|
597
|
+
verdict = ("not yet re-run warm, so the bias is "
|
|
598
|
+
"unmeasured")
|
|
599
|
+
elif near is None or not np.isfinite(near):
|
|
600
|
+
verdict = "bias unmeasured"
|
|
601
|
+
elif near > 0.1 * _PROFILE_CI_THRESHOLD:
|
|
602
|
+
verdict = (f"the warm pass still lowered points near the "
|
|
603
|
+
f"crossing by up to {near:.3g} nats, so this "
|
|
604
|
+
f"interval is too narrow")
|
|
605
|
+
elif near > 0:
|
|
606
|
+
verdict = (f"but the warm pass moved them by at most "
|
|
607
|
+
f"{near:.3g} nats near the crossing, which does "
|
|
608
|
+
f"not move the bound")
|
|
609
|
+
else:
|
|
610
|
+
verdict = ("and the warm pass did not lower them, so the "
|
|
611
|
+
"cap was polishing a minimum already reached")
|
|
612
|
+
parts.append(f"{capped} hit the optimizer cap ({verdict})")
|
|
613
|
+
if running:
|
|
614
|
+
parts.append(f"{running} still in progress")
|
|
615
|
+
tail = (", " + ", ".join(parts)) if parts else ""
|
|
616
|
+
L.append(f" points {pp.get('n', 0)}{tail}")
|
|
617
|
+
if status == "flat":
|
|
618
|
+
L.append(" NOTE the profile is flat: this parameter "
|
|
619
|
+
"is structurally non-identifiable")
|
|
620
|
+
|
|
621
|
+
# ── The verdict, which is the reason anyone opens this file ──────────
|
|
622
|
+
n_ok = sum(1 for s in profile_ci_state if s == "ok")
|
|
623
|
+
n_one = sum(1 for s in profile_ci_state if s in ("open_lower", "open_upper"))
|
|
624
|
+
n_open = sum(1 for s in profile_ci_state if s == "open")
|
|
625
|
+
n_flat = sum(1 for s in profile_ci_state if s == "flat")
|
|
626
|
+
at_bound, at_budget = [], []
|
|
627
|
+
for pname, sides in reach.items():
|
|
628
|
+
for key, d in sides.items():
|
|
629
|
+
if d.get("state") == "bound":
|
|
630
|
+
at_bound.append(f"{pname} ({key})")
|
|
631
|
+
elif d.get("state") == "budget":
|
|
632
|
+
at_budget.append(f"{pname} ({key})")
|
|
633
|
+
|
|
634
|
+
L.append("")
|
|
635
|
+
L.append("-" * 78)
|
|
636
|
+
L.append("IDENTIFIABILITY")
|
|
637
|
+
L.append("-" * 78)
|
|
638
|
+
L.append(f" both bounds found {n_ok} of {len(param_names)}")
|
|
639
|
+
if n_one:
|
|
640
|
+
L.append(f" one bound only {n_one}")
|
|
641
|
+
if n_open:
|
|
642
|
+
L.append(f" neither bound found {n_open}")
|
|
643
|
+
if n_flat:
|
|
644
|
+
L.append(f" flat (non-identifiable) {n_flat}")
|
|
645
|
+
|
|
646
|
+
if at_bound:
|
|
647
|
+
L.append("")
|
|
648
|
+
L.append(" Walked to the parameter's own bound without reaching the")
|
|
649
|
+
L.append(" threshold - not identifiable anywhere it is allowed to go.")
|
|
650
|
+
L.append(" Widen that bound only if wider values are physical:")
|
|
651
|
+
for s in at_bound[:20]:
|
|
652
|
+
L.append(f" {s}")
|
|
653
|
+
if len(at_bound) > 20:
|
|
654
|
+
L.append(f" ... and {len(at_bound) - 20} more")
|
|
655
|
+
if at_budget:
|
|
656
|
+
L.append("")
|
|
657
|
+
L.append(" Ran out of extension steps - nothing established either")
|
|
658
|
+
L.append(" way. Raise profile_max_extend and re-run; the checkpoint")
|
|
659
|
+
L.append(" keeps the points already computed:")
|
|
660
|
+
for s in at_budget[:20]:
|
|
661
|
+
L.append(f" {s}")
|
|
662
|
+
if len(at_budget) > 20:
|
|
663
|
+
L.append(f" ... and {len(at_budget) - 20} more")
|
|
664
|
+
|
|
665
|
+
if plot_files:
|
|
666
|
+
L.append("")
|
|
667
|
+
L.append("-" * 78)
|
|
668
|
+
L.append("FIGURES")
|
|
669
|
+
L.append("-" * 78)
|
|
670
|
+
for p in plot_files:
|
|
671
|
+
L.append(f" {os.path.basename(p)}")
|
|
672
|
+
|
|
673
|
+
L.append("")
|
|
674
|
+
L.append(rule)
|
|
675
|
+
return "\n".join(L)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _save_profile_likelihood_plot(opt, param_names, plot_path, model_name,
|
|
679
|
+
tag="ALL", profile_kwargs=None):
|
|
680
|
+
"""Run profile likelihood once per parameter, extract CIs, and save plot.
|
|
681
|
+
|
|
682
|
+
Parallelises across parameters on Linux / macOS via fork-based
|
|
683
|
+
multiprocessing.Process (inherits the closure, including non-picklable
|
|
684
|
+
RoadRunner objects, via copy-on-write — no serialisation needed). Windows
|
|
685
|
+
lacks fork, so it falls back to the sequential loop below.
|
|
686
|
+
|
|
687
|
+
Stores profile_ci into opt['stats']['profile_ci'] so log_optimization_results
|
|
688
|
+
can include it in the CSV when called afterwards.
|
|
689
|
+
"""
|
|
690
|
+
import sys
|
|
691
|
+
from datetime import datetime
|
|
692
|
+
import numpy as np
|
|
693
|
+
import matplotlib.pyplot as plt
|
|
694
|
+
|
|
695
|
+
profile_func = opt.get("stats", {}).get("profile_likelihood")
|
|
696
|
+
if not profile_func:
|
|
697
|
+
print("Warning: no profile_likelihood closure in opt['stats'] — skipping plot.")
|
|
698
|
+
return
|
|
699
|
+
|
|
700
|
+
# One stamp for every artefact this call produces, so the three figures and
|
|
701
|
+
# the report are identifiable as one run at a glance. Successive runs used
|
|
702
|
+
# to overwrite each other's figures, which made comparing a re-run against
|
|
703
|
+
# the run that motivated it impossible -- the evidence was already gone.
|
|
704
|
+
stamp = datetime.now()
|
|
705
|
+
ts = stamp.strftime("%Y%m%d_%H%M%S")
|
|
706
|
+
|
|
707
|
+
colors = ['blue', 'green', 'red', 'orange', 'purple', 'brown']
|
|
708
|
+
markers = ['o', 's', '^', 'D', 'v']
|
|
709
|
+
params_estimated = np.asarray(opt["x"])
|
|
710
|
+
n_params = len(param_names)
|
|
711
|
+
|
|
712
|
+
print("\nProfile likelihood:")
|
|
713
|
+
cache = {}
|
|
714
|
+
|
|
715
|
+
# Preferred path: the parallel, checkpointed grid. It replaces both the
|
|
716
|
+
# fork-only branch below (which never ran on Windows) and the sequential
|
|
717
|
+
# fallback, and it is resumable.
|
|
718
|
+
profile_all = opt.get("stats", {}).get("profile_likelihood_all")
|
|
719
|
+
if profile_all is not None:
|
|
720
|
+
try:
|
|
721
|
+
traces, anchor, where, convergence = profile_all(
|
|
722
|
+
**(profile_kwargs or {}))
|
|
723
|
+
opt["stats"]["profile_anchor_gap"] = float(anchor)
|
|
724
|
+
opt["stats"]["profile_convergence"] = convergence
|
|
725
|
+
if where is not None and anchor < -1e-3:
|
|
726
|
+
# The engine assembles the full linear vector; keep the raw
|
|
727
|
+
# nuisance solution beside it for anyone re-entering the
|
|
728
|
+
# optimizer in its own space.
|
|
729
|
+
better = dict(convergence.get("better_point") or {})
|
|
730
|
+
better.setdefault("parameter", where[0])
|
|
731
|
+
better.setdefault("value", where[1])
|
|
732
|
+
better["nuisance_x"] = where[2]
|
|
733
|
+
opt["stats"]["profile_better_point"] = better
|
|
734
|
+
for i, pname in enumerate(param_names):
|
|
735
|
+
if pname not in traces:
|
|
736
|
+
continue
|
|
737
|
+
pv, nr = traces[pname]
|
|
738
|
+
if len(pv) < 2:
|
|
739
|
+
print(f" {pname}: too few points to profile")
|
|
740
|
+
continue
|
|
741
|
+
cache[i] = (np.asarray(pv), np.asarray(nr))
|
|
742
|
+
print(f" {pname}: dNLL range [{nr.min():.4g}, {nr.max():.4g}]")
|
|
743
|
+
# Range, not distance from zero: the optimum is no longer
|
|
744
|
+
# spliced in as a hardcoded 0.0, so a genuinely flat profile
|
|
745
|
+
# really does have zero range and is detected. The old test
|
|
746
|
+
# could never fire.
|
|
747
|
+
if np.ptp(nr) < _PROFILE_FLAT_TOL:
|
|
748
|
+
print(f" *** FLAT: {pname} is structurally non-identifiable "
|
|
749
|
+
f"(profile varies by {np.ptp(nr):.3g} over the whole grid) ***")
|
|
750
|
+
except UnidentifiableParameters:
|
|
751
|
+
# Not a failure of the parallel path, so there is nothing to fall
|
|
752
|
+
# back to: the sequential profile would spend days reaching the
|
|
753
|
+
# same verdict the screen has already proved. It has to travel past
|
|
754
|
+
# this handler intact or the run would quietly continue.
|
|
755
|
+
raise
|
|
756
|
+
except Exception as exc:
|
|
757
|
+
import traceback
|
|
758
|
+
print(f" parallel profile failed ({exc}); falling back.")
|
|
759
|
+
traceback.print_exc()
|
|
760
|
+
cache = {}
|
|
761
|
+
|
|
762
|
+
_is_posix = sys.platform != 'win32'
|
|
763
|
+
|
|
764
|
+
if cache:
|
|
765
|
+
pass # parallel grid already produced every trace
|
|
766
|
+
elif n_params > 1 and _is_posix:
|
|
767
|
+
# ── Linux / macOS: fork each parameter into its own process ──────────
|
|
768
|
+
# Forked children inherit the closure (including non-picklable RoadRunner
|
|
769
|
+
# objects) directly via copy-on-write — no serialisation needed.
|
|
770
|
+
import multiprocessing as mp
|
|
771
|
+
ctx = mp.get_context('fork')
|
|
772
|
+
q = ctx.Queue()
|
|
773
|
+
procs = [
|
|
774
|
+
ctx.Process(target=_profile_fork_worker, args=(q, i, profile_func))
|
|
775
|
+
for i in range(n_params)
|
|
776
|
+
]
|
|
777
|
+
print(f" [parallel] forking {n_params} processes (one per parameter) …")
|
|
778
|
+
for p in procs:
|
|
779
|
+
p.start()
|
|
780
|
+
for p in procs:
|
|
781
|
+
p.join()
|
|
782
|
+
for _ in range(n_params):
|
|
783
|
+
i, pv, nr, err = q.get()
|
|
784
|
+
if err is None:
|
|
785
|
+
cache[i] = (pv, nr)
|
|
786
|
+
print(f" {param_names[i]}: dNLL range [{nr.min():.4g}, {nr.max():.4g}]")
|
|
787
|
+
if np.all(np.abs(nr) < 1e-10):
|
|
788
|
+
print(f" *** FLAT: model may not respond to {param_names[i]} ***")
|
|
789
|
+
else:
|
|
790
|
+
print(f" {param_names[i]}: error — {err}")
|
|
791
|
+
|
|
792
|
+
else:
|
|
793
|
+
# ── Sequential fallback (single param, or non-POSIX platforms) ────────
|
|
794
|
+
for i, pname in enumerate(param_names):
|
|
795
|
+
try:
|
|
796
|
+
pv, nr = profile_func(i)
|
|
797
|
+
cache[i] = (pv, nr)
|
|
798
|
+
print(f" {pname}: dNLL range [{nr.min():.4g}, {nr.max():.4g}]")
|
|
799
|
+
if np.all(np.abs(nr) < 1e-10):
|
|
800
|
+
print(f" *** FLAT: model may not respond to {pname} ***")
|
|
801
|
+
except Exception as exc:
|
|
802
|
+
print(f" {pname}: error — {exc}")
|
|
803
|
+
|
|
804
|
+
# Extract profile CIs and store so Results.py can write them to CSV
|
|
805
|
+
profile_ci = []
|
|
806
|
+
profile_ci_state = []
|
|
807
|
+
profile_traces = {}
|
|
808
|
+
print("\n Profile likelihood 95% CIs:")
|
|
809
|
+
_status_note = {
|
|
810
|
+
"flat": "structurally non-identifiable — profile is flat",
|
|
811
|
+
"open": "no crossing either side",
|
|
812
|
+
"open_lower": "no lower crossing",
|
|
813
|
+
"open_upper": "no upper crossing",
|
|
814
|
+
"empty": "no profile points",
|
|
815
|
+
"missing": "profile not computed",
|
|
816
|
+
}
|
|
817
|
+
# Which sides a status leaves unresolved, so the advice can name what
|
|
818
|
+
# actually stopped each one rather than always blaming the grid width.
|
|
819
|
+
_status_sides = {"open": ("lower", "upper"), "open_lower": ("lower",),
|
|
820
|
+
"open_upper": ("upper",)}
|
|
821
|
+
_reach = (opt.get("stats", {}).get("profile_convergence") or {}).get("reach", {})
|
|
822
|
+
|
|
823
|
+
def _reach_advice(pname, status):
|
|
824
|
+
"""Why the missing bound is missing: a real answer, or an exhausted budget.
|
|
825
|
+
|
|
826
|
+
These call for opposite responses. A side that walked to the parameter's
|
|
827
|
+
own bound without dNLL reaching 1.9207 has answered the question -- the
|
|
828
|
+
parameter is not identifiable anywhere it is allowed to go -- and
|
|
829
|
+
re-running with a wider grid would change nothing, because the bound,
|
|
830
|
+
not the grid, is the constraint. A side that merely ran out of extension
|
|
831
|
+
steps has answered nothing and does want a re-run.
|
|
832
|
+
"""
|
|
833
|
+
sides = _reach.get(pname) or {}
|
|
834
|
+
notes = []
|
|
835
|
+
for key in _status_sides.get(status, ()):
|
|
836
|
+
d = sides.get(key)
|
|
837
|
+
if not d:
|
|
838
|
+
continue
|
|
839
|
+
if d["state"] == "bound":
|
|
840
|
+
notes.append(f"{key} side reached the parameter bound "
|
|
841
|
+
f"({d['reach']:.4g}) at dNLL {d['max_dnll']:.3g} — "
|
|
842
|
+
f"not identifiable within its declared bounds")
|
|
843
|
+
elif d["state"] == "budget":
|
|
844
|
+
notes.append(f"{key} side stopped at {d['reach']:.4g} with dNLL "
|
|
845
|
+
f"{d['max_dnll']:.3g} — raise profile_max_extend")
|
|
846
|
+
return notes
|
|
847
|
+
|
|
848
|
+
for i, pname in enumerate(param_names):
|
|
849
|
+
cached = cache.get(i)
|
|
850
|
+
if cached is not None:
|
|
851
|
+
lo, hi = _extract_profile_ci(cached[0], cached[1], threshold=_PROFILE_CI_THRESHOLD)
|
|
852
|
+
status = profile_ci_status(cached[0], cached[1], lo, hi,
|
|
853
|
+
flat_tol=_PROFILE_FLAT_TOL)
|
|
854
|
+
profile_traces[pname] = {"x": cached[0].tolist(), "y": cached[1].tolist()}
|
|
855
|
+
else:
|
|
856
|
+
lo, hi, status = float('nan'), float('nan'), "missing"
|
|
857
|
+
profile_ci.append((lo, hi))
|
|
858
|
+
profile_ci_state.append(status)
|
|
859
|
+
note = f" [{status}: {_status_note[status]}]" if status != "ok" else ""
|
|
860
|
+
for advice in _reach_advice(pname, status):
|
|
861
|
+
note += f"\n {advice}"
|
|
862
|
+
# A CI is only as trustworthy as the nuisance optimizations underneath
|
|
863
|
+
# it, so the caveat belongs on the interval itself rather than only in a
|
|
864
|
+
# summary further up the log.
|
|
865
|
+
conv = (opt.get("stats", {}).get("profile_convergence") or {}) \
|
|
866
|
+
.get("per_param", {}).get(pname)
|
|
867
|
+
if conv and conv.get("n_not_converged"):
|
|
868
|
+
note += (f" [{conv['n_not_converged']}/{conv['n']} point(s) hit "
|
|
869
|
+
f"the optimizer cap — interval is too narrow]")
|
|
870
|
+
print(f" {pname}: [{lo:.4g}, {hi:.4g}]{note}")
|
|
871
|
+
opt["stats"]["profile_ci"] = profile_ci
|
|
872
|
+
opt["stats"]["profile_ci_status"] = profile_ci_state
|
|
873
|
+
opt["stats"]["profile_traces"] = profile_traces
|
|
874
|
+
|
|
875
|
+
fig, ax = plt.subplots(figsize=(8, 6))
|
|
876
|
+
for idx, pname in enumerate(param_names):
|
|
877
|
+
cached = cache.get(idx)
|
|
878
|
+
if cached is None:
|
|
879
|
+
continue
|
|
880
|
+
pv, nr = cached
|
|
881
|
+
ax.plot(pv / params_estimated[idx], nr,
|
|
882
|
+
marker=markers[(idx // len(colors)) % len(markers)], linestyle='-',
|
|
883
|
+
label=pname, linewidth=2, color=colors[idx % len(colors)])
|
|
884
|
+
|
|
885
|
+
ax.axhline(_PROFILE_CI_THRESHOLD, color='gray', linestyle=':', alpha=0.7, linewidth=1.5,
|
|
886
|
+
label=f'95% CI (Δ NLL = {_PROFILE_CI_THRESHOLD:.2f})')
|
|
887
|
+
ax.axvline(1.0, color='red', linestyle='--', alpha=0.5, linewidth=1.5, label='Optimal')
|
|
888
|
+
ax.set_xlabel('Parameter Value (relative to optimal)')
|
|
889
|
+
ax.set_ylabel('Δ NLL (relative to profile minimum)')
|
|
890
|
+
_gap = opt.get("stats", {}).get("profile_anchor_gap", 0.0)
|
|
891
|
+
if _gap < -1e-3:
|
|
892
|
+
# Say it on the figure too — a plot whose optimum line sits off the
|
|
893
|
+
# minimum is the single most misleading output this code can produce.
|
|
894
|
+
ax.set_title('Profile Likelihood — WARNING: fit is '
|
|
895
|
+
f'{abs(_gap):.3g} nats above the profile minimum')
|
|
896
|
+
else:
|
|
897
|
+
ax.set_title('Profile Likelihood (Identifiability Check)')
|
|
898
|
+
ax.legend(fontsize=8)
|
|
899
|
+
ax.grid(True, alpha=0.3)
|
|
900
|
+
_xwin, _use_log = _profile_plot_x(cache, params_estimated, profile_ci)
|
|
901
|
+
if _use_log:
|
|
902
|
+
ax.set_xscale('log')
|
|
903
|
+
if _xwin:
|
|
904
|
+
ax.set_xlim(*_xwin)
|
|
905
|
+
plt.tight_layout()
|
|
906
|
+
_base = os.path.join(plot_path, f"{model_name}_{tag}_profile_likelihood")
|
|
907
|
+
out_path = f"{_base}_{ts}.png"
|
|
908
|
+
plt.savefig(out_path, bbox_inches="tight")
|
|
909
|
+
print(f"Profile likelihood saved to: {out_path}")
|
|
910
|
+
|
|
911
|
+
zoom_half = _PROFILE_CI_THRESHOLD * 0.10
|
|
912
|
+
ax.set_ylim(-zoom_half, zoom_half)
|
|
913
|
+
ax.set_title(f'Profile Likelihood (Identifiability Check, y-axis ±10% CI)')
|
|
914
|
+
out_path_zoom = f"{_base}_zoom_{ts}.png"
|
|
915
|
+
plt.savefig(out_path_zoom, bbox_inches="tight")
|
|
916
|
+
print(f"Profile likelihood (zoomed) saved to: {out_path_zoom}")
|
|
917
|
+
|
|
918
|
+
ax.set_ylim(-_PROFILE_CI_THRESHOLD * 0.10, _PROFILE_CI_THRESHOLD)
|
|
919
|
+
ax.set_title(f'Profile Likelihood (Identifiability Check, y-axis -10% to +100% CI)')
|
|
920
|
+
out_path_zoom2 = f"{_base}_zoom2_{ts}.png"
|
|
921
|
+
plt.savefig(out_path_zoom2, bbox_inches="tight")
|
|
922
|
+
print(f"Profile likelihood (zoomed2) saved to: {out_path_zoom2}")
|
|
923
|
+
plt.close(fig)
|
|
924
|
+
|
|
925
|
+
# ── Summary report ───────────────────────────────────────────────────
|
|
926
|
+
report = _profile_report_text(
|
|
927
|
+
opt, param_names, params_estimated, profile_ci, profile_ci_state,
|
|
928
|
+
cache, model_name, tag, stamp.strftime("%Y-%m-%d %H:%M:%S"),
|
|
929
|
+
plot_files=(out_path, out_path_zoom, out_path_zoom2),
|
|
930
|
+
)
|
|
931
|
+
report_path = os.path.join(
|
|
932
|
+
plot_path, f"{model_name}_{tag}_profile_summary_{ts}.txt")
|
|
933
|
+
try:
|
|
934
|
+
with open(report_path, "w", encoding="utf-8") as fh:
|
|
935
|
+
fh.write(report + "\n")
|
|
936
|
+
print(f"\n{report}")
|
|
937
|
+
print(f"\nProfile summary saved to: {report_path}")
|
|
938
|
+
except OSError as exc:
|
|
939
|
+
# The report is a convenience; losing it must not cost the run the
|
|
940
|
+
# diagnostics it just spent hours computing.
|
|
941
|
+
print(f"\n{report}")
|
|
942
|
+
print(f"\n [profile] could not write {report_path}: {exc}")
|
|
943
|
+
opt["stats"]["profile_report"] = report
|
|
944
|
+
opt["stats"]["profile_report_path"] = report_path
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _slice_dnll_at_optimum(param_vals, dnll, opt_val, rtol=1e-6):
|
|
948
|
+
"""The slice's own dNLL at the fitted value, or None if the grid missed it.
|
|
949
|
+
|
|
950
|
+
The grid is built to contain the optimum, so this is normally a lookup. It
|
|
951
|
+
can still come back None for a parameter whose fitted value is zero on a
|
|
952
|
+
linear scale, or one whose slice errored, and the caller has to survive
|
|
953
|
+
both.
|
|
954
|
+
"""
|
|
955
|
+
import numpy as np
|
|
956
|
+
|
|
957
|
+
pv = np.asarray(param_vals, dtype=float)
|
|
958
|
+
y = np.asarray(dnll, dtype=float)
|
|
959
|
+
if pv.size == 0 or opt_val == 0:
|
|
960
|
+
return None
|
|
961
|
+
ratio = np.abs(pv / opt_val - 1.0)
|
|
962
|
+
i = int(np.argmin(ratio))
|
|
963
|
+
if ratio[i] > rtol:
|
|
964
|
+
return None
|
|
965
|
+
return float(y[i])
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _save_likelihood_slice_plot(opt, param_names, plot_path, model_name, tag="ALL"):
|
|
969
|
+
"""Run likelihood slice once per parameter and save plot."""
|
|
970
|
+
from datetime import datetime
|
|
971
|
+
import numpy as np
|
|
972
|
+
import matplotlib.pyplot as plt
|
|
973
|
+
|
|
974
|
+
slice_func = opt.get("stats", {}).get("likelihood_slice")
|
|
975
|
+
if not slice_func:
|
|
976
|
+
print("Warning: no likelihood_slice closure in opt['stats'] — skipping plot.")
|
|
977
|
+
return
|
|
978
|
+
|
|
979
|
+
# Stamped like the profile figures, and for the same reason: successive runs
|
|
980
|
+
# used to overwrite each other, so the slice that motivated a change was
|
|
981
|
+
# gone by the time there was anything to compare it against.
|
|
982
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
983
|
+
|
|
984
|
+
colors = ['blue', 'green', 'red', 'orange', 'purple', 'brown']
|
|
985
|
+
markers = ['o', 's', '^', 'D', 'v']
|
|
986
|
+
params_estimated = np.asarray(opt["x"])
|
|
987
|
+
|
|
988
|
+
print("\nLikelihood slice:")
|
|
989
|
+
cache = {}
|
|
990
|
+
slice_traces = {}
|
|
991
|
+
|
|
992
|
+
# Prefer the all-parameter form: it submits k x n_points evaluations as a
|
|
993
|
+
# single batch, so a 24-core pool is fully fed. Going parameter-by-parameter
|
|
994
|
+
# caps the batch at n_points (20), leaving most workers idle.
|
|
995
|
+
slice_all = opt.get("stats", {}).get("likelihood_slice_all")
|
|
996
|
+
results = None
|
|
997
|
+
if slice_all is not None:
|
|
998
|
+
try:
|
|
999
|
+
results = slice_all(n_points=20, range_factor=2.0)
|
|
1000
|
+
except Exception as exc:
|
|
1001
|
+
print(f" batched slice failed ({exc}); falling back per parameter.")
|
|
1002
|
+
|
|
1003
|
+
dnll_at_opt = {}
|
|
1004
|
+
for i, pname in enumerate(param_names):
|
|
1005
|
+
try:
|
|
1006
|
+
if results is not None and pname in results:
|
|
1007
|
+
pv, nr = results[pname]
|
|
1008
|
+
else:
|
|
1009
|
+
pv, nr = slice_func(i, n_points=20, range_factor=2.0)
|
|
1010
|
+
cache[i] = (pv, nr)
|
|
1011
|
+
slice_traces[pname] = {"x": np.asarray(pv).tolist(),
|
|
1012
|
+
"y": np.asarray(nr).tolist()}
|
|
1013
|
+
# The optimum is a sampled point, not an assumption, so its dNLL is
|
|
1014
|
+
# worth stating: it should be 0, and anything else means the
|
|
1015
|
+
# diagnostic evaluator and the fit disagree about the fitted point.
|
|
1016
|
+
d0 = _slice_dnll_at_optimum(pv, nr, float(params_estimated[i]))
|
|
1017
|
+
if d0 is not None:
|
|
1018
|
+
dnll_at_opt[pname] = d0
|
|
1019
|
+
at_opt = (f", dNLL at optimum {d0:.4g}" if d0 is not None
|
|
1020
|
+
else ", optimum not on the grid")
|
|
1021
|
+
print(f" {pname}: dNLL range [{nr.min():.4g}, {nr.max():.4g}]{at_opt}")
|
|
1022
|
+
if np.all(np.abs(nr) < 1e-10):
|
|
1023
|
+
print(f" *** FLAT: model may not respond to {pname} ***")
|
|
1024
|
+
# A slice does not re-optimize the nuisances, so a point below the
|
|
1025
|
+
# optimum is not a definition mismatch the way it can be in a
|
|
1026
|
+
# profile: the fit is beatable by moving this parameter alone.
|
|
1027
|
+
if nr.min() < -_SLICE_DNLL_TOL:
|
|
1028
|
+
j = int(np.argmin(nr))
|
|
1029
|
+
print(f" *** WARNING: {pname}={pv[j]:.6g} scores "
|
|
1030
|
+
f"{abs(nr.min()):.4g} nats BELOW the reported optimum "
|
|
1031
|
+
f"with every other parameter held fixed - the fit has "
|
|
1032
|
+
f"not converged ***")
|
|
1033
|
+
except Exception as exc:
|
|
1034
|
+
print(f" {pname}: error — {exc}")
|
|
1035
|
+
opt["stats"]["slice_traces"] = slice_traces
|
|
1036
|
+
opt["stats"]["slice_dnll_at_optimum"] = dnll_at_opt
|
|
1037
|
+
_worst_at_opt = max((abs(v) for v in dnll_at_opt.values()), default=0.0)
|
|
1038
|
+
if _worst_at_opt > _SLICE_DNLL_TOL:
|
|
1039
|
+
print(f" *** WARNING: the slice scores the fitted point "
|
|
1040
|
+
f"{_worst_at_opt:.4g} nats away from the value the fit reported "
|
|
1041
|
+
f"there - every dNLL above is measured from the wrong place ***")
|
|
1042
|
+
|
|
1043
|
+
fig, ax = plt.subplots(figsize=(8, 6))
|
|
1044
|
+
for idx, pname in enumerate(param_names):
|
|
1045
|
+
cached = cache.get(idx)
|
|
1046
|
+
if cached is None:
|
|
1047
|
+
continue
|
|
1048
|
+
pv, nr = cached
|
|
1049
|
+
ax.plot(pv / params_estimated[idx], nr,
|
|
1050
|
+
marker=markers[(idx // len(colors)) % len(markers)], linestyle='-',
|
|
1051
|
+
label=pname, linewidth=2, color=colors[idx % len(colors)])
|
|
1052
|
+
|
|
1053
|
+
# The fitted point itself, drawn on top of the curves. It is where the
|
|
1054
|
+
# curves now meet, and on the zoomed figures it is often the only point of a
|
|
1055
|
+
# steep slice still on screen, so it needs to be identifiable as such.
|
|
1056
|
+
_opt_ys = [dnll_at_opt[p] for p in param_names if p in dnll_at_opt]
|
|
1057
|
+
if _opt_ys:
|
|
1058
|
+
ax.plot([1.0] * len(_opt_ys), _opt_ys, linestyle='none', marker='x',
|
|
1059
|
+
color='black', markersize=8, markeredgewidth=1.5, zorder=5,
|
|
1060
|
+
label=f'Optimum (Δ NLL = {max(_opt_ys, key=abs):.3g})')
|
|
1061
|
+
|
|
1062
|
+
ax.axhline(_PROFILE_CI_THRESHOLD, color='gray', linestyle=':', alpha=0.7, linewidth=1.5,
|
|
1063
|
+
label=f'95% CI threshold (Δ NLL = {_PROFILE_CI_THRESHOLD:.2f})')
|
|
1064
|
+
ax.axvline(1.0, color='red', linestyle='--', alpha=0.5, linewidth=1.5, label='Optimal')
|
|
1065
|
+
ax.set_xlabel('Parameter Value (relative to optimal)')
|
|
1066
|
+
ax.set_ylabel('Δ NLL (relative to minimum)')
|
|
1067
|
+
if _worst_at_opt > _SLICE_DNLL_TOL:
|
|
1068
|
+
ax.set_title('Likelihood Slice — WARNING: slice is '
|
|
1069
|
+
f'{_worst_at_opt:.3g} nats off the fit at the optimum')
|
|
1070
|
+
else:
|
|
1071
|
+
ax.set_title('Likelihood Slice')
|
|
1072
|
+
ax.legend(fontsize=8)
|
|
1073
|
+
ax.grid(True, alpha=0.3)
|
|
1074
|
+
ax.set_xlim(0.2, 3.5)
|
|
1075
|
+
plt.tight_layout()
|
|
1076
|
+
_base = os.path.join(plot_path, f"{model_name}_{tag}_likelihood_slice")
|
|
1077
|
+
out_path = f"{_base}_{ts}.png"
|
|
1078
|
+
plt.savefig(out_path, bbox_inches="tight")
|
|
1079
|
+
print(f"Likelihood slice saved to: {out_path}")
|
|
1080
|
+
|
|
1081
|
+
zoom_half = _PROFILE_CI_THRESHOLD * 0.10
|
|
1082
|
+
ax.set_ylim(-zoom_half, zoom_half)
|
|
1083
|
+
ax.set_title(f'Likelihood Slice (y-axis ±10% CI)')
|
|
1084
|
+
out_path_zoom = f"{_base}_zoom_{ts}.png"
|
|
1085
|
+
plt.savefig(out_path_zoom, bbox_inches="tight")
|
|
1086
|
+
print(f"Likelihood slice (zoomed) saved to: {out_path_zoom}")
|
|
1087
|
+
|
|
1088
|
+
ax.set_ylim(-_PROFILE_CI_THRESHOLD * 0.10, _PROFILE_CI_THRESHOLD)
|
|
1089
|
+
ax.set_title(f'Likelihood Slice (y-axis -10% to +100% CI)')
|
|
1090
|
+
out_path_zoom2 = f"{_base}_zoom2_{ts}.png"
|
|
1091
|
+
plt.savefig(out_path_zoom2, bbox_inches="tight")
|
|
1092
|
+
print(f"Likelihood slice (zoomed2) saved to: {out_path_zoom2}")
|
|
1093
|
+
plt.close(fig)
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
# ---------------------------------------------------------------------------
|
|
1097
|
+
# Original flat-dict entry point
|
|
1098
|
+
# ---------------------------------------------------------------------------
|
|
1099
|
+
|
|
1100
|
+
def setup_optimization(settings, optimization_settings, experiment_dict):
|
|
1101
|
+
"""
|
|
1102
|
+
Run parameter optimization using a flat dict of experiment treatments.
|
|
1103
|
+
|
|
1104
|
+
experiment_dict keys
|
|
1105
|
+
--------------------
|
|
1106
|
+
"experiment" : dict mapping experiment id -> treatment dict
|
|
1107
|
+
"plot" : callable(paths, results_dict)
|
|
1108
|
+
"""
|
|
1109
|
+
MODEL_NAME = settings.get("MODEL_NAME", AntiGen_paths.MODEL_NAME)
|
|
1110
|
+
model_text, paths = AntimonyGen(MODEL_NAME, repo_root=REPO_ROOT)
|
|
1111
|
+
|
|
1112
|
+
if settings.get("run_steady_state_first"):
|
|
1113
|
+
_run_steady_state(model_text, paths, settings)
|
|
1114
|
+
model_text, paths = AntimonyGen(MODEL_NAME, repo_root=REPO_ROOT)
|
|
1115
|
+
|
|
1116
|
+
param_names = optimization_settings["param_names"]
|
|
1117
|
+
x0 = optimization_settings["x0"]
|
|
1118
|
+
bounds = optimization_settings.get("bounds")
|
|
1119
|
+
method = optimization_settings.get("method", "Nelder-Mead")
|
|
1120
|
+
opt_kwargs = optimization_settings.get("optimizer_kwargs", {})
|
|
1121
|
+
|
|
1122
|
+
if not param_names:
|
|
1123
|
+
print("Error: No parameters to optimize. Set param_names and x0 in optimization_settings.")
|
|
1124
|
+
return
|
|
1125
|
+
|
|
1126
|
+
experiments = experiment_dict['experiment']
|
|
1127
|
+
plot_function = experiment_dict["plot"]
|
|
1128
|
+
|
|
1129
|
+
opt = run_optimization(
|
|
1130
|
+
model_text, paths, experiments,
|
|
1131
|
+
param_names=param_names,
|
|
1132
|
+
x0=x0,
|
|
1133
|
+
bounds=bounds,
|
|
1134
|
+
loss_config={"observables": optimization_settings["observables"]},
|
|
1135
|
+
wald_analysis=optimization_settings.get("wald_analysis", False),
|
|
1136
|
+
slice_analysis=optimization_settings.get("slice_analysis", False),
|
|
1137
|
+
profile_likelihood_analysis=optimization_settings.get("profile_likelihood_analysis", False),
|
|
1138
|
+
fast_profile_likelihood_analysis=optimization_settings.get("fast_profile_likelihood_analysis", False),
|
|
1139
|
+
sobol_analysis=optimization_settings.get("sobol_analysis", False),
|
|
1140
|
+
sobol_kwargs={"N": optimization_settings.get("sobol_N", 128), "mode": optimization_settings.get("sobol_mode", "loss")},
|
|
1141
|
+
method=method,
|
|
1142
|
+
optimizer_kwargs=opt_kwargs,
|
|
1143
|
+
fit_mode=settings.get("fit_mode"),
|
|
1144
|
+
)
|
|
1145
|
+
print(f"Optimization success: {opt['success']} loss: {opt['fun']:.6g}")
|
|
1146
|
+
|
|
1147
|
+
csv_path = os.path.join(paths["plot_path"], f"{MODEL_NAME}_optimization_results.csv")
|
|
1148
|
+
log_optimization_results(opt, param_names, csv_path,
|
|
1149
|
+
model_name=MODEL_NAME, experiment_id="ALL", method=method)
|
|
1150
|
+
|
|
1151
|
+
if opt.get("results_dict") is not None:
|
|
1152
|
+
plot_function(paths, opt["results_dict"])
|
|
1153
|
+
|
|
1154
|
+
if optimization_settings.get("slice_analysis") and opt["stats"].get("likelihood_slice"):
|
|
1155
|
+
_save_likelihood_slice_plot(opt, param_names, paths["plot_path"], MODEL_NAME)
|
|
1156
|
+
if (optimization_settings.get("profile_likelihood_analysis") or optimization_settings.get("fast_profile_likelihood_analysis")) and opt["stats"].get("profile_likelihood"):
|
|
1157
|
+
_save_profile_likelihood_plot(
|
|
1158
|
+
opt, param_names, paths["plot_path"], MODEL_NAME,
|
|
1159
|
+
profile_kwargs=_profile_kwargs(optimization_settings))
|
|
1160
|
+
if optimization_settings.get("sobol_analysis") and opt["stats"].get("sobol"):
|
|
1161
|
+
from Engine.Sensitivity_analysis import save_sobol_plot
|
|
1162
|
+
save_sobol_plot(opt["stats"]["sobol"], paths["plot_path"], MODEL_NAME)
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
# ---------------------------------------------------------------------------
|
|
1166
|
+
# Group-aware entry point
|
|
1167
|
+
# ---------------------------------------------------------------------------
|
|
1168
|
+
|
|
1169
|
+
def _is_per_group_settings(optimization_settings):
|
|
1170
|
+
"""Return True when optimization_settings contains per-group sub-dicts
|
|
1171
|
+
(e.g. the PK block keyed by drug name), False for a flat shared dict."""
|
|
1172
|
+
return any(
|
|
1173
|
+
isinstance(v, dict) and "param_names" in v
|
|
1174
|
+
for v in optimization_settings.values()
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def setup_optimization_from_groups(settings, optimization_settings, EXPERIMENT_dict):
|
|
1179
|
+
"""
|
|
1180
|
+
Run parameter optimization using Experiment.opt_groups.
|
|
1181
|
+
|
|
1182
|
+
Loss_config is read from each replicate's ``Loss_config`` key.
|
|
1183
|
+
Replicates whose Loss_config is ``no_optimization()`` are simulated at the
|
|
1184
|
+
end with the optimal parameters for use by the plot function.
|
|
1185
|
+
|
|
1186
|
+
Flat mode — optimization_settings has top-level param_names/x0/bounds:
|
|
1187
|
+
one optimization is run, summing NLL across all active groups.
|
|
1188
|
+
|
|
1189
|
+
Per-group mode — optimization_settings has per-group sub-dicts each
|
|
1190
|
+
containing param_names/x0/bounds (e.g. the PK block keyed by drug):
|
|
1191
|
+
one independent optimization is run per group, results accumulated,
|
|
1192
|
+
then plot_function is called once.
|
|
1193
|
+
"""
|
|
1194
|
+
MODEL_NAME = settings.get("MODEL_NAME", AntiGen_paths.MODEL_NAME)
|
|
1195
|
+
model_text, paths = AntimonyGen(MODEL_NAME, repo_root=REPO_ROOT)
|
|
1196
|
+
|
|
1197
|
+
if settings.get("run_steady_state_first"):
|
|
1198
|
+
_run_steady_state(model_text, paths, settings)
|
|
1199
|
+
model_text, paths = AntimonyGen(MODEL_NAME, repo_root=REPO_ROOT)
|
|
1200
|
+
|
|
1201
|
+
experiment = EXPERIMENT_dict["EXPERIMENT"]
|
|
1202
|
+
plot_function = EXPERIMENT_dict["plot"]
|
|
1203
|
+
|
|
1204
|
+
# Support for the new decoupled, nested Optimization spec
|
|
1205
|
+
from Modules.Optimizer_settings import Optimization
|
|
1206
|
+
if isinstance(optimization_settings, Optimization):
|
|
1207
|
+
opt = run_optimization_from_groups(
|
|
1208
|
+
model_text, paths, experiment,
|
|
1209
|
+
param_names=optimization_settings.param_names,
|
|
1210
|
+
x0=optimization_settings.x0,
|
|
1211
|
+
bounds=optimization_settings.bounds,
|
|
1212
|
+
method=optimization_settings.method,
|
|
1213
|
+
optimizer_kwargs=optimization_settings.optimizer_kwargs,
|
|
1214
|
+
wald_analysis=settings.get("wald_analysis", False),
|
|
1215
|
+
slice_analysis=settings.get("slice_analysis", False),
|
|
1216
|
+
profile_likelihood_analysis=settings.get("profile_likelihood_analysis", False),
|
|
1217
|
+
fast_profile_likelihood_analysis=settings.get("fast_profile_likelihood_analysis", False),
|
|
1218
|
+
sobol_analysis=settings.get("sobol_analysis", False),
|
|
1219
|
+
sobol_kwargs={"N": settings.get("sobol_N", 128), "mode": settings.get("sobol_mode", "loss")},
|
|
1220
|
+
optimization_spec=optimization_settings,
|
|
1221
|
+
fit_mode=settings.get("fit_mode"),
|
|
1222
|
+
n_workers=settings.get("n_workers"),
|
|
1223
|
+
profile_checkpoint=settings.get("profile_checkpoint", True),
|
|
1224
|
+
preequil_cache=settings.get("preequil_cache", True),
|
|
1225
|
+
reuse_fit=settings.get("reuse_fit", True),
|
|
1226
|
+
)
|
|
1227
|
+
|
|
1228
|
+
groups_tag = "_".join(opt.get("groups", ["ALL"]))
|
|
1229
|
+
|
|
1230
|
+
if settings.get("slice_analysis") and opt.get("stats", {}).get("likelihood_slice"):
|
|
1231
|
+
_save_likelihood_slice_plot(opt, optimization_settings.param_names, paths["plot_path"],
|
|
1232
|
+
MODEL_NAME, tag=groups_tag)
|
|
1233
|
+
if (settings.get("profile_likelihood_analysis") or settings.get("fast_profile_likelihood_analysis")) and opt.get("stats", {}).get("profile_likelihood"):
|
|
1234
|
+
_save_profile_likelihood_plot(
|
|
1235
|
+
opt, optimization_settings.param_names, paths["plot_path"],
|
|
1236
|
+
MODEL_NAME, tag=groups_tag,
|
|
1237
|
+
profile_kwargs=_profile_kwargs(settings, optimization_settings))
|
|
1238
|
+
if settings.get("fast_profile_likelihood_analysis") and opt.get("stats", {}).get("fast_profile_all"):
|
|
1239
|
+
_run_fast_profile_report(opt, settings, optimization_settings)
|
|
1240
|
+
if settings.get("sobol_analysis") and opt.get("stats", {}).get("sobol"):
|
|
1241
|
+
from Engine.Sensitivity_analysis import save_sobol_plot
|
|
1242
|
+
save_sobol_plot(opt["stats"]["sobol"], paths["plot_path"], MODEL_NAME, tag=groups_tag)
|
|
1243
|
+
|
|
1244
|
+
csv_path = os.path.join(
|
|
1245
|
+
paths["plot_path"],
|
|
1246
|
+
f"{MODEL_NAME}_{groups_tag}_optimization_results.csv",
|
|
1247
|
+
)
|
|
1248
|
+
log_optimization_results(opt, optimization_settings.param_names, csv_path,
|
|
1249
|
+
model_name=MODEL_NAME, experiment_id=groups_tag, method=optimization_settings.method)
|
|
1250
|
+
|
|
1251
|
+
if opt.get("results_dict") is not None and plot_function:
|
|
1252
|
+
plot_function(paths, opt["results_dict"])
|
|
1253
|
+
_shutdown_evaluator(opt)
|
|
1254
|
+
return opt
|
|
1255
|
+
|
|
1256
|
+
if _is_per_group_settings(optimization_settings):
|
|
1257
|
+
# ── Per-group mode ────────────────────────────────────────────────
|
|
1258
|
+
all_results = {}
|
|
1259
|
+
group_optimizations = {}
|
|
1260
|
+
allowed_groups = optimization_settings.get("group_names") # None = run all
|
|
1261
|
+
# Replicates whose Opt_group is not among any per-group sub-dict key
|
|
1262
|
+
# are treated as passive (plot-only) and kept in all_results regardless
|
|
1263
|
+
# of which sub-group is being optimized.
|
|
1264
|
+
known_opt_groups = {
|
|
1265
|
+
name for name, val in optimization_settings.items()
|
|
1266
|
+
if isinstance(val, dict) and val.get("param_names")
|
|
1267
|
+
}
|
|
1268
|
+
for group_name, group_settings in optimization_settings.items():
|
|
1269
|
+
if not isinstance(group_settings, dict) or not group_settings.get("param_names"):
|
|
1270
|
+
continue
|
|
1271
|
+
if allowed_groups is not None and group_name not in allowed_groups:
|
|
1272
|
+
continue
|
|
1273
|
+
param_names = group_settings["param_names"]
|
|
1274
|
+
method = group_settings.get("method", "Nelder-Mead")
|
|
1275
|
+
|
|
1276
|
+
try:
|
|
1277
|
+
opt = run_optimization_from_groups(
|
|
1278
|
+
model_text, paths, experiment,
|
|
1279
|
+
param_names=param_names,
|
|
1280
|
+
x0=group_settings["x0"],
|
|
1281
|
+
bounds=group_settings.get("bounds"),
|
|
1282
|
+
group_names=[group_name],
|
|
1283
|
+
method=method,
|
|
1284
|
+
optimizer_kwargs=group_settings.get("optimizer_kwargs", {}),
|
|
1285
|
+
wald_analysis=group_settings.get("wald_analysis", False),
|
|
1286
|
+
slice_analysis=group_settings.get("slice_analysis", False),
|
|
1287
|
+
profile_likelihood_analysis=group_settings.get("profile_likelihood_analysis", False),
|
|
1288
|
+
fast_profile_likelihood_analysis=group_settings.get("fast_profile_likelihood_analysis", False),
|
|
1289
|
+
sobol_analysis=group_settings.get("sobol_analysis", False),
|
|
1290
|
+
sobol_kwargs={"N": group_settings.get("sobol_N", 128), "mode": group_settings.get("sobol_mode", "loss")},
|
|
1291
|
+
fit_mode=group_settings.get("fit_mode", settings.get("fit_mode")),
|
|
1292
|
+
n_workers=group_settings.get("n_workers", settings.get("n_workers")),
|
|
1293
|
+
)
|
|
1294
|
+
except Exception as e:
|
|
1295
|
+
print(f"Warning: optimization for '{group_name}' failed: {e}")
|
|
1296
|
+
continue
|
|
1297
|
+
|
|
1298
|
+
if opt.get("results_dict"):
|
|
1299
|
+
filtered_results = {}
|
|
1300
|
+
for req_id, item in opt["results_dict"].items():
|
|
1301
|
+
item_group = item.get("replicate", {}).get("Opt_group")
|
|
1302
|
+
if item_group == group_name or item_group not in known_opt_groups:
|
|
1303
|
+
filtered_results[req_id] = item
|
|
1304
|
+
all_results.update(filtered_results)
|
|
1305
|
+
|
|
1306
|
+
group_optimizations[group_name] = opt
|
|
1307
|
+
|
|
1308
|
+
# Run analysis plots first so profile_ci is populated before CSV write
|
|
1309
|
+
if group_settings.get("slice_analysis") and opt.get("stats", {}).get("likelihood_slice"):
|
|
1310
|
+
_save_likelihood_slice_plot(opt, param_names, paths["plot_path"],
|
|
1311
|
+
MODEL_NAME, tag=group_name)
|
|
1312
|
+
if (group_settings.get("profile_likelihood_analysis") or group_settings.get("fast_profile_likelihood_analysis")) and opt.get("stats", {}).get("profile_likelihood"):
|
|
1313
|
+
_save_profile_likelihood_plot(
|
|
1314
|
+
opt, param_names, paths["plot_path"], MODEL_NAME,
|
|
1315
|
+
tag=group_name,
|
|
1316
|
+
profile_kwargs=_profile_kwargs(group_settings))
|
|
1317
|
+
if group_settings.get("sobol_analysis") and opt.get("stats", {}).get("sobol"):
|
|
1318
|
+
from Engine.Sensitivity_analysis import save_sobol_plot
|
|
1319
|
+
save_sobol_plot(opt.get("stats", {}).get("sobol"), paths["plot_path"],
|
|
1320
|
+
MODEL_NAME, tag=group_name)
|
|
1321
|
+
|
|
1322
|
+
print(f"\nOptimization Summary for {group_name}:")
|
|
1323
|
+
print("-" * 85)
|
|
1324
|
+
print(f"{'Parameter':<30} | {'Optimized Value':<15} | {'Wald SE':<15} | {'Wald 95% CI':<20}")
|
|
1325
|
+
print("-" * 85)
|
|
1326
|
+
x_vals = opt.get("x", [])
|
|
1327
|
+
se = opt.get("stats", {}).get("wald_se")
|
|
1328
|
+
ci = opt.get("stats", {}).get("wald_ci")
|
|
1329
|
+
for i, p_name in enumerate(param_names):
|
|
1330
|
+
val = x_vals[i] if i < len(x_vals) else float('nan')
|
|
1331
|
+
std_err = se[i] if se is not None and i < len(se) else "N/A"
|
|
1332
|
+
std_err_str = f"{std_err:.4g}" if isinstance(std_err, (int, float)) else std_err
|
|
1333
|
+
conf_int = ci[i] if ci is not None and i < len(ci) else ("N/A", "N/A")
|
|
1334
|
+
if isinstance(conf_int, tuple) and len(conf_int) == 2:
|
|
1335
|
+
if isinstance(conf_int[0], (int, float)) and isinstance(conf_int[1], (int, float)):
|
|
1336
|
+
conf_int_str = f"[{conf_int[0]:.4g}, {conf_int[1]:.4g}]"
|
|
1337
|
+
else:
|
|
1338
|
+
conf_int_str = f"[{conf_int[0]}, {conf_int[1]}]"
|
|
1339
|
+
else:
|
|
1340
|
+
conf_int_str = str(conf_int)
|
|
1341
|
+
print(f"{p_name:<30} | {val:<15.4g} | {std_err_str:<15} | {conf_int_str:<20}")
|
|
1342
|
+
print("-" * 85)
|
|
1343
|
+
print(f"Final Objective Value (NLL): {opt.get('fun', 'N/A'):.6g}\n")
|
|
1344
|
+
|
|
1345
|
+
corr_matrix = opt.get("stats", {}).get("wald_correlation")
|
|
1346
|
+
if corr_matrix is not None:
|
|
1347
|
+
print(f"\nWald Correlation Matrix:")
|
|
1348
|
+
print("-" * 85)
|
|
1349
|
+
header_str = f"{'':<25} | " + " | ".join(f"{p[:10]:<10}" for p in param_names)
|
|
1350
|
+
print(header_str)
|
|
1351
|
+
print("-" * 85)
|
|
1352
|
+
for i, p_row in enumerate(param_names):
|
|
1353
|
+
row_str = f"{p_row[:25]:<25} | "
|
|
1354
|
+
row_vals = []
|
|
1355
|
+
for j in range(len(param_names)):
|
|
1356
|
+
if i < len(corr_matrix) and j < len(corr_matrix[i]):
|
|
1357
|
+
row_vals.append(f"{corr_matrix[i][j]:<10.4g}")
|
|
1358
|
+
else:
|
|
1359
|
+
row_vals.append(f"{'N/A':<10}")
|
|
1360
|
+
row_str += " | ".join(row_vals)
|
|
1361
|
+
print(row_str)
|
|
1362
|
+
print("-" * 85)
|
|
1363
|
+
print()
|
|
1364
|
+
|
|
1365
|
+
csv_path = os.path.join(
|
|
1366
|
+
paths["plot_path"],
|
|
1367
|
+
f"{MODEL_NAME}_{group_name}_optimization_results.csv",
|
|
1368
|
+
)
|
|
1369
|
+
log_optimization_results(opt, param_names, csv_path,
|
|
1370
|
+
model_name=MODEL_NAME, experiment_id=group_name,
|
|
1371
|
+
method=method)
|
|
1372
|
+
|
|
1373
|
+
if optimization_settings.get("petab_export"):
|
|
1374
|
+
_write_petab_archive(paths, MODEL_NAME, experiment,
|
|
1375
|
+
optimization_settings, group_optimizations)
|
|
1376
|
+
|
|
1377
|
+
if all_results and plot_function:
|
|
1378
|
+
plot_function(paths, all_results)
|
|
1379
|
+
for _o in group_optimizations.values():
|
|
1380
|
+
_shutdown_evaluator(_o)
|
|
1381
|
+
return group_optimizations
|
|
1382
|
+
|
|
1383
|
+
else:
|
|
1384
|
+
# ── Flat (shared) mode ────────────────────────────────────────────
|
|
1385
|
+
param_names = optimization_settings["param_names"]
|
|
1386
|
+
x0 = optimization_settings["x0"]
|
|
1387
|
+
bounds = optimization_settings.get("bounds")
|
|
1388
|
+
method = optimization_settings.get("method", "Nelder-Mead")
|
|
1389
|
+
opt_kwargs = optimization_settings.get("optimizer_kwargs", {})
|
|
1390
|
+
group_names = optimization_settings.get("group_names", None)
|
|
1391
|
+
|
|
1392
|
+
if not param_names:
|
|
1393
|
+
print("Error: No parameters to optimize. Set param_names and x0 in optimization_settings.")
|
|
1394
|
+
return
|
|
1395
|
+
|
|
1396
|
+
opt = run_optimization_from_groups(
|
|
1397
|
+
model_text, paths, experiment,
|
|
1398
|
+
param_names=param_names,
|
|
1399
|
+
x0=x0,
|
|
1400
|
+
bounds=bounds,
|
|
1401
|
+
group_names=group_names,
|
|
1402
|
+
method=method,
|
|
1403
|
+
optimizer_kwargs=opt_kwargs,
|
|
1404
|
+
wald_analysis=optimization_settings.get("wald_analysis", False),
|
|
1405
|
+
slice_analysis=optimization_settings.get("slice_analysis", False),
|
|
1406
|
+
profile_likelihood_analysis=optimization_settings.get("profile_likelihood_analysis", False),
|
|
1407
|
+
fast_profile_likelihood_analysis=optimization_settings.get("fast_profile_likelihood_analysis", False),
|
|
1408
|
+
sobol_analysis=optimization_settings.get("sobol_analysis", False),
|
|
1409
|
+
sobol_kwargs={"N": optimization_settings.get("sobol_N", 128), "mode": optimization_settings.get("sobol_mode", "loss")},
|
|
1410
|
+
fit_mode=optimization_settings.get("fit_mode", settings.get("fit_mode")),
|
|
1411
|
+
n_workers=optimization_settings.get("n_workers", settings.get("n_workers")),
|
|
1412
|
+
)
|
|
1413
|
+
|
|
1414
|
+
groups_tag = "_".join(opt.get("groups", ["ALL"]))
|
|
1415
|
+
|
|
1416
|
+
# Run analysis plots first so profile_ci is populated before CSV write
|
|
1417
|
+
if optimization_settings.get("slice_analysis") and opt["stats"].get("likelihood_slice"):
|
|
1418
|
+
_save_likelihood_slice_plot(opt, param_names, paths["plot_path"],
|
|
1419
|
+
MODEL_NAME, tag=groups_tag)
|
|
1420
|
+
if (optimization_settings.get("profile_likelihood_analysis") or optimization_settings.get("fast_profile_likelihood_analysis")) and opt["stats"].get("profile_likelihood"):
|
|
1421
|
+
_save_profile_likelihood_plot(
|
|
1422
|
+
opt, param_names, paths["plot_path"], MODEL_NAME,
|
|
1423
|
+
tag=groups_tag,
|
|
1424
|
+
profile_kwargs=_profile_kwargs(optimization_settings))
|
|
1425
|
+
if optimization_settings.get("sobol_analysis") and opt["stats"].get("sobol"):
|
|
1426
|
+
from Engine.Sensitivity_analysis import save_sobol_plot
|
|
1427
|
+
save_sobol_plot(opt["stats"]["sobol"], paths["plot_path"],
|
|
1428
|
+
MODEL_NAME, tag=groups_tag)
|
|
1429
|
+
|
|
1430
|
+
csv_path = os.path.join(
|
|
1431
|
+
paths["plot_path"],
|
|
1432
|
+
f"{MODEL_NAME}_{groups_tag}_optimization_results.csv",
|
|
1433
|
+
)
|
|
1434
|
+
log_optimization_results(opt, param_names, csv_path,
|
|
1435
|
+
model_name=MODEL_NAME, experiment_id=groups_tag, method=method)
|
|
1436
|
+
|
|
1437
|
+
if optimization_settings.get("petab_export"):
|
|
1438
|
+
_write_petab_archive(paths, MODEL_NAME, experiment,
|
|
1439
|
+
optimization_settings, {"__flat__": opt})
|
|
1440
|
+
|
|
1441
|
+
if opt.get("results_dict") is not None and plot_function:
|
|
1442
|
+
plot_function(paths, opt["results_dict"])
|
|
1443
|
+
_shutdown_evaluator(opt)
|
|
1444
|
+
return opt
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def _write_petab_archive(paths, model_name, experiment,
|
|
1448
|
+
optimization_settings, group_optimizations):
|
|
1449
|
+
"""Write a PEtab v2 archive to results/<model>/petab/<expid>/.
|
|
1450
|
+
|
|
1451
|
+
``expid`` is built from the sorted union of optimized group names so
|
|
1452
|
+
successive runs against different groups land in distinct subdirs.
|
|
1453
|
+
"""
|
|
1454
|
+
group_keys = sorted(group_optimizations.keys()) or ["ALL"]
|
|
1455
|
+
expid = "_".join(_sanitize_petab_id(g) for g in group_keys)
|
|
1456
|
+
out_dir = os.path.join(paths["plot_path"], "petab", expid)
|
|
1457
|
+
|
|
1458
|
+
model_file_abs = os.path.join(
|
|
1459
|
+
paths["repo_root"], "antimony_models", model_name, f"{model_name}.txt",
|
|
1460
|
+
)
|
|
1461
|
+
if not os.path.exists(model_file_abs):
|
|
1462
|
+
model_file_rel = f"{model_name}.txt"
|
|
1463
|
+
else:
|
|
1464
|
+
model_file_rel = os.path.relpath(model_file_abs, out_dir).replace("\\", "/")
|
|
1465
|
+
|
|
1466
|
+
try:
|
|
1467
|
+
export_petab(
|
|
1468
|
+
out_dir=out_dir,
|
|
1469
|
+
model_name=model_name,
|
|
1470
|
+
experiment=experiment,
|
|
1471
|
+
optimization_settings=optimization_settings,
|
|
1472
|
+
group_optimizations=group_optimizations,
|
|
1473
|
+
data_path=paths["data_path"],
|
|
1474
|
+
model_file_rel=model_file_rel,
|
|
1475
|
+
)
|
|
1476
|
+
except Exception as exc:
|
|
1477
|
+
print(f"[petab] Export failed: {exc}")
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def _sanitize_petab_id(s):
|
|
1481
|
+
import re as _re
|
|
1482
|
+
out = _re.sub(r'[^A-Za-z0-9_]', '_', str(s))
|
|
1483
|
+
return out or "id"
|