distparser 0.2.0__tar.gz → 0.3.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.
- {distparser-0.2.0/src/distparser.egg-info → distparser-0.3.1}/PKG-INFO +33 -34
- distparser-0.3.1/README.md +62 -0
- {distparser-0.2.0 → distparser-0.3.1}/pyproject.toml +7 -3
- {distparser-0.2.0 → distparser-0.3.1}/src/distparser/__init__.py +7 -0
- {distparser-0.2.0 → distparser-0.3.1}/src/distparser/core.py +3 -1
- distparser-0.3.1/src/distparser/graph.py +272 -0
- distparser-0.3.1/src/distparser/mapping.py +18 -0
- distparser-0.3.1/src/distparser/version.py +1 -0
- {distparser-0.2.0 → distparser-0.3.1/src/distparser.egg-info}/PKG-INFO +33 -34
- {distparser-0.2.0 → distparser-0.3.1}/src/distparser.egg-info/SOURCES.txt +5 -1
- {distparser-0.2.0 → distparser-0.3.1}/src/distparser.egg-info/requires.txt +1 -1
- {distparser-0.2.0 → distparser-0.3.1}/tests/test_core.py +23 -0
- distparser-0.3.1/tests/test_graph.py +260 -0
- distparser-0.3.1/tests/test_mapping.py +26 -0
- distparser-0.2.0/README.md +0 -63
- distparser-0.2.0/src/distparser/version.py +0 -1
- {distparser-0.2.0 → distparser-0.3.1}/LICENSE +0 -0
- {distparser-0.2.0 → distparser-0.3.1}/setup.cfg +0 -0
- {distparser-0.2.0 → distparser-0.3.1}/src/distparser.egg-info/dependency_links.txt +0 -0
- {distparser-0.2.0 → distparser-0.3.1}/src/distparser.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: distparser
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Parse function-call strings into frozen scipy.stats distributions
|
|
5
5
|
Author-email: Juyoung Choi <njuyoung35@gmail.com>
|
|
6
6
|
Maintainer-email: Juyoung Choi <njuyoung35@gmail.com>
|
|
@@ -18,6 +18,7 @@ Description-Content-Type: text/markdown
|
|
|
18
18
|
License-File: LICENSE
|
|
19
19
|
Requires-Dist: scipy>=1.7.0
|
|
20
20
|
Requires-Dist: numpy>=1.21.0
|
|
21
|
+
Requires-Dist: asteval>=0.9.0
|
|
21
22
|
Provides-Extra: dev
|
|
22
23
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
23
24
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -27,25 +28,18 @@ Requires-Dist: build; extra == "dev"
|
|
|
27
28
|
Requires-Dist: twine; extra == "dev"
|
|
28
29
|
Provides-Extra: docs
|
|
29
30
|
Requires-Dist: mkdocs>=1.5.0; extra == "docs"
|
|
30
|
-
Requires-Dist: mkdocs-rtd-theme>=2.0.0; extra == "docs"
|
|
31
31
|
Requires-Dist: pymdown-extensions>=10.0; extra == "docs"
|
|
32
32
|
Dynamic: license-file
|
|
33
33
|
|
|
34
34
|
# distparser
|
|
35
35
|
|
|
36
|
-
**Parse function‑call
|
|
36
|
+
**Parse function‑call strings into frozen `scipy.stats` distributions —**
|
|
37
|
+
**with dependency resolution, arithmetic expressions, and seed management.**
|
|
37
38
|
|
|
38
39
|
[](https://badge.fury.io/py/distparser)
|
|
39
40
|
[](https://www.python.org/downloads/)
|
|
40
41
|
[](https://opensource.org/licenses/MIT)
|
|
41
42
|
|
|
42
|
-
## Why `distparser`?
|
|
43
|
-
|
|
44
|
-
When you have configuration files or user input that specifies a probability
|
|
45
|
-
distribution (e.g. `"norm(loc=0, scale=1)"`), you want to turn that string
|
|
46
|
-
into a ready‑to‑sample object. `distparser` does exactly that, with a tiny
|
|
47
|
-
API and zero surprises.
|
|
48
|
-
|
|
49
43
|
## Installation
|
|
50
44
|
|
|
51
45
|
```bash
|
|
@@ -54,42 +48,47 @@ pip install distparser
|
|
|
54
48
|
|
|
55
49
|
## Quick Start
|
|
56
50
|
|
|
57
|
-
```
|
|
58
|
-
|
|
51
|
+
```python
|
|
52
|
+
import distparser as lib
|
|
59
53
|
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
54
|
+
# Parse a single distribution string
|
|
55
|
+
d = lib.parse_dist("norm(loc=0, scale=1)")
|
|
56
|
+
print(d.rvs(size=3)) # array([...])
|
|
57
|
+
```
|
|
63
58
|
|
|
64
|
-
|
|
65
|
-
dist = parse_dist("norm(loc=0, scale=1)")
|
|
66
|
-
samples = dist.rvs(size=5) # array([-0.12, 1.03, ...])
|
|
59
|
+
## DistGraph — dependency-aware evaluation
|
|
67
60
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
```
|
|
61
|
+
```python
|
|
62
|
+
from distparser import DistGraph
|
|
71
63
|
|
|
72
|
-
|
|
64
|
+
config = {
|
|
65
|
+
"mean": 5.0,
|
|
66
|
+
"noise": "norm(0, 0.1)",
|
|
67
|
+
"measurement": "mean + noise",
|
|
68
|
+
}
|
|
73
69
|
|
|
74
|
-
|
|
70
|
+
graph = DistGraph(config, seed=42)
|
|
71
|
+
result = graph.resolve_all()
|
|
72
|
+
print(result["measurement"]) # 5.0 + sample from N(0, 0.1)
|
|
73
|
+
```
|
|
75
74
|
|
|
76
|
-
|
|
77
|
-
- `norm(loc, scale)`
|
|
78
|
-
- `expon(loc, scale)`
|
|
79
|
-
- … and many more (see [docs](https://distparser.readthedocs.io)).
|
|
75
|
+
## Features
|
|
80
76
|
|
|
81
|
-
|
|
77
|
+
- **15 built-in distributions** — `uniform`, `norm`, `expon`, `gamma`, `beta`, `lognorm`, `weibull_min`, `t`, `chi2`, `f`, `pareto`, `cauchy`, `laplace`, `logistic`, `rayleigh`
|
|
78
|
+
- **Distribution aliases** — `normal` → `norm`, `gaussian` → `norm`, `unif` → `uniform`
|
|
79
|
+
- **DistGraph** — automatic dependency resolution via topological sort
|
|
80
|
+
- **Arithmetic expressions** — `"60 + 30 * uniform(0, 1)"` with `sin`, `cos`, `exp`, `sqrt`, `pi` and more
|
|
81
|
+
- **Seed management** — global `seed()`, context manager `seed_context()`, per-instance `DistGraph(seed=...)`
|
|
82
|
+
- **Bounds constraints** — annotate parameters with physical limits for downstream validation
|
|
83
|
+
- **Extensible registry** — register custom distributions at runtime
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
from distparser import register_distribution
|
|
85
|
-
from scipy.stats import gumbel_r
|
|
85
|
+
## Docs
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
```
|
|
87
|
+
Full documentation at [distparser.readthedocs.io](https://distparser.readthedocs.io).
|
|
89
88
|
|
|
90
89
|
## Development
|
|
91
90
|
|
|
92
|
-
See [AGENTS.md](AGENTS.md) for
|
|
91
|
+
See [AGENTS.md](https://github.com/njuyoung35/distparser/blob/main/AGENTS.md) for setup and guidelines.
|
|
93
92
|
|
|
94
93
|
## License
|
|
95
94
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# distparser
|
|
2
|
+
|
|
3
|
+
**Parse function‑call strings into frozen `scipy.stats` distributions —**
|
|
4
|
+
**with dependency resolution, arithmetic expressions, and seed management.**
|
|
5
|
+
|
|
6
|
+
[](https://badge.fury.io/py/distparser)
|
|
7
|
+
[](https://www.python.org/downloads/)
|
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install distparser
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import distparser as lib
|
|
20
|
+
|
|
21
|
+
# Parse a single distribution string
|
|
22
|
+
d = lib.parse_dist("norm(loc=0, scale=1)")
|
|
23
|
+
print(d.rvs(size=3)) # array([...])
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## DistGraph — dependency-aware evaluation
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from distparser import DistGraph
|
|
30
|
+
|
|
31
|
+
config = {
|
|
32
|
+
"mean": 5.0,
|
|
33
|
+
"noise": "norm(0, 0.1)",
|
|
34
|
+
"measurement": "mean + noise",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
graph = DistGraph(config, seed=42)
|
|
38
|
+
result = graph.resolve_all()
|
|
39
|
+
print(result["measurement"]) # 5.0 + sample from N(0, 0.1)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
|
|
44
|
+
- **15 built-in distributions** — `uniform`, `norm`, `expon`, `gamma`, `beta`, `lognorm`, `weibull_min`, `t`, `chi2`, `f`, `pareto`, `cauchy`, `laplace`, `logistic`, `rayleigh`
|
|
45
|
+
- **Distribution aliases** — `normal` → `norm`, `gaussian` → `norm`, `unif` → `uniform`
|
|
46
|
+
- **DistGraph** — automatic dependency resolution via topological sort
|
|
47
|
+
- **Arithmetic expressions** — `"60 + 30 * uniform(0, 1)"` with `sin`, `cos`, `exp`, `sqrt`, `pi` and more
|
|
48
|
+
- **Seed management** — global `seed()`, context manager `seed_context()`, per-instance `DistGraph(seed=...)`
|
|
49
|
+
- **Bounds constraints** — annotate parameters with physical limits for downstream validation
|
|
50
|
+
- **Extensible registry** — register custom distributions at runtime
|
|
51
|
+
|
|
52
|
+
## Docs
|
|
53
|
+
|
|
54
|
+
Full documentation at [distparser.readthedocs.io](https://distparser.readthedocs.io).
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
See [AGENTS.md](https://github.com/njuyoung35/distparser/blob/main/AGENTS.md) for setup and guidelines.
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "distparser"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Parse function-call strings into frozen scipy.stats distributions"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -21,6 +21,7 @@ requires-python = ">=3.8,<3.14"
|
|
|
21
21
|
dependencies = [
|
|
22
22
|
"scipy>=1.7.0",
|
|
23
23
|
"numpy>=1.21.0",
|
|
24
|
+
"asteval>=0.9.0",
|
|
24
25
|
]
|
|
25
26
|
|
|
26
27
|
[project.urls]
|
|
@@ -39,7 +40,6 @@ dev = [
|
|
|
39
40
|
]
|
|
40
41
|
docs = [
|
|
41
42
|
"mkdocs>=1.5.0",
|
|
42
|
-
"mkdocs-rtd-theme>=2.0.0",
|
|
43
43
|
"pymdown-extensions>=10.0",
|
|
44
44
|
]
|
|
45
45
|
|
|
@@ -52,12 +52,16 @@ testpaths = ["tests"]
|
|
|
52
52
|
addopts = "-v --cov=src/distparser --cov-report=term-missing"
|
|
53
53
|
|
|
54
54
|
[tool.mypy]
|
|
55
|
-
python_version = "3.
|
|
55
|
+
python_version = "3.12"
|
|
56
56
|
strict = true
|
|
57
57
|
warn_return_any = true
|
|
58
58
|
warn_unused_configs = true
|
|
59
59
|
ignore_missing_imports = true
|
|
60
60
|
|
|
61
|
+
[[tool.mypy.overrides]]
|
|
62
|
+
module = ["scipy.stats", "asteval"]
|
|
63
|
+
ignore_missing_imports = true
|
|
64
|
+
|
|
61
65
|
[tool.ruff]
|
|
62
66
|
line-length = 88
|
|
63
67
|
target-version = "py38"
|
|
@@ -8,6 +8,8 @@ from distparser.core import (
|
|
|
8
8
|
parse_dist,
|
|
9
9
|
register_distribution,
|
|
10
10
|
)
|
|
11
|
+
from distparser.graph import DistGraph, seed, seed_context
|
|
12
|
+
from distparser.mapping import DISTRIBUTION_ALIASES, normalize_dist_name
|
|
11
13
|
from distparser.version import __version__
|
|
12
14
|
|
|
13
15
|
__all__ = [
|
|
@@ -16,6 +18,11 @@ __all__ = [
|
|
|
16
18
|
"ParseError",
|
|
17
19
|
"UnknownDistributionError",
|
|
18
20
|
"REGISTRY",
|
|
21
|
+
"DISTRIBUTION_ALIASES",
|
|
22
|
+
"normalize_dist_name",
|
|
19
23
|
"parse_dist",
|
|
20
24
|
"register_distribution",
|
|
25
|
+
"DistGraph",
|
|
26
|
+
"seed",
|
|
27
|
+
"seed_context",
|
|
21
28
|
]
|
|
@@ -24,6 +24,8 @@ from scipy.stats import (
|
|
|
24
24
|
weibull_min,
|
|
25
25
|
)
|
|
26
26
|
|
|
27
|
+
from distparser.mapping import normalize_dist_name
|
|
28
|
+
|
|
27
29
|
__all__ = [
|
|
28
30
|
"DistParserError",
|
|
29
31
|
"ParseError",
|
|
@@ -154,7 +156,7 @@ def parse_dist(s: str) -> Any:
|
|
|
154
156
|
if not m:
|
|
155
157
|
raise ParseError(f"Cannot parse {s!r}. Expected format: 'name(args)'.")
|
|
156
158
|
|
|
157
|
-
name = m.group(1)
|
|
159
|
+
name = normalize_dist_name(m.group(1))
|
|
158
160
|
if name not in REGISTRY:
|
|
159
161
|
raise UnknownDistributionError(
|
|
160
162
|
f"Unknown distribution {name!r}. Registered: {', '.join(sorted(REGISTRY))}."
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""DistGraph: dependency-aware distribution graph with safe expression evaluation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast as _ast
|
|
6
|
+
import contextlib
|
|
7
|
+
from typing import Any, Iterator
|
|
8
|
+
|
|
9
|
+
import graphlib
|
|
10
|
+
import numpy as np
|
|
11
|
+
from asteval import Interpreter
|
|
12
|
+
|
|
13
|
+
from distparser.core import REGISTRY, ParseError
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Seed management
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
_DEFAULT_SEED: int | None = None
|
|
20
|
+
_DEFAULT_RNG: np.random.Generator | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def seed(value: int) -> None:
|
|
24
|
+
"""Set the global legacy seed for distribution sampling."""
|
|
25
|
+
global _DEFAULT_SEED, _DEFAULT_RNG
|
|
26
|
+
_DEFAULT_SEED = value
|
|
27
|
+
_DEFAULT_RNG = np.random.default_rng(value)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@contextlib.contextmanager
|
|
31
|
+
def seed_context(value: int) -> Iterator[None]:
|
|
32
|
+
"""Temporarily override the global seed within a ``with`` block.
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
>>> with seed_context(42):
|
|
36
|
+
... graph = DistGraph({"x": "uniform(0,1)"})
|
|
37
|
+
... graph.resolve_all()
|
|
38
|
+
"""
|
|
39
|
+
global _DEFAULT_RNG
|
|
40
|
+
old_rng = _DEFAULT_RNG
|
|
41
|
+
_DEFAULT_RNG = np.random.default_rng(value)
|
|
42
|
+
try:
|
|
43
|
+
yield
|
|
44
|
+
finally:
|
|
45
|
+
_DEFAULT_RNG = old_rng
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_current_rng() -> np.random.Generator:
|
|
49
|
+
"""Return the current global RNG, creating a fresh one if unset."""
|
|
50
|
+
global _DEFAULT_RNG
|
|
51
|
+
if _DEFAULT_RNG is None:
|
|
52
|
+
_DEFAULT_RNG = np.random.default_rng()
|
|
53
|
+
return _DEFAULT_RNG
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Distribution sampler factory
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
# Math constants and functions exposed in expressions
|
|
61
|
+
_MATH_SYMBOLS: dict[str, Any] = {
|
|
62
|
+
"sin": np.sin,
|
|
63
|
+
"cos": np.cos,
|
|
64
|
+
"tan": np.tan,
|
|
65
|
+
"exp": np.exp,
|
|
66
|
+
"log": np.log,
|
|
67
|
+
"log10": np.log10,
|
|
68
|
+
"sqrt": np.sqrt,
|
|
69
|
+
"abs": abs,
|
|
70
|
+
"pi": np.pi,
|
|
71
|
+
"e": np.e,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _make_dist_samplers(rng: np.random.Generator) -> dict[str, Any]:
|
|
76
|
+
"""Build a dict mapping each registered distribution name to a callable
|
|
77
|
+
sampler that returns a single ``.rvs()`` sample."""
|
|
78
|
+
samplers: dict[str, Any] = {}
|
|
79
|
+
for name, entry in REGISTRY.items():
|
|
80
|
+
dist_class = entry["class"]
|
|
81
|
+
param_order: list[str] = entry["param_order"]
|
|
82
|
+
|
|
83
|
+
def _sampler(
|
|
84
|
+
*args: Any,
|
|
85
|
+
_dc: Any = dist_class,
|
|
86
|
+
_po: list[str] = param_order,
|
|
87
|
+
_rng: np.random.Generator = rng,
|
|
88
|
+
**kwargs: Any,
|
|
89
|
+
) -> Any:
|
|
90
|
+
params: dict[str, Any] = {}
|
|
91
|
+
for i, val in enumerate(args):
|
|
92
|
+
if i < len(_po):
|
|
93
|
+
params[_po[i]] = val
|
|
94
|
+
params.update(kwargs)
|
|
95
|
+
return _dc(**params).rvs(random_state=_rng)
|
|
96
|
+
|
|
97
|
+
samplers[name] = _sampler
|
|
98
|
+
return samplers
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# DistGraph
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class DistGraph:
|
|
107
|
+
"""A dependency-aware graph of distribution expressions.
|
|
108
|
+
|
|
109
|
+
Users supply a flat dictionary mapping keys to expressions (or numeric
|
|
110
|
+
values). ``DistGraph`` detects cross-references, resolves evaluation
|
|
111
|
+
order via topological sort, and injects resolved values into subsequent
|
|
112
|
+
expressions.
|
|
113
|
+
|
|
114
|
+
Parameters
|
|
115
|
+
----------
|
|
116
|
+
config : dict
|
|
117
|
+
Mapping of key → expression string, numeric value, or bounds dict.
|
|
118
|
+
See the user guide for the bounds dict format.
|
|
119
|
+
seed : int | None
|
|
120
|
+
Per-instance RNG seed. Overrides any global seed setting.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(self, config: dict[str, Any], seed: int | None = None) -> None:
|
|
124
|
+
self._raw_config = config
|
|
125
|
+
self._rng = np.random.default_rng(seed) if seed is not None else None
|
|
126
|
+
self._resolved: dict[str, Any] = {}
|
|
127
|
+
self._bounds: dict[str, dict[str, tuple[float, float]]] = {}
|
|
128
|
+
|
|
129
|
+
# Parse config into dist expressions + bounds
|
|
130
|
+
self._dists: dict[str, Any] = {}
|
|
131
|
+
self._parse_config()
|
|
132
|
+
|
|
133
|
+
# Build dependency graph and resolve evaluation order
|
|
134
|
+
self._order = self._resolve_order()
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
# Config parsing
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def _parse_config(self) -> None:
|
|
141
|
+
for key, value in self._raw_config.items():
|
|
142
|
+
if isinstance(value, dict):
|
|
143
|
+
if "dist" not in value:
|
|
144
|
+
raise ParseError(
|
|
145
|
+
f"Config key {key!r} is a dict but missing required "
|
|
146
|
+
f"'dist' key. Use 'dist' for the expression string."
|
|
147
|
+
)
|
|
148
|
+
self._dists[key] = value["dist"]
|
|
149
|
+
if "bounds" in value:
|
|
150
|
+
self._bounds[key] = value["bounds"]
|
|
151
|
+
else:
|
|
152
|
+
self._dists[key] = value
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# Dependency resolution
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def _resolve_order(self) -> list[str]:
|
|
159
|
+
graph: dict[str, set[str]] = {k: set() for k in self._dists}
|
|
160
|
+
for key, expr in self._dists.items():
|
|
161
|
+
if isinstance(expr, str):
|
|
162
|
+
for var in self._extract_variables(expr):
|
|
163
|
+
if var in self._dists:
|
|
164
|
+
graph[key].add(var)
|
|
165
|
+
try:
|
|
166
|
+
ts = graphlib.TopologicalSorter(graph)
|
|
167
|
+
return list(ts.static_order())
|
|
168
|
+
except graphlib.CycleError as exc:
|
|
169
|
+
raise ParseError(f"Circular dependency detected in config: {exc}") from exc
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _extract_variables(expr: str) -> set[str]:
|
|
173
|
+
"""Return the set of bare-name identifiers in *expr*."""
|
|
174
|
+
try:
|
|
175
|
+
tree = _ast.parse(expr, mode="eval")
|
|
176
|
+
except SyntaxError:
|
|
177
|
+
return set()
|
|
178
|
+
return {node.id for node in _ast.walk(tree) if isinstance(node, _ast.Name)}
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
# Evaluation
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def _evaluate(self, key: str) -> Any:
|
|
185
|
+
expr = self._dists[key]
|
|
186
|
+
if not isinstance(expr, str):
|
|
187
|
+
return expr
|
|
188
|
+
|
|
189
|
+
rng = self._rng or _get_current_rng()
|
|
190
|
+
|
|
191
|
+
interp = Interpreter()
|
|
192
|
+
# Pre-populate with already-resolved dependencies
|
|
193
|
+
interp.symtable.update(self._resolved)
|
|
194
|
+
# Math symbols
|
|
195
|
+
interp.symtable.update(_MATH_SYMBOLS)
|
|
196
|
+
# Distribution samplers
|
|
197
|
+
interp.symtable.update(_make_dist_samplers(rng))
|
|
198
|
+
|
|
199
|
+
result = interp(expr)
|
|
200
|
+
if interp.error:
|
|
201
|
+
messages: list[str] = []
|
|
202
|
+
for err in interp.error:
|
|
203
|
+
messages.append(f"{err.msg} (line {err.lineno})")
|
|
204
|
+
raise ParseError(
|
|
205
|
+
f"Error evaluating {expr!r} for key {key!r}: " + "; ".join(messages)
|
|
206
|
+
)
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
def resolve_all(self) -> dict[str, Any]:
|
|
210
|
+
"""Evaluate all keys in dependency order and return resolved values."""
|
|
211
|
+
for key in self._order:
|
|
212
|
+
if key not in self._resolved:
|
|
213
|
+
self._resolved[key] = self._evaluate(key)
|
|
214
|
+
return dict(self._resolved)
|
|
215
|
+
|
|
216
|
+
# ------------------------------------------------------------------
|
|
217
|
+
# Access
|
|
218
|
+
# ------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def __getitem__(self, key: str) -> Any:
|
|
221
|
+
"""Access a resolved value, evaluating on demand if needed."""
|
|
222
|
+
if key not in self._dists:
|
|
223
|
+
raise KeyError(key)
|
|
224
|
+
if key not in self._resolved:
|
|
225
|
+
# Build the dependency graph to find what 'key' needs
|
|
226
|
+
graph = self._build_dep_graph()
|
|
227
|
+
self._resolve_deps(key, graph)
|
|
228
|
+
return self._resolved[key]
|
|
229
|
+
|
|
230
|
+
def _build_dep_graph(self) -> dict[str, set[str]]:
|
|
231
|
+
"""Build the dependency graph (key → set of keys it depends on)."""
|
|
232
|
+
graph: dict[str, set[str]] = {k: set() for k in self._dists}
|
|
233
|
+
for k, expr in self._dists.items():
|
|
234
|
+
if isinstance(expr, str):
|
|
235
|
+
for var in self._extract_variables(expr):
|
|
236
|
+
if var in self._dists:
|
|
237
|
+
graph[k].add(var)
|
|
238
|
+
return graph
|
|
239
|
+
|
|
240
|
+
def _resolve_deps(self, key: str, graph: dict[str, set[str]]) -> None:
|
|
241
|
+
"""Recursively resolve dependencies of *key*, then evaluate *key*."""
|
|
242
|
+
if key in self._resolved:
|
|
243
|
+
return
|
|
244
|
+
for dep in graph.get(key, set()):
|
|
245
|
+
self._resolve_deps(dep, graph)
|
|
246
|
+
self._resolved[key] = self._evaluate(key)
|
|
247
|
+
|
|
248
|
+
def __contains__(self, key: str) -> bool:
|
|
249
|
+
return key in self._dists
|
|
250
|
+
|
|
251
|
+
def __len__(self) -> int:
|
|
252
|
+
return len(self._dists)
|
|
253
|
+
|
|
254
|
+
def __iter__(self) -> Iterator[str]:
|
|
255
|
+
return iter(self._dists)
|
|
256
|
+
|
|
257
|
+
def keys(self) -> Any:
|
|
258
|
+
return self._dists.keys()
|
|
259
|
+
|
|
260
|
+
def get_bounds(self, key: str) -> dict[str, tuple[float, float]] | None:
|
|
261
|
+
"""Return the bounds dict for *key*, or ``None`` if no bounds set."""
|
|
262
|
+
return self._bounds.get(key)
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def order(self) -> list[str]:
|
|
266
|
+
"""The topological evaluation order (read-only)."""
|
|
267
|
+
return list(self._order)
|
|
268
|
+
|
|
269
|
+
def __repr__(self) -> str:
|
|
270
|
+
resolved_count = len(self._resolved)
|
|
271
|
+
total = len(self._dists)
|
|
272
|
+
return f"<DistGraph keys={total} resolved={resolved_count}>"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Distribution name aliases and canonicalization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
DISTRIBUTION_ALIASES: dict[str, str] = {
|
|
6
|
+
"normal": "norm",
|
|
7
|
+
"gaussian": "norm",
|
|
8
|
+
"unif": "uniform",
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalize_dist_name(name: str) -> str:
|
|
13
|
+
"""Return the canonical distribution name for *name*.
|
|
14
|
+
|
|
15
|
+
If *name* is a known alias, the canonical name is returned.
|
|
16
|
+
Otherwise *name* is returned unchanged.
|
|
17
|
+
"""
|
|
18
|
+
return DISTRIBUTION_ALIASES.get(name, name)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.1"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: distparser
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Parse function-call strings into frozen scipy.stats distributions
|
|
5
5
|
Author-email: Juyoung Choi <njuyoung35@gmail.com>
|
|
6
6
|
Maintainer-email: Juyoung Choi <njuyoung35@gmail.com>
|
|
@@ -18,6 +18,7 @@ Description-Content-Type: text/markdown
|
|
|
18
18
|
License-File: LICENSE
|
|
19
19
|
Requires-Dist: scipy>=1.7.0
|
|
20
20
|
Requires-Dist: numpy>=1.21.0
|
|
21
|
+
Requires-Dist: asteval>=0.9.0
|
|
21
22
|
Provides-Extra: dev
|
|
22
23
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
23
24
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -27,25 +28,18 @@ Requires-Dist: build; extra == "dev"
|
|
|
27
28
|
Requires-Dist: twine; extra == "dev"
|
|
28
29
|
Provides-Extra: docs
|
|
29
30
|
Requires-Dist: mkdocs>=1.5.0; extra == "docs"
|
|
30
|
-
Requires-Dist: mkdocs-rtd-theme>=2.0.0; extra == "docs"
|
|
31
31
|
Requires-Dist: pymdown-extensions>=10.0; extra == "docs"
|
|
32
32
|
Dynamic: license-file
|
|
33
33
|
|
|
34
34
|
# distparser
|
|
35
35
|
|
|
36
|
-
**Parse function‑call
|
|
36
|
+
**Parse function‑call strings into frozen `scipy.stats` distributions —**
|
|
37
|
+
**with dependency resolution, arithmetic expressions, and seed management.**
|
|
37
38
|
|
|
38
39
|
[](https://badge.fury.io/py/distparser)
|
|
39
40
|
[](https://www.python.org/downloads/)
|
|
40
41
|
[](https://opensource.org/licenses/MIT)
|
|
41
42
|
|
|
42
|
-
## Why `distparser`?
|
|
43
|
-
|
|
44
|
-
When you have configuration files or user input that specifies a probability
|
|
45
|
-
distribution (e.g. `"norm(loc=0, scale=1)"`), you want to turn that string
|
|
46
|
-
into a ready‑to‑sample object. `distparser` does exactly that, with a tiny
|
|
47
|
-
API and zero surprises.
|
|
48
|
-
|
|
49
43
|
## Installation
|
|
50
44
|
|
|
51
45
|
```bash
|
|
@@ -54,42 +48,47 @@ pip install distparser
|
|
|
54
48
|
|
|
55
49
|
## Quick Start
|
|
56
50
|
|
|
57
|
-
```
|
|
58
|
-
|
|
51
|
+
```python
|
|
52
|
+
import distparser as lib
|
|
59
53
|
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
54
|
+
# Parse a single distribution string
|
|
55
|
+
d = lib.parse_dist("norm(loc=0, scale=1)")
|
|
56
|
+
print(d.rvs(size=3)) # array([...])
|
|
57
|
+
```
|
|
63
58
|
|
|
64
|
-
|
|
65
|
-
dist = parse_dist("norm(loc=0, scale=1)")
|
|
66
|
-
samples = dist.rvs(size=5) # array([-0.12, 1.03, ...])
|
|
59
|
+
## DistGraph — dependency-aware evaluation
|
|
67
60
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
```
|
|
61
|
+
```python
|
|
62
|
+
from distparser import DistGraph
|
|
71
63
|
|
|
72
|
-
|
|
64
|
+
config = {
|
|
65
|
+
"mean": 5.0,
|
|
66
|
+
"noise": "norm(0, 0.1)",
|
|
67
|
+
"measurement": "mean + noise",
|
|
68
|
+
}
|
|
73
69
|
|
|
74
|
-
|
|
70
|
+
graph = DistGraph(config, seed=42)
|
|
71
|
+
result = graph.resolve_all()
|
|
72
|
+
print(result["measurement"]) # 5.0 + sample from N(0, 0.1)
|
|
73
|
+
```
|
|
75
74
|
|
|
76
|
-
|
|
77
|
-
- `norm(loc, scale)`
|
|
78
|
-
- `expon(loc, scale)`
|
|
79
|
-
- … and many more (see [docs](https://distparser.readthedocs.io)).
|
|
75
|
+
## Features
|
|
80
76
|
|
|
81
|
-
|
|
77
|
+
- **15 built-in distributions** — `uniform`, `norm`, `expon`, `gamma`, `beta`, `lognorm`, `weibull_min`, `t`, `chi2`, `f`, `pareto`, `cauchy`, `laplace`, `logistic`, `rayleigh`
|
|
78
|
+
- **Distribution aliases** — `normal` → `norm`, `gaussian` → `norm`, `unif` → `uniform`
|
|
79
|
+
- **DistGraph** — automatic dependency resolution via topological sort
|
|
80
|
+
- **Arithmetic expressions** — `"60 + 30 * uniform(0, 1)"` with `sin`, `cos`, `exp`, `sqrt`, `pi` and more
|
|
81
|
+
- **Seed management** — global `seed()`, context manager `seed_context()`, per-instance `DistGraph(seed=...)`
|
|
82
|
+
- **Bounds constraints** — annotate parameters with physical limits for downstream validation
|
|
83
|
+
- **Extensible registry** — register custom distributions at runtime
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
from distparser import register_distribution
|
|
85
|
-
from scipy.stats import gumbel_r
|
|
85
|
+
## Docs
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
```
|
|
87
|
+
Full documentation at [distparser.readthedocs.io](https://distparser.readthedocs.io).
|
|
89
88
|
|
|
90
89
|
## Development
|
|
91
90
|
|
|
92
|
-
See [AGENTS.md](AGENTS.md) for
|
|
91
|
+
See [AGENTS.md](https://github.com/njuyoung35/distparser/blob/main/AGENTS.md) for setup and guidelines.
|
|
93
92
|
|
|
94
93
|
## License
|
|
95
94
|
|
|
@@ -3,10 +3,14 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/distparser/__init__.py
|
|
5
5
|
src/distparser/core.py
|
|
6
|
+
src/distparser/graph.py
|
|
7
|
+
src/distparser/mapping.py
|
|
6
8
|
src/distparser/version.py
|
|
7
9
|
src/distparser.egg-info/PKG-INFO
|
|
8
10
|
src/distparser.egg-info/SOURCES.txt
|
|
9
11
|
src/distparser.egg-info/dependency_links.txt
|
|
10
12
|
src/distparser.egg-info/requires.txt
|
|
11
13
|
src/distparser.egg-info/top_level.txt
|
|
12
|
-
tests/test_core.py
|
|
14
|
+
tests/test_core.py
|
|
15
|
+
tests/test_graph.py
|
|
16
|
+
tests/test_mapping.py
|
|
@@ -190,6 +190,29 @@ class TestParseDistNewDistributions:
|
|
|
190
190
|
assert d.rvs(size=3, random_state=42).shape == (3,)
|
|
191
191
|
|
|
192
192
|
|
|
193
|
+
class TestParseDistAliases:
|
|
194
|
+
"""Verify that distribution aliases are resolved."""
|
|
195
|
+
|
|
196
|
+
def test_normal_alias(self):
|
|
197
|
+
d = parse_dist("normal(loc=0, scale=1)")
|
|
198
|
+
assert abs(d.kwds["loc"] - 0.0) < 1e-12
|
|
199
|
+
|
|
200
|
+
def test_gaussian_alias(self):
|
|
201
|
+
d = parse_dist("gaussian(loc=5, scale=2)")
|
|
202
|
+
assert abs(d.kwds["loc"] - 5.0) < 1e-12
|
|
203
|
+
|
|
204
|
+
def test_unif_alias(self):
|
|
205
|
+
d = parse_dist("unif(0, 1)")
|
|
206
|
+
assert abs(d.kwds["loc"] - 0.0) < 1e-12
|
|
207
|
+
|
|
208
|
+
def test_unknown_distribution_with_alias_message(self):
|
|
209
|
+
# Even with normalize, unknown names still raise UnknownDistributionError
|
|
210
|
+
from distparser.core import UnknownDistributionError
|
|
211
|
+
|
|
212
|
+
with pytest.raises(UnknownDistributionError):
|
|
213
|
+
parse_dist("not_an_alias(0, 1)")
|
|
214
|
+
|
|
215
|
+
|
|
193
216
|
# ---------------------------------------------------------------------------
|
|
194
217
|
# parse_dist – error cases
|
|
195
218
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Tests for distparser.graph (DistGraph + seed management)."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from distparser.core import ParseError
|
|
7
|
+
from distparser.graph import DistGraph, seed, seed_context
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# Simple configs — no dependencies
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestDistGraphSimple:
|
|
15
|
+
def test_single_distribution(self):
|
|
16
|
+
g = DistGraph({"x": "uniform(0, 1)"})
|
|
17
|
+
result = g.resolve_all()
|
|
18
|
+
assert "x" in result
|
|
19
|
+
assert 0 <= result["x"] <= 1
|
|
20
|
+
|
|
21
|
+
def test_numeric_value_passthrough(self):
|
|
22
|
+
g = DistGraph({"a": 42, "b": 3.14})
|
|
23
|
+
result = g.resolve_all()
|
|
24
|
+
assert result["a"] == 42
|
|
25
|
+
assert result["b"] == 3.14
|
|
26
|
+
|
|
27
|
+
def test_empty_config(self):
|
|
28
|
+
g = DistGraph({})
|
|
29
|
+
result = g.resolve_all()
|
|
30
|
+
assert result == {}
|
|
31
|
+
|
|
32
|
+
def test_arithmetic_expression_no_deps(self):
|
|
33
|
+
g = DistGraph({"x": "60 + 30 * uniform(0, 1)"})
|
|
34
|
+
result = g.resolve_all()
|
|
35
|
+
assert 60 <= result["x"] <= 90
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Dependencies — cross-references between keys
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestDistGraphDependencies:
|
|
44
|
+
def test_linear_chain(self):
|
|
45
|
+
g = DistGraph({"a": 10, "b": "a + 5", "c": "b * 2"})
|
|
46
|
+
result = g.resolve_all()
|
|
47
|
+
assert result["a"] == 10
|
|
48
|
+
assert result["b"] == 15
|
|
49
|
+
assert result["c"] == 30
|
|
50
|
+
|
|
51
|
+
def test_topological_order(self):
|
|
52
|
+
g = DistGraph({"a": 1, "b": "a + 1", "c": "a + b"})
|
|
53
|
+
result = g.resolve_all()
|
|
54
|
+
assert result["a"] == 1
|
|
55
|
+
assert result["b"] == 2
|
|
56
|
+
assert result["c"] == 3
|
|
57
|
+
# 'a' must come before 'b' and 'c'
|
|
58
|
+
order = g.order
|
|
59
|
+
assert order.index("a") < order.index("b")
|
|
60
|
+
assert order.index("a") < order.index("c")
|
|
61
|
+
|
|
62
|
+
def test_distribution_with_dependency(self):
|
|
63
|
+
g = DistGraph({"mean": 5.0, "x": "norm(mean, 0.1)"}, seed=42)
|
|
64
|
+
result = g.resolve_all()
|
|
65
|
+
assert result["mean"] == 5.0
|
|
66
|
+
assert abs(result["x"] - 5.0) < 1.0 # should be near mean
|
|
67
|
+
|
|
68
|
+
def test_separate_sampling_per_evaluation(self):
|
|
69
|
+
g = DistGraph({"a": "uniform(0, 1)", "b": "uniform(0, 1)"}, seed=42)
|
|
70
|
+
result = g.resolve_all()
|
|
71
|
+
# With same seed but separate calls, patterns should differ
|
|
72
|
+
assert result["a"] != result["b"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Circular dependencies
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestDistGraphCycles:
|
|
81
|
+
def test_direct_cycle_detected(self):
|
|
82
|
+
with pytest.raises(ParseError, match="Circular"):
|
|
83
|
+
DistGraph({"a": "b + 1", "b": "a + 1"})
|
|
84
|
+
|
|
85
|
+
def test_indirect_cycle_detected(self):
|
|
86
|
+
with pytest.raises(ParseError, match="Circular"):
|
|
87
|
+
DistGraph({"a": "b", "b": "c", "c": "a"})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# __getitem__ access
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TestDistGraphGetItem:
|
|
96
|
+
def test_lazy_resolution(self):
|
|
97
|
+
g = DistGraph({"a": 1, "b": "a + 2"})
|
|
98
|
+
assert g["a"] == 1
|
|
99
|
+
assert g["b"] == 3
|
|
100
|
+
|
|
101
|
+
def test_missing_key(self):
|
|
102
|
+
g = DistGraph({"a": 1})
|
|
103
|
+
with pytest.raises(KeyError):
|
|
104
|
+
_ = g["nonexistent"]
|
|
105
|
+
|
|
106
|
+
def test_contains_and_len(self):
|
|
107
|
+
g = DistGraph({"a": 1, "b": "a + 2"})
|
|
108
|
+
assert "a" in g
|
|
109
|
+
assert "c" not in g
|
|
110
|
+
assert len(g) == 2
|
|
111
|
+
|
|
112
|
+
def test_iter_and_keys(self):
|
|
113
|
+
g = DistGraph({"a": 1, "b": 2})
|
|
114
|
+
assert set(g) == {"a", "b"}
|
|
115
|
+
assert set(g.keys()) == {"a", "b"}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Seed management
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TestSeedManagement:
|
|
124
|
+
def test_global_seed_reproducible(self):
|
|
125
|
+
seed(42)
|
|
126
|
+
g1 = DistGraph({"x": "uniform(0, 1)"})
|
|
127
|
+
v1 = g1.resolve_all()["x"]
|
|
128
|
+
|
|
129
|
+
seed(42)
|
|
130
|
+
g2 = DistGraph({"x": "uniform(0, 1)"})
|
|
131
|
+
v2 = g2.resolve_all()["x"]
|
|
132
|
+
|
|
133
|
+
assert v1 == v2
|
|
134
|
+
|
|
135
|
+
def test_per_instance_seed_overrides_global(self):
|
|
136
|
+
seed(99)
|
|
137
|
+
g1 = DistGraph({"x": "uniform(0, 1)"}, seed=42)
|
|
138
|
+
g2 = DistGraph({"x": "uniform(0, 1)"}, seed=42)
|
|
139
|
+
assert g1.resolve_all()["x"] == g2.resolve_all()["x"]
|
|
140
|
+
|
|
141
|
+
def test_seed_context_manager(self):
|
|
142
|
+
seed(0)
|
|
143
|
+
with seed_context(42):
|
|
144
|
+
g = DistGraph({"x": "uniform(0, 1)"})
|
|
145
|
+
v_ctx = g.resolve_all()["x"]
|
|
146
|
+
|
|
147
|
+
# Outside context, back to seed(0) RNG
|
|
148
|
+
g2 = DistGraph({"x": "uniform(0, 1)"})
|
|
149
|
+
v_outside = g2.resolve_all()["x"]
|
|
150
|
+
|
|
151
|
+
# Different seeds should produce different values (usually)
|
|
152
|
+
assert v_ctx != v_outside
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Bounds
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class TestDistGraphBounds:
|
|
161
|
+
def test_bounds_dict_format(self):
|
|
162
|
+
g = DistGraph(
|
|
163
|
+
{
|
|
164
|
+
"wall": {
|
|
165
|
+
"dist": "norm(loc=0.25, scale=0.025)",
|
|
166
|
+
"bounds": {"loc": (0.0, 1.0), "scale": (0.001, 0.1)},
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
result = g.resolve_all()
|
|
171
|
+
assert abs(result["wall"] - 0.25) < 0.2
|
|
172
|
+
|
|
173
|
+
def test_get_bounds(self):
|
|
174
|
+
g = DistGraph({"x": {"dist": "uniform(0, 1)", "bounds": {"loc": (-1, 2)}}})
|
|
175
|
+
bounds = g.get_bounds("x")
|
|
176
|
+
assert bounds == {"loc": (-1, 2)}
|
|
177
|
+
|
|
178
|
+
def test_get_bounds_none(self):
|
|
179
|
+
g = DistGraph({"x": "uniform(0, 1)"})
|
|
180
|
+
assert g.get_bounds("x") is None
|
|
181
|
+
|
|
182
|
+
def test_missing_dist_key_raises(self):
|
|
183
|
+
with pytest.raises(ParseError, match="missing required 'dist'"):
|
|
184
|
+
DistGraph({"x": {"bounds": {"loc": (0, 1)}}})
|
|
185
|
+
|
|
186
|
+
def test_bounds_not_used_for_sampling(self):
|
|
187
|
+
# bounds are metadata only — sampling should still work
|
|
188
|
+
g = DistGraph(
|
|
189
|
+
{
|
|
190
|
+
"x": {
|
|
191
|
+
"dist": "norm(loc=100, scale=0.01)",
|
|
192
|
+
"bounds": {"loc": (0.0, 1.0)},
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
seed=42,
|
|
196
|
+
)
|
|
197
|
+
result = g.resolve_all()
|
|
198
|
+
# Value is near loc=100, not clipped to bounds (0, 1)
|
|
199
|
+
assert abs(result["x"] - 100.0) < 1.0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Math expressions
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class TestDistGraphMath:
|
|
208
|
+
def test_sin_cos(self):
|
|
209
|
+
g = DistGraph({"x": "sin(0)", "y": "cos(0)"})
|
|
210
|
+
result = g.resolve_all()
|
|
211
|
+
assert abs(result["x"]) < 1e-12
|
|
212
|
+
assert abs(result["y"] - 1.0) < 1e-12
|
|
213
|
+
|
|
214
|
+
def test_pi(self):
|
|
215
|
+
g = DistGraph({"x": "pi"})
|
|
216
|
+
result = g.resolve_all()
|
|
217
|
+
assert abs(result["x"] - np.pi) < 1e-12
|
|
218
|
+
|
|
219
|
+
def test_sqrt(self):
|
|
220
|
+
g = DistGraph({"x": "sqrt(16)"})
|
|
221
|
+
result = g.resolve_all()
|
|
222
|
+
assert abs(result["x"] - 4.0) < 1e-12
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
# Error cases
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class TestDistGraphErrors:
|
|
231
|
+
def test_invalid_expression(self):
|
|
232
|
+
g = DistGraph({"x": "1 / 0"})
|
|
233
|
+
with pytest.raises(ParseError, match="Error evaluating"):
|
|
234
|
+
g.resolve_all()
|
|
235
|
+
|
|
236
|
+
def test_undefined_variable(self):
|
|
237
|
+
g = DistGraph({"x": "unknown_func(0)"})
|
|
238
|
+
with pytest.raises(ParseError, match="Error evaluating"):
|
|
239
|
+
g.resolve_all()
|
|
240
|
+
|
|
241
|
+
def test_syntax_error_expression_graceful(self):
|
|
242
|
+
# Invalid Python syntax → extract returns empty set, init succeeds
|
|
243
|
+
g = DistGraph({"bad": "x +", "ok": "42"})
|
|
244
|
+
# 'ok' has no deps and is valid — resolves fine
|
|
245
|
+
assert g["ok"] == 42
|
|
246
|
+
# 'bad' has invalid syntax — evaluation raises
|
|
247
|
+
with pytest.raises(ParseError, match="Error evaluating"):
|
|
248
|
+
g.resolve_all()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
# Repr
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class TestDistGraphRepr:
|
|
257
|
+
def test_repr(self):
|
|
258
|
+
g = DistGraph({"a": 1, "b": "a + 2"})
|
|
259
|
+
assert "DistGraph" in repr(g)
|
|
260
|
+
assert "keys=2" in repr(g)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Tests for distparser.mapping."""
|
|
2
|
+
|
|
3
|
+
from distparser.mapping import DISTRIBUTION_ALIASES, normalize_dist_name
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestNormalizeDistName:
|
|
7
|
+
def test_known_alias(self):
|
|
8
|
+
assert normalize_dist_name("normal") == "norm"
|
|
9
|
+
assert normalize_dist_name("gaussian") == "norm"
|
|
10
|
+
assert normalize_dist_name("unif") == "uniform"
|
|
11
|
+
|
|
12
|
+
def test_unknown_name_passthrough(self):
|
|
13
|
+
assert normalize_dist_name("norm") == "norm"
|
|
14
|
+
assert normalize_dist_name("gamma") == "gamma"
|
|
15
|
+
assert normalize_dist_name("custom_dist") == "custom_dist"
|
|
16
|
+
|
|
17
|
+
def test_case_sensitive(self):
|
|
18
|
+
assert normalize_dist_name("Normal") == "Normal"
|
|
19
|
+
assert normalize_dist_name("NORM") == "NORM"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestAliasMap:
|
|
23
|
+
def test_contains_expected_aliases(self):
|
|
24
|
+
assert DISTRIBUTION_ALIASES["normal"] == "norm"
|
|
25
|
+
assert DISTRIBUTION_ALIASES["gaussian"] == "norm"
|
|
26
|
+
assert DISTRIBUTION_ALIASES["unif"] == "uniform"
|
distparser-0.2.0/README.md
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
# distparser
|
|
2
|
-
|
|
3
|
-
**Parse function‑call syntax strings into frozen `scipy.stats` distributions**
|
|
4
|
-
|
|
5
|
-
[](https://badge.fury.io/py/distparser)
|
|
6
|
-
[](https://www.python.org/downloads/)
|
|
7
|
-
[](https://opensource.org/licenses/MIT)
|
|
8
|
-
|
|
9
|
-
## Why `distparser`?
|
|
10
|
-
|
|
11
|
-
When you have configuration files or user input that specifies a probability
|
|
12
|
-
distribution (e.g. `"norm(loc=0, scale=1)"`), you want to turn that string
|
|
13
|
-
into a ready‑to‑sample object. `distparser` does exactly that, with a tiny
|
|
14
|
-
API and zero surprises.
|
|
15
|
-
|
|
16
|
-
## Installation
|
|
17
|
-
|
|
18
|
-
```bash
|
|
19
|
-
pip install distparser
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
## Quick Start
|
|
23
|
-
|
|
24
|
-
```
|
|
25
|
-
from distparser import parse_dist
|
|
26
|
-
|
|
27
|
-
# Positional arguments (order matters)
|
|
28
|
-
dist = parse_dist("uniform(0, 1)")
|
|
29
|
-
sample = dist.rvs() # e.g. 0.374
|
|
30
|
-
|
|
31
|
-
# Keyword arguments (order ignored)
|
|
32
|
-
dist = parse_dist("norm(loc=0, scale=1)")
|
|
33
|
-
samples = dist.rvs(size=5) # array([-0.12, 1.03, ...])
|
|
34
|
-
|
|
35
|
-
# Mixed (positional + keyword) – works, but not recommended
|
|
36
|
-
dist = parse_dist("expon(scale=2, loc=1)")
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
## Supported Distributions
|
|
40
|
-
|
|
41
|
-
Built‑in registry covers the most common continuous distributions:
|
|
42
|
-
|
|
43
|
-
- `uniform(loc, scale)`
|
|
44
|
-
- `norm(loc, scale)`
|
|
45
|
-
- `expon(loc, scale)`
|
|
46
|
-
- … and many more (see [docs](https://distparser.readthedocs.io)).
|
|
47
|
-
|
|
48
|
-
You can **add your own** at runtime:
|
|
49
|
-
|
|
50
|
-
```python
|
|
51
|
-
from distparser import register_distribution
|
|
52
|
-
from scipy.stats import gumbel_r
|
|
53
|
-
|
|
54
|
-
register_distribution("gumbel", gumbel_r, ["loc", "scale"])
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
## Development
|
|
58
|
-
|
|
59
|
-
See [AGENTS.md](AGENTS.md) for development setup and guidelines.
|
|
60
|
-
|
|
61
|
-
## License
|
|
62
|
-
|
|
63
|
-
MIT
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|