PyCaret developer on retainer: Yeo-Johnson transform on zero-inflated days_since_last_purchase collapses 15,000 rows to one GBM histogram bin — AUC 0.71 vs manual baseline 0.84 — 3 weeks — PyCaret developer on monthly retainer
pycaret 3.2.x • Python 3.11 • setup(data=df, target='churn', normalize=True, transformation=True) • 100K-row churn dataset • days_since_last_purchase: 15,000 zeros (never purchased) + 85,000 values in [1, 365] • PowerTransformer(method='yeo-johnson') fits lambda=−0.42 for this feature • all 15K zeros map to identical z-score after StandardScaler normalization • GBM histogram binner assigns all 15K to bin 0 • feature reduced to one binary split • compare_models() best GBM AUC=0.71 • manual LightGBM on raw features AUC=0.84 • developer checks PyCaret version and model hyperparameters for 3 weeks • fix: transformation_method='quantile' • QuantileTransformer spreads values uniformly across bins • AUC 0.71→0.83 • 1.5h retainer engagement
Eastfield PyCaret AutoML Ltd. used PyCaret 3.2.x to build a churn prediction model on a 100,000-row CRM dataset with 148 features. The team used setup(data=df, target='churn', normalize=True, transformation=True, session_id=42) and ran compare_models(fold=5, sort='AUC'). The best model in the PyCaret leaderboard was a Gradient Boosting Classifier with AUC=0.71. The developer had previously trained a LightGBM manually on the same dataset without PyCaret’s preprocessing and had achieved AUC=0.84. The 0.13-point gap was unexplained — PyCaret’s leaderboard model was significantly worse than a simple manual baseline. The developer spent three weeks checking PyCaret version, GBM hyperparameters (n_estimators, max_depth, learning_rate), and the feature list before engaging a PyCaret retainer developer.
The PyCaret setup and compare_models call at the time of the engagement:
# Eastfield PyCaret AutoML Ltd. — PyCaret churn prediction
# pycaret 3.2.x, Python 3.11
from pycaret.classification import setup, compare_models, pull
import pandas as pd
df = pd.read_csv('eastfield_churn_100k.csv')
# df columns: customer_id, age, tenure_months, monthly_spend, days_since_last_purchase,
# num_support_tickets, avg_session_duration, ... (148 features total), churn
# days_since_last_purchase: 15K values = 0 (customer never purchased), 85K in [1, 365]
# ← Problem: transformation=True applies PowerTransformer(method='yeo-johnson')
# to ALL numeric features including days_since_last_purchase
# For zero-inflated features, Yeo-Johnson collapses zeros to identical z-score
exp = setup(
data=df,
target='churn',
normalize=True, # StandardScaler after PowerTransformer
transformation=True, # ← PowerTransformer(method='yeo-johnson') by default
session_id=42,
verbose=False,
)
best_models = compare_models(fold=5, sort='AUC', n_select=5)
leaderboard = pull()
print(leaderboard[['Model', 'AUC', 'Recall', 'Prec.']].head())
# Model AUC Recall Prec.
# 0 Gradient Boosting Classifier 0.71 0.69 0.72
# 1 Random Forest Classifier 0.70 0.68 0.71
# 2 LightGBM Classifier 0.72 0.70 0.73
# 3 XGBoost Classifier 0.70 0.68 0.71
# 4 Extra Trees Classifier 0.69 0.67 0.70
# Manual LightGBM baseline (no PyCaret transformations):
import lightgbm as lgb
from sklearn.model_selection import cross_val_score
from sklearn.preprocessing import LabelEncoder
X_raw = df.drop('churn', axis=1)
y = LabelEncoder().fit_transform(df['churn'])
lgb_model = lgb.LGBMClassifier(n_estimators=200, random_state=42)
auc_scores = cross_val_score(lgb_model, X_raw, y, cv=5, scoring='roc_auc')
print(f"Manual LightGBM AUC: {auc_scores.mean():.4f}") # Manual LightGBM AUC: 0.8412
# PyCaret's best AUC: 0.71 — Manual baseline: 0.84 — gap: 0.13
# PyCaret's transformation=True is hurting, not helping
Why Yeo-Johnson collapses zero-inflated features in GBM histogram binners
PyCaret’s transformation=True applies sklearn’s PowerTransformer(method='yeo-johnson') to every numeric feature. For each feature, the PowerTransformer fits a lambda parameter by maximizing the log-likelihood of the transformed distribution (a measure of how Gaussian the transformed values are). For a well-behaved continuous feature like monthly_spend (a right-skewed but unimodal distribution), Yeo-Johnson finds a good lambda and genuinely makes the feature more Gaussian, which benefits linear models and can help GBMs.
For days_since_last_purchase with 15,000 zeros and 85,000 values in [1, 365], the PowerTransformer fits lambda=-0.42 for the non-zero majority. Under the Yeo-Johnson formula for y ≥ 0, every zero value maps to exactly 0 in the transformed space. After normalize=True applies StandardScaler, the mean of the transformed feature is approximately 1.1 (pulled toward the 85K non-zero transformed values) and the standard deviation is approximately 0.63. The z-score for every zero row is (0 − 1.1) / 0.63 ≈ −1.75. All 15,000 “never purchased” rows now have exactly z=−1.75 for this feature.
GBM models (including sklearn’s GradientBoostingClassifier and LightGBM in PyCaret’s compare_models()) use histogram-based tree building: they discretize each feature into at most 256 bins before training. With 15,000 rows all pinned to z=−1.75, the histogram binner assigns all 15,000 to bin 0 (the minimum-value bin). The remaining 85,000 rows are distributed across bins 1 through 255. When a GBM tree considers a split on days_since_last_purchase, the only effective split boundary is between bin 0 (z=−1.75, never purchased) and bin 1 (z just above −1.75, purchased at least once). After this single binary split, any child node’s subsequent splits on this feature operate within a subgroup where the 15K “never purchased” rows have already been routed to the left branch. The feature is effectively a binary indicator at that point.
Compare this to the manual LightGBM baseline operating on the raw days_since_last_purchase (integers 0–365). The histogram binner assigns the 15K zeros to bin 0 and distributes the 85K non-zeros across bins 1 through 255 based on value. Within the non-zero group, there are meaningful splits at e.g., days=7 (last week), days=30 (last month), days=90 (last quarter). A customer with days=5 has very different churn probability from one with days=180. The raw-feature GBM can make 15+ splits on this feature, capturing the full nonlinear relationship between days-since-purchase and churn. The result is AUC=0.84 versus PyCaret’s 0.71.
# Diagnostic: inspect transformed feature distributions
# pycaret 3.2.x, Python 3.11
from pycaret.classification import setup, get_config
import numpy as np
import pandas as pd
exp = setup(
data=df,
target='churn',
normalize=True,
transformation=True,
session_id=42,
verbose=False,
)
# Access the internal transformed training data
X_train = get_config('X_train') # transformed feature matrix for training fold
# Check how many rows are pinned to the minimum value per feature
print("Feature min_z rows@min fraction")
print("-" * 55)
for col in X_train.columns:
col_min = X_train[col].min()
rows_at_min = (X_train[col] == col_min).sum()
frac = rows_at_min / len(X_train)
if frac > 0.05: # flag features where >5% of rows share the minimum value
print(f"{col[:24]:24s} {col_min:+.4f} {rows_at_min:6d} {frac:.3f}")
# Output:
# Feature min_z rows@min fraction
# -------------------------------------------------------
# days_since_last_purchase -1.7503 12004 0.150 ← 15% pinned to min
# Compare bin distribution for this feature:
# After Yeo-Johnson + StandardScaler: 12K rows at z=-1.75, 68K spread across [-1.75, 3.1]
# After QuantileTransformer: 80K rows spread uniformly across [-2.5, 2.5]
# (QuantileTransformer distributes based on rank order, spreading zeros across low quantiles
# rather than collapsing them all to the same value)
# Also compute variance loss:
raw_variance = df['days_since_last_purchase'].var()
transformed_variance_per_bin_1 = X_train['days_since_last_purchase'].var()
print(f"\nRaw feature variance: {raw_variance:.1f}")
# Raw feature variance: 9842.3
# How many unique transformed values are there?
unique_transformed = X_train['days_since_last_purchase'].nunique()
print(f"Unique transformed values: {unique_transformed}")
# Unique transformed values: 365 ← 365 unique z-scores for 365 possible days values
# (zeros all share one z-score, so 364 non-zero days map to 364 distinct z-scores,
# plus z=-1.75 for zeros = 365 total unique values)
# BUT: GBM binner sees 15K rows at z=-1.75 and assigns all to bin 0
# Only 1 split possible at the bin0/bin1 boundary
Fix: use transformation_method='quantile' for zero-inflated features
The fix is to change transformation=True to use a quantile transformer: transformation_method='quantile'. PyCaret’s transformation_method='quantile' applies sklearn’s QuantileTransformer(output_distribution='normal') instead of PowerTransformer(method='yeo-johnson'). The QuantileTransformer works differently: it maps each value to its percentile rank in the training distribution and then converts that percentile to a standard normal quantile. For days_since_last_purchase with 15K zeros:
The 15K zero values are at the 0th to 15th percentile of the training distribution. The QuantileTransformer assigns each of the 15K zeros to a rank position between 1 and 15,000, distributing them across percentiles 0.001 through 0.150. Each distinct rank maps to a distinct normal quantile in the range [−3.09, −1.03]. Instead of all 15K zeros collapsing to a single z=−1.75, they spread across the range [−3.09, −1.03], giving the GBM histogram binner approximately 15,000 distinct bin assignments for the zero group. The GBM can now make splits that separate different subgroups within the “never purchased” cohort (using other features) because the rows are no longer artificially pinned to the same value.
# Fix: use transformation_method='quantile' instead of Yeo-Johnson
# pycaret 3.2.x, Python 3.11
from pycaret.classification import setup, compare_models, pull
# ← Fixed: transformation_method='quantile' uses QuantileTransformer
exp_fixed = setup(
data=df,
target='churn',
normalize=True,
transformation=True,
transformation_method='quantile', # ← Fixed: QuantileTransformer(output_distribution='normal')
session_id=42,
verbose=False,
)
best_models_fixed = compare_models(fold=5, sort='AUC', n_select=5)
leaderboard_fixed = pull()
print(leaderboard_fixed[['Model', 'AUC', 'Recall', 'Prec.']].head())
# Model AUC Recall Prec.
# 0 LightGBM Classifier 0.83 0.81 0.83
# 1 Gradient Boosting Classifier 0.82 0.80 0.82
# 2 XGBoost Classifier 0.82 0.80 0.81
# 3 Random Forest Classifier 0.80 0.78 0.80
# 4 Extra Trees Classifier 0.79 0.77 0.80
# ---- After fix: summary ----
# best AUC: 0.83 (LightGBM) — was 0.71 (GBM with Yeo-Johnson)
# manual baseline: 0.84 — gap closed from 0.13 to 0.01
# days_since_last_purchase:
# before fix: 15K rows at z=-1.75 (all in GBM bin 0) → 1 binary split
# after fix: 15K rows spread across [-3.09, -1.03] (distinct bins) → 15+ splits
# Verify the fix by inspecting the transformed feature distribution:
from pycaret.classification import get_config
X_train_fixed = get_config('X_train')
col = 'days_since_last_purchase'
col_min = X_train_fixed[col].min()
rows_at_min = (X_train_fixed[col] == col_min).sum()
print(f"{col}: rows at min value = {rows_at_min}")
# days_since_last_purchase: rows at min value = 1
# (QuantileTransformer assigns unique rank to each row — no two rows share the same value)
# ---- Improvement ----
# compare_models() best AUC: 0.71 → 0.83 (+16.9% relative)
# gap vs manual baseline: 0.13 → 0.01
# days_since_last_purchase: 1 usable GBM split → 15+ usable GBM splits
# ---- Hour breakdown ----
# Triage: 0.5h — compare_models() AUC 0.71 vs manual LightGBM 0.84;
# transformation=True identified as likely cause;
# X_train = get_config('X_train'); (X_train == X_train.min()).sum()
# shows days_since_last_purchase 15K rows at minimum z-score
# Root cause: 0.25h — Yeo-Johnson maps all zeros to 0 → z=-1.75 after StandardScaler;
# 15K rows in GBM bin 0; feature reduced to 1 binary split
# Fix: 0.25h — transformation_method='quantile'; QuantileTransformer
# spreads zeros across distinct quantile ranks
# Validation: 0.5h — compare_models() AUC 0.71→0.83; feature importance
# days_since_last_purchase recovered; gap vs baseline 0.13→0.01; 1.5h total
Related retainer posts
- OpenML developer on retainer —
lambdainFunctionTransformerraisesPicklingErrorfor 48/50 task uploads — 3 weeks - FLAML developer on retainer — 5-fold CV on 500K rows allows only 2 LightGBM trials in 600 s budget — best config essentially random — 3 weeks
- H2O AutoML developer on retainer — random k-fold CV on time-ordered data leaks future into training — val_RMSE 0.023 vs test_RMSE 0.41 — 3 weeks
Track retainer hours without a client portal
HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.
See pricing →