PythonMastery
beginner 15 min read · lesson 4 of 6 in Machine Learning Fundamentals

Classification: Predicting Categories

1 · The lesson

read

You're predicting a category. Spam or not-spam. Fraud or legitimate. Cat, dog, or bird. The target is discrete: a label, not a number.

This is one of the most common kinds of ML problem in industry. Almost every "is this X?" question is classification. This lesson covers the most widely used basic classifier (logistic regression), what a decision boundary is, and, most importantly, the four metrics that work better than accuracy on imbalanced data.

Run these right here: scikit-learn and NumPy both work in the browser. The first scikit-learn import takes a few seconds while it downloads. After that it's instant. Expected output is also shown in comments below each block.


1. Binary vs Multi-class

Binary: exactly two classes. Spam / not-spam. Click / no-click. Healthy / diseased. By convention, label them 0 and 1, with 1 usually being the "positive" or "interesting" class.

Multi-class: three or more classes. Cat / dog / bird. Iris-setosa / versicolor / virginica. Customer-tier bronze / silver / gold.

scikit-learn handles both with the same API. Most classifiers handle multi-class problems automatically (some, for example, train one binary classifier per class, called "one-vs-rest"). You need to know which kind of problem you're solving so you can choose the right metric.


2. The Decision Boundary

A classifier learns a decision boundary: a line, curve, or surface that separates one class from the other. Points on one side are predicted as class 0, and points on the other side as class 1.

python
x₂
 ▲   ● ●           class 0
 │  ● ● ●  ╲       ← decision boundary
 │ ● ●     ╲
 │          ╲ ▲ ▲
 │           ╲▲ ▲ ▲   class 1
 │            ╲ ▲ ▲
 └────────────────► x₁

Different model families draw boundaries of different shapes:


  • Logistic regression → a straight line (or flat hyperplane).

  • Decision trees → axis-aligned rectangles.

  • KNN → irregular regions made of Voronoi cells.

  • SVM with RBF kernel → smooth curves.

A useful way to compare classifier families is the kind of boundary they can draw.


3. Logistic Regression: the Usual First Choice

Despite the name, logistic regression is a classifier, not a regressor. (The name is historical and unhelpful.)

It is closely related to linear regression. It also adds up weighted features, but then passes the result through a function called the sigmoid, which maps any real number into the range [0, 1]. That output is interpreted as a probability:

python
linear part         squash
  ↓                   ↓
z = w·x + b   →   P(y=1) = 1 / (1 + e^(-z))

If P(y=1) > 0.5, predict class 1. Otherwise, predict class 0. It is fast to train, you can interpret its coefficients, and its probabilities are usually sensible. It is a good first classifier for a new problem.

python
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split

X, y = make_classification(n_samples=500, n_features=5,
                           n_informative=3, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

model = LogisticRegression(max_iter=1000).fit(X_train, y_train)

print("Predictions:    ", model.predict(X_test[:5]))
print("Probabilities:  ", model.predict_proba(X_test[:5]).round(2))
print("Accuracy:       ", model.score(X_test, y_test))
# → Predictions:     [1 0 0 0 1]
# → Probabilities:   [[0.35 0.65]
# →  [0.95 0.05]
# →  [0.59 0.41]
# →  [0.99 0.01]
# →  [0.   1.  ]]
# → Accuracy:        0.83

predict_proba returns one column per class: [P(class=0), P(class=1)]. The values in each row sum to 1. Use them when you need a risk score, not just a yes/no answer.


4. Threshold Tuning: 0.5 Is Only a Default

predict() uses a 0.5 cutoff. Probability > 0.5 → predict 1, else 0. You can change this.

When does it matter?


  • Fraud detection. A false negative (missed fraud) is expensive. You'd rather flag suspicious transactions at probability > 0.3 even if half are wrong.

  • Spam. A false positive (real email in spam folder) is very expensive. Maybe only flag at > 0.8.

python
import numpy as np

probs = model.predict_proba(X_test)[:, 1]   # P(class=1) for every row
y_pred_strict   = (probs > 0.8).astype(int)
y_pred_lenient  = (probs > 0.3).astype(int)
+ setup added so this can run · re-runs 1 earlier block on this page first
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

X_test = _AutoMock('X_test')
model = _AutoMock('model')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from sklearn.datasets import make_classification
    from sklearn.linear_model import LogisticRegression
    from sklearn.model_selection import train_test_split
except Exception:
    pass
_pm_earlier = [
    'from sklearn.datasets import make_classification\nfrom sklearn.linear_model import LogisticRegression\nfrom sklearn.model_selection import train_test_split\n\nX, y = make_classification(n_samples=500, n_features=5,\n                           n_informative=3, random_state=42)\nX_train, X_test, y_train, y_test = train_test_split(\n    X, y, test_size=0.2, random_state=42\n)\n\nmodel = LogisticRegression(max_iter=1000).fit(X_train, y_train)\n\nprint("Predictions:    ", model.predict(X_test[:5]))\nprint("Probabilities:  ", model.predict_proba(X_test[:5]).round(2))\nprint("Accuracy:       ", model.score(X_test, y_test))\n# → Predictions:     [1 0 0 0 1]\n# → Probabilities:   [[0.35 0.65]\n# →  [0.95 0.05]\n# →  [0.59 0.41]\n# →  [0.99 0.01]\n# →  [0.   1.  ]]\n# → Accuracy:        0.83',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

You are trading precision against recall, which the next sections explain.


5. The Confusion Matrix

Four possible outcomes for any binary prediction:

Predicted 0Predicted 1
Actually 0True Negative (TN)False Positive (FP)
Actually 1False Negative (FN)True Positive (TP)

Learn the four names. Every binary metric is built from these four numbers.

python
from sklearn.metrics import confusion_matrix

y_pred = model.predict(X_test)
print(confusion_matrix(y_test, y_pred))
# → [[39 12]
# →  [ 5 44]]
#
# Reading: 39 TN and 12 FP in the top row, 5 FN and 44 TP in the bottom row.
+ setup added so this can run · re-runs 2 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

X_test = _AutoMock('X_test')
model = _AutoMock('model')
y_test = _AutoMock('y_test')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from sklearn.datasets import make_classification
    from sklearn.linear_model import LogisticRegression
    from sklearn.model_selection import train_test_split
    import numpy as np
except Exception:
    pass
_pm_earlier = [
    'from sklearn.datasets import make_classification\nfrom sklearn.linear_model import LogisticRegression\nfrom sklearn.model_selection import train_test_split\n\nX, y = make_classification(n_samples=500, n_features=5,\n                           n_informative=3, random_state=42)\nX_train, X_test, y_train, y_test = train_test_split(\n    X, y, test_size=0.2, random_state=42\n)\n\nmodel = LogisticRegression(max_iter=1000).fit(X_train, y_train)\n\nprint("Predictions:    ", model.predict(X_test[:5]))\nprint("Probabilities:  ", model.predict_proba(X_test[:5]).round(2))\nprint("Accuracy:       ", model.score(X_test, y_test))\n# → Predictions:     [1 0 0 0 1]\n# → Probabilities:   [[0.35 0.65]\n# →  [0.95 0.05]\n# →  [0.59 0.41]\n# →  [0.99 0.01]\n# →  [0.   1.  ]]\n# → Accuracy:        0.83',
    'import numpy as np\n\nprobs = model.predict_proba(X_test)[:, 1]   # P(class=1) for every row\ny_pred_strict   = (probs > 0.8).astype(int)\ny_pred_lenient  = (probs > 0.3).astype(int)',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

6. Four Metrics That Are Better Than Accuracy

Accuracy: the basic one

python
accuracy = (TP + TN) / total
+ setup added so this can run · defines total, TP, TN
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

total = _AutoMock('total')
TP = _AutoMock('TP')
TN = _AutoMock('TN')

"What fraction did we get right?" Reasonable when classes are balanced. Useless when they aren't (see Section 7).

Precision: how many of my positive predictions are right?

python
precision = TP / (TP + FP)
+ setup added so this can run · defines TP, FP
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

TP = _AutoMock('TP')
FP = _AutoMock('FP')

"Of everything I flagged as positive, how many really were?" Optimise for precision when false positives are costly: spam filtering, sending alerts.

Recall: how many of the real positives did I find?

python
recall = TP / (TP + FN)
+ setup added so this can run · defines TP, FN
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

TP = _AutoMock('TP')
FN = _AutoMock('FN')

"Of all the actual positives, how many did I catch?" Optimise for recall when false negatives are costly: fraud detection, cancer screening, security.

F1: a balance of the two

python
F1 = 2 · (precision · recall) / (precision + recall)

F1 is the harmonic mean of precision and recall. It is a single number that is low if either precision or recall is low. It is a common default metric for imbalanced classification.

ROC-AUC: independent of the threshold

How well does the model rank positives above negatives, regardless of the 0.5 threshold? AUC of 1.0 = perfect ranker, 0.5 = random. Useful when you'll be tuning the threshold later anyway.

python
from sklearn.metrics import classification_report, roc_auc_score

print(classification_report(y_test, y_pred))
print("ROC-AUC:", roc_auc_score(y_test, probs))
# →               precision    recall  f1-score   support
# →            0       0.89      0.76      0.82        51
# →            1       0.79      0.90      0.84        49
# →     accuracy                           0.83       100
# →    macro avg       0.84      0.83      0.83       100
# → weighted avg       0.84      0.83      0.83       100
# → ROC-AUC: 0.9251700680272109
+ setup added so this can run · re-runs 3 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

y_test = _AutoMock('y_test')
y_pred = _AutoMock('y_pred')
probs = _AutoMock('probs')
X_test = _AutoMock('X_test')
model = _AutoMock('model')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from sklearn.datasets import make_classification
    from sklearn.linear_model import LogisticRegression
    from sklearn.model_selection import train_test_split
    import numpy as np
    from sklearn.metrics import confusion_matrix
except Exception:
    pass
_pm_earlier = [
    'from sklearn.datasets import make_classification\nfrom sklearn.linear_model import LogisticRegression\nfrom sklearn.model_selection import train_test_split\n\nX, y = make_classification(n_samples=500, n_features=5,\n                           n_informative=3, random_state=42)\nX_train, X_test, y_train, y_test = train_test_split(\n    X, y, test_size=0.2, random_state=42\n)\n\nmodel = LogisticRegression(max_iter=1000).fit(X_train, y_train)\n\nprint("Predictions:    ", model.predict(X_test[:5]))\nprint("Probabilities:  ", model.predict_proba(X_test[:5]).round(2))\nprint("Accuracy:       ", model.score(X_test, y_test))\n# → Predictions:     [1 0 0 0 1]\n# → Probabilities:   [[0.35 0.65]\n# →  [0.95 0.05]\n# →  [0.59 0.41]\n# →  [0.99 0.01]\n# →  [0.   1.  ]]\n# → Accuracy:        0.83',
    'import numpy as np\n\nprobs = model.predict_proba(X_test)[:, 1]   # P(class=1) for every row\ny_pred_strict   = (probs > 0.8).astype(int)\ny_pred_lenient  = (probs > 0.3).astype(int)',
    'from sklearn.metrics import confusion_matrix\n\ny_pred = model.predict(X_test)\nprint(confusion_matrix(y_test, y_pred))\n# → [[39 12]\n# →  [ 5 44]]\n#\n# Reading: 39 TN and 12 FP in the top row, 5 FN and 44 TP in the bottom row.',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

classification_report gives you all of these in one summary. Print it every time.


7. The Imbalance Problem

99% of credit-card transactions are legitimate. A model that predicts "legitimate" for every single transaction gets 99% accuracy. It also catches no fraud at all, so it is worthless.

python
import numpy as np

y_true = np.array([0] * 990 + [1] * 10)     # 990 zeros and 10 ones: a 99 to 1 imbalance
y_pred = np.zeros(1000, dtype=int)           # always predict 0

from sklearn.metrics import accuracy_score, f1_score, recall_score
print("Accuracy:", accuracy_score(y_true, y_pred))  # 0.99 (looks great)
print("F1:      ", f1_score(y_true, y_pred))        # 0.00 (a disaster)
print("Recall:  ", recall_score(y_true, y_pred))    # 0.00 (it found none of the 1s)
# → Accuracy: 0.99
# → F1:       0.0
# → Recall:   0.0

Rule: on imbalanced data, accuracy is misleading. Report F1, recall on the minority class, and ROC-AUC. Never accuracy alone.

Common imbalanced problems: fraud, churn, rare disease, conversion, defect detection. Many of the most useful problems are imbalanced.


8. Other Classifiers: a Short Overview

You'll meet logistic regression first. Here are the other common classifiers and when each one works best.

ModelBoundary shapeStrengthWeakness
Logistic RegressionStraight lineFast, interpretable, calibrated probabilitiesCan't bend
SVM (RBF kernel)Smooth curvesGreat on small/medium data with clean boundariesSlow on big data, needs scaling
KNNVoronoi cellsZero training, intuitiveSlow at prediction time, scales badly
Naive BayesProbabilisticVery fast, good baseline for textStrong independence assumption
Decision TreeAxis-aligned rectanglesInterpretable, handles mixed typesHigh variance: see trees
Random ForestMany trees averagedStrong default for tabular dataLess interpretable, slower
Gradient BoostingMany trees, sequentialOften best on tabular dataMore hyperparameters to tune

Default progression for a new problem: logistic regression → random forest → gradient boosting. Stop as soon as one is good enough.


Common Mistakes

  • Trusting accuracy on imbalanced data. On a 99-1 split, a model that does nothing already gets 99% accuracy. Use F1 or AUC.
  • Assuming every predict_proba is calibrated. Random forests and SVMs return probabilities, but they are often miscalibrated: a "0.9" doesn't mean 90% likely. Use CalibratedClassifierCV if probabilities need to mean something.
  • Inconsistent random_state. Your colleague gets a different score. Set the seed on train_test_split, the model, and any shuffle step. random_state=42 everywhere is a common, simple convention.
  • Comparing models on different train/test splits. Use the same split (or, better, the same cross_val_score) for every model you benchmark.
  • Training on a feature that's a leak from the future. If "customer_signed_up_yesterday" is a feature for predicting "customer_will_sign_up", the model is using the answer. Check your features for leakage before you report results.

🎯 Your Turn: Fit and Report

Inline data:

python
from sklearn.datasets import make_classification

X, y = make_classification(n_samples=400, n_features=8, n_informative=5,
                           weights=[0.7, 0.3], random_state=42)

The dataset is mildly imbalanced (70-30). Your task:

1. Split 80/20, random_state=42.
2. Fit a LogisticRegression.
3. Print classification_report(y_test, y_pred) and confusion_matrix(y_test, y_pred).
4. Return the test set's macro-averaged F1, rounded to 3 decimals.

python
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, confusion_matrix, f1_score

def fit_and_report():
    X, y = make_classification(n_samples=400, n_features=8, n_informative=5,
                               weights=[0.7, 0.3], random_state=42)

    # TODO 1: split 80/20 with random_state=42
    # TODO 2: fit LogisticRegression(max_iter=1000)
    # TODO 3: predict on test set
    # TODO 4: print classification_report and confusion_matrix
    # TODO 5: return round(f1_score(y_test, y_pred, average="macro"), 3)

    pass

print(fit_and_report())
Hint 1: macro vs binary F1 f1_score(y_test, y_pred, average="macro") averages the F1 across both classes equally, which is what you want when classes are imbalanced and both matter. average="binary" only looks at class 1.
Hint 2: get y_pred once, use it twice Call y_pred = model.predict(X_test) once, then pass y_pred to both classification_report and confusion_matrix. Cleaner than calling predict multiple times.
Show full solution
python
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, confusion_matrix, f1_score

def fit_and_report():
    X, y = make_classification(n_samples=400, n_features=8, n_informative=5,
                               weights=[0.7, 0.3], random_state=42)

    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.2, random_state=42
    )

    model = LogisticRegression(max_iter=1000).fit(X_train, y_train)
    y_pred = model.predict(X_test)

    print(classification_report(y_test, y_pred))
    print(confusion_matrix(y_test, y_pred))

    return round(f1_score(y_test, y_pred, average="macro"), 3)

print(fit_and_report())
# →               precision    recall  f1-score   support
# →            0       0.80      0.89      0.84        53
# →            1       0.71      0.56      0.62        27
# →     accuracy                           0.78        80
# →    macro avg       0.76      0.72      0.73        80
# → weighted avg       0.77      0.78      0.77        80
# → [[47  6]
# →  [12 15]]
# → 0.732

Notice the report shows the minority class (1) with lower F1 than the majority. That's normal, and it is a starting point for tuning. Try LogisticRegression(class_weight="balanced") and compare the recall for the minority class.


What You Learned

  • Classification predicts a category, and regression predicts a number.
  • Logistic regression: a linear model whose output is turned into a probability. Fast, interpretable, and a good first choice.
  • The 0.5 prediction threshold is only a default. Change it to trade precision against recall.
  • The confusion matrix is the source of every binary metric: TP, FP, TN, FN.
  • Accuracy is misleading on imbalanced data. Use precision, recall, F1, ROC-AUC instead. classification_report gives you all of them.
  • Logistic regression → random forest → gradient boosting is the usual order to try models in.

Next: Decision Trees: when relationships aren't linear, and you want to see exactly how the model decides.

Practice this

on practicepython.in

Short exercises that run in your browser and tell you what your code did, as well as whether a test passed.

Found this useful? Share it with a friend who’s learning Python.