Skip to content

Commit 549adc5

Browse files
committed
added outliers, co-efficient of variations and easy data profiling
1 parent c1314b7 commit 549adc5

3 files changed

Lines changed: 266 additions & 4 deletions

File tree

percentify/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from .core import percent, change, difference, split, display
2-
from .stats import vif
2+
from .stats import vif, missing, cv, outliers
33

4-
__all__ = ["percent", "change", "difference", "split", "display", "vif"]
4+
__all__ = ["percent", "change", "difference", "split", "display", "vif", "missing", "cv", "outliers"]
55
__version__ = "0.3.2"

percentify/stats.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Dict, List, Optional, Union
1+
from typing import Dict, List, Optional, Sequence, Union
22

33
import numpy as np
44
import pandas as pd
@@ -70,3 +70,102 @@ def vif(df: pd.DataFrame, decimals: Optional[int] = 2, flag: Optional[float] = N
7070
result = {k: v for k, v in result.items() if v > flag}
7171

7272
return result
73+
74+
75+
def _round(value: float, decimals: Optional[int]) -> float:
76+
if decimals is None:
77+
return value
78+
return round(value, decimals)
79+
80+
81+
def missing(df: pd.DataFrame, decimals: Optional[int] = 2) -> Dict[str, float]:
82+
"""
83+
Calculate the percentage of missing values for each column.
84+
85+
Args:
86+
df: DataFrame to profile.
87+
decimals: Number of decimal places to round to.
88+
89+
Returns:
90+
dict: Column names mapped to their missing percentage,
91+
sorted from highest to lowest.
92+
"""
93+
total = len(df)
94+
if total == 0:
95+
return {col: 0.0 for col in df.columns}
96+
97+
result = {}
98+
for col in df.columns:
99+
pct = df[col].isnull().sum() / total * 100.0
100+
result[col] = _round(pct, decimals)
101+
102+
return dict(sorted(result.items(), key=lambda x: x[1], reverse=True))
103+
104+
105+
def cv(data: Union[pd.Series, pd.DataFrame], decimals: Optional[int] = 2) -> Union[float, Dict[str, float]]:
106+
"""
107+
Calculate the coefficient of variation (CV = std / mean * 100).
108+
109+
Args:
110+
data: A Series (single result) or DataFrame (all numeric columns).
111+
decimals: Number of decimal places to round to.
112+
113+
Returns:
114+
float if Series, dict if DataFrame.
115+
116+
Raises:
117+
ValueError: If the mean is zero (CV is undefined).
118+
"""
119+
if isinstance(data, pd.Series):
120+
mean = data.mean()
121+
if mean == 0:
122+
raise ValueError("Coefficient of variation is undefined when mean is zero.")
123+
val = data.std() / abs(mean) * 100.0
124+
return _round(val, decimals)
125+
126+
numeric = data.select_dtypes(include=[np.number])
127+
result = {}
128+
for col in numeric.columns:
129+
mean = numeric[col].mean()
130+
if mean == 0:
131+
result[col] = float("inf")
132+
else:
133+
val = numeric[col].std() / abs(mean) * 100.0
134+
result[col] = _round(val, decimals)
135+
return result
136+
137+
138+
def outliers(data: Union[pd.Series, pd.DataFrame], decimals: Optional[int] = 2, multiplier: float = 1.5) -> Union[float, Dict[str, float]]:
139+
"""
140+
Calculate the percentage of outliers using the IQR method.
141+
142+
An outlier is any value below Q1 - multiplier*IQR or above Q3 + multiplier*IQR.
143+
144+
Args:
145+
data: A Series (single result) or DataFrame (all numeric columns).
146+
decimals: Number of decimal places to round to.
147+
multiplier: IQR multiplier for defining outlier bounds (default: 1.5).
148+
149+
Returns:
150+
float if Series, dict if DataFrame.
151+
"""
152+
def _calc(s: pd.Series) -> float:
153+
s = s.dropna()
154+
if len(s) == 0:
155+
return 0.0
156+
q1 = s.quantile(0.25)
157+
q3 = s.quantile(0.75)
158+
iqr = q3 - q1
159+
lower = q1 - multiplier * iqr
160+
upper = q3 + multiplier * iqr
161+
count = ((s < lower) | (s > upper)).sum()
162+
return count / len(s) * 100.0
163+
164+
if isinstance(data, pd.Series):
165+
return _round(_calc(data), decimals)
166+
167+
numeric = data.select_dtypes(include=[np.number])
168+
result = {}
169+
for col in numeric.columns:
170+
result[col] = _round(_calc(numeric[col]), decimals)
171+
return result

tests/test_stats.py

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import pytest
22
import numpy as np
33
import pandas as pd
4-
from percentify import vif
4+
from percentify import vif, missing, cv, outliers
55

66

7+
# ===== Fixtures =====
8+
79
@pytest.fixture
810
def independent_df():
911
np.random.seed(42)
@@ -25,6 +27,8 @@ def collinear_df():
2527
})
2628

2729

30+
# ===== vif =====
31+
2832
def test_vif_returns_all_columns(independent_df):
2933
result = vif(independent_df)
3034
assert set(result.keys()) == {"a", "b", "c"}
@@ -77,3 +81,162 @@ def test_vif_custom_decimals(independent_df):
7781
str_val = str(val)
7882
if "." in str_val:
7983
assert len(str_val.split(".")[1]) <= 4
84+
85+
86+
# ===== missing =====
87+
88+
def test_missing_basic():
89+
df = pd.DataFrame({
90+
"a": [1, 2, None, 4, 5],
91+
"b": [None, None, 3, 4, 5],
92+
"c": [1, 2, 3, 4, 5],
93+
})
94+
result = missing(df)
95+
assert result["b"] == 40.0
96+
assert result["a"] == 20.0
97+
assert result["c"] == 0.0
98+
99+
100+
def test_missing_sorted_descending():
101+
df = pd.DataFrame({
102+
"a": [1, 2, None, 4, 5],
103+
"b": [None, None, 3, 4, 5],
104+
"c": [1, 2, 3, 4, 5],
105+
})
106+
result = missing(df)
107+
keys = list(result.keys())
108+
assert keys == ["b", "a", "c"]
109+
110+
111+
def test_missing_no_nulls():
112+
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
113+
result = missing(df)
114+
assert result["a"] == 0.0
115+
assert result["b"] == 0.0
116+
117+
118+
def test_missing_all_null():
119+
df = pd.DataFrame({"a": [None, None, None]})
120+
result = missing(df)
121+
assert result["a"] == 100.0
122+
123+
124+
def test_missing_empty_df():
125+
df = pd.DataFrame({"a": [], "b": []})
126+
result = missing(df)
127+
assert result["a"] == 0.0
128+
129+
130+
def test_missing_includes_non_numeric():
131+
df = pd.DataFrame({
132+
"name": ["Alice", None, "Charlie"],
133+
"age": [25, None, 35],
134+
})
135+
result = missing(df)
136+
assert "name" in result
137+
assert "age" in result
138+
assert result["name"] == 33.33
139+
assert result["age"] == 33.33
140+
141+
142+
# ===== cv =====
143+
144+
def test_cv_series():
145+
s = pd.Series([10, 20, 30, 40, 50])
146+
result = cv(s)
147+
assert result > 0
148+
assert isinstance(result, float)
149+
150+
151+
def test_cv_dataframe():
152+
df = pd.DataFrame({
153+
"a": [10, 20, 30, 40, 50],
154+
"b": [100, 100, 100, 100, 100],
155+
})
156+
result = cv(df)
157+
assert isinstance(result, dict)
158+
assert result["a"] > 0
159+
assert result["b"] == 0.0
160+
161+
162+
def test_cv_zero_mean_series():
163+
s = pd.Series([-1, 0, 1])
164+
with pytest.raises(ValueError):
165+
cv(s)
166+
167+
168+
def test_cv_zero_mean_dataframe():
169+
df = pd.DataFrame({
170+
"a": [-1, 0, 1],
171+
"b": [10, 20, 30],
172+
})
173+
result = cv(df)
174+
assert result["a"] == float("inf")
175+
assert result["b"] > 0
176+
177+
178+
def test_cv_ignores_non_numeric():
179+
df = pd.DataFrame({
180+
"a": [10, 20, 30],
181+
"name": ["x", "y", "z"],
182+
})
183+
result = cv(df)
184+
assert "name" not in result
185+
assert "a" in result
186+
187+
188+
def test_cv_custom_decimals():
189+
s = pd.Series([10, 20, 30, 40, 50])
190+
result = cv(s, decimals=4)
191+
str_val = str(result)
192+
if "." in str_val:
193+
assert len(str_val.split(".")[1]) <= 4
194+
195+
196+
# ===== outliers =====
197+
198+
def test_outliers_series():
199+
s = pd.Series([1, 2, 3, 4, 5, 6, 7, 8, 9, 100])
200+
result = outliers(s)
201+
assert isinstance(result, float)
202+
assert result > 0
203+
204+
205+
def test_outliers_no_outliers():
206+
s = pd.Series([1, 2, 3, 4, 5])
207+
result = outliers(s)
208+
assert result == 0.0
209+
210+
211+
def test_outliers_dataframe():
212+
df = pd.DataFrame({
213+
"a": [1, 2, 3, 4, 5, 6, 7, 8, 9, 100],
214+
"b": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
215+
})
216+
result = outliers(df)
217+
assert isinstance(result, dict)
218+
assert result["a"] > 0
219+
assert result["b"] == 0.0
220+
221+
222+
def test_outliers_all_nan():
223+
s = pd.Series([None, None, None])
224+
result = outliers(s)
225+
assert result == 0.0
226+
227+
228+
def test_outliers_ignores_non_numeric():
229+
df = pd.DataFrame({
230+
"a": [1, 2, 3, 4, 100],
231+
"name": ["x", "y", "z", "w", "v"],
232+
})
233+
result = outliers(df)
234+
assert "name" not in result
235+
assert "a" in result
236+
237+
238+
def test_outliers_custom_multiplier():
239+
s = pd.Series([1, 2, 3, 4, 5, 6, 7, 8, 9, 15])
240+
strict = outliers(s, multiplier=1.0)
241+
loose = outliers(s, multiplier=3.0)
242+
assert strict >= loose

0 commit comments

Comments
 (0)