"""SVD noise removal (step 3 of preprocessing pipeline).""" import numpy as np import polars as pl def svd_denoise( lf: pl.LazyFrame, feature_cols: list[str], variance_threshold: float = 0.95, ) -> tuple[pl.LazyFrame, dict]: """Remove noise from numeric features via TruncatedSVD. 1. Fits TruncatedSVD on *feature_cols*. 2. Keeps *k* components where cumulative explained variance ``trace(S[:k]) / trace(S) ≥ variance_threshold`` (default 0.95). 3. Inverse-transforms to reconstruct the denoised matrix. 4. **Replaces** the original *feature_cols* with denoised values. The noise components (removed by SVD) are returned as a dict for display, NOT as new feature columns. Parameters ---------- lf: LazyFrame containing *feature_cols*. feature_cols: Numeric column names to denoise. These columns are replaced in-place by their denoised reconstructions. variance_threshold: Fraction of total variance to preserve (0.0–1.0). Default 0.95 keeps 95 % of signal and treats 5 % as noise. Returns ------- tuple[pl.LazyFrame, dict] ``(denoised_lf, noise_profile)`` where *noise_profile* contains: - ``kept_components`` : int — number of components kept - ``total_components`` : int — max possible components - ``kept_variance`` : float — fraction of variance kept - ``noise_components`` : list[dict] — per-component noise info (component index, explained variance, top-5 contributing features) """ noise_profile = { 'kept_components': 0, 'total_components': 0, 'kept_variance': 0.0, 'noise_components': [], } available = [c for c in feature_cols if c in lf.collect_schema().names()] if not available: return lf, noise_profile df = lf.select(available).collect(streaming=True) mat = df.to_numpy().astype(np.float64) mat = np.nan_to_num(mat, nan=0.0) n, d = mat.shape max_components = min(n, d - 1) if max_components < 1: return lf, noise_profile from sklearn.decomposition import TruncatedSVD svd = TruncatedSVD(n_components=max_components, random_state=42) _ = svd.fit_transform(mat) total_variance = float(np.sum(svd.explained_variance_)) if total_variance <= 1e-10: return lf, noise_profile cumsum = np.cumsum(svd.explained_variance_) for k in range(1, max_components + 1): if cumsum[k - 1] / total_variance >= variance_threshold: break else: k = max_components # Re-fit with chosen k svd_k = TruncatedSVD(n_components=k, random_state=42) reduced = svd_k.fit_transform(mat) reconstructed = svd_k.inverse_transform(reduced) # --- Build noise profile (removed components) --- # The "noise" is the variance in components k..max_components kept_var_ratio = cumsum[k - 1] / total_variance if k > 0 else 0.0 noise_profile = { 'kept_components': k, 'total_components': max_components, 'kept_variance': round(float(kept_var_ratio), 4), } # Per-component noise info for removed components (k and beyond) noise_info = [] for comp_idx in range(k, min(k + 10, max_components)): loading = np.abs(svd.components_[comp_idx]) top5_idx = np.argsort(loading)[-5:][::-1] top5 = [ {'feature': available[i], 'strength': round(float(loading[i]), 4)} for i in top5_idx ] noise_info.append({ 'component': int(comp_idx + 1), 'explained_var_ratio': round(float(svd.explained_variance_ratio_[comp_idx]) * 100, 3), 'top_features': top5, }) noise_profile['noise_components'] = noise_info # --- Replace original columns with denoised values --- df_result = lf.collect(streaming=True) for i, col in enumerate(available): df_result = df_result.with_columns( pl.Series(col, reconstructed[:, i]) ) return df_result.lazy(), noise_profile