|
1 | 1 | import functools |
2 | 2 | import warnings |
3 | | -from typing import Optional, Sequence, Union |
| 3 | +from typing import Optional, Union |
4 | 4 |
|
5 | 5 | import numpy as np |
6 | 6 | import pandas as pd |
@@ -324,50 +324,6 @@ def _calc(s: pd.Series) -> float: |
324 | 324 | return result.sort_values("outlier_pct", ascending=False).reset_index(drop=True) |
325 | 325 |
|
326 | 326 |
|
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 | | - |
371 | 327 | @_backend_aware |
372 | 328 | def pca_variance( |
373 | 329 | df: pd.DataFrame, decimals: Optional[int] = 2, n_components: Optional[int] = None, |
@@ -443,6 +399,77 @@ def pca_variance( |
443 | 399 | }) |
444 | 400 |
|
445 | 401 |
|
| 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 | + |
446 | 473 | @_backend_aware |
447 | 474 | def imbalance(data, decimals: Optional[int] = 2) -> pd.DataFrame: |
448 | 475 | """ |
|
0 commit comments