From 140023fb9c3d82609fbf2f594a0741a2eab2ce5b Mon Sep 17 00:00:00 2001 From: Priscilla Ayegbusi Date: Tue, 13 Oct 2026 15:24:42 +0100 Subject: [PATCH] Add pca_transform --- README.md | 4 ++- docs/documentation.md | 2 +- percentify/__init__.py | 4 +-- percentify/stats.py | 73 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0be966c..8cb3978 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ Variance tells you *how much* of the signal each axis carries; loadings tell you ```python import pandas as pd -from percentify import pca_variance, pca_loadings +from percentify import pca_variance, pca_loadings, pca_transform df = pd.DataFrame({ "height_cm": [160, 170, 180, 175, 165], @@ -178,6 +178,7 @@ df = pd.DataFrame({ print(pca_variance(df)) # PC1 carries most of the variance print(pca_loadings(df)) # PC1 = (height, weight) with similar signs +print(pca_transform(df)) # each row expressed as PC1, PC2, PC3 scores ``` #### Drop collinear columns before modelling @@ -231,6 +232,7 @@ print(change(kpis)) | `outliers` | What percentage of each column are outliers? | | `pca_variance` | How much variance does each principal component explain? | | `pca_loadings` | What does each principal component consist of? | +| `pca_transform` | Where does each row sit on the principal components? | | `imbalance` | How skewed are the classes in a target column? | | `correlate` | Which features move together, and is it significant? | | `skew_report` | How skewed is each column, and what transform helps? | diff --git a/docs/documentation.md b/docs/documentation.md index cc332ab..02cae96 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -25,7 +25,7 @@ Every function is available on the namespace. Importing functions directly works too, and is what the examples on this page use so each call stays on one line: ```python -from percentify import change, vif, missing, cv, outliers, pca_variance, pca_loadings +from percentify import change, vif, missing, cv, outliers, pca_variance, pca_loadings, pca_transform ``` The two styles are interchangeable. Pick one and stay with it. diff --git a/percentify/__init__.py b/percentify/__init__.py index 7fe7d36..0388068 100644 --- a/percentify/__init__.py +++ b/percentify/__init__.py @@ -1,13 +1,13 @@ from .profiling import profiler, ProfileReport, Finding from .stats import ( - change, vif, missing, cv, outliers, pca_variance, pca_loadings, imbalance, + change, vif, missing, cv, outliers, pca_variance, pca_loadings, pca_transform, imbalance, correlate, skew_report, bootstrap_ci, permutation_test, effect_size, difference, split, display, PercentifyWarning, ) __all__ = [ "profiler", "ProfileReport", "Finding", - "change", "vif", "missing", "cv", "outliers", "pca_variance", "pca_loadings", "imbalance", + "change", "vif", "missing", "cv", "outliers", "pca_variance", "pca_loadings", "pca_transform", "imbalance", "correlate", "skew_report", "bootstrap_ci", "permutation_test", "effect_size", "difference", "split", "display", "PercentifyWarning", ] diff --git a/percentify/stats.py b/percentify/stats.py index b2ae5b5..df609d0 100644 --- a/percentify/stats.py +++ b/percentify/stats.py @@ -552,6 +552,79 @@ def pca_loadings( return result +@_backend_aware +def pca_transform( + df: pd.DataFrame, decimals: Optional[int] = None, n_components: Optional[int] = None, + standardize: bool = True, +) -> pd.DataFrame: + """Project a DataFrame's numeric features onto principal components. + + This uses the same PCA calculation as :func:`pca_variance` and + :func:`pca_loadings`. By default, each feature is standardized before the + projection, so scores are based on correlations rather than the original + measurement scales. Set ``standardize=False`` for covariance-based PCA. + + Non-numeric columns are ignored and rows containing missing numeric values + are omitted. Constant columns are dropped when standardizing because they + cannot contribute to a component. + + Args: + df: DataFrame with numeric columns. + decimals: Number of decimal places to round scores to. If None, scores + are returned without rounding. + n_components: Number of component columns to return. If None, returns + every available component. + standardize: If True (default), scale each feature to unit variance + before calculating scores. + + Returns: + DataFrame with one row per complete input row and columns ``PC1``, + ``PC2``, and so on. + """ + if not isinstance(df, pd.DataFrame): + raise TypeError(f"pca_transform expects a pandas DataFrame, got {type(df).__name__}.") + + numeric = df.select_dtypes(include=[np.number]).dropna() + if numeric.shape[1] < 2: + _warn("Numeric columns required: pca_transform needs at least 2 numeric columns.") + return pd.DataFrame() + + if numeric.shape[0] < 2: + _warn("Not enough data: need at least 2 complete (non-null) rows.") + return pd.DataFrame() + + X = numeric.values.astype(float) + if standardize: + scale = X.std(axis=0, ddof=1) + keep = scale > 0 + if keep.sum() < 2: + _warn("Numeric columns required: after dropping constant (zero-variance) " + "columns, fewer than 2 columns remain to standardize for PCA.") + return pd.DataFrame() + X = X[:, keep] + scale = scale[keep] + X = (X - X.mean(axis=0)) / scale + else: + X = X - X.mean(axis=0) + + eigenvalues, eigenvectors = np.linalg.eigh(np.cov(X, rowvar=False)) + if eigenvalues.sum() == 0: + _warn("All numeric columns are constant - there are no components to transform.") + return pd.DataFrame() + + eigenvectors = eigenvectors[:, np.argsort(eigenvalues)[::-1]] + if n_components is not None: + eigenvectors = eigenvectors[:, :n_components] + + scores = X @ eigenvectors + result = pd.DataFrame( + scores, + index=numeric.index, + columns=[f"PC{i + 1}" for i in range(scores.shape[1])], + ) + return result if decimals is None else result.round(decimals) + + @_backend_aware def imbalance(data, decimals: Optional[int] = 2) -> pd.DataFrame: """