diff --git a/docs/altair/altair_probscale.png b/docs/altair/altair_probscale.png new file mode 100644 index 0000000..e68f9e4 Binary files /dev/null and b/docs/altair/altair_probscale.png differ diff --git a/docs/altair/altair_probscale.py b/docs/altair/altair_probscale.py new file mode 100644 index 0000000..b252980 --- /dev/null +++ b/docs/altair/altair_probscale.py @@ -0,0 +1,81 @@ +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "altair>=6.3.0", +# "numpy>=2.5.4", +# "pandas>=3.0.6", +# "scipy>=1.18.1", +# "vl-convert-python>=1.9.0.post1", +# ] +# /// + +"""Probability plot (normal distribution) in Altair / Vega-Lite. + +Vega-Lite has no probit scale, but the same effect is achieved by: + 1. computing plotting positions and transforming them with the normal + quantile function (probit) *inside the spec* via Vega's `quantileNormal`, + 2. placing axis ticks at the probit of familiar percentages and relabelling + them with `cumulativeNormal` (the inverse transform). +""" + +from pathlib import Path + +import altair as alt +import numpy as np +import pandas as pd +from scipy import stats + +PERCENTS = [0.5, 1, 2, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 98, 99, 99.5] + + +def probability_plot(df, value, title=None, a=0.0, direction="x"): + """Probability plot of `df[value]`; `direction` is the axis holding probabilities.""" + + probit_ticks = [float(stats.norm.ppf(p / 100)) for p in PERCENTS] + label_expr = "format(100 * cumulativeNormal(datum.value, 0, 1), '.4~f')" + + # Weibull-style plotting positions (i - a) / (n + 1 - 2a), a=0 by default + base = ( + alt.Chart(df) + .transform_window(rank="rank()", sort=[alt.SortField(value)]) + .transform_joinaggregate(n="count()") + .transform_calculate( + prob=f"(datum.rank - {a}) / (datum.n + 1 - {2 * a})", + z="quantileNormal(datum.prob, 0, 1)", + ) + ) + + prob_enc = alt.Axis( + values=probit_ticks, labelExpr=label_expr, title="Non-exceedance probability", + grid=True, + ) + prob_scale = alt.Scale(domain=[probit_ticks[0] - 0.3, probit_ticks[-1] + 0.3]) + prob_ch = alt.X if direction == "x" else alt.Y + val_ch = alt.Y if direction == "x" else alt.X + + points = base.mark_point(filled=True, size=40).encode( + prob_ch("z:Q", axis=prob_enc, scale=prob_scale), + val_ch(f"{value}:Q", scale=alt.Scale(zero=False), title=value), + tooltip=[value, alt.Tooltip("prob:Q", format=".3f")], + ) + + # Least-squares best-fit line in probit space (the normal fit) + fit = ( + base.transform_regression("z", value, method="linear") + if direction == "x" + else base.transform_regression(value, "z", method="linear") + ).mark_line(color="firebrick") + fit = fit.encode( + prob_ch("z:Q", axis=prob_enc, scale=prob_scale), + val_ch(f"{value}:Q"), + ) + return (points + fit).properties(width=500, height=350, title=title or "Normal probability plot") + + +if __name__ == "__main__": + rng = np.random.default_rng(0) + data = pd.DataFrame({"conc": np.hstack([rng.normal(10, 2, 80), rng.normal(18, 3, 20)])}) + chart = probability_plot(data, "conc", direction="x") + here = Path(__file__).parent + chart.save(here / "altair_probscale.png", scale_factor=2) + print("wrote altair_probscale.png") diff --git a/docs/altair/index.rst b/docs/altair/index.rst new file mode 100644 index 0000000..bee16ca --- /dev/null +++ b/docs/altair/index.rst @@ -0,0 +1,30 @@ +Probability plots in Altair / Vega-Lite +======================================= + +.. note:: + + This is a standalone sketch for people working in Vega-Lite, not part of + the mpl-probscale API. It is not run during the docs build and is not + covered by the test suite. Altair is not a dependency of this package. + +Vega-Lite has no probit scale, but a normal probability plot can be built +from two pieces: + +1. Plotting positions are computed inside the spec and passed through + Vega's ``quantileNormal`` function to get z-scores. +2. Axis ticks are placed at the z-scores of familiar percentages and + relabelled with ``cumulativeNormal``, the inverse transform. + +A least-squares line fit in z-space serves as the normal-distribution fit. + +.. image:: altair_probscale.png + :width: 600 + +The full script (``uv add altair vl-convert-python`` to run it): + +.. literalinclude:: altair_probscale.py + :language: python + +Limitations compared to mpl-probscale: only the normal distribution, no +confidence bands, fixed tick locations, and a single plotting-position +formula (via the ``a`` argument). diff --git a/docs/index.rst b/docs/index.rst index 2d1fb0b..1e21571 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -54,6 +54,7 @@ Examples :maxdepth: 2 auto_examples/index + altair/index API Reference ------------- diff --git a/pyproject.toml b/pyproject.toml index f47881f..0bf0979 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ docs = [ "sphinx>=8", "sphinx-gallery>=0.19", ] +extra = [] [project.urls] Homepage = "https://github.com/matplotlib/mpl-probscale" diff --git a/uv.lock b/uv.lock index 8a69f4d..a8beeac 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -97,7 +97,7 @@ name = "cffi" version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, + { name = "pycparser" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ @@ -383,7 +383,7 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -473,7 +473,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1", size = 13323726, upload-time = "2026-09-11T19:05:05.808Z" } wheels = [ @@ -1643,7 +1643,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -1828,6 +1828,7 @@ docs = [ { name = "sphinx", specifier = ">=8" }, { name = "sphinx-gallery", specifier = ">=0.19" }, ] +extra = [] [[package]] name = "prompt-toolkit" @@ -2317,7 +2318,7 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -2396,7 +2397,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } wheels = [ @@ -2514,23 +2515,23 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version < '3.12'" }, - { name = "babel", marker = "python_full_version < '3.12'" }, - { name = "colorama", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version < '3.12'" }, - { name = "imagesize", marker = "python_full_version < '3.12'" }, - { name = "jinja2", marker = "python_full_version < '3.12'" }, - { name = "packaging", marker = "python_full_version < '3.12'" }, - { name = "pygments", marker = "python_full_version < '3.12'" }, - { name = "requests", marker = "python_full_version < '3.12'" }, - { name = "roman-numerals", marker = "python_full_version < '3.12'" }, - { name = "snowballstemmer", marker = "python_full_version < '3.12'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.12'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.12'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.12'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.12'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.12'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.12'" }, + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } wheels = [ @@ -2550,23 +2551,23 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version >= '3.12'" }, - { name = "babel", marker = "python_full_version >= '3.12'" }, - { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version >= '3.12'" }, - { name = "imagesize", marker = "python_full_version >= '3.12'" }, - { name = "jinja2", marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pygments", marker = "python_full_version >= '3.12'" }, - { name = "requests", marker = "python_full_version >= '3.12'" }, - { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, - { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } wheels = [