Login to unlock
Machine Learning Fundamentals · Phase 2: Data Wrangling, Preprocessing & ML Workflow · Mini-Project: Messy Dataset

Mini-Project: Clean and Pipeline a Messy Dataset

Lab 1 of 1 4 min read
Back to course

Objective: Profile a messy dataset, find and remove a target-leaking column, build a ColumnTransformer-based pipeline that handles missing values, scaling, and encoding, and train a model end to end, entirely in your browser.


Setup

Every code block below runs live in your browser using Pyodide. Click Run to execute it. The dataset is generated directly in code, with missing values, mixed numeric and categorical columns, and one leaky column planted on purpose, exactly the kind of mess a real dataset arrives in.


Task 1: Generate and Profile the Messy Dataset

Run the EDA process from the lesson: check shape, missing values, and column types before touching anything else.

import numpy as np
import pandas as pd

np.random.seed(0)
n = 400

age = np.random.randint(18, 70, n).astype(float)
income = np.random.normal(55000, 18000, n)
income = np.clip(income, 15000, None)
channel = np.random.choice(["web", "referral", "ads"], n, p=[0.5, 0.2, 0.3])
monthly_spend = np.random.normal(80, 30, n)

# True churn signal: low spend and older accounts churn more, plus noise
churn_score = -0.04 * monthly_spend + 0.02 * age + np.random.randn(n) * 1.5
churned = (churn_score > churn_score.mean()).astype(int)

# A LEAKY column: only populated (non-null) for customers who already churned
days_since_cancel_request = np.where(churned == 1, np.random.randint(1, 30, n), np.nan)

df = pd.DataFrame({
    "age": age,
    "income": income,
    "channel": channel,
    "monthly_spend": monthly_spend,
    "days_since_cancel_request": days_since_cancel_request,
    "churned": churned,
})

# Inject missing values into age and income, simulating real-world gaps
missing_idx = np.random.choice(n, size=40, replace=False)
df.loc[missing_idx, "age"] = np.nan
missing_idx2 = np.random.choice(n, size=25, replace=False)
df.loc[missing_idx2, "income"] = np.nan

print("Shape:", df.shape)
print("\nMissing values per column:")
print(df.isna().sum())
print("\nDtypes:")
print(df.dtypes)

Task 2: Find the Leaky Column

Check each feature's relationship with the target. One column should stand out as suspiciously predictive, exactly the red flag the data leakage lesson warned about.

import numpy as np
import pandas as pd

np.random.seed(0)
n = 400
age = np.random.randint(18, 70, n).astype(float)
income = np.random.normal(55000, 18000, n)
income = np.clip(income, 15000, None)
channel = np.random.choice(["web", "referral", "ads"], n, p=[0.5, 0.2, 0.3])
monthly_spend = np.random.normal(80, 30, n)
churn_score = -0.04 * monthly_spend + 0.02 * age + np.random.randn(n) * 1.5
churned = (churn_score > churn_score.mean()).astype(int)
days_since_cancel_request = np.where(churned == 1, np.random.randint(1, 30, n), np.nan)
df = pd.DataFrame({
    "age": age, "income": income, "channel": channel,
    "monthly_spend": monthly_spend,
    "days_since_cancel_request": days_since_cancel_request,
    "churned": churned,
})

# Is "days_since_cancel_request" suspiciously tied to the target?
print("Is days_since_cancel_request non-null exactly when churned == 1?")
print((df["days_since_cancel_request"].notna() == (df["churned"] == 1)).all())

This confirms it: days_since_cancel_request is non-null exactly when, and only when, a customer has already churned. In the real world, you would never have this value available at the moment you're trying to predict whether someone will churn. This column has to be dropped before modeling, no matter how predictive it looks.


Task 3: Build a Pipeline That Handles the Rest

With the leaky column removed, build a ColumnTransformer that imputes missing numeric values, scales them, and one-hot encodes the categorical column, wrapped in a Pipeline with a classifier.

import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split

np.random.seed(0)
n = 400
age = np.random.randint(18, 70, n).astype(float)
income = np.random.normal(55000, 18000, n)
income = np.clip(income, 15000, None)
channel = np.random.choice(["web", "referral", "ads"], n, p=[0.5, 0.2, 0.3])
monthly_spend = np.random.normal(80, 30, n)
churn_score = -0.04 * monthly_spend + 0.02 * age + np.random.randn(n) * 1.5
churned = (churn_score > churn_score.mean()).astype(int)
df = pd.DataFrame({"age": age, "income": income, "channel": channel, "monthly_spend": monthly_spend, "churned": churned})
missing_idx = np.random.choice(n, size=40, replace=False)
df.loc[missing_idx, "age"] = np.nan
missing_idx2 = np.random.choice(n, size=25, replace=False)
df.loc[missing_idx2, "income"] = np.nan

X = df.drop(columns=["churned"])
y = df["churned"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=0, stratify=y)

numeric_features = ["age", "income", "monthly_spend"]
categorical_features = ["channel"]

numeric_pipeline = Pipeline([
    ("imputer", SimpleImputer(strategy="median")),
    ("scaler", StandardScaler()),
])

preprocessor = ColumnTransformer([
    ("num", numeric_pipeline, numeric_features),
    ("cat", OneHotEncoder(handle_unknown="ignore"), categorical_features),
])

full_pipeline = Pipeline([
    ("preprocessing", preprocessor),
    ("model", LogisticRegression(max_iter=5000)),
])

full_pipeline.fit(X_train, y_train)
print("Test accuracy (leaky column excluded):", full_pipeline.score(X_test, y_test))

Notice X is built by dropping only churned, the target, not days_since_cancel_request explicitly here, because Task 2's investigation should have already convinced you to drop that column before it ever reaches this step. If you're unsure, go back and explicitly exclude it from X and compare accuracy with and without it. It should look better with it included, for exactly the wrong reason.


Task 4: Reflection

Run the pipeline once more, this time deliberately leaving days_since_cancel_request in X (impute its missing values as 0 to make it usable). Compare the resulting test accuracy to the clean version above. The version with the leaky column will likely score higher, which is precisely the trap: a leaked model looks like the better model right up until it meets a real customer who hasn't churned yet, for whom this column would never have a meaningful value to give it in the first place.

Found this useful?

Finished this lab?

Mark it complete to update your Phase 2: Data Wrangling, Preprocessing & ML Workflow progress.