Dictionary of Applied Machine Learning · decision boundary

decision boundary — Python demo

Updated on 2026-11-02

Numerical companion to the entry decision boundary: it recomputes what the entry states and prints one line per check

The same two-dimensional training set of ten datapoints is used to learn three classifiers: logistic regression on the original features, a depth-4 decision tree, and logistic regression on polynomial features of degree two. Their learned decision regions and boundaries are shown in decisionboundary.png.

Run it without installing anything:
uv run https://dictionaryofml.org/terms/decisionboundary.py
uv downloads this script and the pinned NumPy and Matplotlib it needs, then runs it; the script fetches any input file it uses. To keep the output files, download decisionboundary.py into a folder and run uv run decisionboundary.py there. With NumPy and Matplotlib already installed, python3 decisionboundary.py, from any directory — it writes its output files into the current directory. Fixed seeds, so the printed numbers reproduce exactly. Download decisionboundary.py · Notebook · Open in Colab

Preview figure produced by decisionboundary.py
The preview figure the script writes when it runs

Source

"""Numerical companion to the glossary entry 'decision boundary'.

The same two-dimensional training set of ten datapoints is used to learn three classifiers:
logistic regression on the original features, a depth-4 decision tree, and
logistic regression on polynomial features of degree two.  Their learned
decision regions and boundaries are shown in decisionboundary.png.

The implementation uses only NumPy and Matplotlib and a fixed random seed.
"""

from pathlib import Path

import matplotlib
import numpy as np

matplotlib.use("Agg")
import matplotlib.pyplot as plt

OUT_DIR = Path(__file__).parent


def check(name, condition):
    print(f"  [{'ok' if condition else 'FAIL'}] {name}")
    if not condition:
        raise RuntimeError(name)


# Ten training datapoints whose classes are separated by a quadratic curve.
X = np.array([
    [-2.1, 1.5], [-1.2, 0.2], [0.0, 0.0], [1.2, 0.2], [2.1, 1.5],
    [-2.1, 0.5], [-1.2, -0.5], [0.0, -1.0], [1.2, -0.5], [2.1, 0.5],
])
threshold = 0.42 * X[:, 0] ** 2 - 0.65
y = np.where(X[:, 1] > threshold, 1, -1)
check("the training set contains at most ten datapoints", len(X) <= 10)


def feature_map(x, degree):
    """Original features, or all monomials up to degree two."""
    if degree == 1:
        return np.column_stack((np.ones(len(x)), x[:, 0], x[:, 1]))
    return np.column_stack((np.ones(len(x)), x[:, 0], x[:, 1],
                            x[:, 0] ** 2, x[:, 0] * x[:, 1],
                            x[:, 1] ** 2))


def train_logistic(x, labels, degree):
    phi = feature_map(x, degree)
    weights = np.zeros(phi.shape[1])
    binary_labels = (labels + 1) / 2
    for _ in range(5000):
        scores = np.clip(phi @ weights, -30, 30)
        probs = 1 / (1 + np.exp(-scores))
        weights -= 0.3 * phi.T @ (probs - binary_labels) / len(labels)
    return weights


def logistic_predict(x, weights, degree):
    return np.where(feature_map(x, degree) @ weights >= 0, 1, -1)


def grow_tree(x, labels, depth=0, max_depth=4, min_leaf=1):
    """Grow a small CART classification tree using Gini impurity."""
    node = {"prediction": 1 if np.mean(labels == 1) >= 0.5 else -1}
    if depth == max_depth or np.all(labels == labels[0]):
        return node
    best = None
    for feature in range(x.shape[1]):
        values = np.unique(x[:, feature])
        for cut in (values[:-1] + values[1:]) / 2:
            left = x[:, feature] <= cut
            if left.sum() < min_leaf or (~left).sum() < min_leaf:
                continue
            impurity = 0.0
            for part in (left, ~left):
                p = np.mean(labels[part] == 1)
                impurity += part.mean() * 2 * p * (1 - p)
            if best is None or impurity < best[0]:
                best = (impurity, feature, cut, left)
    if best is None:
        return node
    _, feature, cut, left = best
    node.update(feature=feature, cut=cut,
                left=grow_tree(x[left], labels[left], depth + 1,
                               max_depth, min_leaf),
                right=grow_tree(x[~left], labels[~left], depth + 1,
                                max_depth, min_leaf))
    return node


def tree_predict(x, tree):
    predictions = np.empty(len(x), dtype=int)
    for i, row in enumerate(x):
        node = tree
        while "feature" in node:
            node = node["left"] if row[node["feature"]] <= node["cut"] \
                else node["right"]
        predictions[i] = node["prediction"]
    return predictions


w_linear = train_logistic(X, y, degree=1)
w_poly = train_logistic(X, y, degree=2)
tree = grow_tree(X, y)

models = [
    ("linear classifier", lambda z: logistic_predict(z, w_linear, 1)),
    ("decision tree", lambda z: tree_predict(z, tree)),
    ("logistic regression\non polynomial features",
     lambda z: logistic_predict(z, w_poly, 2)),
]

grid_axis = np.linspace(-2.5, 2.5, 401)
g1, g2 = np.meshgrid(grid_axis, grid_axis)
grid = np.column_stack((g1.ravel(), g2.ravel()))

errors = [np.mean(predict(X) != y) for _, predict in models]
check("all three classifiers produce both decision regions",
      all(len(np.unique(predict(grid))) == 2 for _, predict in models))
check("polynomial features capture the quadratic boundary",
      errors[2] < errors[0])

fig, axes = plt.subplots(1, 3, figsize=(10.2, 3.35), sharex=True, sharey=True)
for ax, ((title, predict), error) in zip(axes, zip(models, errors)):
    region = predict(grid).reshape(g1.shape)
    ax.contourf(g1, g2, region, levels=[0, 1.5], colors=["0.85"])
    ax.contour(g1, g2, region, levels=[0], colors="black", linewidths=2.5)
    ax.scatter(X[y == -1, 0], X[y == -1, 1], s=22, marker="o",
               facecolors="white", edgecolors="black", linewidths=0.9)
    ax.scatter(X[y == 1, 0], X[y == 1, 1], s=15, marker="s",
               facecolors="black", edgecolors="black", linewidths=0.5)
    ax.set_title(title, fontsize=9)
    ax.set_xlabel(r"feature $x_1$")
    ax.set_aspect("equal")
    ax.set_xticks([-2, 0, 2])
    ax.set_yticks([-2, 0, 2])
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
axes[0].set_ylabel(r"feature $x_2$")
fig.tight_layout()
fig.savefig(OUT_DIR / "decisionboundary.png", dpi=180)
print(f"  wrote {OUT_DIR / 'decisionboundary.png'}")