PyAntiGen 1.0.12__tar.gz → 1.0.13__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyantigen-1.0.12 → pyantigen-1.0.13}/PKG-INFO +1 -1
- {pyantigen-1.0.12 → pyantigen-1.0.13}/PyAntiGen.egg-info/PKG-INFO +1 -1
- pyantigen-1.0.13/PyAntiGen.egg-info/scm_version.json +8 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/_version.py +3 -3
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Evaluator.py +107 -35
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Fast_profile.py +22 -3
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Identifiability.py +92 -44
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Optimize.py +211 -9
- pyantigen-1.0.12/PyAntiGen.egg-info/scm_version.json +0 -8
- {pyantigen-1.0.12 → pyantigen-1.0.13}/.github/workflows/publish.yml +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/.gitignore +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/.vscode/launch.json +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/LICENSE +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/MANIFEST.in +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/PyAntiGen.egg-info/SOURCES.txt +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/PyAntiGen.egg-info/dependency_links.txt +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/PyAntiGen.egg-info/entry_points.txt +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/PyAntiGen.egg-info/requires.txt +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/PyAntiGen.egg-info/scm_file_list.json +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/PyAntiGen.egg-info/top_level.txt +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/README.md +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/.agents/skills/module_generation/SKILL.md +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/.agents/skills/ode_conversion_antimony/SKILL.md +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/.agents/skills/ode_extraction_pipeline/SKILL.md +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/.gitignore +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/AntimonyGen.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/RxnDict_to_antimony.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/TelluriumGen.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/__init__.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/antimony_utils.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/cli.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/data_interpolation.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/isotopomer_tools.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/model_generation.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/models.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/module_base.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/pyantigen.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/rate_laws.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/reaction_creation.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/AntiGen_paths.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Anchor_cache.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Deadline.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Event_times.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Fit_cache.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Model_optimize.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Model_simulate.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Nuisance_sensitivity.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Petab_export.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Preequil_cache.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Profile_checkpoint.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Results.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Sensitivity_analysis.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Simulate.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Flipflop_reference.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Model_generate.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Model_run.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Data.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Events.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Experiment.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Loss_config.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Observed_species.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Optimizer_settings.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Plots.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Solver_settings.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Update_opt_parameters.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Update_parameters.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/data/ADneg.csv +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/data/ADpos.csv +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/data/Flipflop.csv +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/data/make_flipflop_data.py +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/pyproject.toml +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/setup.cfg +0 -0
- {pyantigen-1.0.12 → pyantigen-1.0.13}/setup.py +0 -0
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '1.0.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 0,
|
|
21
|
+
__version__ = version = '1.0.13'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 0, 13)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'ga5c2cee6b'
|
|
@@ -208,7 +208,7 @@ def _init_worker(spec_blob):
|
|
|
208
208
|
f"{time.time() - t0:.1f}s", flush=True)
|
|
209
209
|
|
|
210
210
|
|
|
211
|
-
def _worker_nll(x):
|
|
211
|
+
def _worker_nll(x, frozen_sigmas=None):
|
|
212
212
|
"""The joint NLL, evaluated with this worker's own compiled models."""
|
|
213
213
|
from Engine.Optimize import evaluate_nll_fixed
|
|
214
214
|
|
|
@@ -221,17 +221,18 @@ def _worker_nll(x):
|
|
|
221
221
|
events_dynamic=spec.events_dynamic, failure_value=FAILURE_VALUE,
|
|
222
222
|
for_inference=getattr(spec, "for_inference", True),
|
|
223
223
|
concentrated=getattr(spec, "concentrated", True),
|
|
224
|
+
frozen_sigmas=frozen_sigmas,
|
|
224
225
|
)
|
|
225
226
|
|
|
226
227
|
|
|
227
|
-
def _eval_task(x):
|
|
228
|
+
def _eval_task(x, frozen_sigmas=None):
|
|
228
229
|
"""Evaluate one parameter vector. Never raises across the pool boundary."""
|
|
229
230
|
if _WORKER["spec"] is None:
|
|
230
231
|
return (FAILURE_VALUE, "worker-not-initialized", 0.0)
|
|
231
232
|
|
|
232
233
|
t0 = time.time()
|
|
233
234
|
try:
|
|
234
|
-
val = _worker_nll(x)
|
|
235
|
+
val = _worker_nll(x, frozen_sigmas=frozen_sigmas)
|
|
235
236
|
_WORKER["n_evals"] += 1
|
|
236
237
|
status = "ok" if np.isfinite(val) and val < FAILURE_VALUE else "sentinel"
|
|
237
238
|
return (float(val), status, time.time() - t0)
|
|
@@ -522,7 +523,17 @@ def _profile_task(job):
|
|
|
522
523
|
cache = {}
|
|
523
524
|
cache_order = []
|
|
524
525
|
|
|
525
|
-
|
|
526
|
+
# Every profile point pins each floored block at its own sigma_used
|
|
527
|
+
# from the fit rather than letting it re-concentrate (see
|
|
528
|
+
# Engine.Optimize._freeze_floor) -- stamped onto the job by
|
|
529
|
+
# profile_batch's frozen_sigmas, not decided here, so the caller
|
|
530
|
+
# controls it per batch.
|
|
531
|
+
frozen = job.get("frozen_sigmas")
|
|
532
|
+
|
|
533
|
+
def _pinned_nll(x_full):
|
|
534
|
+
return _worker_nll(x_full, frozen_sigmas=frozen)
|
|
535
|
+
|
|
536
|
+
raw_objective = _make_nuisance_objective(_pinned_nll, param_idx, n_params)
|
|
526
537
|
|
|
527
538
|
def nuisance_objective(x_nuisance, fixed_val):
|
|
528
539
|
x_arr = np.asarray(x_nuisance, dtype=float)
|
|
@@ -877,32 +888,50 @@ class ParallelEvaluator:
|
|
|
877
888
|
|
|
878
889
|
# -- evaluation --------------------------------------------------------
|
|
879
890
|
|
|
880
|
-
def evaluate_batch(self, xs, label=None
|
|
881
|
-
|
|
891
|
+
def evaluate_batch(self, xs, label=None, heartbeat_s=_HEARTBEAT_SECONDS,
|
|
892
|
+
frozen_sigmas=None):
|
|
893
|
+
"""Evaluate every parameter vector in *xs*; return losses in input order.
|
|
894
|
+
|
|
895
|
+
Uses submit/wait, not map -- see profile_batch's docstring for the
|
|
896
|
+
general reasoning. map() (the previous implementation here) returns
|
|
897
|
+
nothing until the WHOLE batch is done, so one slow straggler among
|
|
898
|
+
many fast points -- a slice-screen point far from the optimum landing
|
|
899
|
+
in a stiff numerical regime, say -- makes the entire batch silent for
|
|
900
|
+
as long as that one point takes, indistinguishable from a hang. A
|
|
901
|
+
heartbeat every heartbeat_s while nothing has landed answers that
|
|
902
|
+
directly: it says how many are done, how many are still in flight, and
|
|
903
|
+
an ETA once at least one has finished.
|
|
904
|
+
|
|
905
|
+
chunk_size no longer applies to this method: submitting one task per
|
|
906
|
+
vector is what makes the heartbeat and per-point completion visibility
|
|
907
|
+
possible at all, and no caller in this codebase sets chunk_size to
|
|
908
|
+
anything but the default anyway.
|
|
909
|
+
|
|
910
|
+
Still returns losses in INPUT order, not completion order -- unlike
|
|
911
|
+
profile_batch, whose callers key off fields in each job/result dict,
|
|
912
|
+
callers here (the slice screen especially) index into the return value
|
|
913
|
+
positionally.
|
|
914
|
+
"""
|
|
882
915
|
xs = [np.asarray(x, dtype=float) for x in xs]
|
|
883
|
-
|
|
916
|
+
n = len(xs)
|
|
917
|
+
if n == 0:
|
|
884
918
|
return []
|
|
885
919
|
if self._pool is None:
|
|
886
920
|
self.start()
|
|
887
921
|
|
|
888
|
-
|
|
889
|
-
if chunk is None:
|
|
890
|
-
# Enough chunks to keep every worker fed, few enough to avoid
|
|
891
|
-
# per-task overhead dominating.
|
|
892
|
-
chunk = max(1, len(xs) // (self.n_workers * 4) or 1)
|
|
922
|
+
from concurrent.futures import wait, FIRST_COMPLETED
|
|
893
923
|
|
|
894
924
|
t0 = time.time()
|
|
925
|
+
tag = f" [{label}]" if label else ""
|
|
895
926
|
if self.verbose:
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
print(f"[pool]{tag} {len(xs)} evaluation(s) submitted to "
|
|
902
|
-
f"{self.n_workers} worker(s) in chunks of {chunk}; "
|
|
903
|
-
f"no output until the batch completes.", flush=True)
|
|
927
|
+
print(f"[pool]{tag} {n} evaluation(s) submitted to "
|
|
928
|
+
f"{self.n_workers} worker(s); progress every "
|
|
929
|
+
f"{_fmt_dur(heartbeat_s)} until results start landing.",
|
|
930
|
+
flush=True)
|
|
931
|
+
|
|
904
932
|
try:
|
|
905
|
-
|
|
933
|
+
futures = {self._pool.submit(_eval_task, x, frozen_sigmas): i
|
|
934
|
+
for i, x in enumerate(xs)}
|
|
906
935
|
except RuntimeError as exc:
|
|
907
936
|
if "bootstrapping phase" in str(exc):
|
|
908
937
|
# spawn re-imports the __main__ module in every worker. If the
|
|
@@ -922,15 +951,46 @@ class ParallelEvaluator:
|
|
|
922
951
|
) from exc
|
|
923
952
|
raise
|
|
924
953
|
|
|
925
|
-
|
|
954
|
+
pending = set(futures)
|
|
955
|
+
out = [None] * n
|
|
956
|
+
work = 0.0
|
|
957
|
+
done = 0
|
|
926
958
|
failures = []
|
|
927
|
-
for i, (val, status, secs) in enumerate(out):
|
|
928
|
-
losses.append(val)
|
|
929
|
-
self.total_worker_seconds += secs
|
|
930
|
-
if status != "ok":
|
|
931
|
-
failures.append((i, status))
|
|
932
959
|
|
|
933
|
-
|
|
960
|
+
while pending:
|
|
961
|
+
finished, pending = wait(pending, timeout=heartbeat_s,
|
|
962
|
+
return_when=FIRST_COMPLETED)
|
|
963
|
+
|
|
964
|
+
if not finished:
|
|
965
|
+
if self.verbose:
|
|
966
|
+
now = time.time()
|
|
967
|
+
msg = (f" [pool{tag}] {done}/{n} done, "
|
|
968
|
+
f"{len(pending)} in flight, "
|
|
969
|
+
f"{_fmt_dur(now - t0)} elapsed")
|
|
970
|
+
if done:
|
|
971
|
+
rate = done / max(now - t0, 1e-9)
|
|
972
|
+
msg += f", ~{_fmt_dur((n - done) / rate)} remaining"
|
|
973
|
+
else:
|
|
974
|
+
msg += " (no point has finished yet, so no estimate)"
|
|
975
|
+
print(msg, flush=True)
|
|
976
|
+
continue
|
|
977
|
+
|
|
978
|
+
for fut in finished:
|
|
979
|
+
i = futures[fut]
|
|
980
|
+
try:
|
|
981
|
+
val, status, secs = fut.result()
|
|
982
|
+
except Exception as exc:
|
|
983
|
+
val = FAILURE_VALUE
|
|
984
|
+
status = f"error: {type(exc).__name__}: {exc}"
|
|
985
|
+
secs = 0.0
|
|
986
|
+
out[i] = val
|
|
987
|
+
work += secs
|
|
988
|
+
self.total_worker_seconds += secs
|
|
989
|
+
if status != "ok":
|
|
990
|
+
failures.append((i, status))
|
|
991
|
+
done += 1
|
|
992
|
+
|
|
993
|
+
self.n_evals += n
|
|
934
994
|
self.n_failures += len(failures)
|
|
935
995
|
|
|
936
996
|
if self.verbose:
|
|
@@ -939,22 +999,31 @@ class ParallelEvaluator:
|
|
|
939
999
|
# wall time is the speedup actually realized. On the first batch it
|
|
940
1000
|
# includes worker startup, so it understates steady-state throughput
|
|
941
1001
|
# -- report both numbers rather than one flattering one.
|
|
942
|
-
|
|
943
|
-
tag = f" [{label}]" if label else ""
|
|
944
|
-
print(f"[pool]{tag} {len(xs)} evals in {elapsed:.1f}s wall "
|
|
1002
|
+
print(f"[pool]{tag} {n} evals in {elapsed:.1f}s wall "
|
|
945
1003
|
f"({work:.1f}s of work, {work / elapsed:.1f}x, "
|
|
946
|
-
f"{
|
|
1004
|
+
f"{n / elapsed:.1f} eval/s)", flush=True)
|
|
947
1005
|
if failures:
|
|
948
1006
|
shown = "; ".join(f"#{i}: {s}" for i, s in failures[:3])
|
|
949
1007
|
more = f" (+{len(failures) - 3} more)" if len(failures) > 3 else ""
|
|
950
1008
|
print(f"[pool]{tag} {len(failures)} failed — {shown}{more}", flush=True)
|
|
951
1009
|
|
|
952
|
-
return
|
|
1010
|
+
return out
|
|
953
1011
|
|
|
954
1012
|
def profile_batch(self, jobs, on_result=None, label=None,
|
|
955
|
-
heartbeat_s=_HEARTBEAT_SECONDS, budget=None
|
|
1013
|
+
heartbeat_s=_HEARTBEAT_SECONDS, budget=None,
|
|
1014
|
+
frozen_sigmas=None):
|
|
956
1015
|
"""Run profile-likelihood points in parallel, within a wall budget.
|
|
957
1016
|
|
|
1017
|
+
``frozen_sigmas``, stamped onto every job here rather than left to
|
|
1018
|
+
each caller's job-building code, is a ``{(block_key_or_exp_id,
|
|
1019
|
+
obs_label): sigma_used}`` lookup (see ``Engine.Optimize.
|
|
1020
|
+
block_sigmas``) pinning each data-floored block found in it at its own
|
|
1021
|
+
resolved sigma for the point's whole nuisance re-optimization, instead
|
|
1022
|
+
of letting it re-concentrate as the nuisance vector moves -- see
|
|
1023
|
+
``Engine.Optimize._freeze_floor``. Every profile pass submitted
|
|
1024
|
+
through one ``batch()`` closure gets it uniformly this way, with no
|
|
1025
|
+
change needed at the individual job-building sites.
|
|
1026
|
+
|
|
958
1027
|
Unlike ``evaluate_batch`` this uses submit/wait rather than map, because
|
|
959
1028
|
each job is minutes to hours long and results must be checkpointed *as
|
|
960
1029
|
they land* -- the whole point of checkpointing is that killing the run
|
|
@@ -994,7 +1063,10 @@ class ParallelEvaluator:
|
|
|
994
1063
|
|
|
995
1064
|
t0 = time.time()
|
|
996
1065
|
results = []
|
|
997
|
-
|
|
1066
|
+
# Stamped once here, not per admitted job: unlike deadline/sec_per_eval
|
|
1067
|
+
# this does not depend on the clock, so every job in the batch gets it
|
|
1068
|
+
# up front.
|
|
1069
|
+
backlog = [dict(j, frozen_sigmas=frozen_sigmas) for j in jobs]
|
|
998
1070
|
futures = {}
|
|
999
1071
|
pending = set()
|
|
1000
1072
|
n_jobs = len(jobs)
|
|
@@ -281,13 +281,27 @@ def run_fast_profile(batch, nll_batch, res_x, nll_at_optimum, param_names,
|
|
|
281
281
|
n_rounds=DEFAULT_ROUNDS,
|
|
282
282
|
near_zero_frac=DEFAULT_NEAR_ZERO_FRAC,
|
|
283
283
|
span_decades=SPAN_DECADES,
|
|
284
|
-
min_reach_decades=MIN_REACH_DECADES, verbose=True
|
|
284
|
+
min_reach_decades=MIN_REACH_DECADES, verbose=True,
|
|
285
|
+
nll_at_optimum_profile=None):
|
|
285
286
|
"""Screen, then one capped profile point per crossed side, in rounds.
|
|
286
287
|
|
|
287
288
|
*batch* has the profile_batch signature: ``batch(jobs, on_result, label)``.
|
|
288
289
|
*nll_batch* evaluates a list of full parameter vectors, for the screen.
|
|
289
290
|
Returns the report dict; :func:`print_fast_profile_report` renders it and
|
|
290
291
|
:func:`fast_profile_summary` shrinks it for the results snapshot.
|
|
292
|
+
|
|
293
|
+
*nll_at_optimum* anchors the screen, which goes through *nll_batch* and so
|
|
294
|
+
is never sigma-frozen (see ``Engine.Optimize._freeze_floor``).
|
|
295
|
+
*nll_at_optimum_profile* anchors the profile points that come back through
|
|
296
|
+
*batch*, which the caller submits with each floored block's sigma pinned
|
|
297
|
+
at its own ``sigma_used`` from the fit (``frozen_sigmas=sigma_by_block``,
|
|
298
|
+
not the raw floor -- see ``Engine.Optimize._freeze_floor``). Reusing the
|
|
299
|
+
unfrozen anchor for a frozen point's dNLL would compare two different
|
|
300
|
+
objectives, and if freezing were done at the raw floor instead of the
|
|
301
|
+
block's own resolved sigma that mismatch could run to hundreds of nats
|
|
302
|
+
even a fraction of a Wald SE from the optimum, since most floored blocks
|
|
303
|
+
are not binding at a good fit. Defaults to *nll_at_optimum* when not
|
|
304
|
+
given, which is correct whenever the caller submits unfrozen points too.
|
|
291
305
|
"""
|
|
292
306
|
from Engine.Optimize import _cold_simplex, _param_bounds
|
|
293
307
|
|
|
@@ -295,6 +309,8 @@ def run_fast_profile(batch, nll_batch, res_x, nll_at_optimum, param_names,
|
|
|
295
309
|
n = len(param_names)
|
|
296
310
|
scales = list(scales) if scales is not None else ["lin"] * n
|
|
297
311
|
t_start = time.time()
|
|
312
|
+
if nll_at_optimum_profile is None:
|
|
313
|
+
nll_at_optimum_profile = nll_at_optimum
|
|
298
314
|
|
|
299
315
|
# ── The screen, reused when this fit already has one ──────────────────
|
|
300
316
|
screen = load_screen(ckpt_dir, param_names, res_x, threshold,
|
|
@@ -411,7 +427,7 @@ def run_fast_profile(batch, nll_batch, res_x, nll_at_optimum, param_names,
|
|
|
411
427
|
nll = res.get("nll")
|
|
412
428
|
ok = (res.get("status") == "ok" and nll is not None
|
|
413
429
|
and np.isfinite(nll) and nll < FAILURE_VALUE)
|
|
414
|
-
d = float(nll) - float(
|
|
430
|
+
d = float(nll) - float(nll_at_optimum_profile) if ok else None
|
|
415
431
|
prev = side["dnll"]
|
|
416
432
|
side["status"] = res.get("status")
|
|
417
433
|
side["rounds"].append({
|
|
@@ -525,7 +541,10 @@ def run_fast_profile(batch, nll_batch, res_x, nll_at_optimum, param_names,
|
|
|
525
541
|
|
|
526
542
|
report = {
|
|
527
543
|
"threshold": float(threshold),
|
|
528
|
-
|
|
544
|
+
# The anchor every dNLL in this report was actually measured against
|
|
545
|
+
# -- the frozen one, since every round's points come back through
|
|
546
|
+
# batch(). Not the same number as the screen's own anchor above.
|
|
547
|
+
"anchor": float(nll_at_optimum_profile),
|
|
529
548
|
"res_x": [float(v) for v in res_x],
|
|
530
549
|
"param_names": list(param_names),
|
|
531
550
|
"round_evals": int(round_evals),
|
|
@@ -133,6 +133,17 @@ class UnidentifiableParameters(RuntimeError):
|
|
|
133
133
|
)
|
|
134
134
|
|
|
135
135
|
|
|
136
|
+
def _is_usable(p):
|
|
137
|
+
"""Whether a slice point carries real information rather than a failure
|
|
138
|
+
sentinel. Shared by :func:`_verdict` (to find the true furthest usable
|
|
139
|
+
point) and :func:`run_slice_screen` (to decide whether a ladder's
|
|
140
|
+
outermost rung already settles a side) so the two never disagree about
|
|
141
|
+
what counts as a real crossing. ``nll < FAILURE_VALUE`` matters because
|
|
142
|
+
the sentinel is a large *finite* number, not inf or nan -- a failed
|
|
143
|
+
integration must not read as "crossed"."""
|
|
144
|
+
return np.isfinite(p["dnll"]) and p["nll"] < FAILURE_VALUE
|
|
145
|
+
|
|
146
|
+
|
|
136
147
|
def _se_for(wald_se, param_idx):
|
|
137
148
|
"""The Wald SE for one parameter in optimizer space, or None.
|
|
138
149
|
|
|
@@ -313,14 +324,11 @@ def _verdict(points, bound, threshold, p_opt, sign, is_log,
|
|
|
313
324
|
to be spent there. It is recorded rather than consumed: wiring it into the
|
|
314
325
|
profile's opening grid is a separate change.
|
|
315
326
|
"""
|
|
316
|
-
|
|
317
|
-
return np.isfinite(p["dnll"]) and p["nll"] < FAILURE_VALUE
|
|
318
|
-
|
|
319
|
-
finite = [p for p in points if _usable(p)]
|
|
327
|
+
finite = [p for p in points if _is_usable(p)]
|
|
320
328
|
|
|
321
329
|
inner = None
|
|
322
330
|
for p in points: # points are ordered outward
|
|
323
|
-
if not
|
|
331
|
+
if not _is_usable(p) or p["dnll"] > threshold:
|
|
324
332
|
break
|
|
325
333
|
inner = p["x_linear"]
|
|
326
334
|
|
|
@@ -389,25 +397,46 @@ def run_slice_screen(nll_batch, res_x, nll_at_optimum, param_names, bounds,
|
|
|
389
397
|
max_points=6, growth=2.0, range_factor=2.0,
|
|
390
398
|
span_decades=SPAN_DECADES,
|
|
391
399
|
min_reach_decades=MIN_REACH_DECADES, verbose=True):
|
|
392
|
-
"""Evaluate every parameter's slice out across decades,
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
400
|
+
"""Evaluate every parameter's slice out across decades, stopping each side
|
|
401
|
+
the moment it crosses.
|
|
402
|
+
|
|
403
|
+
The screen's job is to hand the profile a starting point, not to walk
|
|
404
|
+
every side all the way out. So the candidate ladder for each side --
|
|
405
|
+
:func:`screen_values`, unchanged -- is submitted one round at a time: round
|
|
406
|
+
*r* asks the pool for every side's *r*-th candidate, all still-active
|
|
407
|
+
sides together in one batch, which keeps this as parallel as the old
|
|
408
|
+
single-batch version was. A side is retired the instant one of its points
|
|
409
|
+
reads ``dnll > threshold`` and never contributes another evaluation --
|
|
410
|
+
:func:`_verdict` reads a side's state from whichever point is furthest out
|
|
411
|
+
among those actually evaluated, so nothing is lost by stopping there. A
|
|
412
|
+
side that never crosses keeps going, round after round, all the way to its
|
|
413
|
+
candidate list's last point -- the declared bound or ``span_decades`` out,
|
|
414
|
+
whichever is further, see :func:`screen_target` -- exactly as before,
|
|
415
|
+
because that is the one thing this screen has to be able to prove.
|
|
416
|
+
|
|
417
|
+
Why stopping matters now and did not always: once a block's sigma is
|
|
418
|
+
capped at a data-derived floor (``sigma_floor_from_data``), a point far
|
|
419
|
+
outside the fitted region can return a genuinely enormous NLL rather than
|
|
420
|
+
a merely large one -- the self-forgiving log(sigma) term that used to
|
|
421
|
+
compress a bad far-out fit is exactly what the floor removes -- and that
|
|
422
|
+
region is also where the integrator is slowest. A declared bound is a
|
|
423
|
+
user-supplied number with no guaranteed relationship to anything physical,
|
|
424
|
+
so nothing about it -- not "it is inside the box", not "it is only one
|
|
425
|
+
point" -- is safe to evaluate unconditionally. The only bound that is safe
|
|
426
|
+
is not asking for a point once its side has already answered the question.
|
|
427
|
+
|
|
428
|
+
The one thing round-by-round stopping gives up: a slice that crosses early
|
|
429
|
+
and then dips back below threshold at a point it never reaches would have
|
|
430
|
+
been read as "open" by a full evaluation, and reads "crossed" here
|
|
431
|
+
instead. That trades a slower profile on a rare, specific slice shape for
|
|
432
|
+
never paying an unbounded evaluation on the common one.
|
|
404
433
|
"""
|
|
405
434
|
from Engine.Optimize import _param_bounds
|
|
406
435
|
|
|
407
436
|
res_x = np.asarray(res_x, dtype=float)
|
|
408
437
|
scales = list(scales) if scales is not None else ["lin"] * len(param_names)
|
|
409
438
|
|
|
410
|
-
plan
|
|
439
|
+
plan = []
|
|
411
440
|
for i, name in enumerate(param_names):
|
|
412
441
|
is_log = scales[i] == "log10"
|
|
413
442
|
lb, ub = _param_bounds(bounds, i)
|
|
@@ -418,21 +447,48 @@ def run_slice_screen(nll_batch, res_x, nll_at_optimum, param_names, bounds,
|
|
|
418
447
|
range_factor=range_factor,
|
|
419
448
|
span_decades=span_decades)
|
|
420
449
|
plan.append({"index": i, "name": name, "side": side, "sign": sign,
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
x = res_x.copy()
|
|
426
|
-
x[i] = v
|
|
427
|
-
xs.append(x)
|
|
450
|
+
"is_log": is_log, "values": vals, "points": [],
|
|
451
|
+
"settled": False,
|
|
452
|
+
"p_opt": float(res_x[i]),
|
|
453
|
+
"bound": (lb if sign < 0 else ub)})
|
|
428
454
|
|
|
455
|
+
n_candidates = sum(len(e["values"]) for e in plan)
|
|
456
|
+
max_rounds = max((len(e["values"]) for e in plan), default=0)
|
|
429
457
|
if verbose:
|
|
430
458
|
print(f"\n[screen] slice screen: {len(param_names)} parameter(s) x 2 "
|
|
431
|
-
f"side(s)
|
|
432
|
-
f"
|
|
433
|
-
f"
|
|
434
|
-
|
|
435
|
-
|
|
459
|
+
f"side(s); up to {n_candidates} evaluation(s) across at most "
|
|
460
|
+
f"{max_rounds} round(s), one round per pool submission. A side "
|
|
461
|
+
f"stops the round it first crosses dNLL={threshold:g}; only "
|
|
462
|
+
f"sides still undecided pay for the next, further-out point. "
|
|
463
|
+
f"No nuisance optimization: each point is an upper bound on "
|
|
464
|
+
f"the profile, which is all the screen needs.", flush=True)
|
|
465
|
+
|
|
466
|
+
n_evaluations = 0
|
|
467
|
+
for r in range(max_rounds):
|
|
468
|
+
round_xs, round_owner = [], []
|
|
469
|
+
for entry in plan:
|
|
470
|
+
if entry["settled"] or r >= len(entry["values"]):
|
|
471
|
+
continue
|
|
472
|
+
v = entry["values"][r]
|
|
473
|
+
x = res_x.copy()
|
|
474
|
+
x[entry["index"]] = v
|
|
475
|
+
round_xs.append(x)
|
|
476
|
+
round_owner.append((entry, v))
|
|
477
|
+
if not round_xs:
|
|
478
|
+
break
|
|
479
|
+
if verbose:
|
|
480
|
+
print(f"[screen] round {r + 1}: {len(round_xs)} side(s) still "
|
|
481
|
+
f"undecided.", flush=True)
|
|
482
|
+
n_evaluations += len(round_xs)
|
|
483
|
+
nlls = nll_batch(round_xs, label=f"slice-screen-r{r + 1}")
|
|
484
|
+
for (entry, v), nll in zip(round_owner, nlls):
|
|
485
|
+
nll = float(nll)
|
|
486
|
+
point = {"x": float(v),
|
|
487
|
+
"x_linear": float(10.0 ** v if entry["is_log"] else v),
|
|
488
|
+
"nll": nll, "dnll": float(nll - nll_at_optimum)}
|
|
489
|
+
entry["points"].append(point)
|
|
490
|
+
if _is_usable(point) and point["dnll"] > threshold:
|
|
491
|
+
entry["settled"] = True
|
|
436
492
|
|
|
437
493
|
report = {"threshold": float(threshold),
|
|
438
494
|
"anchor": float(nll_at_optimum),
|
|
@@ -440,25 +496,17 @@ def run_slice_screen(nll_batch, res_x, nll_at_optimum, param_names, bounds,
|
|
|
440
496
|
"param_names": list(param_names),
|
|
441
497
|
"span_decades": float(span_decades),
|
|
442
498
|
"min_reach_decades": float(min_reach_decades),
|
|
443
|
-
"n_evaluations":
|
|
499
|
+
"n_evaluations": n_evaluations,
|
|
500
|
+
"n_candidates": n_candidates,
|
|
444
501
|
"parameters": {}}
|
|
445
502
|
|
|
446
|
-
pos = 0
|
|
447
503
|
for entry in plan:
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
nll = float(values[pos])
|
|
451
|
-
pos += 1
|
|
452
|
-
pts.append({
|
|
453
|
-
"x": float(v),
|
|
454
|
-
"x_linear": float(10.0 ** v if entry["is_log"] else v),
|
|
455
|
-
"nll": nll,
|
|
456
|
-
"dnll": float(nll - nll_at_optimum),
|
|
457
|
-
})
|
|
458
|
-
side = _verdict(pts, entry["bound"], threshold, entry["p_opt"],
|
|
459
|
-
entry["sign"], entry["is_log"],
|
|
504
|
+
side = _verdict(entry["points"], entry["bound"], threshold,
|
|
505
|
+
entry["p_opt"], entry["sign"], entry["is_log"],
|
|
460
506
|
min_reach_decades=min_reach_decades)
|
|
461
507
|
side["is_log"] = entry["is_log"]
|
|
508
|
+
side["stopped_early"] = bool(entry["settled"]
|
|
509
|
+
and len(entry["points"]) < len(entry["values"]))
|
|
462
510
|
report["parameters"].setdefault(entry["name"], {})[entry["side"]] = side
|
|
463
511
|
|
|
464
512
|
states = [s["state"] for sides in report["parameters"].values()
|
|
@@ -479,6 +479,42 @@ def _sigma_floor_for(obs_cfg, t_data, y_data, y_sim_ref=None):
|
|
|
479
479
|
return (nf.sigma if nf is not None else None), nf
|
|
480
480
|
|
|
481
481
|
|
|
482
|
+
def _freeze_floor(known_sigma, sigma_floor, frozen_value):
|
|
483
|
+
"""Pin a floored block's sigma at *frozen_value* instead of re-concentrating.
|
|
484
|
+
|
|
485
|
+
Used during profile-point evaluation, wired from :func:`evaluate_nll_fixed`
|
|
486
|
+
down from :meth:`ParallelEvaluator.profile_batch`, so a floored block's
|
|
487
|
+
sigma sits still for the whole nuisance re-optimization instead of being
|
|
488
|
+
re-estimated at every point the profile visits -- that re-estimation is
|
|
489
|
+
the self-forgiveness the floor exists to stop when it pushes sigma_hat
|
|
490
|
+
*above* the floor, and is worth stopping just as much below it, where the
|
|
491
|
+
floor itself has nothing to say.
|
|
492
|
+
|
|
493
|
+
*frozen_value* is the block's own ``sigma_used`` at the fitted optimum
|
|
494
|
+
(``min(sigma_hat_opt, floor)``; see :func:`block_sigmas`), not the raw
|
|
495
|
+
floor constant. That matters: most floored blocks are *not* binding at a
|
|
496
|
+
good optimum (``sigma_hat_opt`` already sits below the floor, which is
|
|
497
|
+
exactly why the fit was allowed to reach it), and pinning those at the
|
|
498
|
+
floor anyway would inflate every such block's term for the whole profile,
|
|
499
|
+
not just far from the optimum where self-forgiveness is the actual risk --
|
|
500
|
+
flattening the profile's true local sensitivity and reporting a large,
|
|
501
|
+
spurious dNLL even a fraction of a Wald SE from the fitted value. Pinning
|
|
502
|
+
at the block's own resolved value instead reproduces the concentrated
|
|
503
|
+
likelihood exactly at the optimum (the anchor and the profile then agree
|
|
504
|
+
there by construction) and only departs from it exactly where the block's
|
|
505
|
+
fit genuinely degrades as the profiled parameter moves.
|
|
506
|
+
|
|
507
|
+
The main fit and every other diagnostic (Wald, slice, Sobol) pass
|
|
508
|
+
``frozen_value=None`` and keep floored blocks profiled as before; only the
|
|
509
|
+
profile passes supply one. A block with no floor (``sigma_floor is
|
|
510
|
+
None``) is untouched either way, and a block with a true declared sigma
|
|
511
|
+
(``known_sigma`` already set) is never overridden.
|
|
512
|
+
"""
|
|
513
|
+
if frozen_value is not None and known_sigma is None and sigma_floor is not None:
|
|
514
|
+
return float(frozen_value), None
|
|
515
|
+
return known_sigma, sigma_floor
|
|
516
|
+
|
|
517
|
+
|
|
482
518
|
def _apply_n_eff_scale(weights, n_eff_scale):
|
|
483
519
|
"""Rescale a block's per-point weights to state an effective sample size.
|
|
484
520
|
|
|
@@ -729,6 +765,7 @@ def effective_k(param_names, blocks):
|
|
|
729
765
|
def collect_loss_blocks(
|
|
730
766
|
sim_results, groups, replicates, param_names, p_lin, p_dict,
|
|
731
767
|
trace_collector=None, loss_components=None, seen=None,
|
|
768
|
+
frozen_sigmas=None,
|
|
732
769
|
):
|
|
733
770
|
"""Walk every loss element in *groups* and return ``{block: [sse, n]}``.
|
|
734
771
|
|
|
@@ -759,6 +796,9 @@ def collect_loss_blocks(
|
|
|
759
796
|
every contributing block is added to it. Block count cannot measure data
|
|
760
797
|
coverage once elements share a sigma_block, so coverage checks must count
|
|
761
798
|
these instead.
|
|
799
|
+
|
|
800
|
+
``frozen_sigmas`` is passed straight through to every element's sigma
|
|
801
|
+
resolution -- see :func:`_freeze_floor`.
|
|
762
802
|
"""
|
|
763
803
|
blocks = {}
|
|
764
804
|
for g_name, g_config in groups.items():
|
|
@@ -787,6 +827,7 @@ def collect_loss_blocks(
|
|
|
787
827
|
trace_collector=trace_collector, blocks=blocks,
|
|
788
828
|
block_key=elem.get("sigma_block") or key, seen=seen,
|
|
789
829
|
n_eff_scale=elem.get("n_eff_scale"),
|
|
830
|
+
frozen_sigmas=frozen_sigmas,
|
|
790
831
|
)
|
|
791
832
|
else:
|
|
792
833
|
sim = elem.get("simulation")
|
|
@@ -799,6 +840,7 @@ def collect_loss_blocks(
|
|
|
799
840
|
loss_config=lc, trace_collector=trace_collector,
|
|
800
841
|
blocks=blocks, block_key=elem.get("sigma_block") or key,
|
|
801
842
|
seen=seen, n_eff_scale=elem.get("n_eff_scale"),
|
|
843
|
+
frozen_sigmas=frozen_sigmas,
|
|
802
844
|
)
|
|
803
845
|
|
|
804
846
|
if loss_components is not None:
|
|
@@ -819,6 +861,7 @@ def loss_function_evaluated(
|
|
|
819
861
|
block_key=None,
|
|
820
862
|
seen=None,
|
|
821
863
|
n_eff_scale=None,
|
|
864
|
+
frozen_sigmas=None,
|
|
822
865
|
):
|
|
823
866
|
"""
|
|
824
867
|
Evaluate the loss for already simulated results.
|
|
@@ -830,6 +873,10 @@ def loss_function_evaluated(
|
|
|
830
873
|
runs, so it does not depend on ``loss_type`` or on which sigma heuristic
|
|
831
874
|
fired. *block_key* lets the caller pin a stable identity for the element
|
|
832
875
|
even when ``results_dict`` is keyed by a display label.
|
|
876
|
+
|
|
877
|
+
*frozen_sigmas*, when given, is a ``{(block_key_or_exp_id, obs_label):
|
|
878
|
+
sigma_used}`` lookup (see :func:`block_sigmas`) consulted for every
|
|
879
|
+
floored observable -- see :func:`_freeze_floor`.
|
|
833
880
|
"""
|
|
834
881
|
loss_config = loss_config or {}
|
|
835
882
|
observables_config = loss_config.get("observables", [])
|
|
@@ -920,11 +967,16 @@ def loss_function_evaluated(
|
|
|
920
967
|
n_eff = obs_weights_v.sum()
|
|
921
968
|
loss_type = obs_cfg.get("loss_type", "nll")
|
|
922
969
|
|
|
970
|
+
block_id = block_key if block_key is not None else exp_id
|
|
923
971
|
known_sigma = _known_sigma_for(obs_cfg, obs_df, valid)
|
|
924
972
|
sigma_floor, floor_obj = (
|
|
925
973
|
_sigma_floor_for(obs_cfg, t_data, y_data, y_sim_ref=y_pred)
|
|
926
974
|
if known_sigma is None else (None, None))
|
|
927
|
-
|
|
975
|
+
frozen_value = (frozen_sigmas.get((block_id, _short_obs_label(obs)))
|
|
976
|
+
if frozen_sigmas else None)
|
|
977
|
+
known_sigma, sigma_floor = _freeze_floor(
|
|
978
|
+
known_sigma, sigma_floor, frozen_value)
|
|
979
|
+
_record_block(blocks, block_id,
|
|
928
980
|
obs, residuals, obs_weights_v,
|
|
929
981
|
known_sigma=known_sigma, sigma_floor=sigma_floor)
|
|
930
982
|
if seen is not None:
|
|
@@ -1034,6 +1086,7 @@ def loss_function_composite(
|
|
|
1034
1086
|
block_key=None,
|
|
1035
1087
|
seen=None,
|
|
1036
1088
|
n_eff_scale=None,
|
|
1089
|
+
frozen_sigmas=None,
|
|
1037
1090
|
):
|
|
1038
1091
|
"""
|
|
1039
1092
|
Evaluate aggregated composite loss across multiple simulated results.
|
|
@@ -1177,11 +1230,16 @@ def loss_function_composite(
|
|
|
1177
1230
|
n_eff = obs_weights_v.sum()
|
|
1178
1231
|
loss_type = obs_cfg.get("loss_type", "nll")
|
|
1179
1232
|
|
|
1233
|
+
block_id = block_key if block_key is not None else exp_id
|
|
1180
1234
|
known_sigma = _known_sigma_for(obs_cfg, obs_df, valid)
|
|
1181
1235
|
sigma_floor, floor_obj = (
|
|
1182
1236
|
_sigma_floor_for(obs_cfg, t_data, y_data, y_sim_ref=y_pred)
|
|
1183
1237
|
if known_sigma is None else (None, None))
|
|
1184
|
-
|
|
1238
|
+
frozen_value = (frozen_sigmas.get((block_id, _short_obs_label(obs)))
|
|
1239
|
+
if frozen_sigmas else None)
|
|
1240
|
+
known_sigma, sigma_floor = _freeze_floor(
|
|
1241
|
+
known_sigma, sigma_floor, frozen_value)
|
|
1242
|
+
_record_block(blocks, block_id,
|
|
1185
1243
|
obs, residuals, obs_weights_v,
|
|
1186
1244
|
known_sigma=known_sigma, sigma_floor=sigma_floor)
|
|
1187
1245
|
if seen is not None:
|
|
@@ -1458,7 +1516,7 @@ def evaluate_nll_fixed(
|
|
|
1458
1516
|
p, models, replicates, param_names, scales, groups, group_normalization,
|
|
1459
1517
|
fixed_sigmas, model_text=None, paths=None, events_dynamic=False,
|
|
1460
1518
|
failure_value=1e10, for_inference=True, concentrated=True,
|
|
1461
|
-
include_constant=False,
|
|
1519
|
+
include_constant=False, frozen_sigmas=None,
|
|
1462
1520
|
):
|
|
1463
1521
|
"""Joint NLL at *p* (optimizer space).
|
|
1464
1522
|
|
|
@@ -1474,6 +1532,12 @@ def evaluate_nll_fixed(
|
|
|
1474
1532
|
``concentrated=False`` reproduces the older frozen-sigma behaviour, where
|
|
1475
1533
|
``fixed_sigmas`` carries sigmas estimated once at the optimum. Retained for
|
|
1476
1534
|
comparison against archived runs; it is not the inference path.
|
|
1535
|
+
|
|
1536
|
+
``frozen_sigmas``, when given, pins every data-floored block found in it
|
|
1537
|
+
at its own ``sigma_used`` from the fit (see :func:`block_sigmas`), instead
|
|
1538
|
+
of letting it re-concentrate (up to its floor) at this particular point --
|
|
1539
|
+
see :func:`_freeze_floor`. Only the profile passes supply this; every
|
|
1540
|
+
other diagnostic leaves it None.
|
|
1477
1541
|
"""
|
|
1478
1542
|
p_lin = _to_linear(p, scales)
|
|
1479
1543
|
p_dict = dict(zip(param_names, p_lin.tolist()))
|
|
@@ -1487,6 +1551,7 @@ def evaluate_nll_fixed(
|
|
|
1487
1551
|
if concentrated:
|
|
1488
1552
|
blocks = collect_loss_blocks(
|
|
1489
1553
|
sim_results, groups, replicates, param_names, p_lin, p_dict,
|
|
1554
|
+
frozen_sigmas=frozen_sigmas,
|
|
1490
1555
|
)
|
|
1491
1556
|
if not blocks:
|
|
1492
1557
|
return failure_value
|
|
@@ -2051,6 +2116,73 @@ def _resolve_profile_optimizer(method, optimizer_kwargs):
|
|
|
2051
2116
|
return profile_method, profile_kwargs
|
|
2052
2117
|
|
|
2053
2118
|
|
|
2119
|
+
def _resolve_named(value, param_names, label):
|
|
2120
|
+
"""Normalize a per-parameter spec value (``x0`` or ``bounds``) to a list
|
|
2121
|
+
aligned with ``param_names``.
|
|
2122
|
+
|
|
2123
|
+
Accepts a ``{name: value}`` dict or an explicit sequence already aligned
|
|
2124
|
+
with ``param_names`` position-for-position -- the same duality
|
|
2125
|
+
``_resolve_scales`` already gives ``parameter_scale``, for the same
|
|
2126
|
+
reason: ``param_names``, ``x0`` and ``bounds`` are three separately
|
|
2127
|
+
authored per-parameter lists that today have no cross-check that they
|
|
2128
|
+
actually correspond, so reordering or editing one without the matching
|
|
2129
|
+
edit to the others is a silent misalignment, not an error. A dict closes
|
|
2130
|
+
that off structurally.
|
|
2131
|
+
|
|
2132
|
+
Unlike ``parameter_scale``'s dict, where an unlisted name safely defaults
|
|
2133
|
+
to "lin", there is no safe default for a missing x0 or bounds entry -- so
|
|
2134
|
+
both an unknown name (a typo, or a name that's no longer in param_names)
|
|
2135
|
+
and a missing one (an omission) raise, naming the parameter, rather than
|
|
2136
|
+
silently doing something plausible-looking with the wrong one or falling
|
|
2137
|
+
back to unbounded.
|
|
2138
|
+
"""
|
|
2139
|
+
if value is None:
|
|
2140
|
+
return None
|
|
2141
|
+
if isinstance(value, dict):
|
|
2142
|
+
names = set(param_names)
|
|
2143
|
+
unknown = sorted(set(value) - names)
|
|
2144
|
+
missing = sorted(names - set(value))
|
|
2145
|
+
if unknown or missing:
|
|
2146
|
+
problems = []
|
|
2147
|
+
if unknown:
|
|
2148
|
+
problems.append(f"unknown name(s) {unknown}")
|
|
2149
|
+
if missing:
|
|
2150
|
+
problems.append(f"missing name(s) {missing}")
|
|
2151
|
+
raise ValueError(
|
|
2152
|
+
f"{label} dict does not match param_names: "
|
|
2153
|
+
+ "; ".join(problems)
|
|
2154
|
+
)
|
|
2155
|
+
return [value[name] for name in param_names]
|
|
2156
|
+
values = list(value)
|
|
2157
|
+
if len(values) != len(param_names):
|
|
2158
|
+
raise ValueError(
|
|
2159
|
+
f"{label} has {len(values)} entries but there are "
|
|
2160
|
+
f"{len(param_names)} parameters"
|
|
2161
|
+
)
|
|
2162
|
+
return values
|
|
2163
|
+
|
|
2164
|
+
|
|
2165
|
+
def _resolve_bounds(bounds, param_names):
|
|
2166
|
+
"""``bounds`` as a ``{name: (lo, hi)}`` dict or an aligned sequence,
|
|
2167
|
+
normalized to the list every downstream consumer (``_param_bounds``,
|
|
2168
|
+
``_multistart_points``, the Wald Hessian, the profile grid, ``EvalSpec``,
|
|
2169
|
+
checkpoint fingerprinting) already expects. ``None`` means unbounded and
|
|
2170
|
+
passes through unchanged -- see ``_resolve_named``.
|
|
2171
|
+
"""
|
|
2172
|
+
return _resolve_named(bounds, param_names, "bounds")
|
|
2173
|
+
|
|
2174
|
+
|
|
2175
|
+
def _resolve_x0(x0, param_names):
|
|
2176
|
+
"""``x0`` as a ``{name: value}`` dict or an aligned sequence, normalized
|
|
2177
|
+
the same way as ``_resolve_bounds``. Unlike bounds, x0 is required --
|
|
2178
|
+
``None`` is a spec error, not "no starting point".
|
|
2179
|
+
"""
|
|
2180
|
+
resolved = _resolve_named(x0, param_names, "x0")
|
|
2181
|
+
if resolved is None:
|
|
2182
|
+
raise ValueError("x0 is required and was None")
|
|
2183
|
+
return resolved
|
|
2184
|
+
|
|
2185
|
+
|
|
2054
2186
|
def _auto_scale(value, bound, search_decades=None,
|
|
2055
2187
|
min_decades=_AUTO_MIN_DECADES):
|
|
2056
2188
|
"""Pick 'log10' or 'lin' for one parameter.
|
|
@@ -3773,6 +3905,20 @@ def run_parallel_profile(
|
|
|
3773
3905
|
n_params = len(param_names)
|
|
3774
3906
|
completed = checkpoint.load(param_names) if checkpoint is not None else \
|
|
3775
3907
|
{n: {} for n in param_names}
|
|
3908
|
+
# A loaded record's dnll was computed against whatever anchor was current
|
|
3909
|
+
# in the launch that wrote it. If that differs from this launch's anchor
|
|
3910
|
+
# -- the frozen-sigma anchor shipping is exactly such a change, see
|
|
3911
|
+
# _run_parallel_profile_with_checkpoint -- every downstream threshold
|
|
3912
|
+
# comparison below would silently compare against the wrong number.
|
|
3913
|
+
# Recomputed here, once, from the anchor-independent nll every record
|
|
3914
|
+
# already carries, so a resume is never wrong about the anchor a fresh
|
|
3915
|
+
# point would use, and no checkpoint has to be discarded when the anchor
|
|
3916
|
+
# definition changes.
|
|
3917
|
+
for pts in completed.values():
|
|
3918
|
+
for r in pts.values():
|
|
3919
|
+
nll = r.get("nll")
|
|
3920
|
+
r["dnll"] = (float(nll) - nll_at_optimum
|
|
3921
|
+
if nll is not None else float("nan"))
|
|
3776
3922
|
|
|
3777
3923
|
def _lin(v, is_log):
|
|
3778
3924
|
return 10.0 ** v if is_log else v
|
|
@@ -4748,7 +4894,7 @@ def _run_parallel_profile_with_checkpoint(
|
|
|
4748
4894
|
n_grid, range_factor, se_span, n_refine, run_id, checkpoint_enabled=True,
|
|
4749
4895
|
fixed_sigmas=None, warm_passes=1, max_extend=8, extend_growth=2.0,
|
|
4750
4896
|
bracket_rtol=0.05, screen_span_decades=None, screen_min_reach_decades=None,
|
|
4751
|
-
replicates=None,
|
|
4897
|
+
replicates=None, sigma_by_block=None,
|
|
4752
4898
|
):
|
|
4753
4899
|
"""Wire the pool, the checkpoint store, the wall budget and the profile."""
|
|
4754
4900
|
from Engine.Profile_checkpoint import (
|
|
@@ -4829,12 +4975,34 @@ def _run_parallel_profile_with_checkpoint(
|
|
|
4829
4975
|
)
|
|
4830
4976
|
|
|
4831
4977
|
def batch(jobs, on_result=None, label=None):
|
|
4978
|
+
# sigma_by_block: every profile point is a nuisance re-optimization,
|
|
4979
|
+
# and letting a floored block's sigma re-concentrate at each point is
|
|
4980
|
+
# the same self-forgiveness the floor exists to stop. Pinned instead
|
|
4981
|
+
# at the block's own sigma_used from the fit -- its floor only when
|
|
4982
|
+
# the floor was actually binding there, its sharper sigma_hat
|
|
4983
|
+
# otherwise -- for the whole profile. See _freeze_floor.
|
|
4832
4984
|
return evaluator.profile_batch(jobs, on_result=on_result, label=label,
|
|
4833
|
-
budget=budget
|
|
4985
|
+
budget=budget,
|
|
4986
|
+
frozen_sigmas=sigma_by_block)
|
|
4987
|
+
|
|
4988
|
+
# Every profile point comes back frozen (batch, above), so dNLL has to be
|
|
4989
|
+
# read against an anchor computed the same way -- reusing nll_at_optimum
|
|
4990
|
+
# (unfrozen; correct for the screen just above) would compare a frozen
|
|
4991
|
+
# point's NLL against an unfrozen anchor. Freezing each block at its own
|
|
4992
|
+
# sigma_used from the fit (rather than always at the raw floor) means this
|
|
4993
|
+
# reproduces the concentrated likelihood exactly at the optimum, so the
|
|
4994
|
+
# two anchors should differ only by float noise -- a real gap here means
|
|
4995
|
+
# sigma_by_block does not match opt_blocks, not that floors are binding.
|
|
4996
|
+
nll_at_optimum_frozen = evaluator.evaluate_batch(
|
|
4997
|
+
[res_x], label="profile-anchor-frozen", frozen_sigmas=sigma_by_block)[0]
|
|
4998
|
+
drift = nll_at_optimum_frozen - nll_at_optimum
|
|
4999
|
+
print(f"[profile] frozen anchor at the optimum: {nll_at_optimum_frozen:.6g} "
|
|
5000
|
+
f"(unfrozen: {nll_at_optimum:.6g}, {drift:+.6g}) -- every profile "
|
|
5001
|
+
f"dNLL below is measured from the frozen value.")
|
|
4834
5002
|
|
|
4835
5003
|
try:
|
|
4836
5004
|
traces, anchor, where, convergence = run_parallel_profile(
|
|
4837
|
-
batch, res_x,
|
|
5005
|
+
batch, res_x, nll_at_optimum_frozen, param_names,
|
|
4838
5006
|
bounds, scales, method=method, optimizer_kwargs=optimizer_kwargs,
|
|
4839
5007
|
wald_se=wald_se, n_grid=n_grid, range_factor=range_factor,
|
|
4840
5008
|
se_span=se_span, n_refine=n_refine, checkpoint=ckpt,
|
|
@@ -4859,6 +5027,7 @@ def _run_fast_profile_with_checkpoint(
|
|
|
4859
5027
|
checkpoint_enabled=True, fixed_sigmas=None, replicates=None,
|
|
4860
5028
|
round_evals=None, n_rounds=None, near_zero_frac=None,
|
|
4861
5029
|
screen_span_decades=None, screen_min_reach_decades=None,
|
|
5030
|
+
sigma_by_block=None,
|
|
4862
5031
|
):
|
|
4863
5032
|
"""Wire the pool, the checkpoint store and the wall budget to the fast pass.
|
|
4864
5033
|
|
|
@@ -4898,18 +5067,43 @@ def _run_fast_profile_with_checkpoint(
|
|
|
4898
5067
|
print(f"[fast profile] {budget.describe()}")
|
|
4899
5068
|
|
|
4900
5069
|
def batch(jobs, on_result=None, label=None):
|
|
5070
|
+
# Same reasoning as the full profile's own batch() -- a fast-profile
|
|
5071
|
+
# point is also a nuisance re-optimization, so each floored block's
|
|
5072
|
+
# sigma is pinned at its own sigma_used from the fit (its floor only
|
|
5073
|
+
# where that was actually binding) for the point rather than
|
|
5074
|
+
# re-concentrated at it. See _freeze_floor.
|
|
4901
5075
|
return evaluator.profile_batch(jobs, on_result=on_result, label=label,
|
|
4902
|
-
budget=budget
|
|
5076
|
+
budget=budget,
|
|
5077
|
+
frozen_sigmas=sigma_by_block)
|
|
4903
5078
|
|
|
4904
5079
|
def nll_batch(xs, label=None):
|
|
5080
|
+
# Plain evaluations for the screen this pass reuses -- no nuisance
|
|
5081
|
+
# re-optimization happens here, so left unfrozen like every other
|
|
5082
|
+
# slice-style evaluation.
|
|
4905
5083
|
return evaluator.evaluate_batch(xs, label=label)
|
|
4906
5084
|
|
|
5085
|
+
# See the full profile's own comment at the matching line: batch() above
|
|
5086
|
+
# submits every point frozen at sigma_by_block, so its dNLL has to be
|
|
5087
|
+
# read against an anchor computed the same way, not the unfrozen
|
|
5088
|
+
# nll_at_optimum the screen (nll_batch) uses. Freezing at each block's own
|
|
5089
|
+
# sigma_used (not the raw floor) means this should equal nll_at_optimum to
|
|
5090
|
+
# float noise -- a real gap means sigma_by_block is stale, not that floors
|
|
5091
|
+
# are binding.
|
|
5092
|
+
nll_at_optimum_frozen = evaluator.evaluate_batch(
|
|
5093
|
+
[res_x], label="profile-anchor-frozen", frozen_sigmas=sigma_by_block)[0]
|
|
5094
|
+
drift = nll_at_optimum_frozen - nll_at_optimum
|
|
5095
|
+
print(f"[fast profile] frozen anchor at the optimum: "
|
|
5096
|
+
f"{nll_at_optimum_frozen:.6g} (unfrozen: {nll_at_optimum:.6g}, "
|
|
5097
|
+
f"{drift:+.6g}) -- every profile-point dNLL below is measured "
|
|
5098
|
+
f"from the frozen value.")
|
|
5099
|
+
|
|
4907
5100
|
try:
|
|
4908
5101
|
report = run_fast_profile(
|
|
4909
5102
|
batch, nll_batch, res_x, nll_at_optimum, param_names, bounds,
|
|
4910
5103
|
scales, method=method, optimizer_kwargs=optimizer_kwargs,
|
|
4911
5104
|
wald_se=wald_se, wald_cov=wald_cov, checkpoint=ckpt,
|
|
4912
5105
|
ckpt_dir=ckpt.dir, threshold=_PROFILE_THRESHOLD,
|
|
5106
|
+
nll_at_optimum_profile=nll_at_optimum_frozen,
|
|
4913
5107
|
round_evals=(round_evals if round_evals is not None
|
|
4914
5108
|
else DEFAULT_ROUND_EVALS),
|
|
4915
5109
|
n_rounds=n_rounds if n_rounds is not None else DEFAULT_ROUNDS,
|
|
@@ -5327,8 +5521,14 @@ def run_optimization_from_groups(
|
|
|
5327
5521
|
# =========================================================================
|
|
5328
5522
|
if optimization_spec is not None:
|
|
5329
5523
|
param_names = optimization_spec.param_names
|
|
5330
|
-
|
|
5331
|
-
|
|
5524
|
+
# x0 and bounds may each be authored as a {name: value} dict or a
|
|
5525
|
+
# sequence already aligned with param_names -- resolved to the aligned
|
|
5526
|
+
# list here, once, so every consumer below and in every module this
|
|
5527
|
+
# spec's fields get threaded into (Engine.Evaluator's EvalSpec,
|
|
5528
|
+
# checkpoint fingerprinting, the profile grid, ...) keeps working with
|
|
5529
|
+
# plain positional values exactly as before. See _resolve_named.
|
|
5530
|
+
x0_lin = _resolve_x0(optimization_spec.x0, param_names)
|
|
5531
|
+
bounds_lin = _resolve_bounds(optimization_spec.bounds, param_names)
|
|
5332
5532
|
method = optimization_spec.method
|
|
5333
5533
|
optimizer_kwargs = optimization_spec.optimizer_kwargs or {}
|
|
5334
5534
|
selected_group_names = set(optimization_spec.groups.keys())
|
|
@@ -6075,6 +6275,7 @@ def run_optimization_from_groups(
|
|
|
6075
6275
|
screen_span_decades=screen_span_decades,
|
|
6076
6276
|
screen_min_reach_decades=(
|
|
6077
6277
|
screen_min_reach_decades),
|
|
6278
|
+
sigma_by_block=sigma_by_block,
|
|
6078
6279
|
)
|
|
6079
6280
|
)
|
|
6080
6281
|
elif profile_likelihood_analysis:
|
|
@@ -6120,6 +6321,7 @@ def run_optimization_from_groups(
|
|
|
6120
6321
|
screen_min_reach_decades=(
|
|
6121
6322
|
screen_min_reach_decades),
|
|
6122
6323
|
replicates=active_replicates,
|
|
6324
|
+
sigma_by_block=sigma_by_block,
|
|
6123
6325
|
)
|
|
6124
6326
|
)
|
|
6125
6327
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pyantigen-1.0.12 → pyantigen-1.0.13}/framework/.agents/skills/ode_conversion_antimony/SKILL.md
RENAMED
|
File without changes
|
{pyantigen-1.0.12 → pyantigen-1.0.13}/framework/.agents/skills/ode_extraction_pipeline/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Nuisance_sensitivity.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Profile_checkpoint.py
RENAMED
|
File without changes
|
|
File without changes
|
{pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Engine/Sensitivity_analysis.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Observed_species.py
RENAMED
|
File without changes
|
{pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Optimizer_settings.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Update_opt_parameters.py
RENAMED
|
File without changes
|
{pyantigen-1.0.12 → pyantigen-1.0.13}/framework/template/Example/Modules/Update_parameters.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|