Start here · before every model

Make the course yours.

This page sets up the one notebook you will keep for the whole course. Together we will load some data, say plainly what you want to predict, split the rows fairly, tidy the columns, and get a first honest result to beat. Take it slowly—there is no rush.

CSV or Excelpredict a category or a number~35 minutes
Start with our example, then bring your own

You do not need any data of your own to begin. Every cell on this page already runs on a small set of example students. So the friendly way to start is: run all six cells in order and watch the whole thing work end to end. Once you have seen it, come back to Cell 1 and Cell 2 and point them at your own file—each one tells you exactly which line to change.

You do not pick a model yet

It is tempting to jump straight to a fancy model, but the important choices come first. Before anything else we decide four small things: what one row of your data stands for, what you are trying to predict, what information you would actually have on hand at the moment of predicting, and which kind of mistake would hurt you more. Answer those, and the rest of the setup almost writes itself.

DECISION 1

Is your answer a category or a number?

The target is the column holding the answer you wish you could predict. Look at yours and ask a simple question: is each answer a label you could sort into buckets, or is it a measured number? That single question decides which path you are on. If you are not sure yet, the example on this page uses a category, so you can just follow along.

Classification

The answer is a category—one of a limited set of labels. Think approved or declined, pass or fail, which of three plans, spam or not. If you can list the possible answers, you are here.

Regression

The answer is a measured amount that can slide anywhere along a range—minutes, rupees, a score, a temperature, tomorrow's demand. If it makes sense to ask "how much" or "how many", you are here.

No answer column

Your file has no column with the answer in it. That is fine, but this particular path needs one to learn from. The clustering lesson later is built for exactly this case.

DECISION 2

How will you set the fair final exam?

To find out if the model really learned anything, we hold back some rows as a test set—a final exam the model never gets to study. The only question is how to pick which rows go into that exam. There are three honest styles. For the example on this page, and for most first projects, plain random is the right choice.

Random

Shuffle the rows and deal some aside. Right when each row stands on its own and tomorrow's rows come from the same kind of situation as today's. This is the usual default.

Time

Predict the future from the past. If the real job is to guess what happens next, then everything you train on must come from before the moment you are predicting—so older rows train, newer rows test.

Group

Keep the same person on one side only. When one customer, patient, or device shows up in many rows, put all of that person's rows together in train or in test—never split across both.

Why this matters

If closely related rows end up on both sides of the exam, the model can quietly "recognise" test rows it has effectively already seen, and its score looks better than it is. A modest score from the honest exam tells you far more than an impressive score from a leaky one.

CELL 1 OF 6

Load some data into the notebook

Open a new Google Colab notebook—it is a free page in your browser where you paste and run these cells—and keep that one notebook open for the whole course. This first cell brings in a small set of example students so you can see everything work before touching your own data. When you are ready to use your own file, there is a clearly labelled Option B inside the cell that tells you exactly which line to change.

What this does: it reads a spreadsheet of example students into the notebook and prints how many rows and which columns it found.

CELL 1 · LOAD YOUR DATA
import io
import pandas as pd
import numpy as np

# ---- OPTION A: follow along with the example (just run this cell) ----------
# One row = one student. We will predict whether they passed.
example_url = (
    "https://raw.githubusercontent.com/Sreenivas-Sadhu-Prabhakara/"
    "learn-ml-models/main/data/study_habits.csv"
)
df = pd.read_csv(example_url)

# ---- OPTION B: use YOUR OWN file instead -----------------------------------
# 1) Put a "#" in front of the "df = pd.read_csv(example_url)" line above.
# 2) Delete the "#" in front of the four lines below.
# 3) Run the cell and pick your .csv or .xlsx file when Colab asks.
# from google.colab import files
# uploaded = files.upload()
# name = next(iter(uploaded))
# df = pd.read_csv(io.BytesIO(uploaded[name])) if name.lower().endswith(".csv") else pd.read_excel(io.BytesIO(uploaded[name]))

print(f"Rows: {len(df):,}")
print(f"Columns: {list(df.columns)}")
df.head()
WHAT YOU'LL SEE

With the example, roughly Rows: 320 and a small table whose columns include hours_studied, previous_score, participated, and passed. That first peek at the table is just to confirm the data arrived. When you swap in your own file you will see your own row count and your own column names—there is no "correct" number here, only a check that the load worked.

CELL 2 OF 6

Fill in your project card

This is where you tell the notebook what you are doing, in plain terms. The good news is that it already comes filled in for the example students, so you can run it untouched the first time. Here you will meet the target (the answer column) and the features (the clue columns it is allowed to look at).

In plain words

Target is the answer you want; features are the clues. On a class register, "did they pass?" is the target, and "hours studied", "attendance", and so on are the features. The golden rule: the answer is never allowed to be one of its own clues.

Notice you list the clue columns by hand rather than telling it to "use everything". That is on purpose. A "grab every column" shortcut would quietly hand the model things it should not have—an ID number, a fact that only exists after the answer is known, or the answer itself wearing a different label—and that quietly ruins the result.

What this does: it writes down, in one place, what you are predicting, which column is the answer, and which columns are the clues.

CELL 2 · PROJECT CONFIGURATION
# The values below already work for the EXAMPLE dataset, so you can run this
# cell as-is. When you switch to your own file, change the lines marked  <--

# In plain words, describe your project (these are notes for you):
ROW_MEANING = "one student and how they studied"              # <-- what is one row?
PREDICTION_GOAL = "whether the student passed"                # <-- what do you want to predict?
COSTLY_MISTAKE = "telling an at-risk student they will pass"  # <-- which mistake hurts most?

# Is your answer a category or a number?
TASK = "classification"       # <-- "classification" for a category, "regression" for a number

# Which column is the answer, and which columns are the clues?
TARGET = "passed"             # <-- the column you want to predict
FEATURES = [                  # <-- the columns used as clues (never include TARGET)
    "hours_studied",
    "attendance_percent",
    "previous_score",
    "sleep_hours",
    "participated",
]

# How should new data arrive? Keep "random" unless you have a reason to change it.
SPLIT_MODE = "random"         # "random", "time", or "group"
TIME_COLUMN = None             # only for a time split, e.g. "event_date"
GROUP_COLUMN = None            # only for a group split, e.g. "customer_id"
TEST_SIZE = 0.25              # keep 25% of rows for the final test
RANDOM_STATE = 42            # any fixed number, so results repeat
WHAT YOU'LL SEE

This cell does not print anything—it just quietly stores your choices. That silence is fine. The real check is you: read the three plain-English sentences at the top aloud. If your goal or your "costly mistake" still sounds fuzzy when you say it, that is worth a pause. A clear sentence here saves you a lot of confusion later. (With the example, it is all set already.)

CELL 3 OF 6

Let the notebook double-check your setup

Before we go any further, this cell reads back what you filled in and makes sure it holds together—that the answer column really exists, that the answer is not hiding in the clues, and so on. If something is off, it stops with a short message telling you what to fix.

An error here is a helper, not a scolding

If this cell turns red, nothing is broken and you did nothing wrong. It is the notebook catching a small slip—usually a typo in a column name—before it could waste your time downstream. Read the one-line message, fix that one thing, and run it again. That is the whole loop.

It also gives you a quick tour of your columns: which are numbers, which hold gaps, and whether any column looks more like a name tag than a real clue. Nothing gets deleted; a flag just means "take a look".

What this does: it checks your choices make sense, drops rows that have no answer, and shows a quick summary of every clue column.

CELL 3 · DATA AUDIT
if TASK not in {"classification", "regression"}:
    raise ValueError("TASK must be 'classification' or 'regression'.")

if not 0 < TEST_SIZE < 1:
    raise ValueError("TEST_SIZE must be a number between 0 and 1.")

if TARGET not in df.columns:
    raise ValueError(f"TARGET '{TARGET}' is not a column in your file.")

if not FEATURES:
    raise ValueError("Choose at least one feature.")

if len(FEATURES) != len(set(FEATURES)):
    raise ValueError("Each feature should appear only once in FEATURES.")

if TARGET in FEATURES:
    raise ValueError("Remove TARGET from FEATURES. The answer cannot be a clue.")

extra_columns = [
    column for column in (TIME_COLUMN, GROUP_COLUMN)
    if column is not None
]
required_columns = list(dict.fromkeys(FEATURES + [TARGET] + extra_columns))
missing_columns = [column for column in required_columns if column not in df.columns]

if missing_columns:
    raise ValueError(f"These configured columns do not exist: {missing_columns}")

model_data = df[required_columns].dropna(subset=[TARGET]).copy()

if TASK == "regression":
    model_data[TARGET] = pd.to_numeric(model_data[TARGET], errors="raise")
    if not np.isfinite(model_data[TARGET]).all():
        raise ValueError("A regression target cannot contain infinity.")
elif model_data[TARGET].nunique() < 2:
    raise ValueError("Classification needs at least two target classes.")

numeric_input_features = model_data[FEATURES].select_dtypes(
    include="number"
).columns.tolist()
infinite_counts = np.isinf(model_data[numeric_input_features]).sum()
infinite_counts = infinite_counts[infinite_counts > 0]
if not infinite_counts.empty:
    print("Treating these infinite feature values as missing:")
    display(infinite_counts.to_frame("infinite_values"))
    model_data[numeric_input_features] = model_data[
        numeric_input_features
    ].replace([np.inf, -np.inf], np.nan)

print("One row:", ROW_MEANING)
print("Goal:", PREDICTION_GOAL)
print("Costly mistake:", COSTLY_MISTAKE)
print("Usable rows:", len(model_data))

profile = pd.DataFrame({
    "dtype": model_data[FEATURES].dtypes.astype(str),
    "missing": model_data[FEATURES].isna().sum(),
    "unique_values": model_data[FEATURES].nunique(dropna=True),
})
display(profile)

if TASK == "classification":
    target_profile = pd.DataFrame({
        "rows": model_data[TARGET].value_counts(dropna=False),
        "share": model_data[TARGET].value_counts(normalize=True, dropna=False),
    })
    display(target_profile)
else:
    display(model_data[TARGET].describe().to_frame("target"))

possible_identifiers = []
for column in FEATURES:
    name_looks_like_id = "id" in column.lower().replace("-", "_").split("_")
    mostly_unique_text = (
        not pd.api.types.is_numeric_dtype(model_data[column])
        and model_data[column].nunique(dropna=True) > 0.5 * len(model_data)
    )
    if name_looks_like_id or mostly_unique_text:
        possible_identifiers.append(column)

if possible_identifiers:
    print("Review these possible identifier/high-cardinality columns:", possible_identifiers)
WHAT YOU'LL SEE

A short recap of your project, a "usable rows" count, and a small table describing each clue column—its type, how many blanks it has, and how many distinct values. For the example you will also see how the pass/fail answers split. Glance at it and try to say in your own words: is the answer lopsided toward one option, and does any clue look like it might secretly give the answer away or just tag a row rather than describe it? Your numbers will differ from the example, and that is expected.

Let the real balance be what it is

If your categories are lopsided—say most students pass—do not "fix" that by forcing it to a tidy 50/50. The imbalance is a true fact about your world, and the model should learn on it as it really is. Keep the natural mix, and later judge the model on more than raw accuracy so a lazy "always guess the common answer" cannot look good.

Going deeper (optional)

When you keep the natural mix, plain accuracy can be misleading, so we also watch balanced accuracy and macro-F1, which treat each category fairly rather than letting the biggest one dominate. If the costs really justify it, techniques like class weights or resampling can help—but they belong strictly inside the training rows. Never reshape the held-out test set; the final exam has to reflect reality.

CELL 4 OF 6

Set aside the fair final exam

Now we actually carve off the test set—the rows the model will never learn from—using the split style you picked in Decision 2. You are the one choosing the style, on purpose, because only you know how your data behaves in the real world.

Why hold rows back at all?

If you graded the model on the same rows it studied, of course it would look brilliant—it already saw the answers. Holding some rows aside gives it a genuine final exam on rows it has never met, which is the only fair way to know whether it truly learned or just memorised.

A quick reminder of the three styles: random shuffles the rows and deals some aside; time trains on the past and tests on the future; group keeps every row belonging to the same person or case entirely on one side. The example uses random.

What this does: it splits your rows into a training pile the model learns from and a testing pile it is graded on, using the style you chose.

CELL 4 · TRAIN / TEST SPLIT
from sklearn.model_selection import train_test_split, GroupShuffleSplit

if SPLIT_MODE == "random":
    stratify = None
    if TASK == "classification":
        class_counts = model_data[TARGET].value_counts()
        if class_counts.min() >= 2:
            stratify = model_data[TARGET]

    train_rows, test_rows = train_test_split(
        model_data,
        test_size=TEST_SIZE,
        random_state=RANDOM_STATE,
        stratify=stratify,
    )

elif SPLIT_MODE == "time":
    if not TIME_COLUMN:
        raise ValueError("Set TIME_COLUMN before using a time split.")

    if model_data[TIME_COLUMN].isna().any():
        raise ValueError(
            f"TIME_COLUMN '{TIME_COLUMN}' contains missing values. "
            "Fill or remove them before splitting."
        )

    model_data[TIME_COLUMN] = pd.to_datetime(
        model_data[TIME_COLUMN], errors="raise"
    )
    ordered = model_data.sort_values(TIME_COLUMN)
    split_at = int(len(ordered) * (1 - TEST_SIZE))

    if split_at < 1 or split_at >= len(ordered):
        raise ValueError("TEST_SIZE leaves an empty train or test set.")

    # Keep identical timestamps on the same side of the boundary.
    test_start_time = ordered.iloc[split_at][TIME_COLUMN]
    train_rows = ordered[ordered[TIME_COLUMN] < test_start_time]
    test_rows = ordered[ordered[TIME_COLUMN] >= test_start_time]

    if train_rows.empty or test_rows.empty:
        raise ValueError(
            "The time boundary leaves an empty train or test set. "
            "Choose a different TEST_SIZE or review the timestamp values."
        )

elif SPLIT_MODE == "group":
    if not GROUP_COLUMN:
        raise ValueError("Set GROUP_COLUMN before using a group split.")

    if model_data[GROUP_COLUMN].isna().any():
        raise ValueError(
            f"GROUP_COLUMN '{GROUP_COLUMN}' contains missing values. "
            "Fill or remove them before splitting."
        )

    if model_data[GROUP_COLUMN].nunique() < 2:
        raise ValueError("A group split needs at least two distinct groups.")

    splitter = GroupShuffleSplit(
        n_splits=1,
        test_size=TEST_SIZE,
        random_state=RANDOM_STATE,
    )
    train_index, test_index = next(
        splitter.split(model_data, groups=model_data[GROUP_COLUMN])
    )
    train_rows = model_data.iloc[train_index]
    test_rows = model_data.iloc[test_index]

else:
    raise ValueError("SPLIT_MODE must be 'random', 'time', or 'group'.")

X_train = train_rows[FEATURES].copy()
X_test = test_rows[FEATURES].copy()
y_train = train_rows[TARGET].copy()
y_test = test_rows[TARGET].copy()

if TASK == "classification" and y_train.nunique() < 2:
    raise ValueError(
        "The training split contains fewer than two classes. "
        "Use more data or choose a different valid split."
    )

print("Training rows:", len(X_train))
print("Testing rows: ", len(X_test))
print("Split mode:   ", SPLIT_MODE)

if SPLIT_MODE == "time":
    print("Train ends:", train_rows[TIME_COLUMN].max())
    print("Test starts:", test_rows[TIME_COLUMN].min())
elif SPLIT_MODE == "group":
    overlap = set(train_rows[GROUP_COLUMN]) & set(test_rows[GROUP_COLUMN])
    print("Groups shared across train and test:", len(overlap))
WHAT YOU'LL SEE

Two counts, the training rows and the testing rows, which should add up to your usable rows from Cell 3—for the example that is roughly 240 for training and 80 for testing. If you chose a group split, it should report zero shared groups across the two piles (nobody is on both sides). A time split should show every test date falling after the training cutoff. Your own totals will differ; what matters is that the two piles add up and the boundary is clean.

CELL 5 OF 6

Get the columns ready for a model

Models are fussy eaters: they want every cell filled in, every word turned into a number, and the numbers on a comparable scale. This cell builds a small reusable helper, make_preprocessor(...), that does all of that tidying for you.

What the preprocessor does

Three quiet jobs. It fills blank cells with a sensible stand-in so no gaps remain. It turns words into numbers, because a model cannot do arithmetic on the word "yes". And it puts numbers on a fair scale so a column measured in thousands does not shout over a column measured in single digits. Crucially, it learns how to do all this from the training rows only—it never peeks at the test rows, so the final exam stays honest.

It handles number columns and word columns side by side, and if a brand-new category shows up in the test rows that it never saw in training, it shrugs it off safely instead of crashing.

What this does: it builds a reusable tidy-up step that fills blanks, turns words into numbers, and evens out the scales—learning only from the training rows.

CELL 5 · PREPROCESSOR FACTORY
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import OneHotEncoder, StandardScaler

numeric_features = X_train.select_dtypes(include="number").columns.tolist()
categorical_features = [
    column for column in FEATURES
    if column not in numeric_features
]

print("Numeric features:", numeric_features)
print("Categorical features:", categorical_features)

high_cardinality = [
    column for column in categorical_features
    if X_train[column].nunique(dropna=True) > 100
]
if high_cardinality:
    print("Review high-cardinality features before continuing:", high_cardinality)

def make_encoder():
    try:
        return OneHotEncoder(handle_unknown="ignore", sparse_output=False)
    except TypeError:  # Compatibility with older scikit-learn versions
        return OneHotEncoder(handle_unknown="ignore", sparse=False)

def make_preprocessor(scale_numeric=False):
    transformers = []

    if numeric_features:
        numeric_steps = [
            ("fill_missing", SimpleImputer(strategy="median")),
        ]
        if scale_numeric:
            numeric_steps.append(("scale", StandardScaler()))

        transformers.append((
            "numeric",
            Pipeline(numeric_steps),
            numeric_features,
        ))

    if categorical_features:
        categorical_steps = Pipeline([
            ("fill_missing", SimpleImputer(strategy="most_frequent")),
            ("encode", make_encoder()),
        ])
        transformers.append((
            "categorical",
            categorical_steps,
            categorical_features,
        ))

    return ColumnTransformer(
        transformers,
        remainder="drop",
        verbose_feature_names_out=False,
    )
WHAT YOU'LL SEE

Two short lists: the columns it treated as numbers and the ones it treated as words. Take a second to check the sorting looks right for your data. For the example, the study columns land in the number list. If a column has a huge number of distinct text values, it gets gently flagged for a look—your own file may or may not show that, and either way it is just a heads-up.

Going deeper (optional)

Raw dates, free-form text, and columns with hundreds of distinct labels usually deserve some hand-crafting before they make good clues—turning a date into "day of week", for instance. You can leave that for later; the helper will still run without it.

CELL 6 OF 6

Set the score to beat

Last setup step, and a satisfying one: we build a baseline. Every real model you try later has to do better than this, or it is not earning its keep.

What a baseline is

The baseline is the laziest guess that still makes sense. For a category, it always blurts out the most common answer; for a number, it always says the average. It looks at no clues at all. If a clever model cannot beat this, the clues are not helping—so the baseline is the honest bar every model must clear.

This cell also quietly prepares a couple of shared tools the model lessons lean on later: a results_log that keeps a running scoreboard, and a cross-validation helper that respects your random, time, or group boundary while models are being tuned. You do not need to fuss over those today—they are just waiting there for the next lessons.

What this does: it makes the simplest possible guesser and scores it, giving you the number every real model has to beat.

CELL 6 · EVALUATION + BASELINE
import matplotlib.pyplot as plt
from sklearn.dummy import DummyClassifier, DummyRegressor
from sklearn.metrics import (
    accuracy_score,
    balanced_accuracy_score,
    classification_report,
    ConfusionMatrixDisplay,
    f1_score,
    mean_absolute_error,
    mean_squared_error,
    r2_score,
)
from sklearn.model_selection import (
    GroupKFold,
    KFold,
    StratifiedKFold,
    TimeSeriesSplit,
)

results_log = []

# Pass this dictionary into every cross-validation search's fit() call.
# Group labels are needed while folds are constructed, but are never features.
CV_FIT_PARAMS = (
    {"groups": train_rows[GROUP_COLUMN].to_numpy()}
    if SPLIT_MODE == "group"
    else {}
)

def make_cv():
    if SPLIT_MODE == "time":
        folds = min(5, len(y_train) - 1)
        if folds < 2:
            raise ValueError(
                "Time-based CV needs at least three training rows."
            )
        return TimeSeriesSplit(n_splits=folds)

    if SPLIT_MODE == "group":
        groups = train_rows[GROUP_COLUMN]
        folds = min(5, groups.nunique())
        if folds < 2:
            raise ValueError(
                "Group-based CV needs at least two training groups."
            )
        return GroupKFold(n_splits=folds)

    if TASK == "classification":
        folds = min(5, int(y_train.value_counts().min()))
        if folds < 2:
            raise ValueError("Every class needs at least two training rows for CV.")
        return StratifiedKFold(
            n_splits=folds,
            shuffle=True,
            random_state=RANDOM_STATE,
        )

    folds = min(5, len(y_train))
    if folds < 2:
        raise ValueError("Regression needs at least two training rows for CV.")
    return KFold(
        n_splits=folds,
        shuffle=True,
        random_state=RANDOM_STATE,
    )

PRIMARY_SCORING = (
    "balanced_accuracy" if TASK == "classification"
    else "neg_mean_absolute_error"
)

def evaluate_predictions(name, y_true, y_pred, show_details=True):
    if TASK == "classification":
        row = {
            "model": name,
            "accuracy": accuracy_score(y_true, y_pred),
            "balanced_accuracy": balanced_accuracy_score(y_true, y_pred),
            "macro_f1": f1_score(
                y_true, y_pred, average="macro", zero_division=0
            ),
        }
    else:
        row = {
            "model": name,
            "MAE": mean_absolute_error(y_true, y_pred),
            "RMSE": np.sqrt(mean_squared_error(y_true, y_pred)),
            "R2": r2_score(y_true, y_pred),
        }

    results_log[:] = [item for item in results_log if item["model"] != name]
    results_log.append(row)
    display(pd.DataFrame(results_log))

    if show_details and TASK == "classification":
        print(classification_report(y_true, y_pred, zero_division=0))
        ConfusionMatrixDisplay.from_predictions(
            y_true, y_pred, xticks_rotation=45
        )
        plt.show()

    return row

dummy_train = np.zeros((len(y_train), 1))
dummy_test = np.zeros((len(y_test), 1))

if TASK == "classification":
    baseline_model = DummyClassifier(strategy="most_frequent")
else:
    baseline_model = DummyRegressor(strategy="mean")

baseline_model.fit(dummy_train, y_train)
baseline_predictions = baseline_model.predict(dummy_test)
evaluate_predictions("Baseline", y_test, baseline_predictions, show_details=False)
WHAT YOU'LL SEE

A little scoreboard with one row—"Baseline"—and its score. For the example students, accuracy lands around 64%, because always guessing "pass" happens to be right about that often here. Do not expect 50%; a baseline reflects how lopsided your answers are, so yours will be its own number entirely. That figure is not a grade—it is simply the line in the sand your first real model needs to step over.

FOUNDATION COMPLETE

Keep this one notebook

That is the whole foundation—nicely done. From here, every model lesson simply adds new cells underneath these six and reuses everything you just built. So keep this notebook; do not start a fresh one each lesson. If Colab ever disconnects, no panic: choose Runtime → Run all and it rebuilds everything in order. One thing to hold onto: never make a new train/test split for each model. A fair comparison means every model sits the exact same final exam.

PAUSE & WRITE
  1. What information will genuinely be available when this prediction is made?
  2. Which wrong prediction is more expensive, and does your chosen metric reflect that?
  3. What would make a result suspiciously good?

YOUR PACE, YOUR PROJECT

Save your work before moving on.

Go at whatever pace suits you—there is no clock. And keep this in mind as you continue: your score is evidence about your data, not a grade about you. Jot down what you changed, what improved, and what still puzzles you; that little log is what turns each lesson into real understanding.