Linear and Quadratic Discriminant Analysis › Visualizing LDA and QDA
Machine Learning from ScratchAll chapters Linear and Quadratic Discriminant Analysis › Visualizing LDA and QDASection view · Sections in this chapter5 / 10 ↓
Reading tools Definitions, links & notes
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
We now illustrate the geometric differences on a synthetic two-class problem with deliberately different covariances per class. Both classes are drawn from two-dimensional Gaussians. Class 1 has larger marginal variances and a stronger positive feature correlation than class 0. This violates LDA's shared-covariance assumption. QDA can represent the population Gaussian Bayes boundary here, although fitting its extra covariance parameters does not guarantee higher training or test accuracy.
The following cells prepare the data, fit both reference classifiers, and compute the decision grid and class probabilities before plotting. For binary classification, decision_function returns the one-dimensional log-posterior ratio . The common normalization cancels exactly in this difference. We place a zero column beside that ratio to make two class scores with the same difference, then apply softmax across the two columns to recover the posterior probabilities.
PythonIn [5]Expand
import matplotlib.pyplot as plt
import numpy as np
from sklearn.discriminant_analysis import (
LinearDiscriminantAnalysis,
QuadraticDiscriminantAnalysis,
)
rng = np.random.default_rng(7)
n_per_class = 80
mean_0 = np.array([0.0, 0.0])
mean_1 = np.array([3.5, 2.5])
cov_0 = np.array([[1.0, 0.3], [0.3, 1.2]])
cov_1 = np.array([[2.8, 1.3], [1.3, 1.6]])
X0 = rng.multivariate_normal(mean_0, cov_0, size=n_per_class)
X1 = rng.multivariate_normal(mean_1, cov_1, size=n_per_class)
X_two = np.vstack([X0, X1])
y_two = np.concatenate(
[np.zeros(n_per_class, dtype=int), np.ones(n_per_class, dtype=int)]
)
lda_sklearn = LinearDiscriminantAnalysis().fit(X_two, y_two)
qda_sklearn = QuadraticDiscriminantAnalysis().fit(X_two, y_two)
# Class posteriors from the discriminant scores, computed dynamically.
def softmax(scores):
shifted = scores - scores.max(axis=1, keepdims=True)
exp_scores = np.exp(shifted)
return exp_scores / exp_scores.sum(axis=1, keepdims=True)
log_odds_lda = lda_sklearn.decision_function(X_two)
log_odds_qda = qda_sklearn.decision_function(X_two)
posterior_lda = softmax(
np.column_stack([np.zeros_like(log_odds_lda), log_odds_lda])
)
posterior_qda = softmax(
np.column_stack([np.zeros_like(log_odds_qda), log_odds_qda])
)
mean_posterior_lda = posterior_lda.mean(axis=0)
mean_posterior_qda = posterior_qda.mean(axis=0)PythonIn [6]Expand
grid_lower = X_two.min(axis=0) - 0.75
grid_upper = X_two.max(axis=0) + 0.75
xx, yy = np.meshgrid(
np.linspace(grid_lower[0], grid_upper[0], 300),
np.linspace(grid_lower[1], grid_upper[1], 300),
)
grid = np.c_[xx.ravel(), yy.ravel()]
Z_lda = lda_sklearn.predict(grid).reshape(xx.shape)
Z_qda = qda_sklearn.predict(grid).reshape(xx.shape)
D_lda = lda_sklearn.decision_function(grid).reshape(xx.shape)
D_qda = qda_sklearn.decision_function(grid).reshape(xx.shape)
acc_lda_sklearn = lda_sklearn.score(X_two, y_two)
acc_qda_sklearn = qda_sklearn.score(X_two, y_two)ConsoleOut [7]Expand
LDA training accuracy (scikit-learn): 0.944 QDA training accuracy (scikit-learn): 0.963 LDA mean class posterior (class 0, class 1): 0.543, 0.457 QDA mean class posterior (class 0, class 1): 0.504, 0.496
For this seeded training sample, the displayed accuracies show that QDA's higher value here is an observed result, not a guarantee; Gaussian likelihood fitting does not directly maximize classification accuracy, and a single training sample cannot establish a general ranking. Each mean posterior averages one class's fitted probability across all 160 training observations, and the following figures show both fitted decision regions on that same sample. An average near one-half is consistent with this balanced sample, but it does not measure confidence, class separation, or calibration: both confident predictions split between the classes and uniformly uncertain predictions can average to one-half. A held-out comparison below checks how both fitted rules classify new observations.
VisualizationOut [8]Expand

VisualizationOut [9]Expand

The first figure shows LDA's straight boundary, whose normal uses the pooled estimate and the class means, . Its shared covariance is estimated from the within-class scatter of both classes. It is a compromise, rather than class 0's covariance imposed on class 1. LDA can represent correlated and tilted Gaussian clouds, but it forces both clouds to have the same estimated shape and orientation. The plot compares this shared-shape restriction with QDA's class-specific fitted shapes.
The second figure shows QDA. The boundary is now curved. QDA allows class 1 to "reach out" along its long axis, so points that fall inside the elongated direction of the class-1 cloud are more likely to be attributed to class 1. QDA's distance penalty is smaller along a class's broad covariance directions than along its narrow directions. The log-determinant penalty depends on the full covariance volume and stays constant as the observation moves. The price of this flexibility is that QDA estimates full covariances. In higher dimensions, the per-class covariance estimates are more sensitive to sample size. This tradeoff is the subject of the next section.
Comments
Add to the conversation
A question, a correction, or an example from your own work.
No comments yet. Be the first to share your thoughts!