Build a Bank Marketing Predictor

Build a Colab notebook that predicts bank term-deposit subscriptions.

Introduction

30 Second Summary

When a call list contains thousands of names, choosing who to contact first becomes guesswork. Past campaign results can turn that guess into a question you can test.

In this project, you will build a Google Colab notebook that studies past bank marketing calls. It will train a prediction model to estimate which clients are more likely to subscribe before a call begins.

What You'll Build

A top-to-bottom run of your finished notebook reveals a fair pre-call ranking drawn from 45,211 real campaign records.

By the end of this project, you'll have:

  • A data walkthrough that lets you inspect 45,211 campaign records before showing the subscription imbalance in a bar chart.
  • An honest pre-call model that removes data leakage. Its report makes precision, recall, ROC-AUC, and the confusion matrix readable.
  • A business recommendation that explains when the model can help prioritize calls. It also names the limits that prevent automatic customer decisions.
  • Secret Mission: Change the prediction cutoff to control how easily the model says Yes.

Are there any prerequisites?

You need a Google account with access to Google Colab.

Basic Python familiarity is helpful. Every data-science term is introduced in plain English.

Before We Start

Before any hands-on work begins, this is your moment to define the question your notebook answers. You are predicting whether a client will subscribe to a term deposit using information available before a marketing call begins.

Open Colab and Check the Tools

Your pre-call prediction question now needs a workspace for running code. Google Colab gives you a hosted notebook.

A Colab runtime is the temporary computer that executes each notebook cell. The pinned runtime gives you Python 3.12.13.

The installation cell pins each library to the version used in this guide. Consistent versions give every later cell the same foundation.

In this step, get ready to:
  • Prepare a blank Colab notebook named Bank Marketing Predictor on runtime version 2026.07.
  • Install the four pinned Python libraries.
  • Run the environment-check cell to confirm every tool version.
Prepare your Colab workspace

Your notebook stays in Google Drive while its code runs on the temporary Colab runtime. Naming the notebook now makes it easy to find again.

  • Open Google Colab in your browser.
  • Sign in with your Google account.

You should see the Colab welcome page after signing in.

  • Create a blank notebook from the Colab welcome page.

You should see an empty notebook with its first code cell ready.

  • Select the notebook title in the top-left corner.
  • Enter Bank Marketing Predictor.

The notebook title should now read Bank Marketing Predictor.

What is a Colab runtime?

The runtime is a private virtual machine that runs your notebook code. Its installed libraries can disappear when the temporary machine is deleted.

Your notebook remains stored separately. The installation cell rebuilds its tool environment when needed.

  • Open Runtime from the top menu.
  • Select Change runtime type.

The runtime settings dialog should include the Runtime Version dropdown.

  • Open the Runtime Version dropdown.
  • Select 2026.07.

The dropdown should now show 2026.07.

  • Save the runtime selection in the dialog.

Your named notebook now has the runtime required by the project. That gives every package the correct Python foundation.

Pin the library versions

Python libraries are reusable toolboxes. pandas works with tables.

scikit-learn provides model tools. Matplotlib draws charts.

The ucimlrepo library loads datasets from the UCI Machine Learning Repository.

Expect a short wait while Colab downloads the packages. A quiet cell during this installation is normal.

  • Install the pinned libraries by running this command in the first code cell:
!pip3 install pandas==3.0.5 scikit-learn==1.9.0 matplotlib==3.11.1 ucimlrepo==0.0.7

What does this command do?

  • pandas==3.0.5 installs the table-processing library used to inspect the campaign records.
  • scikit-learn==1.9.0 installs the tools used to prepare data and train the model.
  • matplotlib==3.11.1 installs the charting library used to display campaign outcomes.
  • ucimlrepo==0.0.7 installs the library used to fetch the bank marketing dataset.
  • Wait for the installation cell to finish.

The cell should finish with installation output and no package installation error.

That is the shared toolbox ready. Every later cell can now use the library versions from this guide.

Library installation not finishing?

  • Check that the command matches all four package names and version pins exactly.
  • Run the installation cell again if a download stopped before completion.

Help me fix my Colab library installation.

Build the environment check

An import makes a library available inside your notebook. This cell also prints each installed version so you can catch an environment mismatch before working with data.

  • Add a new code cell beneath the installation cell from the notebook toolbar.
  • Paste the import and version-check code below into the new cell:
# Cell 1: Import the toolboxes and check their versions.
import sys
from importlib.metadata import version

import matplotlib
import matplotlib.pyplot as plt
import pandas as pd
import sklearn
from ucimlrepo import fetch_ucirepo
from sklearn.compose import ColumnTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import ConfusionMatrixDisplay, classification_report, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

print("Python:", sys.version.split()[0])
print("pandas:", pd.__version__)
print("scikit-learn:", sklearn.__version__)
print("Matplotlib:", matplotlib.__version__)
print("ucimlrepo:", version("ucimlrepo"))

What does this code do?

  • sys exposes details about the Python runtime.
  • fetch_ucirepo makes the dataset-loading function available for the next step.
  • The scikit-learn imports provide the modeling workflow used later in the notebook.
  • matplotlib.pyplot provides the chart commands used during data exploration.
  • The five print() calls expose the exact versions currently loaded by the runtime.

Before you run this cell, consider which five version numbers you expect it to print.

  • Run the version-check cell using the play button on its left.

The Python line should show 3.12.13. The pandas line should show 3.0.5.

The scikit-learn line should show 1.9.0. The Matplotlib line should show 3.11.1.

The ucimlrepo line should show 0.0.7.

✔️ I see all five pinned versions

Your environment check passed. The notebook now has the exact runtime and libraries used throughout this project.

ⓧ Python shows a different version

  • Return to Runtime in the top menu.
  • Select Change runtime type.
  • Open the Runtime Version dropdown.
  • Select 2026.07.
  • Save the runtime selection in the dialog.
  • Run the installation cell again.
  • Run the version-check cell again.

ⓧ A library is missing or differs

  • Return to the installation cell above your import cell.
  • Run the installation cell again.
  • Wait for the installation output to finish.
  • Return to the version-check cell.
  • Run the version-check cell again.

Versions still not matching?

Help me diagnose the remaining version mismatch.

Your version-check cell should match this complete reference before you continue.

✔️ Awesome, I've got everything!

Great. Keep the installation cell above this saved import and version-check cell.

ⓧ I'd like to double check the full code

Compare your import and version-check cell with this complete reference:

# Cell 1: Import the toolboxes and check their versions.
import sys
from importlib.metadata import version

import matplotlib
import matplotlib.pyplot as plt
import pandas as pd
import sklearn
from ucimlrepo import fetch_ucirepo
from sklearn.compose import ColumnTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import ConfusionMatrixDisplay, classification_report, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

print("Python:", sys.version.split()[0])
print("pandas:", pd.__version__)
print("scikit-learn:", sklearn.__version__)
print("Matplotlib:", matplotlib.__version__)
print("ucimlrepo:", version("ucimlrepo"))

What should this reference contain?

This reference imports every tool required by the later notebook cells. It finishes with five version checks for the active environment.

Your Colab notebook now runs with the required tools. Next up, you will load the bank marketing records and learn what each part of the table means.

Explore the Bank Marketing Data

Your Google Colab notebook now has the exact libraries for this project. Its version check proves the workspace is ready.

A prediction model cannot rescue a misunderstood dataset. This step shows what one campaign record contains before training begins.

In this step, get ready to:
  • Load the campaign records from the UCI Machine Learning Repository.
  • Inspect the table structure and subscription answers.
  • Compare the subscription outcomes across the dataset.
Load and inspect the campaign records

Each row represents one client contact. Each column captures one detail about that contact.

The X table stores the features used as model inputs. The y Series stores the target that records whether the client subscribed.

  • Create a code cell directly below the version-check cell using the notebook's code-cell control.
  • Paste the following code into the new cell:
# Cell 2: Load the input table X and answer column y.
bank_marketing = fetch_ucirepo(id=222)
X = bank_marketing.data.features.copy()
y = bank_marketing.data.targets["y"].map({"no": 0, "yes": 1}).astype(int)

data = X.copy()
data["subscribed"] = y

print("Feature shape:", X.shape)
print(data.head().to_string())
print("\nData types:\n", X.dtypes)
print("\nTotal missing values:", int(data.isna().sum().sum()))
print("\nClass counts:\n", y.value_counts().sort_index())

What does this code do?

  • The fetch_ucirepo(id=222) call retrieves the Bank Marketing dataset.
  • The X variable copies the input feature table.
  • The mapping converts no into 0.
  • The same mapping converts yes into 1.
  • The combined data table supports inspection of inputs beside the answer.
  • The print statements expose the table structure and data quality.
  • Run the new code cell.

What should I see?

You should see Feature shape: (45211, 16). This means the feature table has 45,211 rows with 16 input columns.

The preview displays the first five campaign records. The data-type list shows which columns contain numbers or text categories.

The missing-value check prints 0. Every value needed for this beginner analysis is present.

The class counts show that 0 appears much more often than 1.

That first data audit gives you a trustworthy starting point for every model check ahead.

Having trouble loading the data?

  • Confirm that the version-check cell completed successfully.
  • Check that the dataset ID inside fetch_ucirepo(id=222) is still 222.
  • Rerun the data cell if the temporary runtime briefly lost its connection.

Help me diagnose why my Colab cell cannot load UCI dataset 222.

Chart the subscription balance

The target classes are the two possible answers stored in y. A chart makes their imbalance visible at a glance.

Historical rates by job reveal patterns in these old campaign records. Those patterns do not prove that a job caused a subscription.

  • Create another code cell directly below the data-inspection cell.
  • Paste the following code into the new cell:
# Cell 3: Make one chart and summarize results by job.
no_count = int((y == 0).sum())
yes_count = int((y == 1).sum())
plt.bar(["No subscription", "Subscription"], [no_count, yes_count])
plt.title("Bank marketing outcomes")
plt.ylabel("Campaign records")
plt.show()

response_by_job = data.groupby("job")["subscribed"].mean().sort_values(ascending=False)
print("\nHistorical subscription rate by job:\n")
print((response_by_job * 100).round(1).astype(str) + "%")

How does this exploration work?

  • The two count variables total each subscription answer separately.
  • The plt.bar(...) call compares those totals visually.
  • The response_by_job calculation finds the average binary target within each job group.
  • Multiplying by 100 converts each average into a readable percentage.

Before you run this cell, do you expect the two bars to have similar heights? Keep your prediction in mind for the next action.

  • Run the chart cell.

You should see the No subscription bar stand much taller than the Subscription bar.

Below the chart, the printed percentages rank the historical subscription rate for each job group.

You can now see the class imbalance instead of relying on a vague description of it.

Chart or job rates missing?

  • Run the data-loading cell again before running the chart cell.
  • Check that the grouped column is spelled job.
  • Check that the target column is spelled subscribed.

Help me fix my missing Bank Marketing chart or job-group rates.

✔️ Awesome, I've got everything!

Your exploration cells are complete. Google Colab has saved the notebook changes to Google Drive.

ⓧ I'd like to double check the full code

The complete notebook code through this step appears below for comparison.

# Cell 1: Import the toolboxes and check their versions.
import sys
from importlib.metadata import version

import matplotlib
import matplotlib.pyplot as plt
import pandas as pd
import sklearn
from ucimlrepo import fetch_ucirepo
from sklearn.compose import ColumnTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import ConfusionMatrixDisplay, classification_report, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

print("Python:", sys.version.split()[0])
print("pandas:", pd.__version__)
print("scikit-learn:", sklearn.__version__)
print("Matplotlib:", matplotlib.__version__)
print("ucimlrepo:", version("ucimlrepo"))

# Cell 2: Load the input table X and answer column y.
bank_marketing = fetch_ucirepo(id=222)
X = bank_marketing.data.features.copy()
y = bank_marketing.data.targets["y"].map({"no": 0, "yes": 1}).astype(int)

data = X.copy()
data["subscribed"] = y

print("Feature shape:", X.shape)
print(data.head().to_string())
print("\nData types:\n", X.dtypes)
print("\nTotal missing values:", int(data.isna().sum().sum()))
print("\nClass counts:\n", y.value_counts().sort_index())

# Cell 3: Make one chart and summarize results by job.
no_count = int((y == 0).sum())
yes_count = int((y == 1).sum())
plt.bar(["No subscription", "Subscription"], [no_count, yes_count])
plt.title("Bank marketing outcomes")
plt.ylabel("Campaign records")
plt.show()

response_by_job = data.groupby("job")["subscribed"].mean().sort_values(ascending=False)
print("\nHistorical subscription rate by job:\n")
print((response_by_job * 100).round(1).astype(str) + "%")
Verify the exploration

Before you rerun the exploration, consider what you expect the complete output to reveal.

  • Run the three notebook cells in order.

The feature shape should print (45211, 16).

The total missing-value count should print 0.

The class counts should show many more 0 targets than 1 targets.

The bar chart should show the same imbalance visually. The final output should list historical subscription rates by job.

How should I read these checks?

The shape confirms that you loaded 45,211 campaign records with 16 input columns. The missing-value result confirms that the table has no blank entries recorded by the repository.

This project has two answer classes. The larger class represents clients who did not subscribe.

A model trained on this table must learn from an imbalanced target. That imbalance shapes how you judge its predictions later.

Well done. Your notebook now turns a real campaign dataset into readable evidence about its structure and subscription outcomes.

Next up, you will hide part of this data for testing before teaching your first prediction model.

Train Your First Model

The table in your notebook now has a clear structure. You know which columns describe each client contact and which target records the subscription result.

Your next goal is to train a prediction model. A fair evaluation keeps some rows hidden so the model cannot study the same examples used to test its work.

Training data gives the model examples to learn from. Test data checks the model on rows it has never seen.

In this step, get ready to:
  • Separate the dataset into training rows and hidden test rows.
  • Compare the model against a simple majority baseline.
  • Train a first model and inspect its evaluation results.
Split the rows and run a baseline

A baseline prediction gives your model a simple result to beat. This baseline always predicts the most common answer in the training rows.

  • Add a new code cell beneath the historical response-rate output.
  • Create the split and baseline by pasting this code into the new cell:
# Cell 4: Save 20 percent of the rows for the final test.
X_train, X_test, y_train, y_test = train_test_split(
    X,
    y,
    test_size=0.2,
    random_state=42,
    stratify=y,
)

# The baseline always guesses the most common training answer.
majority_class = int(y_train.mode().iloc[0])
baseline_predictions = [majority_class] * len(y_test)
print("\nMajority baseline:\n")
print(
    classification_report(
        y_test,
        baseline_predictions,
        labels=[0, 1],
        target_names=["No", "Yes"],
        zero_division=0,
    )
)

What does this code do?

  • The split reserves 20 percent of the rows for the final test.
  • The remaining 80 percent becomes the training data.
  • The stratify=y setting keeps a similar balance of subscription answers in both groups.
  • The random_state=42 setting puts the same rows into each group whenever the notebook reruns.
  • The baseline finds the most common training answer and predicts it for every test row.

Before you run the cell, which answer do you think the majority baseline predicts for every row?

  • Run the cell using its play button.

You should see Majority baseline followed by a classification report. The Yes row should show zero precision and zero recall because the baseline never predicts a subscription.

Good. Your baseline now gives the real model a clear minimum to beat.

Baseline cell not running?

  • Confirm the earlier dataset cell has created X and y in the current runtime.
  • Check that every line from train_test_split( through the final closing parenthesis is present.
  • Rerun the import cell if the runtime restarted.

Help me fix my train-test split or majority baseline cell.

Build the preparation pipeline

The input table mixes numeric columns with text categories. A pipeline prepares each type consistently before logistic regression learns patterns from the rows.

  • Add a new code cell beneath the baseline cell.
  • Define the preparation helper by pasting this code into the new cell:
# Cell 5: Define two reusable helpers.
def build_pipeline(frame):
    # Numeric columns need scaling. Text categories need one-hot encoding.
    numeric_columns = frame.select_dtypes(include="number").columns.tolist()
    categorical_columns = frame.select_dtypes(exclude="number").columns.tolist()

    preprocessor = ColumnTransformer(
        transformers=[
            ("numeric", StandardScaler(), numeric_columns),
            (
                "categorical",
                OneHotEncoder(handle_unknown="ignore"),
                categorical_columns,
            ),
        ]
    )

    return Pipeline(
        steps=[
            ("preprocessor", preprocessor),
            (
                "model",
                LogisticRegression(
                    class_weight="balanced",
                    max_iter=1000,
                    solver="lbfgs",
                ),
            ),
        ]
    )

How does the pipeline prepare the data?

  • The helper identifies numeric columns separately from categorical columns.
  • StandardScaler puts numeric columns on comparable scales.
  • OneHotEncoder converts text categories into numeric indicators the model can use.
  • LogisticRegression estimates the probability that a row belongs to the Yes class.
  • The pipeline keeps preparation and training together so the same rules apply during evaluation.
  • Run the helper cell using its play button.

You should see the cell finish without an error. The notebook now has a reusable build_pipeline function in memory.

That is the preparation work packaged up. Every model built with this helper follows the same process.

Helper cell showing an error?

  • Check that the indentation inside build_pipeline matches the snippet.
  • Confirm the import cell includes the preprocessing and model imports from earlier.
  • Check that each opening parenthesis has a matching closing parenthesis.

Help me debug my build_pipeline function.

Evaluate and train the model

The model needs more than one score to explain its work. Your evaluator creates a classification report with precision and recall.

It also calculates ROC-AUC and draws a confusion matrix. Together these outputs show how the model handles both possible answers.

  • Return to the helper cell containing build_pipeline.
  • Add the first part of evaluate_model beneath the existing helper by pasting this code:
def evaluate_model(name, model, X_evaluation, y_evaluation):
    # Predictions are Yes or No. Probabilities are confidence scores.
    predictions = model.predict(X_evaluation)
    probabilities = model.predict_proba(X_evaluation)[:, 1]
    auc = roc_auc_score(y_evaluation, probabilities)

    print(f"\n{name}\n")
    print(
        classification_report(
            y_evaluation,
            predictions,
            labels=[0, 1],
            target_names=["No", "Yes"],
            zero_division=0,
        )
    )
    print(f"ROC-AUC: {auc:.3f}")

What does this section calculate?

  • The predictions hold the final No or Yes decision for each test row.
  • The probabilities hold the model's confidence for the positive class.
  • The classification report summarizes the model's positive and negative predictions.
  • The ROC-AUC value measures how well the model ranks positive examples above negative examples.
  • Rerun the helper cell using its play button.

You should see the helper cell complete without an error. The evaluator can now calculate predictions and print the first set of metrics.

  • Extend evaluate_model by pasting this continuation directly below its ROC-AUC print line:

    ConfusionMatrixDisplay.from_predictions(
        y_evaluation,
        predictions,
        labels=[0, 1],
        display_labels=["No", "Yes"],
    )
    plt.title(name)
    plt.show()

    return {
        "predictions": predictions,
        "probabilities": probabilities,
        "roc_auc": auc,
    }

What does the continuation add?

  • The display turns the prediction counts into a square confusion-matrix chart.
  • The chart labels each axis with the two possible answers.
  • The returned dictionary keeps the predictions for later checks.
  • It also keeps the probabilities and ROC-AUC score for later comparisons.
  • Rerun the completed helper cell using its play button.

You should see the cell complete without an error. The finished evaluate_model function can now print metrics and display the chart.

Evaluator cell not completing?

  • Confirm the confusion-matrix section remains indented inside evaluate_model.
  • Check that the returned dictionary closes with a final brace.
  • Rerun the import cell if the plotting or metrics names are unavailable.

Help me fix my evaluate_model function.

Your helpers are ready to process all 16 input columns. The first fit uses every available feature to create an initial result.

  • Add a new code cell beneath the helper cell.
  • Create the first trained model by pasting this code into the new cell:
# Cell 6: Train the tempting first model with every input column.
naive_model = build_pipeline(X_train)
naive_model.fit(X_train, y_train)
naive_results = evaluate_model(
    "First model with duration",
    naive_model,
    X_test,
    y_test,
)

What does this training cell do?

  • The first line builds a fresh preparation and modeling pipeline from the training columns.
  • The fit step learns from X_train and its known answers in y_train.
  • The evaluator checks the fitted model against the hidden test rows.
  • The naive_results dictionary keeps the predictions and probabilities with the ROC-AUC score.

Before you run the training cell, do you expect this model to find any subscribers that the majority baseline missed?

This fit processes every training row. The running indicator means the notebook is still preparing categories and fitting the model.

  • Run the training cell using its play button.

You should see a model report and a ROC-AUC number. You should also see a square confusion-matrix chart titled First model with duration.

You have trained your first real model. It now makes positive predictions that the majority baseline could never produce.

How should I read these results?

  • Precision asks how many clients predicted Yes actually subscribed.
  • Recall asks how many real subscribers the model found.
  • The confusion matrix counts correct and incorrect predictions for both classes.
  • ROC-AUC compares how the model ranks positive examples against negative examples.

Training cell failing?

  • Rerun the earlier notebook cells from the imports through the helper definitions.
  • Confirm build_pipeline and evaluate_model both completed without an error.
  • Check that the split cell created X_train and y_train in the current runtime.

Help me troubleshoot my first model training cell.

✔️ Awesome, I've got everything!

Strong work. Your notebook now holds the split data, baseline, reusable helpers, trained model, and first evaluation results.

ⓧ I'd like to double check the full code

Compare your notebook cells with this cumulative project code. Keep each numbered cell section in the same order.

# Cell 1: Import the toolboxes and check their versions.
import sys
from importlib.metadata import version

import matplotlib
import matplotlib.pyplot as plt
import pandas as pd
import sklearn
from ucimlrepo import fetch_ucirepo
from sklearn.compose import ColumnTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import ConfusionMatrixDisplay, classification_report, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

print("Python:", sys.version.split()[0])
print("pandas:", pd.__version__)
print("scikit-learn:", sklearn.__version__)
print("Matplotlib:", matplotlib.__version__)
print("ucimlrepo:", version("ucimlrepo"))

# Cell 2: Load the input table X and answer column y.
bank_marketing = fetch_ucirepo(id=222)
X = bank_marketing.data.features.copy()
y = bank_marketing.data.targets["y"].map({"no": 0, "yes": 1}).astype(int)

data = X.copy()
data["subscribed"] = y

print("Feature shape:", X.shape)
print(data.head().to_string())
print("\nData types:\n", X.dtypes)
print("\nTotal missing values:", int(data.isna().sum().sum()))
print("\nClass counts:\n", y.value_counts().sort_index())

# Cell 3: Make one chart and summarize results by job.
no_count = int((y == 0).sum())
yes_count = int((y == 1).sum())
plt.bar(["No subscription", "Subscription"], [no_count, yes_count])
plt.title("Bank marketing outcomes")
plt.ylabel("Campaign records")
plt.show()

response_by_job = data.groupby("job")["subscribed"].mean().sort_values(ascending=False)
print("\nHistorical subscription rate by job:\n")
print((response_by_job * 100).round(1).astype(str) + "%")

# Cell 4: Save 20 percent of the rows for the final test.
X_train, X_test, y_train, y_test = train_test_split(
    X,
    y,
    test_size=0.2,
    random_state=42,
    stratify=y,
)

# The baseline always guesses the most common training answer.
majority_class = int(y_train.mode().iloc[0])
baseline_predictions = [majority_class] * len(y_test)
print("\nMajority baseline:\n")
print(
    classification_report(
        y_test,
        baseline_predictions,
        labels=[0, 1],
        target_names=["No", "Yes"],
        zero_division=0,
    )
)

# Cell 5: Define two reusable helpers.
def build_pipeline(frame):
    # Numeric columns need scaling. Text categories need one-hot encoding.
    numeric_columns = frame.select_dtypes(include="number").columns.tolist()
    categorical_columns = frame.select_dtypes(exclude="number").columns.tolist()

    preprocessor = ColumnTransformer(
        transformers=[
            ("numeric", StandardScaler(), numeric_columns),
            (
                "categorical",
                OneHotEncoder(handle_unknown="ignore"),
                categorical_columns,
            ),
        ]
    )

    return Pipeline(
        steps=[
            ("preprocessor", preprocessor),
            (
                "model",
                LogisticRegression(
                    class_weight="balanced",
                    max_iter=1000,
                    solver="lbfgs",
                ),
            ),
        ]
    )


def evaluate_model(name, model, X_evaluation, y_evaluation):
    # Predictions are Yes or No. Probabilities are confidence scores.
    predictions = model.predict(X_evaluation)
    probabilities = model.predict_proba(X_evaluation)[:, 1]
    auc = roc_auc_score(y_evaluation, probabilities)

    print(f"\n{name}\n")
    print(
        classification_report(
            y_evaluation,
            predictions,
            labels=[0, 1],
            target_names=["No", "Yes"],
            zero_division=0,
        )
    )
    print(f"ROC-AUC: {auc:.3f}")

    ConfusionMatrixDisplay.from_predictions(
        y_evaluation,
        predictions,
        labels=[0, 1],
        display_labels=["No", "Yes"],
    )
    plt.title(name)
    plt.show()

    return {
        "predictions": predictions,
        "probabilities": probabilities,
        "roc_auc": auc,
    }


# Cell 6: Train the tempting first model with every input column.
naive_model = build_pipeline(X_train)
naive_model.fit(X_train, y_train)
naive_results = evaluate_model(
    "First model with duration",
    naive_model,
    X_test,
    y_test,
)

Your first model can now be compared with the simple baseline. Next, you will check whether every input supports a fair prediction before a marketing call begins.

Remove the Leaking Feature

Your first prediction model has produced a classification report, a score, and a confusion matrix. The result may look promising.

The notebook needs to rank clients before a marketing call begins. However, duration records how long a completed call lasted.

Using information from after the prediction moment creates data leakage. You will remove that leaking feature while keeping the original row split unchanged.

In this step, get ready to:
  • Identify why call duration leaks future information.
  • Create duration-free copies of the training and test tables.
  • Train the fair pre-call model on the original row split.
Identify the leaking feature

Imagine a student seeing part of an answer before taking an exam. Training with duration gives the model a clue from after the prediction moment.

Data leakage makes an evaluation look more useful than the real workflow allows. A fair pre-call model can only use information available before the call begins.

  • Return to Cell 6 in your notebook.
  • Confirm that naive_model receives X_train, which still includes duration.
  • Compare the moment when call duration becomes known with the moment when the prediction is needed.
Train the duration-free model

A fair comparison keeps the original train-test row split. Only the leaking duration column changes.

  • Use the notebook toolbar's code-cell control to create a new cell directly below Cell 6.
  • Paste this Cell 7 code into the new cell:
# Cell 7: Remove duration and train the fair pre-call model.
X_train_honest = X_train.drop(columns=["duration"])
X_test_honest = X_test.drop(columns=["duration"])

honest_model = build_pipeline(X_train_honest)
honest_model.fit(X_train_honest, y_train)
honest_results = evaluate_model(
    "Pre-call model without duration",
    honest_model,
    X_test_honest,
    y_test,
)

What does this code do?

  • The two drop calls create new input tables without duration.
  • The original X_train table stays unchanged. The original X_test table also stays unchanged.
  • The honest_model reuses build_pipeline with the duration-free training table.
  • The evaluate_model helper prints a classification report.
  • The helper calculates a ROC-AUC score. It also draws a confusion matrix.

Before you run this cell, do you expect the honest model's ROC-AUC to rise or fall after losing duration?

  • Select the play control beside Cell 7 to run the duration-free model.

You'll see Pre-call model without duration first. A second classification report follows.

You'll also see a ROC-AUC score. A square confusion-matrix chart appears underneath.

A lower score is acceptable. This result matches the information available before a call.

No Second Model Output?

  • Place Cell 7 below Cell 6. The existing helper functions must run before this cell.
  • Check that both drop calls spell duration exactly.
  • Confirm that honest_model.fit uses X_train_honest.

Help me troubleshoot my duration-free model cell.

✔️ Awesome, I've got everything!

You've made the important correction. Your first model remains available for comparison while the honest model uses only pre-call information.

ⓧ I'd like to double check the full code

Compare the end of your notebook with this cumulative checkpoint. Cells 1 through 6 stay unchanged.

# Cell 1 through Cell 6 remain exactly as previously created.

# Cell 7: Remove duration and train the fair pre-call model.
X_train_honest = X_train.drop(columns=["duration"])
X_test_honest = X_test.drop(columns=["duration"])

honest_model = build_pipeline(X_train_honest)
honest_model.fit(X_train_honest, y_train)
honest_results = evaluate_model(
    "Pre-call model without duration",
    honest_model,
    X_test_honest,
    y_test,
)

The honest model now answers the right pre-call question. Next, you'll turn its results into a responsible recommendation that another person can understand.

Explain the Model Results

Your Google Colab notebook now contains a duration-free model that uses only information available before a call. That gives you a fair pre-call test after removing data leakage.

A strong analysis becomes useful when another person can understand its result within clear limits. In this step, you will compare both ROC-AUC scores before printing a responsible business recommendation.

In this step, get ready to:
  • Compare the first model's ROC-AUC with the duration-free model's score.
  • Print a plain-English recommendation for call-list prioritization.
  • Rerun the complete notebook to prove every result can be recreated.
Compare the two model scores

The two ROC-AUC results use the same hidden test rows. That makes the removal of duration the meaningful difference between their inputs.

The duration-free score addresses which clients look more likely to subscribe before a call begins. Its lower score remains useful because the model can access every input at prediction time.

  • Return to the bottom of the open Bank Marketing Predictor notebook.
  • Add a new code cell directly below the cell that creates honest_results.
Add the final recommendation

The reporting cell gives the comparison a business boundary. It presents the honest model as a campaign prioritization aid while leaving operational decisions to people.

This is historical practice data. The recommendation calls for newer data plus audits across relevant groups before operational use.

  • Paste the reporting code below into the new cell:
# Cell 8: Compare the scores and print a plain-English conclusion.
print("\nModel comparison")
print(f"With duration ROC-AUC:    {naive_results['roc_auc']:.3f}")
print(f"Without duration ROC-AUC: {honest_results['roc_auc']:.3f}")

print("\nBusiness recommendation")
print(
    "Use the duration-free model only as a campaign prioritization aid. "
    "Its score is less flattering but matches the information available before a call."
)
print(
    "Validate on newer campaign data and audit performance across relevant groups "
    "before any operational use."
)
print("\nThree highest historical job-group response rates:")
print((response_by_job.head(3) * 100).round(1).astype(str) + "%")

What does this code do?

  • The naive_results['roc_auc'] value supplies the score from the model with duration.
  • The honest_results['roc_auc'] value supplies the score from the fair pre-call model.
  • The recommendation limits the model to campaign prioritization.
  • The response_by_job.head(3) expression prints three historical job-group rates as context.
  • Run the new cell using the play button on its left.

You should see a model comparison followed by the business recommendation.

Below it, you should see three historical job-group response rates.

  • Keep the notebook open for a moment while Google Colab saves the updated cell to Google Drive.

Missing a result from the final cell?

  • If a variable is reported as missing, use the notebook's top-to-bottom run control so its earlier cell recreates it.
  • If the three job-group rates are missing, confirm the earlier exploration cell created response_by_job.

Get help tracing the missing value with this notebook debugging prompt.

✔️ Awesome, I've got everything!

Good work. Your notebook now explains which result is fair for the pre-call question.

ⓧ I'd like to double check the full code

Compare the bottom of your notebook with this final artifact:

# Cell 1 through Cell 7 remain exactly as previously created.

# Cell 8: Compare the scores and print a plain-English conclusion.
print("\nModel comparison")
print(f"With duration ROC-AUC:    {naive_results['roc_auc']:.3f}")
print(f"Without duration ROC-AUC: {honest_results['roc_auc']:.3f}")

print("\nBusiness recommendation")
print(
    "Use the duration-free model only as a campaign prioritization aid. "
    "Its score is less flattering but matches the information available before a call."
)
print(
    "Validate on newer campaign data and audit performance across relevant groups "
    "before any operational use."
)
print("\nThree highest historical job-group response rates:")
print((response_by_job.head(3) * 100).round(1).astype(str) + "%")
Rerun the complete notebook

A rerunnable notebook rebuilds every output from its saved cells. This checks that the final conclusion does not depend on forgotten notebook memory.

Before you run every cell, predict whether the final comparison will reappear from the saved notebook alone.

  • Use Colab's runtime controls to run every notebook cell from top to bottom.

You should see the dataset preview plus the subscription chart again.

The baseline output should be followed by both model evaluations.

The last output should compare the two ROC-AUC scores. It should print the recommendation beneath them.

The final lines should list three historical job-group response rates.

That top-to-bottom run proves the notebook is complete and rerunnable. You now have a shareable analysis that connects an honest model to a responsible recommendation.

Secret mission

Change When the Model Says Yes

Your fair model already scores each client between 0 and 1. Compare four cutoffs to see how the choice changes precision and recall.

Clean Up Your Resources

Clean Up Your Resources

This project costs $0 on Google Colab's free tier. Choose the cleanup option that matches what you want to keep.

Resources you used:

  • The Bank Marketing Predictor notebook stored in Google Drive.
  • The temporary Google Colab virtual machine containing your session environment.

Keep everything running

No action needed. Choose this option if you want to keep sharing or extending your notebook.

Your notebook stays in Google Drive. Colab may delete the temporary virtual machine after it sits idle.

Pause - I'll come back to this later

Shut down the temporary runtime to clear its session environment. Your notebook stays in Google Drive.

  • In the Bank Marketing Predictor notebook from earlier, click Runtime in the menu bar.
  • Select Disconnect and delete runtime.

Colab removes the temporary virtual machine. The notebook remains in Google Drive.

Its install cell can rebuild the environment when you return.

Delete - I don't want to use this again

This option removes the whole project. Start with the temporary runtime before deleting the notebook.

  • In the Bank Marketing Predictor notebook from earlier, click Runtime in the menu bar.
  • Select Disconnect and delete runtime.
  • Return to Google Drive.
  • Locate the Bank Marketing Predictor notebook.
  • Delete the Bank Marketing Predictor notebook from Google Drive.

Your temporary virtual machine is gone. Your notebook is deleted from Google Drive.

No paid cloud resources or ongoing charges remain from this project.

Nice Work!

Nice Work!

You did it! Your Google Colab notebook now turns real bank marketing data into an honest pre-call prediction model.

You've learned how to:

  • Turned 45,211 campaign records into a readable analysis. Your chart made the class imbalance visible. You also summarized historical response rates by job.
  • Compared a majority baseline with a logistic regression model on hidden test data. You interpreted precision. You interpreted recall. You read ROC-AUC as a ranking score. You used a confusion matrix to count correct predictions. You also used it to count incorrect predictions.
  • Caught data leakage caused by call duration. You replaced the tempting model with a fair pre-call model. You finished with a responsible recommendation for using it to prioritize calls.
  • Completed the Secret Mission by testing four prediction cutoffs. You saw how lower cutoffs find more likely subscribers. You saw how higher cutoffs avoid more unnecessary calls.

Ready to quiz yourself?