Skip to content

Commit 4d6c051

Browse files
committed
pca_doadings implemented. all bugs fixed and added in documentation
1 parent b9fe056 commit 4d6c051

7 files changed

Lines changed: 191 additions & 127 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ One import, one line. A clean, sorted DataFrame you can read or feed into the ne
6161
| `missing` | How much of each column is missing? |
6262
| `cv` | How variable is each column, relative to its mean? |
6363
| `outliers` | What percentage of each column are outliers? |
64-
| `r_squared` | How well do predictions fit? |
6564
| `pca_variance` | How much variance does each principal component explain? |
65+
| `pca_loadings` | What does each principal component consist of? |
6666
| `imbalance` | How skewed are the classes in a target column? |
6767
| `difference` | How far apart are two values or columns? |
6868
| `split` | How does a total divide across weights or groups? |

docs/documentation.md

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ pip install percentify
1111
Percentify requires `numpy` and `pandas`.
1212

1313
```python
14-
from percentify import change, vif, missing, cv, outliers, r_squared, pca_variance
14+
from percentify import change, vif, missing, cv, outliers, pca_variance, pca_loadings
1515
```
1616

1717
---
@@ -353,53 +353,68 @@ Tune the sensitivity with `multiplier` (e.g. `multiplier=3.0` for a looser bound
353353

354354
---
355355

356-
## `r_squared`
356+
## `pca_variance`
357357

358-
R-squared (coefficient of determination), as a percentage.
358+
The percentage of variance explained by each principal component, plus a running cumulative total.
359359

360360
!!! tip "Similar concept"
361-
`sklearn.metrics.r2_score`
361+
`sklearn.decomposition.PCA` (`.explained_variance_ratio_`)
362362

363363
**Signature**
364364

365365
```python
366-
r_squared(y_true, y_pred, decimals=2)
366+
pca_variance(df, decimals=2, n_components=None, standardize=True)
367367
```
368368

369369
**Example**
370370

371371
```python
372-
from percentify import r_squared
372+
import numpy as np, pandas as pd
373+
from percentify import pca_variance
374+
375+
np.random.seed(7)
376+
base = np.random.randn(200)
377+
df = pd.DataFrame({
378+
"height": base + np.random.randn(200) * 0.3,
379+
"weight": base + np.random.randn(200) * 0.3, # shares a signal with height
380+
"noise": np.random.randn(200),
381+
})
373382

374-
r_squared([1, 2, 3, 4, 5], [1.1, 1.9, 3.2, 3.8, 5.1])
383+
pca_variance(df)
375384
```
376385

377386
```text
378-
98.9
387+
component variance_explained cumulative
388+
0 PC1 64.04 64.04
389+
1 PC2 33.34 97.38
390+
2 PC3 2.62 100.00
379391
```
380392

381-
The predictions explain 98.9% of the variance in the true values. Accepts lists, numpy arrays, or pandas Series.
393+
Read the `cumulative` column to decide how many components to keep — here PC1 + PC2 already capture 97.4% of the variance.
394+
395+
!!! warning "Standardization matters"
396+
By default `standardize=True`, so each column is scaled to unit variance first. This stops a column measured in large units (e.g. dollars) from dominating purely because of its scale. Pass `standardize=False` for covariance-based PCA on the raw values.
382397

383398
---
384399

385-
## `pca_variance`
400+
## `pca_loadings`
386401

387-
The percentage of variance explained by each principal component, plus a running cumulative total.
402+
Principal component loadings: what each component is *made of*. Where `pca_variance` tells you how important each PC is, `pca_loadings` shows how much each original feature contributes to it, as a feature-by-component table.
388403

389404
!!! tip "Similar concept"
390-
`sklearn.decomposition.PCA` (`.explained_variance_ratio_`)
405+
`sklearn.decomposition.PCA` (`.components_`)
391406

392407
**Signature**
393408

394409
```python
395-
pca_variance(df, decimals=2, n_components=None, standardize=True)
410+
pca_loadings(df, decimals=2, n_components=None, standardize=True)
396411
```
397412

398413
**Example**
399414

400415
```python
401416
import numpy as np, pandas as pd
402-
from percentify import pca_variance
417+
from percentify import pca_loadings
403418

404419
np.random.seed(7)
405420
base = np.random.randn(200)
@@ -409,20 +424,20 @@ df = pd.DataFrame({
409424
"noise": np.random.randn(200),
410425
})
411426

412-
pca_variance(df)
427+
pca_loadings(df)
413428
```
414429

415430
```text
416-
component variance_explained cumulative
417-
0 PC1 64.04 64.04
418-
1 PC2 33.34 97.38
419-
2 PC3 2.62 100.00
431+
feature PC1 PC2 PC3
432+
height 0.71 0.01 -0.71
433+
weight 0.71 -0.02 0.71
434+
noise 0.01 1.00 0.02
420435
```
421436

422-
Read the `cumulative` column to decide how many components to keep — here PC1 + PC2 already capture 97.4% of the variance.
437+
Read down a column: PC1 is `height` and `weight` together (both `0.71`), while `noise` sits near `0`, so PC1 is the shared height/weight signal. Standardized by default, matching `pca_variance`.
423438

424-
!!! warning "Standardization matters"
425-
By default `standardize=True`, so each column is scaled to unit variance first. This stops a column measured in large units (e.g. dollars) from dominating purely because of its scale. Pass `standardize=False` for covariance-based PCA on the raw values.
439+
!!! note "Sign is arbitrary"
440+
A component and its negation are equivalent, so the sign of a loading is not meaningful on its own. Only the relative signs and magnitudes *within* a column matter.
426441

427442
---
428443

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ One import, one line. A clean, sorted DataFrame you can read or feed straight in
6060
| `missing` | How much of each column is missing? |
6161
| `cv` | How variable is each column, relative to its mean? |
6262
| `outliers` | What percentage of each column are outliers? |
63-
| `r_squared` | How well do predictions fit? |
6463
| `pca_variance` | How much variance does each principal component explain? |
64+
| `pca_loadings` | What does each principal component consist of? |
6565
| `imbalance` | How skewed are the classes in a target column? |
6666
| `difference` | How far apart are two values or columns (regardless of direction)? |
6767
| `split` | How does a total divide across weights or groups? |

percentify/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
from .profiling import profiler, ProfileReport, Finding
22
from .stats import (
3-
change, vif, missing, cv, outliers, r_squared, pca_variance, imbalance,
3+
change, vif, missing, cv, outliers, pca_variance, pca_loadings, imbalance,
44
difference, split, display, PercentifyWarning,
55
)
66

77
__all__ = [
88
"profiler", "ProfileReport", "Finding",
9-
"change", "vif", "missing", "cv", "outliers", "r_squared", "pca_variance", "imbalance",
9+
"change", "vif", "missing", "cv", "outliers", "pca_variance", "pca_loadings", "imbalance",
1010
"difference", "split", "display", "PercentifyWarning",
1111
]
1212
__version__ = "1.0.0"

percentify/stats.py

Lines changed: 72 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import functools
22
import warnings
3-
from typing import Optional, Sequence, Union
3+
from typing import Optional, Union
44

55
import numpy as np
66
import pandas as pd
@@ -324,50 +324,6 @@ def _calc(s: pd.Series) -> float:
324324
return result.sort_values("outlier_pct", ascending=False).reset_index(drop=True)
325325

326326

327-
@_backend_aware
328-
def r_squared(
329-
y_true: Union[pd.Series, Sequence, np.ndarray],
330-
y_pred: Union[pd.Series, Sequence, np.ndarray],
331-
decimals: Optional[int] = 2,
332-
) -> float:
333-
"""
334-
Calculate R-squared (coefficient of determination).
335-
336-
R² = 1 - (SS_res / SS_tot), expressed as a percentage.
337-
338-
Args:
339-
y_true: Actual values.
340-
y_pred: Predicted values.
341-
decimals: Number of decimal places to round to.
342-
343-
Returns:
344-
float: R-squared as a percentage (e.g. 87.3 means 87.3%).
345-
346-
Raises:
347-
ValueError: If inputs are non-numeric, differ in length, or have < 2 values.
348-
"""
349-
try:
350-
y_true = np.asarray(y_true, dtype=float)
351-
y_pred = np.asarray(y_pred, dtype=float)
352-
except (ValueError, TypeError):
353-
raise ValueError("r_squared expects numeric values, but got non-numeric input.")
354-
355-
if len(y_true) != len(y_pred):
356-
raise ValueError("`y_true` and `y_pred` must have the same length.")
357-
358-
if len(y_true) < 2:
359-
raise ValueError("Need at least 2 values to compute R-squared.")
360-
361-
ss_res = np.sum((y_true - y_pred) ** 2)
362-
ss_tot = np.sum((y_true - np.mean(y_true)) ** 2)
363-
364-
if ss_tot == 0:
365-
return 0.0
366-
367-
val = (1.0 - ss_res / ss_tot) * 100.0
368-
return _round(val, decimals)
369-
370-
371327
@_backend_aware
372328
def pca_variance(
373329
df: pd.DataFrame, decimals: Optional[int] = 2, n_components: Optional[int] = None,
@@ -443,6 +399,77 @@ def pca_variance(
443399
})
444400

445401

402+
@_backend_aware
403+
def pca_loadings(
404+
df: pd.DataFrame, decimals: Optional[int] = 2, n_components: Optional[int] = None,
405+
standardize: bool = True,
406+
) -> pd.DataFrame:
407+
"""
408+
Principal component loadings: how much each feature contributes to each PC.
409+
410+
Returns the eigenvectors of the (optionally standardized) covariance matrix
411+
as a feature x component table. Read down a column to see what a component
412+
is made of. Columns are standardized first by default (matching
413+
pca_variance); pass standardize=False for covariance-based loadings.
414+
415+
Non-numeric columns are ignored. If fewer than 2 usable numeric columns are
416+
available, a warning is raised and an empty DataFrame is returned.
417+
418+
Args:
419+
df: DataFrame with numeric columns.
420+
decimals: Number of decimal places to round to.
421+
n_components: Number of components (columns) to return. If None, all.
422+
standardize: If True (default), scale each column to unit variance first.
423+
424+
Returns:
425+
DataFrame with a "feature" column followed by PC1, PC2, ... loadings.
426+
Note: the sign of a component is arbitrary, so only the relative signs
427+
and magnitudes within a column are meaningful.
428+
"""
429+
if not isinstance(df, pd.DataFrame):
430+
raise TypeError(f"pca_loadings expects a pandas DataFrame, got {type(df).__name__}.")
431+
432+
empty = pd.DataFrame(columns=["feature"])
433+
numeric = df.select_dtypes(include=[np.number]).dropna()
434+
435+
if numeric.shape[1] < 2:
436+
_warn("Numeric columns required: pca_loadings needs at least 2 numeric columns.")
437+
return empty
438+
439+
if numeric.shape[0] < 2:
440+
_warn("Not enough data: need at least 2 complete (non-null) rows.")
441+
return empty
442+
443+
cols = numeric.columns.tolist()
444+
X = numeric.values.astype(float)
445+
446+
if standardize:
447+
keep = X.std(axis=0, ddof=1) > 0
448+
if keep.sum() < 2:
449+
_warn("Numeric columns required: after dropping constant (zero-variance) "
450+
"columns, fewer than 2 columns remain to standardize for PCA.")
451+
return empty
452+
cols = [c for c, k in zip(cols, keep) if k]
453+
X = X[:, keep]
454+
X = (X - X.mean(axis=0)) / X.std(axis=0, ddof=1)
455+
456+
eigenvalues, eigenvectors = np.linalg.eigh(np.cov(X, rowvar=False))
457+
458+
if eigenvalues.sum() == 0:
459+
_warn("All numeric columns are constant - there are no components to describe.")
460+
return empty
461+
462+
order = np.argsort(eigenvalues)[::-1]
463+
eigenvectors = eigenvectors[:, order]
464+
if n_components is not None:
465+
eigenvectors = eigenvectors[:, :n_components]
466+
467+
result = pd.DataFrame({"feature": cols})
468+
for i in range(eigenvectors.shape[1]):
469+
result[f"PC{i + 1}"] = [_round(float(v), decimals) for v in eigenvectors[:, i]]
470+
return result
471+
472+
446473
@_backend_aware
447474
def imbalance(data, decimals: Optional[int] = 2) -> pd.DataFrame:
448475
"""

tests/test_polars.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
pl = pytest.importorskip("polars")
55
import pandas as pd # noqa: E402
66
from percentify import ( # noqa: E402
7-
change, vif, missing, cv, outliers, r_squared,
8-
pca_variance, imbalance, difference, split, display,
7+
change, vif, missing, cv, outliers,
8+
pca_variance, pca_loadings, imbalance, difference, split, display,
99
)
1010

1111

@@ -60,6 +60,19 @@ def test_pca_variance_polars():
6060
assert result.columns == ["component", "variance_explained", "cumulative"]
6161

6262

63+
def test_pca_loadings_polars():
64+
np.random.seed(1)
65+
base = np.random.randn(80)
66+
df = pl.DataFrame({
67+
"a": base + np.random.randn(80) * 0.05,
68+
"b": base + np.random.randn(80) * 0.05,
69+
"c": np.random.randn(80),
70+
})
71+
result = pca_loadings(df)
72+
assert isinstance(result, pl.DataFrame)
73+
assert result.columns == ["feature", "PC1", "PC2", "PC3"]
74+
75+
6376
# ===== Series in -> scalar out =====
6477

6578
def test_cv_polars_series_returns_float():
@@ -70,12 +83,6 @@ def test_outliers_polars_series_returns_float():
7083
assert isinstance(outliers(pl.Series([1.0, 2, 3, 4, 5, 6, 100])), float)
7184

7285

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-
7986
# ===== Series in -> polars Series out =====
8087

8188
def test_change_polars_period_over_period():

0 commit comments

Comments
 (0)