From 1884881b1cf27a421cdc2dbf6cbe9c207daf9856 Mon Sep 17 00:00:00 2001 From: gepcel Date: Fri, 14 Aug 2026 15:41:01 +0800 Subject: [PATCH 01/15] Add kde support for hist --- ultraplot/axes/plot.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 6bd62acaf..2a28b6a29 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -7114,6 +7114,8 @@ def _apply_hist( filled=None, histtype=None, orientation="vertical", + kde=False, + kde_kw=None, **kwargs, ): """ @@ -7157,6 +7159,24 @@ def _apply_hist( if type(sub) is list: res[i] = cbook.silent_list("Polygon", sub) self._update_guide(res, **guide_kw) + # add kde line + if not kde: + return obj + from scipy.stats import gaussian_kde + edges = obj[1] + kde_kw = dict(kde_kw or {}) + density = kw.get('density', False) + data2d = xs if xs.ndim > 1 else xs[:, None] # (M, N) data + stepsize = kde_kw.pop('stepsize', 300) + for i in range(data2d.shape[1]): + _x = data2d[:, i] + xa = np.linspace(_x.min(), _x.max(), stepsize) + ya = gaussian_kde(_x)(xa) + if not density: + idx = np.clip(np.digitize(xa, edges)-1, 0, len(edges)-2) + ya = ya * len(_x) * np.diff(edges)[idx] + x_line, y_line = (xa, ya) if orientation=="vertical" else (ya, xa) + self._call_native("plot", x_line, y_line, **kde_kw) return obj @inputs._preprocess_or_redirect("x", "bins", keywords="weights") From d94ea35ae663afa45d2db08624c8029a3a39bbe0 Mon Sep 17 00:00:00 2001 From: gepcel Date: Fri, 14 Aug 2026 18:00:27 +0800 Subject: [PATCH 02/15] add some tests --- ultraplot/tests/test_1dplots.py | 36 +++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/ultraplot/tests/test_1dplots.py b/ultraplot/tests/test_1dplots.py index c04486a12..aa49d603c 100644 --- a/ultraplot/tests/test_1dplots.py +++ b/ultraplot/tests/test_1dplots.py @@ -243,6 +243,42 @@ def test_histogram_types(rng): ax.hist(data, ec="k", **kw) return fig +def test_hist_kde_lines(rng): + """ + Test kde for hist. + """ + data = rng.normal(size=200) + # No kde if no kde is not given + fig, ax = uplt.subplot() + ax.hist(data, bins=20) + assert len(ax.lines) == 0 + # No kde if kde=False + ax.hist(data, bins=20, kde=False) + assert len(ax.lines) == 0 + # One kde line with + ax.hist(data, bins=20, kde=True) + assert len(ax.lines) == 1 + # test step size + line = ax.lines[-1] + # default stepsize is 300 + assert line.get_xdata().size == 300 + assert line.get_ydata().size == 300 + assert line.get_xdata()[0] == pytest.approx(data.min()) + assert line.get_xdata()[-1] == pytest.approx(data.max()) + # use kde_kw to set stepsize=150 + ax.hist(data, bins=20, kde=True, density=True, + kde_kw={'stepsize': 150}) + density_line = ax.lines[-1] + assert density_line.get_xdata().size == 150 + assert density_line.get_ydata().size == 150 + # test density, default is False, but to to test accurate? + assert line.get_ydata().max() > 1.0 + assert density_line.get_ydata().max() <= 1.0 + # test area==1 + area = np.trapezoid(density_line.get_ydata(), density_line.get_xdata()) + assert area == pytest.approx(1.0, rel=1e-2) + uplt.close(fig) + @pytest.mark.mpl_image_compare def test_invalid_plot(rng): From dcb9d066d714364d647daa68ae609424e0456735 Mon Sep 17 00:00:00 2001 From: gepcel Date: Sat, 15 Aug 2026 10:24:35 +0800 Subject: [PATCH 03/15] Add scipy to environment.yml as optional dependency. --- environment.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/environment.yml b/environment.yml index 5ce6193e7..204655701 100644 --- a/environment.yml +++ b/environment.yml @@ -22,5 +22,6 @@ dependencies: - cftime - markdown - requests + - scipy - pip: - pycirclize From 159b9be9e3e00c06a81f6b1434ea760bf73b4d54 Mon Sep 17 00:00:00 2001 From: gepcel Date: Sat, 15 Aug 2026 10:25:35 +0800 Subject: [PATCH 04/15] Add kde test with multiple columns data and horizontal orientation --- ultraplot/tests/test_1dplots.py | 36 +++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/ultraplot/tests/test_1dplots.py b/ultraplot/tests/test_1dplots.py index aa49d603c..a513013c8 100644 --- a/ultraplot/tests/test_1dplots.py +++ b/ultraplot/tests/test_1dplots.py @@ -248,14 +248,14 @@ def test_hist_kde_lines(rng): Test kde for hist. """ data = rng.normal(size=200) - # No kde if no kde is not given + # No kde line if no kde arg is not given fig, ax = uplt.subplot() ax.hist(data, bins=20) assert len(ax.lines) == 0 # No kde if kde=False ax.hist(data, bins=20, kde=False) assert len(ax.lines) == 0 - # One kde line with + # One kde line with density=False and stepsize=300 by default ax.hist(data, bins=20, kde=True) assert len(ax.lines) == 1 # test step size @@ -265,21 +265,45 @@ def test_hist_kde_lines(rng): assert line.get_ydata().size == 300 assert line.get_xdata()[0] == pytest.approx(data.min()) assert line.get_xdata()[-1] == pytest.approx(data.max()) - # use kde_kw to set stepsize=150 - ax.hist(data, bins=20, kde=True, density=True, + # Another line with stepsize=150 and density=True + ax.hist(data, bins=20, kde=True, density=True, kde_kw={'stepsize': 150}) density_line = ax.lines[-1] assert density_line.get_xdata().size == 150 assert density_line.get_ydata().size == 150 - # test density, default is False, but to to test accurate? + # Do we need to test accurate counts when density=False? assert line.get_ydata().max() > 1.0 assert density_line.get_ydata().max() <= 1.0 - # test area==1 + # test area==1 when density=True area = np.trapezoid(density_line.get_ydata(), density_line.get_xdata()) assert area == pytest.approx(1.0, rel=1e-2) uplt.close(fig) +def test_hist_kde_multiple_columns(rng): + """ + Test data with multiple columns + """ + data = rng.normal(size=(100, 3)) + fig, ax = uplt.subplots() + ax.hist(data, bins=20, kde=True) + assert len(ax.lines) == 3 + uplt.close(fig) + + +def test_hist_kde_orientation(rng): + """ + Test when orientation='horizontal' + """ + data = rng.normal(size=200) + fig, ax = uplt.subplots() + ax.hist(data, bins=20, kde=True, orientation="horizontal") + line = ax.lines[0] + assert line.get_ydata()[0] == pytest.approx(data.min()) + assert line.get_ydata()[-1] == pytest.approx(data.max()) + uplt.close(fig) + + @pytest.mark.mpl_image_compare def test_invalid_plot(rng): """ From b2851bf008860c1d287b8d8dfa1fea48912c9fc4 Mon Sep 17 00:00:00 2001 From: gepcel Date: Sat, 15 Aug 2026 10:52:53 +0800 Subject: [PATCH 05/15] Add to doc of ax.hist --- ultraplot/axes/plot.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 2a28b6a29..e1e67f987 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -1514,6 +1514,11 @@ Whether to "stack" successive columns of {y} data for bar-type histograms or show side-by-side in groups. Setting this to ``False`` is equivalent to ``histtype='bar'`` and to ``True`` is equivalent to ``histtype='barstacked'``. +kde : book, optional + Whether to compute and draw a kernel density line to estimate and smooth the + distribution on the plot. +kde_kw : dict, optional + Parameters to control the kde line plotting, passed to `matplotlib.axes.Axes.plot()` fill, filled : bool, optional Whether to "fill" step-type histograms or just plot the edges. Setting this to ``False`` is equivalent to ``histtype='step'`` and to ``True`` From c12a9eceb6cb3a74b8adab841ed9def1b0dfcc0b Mon Sep 17 00:00:00 2001 From: gepcel Date: Sat, 15 Aug 2026 10:54:44 +0800 Subject: [PATCH 06/15] doc: kde default to false --- ultraplot/axes/plot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index e1e67f987..fc9a803ff 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -1514,7 +1514,7 @@ Whether to "stack" successive columns of {y} data for bar-type histograms or show side-by-side in groups. Setting this to ``False`` is equivalent to ``histtype='bar'`` and to ``True`` is equivalent to ``histtype='barstacked'``. -kde : book, optional +kde : book, optional, default: False Whether to compute and draw a kernel density line to estimate and smooth the distribution on the plot. kde_kw : dict, optional From e603c7df407d670076962ae2620ab1b75aee3082 Mon Sep 17 00:00:00 2001 From: gepcel Date: Sat, 15 Aug 2026 10:56:05 +0800 Subject: [PATCH 07/15] fix a typo --- ultraplot/axes/plot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index fc9a803ff..64745a7a0 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -1514,7 +1514,7 @@ Whether to "stack" successive columns of {y} data for bar-type histograms or show side-by-side in groups. Setting this to ``False`` is equivalent to ``histtype='bar'`` and to ``True`` is equivalent to ``histtype='barstacked'``. -kde : book, optional, default: False +kde : bool, optional, default: False Whether to compute and draw a kernel density line to estimate and smooth the distribution on the plot. kde_kw : dict, optional From ccacd755389dede4115e70df9c206591f89adb32 Mon Sep 17 00:00:00 2001 From: gepcel Date: Mon, 17 Aug 2026 08:50:30 +0800 Subject: [PATCH 08/15] If scipy is not installed, will raise a error, and give explaination, and skip 3 tests. --- ultraplot/axes/plot.py | 7 ++++++- ultraplot/tests/test_1dplots.py | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 92d12d53b..6b4e4afa7 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -7170,7 +7170,12 @@ def _apply_hist( # add kde line if not kde: return obj - from scipy.stats import gaussian_kde + try: + from scipy.stats import gaussian_kde + except ModuleNotFoundError: + raise ImportError( + "scipy is required for histogram kde line. Install it with: pip install scipy" + ) edges = obj[1] kde_kw = dict(kde_kw or {}) density = kw.get('density', False) diff --git a/ultraplot/tests/test_1dplots.py b/ultraplot/tests/test_1dplots.py index 7dfd920f8..9aa1f6137 100644 --- a/ultraplot/tests/test_1dplots.py +++ b/ultraplot/tests/test_1dplots.py @@ -302,6 +302,7 @@ def test_hist_kde_lines(rng): """ Test kde for hist. """ + scipy = pytest.importorskip("scipy") data = rng.normal(size=200) # No kde line if no kde arg is not given fig, ax = uplt.subplot() @@ -339,6 +340,7 @@ def test_hist_kde_multiple_columns(rng): """ Test data with multiple columns """ + scipy = pytest.importorskip("scipy") data = rng.normal(size=(100, 3)) fig, ax = uplt.subplots() ax.hist(data, bins=20, kde=True) @@ -350,6 +352,7 @@ def test_hist_kde_orientation(rng): """ Test when orientation='horizontal' """ + scipy = pytest.importorskip("scipy") data = rng.normal(size=200) fig, ax = uplt.subplots() ax.hist(data, bins=20, kde=True, orientation="horizontal") From 3618a5559e3ca9fb99e9f14d07ef255d29fde307 Mon Sep 17 00:00:00 2001 From: gepcel Date: Tue, 18 Aug 2026 13:59:12 +0800 Subject: [PATCH 09/15] extract the kde part as a helper func. --- ultraplot/axes/plot.py | 99 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 88 insertions(+), 11 deletions(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 6b4e4afa7..38061c72b 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -7170,6 +7170,78 @@ def _apply_hist( # add kde line if not kde: return obj + + kde_kw = dict(kde_kw or {}) + # kde_kw includes both kde line generating args and kde line plotting args: + # 1. for kde line generating: stepsize, bw_method... + # 2. for line plotting, passed to ax.plot, controlling line style. + # The following code supposed to extract the 1st kind, and leave the 2nd. + _kde_kw_only = { + "density": kw.get("density", False), # kde share the density arg with hist + "stepsize": kde_kw.pop("stepsize", 300), # stepsize only works for kde line + "weights": kw.get("weights", None), # share arg with hist + "bw_method": kde_kw.pop( + "bw_method", None + ), # kde arg passed to gaussian_kde + "orientation": orientation, # share arg with hist + } + lines = self._kde_line1d(xs, obj, _kde_kw_only) + for x_line, y_line in lines: + self._call_native("plot", x_line, y_line, **kde_kw) + return obj + + def _kde_line1d(self, x, obj, kw): + """ + Compute Gaussian kernel density estimate (KDE) lines for 1D/2D data. + + This helper is called by :meth:`_apply_hist` to draw KDE curves on + top of a 1d histogram. It evaluates :func:`scipy.stats.gaussian_kde` on + a regular grid spanning each data column and, when ``density=False``, + rescales each estimate so its area matches the histogram bin counts. + For horizontal histograms the coordinates are swapped. One ``(x, y)`` + line is produced per column of ``x``. + + Parameters + ---------- + x : array-like + The input data. A 1D array is treated as a single sample column; + a 2D array is interpreted as ``(M, N)``, producing one KDE line + per column. + obj : tuple + The return value of the native ``hist`` call. ``obj[1]`` holds the + bin edges, which are used to rescale each KDE so it matches the + histogram bin counts. + kw : dict + Options controlling the KDE computation. Recognized keys: + + ``density`` : bool, default False + Whether to return the raw probability density. If False, each + estimate is rescaled by the sample size and bin width so the + curve matches the histogram bin counts. + ``stepsize`` : int, default 300 + Number of points in the evaluation grid spanning each column's + data range. + ``weights`` : array-like or None, optional + Sample weights passed to :func:`scipy.stats.gaussian_kde`. + ``bw_method`` : str, scalar or callable, optional + Bandwidth method passed to :func:`scipy.stats.gaussian_kde`. + ``orientation`` : {'vertical', 'horizontal'} + Line orientation. Coordinates are swapped for horizontal + histograms. + + Returns + ------- + lines : list of tuple of ndarray + One ``(x_line, y_line)`` tuple of coordinates per column of + ``x``. + + Raises + ------ + ImportError + If scipy is not installed. + ValueError + If ``kw['orientation']`` is neither 'vertical' nor 'horizontal'. + """ try: from scipy.stats import gaussian_kde except ModuleNotFoundError: @@ -7177,20 +7249,25 @@ def _apply_hist( "scipy is required for histogram kde line. Install it with: pip install scipy" ) edges = obj[1] - kde_kw = dict(kde_kw or {}) - density = kw.get('density', False) - data2d = xs if xs.ndim > 1 else xs[:, None] # (M, N) data - stepsize = kde_kw.pop('stepsize', 300) + data2d = x if x.ndim > 1 else x[:, None] + lines = [] for i in range(data2d.shape[1]): _x = data2d[:, i] - xa = np.linspace(_x.min(), _x.max(), stepsize) - ya = gaussian_kde(_x)(xa) - if not density: - idx = np.clip(np.digitize(xa, edges)-1, 0, len(edges)-2) + xa = np.linspace(_x.min(), _x.max(), kw["stepsize"]) + ya = gaussian_kde(_x, bw_method=kw["bw_method"], weights=kw["weights"])(xa) + if not kw["density"]: + idx = np.clip(np.digitize(xa, edges) - 1, 0, len(edges) - 2) ya = ya * len(_x) * np.diff(edges)[idx] - x_line, y_line = (xa, ya) if orientation=="vertical" else (ya, xa) - self._call_native("plot", x_line, y_line, **kde_kw) - return obj + if kw["orientation"] == "vertical": + x_line, y_line = xa, ya + elif kw["orientation"] == "horizontal": + x_line, y_line = ya, xa + else: + raise ValueError( + f"Invalid orientation: {kw['orientation']}, must be 'vertical' or 'horizontal'" + ) + lines.append((x_line, y_line)) + return lines @inputs._preprocess_or_redirect("x", "bins", keywords="weights") @docstring._concatenate_inherited From 2a07f810179c0e959ae2940b727823ead51aa423 Mon Sep 17 00:00:00 2001 From: gepcel Date: Tue, 18 Aug 2026 14:04:57 +0800 Subject: [PATCH 10/15] scipy as optional denpendency in pyproject.toml --- environment.yml | 1 - pyproject.toml | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/environment.yml b/environment.yml index 204655701..5ce6193e7 100644 --- a/environment.yml +++ b/environment.yml @@ -22,6 +22,5 @@ dependencies: - cftime - markdown - requests - - scipy - pip: - pycirclize diff --git a/pyproject.toml b/pyproject.toml index d03a0c617..8084e38ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,3 +88,6 @@ docs = [ "sphinx-sitemap", "typing-extensions" ] +stats = [ + "scipy", +] From 736b1f2554ed56b929d7099a6e07636d51cc7683 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 18 Aug 2026 16:47:07 +1000 Subject: [PATCH 11/15] minor refactor and integration with existing kde --- docs/stats.py | 8 +- ultraplot/axes/plot.py | 117 ++++++++++++------- ultraplot/tests/test_statistical_plotting.py | 14 +++ 3 files changed, 91 insertions(+), 48 deletions(-) diff --git a/docs/stats.py b/docs/stats.py index d9136328c..57a13f8ab 100644 --- a/docs/stats.py +++ b/docs/stats.py @@ -216,11 +216,9 @@ # the :ref:`2D plotting section `). Marginal distributions # for the 2D histograms can be added using :ref:`panel axes `. # -# In the future, UltraPlot will include options for adding "smooth" kernel density -# estimations to histograms plots using a `kde` keyword. It will also include -# separate `ultraplot.axes.PlotAxes.kde` and `ultraplot.axes.PlotAxes.kde2d` commands. -# The :func:`~ultraplot.axes.PlotAxes.violin` and :func:`~ultraplot.axes.PlotAxes.violinh` commands -# will use the same algorithm for kernel density estimation as the `kde` commands. +# Histograms support a ``kde=True`` option to draw a kernel density overlay. +# Separate :func:`~ultraplot.axes.PlotAxes.kde` and +# :func:`~ultraplot.axes.PlotAxes.kde2d` commands are still planned. # %% import numpy as np diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 38061c72b..ec93330a5 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -1899,6 +1899,36 @@ def _parse_vert( return kwargs +def _kde_eval1d(x, points, bw_method=None, weights=None, margin=0.0): + """ + Evaluate Gaussian KDE on an evenly spaced 1D grid. + """ + try: + from scipy.stats import gaussian_kde + except ModuleNotFoundError: + raise ImportError( + "scipy is required for KDE line generation. Install with: pip install \"ultraplot[stats]\"" + ) + + try: + points = int(points) + except Exception as exc: + raise TypeError("`points` must be an integer") from exc + if points < 2: + raise ValueError("`points` must be at least 2") + + x = np.asarray(x) + if x.size < 2: + raise ValueError("KDE requires at least two points") + + x_min, x_max = x.min(), x.max() + x_span = x_max - x_min + x_margin = x_span * margin + x_eval = np.linspace(x_min - x_margin, x_max + x_margin, points) + y_eval = gaussian_kde(x, bw_method=bw_method, weights=weights)(x_eval) + return x_eval, y_eval + + class PlotAxes(base.Axes): """ The second lowest-level `~matplotlib.axes.Axes` subclass used by ultraplot. @@ -6691,12 +6721,16 @@ def _apply_ridgeline( Amount of overlap between ridges (0-1). Higher values create more overlap. Only used in categorical mode. kde_kw : dict, optional - Keyword arguments passed to `scipy.stats.gaussian_kde`. Common parameters: + Keyword arguments used by the KDE generator and ridge outline styling. + Common ``gaussian_kde`` parameters: * ``bw_method`` : Bandwidth selection method * ``weights`` : Array of weights for each data point + * ``stepsize`` : alias for ``points`` when provided. - Only used when hist=False. + Remaining keys are forwarded to the ridge line styling call + (for example ``color``, ``linestyle``, ``linewidth``). Only used + when hist=False. points : int, default: 200 Number of points to evaluate the KDE at. Higher values create smoother curves but take longer to compute. Only used when hist=False. @@ -6732,8 +6766,6 @@ def _apply_ridgeline( list List of PolyCollection objects for each ridge. """ - from scipy.stats import gaussian_kde - # Validate input if not isinstance(data, (list, tuple)): data = [data] @@ -6769,6 +6801,16 @@ def _apply_ridgeline( # Prepare KDE kwargs if kde_kw is None: kde_kw = {} + kde_kw = dict(kde_kw) + kde_kw_plot = dict(kde_kw) + kde_points = int(kde_kw_plot.pop("stepsize", points)) + kde_points = int(kde_kw_plot.pop("points", kde_points)) + kde_kw_gen = { + "bw_method": kde_kw_plot.pop("bw_method", None), + "weights": kde_kw_plot.pop("weights", None), + } + if kde_points < 2: + raise ValueError("`points` must be at least 2") # Calculate KDE or histogram for each distribution ridges = [] @@ -6816,13 +6858,7 @@ def _apply_ridgeline( else: # Perform KDE try: - kde = gaussian_kde(dist, **kde_kw) - # Create smooth x values - x_min, x_max = dist.min(), dist.max() - x_range = x_max - x_min - x_margin = x_range * 0.1 # 10% margin - x = np.linspace(x_min - x_margin, x_max + x_margin, points) - y = kde(x) + x, y = _kde_eval1d(dist, points=kde_points, margin=0.1, **kde_kw_gen) ridges.append({"x": x, "y": y, "hist": False}) except Exception as e: warnings._warn_ultraplot( @@ -6938,6 +6974,10 @@ def _apply_ridgeline( zorder=fill_zorder, ) elif is_hist and histtype in ("step", "stepfilled"): + stepline_kw = dict( + color=edgecolor, linewidth=linewidth, zorder=outline_zorder + ) + stepline_kw.update(kde_kw_plot) if vert: if histtype == "stepfilled": poly = self.fill_between( @@ -6955,19 +6995,15 @@ def _apply_ridgeline( poly = self.plot( x, y_plot, - color=edgecolor, - linewidth=linewidth, label=labels[i], drawstyle="steps-mid", - zorder=outline_zorder, + **stepline_kw, )[0] self.plot( x, y_plot, - color=edgecolor, - linewidth=linewidth, drawstyle="steps-mid", - zorder=outline_zorder, + **stepline_kw, ) else: if histtype == "stepfilled": @@ -6986,22 +7022,20 @@ def _apply_ridgeline( poly = self.plot( y_plot, x, - color=edgecolor, - linewidth=linewidth, label=labels[i], drawstyle="steps-mid", - zorder=outline_zorder, + **stepline_kw, )[0] self.plot( y_plot, x, - color=edgecolor, - linewidth=linewidth, drawstyle="steps-mid", - zorder=outline_zorder, + **stepline_kw, ) else: if vert: + line_kw = dict(color=edgecolor, linewidth=linewidth, zorder=outline_zorder) + line_kw.update(kde_kw_plot) # Traditional horizontal ridges if fill: # Fill without edge @@ -7019,21 +7053,23 @@ def _apply_ridgeline( self.plot( x, y_plot, - color=edgecolor, - linewidth=linewidth, - zorder=outline_zorder, + **line_kw, ) else: + line_kw = dict( + color=colors[i], linewidth=linewidth, zorder=outline_zorder + ) + line_kw.update(kde_kw_plot) poly = self.plot( x, y_plot, - color=colors[i], - linewidth=linewidth, label=labels[i], - zorder=outline_zorder, + **line_kw, )[0] else: # Vertical ridges + line_kw = dict(color=edgecolor, linewidth=linewidth, zorder=outline_zorder) + line_kw.update(kde_kw_plot) if fill: # Fill without edge poly = self.fill_betweenx( @@ -7050,18 +7086,18 @@ def _apply_ridgeline( self.plot( y_plot, x, - color=edgecolor, - linewidth=linewidth, - zorder=outline_zorder, + **line_kw, ) else: + line_kw = dict( + color=colors[i], linewidth=linewidth, zorder=outline_zorder + ) + line_kw.update(kde_kw_plot) poly = self.plot( y_plot, x, - color=colors[i], - linewidth=linewidth, label=labels[i], - zorder=outline_zorder, + **line_kw, )[0] artists.append(poly) @@ -7242,19 +7278,14 @@ def _kde_line1d(self, x, obj, kw): ValueError If ``kw['orientation']`` is neither 'vertical' nor 'horizontal'. """ - try: - from scipy.stats import gaussian_kde - except ModuleNotFoundError: - raise ImportError( - "scipy is required for histogram kde line. Install it with: pip install scipy" - ) edges = obj[1] data2d = x if x.ndim > 1 else x[:, None] lines = [] for i in range(data2d.shape[1]): _x = data2d[:, i] - xa = np.linspace(_x.min(), _x.max(), kw["stepsize"]) - ya = gaussian_kde(_x, bw_method=kw["bw_method"], weights=kw["weights"])(xa) + xa, ya = _kde_eval1d( + _x, points=kw["stepsize"], bw_method=kw["bw_method"], weights=kw["weights"] + ) if not kw["density"]: idx = np.clip(np.digitize(xa, edges) - 1, 0, len(edges) - 2) ya = ya * len(_x) * np.diff(edges)[idx] diff --git a/ultraplot/tests/test_statistical_plotting.py b/ultraplot/tests/test_statistical_plotting.py index 2c82b14ff..e6887ffd1 100644 --- a/ultraplot/tests/test_statistical_plotting.py +++ b/ultraplot/tests/test_statistical_plotting.py @@ -369,6 +369,20 @@ def test_ridgeline_kde_kw(rng): assert len(artists) == 3 uplt.close(fig) + # Test style and stepsize passthrough (compatibility with histogram naming) + fig, ax = uplt.subplots() + artists = ax.ridgeline( + data, + labels=labels, + overlap=0.5, + fill=False, + kde_kw={"stepsize": 80, "color": "k", "linestyle": "--"}, + ) + assert len(artists) == 3 + assert len(ax.lines[0].get_xdata()) == 80 + assert ax.lines[0].get_linestyle() == "--" + uplt.close(fig) + def test_ridgeline_points(rng): """ From 6727164eb340032ed484828a0507a52f93709805 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 18 Aug 2026 17:19:30 +1000 Subject: [PATCH 12/15] redo refactor --- docs/stats.py | 16 +- ultraplot/axes/plot.py | 494 +++++++++---------- ultraplot/internals/inputs.py | 101 ++++ ultraplot/tests/test_1dplots.py | 104 +++- ultraplot/tests/test_statistical_plotting.py | 25 +- 5 files changed, 455 insertions(+), 285 deletions(-) diff --git a/docs/stats.py b/docs/stats.py index 57a13f8ab..373ba3642 100644 --- a/docs/stats.py +++ b/docs/stats.py @@ -216,8 +216,18 @@ # the :ref:`2D plotting section `). Marginal distributions # for the 2D histograms can be added using :ref:`panel axes `. # -# Histograms support a ``kde=True`` option to draw a kernel density overlay. -# Separate :func:`~ultraplot.axes.PlotAxes.kde` and +# Histograms accept a ``kde=True`` keyword that overlays a smooth +# `kernel density estimate `__ +# of each column of data. The curve follows the histogram -- it is scaled to +# the bin counts unless ``density=True``, accumulated when the histogram is +# stacked, and drawn in the color of the histogram it belongs to. Use the +# `kde_kw` keyword to control the estimate (``bw_method``, ``weights``, +# ``points``) and to style the curve (any `~matplotlib.axes.Axes.plot` +# property). The same keyword is used by +# :func:`~ultraplot.axes.PlotAxes.ridgeline` (see :ref:`ug_ridgeline`). +# This requires `scipy `__, which is installed with +# ``pip install ultraplot[stats]``. Separate +# :func:`~ultraplot.axes.PlotAxes.kde` and # :func:`~ultraplot.axes.PlotAxes.kde2d` commands are still planned. # %% @@ -242,6 +252,8 @@ cycle=("indigo9", "gray3", "red9"), labels=list("abc"), legend="ul", + kde=True, + kde_kw={"lw": 2}, ) # %% diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index ec93330a5..282c939e4 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -65,6 +65,10 @@ # This is half of rc['patch.linewidth'] of 0.6. Half seems like a nice default. EDGEWIDTH = 0.3 +# NOTE: Shared by every command that draws a kernel density estimate so that +# 'hist' and 'ridgeline' curves are sampled identically by default. +KDE_POINTS = 200 + DataInput: TypeAlias = ArrayLike ColorTupleRGB: TypeAlias = tuple[float, float, float] ColorTupleRGBA: TypeAlias = tuple[float, float, float, float] @@ -1425,11 +1429,16 @@ Higher values create more dramatic visual overlapping. Only used in categorical positioning mode (when positions is None). kde_kw : dict, optional - Keyword arguments passed to `scipy.stats.gaussian_kde`. Common parameters include: + Settings for the kernel density estimate. The following keys control the + estimate itself and are passed to `scipy.stats.gaussian_kde`: * ``bw_method`` : Bandwidth selection method (scalar, 'scott', 'silverman', or callable) * ``weights`` : Array of weights for each data point + * ``points`` : Number of evaluation points, overriding `points` + (``stepsize`` is accepted as an alias) + The remaining keys style the resulting curve and are passed to + `matplotlib.axes.Axes.plot`, e.g. ``color``, ``linestyle``, ``linewidth``. Only used when hist=False. points : int, default: 200 Number of evaluation points for KDE curves. Higher values create smoother @@ -1514,11 +1523,23 @@ Whether to "stack" successive columns of {y} data for bar-type histograms or show side-by-side in groups. Setting this to ``False`` is equivalent to ``histtype='bar'`` and to ``True`` is equivalent to ``histtype='barstacked'``. -kde : bool, optional, default: False - Whether to compute and draw a kernel density line to estimate and smooth the - distribution on the plot. +kde : bool, default: False + Whether to overlay a gaussian kernel density estimate of each column of + data. The curve tracks the histogram, i.e. it is scaled to the bin counts + unless ``density=True`` and accumulated when the histogram is stacked. + Requires `scipy `__. kde_kw : dict, optional - Parameters to control the kde line plotting, passed to `matplotlib.axes.Axes.plot()` + Settings for the kernel density estimate. The following keys control the + estimate itself and are passed to `scipy.stats.gaussian_kde`: + + * ``bw_method`` : Bandwidth selection method (scalar, 'scott', 'silverman', or callable) + * ``weights`` : Array of weights for each data point, defaults to `weights` + * ``points`` : Number of evaluation points, default ``200`` + (``stepsize`` is accepted as an alias) + + The remaining keys style the resulting curve and are passed to + `matplotlib.axes.Axes.plot`, e.g. ``color``, ``linestyle``, ``linewidth``. + By default each curve takes the color of its histogram. fill, filled : bool, optional Whether to "fill" step-type histograms or just plot the edges. Setting this to ``False`` is equivalent to ``histtype='step'`` and to ``True`` @@ -1899,34 +1920,71 @@ def _parse_vert( return kwargs -def _kde_eval1d(x, points, bw_method=None, weights=None, margin=0.0): +def _parse_kde_kw(kde_kw=None, *, points=None, weights=None): """ - Evaluate Gaussian KDE on an evenly spaced 1D grid. + Split the `kde_kw` keyword arguments shared by the commands that draw kernel + density estimates into arguments that control the estimate itself and + arguments that style the resulting line. + + Parameters + ---------- + kde_kw : dict, optional + The user input. The ``points`` (or its ``stepsize`` alias), + ``bw_method``, and ``weights`` keys control the estimate and everything + else is treated as a line property. + points, weights : optional + The defaults inherited from the parent command, used when the + corresponding key is absent from `kde_kw`. + + Returns + ------- + kw_kde : dict + The keyword arguments accepted by `~ultraplot.internals.inputs._dist_kde`. + kw_line : dict + The remaining keyword arguments, meant for `~matplotlib.axes.Axes.plot`. """ - try: - from scipy.stats import gaussian_kde - except ModuleNotFoundError: - raise ImportError( - "scipy is required for KDE line generation. Install with: pip install \"ultraplot[stats]\"" - ) - - try: - points = int(points) - except Exception as exc: - raise TypeError("`points` must be an integer") from exc - if points < 2: - raise ValueError("`points` must be at least 2") - - x = np.asarray(x) - if x.size < 2: - raise ValueError("KDE requires at least two points") - - x_min, x_max = x.min(), x.max() - x_span = x_max - x_min - x_margin = x_span * margin - x_eval = np.linspace(x_min - x_margin, x_max + x_margin, points) - y_eval = gaussian_kde(x, bw_method=bw_method, weights=weights)(x_eval) - return x_eval, y_eval + kw_line = dict(kde_kw or {}) + kw_kde = { + "points": _not_none( + points=kw_line.pop("points", None), + stepsize=kw_line.pop("stepsize", None), # backwards compatible alias + default=_not_none(points, KDE_POINTS), + ), + "bw_method": kw_line.pop("bw_method", None), + "weights": _not_none(kw_line.pop("weights", None), weights), + } + return kw_kde, kw_line + + +def _get_hist_colors(res, n): + """ + Return one color per column of a histogram drawn by + `~matplotlib.axes.Axes.hist`, so that overlays can be colored to match. + + Parameters + ---------- + res : sequence + The artists returned by the histogram, i.e. one `~matplotlib.container.BarContainer` + or list of `~matplotlib.patches.Polygon` per column. A single column is + returned unnested by matplotlib and is handled here. + n : int + The number of columns. + """ + # NOTE: 'step' histograms leave the faces transparent and carry the cycle + # color on the edges instead, so fall back to the edge color. + colors = [] + for group in [res] if n == 1 else res: + artists = list(group) + if not artists: + colors.append(None) + continue + color = np.atleast_2d(artists[0].get_facecolor()) + if not color.size or not np.any(color[:, -1]): + color = np.atleast_2d(artists[0].get_edgecolor()) + # NOTE: Drop the alpha channel so that overlays stay opaque even when + # the histogram itself was drawn translucent. + colors.append(None if not color.size else tuple(color[0, :3])) + return colors class PlotAxes(base.Axes): @@ -6721,16 +6779,11 @@ def _apply_ridgeline( Amount of overlap between ridges (0-1). Higher values create more overlap. Only used in categorical mode. kde_kw : dict, optional - Keyword arguments used by the KDE generator and ridge outline styling. - Common ``gaussian_kde`` parameters: - - * ``bw_method`` : Bandwidth selection method - * ``weights`` : Array of weights for each data point - * ``stepsize`` : alias for ``points`` when provided. - - Remaining keys are forwarded to the ridge line styling call - (for example ``color``, ``linestyle``, ``linewidth``). Only used - when hist=False. + Settings for the kernel density estimate. The ``bw_method``, + ``weights``, and ``points`` keys (``stepsize`` is an accepted alias + for the latter) control the estimate and the remaining keys style + the resulting curve, e.g. ``color``, ``linestyle``, ``linewidth``. + Only used when hist=False. points : int, default: 200 Number of points to evaluate the KDE at. Higher values create smoother curves but take longer to compute. Only used when hist=False. @@ -6798,19 +6851,8 @@ def _apply_ridgeline( colors = colors * (n_ridges // len(colors) + 1) colors = colors[:n_ridges] - # Prepare KDE kwargs - if kde_kw is None: - kde_kw = {} - kde_kw = dict(kde_kw) - kde_kw_plot = dict(kde_kw) - kde_points = int(kde_kw_plot.pop("stepsize", points)) - kde_points = int(kde_kw_plot.pop("points", kde_points)) - kde_kw_gen = { - "bw_method": kde_kw_plot.pop("bw_method", None), - "weights": kde_kw_plot.pop("weights", None), - } - if kde_points < 2: - raise ValueError("`points` must be at least 2") + # Split the KDE settings into estimator and line style arguments + kw_kde, kw_line = _parse_kde_kw(kde_kw, points=points) # Calculate KDE or histogram for each distribution ridges = [] @@ -6823,8 +6865,7 @@ def _apply_ridgeline( f"Invalid histtype={histtype!r}. Options are {allowed}." ) for i, dist in enumerate(data): - dist = np.asarray(dist).ravel() - dist = dist[~np.isnan(dist)] # Remove NaNs + dist, _ = inputs._dist_finite(dist) if len(dist) < 2: warnings._warn_ultraplot( @@ -6858,8 +6899,10 @@ def _apply_ridgeline( else: # Perform KDE try: - x, y = _kde_eval1d(dist, points=kde_points, margin=0.1, **kde_kw_gen) + x, y = inputs._dist_kde(dist, margin=0.1, **kw_kde) ridges.append({"x": x, "y": y, "hist": False}) + except ImportError: # scipy is missing, no point continuing + raise except Exception as e: warnings._warn_ultraplot( f"KDE failed for distribution {i}: {e}, skipping" @@ -6935,6 +6978,21 @@ def _apply_ridgeline( fill_zorder = base_zorder + (n_ridges - i - 1) * 2 outline_zorder = fill_zorder + 1 + # Outline style. Ridges drawn without a fill carry the ridge color on + # the outline instead, and KDE ridges additionally honor the line + # properties left over in `kde_kw`. + stepped = is_hist and histtype in ("step", "stepfilled") + filled = histtype == "stepfilled" if stepped else fill + line_kw = dict( + color=edgecolor if filled or stepped else colors[i], + linewidth=linewidth, + zorder=outline_zorder, + ) + if stepped: + line_kw["drawstyle"] = "steps-mid" + if not is_hist: + line_kw.update(kw_line) + if is_hist and histtype == "bar": counts = ridge["counts"] bin_edges = ridge["bin_edges"] @@ -6973,132 +7031,29 @@ def _apply_ridgeline( label=labels[i], zorder=fill_zorder, ) - elif is_hist and histtype in ("step", "stepfilled"): - stepline_kw = dict( - color=edgecolor, linewidth=linewidth, zorder=outline_zorder - ) - stepline_kw.update(kde_kw_plot) - if vert: - if histtype == "stepfilled": - poly = self.fill_between( - x, - offset, - y_plot, - facecolor=colors[i], - alpha=alpha, - edgecolor="none", - label=labels[i], - step="mid", - zorder=fill_zorder, - ) - else: - poly = self.plot( - x, - y_plot, - label=labels[i], - drawstyle="steps-mid", - **stepline_kw, - )[0] - self.plot( + else: + # Curve ridges, i.e. a KDE curve or a filled histogram outline. + # Both orientations take the same fill arguments and only differ + # in the order of the line coordinates. + fill_func = self.fill_between if vert else self.fill_betweenx + line_args = (x, y_plot) if vert else (y_plot, x) + if filled: + # Fill without an edge and draw the outline on top so that + # the baseline is excluded from the outline + poly = fill_func( x, + offset, y_plot, - drawstyle="steps-mid", - **stepline_kw, + facecolor=colors[i], + alpha=alpha, + edgecolor="none", + label=labels[i], + step="mid" if stepped else None, + zorder=fill_zorder, ) + self.plot(*line_args, **line_kw) else: - if histtype == "stepfilled": - poly = self.fill_betweenx( - x, - offset, - y_plot, - facecolor=colors[i], - alpha=alpha, - edgecolor="none", - label=labels[i], - step="mid", - zorder=fill_zorder, - ) - else: - poly = self.plot( - y_plot, - x, - label=labels[i], - drawstyle="steps-mid", - **stepline_kw, - )[0] - self.plot( - y_plot, - x, - drawstyle="steps-mid", - **stepline_kw, - ) - else: - if vert: - line_kw = dict(color=edgecolor, linewidth=linewidth, zorder=outline_zorder) - line_kw.update(kde_kw_plot) - # Traditional horizontal ridges - if fill: - # Fill without edge - poly = self.fill_between( - x, - offset, - y_plot, - facecolor=colors[i], - alpha=alpha, - edgecolor="none", - label=labels[i], - zorder=fill_zorder, - ) - # Draw outline on top (excluding baseline) - self.plot( - x, - y_plot, - **line_kw, - ) - else: - line_kw = dict( - color=colors[i], linewidth=linewidth, zorder=outline_zorder - ) - line_kw.update(kde_kw_plot) - poly = self.plot( - x, - y_plot, - label=labels[i], - **line_kw, - )[0] - else: - # Vertical ridges - line_kw = dict(color=edgecolor, linewidth=linewidth, zorder=outline_zorder) - line_kw.update(kde_kw_plot) - if fill: - # Fill without edge - poly = self.fill_betweenx( - x, - offset, - y_plot, - facecolor=colors[i], - alpha=alpha, - edgecolor="none", - label=labels[i], - zorder=fill_zorder, - ) - # Draw outline on top (excluding baseline) - self.plot( - y_plot, - x, - **line_kw, - ) - else: - line_kw = dict( - color=colors[i], linewidth=linewidth, zorder=outline_zorder - ) - line_kw.update(kde_kw_plot) - poly = self.plot( - y_plot, - x, - label=labels[i], - **line_kw, - )[0] + poly = self.plot(*line_args, label=labels[i], **line_kw)[0] artists.append(poly) @@ -7203,102 +7158,111 @@ def _apply_hist( if type(sub) is list: res[i] = cbook.silent_list("Polygon", sub) self._update_guide(res, **guide_kw) - # add kde line - if not kde: - return obj - - kde_kw = dict(kde_kw or {}) - # kde_kw includes both kde line generating args and kde line plotting args: - # 1. for kde line generating: stepsize, bw_method... - # 2. for line plotting, passed to ax.plot, controlling line style. - # The following code supposed to extract the 1st kind, and leave the 2nd. - _kde_kw_only = { - "density": kw.get("density", False), # kde share the density arg with hist - "stepsize": kde_kw.pop("stepsize", 300), # stepsize only works for kde line - "weights": kw.get("weights", None), # share arg with hist - "bw_method": kde_kw.pop( - "bw_method", None - ), # kde arg passed to gaussian_kde - "orientation": orientation, # share arg with hist - } - lines = self._kde_line1d(xs, obj, _kde_kw_only) - for x_line, y_line in lines: - self._call_native("plot", x_line, y_line, **kde_kw) + # Overlay the kernel density estimate of each column + if kde: + kw_kde, kw_line = _parse_kde_kw(kde_kw, weights=kw.get("weights", None)) + self._add_kde_lines( + xs, + edges=obj[1], + density=kw.get("density", None), + stack=histtype == "barstacked", + orientation=orientation, + colors=_get_hist_colors(res, n), + **kw_kde, + **kw_line, + ) return obj - def _kde_line1d(self, x, obj, kw): + def _add_kde_lines( + self, + xs, + *, + edges=None, + density=None, + stack=False, + orientation="vertical", + colors=None, + points=None, + bw_method=None, + weights=None, + **kwargs, + ): """ - Compute Gaussian kernel density estimate (KDE) lines for 1D/2D data. - - This helper is called by :meth:`_apply_hist` to draw KDE curves on - top of a 1d histogram. It evaluates :func:`scipy.stats.gaussian_kde` on - a regular grid spanning each data column and, when ``density=False``, - rescales each estimate so its area matches the histogram bin counts. - For horizontal histograms the coordinates are swapped. One ``(x, y)`` - line is produced per column of ``x``. + Add a gaussian kernel density estimate line for each column of `xs`. Parameters ---------- - x : array-like - The input data. A 1D array is treated as a single sample column; - a 2D array is interpreted as ``(M, N)``, producing one KDE line - per column. - obj : tuple - The return value of the native ``hist`` call. ``obj[1]`` holds the - bin edges, which are used to rescale each KDE so it matches the - histogram bin counts. - kw : dict - Options controlling the KDE computation. Recognized keys: - - ``density`` : bool, default False - Whether to return the raw probability density. If False, each - estimate is rescaled by the sample size and bin width so the - curve matches the histogram bin counts. - ``stepsize`` : int, default 300 - Number of points in the evaluation grid spanning each column's - data range. - ``weights`` : array-like or None, optional - Sample weights passed to :func:`scipy.stats.gaussian_kde`. - ``bw_method`` : str, scalar or callable, optional - Bandwidth method passed to :func:`scipy.stats.gaussian_kde`. - ``orientation`` : {'vertical', 'horizontal'} - Line orientation. Coordinates are swapped for horizontal - histograms. + xs : array-like + The sample data. 1D arrays are treated as a single column and 2D + arrays produce one line per column. + edges : array-like, optional + The histogram bin edges. When passed, the estimates are rescaled + from probability densities to bin counts (see `density`). + density : bool, optional + The `~matplotlib.axes.Axes.hist` normalization. When ``False`` and + `edges` was passed, each estimate is scaled by the sample mass and + the local bin width so the curve tracks the bin counts. + stack : bool, default: False + Whether the parent histogram is stacked. If so the estimates share + one evaluation grid and are accumulated like the bin counts. + orientation : {'vertical', 'horizontal'}, optional + The parent histogram orientation. Coordinates are swapped for + horizontal histograms. + colors : sequence, optional + The line color for each column, generally the color of the + corresponding histogram artists. + points, bw_method, weights : optional + Passed to `~ultraplot.internals.inputs._dist_kde`. + **kwargs + Passed to `~matplotlib.axes.Axes.plot`. Returns ------- - lines : list of tuple of ndarray - One ``(x_line, y_line)`` tuple of coordinates per column of - ``x``. - - Raises - ------ - ImportError - If scipy is not installed. - ValueError - If ``kw['orientation']`` is neither 'vertical' nor 'horizontal'. - """ - edges = obj[1] - data2d = x if x.ndim > 1 else x[:, None] - lines = [] - for i in range(data2d.shape[1]): - _x = data2d[:, i] - xa, ya = _kde_eval1d( - _x, points=kw["stepsize"], bw_method=kw["bw_method"], weights=kw["weights"] + list of `~matplotlib.lines.Line2D` + The kernel density estimate lines. + """ + # Sanitize the data and record the sample mass of each column. The mass + # is the number of valid points, or their total weight if weighted, and + # converts the probability densities back into histogram bin counts. + xs = inputs._to_numpy_array(xs) + xs = xs[:, None] if xs.ndim == 1 else xs + weights = None if weights is None else inputs._to_numpy_array(weights) + if weights is not None and weights.ndim == 1: + weights = np.repeat(weights[:, None], xs.shape[1], axis=1) + dists = [ + inputs._dist_finite(xs[:, i], None if weights is None else weights[:, i]) + for i in range(xs.shape[1]) + ] + masses = np.array( + [dist.size if w is None else w.sum() for dist, w in dists], dtype=float + ) + + # Share one evaluation grid between stacked columns so the estimates can + # be accumulated the same way matplotlib accumulates the bin counts. + coords = None + if stack and len(dists) > 1: + valid = np.concatenate([dist for dist, _ in dists]) + coords = np.linspace(valid.min(), valid.max(), int(points or KDE_POINTS)) + + objs, accum = [], 0 + for i, (dist, w) in enumerate(dists): + x, y = inputs._dist_kde( + dist, coords=coords, points=points, bw_method=bw_method, weights=w ) - if not kw["density"]: - idx = np.clip(np.digitize(xa, edges) - 1, 0, len(edges) - 2) - ya = ya * len(_x) * np.diff(edges)[idx] - if kw["orientation"] == "vertical": - x_line, y_line = xa, ya - elif kw["orientation"] == "horizontal": - x_line, y_line = ya, xa - else: - raise ValueError( - f"Invalid orientation: {kw['orientation']}, must be 'vertical' or 'horizontal'" - ) - lines.append((x_line, y_line)) - return lines + if stack and density: # each column contributes its share of the total + y = y * masses[i] / masses.sum() + elif edges is not None and not density: # rescale to the bin counts + idxs = np.clip(np.digitize(x, edges) - 1, 0, len(edges) - 2) + y = y * masses[i] * np.diff(edges)[idxs] + if coords is not None: + y = accum = accum + y + kw = kwargs.copy() + if colors is not None and i < len(colors): + kw.setdefault("color", colors[i]) + if orientation == "horizontal": + x, y = y, x + objs.extend(self._call_native("plot", x, y, **kw)) + return objs @inputs._preprocess_or_redirect("x", "bins", keywords="weights") @docstring._concatenate_inherited diff --git a/ultraplot/internals/inputs.py b/ultraplot/internals/inputs.py index 14438c686..cd878bf7f 100644 --- a/ultraplot/internals/inputs.py +++ b/ultraplot/internals/inputs.py @@ -410,6 +410,107 @@ def _preprocess_or_redirect(self, *args, **kwargs): # Stats utiltiies +def _dist_finite(distribution, weights=None): + """ + Return the finite subset of the distribution together with the matching + subset of the weights. Used to sanitize input for `_dist_kde`. + """ + distribution = _to_numpy_array(distribution).ravel() + mask = np.isfinite(distribution) + if weights is not None: + weights = _to_numpy_array(weights).ravel() + if weights.size != distribution.size: + raise ValueError( + f"Got {weights.size} weights but {distribution.size} data points." + ) + weights = weights[mask] + return distribution[mask], weights + + +def _dist_kde( + distribution, + *, + coords=None, + points=200, + margin=0.0, + bw_method=None, + weights=None, +): + """ + Return the coordinates and gaussian kernel density estimate of the input + distribution. This is the single entry point for the kernel density + estimates drawn by `~ultraplot.axes.PlotAxes.hist` and + `~ultraplot.axes.PlotAxes.ridgeline`. + + Parameters + ---------- + distribution : array-like + The sample. Flattened to 1D and stripped of non-finite values. + coords : array-like, optional + The coordinates to evaluate the estimate on. If ``None`` an evenly + spaced grid is built from the data range (see `points` and `margin`). + points : int, default: 200 + The number of evenly spaced evaluation coordinates. Larger values give + smoother curves at the cost of speed. Ignored if `coords` was passed. + margin : float, default: 0 + The fraction of the data range used to pad either side of the + evaluation grid. Ignored if `coords` was passed. + bw_method : str, float, or callable, optional + The bandwidth selector passed to `scipy.stats.gaussian_kde`. Can be + ``'scott'``, ``'silverman'``, a scalar, or a callable. + weights : array-like, optional + The per-sample weights passed to `scipy.stats.gaussian_kde`. + + Returns + ------- + coords : ndarray + The evaluation coordinates. + density : ndarray + The probability density evaluated on `coords`. Integrates to ``1``. + """ + # NOTE: scipy is an optional dependency so it is imported lazily here rather + # than at the top of the module. This is the only place ultraplot needs it. + try: + from scipy.stats import gaussian_kde + except ModuleNotFoundError: + raise ImportError( + "Kernel density estimation requires scipy. Install it with " + "'pip install scipy' or 'pip install ultraplot[stats]'." + ) from None + + distribution, weights = _dist_finite(distribution, weights) + if distribution.size < 2: + raise ValueError( + "Kernel density estimation requires at least 2 finite data points " + f"but got {distribution.size}." + ) + if coords is None: + try: + points = int(points) + except (TypeError, ValueError): + raise ValueError( + f"Number of evaluation points must be an integer but got {points!r}." + ) from None + if points < 2: + raise ValueError( + f"Number of evaluation points must be at least 2 but got {points}." + ) + lo, hi = distribution.min(), distribution.max() + pad = margin * (hi - lo) + coords = np.linspace(lo - pad, hi + pad, points) + else: + coords = _to_numpy_array(coords).ravel() + + try: + density = gaussian_kde(distribution, bw_method=bw_method, weights=weights) + except np.linalg.LinAlgError: + raise ValueError( + "Kernel density estimation failed because the distribution has zero " + "variance. Pass varying data or use hist=True instead." + ) from None + return coords, density(coords) + + def _dist_clean(distribution): """ Clean the distribution data for processing by `boxplot` or `violinplot`. diff --git a/ultraplot/tests/test_1dplots.py b/ultraplot/tests/test_1dplots.py index 9aa1f6137..28b0f03c8 100644 --- a/ultraplot/tests/test_1dplots.py +++ b/ultraplot/tests/test_1dplots.py @@ -298,49 +298,92 @@ def test_histogram_types(rng): ax.hist(data, ec="k", **kw) return fig + def test_hist_kde_lines(rng): """ - Test kde for hist. + Test the kde overlay drawn by hist. """ - scipy = pytest.importorskip("scipy") + pytest.importorskip("scipy") data = rng.normal(size=200) - # No kde line if no kde arg is not given + # No kde line unless kde=True fig, ax = uplt.subplot() ax.hist(data, bins=20) assert len(ax.lines) == 0 - # No kde if kde=False ax.hist(data, bins=20, kde=False) assert len(ax.lines) == 0 - # One kde line with density=False and stepsize=300 by default + # One kde line spanning the data range, sampled at the default resolution ax.hist(data, bins=20, kde=True) assert len(ax.lines) == 1 - # test step size line = ax.lines[-1] - # default stepsize is 300 - assert line.get_xdata().size == 300 - assert line.get_ydata().size == 300 + assert line.get_xdata().size == uplt.axes.plot.KDE_POINTS + assert line.get_ydata().size == uplt.axes.plot.KDE_POINTS assert line.get_xdata()[0] == pytest.approx(data.min()) assert line.get_xdata()[-1] == pytest.approx(data.max()) - # Another line with stepsize=150 and density=True - ax.hist(data, bins=20, kde=True, density=True, - kde_kw={'stepsize': 150}) + # Another line with a custom resolution and density=True + ax.hist(data, bins=20, kde=True, density=True, kde_kw={"points": 150}) density_line = ax.lines[-1] assert density_line.get_xdata().size == 150 assert density_line.get_ydata().size == 150 - # Do we need to test accurate counts when density=False? + # The default curve tracks the bin counts, the density curve integrates to 1 assert line.get_ydata().max() > 1.0 assert density_line.get_ydata().max() <= 1.0 - # test area==1 when density=True area = np.trapezoid(density_line.get_ydata(), density_line.get_xdata()) assert area == pytest.approx(1.0, rel=1e-2) uplt.close(fig) +def test_hist_kde_points_alias(rng): + """ + Test that 'stepsize' remains accepted as an alias for 'points'. + """ + pytest.importorskip("scipy") + data = rng.normal(size=200) + fig, ax = uplt.subplots() + ax.hist(data, bins=20, kde=True, kde_kw={"stepsize": 42}) + assert ax.lines[-1].get_xdata().size == 42 + uplt.close(fig) + + +def test_hist_kde_line_style(rng): + """ + Test that leftover kde_kw keys style the kde line and that the line + otherwise inherits the color of its histogram. + """ + pytest.importorskip("scipy") + data = rng.normal(size=(100, 3)) + fig, ax = uplt.subplots() + obj = ax.hist(data, bins=20, kde=True) + assert len(ax.lines) == 3 + for line, container in zip(ax.lines, obj[2]): + assert np.allclose(line.get_color(), container[0].get_facecolor()[:3]) + ax.hist(data[:, 0], bins=20, kde=True, kde_kw={"color": "k", "ls": "--"}) + assert ax.lines[-1].get_linestyle() == "--" + assert uplt.colors.to_rgba(ax.lines[-1].get_color()) == uplt.colors.to_rgba("k") + uplt.close(fig) + + +def test_hist_kde_stacked(rng): + """ + Test that stacked histograms accumulate their kde lines. + """ + pytest.importorskip("scipy") + data = rng.normal(size=(100, 3)) + fig, ax = uplt.subplots() + ax.hist(data, bins=20, kde=True, stack=True) + assert len(ax.lines) == 3 + # Stacked curves share one grid and increase monotonically + xs = [line.get_xdata() for line in ax.lines] + assert all(np.allclose(x, xs[0]) for x in xs) + ys = [line.get_ydata() for line in ax.lines] + assert np.all(ys[1] >= ys[0]) and np.all(ys[2] >= ys[1]) + uplt.close(fig) + + def test_hist_kde_multiple_columns(rng): """ Test data with multiple columns """ - scipy = pytest.importorskip("scipy") + pytest.importorskip("scipy") data = rng.normal(size=(100, 3)) fig, ax = uplt.subplots() ax.hist(data, bins=20, kde=True) @@ -352,7 +395,7 @@ def test_hist_kde_orientation(rng): """ Test when orientation='horizontal' """ - scipy = pytest.importorskip("scipy") + pytest.importorskip("scipy") data = rng.normal(size=200) fig, ax = uplt.subplots() ax.hist(data, bins=20, kde=True, orientation="horizontal") @@ -362,6 +405,35 @@ def test_hist_kde_orientation(rng): uplt.close(fig) +def test_hist_kde_nan_and_weights(rng): + """ + Test that non-finite values are dropped and weights are shared with hist. + """ + pytest.importorskip("scipy") + data = rng.normal(size=200) + data[::10] = np.nan + fig, ax = uplt.subplots() + ax.hist(data, bins=20, kde=True) + line = ax.lines[-1] + assert np.all(np.isfinite(line.get_ydata())) + assert line.get_xdata()[0] == pytest.approx(np.nanmin(data)) + # Weighted counts scale the curve by the total weight rather than the count + ax.hist(data, bins=20, kde=True, weights=np.full(data.size, 2.0)) + assert ax.lines[-1].get_ydata().max() > 1.5 * line.get_ydata().max() + uplt.close(fig) + + +def test_hist_kde_requires_variation(rng): + """ + Test that a degenerate distribution raises a helpful error. + """ + pytest.importorskip("scipy") + fig, ax = uplt.subplots() + with pytest.raises(ValueError, match="zero variance"): + ax.hist(np.ones(50), bins=5, kde=True) + uplt.close(fig) + + @pytest.mark.mpl_image_compare def test_invalid_plot(rng): """ diff --git a/ultraplot/tests/test_statistical_plotting.py b/ultraplot/tests/test_statistical_plotting.py index e6887ffd1..c82401d72 100644 --- a/ultraplot/tests/test_statistical_plotting.py +++ b/ultraplot/tests/test_statistical_plotting.py @@ -369,20 +369,41 @@ def test_ridgeline_kde_kw(rng): assert len(artists) == 3 uplt.close(fig) - # Test style and stepsize passthrough (compatibility with histogram naming) + # Test that leftover keys style the curve and that 'points' overrides the + # 'points' argument of the command itself fig, ax = uplt.subplots() artists = ax.ridgeline( data, labels=labels, overlap=0.5, fill=False, - kde_kw={"stepsize": 80, "color": "k", "linestyle": "--"}, + points=200, + kde_kw={"points": 80, "color": "k", "linestyle": "--"}, ) assert len(artists) == 3 assert len(ax.lines[0].get_xdata()) == 80 assert ax.lines[0].get_linestyle() == "--" uplt.close(fig) + # Test that 'stepsize' remains accepted as an alias for 'points' + fig, ax = uplt.subplots() + ax.ridgeline(data, labels=labels, fill=False, kde_kw={"stepsize": 42}) + assert len(ax.lines[0].get_xdata()) == 42 + uplt.close(fig) + + +def test_ridgeline_histogram_step(rng): + """ + Test that step histogram ridges draw exactly one outline each. + """ + data = [rng.normal(i, 1, 300) for i in range(3)] + for histtype, nlines in (("step", 3), ("stepfilled", 3)): + fig, ax = uplt.subplots() + artists = ax.ridgeline(data, hist=True, histtype=histtype) + assert len(artists) == 3 + assert len(ax.lines) == nlines + uplt.close(fig) + def test_ridgeline_points(rng): """ From ca57b8080764584ad345ad4c36b484e89283da82 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 18 Aug 2026 17:31:18 +1000 Subject: [PATCH 13/15] trying this layout out --- ultraplot/axes/plot.py | 192 ++++++++++++++++---------------- ultraplot/internals/inputs.py | 5 +- ultraplot/tests/test_1dplots.py | 4 +- 3 files changed, 101 insertions(+), 100 deletions(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 282c939e4..76c809cd9 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -65,10 +65,6 @@ # This is half of rc['patch.linewidth'] of 0.6. Half seems like a nice default. EDGEWIDTH = 0.3 -# NOTE: Shared by every command that draws a kernel density estimate so that -# 'hist' and 'ridgeline' curves are sampled identically by default. -KDE_POINTS = 200 - DataInput: TypeAlias = ArrayLike ColorTupleRGB: TypeAlias = tuple[float, float, float] ColorTupleRGBA: TypeAlias = tuple[float, float, float, float] @@ -1948,7 +1944,7 @@ def _parse_kde_kw(kde_kw=None, *, points=None, weights=None): "points": _not_none( points=kw_line.pop("points", None), stepsize=kw_line.pop("stepsize", None), # backwards compatible alias - default=_not_none(points, KDE_POINTS), + default=_not_none(points, inputs.KDE_POINTS), ), "bw_method": kw_line.pop("bw_method", None), "weights": _not_none(kw_line.pop("weights", None), weights), @@ -3800,6 +3796,98 @@ def _inbounds_xylim(self, extents, x, y, **kwargs): f"data within locked x (y) limits only. Error message: {err}" ) + def _add_kde_lines( + self, + xs, + *, + edges=None, + density=None, + stack=False, + orientation="vertical", + colors=None, + points=None, + bw_method=None, + weights=None, + **kwargs, + ): + """ + Add a gaussian kernel density estimate line for each column of `xs`. + + Parameters + ---------- + xs : array-like + The sample data. 1D arrays are treated as a single column and 2D + arrays produce one line per column. + edges : array-like, optional + The histogram bin edges. When passed, the estimates are rescaled + from probability densities to bin counts (see `density`). + density : bool, optional + The `~matplotlib.axes.Axes.hist` normalization. When ``False`` and + `edges` was passed, each estimate is scaled by the sample mass and + the local bin width so the curve tracks the bin counts. + stack : bool, default: False + Whether the parent histogram is stacked. If so the estimates share + one evaluation grid and are accumulated like the bin counts. + orientation : {'vertical', 'horizontal'}, optional + The parent histogram orientation. Coordinates are swapped for + horizontal histograms. + colors : sequence, optional + The line color for each column, generally the color of the + corresponding histogram artists. + points, bw_method, weights : optional + Passed to `~ultraplot.internals.inputs._dist_kde`. + **kwargs + Passed to `~matplotlib.axes.Axes.plot`. + + Returns + ------- + list of `~matplotlib.lines.Line2D` + The kernel density estimate lines. + """ + # Sanitize the data and record the sample mass of each column. The mass + # is the number of valid points, or their total weight if weighted, and + # converts the probability densities back into histogram bin counts. + points = _not_none(points, inputs.KDE_POINTS) + xs = inputs._to_numpy_array(xs) + xs = xs[:, None] if xs.ndim == 1 else xs + weights = None if weights is None else inputs._to_numpy_array(weights) + if weights is not None and weights.ndim == 1: + weights = np.repeat(weights[:, None], xs.shape[1], axis=1) + dists = [ + inputs._dist_finite(xs[:, i], None if weights is None else weights[:, i]) + for i in range(xs.shape[1]) + ] + masses = np.array( + [dist.size if w is None else w.sum() for dist, w in dists], dtype=float + ) + + # Share one evaluation grid between stacked columns so the estimates can + # be accumulated the same way matplotlib accumulates the bin counts. + coords = None + if stack and len(dists) > 1: + valid = np.concatenate([dist for dist, _ in dists]) + coords = np.linspace(valid.min(), valid.max(), int(points)) + + objs, accum = [], 0 + for i, (dist, w) in enumerate(dists): + x, y = inputs._dist_kde( + dist, coords=coords, points=points, bw_method=bw_method, weights=w + ) + if stack and density: # each column contributes its share of the total + y = y * masses[i] / masses.sum() + elif edges is not None and not density: # rescale to the bin counts + idxs = np.clip(np.digitize(x, edges) - 1, 0, len(edges) - 2) + y = y * masses[i] * np.diff(edges)[idxs] + if coords is not None: + y = accum = accum + y + kw = kwargs.copy() + if colors is not None and i < len(colors): + kw.setdefault("color", colors[i]) + if orientation == "horizontal": + x, y = y, x + objs.extend(self._call_native("plot", x, y, **kw)) + return objs + def _parse_1d_args(self, x, *ys, **kwargs): """ Interpret positional arguments for all 1D plotting commands. @@ -6748,7 +6836,7 @@ def _apply_ridgeline( height=None, overlap=0.5, kde_kw=None, - points=200, + points=None, hist=False, bins="auto", histtype=None, @@ -7158,6 +7246,7 @@ def _apply_hist( if type(sub) is list: res[i] = cbook.silent_list("Polygon", sub) self._update_guide(res, **guide_kw) + # Overlay the kernel density estimate of each column if kde: kw_kde, kw_line = _parse_kde_kw(kde_kw, weights=kw.get("weights", None)) @@ -7173,97 +7262,6 @@ def _apply_hist( ) return obj - def _add_kde_lines( - self, - xs, - *, - edges=None, - density=None, - stack=False, - orientation="vertical", - colors=None, - points=None, - bw_method=None, - weights=None, - **kwargs, - ): - """ - Add a gaussian kernel density estimate line for each column of `xs`. - - Parameters - ---------- - xs : array-like - The sample data. 1D arrays are treated as a single column and 2D - arrays produce one line per column. - edges : array-like, optional - The histogram bin edges. When passed, the estimates are rescaled - from probability densities to bin counts (see `density`). - density : bool, optional - The `~matplotlib.axes.Axes.hist` normalization. When ``False`` and - `edges` was passed, each estimate is scaled by the sample mass and - the local bin width so the curve tracks the bin counts. - stack : bool, default: False - Whether the parent histogram is stacked. If so the estimates share - one evaluation grid and are accumulated like the bin counts. - orientation : {'vertical', 'horizontal'}, optional - The parent histogram orientation. Coordinates are swapped for - horizontal histograms. - colors : sequence, optional - The line color for each column, generally the color of the - corresponding histogram artists. - points, bw_method, weights : optional - Passed to `~ultraplot.internals.inputs._dist_kde`. - **kwargs - Passed to `~matplotlib.axes.Axes.plot`. - - Returns - ------- - list of `~matplotlib.lines.Line2D` - The kernel density estimate lines. - """ - # Sanitize the data and record the sample mass of each column. The mass - # is the number of valid points, or their total weight if weighted, and - # converts the probability densities back into histogram bin counts. - xs = inputs._to_numpy_array(xs) - xs = xs[:, None] if xs.ndim == 1 else xs - weights = None if weights is None else inputs._to_numpy_array(weights) - if weights is not None and weights.ndim == 1: - weights = np.repeat(weights[:, None], xs.shape[1], axis=1) - dists = [ - inputs._dist_finite(xs[:, i], None if weights is None else weights[:, i]) - for i in range(xs.shape[1]) - ] - masses = np.array( - [dist.size if w is None else w.sum() for dist, w in dists], dtype=float - ) - - # Share one evaluation grid between stacked columns so the estimates can - # be accumulated the same way matplotlib accumulates the bin counts. - coords = None - if stack and len(dists) > 1: - valid = np.concatenate([dist for dist, _ in dists]) - coords = np.linspace(valid.min(), valid.max(), int(points or KDE_POINTS)) - - objs, accum = [], 0 - for i, (dist, w) in enumerate(dists): - x, y = inputs._dist_kde( - dist, coords=coords, points=points, bw_method=bw_method, weights=w - ) - if stack and density: # each column contributes its share of the total - y = y * masses[i] / masses.sum() - elif edges is not None and not density: # rescale to the bin counts - idxs = np.clip(np.digitize(x, edges) - 1, 0, len(edges) - 2) - y = y * masses[i] * np.diff(edges)[idxs] - if coords is not None: - y = accum = accum + y - kw = kwargs.copy() - if colors is not None and i < len(colors): - kw.setdefault("color", colors[i]) - if orientation == "horizontal": - x, y = y, x - objs.extend(self._call_native("plot", x, y, **kw)) - return objs - @inputs._preprocess_or_redirect("x", "bins", keywords="weights") @docstring._concatenate_inherited @docstring._snippet_manager diff --git a/ultraplot/internals/inputs.py b/ultraplot/internals/inputs.py index cd878bf7f..40f57be3a 100644 --- a/ultraplot/internals/inputs.py +++ b/ultraplot/internals/inputs.py @@ -23,6 +23,9 @@ # Constants +# NOTE: Shared by every command that draws a kernel density estimate so that +# 'hist' and 'ridgeline' curves are sampled identically by default. +KDE_POINTS = 200 BASEMAP_FUNCS = ( # default latlon=True "barbs", "contour", @@ -431,7 +434,7 @@ def _dist_kde( distribution, *, coords=None, - points=200, + points=KDE_POINTS, margin=0.0, bw_method=None, weights=None, diff --git a/ultraplot/tests/test_1dplots.py b/ultraplot/tests/test_1dplots.py index 28b0f03c8..3ef068d3f 100644 --- a/ultraplot/tests/test_1dplots.py +++ b/ultraplot/tests/test_1dplots.py @@ -315,8 +315,8 @@ def test_hist_kde_lines(rng): ax.hist(data, bins=20, kde=True) assert len(ax.lines) == 1 line = ax.lines[-1] - assert line.get_xdata().size == uplt.axes.plot.KDE_POINTS - assert line.get_ydata().size == uplt.axes.plot.KDE_POINTS + assert line.get_xdata().size == uplt.internals.inputs.KDE_POINTS + assert line.get_ydata().size == uplt.internals.inputs.KDE_POINTS assert line.get_xdata()[0] == pytest.approx(data.min()) assert line.get_xdata()[-1] == pytest.approx(data.max()) # Another line with a custom resolution and density=True From 3e985b7289763e1d67104005891852979199ed21 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 18 Aug 2026 17:39:01 +1000 Subject: [PATCH 14/15] move some stuff to rc --- docs/stats.py | 1 - ultraplot/axes/plot.py | 17 ++++++++++------- ultraplot/internals/inputs.py | 11 +++++------ ultraplot/internals/rcsetup.py | 7 +++++++ ultraplot/tests/test_1dplots.py | 26 ++++++++++++++++++++++++-- 5 files changed, 46 insertions(+), 16 deletions(-) diff --git a/docs/stats.py b/docs/stats.py index 373ba3642..1d7ef151a 100644 --- a/docs/stats.py +++ b/docs/stats.py @@ -253,7 +253,6 @@ labels=list("abc"), legend="ul", kde=True, - kde_kw={"lw": 2}, ) # %% diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 76c809cd9..6260686a8 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -1436,7 +1436,7 @@ The remaining keys style the resulting curve and are passed to `matplotlib.axes.Axes.plot`, e.g. ``color``, ``linestyle``, ``linewidth``. Only used when hist=False. -points : int, default: 200 +points : int, default: :rc:`kde.points` Number of evaluation points for KDE curves. Higher values create smoother curves but take longer to compute. Only used when hist=False. hist : bool, default: False @@ -1530,7 +1530,7 @@ * ``bw_method`` : Bandwidth selection method (scalar, 'scott', 'silverman', or callable) * ``weights`` : Array of weights for each data point, defaults to `weights` - * ``points`` : Number of evaluation points, default ``200`` + * ``points`` : Number of evaluation points, default :rc:`kde.points` (``stepsize`` is accepted as an alias) The remaining keys style the resulting curve and are passed to @@ -1930,7 +1930,8 @@ def _parse_kde_kw(kde_kw=None, *, points=None, weights=None): else is treated as a line property. points, weights : optional The defaults inherited from the parent command, used when the - corresponding key is absent from `kde_kw`. + corresponding key is absent from `kde_kw`. Leaving `points` as ``None`` + defers to :rc:`kde.points`. Returns ------- @@ -1944,7 +1945,7 @@ def _parse_kde_kw(kde_kw=None, *, points=None, weights=None): "points": _not_none( points=kw_line.pop("points", None), stepsize=kw_line.pop("stepsize", None), # backwards compatible alias - default=_not_none(points, inputs.KDE_POINTS), + default=points, ), "bw_method": kw_line.pop("bw_method", None), "weights": _not_none(kw_line.pop("weights", None), weights), @@ -3834,7 +3835,9 @@ def _add_kde_lines( colors : sequence, optional The line color for each column, generally the color of the corresponding histogram artists. - points, bw_method, weights : optional + points : int, default: :rc:`kde.points` + The number of coordinates used to evaluate each estimate. + bw_method, weights : optional Passed to `~ultraplot.internals.inputs._dist_kde`. **kwargs Passed to `~matplotlib.axes.Axes.plot`. @@ -3847,7 +3850,7 @@ def _add_kde_lines( # Sanitize the data and record the sample mass of each column. The mass # is the number of valid points, or their total weight if weighted, and # converts the probability densities back into histogram bin counts. - points = _not_none(points, inputs.KDE_POINTS) + points = _not_none(points, rc["kde.points"]) xs = inputs._to_numpy_array(xs) xs = xs[:, None] if xs.ndim == 1 else xs weights = None if weights is None else inputs._to_numpy_array(weights) @@ -6872,7 +6875,7 @@ def _apply_ridgeline( for the latter) control the estimate and the remaining keys style the resulting curve, e.g. ``color``, ``linestyle``, ``linewidth``. Only used when hist=False. - points : int, default: 200 + points : int, default: :rc:`kde.points` Number of points to evaluate the KDE at. Higher values create smoother curves but take longer to compute. Only used when hist=False. hist : bool, default: False diff --git a/ultraplot/internals/inputs.py b/ultraplot/internals/inputs.py index 40f57be3a..800345f6c 100644 --- a/ultraplot/internals/inputs.py +++ b/ultraplot/internals/inputs.py @@ -23,9 +23,6 @@ # Constants -# NOTE: Shared by every command that draws a kernel density estimate so that -# 'hist' and 'ridgeline' curves are sampled identically by default. -KDE_POINTS = 200 BASEMAP_FUNCS = ( # default latlon=True "barbs", "contour", @@ -434,7 +431,7 @@ def _dist_kde( distribution, *, coords=None, - points=KDE_POINTS, + points=None, margin=0.0, bw_method=None, weights=None, @@ -452,7 +449,7 @@ def _dist_kde( coords : array-like, optional The coordinates to evaluate the estimate on. If ``None`` an evenly spaced grid is built from the data range (see `points` and `margin`). - points : int, default: 200 + points : int, default: :rc:`kde.points` The number of evenly spaced evaluation coordinates. Larger values give smoother curves at the cost of speed. Ignored if `coords` was passed. margin : float, default: 0 @@ -488,8 +485,10 @@ def _dist_kde( f"but got {distribution.size}." ) if coords is None: + from ..config import rc # avoid a circular import at module load + try: - points = int(points) + points = int(_not_none(points, rc["kde.points"])) except (TypeError, ValueError): raise ValueError( f"Number of evaluation points must be an integer but got {points!r}." diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 8db81854e..6558f6933 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -1985,6 +1985,13 @@ def _validator_accepts(validator, value): _validate_float, "Z-order for internal political border lines.", ), + # Kernel density settings + "kde.points": ( + 200, + _validate_int, + "Number of evenly spaced coordinates used to evaluate kernel density " + "estimates. Larger values give smoother curves at the cost of speed.", + ), # Axis label settings "label.color": (BLACK, _validate_color, "Alias for :rcraw:`axes.labelcolor`."), "label.pad": ( diff --git a/ultraplot/tests/test_1dplots.py b/ultraplot/tests/test_1dplots.py index 3ef068d3f..d368cdd04 100644 --- a/ultraplot/tests/test_1dplots.py +++ b/ultraplot/tests/test_1dplots.py @@ -315,8 +315,8 @@ def test_hist_kde_lines(rng): ax.hist(data, bins=20, kde=True) assert len(ax.lines) == 1 line = ax.lines[-1] - assert line.get_xdata().size == uplt.internals.inputs.KDE_POINTS - assert line.get_ydata().size == uplt.internals.inputs.KDE_POINTS + assert line.get_xdata().size == uplt.rc["kde.points"] + assert line.get_ydata().size == uplt.rc["kde.points"] assert line.get_xdata()[0] == pytest.approx(data.min()) assert line.get_xdata()[-1] == pytest.approx(data.max()) # Another line with a custom resolution and density=True @@ -332,6 +332,28 @@ def test_hist_kde_lines(rng): uplt.close(fig) +def test_hist_kde_points_rc(rng): + """ + Test that the kde resolution follows the rc setting and that an explicit + keyword still wins over it. + """ + pytest.importorskip("scipy") + data = rng.normal(size=200) + with uplt.rc.context({"kde.points": 57}): + fig, ax = uplt.subplots() + ax.hist(data, bins=20, kde=True) + assert ax.lines[-1].get_xdata().size == 57 + ax.hist(data, bins=20, kde=True, kde_kw={"points": 21}) + assert ax.lines[-1].get_xdata().size == 21 + uplt.close(fig) + # Ridgeline reads the same setting + with uplt.rc.context({"kde.points": 64}): + fig, ax = uplt.subplots() + ax.ridgeline([rng.normal(i, 1, 300) for i in range(3)], fill=False) + assert ax.lines[0].get_xdata().size == 64 + uplt.close(fig) + + def test_hist_kde_points_alias(rng): """ Test that 'stepsize' remains accepted as an alias for 'points'. From ca6fc0a34b6a2d6bb12f01652f09ccfdc6907d33 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 18 Aug 2026 17:47:08 +1000 Subject: [PATCH 15/15] moving more stuff around --- ultraplot/axes/plot.py | 197 ++++++++++++---------------------- ultraplot/internals/inputs.py | 7 +- 2 files changed, 74 insertions(+), 130 deletions(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 6260686a8..55fcb78a5 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -1918,38 +1918,15 @@ def _parse_vert( def _parse_kde_kw(kde_kw=None, *, points=None, weights=None): """ - Split the `kde_kw` keyword arguments shared by the commands that draw kernel - density estimates into arguments that control the estimate itself and - arguments that style the resulting line. - - Parameters - ---------- - kde_kw : dict, optional - The user input. The ``points`` (or its ``stepsize`` alias), - ``bw_method``, and ``weights`` keys control the estimate and everything - else is treated as a line property. - points, weights : optional - The defaults inherited from the parent command, used when the - corresponding key is absent from `kde_kw`. Leaving `points` as ``None`` - defers to :rc:`kde.points`. - - Returns - ------- - kw_kde : dict - The keyword arguments accepted by `~ultraplot.internals.inputs._dist_kde`. - kw_line : dict - The remaining keyword arguments, meant for `~matplotlib.axes.Axes.plot`. + Split `kde_kw` into the keyword arguments that control the kernel density + estimate, i.e. those accepted by `~ultraplot.internals.inputs._dist_kde`, and + the remaining line properties meant for `~matplotlib.axes.Axes.plot`. The + `points` and `weights` arguments supply defaults from the parent command. """ kw_line = dict(kde_kw or {}) - kw_kde = { - "points": _not_none( - points=kw_line.pop("points", None), - stepsize=kw_line.pop("stepsize", None), # backwards compatible alias - default=points, - ), - "bw_method": kw_line.pop("bw_method", None), - "weights": _not_none(kw_line.pop("weights", None), weights), - } + kw_kde = _pop_kwargs(kw_line, "bw_method", "weights", points="stepsize") + kw_kde.setdefault("points", points) # ``None`` defers to :rc:`kde.points` + kw_kde.setdefault("weights", weights) return kw_kde, kw_line @@ -1957,30 +1934,17 @@ def _get_hist_colors(res, n): """ Return one color per column of a histogram drawn by `~matplotlib.axes.Axes.hist`, so that overlays can be colored to match. - - Parameters - ---------- - res : sequence - The artists returned by the histogram, i.e. one `~matplotlib.container.BarContainer` - or list of `~matplotlib.patches.Polygon` per column. A single column is - returned unnested by matplotlib and is handled here. - n : int - The number of columns. """ - # NOTE: 'step' histograms leave the faces transparent and carry the cycle - # color on the edges instead, so fall back to the edge color. + # NOTE: A single column is returned unnested by matplotlib. Also 'step' + # histograms leave the faces transparent and carry the color on the edges + # instead, and the alpha is dropped so that overlays stay opaque. colors = [] for group in [res] if n == 1 else res: - artists = list(group) - if not artists: - colors.append(None) - continue - color = np.atleast_2d(artists[0].get_facecolor()) - if not color.size or not np.any(color[:, -1]): - color = np.atleast_2d(artists[0].get_edgecolor()) - # NOTE: Drop the alpha channel so that overlays stay opaque even when - # the histogram itself was drawn translucent. - colors.append(None if not color.size else tuple(color[0, :3])) + artist = next(iter(group)) + color = artist.get_facecolor() + if not mcolors.to_rgba(color)[3]: + color = artist.get_edgecolor() + colors.append(mcolors.to_rgb(color)) return colors @@ -3801,94 +3765,70 @@ def _add_kde_lines( self, xs, *, - edges=None, + edges, + colors, density=None, stack=False, orientation="vertical", - colors=None, points=None, bw_method=None, weights=None, **kwargs, ): """ - Add a gaussian kernel density estimate line for each column of `xs`. - - Parameters - ---------- - xs : array-like - The sample data. 1D arrays are treated as a single column and 2D - arrays produce one line per column. - edges : array-like, optional - The histogram bin edges. When passed, the estimates are rescaled - from probability densities to bin counts (see `density`). - density : bool, optional - The `~matplotlib.axes.Axes.hist` normalization. When ``False`` and - `edges` was passed, each estimate is scaled by the sample mass and - the local bin width so the curve tracks the bin counts. - stack : bool, default: False - Whether the parent histogram is stacked. If so the estimates share - one evaluation grid and are accumulated like the bin counts. - orientation : {'vertical', 'horizontal'}, optional - The parent histogram orientation. Coordinates are swapped for - horizontal histograms. - colors : sequence, optional - The line color for each column, generally the color of the - corresponding histogram artists. - points : int, default: :rc:`kde.points` - The number of coordinates used to evaluate each estimate. - bw_method, weights : optional - Passed to `~ultraplot.internals.inputs._dist_kde`. - **kwargs - Passed to `~matplotlib.axes.Axes.plot`. + Add a gaussian kernel density estimate line for each column of `xs`, drawn + in `colors` and passing `**kwargs` to `~matplotlib.axes.Axes.plot`. - Returns - ------- - list of `~matplotlib.lines.Line2D` - The kernel density estimate lines. + Unless `density` is ``True`` each estimate is rescaled from a probability + density to the bin counts implied by the histogram bin `edges`. Stacked + histograms share a single evaluation grid so that the estimates accumulate + the way the bin counts do. Remaining arguments go to + `~ultraplot.internals.inputs._dist_kde`. """ - # Sanitize the data and record the sample mass of each column. The mass - # is the number of valid points, or their total weight if weighted, and - # converts the probability densities back into histogram bin counts. - points = _not_none(points, rc["kde.points"]) + # Filter invalid points up front. The sample mass, i.e. the number of valid + # points or their total weight, converts densities back into bin counts. xs = inputs._to_numpy_array(xs) xs = xs[:, None] if xs.ndim == 1 else xs - weights = None if weights is None else inputs._to_numpy_array(weights) - if weights is not None and weights.ndim == 1: - weights = np.repeat(weights[:, None], xs.shape[1], axis=1) + if weights is not None: + weights = inputs._to_numpy_array(weights) + if weights.ndim == 1: # the same weights apply to every column + weights = np.broadcast_to(weights[:, None], xs.shape) dists = [ inputs._dist_finite(xs[:, i], None if weights is None else weights[:, i]) for i in range(xs.shape[1]) ] - masses = np.array( - [dist.size if w is None else w.sum() for dist, w in dists], dtype=float - ) - # Share one evaluation grid between stacked columns so the estimates can - # be accumulated the same way matplotlib accumulates the bin counts. - coords = None + # Share one evaluation grid between stacked columns so that the estimates + # can be accumulated, and scale by the bin widths to recover bin counts. + coords = total = widths = None if stack and len(dists) > 1: - valid = np.concatenate([dist for dist, _ in dists]) - coords = np.linspace(valid.min(), valid.max(), int(points)) + lo = min(dist.min() for dist, _ in dists) + hi = max(dist.max() for dist, _ in dists) + coords = np.linspace(lo, hi, int(_not_none(points, rc["kde.points"]))) + if density: # each column contributes its share of the total + total = sum(dist.size if w is None else w.sum() for dist, w in dists) + if not density: + widths = np.diff(edges) objs, accum = [], 0 - for i, (dist, w) in enumerate(dists): + for color, (dist, w) in zip(colors, dists): x, y = inputs._dist_kde( dist, coords=coords, points=points, bw_method=bw_method, weights=w ) - if stack and density: # each column contributes its share of the total - y = y * masses[i] / masses.sum() - elif edges is not None and not density: # rescale to the bin counts - idxs = np.clip(np.digitize(x, edges) - 1, 0, len(edges) - 2) - y = y * masses[i] * np.diff(edges)[idxs] + mass = dist.size if w is None else w.sum() + if total is not None: + y = y * mass / total + elif widths is not None: + y = ( + y + * mass + * widths[np.clip(np.digitize(x, edges) - 1, 0, widths.size - 1)] + ) # noqa: E501 if coords is not None: y = accum = accum + y - kw = kwargs.copy() - if colors is not None and i < len(colors): - kw.setdefault("color", colors[i]) if orientation == "horizontal": x, y = y, x - objs.extend(self._call_native("plot", x, y, **kw)) + objs.extend(self._call_native("plot", x, y, **{"color": color, **kwargs})) return objs def _parse_1d_args(self, x, *ys, **kwargs): @@ -7044,10 +6984,20 @@ def _apply_ridgeline( base_zorder = kwargs.pop("zorder", 2) n_ridges = len(ridges) + # Outline style, identical for every ridge. Ridges drawn without a fill + # carry the ridge color on the outline instead, and KDE ridges also honor + # the line properties left over in `kde_kw`. + stepped = hist and histtype in ("step", "stepfilled") + filled = histtype == "stepfilled" if stepped else fill + outline_kw = {"linewidth": linewidth} + if stepped: + outline_kw["drawstyle"] = "steps-mid" + if not hist: + outline_kw.update(kw_line) + for i, ridge in enumerate(ridges): x = ridge["x"] y = ridge["y"] - is_hist = ridge.get("hist", False) if continuous_mode: # Continuous mode: scale to specified height and position at coordinate y_max = y.max() @@ -7069,22 +7019,13 @@ def _apply_ridgeline( fill_zorder = base_zorder + (n_ridges - i - 1) * 2 outline_zorder = fill_zorder + 1 - # Outline style. Ridges drawn without a fill carry the ridge color on - # the outline instead, and KDE ridges additionally honor the line - # properties left over in `kde_kw`. - stepped = is_hist and histtype in ("step", "stepfilled") - filled = histtype == "stepfilled" if stepped else fill - line_kw = dict( - color=edgecolor if filled or stepped else colors[i], - linewidth=linewidth, - zorder=outline_zorder, - ) - if stepped: - line_kw["drawstyle"] = "steps-mid" - if not is_hist: - line_kw.update(kw_line) + line_kw = { + "color": edgecolor if filled or stepped else colors[i], + "zorder": outline_zorder, + **outline_kw, # a 'color' from kde_kw wins over the default + } - if is_hist and histtype == "bar": + if hist and histtype == "bar": counts = ridge["counts"] bin_edges = ridge["bin_edges"] if continuous_mode: @@ -7257,6 +7198,8 @@ def _apply_hist( xs, edges=obj[1], density=kw.get("density", None), + # NOTE: 'histtype' rather than 'stack' since users may stack by + # passing histtype='barstacked' directly. stack=histtype == "barstacked", orientation=orientation, colors=_get_hist_colors(res, n), diff --git a/ultraplot/internals/inputs.py b/ultraplot/internals/inputs.py index 800345f6c..e3dd461b6 100644 --- a/ultraplot/internals/inputs.py +++ b/ultraplot/internals/inputs.py @@ -416,15 +416,16 @@ def _dist_finite(distribution, weights=None): subset of the weights. Used to sanitize input for `_dist_kde`. """ distribution = _to_numpy_array(distribution).ravel() - mask = np.isfinite(distribution) if weights is not None: weights = _to_numpy_array(weights).ravel() if weights.size != distribution.size: raise ValueError( f"Got {weights.size} weights but {distribution.size} data points." ) - weights = weights[mask] - return distribution[mask], weights + mask = np.isfinite(distribution) + if mask.all(): # no copy needed, and makes repeated calls cheap + return distribution, weights + return distribution[mask], None if weights is None else weights[mask] def _dist_kde(