From 181cb25785575566ba030f9491032fce3401aa16 Mon Sep 17 00:00:00 2001 From: Jerome Dockes Date: Thu, 25 Jun 2026 11:53:44 +0200 Subject: [PATCH 1/9] fix tablereport error in histogram when data range is very narrow --- skrub/_reporting/_plotting.py | 17 +++++++++++++++-- skrub/_reporting/tests/test_plotting.py | 5 +++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/skrub/_reporting/_plotting.py b/skrub/_reporting/_plotting.py index a8c85e65a..053b7e386 100644 --- a/skrub/_reporting/_plotting.py +++ b/skrub/_reporting/_plotting.py @@ -193,6 +193,15 @@ def _get_range(values, frac=0.2, factor=3.0): return low, high +def _get_safe_hist_range(values): + # make sure numpy can find bin edges between the range low and high bounds + vmin, vmax = values.min(), values.max() + delta = max(np.spacing(vmin), np.spacing(vmax)) + if vmax - vmin > 12 * delta: + return None + return vmin - 6 * delta, vmax + 6 * delta + + def _robust_hist(col, ax=None, color=None): col = sbd.drop_nulls(col) if sbd.is_float(col): @@ -218,12 +227,16 @@ def _robust_hist(col, ax=None, color=None): n_low_outliers = (values < low).sum() n_high_outliers = (high < values).sum() result = {"n_low_outliers": n_low_outliers, "n_high_outliers": n_high_outliers} + np_histogram_inliers = np_histogram_values[inlier_mask] result["bin_counts"], result["bin_edges"] = np.histogram( - np_histogram_values[inlier_mask] + np_histogram_inliers, range=_get_safe_hist_range(np_histogram_inliers) ) if ax is None: return result - n, bins, patches = ax.hist(values[inlier_mask]) + histogram_inliers = values[inlier_mask] + n, bins, patches = ax.hist( + histogram_inliers, range=_get_safe_hist_range(histogram_inliers) + ) n_out = n_low_outliers + n_high_outliers if not n_out: return result diff --git a/skrub/_reporting/tests/test_plotting.py b/skrub/_reporting/tests/test_plotting.py index 601f96526..fb577c66c 100644 --- a/skrub/_reporting/tests/test_plotting.py +++ b/skrub/_reporting/tests/test_plotting.py @@ -28,3 +28,8 @@ def test_histogram(): data = pd.Series([0.0]) _, hist = _plotting.histogram(data) assert (hist["n_low_outliers"], hist["n_high_outliers"]) == (0, 0) + + low = np.float32(10.0) + high = np.nextafter(low, 11.0) + data = pd.Series([low, high]) + _, hist = _plotting.histogram(data) From 1aa297879028141ac00d08106d2851a0a757797a Mon Sep 17 00:00:00 2001 From: Jerome Dockes Date: Thu, 25 Jun 2026 12:05:41 +0200 Subject: [PATCH 2/9] _ --- skrub/_reporting/_plotting.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/skrub/_reporting/_plotting.py b/skrub/_reporting/_plotting.py index 053b7e386..c69a51a05 100644 --- a/skrub/_reporting/_plotting.py +++ b/skrub/_reporting/_plotting.py @@ -195,6 +195,11 @@ def _get_range(values, frac=0.2, factor=3.0): def _get_safe_hist_range(values): # make sure numpy can find bin edges between the range low and high bounds + if not ( + np.issubdtype(values.dtype, np.floating) + or np.issubdtype(values.dtype, np.integer) + ): + return None vmin, vmax = values.min(), values.max() delta = max(np.spacing(vmin), np.spacing(vmax)) if vmax - vmin > 12 * delta: From 9a392d6bcee7d55bd0899db15f00ef9ef574e6b4 Mon Sep 17 00:00:00 2001 From: Jerome Dockes Date: Thu, 25 Jun 2026 13:20:04 +0200 Subject: [PATCH 3/9] _ --- skrub/_reporting/_plotting.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/skrub/_reporting/_plotting.py b/skrub/_reporting/_plotting.py index c69a51a05..63abe5e13 100644 --- a/skrub/_reporting/_plotting.py +++ b/skrub/_reporting/_plotting.py @@ -208,6 +208,7 @@ def _get_safe_hist_range(values): def _robust_hist(col, ax=None, color=None): + result = {} col = sbd.drop_nulls(col) if sbd.is_float(col): # avoid any issues with pandas nullable dtypes @@ -225,13 +226,15 @@ def _robust_hist(col, ax=None, color=None): np_histogram_values = sbd.to_numpy( _datetime_encoder.DatetimeEncoder(resolution=None).fit_transform(col) ).ravel() + result["total_seconds_offset"] = np_histogram_values.min() + np_histogram_values -= result["total_seconds_offset"] else: np_histogram_values = values low, high = _get_range(values) inlier_mask = (low <= values) & (values <= high) n_low_outliers = (values < low).sum() n_high_outliers = (high < values).sum() - result = {"n_low_outliers": n_low_outliers, "n_high_outliers": n_high_outliers} + result.update(n_low_outliers=n_low_outliers, n_high_outliers=n_high_outliers) np_histogram_inliers = np_histogram_values[inlier_mask] result["bin_counts"], result["bin_edges"] = np.histogram( np_histogram_inliers, range=_get_safe_hist_range(np_histogram_inliers) From 2a8ef454fcbcf5d2486c9aa077e60bd09f066978 Mon Sep 17 00:00:00 2001 From: Jerome Dockes Date: Thu, 25 Jun 2026 13:47:36 +0200 Subject: [PATCH 4/9] _ --- skrub/_reporting/_plotting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skrub/_reporting/_plotting.py b/skrub/_reporting/_plotting.py index 63abe5e13..3fefdc81d 100644 --- a/skrub/_reporting/_plotting.py +++ b/skrub/_reporting/_plotting.py @@ -227,7 +227,7 @@ def _robust_hist(col, ax=None, color=None): _datetime_encoder.DatetimeEncoder(resolution=None).fit_transform(col) ).ravel() result["total_seconds_offset"] = np_histogram_values.min() - np_histogram_values -= result["total_seconds_offset"] + np_histogram_values = np_histogram_values - result["total_seconds_offset"] else: np_histogram_values = values low, high = _get_range(values) From 6dc9e5450945496792fd8fe1bb517083561c7634 Mon Sep 17 00:00:00 2001 From: Jerome Dockes Date: Thu, 25 Jun 2026 15:46:41 +0200 Subject: [PATCH 5/9] _ --- skrub/_reporting/_plotting.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/skrub/_reporting/_plotting.py b/skrub/_reporting/_plotting.py index 3fefdc81d..01fda9c90 100644 --- a/skrub/_reporting/_plotting.py +++ b/skrub/_reporting/_plotting.py @@ -173,8 +173,11 @@ def _adjust_fig_size(fig, ax, target_w, target_h): def _get_range(values, frac=0.2, factor=3.0): + finite_values = values[np.isfinite(values)] + if not len(finite_values): + return 0, 0 min_value, low_p, high_p, max_value = np.quantile( - values, [0.0, frac, 1.0 - frac, 1.0] + finite_values, [0.0, frac, 1.0 - frac, 1.0] ) delta = high_p - low_p if not delta: @@ -195,7 +198,7 @@ def _get_range(values, frac=0.2, factor=3.0): def _get_safe_hist_range(values): # make sure numpy can find bin edges between the range low and high bounds - if not ( + if not len(values) or not ( np.issubdtype(values.dtype, np.floating) or np.issubdtype(values.dtype, np.integer) ): From 659a0321c5c865f191419ae5ea29af8c26114fe2 Mon Sep 17 00:00:00 2001 From: Jerome Dockes Date: Thu, 25 Jun 2026 16:09:28 +0200 Subject: [PATCH 6/9] _ --- skrub/_reporting/_plotting.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/skrub/_reporting/_plotting.py b/skrub/_reporting/_plotting.py index 01fda9c90..df5f5942b 100644 --- a/skrub/_reporting/_plotting.py +++ b/skrub/_reporting/_plotting.py @@ -173,7 +173,10 @@ def _adjust_fig_size(fig, ax, target_w, target_h): def _get_range(values, frac=0.2, factor=3.0): - finite_values = values[np.isfinite(values)] + if np.issubdtype(values.dtype, np.floating): + finite_values = values[np.isfinite(values)] + else: + finite_values = values if not len(finite_values): return 0, 0 min_value, low_p, high_p, max_value = np.quantile( From d468e57e67fa5f0c29c74635958da6e01e91e53e Mon Sep 17 00:00:00 2001 From: Jerome Dockes Date: Mon, 29 Jun 2026 16:36:46 +0200 Subject: [PATCH 7/9] test --- skrub/_reporting/tests/test_plotting.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/skrub/_reporting/tests/test_plotting.py b/skrub/_reporting/tests/test_plotting.py index fb577c66c..4e4115d74 100644 --- a/skrub/_reporting/tests/test_plotting.py +++ b/skrub/_reporting/tests/test_plotting.py @@ -33,3 +33,11 @@ def test_histogram(): high = np.nextafter(low, 11.0) data = pd.Series([low, high]) _, hist = _plotting.histogram(data) + + # all infinite. +- inf are outliers + data = pd.Series( + [None, None, float("nan"), float("-inf"), float("-inf"), float("inf")] + ) + _, hist = _plotting.histogram(data) + assert hist["n_low_outliers"] == 2 + assert hist["n_high_outliers"] == 1 From 814bd23d7b1ceace9f51e0538273d1b2e29f0afb Mon Sep 17 00:00:00 2001 From: Jerome Dockes Date: Mon, 29 Jun 2026 16:39:06 +0200 Subject: [PATCH 8/9] changelog --- CHANGES.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 7c87367fa..cc61e88d8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -86,6 +86,10 @@ Bugfixes - An error that happened when running ``TableReport`` or ``column_associations`` on some dataframes with non-string column names has been fixed in :pr:`2179` by :user:`Jérôme Dockès `. +- An error that could arise in histograms when running :class:`TableReport` on + data with a very small range (less than 10 representable floating-point + numbers between min and max) has been fixed. + :pr:`2189` by :user:`Jérôme Dockès `. Deprecations ------------ From 985b758404496e7765a63be4aad9aea5ddf24ab3 Mon Sep 17 00:00:00 2001 From: Jerome Dockes Date: Wed, 1 Jul 2026 16:12:48 +0200 Subject: [PATCH 9/9] comments --- skrub/_reporting/_plotting.py | 20 +++++++++++++++++++- skrub/_reporting/tests/test_plotting.py | 8 ++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/skrub/_reporting/_plotting.py b/skrub/_reporting/_plotting.py index df5f5942b..b69fdf7bc 100644 --- a/skrub/_reporting/_plotting.py +++ b/skrub/_reporting/_plotting.py @@ -200,16 +200,34 @@ def _get_range(values, frac=0.2, factor=3.0): def _get_safe_hist_range(values): - # make sure numpy can find bin edges between the range low and high bounds + # Get a safe min, max range for the histogram (the 'range' parameter of + # np.histogram). + # + # This handles a corner case where the range of the data is very narrow and + # there are less than 10 (the default number of bins) representable + # floating-point numbers between the data min and max (with the precision + # of the input column dtype). In this case numpy/matplotlib histogram with + # default parameters fails, so we pass the 'range' parameter to extend it + # slightly beyond the data range so that it is wide enough to contain 10 + # bins. if not len(values) or not ( np.issubdtype(values.dtype, np.floating) or np.issubdtype(values.dtype, np.integer) ): + # empty or non-numeric inputs (datetimes) need no special handling. return None vmin, vmax = values.min(), values.max() delta = max(np.spacing(vmin), np.spacing(vmax)) if vmax - vmin > 12 * delta: + # Min and max are far enough to divide the data range into 10 bins, we + # can keep the default range=None. + # + # 12 is a bit conservative, 10 is probably enough, but there is no + # harm in having the plot range slightly wider than is strictly + # required. return None + # Extend the range by 12 spacing steps (6 on each side) so the new range + # can contain the bins. return vmin - 6 * delta, vmax + 6 * delta diff --git a/skrub/_reporting/tests/test_plotting.py b/skrub/_reporting/tests/test_plotting.py index 4e4115d74..4333452af 100644 --- a/skrub/_reporting/tests/test_plotting.py +++ b/skrub/_reporting/tests/test_plotting.py @@ -29,10 +29,18 @@ def test_histogram(): _, hist = _plotting.histogram(data) assert (hist["n_low_outliers"], hist["n_high_outliers"]) == (0, 0) + # Test a column with values very close to each other. + # There are less than 10 (default number of histogram bins) representable + # floating-point numbers in the range, which would cause numpy or + # matplotlib histogram to raise an exception without appropriate handling + # in skrub. low = np.float32(10.0) high = np.nextafter(low, 11.0) + data = pd.Series([low, high]) _, hist = _plotting.histogram(data) + assert hist["n_low_outliers"] == 0 + assert hist["n_high_outliers"] == 0 # all infinite. +- inf are outliers data = pd.Series(