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 +11 -0
- toxsim/data/model/age_score.yml +32 -0
- toxsim/data/model/cirrhosis_score.yml +8 -0
- toxsim/data/model/dysrhythmia_score.yml +8 -0
- toxsim/data/model/gcs_score.yml +22 -0
- toxsim/data/model/hr_score.yml +27 -0
- toxsim/data/model/intoxicant_score.yml +26 -0
- toxsim/data/model/predictive_variables.yml +89 -0
- toxsim/data/model/respiratory_score.yml +8 -0
- toxsim/data/model/sbp_score.yml +32 -0
- toxsim/data/model/second_diagnose_score.yml +8 -0
- toxsim/data/model.schema.yml +7 -0
- toxsim/generate.py +103 -0
- toxsim/simulator.py +553 -0
- toxsim-0.1.0.dist-info/METADATA +88 -0
- toxsim-0.1.0.dist-info/RECORD +18 -0
- toxsim-0.1.0.dist-info/WHEEL +4 -0
- toxsim-0.1.0.dist-info/entry_points.txt +2 -0
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,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,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
|
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,,
|