simtrial 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ on:
2
+ push:
3
+ branches:
4
+ - main
5
+ pull_request:
6
+ branches:
7
+ - main
8
+
9
+ name: CI Tests
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
17
+ fail-fast: false
18
+
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - name: Set up Python ${{ matrix.python-version }}
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: ${{ matrix.python-version }}
25
+ - name: Install dependencies
26
+ run: |
27
+ # we are using the -e flag, so that code cov finds the source.
28
+ # this is not ideal, since installing an editable can technically
29
+ # differ from a normal install in surprising ways.
30
+ pip install -e '.[all]'
31
+ - name: Test with pytest
32
+ run: |
33
+ pip install pytest pytest-cov
34
+ pytest --cov=simtrial --cov-report=xml
35
+
36
+ # - name: Upload coverage reports to Codecov
37
+ # uses: codecov/codecov-action@v4
38
+ # with:
39
+ # name: "py${{ matrix.python-version }}"
40
+ # token: ${{ secrets.CODECOV_TOKEN }}
41
+
42
+ test-windows:
43
+ runs-on: windows-latest
44
+ steps:
45
+ - uses: actions/checkout@v4
46
+ - name: Set up Python
47
+ uses: actions/setup-python@v5
48
+ with:
49
+ python-version: "3.13"
50
+ - name: Install dependencies
51
+ run: |
52
+ pip install -e '.[all]'
53
+ - name: Test with pytest
54
+ run: |
55
+ pip install pytest pytest-cov
56
+ pytest --cov=simtrial --cov-report=xml
@@ -0,0 +1,34 @@
1
+ name: Mypy check
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+ branches:
9
+ - '**'
10
+
11
+ jobs:
12
+ mypy:
13
+ runs-on: ubuntu-latest
14
+
15
+ steps:
16
+ - name: Checkout repository
17
+ uses: actions/checkout@v4
18
+
19
+ - name: Set up Python
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: 3.13.7
23
+
24
+ - name: Install uv
25
+ run: |
26
+ pip install uv
27
+
28
+ - name: Install dependencies
29
+ run: |
30
+ uv sync --dev
31
+
32
+ - name: Run Type Checks
33
+ run: |
34
+ uv run mypy .
@@ -0,0 +1,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Jupyter notebooks
13
+ .ipynb_checkpoints/
@@ -0,0 +1 @@
1
+ 3.13.7
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## simtrial-python 0.0.1
4
+
5
+ ### New features
6
+
7
+ - Added a piecewise exponential sampler that mirrors the R implementation using
8
+ inverse-CDF sampling with reproducibility hooks and type hints (#1).
9
+
10
+ ### Testing
11
+
12
+ - Added pytest tests with 100% code coverage for the sampler, including
13
+ validation, broadcasting, and RNG behaviors (#1).
14
+ - Added deterministic R-generated fixtures (`tests/fixtures/`) to
15
+ cross-check Python draws (#1).
simtrial-0.0.1/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2025, simtrial-python authors
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: simtrial
3
+ Version: 0.0.1
4
+ Summary: Clinical trial simulation
5
+ Project-URL: Repository, https://github.com/nanxstats/simtrial-python
6
+ Project-URL: Issues, https://github.com/nanxstats/simtrial-python/issues
7
+ Project-URL: Changelog, https://github.com/nanxstats/simtrial-python/blob/main/CHANGELOG.md
8
+ Author-email: Nan Xiao <me@nanx.me>
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: numpy>=2.0.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # simtrial-python
26
+
27
+ [![PyPI version](https://img.shields.io/pypi/v/simtrial)](https://pypi.org/project/simtrial/)
28
+ ![Python versions](https://img.shields.io/pypi/pyversions/simtrial)
29
+ [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
30
+ [![CI Tests](https://github.com/nanxstats/simtrial-python/actions/workflows/ci-tests.yml/badge.svg)](https://github.com/nanxstats/simtrial-python/actions/workflows/ci-tests.yml)
31
+ ![License](https://img.shields.io/pypi/l/simtrial)
32
+
33
+ simtrial-python is an experimental Python package for clinical trial
34
+ simulation with time-to-event endpoints.
35
+
36
+ ## Installation
37
+
38
+ You can install simtrial-python from PyPI:
39
+
40
+ ```bash
41
+ pip install simtrial
42
+ ```
43
+
44
+ Or install the development version from GitHub:
45
+
46
+ ```bash
47
+ git clone https://github.com/nanxstats/simtrial-python.git
48
+ cd simtrial-python
49
+ python3 -m pip install -e .
50
+ ```
@@ -0,0 +1,26 @@
1
+ # simtrial-python
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/simtrial)](https://pypi.org/project/simtrial/)
4
+ ![Python versions](https://img.shields.io/pypi/pyversions/simtrial)
5
+ [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
6
+ [![CI Tests](https://github.com/nanxstats/simtrial-python/actions/workflows/ci-tests.yml/badge.svg)](https://github.com/nanxstats/simtrial-python/actions/workflows/ci-tests.yml)
7
+ ![License](https://img.shields.io/pypi/l/simtrial)
8
+
9
+ simtrial-python is an experimental Python package for clinical trial
10
+ simulation with time-to-event endpoints.
11
+
12
+ ## Installation
13
+
14
+ You can install simtrial-python from PyPI:
15
+
16
+ ```bash
17
+ pip install simtrial
18
+ ```
19
+
20
+ Or install the development version from GitHub:
21
+
22
+ ```bash
23
+ git clone https://github.com/nanxstats/simtrial-python.git
24
+ cd simtrial-python
25
+ python3 -m pip install -e .
26
+ ```
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "simtrial"
3
+ version = "0.0.1"
4
+ description = "Clinical trial simulation"
5
+ authors = [
6
+ { name = "Nan Xiao", email = "me@nanx.me" }
7
+ ]
8
+ dependencies = [
9
+ "numpy>=2.0.0",
10
+ ]
11
+ readme = "README.md"
12
+
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+
16
+ "Intended Audience :: Science/Research",
17
+
18
+ "License :: OSI Approved :: MIT License",
19
+
20
+ "Operating System :: OS Independent",
21
+
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+
28
+ "Topic :: Scientific/Engineering :: Mathematics",
29
+
30
+ "Typing :: Typed",
31
+ ]
32
+
33
+ requires-python = ">=3.10"
34
+
35
+ [project.urls]
36
+ Repository = "https://github.com/nanxstats/simtrial-python"
37
+ Issues = "https://github.com/nanxstats/simtrial-python/issues"
38
+ Changelog = "https://github.com/nanxstats/simtrial-python/blob/main/CHANGELOG.md"
39
+
40
+ [build-system]
41
+ requires = ["hatchling"]
42
+ build-backend = "hatchling.build"
43
+
44
+ [dependency-groups]
45
+ dev = [
46
+ "isort>=6.1.0",
47
+ "mypy>=1.18.2",
48
+ "pytest>=8.4.2",
49
+ "pytest-cov>=7.0.0",
50
+ "ruff>=0.13.3",
51
+ ]
@@ -0,0 +1,7 @@
1
+ """
2
+ Public API for the simtrial package.
3
+ """
4
+
5
+ from .piecewise_exponential import PiecewiseExponential, set_random_seed
6
+
7
+ __all__ = ["PiecewiseExponential", "set_random_seed"]
@@ -0,0 +1,154 @@
1
+ """
2
+ Tools for working with the piecewise exponential distribution.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import math
8
+ from dataclasses import dataclass
9
+ from typing import Sequence, cast
10
+
11
+ import numpy as np
12
+ from numpy.typing import NDArray
13
+
14
+ _RANDOM_STATE: np.random.Generator | None = None
15
+
16
+
17
+ def _default_rng() -> np.random.Generator:
18
+ """
19
+ Return a module-level random number generator.
20
+
21
+ Returns:
22
+ The reusable random number generator for piecewise exponential sampling.
23
+ """
24
+
25
+ global _RANDOM_STATE
26
+ if _RANDOM_STATE is None:
27
+ _reset_rng()
28
+ return cast(np.random.Generator, _RANDOM_STATE)
29
+
30
+
31
+ def _reset_rng(seed: int | None = None) -> None:
32
+ """
33
+ Initialize the module-level random number generator.
34
+
35
+ Args:
36
+ seed: Seed used to reset the generator.
37
+
38
+ Returns:
39
+ Nothing.
40
+ """
41
+
42
+ global _RANDOM_STATE
43
+ _RANDOM_STATE = np.random.default_rng(seed)
44
+
45
+
46
+ def set_random_seed(seed: int) -> None:
47
+ """
48
+ Set the seed for the module-level random number generator.
49
+
50
+ Args:
51
+ seed: Seed value that controls reproducibility.
52
+
53
+ Returns:
54
+ Nothing.
55
+ """
56
+
57
+ _reset_rng(seed=seed)
58
+
59
+
60
+ @dataclass
61
+ class PiecewiseExponential:
62
+ """
63
+ Piecewise exponential sampler based on the inverse cumulative distribution.
64
+
65
+ Args:
66
+ durations: Interval durations for each hazard rate segment; all but the
67
+ final duration must be finite and strictly positive, while the last
68
+ entry may be set to ``math.inf`` to represent an open-ended tail.
69
+ rates: Hazard rates aligned with the provided durations.
70
+ rng: Optional random number generator.
71
+
72
+ Raises:
73
+ ValueError: Raised when inputs are invalid.
74
+ """
75
+
76
+ durations: Sequence[float]
77
+ rates: Sequence[float]
78
+ rng: np.random.Generator | None = None
79
+
80
+ def __post_init__(self) -> None:
81
+ """
82
+ Validate inputs and precompute cumulative quantities.
83
+
84
+ Returns:
85
+ Nothing.
86
+ """
87
+
88
+ self._durations = np.asarray(self.durations, dtype=float)
89
+ self._rates = np.asarray(self.rates, dtype=float)
90
+
91
+ if self._durations.ndim != 1:
92
+ raise ValueError("durations must be one-dimensional")
93
+ if self._rates.ndim != 1:
94
+ raise ValueError("rates must be one-dimensional")
95
+ if self._durations.size == 0:
96
+ raise ValueError("durations must contain at least one interval")
97
+ if self._durations.size != self._rates.size:
98
+ raise ValueError("durations and rates must have the same length")
99
+ if not np.all(np.isfinite(self._durations[:-1])):
100
+ raise ValueError(
101
+ "durations must be finite except possibly the last interval, "
102
+ "which may extend to infinity"
103
+ )
104
+ if np.any(self._durations[:-1] <= 0):
105
+ raise ValueError("durations before the final interval must be positive")
106
+ last_duration = float(self._durations[-1])
107
+ if math.isnan(last_duration):
108
+ raise ValueError("final duration must be finite or math.inf")
109
+ if last_duration <= 0:
110
+ raise ValueError("final duration must be positive")
111
+ if np.any(~np.isfinite(self._rates)):
112
+ raise ValueError("rates must be finite")
113
+ if np.any(self._rates <= 0):
114
+ raise ValueError("rates must be strictly positive")
115
+
116
+ self._cum_time = np.concatenate(
117
+ (np.array([0.0]), np.cumsum(self._durations[:-1]))
118
+ )
119
+ self._cum_hazard = np.concatenate(
120
+ (np.array([0.0]), np.cumsum(self._durations[:-1] * self._rates[:-1]))
121
+ )
122
+
123
+ def sample(
124
+ self,
125
+ size: int | tuple[int, ...] | None = None,
126
+ rng: np.random.Generator | None = None,
127
+ ) -> float | NDArray[np.float64]:
128
+ """
129
+ Draw samples using the inverse cumulative distribution function.
130
+
131
+ Args:
132
+ size: Requested sample size or shape.
133
+ rng: Optional random number generator overriding the stored generator.
134
+
135
+ Returns:
136
+ A scalar when `size` is `None`, otherwise an array of samples.
137
+ """
138
+
139
+ generator = rng or self.rng or _default_rng()
140
+
141
+ if size is None:
142
+ uniform = float(generator.uniform())
143
+ hazard = -math.log(uniform)
144
+ index = int(np.searchsorted(self._cum_hazard, hazard, side="right") - 1)
145
+ base_time = float(self._cum_time[index])
146
+ return base_time + (hazard - float(self._cum_hazard[index])) / float(
147
+ self._rates[index]
148
+ )
149
+
150
+ uniforms = generator.uniform(size=size)
151
+ hazards = -np.log(uniforms)
152
+ indices = np.searchsorted(self._cum_hazard, hazards, side="right") - 1
153
+ base_times = self._cum_time[indices]
154
+ return base_times + (hazards - self._cum_hazard[indices]) / self._rates[indices]
File without changes
@@ -0,0 +1,18 @@
1
+ # Reference data fixtures
2
+
3
+ The `.txt` files in this directory contain reference outputs from the original
4
+ simtrial R implementation. They are used to validate the Python port of the
5
+ piecewise exponential generator.
6
+
7
+ ## Regenerating fixtures
8
+
9
+ The fixture generator script reproduces the draws using the R implementation
10
+ for specific seeds and parameter sets, and writes both the uniform random
11
+ numbers and the resulting event times to plain-text files without headers.
12
+
13
+ ```sh
14
+ Rscript tests/fixtures/generate_piecewise_exponential.R
15
+ ```
16
+
17
+ The pytest suite consumes these numbers with `numpy.loadtxt` to cross-check the
18
+ Python implementation against the reference algorithm.
@@ -0,0 +1,50 @@
1
+ options(digits = 16, scipen = 999)
2
+
3
+ suppressPackageStartupMessages(library(simtrial))
4
+
5
+ output_dir <- "tests/fixtures"
6
+ dir.create(output_dir, showWarnings = FALSE, recursive = TRUE)
7
+
8
+ write_matrix <- function(mat, filename) {
9
+ path <- file.path(output_dir, filename)
10
+ write.table(
11
+ mat,
12
+ file = path,
13
+ row.names = FALSE,
14
+ col.names = FALSE,
15
+ quote = FALSE
16
+ )
17
+ }
18
+
19
+ write_fixture <- function(filename, seed, n, durations, rates) {
20
+ fail_rate <- data.frame(duration = durations, rate = rates)
21
+
22
+ set.seed(seed)
23
+ uniforms <- runif(n)
24
+
25
+ set.seed(seed)
26
+ r_times <- simtrial::rpwexp(n = n, fail_rate = fail_rate)
27
+
28
+ if (!all.equal(length(r_times), n)) {
29
+ stop("Generated R sample length does not match request.")
30
+ }
31
+
32
+ data <- cbind(uniforms, r_times)
33
+ write_matrix(data, filename)
34
+ }
35
+
36
+ write_fixture(
37
+ filename = "pwexp_single_seed_123_n20.txt",
38
+ seed = 123,
39
+ n = 20,
40
+ durations = c(1.0),
41
+ rates = c(2.0)
42
+ )
43
+
44
+ write_fixture(
45
+ filename = "pwexp_multi_seed_456_n30.txt",
46
+ seed = 456,
47
+ n = 30,
48
+ durations = c(0.5, 0.5, 1.0),
49
+ rates = c(1.0, 3.0, 10.0)
50
+ )
@@ -0,0 +1,30 @@
1
+ 0.0895516003947705 1.04129402791134
2
+ 0.21051231934689 0.852737034452974
3
+ 0.73295526811853 0.3106706047168
4
+ 0.852133540669456 0.16001202655997
5
+ 0.788397894473746 0.237752374339119
6
+ 0.331959968432784 0.700913631448442
7
+ 0.0824327350128442 1.0495772651384
8
+ 0.285526945022866 0.751139625325209
9
+ 0.23750327154994 0.812524626885066
10
+ 0.38523616688326 0.651299570761263
11
+ 0.372945911018178 0.662107293509504
12
+ 0.217908559599891 0.84122658518801
13
+ 0.755105035379529 0.280898419701376
14
+ 0.82168106874451 0.196402953443282
15
+ 0.598918164148927 0.504210103662197
16
+ 0.651033560046926 0.429194086570926
17
+ 0.843117238022387 0.170649258262776
18
+ 0.4532381426543 0.597112530135988
19
+ 0.716757099609822 0.333018268987182
20
+ 0.291222166502848 0.744556281441457
21
+ 0.179883137578145 0.905149291537882
22
+ 0.721743599977344 0.326085327846133
23
+ 0.905087579507381 0.0997235670280772
24
+ 0.445766670163721 0.602653208308821
25
+ 0.838601463707164 0.17601969882944
26
+ 0.70349283167161 0.351697592001127
27
+ 0.950928996317089 0.0503158813512434
28
+ 0.643152175005525 0.441373918661801
29
+ 0.0748198556248099 1.05926719796977
30
+ 0.253490373725072 0.790809810047676
@@ -0,0 +1,20 @@
1
+ 0.287577520124614 0.623131409936862
2
+ 0.788305135443807 0.118935018178498
3
+ 0.4089769218117 0.4470482752098
4
+ 0.883017404004931 0.0622051842437791
5
+ 0.940467284293845 0.0306892081429518
6
+ 0.0455564993899316 1.54440098925953
7
+ 0.528105488047004 0.319229613631512
8
+ 0.892419044394046 0.0569097380018624
9
+ 0.551435014465824 0.297615640691194
10
+ 0.456614735303447 0.391957636804057
11
+ 0.956833345349878 0.0220630227419475
12
+ 0.453334156190977 0.395562886897321
13
+ 0.677570635452867 0.194620736345925
14
+ 0.572633401956409 0.278754777090065
15
+ 0.102924682665616 1.13687889724614
16
+ 0.899824970401824 0.0527775059511509
17
+ 0.24608773435466 0.701033581456949
18
+ 0.0420595335308462 1.58433459966692
19
+ 0.327920719282702 0.557491704659201
20
+ 0.954503649147227 0.0232819063627237