Feature Engineering, Imbalanced Data & Unsupervised Learning
Features still beat models on tabular problems. Learn the engineering patterns that win, the honest way to handle imbalance, and how to evaluate unsupervised models with no labels.
Covers: Feature engineering patterns, missing values, class imbalance, clustering, anomaly detection, dimensionality reduction
There is a reason experienced practitioners say 'better features beat better models'. On tabular problems, a well-constructed aggregate feature routinely delivers more lift than switching model families or a week of hyperparameter search — and unlike a bigger model, it costs nothing at inference time.
30-second answer
Start from the mechanism of churn, then build features across five families: recency/frequency/monetary aggregates over multiple windows, trend and ratio features that compare recent to historical behaviour, engagement-breadth features, support and friction signals, and time-derived features. Every aggregate must be computed with a point-in-time cutoff so nothing after the prediction timestamp leaks in.
Five families that cover most of the value
| Family | Examples | Why it works |
|---|---|---|
| RFM over multiple windows | days_since_last_txn, txn_count_7/30/90d, spend_30/90/365d | The classic backbone; multiple windows let the model find the right timescale itself |
| Trend & ratio | spend_30d / spend_90d_avg, txn_count_7d / txn_count_28d, slope of weekly spend | Change predicts churn far better than level — a heavy user going quiet is the signal |
| Breadth & depth | distinct_categories_90d, distinct_devices, feature_adoption_count | Multi-product users churn far less; breadth is a strong retention proxy |
| Friction & support | tickets_30d, failed_payments, avg_resolution_hours, app_crashes | Directly encodes the causes of churn rather than its symptoms |
| Temporal & lifecycle | tenure_days, days_since_signup, is_within_trial, day_of_week_entropy | Churn hazard is strongly non-monotonic in tenure |
import pandas as pd, numpy as np
def build_features(txns: pd.DataFrame, cutoff: pd.Timestamp) -> pd.DataFrame:
"""Every feature uses ONLY data strictly before `cutoff`."""
hist = txns[txns["ts"] < cutoff] # <-- the whole ballgame
out = {}
for days in (7, 30, 90, 365):
w = hist[hist["ts"] >= cutoff - pd.Timedelta(days=days)]
g = w.groupby("user_id")["amount"]
out[f"txn_count_{days}d"] = g.size()
out[f"spend_{days}d"] = g.sum()
out[f"avg_amount_{days}d"]= g.mean()
out[f"distinct_cat_{days}d"] = (
w.groupby("user_id")["category"].nunique())
f = pd.DataFrame(out).fillna(0)
# Recency
f["days_since_last"] = (
cutoff - hist.groupby("user_id")["ts"].max()).dt.days.fillna(9999)
# TREND features: recent vs. baseline. Usually the strongest predictors.
f["spend_trend_30_90"] = f["spend_30d"] / (f["spend_90d"] / 3 + 1e-6)
f["freq_trend_7_28"] = f["txn_count_7d"] / (f["txn_count_30d"] / 4 + 1e-6)
f["breadth_decay"] = f["distinct_cat_30d"] / (f["distinct_cat_365d"] + 1e-6)
# Lifecycle
first = hist.groupby("user_id")["ts"].min()
f["tenure_days"] = (cutoff - first).dt.days.fillna(0)
f["txn_per_tenure_day"] = f["txn_count_365d"] / (f["tenure_days"] + 1)
return f
# Build a TRAINING set by rolling the cutoff -- never one global aggregate.
frames = []
for cutoff in pd.date_range("2025-01-01", "2025-12-01", freq="MS"):
X = build_features(txns, cutoff)
X["label"] = churn_label(users, cutoff, horizon_days=60) # forward window
X["cutoff"] = cutoff
frames.append(X)
train = pd.concat(frames)Interactions and encodings worth adding
- Ratios beat differences for tree models, because a tree can already express a difference with two splits but needs many to approximate a ratio.
- Cyclical encodings for time:
sin(2π·hour/24),cos(2π·hour/24)so 23:00 and 01:00 are close, instead of maximally far apart. - Entropy features capture regularity: the entropy of a user's category distribution or day-of-week distribution distinguishes habitual from erratic users.
- Relative-to-cohort features:
user_spend / cohort_median_spendnormalises away seasonality and macro effects that would otherwise be learned as spurious level shifts. - Lag and delta features for anything time-series-like —
value_t − value_{t−1}andvalue_t / rolling_mean_28.
Say these points
- Reason from the mechanism of churn before listing transformations.
- Five families: RFM windows, trend/ratio, breadth, friction, lifecycle.
- Trend features (recent vs baseline) usually beat level features.
- Use a rolling-cutoff design: past-only features, forward-window labels.
- Every feature is a production dependency with a freshness SLA.
Avoid these mistakes
- Computing aggregates over the full history and then splitting randomly.
- Adding hundreds of features without checking serving-time availability.
- Encoding hour-of-day as a plain integer for a linear model.
Expect these follow-ups
- How do you keep training and serving feature computation identical?
- Which features would you drop first if inference latency was too high?
- How would you handle a user with only three transactions?
30-second answer
First classify the mechanism: MCAR, MAR or MNAR. For MCAR/MAR, simple imputation plus a missingness indicator is usually enough; for MNAR the missingness itself carries signal and must become a feature. Imputation is harmful when it destroys that signal, when it is fitted outside the CV fold, or when serving-time missingness patterns differ from training. Tree ensembles can often handle NaN natively — prefer that over guessing.
| Mechanism | Meaning | Example | Handling |
|---|---|---|---|
| MCAR | Missing completely at random | A sensor dropped packets randomly | Any reasonable imputation; dropping rows is unbiased but wasteful |
| MAR | Missingness depends on observed variables | Income missing more often for younger users | Model-based imputation conditioned on the observed features |
| MNAR | Missingness depends on the unobserved value itself | High earners decline to state income; sick patients skip follow-ups | Add a missingness indicator — the pattern is the signal. Imputing destroys it. |
import numpy as np, pandas as pd
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import cross_val_score
# 1. Always test whether missingness itself predicts the target
for col in df.columns[df.isna().any()]:
miss = df[col].isna()
rate_missing = df.loc[miss, "target"].mean()
rate_present = df.loc[~miss, "target"].mean()
lift = rate_missing / max(rate_present, 1e-9)
print(f"{col:24s} {miss.mean():5.1%} missing | "
f"target when missing={rate_missing:.3f} vs present={rate_present:.3f} "
f"| lift={lift:.2f}")
# income_verified 31.2% missing | target when missing=0.183 vs present=0.041 | lift=4.46
# ^^^^ MNAR: 4.5x lift. Keep the indicator!
# 2. add_indicator=True keeps the signal AND fills the value
numeric = Pipeline([("impute", SimpleImputer(strategy="median", add_indicator=True))])
pipe = Pipeline([
("prep", ColumnTransformer([("num", numeric, num_cols)], remainder="passthrough")),
("model", HistGradientBoostingClassifier()),
])
# 3. Or skip imputation entirely -- HistGB/LightGBM/XGBoost learn a default
# direction for NaN at every split, which is strictly more expressive.
native = HistGradientBoostingClassifier() # handles NaN natively
print("median impute + indicator:", cross_val_score(pipe, X, y, cv=5).mean())
print("native NaN handling :", cross_val_score(native, X, y, cv=5).mean())
# median impute + indicator: 0.8412
# native NaN handling : 0.8467When imputation actively hurts
It erases MNAR signal
The most common and most expensive case. Always add the indicator before deciding imputation is harmless.It is fitted outside the CV fold
A median computed on the full dataset leaks validation statistics into training. Imputers belong insidePipeline, always.It manufactures impossible rows
Mean-imputingn_childrengives 2.3 children; mean-imputing a bimodal distribution lands you in the empty valley between the modes. The model then learns from data points that cannot exist.Serving-time missingness differs from training
If a feature is 5% missing in training but 60% missing at serving (an upstream service is down), your imputed constant now dominates the input distribution. Monitor missingness rates in production as a first-class metric.It shrinks variance and biases inference
Single imputation understates uncertainty. If you need valid standard errors, use multiple imputation (MICE) and pool across imputations — not a single fill.
Say these points
- Classify MCAR / MAR / MNAR before choosing a strategy.
- MNAR: missingness is signal — always add an indicator.
- Fit imputers inside the CV fold; they are model parameters.
- Tree ensembles handle NaN natively and often beat imputation.
- Monitor serving-time missingness rates; upstream outages change the distribution.
Avoid these mistakes
- fillna(0) applied blindly across all numeric columns.
- Imputing before the train/test split.
- Dropping rows with any missing value and silently losing 40% of the data.
Expect these follow-ups
- How does MICE work and when is the extra complexity justified?
- How do XGBoost and LightGBM decide the default direction for NaN at a split?
- A feature is 90% missing. Keep it or drop it?
30-second answer
In order: (1) change the metric and threshold rather than the data — this alone fixes most cases; (2) use class weights or scale_pos_weight, which is equivalent to resampling but without the calibration damage; (3) undersample the majority if training cost is the problem; (4) SMOTE and its variants last, because synthetic points in high dimensions are often unrealistic. Whatever you do, calibrate afterwards and evaluate at the true prevalence.
| Approach | Effect | Cost | Rank |
|---|---|---|---|
| Right metric + tuned threshold | Fixes the evaluation and the decision, model untouched | None. Keeps calibration. | 1 — always do this |
| Class weights / scale_pos_weight | Reweights the loss; equivalent to resampling in expectation | Distorts probabilities — recalibrate | 2 |
| Random undersampling of the majority | Faster training, same ranking quality in most cases | Discards data; needs prior correction | 3 — good when training cost dominates |
| Two-stage funnel | Cheap high-recall filter, then an expensive precise model | More system complexity | 3 — excellent when review capacity is fixed |
| SMOTE / ADASYN / borderline-SMOTE | Synthesises minority points by interpolation | Unrealistic points in high dimensions; can worsen precision | 4 — try last, measure honestly |
| Anomaly detection framing | Model P(normal) and flag low-likelihood points | Ignores available labels | Only when positives are extremely few (<100) |
import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.metrics import average_precision_score, roc_auc_score
from sklearn.ensemble import HistGradientBoostingClassifier
from imblearn.over_sampling import SMOTE
from imblearn.under_sampling import RandomUnderSampler
X, y = make_classification(n_samples=200_000, n_features=30, n_informative=10,
weights=[0.999, 0.001], flip_y=0.01, random_state=0)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, stratify=y, random_state=0)
print("positives in train:", int(y_tr.sum())) # 140
def evaluate(name, Xt, yt, **kw):
m = HistGradientBoostingClassifier(random_state=0, **kw).fit(Xt, yt)
p = m.predict_proba(X_te)[:, 1]
print(f"{name:22s} PR-AUC={average_precision_score(y_te, p):.4f} "
f" ROC-AUC={roc_auc_score(y_te, p):.4f} mean_pred={p.mean():.5f}")
evaluate("baseline", X_tr, y_tr)
evaluate("class weights", X_tr, y_tr, class_weight="balanced")
Xs, ys = SMOTE(random_state=0, k_neighbors=5).fit_resample(X_tr, y_tr)
evaluate("SMOTE", Xs, ys)
Xu, yu = RandomUnderSampler(sampling_strategy=0.05, random_state=0).fit_resample(X_tr, y_tr)
evaluate("undersample 1:20",Xu, yu)
# positives in train: 140
# baseline PR-AUC=0.1834 ROC-AUC=0.9241 mean_pred=0.00102
# class weights PR-AUC=0.1791 ROC-AUC=0.9250 mean_pred=0.14903
# SMOTE PR-AUC=0.1103 ROC-AUC=0.9017 mean_pred=0.48771
# undersample 1:20 PR-AUC=0.1742 ROC-AUC=0.9233 mean_pred=0.03318
#
# Two things to notice:
# 1. NOTHING beats the plain baseline on PR-AUC. SMOTE clearly HURTS.
# 2. mean_pred explodes from 0.001 to 0.49 -- calibration is destroyed,
# so any expected-value decision downstream is now wrong by ~500x.Restoring calibration after resampling
Prior-correction for scores from an undersampled model (King & Zeng). The alternative is to fit an isotonic calibrator on a natural-prevalence holdout.
- Resample only the training fold, never validation or test. Your evaluation must reflect production prevalence.
- Resample inside the CV loop via
imblearn.pipeline.Pipeline, or you leak synthetic neighbours across the fold boundary. - Prefer class weights to physical resampling — same expected effect, no fabricated data, less pipeline complexity.
- Collect more positives if you possibly can. 140 positive examples is the actual constraint here; every algorithmic trick is working around a data problem.
Say these points
- Fix the metric and threshold before touching the data distribution.
- Class weights ≈ resampling in expectation, with less pipeline damage.
- SMOTE frequently hurts in high dimensions — always measure PR-AUC before and after.
- All resampling destroys calibration; recalibrate or apply prior correction.
- Resample only the training fold, inside the CV loop.
Avoid these mistakes
- Applying SMOTE before the train/test split (synthetic points span the boundary).
- Reporting metrics on a rebalanced test set.
- Using accuracy anywhere in an imbalanced problem.
Expect these follow-ups
- Derive the prior-correction formula for undersampled training.
- When would focal loss beat class weighting?
- Design a two-stage funnel for a 1:10,000 problem with 200 reviews/day of capacity.
30-second answer
Use the elbow on inertia as a rough guide, silhouette score for cluster separation, gap statistic for a principled comparison against a null reference, and stability analysis across bootstrap resamples. But the real test is external: do the clusters differ on variables you did not cluster on, and can a domain expert name them? A statistically clean clustering nobody can act on is a failed clustering.
| Method | Measures | Weakness |
|---|---|---|
| Elbow (inertia) | Within-cluster sum of squares | Often no clear elbow; monotone decreasing so it never says 'stop' |
| Silhouette | (b − a)/max(a, b) per point: separation vs cohesion | Biased toward convex, equal-sized, globular clusters |
| Davies–Bouldin / Calinski–Harabasz | Ratio of between- to within-cluster dispersion | Same convexity bias |
| Gap statistic | Inertia vs. that of a uniform null reference | Computationally expensive (many reference datasets) |
| Stability / consensus | Do cluster assignments survive resampling? | Needs many refits; my usual tie-breaker |
import numpy as np
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score, adjusted_rand_score
from sklearn.preprocessing import StandardScaler
Xs = StandardScaler().fit_transform(X) # k-means uses Euclidean distance: SCALE FIRST
print(f"{'k':>3} {'inertia':>12} {'silhouette':>11} {'stability':>10}")
for k in range(2, 11):
km = KMeans(k, n_init=10, random_state=0).fit(Xs)
sil = silhouette_score(Xs, km.labels_, sample_size=10_000, random_state=0)
# Stability: cluster two bootstrap halves, compare on their overlap
rng = np.random.default_rng(0)
aris = []
for _ in range(10):
idx = rng.choice(len(Xs), len(Xs) // 2, replace=False)
a = KMeans(k, n_init=5, random_state=1).fit_predict(Xs[idx])
b = KMeans(k, n_init=5, random_state=2).fit_predict(Xs[idx])
aris.append(adjusted_rand_score(a, b))
print(f"{k:>3} {km.inertia_:>12.0f} {sil:>11.3f} {np.mean(aris):>10.3f}")
# k inertia silhouette stability
# 2 184213 0.412 0.981
# 3 142887 0.396 0.947
# 4 118654 0.451 0.923 <- best silhouette, still stable
# 5 109331 0.318 0.641 <- stability COLLAPSES: k=4 is real
# 6 102117 0.301 0.588k-means assumptions and when to use something else
- Assumes spherical, similarly-sized, similarly-dense clusters and uses Euclidean distance — so features must be scaled, and elongated or nested shapes will be split wrongly.
- DBSCAN/HDBSCAN for arbitrary shapes, no need to pick k, and a native notion of noise points. Needs a density parameter instead.
- Gaussian mixtures for elliptical clusters and soft assignments, with BIC/AIC giving a principled model-selection criterion.
- Hierarchical/agglomerative when you want a dendrogram and the ability to choose the granularity afterwards.
- Embedding + clustering for text/images: encode with a sentence or vision model, reduce with UMAP, then cluster with HDBSCAN. This is the standard modern pipeline for topic discovery.
Close by naming the business test: “A segmentation is successful when someone can act differently on each segment. If marketing cannot write a different email for cluster 3, the clustering has not earned its keep — no matter what the gap statistic says.”
Say these points
- Combine elbow, silhouette, gap statistic and stability — never rely on one.
- Stability across resamples is the strongest practical criterion.
- k-means assumes spherical, equal-sized clusters and requires scaling.
- Validate on variables not used for clustering (churn, LTV, support).
- The real test is whether someone can act differently per segment.
Avoid these mistakes
- Running k-means on unscaled features.
- Reporting an elbow that is not actually there.
- Presenting clusters without checking they differ on any outcome variable.
Expect these follow-ups
- How does HDBSCAN decide cluster membership and label noise?
- When would you use GMM with BIC instead of k-means?
- How would you cluster 50 million users efficiently?
30-second answer
Start with robust statistical baselines (per-segment z-scores, seasonal decomposition residuals) because they are interpretable and cheap. Add Isolation Forest for multivariate structure and an autoencoder or density model if the data is high-dimensional. Critically, build a labelling flywheel: every analyst decision on a flagged case becomes a label, and once you have a few thousand you migrate to supervised or semi-supervised learning.
| Method | Catches | Trade-off |
|---|---|---|
| Robust z-score / MAD per segment | Univariate spikes | Trivial and interpretable; blind to multivariate anomalies |
| STL / Prophet residuals | Seasonal time-series deviations | Handles trend + seasonality; needs history per series |
| Isolation Forest | Multivariate outliers via random partitioning | Fast, few assumptions; hard to explain a score |
| One-class SVM | A boundary around 'normal' | Poor scaling above ~10k rows; kernel sensitive |
| Autoencoder reconstruction error | Complex non-linear structure | Needs lots of clean normal data; can learn to reconstruct anomalies too |
| Density models (GMM, normalising flows, KDE) | Low-likelihood regions | Gives a principled probability; struggles in very high dimensions |
import numpy as np, pandas as pd
from sklearn.ensemble import IsolationForest
from statsmodels.tsa.seasonal import STL
class LayeredAnomalyDetector:
"""Cheap interpretable layers first; ML only for what they miss."""
def __init__(self, contamination=0.01):
self.iforest = IsolationForest(contamination=contamination,
n_estimators=300, random_state=0)
# Layer 1: robust univariate (median/MAD is outlier-resistant; mean/std is not)
@staticmethod
def robust_z(series: pd.Series) -> pd.Series:
med = series.median()
mad = (series - med).abs().median()
return 0.6745 * (series - med) / (mad + 1e-9) # 0.6745 -> ~N(0,1)
# Layer 2: seasonal residual for time series
@staticmethod
def seasonal_residual(series: pd.Series, period=7) -> pd.Series:
res = STL(series, period=period, robust=True).fit().resid
return LayeredAnomalyDetector.robust_z(res)
# Layer 3: multivariate structure
def fit(self, X):
self.iforest.fit(X)
return self
def score(self, X, univariate: pd.Series, ts: pd.Series | None = None):
s = pd.DataFrame(index=X.index)
s["uni"] = self.robust_z(univariate).abs()
s["multi"] = -self.iforest.score_samples(X) # higher = more anomalous
if ts is not None:
s["seasonal"] = self.seasonal_residual(ts).abs()
# Rank-normalise each layer so scales are comparable, then take the max:
# an anomaly on ANY axis should surface.
ranks = s.rank(pct=True)
s["combined"] = ranks.max(axis=1)
return s.sort_values("combined", ascending=False)
# Report precision@k -- the metric that matches review capacity.
top = detector.score(X, df["amount"], df["daily_count"]).head(50)
print(f"precision@50 = {labels.loc[top.index].mean():.2%}")The labelling flywheel is the real system
Log every review decision
Analyst confirms/dismisses → a label with a timestamp and a reason code. This is free labelled data generated by work you were doing anyway.Reserve a random-sample bucket
Review ~1% of unflagged traffic too. Without it you only ever learn about cases your current detector already catches, and you can never estimate recall.Migrate to supervised once you have enough
With a few thousand confirmed positives, a supervised model on the same features almost always beats an unsupervised score, because it learns which anomalies actually matter rather than which are merely unusual.Keep the unsupervised layer running
The supervised model only knows the attack patterns in its training data. The unsupervised layer is your detector for genuinely novel behaviour — it is the thing that catches the attack nobody has seen.
Two production details worth mentioning: seasonality and drift mean thresholds must be recomputed on a rolling window, not fixed at launch (Black Friday is not an anomaly); and alert fatigue is a real failure mode — a detector nobody trusts is worse than no detector, so tune for precision at the team's real capacity and track the analyst confirmation rate as a first-class health metric.
Say these points
- Alert capacity is the binding constraint — design for precision@k.
- Layer cheap interpretable detectors before ML ones.
- Use robust statistics (median/MAD), because mean/std are corrupted by the outliers.
- Build a labelling flywheel from analyst decisions plus a random unflagged sample.
- Keep an unsupervised layer for novel attacks after migrating to supervised.
Avoid these mistakes
- Using mean and standard deviation for outlier detection.
- Never sampling unflagged traffic, so recall can never be estimated.
- Confusing 'statistically unusual' with 'actually bad'.
Expect these follow-ups
- How would you detect an anomaly in a metric that has strong weekly seasonality?
- Explain how Isolation Forest scores a point.
- How do you set the contamination parameter without labels?
Showing 5 of 5 questions for feature-engineering-and-imbalanced-data.
Check your understanding
5 questions · no sign-up, nothing stored
Hands-on challenge
Build it — this is what you talk about in a deep-dive round.
End-to-end tabular pipeline on an imbalanced problem
Build a pipeline that a production team could actually adopt, and document every choice.
Requirements
- Point-in-time feature builder with a rolling cutoff design and a forward-window label.
- Missing-value strategy justified per column, with an MNAR screen showing target lift by missingness.
- Head-to-head comparison of baseline / class weights / undersampling / SMOTE on PR-AUC *and* calibration.
- Threshold chosen from an explicit cost matrix, with expected cost reported at that operating point.
Stretch goals
- Add an unsupervised anomaly layer and measure how many positives it catches that the supervised model misses.
- Cluster the positives and check whether the clusters correspond to distinct failure modes.