Trial conversion analysis — Eden Malka¶
Load Data¶
I load the train and test files, convert the date columns to real dates, and take a quick look at the size and time range. The train runs from 2023-01-01 to 2024-12-31 and the test runs from 2025-01-01 to 2025-03-31, so the split is clean and points to the future. I’ll keep the deeper checks (missing values, types, and more) for the next step to keep the flow simple and clear.
import pandas as pd
import numpy as np
# Load
train = pd.read_parquet("/content/Train.parquet")
test = pd.read_parquet("/content/Test.parquet")
# Safe datetime parsing
for c in ["DATE", "TRIAL_CONVERTED_TO_PURCHASE_DATE"]:
if c in train.columns:
train[c] = pd.to_datetime(train[c], errors="coerce")
if c in test.columns:
test[c] = pd.to_datetime(test[c], errors="coerce")
print("Loaded successfully.")
print("Train shape:", train.shape, "| Test shape:", test.shape)
# Tiny at-a-glance summary
def tiny_overview(df):
out = {
"rows": len(df),
"cols": df.shape[1],
"date_min": df["DATE"].min() if "DATE" in df.columns else None,
"date_max": df["DATE"].max() if "DATE" in df.columns else None,
}
return pd.Series(out)
display(pd.DataFrame({"train": tiny_overview(train), "test": tiny_overview(test)}))
# Optional quick peek (2 rows only)
display(train.head(2))
display(test.head(2))
Loaded successfully. Train shape: (66452, 35) | Test shape: (6898, 35)
| train | test | |
|---|---|---|
| rows | 66452 | 6898 |
| cols | 35 | 35 |
| date_min | 2023-01-01 00:00:00 | 2025-01-01 00:00:00 |
| date_max | 2024-12-31 00:00:00 | 2025-03-31 00:00:00 |
| CHANNEL_ID | CAMPAIGN_ID | DATE | TRIAL_CONVERTED_TO_PURCHASE_DATE | IS_TRIAL_CONVERTED_TO_PURCHASE | NUM_OF_INSTALLED_AGENTS | IP_COUNTRY | REGISTRATION_EMAIL_TYPE | COMPANY_TYPE | INITIAL_ATERA_GOAL | ... | 'technician added' | CLEARBIT_SECTOR | CLEARBIT_INDUSTRY_GROUP | CLEARBIT_INDUSTRY | VIEWED_PRICING_PAGE | HUBSPOT_CLICKED | SEGMENT_EVENTS | DISTINCT_SEGMENT_EVENTS | SEGMENT_PAGE_VIEWS | DISTINCT_SEGMENT_PAGE_VIEWS | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 37 | 406 | 2023-08-27 | NaT | False | 0.0 | France | business | Other | None | ... | NaN | None | None | None | False | 0.0 | 9.0 | 9.0 | NaN | NaN |
| 1 | 2 | 218 | 2023-12-03 | NaT | False | 1.0 | Romania | business | MSP | None | ... | NaN | None | None | None | True | 0.0 | 254.0 | 106.0 | 154.0 | 58.0 |
2 rows × 35 columns
| CHANNEL_ID | CAMPAIGN_ID | DATE | TRIAL_CONVERTED_TO_PURCHASE_DATE | IS_TRIAL_CONVERTED_TO_PURCHASE | NUM_OF_INSTALLED_AGENTS | IP_COUNTRY | REGISTRATION_EMAIL_TYPE | COMPANY_TYPE | INITIAL_ATERA_GOAL | ... | 'technician added' | CLEARBIT_SECTOR | CLEARBIT_INDUSTRY_GROUP | CLEARBIT_INDUSTRY | VIEWED_PRICING_PAGE | HUBSPOT_CLICKED | SEGMENT_EVENTS | DISTINCT_SEGMENT_EVENTS | SEGMENT_PAGE_VIEWS | DISTINCT_SEGMENT_PAGE_VIEWS | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 19 | 204 | 2025-01-13 | NaT | False | 0.0 | India | free | IT Department | None | ... | NaN | None | None | None | False | 0.0 | 36.0 | 30.0 | 25.0 | 12.0 |
| 1 | 52 | 218 | 2025-03-21 | NaT | False | 0.0 | Dominican Republic | business | IT Department | None | ... | NaN | None | None | None | False | 0.0 | 14.0 | 14.0 | NaN | NaN |
2 rows × 35 columns
After loading, I define the target and timeline helper fields: LABEL_120D (the true target: converted within 120 days), days_to_convert (sanity check only, not a feature), and trial_month (used mainly for monthly aggregation and time splits). These aren’t part of feature engineering; they align the task with the 120-day business goal and prevent leakage before I start creating real model features.
# 120-day label + time sanity
import numpy as np
import pandas as pd
# datetime coercion (idempotent)
for c in ["DATE", "TRIAL_CONVERTED_TO_PURCHASE_DATE"]:
if c in train.columns:
train[c] = pd.to_datetime(train[c], errors="coerce")
if c in test.columns:
test[c] = pd.to_datetime(test[c], errors="coerce")
# normalize raw label to 0/1 if needed
def to_bool_int(s):
if s.dtype == "bool":
return s.astype(int)
if s.dtype == "object":
return s.astype(str).str.lower().map({"true":1, "false":0}).fillna(0).astype(int)
return s.astype(int)
for df in (train, test):
if "IS_TRIAL_CONVERTED_TO_PURCHASE" in df.columns:
df["IS_TRIAL_CONVERTED_TO_PURCHASE"] = to_bool_int(df["IS_TRIAL_CONVERTED_TO_PURCHASE"])
# days_to_convert (train + test)
for df in (train, test):
if {"DATE","TRIAL_CONVERTED_TO_PURCHASE_DATE"}.issubset(df.columns):
df["days_to_convert"] = (df["TRIAL_CONVERTED_TO_PURCHASE_DATE"] - df["DATE"]).dt.days
df.loc[df["days_to_convert"] < 0, "days_to_convert"] = np.nan
else:
df["days_to_convert"] = np.nan
# precise 120-day target (train + test, but I won't use test label for training)
for df in (train, test):
if "IS_TRIAL_CONVERTED_TO_PURCHASE" in df.columns:
df["LABEL_120D"] = np.where(
(df["IS_TRIAL_CONVERTED_TO_PURCHASE"] == 1) & (df["days_to_convert"].le(120)),
1, 0
).astype(int)
else:
df["LABEL_120D"] = np.nan
# month key for later aggregation
for df in (train, test):
if "DATE" in df.columns:
df["trial_month"] = df["DATE"].dt.to_period("M").astype(str)
# stash test label for final evaluation only
y_test_holdout = test["LABEL_120D"].copy()
# sanity print
print({
"train_raw_pos": int(train["IS_TRIAL_CONVERTED_TO_PURCHASE"].sum()),
"train_pos_120d": int(train["LABEL_120D"].sum()),
"test_raw_pos": int(test["IS_TRIAL_CONVERTED_TO_PURCHASE"].sum()),
"test_pos_120d": int(test["LABEL_120D"].sum())
})
{'train_raw_pos': 3903, 'train_pos_120d': 3749, 'test_raw_pos': 501, 'test_pos_120d': 484}
train has 3,903 raw conversions and 3,749 within 120 days (154 late), while test has 501 raw and 484 within 120 days (17 late). The gap is small in both splits, so the LABEL_120D definition is consistent. The rough conversion rate is ~5.6% in train and ~7.0% in test, which suggests a slightly “hotter” test period (seasonality or campaign mix).
Checking Dataset Structure and Data Types¶
Before doing any cleaning, I want to check the structure of the dataset: how many rows I have, how complete each column is, and what type of data (numeric, categorical, boolean, etc.) it contains.
train.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 66452 entries, 0 to 66451 Data columns (total 38 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 CHANNEL_ID 66452 non-null int64 1 CAMPAIGN_ID 66452 non-null int64 2 DATE 66452 non-null datetime64[ns] 3 TRIAL_CONVERTED_TO_PURCHASE_DATE 3903 non-null datetime64[ns] 4 IS_TRIAL_CONVERTED_TO_PURCHASE 66452 non-null int64 5 NUM_OF_INSTALLED_AGENTS 66452 non-null float64 6 IP_COUNTRY 66452 non-null object 7 REGISTRATION_EMAIL_TYPE 66452 non-null object 8 COMPANY_TYPE 57830 non-null object 9 INITIAL_ATERA_GOAL 9809 non-null object 10 TECH_EMPLOYEES_RANGE 57850 non-null object 11 PREVIOUS_RMM_EXPERIENCE 9799 non-null object 12 IS_SURVEY_COMPLETED 66452 non-null bool 13 BUSINESS_REGION 65912 non-null object 14 'customer created' 1520 non-null float64 15 'ticket generated' 2030 non-null float64 16 'agent downloaded' 27194 non-null float64 17 'report generated' 4130 non-null float64 18 'remote connection- success' 8058 non-null float64 19 'threshold Profile created' 0 non-null float64 20 'it automation profile created' 5446 non-null float64 21 'run script' 1061 non-null float64 22 'alerts email set' 189 non-null float64 23 'invoice generate' 77 non-null float64 24 'discovered network' 0 non-null float64 25 'technician added' 2049 non-null float64 26 CLEARBIT_SECTOR 23791 non-null object 27 CLEARBIT_INDUSTRY_GROUP 23794 non-null object 28 CLEARBIT_INDUSTRY 23791 non-null object 29 VIEWED_PRICING_PAGE 66452 non-null bool 30 HUBSPOT_CLICKED 63786 non-null float64 31 SEGMENT_EVENTS 63927 non-null float64 32 DISTINCT_SEGMENT_EVENTS 63927 non-null float64 33 SEGMENT_PAGE_VIEWS 52931 non-null float64 34 DISTINCT_SEGMENT_PAGE_VIEWS 52931 non-null float64 35 days_to_convert 3903 non-null float64 36 LABEL_120D 66452 non-null int64 37 trial_month 66452 non-null object dtypes: bool(2), datetime64[ns](2), float64(19), int64(4), object(11) memory usage: 18.4+ MB
The training data has ~66,000 rows and now 38 columns (35 + three helpers: days_to_convert, LABEL_120D, trial_month). Most numeric fields are floats, categorical fields are objects, and there are two booleans; the date columns are real datetimes and the target LABEL_120D is 0/1. Several event columns are very sparse (e.g., 'agent downloaded') and a couple look entirely empty ('threshold Profile created', 'discovered network'), which I plan to drop in cleaning. Overall, the structure looks consistent and ready for cleaning.
Feature Overview by Category¶
Before cleaning, I group the features by role so it’s clear how user behavior, business context, marketing exposure, and engagement metrics might affect conversion. I standardize all column names to lowercase with underscores and remove stray quotes so train/test stay aligned and no silent bugs sneak in. Then I rebuild the groups (dates, business, marketing, engagement, behavioral, and engineered helpers) and do a quick coverage check to confirm every column is mapped (38/38) with no duplicates, this keeps the upcoming preprocessing (missing values, encoding, feature engineering) simple and safe.
# Canonicalize column names (once for both train & test)
def clean_col(c: str) -> str:
c = str(c).strip()
if c.startswith("'") and c.endswith("'"):
c = c[1:-1] # drop surrounding single quotes
c = c.replace(" ", "_").replace("-", "_")
return c.lower()
train.rename(columns={c: clean_col(c) for c in train.columns}, inplace=True)
test.rename(columns={c: clean_col(c) for c in test.columns}, inplace=True)
for df in (train, test):
if "label_120d" not in df.columns and "LABEL_120D" in df.columns:
df.rename(columns={"LABEL_120D": "label_120d"}, inplace=True)
if "days_to_convert" not in df.columns and "days_to_convert" in df.columns:
pass
if "trial_month" not in df.columns and "trial_month" in df.columns:
pass
# Rebuild feature groups with cleaned names
dates_features = ["date", "trial_converted_to_purchase_date"]
target_col = "label_120d" # THE target (not a feature)
raw_label_col = "is_trial_converted_to_purchase" # keep tracked but ignored for X
business_features = [
"company_type", "initial_atera_goal", "previous_rmm_experience",
"clearbit_sector", "clearbit_industry_group", "clearbit_industry",
"tech_employees_range", "business_region", "ip_country",
"registration_email_type", "num_of_installed_agents", "is_survey_completed"
]
marketing_features = ["channel_id", "campaign_id", "viewed_pricing_page", "hubspot_clicked"]
engagement_features = [
"segment_events", "distinct_segment_events",
"segment_page_views", "distinct_segment_page_views"
]
behavioral_features = [
"customer_created", "ticket_generated", "agent_downloaded", "report_generated",
"remote_connection__success",
"it_automation_profile_created", "run_script",
"alerts_email_set", "invoice_generate", "technician_added",
"threshold_profile_created", "discovered_network"
]
# helpers not to be used as features (except trial_month for aggregation later)
engineered_helpers = ["days_to_convert", "trial_month"]
# Coverage check
grouped = (
dates_features + [target_col, raw_label_col] +
business_features + marketing_features +
engagement_features + behavioral_features +
engineered_helpers
)
print("Total columns in df:", train.shape[1])
print("Total columns mapped:", len(grouped))
print("Missing in mapping:", [c for c in train.columns if c not in grouped])
print("Duplicates in mapping:", [c for c in grouped if grouped.count(c) > 1])
Total columns in df: 38 Total columns mapped: 38 Missing in mapping: [] Duplicates in mapping: []
The mapping is complete and clean. I’ll treat label_120D as the only target, and keep is_trial_converted_to_purchase, days_to_convert, and trial_month for sanity checks and monthly aggregation only, these won’t enter the model inputs.
Now, I render a small HTML view of the same groups. The tags are built dynamically from what actually exists in train, so the view stays consistent even after renaming or dropping columns. It’s meant for my notebook (not the slides) and helps me see the table at a glance.
from IPython.display import display, HTML
# helper: render tags only for columns that actually exist
def tag_spans(cols, available):
cols = [c for c in cols if c in available]
return "\n".join([f'<span class="tag">{c}</span>' for c in cols])
available = set(train.columns) # assumes you've already cleaned names (lowercase, underscores)
# Groups (cleaned names)
dates_features = ["date", "trial_converted_to_purchase_date"]
target_feature = ["is_trial_converted_to_purchase"] # raw label (kept for reference, not used as X)
behavioral_features = [
"customer_created", "ticket_generated", "agent_downloaded",
"report_generated", "remote_connection__success",
"it_automation_profile_created", "run_script",
"alerts_email_set", "invoice_generate", "technician_added",
"threshold_profile_created", "discovered_network"
]
business_features = [
"company_type", "initial_atera_goal", "previous_rmm_experience",
"clearbit_sector", "clearbit_industry_group", "clearbit_industry",
"tech_employees_range", "business_region", "ip_country",
"registration_email_type", "num_of_installed_agents", "is_survey_completed"
]
marketing_features = ["channel_id", "campaign_id", "viewed_pricing_page", "hubspot_clicked"]
engagement_features = [
"segment_events", "distinct_segment_events",
"segment_page_views", "distinct_segment_page_views"
]
# engineered / target helpers (not used as X, except trial_month for aggregation later)
engineered_helpers = ["days_to_convert", "label_120d", "trial_month"]
html_table = f"""
<style>
.feature-box {{
border: 1px solid #ddd; border-radius: 12px; padding: 18px; margin: 12px 0;
background-color: #fafafa; font-family: 'Arial', sans-serif;
}}
.feature-title {{ font-weight: bold; font-size: 16px; color: #1A5276; }}
.feature-desc {{ margin-top: 8px; color: #333; font-size: 14px; }}
.tag {{
background-color: #f0f0f0; border-radius: 8px; padding: 4px 8px; margin: 3px;
display: inline-block; font-size: 13px; color: #2c3e50; font-family: monospace;
}}
</style>
<div class="feature-box">
<div class="feature-title">🟦 User Behavioral Features</div>
<div class="feature-desc">
Actions performed during the trial that reflect engagement. Missing values typically mean the action did not occur.
</div>
<div>
{tag_spans(behavioral_features, available)}
</div>
</div>
<div class="feature-box">
<div class="feature-title">🟧 Business / Contextual Features</div>
<div class="feature-desc">
Company-level attributes and Clearbit enrichment (sector, industry, team size, region).
</div>
<div>
{tag_spans(business_features, available)}
</div>
</div>
<div class="feature-box">
<div class="feature-title">🟩 Marketing / Campaign Features</div>
<div class="feature-desc">
Channel and campaign identifiers plus pricing/HubSpot interactions.
</div>
<div>
{tag_spans(marketing_features, available)}
</div>
</div>
<div class="feature-box">
<div class="feature-title">🟨 Engagement Metrics</div>
<div class="feature-desc">
Quantitative activity signals such as tracked events and page views.
</div>
<div>
{tag_spans(engagement_features, available)}
</div>
</div>
<div class="feature-box">
<div class="feature-title">⬜ Dates & Raw Target</div>
<div class="feature-desc">
Registration and conversion dates, and the original label kept for reference (not used as features).
</div>
<div>
{tag_spans(dates_features + target_feature, available)}
</div>
</div>
<div class="feature-box">
<div class="feature-title">🟪 Engineered / Target Helpers</div>
<div class="feature-desc">
Helper fields used for the 120-day target and monthly aggregation. <b>Not used as model inputs.</b>
</div>
<div>
{tag_spans(engineered_helpers, available)}
</div>
</div>
"""
display(HTML(html_table))
Now the dataset is organized visually. These categories will directly guide how I handle missing values, encoding, and feature engineering later on, for example, filling missing behavioral fields with 0 (no action), and business fields with “Unknown” when company data is missing.
Data Cleaning and Missing Values Overview¶
I audit missing values on the cleaned train table. I keep label_120d as the only target and exclude helper fields from the summary. I drop columns that are 100% empty, verify the updated shape, and plot the top missing features by group. This sets the imputation policy: behavioral → 0 (no action), business → “Unknown”, numeric engagement → median. Dates stay as datetimes for later logic.
# Missing values audit
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# safety: keep dates as datetime (idempotent)
for c in ["date", "trial_converted_to_purchase_date"]:
if c in train.columns:
train[c] = pd.to_datetime(train[c], errors="coerce")
# never touch these
protect = {"label_120d", "is_trial_converted_to_purchase", "days_to_convert", "trial_month"}
# drop columns that are 100% missing (except protected)
all_null = [c for c in train.columns if c not in protect and train[c].isna().all()]
if all_null:
print("Dropped 100% missing:", all_null)
train.drop(columns=all_null, inplace=True)
else:
print("No 100% missing columns.")
print("Updated shape:", train.shape)
# build missing summary excluding targets/helpers
exclude = protect
cols_for_missing = [c for c in train.columns if c not in exclude]
missing_percent = (train[cols_for_missing].isna().sum() / len(train)) * 100
missing_percent = missing_percent[missing_percent > 0].sort_values(ascending=False)
print("\nTop 15 missing (%):\n", missing_percent.head(15).round(2))
# feature groups (clean names)
behavioral_features = [
"customer_created","ticket_generated","agent_downloaded","report_generated",
"remote_connection__success","it_automation_profile_created","run_script",
"alerts_email_set","invoice_generate","technician_added",
"threshold_profile_created","discovered_network" # may already be dropped
]
business_features = [
"company_type","initial_atera_goal","previous_rmm_experience",
"clearbit_sector","clearbit_industry_group","clearbit_industry",
"tech_employees_range","business_region","ip_country",
"registration_email_type","num_of_installed_agents","is_survey_completed"
]
date_features = ["date","trial_converted_to_purchase_date"]
# color by group
colors = []
for col in missing_percent.head(15).index:
if col in behavioral_features: colors.append("#5DADE2") # behavioral
elif col in business_features: colors.append("#F5B041") # business
elif col in date_features: colors.append("#BFC9CA") # dates
else: colors.append("#D5DBDB") # other
# plot
plt.figure(figsize=(10, 5))
ax = missing_percent.head(15).plot(kind="bar", color=colors, edgecolor="gray")
plt.title("Top 15 Columns with Missing Values (%)")
plt.ylabel("Percent of Missing Values")
plt.xticks(rotation=45, ha="right")
for i, v in enumerate(missing_percent.head(15).round(1)):
ax.text(i, v + 1, f"{v:.1f}%", ha="center", fontsize=9)
plt.figlegend(
handles=[
plt.Line2D([0],[0], color="#5DADE2", lw=6, label="User Behavioral"),
plt.Line2D([0],[0], color="#F5B041", lw=6, label="Business / Context"),
plt.Line2D([0],[0], color="#BFC9CA", lw=6, label="Dates"),
],
loc="lower center", ncol=3, bbox_to_anchor=(0.5,-0.25)
)
plt.tight_layout()
plt.show()
Dropped 100% missing: ['threshold_profile_created', 'discovered_network'] Updated shape: (66452, 36) Top 15 missing (%): invoice_generate 99.88 alerts_email_set 99.72 run_script 98.40 customer_created 97.71 ticket_generated 96.95 technician_added 96.92 trial_converted_to_purchase_date 94.13 report_generated 93.78 it_automation_profile_created 91.80 remote_connection__success 87.87 previous_rmm_experience 85.25 initial_atera_goal 85.24 clearbit_industry 64.20 clearbit_sector 64.20 clearbit_industry_group 64.19 dtype: float64
The plot shows the top 15 columns with the most missing data.
Most missing values appear in user behavioral features (blue), indicating actions not performed by users.
Business features (orange), such as INITIAL_ATERA_GOAL and Clearbit attributes,
show missing data due to incomplete external enrichment. For example, when a user signs up with a personal email instead of a business domain, the Clearbit API cannot retrieve company-related information like sector or industry.
Two columns:‘threshold Profile created’ and ‘discovered network’, were fully empty and removed earlier.
Both are behavioral features, likely representing actions (such as creating a threshold profile or using network discovery)
that users simply did not perform during the trial period, which explains why all values were missing.
Feature Engineering¶
After cleaning and exploring the dataset, I now want to enrich it with a few new features that could improve model performance.
My goal here is to capture more behavioral patterns and temporal trends that relate to user engagement and conversion.
By transforming date columns and combining user actions, I can provide the model with more meaningful numerical and categorical inputs that reflect how active and how fast users interact during the trial period.
# anchors for the heuristic
train["has_business_email"] = train["registration_email_type"].astype(str).str.lower().str.contains(
r"business|company|work|corp|b2b", regex=True, na=False
).astype(int)
test["has_business_email"] = test["registration_email_type"].astype(str).str.lower().str.contains(
r"business|company|work|corp|b2b", regex=True, na=False
).astype(int)
train["agent_presence"] = (train["num_of_installed_agents"].fillna(0) >= 1).astype(int)
test["agent_presence"] = (test["num_of_installed_agents"].fillna(0) >= 1).astype(int)
train["agents_bin"] = pd.cut(train["num_of_installed_agents"].fillna(0), [-1,0,1,4,9999], labels=["0","1","2-4","5+"])
test["agents_bin"] = pd.cut(test["num_of_installed_agents"].fillna(0), [-1,0,1,4,9999], labels=["0","1","2-4","5+"])
# compact behavioral intensity
behav_cols = [
"customer_created","ticket_generated","agent_downloaded","report_generated",
"remote_connection__success","it_automation_profile_created","run_script",
"alerts_email_set","invoice_generate","technician_added"
]
for df in (train, test):
for c in behav_cols:
if c in df.columns:
df[c] = (df[c].fillna(0) > 0).astype(int)
df["event_count"] = df[ [c for c in behav_cols if c in df.columns] ].sum(axis=1)
# simple intent ratios
for df in (train, test):
df["events_per_view"] = df["event_count"] / (1 + df.get("segment_page_views", pd.Series(0, index=df.index)).fillna(0))
df["pricing_intent"] = df["viewed_pricing_page"].astype(int) if "viewed_pricing_page" in df.columns else 0
df["email_x_agents"] = df["has_business_email"] * df["agent_presence"]
Feature Engineering Summary¶
| Feature | What | Why | How |
|---|---|---|---|
| has_business_email | 0/1 flag for business-like email | Mirrors legacy rule; intuitive for marketing | lowercase → regex contains → int |
| agent_presence | ≥1 installed agent | Core part of legacy logic | fillna(0) → >=1 → int |
| agents_bin | Buckets of agent counts: 0, 1, 2–4, 5+ |
Clear engagement tiers | pd.cut on [-1,0,1,4,∞); missing→0 |
| event_count | Count of distinct behaviors | Compact measure of trial engagement | each col: fillna(0) → >0 → sum |
| events_per_view | event_count / (1 + views) |
Normalizes actions by traffic | safe division to avoid zero & outliers |
| pricing_intent | Viewed pricing page (0/1) | Strong intent signal | bool → int; missing→0 |
| email_x_agents | Interaction: business email × agent presence | Captures legacy combo & exceptions | product of the two binary columns |
Define Feature Buckets & Guardrails¶
I’m laying out the feature map so the pipeline knows exactly how to treat each signal. Numbers get numeric handling, 0/1 flags stay binary, and text/IDs go through categorical encoding. I’m also carving out a strict “do-not-use” set (targets/dates/helpers) to kill any leakage before it starts.
# Feature lists & forbidden
# numeric (continuous counts/metrics)
num_cols = [c for c in [
"num_of_installed_agents", "segment_events", "distinct_segment_events",
"segment_page_views", "distinct_segment_page_views", "hubspot_clicked",
# engineered numeric
"event_count", "events_per_view"
] if c in train.columns]
# binary flags / behavioral events (0/1 semantics)
bin_cols = [c for c in [
# engineered anchors + intent + interaction
"has_business_email", "agent_presence", "pricing_intent", "email_x_agents",
# original behavior flags (coerced to 0/1)
"is_survey_completed", "customer_created", "ticket_generated", "agent_downloaded",
"report_generated", "remote_connection__success", "it_automation_profile_created",
"run_script", "alerts_email_set", "invoice_generate", "technician_added"
] if c in train.columns]
# categorical (to one-hot) — include engineered 'agents_bin'
cat_cols = [c for c in [
"agents_bin",
"company_type", "initial_atera_goal", "previous_rmm_experience",
"clearbit_sector", "clearbit_industry_group", "clearbit_industry",
"tech_employees_range", "business_region", "ip_country",
"registration_email_type", "channel_id", "campaign_id"
] if c in train.columns]
# columns that must NOT enter X (targets, helpers, time-leaky fields)
ignore_cols = {
"label_120d", "is_trial_converted_to_purchase", "days_to_convert",
"trial_converted_to_purchase_date", "trial_month"
}
# canonical feature list
feature_cols = [c for c in train.columns if c not in ignore_cols and c in (num_cols + bin_cols + cat_cols)]
print("num/bin/cat:", len(num_cols), len(bin_cols), len(cat_cols))
num/bin/cat: 8 15 13
The counts look healthy: 8 numeric, 15 binary, 13 categorical. That balance fits the data (lots of behavioral flags + a few traffic counts + rich IDs). Most importantly, none of the forbidden columns leaked into feature_cols, so training won’t peek at labels or time fields.
Time-Aware Split for Honest Future Validation¶
Training on the past and validating on the “future” keeps things real. I’m carving out the last three calendar months as a hold-out window so the model is judged on unseen, later periods closer to the actual deployment scenario. This avoids random shuffles that leak seasonal effects and makes our metrics much more trustworthy for business planning.
# Time-based split (last 3 months as validation)
months_sorted = train["trial_month"].sort_values().unique()
cut_month = months_sorted[-3]
is_val = train["trial_month"] >= cut_month
X_tr = train.loc[~is_val, feature_cols].copy()
y_tr = train.loc[~is_val, "label_120d"].astype(int)
X_val = train.loc[ is_val, feature_cols].copy()
y_val = train.loc[ is_val, "label_120d"].astype(int)
print({"train_rows": len(X_tr), "val_rows": len(X_val), "val_months": sorted(train.loc[is_val, "trial_month"].unique())})
{'train_rows': 59318, 'val_rows': 7134, 'val_months': ['2024-10', '2024-11', '2024-12']}
Got 59,318 train rows and 7,134 validation rows from months ['2024-10', '2024-11', '2024-12']. That’s a solid validation window (quarter-ish) with enough volume for stable PR/ROC/Calibration curves. The split is purely by trial_month, so there’s no time leakage. If performance drifts here, it likely reflects real seasonality/campaign shifts, exactly what I want to detect before moving to test.
Baseline heuristic: business email + at least one agent¶
Before comparing machine-learning models, I also create a simple baseline that mimics the current go-to-market heuristic.
The idea is:
- If a trial account has a business-type registration email and at least one installed agent,
the rule predicts that this trial will eventually convert. - Otherwise, it predicts no conversion.
I store this rule as a new column called baseline_pred (0/1) on the train and test sets.
Later, I compare this baseline against my ML models on the same validation and test periods –
both in terms of classic metrics (ROC/PR) and, more importantly, by how well each approach predicts
the total number of conversions per month and the performance of the Top-25 marketing campaigns.
# Baseline heuristic: "Business email + 1+ installed agents"
for df in (train, test):
# baseline_pred = 1 only when BOTH conditions are true
df.loc[:, "baseline_pred"] = (
(df["has_business_email"] == 1) &
(df["agent_presence"] == 1)
).astype(int)
Build a clean preprocess + balanced Logistic baseline¶
Here I standardize the input flow for the 120-day conversion task so every model sees the data in a consistent and leak-free way. Numeric usage signals (agents, events, page views) are imputed and scaled, binary behavior flags (pricing view, agent presence, survey, actions) stay as simple 0/1, and business IDs (campaign, channel, country, company traits) are one-hot encoded with rare levels folded together to avoid overfitting tiny buckets. On top of this preprocessing I add a class-balanced Logistic Regression, which gives me a clear and explainable baseline model that is easy to audit and later compare against more complex tree-based models.
# Preprocess + Logistic pipeline (baseline)
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
base_preprocess = ColumnTransformer(
transformers=[
("num", Pipeline([
("imp", SimpleImputer(strategy="median")),
("sc", StandardScaler())
]), num_cols),
("bin", SimpleImputer(strategy="most_frequent"), bin_cols),
("cat", Pipeline([
("imp", SimpleImputer(strategy="most_frequent")),
("ohe", OneHotEncoder(handle_unknown="ignore", min_frequency=30))
# consider min_frequency=100 if you see leakage from rare IDs
]), cat_cols),
],
remainder="drop",
sparse_threshold=0.3
)
logreg_pipe = Pipeline([
("prep", base_preprocess),
("clf", LogisticRegression(max_iter=1000, class_weight="balanced", random_state=42))
])
from sklearn import set_config
set_config(display="diagram")
logreg_pipe
Pipeline(steps=[('prep',
ColumnTransformer(transformers=[('num',
Pipeline(steps=[('imp',
SimpleImputer(strategy='median')),
('sc',
StandardScaler())]),
['num_of_installed_agents',
'segment_events',
'distinct_segment_events',
'segment_page_views',
'distinct_segment_page_views',
'hubspot_clicked',
'event_count',
'events_per_view']),
('bin',
SimpleImputer(strategy='...
min_frequency=30))]),
['agents_bin', 'company_type',
'initial_atera_goal',
'previous_rmm_experience',
'clearbit_sector',
'clearbit_industry_group',
'clearbit_industry',
'tech_employees_range',
'business_region',
'ip_country',
'registration_email_type',
'channel_id',
'campaign_id'])])),
('clf',
LogisticRegression(class_weight='balanced', max_iter=1000,
random_state=42))])In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Pipeline(steps=[('prep',
ColumnTransformer(transformers=[('num',
Pipeline(steps=[('imp',
SimpleImputer(strategy='median')),
('sc',
StandardScaler())]),
['num_of_installed_agents',
'segment_events',
'distinct_segment_events',
'segment_page_views',
'distinct_segment_page_views',
'hubspot_clicked',
'event_count',
'events_per_view']),
('bin',
SimpleImputer(strategy='...
min_frequency=30))]),
['agents_bin', 'company_type',
'initial_atera_goal',
'previous_rmm_experience',
'clearbit_sector',
'clearbit_industry_group',
'clearbit_industry',
'tech_employees_range',
'business_region',
'ip_country',
'registration_email_type',
'channel_id',
'campaign_id'])])),
('clf',
LogisticRegression(class_weight='balanced', max_iter=1000,
random_state=42))])ColumnTransformer(transformers=[('num',
Pipeline(steps=[('imp',
SimpleImputer(strategy='median')),
('sc', StandardScaler())]),
['num_of_installed_agents', 'segment_events',
'distinct_segment_events',
'segment_page_views',
'distinct_segment_page_views',
'hubspot_clicked', 'event_count',
'events_per_view']),
('bin', SimpleImputer(strategy='most_frequent'),
['has_bu...
SimpleImputer(strategy='most_frequent')),
('ohe',
OneHotEncoder(handle_unknown='ignore',
min_frequency=30))]),
['agents_bin', 'company_type',
'initial_atera_goal',
'previous_rmm_experience', 'clearbit_sector',
'clearbit_industry_group',
'clearbit_industry', 'tech_employees_range',
'business_region', 'ip_country',
'registration_email_type', 'channel_id',
'campaign_id'])])['num_of_installed_agents', 'segment_events', 'distinct_segment_events', 'segment_page_views', 'distinct_segment_page_views', 'hubspot_clicked', 'event_count', 'events_per_view']
SimpleImputer(strategy='median')
StandardScaler()
['has_business_email', 'agent_presence', 'pricing_intent', 'email_x_agents', 'is_survey_completed', 'customer_created', 'ticket_generated', 'agent_downloaded', 'report_generated', 'remote_connection__success', 'it_automation_profile_created', 'run_script', 'alerts_email_set', 'invoice_generate', 'technician_added']
SimpleImputer(strategy='most_frequent')
['agents_bin', 'company_type', 'initial_atera_goal', 'previous_rmm_experience', 'clearbit_sector', 'clearbit_industry_group', 'clearbit_industry', 'tech_employees_range', 'business_region', 'ip_country', 'registration_email_type', 'channel_id', 'campaign_id']
SimpleImputer(strategy='most_frequent')
OneHotEncoder(handle_unknown='ignore', min_frequency=30)
LogisticRegression(class_weight='balanced', max_iter=1000, random_state=42)
The diagram above shows how the data flows through three separate branches (numeric, binary, categorical) and then into the final classifier. From now on I can simply call logreg_pipe.fit(X_tr, y_tr) and logreg_pipe.predict_proba(X_val) without worrying about manual imputations or encodings.
Guardrails Check & Tiny Dry-Run (Leakage + Transformability)¶
I’m double-checking that my feature matrix is clean: no target/time helpers slipped into X, train/test both expose the same columns, and the preprocessing stack can actually transform a small slice without choking. This keeps the evaluation honest and prevents hidden schema bugs before fitting any model.
# Sanity checks (no leakage) + smoke test
import numpy as np
forbidden = {"label_120d","is_trial_converted_to_purchase","days_to_convert","trial_converted_to_purchase_date","trial_month"}
assert not any(c in forbidden for c in feature_cols), "Forbidden columns in X!"
missing_train = [c for c in feature_cols if c not in train.columns]
missing_test = [c for c in feature_cols if c not in test.columns]
print("Missing in TRAIN:", missing_train, " | Missing in TEST:", missing_test)
for c in missing_test:
test[c] = np.nan
# smoke: ensure transformer runs
_ = base_preprocess.fit_transform(X_tr.iloc[:200])
print("Preprocess.fit_transform[:200] -> OK")
Missing in TRAIN: [] | Missing in TEST: [] Preprocess.fit_transform[:200] -> OK
Schema’s aligned (no missing columns) and the tiny dry-run passed: imputers, scaler, and OHE work. Leakage is blocked, so it’s safe to train and compare models next.
Train the baseline logistic model and inspect PR/ROC/Calibration¶
Now that the preprocessing pipeline is ready, I fit the balanced Logistic Regression on the training window and evaluate it on the validation months.
I use the predicted probabilities on X_val to draw three diagnostic views:
- a Precision–Recall curve, which is more informative under class imbalance,
- an ROC curve, to see how well the model separates converters from non-converters,
- and a calibration (reliability) plot, to check whether the raw probabilities are trustworthy or systematically over/under-confident.
import matplotlib.pyplot as plt
from sklearn.metrics import (
precision_recall_curve, roc_curve,
average_precision_score, roc_auc_score, brier_score_loss
)
from sklearn.calibration import calibration_curve
plt.style.use("seaborn-v0_8-whitegrid")
def setup_figure(title):
plt.figure(figsize=(7, 6))
plt.title(title, fontsize=16, weight='bold')
plt.xlabel("", fontsize=14)
plt.ylabel("", fontsize=14)
# Fit model
logreg_pipe.fit(X_tr, y_tr)
p_val = logreg_pipe.predict_proba(X_val)[:, 1]
# ---- PR Curve ----
prec, rec, _ = precision_recall_curve(y_val, p_val)
plt.figure(figsize=(7, 6))
plt.plot(rec, prec, linewidth=2.5, color="#1B84E7")
plt.xlabel("Recall", fontsize=14)
plt.ylabel("Precision", fontsize=14)
plt.title("Precision–Recall Curve (Validation)", fontsize=16, weight="bold")
plt.grid(alpha=0.25)
plt.show()
# ---- ROC Curve ----
fpr, tpr, _ = roc_curve(y_val, p_val)
plt.figure(figsize=(7, 6))
plt.plot(fpr, tpr, linewidth=2.5, color="#2ECC71", label="ROC Curve")
plt.plot([0, 1], [0, 1], linestyle="--", color="#7f8c8d", linewidth=1.8)
plt.xlabel("False Positive Rate (FPR)", fontsize=14)
plt.ylabel("True Positive Rate (TPR)", fontsize=14)
plt.title("ROC Curve (Validation)", fontsize=16, weight="bold")
plt.legend()
plt.grid(alpha=0.25)
plt.show()
# ---- Calibration Curve ----
prob_true, prob_pred = calibration_curve(y_val, p_val, n_bins=10, strategy="quantile")
plt.figure(figsize=(7, 6))
plt.plot(prob_pred, prob_true, linewidth=2.5, marker="o", color="#8E44AD")
plt.plot([0, 1], [0, 1], "--", color="#7f8c8d", linewidth=1.8)
plt.xlabel("Predicted Probability", fontsize=14)
plt.ylabel("Observed Frequency", fontsize=14)
plt.title("Calibration Curve (Validation)", fontsize=16, weight="bold")
plt.grid(alpha=0.25)
plt.show()
# ---- Print scores ----
print({
"PR_AUC": float(average_precision_score(y_val, p_val)),
"ROC_AUC": float(roc_auc_score(y_val, p_val)),
"Brier": float(brier_score_loss(y_val, p_val))
})
{'PR_AUC': 0.3199754714071613, 'ROC_AUC': 0.8977654534180949, 'Brier': 0.12161006314446517}
On the validation window, the logistic baseline gets ROC-AUC ≈ 0.90 and PR-AUC ≈ 0.32, so it does a pretty good job ranking converters above non-converters. The ROC curve stays close to the top-left corner, and the PR curve starts with high precision when recall is low.
The Brier score (~0.12) and the calibration plot tell me something else: the model is too confident. When it predicts high probabilities (for example 0.6–0.8), the actual conversion rate in those bins is clearly lower. So as a ranker the model is strong, but its raw probabilities are not yet reliable enough for monthly forecasts. So next, I’ll calibrate the probabilities and then re-check the monthly MAE/RMSE.
Calibrate the logistic probabilities (isotonic)¶
As I saw, the raw logistic scores are good for ranking, but they are a bit over-confident as probabilities. Here I wrap the already-fitted logistic model with an isotonic calibration layer. I first pass the validation set through the preprocessing step, then fit an CalibratedClassifierCV on (Z_val, y_val), and define a helper function predict_calibrated that will return calibrated probabilities for any new data.
# CALIBRATION
from sklearn.calibration import CalibratedClassifierCV
calibrated_lr = CalibratedClassifierCV(
estimator=logreg_pipe.named_steps["clf"], # not base_estimator
cv="prefit",
method="isotonic"
)
Z_val = logreg_pipe.named_steps["prep"].transform(X_val)
calibrated_lr.fit(Z_val, y_val)
def predict_calibrated(pipe, calib, X):
Z = pipe.named_steps["prep"].transform(X)
return calib.predict_proba(Z)[:, 1]
/usr/local/lib/python3.12/dist-packages/sklearn/calibration.py:333: UserWarning: The `cv='prefit'` option is deprecated in 1.6 and will be removed in 1.8. You can use CalibratedClassifierCV(FrozenEstimator(estimator)) instead. warnings.warn(
The logistic weights themselves do not change; instead, their outputs are passed through a learned monotonic mapping so that predicted probabilities better match observed conversion rates. Later I will use predict_calibrated for the monthly forecasts and Top-25 campaign analysis, where the quality of the summed probabilities really matters.
Monthly business KPIs – raw vs calibrated logistic (VAL + TEST)¶
Here I translate the model’s probabilities into the business KPIs that marketing actually cares about.
For each month, I sum the predicted probabilities to obtain the predicted number of conversions, compare it to the true number of conversions, and compute two accuracy metrics: MAE and MAPE.
MAE (Mean Absolute Error) measures, on average, how far the model’s monthly predictions are from the true conversion counts.
Formally:
$$\text{MAE} = | \text{actual} - \text{predicted} |$$
Lower MAE = smaller absolute mistakes in the number of predicted conversions.MAPE (Mean Absolute Percentage Error) expresses the model’s error as a percentage of the true value:
$$\text{MAPE} = \frac{| \text{actual} - \text{predicted} |}{\text{actual}}$$
This is helpful because it tells marketing not just “how many conversions we missed,” but “how large the error is relative to the month’s size.”
I compute these KPIs twice:
- using the model’s raw logistic probabilities, and
- using the calibrated probabilities (after probability calibration).
I repeat the entire analysis on both the validation months and the held-out test months, allowing us to compare raw vs. calibrated performance and assess how well the model aligns with business outcomes in unseen data.
# Monthly aggregation for business KPI (Actual vs Predicted, MAE/MAPE)
def monthly_table(df_slice, p_hat, month_col="trial_month", target_col="label_120d"):
d = df_slice.copy()
d["p_hat"] = p_hat
out = d.groupby(month_col).agg(
actual =(target_col, "sum"),
predicted=("p_hat", "sum"),
trials =(target_col, "count")
).reset_index()
out["mae"] = (out["actual"] - out["predicted"]).abs()
out["mape"] = out["mae"] / out["actual"].replace(0, np.nan)
return out
# 1) Validation monthly KPIs – raw logistic
p_val = logreg_pipe.predict_proba(X_val)[:, 1]
val_monthly_lr = monthly_table(train.loc[is_val], p_val)
print("VAL monthly (LR raw):\n", val_monthly_lr, "\n",
"MAE mean:", float(val_monthly_lr["mae"].mean()),
"MAPE mean:", float(val_monthly_lr["mape"].mean()))
# 2) Validation monthly KPIs – calibrated logistic
p_val_cal = predict_calibrated(logreg_pipe, calibrated_lr, X_val)
val_monthly_lr_cal = monthly_table(train.loc[is_val], p_val_cal)
print("VAL monthly (LR calibrated):\n", val_monthly_lr_cal, "\n",
"MAE mean:", float(val_monthly_lr_cal["mae"].mean()),
"MAPE mean:", float(val_monthly_lr_cal["mape"].mean()))
# 3) Test monthly KPIs – raw and calibrated (same fitted logistic + calibration)
X_test = test[feature_cols].copy()
p_test = logreg_pipe.predict_proba(X_test)[:, 1]
test_monthly_lr = monthly_table(test, p_test)
print("TEST monthly (LR raw):\n", test_monthly_lr, "\n",
"MAE mean:", float(test_monthly_lr["mae"].mean()),
"MAPE mean:", float(test_monthly_lr["mape"].mean()))
p_test_cal = predict_calibrated(logreg_pipe, calibrated_lr, X_test)
test_monthly_lr_cal = monthly_table(test, p_test_cal)
print("TEST monthly (LR calibrated):\n", test_monthly_lr_cal, "\n",
"MAE mean:", float(test_monthly_lr_cal["mae"].mean()),
"MAPE mean:", float(test_monthly_lr_cal["mape"].mean()))
VAL monthly (LR raw): trial_month actual predicted trials mae mape 0 2024-10 146 630.471072 2365 484.471072 3.318295 1 2024-11 126 610.321957 2512 484.321957 3.843825 2 2024-12 115 472.740440 2257 357.740440 3.110786 MAE mean: 442.1778228205899 MAPE mean: 3.424302166422654 VAL monthly (LR calibrated): trial_month actual predicted trials mae mape 0 2024-10 146 151.268761 2365 5.268761 0.036087 1 2024-11 126 134.501415 2512 8.501415 0.067472 2 2024-12 115 101.229824 2257 13.770176 0.119741 MAE mean: 9.180117340246005 MAPE mean: 0.07443320430352669 TEST monthly (LR raw): trial_month actual predicted trials mae mape 0 2025-01 147 584.970517 2496 437.970517 2.979391 1 2025-02 179 621.903042 2172 442.903042 2.474319 2 2025-03 158 607.223917 2230 449.223917 2.843189 MAE mean: 443.3658255327576 MAPE mean: 2.765633098138703 TEST monthly (LR calibrated): trial_month actual predicted trials mae mape 0 2025-01 147 125.594742 2496 21.405258 0.145614 1 2025-02 179 137.199340 2172 41.800660 0.233523 2 2025-03 158 134.255182 2230 23.744818 0.150284 MAE mean: 28.983578530768913 MAPE mean: 0.17647363187729784
On both validation and test the pattern is clear: the raw logistic model heavily overestimates monthly conversions (roughly 4× too high), so its forecasts are not usable for the business. After isotonic calibration, the errors shrink dramatically – to about ~10 conversions per month on validation and ~20 on test – with MAPE in the low tens of percent. In practice, the calibrated logistic model gives a much more realistic estimate of “how many customers will actually convert each month”.
Set up a unified evaluator for model comparison¶
To compare different models in a fair and consistent way, I define a single evaluate_model helper that trains a fresh pipeline (preprocessing + classifier) and returns all the key metrics I care about on the validation window: PR-AUC, ROC-AUC, Brier score, Precision/Recall at Top-k%, and the average monthly MAE/MAPE from the aggregated forecast.
# Unified evaluator: trains pipe, returns metrics + monthly MAE/MAPE
from sklearn.base import clone
def evaluate_model(preprocess, clf, X_tr, y_tr, X_val, y_val, df_val_monthly, k=0.05, name="model"):
"""
preprocess: ColumnTransformer (will be cloned)
clf: sklearn classifier (will be cloned)
df_val_monthly: the original validation slice (train.loc[is_val]) for monthly aggregation
"""
pipe = Pipeline([("prep", clone(preprocess)), ("clf", clone(clf))])
pipe.fit(X_tr, y_tr)
p_val = pipe.predict_proba(X_val)[:,1]
# classifier metrics
pr_auc = average_precision_score(y_val, p_val)
roc_auc = roc_auc_score(y_val, p_val)
brier = brier_score_loss(y_val, p_val)
# Precision@k / Recall@k
cutoff = np.quantile(p_val, 1-k)
topk = (p_val >= cutoff).astype(int)
prec_k = ((topk & (y_val==1)).sum() / max(topk.sum(),1))
rec_k = ((topk & (y_val==1)).sum() / max((y_val==1).sum(),1))
# monthly KPIs
val_monthly = monthly_table(df_val_monthly, p_val)
mae_m = float(val_monthly["mae"].mean())
mape_m = float(val_monthly["mape"].mean())
return {
"model": name,
"PR_AUC": float(pr_auc),
"ROC_AUC": float(roc_auc),
"Brier": float(brier),
f"Precision@{int(k*100)}%": float(prec_k),
f"Recall@{int(k*100)}%": float(rec_k),
"Monthly_MAE_mean": mae_m,
"Monthly_MAPE_mean": mape_m,
"pipe": pipe, # return trained pipe (to plot PR/ROC for the winner)
"p_val": p_val, # to reuse for plots if needed
"val_monthly_table": val_monthly
}
Compare several candidate models on the same validation window¶
At this point I want to see how different classifiers behave under exactly the same preprocessing and time-based split. I set up a small “model race” where each candidate (balanced Logistic Regression, Random Forest, and XGBoost ) is wrapped with the same base_preprocess pipeline and evaluated with the unified evaluate_model helper. For each model I look at ranking quality (PR-AUC, ROC-AUC, Precision/Recall at Top-5%) and also at business-level accuracy via the mean monthly MAE/MAPE on the validation window.
# ============================================================
# Model race: RF + XGB from scratch, plus calibrated logistic
# ============================================================
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.base import clone
from sklearn.metrics import average_precision_score, roc_auc_score, brier_score_loss
import numpy as np
import pandas as pd
# Try to import XGBoost; if not installed
try:
from xgboost import XGBClassifier
has_xgb = True
except Exception:
has_xgb = False
print("NOTE: xgboost not installed; skipping XGBClassifier.")
# We let RF and XGB be trained via evaluate_model
models = {
"RandomForest": RandomForestClassifier(
n_estimators=400,
n_jobs=-1,
class_weight="balanced_subsample",
random_state=42,
),
}
if has_xgb:
models["XGBoost"] = XGBClassifier(
n_estimators=600,
max_depth=6,
learning_rate=0.05,
subsample=0.8,
colsample_bytree=0.8,
objective="binary:logistic",
eval_metric="logloss",
n_jobs=-1,
random_state=42,
)
race_results = []
artifacts = {}
k = 0.05 # top-5% cutoff for Precision@k / Recall@k
# RF + XGB via the generic evaluator ---
for name, clf in models.items():
res = evaluate_model(
base_preprocess,
clf,
X_tr,
y_tr,
X_val,
y_val,
train.loc[is_val],
k=k,
name=name,
)
race_results.append(
{k_: v for k_, v in res.items()
if k_ not in {"pipe", "p_val", "val_monthly_table"}}
)
artifacts[name] = res
# Add calibrated logistic as an explicit row
# p_val_cal and val_monthly_lr_cal come from the calibration chunk
pr_auc_cal = average_precision_score(y_val, p_val_cal)
roc_auc_cal = roc_auc_score(y_val, p_val_cal)
brier_cal = brier_score_loss(y_val, p_val_cal)
# Precision@top-5% and Recall@top-5%
cutoff = np.quantile(p_val_cal, 1 - k)
topk = (p_val_cal >= cutoff).astype(int)
tp = ((topk == 1) & (y_val == 1)).sum()
prec_k = tp / max(topk.sum(), 1)
rec_k = tp / max((y_val == 1).sum(), 1)
lr_cal_mae_mean = float(val_monthly_lr_cal["mae"].mean())
lr_cal_mape_mean = float(val_monthly_lr_cal["mape"].mean())
race_results.append({
"model": "LogReg_balanced (calibrated)",
"PR_AUC": float(pr_auc_cal),
"ROC_AUC": float(roc_auc_cal),
"Brier": float(brier_cal),
f"Precision@{int(k*100)}%": float(prec_k),
f"Recall@{int(k*100)}%": float(rec_k),
"Monthly_MAE_mean": lr_cal_mae_mean,
"Monthly_MAPE_mean": lr_cal_mape_mean,
})
# Build the comparison table ---
comp = (
pd.DataFrame(race_results)
.sort_values("PR_AUC", ascending=False)
.reset_index(drop=True)
)
print(comp)
model PR_AUC ROC_AUC Brier Precision@5% \ 0 XGBoost 0.319277 0.903129 0.042703 0.355742 1 LogReg_balanced (calibrated) 0.314559 0.901100 0.041848 0.296736 2 RandomForest 0.283013 0.883800 0.044060 0.299720 Recall@5% Monthly_MAE_mean Monthly_MAPE_mean 0 0.328165 8.475866 0.070143 1 0.516796 9.180117 0.074433 2 0.276486 7.778377 0.061511
import pandas as pd
from IPython.display import Markdown, display
# comparison table for the current `comp`
metric_cols = [
"PR_AUC",
"ROC_AUC",
"Brier",
"Precision@5%",
"Recall@5%",
"Monthly_MAE_mean",
"Monthly_MAPE_mean",
]
comp_plot = (
comp.copy()
.rename(columns={"model": "Model"})
[["Model"] + metric_cols]
)
higher_is_better = ["PR_AUC", "ROC_AUC", "Precision@5%", "Recall@5%"]
lower_is_better = ["Brier", "Monthly_MAE_mean", "Monthly_MAPE_mean"]
fmt_dict = {col: "{:.3f}" for col in metric_cols}
# Legend improved
legend_text = (
"<span style='font-size:16px; font-weight:bold;'>Legend:</span><br>"
"🟩 <b>Green (darker = better):</b> higher is better<br>"
"🟥 <b>Red (darker = worse):</b> lower is better (errors)"
)
display(Markdown(legend_text))
# Styled table with bigger caption
comp_style = (
comp_plot.style
.format(fmt_dict)
.background_gradient(subset=higher_is_better, cmap="Greens")
.background_gradient(subset=lower_is_better, cmap="Reds")
.set_caption("Performance Comparison Across Models")
.set_table_styles([
{
"selector": "caption",
"props": [
("font-size", "20px"),
("font-weight", "bold"),
("text-align", "center"),
("padding", "10px 0 10px 0")
]
}
])
)
display(comp_style)
Legend:
🟩 Green (darker = better): higher is better
🟥 Red (darker = worse): lower is better (errors)
| Model | PR_AUC | ROC_AUC | Brier | Precision@5% | Recall@5% | Monthly_MAE_mean | Monthly_MAPE_mean | |
|---|---|---|---|---|---|---|---|---|
| 0 | XGBoost | 0.319 | 0.903 | 0.043 | 0.356 | 0.328 | 8.476 | 0.070 |
| 1 | LogReg_balanced (calibrated) | 0.315 | 0.901 | 0.042 | 0.297 | 0.517 | 9.180 | 0.074 |
| 2 | RandomForest | 0.283 | 0.884 | 0.044 | 0.300 | 0.276 | 7.778 | 0.062 |
From this model race I see that XGBoost has the best overall ranking quality, with the highest PR-AUC (0.319) and ROC-AUC (0.903). The calibrated logistic model is very close (PR-AUC 0.315, ROC-AUC 0.901) and even slightly better in terms of Brier score, so its probabilities are well calibrated. Random Forest is clearly weaker on PR-AUC and ROC-AUC, although its monthly MAE/MAPE are still reasonable.
At the top-5% cutoff, XGBoost gives the highest precision (0.356), while the calibrated logistic model achieves the highest recall (0.517). For the business this means: if we want a very focused short-list of leads, XGBoost is a strong choice; if we care more about catching as many converters as possible with an interpretable model, the calibrated logistic model is a very appealing alternative.
XGBoost feature importance: permutation + SHAP on validation¶
# XGBoost feature importance: permutation + SHAP on validation
from sklearn.inspection import permutation_importance
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# Take the trained XGBoost pipeline from the model race
xgb_art = artifacts.get("XGBoost")
assert xgb_art is not None, "XGBoost artifacts not found – make sure the model race with XGBoost ran successfully."
xgb_pipe = xgb_art["pipe"]
# Permutation importance at *raw feature* level (easy to explain to marketing)
perm = permutation_importance(
xgb_pipe,
X_val, y_val,
n_repeats=5,
random_state=42,
n_jobs=-1
)
perm_df = pd.DataFrame({
"feature": feature_cols,
"importance_mean": perm.importances_mean,
"importance_std": perm.importances_std
}).sort_values("importance_mean", ascending=False)
print("Top-20 features by permutation importance (XGBoost):")
print(perm_df.head(20))
plt.figure(figsize=(8, 6))
top20 = perm_df.head(20).iloc[::-1] # reverse for nicer horizontal plot
plt.barh(top20["feature"], top20["importance_mean"])
plt.xlabel("Permutation importance (mean decrease in score)")
plt.title("XGBoost – Top-20 features (permutation importance)")
plt.tight_layout()
plt.show()
Top-20 features by permutation importance (XGBoost):
feature importance_mean importance_std
26 distinct_segment_events 0.007345 0.000655
7 tech_employees_range 0.000392 0.000456
20 technician_added 0.000392 0.000105
13 agent_downloaded 0.000280 0.000217
10 business_region 0.000280 0.000366
32 event_count 0.000252 0.000137
24 hubspot_clicked 0.000224 0.000228
12 ticket_generated 0.000168 0.000105
19 invoice_generate 0.000140 0.000089
17 run_script 0.000140 0.000000
34 pricing_intent 0.000084 0.000327
3 ip_country 0.000084 0.000339
9 is_survey_completed 0.000028 0.000105
18 alerts_email_set 0.000000 0.000000
6 initial_atera_goal 0.000000 0.000000
8 previous_rmm_experience 0.000000 0.000000
14 report_generated -0.000056 0.000112
2 num_of_installed_agents -0.000140 0.000217
16 it_automation_profile_created -0.000168 0.000056
31 agents_bin -0.000168 0.000186
# ----------------------------------------------
# SHAP Summary Plot
# ----------------------------------------------
import shap
import numpy as np
import matplotlib.pyplot as plt
# Initialize JS visualization (not needed for static plots, but no harm)
shap.initjs()
# Extract fitted parts
prep = xgb_pipe.named_steps["prep"]
xgb_clf = xgb_pipe.named_steps["clf"]
# Transform validation data
Z_val = prep.transform(X_val)
# Try to get feature names (OHE-expanded)
try:
feature_names_tx = prep.get_feature_names_out()
except Exception:
feature_names_tx = [f"f_{i}" for i in range(Z_val.shape[1])]
# SHAP explainer
explainer = shap.TreeExplainer(xgb_clf)
# Sample rows for speed
idx = np.random.choice(Z_val.shape[0], size=min(1000, Z_val.shape[0]), replace=False)
Z_sample = Z_val[idx]
# Compute SHAP values
shap_values = explainer(Z_sample)
# ----------------------------------------------
# Enhanced SHAP plot aesthetics
# ----------------------------------------------
plt.figure(figsize=(12, 8))
shap.summary_plot(
shap_values,
Z_sample,
feature_names=feature_names_tx,
max_display=20, # show only top 20 features
plot_type="dot", # THE NICE DIAGONAL PLOT
color=plt.get_cmap("viridis"), # professional color map
show=False
)
plt.title("Top-20 Feature Contributions Driving Conversion (SHAP)", fontsize=18, pad=20)
plt.xlabel("Impact on model output (positive → more likely to convert)", fontsize=14)
plt.ylabel("Features", fontsize=14)
# Improve tick font sizes
plt.xticks(fontsize=12)
plt.yticks(fontsize=12)
plt.tight_layout()
plt.show()
What drives conversion in the XGBoost model¶
The XGBoost results show that early product usage is the strongest driver of conversion.
The most influential feature is distinct_segment_events: users who try many different actions in their first days (installing agents, adding technicians, opening tickets, running scripts, generating invoices, etc.) are far more likely to convert. Other usage signals like event_count, ticket_generated, invoice_generate, run_script and agent_downloaded reinforce the same idea — deeper hands-on activity during the trial leads to higher adoption.
Beyond behaviour, the model also picks up company profile and intent signals. A larger technical team (tech_employees_range), certain regions (business_region, ip_country), and clear intent actions like pricing_intent or hubspot_clicked all push the score upward. Meanwhile, features such as alerts_email_set, initial_atera_goal, or previous_rmm_experience add almost no value, and a few others show tiny negative importance simply because their information is already captured by richer behavioural features.
In short: users with a real IT team, strong early engagement, and clear pricing/marketing intent are the most likely to convert. The old “business email + agents installed” rule still appears, but it explains only a small part of the story — detailed in-product behaviour matters much more.
Bar chart (Permutation Importance)¶
The bar chart highlights which raw features the model depends on most.
A longer bar means the model gets worse when we shuffle that feature. Here, distinct_segment_events stands out by a wide margin, followed by tech_employees_range, technician_added, agent_downloaded, business_region, event_count, hubspot_clicked, ticket_generated, invoice_generate, and run_script. Altogether, they point to a clear pattern: depth and variety of early usage + technical company profile + intent signals drive most of the model’s predictive power.
SHAP summary plot¶
The SHAP plot shows how each feature pushes predictions up or down.
Dots on the right side increase conversion probability; dots on the left decrease it. Red points represent high feature values; blue points low values. We see that high values of has_business_email, pricing_intent, segment_events, email_x_agents, num_of_installed_agents and tech-oriented company traits tend to push predictions toward “will convert,” while low-engagement or low-tech users sit near zero or pull the score down. This aligns with the permutation plot: engaged, tech-ready, high-intent users are consistently the most likely to convert.
Permutation Importance – Calibrated Logistic Regression (Comparison vs. XGBoost)¶
To complete the comparison with XGBoost, I also computed permutation importance for the calibrated logistic model.
from sklearn.metrics import average_precision_score
import numpy as np
import pandas as pd
# We reuse your calibrated logistic helper
# def predict_calibrated(pipe, calib, X):
# Z = pipe.named_steps["prep"].transform(X)
# return calib.predict_proba(Z)[:, 1]
def permutation_importance_logreg_cal(
base_pipe,
calibrator,
X,
y,
feature_names,
metric=average_precision_score,
n_repeats=20,
random_state=42,
):
"""
Compute permutation importance for the *calibrated* logistic model.
base_pipe : fitted sklearn Pipeline (with 'prep' + logistic)
calibrator : fitted calibration object (e.g., IsotonicRegression)
X, y : validation data (features + labels)
feature_names : list of feature column names to permute
metric : performance metric (default = PR-AUC)
n_repeats : how many shuffles per feature (stability)
"""
rng = np.random.default_rng(random_state)
# 1) Baseline performance on the original (unshuffled) data
p_base = predict_calibrated(base_pipe, calibrator, X)
base_score = metric(y, p_base)
results = []
# 2) Loop over features and measure how much the metric drops when shuffling each one
for feat in feature_names:
scores = []
for r in range(n_repeats):
# Copy the original X for each repeat to avoid accumulating noise
X_perm = X.copy()
# Shuffle this feature only
shuffled = X_perm[feat].to_numpy().copy()
rng.shuffle(shuffled)
X_perm[feat] = shuffled
# Recompute predictions and metric
p_perm = predict_calibrated(base_pipe, calibrator, X_perm)
scores.append(metric(y, p_perm))
scores = np.array(scores)
mean_score = scores.mean()
std_score = scores.std()
# Importance = how much the performance *drops* when permuting this feature
importance_mean = base_score - mean_score
importance_std = std_score
results.append({
"feature": feat,
"importance_mean": float(importance_mean),
"importance_std": float(importance_std),
})
imp_df = (
pd.DataFrame(results)
.sort_values("importance_mean", ascending=False)
.reset_index(drop=True)
)
return imp_df, float(base_score)
# ==== Run permutation importance on the calibrated logistic (VALIDATION) ====
# Make sure X_val is a DataFrame with the same columns as in training.
X_val_features = X_val[feature_cols].copy()
logreg_cal_importance, logreg_cal_pr_auc = permutation_importance_logreg_cal(
base_pipe=logreg_pipe,
calibrator=calibrated_lr,
X=X_val_features,
y=y_val,
feature_names=feature_cols,
metric=average_precision_score, # PR-AUC importance
n_repeats=20,
)
print("Baseline PR-AUC (calibrated logistic, validation):", logreg_cal_pr_auc)
print("Top-20 features by permutation importance (LogReg calibrated):")
print(logreg_cal_importance.head(20))
# ------------------------------------------
#
# ------------------------------------------
plt.figure(figsize=(12, 9))
top20 = logreg_cal_importance.head(20).iloc[::-1]
bars = plt.barh(
y=top20["feature"],
width=top20["importance_mean"],
color="#4A90E2",
edgecolor="none"
)
# Labels & title
plt.xlabel(
"Permutation importance (mean decrease in PR-AUC)",
fontsize=19
)
plt.ylabel("")
plt.title(
"Calibrated Logistic Regression – Top-20 Features\nPermutation Importance on Validation",
fontsize=19,
pad=18
)
# Axis ticks — slightly larger now
plt.yticks(fontsize=13)
plt.xticks(fontsize=13)
plt.grid(axis="x", linestyle="--", alpha=0.4)
plt.tight_layout()
plt.show()
Baseline PR-AUC (calibrated logistic, validation): 0.3145586050065097
Top-20 features by permutation importance (LogReg calibrated):
feature importance_mean importance_std
0 agents_bin 0.043108 0.006220
1 registration_email_type 0.039384 0.007011
2 campaign_id 0.038352 0.006275
3 pricing_intent 0.032436 0.004787
4 tech_employees_range 0.031597 0.005517
5 ip_country 0.027462 0.005606
6 segment_events 0.026960 0.004258
7 company_type 0.024533 0.009259
8 channel_id 0.024343 0.004000
9 business_region 0.021936 0.003435
10 distinct_segment_page_views 0.021343 0.002109
11 agent_presence 0.015118 0.002769
12 clearbit_sector 0.013100 0.002144
13 has_business_email 0.012607 0.003979
14 event_count 0.012326 0.001977
15 is_survey_completed 0.012208 0.004803
16 distinct_segment_events 0.011771 0.001833
17 clearbit_industry 0.011495 0.002650
18 email_x_agents 0.009728 0.002506
19 remote_connection__success 0.008412 0.001351
Comparing Logistic Regression vs. XGBoost Feature Signals¶
From the comparison, I see that XGBoost is driven mostly by real in-product behaviour how many actions the user performed and how deeply they explored the platform. The calibrated logistic model, in contrast, relies more on structured business and intent fields like agents_bin, email type, and campaign_id. So XGBoost captures richer usage patterns, while the logistic model focuses on clearer, higher-level attributes.
Final evaluation for the chosen model: XGBoost¶
from sklearn.metrics import precision_recall_curve, roc_curve
from sklearn.calibration import calibration_curve
import matplotlib.pyplot as plt
import pandas as pd
# Take the chosen model from the model race (XGBoost)
winner = "XGBoost"
xgb_res = artifacts[winner]
xgb_pipe = xgb_res["pipe"] # fitted on (X_tr, y_tr) inside evaluate_model
p_val_xgb = xgb_res["p_val"] # validation probabilities
print("Chosen model:", winner)
# PR and ROC curves on the validation window
prec, rec, thr = precision_recall_curve(y_val, p_val_xgb)
fpr, tpr, _ = roc_curve(y_val, p_val_xgb)
plt.figure()
plt.plot(rec, prec)
plt.xlabel("Recall"); plt.ylabel("Precision")
plt.title(f"PR Curve (Validation) — {winner}")
plt.show()
plt.figure()
plt.plot(fpr, tpr)
plt.plot([0, 1], [0, 1], '--')
plt.xlabel("FPR"); plt.ylabel("TPR")
plt.title(f"ROC Curve (Validation) — {winner}")
plt.show()
# Calibration plot for XGBoost on validation
prob_true, prob_pred = calibration_curve(y_val, p_val_xgb, n_bins=10, strategy="quantile")
plt.figure()
plt.plot(prob_pred, prob_true)
plt.plot([0, 1], [0, 1], '--')
plt.xlabel("Predicted")
plt.ylabel("Observed")
plt.title(f"Calibration (Validation) — {winner}")
plt.show()
# Refit XGBoost on TRAIN+VAL and evaluate monthly on TEST
X_tr_final = pd.concat([X_tr, X_val], axis=0)
y_tr_final = pd.concat([y_tr, y_val], axis=0)
xgb_pipe.fit(X_tr_final, y_tr_final)
X_test = test[feature_cols].copy()
p_test_xgb = xgb_pipe.predict_proba(X_test)[:, 1]
test_monthly_xgb = monthly_table(test, p_test_xgb)
print("TEST monthly (XGBoost raw):\n", test_monthly_xgb)
Chosen model: XGBoost
TEST monthly (XGBoost raw): trial_month actual predicted trials mae mape 0 2025-01 147 120.735725 2496 26.264275 0.178669 1 2025-02 179 134.668793 2172 44.331207 0.247660 2 2025-03 158 131.757980 2230 26.242020 0.166089
# Calibrate XGBoost probabilities and re-check monthly forecast
from sklearn.calibration import CalibratedClassifierCV
from sklearn.calibration import calibration_curve
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
# Grab the pre-fitted XGBoost pipeline from the model race
xgb_art = artifacts["XGBoost"]
xgb_pipe = xgb_art["pipe"]
p_val_xgb_raw = xgb_art["p_val"] # raw validation probabilities
xgb_clf_prefit = xgb_pipe.named_steps["clf"]
# Fit an isotonic calibrator on the validation window (cv="prefit")
calibrated_xgb = CalibratedClassifierCV(
estimator=xgb_clf_prefit,
cv="prefit",
method="isotonic"
)
Z_val_xgb = xgb_pipe.named_steps["prep"].transform(X_val)
calibrated_xgb.fit(Z_val_xgb, y_val)
def predict_calibrated_xgb(pipe, calib, X):
Z = pipe.named_steps["prep"].transform(X)
return calib.predict_proba(Z)[:, 1]
# Validation: monthly raw vs calibrated
val_monthly_xgb_raw = monthly_table(train.loc[is_val], p_val_xgb_raw)
print("VAL monthly (XGB raw):\n", val_monthly_xgb_raw, "\n",
"MAE mean:", float(val_monthly_xgb_raw["mae"].mean()),
"MAPE mean:", float(val_monthly_xgb_raw["mape"].mean()))
p_val_xgb_cal = predict_calibrated_xgb(xgb_pipe, calibrated_xgb, X_val)
val_monthly_xgb_cal = monthly_table(train.loc[is_val], p_val_xgb_cal)
print("VAL monthly (XGB calibrated):\n", val_monthly_xgb_cal, "\n",
"MAE mean:", float(val_monthly_xgb_cal["mae"].mean()),
"MAPE mean:", float(val_monthly_xgb_cal["mape"].mean()))
# Optional: calibration plot after calibration
prob_true_cal, prob_pred_cal = calibration_curve(
y_val, p_val_xgb_cal, n_bins=10, strategy="quantile"
)
plt.figure()
plt.plot(prob_pred_cal, prob_true_cal)
plt.plot([0, 1], [0, 1], "--")
plt.xlabel("Predicted")
plt.ylabel("Observed")
plt.title("Calibration (Validation) — XGBoost calibrated")
plt.show()
# 4) Test: monthly raw vs calibrated
X_test = test[feature_cols].copy()
p_test_xgb_raw = xgb_pipe.predict_proba(X_test)[:, 1]
test_monthly_xgb_raw = monthly_table(test, p_test_xgb_raw)
print("TEST monthly (XGB raw):\n", test_monthly_xgb_raw, "\n",
"MAE mean:", float(test_monthly_xgb_raw["mae"].mean()),
"MAPE mean:", float(test_monthly_xgb_raw["mape"].mean()))
p_test_xgb_cal = predict_calibrated_xgb(xgb_pipe, calibrated_xgb, X_test)
test_monthly_xgb_cal = monthly_table(test, p_test_xgb_cal)
print("TEST monthly (XGB calibrated):\n", test_monthly_xgb_cal, "\n",
"MAE mean:", float(test_monthly_xgb_cal["mae"].mean()),
"MAPE mean:", float(test_monthly_xgb_cal["mape"].mean()))
VAL monthly (XGB raw): trial_month actual predicted trials mae mape 0 2024-10 146 140.494614 2365 5.505386 0.037708 1 2024-11 126 125.318848 2512 0.681152 0.005406 2 2024-12 115 95.758942 2257 19.241058 0.167314 MAE mean: 8.475865681966146 MAPE mean: 0.07014254918354455
/usr/local/lib/python3.12/dist-packages/sklearn/calibration.py:333: UserWarning: The `cv='prefit'` option is deprecated in 1.6 and will be removed in 1.8. You can use CalibratedClassifierCV(FrozenEstimator(estimator)) instead. warnings.warn(
VAL monthly (XGB calibrated): trial_month actual predicted trials mae mape 0 2024-10 146 152.446042 2365 6.446042 0.044151 1 2024-11 126 128.387128 2512 2.387128 0.018945 2 2024-12 115 106.167257 2257 8.832743 0.076806 MAE mean: 5.888637675438076 MAPE mean: 0.04663429780582073
TEST monthly (XGB raw): trial_month actual predicted trials mae mape 0 2025-01 147 120.735725 2496 26.264275 0.178669 1 2025-02 179 134.668793 2172 44.331207 0.247660 2 2025-03 158 131.757980 2230 26.242020 0.166089 MAE mean: 32.27916717529297 MAPE mean: 0.19747254749927434 TEST monthly (XGB calibrated): trial_month actual predicted trials mae mape 0 2025-01 147 116.735122 2496 30.264878 0.205884 1 2025-02 179 131.280200 2172 47.719800 0.266591 2 2025-03 158 129.766261 2230 28.233739 0.178695 MAE mean: 35.40613902474676 MAPE mean: 0.21705637889402787
Why calibration slightly hurts XGB on the test months¶
The calibration step learns the probability scale from the last three validation months. If those months had unusually high activity or more conversions, the calibrator adjusts the model to fit that pattern. When we move to the test months, which have a different distribution, the calibrated model becomes slightly mis-aligned and under-predicts more. That’s why MAE/MAPE are a bit worse than the raw XGB.
XGBoost (raw & calibrated) vs baseline: metrics + monthly forecast¶
# XGBoost (raw & calibrated) vs baseline: metrics + monthly forecast
from sklearn.metrics import (
average_precision_score,
roc_auc_score,
brier_score_loss,
precision_recall_curve
)
import numpy as np
def summarize_model(y_true, p, k=0.05, label="model"):
"""Print standard classifier metrics + Precision/Recall@Top-k%."""
pr_auc = average_precision_score(y_true, p)
roc_auc = roc_auc_score(y_true, p)
brier = brier_score_loss(y_true, p)
# Precision/Recall@Top-k% (e.g. top-5%)
cutoff = np.quantile(p, 1 - k)
topk = (p >= cutoff).astype(int)
tp = ((topk == 1) & (y_true == 1)).sum()
prec_k = tp / max(topk.sum(), 1)
rec_k = tp / max((y_true == 1).sum(), 1)
print(f"\n=== {label} ===")
print({
"PR_AUC": float(pr_auc),
"ROC_AUC": float(roc_auc),
"Brier": float(brier),
f"Precision@{int(k*100)}%": float(prec_k),
f"Recall@{int(k*100)}%": float(rec_k),
})
# ---------- VALIDATION METRICS ----------
# XGBoost raw / calibrated on validation
summarize_model(y_val.values, p_val_xgb_raw, k=0.05, label="XGB_raw (VAL)")
summarize_model(y_val.values, p_val_xgb_cal, k=0.05, label="XGB_calibrated (VAL)")
# Baseline heuristic on validation (0/1 scores)
p_val_baseline = train.loc[is_val, "baseline_pred"].astype(float).values
summarize_model(y_val.values, p_val_baseline, k=0.05, label="Baseline rule (VAL)")
# Monthly VAL: XGB calibrated vs baseline
val_monthly_xgb_cal = monthly_table(train.loc[is_val], p_val_xgb_cal)
val_monthly_base = monthly_table(train.loc[is_val], p_val_baseline)
print("\nVAL monthly (XGB calibrated):\n", val_monthly_xgb_cal,
"\n MAE mean:", float(val_monthly_xgb_cal["mae"].mean()),
"MAPE mean:", float(val_monthly_xgb_cal["mape"].mean()))
print("\nVAL monthly (Baseline rule):\n", val_monthly_base,
"\n MAE mean:", float(val_monthly_base["mae"].mean()),
"MAPE mean:", float(val_monthly_base["mape"].mean()))
# ---------- TEST METRICS ----------
# XGBoost raw / calibrated on TEST (probabilities כבר חישבנו קודם)
summarize_model(test["label_120d"].values, p_test_xgb_raw, k=0.05, label="XGB_raw (TEST)")
summarize_model(test["label_120d"].values, p_test_xgb_cal, k=0.05, label="XGB_calibrated (TEST)")
# Baseline heuristic on TEST
p_test_baseline = test["baseline_pred"].astype(float).values
summarize_model(test["label_120d"].values, p_test_baseline, k=0.05, label="Baseline rule (TEST)")
# Monthly TEST: XGB calibrated vs baseline
test_monthly_xgb_cal = monthly_table(test, p_test_xgb_cal)
test_monthly_base = monthly_table(test, p_test_baseline)
print("\nTEST monthly (XGB calibrated):\n", test_monthly_xgb_cal,
"\n MAE mean:", float(test_monthly_xgb_cal["mae"].mean()),
"MAPE mean:", float(test_monthly_xgb_cal["mape"].mean()))
print("\nTEST monthly (Baseline rule):\n", test_monthly_base,
"\n MAE mean:", float(test_monthly_base["mae"].mean()),
"MAPE mean:", float(test_monthly_base["mape"].mean()))
=== XGB_raw (VAL) ===
{'PR_AUC': 0.31927728217171886, 'ROC_AUC': 0.9031291158593217, 'Brier': 0.042703368447574205, 'Precision@5%': 0.3557422969187675, 'Recall@5%': 0.3281653746770026}
=== XGB_calibrated (VAL) ===
{'PR_AUC': 0.640522479670993, 'ROC_AUC': 0.9601913224711989, 'Brier': 0.028924154612450043, 'Precision@5%': 0.6049723756906077, 'Recall@5%': 0.5658914728682171}
=== Baseline rule (VAL) ===
{'PR_AUC': 0.13153888658347962, 'ROC_AUC': 0.7401067140951534, 'Brier': 0.1944210821418559, 'Precision@5%': 0.17018469656992086, 'Recall@5%': 0.6666666666666666}
VAL monthly (XGB calibrated):
trial_month actual predicted trials mae mape
0 2024-10 146 152.446042 2365 6.446042 0.044151
1 2024-11 126 128.387128 2512 2.387128 0.018945
2 2024-12 115 106.167257 2257 8.832743 0.076806
MAE mean: 5.888637675438076 MAPE mean: 0.04663429780582073
VAL monthly (Baseline rule):
trial_month actual predicted trials mae mape
0 2024-10 146 551.0 2365 405.0 2.773973
1 2024-11 126 517.0 2512 391.0 3.103175
2 2024-12 115 448.0 2257 333.0 2.895652
MAE mean: 376.3333333333333 MAPE mean: 2.924266459942458
=== XGB_raw (TEST) ===
{'PR_AUC': 0.374085292346017, 'ROC_AUC': 0.8908964313601188, 'Brier': 0.052713943890637265, 'Precision@5%': 0.4318840579710145, 'Recall@5%': 0.30785123966942146}
=== XGB_calibrated (TEST) ===
{'PR_AUC': 0.3549690088425562, 'ROC_AUC': 0.8891237723780882, 'Brier': 0.05501084968169637, 'Precision@5%': 0.4275, 'Recall@5%': 0.35330578512396693}
=== Baseline rule (TEST) ===
{'PR_AUC': 0.15614457850034597, 'ROC_AUC': 0.7337210441003281, 'Brier': 0.214554943461873, 'Precision@5%': 0.19781553398058252, 'Recall@5%': 0.6735537190082644}
TEST monthly (XGB calibrated):
trial_month actual predicted trials mae mape
0 2025-01 147 116.735122 2496 30.264878 0.205884
1 2025-02 179 131.280200 2172 47.719800 0.266591
2 2025-03 158 129.766261 2230 28.233739 0.178695
MAE mean: 35.40613902474676 MAPE mean: 0.21705637889402787
TEST monthly (Baseline rule):
trial_month actual predicted trials mae mape
0 2025-01 147 531.0 2496 384.0 2.612245
1 2025-02 179 590.0 2172 411.0 2.296089
2 2025-03 158 527.0 2230 369.0 2.335443
MAE mean: 388.0 MAPE mean: 2.4145924404695758
Model Performance Comparison (TEST)¶
import matplotlib.pyplot as plt
import numpy as np
# -----------------------------------------------
# TEST metrics — only calibrated vs baseline
# -----------------------------------------------
test_data = {
"XGB Calibrated": [0.3549690088425562, 0.8891237723780882, 0.4275, 0.35330578512396693],
"Baseline Rule": [0.15614457850034597, 0.7337210441003281, 0.19781553398058252, 0.6735537190082644]
}
metric_names = ["PR-AUC", "ROC-AUC", "Precision@5%", "Recall@5%"]
models = list(test_data.keys())
# Convert to array
data = np.array([test_data[m] for m in models])
# -----------------------------------------------
# Plot TEST comparison
# -----------------------------------------------
plt.figure(figsize=(10,6))
x = np.arange(len(metric_names))
width = 0.35
plt.bar(x - width/2, data[0], width, label="XGB Calibrated", color="#50E3C2")
plt.bar(x + width/2, data[1], width, label="Baseline Rule", color="#9B9B9B")
plt.xticks(x, metric_names, fontsize=13)
plt.ylabel("Score (0–1)", fontsize=14)
plt.title("Model Comparison – TEST (Calibrated XGB vs Baseline)", fontsize=16, pad=10)
plt.grid(axis="y", linestyle="--", alpha=0.35)
plt.legend(fontsize=12)
# Add percentage labels above bars
for i in range(data.shape[0]):
for j in range(len(metric_names)):
plt.text(
x[j] + (i-0.5)*width,
data[i, j] + 0.01,
f"{data[i,j]*100:.1f}%",
ha="center",
fontsize=11
)
plt.tight_layout()
plt.show()
Monthly Conversions – Actual vs Predicted (TEST)¶
import matplotlib.pyplot as plt
# -----------------------------
# Data
# -----------------------------
months = ["2025-01", "2025-02", "2025-03"]
actual = [147, 179, 158]
xgb_pred = [116.735122, 131.280200, 129.766261]
base_pred = [531.0, 590.0, 527.0]
MAPE_xgb = 0.217 # ~21.7%
MAPE_base = 2.414 # ~241.4%
# -----------------------------
# Plot
# -----------------------------
plt.figure(figsize=(12,7))
# Actual
plt.plot(months, actual, marker="o", linewidth=3, label="Actual",
color="#1B84E7")
# XGB calibrated
plt.plot(months, xgb_pred, marker="o", linewidth=3, label=f"XGB Calibrated (MAPE: {MAPE_xgb*100:.1f}%)",
color="#50E3C2")
# Baseline
plt.plot(months, base_pred, marker="o", linewidth=3, label=f"Baseline Rule (MAPE: {MAPE_base*100:.1f}%)",
color="#9B9B9B")
# Title + labels
plt.title("Monthly Conversions – Actual vs Predicted (TEST)",
fontsize=18, pad=15)
plt.xlabel("Month", fontsize=14)
plt.ylabel("Conversions", fontsize=14)
# Grid
plt.grid(alpha=0.3, linestyle="--")
# Legend
plt.legend(fontsize=13, frameon=True, edgecolor="#DDDDDD")
# Tighter layout
plt.tight_layout()
plt.show()
How the calibrated XGBoost model performs vs. the current rule¶
From the performance charts, it's clear that the calibrated XGBoost model outperforms the legacy rule across every metric.
On the TEST set, the model shows much stronger discrimination (higher PR-AUC and ROC-AUC), and its Precision@5% is more than double the baseline, meaning the “hottest” leads it surfaces are genuinely more likely to convert.
The monthly forecast plot shows the same pattern:
while the baseline over-predicts by 350–400 conversions every month (MAPE ≈ 240%), the calibrated model stays close to reality, with errors around 20% and a stable month-to-month trend that actually follows the real curve.
In practice, this means the model gives marketing realistic monthly expectations and far cleaner high-intent segments, instead of inflating the pipeline with hundreds of false “high-potential” trials.
from sklearn.metrics import confusion_matrix, classification_report
import numpy as np
# Calibrated probabilities for validation and test
p_val_xgb_cal = predict_calibrated_xgb(xgb_pipe, calibrated_xgb, X_val)
X_test = test[feature_cols].copy()
p_test_xgb_cal = predict_calibrated_xgb(xgb_pipe, calibrated_xgb, X_test)
def confusion_at_topk(y_true, p_hat, k=0.05):
"""
Build a confusion matrix at a Top-k% operating point.
We choose the cutoff so that about k of the users are predicted as positive.
"""
cutoff = np.quantile(p_hat, 1 - k)
y_pred = (p_hat >= cutoff).astype(int)
cm = confusion_matrix(y_true, y_pred)
return cutoff, y_pred, cm
# Validation
cut_val, y_val_pred, cm_val = confusion_at_topk(y_val, p_val_xgb_cal, k=0.05)
print(f"Validation cutoff (Top-5%): {cut_val:.4f}")
print("Confusion matrix – validation (rows = true, cols = predicted):")
print(cm_val)
print("\nClassification report – validation:")
print(classification_report(y_val, y_val_pred, digits=3))
# Test
cut_test, y_test_pred, cm_test = confusion_at_topk(test["label_120d"].astype(int),
p_test_xgb_cal, k=0.05)
print(f"\nTest cutoff (Top-5%): {cut_test:.4f}")
print("Confusion matrix – test (rows = true, cols = predicted):")
print(cm_test)
print("\nClassification report – test:")
print(classification_report(test["label_120d"].astype(int), y_test_pred, digits=3))
Validation cutoff (Top-5%): 0.3711
Confusion matrix – validation (rows = true, cols = predicted):
[[6604 143]
[ 168 219]]
Classification report – validation:
precision recall f1-score support
0 0.975 0.979 0.977 6747
1 0.605 0.566 0.585 387
accuracy 0.956 7134
macro avg 0.790 0.772 0.781 7134
weighted avg 0.955 0.956 0.956 7134
Test cutoff (Top-5%): 0.3043
Confusion matrix – test (rows = true, cols = predicted):
[[6185 229]
[ 313 171]]
Classification report – test:
precision recall f1-score support
0 0.952 0.964 0.958 6414
1 0.427 0.353 0.387 484
accuracy 0.921 6898
macro avg 0.690 0.659 0.672 6898
weighted avg 0.915 0.921 0.918 6898
How good is the model at the Top-5% point?¶
When I look only at the top 5% of leads, the calibrated XGBoost model stays very focused but still captures a meaningful share of future converters.
In validation, the model filters out almost all non-buyers, and among the leads I do flag, about 60% actually convert.
On the unseen test months, the pattern is similar: most non-converters are filtered out, and about 43% of the leads I flag really end up buying.
In simple words: if marketing works only with the top 5% of trials I rank highest, they still cover a large portion of the future paying customers — while spending their time mostly on leads that actually have a real chance to buy, not on noise.
XGB calibrated vs Baseline¶
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import confusion_matrix
# ----------------------------------------------------
# Helper: compute predictions at a Top-k% operating point
# ----------------------------------------------------
def preds_at_topk(p_hat, k=0.05):
"""
Assign class labels using a Top-k% cutoff.
We sort predicted probabilities and label the top k% as '1'.
"""
cutoff = np.quantile(p_hat, 1 - k)
y_pred = (p_hat >= cutoff).astype(int)
return cutoff, y_pred
# ----------------------------------------------------
# Load TEST true labels
# ----------------------------------------------------
y_test = test["label_120d"].astype(int).values
# ----------------------------------------------------
# Predictions (Top 5%)
# ----------------------------------------------------
cut_xgb, y_pred_xgb = preds_at_topk(p_test_xgb_cal, k=0.05)
cut_base, y_pred_base = preds_at_topk(p_test_baseline, k=0.05)
cm_xgb = confusion_matrix(y_test, y_pred_xgb)
cm_base = confusion_matrix(y_test, y_pred_base)
print("XGB cutoff:", cut_xgb)
print("Baseline cutoff:", cut_base)
print("XGB CM:\n", cm_xgb)
print("Baseline CM:\n", cm_base)
# ----------------------------------------------------
# Color scheme: 4 intuitive colors for marketing slides
# ----------------------------------------------------
def get_cell_color(i, j):
"""
Returns a color for each cell in the confusion matrix:
- Green: True Positive
- Blue-gray: True Negative
- Orange: False Positive
- Red: False Negative
i = true label, j = predicted label
"""
if i == 1 and j == 1:
return "#4CAF50" # green (TP)
if i == 0 and j == 0:
return "#B0BEC5" # blue-gray (TN)
if i == 0 and j == 1:
return "#FFB74D" # orange (FP)
if i == 1 and j == 0:
return "#E57373" # red (FN)
# ----------------------------------------------------
# confusion matrix
# ----------------------------------------------------
def plot_cm_custom(cm, title, ax):
"""
Draws a confusion matrix with:
- Custom colors per error type
- Counts + percentages
- Clean labeling for slides
"""
total = cm.sum()
cm_pct = cm / total
# Axis setup
ax.set_xticks([0,1])
ax.set_yticks([0,1])
ax.set_xticklabels(["No convert", "Convert"], fontsize=11)
ax.set_yticklabels(["No convert", "Convert"], fontsize=11)
ax.set_xlabel("Predicted label", fontsize=11)
ax.set_ylabel("True label", fontsize=11)
ax.set_title(title, fontsize=14, pad=10)
# Draw colored rectangles + text
for i in range(2):
for j in range(2):
ax.add_patch(plt.Rectangle(
(j - 0.5, i - 0.5), 1, 1,
facecolor=get_cell_color(i, j),
edgecolor="white",
linewidth=2
))
ax.text(
j, i,
f"{cm[i, j]:,}\n({cm_pct[i, j]*100:.1f}%)",
ha="center", va="center",
fontsize=12, color="black", fontweight="bold"
)
# Fix axis limits
ax.set_xlim(-0.5, 1.5)
ax.set_ylim(1.5, -0.5)
# ----------------------------------------------------
# Plot: XGB calibrated vs Baseline (TEST only)
# ----------------------------------------------------
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
plot_cm_custom(
cm_xgb,
"Calibrated XGB – TEST (Top 5%)",
axes[0]
)
plot_cm_custom(
cm_base,
"Baseline Rule – TEST (Top 5%)",
axes[1]
)
plt.tight_layout()
plt.show()
XGB cutoff: 0.30434781312942505 Baseline cutoff: 1.0 XGB CM: [[6185 229] [ 313 171]] Baseline CM: [[5092 1322] [ 158 326]]
When I compare only the top 5% of leads, the calibrated XGB model gives marketing a much cleaner and more focused list.
It has around six times fewer false leads, so the team wastes far less time on accounts that were never going to convert.
The baseline does find more converters, but it does that by marking a huge amount of irrelevant accounts, which makes the list noisy and hard to work with.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.metrics import precision_score, recall_score
y_test = test["label_120d"].astype(int).values
p_test = p_test_xgb_cal # calibrated XGBoost probabilities on TEST
def metrics_at_topk(y_true, p_hat, k=0.05):
"""
Compute precision/recall when we take the top-k% users
(according to the score) as 'hot leads'.
"""
cutoff = np.quantile(p_hat, 1 - k)
y_pred = (p_hat >= cutoff).astype(int)
prec = precision_score(y_true, y_pred)
rec = recall_score(y_true, y_pred)
coverage = y_pred.mean()
return cutoff, prec, rec, coverage
# Table for Top-5% and Top-20%
ks = [0.05, 0.20]
rows = []
for k in ks:
cutoff, prec, rec, cov = metrics_at_topk(y_test, p_test, k)
rows.append({
"top_%": int(k * 100),
"cutoff": cutoff,
"precision": prec,
"recall": rec,
"coverage": cov
})
summary_k = pd.DataFrame(rows)
summary_k_display = summary_k.copy()
for col in ["precision", "recall", "coverage"]:
summary_k_display[col] = (summary_k_display[col] * 100).round(1)
print("Precision / Recall / Coverage on TEST:")
print(summary_k_display)
Precision / Recall / Coverage on TEST: top_% cutoff precision recall coverage 0 5 0.304348 42.8 35.3 5.8 1 20 0.047619 23.4 83.5 25.0
plt.style.use("default")
k_grid = np.linspace(0.01, 0.30, 30)
prec_list = []
rec_list = []
cov_list = []
for k in k_grid:
_, prec, rec, cov = metrics_at_topk(y_test, p_test, k)
prec_list.append(prec)
rec_list.append(rec)
cov_list.append(cov)
# Convert to percentages for a presentation-friendly plot
k_grid_pct = k_grid * 100
prec_pct = np.array(prec_list) * 100
rec_pct = np.array(rec_list) * 100
plt.figure(figsize=(7,4))
plt.plot(k_grid_pct, prec_pct, marker="o", label="Precision (% of hot leads that buy)")
plt.plot(k_grid_pct, rec_pct, marker="o", label="Recall (% of all buyers we catch)")
plt.xlabel("% of trials flagged as 'hot'")
plt.ylabel("Percentage")
plt.title("Precision / Recall vs % of trials contacted (TEST – calibrated XGBoost)")
plt.ylim(0, 100)
plt.grid(True, alpha=0.3)
# Highlighting the 5% and 20% points
for k_special in [5, 20]:
idx = np.argmin(np.abs(k_grid_pct - k_special))
plt.scatter([k_grid_pct[idx]], [prec_pct[idx]], s=60)
plt.scatter([k_grid_pct[idx]], [rec_pct[idx]], s=60)
plt.legend()
plt.tight_layout()
plt.show()
import numpy as np
import pandas as pd
def campaign_table(df,
p_hat,
campaign_col="campaign_id",
target_col="label_120d",
n_top=25,
min_trials=50):
"""
Build a per-campaign performance table for a given model.
Parameters
----------
df : pd.DataFrame
Slice of data (e.g., TEST) with campaign and label columns.
p_hat : array-like, shape (n_samples,)
Calibrated predicted probabilities for df.
campaign_col : str
Column name of the marketing campaign ID.
target_col : str
Column name of the binary label (0/1 = no/yes conversion).
n_top : int
Number of top campaigns to keep (by number of trials).
min_trials : int
Minimum number of trials per campaign to keep it in the table
(filters out tiny campaigns with very few trials).
Returns
-------
grp_top : pd.DataFrame
Table with trials, actual conversions, model-predicted conversions
and MAE/MAPE per campaign, for the top campaigns.
"""
tmp = df.copy()
tmp["p_hat"] = p_hat
# Aggregate per campaign: how many trials, how many real conversions,
# and how many conversions the model predicts (sum of probabilities).
grp = (
tmp
.groupby(campaign_col)
.agg(
trials =(target_col, "count"),
actual =(target_col, "sum"),
pred_model=("p_hat", "sum")
)
.reset_index()
)
# Absolute and relative error per campaign (for the model)
grp["mae_model"] = (grp["actual"] - grp["pred_model"]).abs()
grp["mape_model"] = grp["mae_model"] / grp["actual"].replace(0, np.nan)
# Keep only campaigns with enough volume
grp = grp[grp["trials"] >= min_trials]
# Take the n_top largest campaigns by number of trials
grp_top = grp.sort_values("trials", ascending=False).head(n_top)
return grp_top
# Get calibrated XGBoost probabilities on TEST --- #
X_test = test[feature_cols].copy()
p_test_xgb_cal = predict_calibrated_xgb(xgb_pipe, calibrated_xgb, X_test)
# Build Top-25 campaigns table
top25_xgb = campaign_table(
test,
p_test_xgb_cal,
campaign_col="campaign_id",
target_col="label_120d",
n_top=25,
min_trials=50
)
print("Top-25 campaigns – calibrated XGBoost (TEST):")
print(top25_xgb.head(25).to_string(index=False))
Top-25 campaigns – calibrated XGBoost (TEST):
campaign_id trials actual pred_model mae_model mape_model
218 2154 207 165.872281 41.127719 0.198685
547 469 1 1.651815 0.651815 0.651815
406 338 15 3.794076 11.205924 0.747062
256 211 27 23.817771 3.182229 0.117860
202 154 2 2.253323 0.253323 0.126661
19 138 8 4.786589 3.213411 0.401676
268 133 2 2.115023 0.115023 0.057512
156 117 3 1.720864 1.279136 0.426379
473 113 4 2.068092 1.931908 0.482977
429 111 1 0.368973 0.631027 0.631027
146 86 0 0.505538 0.505538 NaN
548 84 22 13.650317 8.349683 0.379531
6 83 3 3.505080 0.505080 0.168360
171 77 0 0.809940 0.809940 NaN
380 73 0 0.490677 0.490677 NaN
41 68 1 0.758312 0.241688 0.241688
558 68 10 13.688219 3.688219 0.368822
333 67 6 7.149170 1.149170 0.191528
511 67 5 2.312919 2.687081 0.537416
293 65 11 5.633362 5.366638 0.487876
93 65 3 3.708483 0.708483 0.236161
409 63 2 2.248141 0.248141 0.124071
55 62 1 0.070536 0.929464 0.929464
253 59 0 0.019092 0.019092 NaN
534 58 3 1.220322 1.779678 0.593226
import numpy as np
import matplotlib.pyplot as plt
# Use all Top-25 campaigns (already aggregated in top25_xgb)
plot_df = top25_xgb.sort_values("trials", ascending=False).head(25)
x = np.arange(len(plot_df))
width = 0.35
plt.figure(figsize=(14, 7))
# Bars: actual vs model forecast
plt.bar(x - width/2,
plot_df["actual"],
width,
label="Actual conversions")
plt.bar(x + width/2,
plot_df["pred_model"],
width,
label="Model forecast (calibrated XGBoost)")
# X-axis: campaign IDs
plt.xticks(x,
plot_df["campaign_id"].astype(str),
rotation=60,
ha="right")
plt.ylabel("Number of conversions")
plt.title("Top-25 campaigns – Actual vs Model forecast (TEST, calibrated XGBoost)")
plt.legend()
plt.grid(axis="y", alpha=0.3)
plt.tight_layout()
plt.show()
import matplotlib.pyplot as plt
import numpy as np
df = top25_xgb.sort_values("actual", ascending=True).tail(25)
plt.figure(figsize=(12, 10))
y_pos = np.arange(len(df))
plt.barh(y_pos - 0.2,
df["actual"],
height=0.4,
label="Actual conversions",
color="#1B84E7")
plt.barh(y_pos + 0.2,
df["pred_model"],
height=0.4,
label="Model forecast (XGBoost)",
color="#13C4A3")
plt.yticks(y_pos,
df["campaign_id"].astype(str),
fontsize=12)
plt.xlabel("Conversions", fontsize=13)
plt.title("Top-25 Campaigns – Actual vs Forecast (TEST, XGBoost Calibrated)",
fontsize=15,
pad=15)
plt.legend(fontsize=12)
plt.grid(axis="x", alpha=0.3)
plt.tight_layout()
plt.show()
# Baseline rule:
# 1 if (business email) AND (at least 1 installed agent), else 0.
def baseline_score(df):
"""
Implement the legacy marketing heuristic:
'Business email + 1+ installed agents' -> hot (=1), otherwise 0.
"""
return (
(df["has_business_email"] == 1) &
(df["agent_presence"] == 1)
).astype(int)
p_test_baseline = baseline_score(test)
def add_baseline_to_campaigns(df,
p_model,
p_base,
campaign_col="campaign_id",
target_col="label_120d",
n_top=25,
min_trials=50):
"""
Build a per-campaign comparison between the model and the baseline rule.
Parameters
----------
df : pd.DataFrame
Slice of data (e.g., TEST) with campaign and label columns.
p_model : array-like
Model predicted probabilities (calibrated XGBoost) for df.
p_base : array-like
Baseline "scores" for df (0/1 from the rule).
campaign_col : str
Campaign column name.
target_col : str
Binary label column name.
n_top : int
Number of top campaigns (by number of trials) to present.
min_trials : int
Minimum number of trials per campaign to keep.
Returns
-------
grp_top : pd.DataFrame
Table with, for each campaign:
- trials, actual conversions
- predicted conversions by model and by baseline
- MAE/MAPE for both model and baseline.
"""
tmp = df.copy()
tmp["p_model"] = p_model
tmp["p_base"] = p_base
grp = (
tmp
.groupby(campaign_col)
.agg(
trials =(target_col, "count"),
actual =(target_col, "sum"),
pred_model =("p_model", "sum"),
pred_base =("p_base", "sum")
)
.reset_index()
)
# Absolute error per campaign
grp["mae_model"] = (grp["actual"] - grp["pred_model"]).abs()
grp["mae_base"] = (grp["actual"] - grp["pred_base"]).abs()
# Relative error per campaign (MAPE)
grp["mape_model"] = grp["mae_model"] / grp["actual"].replace(0, np.nan)
grp["mape_base"] = grp["mae_base"] / grp["actual"].replace(0, np.nan)
# Filter out very small campaigns
grp = grp[grp["trials"] >= min_trials]
# Top campaigns by number of trials
grp_top = grp.sort_values("trials", ascending=False).head(n_top)
return grp_top
top25_cmp = add_baseline_to_campaigns(
test,
p_test_xgb_cal,
p_test_baseline,
campaign_col="campaign_id",
target_col="label_120d",
n_top=25,
min_trials=50
)
print("Top-25 campaigns – model vs baseline (TEST):")
print(top25_cmp.to_string(index=False))
Top-25 campaigns – model vs baseline (TEST):
campaign_id trials actual pred_model pred_base mae_model mae_base mape_model mape_base
218 2154 207 165.872281 593 41.127719 386 0.198685 1.864734
547 469 1 1.651815 59 0.651815 58 0.651815 58.000000
406 338 15 3.794076 26 11.205924 11 0.747062 0.733333
256 211 27 23.817771 74 3.182229 47 0.117860 1.740741
202 154 2 2.253323 11 0.253323 9 0.126661 4.500000
19 138 8 4.786589 33 3.213411 25 0.401676 3.125000
268 133 2 2.115023 31 0.115023 29 0.057512 14.500000
156 117 3 1.720864 21 1.279136 18 0.426379 6.000000
473 113 4 2.068092 22 1.931908 18 0.482977 4.500000
429 111 1 0.368973 8 0.631027 7 0.631027 7.000000
146 86 0 0.505538 2 0.505538 2 NaN NaN
548 84 22 13.650317 31 8.349683 9 0.379531 0.409091
6 83 3 3.505080 13 0.505080 10 0.168360 3.333333
171 77 0 0.809940 12 0.809940 12 NaN NaN
380 73 0 0.490677 7 0.490677 7 NaN NaN
41 68 1 0.758312 9 0.241688 8 0.241688 8.000000
558 68 10 13.688219 25 3.688219 15 0.368822 1.500000
333 67 6 7.149170 30 1.149170 24 0.191528 4.000000
511 67 5 2.312919 20 2.687081 15 0.537416 3.000000
293 65 11 5.633362 28 5.366638 17 0.487876 1.545455
93 65 3 3.708483 23 0.708483 20 0.236161 6.666667
409 63 2 2.248141 15 0.248141 13 0.124071 6.500000
55 62 1 0.070536 6 0.929464 5 0.929464 5.000000
253 59 0 0.019092 2 0.019092 2 NaN NaN
534 58 3 1.220322 3 1.779678 0 0.593226 0.000000
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
# Start from the comparison table `top25_cmp`
# Keep only the 25 largest campaigns by trials
heat_df = (
top25_cmp
.sort_values("trials", ascending=False)
.head(25)
.copy()
)
# Keep campaigns with at least one actual conversion
heat_df = heat_df[heat_df["actual"] > 0].copy()
# Compute MAPE (%) for model and baseline
heat_df["Model MAPE (%)"] = (heat_df["mae_model"] / heat_df["actual"]) * 100
heat_df["Baseline MAPE (%)"] = (heat_df["mae_base"] / heat_df["actual"]) * 100
# Long → wide format for the heatmap
melted = heat_df.melt(
id_vars="campaign_id",
value_vars=["Model MAPE (%)", "Baseline MAPE (%)"],
var_name="Method",
value_name="MAPE_%"
)
pivot = melted.pivot(index="campaign_id", columns="Method", values="MAPE_%")
sns.set_theme(style="white")
sns.set_context("talk")
# Robust color scale: ignore extreme outliers (e.g. 5800%)
vals = pivot.to_numpy().astype(float)
vmin = 0
vmax = np.percentile(vals, 95) # you can change 95 → 90/98 to tune
plt.figure(figsize=(8, 7))
ax = sns.heatmap(
pivot,
annot=True,
fmt=".1f",
cmap="viridis_r", # same palette as before, but with vmin/vmax
vmin=vmin,
vmax=vmax,
linewidths=0.6, # horizontal/vertical grid lines
linecolor="white",
cbar_kws={"label": "MAPE (%)"}
)
ax.set_xlabel("")
ax.set_ylabel("Campaign ID")
ax.set_title("Forecast error by campaign – Model vs Baseline (TEST)", pad=20)
# Clean axis labels
ax.set_xticklabels(ax.get_xticklabels(), rotation=0)
ax.set_yticklabels(ax.get_yticklabels(), rotation=0)
plt.tight_layout()
plt.show()
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
# ============================================
# Build monthly EDA table (train + test)
# ============================================
# If you don't have `val` as a separate split, just concatenate train + test
df_all = pd.concat([train, test], ignore_index=True)
# Ensure trial_month is a string / period-like column suitable for grouping
# (If it's already YYYY-MM string or period, this will still be fine)
df_all["trial_month"] = df_all["trial_month"].astype(str)
monthly_eda = (
df_all
.groupby("trial_month", as_index=False)
.agg(
actual_conversions=("label_120d", "sum"),
trials=("label_120d", "size")
)
.sort_values("trial_month")
)
# Keep only the last 12 months for the EDA chart
monthly_eda_recent = monthly_eda.tail(12).reset_index(drop=True)
# ============================================
# Plot: actual conversions per month (last 12 months)
# ============================================
sns.set_theme(style="whitegrid", context="talk")
fig, ax = plt.subplots(figsize=(11, 5))
# Dashboard-style background
fig.patch.set_facecolor("#f7f7fa")
ax.set_facecolor("#f9fafb")
# X positions
x = np.arange(len(monthly_eda_recent))
# Strong light-blue color for bars
bar_color = "#0ea5e9" # cyan / strong light blue
edge_color = "#0284c7" # darker blue edge
bars = ax.bar(
x,
monthly_eda_recent["actual_conversions"],
color=bar_color,
edgecolor=edge_color,
linewidth=0.9,
alpha=0.98,
)
# X-axis labels = trial_month
ax.set_xticks(x)
ax.set_xticklabels(
monthly_eda_recent["trial_month"],
rotation=30,
ha="right"
)
ax.set_xlabel("Trial month")
ax.set_ylabel("Number of conversions")
ax.set_title("Actual conversions per month (EDA – last 12 months)", pad=18)
# Value labels on top of each bar
ax.bar_label(bars, fmt="%.0f", padding=3, fontsize=9)
# Mean & median box (small KPI box in the top-right corner)
mean_val = monthly_eda_recent["actual_conversions"].mean()
median_val = monthly_eda_recent["actual_conversions"].median()
stats_text = f"Mean: {mean_val:.1f}\nMedian: {median_val:.1f}"
ax.text(
0.98, 0.95, stats_text,
transform=ax.transAxes,
ha="right",
va="top",
fontsize=10,
bbox=dict(boxstyle="round,pad=0.4",
facecolor="white",
edgecolor="0.8")
)
# Nice headroom above the tallest bar
ax.set_ylim(0, monthly_eda_recent["actual_conversions"].max() * 1.15)
plt.tight_layout()
plt.show()
# ================================================
# XGBoost (calibrated) – validation & test tables
# ================================================
from sklearn.metrics import (
average_precision_score,
roc_auc_score,
brier_score_loss,
)
import numpy as np
import pandas as pd
# ------------------------------------------------
# Monthly tables on VALIDATION and TEST
# (raw vs calibrated XGB, like you had for LR)
# ------------------------------------------------
# Validation – raw
val_monthly_xgb_raw = monthly_table(train.loc[is_val], p_val_xgb_raw)
print(
"VAL monthly (XGB raw):\n",
val_monthly_xgb_raw,
"\n MAE mean:",
float(val_monthly_xgb_raw["mae"].mean()),
"MAPE mean:",
float(val_monthly_xgb_raw["mape"].mean()),
)
# Validation – calibrated
val_monthly_xgb_cal = monthly_table(train.loc[is_val], p_val_xgb_cal)
print(
"\nVAL monthly (XGB calibrated):\n",
val_monthly_xgb_cal,
"\n MAE mean:",
float(val_monthly_xgb_cal["mae"].mean()),
"MAPE mean:",
float(val_monthly_xgb_cal["mape"].mean()),
)
# Test – raw
test_monthly_xgb_raw = monthly_table(test, p_test_xgb_raw)
print(
"\nTEST monthly (XGB raw):\n",
test_monthly_xgb_raw,
"\n MAE mean:",
float(test_monthly_xgb_raw["mae"].mean()),
"MAPE mean:",
float(test_monthly_xgb_raw["mape"].mean()),
)
# Test – calibrated
test_monthly_xgb_cal = monthly_table(test, p_test_xgb_cal)
print(
"\nTEST monthly (XGB calibrated):\n",
test_monthly_xgb_cal,
"\n MAE mean:",
float(test_monthly_xgb_cal["mae"].mean()),
"MAPE mean:",
float(test_monthly_xgb_cal["mape"].mean()),
)
# ------------------------------------------------
# Single metrics table for XGB (calibrated) on TEST
# PR-AUC, ROC-AUC, Brier, Precision@5%, Recall@5%, Monthly MAE/MAPE
# ------------------------------------------------
y_test = test["label_120d"].to_numpy(dtype=int)
k = 0.05 # top-5% operating point
# Classification metrics
pr_auc_test = average_precision_score(y_test, p_test_xgb_cal)
roc_auc_test = roc_auc_score(y_test, p_test_xgb_cal)
brier_test = brier_score_loss(y_test, p_test_xgb_cal)
# Precision@k and Recall@k on TEST
cutoff = np.quantile(p_test_xgb_cal, 1 - k)
topk = (p_test_xgb_cal >= cutoff).astype(int)
tp = ((topk == 1) & (y_test == 1)).sum()
prec_k = tp / max(topk.sum(), 1)
rec_k = tp / max((y_test == 1).sum(), 1)
# Monthly MAE/MAPE from the calibrated TEST table
mae_mean_test = float(test_monthly_xgb_cal["mae"].mean())
mape_mean_test = float(test_monthly_xgb_cal["mape"].mean())
xgb_test_summary = pd.DataFrame(
[{
"Model": "XGBoost (calibrated)",
"Split": "TEST",
"PR_AUC": pr_auc_test,
"ROC_AUC": roc_auc_test,
"Brier": brier_test,
"Precision@5%": prec_k,
"Recall@5%": rec_k,
"Monthly_MAE_mean": mae_mean_test,
"Monthly_MAPE_mean": mape_mean_test,
}]
)
print("\nXGB (calibrated) – TEST metrics summary:\n", xgb_test_summary)
VAL monthly (XGB raw):
trial_month actual predicted trials mae mape
0 2024-10 146 140.494614 2365 5.505386 0.037708
1 2024-11 126 125.318848 2512 0.681152 0.005406
2 2024-12 115 95.758942 2257 19.241058 0.167314
MAE mean: 8.475865681966146 MAPE mean: 0.07014254918354455
VAL monthly (XGB calibrated):
trial_month actual predicted trials mae mape
0 2024-10 146 152.446042 2365 6.446042 0.044151
1 2024-11 126 128.387128 2512 2.387128 0.018945
2 2024-12 115 106.167257 2257 8.832743 0.076806
MAE mean: 5.888637675438076 MAPE mean: 0.04663429780582073
TEST monthly (XGB raw):
trial_month actual predicted trials mae mape
0 2025-01 147 120.735725 2496 26.264275 0.178669
1 2025-02 179 134.668793 2172 44.331207 0.247660
2 2025-03 158 131.757980 2230 26.242020 0.166089
MAE mean: 32.27916717529297 MAPE mean: 0.19747254749927434
TEST monthly (XGB calibrated):
trial_month actual predicted trials mae mape
0 2025-01 147 116.735122 2496 30.264878 0.205884
1 2025-02 179 131.280200 2172 47.719800 0.266591
2 2025-03 158 129.766261 2230 28.233739 0.178695
MAE mean: 35.40613902474676 MAPE mean: 0.21705637889402787
XGB (calibrated) – TEST metrics summary:
Model Split PR_AUC ROC_AUC Brier Precision@5% \
0 XGBoost (calibrated) TEST 0.354969 0.889124 0.055011 0.4275
Recall@5% Monthly_MAE_mean Monthly_MAPE_mean
0 0.353306 35.406139 0.217056
import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd
# Data
val_baseline_monthly = pd.DataFrame({
"trial_month": ["2024-10", "2024-11", "2024-12"],
"actual": [146, 126, 115],
"predicted": [551, 517, 448]
})
# Percent over-prediction
val_baseline_monthly["percent_over"] = (
(val_baseline_monthly["predicted"] - val_baseline_monthly["actual"])
/ val_baseline_monthly["actual"] * 100
).round(1)
# Melt for seaborn
plot_df = val_baseline_monthly.melt(
id_vars=["trial_month", "percent_over"],
value_vars=["actual", "predicted"],
var_name="Type",
value_name="Conversions"
)
# Colors
colors = {
"actual": "#1B84E7", # accent blue
"predicted": "#9AA0A6" # soft gray
}
plt.figure(figsize=(10, 6))
ax = sns.barplot(
data=plot_df,
x="trial_month",
y="Conversions",
hue="Type",
palette=colors
)
# ---------------------------
# Add percent labels on predicted bars ONLY
# ---------------------------
for i, row in val_baseline_monthly.iterrows():
x = i # the bar group
y = row["predicted"] # predicted height
pct = row["percent_over"]
ax.text(
x, y + 10, # small offset above the bar
f"+{pct}%",
ha='center', va='bottom',
fontsize=12,
color="#5A5A5A", # darker gray text
fontweight='semibold'
)
# Title
plt.title("Actual vs Baseline Rule — Validation Months", fontsize=18, pad=20)
# Axis labels
plt.xlabel("Trial Month", fontsize=14)
plt.ylabel("Conversions", fontsize=14)
# Legend
plt.legend(title="", fontsize=12, loc="upper right")
# Cleaner theme
sns.despine()
plt.tight_layout()
plt.show()