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:
The features are distributed according to a multivariate Gaussian distribution
Classes share the same covariance matrix
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: Model the distribution of the predictors separately for each response class
Step 2: Use Bayes’ theorem to calculate estimates for the posterior probability
Step 3: Derive the discriminant function for each class
Step 4: Apply a decision rule to classify the observation
Step 1: Estimate Class Distributions
We assume that each class generates its data points from a multivariate normal distribution:
where:
is the feature vector
is the number of features
is the mean vector of class
is the covariance matrix (assumed to be the same for all classes)
💡 Key Assumption: LDA assumes that all classes share the same covariance matrix . 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 given a new observation :
where:
is the likelihood (the Gaussian density from Step 1)
is the prior probability of class (how frequent the class is in the data)
is the evidence (the overall probability of observing )
💡 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 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 ():
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 ; see James et al. (ISLR) Chapter 4.4 if you are interested in the details). This will then result in the discriminant function :
where:
is the projection of the data onto the mean direction
adjusts for the distribution’s spread
adjusts for how common the class is (prior probability)
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:
Quadratic Discriminant Analysis (QDA)¶
QDA is a more flexible version of LDA. It:
Also assumes Gaussian distributions for each class.
Allows each class to have its own covariance matrix, resulting in quadratic decision boundaries.
The discriminant function for QDA is:
where:
is the covariance matrix specific to class
The determinant term is present because the spread varies between classes
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");
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()
This makes the generative nature of both models explicit:
Each class is modelled as a multivariate Gaussian, shown by the coloured density contours
LDA uses a single shared covariance matrix, so both classes have the same ellipse shape and orientation. This is what makes its decision boundary linear
QDA estimates a separate covariance matrix per class, so the ellipses can differ in shape and orientation, producing a quadratic boundary
The decision boundary (black line) lies where the posterior probabilities of the two classes are equal