Everything this week, applied properly, measured honestly, against the 0.8238 that a week of ensembles could not beat.
The candidate features
import numpy as np
import pandas as pd
df = pd.read_csv('customers.csv', parse_dates=['signup_date'])
df = df.drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
TARGET = 'churned'
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
def preprocessor(num=NUM, cat=CAT):
return ColumnTransformer([
('num', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), num),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore'))]), cat),
])
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import (StandardScaler, OneHotEncoder,
SplineTransformer, FunctionTransformer)
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import (cross_val_score, StratifiedKFold,
train_test_split)
import numpy as np
def enrich(X):
out = X.copy()
out['rolling_and_calling'] = (
(out['contract'] == 'Month-To-Month') & (out['support_calls'] >= 2)
).astype(int)
out['is_new'] = (out['tenure_months'] <= 6).astype(int)
out['call_rate'] = (out['support_calls']
/ np.maximum(out['tenure_months'], 1) * 12)
return out
X, y = df[NUM + CAT], df[TARGET]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
sample = enrich(X_tr.head(4))
print(sample[['contract', 'support_calls', 'tenure_months',
'rolling_and_calling', 'is_new', 'call_rate']]
.round(2).to_string(index=False))
contract support_calls tenure_months rolling_and_calling is_new call_rate
Month-To-Month 0 9 0 0 0.0
Two Year 0 21 0 0 0.0
Month-To-Month 0 2 0 1 0.0
Month-To-Month 0 12 0 0 0.0
Assemble and measure
import numpy as np
import pandas as pd
df = pd.read_csv('customers.csv', parse_dates=['signup_date'])
df = df.drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
TARGET = 'churned'
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
def preprocessor(num=NUM, cat=CAT):
return ColumnTransformer([
('num', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), num),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore'))]), cat),
])
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import (StandardScaler, OneHotEncoder,
SplineTransformer, FunctionTransformer)
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import (cross_val_score, StratifiedKFold,
train_test_split)
import numpy as np
class GroupRelativeValue(BaseEstimator, TransformerMixin):
def __init__(self, group_col, value_col):
self.group_col = group_col
self.value_col = value_col
def fit(self, X, y=None):
self.medians_ = X.groupby(self.group_col)[self.value_col].median()
self.global_median_ = X[self.value_col].median()
return self
def transform(self, X):
base = X[self.group_col].map(self.medians_).fillna(self.global_median_)
return (X[self.value_col] - base).fillna(0).to_frame('rel')
def get_feature_names_out(self, input_features=None):
return np.array(['rel'])
def enrich(X):
out = X.copy()
out['rolling_and_calling'] = (
(out['contract'] == 'Month-To-Month') & (out['support_calls'] >= 2)
).astype(int)
out['is_new'] = (out['tenure_months'] <= 6).astype(int)
out['call_rate'] = (out['support_calls']
/ np.maximum(out['tenure_months'], 1) * 12)
return out
DERIVED = ['rolling_and_calling', 'is_new', 'call_rate']
prep = ColumnTransformer([
('num', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), NUM),
('derived', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), DERIVED),
('spline', Pipeline([('i', SimpleImputer(strategy='median')),
('s', SplineTransformer(n_knots=5, degree=3))]),
['tenure_months']),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore'))]), CAT),
('rel', Pipeline([('g', GroupRelativeValue('internet_service',
'monthly_charges')),
('s', StandardScaler())]),
['internet_service', 'monthly_charges']),
])
X, y = df[NUM + CAT], df[TARGET]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
cv = StratifiedKFold(5, shuffle=True, random_state=0)
full = Pipeline([
('enrich', FunctionTransformer(enrich)),
('prep', prep),
('clf', LogisticRegression(max_iter=3000, random_state=42)),
])
s = cross_val_score(full, X_tr, y_tr, cv=cv, scoring='roc_auc')
print('engineered: %.4f +/- %.4f' % (s.mean(), s.std()))
print('baseline: 0.8238')
print('gain: %+.4f (fold spread %.4f)' % (s.mean() - 0.8238, s.std()))
engineered: 0.8259 +/- 0.0252
baseline: 0.8238
gain: +0.0021 (fold spread 0.0252)
Does it help the ensembles too?
import numpy as np
import pandas as pd
df = pd.read_csv('customers.csv', parse_dates=['signup_date'])
df = df.drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
TARGET = 'churned'
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder, FunctionTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import (cross_val_score, StratifiedKFold,
train_test_split)
import numpy as np
def enrich(X):
out = X.copy()
out['rolling_and_calling'] = (
(out['contract'] == 'Month-To-Month') & (out['support_calls'] >= 2)
).astype(int)
out['is_new'] = (out['tenure_months'] <= 6).astype(int)
out['call_rate'] = (out['support_calls']
/ np.maximum(out['tenure_months'], 1) * 12)
return out
DERIVED = ['rolling_and_calling', 'is_new', 'call_rate']
X, y = df[NUM + CAT], df[TARGET]
X_tr, _, y_tr, _ = train_test_split(X, y, test_size=0.25, stratify=y,
random_state=42)
cv = StratifiedKFold(5, shuffle=True, random_state=0)
def build(num, clf):
prep = ColumnTransformer([
('num', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), num),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore'))]), CAT)])
return Pipeline([('enrich', FunctionTransformer(enrich)),
('prep', prep), ('clf', clf)])
for name, clf in [('logistic', LogisticRegression(max_iter=3000, random_state=42)),
('boosting', HistGradientBoostingClassifier(random_state=42))]:
plain = cross_val_score(build(NUM, clf), X_tr, y_tr, cv=cv,
scoring='roc_auc').mean()
rich = cross_val_score(build(NUM + DERIVED, clf), X_tr, y_tr, cv=cv,
scoring='roc_auc').mean()
print('%-9s plain %.4f engineered %.4f change %+.4f'
% (name, plain, rich, rich - plain))
logistic plain 0.8238 engineered 0.8268 change +0.0030
boosting plain 0.7966 engineered 0.7958 change -0.0009
The features help the linear model more than the trees
That is exactly what you should expect. is_new and rolling_and_calling are thresholds and interactions, and a tree can already discover those by splitting. Handing them to a linear model gives it something it genuinely could not express. Feature engineering is most valuable for the models with the least flexibility.
The honest verdict
The gains this week are small. That is worth saying plainly, because most material on feature engineering implies otherwise. On this dataset the generator used a straightforward weighted sum, so there is not much structure left for a clever feature to expose, and the honest result of measuring carefully is often that an idea did not work.
Report the failures, and be strict about what counts as a win
The engineered pipeline gains about half a point of AUC, and the fold-to-fold spread is five times that. By the standard set on day 1, keep it only if it beats the baseline by more than the spread. This week has not produced a result you could defend. Say so.
You measured roughly a dozen ideas and most changed nothing. That is a normal, healthy ratio. The discipline is not in producing features that work; it is in measuring against a fixed baseline on fixed folds so you can tell the difference, and in discarding what does not earn its place rather than shipping it because you built it.
Your assignment
The dataset generator used payment_method == 'Electronic check' with a coefficient of +0.42. Build a feature that captures an interaction between payment method and something else, and measure it. Then try a feature the generator definitely did not use, a ratio of charges to support calls, say. Record both results, including the one that fails.
Day 7 takeaway
Engineer against a fixed baseline on fixed folds, with every fitted step inside the pipeline. Justified features beat generated ones. Thresholds and interactions help linear models far more than trees, because trees can already find them. And most ideas will not work, measuring is what makes that useful information rather than a wasted fortnight.