Lesson 03 · Classification

A model that sorts things into groups — and tells you how sure it is

This lesson introduces a friendly, easy-to-read model for predicting a category — like "pass" or "not pass". Its special gift is that it doesn't just pick an answer; it tells you how confident it is, such as "about 80% likely to pass". We'll go slowly, one small idea at a time, at your own pace.

your uploaded dataset classification only about 30 minutes

Continue in your foundation notebook

This lesson expects the variables and helper functions created in Your problem, your data. Keep that Colab notebook open and add each cell below.

Review foundation

The confusing name, cleared up first

Yes, it's called logistic regression — but it is really a classifier. That means its job is to sort each row into a group: yes/no, pass/fail, one of a few types. Don't let the word "regression" trip you up. If the thing you want to predict is a measured number instead (like a score or a price), head back to the model map and pick a regression lesson.

THE IDEA

How it decides, in everyday terms

Think of the model as a friendly judge weighing evidence. It looks at each clue in a row (each feature), decides how much each clue matters, and adds them up into a single running total. Then it converts that total into a probability — a number between 0 and 1 — that leans toward one group or another. Whichever group ends up most likely becomes its answer.

In plain words

Each clue nudges the answer a little. A high total pushes toward one group; a low total pushes toward the other. The model smoothly slides from "probably this group" to "probably that group" rather than flipping abruptly — like a dimmer switch, not an on/off switch.

You'll also meet a setting the code calls C. It controls how much freedom the model has to react strongly to any one clue. A smaller C keeps the model calmer and simpler; a larger C lets it react more. Neither is automatically better, so instead of guessing, you'll let the data tell you which works best — using only the practice data, never the final exam.

A probability is a considered opinion, not a promise

When the model says "about 80% likely to pass," treat that as its best-informed guess, not a guarantee. It's a considered opinion from what it has seen so far. It can be off — especially when you have only a little data, when the future turns out different from the past, or when something important simply wasn't recorded.

CELL 1 OF 5

Get the model ready

This first cell double-checks that your foundation setup ran, then assembles the model so it's ready to learn. It puts two steps together in order: first it puts every number on a comparable footing (called scaling), then it hands the result to the model. Bundling these steps together is called a pipeline.

In plain words

Scaling means measuring every clue with the same-sized ruler, so a column like "attendance percent" doesn't overpower a column like "sleep hours" just because its numbers happen to be bigger. The pipeline makes sure this same preparation happens every single time, automatically.

What this does: Checks that your setup cells ran, confirms you're doing a category-prediction task, and builds the model together with its number-scaling step.

CELL 1 · MODEL SETUP
import pandas as pd

from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV
from sklearn.pipeline import Pipeline

required_foundation = [
    "TASK", "RANDOM_STATE", "FEATURES", "X_train", "X_test",
    "y_train", "y_test", "make_preprocessor", "make_cv",
    "CV_FIT_PARAMS", "PRIMARY_SCORING", "evaluate_predictions",
    "results_log",
]
missing_foundation = [
    name for name in required_foundation if name not in globals()
]
if missing_foundation:
    raise NameError(
        "Run every Foundation cell first. Missing: "
        + ", ".join(missing_foundation)
    )

if TASK != "classification":
    raise ValueError(
        "Logistic regression predicts categories. "
        "Set TASK to 'classification' or choose a regression lesson."
    )

logistic_pipeline = Pipeline([
    ("prepare", make_preprocessor(scale_numeric=True)),
    ("model", LogisticRegression(
        max_iter=3000,
        random_state=RANDOM_STATE,
    )),
])

print("Training rows:", len(X_train))
print("Held-out test rows:", len(X_test))
print("Classes in training:", sorted(y_train.unique(), key=str))
WHAT YOU'LL SEE

A count of your practice rows and your set-aside test rows, plus a list of the groups the model will choose between (on the example students, "passed" is true or false — two groups). Your own data will differ. You just want to see at least two groups here. If a group is missing or has only a single row, that's a gentle nudge to revisit how you defined your target before going further. And if the cell complains that setup is missing, no worries — click Runtime then Run all and try again.

CELL 2 OF 5

Let the data pick the best setting

Remember that C setting? Rather than guessing it, we try a handful of values and let the practice data show which works best. The trick is a fair, careful trial called cross-validation.

In plain words

Cross-validation is like giving the model several small practice quizzes. Each time, it studies most of the practice data and gets quizzed on the slice it hasn't seen yet — then we rotate which slice is held back. Averaging those quiz scores gives an honest sense of how each setting performs. Crucially, the real final exam (your test set) stays sealed the whole time.

What this does: Tries five different C values, quizzes each one fairly on the practice data, and keeps the setting that scored best — then retrains that winner on all the practice data.

CELL 2 · CROSS-VALIDATION
search = GridSearchCV(
    estimator=logistic_pipeline,
    param_grid={
        "model__C": [0.01, 0.1, 1.0, 10.0, 100.0],
    },
    scoring=PRIMARY_SCORING,
    cv=make_cv(),
    n_jobs=-1,
    refit=True,
    return_train_score=True,
)

search.fit(X_train, y_train, **CV_FIT_PARAMS)
best_logistic = search.best_estimator_

cv_table = pd.DataFrame(search.cv_results_)[[
    "param_model__C",
    "mean_train_score",
    "mean_test_score",
    "std_test_score",
    "rank_test_score",
]].sort_values("rank_test_score")

display(cv_table)
print("Chosen settings:", search.best_params_)
print("Validation metric:", PRIMARY_SCORING)
WHAT YOU'LL SEE

A small table ranking the five C values by their average quiz score, and a note of which one won. Don't chase a dazzling winning number — look for a score that stays steady. If several settings land almost neck-and-neck, that's genuinely useful: it tells you the exact winner might wobble a bit, which is honest information about how sure we can be. Your own data will differ, and there's no single "right" number here.

PAUSE & WRITE
  1. Did the calmer, simpler setting or the more reactive one work better on your practice quizzes?
  2. How close were the top settings to each other, compared with the gap to the rest?
CELL 3 OF 5

Take one honest look at the final exam

The model and its setting are now locked in. This is the moment we open the sealed test set — the rows the model has never seen — and let it make its predictions, just once. We add the result to the same log as your baseline and any earlier lessons.

In plain words

The baseline is the "do-nothing" comparison — it just guesses the most common answer every time. On the example students, always guessing "pass" is right about 64% of the time. That's the bar to clear. If your model can't beat guessing the obvious, it isn't yet earning its keep.

What this does: Shows the locked model the test rows for the first and only time, scores how well it did, and records that score next to your baseline.

CELL 3 · FINAL EVALUATION
logistic_predictions = best_logistic.predict(X_test)

logistic_result = evaluate_predictions(
    "Logistic regression",
    y_test,
    logistic_predictions,
)

print("Compare this row with the baseline using the metric that fits your problem.")
WHAT YOU'LL SEE

A single score for how often the model was right on the test rows. On the example students it usually lands around the mid-70s in percent — comfortably above the roughly 64% baseline. Your own data will differ, and there's no "correct" score to aim for: the honest question is simply "did it beat my baseline, and where does it still slip up?" A single overall number can quietly hide poor performance on a group that's rare but really matters, so it's worth a closer look.

Resist the urge to keep tweaking now

It's tempting, but if you change C, your features, or your preparation because of what you just saw on the test score, the test set quietly stops being a fair final exam. That's a subtle trap. Instead, jot the idea down as a future experiment — one you'd run with a fresh, untouched test set.

CELL 4 OF 5

See how confident the model was

This is the model's special gift. Instead of only saying "pass," it can say "about 80% likely to pass." This cell lays those confidence numbers out in a table next to what actually happened, so you can see not just what the model guessed, but how sure it was.

In plain words

For each row, the model spreads 100% of its confidence across the possible answers. If it's 80% sure of "pass," the remaining 20% goes to "not pass" — the numbers in a row always add up to a whole. A high number means "quite sure," a number near the middle means "honestly, this one's a toss-up."

What this does: Builds a table showing, for each test row, what actually happened, what the model guessed, and how confident it was in each possible answer.

CELL 4 · PROBABILITY ESTIMATES
probability_estimates = best_logistic.predict_proba(X_test)
class_labels = best_logistic.named_steps["model"].classes_

probability_table = pd.DataFrame(
    probability_estimates,
    columns=[f"estimated_p({label})" for label in class_labels],
    index=X_test.index,
)
probability_table.insert(0, "predicted", logistic_predictions)
probability_table.insert(0, "actual", y_test)

display(probability_table.head(12))
print(
    "Largest row-sum error:",
    abs(probability_estimates.sum(axis=1) - 1).max(),
)
WHAT YOU'LL SEE

The first several test rows, each with the true answer, the model's guess, and its confidence numbers — plus a tiny check confirming each row's confidence adds up to a whole. Your own data will differ. The interesting rows to hunt for are the two honest surprises: one where the model was unsure yet still got it right, and one where it was very confident yet wrong. Those two tell you far more than the easy rows it always nails.

PAUSE & WRITE
  1. In a real decision, would you just take the model's top pick — or set your own cutoff based on how costly each kind of mistake is?
  2. What might make these confidence numbers less trustworthy once you use the model on brand-new situations?
CELL 5 OF 5

Peek at which clues mattered

One lovely thing about this model is that you can look inside and see what it learned. Each clue gets a weight — the code calls it a coefficient. The sign tells you which group the clue leans toward, and the size hints at how strongly it pushed.

In plain words

A coefficient is just "how much this clue nudged the answer, and in which direction." A positive weight pushes toward one group; a negative weight pushes toward the other. On the study example, you might see that more hours studied and higher attendance push toward "pass" — which matches common sense, and that's reassuring.

What this does: Lists the clues by how strongly they swayed the model, and notes which group the positive ones lean toward.

CELL 5 · COEFFICIENTS
prepared_feature_names = (
    best_logistic.named_steps["prepare"].get_feature_names_out()
)
fitted_logistic = best_logistic.named_steps["model"]

if len(fitted_logistic.classes_) == 2:
    coefficient_table = pd.DataFrame({
        "transformed_feature": prepared_feature_names,
        "coefficient": fitted_logistic.coef_[0],
    })
    coefficient_table["absolute_size"] = (
        coefficient_table["coefficient"].abs()
    )
    coefficient_table = coefficient_table.sort_values(
        "absolute_size", ascending=False
    )
    print(
        "Positive coefficients shift predictions toward:",
        fitted_logistic.classes_[1],
    )
    display(coefficient_table.head(20))
else:
    coefficient_table = pd.DataFrame(
        fitted_logistic.coef_.T,
        index=prepared_feature_names,
        columns=[str(label) for label in fitted_logistic.classes_],
    ).rename_axis("transformed_feature").reset_index()

    coefficient_long = coefficient_table.melt(
        id_vars="transformed_feature",
        var_name="class",
        value_name="coefficient",
    )
    coefficient_long["absolute_size"] = (
        coefficient_long["coefficient"].abs()
    )
    top_by_class = (
        coefficient_long
        .sort_values(["class", "absolute_size"], ascending=[True, False])
        .groupby("class", group_keys=False)
        .head(10)
    )
    display(top_by_class)
WHAT YOU'LL SEE

A table of clues sorted by how strongly they swayed the model, with a note telling you which group the positive weights point toward. Your own data will differ, so read it as a story about your columns, not a verdict.

Going deeper (optional)

A couple of honest cautions if you want to read the weights closely. When your target has two groups, the plus/minus signs are all relative to the second group the model lists. When it has three or more groups, read each group's column on its own. Also, the number clues were put on the same-sized ruler earlier (scaled), while yes/no category clues were not — so you can't line up all the sizes as one tidy ranking of importance. Compare like with like.

"Mattered to the model" is not the same as "caused it"

A weight describes a pattern the model noticed, holding the other clues steady — it does not prove that clue causes the outcome. Clues that travel together, things you never recorded, how categories were coded, and the calming effect of that C setting can all shift a weight's size or even flip its sign. So read weights as interesting hints, not proof.

DEEPER PRACTICE

Explore at your pace

None of this is required — it's just here if you're curious and want to poke around. Here are a few gentle experiments and what to watch for with each. There's no single right answer; the habit to keep is always comparing back to your baseline.

TryObserveKeep honest
Remove one suspicious featureCV stability and coefficient changesChoose with training CV only
Change the classification thresholdWhich errors trade placesUse a validation plan, not the test score
Check probability calibrationWhether predicted frequencies match outcomesUse out-of-fold or separate validation predictions
LESSON NOTES
  1. Which metric matters for your real decision, and why?
  2. Where does the model make confident mistakes?
  3. What is your next training-only experiment?

YOUR PACE, YOUR PROJECT

Save the evidence, not just the score.

Your score is evidence, not a grade — so jot down the story around it: which C won, how close the top settings were, your one honest test result, where the model made costly mistakes, and one reason its confidence numbers might not be fully trustworthy.