Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

➗ LDA & QDA

If we briefly recap the previously introduced classification algorithms, logistic regression and k-nearest neighbors are referred to as discriminative models. This means they try to establish a decision boundary (discriminator), which best separate the classes.

In contrast, generative models such as Linear Discriminant Analysis (LDA) and Quadratic Discriminant Analysis (QDA) (and also Naïve Bayes, which will be introduced in the next session) see the world with different eyes! They are focused on learning the underlying distribution of the data and its labels.

Linear Discriminant Analysis (LDA)

LDA assumes that:

As a visual intuition, this means the class distributions look like ellipses with the same shape and orientation (but centered at different locations if there is a difference between the classes). In detail, LDA requires 4 steps to make a decision:


Step 1: Estimate Class Distributions

We assume that each class kk generates its data points from a multivariate normal distribution:

P(XY=k)=1(2π)p/2Σ1/2exp(12(Xμk)TΣ1(Xμk))P(X | Y = k) = \frac{1}{(2\pi)^{p/2} |\Sigma|^{1/2}} \exp\left(-\frac{1}{2} (X - \mu_k)^T \Sigma^{-1} (X - \mu_k)\right)

where:

💡 Key Assumption: LDA assumes that all classes share the same covariance matrix Σ\Sigma. This makes the decision boundaries linear.


Step 2: Apply Bayes’ Theorem

We want to know the posterior probability. This is the probability of a class kk given a new observation XX:

P(Y=kX)=P(XY=k)P(Y=k)P(X)P(Y = k | X) = \frac{P(X | Y = k) P(Y = k)}{P(X)}

where:

💡 We model how each class generates the data, and then use Bayes’ theorem to “flip” this around and find the most likely class for a new point.


When performing classification, we only need to compare which posterior probability is largest. Taking the logarithm preserves the order of the probabilities while simplifying multiplication into addition. Further, the evidence P(X)P(X) is the same across all classes (because it is the sum over all classes) and therefore does not affect the relative ranking. This allows us to drop it and work with proportionality (\propto):

logP(Y=kX)logP(XY=k)+logP(Y=k)\log P(Y = k | X) \propto \log P(X | Y = k) + \log P(Y = k)

Step 3: Derive the Discriminant Function

We can then perform some linear algebra (substitute the multivariate Gaussian density into the log expression, expand the quadratic form, and remove terms independent of the class kk; see James et al. (ISLR) Chapter 4.4 if you are interested in the details). This will then result in the discriminant function δk(X)\delta_k(X):

δk(X)=XTΣ1μk12μkTΣ1μk+log(πk)\delta_k(X) = X^T \Sigma^{-1} \mu_k - \frac{1}{2} \mu_k^T \Sigma^{-1} \mu_k + \log(\pi_k)

where:


Step 4: Decision Rule

The final decision is made by comparing the discriminant functions, and we classify the new observation into the class with the highest discriminant value:

Y^=argmaxkδk(X)\hat{Y} = \arg \max_k \delta_k(X)

Quadratic Discriminant Analysis (QDA)

QDA is a more flexible version of LDA. It:

The discriminant function for QDA is:

δk(X)=12(Xμk)TΣk1(Xμk)12logΣk+log(πk)\delta_k(X) = -\frac{1}{2} (X - \mu_k)^T \Sigma_k^{-1} (X - \mu_k) - \frac{1}{2} \log |\Sigma_k| + \log(\pi_k)

where:

LDA and QDA in Python

LDA and QDA can be implemented in Python using sklearn. In this example, we use artifical data for classification (2 features, 2 classes):

import matplotlib.pyplot as plt
from sklearn.datasets import make_classification

# Generate synthetic data
X, y = make_classification(n_samples=200, n_features=2, n_informative=2, 
                           n_redundant=0, n_classes=2, n_clusters_per_class=1, 
                           random_state=42)

fig, ax = plt.subplots()
ax.scatter(X[:, 0], X[:, 1], c=y, cmap='bwr')
ax.set(title="Simulated Data", xlabel="Feature 1", ylabel="Feature 2");
<Figure size 640x480 with 1 Axes>

Fitting the model is straightforward. However, please have a look at the documentation for additional options such as the specific solver.

from sklearn.discriminant_analysis import LinearDiscriminantAnalysis as LDA, \
                                          QuadraticDiscriminantAnalysis as QDA

lda = LDA(store_covariance=True)
lda.fit(X, y)

qda = QDA(store_covariance=True)
qda.fit(X, y);

We can then print the classification report:

from sklearn.metrics import classification_report

# Print classification report
print('LDA Classification Report:')
print(classification_report(y, lda.predict(X)))

print('QDA Classification Report:')
print(classification_report(y, qda.predict(X)))
LDA Classification Report:
              precision    recall  f1-score   support

           0       0.85      0.83      0.84       100
           1       0.83      0.85      0.84       100

    accuracy                           0.84       200
   macro avg       0.84      0.84      0.84       200
weighted avg       0.84      0.84      0.84       200

QDA Classification Report:
              precision    recall  f1-score   support

           0       0.88      0.87      0.87       100
           1       0.87      0.88      0.88       100

    accuracy                           0.88       200
   macro avg       0.88      0.88      0.87       200
weighted avg       0.88      0.88      0.87       200

Because LDA and QDA are generative models, we can do more than just draw a decision boundary — we can visualise the actual class distributions the models learned. Each class is modelled as a multivariate Gaussian, so we can plot its density contours together with the resulting decision boundary:

import numpy as np
import seaborn as sns
from scipy.stats import multivariate_normal
from matplotlib.lines import Line2D

sns.set_theme(style="darkgrid")

def plot_distributions(model, X, y, ax, title):
    # Grid over the feature space
    x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1
    y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1
    xx, yy = np.meshgrid(np.linspace(x_min, x_max, 300), np.linspace(y_min, y_max, 300))
    Xgrid = np.c_[xx.ravel(), yy.ravel()]

    # Plot the Gaussian distribution learned for each class
    for k, color in enumerate(['blue', 'red']):
        mu = model.means_[k]
        # LDA shares one covariance matrix, QDA stores one per class
        cov = model.covariance_[k] if isinstance(model.covariance_, list) else model.covariance_
        P = multivariate_normal(mean=mu, cov=cov).pdf(Xgrid)
        P = (P / P.max()).reshape(xx.shape)  # normalise so the peak is 1
        Pm = np.ma.masked_array(P, P < 0.03)
        ax.pcolormesh(xx, yy, Pm, shading='auto', alpha=0.4, cmap=color.title() + 's')
        ax.contour(xx, yy, P, levels=[0.01, 0.1, 0.5, 0.9], colors=color, alpha=0.3)

    # Decision boundary
    Z = model.predict(Xgrid).reshape(xx.shape)
    ax.contour(xx, yy, Z, levels=[0.5], linewidths=2, colors='black')

    # Data points
    ax.scatter(X[:, 0], X[:, 1], c=y, s=40, cmap='bwr')
    ax.set(title=title, xlabel="Feature 1", ylabel="Feature 2")

fig, ax = plt.subplots(1, 2, figsize=(12, 5))
plot_distributions(lda, X, y, ax[0], "LDA Class Distributions")
plot_distributions(qda, X, y, ax[1], "QDA Class Distributions")

legend_elements = [
    Line2D([], [], marker='o', linestyle='None', markerfacecolor='blue',
           markeredgewidth=0, label='Class 0', markersize=8),
    Line2D([], [], marker='o', linestyle='None', markerfacecolor='red',
           markeredgewidth=0, label='Class 1', markersize=8),
    Line2D([], [], color='black', linestyle='-', linewidth=2, label='Decision boundary')
]
ax[1].legend(handles=legend_elements, loc="upper left")  # explicit loc: "best" is slow on a dense mesh
plt.show()
<Figure size 1200x500 with 2 Axes>

This makes the generative nature of both models explicit: