Customer Inactivity / Dormancy Prediction for Banking
A prescriptive workflow for building feature matrices for banking customer inactivity models. Each row is (customer_id, cutoff_date) and every feature must be computable from data observable before cutoff_date. The objective is to predict whether a customer will become dormant or disengaged within a defined future window.
Step 0 — Pin down the problem
Before writing code, confirm the following:
- Inactivity definition. Define inactivity as a customer showing no meaningful activity within the next
N days. In banking, examples include:
- no card transactions in the next 30/60/90 days
- no digital login/app usage in the next 30 days
- no payment / deposit / spend activity in the next 60 days
- no engagement with the account or product family for the next 90 days
- Cutoff date (
cutoff_date). The as-of date used for prediction. Features can only use data with timestamp <= cutoff_date.
- Prediction horizon. Common windows are 30, 60, and 90 days. Longer horizons help label stability; shorter horizons are more operationally useful.
- Target semantics. Decide whether inactivity is measured by card transactions only, total product activity, digital engagement, or a combination.
If the user cannot define these, stop and clarify before engineering features.
Panel and label construction
Create a panel of (customer_id, cutoff_date) rows for every customer with at least some recent activity before the cutoff.
Recommended cutoff pattern:
- Daily or weekly snapshots for a period spanning at least 6–12 months
- Monthly snapshots for production stability
Right-censoring: exclude rows where cutoff_date + horizon > data_end_date.
data_end_date = transactions["txn_ts"].max()
panel = panel[
panel["cutoff_date"] + pd.Timedelta(days=horizon) <= data_end_date
]
Label logic: y = 1 if the customer has no qualifying activity in (cutoff_date, cutoff_date + horizon].
horizon_td = pd.Timedelta(days=horizon)
merged = panel[["customer_id", "cutoff_date"]].merge(
transactions[["customer_id", "txn_ts"]], on="customer_id", how="left"
)
active_in_window = (
(merged["txn_ts"] > merged["cutoff_date"]) &
(merged["txn_ts"] <= merged["cutoff_date"] + horizon_td)
)
active_pairs = (
merged[active_in_window]
.drop_duplicates(subset=["customer_id", "cutoff_date"])
[["customer_id", "cutoff_date"]]
.assign(had_activity=True)
)
panel = panel.merge(active_pairs, on=["customer_id", "cutoff_date"], how="left")
panel["y"] = (~panel["had_activity"].fillna(False)).astype(int)
Label definition must be explicit: if inactivity means “no card usage,” use transaction activity only. If it means “no banking activity at all,” combine card, payment, login, and balance events.
Step 1 — Map the banking schema
The banking version assumes the following canonical tables:
| Table |
Grain |
Key columns |
customers |
one row per customer |
customer_id, signup_ts, dob, acquisition_channel, segment, kyc_level, account_status |
cards |
one row per card/account |
card_id, customer_id, card_type, issue_ts, status, credit_limit, product_tier |
transactions |
one row per transaction |
txn_id, customer_id, card_id, txn_ts, amount, merchant_category, merchant_id, channel, auth_result, fraud_flag, is_refund, is_adjustment |
payments |
one row per payment |
payment_id, customer_id, card_id, payment_ts, amount, method, status |
balances |
one row per account snapshot |
customer_id, date, current_balance, statement_balance, available_credit, credit_limit |
digital_sessions |
one row per app/portal/login session |
customer_id, session_ts, session_id, channel, session_duration, event_type |
applications |
one row per application |
application_id, customer_id, product, app_ts, app_status, decision_ts |
support_tickets |
one row per customer service event |
ticket_id, customer_id, created_ts, resolved_ts, issue_type, severity, sentiment |
bureaus |
one row per bureau pull |
customer_id, pull_ts, score, delinquency_counts, open_accounts, total_credit_limit |
campaign_events |
marketing or cross-sell touchpoints |
customer_id, campaign_id, channel, event_ts, click_flag, presentment_id |
Map the real warehouse tables to these canonical fields before building features. Naming drift is a common source of silent model failure.
Step 2 — Leakage hygiene (non-negotiable)
Every feature must be answerable from the perspective of the customer at cutoff_date without future information.
Rules:
- Filter all event tables by
timestamp <= cutoff_date before aggregating.
- For mutable account state, reconstruct as-of values at
cutoff_date instead of reading the current table.
- For late-settling events, use
settlement_ts or close_ts with a settlement_buffer_days where needed.
- Exclude bureau pulls or decision data that occur after the cutoff.
- Exclude marketing or digital events that happen after the activity label starts.
Examples of leakage traps in banking:
- using post-application approval/decision variables as features
- using future payment resolution or dispute outcomes before settlement
- using future bureau data or account updates introduced after the model snapshot
- using future card presentment events after the inactivity label begins
Step 3 — Window strategy
Choose rolling windows based on the target horizon.
Use this default set unless the use case strongly suggests otherwise:
- short: 7, 14, 30 days
- medium: 60, 90 days
- long: 180, 365 days
For activity and churn modeling, a common pattern is:
- immediate recency: 7/14/30d
- baseline behavior: 60/90/180d
- long-term trend: 365d
Record the window list in feature_spec.yaml.
Step 4 — Feature families
Build features in independent families and validate leakage after each family.
4.1 Lifecycle and customer context
tenure_days = cutoff_date - signup_ts
days_since_first_txn, days_since_first_payment, days_since_first_card_issue
is_new_customer_30d, is_new_customer_90d
account_age_bucket (new / active / mature / dormant)
customer_segment, acquisition_channel, kyc_level
has_credit_card, has_debit_card, has_savings, has_loan
n_active_products, product_depth_score
4.2 Recency features
These are often the strongest inactivity signals.
days_since_last_txn
days_since_last_payment
days_since_last_login
days_since_last_app_session
days_since_last_email_open
days_since_last_card_use
days_since_last_support_contact
active_in_last_7d, active_in_last_30d, active_in_last_90d
days_since_last_merchant_category_usage (top category)
days_since_last_product_usage (top product family)
recency_decay_score = sum(exp(-days_ago / k))
4.3 Frequency and cadence features
txn_count_{7,14,30,60,90,180}d
card_active_days_{30,90}d
payment_count_{30,90}d
login_count_{30,90}d
app_session_count_{30,90}d
avg_days_between_txns_90d
median_days_between_txns_90d
std_days_between_txns_90d
is_overdue = days_since_last_txn > 1.5 * median_days_between_txns
orders_per_week analog in banking = txns_per_week_90d
4.4 Monetary / spend features
total_spend_{30,90,180}d
avg_txn_amount_{30,90}d
median_txn_amount_{90d}
max_txn_amount_{90d}
std_txn_amount_{90d}
spend_per_active_day_{30,90}d
aov_last_30d / aov_lifetime
spend_decline_pct_30d, spend_decline_pct_90d
payment_amount_total_{30,90}d
cashflow_share_by_category
4.5 Balance and utilization features
current_balance_total, statement_balance_total, available_credit_total
credit_utilization_current, credit_utilization_30d, credit_utilization_90d
utilization_trend_30d_vs_90d
max_utilization_last_90d
balance_drop_pct_30d, recent_balance_decline
revolving_balance_share_{30,90}d
credit_limit_growth_{90d}
4.6 Payments and repayment behavior
last_payment_days_ago
on_time_payment_rate_{30,90,365}d
missed_payment_count_{30,90,365}d
payment_lag_mean_days, payment_lag_p90_days
autopay_enrolled_flag, autopay_usage_rate_{90d}
payment_method_share by ACH / branch / transfer / cheque
payment_to_balance_ratio_{30d}
4.7 Delinquency, bureau, and default-risk signals
past_due_bucket_30d, _60d, _90d
max_past_due_days_last_365d
bureau_score_asof, bureau_active_derogatory_count
bureau_total_balance, bureau_total_credit_limit
recent_score_drop and bureau_score_trend_90d
collections_contact_count_{90d}
collections_amount_sum_{365d}
4.8 Fraud, chargeback, and servicing signals
chargeback_count_{90d,365d}
chargeback_rate_{90d}
fraud_flag_recent
auth_failure_rate_{30d}
suspicious_merchant_share_{90d}
support_ticket_count_{30,90}d
customer_complaint_rate_{180d}
avg_support_sentiment_{90d}
4.9 Digital engagement features
digital_session_count_{7,30,90}d
app_login_count_{30,90}d
avg_session_duration_{30,90}d
portal_usage_rate_{30d}
digital_engagement_slope_60d
mobile_vs_web_share
email_open_rate_{30,90}d
email_click_rate_{30,90}d
last_app_login_days_ago
4.10 Trend and comparison features (critical)
These are essential for actual dormancy detection.
txns_first_6m, txns_last_6m, txns_last6m_vs_first6m_ratio
spend_first_6m, spend_last_6m, spend_last6m_vs_first6m_ratio
payments_first_6m, payments_last_6m, payments_change_pct_6m
txns_last_30d_vs_prev_30d
spend_last_30d_vs_prev_30d
digital_sessions_last_30d_vs_prev_30d
txn_slope_30d, txn_slope_90d
spend_slope_60d, spend_slope_90d
trend_direction_90d (up / flat / down)
recent_activity_drop_percent_30d
usage_change_6m and engagement_change_6m
These answer the important business question: is the customer slowly disengaging or already dormant?
4.11 Product and merchant affinity features
top_merchant_category_share_{90d}
top_product_family_share_{90d}
merchant_category_entropy_{90d}
spend_share_grocery_{90d}, spend_share_travel_{90d}
preferred_channel (digital / branch / mobile / ATM)
product_affinity_score_card
merchant_repeat_rate_{90d}
new_merchant_share_{30d}
4.12 Interaction features
utilization × bureau_score_bucket
recent_spend_drop × recent_login_drop
payment_lag × delinquency_flag
support_issue_count × no_txn_30d
recent_chargeback_count × product_affinity_score
digital_engagement × transaction_count
4.13 Geo and location features
n_distinct_cities_last_90d
top_geo_share_90d
geo_region_diversity
travel_spend_share
address_change_flag_last_180d
4.14 Seasonality and calendar features
month_of_year_signal
holiday_spend_index
weekday_vs_weekend_spend_share
payday_proximity_flag
statement_cycle_position
Step 5 — Feature quality, missingness, and denominators
- Always keep denominators with ratios:
total_txn_count, total_spend, sessions_count, payments_count
- Replace zero denominators gracefully with
0 or a missing flag where appropriate
- For
days_since_* features, use a sentinel such as 9999 and a companion has_* flag
- Record missingness flags for critical features where missingness itself signals risk
Step 6 — Leakage checklist for banking models
- No future transaction or payment behavior after cutoff
- No future account status updates
- No future bureau pull or score after cutoff
- No post-outcome digital nudges/marketing events
- No late-settling refunds, chargebacks, or dispute resolution that occur after the observation window
Step 7 — Validation and selection
- Check distribution drift across monthly cutoffs
- Evaluate target-rate stability by time period
- Remove constant or near-constant features
- Measure feature importance and SHAP values
- Remove highly collinear features with correlation pruning
- Keep business-relevant and interpretable features, not just a long raw list
Step 8 — Tests and reproducibility
- Unit tests for each feature function
- Check zero-denominator handling
- Validate off-by-one logic with synthetic fixtures
- Use a feature registry to record each feature name, source, window, type, and owner
Step 9 — Production monitoring
- Monitor PSI / drift for top features
- Alert on sudden null rate increases, stale feed delays, or distribution shifts
- Shadow-run new features and compare with baseline before rollout
- Track uplift in retention or dormancy recall vs production scorecards
Step 10 — Banking-specific summary
This skill is designed for customer inactivity and dormancy modeling in financial services, especially for:
- card inactivity / dormancy risk
- debit and banking account disengagement
- digital engagement attrition
- product-level inactivity and cross-sell risk
- customer retention and activation monitoring
The same core pattern generalizes across domains:
- lifecycle and tenure features
- recency-frequency-monetary features
- trend and period-comparison features
- product affinity and channel preference
- digital engagement and risk signals
- leakage-safe time windows and as-of logic
Deliverables
- A feature-generation pipeline using a
(customer_id, cutoff_date) panel
- A leakage-safe pipeline with as-of filtering
- A tested feature registry covering each feature family
- Reusable templates for other banking and non-banking domains
This banking edition keeps the original modeling discipline of the inactivity workflow but rewrites the business context to customer dormancy in financial products.
n_positive_ratings_last_90d — count of rating >= 4.
rating_trend_30d_vs_lifetime = avg_rating_last_30d - avg_rating_lifetime. Falling ratings predict drop-off.
n_orders_after_low_rating_last_180d — recovery behavior. Reordering after a 1–2 star rating is a forgiveness signal.
3h. Support & complaint signals
n_tickets_last_30d, n_tickets_last_90d, n_tickets_last_180d
n_high_severity_tickets_last_90d
days_since_last_ticket (also in 3b — keep in whichever family makes sense for your team)
avg_resolution_hours_last_90d — long resolution times correlate with frustration.
support_resolution_rate_last_90d = n_resolved_tickets / n_tickets over tickets created in window.
n_tickets_open_at_cutoff — created before cutoff, resolved_ts after cutoff or still NULL. See leakage notes.
tickets_per_order_last_90d — frustration normalized by usage volume.
- If
sentiment_score is available: min_sentiment_last_90d, share_negative_sentiment_last_90d.
- Distribution across
issue_type last 90d — delivery vs payment vs app issues drive different inactivity dynamics.
3i. Promotions & price-sensitivity features
The food-delivery analog of "plan & pricing" — does the customer engage organically or only when discounted?
voucher_usage_rate_last_90d — share of orders with voucher_used = True.
n_voucherless_orders_last_30d — organic engagement count.
share_orders_with_promotion_last_90d — orders with promotion_code non-null.
n_distinct_promotion_codes_used_last_180d — deal-hunter signal.
voucher_dependency_trend — voucher usage rate last 30d vs prev 60d. Rising reliance on promos is a strong inactivity signal: the customer no longer values the product at full price.
avg_order_value_with_voucher_vs_without_last_90d — price-sensitivity gap.
first_order_used_voucher (binary) — was the activation order discounted? Customers who only ever ordered with a discount go inactive faster.
orders_within_7d_of_promo_email_last_90d — marketing responsiveness. Join emails (where email_type = 'promo') to subsequent orders within a 7-day attribution window.
- Note: the schema has no
discount_amount column, so "average discount per order" can't be computed exactly. The voucher flag and promotion code identity are the only available proxies.
3j. Cohort & acquisition
acquisition_channel (categorical)
signup_cohort_month — use cautiously, can leak temporal trends; prefer relative cohort features below.
- Cohort-relative:
tenure_days_vs_cohort_median, orders_30d_vs_cohort_median, order_value_vs_cohort_median.
signup_promo_flag if derivable (signed up under a discount/trial).
3k. Marketing engagement features
Customers usually stop opening emails and pushes before they stop ordering — these are leading indicators.
n_emails_received_last_30d, n_emails_opened_last_30d, n_emails_clicked_last_30d — raw counts.
email_open_rate_last_90d = n_emails_opened / n_emails_received (NaN if no emails sent — see Step 5).
email_click_rate_last_90d
n_pushes_received_last_30d, n_pushes_opened_last_30d
push_open_rate_last_90d
- Trend:
email_open_rate_last_30d / email_open_rate_prev_60d — declining engagement.
- Distribution of
email_type opened last 90d — which content resonates (promo vs transactional vs reactivation).
3l. Restaurant / merchant context
These features describe the quality of the customer's order history. Be careful — many restaurants columns are mutable; see Step 2.
avg_restaurant_rating_ordered_from_last_90d — weighted by order count. Caveat: restaurants.avg_rating is dynamic. If you can't snapshot it at cutoff_date, drop this feature or use a historical version.
share_orders_from_brg_restaurants_last_90d — same caveat for is_brg.
distinct_cities_ordered_in_last_180d — travel / multi-location signal.
- If you have both
orders.customer_address and restaurants.restaurant_address: approximate avg_delivery_distance_last_90d. Use a static geocoder; do not rely on a dynamic distance service.
Step 4 — Categorical encoding
Decision rules by cardinality
| Cardinality |
Approach |
| 2 (binary) |
Cast to int (0/1). No encoder. |
| 3–10 |
One-hot. Tree models handle them fine; linear models need them. |
| 10–~50 |
Frequency encoding (value → count(value) / N). Cheap, leakage-free, often as strong as target encoding. Implement as series.value_counts(normalize=True) on the train set; save the dict, apply via .map(dict).fillna(0) at inference. |
| 50+ |
Target encoding with out-of-fold CV + smoothing (target_encode_oof). Use target_encode_time_aware if the training set has multiple cutoff dates. |
| ≥10k |
Hash encoding (hash_encode) — fixed bucket count, no per-value artifact. |
Free text (subject_line, content_summary) |
Out of scope for tabular FE; treat with a separate NLP pipeline. |
Per-column recommendations for this schema
| Column |
Cardinality |
Recommended |
voucher_used, is_refunded, open_status, click_status |
2 |
Cast to int |
severity (low/med/high/critical) |
4 |
Ordinal (low=0…critical=3) — levels are ordered |
delivery_status, acquisition_channel, email_type, push_type, session_type |
5–10 |
One-hot |
issue_type |
10–20 |
Frequency encoding + per-value share columns (categorical_distribution_shares) |
cuisine |
30–60 |
Frequency encoding (also: entropy, top-cuisine-share via helpers) |
marketing_channel |
10–30 |
Frequency encoding |
city |
50–500 |
Frequency encoding; target encoding on very large platforms |
promotion_code |
100s–1000s |
Target encoding (OOF, smoothed). Hash if >10k |
restaurant_id |
10k+ |
Target encoding (smoothing 50+) OR hash to 256–1024 buckets |
postcode from customer_address |
1k+ |
Target encoding OR hash |
When in doubt, frequency encoding is the safe default — never leaks, surprising signal.
Smoothing strength for target encoding
target_encode_oof and target_encode_time_aware take a smoothing parameter. It pulls rare categories toward the global mean so a restaurant seen once with y=1 doesn't get encoding 1.0.
| Smoothing |
Behavior |
| 0 |
No shrinkage. Rare categories overfit. Avoid. |
| 1–5 |
Light. Use when most categories have ≥20 rows. |
| 10 |
Default. Good balance for typical food-delivery cardinality. |
| 50–100 |
Heavy. Use for restaurant_id and other long-tailed columns where many values have <5 rows. |
The biggest target-encoding bug
Computing mean(y | x) on the full training set and then training on the same set: every row sees its own label in the encoding and the model memorizes the encoding. target_encode_oof does the right thing (out-of-fold).
Multi-cutoff training sets add a second leak: OOF folds don't respect time, so a Jan row gets encoded using labels from July rows — and the July labels reflect events from Jan–Oct, leaking the future into the past. target_encode_time_aware fixes this by using only labels at strictly earlier cutoffs for each row. See leakage.md Trap 12.
CatBoost / LightGBM native handling
Modern GBMs encode low-to-mid cardinality categoricals natively — pass cuisine, marketing_channel, issue_type directly. For high-cardinality columns (restaurant_id, postcode) and any multi-cutoff training set, use manual target/hash encoding — native encoders don't respect cutoff_date time semantics.
Step 5 — Missing-value semantics
The right imputation depends on why a value is missing. Make the distinction explicit:
| Pattern |
Meaning |
Treatment |
n_orders_30d = NaN |
No orders found in window |
Replace with 0. Missing = no activity. |
days_since_last_order = NaN |
Customer has never ordered (has_ever_ordered = False) |
Replace with a large sentinel (e.g., 9999) and keep the has_ever_ordered flag. The sentinel + flag combination lets tree models distinguish "never ordered" from "ordered long ago" cleanly. |
refund_rate_last_90d = NaN |
No orders in window (denominator is 0) |
Replace with 0 and carry the denominator n_orders_last_90d as a separate feature so the model can weight the rate appropriately. |
email_open_rate_last_90d = NaN |
No emails sent (denominator is 0) |
Same treatment as above — 0 plus the denominator. |
sentiment_score = NaN |
Ticket exists but sentiment service didn't score it |
True unknown. Median-impute and add a sentiment_missing flag. |
rating = NaN on an order |
Customer didn't rate that order |
True unknown. Aggregations like avg_rating_given_last_90d should ignore NaNs at aggregation time; never impute zero (zero ≠ "no rating"). |
address = NaN |
Customer never entered one |
Flag as address_missing; do not invent a value. |
Always add a _missing indicator column for true-unknown imputations. Tree models exploit them; linear models still benefit.
Step 6 — Validate and filter the feature matrix
6a. Validation assertions
Before handing off to modeling, assert:
- No future timestamps. For each row, the max timestamp of any event used to compute features is
<= cutoff_date. Use scripts/inactivity_features.py:assert_no_future_leakage.
- Inactivity rate is sane. Compute
mean(y) and compare to the user's expected inactivity rate from product analytics. Off by an order of magnitude? Either the inactivity definition or the join is wrong — stop and debug.
- Feature distributions stable across cutoffs. Food delivery has strong weekly and monthly seasonality (weekends, paydays, holidays). If the training set has multiple cutoff dates, plot key feature means by cutoff. Big jumps either reflect real seasonality (document and consider seasonality-adjusted features) or a data pipeline change (find and fix it).
- No constant columns. Drop them.
- No columns perfectly correlated with the label. If you find one, it is almost certainly leakage. Investigate before celebrating.
- Settlement-buffer check for refunds & delivery status. Refunds and final delivery status sometimes settle days after the order. Confirm orders are fully settled as-of
cutoff_date — e.g., only count orders with order_ts <= cutoff_date - settlement_buffer_days. Otherwise late-settling refunds sneak into "pre-cutoff" features.
6b. Filter to ~50 features (correlation + mutual info, model-agnostic)
After Step 3 the raw matrix typically has 100–300 columns. Many are redundant by construction — rolling-window twins like orders_last_7d and orders_last_14d are highly correlated, as are aov_last_30d and aov_last_90d. A long tail also has weak signal. Filter down to a model-agnostic shortlist (~50) before training: less wasted compute, cleaner feature-importance plots, easier debugging.
Model-agnostic means: no XGBoost importance, no SHAP, no LightGBM gain — nothing that requires having trained a model first. The 50 you pick should be defensible regardless of which model you train next.
Three-step procedure (one call):
- Encode categoricals to numeric (use OOF / time-aware encoding from Step 4 — raw target-encoded values leak).
- Drop near-duplicates: for each pair with
|spearman| > 0.95, drop the lower-MI one.
- Rank survivors by mutual information against
y and keep the top K.
Use the helper from scripts/inactivity_features.py:
from inactivity_features import filter_features_correlation_mi, apply_feature_filter
X_train_filtered, filter_info = filter_features_correlation_mi(
X_train, y_train,
top_k=50,
max_corr=0.95,
corr_method="spearman", # robust to skewed counts / amounts
task="classification", # use "regression" for continuous outcomes
)
# Persist filter_info alongside the model. Apply at val/test/inference:
X_val_filtered = apply_feature_filter(X_val, filter_info)
filter_info contains the dropped columns, the MI ranking of survivors, the final top-K column list, and the thresholds used — persist it as part of the feature spec (Step 7).
Sanity checks before locking in the 50:
- Family coverage. The top-50 should include features from a healthy mix of families (3a–3l). All 50 from a single family usually means either leakage in that family (investigate!) or weak engineering in the others.
- MI floor. The 50th feature's MI shouldn't be near zero. If it is, the signal ceiling is lower than 50 — pick a smaller K (20–30).
- Temporal stability. Re-run the ranking on a held-out future cutoff. If the top-50 lists differ substantially between training and held-out cutoffs, the feature set is unstable — possibly leakage, possibly drift / seasonality. Investigate before shipping.
Why MI instead of Pearson-with-label? Mutual information captures non-linear dependence (U-shapes, thresholds, interactions with a small set of values) that tree models exploit. Pearson misses these and ranks them as noise.
Continuous outcomes (e.g., revenue rather than binary inactivity): swap mutual_info_classif → mutual_info_regression.
Tuning K. 50 is a reasonable default for inactivity on millions of customers. Drop to 20–30 if labels are scarce or you need a small interpretable model; raise to 100+ if you have abundant data and a GBM backend that handles wide inputs well. Persist the chosen K, the correlation threshold (0.95), and the surviving column names in the feature spec (Step 7) — these are part of the model contract.
6c. Class imbalance and evaluation metrics
Inactivity labels are structurally imbalanced — typically 10–30% positive for a 30-day food-delivery horizon. Standard accuracy is useless: a model predicting all zeros scores 70–90% accuracy while identifying nobody.
Choose the right metrics:
| Metric |
When to use |
| AUC-ROC |
Primary metric — threshold-free overall quality. Target > 0.75; > 0.85 is strong; > 0.95 → investigate leakage first. |
| Precision@K |
When the model drives a fixed-capacity intervention (e.g., reach 1,000 customers/week). "Of the K flagged, what share actually go inactive?" |
| Recall@K |
When missing inactive customers is costly. "Of all who go inactive, what share did we catch?" |
| PR-AUC |
Prefer over ROC-AUC when positive rate < 5% — ROC is optimistic at very low positive rates. |
| Accuracy |
Never. |
Handle the imbalance:
- Class weights first. In scikit-learn:
class_weight='balanced'; in XGBoost/LightGBM: scale_pos_weight = n_negatives / n_positives. Reweights the loss without touching the data — do this before anything else.
- Threshold calibration. The 0.5 default threshold is wrong for imbalanced data. Sweep thresholds on a held-out validation set and choose based on your intervention capacity — e.g., "we can reach 1,000 customers/week; maximise recall at that volume." Use the precision-recall curve to select the threshold, not the ROC curve.
- If oversampling (SMOTE): apply within each cutoff cohort separately. Oversampling across cutoffs leaks — synthetic rows built from a June cutoff using July neighbours import July's label signal into June's training rows.
Output: the model produces a ranked score (propensity to go inactive), not a binary flag. Downstream teams select the top-K scores for intervention. Threshold selection is a business decision — intervention capacity and cost of false positives — not a model hyperparameter.
Step 7 — Version and document the feature spec
For every feature pipeline:
- Save a
feature_spec.yaml listing every feature, its source table, its rolling window (if any), and its as-of semantics.
- Hash the feature spec and include the hash in the model artifact name. Feature/model version drift is a top-three cause of production model regressions.
- Re-runs of the pipeline with the same
(cutoff_date, feature_spec_hash, raw_data_snapshot) must produce identical output. If they don't, you have a determinism bug — usually an unordered groupby, a random sample, or a datetime.now() call sneaking in.
Persist encoders alongside the model
Every encoder fit on train must be saved so it can be re-applied at inference. A single encoders.json or .pkl alongside the model is the standard pattern:
encoders = {
"acquisition_channel": {
"kind": "frequency",
"mapping": {"organic": 0.42, "paid_search": 0.31, "referral": 0.18, ...},
},
"restaurant_id": {
"kind": "target_smooth",
"global_mean": 0.07,
"smoothing": 50.0,
"category_to_value": {"r_001": 0.04, "r_002": 0.11, ...},
},
# hash encoder entries (e.g. promotion_code) store only n_buckets + algorithm — no per-value mapping.
}
Unseen-category fallbacks at inference:
| Encoder kind |
Fallback when category not in mapping |
frequency |
0.0 (never-seen → no signal) |
target_smooth |
global_mean |
time_aware_target |
global_mean for the relevant cutoff |
hash |
Works for any input — no fallback needed |
| one-hot |
All zeros (add an explicit unknown column if unseen values are expected) |
Add a unit test: re-apply the encoders to a held-out customer at synthetic cutoff_date = now, compare against features produced by the training pipeline at the same cutoff. Mismatches mean train/serve skew.
Persist the feature filter
The output of Step 6b (filter_info dict — top_features, max_corr, top_k, mi_ranking) is part of the model contract. Save it next to encoders.json and apply at inference via apply_feature_filter(X, filter_info).
Helpers
Reusable pandas helpers in scripts/inactivity_features.py. Use these instead of re-implementing — they handle edge cases (empty windows, ties on cutoff timestamps, single-row groups) that ad-hoc implementations get wrong.
Joins & windows: as_of_join · compute_rfm_window · compute_rfm_multi_window
Lifecycle (3a): time_to_nth_event
Recency (3b): days_since_last_event · days_since_last_event_from_top_group · recency_decay_score
Frequency & cadence (3c): distinct_active_days_in_window · cadence_stats
Trends (3e): build_daily_series · compute_usage_slope
Concentration & diversity (3f): concentration_hhi · shannon_entropy · top_group_share · categorical_distribution_shares
Support tickets (3h): tickets_open_at_cutoff · resolution_rate
Cross-event attribution (3i): events_within_window_of_anchor
Encoders (Step 4): target_encode_oof · target_encode_time_aware · hash_encode
Validation (Step 6a): assert_no_future_leakage
Feature filtering (Step 6b): filter_features_correlation_mi · apply_feature_filter
Reference files
- references/schema.md — canonical schema, column dtypes, mapping template
- references/leakage.md — catalogue of subtle leakage traps
- scripts/inactivity_features.py — pandas helpers (including all encoders)
1---2name: customer-inactivity3description: Build leakage-safe features for banking customer inactivity and dormancy models. Use this skill whenever the goal is to predict whether a customer will become inactive, dormant, or disengaged over the next 30/60/90 days — for example, no card usage, no digital engagement, no payments, no spend, or no product activity. Covers lifecycle, recency, frequency, monetary behavior, spend trends, digital engagement, servicing and risk signals, and explicit first-vs-last-period comparisons. This is the banking adaptation of the inactivity-feature workflow, tailored for credit cards, deposits, loan products, and customer behavior monitoring.4---56# Customer Inactivity / Dormancy Prediction for Banking78A prescriptive workflow for building feature matrices for banking customer inactivity models. Each row is `(customer_id, cutoff_date)` and **every feature must be computable from data observable before `cutoff_date`**. The objective is to predict whether a customer will become dormant or disengaged within a defined future window.910## Step 0 — Pin down the problem1112Before writing code, confirm the following:13141. **Inactivity definition.** Define inactivity as a customer showing no meaningful activity within the next `N` days. In banking, examples include:15 - no card transactions in the next 30/60/90 days16 - no digital login/app usage in the next 30 days17 - no payment / deposit / spend activity in the next 60 days18 - no engagement with the account or product family for the next 90 days192. **Cutoff date (`cutoff_date`).** The as-of date used for prediction. Features can only use data with `timestamp <= cutoff_date`.203. **Prediction horizon.** Common windows are 30, 60, and 90 days. Longer horizons help label stability; shorter horizons are more operationally useful.214. **Target semantics.** Decide whether inactivity is measured by card transactions only, total product activity, digital engagement, or a combination.2223If the user cannot define these, stop and clarify before engineering features.2425### Panel and label construction2627Create a panel of `(customer_id, cutoff_date)` rows for every customer with at least some recent activity before the cutoff.2829**Recommended cutoff pattern:**30- Daily or weekly snapshots for a period spanning at least 6–12 months31- Monthly snapshots for production stability3233**Right-censoring:** exclude rows where `cutoff_date + horizon > data_end_date`.3435```python36data_end_date = transactions["txn_ts"].max()37panel = panel[38 panel["cutoff_date"] + pd.Timedelta(days=horizon) <= data_end_date39]40```4142**Label logic:** `y = 1` if the customer has no qualifying activity in `(cutoff_date, cutoff_date + horizon]`.4344```python45horizon_td = pd.Timedelta(days=horizon)46merged = panel[["customer_id", "cutoff_date"]].merge(47 transactions[["customer_id", "txn_ts"]], on="customer_id", how="left"48)49active_in_window = (50 (merged["txn_ts"] > merged["cutoff_date"]) &51 (merged["txn_ts"] <= merged["cutoff_date"] + horizon_td)52)53active_pairs = (54 merged[active_in_window]55 .drop_duplicates(subset=["customer_id", "cutoff_date"])56 [["customer_id", "cutoff_date"]]57 .assign(had_activity=True)58)59panel = panel.merge(active_pairs, on=["customer_id", "cutoff_date"], how="left")60panel["y"] = (~panel["had_activity"].fillna(False)).astype(int)61```6263**Label definition must be explicit:** if inactivity means “no card usage,” use transaction activity only. If it means “no banking activity at all,” combine card, payment, login, and balance events.6465## Step 1 — Map the banking schema6667The banking version assumes the following canonical tables:6869| Table | Grain | Key columns |70|---|---|---|71| `customers` | one row per customer | `customer_id`, `signup_ts`, `dob`, `acquisition_channel`, `segment`, `kyc_level`, `account_status` |72| `cards` | one row per card/account | `card_id`, `customer_id`, `card_type`, `issue_ts`, `status`, `credit_limit`, `product_tier` |73| `transactions` | one row per transaction | `txn_id`, `customer_id`, `card_id`, `txn_ts`, `amount`, `merchant_category`, `merchant_id`, `channel`, `auth_result`, `fraud_flag`, `is_refund`, `is_adjustment` |74| `payments` | one row per payment | `payment_id`, `customer_id`, `card_id`, `payment_ts`, `amount`, `method`, `status` |75| `balances` | one row per account snapshot | `customer_id`, `date`, `current_balance`, `statement_balance`, `available_credit`, `credit_limit` |76| `digital_sessions` | one row per app/portal/login session | `customer_id`, `session_ts`, `session_id`, `channel`, `session_duration`, `event_type` |77| `applications` | one row per application | `application_id`, `customer_id`, `product`, `app_ts`, `app_status`, `decision_ts` |78| `support_tickets` | one row per customer service event | `ticket_id`, `customer_id`, `created_ts`, `resolved_ts`, `issue_type`, `severity`, `sentiment` |79| `bureaus` | one row per bureau pull | `customer_id`, `pull_ts`, `score`, `delinquency_counts`, `open_accounts`, `total_credit_limit` |80| `campaign_events` | marketing or cross-sell touchpoints | `customer_id`, `campaign_id`, `channel`, `event_ts`, `click_flag`, `presentment_id` |8182Map the real warehouse tables to these canonical fields before building features. Naming drift is a common source of silent model failure.8384## Step 2 — Leakage hygiene (non-negotiable)8586Every feature must be answerable from the perspective of the customer at `cutoff_date` without future information.8788Rules:89901. Filter all event tables by `timestamp <= cutoff_date` before aggregating.912. For mutable account state, reconstruct as-of values at `cutoff_date` instead of reading the current table.923. For late-settling events, use `settlement_ts` or `close_ts` with a `settlement_buffer_days` where needed.934. Exclude bureau pulls or decision data that occur after the cutoff.945. Exclude marketing or digital events that happen after the activity label starts.9596Examples of leakage traps in banking:97- using post-application approval/decision variables as features98- using future payment resolution or dispute outcomes before settlement99- using future bureau data or account updates introduced after the model snapshot100- using future card presentment events after the inactivity label begins101102## Step 3 — Window strategy103104Choose rolling windows based on the target horizon.105106Use this default set unless the use case strongly suggests otherwise:107- short: 7, 14, 30 days108- medium: 60, 90 days109- long: 180, 365 days110111For activity and churn modeling, a common pattern is:112- immediate recency: 7/14/30d113- baseline behavior: 60/90/180d114- long-term trend: 365d115116Record the window list in `feature_spec.yaml`.117118## Step 4 — Feature families119120Build features in independent families and validate leakage after each family.121122### 4.1 Lifecycle and customer context123- `tenure_days = cutoff_date - signup_ts`124- `days_since_first_txn`, `days_since_first_payment`, `days_since_first_card_issue`125- `is_new_customer_30d`, `is_new_customer_90d`126- `account_age_bucket` (new / active / mature / dormant)127- `customer_segment`, `acquisition_channel`, `kyc_level`128- `has_credit_card`, `has_debit_card`, `has_savings`, `has_loan`129- `n_active_products`, `product_depth_score`130131### 4.2 Recency features132These are often the strongest inactivity signals.133134- `days_since_last_txn`135- `days_since_last_payment`136- `days_since_last_login`137- `days_since_last_app_session`138- `days_since_last_email_open`139- `days_since_last_card_use`140- `days_since_last_support_contact`141- `active_in_last_7d`, `active_in_last_30d`, `active_in_last_90d`142- `days_since_last_merchant_category_usage` (top category)143- `days_since_last_product_usage` (top product family)144- `recency_decay_score = sum(exp(-days_ago / k))`145146### 4.3 Frequency and cadence features147- `txn_count_{7,14,30,60,90,180}d`148- `card_active_days_{30,90}d`149- `payment_count_{30,90}d`150- `login_count_{30,90}d`151- `app_session_count_{30,90}d`152- `avg_days_between_txns_90d`153- `median_days_between_txns_90d`154- `std_days_between_txns_90d`155- `is_overdue = days_since_last_txn > 1.5 * median_days_between_txns`156- `orders_per_week` analog in banking = `txns_per_week_90d`157158### 4.4 Monetary / spend features159- `total_spend_{30,90,180}d`160- `avg_txn_amount_{30,90}d`161- `median_txn_amount_{90d}`162- `max_txn_amount_{90d}`163- `std_txn_amount_{90d}`164- `spend_per_active_day_{30,90}d`165- `aov_last_30d / aov_lifetime`166- `spend_decline_pct_30d`, `spend_decline_pct_90d`167- `payment_amount_total_{30,90}d`168- `cashflow_share_by_category`169170### 4.5 Balance and utilization features171- `current_balance_total`, `statement_balance_total`, `available_credit_total`172- `credit_utilization_current`, `credit_utilization_30d`, `credit_utilization_90d`173- `utilization_trend_30d_vs_90d`174- `max_utilization_last_90d`175- `balance_drop_pct_30d`, `recent_balance_decline`176- `revolving_balance_share_{30,90}d`177- `credit_limit_growth_{90d}`178179### 4.6 Payments and repayment behavior180- `last_payment_days_ago`181- `on_time_payment_rate_{30,90,365}d`182- `missed_payment_count_{30,90,365}d`183- `payment_lag_mean_days`, `payment_lag_p90_days`184- `autopay_enrolled_flag`, `autopay_usage_rate_{90d}`185- `payment_method_share` by ACH / branch / transfer / cheque186- `payment_to_balance_ratio_{30d}`187188### 4.7 Delinquency, bureau, and default-risk signals189- `past_due_bucket_30d`, `_60d`, `_90d`190- `max_past_due_days_last_365d`191- `bureau_score_asof`, `bureau_active_derogatory_count`192- `bureau_total_balance`, `bureau_total_credit_limit`193- `recent_score_drop` and `bureau_score_trend_90d`194- `collections_contact_count_{90d}`195- `collections_amount_sum_{365d}`196197### 4.8 Fraud, chargeback, and servicing signals198- `chargeback_count_{90d,365d}`199- `chargeback_rate_{90d}`200- `fraud_flag_recent`201- `auth_failure_rate_{30d}`202- `suspicious_merchant_share_{90d}`203- `support_ticket_count_{30,90}d`204- `customer_complaint_rate_{180d}`205- `avg_support_sentiment_{90d}`206207### 4.9 Digital engagement features208- `digital_session_count_{7,30,90}d`209- `app_login_count_{30,90}d`210- `avg_session_duration_{30,90}d`211- `portal_usage_rate_{30d}`212- `digital_engagement_slope_60d`213- `mobile_vs_web_share`214- `email_open_rate_{30,90}d`215- `email_click_rate_{30,90}d`216- `last_app_login_days_ago`217218### 4.10 Trend and comparison features (critical)219These are essential for actual dormancy detection.220221- `txns_first_6m`, `txns_last_6m`, `txns_last6m_vs_first6m_ratio`222- `spend_first_6m`, `spend_last_6m`, `spend_last6m_vs_first6m_ratio`223- `payments_first_6m`, `payments_last_6m`, `payments_change_pct_6m`224- `txns_last_30d_vs_prev_30d`225- `spend_last_30d_vs_prev_30d`226- `digital_sessions_last_30d_vs_prev_30d`227- `txn_slope_30d`, `txn_slope_90d`228- `spend_slope_60d`, `spend_slope_90d`229- `trend_direction_90d` (up / flat / down)230- `recent_activity_drop_percent_30d`231- `usage_change_6m` and `engagement_change_6m`232233These answer the important business question: is the customer slowly disengaging or already dormant?234235### 4.11 Product and merchant affinity features236- `top_merchant_category_share_{90d}`237- `top_product_family_share_{90d}`238- `merchant_category_entropy_{90d}`239- `spend_share_grocery_{90d}`, `spend_share_travel_{90d}`240- `preferred_channel` (digital / branch / mobile / ATM)241- `product_affinity_score_card`242- `merchant_repeat_rate_{90d}`243- `new_merchant_share_{30d}`244245### 4.12 Interaction features246- `utilization × bureau_score_bucket`247- `recent_spend_drop × recent_login_drop`248- `payment_lag × delinquency_flag`249- `support_issue_count × no_txn_30d`250- `recent_chargeback_count × product_affinity_score`251- `digital_engagement × transaction_count`252253### 4.13 Geo and location features254- `n_distinct_cities_last_90d`255- `top_geo_share_90d`256- `geo_region_diversity`257- `travel_spend_share`258- `address_change_flag_last_180d`259260### 4.14 Seasonality and calendar features261- `month_of_year_signal`262- `holiday_spend_index`263- `weekday_vs_weekend_spend_share`264- `payday_proximity_flag`265- `statement_cycle_position`266267## Step 5 — Feature quality, missingness, and denominators268269- Always keep denominators with ratios: `total_txn_count`, `total_spend`, `sessions_count`, `payments_count`270- Replace zero denominators gracefully with `0` or a missing flag where appropriate271- For `days_since_*` features, use a sentinel such as `9999` and a companion `has_*` flag272- Record missingness flags for critical features where missingness itself signals risk273274## Step 6 — Leakage checklist for banking models275276- No future transaction or payment behavior after cutoff277- No future account status updates278- No future bureau pull or score after cutoff279- No post-outcome digital nudges/marketing events280- No late-settling refunds, chargebacks, or dispute resolution that occur after the observation window281282## Step 7 — Validation and selection283284- Check distribution drift across monthly cutoffs285- Evaluate target-rate stability by time period286- Remove constant or near-constant features287- Measure feature importance and SHAP values288- Remove highly collinear features with correlation pruning289- Keep business-relevant and interpretable features, not just a long raw list290291## Step 8 — Tests and reproducibility292293- Unit tests for each feature function294- Check zero-denominator handling295- Validate off-by-one logic with synthetic fixtures296- Use a feature registry to record each feature name, source, window, type, and owner297298## Step 9 — Production monitoring299300- Monitor PSI / drift for top features301- Alert on sudden null rate increases, stale feed delays, or distribution shifts302- Shadow-run new features and compare with baseline before rollout303- Track uplift in retention or dormancy recall vs production scorecards304305## Step 10 — Banking-specific summary306307This skill is designed for customer inactivity and dormancy modeling in financial services, especially for:308- card inactivity / dormancy risk309- debit and banking account disengagement310- digital engagement attrition311- product-level inactivity and cross-sell risk312- customer retention and activation monitoring313314The same core pattern generalizes across domains:315- lifecycle and tenure features316- recency-frequency-monetary features317- trend and period-comparison features318- product affinity and channel preference319- digital engagement and risk signals320- leakage-safe time windows and as-of logic321322## Deliverables323324- A feature-generation pipeline using a `(customer_id, cutoff_date)` panel325- A leakage-safe pipeline with as-of filtering326- A tested feature registry covering each feature family327- Reusable templates for other banking and non-banking domains328329This banking edition keeps the original modeling discipline of the inactivity workflow but rewrites the business context to customer dormancy in financial products.330- `n_positive_ratings_last_90d` — count of `rating >= 4`.331- `rating_trend_30d_vs_lifetime = avg_rating_last_30d - avg_rating_lifetime`. Falling ratings predict drop-off.332- `n_orders_after_low_rating_last_180d` — recovery behavior. Reordering after a 1–2 star rating is a forgiveness signal.333334### 3h. Support & complaint signals335336- `n_tickets_last_30d`, `n_tickets_last_90d`, `n_tickets_last_180d`337- `n_high_severity_tickets_last_90d`338- `days_since_last_ticket` (also in 3b — keep in whichever family makes sense for your team)339- `avg_resolution_hours_last_90d` — long resolution times correlate with frustration.340- `support_resolution_rate_last_90d = n_resolved_tickets / n_tickets` over tickets created in window.341- `n_tickets_open_at_cutoff` — created before cutoff, `resolved_ts` after cutoff or still NULL. See leakage notes.342- `tickets_per_order_last_90d` — frustration normalized by usage volume.343- If `sentiment_score` is available: `min_sentiment_last_90d`, `share_negative_sentiment_last_90d`.344- Distribution across `issue_type` last 90d — delivery vs payment vs app issues drive different inactivity dynamics.345346### 3i. Promotions & price-sensitivity features347348The food-delivery analog of "plan & pricing" — does the customer engage organically or only when discounted?349350- `voucher_usage_rate_last_90d` — share of orders with `voucher_used = True`.351- `n_voucherless_orders_last_30d` — organic engagement count.352- `share_orders_with_promotion_last_90d` — orders with `promotion_code` non-null.353- `n_distinct_promotion_codes_used_last_180d` — deal-hunter signal.354- `voucher_dependency_trend` — voucher usage rate last 30d vs prev 60d. Rising reliance on promos is a strong inactivity signal: the customer no longer values the product at full price.355- `avg_order_value_with_voucher_vs_without_last_90d` — price-sensitivity gap.356- `first_order_used_voucher` (binary) — was the activation order discounted? Customers who only ever ordered with a discount go inactive faster.357- `orders_within_7d_of_promo_email_last_90d` — marketing responsiveness. Join `emails` (where `email_type = 'promo'`) to subsequent `orders` within a 7-day attribution window.358- *Note*: the schema has no `discount_amount` column, so "average discount per order" can't be computed exactly. The voucher flag and promotion code identity are the only available proxies.359360### 3j. Cohort & acquisition361362- `acquisition_channel` (categorical)363- `signup_cohort_month` — use cautiously, can leak temporal trends; prefer relative cohort features below.364- Cohort-relative: `tenure_days_vs_cohort_median`, `orders_30d_vs_cohort_median`, `order_value_vs_cohort_median`.365- `signup_promo_flag` if derivable (signed up under a discount/trial).366367### 3k. Marketing engagement features368369Customers usually stop opening emails and pushes *before* they stop ordering — these are leading indicators.370371- `n_emails_received_last_30d`, `n_emails_opened_last_30d`, `n_emails_clicked_last_30d` — raw counts.372- `email_open_rate_last_90d = n_emails_opened / n_emails_received` (NaN if no emails sent — see Step 5).373- `email_click_rate_last_90d`374- `n_pushes_received_last_30d`, `n_pushes_opened_last_30d`375- `push_open_rate_last_90d`376- Trend: `email_open_rate_last_30d / email_open_rate_prev_60d` — declining engagement.377- Distribution of `email_type` opened last 90d — which content resonates (promo vs transactional vs reactivation).378379### 3l. Restaurant / merchant context380381These features describe the *quality* of the customer's order history. **Be careful** — many `restaurants` columns are mutable; see Step 2.382383- `avg_restaurant_rating_ordered_from_last_90d` — weighted by order count. **Caveat**: `restaurants.avg_rating` is dynamic. If you can't snapshot it at `cutoff_date`, drop this feature or use a historical version.384- `share_orders_from_brg_restaurants_last_90d` — same caveat for `is_brg`.385- `distinct_cities_ordered_in_last_180d` — travel / multi-location signal.386- If you have both `orders.customer_address` and `restaurants.restaurant_address`: approximate `avg_delivery_distance_last_90d`. Use a static geocoder; do not rely on a dynamic distance service.387388## Step 4 — Categorical encoding389390### Decision rules by cardinality391392| Cardinality | Approach |393|---|---|394| 2 (binary) | Cast to `int` (0/1). No encoder. |395| 3–10 | One-hot. Tree models handle them fine; linear models need them. |396| 10–~50 | **Frequency encoding** (`value → count(value) / N`). Cheap, leakage-free, often as strong as target encoding. Implement as `series.value_counts(normalize=True)` on the train set; save the dict, apply via `.map(dict).fillna(0)` at inference. |397| 50+ | **Target encoding with out-of-fold CV + smoothing** (`target_encode_oof`). Use `target_encode_time_aware` if the training set has multiple cutoff dates. |398| ≥10k | **Hash encoding** (`hash_encode`) — fixed bucket count, no per-value artifact. |399| Free text (`subject_line`, `content_summary`) | Out of scope for tabular FE; treat with a separate NLP pipeline. |400401### Per-column recommendations for this schema402403| Column | Cardinality | Recommended |404|---|---|---|405| `voucher_used`, `is_refunded`, `open_status`, `click_status` | 2 | Cast to int |406| `severity` (low/med/high/critical) | 4 | Ordinal (low=0…critical=3) — levels are ordered |407| `delivery_status`, `acquisition_channel`, `email_type`, `push_type`, `session_type` | 5–10 | One-hot |408| `issue_type` | 10–20 | Frequency encoding + per-value share columns (`categorical_distribution_shares`) |409| `cuisine` | 30–60 | Frequency encoding (also: entropy, top-cuisine-share via helpers) |410| `marketing_channel` | 10–30 | Frequency encoding |411| `city` | 50–500 | Frequency encoding; target encoding on very large platforms |412| `promotion_code` | 100s–1000s | Target encoding (OOF, smoothed). Hash if >10k |413| `restaurant_id` | 10k+ | Target encoding (smoothing 50+) OR hash to 256–1024 buckets |414| `postcode` from `customer_address` | 1k+ | Target encoding OR hash |415416When in doubt, frequency encoding is the safe default — never leaks, surprising signal.417418### Smoothing strength for target encoding419420`target_encode_oof` and `target_encode_time_aware` take a `smoothing` parameter. It pulls rare categories toward the global mean so a restaurant seen once with `y=1` doesn't get encoding `1.0`.421422| Smoothing | Behavior |423|---|---|424| 0 | No shrinkage. Rare categories overfit. Avoid. |425| 1–5 | Light. Use when most categories have ≥20 rows. |426| **10** | **Default.** Good balance for typical food-delivery cardinality. |427| 50–100 | Heavy. Use for `restaurant_id` and other long-tailed columns where many values have <5 rows. |428429### The biggest target-encoding bug430431Computing `mean(y | x)` on the full training set and then training on the same set: every row sees its own label in the encoding and the model memorizes the encoding. `target_encode_oof` does the right thing (out-of-fold).432433**Multi-cutoff training sets** add a second leak: OOF folds don't respect time, so a Jan row gets encoded using labels from July rows — and the July labels reflect events from Jan–Oct, leaking the future into the past. `target_encode_time_aware` fixes this by using only labels at strictly earlier cutoffs for each row. See leakage.md Trap 12.434435### CatBoost / LightGBM native handling436437Modern GBMs encode low-to-mid cardinality categoricals natively — pass `cuisine`, `marketing_channel`, `issue_type` directly. For high-cardinality columns (`restaurant_id`, `postcode`) and **any multi-cutoff training set**, use manual target/hash encoding — native encoders don't respect `cutoff_date` time semantics.438439## Step 5 — Missing-value semantics440441The right imputation depends on *why* a value is missing. Make the distinction explicit:442443| Pattern | Meaning | Treatment |444|---|---|---|445| `n_orders_30d = NaN` | No orders found in window | Replace with `0`. Missing = no activity. |446| `days_since_last_order = NaN` | Customer has never ordered (`has_ever_ordered = False`) | Replace with a large sentinel (e.g., `9999`) **and** keep the `has_ever_ordered` flag. The sentinel + flag combination lets tree models distinguish "never ordered" from "ordered long ago" cleanly. |447| `refund_rate_last_90d = NaN` | No orders in window (denominator is 0) | Replace with `0` **and** carry the denominator `n_orders_last_90d` as a separate feature so the model can weight the rate appropriately. |448| `email_open_rate_last_90d = NaN` | No emails sent (denominator is 0) | Same treatment as above — `0` plus the denominator. |449| `sentiment_score = NaN` | Ticket exists but sentiment service didn't score it | True unknown. Median-impute **and** add a `sentiment_missing` flag. |450| `rating = NaN` on an order | Customer didn't rate that order | True unknown. Aggregations like `avg_rating_given_last_90d` should ignore NaNs at aggregation time; never impute zero (zero ≠ "no rating"). |451| `address = NaN` | Customer never entered one | Flag as `address_missing`; do not invent a value. |452453Always add a `_missing` indicator column for true-unknown imputations. Tree models exploit them; linear models still benefit.454455## Step 6 — Validate and filter the feature matrix456457### 6a. Validation assertions458459Before handing off to modeling, assert:4604611. **No future timestamps.** For each row, the max timestamp of any event used to compute features is `<= cutoff_date`. Use `scripts/inactivity_features.py:assert_no_future_leakage`.4622. **Inactivity rate is sane.** Compute `mean(y)` and compare to the user's expected inactivity rate from product analytics. Off by an order of magnitude? Either the inactivity definition or the join is wrong — stop and debug.4633. **Feature distributions stable across cutoffs.** Food delivery has strong weekly and monthly seasonality (weekends, paydays, holidays). If the training set has multiple cutoff dates, plot key feature means by cutoff. Big jumps either reflect real seasonality (document and consider seasonality-adjusted features) or a data pipeline change (find and fix it).4644. **No constant columns.** Drop them.4655. **No columns perfectly correlated with the label.** If you find one, it is almost certainly leakage. Investigate before celebrating.4666. **Settlement-buffer check for refunds & delivery status.** Refunds and final delivery status sometimes settle days after the order. Confirm orders are fully settled as-of `cutoff_date` — e.g., only count orders with `order_ts <= cutoff_date - settlement_buffer_days`. Otherwise late-settling refunds sneak into "pre-cutoff" features.467468### 6b. Filter to ~50 features (correlation + mutual info, model-agnostic)469470After Step 3 the raw matrix typically has 100–300 columns. Many are redundant by construction — rolling-window twins like `orders_last_7d` and `orders_last_14d` are highly correlated, as are `aov_last_30d` and `aov_last_90d`. A long tail also has weak signal. Filter down to a model-agnostic shortlist (~50) before training: less wasted compute, cleaner feature-importance plots, easier debugging.471472**Model-agnostic** means: no XGBoost importance, no SHAP, no LightGBM gain — nothing that requires having trained a model first. The 50 you pick should be defensible regardless of which model you train next.473474**Three-step procedure (one call):**4754761. Encode categoricals to numeric (use OOF / time-aware encoding from Step 4 — raw target-encoded values leak).4772. Drop near-duplicates: for each pair with `|spearman| > 0.95`, drop the lower-MI one.4783. Rank survivors by mutual information against `y` and keep the top K.479480Use the helper from `scripts/inactivity_features.py`:481482```python483from inactivity_features import filter_features_correlation_mi, apply_feature_filter484485X_train_filtered, filter_info = filter_features_correlation_mi(486 X_train, y_train,487 top_k=50,488 max_corr=0.95,489 corr_method="spearman", # robust to skewed counts / amounts490 task="classification", # use "regression" for continuous outcomes491)492493# Persist filter_info alongside the model. Apply at val/test/inference:494X_val_filtered = apply_feature_filter(X_val, filter_info)495```496497`filter_info` contains the dropped columns, the MI ranking of survivors, the final top-K column list, and the thresholds used — persist it as part of the feature spec (Step 7).498499**Sanity checks before locking in the 50:**500501- **Family coverage.** The top-50 should include features from a healthy mix of families (3a–3l). All 50 from a single family usually means either leakage in that family (investigate!) or weak engineering in the others.502- **MI floor.** The 50th feature's MI shouldn't be near zero. If it is, the signal ceiling is lower than 50 — pick a smaller K (20–30).503- **Temporal stability.** Re-run the ranking on a held-out future cutoff. If the top-50 lists differ substantially between training and held-out cutoffs, the feature set is unstable — possibly leakage, possibly drift / seasonality. Investigate before shipping.504505**Why MI instead of Pearson-with-label?** Mutual information captures non-linear dependence (U-shapes, thresholds, interactions with a small set of values) that tree models exploit. Pearson misses these and ranks them as noise.506507**Continuous outcomes** (e.g., revenue rather than binary inactivity): swap `mutual_info_classif` → `mutual_info_regression`.508509**Tuning K.** 50 is a reasonable default for inactivity on millions of customers. Drop to 20–30 if labels are scarce or you need a small interpretable model; raise to 100+ if you have abundant data and a GBM backend that handles wide inputs well. Persist the chosen `K`, the correlation threshold (`0.95`), and the surviving column names in the feature spec (Step 7) — these are part of the model contract.510511### 6c. Class imbalance and evaluation metrics512513Inactivity labels are structurally imbalanced — typically 10–30% positive for a 30-day food-delivery horizon. Standard accuracy is useless: a model predicting all zeros scores 70–90% accuracy while identifying nobody.514515**Choose the right metrics:**516517| Metric | When to use |518|---|---|519| **AUC-ROC** | Primary metric — threshold-free overall quality. Target > 0.75; > 0.85 is strong; > 0.95 → investigate leakage first. |520| **Precision@K** | When the model drives a fixed-capacity intervention (e.g., reach 1,000 customers/week). "Of the K flagged, what share actually go inactive?" |521| **Recall@K** | When missing inactive customers is costly. "Of all who go inactive, what share did we catch?" |522| **PR-AUC** | Prefer over ROC-AUC when positive rate < 5% — ROC is optimistic at very low positive rates. |523| Accuracy | Never. |524525**Handle the imbalance:**526527- **Class weights first.** In scikit-learn: `class_weight='balanced'`; in XGBoost/LightGBM: `scale_pos_weight = n_negatives / n_positives`. Reweights the loss without touching the data — do this before anything else.528- **Threshold calibration.** The 0.5 default threshold is wrong for imbalanced data. Sweep thresholds on a held-out validation set and choose based on your intervention capacity — e.g., "we can reach 1,000 customers/week; maximise recall at that volume." Use the precision-recall curve to select the threshold, not the ROC curve.529- **If oversampling (SMOTE):** apply within each cutoff cohort separately. Oversampling across cutoffs leaks — synthetic rows built from a June cutoff using July neighbours import July's label signal into June's training rows.530531**Output:** the model produces a ranked score (propensity to go inactive), not a binary flag. Downstream teams select the top-K scores for intervention. Threshold selection is a business decision — intervention capacity and cost of false positives — not a model hyperparameter.532533## Step 7 — Version and document the feature spec534535For every feature pipeline:536537- Save a `feature_spec.yaml` listing every feature, its source table, its rolling window (if any), and its as-of semantics.538- Hash the feature spec and include the hash in the model artifact name. Feature/model version drift is a top-three cause of production model regressions.539- Re-runs of the pipeline with the same `(cutoff_date, feature_spec_hash, raw_data_snapshot)` must produce identical output. If they don't, you have a determinism bug — usually an unordered groupby, a random sample, or a `datetime.now()` call sneaking in.540541### Persist encoders alongside the model542543Every encoder fit on train must be saved so it can be re-applied at inference. A single `encoders.json` or `.pkl` alongside the model is the standard pattern:544545```python546encoders = {547 "acquisition_channel": {548 "kind": "frequency",549 "mapping": {"organic": 0.42, "paid_search": 0.31, "referral": 0.18, ...},550 },551 "restaurant_id": {552 "kind": "target_smooth",553 "global_mean": 0.07,554 "smoothing": 50.0,555 "category_to_value": {"r_001": 0.04, "r_002": 0.11, ...},556 },557 # hash encoder entries (e.g. promotion_code) store only n_buckets + algorithm — no per-value mapping.558}559```560561Unseen-category fallbacks at inference:562563| Encoder kind | Fallback when category not in mapping |564|---|---|565| `frequency` | `0.0` (never-seen → no signal) |566| `target_smooth` | `global_mean` |567| `time_aware_target` | `global_mean` for the relevant cutoff |568| `hash` | Works for any input — no fallback needed |569| one-hot | All zeros (add an explicit `unknown` column if unseen values are expected) |570571Add a unit test: re-apply the encoders to a held-out customer at synthetic `cutoff_date = now`, compare against features produced by the training pipeline at the same cutoff. Mismatches mean train/serve skew.572573### Persist the feature filter574575The output of Step 6b (`filter_info` dict — `top_features`, `max_corr`, `top_k`, `mi_ranking`) is part of the model contract. Save it next to `encoders.json` and apply at inference via `apply_feature_filter(X, filter_info)`.576577## Helpers578579Reusable pandas helpers in [scripts/inactivity_features.py](scripts/inactivity_features.py). Use these instead of re-implementing — they handle edge cases (empty windows, ties on cutoff timestamps, single-row groups) that ad-hoc implementations get wrong.580581**Joins & windows**: `as_of_join` · `compute_rfm_window` · `compute_rfm_multi_window`582583**Lifecycle (3a)**: `time_to_nth_event`584585**Recency (3b)**: `days_since_last_event` · `days_since_last_event_from_top_group` · `recency_decay_score`586587**Frequency & cadence (3c)**: `distinct_active_days_in_window` · `cadence_stats`588589**Trends (3e)**: `build_daily_series` · `compute_usage_slope`590591**Concentration & diversity (3f)**: `concentration_hhi` · `shannon_entropy` · `top_group_share` · `categorical_distribution_shares`592593**Support tickets (3h)**: `tickets_open_at_cutoff` · `resolution_rate`594595**Cross-event attribution (3i)**: `events_within_window_of_anchor`596597**Encoders (Step 4)**: `target_encode_oof` · `target_encode_time_aware` · `hash_encode`598599**Validation (Step 6a)**: `assert_no_future_leakage`600601**Feature filtering (Step 6b)**: `filter_features_correlation_mi` · `apply_feature_filter`602603## Reference files604605- [references/schema.md](references/schema.md) — canonical schema, column dtypes, mapping template606- [references/leakage.md](references/leakage.md) — catalogue of subtle leakage traps607- [scripts/inactivity_features.py](scripts/inactivity_features.py) — pandas helpers (including all encoders)