PyAntiGen 1.0.7__tar.gz → 1.0.9__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.7 → pyantigen-1.0.9}/PKG-INFO +9 -1
- {pyantigen-1.0.7 → pyantigen-1.0.9}/PyAntiGen.egg-info/PKG-INFO +9 -1
- {pyantigen-1.0.7 → pyantigen-1.0.9}/PyAntiGen.egg-info/SOURCES.txt +11 -1
- {pyantigen-1.0.7 → pyantigen-1.0.9}/README.md +8 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/cli.py +22 -0
- pyantigen-1.0.9/framework/template/Example/Engine/Anchor_cache.py +193 -0
- pyantigen-1.0.9/framework/template/Example/Engine/Deadline.py +535 -0
- pyantigen-1.0.9/framework/template/Example/Engine/Evaluator.py +1176 -0
- pyantigen-1.0.9/framework/template/Example/Engine/Event_times.py +491 -0
- pyantigen-1.0.9/framework/template/Example/Engine/Fast_profile.py +701 -0
- pyantigen-1.0.9/framework/template/Example/Engine/Fit_cache.py +329 -0
- pyantigen-1.0.9/framework/template/Example/Engine/Identifiability.py +698 -0
- pyantigen-1.0.9/framework/template/Example/Engine/Model_optimize.py +1483 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Engine/Model_simulate.py +5 -0
- pyantigen-1.0.9/framework/template/Example/Engine/Optimize.py +6862 -0
- pyantigen-1.0.9/framework/template/Example/Engine/Preequil_cache.py +361 -0
- pyantigen-1.0.9/framework/template/Example/Engine/Profile_checkpoint.py +399 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Engine/Results.py +153 -2
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Engine/Sensitivity_analysis.py +14 -4
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Engine/Simulate.py +278 -11
- pyantigen-1.0.9/framework/template/Example/Flipflop_reference.py +401 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Model_generate.py +4 -1
- pyantigen-1.0.9/framework/template/Example/Model_run.py +261 -0
- pyantigen-1.0.9/framework/template/Example/Modules/Data.py +63 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Modules/Experiment.py +34 -0
- pyantigen-1.0.9/framework/template/Example/Modules/Loss_config.py +61 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Modules/Optimizer_settings.py +118 -0
- pyantigen-1.0.9/framework/template/Example/Modules/Plots.py +89 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Modules/Update_parameters.py +10 -0
- pyantigen-1.0.9/framework/template/data/Flipflop.csv +29 -0
- pyantigen-1.0.9/framework/template/data/make_flipflop_data.py +174 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/pyproject.toml +1 -1
- {pyantigen-1.0.7 → pyantigen-1.0.9}/setup.py +1 -1
- pyantigen-1.0.7/framework/template/Example/Engine/Evaluator.py +0 -521
- pyantigen-1.0.7/framework/template/Example/Engine/Model_optimize.py +0 -704
- pyantigen-1.0.7/framework/template/Example/Engine/Optimize.py +0 -4108
- pyantigen-1.0.7/framework/template/Example/Engine/Profile_checkpoint.py +0 -206
- pyantigen-1.0.7/framework/template/Example/Model_run.py +0 -143
- pyantigen-1.0.7/framework/template/Example/Modules/Data.py +0 -36
- pyantigen-1.0.7/framework/template/Example/Modules/Loss_config.py +0 -21
- pyantigen-1.0.7/framework/template/Example/Modules/Plots.py +0 -51
- {pyantigen-1.0.7 → pyantigen-1.0.9}/LICENSE +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/MANIFEST.in +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/PyAntiGen.egg-info/dependency_links.txt +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/PyAntiGen.egg-info/entry_points.txt +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/PyAntiGen.egg-info/requires.txt +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/PyAntiGen.egg-info/top_level.txt +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/AntimonyGen.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/RxnDict_to_antimony.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/TelluriumGen.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/__init__.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/antimony_utils.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/data_interpolation.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/isotopomer_tools.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/model_generation.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/models.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/module_base.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/pyantigen.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/rate_laws.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/reaction_creation.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/AntiGen_paths.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Engine/Nuisance_sensitivity.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Engine/Petab_export.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Modules/Events.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Modules/Observed_species.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Modules/Solver_settings.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/Example/Modules/Update_opt_parameters.py +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/data/ADneg.csv +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/framework/template/data/ADpos.csv +0 -0
- {pyantigen-1.0.7 → pyantigen-1.0.9}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyAntiGen
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.9
|
|
4
4
|
Summary: Short one-line description of what PyAntiGen does
|
|
5
5
|
Author: Open Source Contributor
|
|
6
6
|
Author-email: Don <you@example.com>
|
|
@@ -108,8 +108,16 @@ To run optimization examples use the --optimize flag:
|
|
|
108
108
|
python Model_run.py --optimize Example1
|
|
109
109
|
python Model_run.py --optimize Example2
|
|
110
110
|
python Model_run.py --optimize Example3
|
|
111
|
+
python Model_run.py --optimize Example4
|
|
112
|
+
python Model_run.py --optimize Example5
|
|
111
113
|
```
|
|
112
114
|
|
|
115
|
+
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`.
|
|
116
|
+
|
|
117
|
+
Examples 1–3 demonstrate structural identifiability: Example1/2 split the fit so each sub-problem is well-posed, while Example3 deliberately fits two exactly confounded parameters (`SF`/`V_Comp1`) jointly and shows how profile likelihood flags the ridge that likelihood slices and Sobol indices miss.
|
|
118
|
+
|
|
119
|
+
Examples 4–5 go further and test the *accuracy* of the profile likelihood ΔNLL itself, on a genuinely multimodal problem with a log10 objective. The chain A → B → C observed through `SF*B_Comp1/V_Comp1` has the classic pharmacokinetic "flip-flop" ambiguity — swapping the two rate constants and rescaling `SF` reproduces the data exactly — so the likelihood has two modes separated by a known ΔNLL gap (~2.4 at the NLL optimum, set by a few deliberately noisy observations of A and printed by `data/make_flipflop_data.py`). Example4 starts in the correct basin. Accurate profiles must (a) dip below zero by a known amount (~−2.1), because the fitting objective's per-observable averaging places the fit away from the inference NLL optimum that ΔNLL is anchored to, and (b) show the second mode at ~+0.8 — below the 95% threshold, so the correct confidence set is a union of two disjoint intervals; a walker that stops at the first threshold crossing never finds it, and a first-crossing CI extractor cannot represent it. Example5 starts in the wrong basin: sigmas are frozen at the wrong mode (inflating σ for the A data and deflating every ΔNLL), and the profile must dip to ~−0.98 at the true mode. `Projects/Example/Flipflop_reference.py` recomputes the exact reference profiles from the closed-form solution with scipy (independent of RoadRunner and of the framework's loss code), replicating the pipeline's conventions — fit objective for the anchor, MLE-frozen sigmas, summed NLL — and its `--compare results/Example/<run>.json` mode scores the framework's stored profile traces against the reference automatically.
|
|
120
|
+
|
|
113
121
|
Your own model lives under `Projects/MyNewModel/`. Modify the code for your model in `Projects/MyNewModel/Modules/`, `Projects/MyNewModel/Model_generate.py`, and `Projects/MyNewModel/Model_run.py`. Your problem will also require new modules in `antimony_modules/` to define the model.
|
|
114
122
|
|
|
115
123
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyAntiGen
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.9
|
|
4
4
|
Summary: Short one-line description of what PyAntiGen does
|
|
5
5
|
Author: Open Source Contributor
|
|
6
6
|
Author-email: Don <you@example.com>
|
|
@@ -108,8 +108,16 @@ To run optimization examples use the --optimize flag:
|
|
|
108
108
|
python Model_run.py --optimize Example1
|
|
109
109
|
python Model_run.py --optimize Example2
|
|
110
110
|
python Model_run.py --optimize Example3
|
|
111
|
+
python Model_run.py --optimize Example4
|
|
112
|
+
python Model_run.py --optimize Example5
|
|
111
113
|
```
|
|
112
114
|
|
|
115
|
+
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`.
|
|
116
|
+
|
|
117
|
+
Examples 1–3 demonstrate structural identifiability: Example1/2 split the fit so each sub-problem is well-posed, while Example3 deliberately fits two exactly confounded parameters (`SF`/`V_Comp1`) jointly and shows how profile likelihood flags the ridge that likelihood slices and Sobol indices miss.
|
|
118
|
+
|
|
119
|
+
Examples 4–5 go further and test the *accuracy* of the profile likelihood ΔNLL itself, on a genuinely multimodal problem with a log10 objective. The chain A → B → C observed through `SF*B_Comp1/V_Comp1` has the classic pharmacokinetic "flip-flop" ambiguity — swapping the two rate constants and rescaling `SF` reproduces the data exactly — so the likelihood has two modes separated by a known ΔNLL gap (~2.4 at the NLL optimum, set by a few deliberately noisy observations of A and printed by `data/make_flipflop_data.py`). Example4 starts in the correct basin. Accurate profiles must (a) dip below zero by a known amount (~−2.1), because the fitting objective's per-observable averaging places the fit away from the inference NLL optimum that ΔNLL is anchored to, and (b) show the second mode at ~+0.8 — below the 95% threshold, so the correct confidence set is a union of two disjoint intervals; a walker that stops at the first threshold crossing never finds it, and a first-crossing CI extractor cannot represent it. Example5 starts in the wrong basin: sigmas are frozen at the wrong mode (inflating σ for the A data and deflating every ΔNLL), and the profile must dip to ~−0.98 at the true mode. `Projects/Example/Flipflop_reference.py` recomputes the exact reference profiles from the closed-form solution with scipy (independent of RoadRunner and of the framework's loss code), replicating the pipeline's conventions — fit objective for the anchor, MLE-frozen sigmas, summed NLL — and its `--compare results/Example/<run>.json` mode scores the framework's stored profile traces against the reference automatically.
|
|
120
|
+
|
|
113
121
|
Your own model lives under `Projects/MyNewModel/`. Modify the code for your model in `Projects/MyNewModel/Modules/`, `Projects/MyNewModel/Model_generate.py`, and `Projects/MyNewModel/Model_run.py`. Your problem will also require new modules in `antimony_modules/` to define the model.
|
|
114
122
|
|
|
115
123
|
|
|
@@ -24,14 +24,22 @@ framework/pyantigen.py
|
|
|
24
24
|
framework/rate_laws.py
|
|
25
25
|
framework/reaction_creation.py
|
|
26
26
|
framework/template/Example/AntiGen_paths.py
|
|
27
|
+
framework/template/Example/Flipflop_reference.py
|
|
27
28
|
framework/template/Example/Model_generate.py
|
|
28
29
|
framework/template/Example/Model_run.py
|
|
30
|
+
framework/template/Example/Engine/Anchor_cache.py
|
|
31
|
+
framework/template/Example/Engine/Deadline.py
|
|
29
32
|
framework/template/Example/Engine/Evaluator.py
|
|
33
|
+
framework/template/Example/Engine/Event_times.py
|
|
34
|
+
framework/template/Example/Engine/Fast_profile.py
|
|
35
|
+
framework/template/Example/Engine/Fit_cache.py
|
|
36
|
+
framework/template/Example/Engine/Identifiability.py
|
|
30
37
|
framework/template/Example/Engine/Model_optimize.py
|
|
31
38
|
framework/template/Example/Engine/Model_simulate.py
|
|
32
39
|
framework/template/Example/Engine/Nuisance_sensitivity.py
|
|
33
40
|
framework/template/Example/Engine/Optimize.py
|
|
34
41
|
framework/template/Example/Engine/Petab_export.py
|
|
42
|
+
framework/template/Example/Engine/Preequil_cache.py
|
|
35
43
|
framework/template/Example/Engine/Profile_checkpoint.py
|
|
36
44
|
framework/template/Example/Engine/Results.py
|
|
37
45
|
framework/template/Example/Engine/Sensitivity_analysis.py
|
|
@@ -47,4 +55,6 @@ framework/template/Example/Modules/Solver_settings.py
|
|
|
47
55
|
framework/template/Example/Modules/Update_opt_parameters.py
|
|
48
56
|
framework/template/Example/Modules/Update_parameters.py
|
|
49
57
|
framework/template/data/ADneg.csv
|
|
50
|
-
framework/template/data/ADpos.csv
|
|
58
|
+
framework/template/data/ADpos.csv
|
|
59
|
+
framework/template/data/Flipflop.csv
|
|
60
|
+
framework/template/data/make_flipflop_data.py
|
|
@@ -82,8 +82,16 @@ To run optimization examples use the --optimize flag:
|
|
|
82
82
|
python Model_run.py --optimize Example1
|
|
83
83
|
python Model_run.py --optimize Example2
|
|
84
84
|
python Model_run.py --optimize Example3
|
|
85
|
+
python Model_run.py --optimize Example4
|
|
86
|
+
python Model_run.py --optimize Example5
|
|
85
87
|
```
|
|
86
88
|
|
|
89
|
+
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`.
|
|
90
|
+
|
|
91
|
+
Examples 1–3 demonstrate structural identifiability: Example1/2 split the fit so each sub-problem is well-posed, while Example3 deliberately fits two exactly confounded parameters (`SF`/`V_Comp1`) jointly and shows how profile likelihood flags the ridge that likelihood slices and Sobol indices miss.
|
|
92
|
+
|
|
93
|
+
Examples 4–5 go further and test the *accuracy* of the profile likelihood ΔNLL itself, on a genuinely multimodal problem with a log10 objective. The chain A → B → C observed through `SF*B_Comp1/V_Comp1` has the classic pharmacokinetic "flip-flop" ambiguity — swapping the two rate constants and rescaling `SF` reproduces the data exactly — so the likelihood has two modes separated by a known ΔNLL gap (~2.4 at the NLL optimum, set by a few deliberately noisy observations of A and printed by `data/make_flipflop_data.py`). Example4 starts in the correct basin. Accurate profiles must (a) dip below zero by a known amount (~−2.1), because the fitting objective's per-observable averaging places the fit away from the inference NLL optimum that ΔNLL is anchored to, and (b) show the second mode at ~+0.8 — below the 95% threshold, so the correct confidence set is a union of two disjoint intervals; a walker that stops at the first threshold crossing never finds it, and a first-crossing CI extractor cannot represent it. Example5 starts in the wrong basin: sigmas are frozen at the wrong mode (inflating σ for the A data and deflating every ΔNLL), and the profile must dip to ~−0.98 at the true mode. `Projects/Example/Flipflop_reference.py` recomputes the exact reference profiles from the closed-form solution with scipy (independent of RoadRunner and of the framework's loss code), replicating the pipeline's conventions — fit objective for the anchor, MLE-frozen sigmas, summed NLL — and its `--compare results/Example/<run>.json` mode scores the framework's stored profile traces against the reference automatically.
|
|
94
|
+
|
|
87
95
|
Your own model lives under `Projects/MyNewModel/`. Modify the code for your model in `Projects/MyNewModel/Modules/`, `Projects/MyNewModel/Model_generate.py`, and `Projects/MyNewModel/Model_run.py`. Your problem will also require new modules in `antimony_modules/` to define the model.
|
|
88
96
|
|
|
89
97
|
|
|
@@ -66,6 +66,26 @@ def create_project():
|
|
|
66
66
|
|
|
67
67
|
# Add the reaction to the model
|
|
68
68
|
self.add_reaction(Reaction_name, Reactants, Products, Rate_type, Rate_eqtn_prototype)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class BasicChainReaction(PyAntiGenModule):
|
|
72
|
+
\"\"\"
|
|
73
|
+
Adds the second step of the chain A -> B -> C.
|
|
74
|
+
With k_B_to_C = 0 (the default in Example_parameters.csv) the
|
|
75
|
+
model behaves exactly like the single-step A -> B examples;
|
|
76
|
+
Example4/Example5 fit k_B_to_C to demonstrate flip-flop
|
|
77
|
+
bimodality.
|
|
78
|
+
\"\"\"
|
|
79
|
+
def build(self):
|
|
80
|
+
Compartments = ['Comp1']
|
|
81
|
+
for Comp in Compartments:
|
|
82
|
+
Reaction_name = f"Basic_B_to_C_{{Comp}}"
|
|
83
|
+
Reactants = f"[B_{{Comp}}]"
|
|
84
|
+
Products = f"[C_{{Comp}}]"
|
|
85
|
+
Rate_type = "MA"
|
|
86
|
+
Rate_eqtn_prototype = "k_B_to_C"
|
|
87
|
+
|
|
88
|
+
self.add_reaction(Reaction_name, Reactants, Products, Rate_type, Rate_eqtn_prototype)
|
|
69
89
|
"""))
|
|
70
90
|
print(f" Created folder: {d}/")
|
|
71
91
|
|
|
@@ -126,6 +146,7 @@ def create_project():
|
|
|
126
146
|
with open(param_csv_path, "w") as f:
|
|
127
147
|
f.write("Parameter,Value,Units,Comment\n")
|
|
128
148
|
f.write("k_A_to_B,0.1,,Default rate constant for A to B\n")
|
|
149
|
+
f.write("k_B_to_C,0.0,,Default rate constant for B to C (0 disables the chain step)\n")
|
|
129
150
|
f.write("V_Comp1,1.0,,Default compartment volume\n")
|
|
130
151
|
print(" Created file: antimony_models/Example/Example_parameters.csv")
|
|
131
152
|
|
|
@@ -134,6 +155,7 @@ def create_project():
|
|
|
134
155
|
f.write("Species,InitialCondition,Units,Comment\n")
|
|
135
156
|
f.write("A_Comp1,0.0,,Initial amount of A\n")
|
|
136
157
|
f.write("B_Comp1,0.0,,Initial amount of B\n")
|
|
158
|
+
f.write("C_Comp1,0.0,,Initial amount of C\n")
|
|
137
159
|
print(" Created file: antimony_models/Example/Example_InitialConditions.csv")
|
|
138
160
|
|
|
139
161
|
init_cond_path = os.path.join(example_antimony_dir, "Example_manual.txt")
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Hessian-derived quantities, cached against the fit they belong to.
|
|
2
|
+
|
|
3
|
+
The Wald statistics cost ``1 + 2k + 2k(k-1)`` objective evaluations -- 513 on
|
|
4
|
+
the 16-parameter SILK APP spec -- and they are recomputed from scratch on every
|
|
5
|
+
launch. That was tolerable when an evaluation was assumed to cost seconds. It
|
|
6
|
+
is not: the measured cost on that spec is 116 s, so across 39 workers the
|
|
7
|
+
Hessian alone is about 25 minutes, and it is charged again on every link of a
|
|
8
|
+
chain that may run to a hundred links.
|
|
9
|
+
|
|
10
|
+
On a preemptible partition the number matters for a second and sharper reason.
|
|
11
|
+
Nothing is written until a profile point finishes, so a link only makes
|
|
12
|
+
progress if the node survives setup *plus* one slice. Cutting 25 minutes off
|
|
13
|
+
setup lowers that threshold directly, which is the difference between a
|
|
14
|
+
short-lived node contributing something and contributing nothing at all.
|
|
15
|
+
|
|
16
|
+
Caching is safe here because the Hessian is a pure function of things the run
|
|
17
|
+
already fingerprints: the model, the optimization spec, the parameter scaling
|
|
18
|
+
and the optimum it is taken at. A change in any of them produces a different
|
|
19
|
+
key and a miss, so a stale Hessian cannot be silently reused -- the failure
|
|
20
|
+
mode that would matter, since an SE that does not belong to this fit would set
|
|
21
|
+
the profile's whole grid in the wrong place.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
from Engine.Profile_checkpoint import sweep_stale_temp_files
|
|
32
|
+
|
|
33
|
+
# Everything _attach_wald_stats puts in out["stats"]. Cached and restored as a
|
|
34
|
+
# set: a partial restore would leave the CI from one fit beside the SE of
|
|
35
|
+
# another.
|
|
36
|
+
WALD_FIELDS = ("wald_cov", "wald_se", "wald_se_opt", "wald_ci",
|
|
37
|
+
"wald_correlation")
|
|
38
|
+
|
|
39
|
+
# Bumped when the set or meaning of the cached fields changes, so old files
|
|
40
|
+
# miss rather than being misread.
|
|
41
|
+
# v1 held only the linear "wald_se".
|
|
42
|
+
# v2 adds "wald_se_opt", the SE in the optimizer's own space, which is what
|
|
43
|
+
# the profile and slice grids are placed with. A v1 file restored into a
|
|
44
|
+
# v2 run would leave that key absent and silently drop every grid back to
|
|
45
|
+
# the range_factor fallback.
|
|
46
|
+
_FORMAT = "wald-v2"
|
|
47
|
+
|
|
48
|
+
# Without this the block is not worth restoring: the profile grid is placed
|
|
49
|
+
# from it, and "no SE at all" triggers a different, deliberate fallback than
|
|
50
|
+
# "an SE in the wrong units".
|
|
51
|
+
_REQUIRED = ("wald_se", "wald_se_opt")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _encode(obj):
|
|
55
|
+
"""Arrays to nested lists, with non-finite values as null.
|
|
56
|
+
|
|
57
|
+
Non-finite entries are meaningful here -- an SE of nan is how "this
|
|
58
|
+
direction is flat, there is no usable standard error" is reported -- but
|
|
59
|
+
they are not portable JSON. They come back as nan, which is what every
|
|
60
|
+
consumer tests for with ``np.isfinite``.
|
|
61
|
+
"""
|
|
62
|
+
if obj is None:
|
|
63
|
+
return None
|
|
64
|
+
arr = np.asarray(obj, dtype=float)
|
|
65
|
+
out = arr.tolist()
|
|
66
|
+
|
|
67
|
+
def _clean(v):
|
|
68
|
+
if isinstance(v, list):
|
|
69
|
+
return [_clean(x) for x in v]
|
|
70
|
+
return v if np.isfinite(v) else None
|
|
71
|
+
|
|
72
|
+
return _clean(out)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _decode(obj):
|
|
76
|
+
"""The inverse: nulls back to nan, lists back to arrays."""
|
|
77
|
+
if obj is None:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def _fill(v):
|
|
81
|
+
if isinstance(v, list):
|
|
82
|
+
return [_fill(x) for x in v]
|
|
83
|
+
return float("nan") if v is None else float(v)
|
|
84
|
+
|
|
85
|
+
return np.asarray(_fill(obj), dtype=float)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def bounds_fingerprint(bounds):
|
|
89
|
+
"""Hash of the declared bounds.
|
|
90
|
+
|
|
91
|
+
Separate from the profile's own spec hash on purpose. Bounds change the
|
|
92
|
+
Wald *interval* (it is clipped to them) without changing the model or the
|
|
93
|
+
optimum, so they belong in this cache's key -- but adding them to
|
|
94
|
+
``spec_fingerprint`` would change every existing profile directory name and
|
|
95
|
+
orphan work already done.
|
|
96
|
+
"""
|
|
97
|
+
if bounds is None:
|
|
98
|
+
return "none"
|
|
99
|
+
try:
|
|
100
|
+
blob = json.dumps(
|
|
101
|
+
[None if b is None else [None if v is None else round(float(v), 12)
|
|
102
|
+
for v in b]
|
|
103
|
+
for b in bounds],
|
|
104
|
+
sort_keys=True,
|
|
105
|
+
)
|
|
106
|
+
except (TypeError, ValueError):
|
|
107
|
+
return "unhashable"
|
|
108
|
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AnchorCache:
|
|
112
|
+
"""Reads and writes the Wald block for one fit."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, root, run_id, model_hash, spec_hash, bounds_hash,
|
|
115
|
+
n_params, enabled=True):
|
|
116
|
+
self.enabled = bool(enabled and root)
|
|
117
|
+
self.model_hash = model_hash
|
|
118
|
+
self.spec_hash = spec_hash
|
|
119
|
+
self.bounds_hash = bounds_hash
|
|
120
|
+
self.n_params = int(n_params)
|
|
121
|
+
self.dir = os.path.join(root, "profiles", run_id) if root else None
|
|
122
|
+
if self.enabled and self.dir:
|
|
123
|
+
try:
|
|
124
|
+
os.makedirs(self.dir, exist_ok=True)
|
|
125
|
+
# A kill between writing a temp file and renaming it leaves the
|
|
126
|
+
# temp behind; this directory is where they collect.
|
|
127
|
+
sweep_stale_temp_files(self.dir)
|
|
128
|
+
except OSError:
|
|
129
|
+
self.enabled = False
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def path(self):
|
|
133
|
+
return os.path.join(self.dir, "anchor.json") if self.dir else None
|
|
134
|
+
|
|
135
|
+
def load(self):
|
|
136
|
+
"""The cached Wald block, or None on any miss.
|
|
137
|
+
|
|
138
|
+
Every failure is a miss rather than an error: a corrupt, truncated or
|
|
139
|
+
stale file must cost the 25 minutes of recomputation it was meant to
|
|
140
|
+
save, never the correctness of the run.
|
|
141
|
+
"""
|
|
142
|
+
if not (self.enabled and self.path and os.path.exists(self.path)):
|
|
143
|
+
return None
|
|
144
|
+
try:
|
|
145
|
+
with open(self.path, "r", encoding="utf-8") as fh:
|
|
146
|
+
data = json.load(fh)
|
|
147
|
+
except (OSError, ValueError):
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
if (data.get("format") != _FORMAT
|
|
151
|
+
or data.get("model_hash") != self.model_hash
|
|
152
|
+
or data.get("spec_hash") != self.spec_hash
|
|
153
|
+
or data.get("bounds_hash") != self.bounds_hash
|
|
154
|
+
or int(data.get("n_params") or -1) != self.n_params):
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
stats = {}
|
|
158
|
+
for field in WALD_FIELDS:
|
|
159
|
+
if field in data:
|
|
160
|
+
stats[field] = _decode(data[field])
|
|
161
|
+
|
|
162
|
+
for field in _REQUIRED:
|
|
163
|
+
arr = stats.get(field)
|
|
164
|
+
if arr is None or np.asarray(arr).shape != (self.n_params,):
|
|
165
|
+
return None
|
|
166
|
+
return stats
|
|
167
|
+
|
|
168
|
+
def save(self, stats):
|
|
169
|
+
"""Write the Wald block, atomically."""
|
|
170
|
+
if not (self.enabled and self.path):
|
|
171
|
+
return
|
|
172
|
+
payload = {
|
|
173
|
+
"format": _FORMAT,
|
|
174
|
+
"model_hash": self.model_hash,
|
|
175
|
+
"spec_hash": self.spec_hash,
|
|
176
|
+
"bounds_hash": self.bounds_hash,
|
|
177
|
+
"n_params": self.n_params,
|
|
178
|
+
"saved": datetime.now().isoformat(timespec="seconds"),
|
|
179
|
+
}
|
|
180
|
+
for field in WALD_FIELDS:
|
|
181
|
+
if stats.get(field) is not None:
|
|
182
|
+
payload[field] = _encode(stats[field])
|
|
183
|
+
|
|
184
|
+
tmp = f"{self.path}.{os.getpid()}.tmp"
|
|
185
|
+
try:
|
|
186
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
187
|
+
json.dump(payload, fh)
|
|
188
|
+
os.replace(tmp, self.path)
|
|
189
|
+
except (OSError, TypeError, ValueError):
|
|
190
|
+
try:
|
|
191
|
+
os.unlink(tmp)
|
|
192
|
+
except OSError:
|
|
193
|
+
pass
|