toxsim 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.
toxsim/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Public API for context-aware synthetic poisoned-patient generation."""
2
+
3
+ from .simulator import ModelData, ModelDataError, create_patient, create_patients, load_model_data
4
+
5
+ __all__ = [
6
+ "ModelData",
7
+ "ModelDataError",
8
+ "create_patient",
9
+ "create_patients",
10
+ "load_model_data",
11
+ ]
@@ -0,0 +1,32 @@
1
+ criteria: range
2
+ values:
3
+ - name: "<25"
4
+ representation: "<25"
5
+ min: 0
6
+ max: 24
7
+ score: 0
8
+ - name: "25-35"
9
+ representation: "25-35"
10
+ min: 25
11
+ max: 34
12
+ score: 2
13
+ - name: "35-45"
14
+ representation: "35-45"
15
+ min: 35
16
+ max: 44
17
+ score: 4
18
+ - name: "45-55"
19
+ representation: "45-55"
20
+ min: 45
21
+ max: 54
22
+ score: 6
23
+ - name: "55-65"
24
+ representation: "55-65"
25
+ min: 55
26
+ max: 64
27
+ score: 8
28
+ - name: ">65"
29
+ representation: '$\geq$ 65'
30
+ min: 65
31
+ max: 100
32
+ score: 10
@@ -0,0 +1,8 @@
1
+ criteria: categorical
2
+ values:
3
+ - name: "Yes"
4
+ score: 7
5
+ representation: "Yes"
6
+ - name: "No"
7
+ score: 0
8
+ representation: "No"
@@ -0,0 +1,8 @@
1
+ criteria: categorical
2
+ values:
3
+ - name: "Yes"
4
+ score: 5
5
+ representation: "Yes"
6
+ - name: "No"
7
+ score: 0
8
+ representation: "No"
@@ -0,0 +1,22 @@
1
+ criteria: range
2
+ values:
3
+ - name: $\geq 14$
4
+ representation: ">= 14"
5
+ min: 14
6
+ max: 15
7
+ score: 0
8
+ - name: 9-14
9
+ representation: "9-14"
10
+ min: 9
11
+ max: 13
12
+ score: 3
13
+ - name: 6-9
14
+ representation: "6-9"
15
+ min: 7
16
+ max: 8
17
+ score: 6
18
+ - name: "<6"
19
+ representation: "$\\leq 6$"
20
+ min: 0
21
+ max: 6
22
+ score: 9
@@ -0,0 +1,27 @@
1
+ criteria: range
2
+ values:
3
+ - name: "<75"
4
+ representation: $<75$
5
+ min: 20
6
+ max: 74
7
+ score: 0
8
+ - name: "75-85"
9
+ representation: $75-85$
10
+ min: 75
11
+ max: 84
12
+ score: 1
13
+ - name: "85-95"
14
+ representation: $85-95$
15
+ min: 85
16
+ max: 94
17
+ score: 2
18
+ - name: "95-105"
19
+ representation: $95-105$
20
+ min: 95
21
+ max: 104
22
+ score: 3
23
+ - name: "<105"
24
+ representation: $\geq 105$
25
+ min: 105
26
+ max: 300
27
+ score: 4
@@ -0,0 +1,26 @@
1
+ criteria: categorical
2
+ values:
3
+ - name: Alcohol
4
+ score: -5
5
+ representation: Alcohol
6
+ - name: Analgesic
7
+ score: 1
8
+ representation: Analgesic
9
+ - name: Antidepressant
10
+ score: 0
11
+ representation: Antidepressant
12
+ - name: Street Drugs
13
+ score: 1
14
+ representation: Street Drugs
15
+ - name: Sedatives
16
+ score: -1
17
+ representation: Sedatives
18
+ - name: "CO, As, CN"
19
+ score: -6
20
+ representation: "CO, As, CN"
21
+ - name: Toxins NOS
22
+ score: 2
23
+ representation: Toxins NOS
24
+ - name: Polysubstance
25
+ score: 0
26
+ representation: Polysubstance
@@ -0,0 +1,89 @@
1
+ - name: intoxicant
2
+ repr: Exposure Category
3
+ type: categorical
4
+ allowed_values:
5
+ - Alcohol
6
+ - Analgesic
7
+ - Antidepressant
8
+ - Street Drugs
9
+ - Sedatives
10
+ - "CO, As, CN"
11
+ - Toxins NOS
12
+ - Polysubstance
13
+ - name: age
14
+ type: continuous
15
+ allowed_values:
16
+ min: 12
17
+ max: 100
18
+ - name: sbp
19
+ repr: Systolic Blood Pressure
20
+ type: continuous
21
+ allowed_values:
22
+ min: 100
23
+ max: 140
24
+ - name: hr
25
+ repr: Heart Rate
26
+ type: continuous
27
+ allowed_values:
28
+ min: 60
29
+ max: 200
30
+ - name: gcs
31
+ type: categorical
32
+ allowed_values:
33
+ - 3
34
+ - 4
35
+ - 5
36
+ - 6
37
+ - 7
38
+ - 8
39
+ - 9
40
+ - 10
41
+ - 11
42
+ - 12
43
+ - 13
44
+ - 14
45
+ - 15
46
+ - name: second_diagnose
47
+ repr: Other Reason for ICU Admission
48
+ type: categorical
49
+ dist:
50
+ - value: No
51
+ probability: 0.7
52
+ - value: Yes
53
+ probability: 0.3
54
+ allowed_values:
55
+ - No
56
+ - Yes
57
+ - name: cirrhosis
58
+ repr: History of Cirrhosis
59
+ type: categorical
60
+ allowed_values:
61
+ - No
62
+ - Yes
63
+ dist:
64
+ - value: No
65
+ probability: 0.9
66
+ - value: Yes
67
+ probability: 0.1
68
+ - name: dysrhythmia
69
+ repr: Dysrhythmia
70
+ type: categorical
71
+ dist:
72
+ - value: No
73
+ probability: 0.8
74
+ - value: Yes
75
+ probability: 0.2
76
+ allowed_values:
77
+ - No
78
+ - Yes
79
+ - name: respiratory
80
+ repr: Respiratory Failure
81
+ dist:
82
+ - value: No
83
+ probability: 0.6
84
+ - value: Yes
85
+ probability: 0.4
86
+ type: categorical
87
+ allowed_values:
88
+ - No
89
+ - Yes
@@ -0,0 +1,8 @@
1
+ criteria: categorical
2
+ values:
3
+ - name: "Yes"
4
+ score: 8
5
+ representation: "Yes"
6
+ - name: "No"
7
+ score: 0
8
+ representation: "No"
@@ -0,0 +1,32 @@
1
+ criteria: range
2
+ values:
3
+ - name: "<=140"
4
+ score: -3
5
+ representation: "$\\geq$ 140"
6
+ min: 140
7
+ max: 999
8
+ - name: 130-140
9
+ score: -1
10
+ representation: "130-140"
11
+ min: 130
12
+ max: 139
13
+ - name: 120-130
14
+ score: 0
15
+ representation: "120-130"
16
+ min: 120
17
+ max: 129
18
+ - name: 110-120
19
+ score: 1
20
+ representation: "110-120"
21
+ min: 110
22
+ max: 119
23
+ - name: 100-110
24
+ score: 2
25
+ representation: "100-110"
26
+ min: 100
27
+ max: 109
28
+ - name: <100"
29
+ score: 4
30
+ representation: "$<100$"
31
+ min: 0
32
+ max: 99
@@ -0,0 +1,8 @@
1
+ criteria: categorical
2
+ values:
3
+ - name: "Yes"
4
+ score: 7
5
+ representation: "Yes"
6
+ - name: "No"
7
+ score: 0
8
+ representation: "No"
@@ -0,0 +1,7 @@
1
+ - "@context": https://schema.org
2
+ "@id": https://w3.org/yaml-ld/
3
+ "@type": WebContent
4
+ name: YAML-LD
5
+ author:
6
+ "@id": https://www.w3.org/community/json-ld
7
+ name: JSON-LD Community Group
toxsim/generate.py ADDED
@@ -0,0 +1,103 @@
1
+ """Command-line generation of JSON and JSONL synthetic-patient datasets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Sequence
9
+
10
+ from .simulator import ModelDataError, create_patients, load_model_data
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ """Build the CLI argument parser."""
15
+ parser = argparse.ArgumentParser(description=__doc__)
16
+ parser.add_argument("--count", type=int, default=1, help="Number of patients to generate.")
17
+ destination_group = parser.add_mutually_exclusive_group()
18
+ destination_group.add_argument(
19
+ "--destination",
20
+ type=Path,
21
+ help="Directory to receive patients.json and/or patients.jsonl.",
22
+ )
23
+ destination_group.add_argument(
24
+ "--output",
25
+ type=Path,
26
+ help="Output file path ending in .json or .jsonl; its suffix selects the format.",
27
+ )
28
+ parser.add_argument(
29
+ "--format",
30
+ choices=("json", "jsonl", "both"),
31
+ help="Output format for --destination (default: both).",
32
+ )
33
+ parser.add_argument("--seed", type=int, help="Seed for deterministic output.")
34
+ parser.add_argument(
35
+ "--model-data-dir",
36
+ type=Path,
37
+ help="Directory containing predictive_variables.yml and score YAML files.",
38
+ )
39
+ return parser
40
+
41
+
42
+ def main(argv: Sequence[str] | None = None) -> int:
43
+ """Generate patient files and return a shell-compatible exit code."""
44
+ args = build_parser().parse_args(argv)
45
+ if args.count < 0:
46
+ raise SystemExit("--count must be non-negative.")
47
+ try:
48
+ model_data = load_model_data(args.model_data_dir)
49
+ except ModelDataError as error:
50
+ raise SystemExit(f"toxsim-generate: {error}") from error
51
+
52
+ patients = create_patients(model_data, args.count, seed=args.seed)
53
+ if args.output is not None:
54
+ output_format = _format_from_output_path(args.output)
55
+ if args.format is not None and args.format != output_format:
56
+ raise SystemExit(
57
+ f"--format {args.format!r} conflicts with --output suffix {args.output.suffix!r}."
58
+ )
59
+ args.output.parent.mkdir(parents=True, exist_ok=True)
60
+ _write_patients(args.output, patients, output_format)
61
+ outputs = [args.output]
62
+ else:
63
+ destination = args.destination or Path(".")
64
+ output_format = args.format or "both"
65
+ destination.mkdir(parents=True, exist_ok=True)
66
+ outputs = _write_destination(destination, patients, output_format)
67
+ print("Generated", args.count, "patients:", ", ".join(str(path) for path in outputs))
68
+ return 0
69
+
70
+
71
+ def _format_from_output_path(path: Path) -> str:
72
+ suffix = path.suffix.lower()
73
+ if suffix == ".json":
74
+ return "json"
75
+ if suffix == ".jsonl":
76
+ return "jsonl"
77
+ raise SystemExit("--output must end in .json or .jsonl.")
78
+
79
+
80
+ def _write_destination(destination: Path, patients: list[dict], output_format: str) -> list[Path]:
81
+ outputs: list[Path] = []
82
+ if output_format in {"json", "both"}:
83
+ path = destination / "patients.json"
84
+ path.write_text(json.dumps(patients, indent=2) + "\n", encoding="utf-8")
85
+ outputs.append(path)
86
+ if output_format in {"jsonl", "both"}:
87
+ path = destination / "patients.jsonl"
88
+ _write_patients(path, patients, "jsonl")
89
+ outputs.append(path)
90
+ return outputs
91
+
92
+
93
+ def _write_patients(path: Path, patients: list[dict], output_format: str) -> None:
94
+ if output_format == "json":
95
+ path.write_text(json.dumps(patients, indent=2) + "\n", encoding="utf-8")
96
+ return
97
+ with path.open("w", encoding="utf-8") as handle:
98
+ for patient in patients:
99
+ handle.write(json.dumps(patient) + "\n")
100
+
101
+
102
+ if __name__ == "__main__":
103
+ raise SystemExit(main())
toxsim/simulator.py ADDED
@@ -0,0 +1,553 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from importlib import resources
5
+ from pathlib import Path
6
+ from typing import Any, Mapping, Sequence
7
+ from uuid import UUID
8
+
9
+ import numpy as np
10
+ import yaml
11
+ from scipy.stats import truncnorm
12
+
13
+
14
+ class ModelDataError(ValueError):
15
+ """Raised when synthetic-patient model configuration cannot be loaded."""
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ModelData:
20
+ """Predictive variables and their corresponding scoring tables."""
21
+
22
+ predictive_variables: tuple[dict[str, Any], ...]
23
+ scores: Mapping[str, dict[str, Any]]
24
+
25
+
26
+ @dataclass
27
+ class _Context:
28
+ intoxicant: str | None = None
29
+ severity: int | None = None
30
+ gcs: int | None = None
31
+ respiratory: str | None = None
32
+ dysrhythmia: str | None = None
33
+ hr_true: int | None = None
34
+ sbp_true: int | None = None
35
+
36
+
37
+ def load_model_data(model_data_dir: str | Path | None = None) -> ModelData:
38
+ """Load model YAML data from a directory or packaged resources."""
39
+ if model_data_dir is None:
40
+ root = resources.files("toxsim").joinpath("data", "model")
41
+ source = "packaged toxsim model data"
42
+ else:
43
+ root = Path(model_data_dir).expanduser()
44
+ source = f"model-data directory {root}"
45
+
46
+ predictive_path = root.joinpath("predictive_variables.yml")
47
+ if not predictive_path.is_file():
48
+ raise ModelDataError(
49
+ f"Could not find predictive_variables.yml in {source}. "
50
+ "Provide a directory containing predictive_variables.yml and one "
51
+ "<variable-name>_score.yml file for every predictive variable, "
52
+ "for example load_model_data('/path/to/model-data') or "
53
+ "toxsim-generate --model-data-dir /path/to/model-data."
54
+ )
55
+
56
+ predictive_variables = _load_yaml_list(predictive_path)
57
+ scores: dict[str, dict[str, Any]] = {}
58
+ for variable in predictive_variables:
59
+ name = variable.get("name")
60
+ if not isinstance(name, str) or not name:
61
+ raise ModelDataError(
62
+ f"{predictive_path} contains a variable without a non-empty string name."
63
+ )
64
+ score_path = root.joinpath(f"{name}_score.yml")
65
+ if not score_path.is_file():
66
+ raise ModelDataError(
67
+ f"Missing score file {name}_score.yml in {source}. "
68
+ "Supply a score YAML file for every predictive variable."
69
+ )
70
+ score = _load_yaml_mapping(score_path)
71
+ if "criteria" not in score or "values" not in score:
72
+ raise ModelDataError(f"{score_path} must define 'criteria' and 'values'.")
73
+ scores[name] = score
74
+
75
+ return ModelData(tuple(predictive_variables), scores)
76
+
77
+
78
+ def create_patient(
79
+ predictive_variables: ModelData | Sequence[Mapping[str, Any]] | None = None,
80
+ scores: Mapping[str, Mapping[str, Any]] | None = None,
81
+ *,
82
+ model_data: ModelData | None = None,
83
+ model_data_dir: str | Path | None = None,
84
+ seed: int | None = None,
85
+ rng: np.random.Generator | None = None,
86
+ ) -> dict[str, Any]:
87
+ """Create one context-aware synthetic patient.
88
+
89
+ With no model arguments, bundled model data is loaded lazily. To provide
90
+ custom data, pass either *model_data*, *model_data_dir*, or both
91
+ *predictive_variables* and *scores*. Passing a :class:`ModelData` as the
92
+ first positional argument is retained for compatibility.
93
+
94
+ Supply either *seed* or *rng*, not both. A seed makes all generated fields,
95
+ including the patient ID, deterministic.
96
+ """
97
+ resolved_model_data = _resolve_model_data(
98
+ predictive_variables=predictive_variables,
99
+ scores=scores,
100
+ model_data=model_data,
101
+ model_data_dir=model_data_dir,
102
+ )
103
+ generator = _resolve_rng(seed, rng)
104
+ variables = _variables_by_name(resolved_model_data)
105
+ order = _generation_order(variables)
106
+
107
+ for _ in range(10):
108
+ context = _Context()
109
+ presentation = [
110
+ _create_feature(variables[name], context, resolved_model_data.scores, generator)
111
+ for name in order
112
+ ]
113
+ if _plausible(context, generator):
114
+ return {
115
+ "presentation": presentation,
116
+ "patient_id": str(_random_uuid(generator)),
117
+ "extras": {
118
+ "severity": context.severity,
119
+ "hr_true": context.hr_true,
120
+ "sbp_true": context.sbp_true,
121
+ },
122
+ "risk": int(sum(feature["score"] for feature in presentation)),
123
+ }
124
+
125
+ # The tenth sample remains valid even if it failed a probabilistic
126
+ # plausibility screen; rejecting it would needlessly hide a valid patient.
127
+ return {
128
+ "presentation": presentation,
129
+ "patient_id": str(_random_uuid(generator)),
130
+ "extras": {
131
+ "severity": context.severity,
132
+ "hr_true": context.hr_true,
133
+ "sbp_true": context.sbp_true,
134
+ },
135
+ "risk": int(sum(feature["score"] for feature in presentation)),
136
+ }
137
+
138
+
139
+ def create_patients(
140
+ model_data: ModelData | None = None,
141
+ count: int = 1,
142
+ *,
143
+ predictive_variables: Sequence[Mapping[str, Any]] | None = None,
144
+ scores: Mapping[str, Mapping[str, Any]] | None = None,
145
+ model_data_dir: str | Path | None = None,
146
+ seed: int | None = None,
147
+ rng: np.random.Generator | None = None,
148
+ ) -> list[dict[str, Any]]:
149
+ """Create *count* patients using bundled or explicitly supplied model data.
150
+
151
+ The model source arguments have the same behavior as :func:`create_patient`.
152
+ Supplying *model_data* positionally remains compatible with earlier releases.
153
+ """
154
+ if count < 0:
155
+ raise ValueError("count must be non-negative.")
156
+ resolved_model_data = _resolve_model_data(
157
+ predictive_variables=predictive_variables,
158
+ scores=scores,
159
+ model_data=model_data,
160
+ model_data_dir=model_data_dir,
161
+ )
162
+ generator = _resolve_rng(seed, rng)
163
+ return [create_patient(resolved_model_data, rng=generator) for _ in range(count)]
164
+
165
+
166
+ def score_from_value(value: Any, score_table: Mapping[str, Any]) -> int:
167
+ """Return the score associated with a categorical or ranged value."""
168
+ criteria = score_table["criteria"]
169
+ values = score_table["values"]
170
+ if criteria == "categorical":
171
+ normalized = _normalize_bool(value)
172
+ for item in values:
173
+ if normalized == item["name"]:
174
+ return int(item["score"])
175
+ raise ValueError(f"Value {value!r} is not in the categorical score table.")
176
+ if criteria == "range":
177
+ for item in values:
178
+ if float(item["min"]) <= float(value) <= float(item["max"]):
179
+ return int(item["score"])
180
+ raise ValueError(f"Value {value!r} is not in the range score table.")
181
+ raise ValueError(f"Unknown score criteria: {criteria!r}.")
182
+
183
+
184
+ def _load_yaml_list(path: Any) -> list[dict[str, Any]]:
185
+ with path.open("r", encoding="utf-8") as handle:
186
+ data = yaml.safe_load(handle)
187
+ if not isinstance(data, list) or not all(isinstance(item, dict) for item in data):
188
+ raise ModelDataError(f"{path} must contain a YAML list of variable mappings.")
189
+ return data
190
+
191
+
192
+ def _load_yaml_mapping(path: Any) -> dict[str, Any]:
193
+ with path.open("r", encoding="utf-8") as handle:
194
+ data = yaml.safe_load(handle)
195
+ if not isinstance(data, dict):
196
+ raise ModelDataError(f"{path} must contain a YAML mapping.")
197
+ return data
198
+
199
+
200
+ def _resolve_rng(
201
+ seed: int | None, rng: np.random.Generator | None
202
+ ) -> np.random.Generator:
203
+ if seed is not None and rng is not None:
204
+ raise ValueError("Pass either seed or rng, not both.")
205
+ return rng if rng is not None else np.random.default_rng(seed)
206
+
207
+
208
+ def _resolve_model_data(
209
+ *,
210
+ predictive_variables: ModelData | Sequence[Mapping[str, Any]] | None,
211
+ scores: Mapping[str, Mapping[str, Any]] | None,
212
+ model_data: ModelData | None,
213
+ model_data_dir: str | Path | None,
214
+ ) -> ModelData:
215
+ has_explicit_tables = predictive_variables is not None or scores is not None
216
+ if model_data is not None:
217
+ if has_explicit_tables or model_data_dir is not None:
218
+ raise ModelDataError(
219
+ "Pass only one model source: model_data, model_data_dir, or "
220
+ "predictive_variables with scores."
221
+ )
222
+ return model_data
223
+ if isinstance(predictive_variables, ModelData):
224
+ if scores is not None or model_data_dir is not None:
225
+ raise ModelDataError(
226
+ "A positional ModelData cannot be combined with scores or model_data_dir."
227
+ )
228
+ return predictive_variables
229
+ if has_explicit_tables:
230
+ if model_data_dir is not None:
231
+ raise ModelDataError(
232
+ "Pass either model_data_dir or predictive_variables with scores, not both."
233
+ )
234
+ if predictive_variables is None or scores is None:
235
+ raise ModelDataError(
236
+ "Explicit model data requires both predictive_variables and scores."
237
+ )
238
+ return ModelData(
239
+ tuple(dict(variable) for variable in predictive_variables),
240
+ {name: dict(score) for name, score in scores.items()},
241
+ )
242
+ return load_model_data(model_data_dir)
243
+
244
+
245
+ def _variables_by_name(model_data: ModelData) -> dict[str, dict[str, Any]]:
246
+ variables: dict[str, dict[str, Any]] = {}
247
+ for variable in model_data.predictive_variables:
248
+ name = variable.get("name")
249
+ if not isinstance(name, str) or not name:
250
+ raise ModelDataError(
251
+ "Every predictive variable must have a non-empty string name."
252
+ )
253
+ if name in variables:
254
+ raise ModelDataError(f"Duplicate predictive variable name: {name}.")
255
+ if name not in model_data.scores:
256
+ raise ModelDataError(
257
+ f"No score table was supplied for predictive variable {name}."
258
+ )
259
+ variables[name] = variable
260
+ return variables
261
+
262
+
263
+ def _generation_order(variables: Mapping[str, dict[str, Any]]) -> list[str]:
264
+ contextual = [
265
+ "intoxicant",
266
+ "age",
267
+ "cirrhosis",
268
+ "second_diagnose",
269
+ "gcs",
270
+ "respiratory",
271
+ "dysrhythmia",
272
+ "hr",
273
+ "sbp",
274
+ ]
275
+ return [name for name in contextual if name in variables] + [
276
+ name for name in variables if name not in contextual
277
+ ]
278
+
279
+
280
+ def _create_feature(
281
+ variable: Mapping[str, Any],
282
+ context: _Context,
283
+ scores: Mapping[str, Mapping[str, Any]],
284
+ rng: np.random.Generator,
285
+ ) -> dict[str, Any]:
286
+ name = variable["name"]
287
+ value, true_value = _simulate_value_with_context(variable, context, scores, rng)
288
+ feature: dict[str, Any] = {
289
+ "name": name,
290
+ "value": value,
291
+ "score": score_from_value(value, scores[name]),
292
+ }
293
+ if variable["type"] == "categorical":
294
+ feature["in_original_range"] = True
295
+ else:
296
+ feature["true_value"] = true_value
297
+ feature["in_original_range"] = _is_value_in_range(true_value, variable)
298
+ feature["model_range"] = {
299
+ "min": float(variable["allowed_values"]["min"]),
300
+ "max": float(variable["allowed_values"]["max"]),
301
+ }
302
+ return feature
303
+
304
+
305
+ def _simulate_value_with_context(
306
+ variable: Mapping[str, Any],
307
+ context: _Context,
308
+ scores: Mapping[str, Mapping[str, Any]],
309
+ rng: np.random.Generator,
310
+ ) -> tuple[Any, Any]:
311
+ name = variable["name"]
312
+ if name == "intoxicant":
313
+ value = _simulate_categorical(variable, rng)
314
+ context.intoxicant = str(value)
315
+ return value, None
316
+ if name == "age":
317
+ bounds = variable["allowed_values"]
318
+ value = int(
319
+ round(
320
+ int(bounds["min"])
321
+ + rng.beta(2.2, 3.0) * (int(bounds["max"]) - int(bounds["min"]))
322
+ )
323
+ )
324
+ return value, value
325
+
326
+ if context.severity is None and context.intoxicant is not None:
327
+ context.severity = _sample_severity(context.intoxicant, rng)
328
+
329
+ intoxicant = context.intoxicant or "Polysubstance"
330
+ severity = context.severity if context.severity is not None else 1
331
+ if name == "gcs":
332
+ value = _sample_gcs_from_bins(scores["gcs"], intoxicant, severity, rng)
333
+ context.gcs = value
334
+ return value, value
335
+ if name == "respiratory":
336
+ value = _sample_respiratory(context.gcs or 15, intoxicant, severity, rng)
337
+ context.respiratory = value
338
+ return value, None
339
+ if name == "dysrhythmia":
340
+ value = _sample_dysrhythmia(intoxicant, severity, rng)
341
+ context.dysrhythmia = value
342
+ return value, None
343
+ if name == "hr":
344
+ true_value = _sample_hr_true(
345
+ intoxicant,
346
+ severity,
347
+ context.dysrhythmia or "No",
348
+ context.respiratory or "No",
349
+ rng,
350
+ )
351
+ value = int(true_value)
352
+ context.hr_true = value
353
+ return value, true_value
354
+ if name == "sbp":
355
+ true_value = _sample_sbp_true(intoxicant, severity, context.hr_true or 90, rng)
356
+ value = int(round(true_value / 2.0) * 2)
357
+ context.sbp_true = value
358
+ return value, true_value
359
+
360
+ if variable["type"] == "categorical":
361
+ return _simulate_categorical(variable, rng), None
362
+ if variable["type"] == "continuous":
363
+ value = _simulate_continuous(variable, rng)
364
+ return value, value
365
+ raise ValueError(f"Unknown variable type: {variable.get('type')!r}.")
366
+
367
+
368
+ def _simulate_categorical(variable: Mapping[str, Any], rng: np.random.Generator) -> Any:
369
+ distribution = variable.get("dist")
370
+ if distribution:
371
+ options = [item["value"] for item in distribution]
372
+ probabilities = [float(item["probability"]) for item in distribution]
373
+ return _normalize_bool(_weighted_choice(options, probabilities, rng))
374
+ return _normalize_bool(rng.choice(variable["allowed_values"]).item())
375
+
376
+
377
+ def _simulate_continuous(variable: Mapping[str, Any], rng: np.random.Generator) -> int:
378
+ bounds = variable["allowed_values"]
379
+ low, high = float(bounds["min"]), float(bounds["max"])
380
+ return _truncnorm_int(low, high, (low + high) / 2.0, (high - low) / 6.0, rng)
381
+
382
+
383
+ def _sample_severity(intoxicant: str, rng: np.random.Generator) -> int:
384
+ probabilities = {
385
+ "Alcohol": [0.60, 0.25, 0.12, 0.03],
386
+ "Analgesic": [0.45, 0.28, 0.18, 0.09],
387
+ "Antidepressant": [0.45, 0.30, 0.18, 0.07],
388
+ "Street Drugs": [0.45, 0.30, 0.17, 0.08],
389
+ "Sedatives": [0.40, 0.28, 0.22, 0.10],
390
+ "CO, As, CN": [0.35, 0.30, 0.22, 0.13],
391
+ "Toxins NOS": [0.45, 0.30, 0.18, 0.07],
392
+ "Polysubstance": [0.30, 0.30, 0.25, 0.15],
393
+ }.get(intoxicant, [0.45, 0.30, 0.18, 0.07])
394
+ return int(_weighted_choice([0, 1, 2, 3], probabilities, rng))
395
+
396
+
397
+ def _sample_gcs_from_bins(
398
+ score_table: Mapping[str, Any],
399
+ intoxicant: str,
400
+ severity: int,
401
+ rng: np.random.Generator,
402
+ ) -> int:
403
+ bins = sorted(score_table["values"], key=lambda item: item["max"], reverse=True)
404
+ probabilities = np.array(
405
+ {
406
+ 0: [0.82, 0.14, 0.03, 0.01],
407
+ 1: [0.55, 0.28, 0.12, 0.05],
408
+ 2: [0.28, 0.35, 0.22, 0.15],
409
+ 3: [0.06, 0.22, 0.34, 0.38],
410
+ }[severity],
411
+ dtype=float,
412
+ )
413
+ if intoxicant in {"Sedatives", "Analgesic", "Alcohol", "Polysubstance"}:
414
+ probabilities *= [0.85, 1.10, 1.15, 1.25]
415
+ elif intoxicant == "Street Drugs":
416
+ probabilities *= [1.10, 1.05, 0.90, 0.80]
417
+ selected = _weighted_choice(bins, probabilities, rng)
418
+ return int(rng.integers(int(selected["min"]), int(selected["max"]) + 1))
419
+
420
+
421
+ def _sample_respiratory(
422
+ gcs: int, intoxicant: str, severity: int, rng: np.random.Generator
423
+ ) -> str:
424
+ probability = 0.05 + 0.07 * severity
425
+ if gcs <= 8:
426
+ probability += 0.45
427
+ if gcs <= 6:
428
+ probability += 0.20
429
+ if intoxicant in {"Sedatives", "Analgesic", "Alcohol", "Polysubstance"}:
430
+ probability += 0.12
431
+ if intoxicant == "CO, As, CN":
432
+ probability += 0.05
433
+ return "Yes" if rng.random() < min(probability, 0.95) else "No"
434
+
435
+
436
+ def _sample_dysrhythmia(
437
+ intoxicant: str, severity: int, rng: np.random.Generator
438
+ ) -> str:
439
+ probability = 0.06 + 0.06 * severity
440
+ if intoxicant in {"Antidepressant", "Street Drugs", "CO, As, CN"}:
441
+ probability += 0.10
442
+ if intoxicant == "Polysubstance":
443
+ probability += 0.07
444
+ return "Yes" if rng.random() < min(probability, 0.60) else "No"
445
+
446
+
447
+ def _sample_hr_true(
448
+ intoxicant: str,
449
+ severity: int,
450
+ dysrhythmia: str,
451
+ respiratory: str,
452
+ rng: np.random.Generator,
453
+ ) -> int:
454
+ abnormal_mean = {
455
+ "Street Drugs": 145,
456
+ "Antidepressant": 120,
457
+ "Sedatives": 62,
458
+ "Analgesic": 62,
459
+ "Alcohol": 62,
460
+ }.get(intoxicant, 110)
461
+ probability = 0.10 + 0.15 * severity
462
+ if dysrhythmia == "Yes":
463
+ probability += 0.20
464
+ if respiratory == "Yes":
465
+ probability += 0.10
466
+ mean, deviation = (
467
+ (abnormal_mean, 28) if rng.random() < min(probability, 0.90) else (85, 12)
468
+ )
469
+ return int(round(np.clip(rng.normal(mean, deviation), 40, 250)))
470
+
471
+
472
+ def _sample_sbp_true(
473
+ intoxicant: str, severity: int, hr: int, rng: np.random.Generator
474
+ ) -> int:
475
+ mean = 125.0
476
+ if intoxicant == "Street Drugs":
477
+ mean += 18
478
+ if intoxicant in {"Sedatives", "Analgesic", "Alcohol"}:
479
+ mean -= 10
480
+ if intoxicant == "Polysubstance":
481
+ mean -= 6
482
+ mean -= 9 * severity
483
+ if severity >= 2 and hr >= 140:
484
+ mean -= 6
485
+ return int(round(np.clip(rng.normal(mean, 14 + 6 * severity), 60, 220)))
486
+
487
+
488
+ def _plausible(context: _Context, rng: np.random.Generator) -> bool:
489
+ if (
490
+ context.gcs is None
491
+ or context.respiratory is None
492
+ or context.hr_true is None
493
+ or context.sbp_true is None
494
+ ):
495
+ return True
496
+ if context.gcs <= 6 and context.respiratory == "No":
497
+ return bool(rng.random() < 0.25)
498
+ if context.respiratory == "Yes" and context.gcs >= 14:
499
+ return bool(rng.random() < 0.20)
500
+ if context.sbp_true <= 80 and context.hr_true <= 55:
501
+ return bool(rng.random() < 0.30)
502
+ return True
503
+
504
+
505
+ def _truncnorm_int(
506
+ low: float, high: float, mean: float, deviation: float, rng: np.random.Generator
507
+ ) -> int:
508
+ deviation = max(deviation, 1e-6)
509
+ value = truncnorm.rvs(
510
+ (low - mean) / deviation,
511
+ (high - mean) / deviation,
512
+ loc=mean,
513
+ scale=deviation,
514
+ random_state=rng,
515
+ )
516
+ return int(round(value))
517
+
518
+
519
+ def _weighted_choice(
520
+ options: Sequence[Any], probabilities: Sequence[float], rng: np.random.Generator
521
+ ) -> Any:
522
+ weights = np.asarray(probabilities, dtype=float)
523
+ if (
524
+ len(options) != len(weights)
525
+ or not len(options)
526
+ or np.any(weights < 0)
527
+ or weights.sum() <= 0
528
+ ):
529
+ raise ValueError(
530
+ "Options and non-negative probabilities with a positive sum are required."
531
+ )
532
+ index = rng.choice(len(options), p=weights / weights.sum())
533
+ return options[int(index)]
534
+
535
+
536
+ def _is_value_in_range(value: Any, variable: Mapping[str, Any]) -> bool:
537
+ if value is None:
538
+ return True
539
+ bounds = variable["allowed_values"]
540
+ return float(bounds["min"]) <= float(value) <= float(bounds["max"])
541
+
542
+
543
+ def _normalize_bool(value: Any) -> Any:
544
+ if isinstance(value, (bool, np.bool_)):
545
+ return "Yes" if value else "No"
546
+ return value
547
+
548
+
549
+ def _random_uuid(rng: np.random.Generator) -> UUID:
550
+ raw = bytearray(rng.bytes(16))
551
+ raw[6] = (raw[6] & 0x0F) | 0x40
552
+ raw[8] = (raw[8] & 0x3F) | 0x80
553
+ return UUID(bytes=bytes(raw))
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.5
2
+ Name: toxsim
3
+ Version: 0.1.0
4
+ Summary: Context-aware synthetic poisoned-patient simulator
5
+ Author: toxsim contributors
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: numpy>=1.23
9
+ Requires-Dist: pyyaml>=6.0
10
+ Requires-Dist: scipy>=1.9
11
+ Provides-Extra: cli
12
+ Requires-Dist: rich>=13.0; extra == 'cli'
13
+ Requires-Dist: tqdm>=4.64; extra == 'cli'
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest>=7.0; extra == 'test'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # toxsim
19
+
20
+ `toxsim` generates context-aware synthetic poisoned-patient presentations. It
21
+ ports the latent-severity behavior from INTOXICATE: intoxication class affects
22
+ severity, which in turn influences GCS, respiratory failure, dysrhythmia, heart
23
+ rate, and systolic blood pressure.
24
+
25
+ ## Installation
26
+
27
+ ```shell
28
+ python -m pip install toxsim
29
+ # Include optional progress and rich CLI output:
30
+ python -m pip install "toxsim[cli]"
31
+ ```
32
+
33
+ ## Model data
34
+
35
+ The package includes the canonical clinical model configuration as package
36
+ data, including `predictive_variables.yml`, each variable's score file, and
37
+ `model.schema.yml`. `load_model_data()` uses these resources by default and
38
+ never depends on the current working directory.
39
+
40
+ ```text
41
+ toxsim/data/
42
+ model.schema.yml
43
+ model/
44
+ predictive_variables.yml
45
+ <variable-name>_score.yml
46
+ ```
47
+
48
+ Pass a directory to `load_model_data()` or `toxsim-generate
49
+ --model-data-dir` to override the bundled model with a compatible
50
+ configuration. An override directory contains `predictive_variables.yml` and
51
+ one `<variable-name>_score.yml` file for every predictive variable.
52
+
53
+ ## Python API
54
+
55
+ ```python
56
+ from toxsim import create_patient, create_patients
57
+
58
+ patient = create_patient(seed=2026)
59
+ patients = create_patients(count=100, seed=2026)
60
+ ```
61
+
62
+ Both functions lazily load the bundled model data by default. A seed produces
63
+ the same patient data across runs, including the generated patient ID. Each
64
+ presentation entry has `name`, `value`, and `score`; continuous variables also
65
+ include the model range and whether its underlying value was within that range.
66
+ The patient-level `risk` equals the sum of presentation scores.
67
+
68
+ To use a compatible external model directory, pass
69
+ `model_data_dir="/path/to/model-data"`. Advanced callers can use
70
+ `create_patient(predictive_variables=variables, scores=score_tables)` or load
71
+ once with `model = load_model_data(...)` and pass that `ModelData` object as
72
+ the first argument to `create_patient(model)` or `create_patients(model,
73
+ count=100)`.
74
+
75
+ ## CLI
76
+
77
+ ```shell
78
+ toxsim-generate \
79
+ --count 100 \
80
+ --output patients.jsonl \
81
+ --seed 2026
82
+ ```
83
+
84
+ `--output` writes one file and infers the format from its `.json` or `.jsonl`
85
+ suffix. To generate named default files in a directory instead, use
86
+ `--destination ./generated --format both`; `--format` accepts `json`, `jsonl`,
87
+ or `both` (the default). Add `--model-data-dir /path/to/model-data` to use a
88
+ compatible override.
@@ -0,0 +1,18 @@
1
+ toxsim/__init__.py,sha256=mXu6S4WfBYLxT74hF31HxOoEwR-nxsXyshc-SOyWpm4,296
2
+ toxsim/generate.py,sha256=Mt6OpqLqlxc5UW-zPrWOsrF0Xies3xdMt2b-KfhQGWI,3789
3
+ toxsim/simulator.py,sha256=EI6vsqTwn3DyrXTgEGf8ZKnPgOn9nGKjdwKTeazVWzg,19557
4
+ toxsim/data/model.schema.yml,sha256=jwvHuz9KIk8GMYtv2CJdLN0reo_WM5Xya4bmqW-1pIQ,192
5
+ toxsim/data/model/age_score.yml,sha256=sZQYyVz7K89EUIMGSkBAXH8Yor2718f2ggiHlaiKT9Y,521
6
+ toxsim/data/model/cirrhosis_score.yml,sha256=UOQ6aTeOCPNuvSeojDRmHE2vsyKQyZ9Y3knvMCyu9Pg,138
7
+ toxsim/data/model/dysrhythmia_score.yml,sha256=FUCK4Muof8AsyLs-4iZGqVT6N80XtES-lkd5tWYFax4,138
8
+ toxsim/data/model/gcs_score.yml,sha256=x87IvJs5jWGxx4rHxhGA0TSed0M_pb5Wygd8YtzFV00,344
9
+ toxsim/data/model/hr_score.yml,sha256=Afhrn8FC6runSoc5q0xxaGwlc7Ah4Ly4bRRbwoc6dhE,442
10
+ toxsim/data/model/intoxicant_score.yml,sha256=c42v1CkOiEm0kylCQSlte_O9gepTnohBX2bteSY0Fs8,565
11
+ toxsim/data/model/predictive_variables.yml,sha256=s7ybFOLSS1uJsNbVkTWeMUJsn6K2iBHJFdHQHWH2lps,1434
12
+ toxsim/data/model/respiratory_score.yml,sha256=xBuOzlj8hUiDHHiyR-nBC_M3d8rz0e-0omq2GuKKTnw,138
13
+ toxsim/data/model/sbp_score.yml,sha256=LMbS_nS9IErLozLvJcxBx1-FHvuSikL8uMX9uKRAOVg,546
14
+ toxsim/data/model/second_diagnose_score.yml,sha256=UOQ6aTeOCPNuvSeojDRmHE2vsyKQyZ9Y3knvMCyu9Pg,138
15
+ toxsim-0.1.0.dist-info/METADATA,sha256=si4dky_PONyYKkDn6p16REbAW36SiCihhO9IY6ssFWI,2889
16
+ toxsim-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
17
+ toxsim-0.1.0.dist-info/entry_points.txt,sha256=KvCTTi4nZbxwn8O62XN461aFER-uPMp6zIvKxdfyhNI,57
18
+ toxsim-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ toxsim-generate = toxsim.generate:main