PyAntiGen 2.0.0__tar.gz → 2.0.2__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 (103) hide show
  1. {pyantigen-2.0.0 → pyantigen-2.0.2}/PKG-INFO +2 -1
  2. {pyantigen-2.0.0 → pyantigen-2.0.2}/PyAntiGen.egg-info/PKG-INFO +2 -1
  3. {pyantigen-2.0.0 → pyantigen-2.0.2}/PyAntiGen.egg-info/SOURCES.txt +2 -0
  4. {pyantigen-2.0.0 → pyantigen-2.0.2}/PyAntiGen.egg-info/scm_file_list.json +12 -0
  5. pyantigen-2.0.2/PyAntiGen.egg-info/scm_version.json +8 -0
  6. {pyantigen-2.0.0 → pyantigen-2.0.2}/README.md +1 -0
  7. pyantigen-2.0.2/docs/MULTISTART.md +67 -0
  8. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/_version.py +3 -3
  9. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Evaluator.py +34 -3
  10. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Fast_profile.py +5 -4
  11. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Fit_cache.py +9 -6
  12. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Identifiability.py +181 -15
  13. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Model_optimize.py +12 -2
  14. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Optimize.py +267 -17
  15. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Profile_checkpoint.py +11 -1
  16. pyantigen-2.0.2/pyantigen/engine/Profile_plan.py +343 -0
  17. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/generate/rate_laws.py +2 -2
  18. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/study/lower_v1.py +3 -1
  19. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/study/optimization.py +4 -1
  20. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/Model_run.py +17 -0
  21. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/Modules/Optimizer_settings.py +28 -0
  22. pyantigen-2.0.0/PyAntiGen.egg-info/scm_version.json +0 -8
  23. {pyantigen-2.0.0 → pyantigen-2.0.2}/.github/workflows/publish.yml +0 -0
  24. {pyantigen-2.0.0 → pyantigen-2.0.2}/.github/workflows/tests.yml +0 -0
  25. {pyantigen-2.0.0 → pyantigen-2.0.2}/.gitignore +0 -0
  26. {pyantigen-2.0.0 → pyantigen-2.0.2}/.vscode/launch.json +0 -0
  27. {pyantigen-2.0.0 → pyantigen-2.0.2}/LICENSE +0 -0
  28. {pyantigen-2.0.0 → pyantigen-2.0.2}/MANIFEST.in +0 -0
  29. {pyantigen-2.0.0 → pyantigen-2.0.2}/PyAntiGen.egg-info/dependency_links.txt +0 -0
  30. {pyantigen-2.0.0 → pyantigen-2.0.2}/PyAntiGen.egg-info/entry_points.txt +0 -0
  31. {pyantigen-2.0.0 → pyantigen-2.0.2}/PyAntiGen.egg-info/requires.txt +0 -0
  32. {pyantigen-2.0.0 → pyantigen-2.0.2}/PyAntiGen.egg-info/top_level.txt +0 -0
  33. {pyantigen-2.0.0 → pyantigen-2.0.2}/docs/PROTOCOL_LAYER.md +0 -0
  34. {pyantigen-2.0.0 → pyantigen-2.0.2}/docs/V2_DESIGN.md +0 -0
  35. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/.agents/skills/module_generation/SKILL.md +0 -0
  36. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/.agents/skills/ode_conversion_antimony/SKILL.md +0 -0
  37. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/.agents/skills/ode_extraction_pipeline/SKILL.md +0 -0
  38. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/.gitignore +0 -0
  39. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/__init__.py +0 -0
  40. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/cli.py +0 -0
  41. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Anchor_cache.py +0 -0
  42. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Deadline.py +0 -0
  43. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Differential_evolution.py +0 -0
  44. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Event_times.py +0 -0
  45. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Model_simulate.py +0 -0
  46. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Nelder_mead.py +0 -0
  47. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Noise_floor.py +0 -0
  48. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Petab_export.py +0 -0
  49. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Preequil_cache.py +0 -0
  50. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/README.md +0 -0
  51. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Results.py +0 -0
  52. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Sensitivity_analysis.py +0 -0
  53. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/Simulate.py +0 -0
  54. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/engine/__init__.py +0 -0
  55. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/generate/AntimonyGen.py +0 -0
  56. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/generate/RxnDict_to_antimony.py +0 -0
  57. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/generate/TelluriumGen.py +0 -0
  58. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/generate/__init__.py +0 -0
  59. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/generate/antimony_utils.py +0 -0
  60. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/generate/data_interpolation.py +0 -0
  61. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/generate/isotopomer_tools.py +0 -0
  62. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/generate/model_generation.py +0 -0
  63. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/generate/models.py +0 -0
  64. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/generate/module_base.py +0 -0
  65. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/generate/pyantigen.py +0 -0
  66. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/generate/reaction_creation.py +0 -0
  67. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/study/__init__.py +0 -0
  68. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/study/assay.py +0 -0
  69. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/study/describe.py +0 -0
  70. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/study/design.py +0 -0
  71. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/study/params.py +0 -0
  72. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/study/quantity.py +0 -0
  73. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/study/reagent.py +0 -0
  74. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/study/refs.py +0 -0
  75. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/study/remarks.py +0 -0
  76. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/study/serialize.py +0 -0
  77. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/study/validate.py +0 -0
  78. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/AntiGen_paths.py +0 -0
  79. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/Flipflop_reference.py +0 -0
  80. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/Model_generate.py +0 -0
  81. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/Modules/Data.py +0 -0
  82. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/Modules/Events.py +0 -0
  83. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/Modules/Experiment.py +0 -0
  84. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/Modules/Loss_config.py +0 -0
  85. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/Modules/Observed_species.py +0 -0
  86. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/Modules/Plots.py +0 -0
  87. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/Modules/Solver_settings.py +0 -0
  88. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/Modules/Update_opt_parameters.py +0 -0
  89. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/Modules/Update_parameters.py +0 -0
  90. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/optimizations/Example1_ADneg.json +0 -0
  91. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/optimizations/Example1_ADpos.json +0 -0
  92. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/optimizations/Example3_joint.json +0 -0
  93. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/optimizations/Example4_flipflop.json +0 -0
  94. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/optimizations/example.py +0 -0
  95. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/studies/example.json +0 -0
  96. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/studies/example.py +0 -0
  97. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/Example/studies/flipflop.json +0 -0
  98. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/data/ADneg.csv +0 -0
  99. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/data/ADpos.csv +0 -0
  100. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/data/Flipflop.csv +0 -0
  101. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyantigen/template/data/make_flipflop_data.py +0 -0
  102. {pyantigen-2.0.0 → pyantigen-2.0.2}/pyproject.toml +0 -0
  103. {pyantigen-2.0.0 → pyantigen-2.0.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyAntiGen
3
- Version: 2.0.0
3
+ Version: 2.0.2
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
@@ -191,6 +191,7 @@ python Model_run.py --optimize Example2
191
191
  python Model_run.py --optimize Example3
192
192
  python Model_run.py --optimize Example4
193
193
  python Model_run.py --optimize Example5
194
+ python Model_run.py --optimize Example6 # Sobol multi-start, see docs/MULTISTART.md
194
195
  ```
195
196
 
196
197
  Each example prints a banner explaining what it demonstrates before the run and a "what to look for" summary after it. All output lands in `results/Example/`, prefixed with the example and group names so runs never overwrite each other — e.g. `Example_Example2_ADpos_profile_likelihood.png`, `Example_Example4_Flipflop_optimization_results.csv`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyAntiGen
3
- Version: 2.0.0
3
+ Version: 2.0.2
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
@@ -191,6 +191,7 @@ python Model_run.py --optimize Example2
191
191
  python Model_run.py --optimize Example3
192
192
  python Model_run.py --optimize Example4
193
193
  python Model_run.py --optimize Example5
194
+ python Model_run.py --optimize Example6 # Sobol multi-start, see docs/MULTISTART.md
194
195
  ```
195
196
 
196
197
  Each example prints a banner explaining what it demonstrates before the run and a "what to look for" summary after it. All output lands in `results/Example/`, prefixed with the example and group names so runs never overwrite each other — e.g. `Example_Example2_ADpos_profile_likelihood.png`, `Example_Example4_Flipflop_optimization_results.csv`.
@@ -14,6 +14,7 @@ PyAntiGen.egg-info/requires.txt
14
14
  PyAntiGen.egg-info/scm_file_list.json
15
15
  PyAntiGen.egg-info/scm_version.json
16
16
  PyAntiGen.egg-info/top_level.txt
17
+ docs/MULTISTART.md
17
18
  docs/PROTOCOL_LAYER.md
18
19
  docs/V2_DESIGN.md
19
20
  pyantigen/.gitignore
@@ -39,6 +40,7 @@ pyantigen/engine/Optimize.py
39
40
  pyantigen/engine/Petab_export.py
40
41
  pyantigen/engine/Preequil_cache.py
41
42
  pyantigen/engine/Profile_checkpoint.py
43
+ pyantigen/engine/Profile_plan.py
42
44
  pyantigen/engine/README.md
43
45
  pyantigen/engine/Results.py
44
46
  pyantigen/engine/Sensitivity_analysis.py
@@ -7,6 +7,7 @@
7
7
  "LICENSE",
8
8
  "MANIFEST.in",
9
9
  "README.md",
10
+ "docs/MULTISTART.md",
10
11
  "docs/PROTOCOL_LAYER.md",
11
12
  "docs/V2_DESIGN.md",
12
13
  "pyantigen/.agents/skills/module_generation/SKILL.md",
@@ -31,6 +32,7 @@
31
32
  "pyantigen/engine/Petab_export.py",
32
33
  "pyantigen/engine/Preequil_cache.py",
33
34
  "pyantigen/engine/Profile_checkpoint.py",
35
+ "pyantigen/engine/Profile_plan.py",
34
36
  "pyantigen/engine/README.md",
35
37
  "pyantigen/engine/Results.py",
36
38
  "pyantigen/engine/Sensitivity_analysis.py",
@@ -102,15 +104,25 @@
102
104
  "tests/engine/test_fast_profile.py",
103
105
  "tests/engine/test_fit_cache.py",
104
106
  "tests/engine/test_hessian_steps.py",
107
+ "tests/engine/test_multistart_sampler.py",
105
108
  "tests/engine/test_nelder_mead.py",
106
109
  "tests/engine/test_point_identity.py",
110
+ "tests/engine/test_profile_grid_open.py",
107
111
  "tests/engine/test_profile_quadratic.py",
112
+ "tests/engine/test_profile_records.py",
113
+ "tests/engine/test_profile_stream.py",
108
114
  "tests/engine/test_search_mode.py",
109
115
  "tests/engine/test_sigma_floor.py",
110
116
  "tests/engine/test_slice_screen.py",
117
+ "tests/engine/test_slice_window.py",
111
118
  "tests/engine/test_solver_fingerprint.py",
112
119
  "tests/engine/test_tolerance_floor.py",
113
120
  "tests/engine/test_wald_units.py",
121
+ "tests/generate/test_gen_antimony_utils.py",
122
+ "tests/generate/test_gen_data_interpolation.py",
123
+ "tests/generate/test_gen_models.py",
124
+ "tests/generate/test_gen_pyantigen_api.py",
125
+ "tests/generate/test_gen_rxndict_to_antimony.py",
114
126
  "tests/silk_fixtures/antimony_models/Antimony_Bloomingdale_2021_1a_all_reactions.txt",
115
127
  "tests/silk_fixtures/antimony_models/Antimony_Bloomingdale_2021_1a_rules.txt",
116
128
  "tests/silk_fixtures/antimony_models/Antimony_Elbert_2022_1a_all_reactions.txt",
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "2.0.2",
3
+ "distance": 0,
4
+ "node": "g6b1549beb3e3a5bc372670dbc2e8e502501b547d",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-05"
8
+ }
@@ -159,6 +159,7 @@ python Model_run.py --optimize Example2
159
159
  python Model_run.py --optimize Example3
160
160
  python Model_run.py --optimize Example4
161
161
  python Model_run.py --optimize Example5
162
+ python Model_run.py --optimize Example6 # Sobol multi-start, see docs/MULTISTART.md
162
163
  ```
163
164
 
164
165
  Each example prints a banner explaining what it demonstrates before the run and a "what to look for" summary after it. All output lands in `results/Example/`, prefixed with the example and group names so runs never overwrite each other — e.g. `Example_Example2_ADpos_profile_likelihood.png`, `Example_Example4_Flipflop_optimization_results.csv`.
@@ -0,0 +1,67 @@
1
+ # Multi-start and the start sampler
2
+
3
+ Nelder-Mead is a local optimizer, so a fit lands in whichever basin it starts
4
+ in. Multi-start runs it from several points and keeps the best. Four fields on
5
+ the optimization spec control it:
6
+
7
+ | field | default | meaning |
8
+ |---|---|---|
9
+ | `n_starts` | `1` | number of starts. `1` is a single fit and never touches the random generator |
10
+ | `search_decades` | `None` | radius around `x0` that starts 2..n are drawn from, in log10 units for a log-scaled parameter. Unset, the radius is half the declared bound range |
11
+ | `start_seed` | `None` | seed for the draw. Set it in the same edit that raises `n_starts`, or runs are not reproducible and cached fits cannot resume |
12
+ | `start_sampler` | `"lhs"` | how starts 2..n are drawn: `"lhs"` (Latin hypercube) or `"sobol"` (scrambled Sobol sequence) |
13
+
14
+ Start 1 is always the spec's own `x0`, so a multi-start fit cannot come back
15
+ worse than the single fit it replaces. Cost is linear in `n_starts`.
16
+
17
+ ## Choosing a sampler
18
+
19
+ Both are low-discrepancy samplers from `scipy.stats.qmc`, so for a handful of
20
+ starts in a few parameters they behave alike. Sobol fills the box more evenly
21
+ as the number of parameters grows.
22
+
23
+ Sobol's balance holds only when the number of points drawn is a power of two.
24
+ Starts 2..n are `n_starts - 1` points, so use `n_starts` of 3, 5, 9, 17, ...
25
+ Other counts still run, with a warning from SciPy. Latin hypercube has no such
26
+ restriction.
27
+
28
+ ```python
29
+ Optimization(
30
+ ...,
31
+ n_starts=9, # 1 + 8 Sobol points
32
+ start_sampler="sobol",
33
+ search_decades=0.5,
34
+ start_seed=20260909,
35
+ )
36
+ ```
37
+
38
+ ## Keep the radius small
39
+
40
+ Sample around `x0`, not across the whole bound box. Drawing log-uniformly over
41
+ bounds such as (1e-9, 1) puts starts many decades from the answer, and they
42
+ converge to far worse basins. A `search_decades` of 0.5 to 1.0 is a reasonable
43
+ first try. `Example6` in the template uses 1.0, enough to reach the true mode of
44
+ the flip-flop problem from the wrong basin.
45
+
46
+ ## Reading the result
47
+
48
+ The spread of the final NLLs across starts is the report. If they agree, the
49
+ objective is unimodal in that region and one start will do. If they scatter,
50
+ the fit depends on where it began, and a profile or confidence interval
51
+ anchored on any one of them may be measuring the wrong basin.
52
+
53
+ ## Caching
54
+
55
+ `start_sampler` is part of the fit-cache key only when it is not `"lhs"`, so
56
+ fits cached before the field existed keep their hash. Switching to `"sobol"`
57
+ runs a fresh fit.
58
+
59
+ ## What it does not cover
60
+
61
+ Multi-start applies to local methods. `differential_evolution`,
62
+ `basin_hopping`, `dual_annealing` and `shgo` search the whole domain
63
+ themselves and ignore `n_starts` and `start_sampler`.
64
+
65
+ This is distinct from `sobol_analysis`, the variance-based sensitivity
66
+ diagnostic (`_SOBOL_ONLY`), which samples the bounds to apportion variance in
67
+ the loss and does not search for a minimum.
@@ -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.0'
22
- __version_tuple__ = version_tuple = (2, 0, 0)
21
+ __version__ = version = '2.0.2'
22
+ __version_tuple__ = version_tuple = (2, 0, 2)
23
23
 
24
- __commit_id__ = commit_id = 'g67741a856'
24
+ __commit_id__ = commit_id = 'g6b1549beb'
@@ -924,7 +924,7 @@ class ParallelEvaluator:
924
924
 
925
925
  def profile_batch(self, jobs, on_result=None, label=None,
926
926
  heartbeat_s=_HEARTBEAT_SECONDS, budget=None,
927
- frozen_sigmas=None, state_dir=None):
927
+ frozen_sigmas=None, state_dir=None, refill=None):
928
928
  """Run profile-likelihood points in parallel, within a wall budget.
929
929
 
930
930
  ``state_dir`` is where each running point keeps its own resumable
@@ -973,8 +973,20 @@ class ParallelEvaluator:
973
973
 
974
974
  *on_result* is called with each result dict the moment it arrives.
975
975
  Returns results in completion order; callers key off the job fields.
976
+
977
+ *refill*, when given, turns the batch into a stream. It is called as
978
+ ``refill(n_free, in_flight)`` at the start and again each time results
979
+ land, with the number of worker slots not occupied by a running or
980
+ waiting point and the job dicts currently running, and returns up to
981
+ *n_free* new jobs. Those are queued behind anything already waiting and
982
+ admitted like the rest, so the pool holds ``n_workers`` points whenever
983
+ there is work to give it and never more, and each new point is planned
984
+ from every result so far rather than from the state when the batch was
985
+ assembled. The batch ends when nothing is running and *refill* returns
986
+ nothing. It is not consulted once the wall budget has stopped
987
+ admissions, so a stream cannot plan work the clock will not allow.
976
988
  """
977
- if not jobs:
989
+ if not jobs and refill is None:
978
990
  return []
979
991
  if self._pool is None:
980
992
  self.start()
@@ -1002,6 +1014,23 @@ class ParallelEvaluator:
1002
1014
  # them.
1003
1015
  sweep_stale_temp_files(state_dir)
1004
1016
 
1017
+ def top_up():
1018
+ """Ask the planner for work to fill whatever slots are empty."""
1019
+ nonlocal n_jobs, halted
1020
+ if refill is None or halted:
1021
+ return
1022
+ if budget is not None and not budget.admits():
1023
+ # Nothing planned now could start, so do not plan it.
1024
+ halted = True
1025
+ return
1026
+ n_free = self.n_workers - len(pending) - len(backlog)
1027
+ if n_free <= 0:
1028
+ return
1029
+ new = refill(n_free, [futures[f] for f in pending]) or []
1030
+ for j in list(new)[:n_free]:
1031
+ backlog.append(dict(j, frozen_sigmas=frozen_sigmas))
1032
+ n_jobs += 1
1033
+
1005
1034
  def admit():
1006
1035
  """Start points until the pool is full or the clock says stop."""
1007
1036
  nonlocal halted
@@ -1035,6 +1064,7 @@ class ParallelEvaluator:
1035
1064
  print(f"[pool]{tag} {budget.describe()}",
1036
1065
  flush=True)
1037
1066
 
1067
+ top_up()
1038
1068
  admit()
1039
1069
 
1040
1070
  while pending:
@@ -1097,6 +1127,7 @@ class ParallelEvaluator:
1097
1127
  f"({res.get('n_evals')} evals, {res.get('wall_s', 0):.0f}s)"
1098
1128
  f" [{_fmt_dur(elapsed)} elapsed]", flush=True)
1099
1129
 
1130
+ top_up()
1100
1131
  admit()
1101
1132
 
1102
1133
  if self.verbose:
@@ -1105,7 +1136,7 @@ class ParallelEvaluator:
1105
1136
  print(f"[pool]{tag} {done} profile points in {elapsed:.0f}s wall "
1106
1137
  f"({work:.0f}s of work, {work / elapsed:.1f}x)", flush=True)
1107
1138
 
1108
- if backlog:
1139
+ if backlog or (halted and refill is not None):
1109
1140
  if budget is not None:
1110
1141
  budget.stopped_early = True
1111
1142
  raise DeadlineReached(len(backlog), label)
@@ -282,7 +282,7 @@ def run_fast_profile(batch, nll_batch, res_x, nll_at_optimum, param_names,
282
282
  near_zero_frac=DEFAULT_NEAR_ZERO_FRAC,
283
283
  span_decades=SPAN_DECADES,
284
284
  min_reach_decades=MIN_REACH_DECADES, verbose=True,
285
- nll_at_optimum_profile=None):
285
+ nll_at_optimum_profile=None, window_hi=None):
286
286
  """Screen, then one capped profile point per crossed side, in rounds.
287
287
 
288
288
  *batch* has the profile_batch signature: ``batch(jobs, on_result, label)``.
@@ -314,13 +314,13 @@ def run_fast_profile(batch, nll_batch, res_x, nll_at_optimum, param_names,
314
314
 
315
315
  # ── The screen, reused when this fit already has one ──────────────────
316
316
  screen = load_screen(ckpt_dir, param_names, res_x, threshold,
317
- span_decades, min_reach_decades)
317
+ span_decades, min_reach_decades, window_hi)
318
318
  if screen is None:
319
319
  screen = run_slice_screen(
320
320
  nll_batch, res_x, nll_at_optimum, param_names, bounds,
321
321
  scales=scales, wald_se=wald_se, threshold=threshold,
322
322
  span_decades=span_decades, min_reach_decades=min_reach_decades,
323
- verbose=verbose)
323
+ verbose=verbose, window_hi=window_hi)
324
324
  save_screen(screen, ckpt_dir)
325
325
  elif verbose:
326
326
  print(f"\n[fast profile] reusing the slice screen already run for this "
@@ -400,7 +400,8 @@ def run_fast_profile(batch, nll_batch, res_x, nll_at_optimum, param_names,
400
400
  # A cold grid point as far as the full profile is concerned:
401
401
  # its warm pass owes this point a sweep like any other.
402
402
  "phase": 1,
403
- "direction": 0,
403
+ "direction": -1 if x_slice < p_opt else 1,
404
+ "pass_label": "fast",
404
405
  "x_step": abs(x_slice - p_opt),
405
406
  "fast_profile": True,
406
407
  "_side": side_name,
@@ -126,7 +126,8 @@ def data_fingerprint(models):
126
126
 
127
127
  def fit_fingerprint(param_names, x0_lin, bounds_lin, scales, groups, model_text,
128
128
  method, optimizer_kwargs, n_starts=1, start_seed=None,
129
- search_decades=None, solver_hash=None, data_hash=None):
129
+ search_decades=None, solver_hash=None, data_hash=None,
130
+ start_sampler="lhs"):
130
131
  """Hashes identifying a fit *problem*, before it has been solved.
131
132
 
132
133
  Deliberately distinct from :func:`pyantigen.engine.Profile_checkpoint.spec_fingerprint`,
@@ -156,8 +157,7 @@ def fit_fingerprint(param_names, x0_lin, bounds_lin, scales, groups, model_text,
156
157
  "profile_without_opt"):
157
158
  kwargs.pop(k, None)
158
159
 
159
- blob = json.dumps(
160
- {
160
+ key = {
161
161
  "format": _FORMAT,
162
162
  "param_names": list(param_names),
163
163
  "x0": [_r(v) for v in np.atleast_1d(x0_lin)],
@@ -173,9 +173,12 @@ def fit_fingerprint(param_names, x0_lin, bounds_lin, scales, groups, model_text,
173
173
  "search_decades": search_decades,
174
174
  "solver": solver_hash,
175
175
  "data": data_hash,
176
- },
177
- sort_keys=True, default=_json_default,
178
- )
176
+ }
177
+ # 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":
180
+ key["start_sampler"] = start_sampler
181
+ blob = json.dumps(key, sort_keys=True, default=_json_default)
179
182
  fit_hash = hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
180
183
  return model_hash, fit_hash
181
184
 
@@ -98,6 +98,17 @@ SPAN_DECADES = 3.0
98
98
  # stops evaluating close in and the walk cannot get out to the span.
99
99
  MIN_REACH_DECADES = 1.0
100
100
 
101
+ # The slice's own crossing, located. The ladder stops the round a side first
102
+ # rises above THRESHOLD, and on a model whose other parameters compensate that
103
+ # first point is wildly above it (a slice dNLL of 1e4 against a profile of 5).
104
+ # With ``window_hi`` set, a side whose first crossing lands above it is walked
105
+ # back inward until a point lands in (THRESHOLD, window_hi]: a slice crossing
106
+ # that is actually near the threshold, and so a lower bound on the profile
107
+ # crossing worth having. None keeps the old behaviour exactly.
108
+ MAX_REFINE_ROUNDS = 6
109
+ _REFINE_MIN_RATIO = 1.0 / 16.0
110
+ _REFINE_MAX_RATIO = 0.95
111
+
101
112
  # One side's verdict. Only "open" halts the run.
102
113
  # crossed the slice rose above the threshold: profiling may proceed
103
114
  # open the slice is still below the threshold decades out: PROVEN open
@@ -392,11 +403,93 @@ def _verdict(points, bound, threshold, p_opt, sign, is_log,
392
403
  }
393
404
 
394
405
 
406
+ def _side_distance(p_opt, x, is_log, ratio_mode):
407
+ """Distance from the optimum in the walk's own measure.
408
+
409
+ Opt-space offset for a log10 parameter, log10 ratio for a positive linear
410
+ one while every point so far is positive, plain offset otherwise.
411
+ """
412
+ if is_log:
413
+ return abs(float(x) - float(p_opt))
414
+ if ratio_mode:
415
+ return abs(float(np.log10(x)) - float(np.log10(p_opt)))
416
+ return abs(float(x) - float(p_opt))
417
+
418
+
419
+ def _side_value(p_opt, d, sign, is_log, ratio_mode):
420
+ """Inverse of :func:`_side_distance`."""
421
+ if is_log:
422
+ return float(p_opt) + sign * float(d)
423
+ if ratio_mode:
424
+ return float(p_opt) * float(10.0 ** (sign * d))
425
+ return float(p_opt) + sign * float(d)
426
+
427
+
428
+ def _refine_value(entry, threshold, window_hi):
429
+ """The next value on a side that is being walked back in, or None to stop.
430
+
431
+ Aims at the geometric middle of the window. With a point already at or
432
+ below the threshold inside the one that was too high, the crossing is
433
+ bracketed and the step is a sqrt(dNLL) interpolation, which is linear in
434
+ distance for a locally quadratic curve. With only the optimum inside, the
435
+ step is a power-law extrapolation from the too-high points, dNLL = c d^p
436
+ with p fitted from two of them when it can be and 2 otherwise. A value the
437
+ model could not evaluate counts as too high and halves the distance.
438
+ """
439
+ p_opt, sign, is_log = entry["p_opt"], entry["sign"], entry["is_log"]
440
+ pts = entry["points"]
441
+ ratio_mode = (not is_log) and p_opt > 0 and all(
442
+ float(p["x"]) > 0 for p in pts)
443
+ target = float(np.sqrt(threshold * window_hi))
444
+
445
+ recs = []
446
+ for p in pts:
447
+ d = _side_distance(p_opt, p["x"], is_log, ratio_mode)
448
+ usable = _is_usable(p)
449
+ recs.append((d, float(p["dnll"]) if usable else float("inf"), usable))
450
+
451
+ high = sorted((r for r in recs if r[1] > window_hi or not r[2]),
452
+ key=lambda r: r[0])
453
+ if not high:
454
+ return None
455
+ d_hi, dnll_hi, hi_ok = high[0]
456
+ low = [r for r in recs if r[2] and r[1] <= threshold and r[0] < d_hi]
457
+ d_lo, dnll_lo = (max(low, key=lambda r: r[0])[:2]) if low else (0.0, 0.0)
458
+
459
+ if hi_ok and np.isfinite(dnll_hi) and dnll_hi > dnll_lo and d_hi > d_lo:
460
+ if d_lo > 0.0 and dnll_lo > 0.0:
461
+ r_lo, r_hi = np.sqrt(dnll_lo), np.sqrt(dnll_hi)
462
+ frac = (np.sqrt(target) - r_lo) / (r_hi - r_lo)
463
+ frac = float(min(max(frac, 0.1), 0.9))
464
+ d_new = d_lo + frac * (d_hi - d_lo)
465
+ else:
466
+ exponent = 2.0
467
+ ups = [r for r in high if r[2] and np.isfinite(r[1]) and r[1] > 0]
468
+ if len(ups) >= 2 and ups[1][0] > ups[0][0] * (1.0 + 1e-9) \
469
+ and ups[1][1] > ups[0][1] * (1.0 + 1e-9):
470
+ cand = (np.log(ups[1][1] / ups[0][1])
471
+ / np.log(ups[1][0] / ups[0][0]))
472
+ if np.isfinite(cand) and cand > 0:
473
+ exponent = float(np.clip(cand, 0.5, 6.0))
474
+ ratio = (target / dnll_hi) ** (1.0 / exponent)
475
+ ratio = float(np.clip(ratio, _REFINE_MIN_RATIO, _REFINE_MAX_RATIO))
476
+ d_new = d_hi * ratio
477
+ else:
478
+ d_new = d_lo + 0.5 * (d_hi - d_lo)
479
+
480
+ if not np.isfinite(d_new) or d_new <= 1e-9:
481
+ return None
482
+ if any(abs(d_new - r[0]) <= 1e-12 * max(d_new, 1.0) for r in recs):
483
+ return None
484
+ return _side_value(p_opt, d_new, sign, is_log, ratio_mode)
485
+
486
+
395
487
  def run_slice_screen(nll_batch, res_x, nll_at_optimum, param_names, bounds,
396
488
  scales=None, wald_se=None, threshold=THRESHOLD,
397
489
  max_points=6, growth=2.0, range_factor=2.0,
398
490
  span_decades=SPAN_DECADES,
399
- min_reach_decades=MIN_REACH_DECADES, verbose=True):
491
+ min_reach_decades=MIN_REACH_DECADES, verbose=True,
492
+ window_hi=None, max_refine_rounds=MAX_REFINE_ROUNDS):
400
493
  """Evaluate every parameter's slice out across decades, stopping each side
401
494
  the moment it crosses.
402
495
 
@@ -430,6 +523,15 @@ def run_slice_screen(nll_batch, res_x, nll_at_optimum, param_names, bounds,
430
523
  been read as "open" by a full evaluation, and reads "crossed" here
431
524
  instead. That trades a slower profile on a rare, specific slice shape for
432
525
  never paying an unbounded evaluation on the common one.
526
+
527
+ ``window_hi`` adds one thing after that first crossing. A side whose first
528
+ point above the threshold is also above ``window_hi`` is walked back inward
529
+ (see :func:`_refine_value`) until a point lands in (threshold, window_hi],
530
+ for at most ``max_refine_rounds`` further rounds. The points that come back
531
+ inside the first crossing are real evaluations and are kept: a slice point
532
+ at or below the threshold there is certified inside the interval, which is
533
+ what ``inner_bracket`` records. The "open" verdict is not touched, since a
534
+ side only enters this walk after it has crossed.
433
535
  """
434
536
  from pyantigen.engine.Optimize import _param_bounds
435
537
 
@@ -448,7 +550,8 @@ def run_slice_screen(nll_batch, res_x, nll_at_optimum, param_names, bounds,
448
550
  span_decades=span_decades)
449
551
  plan.append({"index": i, "name": name, "side": side, "sign": sign,
450
552
  "is_log": is_log, "values": vals, "points": [],
451
- "settled": False,
553
+ "settled": False, "ladder_i": 0, "refine": False,
554
+ "n_refine": 0,
452
555
  "p_opt": float(res_x[i]),
453
556
  "bound": (lb if sign < 0 else ub)})
454
557
 
@@ -464,31 +567,56 @@ def run_slice_screen(nll_batch, res_x, nll_at_optimum, param_names, bounds,
464
567
  f"the profile, which is all the screen needs.", flush=True)
465
568
 
466
569
  n_evaluations = 0
467
- for r in range(max_rounds):
570
+ r = 0
571
+ max_total = max_rounds + (int(max_refine_rounds) if window_hi else 0)
572
+ while r < max_total:
468
573
  round_xs, round_owner = [], []
469
574
  for entry in plan:
470
- if entry["settled"] or r >= len(entry["values"]):
575
+ if entry["settled"]:
471
576
  continue
472
- v = entry["values"][r]
577
+ if entry["refine"]:
578
+ v = _refine_value(entry, threshold, window_hi)
579
+ if v is None:
580
+ entry["settled"] = True
581
+ continue
582
+ else:
583
+ if entry["ladder_i"] >= len(entry["values"]):
584
+ continue
585
+ v = entry["values"][entry["ladder_i"]]
586
+ entry["ladder_i"] += 1
473
587
  x = res_x.copy()
474
588
  x[entry["index"]] = v
475
589
  round_xs.append(x)
476
590
  round_owner.append((entry, v))
477
591
  if not round_xs:
478
592
  break
593
+ r += 1
479
594
  if verbose:
480
- print(f"[screen] round {r + 1}: {len(round_xs)} side(s) still "
481
- f"undecided.", flush=True)
595
+ n_ref = sum(1 for e, _ in round_owner if e["refine"])
596
+ extra = (f", {n_ref} walking back in toward the window"
597
+ if n_ref else "")
598
+ print(f"[screen] round {r}: {len(round_xs)} side(s) still "
599
+ f"undecided{extra}.", flush=True)
482
600
  n_evaluations += len(round_xs)
483
- nlls = nll_batch(round_xs, label=f"slice-screen-r{r + 1}")
601
+ nlls = nll_batch(round_xs, label=f"slice-screen-r{r}")
484
602
  for (entry, v), nll in zip(round_owner, nlls):
485
603
  nll = float(nll)
486
604
  point = {"x": float(v),
487
605
  "x_linear": float(10.0 ** v if entry["is_log"] else v),
488
606
  "nll": nll, "dnll": float(nll - nll_at_optimum)}
489
607
  entry["points"].append(point)
490
- if _is_usable(point) and point["dnll"] > threshold:
491
- entry["settled"] = True
608
+ usable = _is_usable(point)
609
+ if entry["refine"]:
610
+ entry["n_refine"] += 1
611
+ if usable and threshold < point["dnll"] <= window_hi:
612
+ entry["settled"] = True
613
+ elif entry["n_refine"] >= int(max_refine_rounds):
614
+ entry["settled"] = True
615
+ elif usable and point["dnll"] > threshold:
616
+ if window_hi is None or point["dnll"] <= window_hi:
617
+ entry["settled"] = True
618
+ else:
619
+ entry["refine"] = True
492
620
 
493
621
  report = {"threshold": float(threshold),
494
622
  "anchor": float(nll_at_optimum),
@@ -496,17 +624,25 @@ def run_slice_screen(nll_batch, res_x, nll_at_optimum, param_names, bounds,
496
624
  "param_names": list(param_names),
497
625
  "span_decades": float(span_decades),
498
626
  "min_reach_decades": float(min_reach_decades),
627
+ "window_hi": (float(window_hi) if window_hi else None),
499
628
  "n_evaluations": n_evaluations,
500
629
  "n_candidates": n_candidates,
501
630
  "parameters": {}}
502
631
 
503
632
  for entry in plan:
633
+ # Evaluation order is not distance order once a side has been walked
634
+ # back in, and everything downstream (the verdict, the inner bracket,
635
+ # the fast profile's crossing) reads points outward from the optimum.
636
+ entry["points"].sort(key=lambda q: abs(q["x"] - entry["p_opt"]))
504
637
  side = _verdict(entry["points"], entry["bound"], threshold,
505
638
  entry["p_opt"], entry["sign"], entry["is_log"],
506
639
  min_reach_decades=min_reach_decades)
507
640
  side["is_log"] = entry["is_log"]
508
641
  side["stopped_early"] = bool(entry["settled"]
509
- and len(entry["points"]) < len(entry["values"]))
642
+ and entry["ladder_i"] < len(entry["values"]))
643
+ side["crossing"] = _crossing_record(entry["points"], threshold,
644
+ window_hi, entry["p_opt"],
645
+ entry["is_log"])
510
646
  report["parameters"].setdefault(entry["name"], {})[entry["side"]] = side
511
647
 
512
648
  states = [s["state"] for sides in report["parameters"].values()
@@ -520,6 +656,27 @@ def run_slice_screen(nll_batch, res_x, nll_at_optimum, param_names, bounds,
520
656
  return report
521
657
 
522
658
 
659
+ def _crossing_record(points, threshold, window_hi, p_opt, is_log):
660
+ """The slice point nearest the threshold from above, or None.
661
+
662
+ This is the slice's crossing as far as the screen located it: the usable
663
+ point with the smallest dNLL among those above the threshold. ``in_window``
664
+ says whether it landed inside (threshold, window_hi]; when it did not, the
665
+ refinement ran out of rounds or was never asked for, and the crossing is
666
+ only an upper limit on where the slice crosses.
667
+ """
668
+ above = [q for q in points if _is_usable(q) and q["dnll"] > threshold]
669
+ if not above:
670
+ return None
671
+ q = min(above, key=lambda t: t["dnll"])
672
+ ratio_mode = (not is_log) and p_opt > 0 and q["x"] > 0
673
+ return {"x": float(q["x"]), "x_linear": float(q["x_linear"]),
674
+ "dnll": float(q["dnll"]),
675
+ "decades": float(_side_distance(p_opt, q["x"], is_log, ratio_mode)),
676
+ "in_window": bool(window_hi is not None
677
+ and q["dnll"] <= window_hi)}
678
+
679
+
523
680
  def failing_sides(report):
524
681
  """Every side the screen proved unbounded, flattened for reporting."""
525
682
  out = []
@@ -550,6 +707,7 @@ def screen_summary(report):
550
707
  "parameters": {
551
708
  name: {side: {"state": rec["state"],
552
709
  "inner_bracket": rec["inner_bracket"],
710
+ "crossing": rec.get("crossing"),
553
711
  "reach": rec["reach"],
554
712
  "reach_decades": rec["reach_decades"],
555
713
  "dnll_at_reach": rec["dnll_at_reach"],
@@ -658,7 +816,7 @@ def save_screen(report, ckpt_dir):
658
816
 
659
817
  def load_screen(ckpt_dir, param_names, res_x, threshold=THRESHOLD,
660
818
  span_decades=SPAN_DECADES,
661
- min_reach_decades=MIN_REACH_DECADES):
819
+ min_reach_decades=MIN_REACH_DECADES, window_hi=None):
662
820
  """A screen already run for this exact fit, or None.
663
821
 
664
822
  The checkpoint directory is keyed by model hash, spec hash and the optimum,
@@ -689,6 +847,13 @@ def load_screen(ckpt_dir, param_names, res_x, threshold=THRESHOLD,
689
847
  return None
690
848
  except (TypeError, ValueError):
691
849
  return None
850
+ # A screen run without the window must not answer for one asked to refine,
851
+ # and the reverse: the points it holds are different points.
852
+ stored_hi = report.get("window_hi")
853
+ if (stored_hi is None) != (window_hi is None):
854
+ return None
855
+ if window_hi is not None and abs(float(stored_hi) - float(window_hi)) > 1e-12:
856
+ return None
692
857
  stored = np.asarray(report.get("res_x") or [], dtype=float)
693
858
  current = np.asarray(res_x, dtype=float)
694
859
  if stored.shape != current.shape or not np.allclose(stored, current,
@@ -701,7 +866,8 @@ def screen_or_raise(nll_batch, res_x, nll_at_optimum, param_names, bounds,
701
866
  scales=None, wald_se=None, ckpt_dir=None,
702
867
  threshold=THRESHOLD, max_points=6, growth=2.0,
703
868
  range_factor=2.0, span_decades=SPAN_DECADES,
704
- min_reach_decades=MIN_REACH_DECADES, verbose=True):
869
+ min_reach_decades=MIN_REACH_DECADES, verbose=True,
870
+ window_hi=None):
705
871
  """Run the screen (or reuse one), report it, and stop the run if it failed.
706
872
 
707
873
  Raises :class:`UnidentifiableParameters` when any side is proven unbounded.
@@ -709,7 +875,7 @@ def screen_or_raise(nll_batch, res_x, nll_at_optimum, param_names, bounds,
709
875
  to lie inside the confidence interval.
710
876
  """
711
877
  report = load_screen(ckpt_dir, param_names, res_x, threshold,
712
- span_decades, min_reach_decades)
878
+ span_decades, min_reach_decades, window_hi)
713
879
  if report is not None:
714
880
  if verbose:
715
881
  print(f"\n[screen] reusing the slice screen already run for this "
@@ -722,7 +888,7 @@ def screen_or_raise(nll_batch, res_x, nll_at_optimum, param_names, bounds,
722
888
  scales=scales, wald_se=wald_se, threshold=threshold,
723
889
  max_points=max_points, growth=growth, range_factor=range_factor,
724
890
  span_decades=span_decades, min_reach_decades=min_reach_decades,
725
- verbose=verbose,
891
+ verbose=verbose, window_hi=window_hi,
726
892
  )
727
893
  path = save_screen(report, ckpt_dir)
728
894