bounded-lognorm 0.1.0__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,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .ipynb_checkpoints/
6
+ build/
7
+ dist/
@@ -0,0 +1,105 @@
1
+ ## Attribution & References
2
+
3
+ ### Provenance
4
+
5
+ This notebook contains work of three distinct kinds. They are separated below so that credit — and responsibility for errors — lands in the right place.
6
+
7
+ **A. Original work — Ted Teske (2020–2024), sole author**
8
+
9
+ - The bounded lognormal (BL) and bounded lognormal 2 (BL2) distributions themselves: the piecewise construction (eq. 2) and the geometric-mean construction (eq. 3).
10
+ - The mode/σ reparametrization `lognorm_params`, including the quartic root formulation.
11
+ - The `rv_continuous` subclass design and its full public API: `pdf`, `cdf`, `ppf`, `rvs`, `stats`, `moment`, `entropy`, `nnlf`, `fit`, and the supporting helper functions.
12
+ - The adaptation of the truncated-normal entropy formula to BL2.
13
+ - All example usage, plots, and the empirical comparison to the normal distribution.
14
+
15
+ **B. Collaborative work — Ted Teske with Claude (Anthropic), 2026**
16
+
17
+ - *Phase 1 CDF analysis:* the demonstration that the BL2 CDF admits no elementary closed form, via the observation that no change of variable linearizes both `ln x` and `ln(upper − x)` simultaneously (they are coupled by `e^w + e^v = u`); together with the catalogue of tractable special cases and the saddle-point route.
18
+ - *Performance refactor of the class:* hoisting the shape parameters and normalizing constant `Z` out of `pdf` (with `lru_cache`); vectorized composite Gauss–Legendre quadrature; restriction of all integration to an *effective support* located by bisection outward from the mode; the cached PCHIP cumulative grid backing `ppf`/`rvs`/`median`.
19
+ - The regression test cell.
20
+ - *Phase 2 math verification and fixes:* proofs that the mode of BL2 is exactly `mode` and that BL2 is C∞ (BL is only C¹), that Z ≤ 1 (a Bhattacharyya coefficient), and of the effective-support truncation bound; a stable, scale-free solver for the `lognorm_params` quartic (same equation, new numerics); mode-graded quadrature panels; moments by quadrature in place of the grid sum; argument guards; regression Suite D against a 30-digit reference. See `docs/phase2_math_verification.md`.
21
+ - *Phase 3 scipy.stats conformance:* the observation that BL2 is an exact location-scale family (Z is scale-free), and the resulting standard form on [0, 1] with shapes `c` = relative mode and `s` = relative sigma, `loc` = lower, `scale` = upper − lower; the `from_bounds` / `bounds_to_shapes` / `to_bounds` bridge and its `None`-bound convention; the `_pdf`/`_logpdf`/`_cdf`/`_sf`/`_ppf`/`_rvs`/`_stats`/`_munp`/`_entropy`/`_fitstart` hooks with caching grouped by distinct (c, s); carrying the precision dial through freezing; entropy by quadrature of −f ln f; the 1e-40 effective-support cutoff for tail accuracy; regression Suite E against a 40-digit reference. See `docs/phase3_api_conformance.md`.
22
+
23
+ **C. Third-party work — cited below**
24
+
25
+ Attribution suggestion, not a rule: the usual convention in published work is that an AI system is **not** listed as an author, since authorship carries accountability an AI cannot hold. If you publish this, the conventional form is to claim the work as your own and add an acknowledgement of AI assistance — e.g. *"Portions of the numerical implementation were developed with assistance from Claude (Anthropic); the author reviewed and verified all derivations and results."* Note also that some journals now require disclosure of AI use. Category B above is written to give you the specifics such a disclosure would need.
26
+
27
+ A caveat worth recording, in the same spirit: the Phase 1 non-closed-form result is an argument plus a symbolic check, **not a proof**. It shows no *elementary* antiderivative was found and explains structurally why one is not expected. It does not rule out a closed form in terms of special functions. Treat it as a well-supported working conclusion.
28
+
29
+ ### Behavioural changes from the original
30
+
31
+ *Correction to an earlier version of this note.* It previously said the original's unnormalized density carried `1/(2π·√(s₁s₂))`. It did not: the original computed `sqrt(lognorm_pdf × lognorm_pdf)`, which gives the correct `1/√(2π·s₁s₂)`. The wrong constant existed only in an intermediate draft of the 2026 refactor and was fixed before release, so `normed=False` output is unchanged from the original.
32
+
33
+ Phase 2 (Nov 2026) intentionally changes these results:
34
+
35
+ - **`mean`, `var`, `std`, `stats`, `moment`, `entropy`.** The original grid-sum moments were biased (weights of `(upper − lower)/n` instead of `/(n − 1)`, never renormalized), applied a sample-skewness correction to a population moment, and aliased narrow peaks on a fixed 1001-point grid. They are now integrated by quadrature and match a 30-digit reference to ~1e-12. Example: `var(100, 1)` was 2.68; it is now 0.99999.
36
+ - **Extreme parameters.** sigma/mode outside roughly 1e-5 to 1e2 (sigma → 0, very wide bounds, mode very near a bound) previously lost accuracy or returned NaN; results there now agree with the reference down to the float64 resolution floor.
37
+ - **Invalid inputs raise.** Infinite bounds, and invalid parameters passed with `ignore_checks=True`, now raise `ValueError` instead of returning NaN or a plausible-looking number. A `RuntimeWarning` flags sigma below 1e-8 of the coordinate scale.
38
+
39
+ Phase 3 (Nov 2026) changes the public API. Details and a migration guide are in `docs/phase3_api_conformance.md`:
40
+
41
+ - **Signature.** Every method now takes `(c, s, loc, scale)` instead of `(mode, sigma, lower, upper)`. Old positional calls are read as the new parameters and return NaN (scipy's convention for invalid shapes) rather than raising. `bl2.from_bounds(mode, sigma, lower, upper)` reproduces the old parameter sets, and only it accepts `None` bounds.
42
+ - **`entropy`** is now BL2's own differential entropy, by quadrature. The original truncated-normal adaptation is retired: it was close for near-symmetric cases (2.860 vs 2.864 nats at mode 5, σ 10 on (−5, 20)) but off by 0.18 nats with the mode near a bound (0.413 vs 0.236 at mode 0.01, σ 1 on (0, 10)). Both figures use the corrected Phase 2 moments in the old formula.
43
+ - **`moment(n)`** returns a value for every n (it returned `None` for n ≥ 5).
44
+ - **`fit`** is scipy's generic estimator (MLE, or `method='MM'`) over (c, s, loc, scale), with `fc`/`fs`/`floc`/`fscale` to fix parameters. The original least-squares-on-the-empirical-CDF option (`MLE=False`) and fixing `mode` or one bound alone are not carried over.
45
+ - **`ignore_checks` and `normed`** are gone. `normalizing_constant(c, s)` returns Z; `pdf × Z` is the old `normed=False` density.
46
+ - **Tails.** `sf` is integrated from the right and the effective-support cutoff moved from 1e-14 to 1e-40 of the peak density, so upper-tail `sf` and lower-tail `cdf` keep ~1e-12 relative accuracy down to ~1e-32 (previously ~1e-16 absolute).
47
+
48
+ ### Link audit
49
+
50
+ Checked July 2026. Changes from the original reference list:
51
+
52
+ - **OSTI (Ford & McLaren)** — the `servlets/purl` deep link is fragile; the stable record is `osti.gov/biblio/5121047`. Updated below.
53
+ - **MathWave** — still live, but the site is unmaintained (last copyright 2017) and the domain is advertised for sale. Archive a copy if you rely on it.
54
+ - **`people.sc.fsu.edu/~jpeterson/numerical_quadrature`** — could not be resolved; appears dead. Replaced with a stable textbook reference (Davis & Rabinowitz) plus the SciPy docs.
55
+ - **`berkeley-stat159-f17.github.io/.../trapezoid..html`** — malformed URL (doubled period) and could not be resolved. Replaced with the NumPy/SciPy documentation, which is what the code actually uses.
56
+ - **`appliedbusinesseconomics.com/files\gvsplndrn01.pdf`** — could not be resolved; the backslash is also not a valid URL path separator. Recommend removing it or replacing it with the specific claim it supported.
57
+ - **Towards Data Science / Medium** — unstable and paywalled; retained only as an informal pointer, not a load-bearing citation.
58
+ - **Wang et al.** — the ResearchGate link is not canonical. Cite the *Transportation Research Record* article of record. I have **not** filled in a DOI, because I could not verify one and will not guess at it; please look it up.
59
+
60
+ ### References
61
+
62
+ **Distributions and statistics**
63
+
64
+ 1. Log-normal distribution. https://en.wikipedia.org/wiki/Log-normal_distribution
65
+ 2. Normal distribution. https://en.wikipedia.org/wiki/Normal_distribution
66
+ 3. Truncated normal distribution. https://en.wikipedia.org/wiki/Truncated_normal_distribution — basis for the original `entropy` adaptation (replaced in Phase 3).
67
+ 4. Mode (statistics). https://en.wikipedia.org/wiki/Mode_(statistics)
68
+ 5. Probability density function. https://en.wikipedia.org/wiki/Probability_density_function
69
+ 6. Cumulative distribution function. https://en.wikipedia.org/wiki/Cumulative_distribution_function
70
+ 7. Quantile function. https://en.wikipedia.org/wiki/Quantile_function
71
+ 8. Differential entropy. https://en.wikipedia.org/wiki/Differential_entropy
72
+ 9. Inverse transform sampling. https://en.wikipedia.org/wiki/Inverse_transform_sampling
73
+ 10. NIST/SEMATECH e-Handbook of Statistical Methods, §1.3.5.11 (measures of skewness and kurtosis). https://www.itl.nist.gov/div898/handbook/eda/section3/eda35b.htm
74
+ 11. Raiford, T. E. "Skewness of Combined Distributions." *Journal of the American Statistical Association*, vol. 37, no. 219, 1942, pp. 391–393. JSTOR, https://www.jstor.org/stable/2279009
75
+ 12. Wang, Y., et al. "Speed Modeling and Travel Time Estimation Based on Truncated Normal and Lognormal Distributions." *Transportation Research Record*, no. 2315, 2012, pp. 66–72. [DOI to be verified.]
76
+ 13. Ford, C. E., and McLaren, R. A. *Methods for Obtaining Distributions of Uranium Occurrence from Estimates of Geologic Features.* Union Carbide Corp. for the U.S. Dept. of Energy, 1980. OSTI, https://www.osti.gov/biblio/5121047
77
+
78
+ **Reparametrization (mode, σ → μ_N, σ_N)**
79
+
80
+ 14. Exact quartic root adapted from the Wolfram Alpha result for `solve[g^4 - g^3 - sigma^2/m^2] for g`. https://www.wolframalpha.com (accessed 21 Oct. 2020).
81
+ 15. Numerical fallback adapted from Warren Weckesser's answer at https://stackoverflow.com/questions/41464753 (accessed 21 Oct. 2020).
82
+ 16. Scalar-coercion idiom from https://stackoverflow.com/questions/67097840
83
+
84
+ *Stack Overflow contributions are licensed CC BY-SA. Retaining the attributions above satisfies the attribution requirement; if this notebook is redistributed, note the share-alike term.*
85
+
86
+ **Numerical methods**
87
+
88
+ 17. Davis, P. J., and Rabinowitz, P. *Methods of Numerical Integration*, 2nd ed. Academic Press, 1984. — Gauss–Legendre and composite quadrature.
89
+ 18. Fritsch, F. N., and Carlson, R. E. "Monotone Piecewise Cubic Interpolation." *SIAM Journal on Numerical Analysis*, vol. 17, no. 2, 1980, pp. 238–246. — the PCHIP scheme used for the cached CDF/PPF grid.
90
+ 19. `scipy.stats.rv_continuous`. https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.rv_continuous.html
91
+ 20. `scipy.optimize.curve_fit`. https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.curve_fit.html — used by the original CDF-curve `fit` (not carried into Phase 3).
92
+ 21. `scipy.interpolate.PchipInterpolator`. https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.PchipInterpolator.html
93
+ 22. `scipy.integrate.cumulative_trapezoid`. https://docs.scipy.org/doc/scipy/reference/generated/scipy.integrate.cumulative_trapezoid.html
94
+ 23. `numpy.polynomial.legendre.leggauss`. https://numpy.org/doc/stable/reference/generated/numpy.polynomial.legendre.leggauss.html
95
+ 24. `functools.lru_cache`. https://docs.python.org/3/library/functools.html#functools.lru_cache
96
+
97
+ **Curve fitting to data**
98
+
99
+ 25. MathWorks. "Fitting a Univariate Distribution Using Cumulative Probabilities." https://www.mathworks.com/help/stats/examples/fitting-a-univariate-distribution-using-cumulative-probabilities.html
100
+ 26. "PDF vs CDF fitting." Mathematics Stack Exchange. https://math.stackexchange.com/questions/2365267
101
+ 27. MathWave Technologies. "Distribution Fitting — Using Distribution Graphs." http://www.mathwave.com/articles/distribution-fitting-graphs.html *(site unmaintained; archive if relied upon)*
102
+
103
+ **Informal / superseded (retained for provenance only)**
104
+
105
+ 28. Rejection-sampling approach, retained as a commented alternative in `rvs`: https://stackoverflow.com/questions/41470070
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The project follows [Semantic Versioning](https://semver.org/); while the version is 0.x, minor releases may change the API.
4
+
5
+ ## 0.1.0 (2026-11-01)
6
+
7
+ First release on PyPI, as `bounded-lognorm`.
8
+
9
+ - The BL2 (bounded lognormal 2) distribution as a `scipy.stats.rv_continuous` subclass: standard form on [0, 1] with shapes `c` (relative mode) and `s` (relative sigma), plus the `from_bounds` / `bounds_to_shapes` / `to_bounds` bridge for the (mode, sigma, lower, upper) form.
10
+ - A speed/precision dial (`precision='fast' | 'balanced' | 'precise' | 'exact'`), carried through freezing and pickling.
11
+ - Every generic scipy method works, including `fit`, `moment(n)` for any n, `entropy` and freezing.
12
+ - The implementation module is `bounded_lognorm._bl2`; `bounded_lognorm.bl2` is the distribution instance only, as with `scipy.stats.norm`.
13
+ - Changes from the pre-package notebook versions are listed in [ATTRIBUTION.md](ATTRIBUTION.md) and the Phase 2 and Phase 3 notes in `docs/`.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 Ted Teske
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: bounded-lognorm
3
+ Version: 0.1.0
4
+ Summary: Bounded lognormal (BL2) continuous distribution for scipy.stats: a smooth, mode/sigma-parametrized distribution on a finite interval.
5
+ Project-URL: Homepage, https://github.com/tedteske/bounded_lognorm
6
+ Project-URL: Source, https://github.com/tedteske/bounded_lognorm
7
+ Project-URL: Issues, https://github.com/tedteske/bounded_lognorm/issues
8
+ Project-URL: Changelog, https://github.com/tedteske/bounded_lognorm/blob/master/CHANGELOG.md
9
+ Author: Ted Teske
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: bounded,distribution,lognormal,monte carlo,probability,scipy,statistics
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Programming Language :: Python :: 3.15
24
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: numpy>=1.23.5
27
+ Requires-Dist: scipy>=1.9
28
+ Description-Content-Type: text/markdown
29
+
30
+ # bounded-lognorm
31
+
32
+ A bounded lognormal continuous probability distribution for `scipy.stats`.
33
+
34
+ The **bounded lognormal** is derived from the [lognormal distribution](https://en.wikipedia.org/wiki/Log-normal_distribution). Unlike the normal and lognormal, its domain has both a lower and an upper bound, and it may be symmetric or skewed either way depending on its parameters. That makes it a natural fit for quantities with known limits, such as adult height, task durations with a hard deadline, or sprint times.
35
+
36
+ It is parametrized the way people tend to describe such quantities: by its **mode** (the most likely value), a spread **sigma**, and the **bounds**.
37
+
38
+ This package provides **BL2**, the smooth (C∞) form of the distribution, as a full `scipy.stats.rv_continuous` subclass. Every scipy method works: `pdf`, `cdf`, `ppf`, `rvs`, `stats`, `moment`, `entropy`, `fit`, freezing, and the rest.
39
+
40
+ ## Installation
41
+
42
+ ```
43
+ pip install bounded-lognorm
44
+ ```
45
+
46
+ Requires Python 3.10+, NumPy 1.23.5+ and SciPy 1.9+.
47
+
48
+ ## Quick start
49
+
50
+ ```python
51
+ from bounded_lognorm import bl2
52
+
53
+ rv = bl2.from_bounds(mode=5, sigma=10, lower=-5, upper=20) # frozen distribution
54
+ rv.pdf(0.0), rv.cdf(0.0), rv.ppf(0.9)
55
+ rv.mean(), rv.std(), rv.entropy()
56
+ sample = rv.rvs(size=1000, random_state=0)
57
+
58
+ c, s, loc, scale = bl2.fit(sample) # scipy's maximum-likelihood fit
59
+ mode, sigma, lower, upper = bl2.to_bounds(c, s, loc, scale)
60
+
61
+ bl2.from_bounds(mode=0, sigma=1) # None bound = unbounded on that side
62
+ ```
63
+
64
+ ## Definition
65
+
66
+ Let `f_L(x; m, σ)` be the lognormal density written in terms of its mode `m` and standard deviation `σ` (rather than the usual `μ_N`, `σ_N` of the underlying normal). BL2 is the normalized geometric mean of a lognormal rising from the lower bound and a mirrored lognormal falling to the upper bound:
67
+
68
+ ```
69
+ g(x) = sqrt( f_L(x - lower; mode - lower, σ) · f_L(upper - x; upper - mode, σ) )
70
+
71
+ pdf(x) = g(x) / Z, Z = ∫ g(x) dx over (lower, upper)
72
+ ```
73
+
74
+ Its mode is exactly `mode`. `sigma` is the standard deviation of each lognormal factor, not of BL2 itself; the two agree as the bounds move far from the mode.
75
+
76
+ The CDF has no elementary closed form, so it is computed by Gauss–Legendre quadrature over the region where the density is non-negligible, with the normalizing constant and a quantile grid cached per parameter set.
77
+
78
+ ## Parametrization
79
+
80
+ BL2 is an exact location-scale family, so it follows the scipy convention: a standard form on [0, 1] with two shape parameters, moved by `loc` and `scale`.
81
+
82
+ - `loc = lower`, `scale = upper - lower`
83
+ - `c = (mode - lower) / (upper - lower)`, the relative mode, 0 < c < 1 (as in `scipy.stats.triang`)
84
+ - `s = sigma / (upper - lower)`, the relative sigma, s > 0
85
+
86
+ `bl2.from_bounds(mode, sigma, lower, upper)` converts and returns a frozen distribution; `bl2.bounds_to_shapes` and `bl2.to_bounds` convert either way. The native form works like any scipy distribution:
87
+
88
+ ```python
89
+ bl2.pdf(5, c=0.4, s=0.4, loc=-5, scale=25) # same as rv.pdf(5) above
90
+ ```
91
+
92
+ `from_bounds` raises `ValueError` for invalid parameters. The native form follows scipy and returns NaN.
93
+
94
+ ## Speed and precision
95
+
96
+ Set once, at construction:
97
+
98
+ ```python
99
+ from bounded_lognorm import bounded_lognorm2_gen
100
+
101
+ exact = bounded_lognorm2_gen(precision='exact')
102
+ rv = exact.from_bounds(mode=5, sigma=10, lower=-5, upper=20)
103
+ ```
104
+
105
+ - `'fast'`: plotting and exploration. `ppf` round-trip error about 2e-6.
106
+ - `'balanced'` (the default, used by `bl2`): ordinary work and fitting. About 1e-7.
107
+ - `'precise'`: about 4e-9.
108
+ - `'exact'`: `ppf` by root-finding, about 1e-13, but roughly 300 times slower per quantile. Use it for one-off high-accuracy quantiles, not for inverting thousands at once.
109
+
110
+ The CDF is accurate to about 1e-13 at every position, and `rvs` uses the quantile grid at every position. Frozen distributions keep their instance's setting.
111
+
112
+ ## Testing
113
+
114
+ ```
115
+ pip install -e . --group test # pip 25.1 or later
116
+ pytest
117
+ ```
118
+
119
+ The suite checks the distribution against independent 30- and 40-digit mpmath reference values at every precision setting.
120
+
121
+ ## Background and history
122
+
123
+ The original bounded lognormal (BL) is piecewise: a lognormal below the mode and a mirrored, rescaled lognormal above it. It has closed-form CDF and quantile functions, but its density is only C¹ at the mode, where the second derivative jumps. BL2 replaces the two pieces with their geometric mean, which is smooth everywhere. The two are different distributions, not two versions of one, so results computed with BL will not reproduce with BL2. BL remains available in the [`bounded_lognorm.ipynb`](https://github.com/tedteske/bounded_lognorm/blob/master/bounded_lognorm.ipynb) notebook; this package ships BL2 only.
124
+
125
+ Derivations and verification notes:
126
+
127
+ - [Phase 1: why the CDF has no elementary closed form](https://github.com/tedteske/bounded_lognorm/blob/master/docs/phase1_cdf_derivation.md)
128
+ - [Phase 2: math verification and numerical fixes](https://github.com/tedteske/bounded_lognorm/blob/master/docs/phase2_math_verification.md)
129
+ - [Phase 3: scipy.stats conformance and migration from the old API](https://github.com/tedteske/bounded_lognorm/blob/master/docs/phase3_api_conformance.md)
130
+ - [Phase 4: packaging](https://github.com/tedteske/bounded_lognorm/blob/master/docs/phase4_packaging.md)
131
+
132
+ ## Attribution and license
133
+
134
+ The bounded lognormal distributions and their original implementation are by Ted Teske (2020–2024). The 2026 optimization, verification and scipy conformance work was done by Ted Teske with assistance from Claude (Anthropic). See [ATTRIBUTION.md](https://github.com/tedteske/bounded_lognorm/blob/master/ATTRIBUTION.md) for the detailed breakdown and references.
135
+
136
+ MIT License. See [LICENSE](https://github.com/tedteske/bounded_lognorm/blob/master/LICENSE).
@@ -0,0 +1,107 @@
1
+ # bounded-lognorm
2
+
3
+ A bounded lognormal continuous probability distribution for `scipy.stats`.
4
+
5
+ The **bounded lognormal** is derived from the [lognormal distribution](https://en.wikipedia.org/wiki/Log-normal_distribution). Unlike the normal and lognormal, its domain has both a lower and an upper bound, and it may be symmetric or skewed either way depending on its parameters. That makes it a natural fit for quantities with known limits, such as adult height, task durations with a hard deadline, or sprint times.
6
+
7
+ It is parametrized the way people tend to describe such quantities: by its **mode** (the most likely value), a spread **sigma**, and the **bounds**.
8
+
9
+ This package provides **BL2**, the smooth (C∞) form of the distribution, as a full `scipy.stats.rv_continuous` subclass. Every scipy method works: `pdf`, `cdf`, `ppf`, `rvs`, `stats`, `moment`, `entropy`, `fit`, freezing, and the rest.
10
+
11
+ ## Installation
12
+
13
+ ```
14
+ pip install bounded-lognorm
15
+ ```
16
+
17
+ Requires Python 3.10+, NumPy 1.23.5+ and SciPy 1.9+.
18
+
19
+ ## Quick start
20
+
21
+ ```python
22
+ from bounded_lognorm import bl2
23
+
24
+ rv = bl2.from_bounds(mode=5, sigma=10, lower=-5, upper=20) # frozen distribution
25
+ rv.pdf(0.0), rv.cdf(0.0), rv.ppf(0.9)
26
+ rv.mean(), rv.std(), rv.entropy()
27
+ sample = rv.rvs(size=1000, random_state=0)
28
+
29
+ c, s, loc, scale = bl2.fit(sample) # scipy's maximum-likelihood fit
30
+ mode, sigma, lower, upper = bl2.to_bounds(c, s, loc, scale)
31
+
32
+ bl2.from_bounds(mode=0, sigma=1) # None bound = unbounded on that side
33
+ ```
34
+
35
+ ## Definition
36
+
37
+ Let `f_L(x; m, σ)` be the lognormal density written in terms of its mode `m` and standard deviation `σ` (rather than the usual `μ_N`, `σ_N` of the underlying normal). BL2 is the normalized geometric mean of a lognormal rising from the lower bound and a mirrored lognormal falling to the upper bound:
38
+
39
+ ```
40
+ g(x) = sqrt( f_L(x - lower; mode - lower, σ) · f_L(upper - x; upper - mode, σ) )
41
+
42
+ pdf(x) = g(x) / Z, Z = ∫ g(x) dx over (lower, upper)
43
+ ```
44
+
45
+ Its mode is exactly `mode`. `sigma` is the standard deviation of each lognormal factor, not of BL2 itself; the two agree as the bounds move far from the mode.
46
+
47
+ The CDF has no elementary closed form, so it is computed by Gauss–Legendre quadrature over the region where the density is non-negligible, with the normalizing constant and a quantile grid cached per parameter set.
48
+
49
+ ## Parametrization
50
+
51
+ BL2 is an exact location-scale family, so it follows the scipy convention: a standard form on [0, 1] with two shape parameters, moved by `loc` and `scale`.
52
+
53
+ - `loc = lower`, `scale = upper - lower`
54
+ - `c = (mode - lower) / (upper - lower)`, the relative mode, 0 < c < 1 (as in `scipy.stats.triang`)
55
+ - `s = sigma / (upper - lower)`, the relative sigma, s > 0
56
+
57
+ `bl2.from_bounds(mode, sigma, lower, upper)` converts and returns a frozen distribution; `bl2.bounds_to_shapes` and `bl2.to_bounds` convert either way. The native form works like any scipy distribution:
58
+
59
+ ```python
60
+ bl2.pdf(5, c=0.4, s=0.4, loc=-5, scale=25) # same as rv.pdf(5) above
61
+ ```
62
+
63
+ `from_bounds` raises `ValueError` for invalid parameters. The native form follows scipy and returns NaN.
64
+
65
+ ## Speed and precision
66
+
67
+ Set once, at construction:
68
+
69
+ ```python
70
+ from bounded_lognorm import bounded_lognorm2_gen
71
+
72
+ exact = bounded_lognorm2_gen(precision='exact')
73
+ rv = exact.from_bounds(mode=5, sigma=10, lower=-5, upper=20)
74
+ ```
75
+
76
+ - `'fast'`: plotting and exploration. `ppf` round-trip error about 2e-6.
77
+ - `'balanced'` (the default, used by `bl2`): ordinary work and fitting. About 1e-7.
78
+ - `'precise'`: about 4e-9.
79
+ - `'exact'`: `ppf` by root-finding, about 1e-13, but roughly 300 times slower per quantile. Use it for one-off high-accuracy quantiles, not for inverting thousands at once.
80
+
81
+ The CDF is accurate to about 1e-13 at every position, and `rvs` uses the quantile grid at every position. Frozen distributions keep their instance's setting.
82
+
83
+ ## Testing
84
+
85
+ ```
86
+ pip install -e . --group test # pip 25.1 or later
87
+ pytest
88
+ ```
89
+
90
+ The suite checks the distribution against independent 30- and 40-digit mpmath reference values at every precision setting.
91
+
92
+ ## Background and history
93
+
94
+ The original bounded lognormal (BL) is piecewise: a lognormal below the mode and a mirrored, rescaled lognormal above it. It has closed-form CDF and quantile functions, but its density is only C¹ at the mode, where the second derivative jumps. BL2 replaces the two pieces with their geometric mean, which is smooth everywhere. The two are different distributions, not two versions of one, so results computed with BL will not reproduce with BL2. BL remains available in the [`bounded_lognorm.ipynb`](https://github.com/tedteske/bounded_lognorm/blob/master/bounded_lognorm.ipynb) notebook; this package ships BL2 only.
95
+
96
+ Derivations and verification notes:
97
+
98
+ - [Phase 1: why the CDF has no elementary closed form](https://github.com/tedteske/bounded_lognorm/blob/master/docs/phase1_cdf_derivation.md)
99
+ - [Phase 2: math verification and numerical fixes](https://github.com/tedteske/bounded_lognorm/blob/master/docs/phase2_math_verification.md)
100
+ - [Phase 3: scipy.stats conformance and migration from the old API](https://github.com/tedteske/bounded_lognorm/blob/master/docs/phase3_api_conformance.md)
101
+ - [Phase 4: packaging](https://github.com/tedteske/bounded_lognorm/blob/master/docs/phase4_packaging.md)
102
+
103
+ ## Attribution and license
104
+
105
+ The bounded lognormal distributions and their original implementation are by Ted Teske (2020–2024). The 2026 optimization, verification and scipy conformance work was done by Ted Teske with assistance from Claude (Anthropic). See [ATTRIBUTION.md](https://github.com/tedteske/bounded_lognorm/blob/master/ATTRIBUTION.md) for the detailed breakdown and references.
106
+
107
+ MIT License. See [LICENSE](https://github.com/tedteske/bounded_lognorm/blob/master/LICENSE).
@@ -0,0 +1,93 @@
1
+ # BL2 — Closed-Form CDF Derivation (Phase 1)
2
+
3
+ **Distribution:** `bounded_lognorm2` (BL2), the geometric-mean ("non-piecewise") bounded lognormal from `bounded_lognorm2.ipynb`.
4
+
5
+ **Phase 1 goal:** Determine whether the BL2 CDF admits a closed form; if not, isolate precisely where the integral fails to close and map the tractable routes for Phase 2.
6
+
7
+ **Result (headline):** No elementary closed form exists in general. The obstruction is structural, not a solver artifact, and is confirmed symbolically. Several exact anchors and closed-form *approximations* do exist and are the natural Phase 2 targets.
8
+
9
+ ---
10
+
11
+ ## 1. Setup and normalization
12
+
13
+ Shift the domain so the lower bound sits at 0:
14
+
15
+ $$u \equiv \text{upper}-\text{lower}, \qquad m \equiv \text{mode}-\text{lower}, \qquad x \leftarrow x-\text{lower}, \qquad x\in(0,u).$$
16
+
17
+ Let $\Lambda(\cdot,\sigma)$ denote `lognorm_params` — the map from (mode, σ) to the underlying normal parameters, obtained by taking the real positive root $g$ of
18
+
19
+ $$g^4 - g^3 - \sigma^2/\text{mode}^2 = 0, \qquad s_N=\sqrt{\ln g}, \qquad \mu_N=\ln(\text{mode}\cdot g).$$
20
+
21
+ Define the two parameter sets used by the left and (reflected) right factors:
22
+
23
+ $$(\mu_1,s_1)=\Lambda(m,\sigma), \qquad (\mu_2,s_2)=\Lambda(u-m,\sigma).$$
24
+
25
+ The BL2 density is the **geometric mean** of a lognormal placed at the lower bound and a reflected lognormal placed at the upper bound:
26
+
27
+ $$f_{BL2}(x)=\frac{1}{Z}\,\sqrt{f_L(x;\mu_1,s_1)\;f_L(u-x;\mu_2,s_2)}.$$
28
+
29
+ Writing out both lognormal pdfs and taking the square root gives the **unnormalized density**
30
+
31
+ $$\boxed{\;g(x)=\frac{1}{\sqrt{2\pi\,s_1 s_2}}\cdot\frac{1}{\sqrt{x(u-x)}}\;\exp\!\left[-\frac{(\ln x-\mu_1)^2}{4s_1^2}-\frac{(\ln(u-x)-\mu_2)^2}{4s_2^2}\right]\;}$$
32
+
33
+ and the CDF
34
+
35
+ $$F(X)=\frac{1}{Z}\int_0^X g(x)\,dx, \qquad Z=\int_0^u g(x)\,dx.$$
36
+
37
+ *(This matches the notebook exactly: `pdf` builds `sqrt(y_left * y_right)` then divides by `Z = cdf(upper, ..., normed=False)`.)*
38
+
39
+ ---
40
+
41
+ ## 2. Where the integral fails to close
42
+
43
+ Substitute $w=\ln x$ (so $x=e^w$, $dx=e^w\,dw$). The prefactor $1/\sqrt{x}$ combines with $dx$ to give $e^{w/2}$, and the integral becomes
44
+
45
+ $$\int \frac{e^{w/2}}{\sqrt{\,u-e^{w}\,}}\;
46
+ \underbrace{\exp\!\left[-\frac{(w-\mu_1)^2}{4s_1^2}\right]}_{\text{Gaussian in }w}\;
47
+ \underbrace{\exp\!\left[-\frac{\big(\ln(u-e^{w})-\mu_2\big)^2}{4s_2^2}\right]}_{\text{transcendental in }w}\,dw.$$
48
+
49
+ - The **left** factor is a clean Gaussian in $w$ → would integrate to `erf`.
50
+ - The **right** factor contains $\ln(u-e^{w})$, which is **not linear or polynomial in $w$**. The product is therefore not Gaussian, and no elementary antiderivative exists.
51
+
52
+ The symmetric substitution $v=\ln(u-x)$ fixes the right factor but breaks the left in the identical way. **No single change of variable linearizes both logs simultaneously**, because $\ln x$ and $\ln(u-x)$ are constrained by
53
+
54
+ $$e^{w}+e^{v}=u,$$
55
+
56
+ which is not log-linear. This coupling — the reflection through the upper bound — is the entire obstruction. (Note it is *intrinsic* to the reflected-geometric-mean construction; it is not introduced by the `lognorm_params` reparametrization, which only sets the constants $\mu_i, s_i$.)
57
+
58
+ **Symbolic confirmation.** SymPy returns the integral of $g(x)$ unevaluated in both the general case and the symmetric case $\mu_1=\mu_2,\ s_1=s_2$ — consistent with the argument above rather than a failure to simplify.
59
+
60
+ ---
61
+
62
+ ## 3. Exact anchors and tractable regimes (Phase 2 material)
63
+
64
+ Although the full CDF is non-elementary, the following are exact or closed-form:
65
+
66
+ **(a) Single-lognormal limits.** When one factor is effectively constant over the region carrying the mass (e.g. `upper` far from the mode relative to σ), $g$ collapses to a single lognormal and
67
+
68
+ $$F(X)\to \tfrac12+\tfrac12\,\mathrm{erf}\!\left(\frac{\ln X-\mu_1}{\sqrt2\,s_1}\right),$$
69
+
70
+ an exact `erf` form usable for the lower tail / limiting checks.
71
+
72
+ **(b) Normal-approximation regime.** As both bounds recede from the mode (large "spread"), BL2 → Normal(mode, σ²) — the notebook already demonstrates this convergence. This yields a closed-form `erf` approximation that is asymptotically exact in that regime.
73
+
74
+ **(c) Symmetric special case, $m=u/2$.** Then $(\mu_1,s_1)=(\mu_2,s_2)$ and the density is symmetric about $u/2$. Even without an elementary antiderivative, this gives exact anchors:
75
+
76
+ $$F(u/2)=\tfrac12, \qquad F(u-x)=1-F(x),$$
77
+
78
+ halving the quadrature work and providing a correctness check.
79
+
80
+ **(d) Saddle-point / Laplace expansion (recommended core route).** Expand the "harder" factor's exponent about the dominant saddle $w^\*$ of the full integrand. Each truncation of $\ln(u-e^{w})$ to polynomial order turns the integrand into *Gaussian × polynomial*, which integrates **in closed form** to `erf` × (polynomial in the endpoint). This produces a controlled closed-form series approximation for $F$ with tunable accuracy.
81
+
82
+ **(e) Series in a coupling parameter.** When $s_2$ is small (right factor sharply peaked) or the two peaks are well separated, expand one factor around its peak and integrate term by term — again Gaussian moments, closed form per term.
83
+
84
+ ---
85
+
86
+ ## 4. Recommended Phase 2
87
+
88
+ 1. Derive the saddle-point closed form from **3(d)**: leading `erf` term + first correction, with an explicit error bound.
89
+ 2. Add the exact symmetric anchors **3(c)** and single-lognormal limits **3(a)** as boundary conditions / validation.
90
+ 3. Benchmark the closed-form approximation against the existing `fixed_quad` `cdf` across the notebook's parameter sweeps (varying mode, σ, and bound spread), reporting max abs error and speedup.
91
+ 4. If a uniform-accuracy closed form proves elusive, fall back to a hybrid: closed-form `erf` backbone (b) with a low-order saddle correction (d).
92
+
93
+ **Bottom line for Phase 1:** the exact CDF is irreducibly an integral; the productive path is a closed-form *approximation* built on the Gaussian left factor plus a polynomial expansion of the reflected log term, anchored by the exact symmetric and limiting cases.
@@ -0,0 +1,66 @@
1
+ # BL2 — Math Verification and Fixes (Phase 2)
2
+
3
+ **Status:** verification complete; fixes implemented on branch `phase2-math-fixes` (Nov 2026). Full test suite: 346/346 passing, including the new Suite D.
4
+
5
+ **Epistemic labels.** *Proof* = a complete argument (SymPy-confirmed where marked). *Numerical* = measured against an independent 30-digit mpmath reference; true for the cases tested, not a theorem. *Sketch* = an argument with a named gap.
6
+
7
+ **Reference model.** mpmath at 30 digits: the `lognorm_params` quartic solved by bisection on a stable form; integrals by tanh-sinh quadrature over ~130 break points graded toward the mode and both bounds. It shares no numerics with the library.
8
+
9
+ ---
10
+
11
+ ## 1. The mathematics holds
12
+
13
+ - **Eq. 1, lognormal pdf (Proof, SymPy).** It is the density of e^W, W ~ N(μ, s²): integrates to 1, mode e^(μ−s²), E[X^k] = exp(kμ + k²s²/2), Var = (e^(s²) − 1)e^(2μ+s²).
14
+ - **`lognorm_params` (Proof).** With g = e^(s²), mode = m gives μ = ln(m g) and Var = m² g³(g − 1), so SD = σ is g⁴ − g³ − σ²/m² = 0. The quartic is negative on (0, 1] and increasing for g ≥ 1, so it has exactly one positive root and that root exceeds 1. σ is the SD of each untruncated factor, not of BL2. The original closed-form root satisfies the quartic to 160 digits in exact arithmetic (Numerical).
15
+ - **Eq. 3 (Proof, SymPy).** √(f_L(a; μ₁, s₁) f_L(b; μ₂, s₂)) equals the fused `_unnorm_pdf` form exactly, with a = x − lower, b = upper − x.
16
+ - **Notation.** Eq. 2–3 in the notebook are printed for lower = 0 and without the 1/Z factor; the code shifts by `lower` and normalizes.
17
+ - **Record correction.** The original `pdf` already carried 1/√(2π s₁ s₂). The 1/(2π√(s₁s₂)) constant existed only in an intermediate draft of the 2026 refactor; `ATTRIBUTION.md` and the `bl2.py` header now say so.
18
+
19
+ ## 2. Mode and smoothness (Proof)
20
+
21
+ - log g(a) = ½[h₁(a) + h₂(u − a)]. h₁′(a) = (ln m − ln a)/(s₁² a) and d/da h₂(u − a) = (ln(u − a) − ln(u − m))/(s₂²(u − a)); both have the sign of (m − a). So BL2 is strictly unimodal with its maximum exactly at `mode`. This also justifies the bisection in `_effective_support`.
22
+ - BL2 is C∞ on (lower, upper) and extends by zero to a C∞ function on ℝ. BL (Eq. 2) is only C¹ at the mode; its second derivative jumps by −0.119, −0.410 and −0.0044 for (m, σ, u) = (3, 1, 10), (1, 3, 10), (5, 10, 25) (Numerical).
23
+
24
+ ## 3. Normalization (Proof + Numerical)
25
+
26
+ - Original and refactored code normalize consistently: Z is the integral of g over the support, and `cdf(upper, normed=False)` ≡ Z.
27
+ - Truncating to the effective support loses at most 1e-14 · g(mode) · ((a − lower) + (upper − b)) of mass, by unimodality.
28
+ - **Z ≤ 1 always** (Cauchy–Schwarz): Z is the Bhattacharyya coefficient of the left and reflected-right lognormals.
29
+
30
+ ## 4. Defects found and fixed
31
+
32
+ 1. **Moments (`_stats`) were wrong.** Weights used /n instead of /(n − 1) and were not renormalized (mean biased toward zero, not shift-equivariant); a sample-skewness correction was applied to a population moment; and a fixed 1001-point grid aliased narrow peaks. Example: `var(100, 1)` returned 2.68. **Fix:** moments integrated with the cdf's graded Gauss–Legendre rule (`_gl_moments`). Now within ~1e-12 of the reference at every dial position.
33
+ 2. **`lognorm_params` lost precision** for σ/mode outside ~1e-5…1e2 (catastrophic cancellation; NaN beyond ~1e-9 and ~1e6; m⁸ overflow near 1e30). **Fix:** the same quartic solved for t = ln g as 3t + log(expm1 t) = 2 log(σ/mode), strictly increasing in t, bracketed analytically, solved by brentq. Scale-free; ~1e-14 relative from σ/mode = 1e-150 to 1e150.
34
+ 3. **Quadrature under-resolved a mode near a bound** (four equal panels; Z error 5e-5 at mode 0.01 on (0, 10)). **Fix:** panel edges graded geometrically toward the mode and both bounds, plus equal panels within the effective support (`_graded_edges`), cached once per parameter set (`_panel_table`). The ppf grid uses the union of a uniform grid and panel-refined points.
35
+ 4. **Guards.** `_argcheck` now encodes finite mode, σ > 0 and lower < mode < upper. Infinite bounds raise (use `None`). Invalid inputs under `ignore_checks=True` raise instead of returning NaN; `fit`'s `curve_fit` wrapper catches this. A `RuntimeWarning` flags σ < 1e-8 of the coordinate scale, where float64 resolution of x − lower limits accuracy.
36
+
37
+ ## 5. Results after the fixes (Numerical)
38
+
39
+ Dial accuracy over the test suite (cdf vs reference; ppf as cdf(ppf(q)) round-trip):
40
+
41
+ - fast: cdf 7e-14, ppf 2e-6, cdf 0.4 ms per 1000 points
42
+ - balanced: cdf 8e-14, ppf 1e-7, cdf 0.9 ms per 1000 points
43
+ - precise: cdf 7e-14, ppf 4e-9, cdf 1.3 ms per 1000 points (ppf grid ~80 ms once per parameter set)
44
+ - exact: cdf 8e-14, ppf 9e-14, cdf 1.9 ms per 1000 points, ppf 0.3 ms per quantile
45
+
46
+ cdf accuracy is now at the reference floor at every position; the dial mainly trades speed and ppf accuracy.
47
+
48
+ Edge cases (σ → 0, bounds to ±1e8, mode within 1e-6 of a bound): cdf within 6e-9 of the reference at every dial position. The remaining floor is float64 resolution of x − lower.
49
+
50
+ ## 6. Test coverage (Suite D, mutation-checked)
51
+
52
+ Reverting each fix makes these fail:
53
+
54
+ - stable `lognorm_params` → D1, D2, D3, D4
55
+ - graded panels → A5, D1, D2, D3
56
+ - quadrature moments → D1, D5, D7
57
+ - `_argcheck` / resolution warning → D6
58
+
59
+ Infinite-bound and invalid-input rejection is enforced by two layers, so those D6 checks fail only when both are reverted. The equal panels within the effective support are not test-guarded: their measured benefit (~4×) appears only below the float64 resolution floor.
60
+
61
+ ## 7. Still open
62
+
63
+ - `moment(n)` returns `None` for n ≥ 5.
64
+ - `entropy` uses a truncated-normal approximation, not BL2's own entropy.
65
+ - As mode → lower the mass does not follow the mode (at mode 1e-8 on (0, 10): median 0.067, mean 0.266); worth a note in the user-facing docs.
66
+ - Bounds → ∞ giving SD → σ is a sketch (dominated-convergence step not written out) plus numerical confirmation: true SD 0.814, 0.959, 0.9996, 1.0000 at ±4, ±10, ±100, ±1000.