Skip to content

Commit 56f5cfa

Browse files
committed
Full polars compatibilty from here on
1 parent 8914a4b commit 56f5cfa

6 files changed

Lines changed: 217 additions & 0 deletions

File tree

.github/workflows/python-app.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ jobs:
2525
run: |
2626
python -m pip install --upgrade pip
2727
python -m pip install -e . pytest flake8
28+
# polars + pyarrow are optional; installed so the polars backend tests run
29+
python -m pip install polars pyarrow
2830
2931
- name: Lint with flake8
3032
run: |

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@
99
[![License](https://img.shields.io/pypi/l/percentify.svg?style=flat&color=orange)](LICENSE)
1010
[![Docs](https://img.shields.io/badge/docs-percentify-14b8a6)](https://data-centt.github.io/percentify/)
1111
[![Build Status](https://github.com/data-centt/percentify/actions/workflows/python-app.yml/badge.svg)](https://github.com/data-centt/percentify/actions/workflows/python-app.yml)
12+
[![Polars](https://img.shields.io/badge/Polars-supported-cd792c?style=flat)](https://data-centt.github.io/percentify/)
1213

1314
**Percentify is a data science library that turns the 20% of data science operations behind 80% of daily work into single, readable function calls.**
1415

16+
**⚡ Full Polars DataFrame support:** pass a polars DataFrame or Series and get the same kind back, no flag needed.
17+
1518

1619
Built on pandas and numpy, it pairs everyday hard to reach tools with lesser-known ones. Where a function wraps an existing library (pandas, scipy, statsmodels, scikit-learn), it names it, so you always know where to dig deeper.
1720

@@ -71,13 +74,16 @@ One import, one line. A clean, sorted DataFrame you can read or feed into the ne
7174
- **No cryptic tracebacks**; Hand a function a text column where numbers are needed and you get a clear PercentifyWarning, not an Arrow/NumPy stack trace.
7275
- **Sensible defaults**; Results come back sorted worst-first, and PCA is standardized out of the box.
7376
- **DataFrames everywhere**; so the output drops straight into your notebook, your next filter, or your model.
77+
- **Pandas or polars**; pass either a pandas or polars object and you get the same kind back, no flag needed.
7478

7579

7680
## 🤝 Contributing
7781

7882
Contributions are welcome but they must follow the repo's guiding principle:
7983
> Keep each method as direct-to-output as possible. A percentify function should return the single most common answer in one line, and point users to the underlying library (pandas, scipy, statsmodels, scikit-learn) for the full, configurable version when the simplest output isn't what they're after.
8084
85+
**It must support polars.** Every function accepts both pandas and polars objects (via the `@_backend_aware` decorator) and returns the same kind, so any new contribution must keep that parity.
86+
8187
If your idea keeps things that simple and direct:
8288
- Open an issue first to discuss it
8389
- Fork the repo

docs/documentation.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,23 @@ A few rules hold across the whole library, so you always know what to expect:
2828

2929
---
3030

31+
## Polars support
32+
33+
Every function accepts **polars** DataFrames and Series as well as pandas, and hands back the same kind you passed in: polars in, polars out. Detection is automatic from the input type, so there is nothing to configure.
34+
35+
```python
36+
import polars as pl
37+
from percentify import missing
38+
39+
df = pl.DataFrame({"salary": [50000, None, 60000, None], "age": [25, 30, None, 40]})
40+
41+
missing(df) # returns a polars DataFrame
42+
```
43+
44+
polars stays optional: it is only imported when you actually pass a polars object, so pandas-only users pay nothing. Conversion goes through Arrow, so `pyarrow` must be installed for the polars path (it usually already is in a polars setup).
45+
46+
---
47+
3148
## `change`
3249

3350
Percentage change — as two numbers, between two columns, or down a whole series.

docs/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
*The 20% of data science operations behind 80% of daily work: each a single, readable function call.*
88

9+
**⚡ Full Polars DataFrame support:** pass a polars DataFrame or Series and get the same kind back, no flag needed.
10+
911
Following the *Pareto principle*, Percentify brings the checks you run on every dataset to the forefront, one call at a time. No more digging through six-line recipes and hard-to-remember import paths.
1012

1113
Built on pandas and numpy, it pairs the everyday tools you reach for constantly with lesser-known ones worth knowing. Where a function wraps an existing library (pandas, scipy, statsmodels, scikit-learn), it names it, so you always know where to dig deeper.

percentify/stats.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import functools
12
import warnings
23
from typing import Optional, Sequence, Union
34

@@ -19,6 +20,51 @@ def _round(value: float, decimals: Optional[int]) -> float:
1920
return round(value, decimals)
2021

2122

23+
def _is_polars(obj) -> bool:
24+
"""True if obj is a polars DataFrame/Series, without importing polars."""
25+
return type(obj).__module__.split(".", 1)[0] == "polars"
26+
27+
28+
def _to_pandas(obj):
29+
"""Convert a polars DataFrame/Series to pandas; leave anything else as-is."""
30+
return obj.to_pandas() if _is_polars(obj) else obj
31+
32+
33+
def _to_polars(result):
34+
"""Convert a pandas result back to polars, carrying any .attrs summary."""
35+
import polars as pl
36+
37+
if isinstance(result, pd.DataFrame):
38+
out = pl.from_pandas(result)
39+
if getattr(result, "attrs", None):
40+
out.attrs = dict(result.attrs)
41+
return out
42+
if isinstance(result, pd.Series):
43+
return pl.from_pandas(result)
44+
return result
45+
46+
47+
def _backend_aware(func):
48+
"""Let a pandas-based function accept and return polars objects.
49+
50+
When any argument is a polars DataFrame/Series, inputs are converted to
51+
pandas, the function runs, and the result is converted back to polars
52+
(polars in -> polars out). Pure-pandas calls pass straight through and
53+
never import polars, so polars stays an optional dependency.
54+
"""
55+
@functools.wraps(func)
56+
def wrapper(*args, **kwargs):
57+
polars_in = (any(_is_polars(a) for a in args)
58+
or any(_is_polars(v) for v in kwargs.values()))
59+
if not polars_in:
60+
return func(*args, **kwargs)
61+
args = tuple(_to_pandas(a) for a in args)
62+
kwargs = {k: _to_pandas(v) for k, v in kwargs.items()}
63+
return _to_polars(func(*args, **kwargs))
64+
return wrapper
65+
66+
67+
@_backend_aware
2268
def change(old, new=None, decimals: Optional[int] = 2):
2369
"""
2470
Percentage change.
@@ -80,6 +126,7 @@ def change(old, new=None, decimals: Optional[int] = 2):
80126
return _round(value, decimals)
81127

82128

129+
@_backend_aware
83130
def vif(df: pd.DataFrame, decimals: Optional[int] = 2, flag: Optional[float] = None) -> pd.DataFrame:
84131
"""
85132
Calculate the Variance Inflation Factor for each numeric column in a DataFrame.
@@ -153,6 +200,7 @@ def vif(df: pd.DataFrame, decimals: Optional[int] = 2, flag: Optional[float] = N
153200
return result
154201

155202

203+
@_backend_aware
156204
def missing(df: pd.DataFrame, decimals: Optional[int] = 2) -> pd.DataFrame:
157205
"""
158206
Calculate the percentage of missing values for each column.
@@ -179,6 +227,7 @@ def missing(df: pd.DataFrame, decimals: Optional[int] = 2) -> pd.DataFrame:
179227
return result.sort_values("missing_pct", ascending=False).reset_index(drop=True)
180228

181229

230+
@_backend_aware
182231
def cv(data: Union[pd.Series, pd.DataFrame], decimals: Optional[int] = 2) -> Union[float, pd.DataFrame]:
183232
"""
184233
Calculate the coefficient of variation (CV = std / mean * 100).
@@ -224,6 +273,7 @@ def cv(data: Union[pd.Series, pd.DataFrame], decimals: Optional[int] = 2) -> Uni
224273
return result.sort_values("cv", ascending=False).reset_index(drop=True)
225274

226275

276+
@_backend_aware
227277
def outliers(
228278
data: Union[pd.Series, pd.DataFrame], decimals: Optional[int] = 2, multiplier: float = 1.5
229279
) -> Union[float, pd.DataFrame]:
@@ -274,6 +324,7 @@ def _calc(s: pd.Series) -> float:
274324
return result.sort_values("outlier_pct", ascending=False).reset_index(drop=True)
275325

276326

327+
@_backend_aware
277328
def r_squared(
278329
y_true: Union[pd.Series, Sequence, np.ndarray],
279330
y_pred: Union[pd.Series, Sequence, np.ndarray],
@@ -317,6 +368,7 @@ def r_squared(
317368
return _round(val, decimals)
318369

319370

371+
@_backend_aware
320372
def pca_variance(
321373
df: pd.DataFrame, decimals: Optional[int] = 2, n_components: Optional[int] = None,
322374
standardize: bool = True,
@@ -391,6 +443,7 @@ def pca_variance(
391443
})
392444

393445

446+
@_backend_aware
394447
def imbalance(data, decimals: Optional[int] = 2) -> pd.DataFrame:
395448
"""
396449
Summarize class imbalance in a categorical target column.
@@ -443,6 +496,7 @@ def imbalance(data, decimals: Optional[int] = 2) -> pd.DataFrame:
443496
return result
444497

445498

499+
@_backend_aware
446500
def difference(a, b, decimals: Optional[int] = 2):
447501
"""
448502
Symmetric percentage difference between two values or two columns.
@@ -484,6 +538,7 @@ def difference(a, b, decimals: Optional[int] = 2):
484538
return _round(abs(a - b) / avg * 100.0, decimals)
485539

486540

541+
@_backend_aware
487542
def split(total, weights, decimals: Optional[int] = 2):
488543
"""
489544
Distribute a total across weights, proportionally.
@@ -524,6 +579,7 @@ def split(total, weights, decimals: Optional[int] = 2):
524579
return shares if is_series else shares.tolist()
525580

526581

582+
@_backend_aware
527583
def display(value, decimals: Optional[int] = 2, suffix: str = "%", multiply: bool = False):
528584
"""
529585
Format a number or a numeric column as percentage strings.

tests/test_polars.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import pytest
2+
import numpy as np
3+
4+
pl = pytest.importorskip("polars")
5+
import pandas as pd # noqa: E402
6+
from percentify import ( # noqa: E402
7+
change, vif, missing, cv, outliers, r_squared,
8+
pca_variance, imbalance, difference, split, display,
9+
)
10+
11+
12+
# ===== DataFrame in -> polars DataFrame out =====
13+
14+
def test_vif_polars():
15+
np.random.seed(0)
16+
base = np.random.randn(80)
17+
df = pl.DataFrame({
18+
"a": base,
19+
"b": base * 2 + np.random.randn(80) * 0.1,
20+
"c": np.random.randn(80),
21+
})
22+
result = vif(df)
23+
assert isinstance(result, pl.DataFrame)
24+
assert result.columns == ["feature", "VIF"]
25+
assert set(result["feature"].to_list()) == {"a", "b", "c"}
26+
27+
28+
def test_missing_polars_values():
29+
df = pl.DataFrame({"salary": [1.0, None, 3.0, None], "age": [1.0, 2.0, None, 4.0]})
30+
result = missing(df)
31+
assert isinstance(result, pl.DataFrame)
32+
d = dict(zip(result["column"].to_list(), result["missing_pct"].to_list()))
33+
assert d["salary"] == 50.0
34+
assert d["age"] == 25.0
35+
36+
37+
def test_cv_polars_dataframe():
38+
result = cv(pl.DataFrame({"a": [10.0, 20, 30], "b": [1.0, 2, 3]}))
39+
assert isinstance(result, pl.DataFrame)
40+
assert result.columns == ["feature", "cv"]
41+
42+
43+
def test_outliers_polars_dataframe():
44+
df = pl.DataFrame({"a": [1.0, 2, 3, 4, 5, 6, 100], "b": [1.0, 2, 3, 4, 5, 6, 7]})
45+
result = outliers(df)
46+
assert isinstance(result, pl.DataFrame)
47+
assert result.columns == ["feature", "outlier_pct"]
48+
49+
50+
def test_pca_variance_polars():
51+
np.random.seed(0)
52+
base = np.random.randn(80)
53+
df = pl.DataFrame({
54+
"a": base + np.random.randn(80) * 0.3,
55+
"b": base + np.random.randn(80) * 0.3,
56+
"c": np.random.randn(80),
57+
})
58+
result = pca_variance(df)
59+
assert isinstance(result, pl.DataFrame)
60+
assert result.columns == ["component", "variance_explained", "cumulative"]
61+
62+
63+
# ===== Series in -> scalar out =====
64+
65+
def test_cv_polars_series_returns_float():
66+
assert isinstance(cv(pl.Series([10.0, 20, 30, 40, 50])), float)
67+
68+
69+
def test_outliers_polars_series_returns_float():
70+
assert isinstance(outliers(pl.Series([1.0, 2, 3, 4, 5, 6, 100])), float)
71+
72+
73+
def test_r_squared_polars_series():
74+
result = r_squared(pl.Series([1.0, 2, 3, 4, 5]), pl.Series([1.1, 1.9, 3.2, 3.8, 5.1]))
75+
assert isinstance(result, float)
76+
assert 90 < result < 100
77+
78+
79+
# ===== Series in -> polars Series out =====
80+
81+
def test_change_polars_period_over_period():
82+
result = change(pl.Series([100.0, 150, 90, 135]))
83+
assert isinstance(result, pl.Series)
84+
vals = result.to_list()
85+
assert vals[1] == 50.0
86+
assert vals[2] == -40.0
87+
88+
89+
def test_change_polars_two_columns():
90+
result = change(pl.Series([100.0, 200, 50]), pl.Series([150.0, 150, 100]))
91+
assert isinstance(result, pl.Series)
92+
assert result.to_list() == [50.0, -25.0, 100.0]
93+
94+
95+
def test_difference_polars_two_columns():
96+
result = difference(pl.Series([10.0, 50]), pl.Series([20.0, 50]))
97+
assert isinstance(result, pl.Series)
98+
assert result.to_list() == [66.67, 0.0]
99+
100+
101+
def test_split_polars_series():
102+
result = split(200, pl.Series([1, 3]))
103+
assert isinstance(result, pl.Series)
104+
assert result.to_list() == [50.0, 150.0]
105+
106+
107+
def test_display_polars_series():
108+
result = display(pl.Series([0.25, 0.5]), multiply=True)
109+
assert isinstance(result, pl.Series)
110+
assert result.to_list() == ["25.0%", "50.0%"]
111+
112+
113+
# ===== imbalance: polars DataFrame + summary preserved =====
114+
115+
def test_imbalance_polars_with_summary():
116+
result = imbalance(pl.Series(["No"] * 850 + ["Yes"] * 150))
117+
assert isinstance(result, pl.DataFrame)
118+
assert result.columns == ["class", "count", "pct"]
119+
summary = result.attrs["summary"]
120+
assert summary["majority_class"] == "No"
121+
assert summary["minority_class"] == "Yes"
122+
assert summary["imbalance_ratio"] == 5.67
123+
124+
125+
# ===== regression: pandas in still returns pandas =====
126+
127+
def test_pandas_input_unchanged():
128+
result = missing(pd.DataFrame({"a": [1.0, None], "b": [1.0, 2.0]}))
129+
assert isinstance(result, pd.DataFrame)
130+
131+
132+
def test_scalar_input_unchanged():
133+
assert change(100, 150) == 50.0
134+
assert display(0.45, multiply=True) == "45.0%"

0 commit comments

Comments
 (0)