Study interactive :: Progress tools open in the Study Hub reader.

All lessons in this module

Previous lesson. Feature Engineering · Next lesson. Complete Feature Engineering Project Tutorial

Advanced Feature Engineering Topics

Advanced feature engineering techniques, automation, and practices.

Table of Contents


Automated Feature Engineering

Featuretools

Automated feature engineering library.

try:
    import featuretools as ft
    
    # Create entity set
    es = ft.EntitySet(id='data')
    
    # Add entities
    es = es.entity_from_dataframe(
        entity_id='customers',
        dataframe=df_customers,
        index='customer_id'
    )
    
    es = es.entity_from_dataframe(
        entity_id='orders',
        dataframe=df_orders,
        index='order_id',
        time_index='order_date'
    )
    
    # Define relationships
    es = es.add_relationship(
        ft.Relationship(es['customers']['customer_id'],
                       es['orders']['customer_id'])
    )
    
    # Generate features
    feature_matrix, feature_defs = ft.dfs(
        entityset=es,
        target_entity='customers',
        max_depth=2,
        verbose=1
    )
    
    print(f"Generated {len(feature_defs)} features")
    
except ImportError:
    print("Install featuretools: pip install featuretools")

AutoFeat

Automatic feature engineering and selection.

try:
    from autofeat import AutoFeatClassifier
    
    # Automatic feature engineering
    autofeat = AutoFeatClassifier(verbose=1)
    X_new = autofeat.fit_transform(X, y)
    
    print(f"Original features: {X.shape[1]}")
    print(f"New features: {X_new.shape[1]}")
    
except ImportError:
    print("Install autofeat: pip install autofeat")

Feature Interactions and Polynomial Features

Manual Interaction Features

# Create interaction features manually
df['feature1_x_feature2'] = df['feature1'] * df['feature2']
df['feature1_div_feature2'] = df['feature1'] / (df['feature2'] + 1e-6)
df['feature1_plus_feature2'] = df['feature1'] + df['feature2']
df['feature1_minus_feature2'] = df['feature1'] - df['feature2']
df['feature1_power_feature2'] = df['feature1'] ** df['feature2']

# Ratio features
df['ratio_1_2'] = df['feature1'] / (df['feature2'] + 1e-6)
df['ratio_2_1'] = df['feature2'] / (df['feature1'] + 1e-6)

# Difference features
df['diff_1_2'] = df['feature1'] - df['feature2']
df['abs_diff_1_2'] = np.abs(df['feature1'] - df['feature2'])

Polynomial Features

from sklearn.preprocessing import PolynomialFeatures

# Full polynomial features (degree 2)
poly_full = PolynomialFeatures(degree=2, include_bias=False)
X_poly_full = poly_full.fit_transform(X)

print(f"Original: {X.shape[1]} features")
print(f"Polynomial: {X_poly_full.shape[1]} features")

# Interaction-only (no squared terms)
poly_interaction = PolynomialFeatures(
    degree=2,
    interaction_only=True,
    include_bias=False
)
X_poly_interaction = poly_interaction.fit_transform(X)

print(f"Interaction-only: {X_poly_interaction.shape[1]} features")

# Compare performance
from sklearn.linear_model import LogisticRegression

models = {
    'Original': LogisticRegression(random_state=42, max_iter=1000),
    'Polynomial': LogisticRegression(random_state=42, max_iter=1000),
    'Interaction': LogisticRegression(random_state=42, max_iter=1000)
}

results = {}
results['Original'] = cross_val_score(
    models['Original'], X, y, cv=5
).mean()

results['Polynomial'] = cross_val_score(
    models['Polynomial'], X_poly_full, y, cv=5
).mean()

results['Interaction'] = cross_val_score(
    models['Interaction'], X_poly_interaction, y, cv=5
).mean()

for name, score in results.items():
    print(f"{name:15s}: {score:.3f}")

Feature Crosses

# Create feature crosses (combinations)
# Example: Combine categorical features
df['category_region_cross'] = df['category'].astype(str) + '_' + df['region'].astype(str)

# One-hot encode the cross
df_cross_encoded = pd.get_dummies(df, columns=['category_region_cross'], prefix='cross')

Time Series Feature Engineering

Temporal Features

# Extract time components
df['year'] = df['timestamp'].dt.year
df['month'] = df['timestamp'].dt.month
df['day'] = df['timestamp'].dt.day
df['day_of_week'] = df['timestamp'].dt.dayofweek
df['day_of_year'] = df['timestamp'].dt.dayofyear
df['week'] = df['timestamp'].dt.isocalendar().week
df['quarter'] = df['timestamp'].dt.quarter
df['hour'] = df['timestamp'].dt.hour
df['minute'] = df['timestamp'].dt.minute

# Cyclical encoding (for periodic features)
df['month_sin'] = np.sin(2 * np.pi * df['month'] / 12)
df['month_cos'] = np.cos(2 * np.pi * df['month'] / 12)
df['day_of_week_sin'] = np.sin(2 * np.pi * df['day_of_week'] / 7)
df['day_of_week_cos'] = np.cos(2 * np.pi * df['day_of_week'] / 7)
df['hour_sin'] = np.sin(2 * np.pi * df['hour'] / 24)
df['hour_cos'] = np.cos(2 * np.pi * df['hour'] / 24)

# Boolean features
df['is_weekend'] = df['day_of_week'].isin([5, 6]).astype(int)
df['is_month_start'] = df['day'] <= 3
df['is_month_end'] = df['day'] >= 28
df['is_holiday'] = df['date'].isin(holidays).astype(int)

Lag Features

# Lag features (previous values)
df['value_lag_1'] = df['value'].shift(1)
df['value_lag_7'] = df['value'].shift(7)  # Weekly lag
df['value_lag_30'] = df['value'].shift(30)  # Monthly lag

# Difference features
df['value_diff_1'] = df['value'].diff(1)
df['value_diff_7'] = df['value'].diff(7)

# Percentage change
df['value_pct_change'] = df['value'].pct_change()
df['value_pct_change_7'] = df['value'].pct_change(7)

Rolling Statistics

# Rolling window statistics
window_sizes = [3, 7, 14, 30]

for window in window_sizes:
    df[f'value_rolling_mean_{window}'] = df['value'].rolling(window=window).mean()
    df[f'value_rolling_std_{window}'] = df['value'].rolling(window=window).std()
    df[f'value_rolling_min_{window}'] = df['value'].rolling(window=window).min()
    df[f'value_rolling_max_{window}'] = df['value'].rolling(window=window).max()
    df[f'value_rolling_median_{window}'] = df['value'].rolling(window=window).median()

# Expanding window statistics
df['value_expanding_mean'] = df['value'].expanding().mean()
df['value_expanding_std'] = df['value'].expanding().std()

Time-Based Aggregations

# Group by time periods
df['date'] = pd.to_datetime(df['timestamp'])

# Daily aggregations
daily_stats = df.groupby(df['date'].dt.date).agg({
    'value': ['mean', 'std', 'min', 'max', 'count']
})

# Weekly aggregations
df['week'] = df['date'].dt.to_period('W')
weekly_stats = df.groupby('week')['value'].agg(['mean', 'std', 'min', 'max'])

# Monthly aggregations
df['month'] = df['date'].dt.to_period('M')
m>'month')['value'].agg(['mean', 'std', 'min', 'max'])

# Merge back
df = df.merge(weekly_stats, left_on='week', right_index=True, suffixes=('', '_weekly'))

Text Feature Engineering

Basic Text Features

# Length features
df['text_length'] = df['text'].str.len()
df['word_count'] = df['text'].str.split().str.len()
df['sentence_count'] = df['text'].str.count(r'[.!?]+')
df['paragraph_count'] = df['text'].str.count('\n\n') + 1

# Average features
df['avg_word_length'] = df['text_length'] / (df['word_count'] + 1)
df['avg_sentence_length'] = df['word_count'] / (df['sentence_count'] + 1)

# Character features
df['uppercase_count'] = df['text'].str.findall(r'[A-Z]').str.len()
df['lowercase_count'] = df['text'].str.findall(r'[a-z]').str.len()
df['digit_count'] = df['text'].str.findall(r'[0-9]').str.len()
df['special_char_count'] = df['text'].str.findall(r'[^a-zA-Z0-9\s]').str.len()

N-gram Features

from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer

# Unigrams and bigrams
vectorizer = CountVectorizer(
    ngram_range=(1, 2),  # Unigrams and bigrams
    max_features=1000
)
X_text = vectorizer.fit_transform(df['text'])

# TF-IDF
tfidf = TfidfVectorizer(
    ngram_range=(1, 2),
    max_features=1000
)
X_tfidf = tfidf.fit_transform(df['text'])

Text Statistics

# Readability features
df['avg_syllables_per_word'] = df['text'].apply(count_syllables) / df['word_count']

# Sentiment features (if using textblob)
try:
    from textblob import TextBlob
    
    df['sentiment_polarity'] = df['text'].apply(lambda x: TextBlob(x).sentiment.polarity)
    df['sentiment_subjectivity'] = df['text'].apply(lambda x: TextBlob(x).sentiment.subjectivity)
except ImportError:
    print("Install textblob: pip install textblob")

Comprehensive sklearn Pipeline and ColumnTransformer Guide

Introduction to Pipelines

Pipelines automate the ML workflow and prevent data leakage by ensuring transformations are applied consistently.

Benefits:

Basic Pipeline

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

# Simple pipeline
pipeline = Pipeline([
    ('scaler', StandardScaler()),
    ('classifier', LogisticRegression(random_state=42))
])

# Fit and predict
pipeline.fit(X_train, y_train)
predicti>
score = pipeline.score(X_test, y_test)

ColumnTransformer Basics

ColumnTransformer applies different transformations to different columns.

from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder

# Define column types
numeric_cols = ['age', 'income', 'score']
categorical_cols = ['city', 'category']

# Create ColumnTransformer
preprocessor = ColumnTransformer(
    transformers=[
        ('num', StandardScaler(), numeric_cols),
        ('cat', OneHotEncoder(drop='first', sparse=False), categorical_cols)
    ],
    remainder='drop'  # What to do with remaining columns
)

# Use in pipeline
pipeline = Pipeline([
    ('preprocessor', preprocessor),
    ('classifier', LogisticRegression(random_state=42))
])

Advanced ColumnTransformer

Multiple Transformations per Column Type:

from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, RobustScaler, OneHotEncoder
from sklearn.impute import SimpleImputer

# Complex preprocessing
preprocessor = ColumnTransformer(
    transformers=[
        # Numeric: impute then scale
        ('numeric', Pipeline([
            ('imputer', SimpleImputer(strategy='median')),
            ('scaler', StandardScaler())
        ]), ['age', 'income']),
        
        # Skewed numeric: impute, transform, scale
        ('skewed', Pipeline([
            ('imputer', SimpleImputer(strategy='median')),
            ('transformer', PowerTransformer(method='yeo-johnson')),
            ('scaler', RobustScaler())
        ]), ['price', 'revenue']),
        
        # Categorical: impute then encode
        ('categorical', Pipeline([
            ('imputer', SimpleImputer(strategy='most_frequent')),
            ('encoder', OneHotEncoder(drop='first', sparse=False, handle_unknown='ignore'))
        ]), ['city', 'category']),
        
        # Binary: just encode
        ('binary', OneHotEncoder(drop='if_binary', sparse=False), ['is_active'])
    ],
    remainder='passthrough'  # Keep other columns
)

Custom Transformers

Creating Custom Transformers:

from sklearn.base import BaseEstimator, TransformerMixin
import numpy as np
import pandas as pd

class LogTransformer(BaseEstimator, TransformerMixin):
    """Log transformation with error handling"""
    
    def fit(self, X, y=None):
        return self
    
    def transform(self, X):
        return np.log1p(np.maximum(X, 0))  # Handle negative values
    
    def fit_transform(self, X, y=None):
        return self.fit(X, y).transform(X)

class FeatureInteraction(BaseEstimator, TransformerMixin):
    """Create interaction features"""
    
    def __init__(self, interactions):
        self.interacti  # List of (i, j) tuples
    
    def fit(self, X, y=None):
        return self
    
    def transform(self, X):
        X_new = X.copy()
        for i, j in self.interactions:
            X_new = np.hstack([X_new, (X[:, i] * X[:, j]).reshape(-1, 1)])
        return X_new

class TargetEncoder(BaseEstimator, TransformerMixin):
    """Target encoding with cross-validation"""
    
    def __init__(self, cv=5):
        self.cv = cv
        self.encodings = {}
    
    def fit(self, X, y):
        from sklearn.model_selection import KFold
        kf = KFold(n_splits=self.cv, shuffle=True, random_state=42)
        
        # Calculate encodings for each fold
        for train_idx, val_idx in kf.split(X):
            X_train_fold, y_train_fold = X[train_idx], y[train_idx]
            for category in np.unique(X_train_fold):
                mask = X_train_fold == category
                if category not in self.encodings:
                    self.encodings[category] = []
                self.encodings[category].append(y_train_fold[mask].mean())
        
        # Average across folds
        for cat in self.encodings:
            self.encodings[cat] = np.mean(self.encodings[cat])
        
        return self
    
    def transform(self, X):
        return np.array([self.encodings.get(cat, 0) for cat in X])

Complex Pipeline Example

from sklearn.pipeline import Pipeline, FeatureUnion
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, PolynomialFeatures
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.impute import SimpleImputer

# Step 1: Preprocessing
preprocessing = ColumnTransformer(
    transformers=[
        ('numeric', Pipeline([
            ('imputer', SimpleImputer(strategy='median')),
            ('scaler', StandardScaler())
        ]), numeric_features),
        
        ('categorical', Pipeline([
            ('imputer', SimpleImputer(strategy='most_frequent')),
            ('encoder', OneHotEncoder(drop='first', sparse=False))
        ]), categorical_features)
    ]
)

# Step 2: Feature Engineering
feature_engineering = FeatureUnion([
    ('polynomial', PolynomialFeatures(degree=2, include_bias=False)),
    ('interactions', FeatureInteraction([(0, 1), (0, 2)]))
])

# Step 3: Feature Selection
feature_selection = SelectKBest(score_func=f_classif, k=20)

# Step 4: Model
model = RandomForestClassifier(n_estimators=100, random_state=42)

# Complete pipeline
full_pipeline = Pipeline([
    ('preprocessing', preprocessing),
    ('feature_engineering', feature_engineering),
    ('feature_selection', feature_selection),
    ('model', model)
])

# Fit and evaluate
full_pipeline.fit(X_train, y_train)
score = full_pipeline.score(X_test, y_test)

Pipeline Best Practices

1. Always Use Pipelines:

# Bad: Manual transformations
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)  # Easy to forget!
model.fit(X_train_scaled, y_train)

# Good: Pipeline
pipeline = Pipeline([('scaler', StandardScaler()), ('model', model)])
pipeline.fit(X_train, y_train)  # Automatically handles train/test

2. Get Feature Names:

# After fitting, get feature names
feature_names = pipeline.named_steps['preprocessor'].get_feature_names_out()
print(f"Feature names: {feature_names}")

3. Access Intermediate Steps:

# Access fitted transformers
scaler = pipeline.named_steps['scaler']
print(f"Mean: {scaler.mean_}")
print(f"Scale: {scaler.scale_}")

4. Grid Search with Pipelines:

from sklearn.model_selection import GridSearchCV

# Parameter grid
param_grid = {
    'preprocessor__num__scaler': [StandardScaler(), RobustScaler()],
    'model__n_estimators': [50, 100, 200],
    'model__max_depth': [5, 10, 15]
}

# Grid search
grid_search = GridSearchCV(
    pipeline,
    param_grid,
    cv=5,
    scoring='accuracy',
    n_jobs=-1
)

grid_search.fit(X_train, y_train)
print(f"Best score: {grid_search.best_score_:.3f}")
print(f"Best params: {grid_search.best_params_}")

Common Pipeline Patterns

Pattern 1: Simple Preprocessing + Model

pipeline = Pipeline([
    ('scaler', StandardScaler()),
    ('model', LogisticRegression())
])

Pattern 2: Mixed Data Types

preprocessor = ColumnTransformer(
    transformers=[
        ('num', StandardScaler(), numeric_cols),
        ('cat', OneHotEncoder(), categorical_cols)
    ]
)
pipeline = Pipeline([
    ('preprocessor', preprocessor),
    ('model', RandomForestClassifier())
])

Pattern 3: Feature Engineering + Selection + Model

pipeline = Pipeline([
    ('preprocessor', preprocessor),
    ('poly', PolynomialFeatures(degree=2)),
    ('selector', SelectKBest(k=10)),
    ('model', LogisticRegression())
])

Debugging Pipelines

# Check pipeline steps
print("Pipeline steps:", pipeline.named_steps.keys())

# Check intermediate outputs
X_transformed = pipeline.named_steps['preprocessor'].transform(X_train)
print(f"Transformed shape: {X_transformed.shape}")

# Visualize pipeline
from sklearn.utils import estimator_html_repr
print(estimator_html_repr(pipeline))

Feature Engineering Pipelines

Complete Pipeline

from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import (StandardScaler, OneHotEncoder,
                                  PowerTransformer, FunctionTransformer)
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.impute import SimpleImputer

# Define feature types
numeric_features = ['feature1', 'feature2', 'feature3']
categorical_features = ['category', 'region']
skewed_features = ['price', 'income']

# Create preprocessing pipeline
preprocessor = ColumnTransformer(
    transformers=[
        ('numeric', Pipeline([
            ('imputer', SimpleImputer(strategy='median')),
            ('scaler', StandardScaler())
        ]), numeric_features),
        
        ('skewed', Pipeline([
            ('imputer', SimpleImputer(strategy='median')),
            ('transformer', PowerTransformer(method='yeo-johnson')),
            ('scaler', StandardScaler())
        ]), skewed_features),
        
        ('categorical', Pipeline([
            ('imputer', SimpleImputer(strategy='most_frequent')),
            ('encoder', OneHotEncoder(drop='first', sparse=False, handle_unknown='ignore'))
        ]), categorical_features)
    ],
    remainder='drop'
)

# Feature selection
selector = SelectKBest(score_func=f_classif, k=10)

# Complete pipeline
pipeline = Pipeline([
    ('preprocessor', preprocessor),
    ('feature_selection', selector),
    ('model', LogisticRegression(random_state=42, max_iter=1000))
])

# Fit and evaluate
pipeline.fit(X_train, y_train)
score = pipeline.score(X_test, y_test)
print(f"Pipeline accuracy: {score:.3f}")

# Get feature names
feature_names_out = pipeline.named_steps['preprocessor'].get_feature_names_out()
selected_features = feature_names_out[pipeline.named_steps['feature_selection'].get_support()]
print(f"Selected features: {selected_features}")

Custom Transformers

from sklearn.base import BaseEstimator, TransformerMixin

class LogTransformer(BaseEstimator, TransformerMixin):
    """Custom log transformer"""
    
    def fit(self, X, y=None):
        return self
    
    def transform(self, X):
        return np.log1p(X)

class InteractionTransformer(BaseEstimator, TransformerMixin):
    """Create interaction features"""
    
    def __init__(self, feature_indices):
        self.feature_indices = feature_indices
    
    def fit(self, X, y=None):
        return self
    
    def transform(self, X):
        interacti>
        for i, j in self.feature_indices:
            interactions.append((X[:, i] * X[:, j]).reshape(-1, 1))
        return np.hstack([X] + interactions)

# Use in pipeline
interacti>
    ('scaler', StandardScaler()),
    ('interactions', InteractionTransformer([(0, 1), (0, 2)])),
    ('model', LogisticRegression(random_state=42))
])

sklearn Deep Dive: Estimators, Mixins, and Composite Transformers

Understanding sklearn Architecture

sklearn uses a consistent interface for all estimators and transformers, built on base classes and mixins.

Base Classes

1. BaseEstimator: Base class for all sklearn objects. Provides get_params() and set_params().

from sklearn.base import BaseEstimator

class CustomEstimator(BaseEstimator):
    def __init__(self, param1=1, param2=2):
        self.param1 = param1
        self.param2 = param2
    
    def get_params(self, deep=True):
        """Get parameters for this estimator"""
        return {'param1': self.param1, 'param2': self.param2}
    
    def set_params(self, **params):
        """Set parameters for this estimator"""
        for key, value in params.items():
            setattr(self, key, value)
        return self

# Usage
estimator = CustomEstimator(param1=10, param2=20)
print(estimator.get_params())  # {'param1': 10, 'param2': 20}
estimator.set_params(param1=100)
print(estimator.param1)  # 100

2. TransformerMixin: Mixin for transformers. Provides fit_transform() method.

from sklearn.base import BaseEstimator, TransformerMixin

class CustomTransformer(BaseEstimator, TransformerMixin):
    def __init__(self, multiplier=1.0):
        self.multiplier = multiplier
    
    def fit(self, X, y=None):
        """Fit transformer (learn parameters)"""
        # Store any necessary statistics
        self.mean_ = X.mean(axis=0)
        return self
    
    def transform(self, X):
        """Transform data"""
        return (X - self.mean_) * self.multiplier
    
    # fit_transform() is automatically provided by TransformerMixin
    # It calls fit() then transform()

# Usage
transformer = CustomTransformer(multiplier=2.0)
X_transformed = transformer.fit_transform(X)

3. ClassifierMixin: Mixin for classifiers. Provides score() method for classification.

from sklearn.base import BaseEstimator, ClassifierMixin

class CustomClassifier(BaseEstimator, ClassifierMixin):
    def __init__(self, threshold=0.5):
        self.threshold = threshold
    
    def fit(self, X, y):
        """Train classifier"""
        # Store training data (simple example)
        self.X_train_ = X
        self.y_train_ = y
        return self
    
    def predict(self, X):
        """Make predictions"""
        # Simple nearest neighbor example
        from sklearn.metrics.pairwise import euclidean_distances
        distances = euclidean_distances(X, self.X_train_)
        nearest_indices = distances.argmin(axis=1)
        return self.y_train_[nearest_indices]
    
    def predict_proba(self, X):
        """Predict probabilities"""
        predicti>self.predict(X)
        # Convert to probabilities (simplified)
        proba = np.zeros((len(X), len(np.unique(self.y_train_))))
        for i, pred in enumerate(predictions):
            proba[i, pred] = 1.0
        return proba
    
    # score() is automatically provided by ClassifierMixin
    # It uses accuracy_score by default

# Usage
classifier = CustomClassifier(threshold=0.5)
classifier.fit(X_train, y_train)
score = classifier.score(X_test, y_test)  # Accuracy

4. RegressorMixin: Mixin for regressors. Provides score() method for regression (R²).

from sklearn.base import BaseEstimator, RegressorMixin

class CustomRegressor(BaseEstimator, RegressorMixin):
    def __init__(self, learning_rate=0.1):
        self.learning_rate = learning_rate
    
    def fit(self, X, y):
        """Train regressor"""
        # Simple linear regression example
        from sklearn.linear_model import LinearRegression
        self.model_ = LinearRegression()
        self.model_.fit(X, y)
        return self
    
    def predict(self, X):
        """Make predictions"""
        return self.model_.predict(X)
    
    # score() is automatically provided by RegressorMixin
    # It uses r2_score by default

# Usage
regressor = CustomRegressor(learning_rate=0.1)
regressor.fit(X_train, y_train)
score = regressor.score(X_test, y_test)  # R² score

Complete Custom Estimator Example

Custom Classifier with Full sklearn Interface:

from sklearn.base import BaseEstimator, ClassifierMixin
from sklearn.utils.validation import check_X_y, check_array
from sklearn.utils.multiclass import unique_labels
import numpy as np

class SimpleKNNClassifier(BaseEstimator, ClassifierMixin):
    """
    Simple k-Nearest Neighbors Classifier
    Implements full sklearn interface
    """
    
    def __init__(self, n_neighbors=5, metric='euclidean'):
        self.n_neighbors = n_neighbors
        self.metric = metric
    
    def fit(self, X, y):
        """
        Fit the model
        
        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Training data
        y : array-like of shape (n_samples,)
            Target values
        
        Returns
        -------
        self : object
            Returns self
        """
        # Validate inputs
        X, y = check_X_y(X, y)
        
        # Store training data
        self.X_train_ = X
        self.y_train_ = y
        
        # Store classes
        self.classes_ = unique_labels(y)
        self.n_classes_ = len(self.classes_)
        
        return self
    
    def predict(self, X):
        """
        Predict class labels
        
        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Test data
        
        Returns
        -------
        y_pred : array of shape (n_samples,)
            Predicted class labels
        """
        # Validate that fit has been called
        check_is_fitted(self)
        
        # Validate input
        X = check_array(X)
        
        # Compute distances
        from sklearn.metrics.pairwise import euclidean_distances
        distances = euclidean_distances(X, self.X_train_)
        
        # Find k nearest neighbors
        nearest_indices = distances.argsort(axis=1)[:, :self.n_neighbors]
        nearest_labels = self.y_train_[nearest_indices]
        
        # Majority vote
        predicti>
        for labels in nearest_labels:
            unique, counts = np.unique(labels, return_counts=True)
            predictions.append(unique[np.argmax(counts)])
        
        return np.array(predictions)
    
    def predict_proba(self, X):
        """
        Predict class probabilities
        
        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Test data
        
        Returns
        -------
        proba : array of shape (n_samples, n_classes_)
            Class probabilities
        """
        check_is_fitted(self)
        X = check_array(X)
        
        from sklearn.metrics.pairwise import euclidean_distances
        distances = euclidean_distances(X, self.X_train_)
        nearest_indices = distances.argsort(axis=1)[:, :self.n_neighbors]
        nearest_labels = self.y_train_[nearest_indices]
        
        # Calculate probabilities
        proba = np.zeros((len(X), self.n_classes_))
        for i, labels in enumerate(nearest_labels):
            unique, counts = np.unique(labels, return_counts=True)
            for j, label in enumerate(self.classes_):
                if label in unique:
                    idx = np.where(unique == label)[0][0]
                    proba[i, j] = counts[idx] / self.n_neighbors
        
        return proba

# Usage
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split

X, y = make_classification(n_samples=1000, n_features=4, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

knn = SimpleKNNClassifier(n_neighbors=5)
knn.fit(X_train, y_train)
predicti>
probabilities = knn.predict_proba(X_test)
score = knn.score(X_test, y_test)

print(f"Accuracy: {score:.3f}")

Composite Transformers

1. FeatureUnion: Combine multiple transformers in parallel (all applied, results concatenated).

from sklearn.pipeline import FeatureUnion
from sklearn.decomposition import PCA
from sklearn.decomposition import TruncatedSVD
from sklearn.preprocessing import PolynomialFeatures

# Create FeatureUnion
feature_union = FeatureUnion([
    ('pca', PCA(n_components=5)),
    ('svd', TruncatedSVD(n_components=3)),
    ('poly', PolynomialFeatures(degree=2, include_bias=False))
])

# Fit and transform
X_combined = feature_union.fit_transform(X)

print(f"Original shape: {X.shape}")
print(f"Combined shape: {X_combined.shape}")  # (n_samples, 5+3+n_poly_features)

# Get feature names
feature_names = feature_union.get_feature_names_out()
print(f"Feature names: {feature_names}")

2. Custom Composite Transformer:

from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.pipeline import FeatureUnion

class CustomFeatureUnion(BaseEstimator, TransformerMixin):
    """Custom composite transformer"""
    
    def __init__(self, transformers):
        self.transformers = transformers
    
    def fit(self, X, y=None):
        """Fit all transformers"""
        for name, transformer in self.transformers:
            transformer.fit(X, y)
        return self
    
    def transform(self, X):
        """Transform with all transformers and concatenate"""
        results = []
        for name, transformer in self.transformers:
            transformed = transformer.transform(X)
            results.append(transformed)
        return np.hstack(results)
    
    def get_feature_names_out(self, input_features=None):
        """Get feature names"""
        feature_names = []
        for name, transformer in self.transformers:
            if hasattr(transformer, 'get_feature_names_out'):
                names = transformer.get_feature_names_out(input_features)
                feature_names.extend([f"{name}__{n}" for n in names])
            else:
                n_features = transformer.n_features_out_ if hasattr(transformer, 'n_features_out_') else X.shape[1]
                feature_names.extend([f"{name}__feature_{i}" for i in range(n_features)])
        return np.array(feature_names)

# Usage
custom_union = CustomFeatureUnion([
    ('scaler', StandardScaler()),
    ('pca', PCA(n_components=2))
])
X_transformed = custom_union.fit_transform(X)

3. Advanced FeatureUnion with Conditional Logic:

class ConditionalFeatureUnion(BaseEstimator, TransformerMixin):
    """FeatureUnion with conditional transformers"""
    
    def __init__(self, transformers, selector=None):
        self.transformers = transformers
        self.selector = selector  # Function to select which transformers to use
    
    def fit(self, X, y=None):
        """Fit selected transformers"""
        selected = self._select_transformers(X, y)
        for name, transformer in selected:
            transformer.fit(X, y)
        return self
    
    def transform(self, X):
        """Transform with selected transformers"""
        selected = self._select_transformers(X, y=None)
        results = []
        for name, transformer in selected:
            results.append(transformer.transform(X))
        return np.hstack(results) if results else X
    
    def _select_transformers(self, X, y):
        """Select transformers based on selector function"""
        if self.selector is None:
            return self.transformers
        return [t for t in self.transformers if self.selector(t[0], X, y)]

# Usage with selector
def select_numeric_only(name, X, y):
    """Only use numeric transformers"""
    return name in ['scaler', 'pca']

c>
    transformers=[
        ('scaler', StandardScaler()),
        ('pca', PCA(n_components=2)),
        ('text_tfidf', TfidfVectorizer())  # Would be skipped
    ],
    selector=select_numeric_only
)

Best Practices for Custom Estimators

1. Input Validation:

from sklearn.utils.validation import check_X_y, check_array, check_is_fitted

class ValidatedEstimator(BaseEstimator, ClassifierMixin):
    def fit(self, X, y):
        # Validate inputs
        X, y = check_X_y(X, y)
        # Store fitted state
        self.is_fitted_ = True
        return self
    
    def predict(self, X):
        # Check if fitted
        check_is_fitted(self, 'is_fitted_')
        # Validate input
        X = check_array(X)
        # Make predictions
        return np.zeros(len(X))

2. Feature Names Support:

class NamedFeatureTransformer(BaseEstimator, TransformerMixin):
    def fit(self, X, y=None):
        if hasattr(X, 'columns'):
            self.feature_names_in_ = X.columns.tolist()
        return self
    
    def transform(self, X):
        return X
    
    def get_feature_names_out(self, input_features=None):
        if input_features is None:
            input_features = self.feature_names_in_
        return np.array([f"transformed_{name}" for name in input_features])

3. Metadata Routing:

from sklearn.utils.metadata_routing import get_routing_for_object

class MetadataAwareEstimator(BaseEstimator):
    def fit(self, X, y, sample_weight=None):
        # Handle sample_weight if provided
        if sample_weight is not None:
            # Use sample weights in training
            pass
        return self

Key Takeaways

  1. BaseEstimator: Provides parameter management
  2. Mixins: Add functionality (TransformerMixin, ClassifierMixin, RegressorMixin)
  3. FeatureUnion: Combine transformers in parallel
  4. Validation: Always use sklearn validation utilities
  5. Interface: Follow sklearn conventions for compatibility

Feature Importance and Selection

Multiple Selection Methods

from sklearn.feature_selection import (SelectKBest, RFE, RFECV,
                                      SelectFromModel, mutual_info_classif)
from sklearn.ensemble import RandomForestClassifier

# Method 1: Univariate selection
selector_kbest = SelectKBest(score_func=f_classif, k=10)
X_kbest = selector_kbest.fit_transform(X, y)

# Method 2: Mutual information
selector_mi = SelectKBest(score_func=mutual_info_classif, k=10)
X_mi = selector_mi.fit_transform(X, y)

# Method 3: RFE
selector_rfe = RFE(
    estimator=LogisticRegression(random_state=42, max_iter=1000),
    n_features_to_select=10
)
X_rfe = selector_rfe.fit_transform(X, y)

# Method 4: Model-based selection
rf = RandomForestClassifier(n_estimators=100, random_state=42)
rf.fit(X, y)
selector_model = SelectFromModel(rf, threshold='median')
X_model = selector_model.fit_transform(X, y)

# Compare methods
methods = {
    'KBest (F-test)': (X_kbest, selector_kbest),
    'Mutual Information': (X_mi, selector_mi),
    'RFE': (X_rfe, selector_rfe),
    'Model-based': (X_model, selector_model)
}

for name, (X_selected, selector) in methods.items():
    model = LogisticRegression(random_state=42, max_iter=1000)
    scores = cross_val_score(model, X_selected, y, cv=5)
    print(f"{name:25s}: {scores.mean():.3f} (+/- {scores.std():.3f})")

Feature Importance Analysis

# Multiple importance methods
importance_methods = {}

# Random Forest importance
rf = RandomForestClassifier(n_estimators=100, random_state=42)
rf.fit(X, y)
importance_methods['Random Forest'] = rf.feature_importances_

# XGBoost importance
try:
    import xgboost as xgb
    xgb_model = xgb.XGBClassifier(random_state=42)
    xgb_model.fit(X, y)
    importance_methods['XGBoost'] = xgb_model.feature_importances_
except ImportError:
    pass

# Permutation importance
from sklearn.inspection import permutation_importance
perm_importance = permutation_importance(rf, X_test, y_test, n_repeats=10, random_state=42)
importance_methods['Permutation'] = perm_importance.importances_mean

# Compare
importance_df = pd.DataFrame(importance_methods, index=feature_names)
importance_df = importance_df.sort_values('Random Forest', ascending=False)

print("Feature Importance Comparison:")
print(importance_df.head(10))

Common Feature Engineering Pitfalls

Pitfall 1: Data Leakage

Problem: Using future information or target information in features

Solution:

# WRONG: Using target to create features
df['target_mean_by_category'] = df.groupby('category')['target'].transform('mean')

# CORRECT: Use cross-validation for target encoding
def target_encode_cv(df, cat_col, target_col, cv=5):
    """Target encoding with cross-validation"""
    df_encoded = df.copy()
    kf = KFold(n_splits=cv, shuffle=True, random_state=42)
    
    for train_idx, val_idx in kf.split(df):
        train_mean = df.iloc[train_idx].groupby(cat_col)[target_col].mean()
        df_encoded.loc[val_idx, f'{cat_col}_target_encoded'] = \
            df.loc[val_idx, cat_col].map(train_mean)
    
    global_mean = df[target_col].mean()
    df_encoded[f'{cat_col}_target_encoded'].fillna(global_mean, inplace=True)
    return df_encoded

Pitfall 2: Scaling Before Split

Problem: Fitting scaler on entire dataset including test set

Solution:

# WRONG: Scale before split
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)  # Uses test data!
X_train, X_test = train_test_split(X_scaled, y)

# CORRECT: Scale after split
X_train, X_test, y_train, y_test = train_test_split(X, y)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)  # Only transform!

Pitfall 3: Too Many Features

Problem: Creating too many features causes overfitting

Solution:

# Use feature selection
selector = SelectKBest(score_func=f_classif, k=20)  # Limit features
X_selected = selector.fit_transform(X, y)

# Or use regularization
model = LogisticRegression(C=0.1, random_state=42)  # Lower C = more regularization

Pitfall 4: Ignoring Feature Interactions

Problem: Missing important feature relationships

Solution:

# Create interaction features
poly = PolynomialFeatures(degree=2, interaction_only=True)
X_interaction = poly.fit_transform(X)

# Or manually create important interactions
df['important_interaction'] = df['feature1'] * df['feature2']

Key Takeaways

  1. Automation: Use tools like Featuretools for automated feature engineering
  2. Interactions: Create interaction and polynomial features
  3. Time Series: Extract temporal features, lags, and rolling statistics
  4. Text Features: Extract length, n-grams, and sentiment features
  5. Pipelines: Use pipelines to automate and prevent data leakage
  6. Feature Selection: Use multiple methods to find best features
  7. Avoid Pitfalls: Watch for data leakage, scaling issues, and overfitting

Next Steps

Try next: Invent two features from domain knowledge and measure lift against a baseline.

All lessons in this module

Previous lesson. Feature Engineering · Next lesson. Complete Feature Engineering Project Tutorial