Skip to content

Commit c87efbd

Browse files
fix(polars): NaN as missing, finite outlier fences, LazyFrame inputs (#224)
- #200: the Polars engine only counted nulls, so float NaN from a native Polars DataFrame/LazyFrame, an Arrow table or Polars-written Parquet was treated as a value: empty rows/columns were kept and missing_before was wrong. Native sources now read float NaN as null at ingestion (pl.from_pandas already did this for pandas input). Outlier fences are computed over finite values only, matching pandas drop_infinite, so +-inf no longer blows up the quantiles. - #203: a pl.LazyFrame source crashed whenever the run needed the pandas fallback ("cannot materialize source of type LazyFrame") and was rejected on the default path ("expected a pandas or polars DataFrame"). The fallback now collects it, and fd.clean(lazyframe) cleans it like a DataFrame and returns a LazyFrame. fallback_policy="error" still blocks before any collect. Closes #200 Closes #203
1 parent 98a13cb commit c87efbd

6 files changed

Lines changed: 134 additions & 16 deletions

File tree

‎src/freshdata/adapters/polars.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,21 @@ def is_polars_frame(obj: object) -> bool:
3131
return isinstance(obj, pl.DataFrame)
3232

3333

34+
def is_polars_lazy(obj: object) -> bool:
35+
"""True for an uncollected ``pl.LazyFrame``."""
36+
try:
37+
pl = _polars_module()
38+
except ImportError:
39+
return False
40+
return isinstance(obj, pl.LazyFrame)
41+
42+
3443
def to_pandas(df: object) -> pd.DataFrame:
3544
if isinstance(df, pd.DataFrame):
3645
return df
46+
if is_polars_lazy(df):
47+
lazy: Any = df
48+
df = lazy.collect() # the pandas pipeline needs the rows in memory
3749
if is_polars_frame(df):
3850
pl_df: Any = df
3951
try:
@@ -53,4 +65,7 @@ def from_pandas(df: pd.DataFrame, original: object | None = None) -> object:
5365
if is_polars_frame(original):
5466
pl = _polars_module()
5567
return pl.from_pandas(df)
68+
if is_polars_lazy(original):
69+
pl = _polars_module()
70+
return pl.from_pandas(df).lazy() # LazyFrame in, LazyFrame out
5671
return df

‎src/freshdata/cleaner.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import pandas as pd
1010

1111
from ._util import memory_bytes
12-
from .adapters.polars import is_polars_frame, to_pandas
12+
from .adapters.polars import is_polars_frame, is_polars_lazy, to_pandas
1313
from .config import CleanConfig, merge_options
1414
from .engine import auto_missing, auto_outliers
1515
from .engine.cache import build_engine_cache
@@ -31,8 +31,10 @@ def _validate_input(df: object, config: CleanConfig) -> pd.DataFrame:
3131
raise TypeError(
3232
"freshdata works on DataFrames; got a Series. Convert it first with s.to_frame()."
3333
)
34-
if not isinstance(df, pd.DataFrame) and not is_polars_frame(df):
35-
raise TypeError(f"expected a pandas or polars DataFrame, got {type(df).__name__}")
34+
if not (isinstance(df, pd.DataFrame) or is_polars_frame(df) or is_polars_lazy(df)):
35+
raise TypeError(
36+
f"expected a pandas or polars DataFrame/LazyFrame, got {type(df).__name__}"
37+
)
3638
frame = to_pandas(df)
3739
if frame.columns.duplicated().any() and not config.column_names:
3840
dupes = sorted({str(c) for c in frame.columns[frame.columns.duplicated()]})

‎src/freshdata/execution/backends/_pandas.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
from .._config import EngineConfig
2121

2222

23+
from .._lazy import has_polars
24+
25+
2326
def materialize_to_pandas(source: Any) -> pd.DataFrame:
2427
"""Load *source* into a pandas DataFrame, reading file paths if needed."""
2528
import pandas as pd
@@ -35,6 +38,11 @@ def materialize_to_pandas(source: Any) -> pd.DataFrame:
3538
if low.endswith((".ipc", ".feather", ".arrow")):
3639
return pd.read_feather(source)
3740
raise ValueError(f"unsupported file type for path {source!r}")
41+
if has_polars():
42+
import polars as pl
43+
44+
if isinstance(source, pl.LazyFrame):
45+
return source.collect().to_pandas()
3846
# polars frame
3947
to_pandas = getattr(source, "to_pandas", None)
4048
if callable(to_pandas):

‎src/freshdata/execution/backends/_polars.py‎

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,21 @@ class PolarsEngine(ExecutionEngine):
5252
# -- source ingestion ---------------------------------------------------
5353

5454
def _to_lazy(self, source: Any, pl: Any) -> tuple[Any, int]:
55-
"""Return ``(LazyFrame, memory_before_bytes)`` for *source*."""
55+
"""Return ``(LazyFrame, memory_before_bytes)`` for *source*.
56+
57+
Float ``NaN`` is read as null, because the pandas reference treats
58+
``NaN`` as missing. ``pl.from_pandas`` already does this; native Polars,
59+
Arrow and file sources keep real ``NaN`` otherwise.
60+
"""
61+
import pandas as pd
62+
63+
if isinstance(source, pd.DataFrame):
64+
return pl.from_pandas(source).lazy(), int(source.memory_usage(deep=True).sum())
65+
lf, memory_before = self._native_to_lazy(source, pl)
66+
return lf.with_columns(pl.col(pl.Float32, pl.Float64).fill_nan(None)), memory_before
67+
68+
@staticmethod
69+
def _native_to_lazy(source: Any, pl: Any) -> tuple[Any, int]:
5670
if isinstance(source, pl.LazyFrame):
5771
return source, 0
5872
if isinstance(source, pl.DataFrame):
@@ -76,10 +90,6 @@ def _to_lazy(self, source: Any, pl: Any) -> tuple[Any, int]:
7690
return pl.from_arrow(table).lazy(), int(table.nbytes)
7791
except ImportError:
7892
pass
79-
import pandas as pd
80-
81-
if isinstance(source, pd.DataFrame):
82-
return pl.from_pandas(source).lazy(), int(source.memory_usage(deep=True).sum())
8393
raise TypeError(f"PolarsEngine: unsupported source type {type(source).__name__}")
8494

8595
def _pandas_index_forces_fallback(self, source: Any) -> bool:
@@ -258,18 +268,24 @@ def _stage_outliers( # noqa: PLR0915 — mirrors the pandas step's branchy poli
258268
clip = config.outliers == "clip"
259269
stat_aggs: list[Any] = []
260270
for n in numeric:
271+
# Fences come from finite values only, like the pandas reference
272+
# (steps.outliers.drop_infinite); inf is still tested against them.
273+
values = (
274+
pl.col(n).filter(pl.col(n).is_finite())
275+
if schema[n] in (pl.Float32, pl.Float64) else pl.col(n)
276+
)
261277
if method == "iqr":
262-
stat_aggs.append(pl.col(n).quantile(0.25, "linear").alias(f"q1_{n}"))
263-
stat_aggs.append(pl.col(n).quantile(0.75, "linear").alias(f"q3_{n}"))
278+
stat_aggs.append(values.quantile(0.25, "linear").alias(f"q1_{n}"))
279+
stat_aggs.append(values.quantile(0.75, "linear").alias(f"q3_{n}"))
264280
else:
265-
stat_aggs.append(pl.col(n).mean().alias(f"m_{n}"))
266-
stat_aggs.append(pl.col(n).std().alias(f"s_{n}"))
281+
stat_aggs.append(values.mean().alias(f"m_{n}"))
282+
stat_aggs.append(values.std().alias(f"s_{n}"))
267283
if clip:
268284
# Skew-aware capping aggregates (see log_widened_bounds).
269-
logs = pl.col(n).filter(pl.col(n) > 0).log()
270-
stat_aggs.append(pl.col(n).skew(bias=False).alias(f"sk_{n}"))
271-
stat_aggs.append(pl.col(n).min().alias(f"mn_{n}"))
272-
stat_aggs.append(pl.col(n).count().alias(f"cnt_{n}"))
285+
logs = values.filter(values > 0).log()
286+
stat_aggs.append(values.skew(bias=False).alias(f"sk_{n}"))
287+
stat_aggs.append(values.min().alias(f"mn_{n}"))
288+
stat_aggs.append(values.count().alias(f"cnt_{n}"))
273289
stat_aggs.append(logs.quantile(0.25, "linear").alias(f"lq1_{n}"))
274290
stat_aggs.append(logs.quantile(0.75, "linear").alias(f"lq3_{n}"))
275291
stats = lf.select(stat_aggs).collect().row(0, named=True)

‎tests/test_execution/test_polars_engine.py‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
import pytest
77

88
import freshdata as fd
9+
from freshdata.config import CleanConfig
910
from freshdata.execution import EngineConfig
11+
from freshdata.execution._config import FallbackError
1012

1113
pl = pytest.importorskip("polars")
1214

@@ -73,3 +75,71 @@ def test_projection_pushdown_drops_empty_before_collect(native_config):
7375
df = pd.DataFrame({"keep": [1, 2], "gone": [None, None]})
7476
out = fd.clean(df, config=native_config, engine="polars", output_format="polars")
7577
assert list(out.columns) == ["keep"]
78+
79+
80+
def _native_nan_source():
81+
return pl.DataFrame({
82+
"a": [1.0, float("nan"), 3.0],
83+
"b": [float("nan")] * 3,
84+
"s": ["x", None, "y"],
85+
})
86+
87+
88+
def _steps(report):
89+
return [(a.step, a.count) for a in report.actions]
90+
91+
92+
@pytest.mark.parametrize(
93+
"make_source",
94+
[lambda df: df, lambda df: df.lazy(), lambda df: df.to_arrow()],
95+
ids=["dataframe", "lazyframe", "arrow"],
96+
)
97+
def test_native_nan_counts_as_missing_like_pandas(native_config, make_source):
98+
src = _native_nan_source()
99+
ref_out, ref = fd.clean(src.to_pandas(), config=native_config, engine="pandas",
100+
return_report=True)
101+
out, report = fd.clean(make_source(src), config=native_config, engine="polars",
102+
return_report=True)
103+
assert list(out.columns) == list(ref_out.columns) == ["a", "s"]
104+
assert report.rows_after == ref.rows_after == 2
105+
assert report.missing_before == ref.missing_before == 5
106+
assert _steps(report) == _steps(ref)
107+
108+
109+
def test_polars_written_parquet_nan_counts_as_missing(tmp_path, native_config):
110+
path = str(tmp_path / "nan.parquet")
111+
_native_nan_source().write_parquet(path)
112+
out, report = fd.clean(path, config=native_config, engine="polars", return_report=True)
113+
assert list(out.columns) == ["a", "s"]
114+
assert report.missing_before == 5
115+
116+
117+
def test_infinite_values_are_excluded_from_outlier_fences():
118+
config = CleanConfig(strategy="conservative", fix_dtypes=False, verbose=False,
119+
outliers="clip", outlier_method="iqr")
120+
df = pd.DataFrame({"x": [1.0, 2.0, 3.0, 4.0] + [float("inf")] * 4})
121+
ref, ref_report = fd.clean(df, config=config, engine="pandas", return_report=True)
122+
out, report = fd.clean(pl.from_pandas(df), config=config, engine="polars",
123+
return_report=True)
124+
assert out["x"].tolist() == ref["x"].tolist()
125+
assert _steps(report) == _steps(ref_report)
126+
127+
128+
@pytest.mark.parametrize(
129+
"kwargs",
130+
[{}, {"engine": "auto"}, {"engine": "polars", "strategy": "balanced"},
131+
{"engine": "polars", "strategy": "conservative"}],
132+
ids=["default", "auto", "polars-balanced", "polars-conservative"],
133+
)
134+
def test_lazy_frame_source_survives_pandas_fallback(kwargs):
135+
lf = pl.DataFrame({"a": [1.0, None, 3.0], "b": ["x", "y", "z"]}).lazy()
136+
out = fd.clean(lf, verbose=False, **kwargs)
137+
frame = out.collect() if isinstance(out, pl.LazyFrame) else out
138+
assert len(frame) == 3
139+
140+
141+
def test_lazy_frame_fallback_still_honours_error_policy():
142+
lf = pl.DataFrame({"a": [1.0, None, 3.0]}).lazy()
143+
with pytest.raises(FallbackError):
144+
fd.clean(lf, engine="polars", strategy="balanced", fallback_policy="error",
145+
verbose=False)

‎tests/test_polars_adapter.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,10 @@ def boom(self, *a, **k):
4949
monkeypatch.setattr(pl.DataFrame, "to_pandas", boom)
5050
with pytest.raises(ModuleNotFoundError, match=r"freshdata-cleaner\[polars\]"):
5151
to_pandas(pl.DataFrame({"a": [1]}))
52+
53+
54+
def test_lazy_frame_round_trips_through_default_clean():
55+
lf = pl.DataFrame({"a": [1.0, None, 3.0], "b": ["x", " y", "z"]}).lazy()
56+
out = fd.clean(lf, verbose=False)
57+
assert isinstance(out, pl.LazyFrame) # LazyFrame in, LazyFrame out
58+
assert out.collect()["b"].to_list() == ["x", "y", "z"]

0 commit comments

Comments
 (0)