"""Optional feature engineering: polynomial features and target encoding.""" import logging import numpy as np import pandas as pd from sklearn.preprocessing import PolynomialFeatures logger = logging.getLogger(__name__) # How many top-correlated numerical columns to use for polynomial features _TOP_N_POLY = 5 def _top_correlated_numericals( df: pd.DataFrame, numerical_cols: list[str], target: pd.Series, top_n: int ) -> list[str]: """Return the top-N numerical columns most correlated with the target. Args: df: Feature DataFrame. numerical_cols: Candidate column names. target: Target series. top_n: Number of columns to select. Returns: List of column names sorted by absolute correlation, descending. """ correlations: dict[str, float] = {} for col in numerical_cols: try: correlations[col] = abs(df[col].corr(target)) except Exception: correlations[col] = 0.0 sorted_cols = sorted(correlations, key=lambda c: correlations[c], reverse=True) return sorted_cols[:top_n] def add_polynomial_features( df: pd.DataFrame, numerical_cols: list[str], target: pd.Series, degree: int = 2, poly_cols: list[str] | None = None, ) -> tuple[pd.DataFrame, list[str]]: """Add degree-2 polynomial interaction terms for the top correlated numerical columns. Args: df: Feature DataFrame (no target column). numerical_cols: All numerical column names in df. target: Target series (used only to rank correlations). degree: Polynomial degree (default 2). poly_cols: If given, expand exactly these columns instead of ranking by correlation. Used at inference so the engineered features match training exactly (the target is not available then). Returns: Tuple of (DataFrame with new polynomial columns appended, list of columns that were expanded — pass back in as poly_cols at inference). """ if poly_cols is not None: top_cols = [c for c in poly_cols if c in df.columns] else: if not numerical_cols: return df, [] top_cols = _top_correlated_numericals(df, numerical_cols, target, _TOP_N_POLY) if not top_cols: return df, [] try: poly = PolynomialFeatures(degree=degree, include_bias=False, interaction_only=False) poly_array = poly.fit_transform(df[top_cols].fillna(0)) poly_names = [ f"poly_{name}" for name in poly.get_feature_names_out(top_cols) ] poly_df = pd.DataFrame(poly_array, columns=poly_names, index=df.index) # Drop columns that already exist in the original df new_cols = [c for c in poly_df.columns if c not in df.columns] df = pd.concat([df, poly_df[new_cols]], axis=1) logger.info("Added %d polynomial features from top-%d numerical cols.", len(new_cols), _TOP_N_POLY) except Exception as exc: logger.warning("Polynomial feature generation failed: %s", exc) return df, [] return df, top_cols def add_target_encoding( df: pd.DataFrame, categorical_cols: list[str], target: pd.Series, n_splits: int = 5, ) -> pd.DataFrame: """Add target-encoded versions of high-cardinality categorical columns. Uses K-fold cross-encoding to avoid target leakage: each fold's encoding is computed from the other folds. Args: df: Feature DataFrame (no target column). categorical_cols: Categorical column names to encode. target: Target series aligned with df. n_splits: Number of folds for cross-encoding (default 5). Returns: DataFrame with new `te_