patchsim 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ # This file marks the `models` directory as a Python package.
@@ -0,0 +1,420 @@
1
+ """Sobol sensitivity studies for PatchSim configurations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import shutil
9
+ import tempfile
10
+ import time
11
+ from copy import deepcopy
12
+ from dataclasses import dataclass
13
+ from importlib.metadata import PackageNotFoundError, version
14
+ from numbers import Real
15
+ from pathlib import Path
16
+ from typing import Any, Callable
17
+
18
+ import numpy as np
19
+ import pandas as pd
20
+
21
+ from patchsim.core.simulation import _simulate_prepared, load_config, setup_simulation
22
+
23
+ _NAME_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z")
24
+ _INPUT_FIELDS = ("PatchFile", "SeedFile", "NetworkFile", "GroupFile", "InteractionFile")
25
+ _ARTIFACT_NAMES = ("samples.csv", "responses.csv", "indices.csv")
26
+ _MIN_SALIB_VERSION = (1, 5, 2)
27
+ _NUM_RESAMPLES = 100
28
+ _CONF_LEVEL = 0.95
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Metric:
33
+ name: str
34
+ columns: tuple[str, ...]
35
+ reducer: str
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class SensitivityPlan:
40
+ name: str
41
+ base_samples: int
42
+ seed: int
43
+ parameters: tuple[tuple[str, float, float], ...]
44
+ metrics: tuple[Metric, ...]
45
+
46
+ @property
47
+ def evaluation_count(self) -> int:
48
+ return self.base_samples * (len(self.parameters) + 2)
49
+
50
+
51
+ def _parse_version(value: str) -> tuple[int, int, int]:
52
+ match = re.match(r"(\d+)\.(\d+)\.(\d+)", value)
53
+ return tuple(map(int, match.groups())) if match else (0, 0, 0)
54
+
55
+
56
+ def _load_salib():
57
+ try:
58
+ salib_version = version("SALib")
59
+ from SALib.analyze import sobol as sobol_analyze
60
+ from SALib.sample import sobol as sobol_sample
61
+ except (ImportError, PackageNotFoundError) as exc:
62
+ raise RuntimeError(
63
+ "Sensitivity analysis requires SALib 1.5.2 or newer. "
64
+ 'Install it with `python -m pip install "patchsim[analysis]"`.'
65
+ ) from exc
66
+ if _parse_version(salib_version) < _MIN_SALIB_VERSION:
67
+ raise RuntimeError(
68
+ f"Sensitivity analysis requires SALib 1.5.2 or newer; found {salib_version}. "
69
+ 'Upgrade with `python -m pip install --upgrade "patchsim[analysis]"`.'
70
+ )
71
+ return sobol_sample, sobol_analyze, salib_version
72
+
73
+
74
+ def _patch_parameter_names(config: dict[str, Any]) -> set[str]:
75
+ names: set[str] = set()
76
+ for entry in config.get("PatchParameters", []):
77
+ if isinstance(entry, dict) and isinstance(entry.get("parameters", {}), dict):
78
+ names.update(entry.get("parameters", {}))
79
+ return names
80
+
81
+
82
+ def get_sensitivity_plan(
83
+ config: dict[str, Any],
84
+ output_columns: list[str],
85
+ *,
86
+ required: bool = True,
87
+ ) -> SensitivityPlan | None:
88
+ """Validate the optional Sensitivity block against a prepared model."""
89
+ raw = config.get("Sensitivity")
90
+ if raw is None:
91
+ if required:
92
+ raise ValueError("The configuration has no 'Sensitivity' block")
93
+ return None
94
+ if not isinstance(raw, dict):
95
+ raise ValueError("'Sensitivity' must be a mapping")
96
+
97
+ name = raw.get("Name")
98
+ if not isinstance(name, str) or not _NAME_PATTERN.fullmatch(name) or name in {".", ".."}:
99
+ raise ValueError("'Sensitivity.Name' must be one safe path component using letters, numbers, '.', '_', or '-'")
100
+ if raw.get("Method") != "sobol":
101
+ raise ValueError("'Sensitivity.Method' must be 'sobol'")
102
+
103
+ base_samples = raw.get("BaseSamples")
104
+ if (
105
+ isinstance(base_samples, bool)
106
+ or not isinstance(base_samples, int)
107
+ or base_samples < 2
108
+ or base_samples & (base_samples - 1)
109
+ ):
110
+ raise ValueError("'Sensitivity.BaseSamples' must be a power of two of at least 2")
111
+ seed = raw.get("Seed")
112
+ if isinstance(seed, bool) or not isinstance(seed, int) or seed < 0:
113
+ raise ValueError("'Sensitivity.Seed' must be a non-negative integer")
114
+
115
+ global_parameters = config.get("Parameters", {})
116
+ if not isinstance(global_parameters, dict):
117
+ raise ValueError("'Parameters' must be a mapping")
118
+ patch_parameters = _patch_parameter_names(config)
119
+ raw_parameters = raw.get("Parameters")
120
+ if not isinstance(raw_parameters, dict) or not raw_parameters:
121
+ raise ValueError("'Sensitivity.Parameters' must contain at least one parameter")
122
+
123
+ parameters = []
124
+ for parameter_name, bounds in raw_parameters.items():
125
+ if not isinstance(parameter_name, str) or not parameter_name or parameter_name == "sample_id":
126
+ raise ValueError("Sensitivity parameter names must be non-empty and may not be 'sample_id'")
127
+ if parameter_name not in global_parameters:
128
+ raise ValueError(f"Unknown global sensitivity parameter: {parameter_name!r}")
129
+ if parameter_name in patch_parameters:
130
+ raise ValueError(f"Cannot sample {parameter_name!r}; it is also set in PatchParameters")
131
+ if not isinstance(bounds, (list, tuple)) or len(bounds) != 2:
132
+ raise ValueError(f"Bounds for {parameter_name!r} must be [lower, upper]")
133
+ lower, upper = bounds
134
+ if any(isinstance(value, bool) or not isinstance(value, Real) for value in bounds):
135
+ raise ValueError(f"Bounds for {parameter_name!r} must be finite real numbers")
136
+ lower, upper = float(lower), float(upper)
137
+ if not np.isfinite(lower) or not np.isfinite(upper) or lower >= upper:
138
+ raise ValueError(f"Bounds for {parameter_name!r} must satisfy finite lower < upper")
139
+ parameters.append((parameter_name, lower, upper))
140
+
141
+ raw_metrics = raw.get("Metrics")
142
+ if not isinstance(raw_metrics, dict) or not raw_metrics:
143
+ raise ValueError("'Sensitivity.Metrics' must contain at least one metric")
144
+ parameter_names = {name for name, _lower, _upper in parameters}
145
+ available_columns = set(output_columns)
146
+ metrics = []
147
+ for metric_name, metric_config in raw_metrics.items():
148
+ if (
149
+ not isinstance(metric_name, str)
150
+ or not metric_name
151
+ or metric_name == "sample_id"
152
+ or metric_name in parameter_names
153
+ ):
154
+ raise ValueError(
155
+ "Sensitivity metric names must be non-empty and distinct from parameter columns and 'sample_id'"
156
+ )
157
+ if not isinstance(metric_config, dict):
158
+ raise ValueError(f"Metric {metric_name!r} must be a mapping")
159
+ columns = metric_config.get("Columns")
160
+ if (
161
+ not isinstance(columns, list)
162
+ or not columns
163
+ or not all(isinstance(column, str) for column in columns)
164
+ or len(columns) != len(set(columns))
165
+ ):
166
+ raise ValueError(f"Metric {metric_name!r} Columns must be a non-empty list of unique names")
167
+ unknown = sorted(set(columns) - available_columns)
168
+ if unknown:
169
+ raise ValueError(f"Metric {metric_name!r} references unknown output columns: {unknown}")
170
+ reducer = metric_config.get("Reduce")
171
+ if reducer not in {"max", "final"}:
172
+ raise ValueError(f"Metric {metric_name!r} Reduce must be 'max' or 'final'")
173
+ metrics.append(Metric(metric_name, tuple(columns), reducer))
174
+
175
+ return SensitivityPlan(name, base_samples, seed, tuple(parameters), tuple(metrics))
176
+
177
+
178
+ def _sha256(path: Path) -> str:
179
+ return hashlib.sha256(path.read_bytes()).hexdigest()
180
+
181
+
182
+ def _canonical_bytes(value: Any) -> bytes:
183
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False, default=str).encode()
184
+
185
+
186
+ def _versions(salib_version: str) -> dict[str, str]:
187
+ result = {"SALib": salib_version}
188
+ for package in ("patchsim", "numpy", "pandas", "scipy"):
189
+ try:
190
+ result[package] = version(package)
191
+ except PackageNotFoundError:
192
+ result[package] = "unknown"
193
+ return result
194
+
195
+
196
+ def _request_record(
197
+ config: dict[str, Any],
198
+ salib_version: str,
199
+ source_config_sha256: str,
200
+ ) -> dict[str, Any]:
201
+ inputs = {
202
+ field: {"path": str(config[field]), "sha256": _sha256(Path(config[field]))}
203
+ for field in _INPUT_FIELDS
204
+ if config.get(field)
205
+ }
206
+ method = {
207
+ "name": "sobol",
208
+ "calc_second_order": False,
209
+ "scramble": True,
210
+ "skip_values": 0,
211
+ "num_resamples": _NUM_RESAMPLES,
212
+ "conf_level": _CONF_LEVEL,
213
+ }
214
+ return {
215
+ "normalized_config": json.loads(json.dumps(config, default=str)),
216
+ "source_config_sha256": source_config_sha256,
217
+ "inputs": inputs,
218
+ "method": method,
219
+ "versions": _versions(salib_version),
220
+ }
221
+
222
+
223
+ def _artifact_paths(target: Path) -> dict[str, str]:
224
+ return {
225
+ "output_dir": str(target),
226
+ "samples_path": str(target / "samples.csv"),
227
+ "responses_path": str(target / "responses.csv"),
228
+ "indices_path": str(target / "indices.csv"),
229
+ "manifest_path": str(target / "manifest.json"),
230
+ }
231
+
232
+
233
+ def _reuse_existing(
234
+ target: Path,
235
+ fingerprint: str,
236
+ evaluation_count: int,
237
+ ) -> dict[str, Any]:
238
+ manifest_path = target / "manifest.json"
239
+ try:
240
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
241
+ except (OSError, json.JSONDecodeError) as exc:
242
+ raise FileExistsError(f"Existing sensitivity target is incomplete: {target}") from exc
243
+ request = manifest.get("request")
244
+ if (
245
+ manifest.get("schema_version") != 1
246
+ or not isinstance(request, dict)
247
+ or hashlib.sha256(_canonical_bytes(request)).hexdigest() != fingerprint
248
+ or manifest.get("study_fingerprint") != fingerprint
249
+ or manifest.get("evaluation_count") != evaluation_count
250
+ or manifest.get("source_config", {}).get("sha256") != request.get("source_config_sha256")
251
+ ):
252
+ raise FileExistsError(f"Existing sensitivity target was produced by a different study: {target}")
253
+
254
+ recorded = manifest.get("artifacts", {})
255
+ for filename in _ARTIFACT_NAMES:
256
+ path = target / filename
257
+ expected = recorded.get(filename, {}).get("sha256")
258
+ if not path.is_file() or expected != _sha256(path):
259
+ raise FileExistsError(f"Existing sensitivity artifact is missing or modified: {path}")
260
+
261
+ return {
262
+ **_artifact_paths(target),
263
+ "reused": True,
264
+ "planned_evaluations": evaluation_count,
265
+ "completed_evaluations": 0,
266
+ }
267
+
268
+
269
+ def _metric_value(frame: pd.DataFrame, metric: Metric) -> float:
270
+ series = frame.loc[:, metric.columns].sum(axis=1, skipna=False)
271
+ return float(series.max() if metric.reducer == "max" else series.iloc[-1])
272
+
273
+
274
+ def run_sensitivity(
275
+ config_path: str | Path,
276
+ *,
277
+ progress: Callable[[str], None] | None = None,
278
+ ) -> dict[str, Any]:
279
+ """Run or reuse one configured Sobol sensitivity study."""
280
+ started = time.monotonic()
281
+ source_path = Path(config_path).expanduser().resolve()
282
+ config = load_config(str(source_path))
283
+ request_config = deepcopy(config)
284
+ net, y0, _patches, _num_patches = setup_simulation(config)
285
+ plan = get_sensitivity_plan(config, list(net.all_compartments))
286
+ assert plan is not None
287
+ if progress:
288
+ progress(f"Planned model evaluations: {plan.evaluation_count}")
289
+
290
+ sobol_sample, sobol_analyze, salib_version = _load_salib()
291
+ source_config_sha256 = _sha256(source_path)
292
+ request = _request_record(request_config, salib_version, source_config_sha256)
293
+ fingerprint = hashlib.sha256(_canonical_bytes(request)).hexdigest()
294
+ output_root = Path(config["OutputDir"]).resolve()
295
+ sensitivity_root = output_root / "sensitivity"
296
+ resolved_sensitivity_root = sensitivity_root.resolve()
297
+ if not resolved_sensitivity_root.is_relative_to(output_root):
298
+ raise ValueError(f"Sensitivity output path escapes OutputDir: {sensitivity_root}")
299
+ target = sensitivity_root / plan.name
300
+ if target.is_symlink() or not target.resolve().is_relative_to(resolved_sensitivity_root):
301
+ raise ValueError(f"Sensitivity output path escapes its study directory: {target}")
302
+ if target.exists():
303
+ summary = _reuse_existing(target, fingerprint, plan.evaluation_count)
304
+ summary["elapsed_seconds"] = time.monotonic() - started
305
+ return summary
306
+
307
+ names = [name for name, _lower, _upper in plan.parameters]
308
+ problem = {
309
+ "num_vars": len(names),
310
+ "names": names,
311
+ "bounds": [[lower, upper] for _name, lower, upper in plan.parameters],
312
+ }
313
+ samples = sobol_sample.sample(
314
+ problem,
315
+ plan.base_samples,
316
+ calc_second_order=False,
317
+ scramble=True,
318
+ skip_values=0,
319
+ seed=plan.seed,
320
+ )
321
+ responses = {metric.name: [] for metric in plan.metrics}
322
+
323
+ for sample_id, sample in enumerate(samples):
324
+ values = dict(zip(names, map(float, sample), strict=True))
325
+ net.base_model.parameters.update(values)
326
+ for patch_parameters in net.patch_parameters.values():
327
+ patch_parameters.update(values)
328
+ try:
329
+ frame = _simulate_prepared(config, net, y0)
330
+ metric_values = {metric.name: _metric_value(frame, metric) for metric in plan.metrics}
331
+ except Exception as exc:
332
+ raise RuntimeError(f"Sensitivity evaluation {sample_id} failed for parameters {values}: {exc}") from exc
333
+ non_finite = [name for name, value in metric_values.items() if not np.isfinite(value)]
334
+ if non_finite:
335
+ raise RuntimeError(
336
+ f"Sensitivity evaluation {sample_id} produced non-finite metrics {non_finite} for parameters {values}"
337
+ )
338
+ for name, value in metric_values.items():
339
+ responses[name].append(value)
340
+
341
+ index_rows = []
342
+ for metric in plan.metrics:
343
+ values = np.asarray(responses[metric.name], dtype=float)
344
+ if np.ptp(values) == 0:
345
+ raise ValueError(f"Sensitivity metric {metric.name!r} is constant at {values[0]}")
346
+ indices = sobol_analyze.analyze(
347
+ problem,
348
+ values,
349
+ calc_second_order=False,
350
+ num_resamples=_NUM_RESAMPLES,
351
+ conf_level=_CONF_LEVEL,
352
+ print_to_console=False,
353
+ parallel=False,
354
+ seed=plan.seed,
355
+ )
356
+ for index, parameter_name in enumerate(names):
357
+ estimates = {
358
+ "S1": float(indices["S1"][index]),
359
+ "S1_conf": float(indices["S1_conf"][index]),
360
+ "ST": float(indices["ST"][index]),
361
+ "ST_conf": float(indices["ST_conf"][index]),
362
+ }
363
+ if not all(np.isfinite(value) for value in estimates.values()):
364
+ raise ValueError(
365
+ f"Sobol analysis produced non-finite indices for metric "
366
+ f"{metric.name!r}, parameter {parameter_name!r}"
367
+ )
368
+ index_rows.append(
369
+ {
370
+ "metric": metric.name,
371
+ "parameter": parameter_name,
372
+ **estimates,
373
+ }
374
+ )
375
+
376
+ samples_frame = pd.DataFrame(samples, columns=names)
377
+ samples_frame.insert(0, "sample_id", np.arange(len(samples), dtype=int))
378
+ responses_frame = pd.DataFrame(responses)
379
+ responses_frame.insert(0, "sample_id", np.arange(len(samples), dtype=int))
380
+ indices_frame = pd.DataFrame(index_rows)
381
+
382
+ parent = target.parent
383
+ parent.mkdir(parents=True, exist_ok=True)
384
+ temporary = Path(tempfile.mkdtemp(prefix=f".{plan.name}.", dir=parent))
385
+ try:
386
+ samples_frame.to_csv(temporary / "samples.csv", index=False, lineterminator="\n")
387
+ responses_frame.to_csv(temporary / "responses.csv", index=False, lineterminator="\n")
388
+ indices_frame.to_csv(temporary / "indices.csv", index=False, lineterminator="\n")
389
+ artifacts = {filename: {"sha256": _sha256(temporary / filename)} for filename in _ARTIFACT_NAMES}
390
+ manifest = {
391
+ "schema_version": 1,
392
+ "study_fingerprint": fingerprint,
393
+ "evaluation_count": plan.evaluation_count,
394
+ "request": request,
395
+ "source_config": {"path": str(source_path), "sha256": source_config_sha256},
396
+ "artifacts": artifacts,
397
+ }
398
+ (temporary / "manifest.json").write_text(
399
+ json.dumps(manifest, indent=2, sort_keys=True, allow_nan=False) + "\n",
400
+ encoding="utf-8",
401
+ )
402
+ try:
403
+ temporary.rename(target)
404
+ except OSError:
405
+ if not target.exists():
406
+ raise
407
+ summary = _reuse_existing(target, fingerprint, plan.evaluation_count)
408
+ summary["elapsed_seconds"] = time.monotonic() - started
409
+ return summary
410
+ finally:
411
+ if temporary.exists():
412
+ shutil.rmtree(temporary)
413
+
414
+ return {
415
+ **_artifact_paths(target),
416
+ "reused": False,
417
+ "planned_evaluations": plan.evaluation_count,
418
+ "completed_evaluations": plan.evaluation_count,
419
+ "elapsed_seconds": time.monotonic() - started,
420
+ }
@@ -0,0 +1,10 @@
1
+ # Built-in SEIR template
2
+ compartments: ["S", "E", "I", "R"]
3
+ Parameters:
4
+ beta: 0.25
5
+ sigma: 0.2
6
+ gamma: 0.1
7
+ Transitions:
8
+ "S -> E": "beta"
9
+ "E -> I": "sigma * E"
10
+ "I -> R": "gamma * I"
@@ -0,0 +1,8 @@
1
+ # Built-in SIR template
2
+ compartments: ["S", "I", "R"]
3
+ Parameters:
4
+ beta: 0.2
5
+ gamma: 0.1
6
+ Transitions:
7
+ "S -> I": "beta"
8
+ "I -> R": "gamma * I"
@@ -0,0 +1,10 @@
1
+ # Built-in SIRS template
2
+ compartments: ["S", "I", "R"]
3
+ Parameters:
4
+ beta: 0.2
5
+ gamma: 0.1
6
+ waning: 0.02
7
+ Transitions:
8
+ "S -> I": "beta"
9
+ "I -> R": "gamma * I"
10
+ "R -> S": "waning * R"
@@ -0,0 +1,8 @@
1
+ # Built-in SIS template
2
+ compartments: ["S", "I"]
3
+ Parameters:
4
+ beta: 0.08
5
+ gamma: 0.1
6
+ Transitions:
7
+ "S -> I": "beta"
8
+ "I -> S": "gamma * I"
@@ -0,0 +1,33 @@
1
+ # -----------------------------------------------------------------------------
2
+ # PatchSim project configuration
3
+ # -----------------------------------------------------------------------------
4
+ # This project was created by: patchsim init {{PROJECT_NAME}}
5
+
6
+ # Input files (relative to this config file)
7
+ PatchFile: data/patch/patch-population.csv
8
+ NetworkFile: data/networks/network-static.csv
9
+ SeedFile: data/seeds/seed-initial.csv
10
+ Logging: False
11
+
12
+ # Model configuration
13
+ ModelName: {{PROJECT_NAME}}
14
+
15
+ # Simulation parameters
16
+ TMax: 60
17
+ Solver: ode
18
+ TimeStep: 1.0
19
+ Tolerance: 1e-8
20
+ MaxIter: 10000
21
+ StartDate: 2020-01-01
22
+ EndDate: 2022-12-31
23
+ OutputDir: output/{{PROJECT_NAME}}
24
+ compartments: ["S", "I", "R"]
25
+
26
+ # Global model parameters
27
+ Parameters:
28
+ beta: 0.08
29
+ gamma: 0.10
30
+
31
+ # Transition map (required format)
32
+ # Use arrow notation only: X -> Y
33
+ Transitions: {S -> I: "beta", I -> R: "gamma * I"}
@@ -0,0 +1,5 @@
1
+ day,source,target,weight
2
+ 0,A,A,0.9
3
+ 0,A,B,0.1
4
+ 0,B,A,0.1
5
+ 0,B,B,0.9
@@ -0,0 +1,3 @@
1
+ patch,Population
2
+ A,1000
3
+ B,500
@@ -0,0 +1,3 @@
1
+ patch,S,I,R
2
+ A,999,1,0
3
+ B,500,0,0
File without changes
File without changes