Skip to content

Commit 6e973e0

Browse files
committed
Added r-squared and principal components breakdown with variance explained
1 parent 549adc5 commit 6e973e0

3 files changed

Lines changed: 224 additions & 3 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, missing, cv, outliers
2+
from .stats import vif, missing, cv, outliers, r_squared, variance_explained
33

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

percentify/stats.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,89 @@ def _calc(s: pd.Series) -> float:
169169
for col in numeric.columns:
170170
result[col] = _round(_calc(numeric[col]), decimals)
171171
return result
172+
173+
174+
def r_squared(y_true: Union[pd.Series, Sequence, np.ndarray], y_pred: Union[pd.Series, Sequence, np.ndarray], decimals: Optional[int] = 2) -> float:
175+
"""
176+
Calculate R-squared (coefficient of determination).
177+
178+
R² = 1 - (SS_res / SS_tot), expressed as a percentage.
179+
180+
Args:
181+
y_true: Actual values.
182+
y_pred: Predicted values.
183+
decimals: Number of decimal places to round to.
184+
185+
Returns:
186+
float: R-squared as a percentage (e.g. 87.3 means 87.3%).
187+
188+
Raises:
189+
ValueError: If inputs have different lengths or fewer than 2 values.
190+
"""
191+
y_true = np.asarray(y_true, dtype=float)
192+
y_pred = np.asarray(y_pred, dtype=float)
193+
194+
if len(y_true) != len(y_pred):
195+
raise ValueError("`y_true` and `y_pred` must have the same length.")
196+
197+
if len(y_true) < 2:
198+
raise ValueError("Need at least 2 values to compute R-squared.")
199+
200+
ss_res = np.sum((y_true - y_pred) ** 2)
201+
ss_tot = np.sum((y_true - np.mean(y_true)) ** 2)
202+
203+
if ss_tot == 0:
204+
return 0.0
205+
206+
val = (1.0 - ss_res / ss_tot) * 100.0
207+
return _round(val, decimals)
208+
209+
210+
def variance_explained(df: pd.DataFrame, decimals: Optional[int] = 2, n_components: Optional[int] = None) -> Dict[str, float]:
211+
"""
212+
Calculate the percentage of variance explained by each principal component.
213+
214+
Performs PCA using eigendecomposition of the covariance matrix.
215+
216+
Args:
217+
df: DataFrame with numeric columns.
218+
decimals: Number of decimal places to round to.
219+
n_components: Number of components to return. If None, returns all.
220+
221+
Returns:
222+
dict: Component names mapped to their explained variance percentage.
223+
e.g. {"PC1": 45.2, "PC2": 23.1, ...}
224+
225+
Raises:
226+
ValueError: If DataFrame has fewer than 2 numeric columns or rows.
227+
"""
228+
numeric = df.select_dtypes(include=[np.number]).dropna()
229+
230+
if numeric.shape[1] < 2:
231+
raise ValueError("DataFrame must have at least 2 numeric columns.")
232+
233+
if numeric.shape[0] < 2:
234+
raise ValueError("DataFrame must have at least 2 non-null rows.")
235+
236+
X = numeric.values.astype(float)
237+
X_centered = X - X.mean(axis=0)
238+
239+
cov_matrix = np.cov(X_centered, rowvar=False)
240+
eigenvalues, _ = np.linalg.eigh(cov_matrix)
241+
242+
eigenvalues = eigenvalues[::-1]
243+
total = eigenvalues.sum()
244+
245+
if total == 0:
246+
return {}
247+
248+
ratios = eigenvalues / total * 100.0
249+
250+
if n_components is not None:
251+
ratios = ratios[:n_components]
252+
253+
result = {}
254+
for i, r in enumerate(ratios):
255+
result[f"PC{i + 1}"] = _round(r, decimals)
256+
257+
return result

tests/test_stats.py

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

66

77
# ===== Fixtures =====
@@ -240,3 +240,138 @@ def test_outliers_custom_multiplier():
240240
strict = outliers(s, multiplier=1.0)
241241
loose = outliers(s, multiplier=3.0)
242242
assert strict >= loose
243+
244+
245+
# ===== r_squared =====
246+
247+
def test_r_squared_perfect():
248+
y = [1, 2, 3, 4, 5]
249+
assert r_squared(y, y) == 100.0
250+
251+
252+
def test_r_squared_good_fit():
253+
y_true = [1, 2, 3, 4, 5]
254+
y_pred = [1.1, 1.9, 3.2, 3.8, 5.1]
255+
result = r_squared(y_true, y_pred)
256+
assert 90 < result < 100
257+
258+
259+
def test_r_squared_bad_fit():
260+
y_true = [1, 2, 3, 4, 5]
261+
y_pred = [5, 4, 3, 2, 1]
262+
result = r_squared(y_true, y_pred)
263+
assert result < 0
264+
265+
266+
def test_r_squared_with_numpy():
267+
y_true = np.array([1, 2, 3, 4, 5])
268+
y_pred = np.array([1, 2, 3, 4, 5])
269+
assert r_squared(y_true, y_pred) == 100.0
270+
271+
272+
def test_r_squared_with_series():
273+
y_true = pd.Series([1, 2, 3, 4, 5])
274+
y_pred = pd.Series([1.1, 2.1, 2.9, 4.0, 5.1])
275+
result = r_squared(y_true, y_pred)
276+
assert result > 0
277+
278+
279+
def test_r_squared_mismatched_length():
280+
with pytest.raises(ValueError):
281+
r_squared([1, 2, 3], [1, 2])
282+
283+
284+
def test_r_squared_too_few():
285+
with pytest.raises(ValueError):
286+
r_squared([1], [1])
287+
288+
289+
def test_r_squared_custom_decimals():
290+
y_true = [1, 2, 3, 4, 5]
291+
y_pred = [1.1, 1.9, 3.2, 3.8, 5.1]
292+
result = r_squared(y_true, y_pred, decimals=4)
293+
str_val = str(result)
294+
if "." in str_val:
295+
assert len(str_val.split(".")[1]) <= 4
296+
297+
298+
# ===== variance_explained =====
299+
300+
def test_variance_explained_returns_all_components():
301+
np.random.seed(42)
302+
df = pd.DataFrame({
303+
"a": np.random.randn(100),
304+
"b": np.random.randn(100),
305+
"c": np.random.randn(100),
306+
})
307+
result = variance_explained(df)
308+
assert len(result) == 3
309+
assert "PC1" in result
310+
assert "PC2" in result
311+
assert "PC3" in result
312+
313+
314+
def test_variance_explained_sums_to_100():
315+
np.random.seed(42)
316+
df = pd.DataFrame({
317+
"a": np.random.randn(100),
318+
"b": np.random.randn(100),
319+
"c": np.random.randn(100),
320+
})
321+
result = variance_explained(df, decimals=None)
322+
assert pytest.approx(sum(result.values()), abs=0.01) == 100.0
323+
324+
325+
def test_variance_explained_sorted_descending():
326+
np.random.seed(42)
327+
df = pd.DataFrame({
328+
"a": np.random.randn(100),
329+
"b": np.random.randn(100),
330+
"c": np.random.randn(100),
331+
})
332+
result = variance_explained(df)
333+
values = list(result.values())
334+
assert values == sorted(values, reverse=True)
335+
336+
337+
def test_variance_explained_correlated_features():
338+
np.random.seed(42)
339+
x = np.random.randn(100)
340+
df = pd.DataFrame({
341+
"a": x,
342+
"b": x + np.random.randn(100) * 0.01,
343+
"c": np.random.randn(100),
344+
})
345+
result = variance_explained(df)
346+
assert result["PC1"] > 50
347+
348+
349+
def test_variance_explained_n_components():
350+
np.random.seed(42)
351+
df = pd.DataFrame({
352+
"a": np.random.randn(100),
353+
"b": np.random.randn(100),
354+
"c": np.random.randn(100),
355+
})
356+
result = variance_explained(df, n_components=2)
357+
assert len(result) == 2
358+
assert "PC1" in result
359+
assert "PC2" in result
360+
assert "PC3" not in result
361+
362+
363+
def test_variance_explained_too_few_columns():
364+
df = pd.DataFrame({"a": [1, 2, 3]})
365+
with pytest.raises(ValueError):
366+
variance_explained(df)
367+
368+
369+
def test_variance_explained_ignores_non_numeric():
370+
np.random.seed(42)
371+
df = pd.DataFrame({
372+
"a": np.random.randn(50),
373+
"b": np.random.randn(50),
374+
"name": ["foo"] * 50,
375+
})
376+
result = variance_explained(df)
377+
assert len(result) == 2

0 commit comments

Comments
 (0)