PyAntiGen 2.0.2__tar.gz → 2.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. {pyantigen-2.0.2 → pyantigen-2.0.3}/PKG-INFO +1 -1
  2. {pyantigen-2.0.2 → pyantigen-2.0.3}/PyAntiGen.egg-info/PKG-INFO +1 -1
  3. {pyantigen-2.0.2 → pyantigen-2.0.3}/PyAntiGen.egg-info/scm_file_list.json +1 -0
  4. {pyantigen-2.0.2 → pyantigen-2.0.3}/PyAntiGen.egg-info/scm_version.json +2 -2
  5. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/_version.py +3 -3
  6. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Fit_cache.py +10 -2
  7. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Model_optimize.py +15 -5
  8. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Optimize.py +147 -9
  9. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Simulate.py +58 -2
  10. {pyantigen-2.0.2 → pyantigen-2.0.3}/.github/workflows/publish.yml +0 -0
  11. {pyantigen-2.0.2 → pyantigen-2.0.3}/.github/workflows/tests.yml +0 -0
  12. {pyantigen-2.0.2 → pyantigen-2.0.3}/.gitignore +0 -0
  13. {pyantigen-2.0.2 → pyantigen-2.0.3}/.vscode/launch.json +0 -0
  14. {pyantigen-2.0.2 → pyantigen-2.0.3}/LICENSE +0 -0
  15. {pyantigen-2.0.2 → pyantigen-2.0.3}/MANIFEST.in +0 -0
  16. {pyantigen-2.0.2 → pyantigen-2.0.3}/PyAntiGen.egg-info/SOURCES.txt +0 -0
  17. {pyantigen-2.0.2 → pyantigen-2.0.3}/PyAntiGen.egg-info/dependency_links.txt +0 -0
  18. {pyantigen-2.0.2 → pyantigen-2.0.3}/PyAntiGen.egg-info/entry_points.txt +0 -0
  19. {pyantigen-2.0.2 → pyantigen-2.0.3}/PyAntiGen.egg-info/requires.txt +0 -0
  20. {pyantigen-2.0.2 → pyantigen-2.0.3}/PyAntiGen.egg-info/top_level.txt +0 -0
  21. {pyantigen-2.0.2 → pyantigen-2.0.3}/README.md +0 -0
  22. {pyantigen-2.0.2 → pyantigen-2.0.3}/docs/MULTISTART.md +0 -0
  23. {pyantigen-2.0.2 → pyantigen-2.0.3}/docs/PROTOCOL_LAYER.md +0 -0
  24. {pyantigen-2.0.2 → pyantigen-2.0.3}/docs/V2_DESIGN.md +0 -0
  25. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/.agents/skills/module_generation/SKILL.md +0 -0
  26. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/.agents/skills/ode_conversion_antimony/SKILL.md +0 -0
  27. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/.agents/skills/ode_extraction_pipeline/SKILL.md +0 -0
  28. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/.gitignore +0 -0
  29. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/__init__.py +0 -0
  30. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/cli.py +0 -0
  31. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Anchor_cache.py +0 -0
  32. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Deadline.py +0 -0
  33. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Differential_evolution.py +0 -0
  34. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Evaluator.py +0 -0
  35. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Event_times.py +0 -0
  36. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Fast_profile.py +0 -0
  37. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Identifiability.py +0 -0
  38. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Model_simulate.py +0 -0
  39. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Nelder_mead.py +0 -0
  40. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Noise_floor.py +0 -0
  41. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Petab_export.py +0 -0
  42. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Preequil_cache.py +0 -0
  43. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Profile_checkpoint.py +0 -0
  44. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Profile_plan.py +0 -0
  45. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/README.md +0 -0
  46. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Results.py +0 -0
  47. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/Sensitivity_analysis.py +0 -0
  48. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/engine/__init__.py +0 -0
  49. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/generate/AntimonyGen.py +0 -0
  50. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/generate/RxnDict_to_antimony.py +0 -0
  51. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/generate/TelluriumGen.py +0 -0
  52. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/generate/__init__.py +0 -0
  53. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/generate/antimony_utils.py +0 -0
  54. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/generate/data_interpolation.py +0 -0
  55. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/generate/isotopomer_tools.py +0 -0
  56. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/generate/model_generation.py +0 -0
  57. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/generate/models.py +0 -0
  58. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/generate/module_base.py +0 -0
  59. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/generate/pyantigen.py +0 -0
  60. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/generate/rate_laws.py +0 -0
  61. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/generate/reaction_creation.py +0 -0
  62. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/study/__init__.py +0 -0
  63. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/study/assay.py +0 -0
  64. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/study/describe.py +0 -0
  65. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/study/design.py +0 -0
  66. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/study/lower_v1.py +0 -0
  67. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/study/optimization.py +0 -0
  68. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/study/params.py +0 -0
  69. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/study/quantity.py +0 -0
  70. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/study/reagent.py +0 -0
  71. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/study/refs.py +0 -0
  72. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/study/remarks.py +0 -0
  73. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/study/serialize.py +0 -0
  74. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/study/validate.py +0 -0
  75. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/AntiGen_paths.py +0 -0
  76. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/Flipflop_reference.py +0 -0
  77. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/Model_generate.py +0 -0
  78. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/Model_run.py +0 -0
  79. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/Modules/Data.py +0 -0
  80. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/Modules/Events.py +0 -0
  81. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/Modules/Experiment.py +0 -0
  82. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/Modules/Loss_config.py +0 -0
  83. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/Modules/Observed_species.py +0 -0
  84. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/Modules/Optimizer_settings.py +0 -0
  85. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/Modules/Plots.py +0 -0
  86. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/Modules/Solver_settings.py +0 -0
  87. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/Modules/Update_opt_parameters.py +0 -0
  88. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/Modules/Update_parameters.py +0 -0
  89. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/optimizations/Example1_ADneg.json +0 -0
  90. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/optimizations/Example1_ADpos.json +0 -0
  91. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/optimizations/Example3_joint.json +0 -0
  92. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/optimizations/Example4_flipflop.json +0 -0
  93. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/optimizations/example.py +0 -0
  94. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/studies/example.json +0 -0
  95. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/studies/example.py +0 -0
  96. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/Example/studies/flipflop.json +0 -0
  97. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/data/ADneg.csv +0 -0
  98. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/data/ADpos.csv +0 -0
  99. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/data/Flipflop.csv +0 -0
  100. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyantigen/template/data/make_flipflop_data.py +0 -0
  101. {pyantigen-2.0.2 → pyantigen-2.0.3}/pyproject.toml +0 -0
  102. {pyantigen-2.0.2 → pyantigen-2.0.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyAntiGen
3
- Version: 2.0.2
3
+ Version: 2.0.3
4
4
  Summary: Declarative generation, simulation and identifiability analysis of compartmental Antimony/SBML models
5
5
  Author-email: Don Elbert <elbert5770@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyAntiGen
3
- Version: 2.0.2
3
+ Version: 2.0.3
4
4
  Summary: Declarative generation, simulation and identifiability analysis of compartmental Antimony/SBML models
5
5
  Author-email: Don Elbert <elbert5770@gmail.com>
6
6
  License-Expression: MIT
@@ -105,6 +105,7 @@
105
105
  "tests/engine/test_fit_cache.py",
106
106
  "tests/engine/test_hessian_steps.py",
107
107
  "tests/engine/test_multistart_sampler.py",
108
+ "tests/engine/test_multistart_stage.py",
108
109
  "tests/engine/test_nelder_mead.py",
109
110
  "tests/engine/test_point_identity.py",
110
111
  "tests/engine/test_profile_grid_open.py",
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "2.0.2",
2
+ "tag": "2.0.3",
3
3
  "distance": 0,
4
- "node": "g6b1549beb3e3a5bc372670dbc2e8e502501b547d",
4
+ "node": "g0218ed4d70bfc541384240d1eda8c923794f4d21",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-10-05"
@@ -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 = '2.0.2'
22
- __version_tuple__ = version_tuple = (2, 0, 2)
21
+ __version__ = version = '2.0.3'
22
+ __version_tuple__ = version_tuple = (2, 0, 3)
23
23
 
24
- __commit_id__ = commit_id = 'g6b1549beb'
24
+ __commit_id__ = commit_id = 'g0218ed4d7'
@@ -156,6 +156,13 @@ def fit_fingerprint(param_names, x0_lin, bounds_lin, scales, groups, model_text,
156
156
  for k in ("profile_method", "profile_optimizer_kwargs", "profile_grid",
157
157
  "profile_without_opt"):
158
158
  kwargs.pop(k, None)
159
+ # The multi-start stage decides where the fit starts, which matters only
160
+ # when there is more than one start. Dropped otherwise so that giving every
161
+ # spec a default stage does not invalidate the fits already cached.
162
+ if int(n_starts or 1) <= 1:
163
+ for k in ("multistart_method", "multistart_optimizer_kwargs",
164
+ "multistart_limits"):
165
+ kwargs.pop(k, None)
159
166
 
160
167
  key = {
161
168
  "format": _FORMAT,
@@ -175,8 +182,9 @@ def fit_fingerprint(param_names, x0_lin, bounds_lin, scales, groups, model_text,
175
182
  "data": data_hash,
176
183
  }
177
184
  # Left out at the default so fits cached before the sampler existed keep
178
- # their hash; it matters only when n_starts > 1.
179
- if (start_sampler or "lhs") != "lhs":
185
+ # their hash, and left out at one start, where nothing is sampled, so that
186
+ # a project-wide default of "sobol" does not invalidate single-start fits.
187
+ if int(n_starts or 1) > 1 and (start_sampler or "lhs") != "lhs":
180
188
  key["start_sampler"] = start_sampler
181
189
  blob = json.dumps(key, sort_keys=True, default=_json_default)
182
190
  fit_hash = hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
@@ -973,12 +973,20 @@ def _slice_dnll_at_optimum(param_vals, dnll, opt_val, rtol=1e-6):
973
973
  return float(y[i])
974
974
 
975
975
 
976
- def _save_likelihood_slice_plot(opt, param_names, plot_path, model_name, tag="ALL"):
977
- """Run likelihood slice once per parameter and save plot."""
976
+ def _save_likelihood_slice_plot(opt, param_names, plot_path, model_name, tag="ALL",
977
+ slice_settings=None):
978
+ """Run likelihood slice once per parameter and save plot.
979
+
980
+ slice_settings is the spec's ``slice_settings`` ({"n_points", "range_factor"});
981
+ absent keys keep the values that used to be fixed here, 20 and 2.0.
982
+ """
978
983
  from datetime import datetime
979
984
  import numpy as np
980
985
  import matplotlib.pyplot as plt
981
986
 
987
+ n_points = int((slice_settings or {}).get("n_points", 20))
988
+ range_factor = float((slice_settings or {}).get("range_factor", 2.0))
989
+
982
990
  slice_func = opt.get("stats", {}).get("likelihood_slice")
983
991
  if not slice_func:
984
992
  print("Warning: no likelihood_slice closure in opt['stats'] — skipping plot.")
@@ -1004,7 +1012,7 @@ def _save_likelihood_slice_plot(opt, param_names, plot_path, model_name, tag="AL
1004
1012
  results = None
1005
1013
  if slice_all is not None:
1006
1014
  try:
1007
- results = slice_all(n_points=20, range_factor=2.0)
1015
+ results = slice_all(n_points=n_points, range_factor=range_factor)
1008
1016
  except Exception as exc:
1009
1017
  print(f" batched slice failed ({exc}); falling back per parameter.")
1010
1018
 
@@ -1014,7 +1022,7 @@ def _save_likelihood_slice_plot(opt, param_names, plot_path, model_name, tag="AL
1014
1022
  if results is not None and pname in results:
1015
1023
  pv, nr = results[pname]
1016
1024
  else:
1017
- pv, nr = slice_func(i, n_points=20, range_factor=2.0)
1025
+ pv, nr = slice_func(i, n_points=n_points, range_factor=range_factor)
1018
1026
  cache[i] = (pv, nr)
1019
1027
  slice_traces[pname] = {"x": np.asarray(pv).tolist(),
1020
1028
  "y": np.asarray(nr).tolist()}
@@ -1233,7 +1241,9 @@ def setup_optimization_from_groups(settings, optimization_settings, EXPERIMENT_d
1233
1241
 
1234
1242
  if settings.get("slice_analysis") and opt.get("stats", {}).get("likelihood_slice"):
1235
1243
  _save_likelihood_slice_plot(opt, optimization_settings.param_names, paths["plot_path"],
1236
- MODEL_NAME, tag=groups_tag)
1244
+ MODEL_NAME, tag=groups_tag,
1245
+ slice_settings=getattr(optimization_settings,
1246
+ "slice_settings", None))
1237
1247
  if (settings.get("profile_likelihood_analysis") or settings.get("fast_profile_likelihood_analysis")) and opt.get("stats", {}).get("profile_likelihood"):
1238
1248
  _save_profile_likelihood_plot(
1239
1249
  opt, optimization_settings.param_names, paths["plot_path"],
@@ -2259,6 +2259,10 @@ _ENGINE_ONLY_OPTIMIZER_KEYS = (
2259
2259
  "profile_method",
2260
2260
  "profile_optimizer_kwargs",
2261
2261
  "profile_grid", # read by pyantigen.engine.Model_optimize._profile_kwargs
2262
+ # Multi-start as its own stage: see _resolve_multistart_stage.
2263
+ "multistart_method",
2264
+ "multistart_optimizer_kwargs",
2265
+ "multistart_limits",
2262
2266
  )
2263
2267
 
2264
2268
 
@@ -2314,6 +2318,58 @@ def _resolve_profile_optimizer(method, optimizer_kwargs):
2314
2318
  return profile_method, profile_kwargs
2315
2319
 
2316
2320
 
2321
+ def _resolve_multistart_stage(method, optimizer_kwargs):
2322
+ """Method and kwargs for a separate multi-start stage, or (None, {}).
2323
+
2324
+ Absent ``multistart_method`` in optimizer_kwargs, multi-start is what it
2325
+ always was: every start is fitted with the spec's own method and options and
2326
+ the best of them IS the fit.
2327
+
2328
+ Present, multi-start becomes a stage that runs BEFORE the fit. Each start is
2329
+ run with this method and ``multistart_optimizer_kwargs`` -- typically a
2330
+ bounds-respecting method with a small iteration cap, since the point is to
2331
+ find the basin, not to converge -- and the best point it reaches is where
2332
+ the standard fit then starts. A global method is not accepted here: it would
2333
+ search the box the starts are meant to sample.
2334
+ """
2335
+ kw = optimizer_kwargs or {}
2336
+ ms_method = kw.get("multistart_method")
2337
+ if not ms_method:
2338
+ return None, {}
2339
+ if str(ms_method).lower() in _GLOBAL_METHODS:
2340
+ raise ValueError(
2341
+ f"multistart_method {ms_method!r} is a global method; the multi-start "
2342
+ f"stage runs a local method from each sampled start.")
2343
+ return ms_method, dict(kw.get("multistart_optimizer_kwargs") or {})
2344
+
2345
+
2346
+ # What a multi-start evaluation may spend, and what a failure costs.
2347
+ # max_attempts safe_simulate attempts per simulation, the first included.
2348
+ # max_depth levels of time-span subdivision after those fail; 0 = none.
2349
+ # failure_dnll a failed evaluation is scored at the worst finite NLL among
2350
+ # the screened starts PLUS this, instead of 1e10.
2351
+ # 1e10 is a cliff: a gradient method that steps into it reads a slope of
2352
+ # ~1e10/step, shrinks to nothing and stalls. A finite, merely large penalty is
2353
+ # something the line search can step back from. It is fixed once, from the
2354
+ # screen, so the surface does not move under the optimizer while it runs.
2355
+ _MULTISTART_LIMIT_DEFAULTS = {"max_attempts": 4, "max_depth": 0, "failure_dnll": 1000.0}
2356
+
2357
+
2358
+ def _resolve_multistart_limits(optimizer_kwargs):
2359
+ """The multi-start stage's evaluation limits: defaults overlaid by the spec's
2360
+ ``multistart_limits``. An unknown key is an error, not a silent no-op."""
2361
+ given = dict((optimizer_kwargs or {}).get("multistart_limits") or {})
2362
+ unknown = sorted(set(given) - set(_MULTISTART_LIMIT_DEFAULTS))
2363
+ if unknown:
2364
+ raise ValueError(f"unknown multistart_limits key(s) {unknown}; expected a "
2365
+ f"subset of {sorted(_MULTISTART_LIMIT_DEFAULTS)}")
2366
+ out = {**_MULTISTART_LIMIT_DEFAULTS, **given}
2367
+ if out["failure_dnll"] is not None and not float(out["failure_dnll"]) > 0:
2368
+ raise ValueError("multistart_limits['failure_dnll'] must be positive, or "
2369
+ "None to keep the 1e10 failure value")
2370
+ return out
2371
+
2372
+
2317
2373
  def _resolve_named(value, param_names, label):
2318
2374
  """Normalize a per-parameter spec value (``x0`` or ``bounds``) to a list
2319
2375
  aligned with ``param_names``.
@@ -2558,7 +2614,8 @@ def _multistart_points(x0, bounds, scales, n_starts, search_decades=None,
2558
2614
 
2559
2615
 
2560
2616
  def _run_multistart(objective, starts, method, bounds, opt_kw, scales,
2561
- screen=True, failure_value=1e10, verbose=True):
2617
+ screen=True, failure_value=1e10, verbose=True,
2618
+ failure_dnll=None):
2562
2619
  """Fit from every start, keep the best, and report the spread.
2563
2620
 
2564
2621
  The spread is as much the point as the best value. If every start lands on
@@ -2567,6 +2624,13 @@ def _run_multistart(objective, starts, method, bounds, opt_kw, scales,
2567
2624
  confidence interval anchored on any single one of them is measuring the
2568
2625
  wrong basin -- which the engine can otherwise only discover much later, via
2569
2626
  ``profile_anchor_gap`` finding a point better than the reported optimum.
2627
+
2628
+ ``failure_dnll``: when given, an evaluation that fails (the objective's
2629
+ ``failure_value``, or a non-finite NLL) is scored at the worst finite NLL
2630
+ seen in the screen plus this, for the fits. The fits then see a large but
2631
+ finite wall rather than a 1e10 cliff, which a gradient method such as
2632
+ L-BFGS-B can step back from. The reference is fixed once, so the surface
2633
+ does not move while the optimizer runs.
2570
2634
  """
2571
2635
  from scipy.optimize import minimize
2572
2636
 
@@ -2582,17 +2646,24 @@ def _run_multistart(objective, starts, method, bounds, opt_kw, scales,
2582
2646
  #
2583
2647
  # Start 1 is never screened out. It is the spec's own x0, and if that
2584
2648
  # cannot be integrated the caller needs to see the failure, not a silent skip.
2649
+ def _eval(s):
2650
+ try:
2651
+ return float(objective(s))
2652
+ except Exception:
2653
+ return float("inf")
2654
+
2655
+ seen = [] # finite NLLs from the screen: the failure penalty's reference
2585
2656
  if screen and len(starts) > 1:
2586
2657
  kept, dropped = [], 0
2587
2658
  for i, s in enumerate(starts):
2588
- if i == 0:
2659
+ if i == 0 and failure_dnll is None:
2589
2660
  kept.append(s)
2590
2661
  continue
2591
- try:
2592
- v = float(objective(s))
2593
- except Exception:
2594
- v = float("inf")
2595
- if np.isfinite(v) and v < failure_value:
2662
+ v = _eval(s)
2663
+ ok = np.isfinite(v) and v < failure_value
2664
+ if ok:
2665
+ seen.append(v)
2666
+ if i == 0 or ok:
2596
2667
  kept.append(s)
2597
2668
  else:
2598
2669
  dropped += 1
@@ -2601,14 +2672,42 @@ def _run_multistart(objective, starts, method, bounds, opt_kw, scales,
2601
2672
  f"the model does not integrate there.")
2602
2673
  starts = kept
2603
2674
 
2675
+ fit_objective, failed, penalty = objective, [0], None
2676
+ if failure_dnll is not None:
2677
+ if not seen and starts:
2678
+ v = _eval(starts[0])
2679
+ if np.isfinite(v) and v < failure_value:
2680
+ seen.append(v)
2681
+ if seen:
2682
+ penalty = max(seen) + float(failure_dnll)
2683
+
2684
+ def fit_objective(x):
2685
+ v = _eval(x)
2686
+ if np.isfinite(v) and v < failure_value:
2687
+ return v
2688
+ failed[0] += 1
2689
+ return penalty
2690
+
2691
+ if verbose:
2692
+ print(f" [opt] a failed evaluation scores {penalty:.6g} "
2693
+ f"(worst screened nll {max(seen):.6g} + {float(failure_dnll):g}) "
2694
+ f"during the multi-start fits.")
2695
+ elif verbose:
2696
+ print(" [opt] no screened start evaluated, so failed evaluations keep "
2697
+ "their 1e10 value in the multi-start fits.")
2698
+
2604
2699
  records, best, best_i = [], None, -1
2605
2700
  for i, s in enumerate(starts):
2606
2701
  s = np.asarray(s, dtype=float)
2607
2702
  try:
2608
- r = minimize(objective, s, method=method,
2703
+ r = minimize(fit_objective, s, method=method,
2609
2704
  bounds=bounds or None, **opt_kw)
2610
2705
  fun = float(r.fun)
2611
2706
  ok, msg = bool(r.success), str(r.message)
2707
+ if penalty is not None and fun >= penalty:
2708
+ # Ended on the failure plateau: not an optimum of anything.
2709
+ fun, ok = float("inf"), False
2710
+ msg = "ended where the model does not integrate (failure penalty)"
2612
2711
  except Exception as exc:
2613
2712
  r, fun, ok = None, float("inf"), False
2614
2713
  msg = f"{type(exc).__name__}: {exc}"
@@ -2626,6 +2725,10 @@ def _run_multistart(objective, starts, method, bounds, opt_kw, scales,
2626
2725
  print(f" [opt] start {i + 1}/{len(starts)}: nll={fun:.6g}{mark}",
2627
2726
  flush=True)
2628
2727
 
2728
+ if verbose and failed[0]:
2729
+ print(f" [opt] {failed[0]} evaluation(s) failed during the multi-start fits "
2730
+ f"and were scored at the failure penalty.")
2731
+
2629
2732
  if verbose:
2630
2733
  vals = [rec["fun"] for rec in records if rec["fun"] is not None]
2631
2734
  if len(vals) > 1:
@@ -6713,8 +6816,43 @@ def run_optimization_from_groups(
6713
6816
  seed=getattr(optimization_spec, "start_seed", None),
6714
6817
  sampler=getattr(optimization_spec, "start_sampler", "lhs"),
6715
6818
  )
6819
+ # A separate multi-start stage, when the spec asks for one: the
6820
+ # starts are run with their own (cheaper, bounds-respecting) method
6821
+ # and the best point reached is where the standard fit below starts.
6822
+ # Without it, the branch further down fits every start with the
6823
+ # spec's own method and the best of them is the answer.
6824
+ staged_start = None
6825
+ ms_method, ms_kw_raw = _resolve_multistart_stage(method, optimizer_kwargs)
6826
+ if len(starts) > 1 and ms_method:
6827
+ if fit_cache is not None and fit_cache.load_partial() is not None:
6828
+ # The standard fit already started in an earlier launch, so
6829
+ # the stage finished then; its answer is in the partial.
6830
+ print("[opt] multi-start stage skipped: an earlier launch "
6831
+ "already began the standard fit from its result.")
6832
+ else:
6833
+ ms_kw = _prepare_optimizer_kwargs(ms_method, ms_kw_raw, fast, None, None)
6834
+ limits = _resolve_multistart_limits(optimizer_kwargs)
6835
+ print(f"[opt] multi-start stage: {len(starts)} starts with "
6836
+ f"{ms_method}, then the standard fit ({method}) from the best. "
6837
+ f"Per evaluation: {limits['max_attempts']} solver attempt(s), "
6838
+ f"subdivision depth {limits['max_depth']}.")
6839
+ from pyantigen.engine.Simulate import retry_limits
6840
+ # The full ladder is restored on leaving the block, so the
6841
+ # standard fit and every diagnostic after it run as before.
6842
+ with retry_limits(limits["max_attempts"], limits["max_depth"]):
6843
+ ms_res, start_records, _best_start = _run_multistart(
6844
+ objective, starts, ms_method, bounds, ms_kw, scales,
6845
+ failure_dnll=limits["failure_dnll"])
6846
+ if ms_res is None:
6847
+ print("[opt] every multi-start fit failed; the standard "
6848
+ "fit starts from the spec's x0.")
6849
+ else:
6850
+ staged_start = np.asarray(ms_res.x, dtype=float)
6851
+ print(f"[opt] multi-start stage best nll {float(ms_res.fun):.6g} "
6852
+ f"(start {_best_start + 1}); the standard fit starts there.")
6853
+ starts = starts[:1]
6716
6854
  if len(starts) == 1:
6717
- x_start = x0
6855
+ x_start = x0 if staged_start is None else staged_start
6718
6856
  partial = fit_cache.load_partial() if fit_cache is not None else None
6719
6857
  if partial is not None:
6720
6858
  # A previous launch of this same fit was killed before it
@@ -359,6 +359,62 @@ class search_mode:
359
359
  return False
360
360
 
361
361
 
362
+ # ---------------------------------------------------------------------------
363
+ # Retry limits: a shorter ladder, but still a ladder
364
+ # ---------------------------------------------------------------------------
365
+ #
366
+ # Search mode (above) removes the ladder altogether. The multi-start stage wants
367
+ # something in between: most vectors it samples integrate on the first attempt,
368
+ # and a handful need a looser tolerance, but none is worth the full ladder of ten
369
+ # attempts and four levels of time-span subdivision that a vector somebody cares
370
+ # about gets. Limits cap both. With max_attempts=4 and max_depth=0 a vector gets
371
+ # the fast path plus three loosened retries, is never subdivided, and a failure
372
+ # raises -- which the objective turns into its failure value.
373
+ #
374
+ # Unlike search mode this does not cap the step allowance, so a vector that
375
+ # integrates slowly still integrates. Process-global, like search mode; the
376
+ # default (None) is the original behaviour, 10 attempts and depth 4.
377
+ _DEFAULT_MAX_ATTEMPTS = 10
378
+ _DEFAULT_MAX_DEPTH = 4
379
+ _RETRY_LIMITS = None
380
+
381
+
382
+ def set_retry_limits(max_attempts=None, max_depth=None):
383
+ """Cap safe_simulate's ladder. ``None`` for both restores the defaults.
384
+
385
+ max_attempts counts the first (fast-path) attempt, as the log's
386
+ "Attempt n/N" does. Returns the previous setting so a caller can restore it.
387
+ """
388
+ global _RETRY_LIMITS
389
+ previous = _RETRY_LIMITS
390
+ if max_attempts is None and max_depth is None:
391
+ _RETRY_LIMITS = None
392
+ return previous
393
+ attempts = _DEFAULT_MAX_ATTEMPTS if max_attempts is None else int(max_attempts)
394
+ depth = _DEFAULT_MAX_DEPTH if max_depth is None else int(max_depth)
395
+ if attempts < 1 or depth < 0:
396
+ raise ValueError(f"retry limits need max_attempts >= 1 and max_depth >= 0, "
397
+ f"got {attempts} and {depth}")
398
+ _RETRY_LIMITS = (attempts, depth)
399
+ return previous
400
+
401
+
402
+ class retry_limits:
403
+ """``with retry_limits(4, 0): ...`` -- the limits for the block."""
404
+
405
+ def __init__(self, max_attempts=None, max_depth=None):
406
+ self.max_attempts, self.max_depth = max_attempts, max_depth
407
+
408
+ def __enter__(self):
409
+ self._previous = set_retry_limits(self.max_attempts, self.max_depth)
410
+ return self
411
+
412
+ def __exit__(self, *exc):
413
+ global _RETRY_LIMITS
414
+ _RETRY_LIMITS = self._previous
415
+ return False
416
+
417
+
362
418
  def _simulate_search(r, start, end, points, observed_species):
363
419
  """One capped attempt. Raises on failure; leaves the step allowance as found."""
364
420
  original = r.integrator.maximum_num_steps
@@ -454,7 +510,7 @@ def safe_simulate(r, solver_settings, observed_species, depth=0, label=None):
454
510
  label_prefix = f" [{label}]" if label else ""
455
511
  print(f" [safe_simulate]{label_prefix} (Depth {depth}) Initial attempt failed: {first_err_str}")
456
512
 
457
- max_attempts = 10
513
+ max_attempts, max_depth = _RETRY_LIMITS or (_DEFAULT_MAX_ATTEMPTS, _DEFAULT_MAX_DEPTH)
458
514
  attempt = 1 # one attempt already burned on the fast path
459
515
 
460
516
  while attempt < max_attempts:
@@ -513,7 +569,7 @@ def safe_simulate(r, solver_settings, observed_species, depth=0, label=None):
513
569
  )
514
570
 
515
571
  # Subdivide as last resort
516
- if depth < 4 and points >= 2:
572
+ if depth < max_depth and points >= 2:
517
573
  mid = (start + end) / 2.0
518
574
  p1 = max(2, points // 2)
519
575
  p2 = max(2, points - p1)
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