From ae8a97680de60b22b8c37ad1fb820911164d85bd Mon Sep 17 00:00:00 2001 From: Arpit Jain Date: Sun, 5 Jul 2026 06:37:29 +0900 Subject: [PATCH 1/2] fix(layer): cast polars decimal columns to float polars' to_pandas() returns a Decimal column as a pandas object column of decimal.Decimal values, which plotnine treats as discrete so a continuous scale rejects it. Cast those object columns to float right after the conversion so they behave like any other numeric column. Closes #1033 Signed-off-by: Arpit Jain --- plotnine/layer.py | 28 ++++++++++++++++++++++++---- tests/test_ggplot_internals.py | 25 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/plotnine/layer.py b/plotnine/layer.py index 47a85e928c..ccd524c78b 100644 --- a/plotnine/layer.py +++ b/plotnine/layer.py @@ -1,5 +1,6 @@ from __future__ import annotations +import decimal import typing from copy import copy, deepcopy from typing import Iterable, List, cast, overload @@ -31,6 +32,23 @@ ) +def _convert_decimal_columns(data: pd.DataFrame) -> pd.DataFrame: + """ + Cast columns of decimal.Decimal values to float + + polars' `to_pandas()` maps a Decimal column onto a pandas object column + holding `decimal.Decimal` values. plotnine treats object columns as + discrete, so such a column cannot be used with a continuous scale. Casting + it to float lets it behave like any other numeric column. + """ + for col in data.columns: + if data[col].dtype == object: + non_null = data[col].dropna() + if len(non_null) and isinstance(non_null.iloc[0], decimal.Decimal): + data[col] = data[col].astype("float64") + return data + + class layer: """ Layer @@ -222,7 +240,9 @@ def _make_layer_data(self, plot_data: DataLike | None): if plot_data is None: data = pd.DataFrame() elif hasattr(plot_data, "to_pandas"): - data = cast("DataFrameConvertible", plot_data).to_pandas() + data = _convert_decimal_columns( + cast("DataFrameConvertible", plot_data).to_pandas() + ) else: data = cast("pd.DataFrame", plot_data) @@ -249,9 +269,9 @@ def _make_layer_data(self, plot_data: DataLike | None): else: # Recognise polars dataframes if hasattr(self._data, "to_pandas"): - self.data = cast( - "DataFrameConvertible", self._data - ).to_pandas() + self.data = _convert_decimal_columns( + cast("DataFrameConvertible", self._data).to_pandas() + ) elif isinstance(self._data, pd.DataFrame): self.data = self._data.copy() else: diff --git a/tests/test_ggplot_internals.py b/tests/test_ggplot_internals.py index 749e3ac7b6..4c3e323616 100644 --- a/tests/test_ggplot_internals.py +++ b/tests/test_ggplot_internals.py @@ -12,6 +12,7 @@ coord_trans, facet_null, geom_bar, + geom_col, geom_histogram, geom_line, geom_point, @@ -21,6 +22,7 @@ labs, lims, scale_x_continuous, + scale_y_continuous, stage, stat_identity, theme, @@ -365,6 +367,29 @@ def to_pandas(self): assert p2 == "to_pandas" +def test_to_pandas_decimal_columns(): + # polars' to_pandas() returns a Decimal column as an object column of + # decimal.Decimal values, which would otherwise be treated as discrete + # and rejected by a continuous scale. See GH #1033. + from decimal import Decimal + + class DecimalData: + def to_pandas(self): + return pd.DataFrame( + { + "x": ["a", "b", "c"], + "y": [Decimal(1), Decimal(2), Decimal(3)], + } + ) + + p = ( + ggplot(DecimalData(), aes("x", "y")) + + geom_col() + + scale_y_continuous(limits=(0, 5)) + ) + p._build() + + def test_callable_as_data(): def _fn(data): return data.rename(columns={"xx": "x", "yy": "y"}) From 9b4bd8da51adce98981c93101ed720b21d1498d1 Mon Sep 17 00:00:00 2001 From: Arpit Jain Date: Wed, 8 Jul 2026 09:20:26 +0900 Subject: [PATCH 2/2] fix(layer): convert decimal columns to float for all dataframes Move the fix to the root cause: any pandas frame with a decimal.Decimal object column now gets that column cast to float once, right where the layer data is finalized in _make_layer_data. This drops the per-call-site casts on the polars to_pandas() paths and covers plain pandas frames too. The helper _decimal_columns_to_float uses a notna() mask instead of dropna() to peek at the first non-null value, since it runs for every dataframe and most object columns are strings. Closes #1033 Signed-off-by: Arpit Jain --- plotnine/layer.py | 53 +++++++++++++++++++--------------- tests/test_ggplot_internals.py | 25 +++++++--------- 2 files changed, 41 insertions(+), 37 deletions(-) diff --git a/plotnine/layer.py b/plotnine/layer.py index ccd524c78b..2a40ebacdb 100644 --- a/plotnine/layer.py +++ b/plotnine/layer.py @@ -32,23 +32,6 @@ ) -def _convert_decimal_columns(data: pd.DataFrame) -> pd.DataFrame: - """ - Cast columns of decimal.Decimal values to float - - polars' `to_pandas()` maps a Decimal column onto a pandas object column - holding `decimal.Decimal` values. plotnine treats object columns as - discrete, so such a column cannot be used with a continuous scale. Casting - it to float lets it behave like any other numeric column. - """ - for col in data.columns: - if data[col].dtype == object: - non_null = data[col].dropna() - if len(non_null) and isinstance(non_null.iloc[0], decimal.Decimal): - data[col] = data[col].astype("float64") - return data - - class layer: """ Layer @@ -240,9 +223,7 @@ def _make_layer_data(self, plot_data: DataLike | None): if plot_data is None: data = pd.DataFrame() elif hasattr(plot_data, "to_pandas"): - data = _convert_decimal_columns( - cast("DataFrameConvertible", plot_data).to_pandas() - ) + data = cast("DataFrameConvertible", plot_data).to_pandas() else: data = cast("pd.DataFrame", plot_data) @@ -269,14 +250,16 @@ def _make_layer_data(self, plot_data: DataLike | None): else: # Recognise polars dataframes if hasattr(self._data, "to_pandas"): - self.data = _convert_decimal_columns( - cast("DataFrameConvertible", self._data).to_pandas() - ) + self.data = cast( + "DataFrameConvertible", self._data + ).to_pandas() elif isinstance(self._data, pd.DataFrame): self.data = self._data.copy() else: raise TypeError(f"Data has a bad type: {type(self.data)}") + self.data = _decimal_columns_to_float(self.data) + def _make_layer_mapping(self, plot_mapping: aes): """ Create the aesthetic mappings to be used by this layer @@ -798,3 +781,27 @@ def _resolve_position( raise PlotnineError(f"Unknown position of type {type(position_spec)}") return klass() + + +def _decimal_columns_to_float(data: pd.DataFrame) -> pd.DataFrame: + """ + Cast columns of decimal.Decimal values to float + + polars' `to_pandas()` maps a Decimal column onto a pandas object column + holding `decimal.Decimal` values. plotnine treats object columns as + discrete, so such a column cannot be used with a continuous scale. Casting + it to float lets it behave like any other numeric column. + + This runs for every dataframe, so we avoid the expense of `.dropna()` + (which builds a filtered copy) and use a mask to peek at the first + non-null value instead. + """ + for col in data.columns: + series = data[col] + if series.dtype == object: + mask = series.notna() + if mask.any() and isinstance( + series[mask.idxmax()], decimal.Decimal + ): + data[col] = series.astype("float64") + return data diff --git a/tests/test_ggplot_internals.py b/tests/test_ggplot_internals.py index 4c3e323616..61f7ceb31e 100644 --- a/tests/test_ggplot_internals.py +++ b/tests/test_ggplot_internals.py @@ -367,23 +367,20 @@ def to_pandas(self): assert p2 == "to_pandas" -def test_to_pandas_decimal_columns(): - # polars' to_pandas() returns a Decimal column as an object column of - # decimal.Decimal values, which would otherwise be treated as discrete - # and rejected by a continuous scale. See GH #1033. +def test_decimal_columns(): + # A pandas object column of decimal.Decimal values (e.g. from polars' + # to_pandas()) would otherwise be treated as discrete and rejected by a + # continuous scale. See GH #1033. from decimal import Decimal - class DecimalData: - def to_pandas(self): - return pd.DataFrame( - { - "x": ["a", "b", "c"], - "y": [Decimal(1), Decimal(2), Decimal(3)], - } - ) - + data = pd.DataFrame( + { + "x": ["a", "b", "c"], + "y": [Decimal(1), Decimal(2), Decimal(3)], + } + ) p = ( - ggplot(DecimalData(), aes("x", "y")) + ggplot(data, aes("x", "y")) + geom_col() + scale_y_continuous(limits=(0, 5)) )