← All Posts
Deep Learning · Data Preparation and Preprocessing · Dimensionality Reduction

Linear Discriminant Analysis (LDA)

LDA uses class labels to find a direction that separates groups. It favors a large gap between class centers and a small spread of points within each class. This can reduce the number of measurements while keeping information useful for classification.

This builds on the PCA guide. PCA looks for the direction with the most overall spread, even if that direction does not separate the labels. LDA uses the labels to choose its direction. We will compare both methods on eight points, then work out how LDA finds its direction.

The widest direction can hide the classes

There are four points in each class. Class A is (−4, −2), (−4, −1), (4, −2), and (4, −1). Class B is (−4, 1), (−4, 2), (4, 1), and (4, 2). Both classes have the same first-feature values, −4 and 4. So that feature varies a lot, but it does not tell us which class a point belongs to.

PCA picks the horizontal direction because it contains about 86.5% of the total variation. But along that line, A and B overlap completely. The vertical direction separates all eight training points. Use the controls to rotate the line and watch how the class scores change:

Original two features x₁x₂ One score per observationAB Vertical offsets reveal overlapping scores.
Squared mean gap0
Within-class scatter128
Separation ratio0
Horizontal scores overlap: both classes have mean 0.
Circles are class A; squares are class B. Dashed segments project the original points onto the chosen line. The two score rows use the same horizontal scale. The values below the plot compare class separation with the scatter left within each class; the next section defines them.

This is a small training example, not proof that the result will work on new data. It shows why keeping most of the overall variation does not always keep the information that separates classes.

Measure separation against the spread inside each class

Choose a direction using weights $w$. A point $x_i$ becomes one score, $z_i=w^\top x_i$: multiply each measurement by its weight and add the results. Let $\bar z_A$ and $\bar z_B$ be the average scores for the two classes.

We want those averages far apart, but that gap is only useful if points within each class stay close together. A gap of 3 is not impressive if each class spreads from −100 to 100. Fisher's rule compares the squared gap between class averages with the total squared distance of each point's score from its own class average. If $y_i$ is point $i$'s label, the two-class criterion is:

$$F(w)=\frac{(\bar z_B-\bar z_A)^2}{\sum_i(z_i-\bar z_{y_i})^2}.$$

For the horizontal direction, both class averages are 0, so the ratio is 0. For the vertical direction, their averages are −1.5 and 1.5, giving a squared gap of 9. Each of the eight points is 0.5 from its own class average, so the total within-class spread is $8\times0.5^2=2$. The ratio is $9/2=4.5$.

If we multiply every weight by 10, all scores grow by 10 and both parts of the ratio grow by 100. The ratio stays the same, so stretching the score axis cannot improve it. This number measures separation; it is not a probability or a test of prediction accuracy.

Find the direction without trying every angle

To find the best direction, summarize the spread inside the classes with the within-class scatter matrix, $S_W$. For each point, subtract its own class average. The diagonal entries add the squared differences for each measurement. The off-diagonal entries add products of differences from two measurements, showing whether those differences move together.

01Subtract the class meanA’s mean is (0, −1.5); B’s is (0, 1.5). Both classes leave residuals (±4, ±0.5), with all four combinations.
02Square each coordinateAcross eight rows: the first feature contributes 8 × 4² = 128. The second contributes 8 × 0.5² = 2.
03Check co-variationPositive and negative residual products cancel. Both off-diagonal entries are zero.
Count deviations within each class. Subtracting the overall mean would also count the gap between the classes. LDA needs to distinguish that gap from the spread within them.

Let $\mu_A$ and $\mu_B$ be the class averages, and let $d=\mu_B-\mu_A$ be the gap between them. If $S_W$ can be inverted, the direction that maximizes Fisher's ratio is found by solving $S_Ww=d$. This accounts for how much spread there is inside each class. In our example:

$$\underbrace{\begin{bmatrix}128&0\\0&2\end{bmatrix}}_{S_W}\begin{bmatrix}w_1\\w_2\end{bmatrix}=\underbrace{\begin{bmatrix}0\\3\end{bmatrix}}_d.$$

The first equation gives $w_1=0$ and the second gives $w_2=1.5$. Divide by its length to get the easier-to-read direction $w=(0,1)$. Here LDA keeps the second feature because that feature separates the class averages. In other datasets, the off-diagonal entries also matter: they account for measurements that vary together inside the classes.

Optional: why solving that system maximizes the ratio

The short version is that the best direction follows the gap between class averages after accounting for spread within the classes. Here is the derivation for readers who want to see why. For $N$ rows, $S_W=\sum_{i=1}^N(x_i-\mu_{y_i})(x_i-\mu_{y_i})^\top$. The projected mean gap is $w^\top d$, and the projected within-class spread is $w^\top S_Ww$.

Assume $S_W$ is positive definite, so its square root and inverse exist. Define $u=S_W^{1/2}w$ and $v=S_W^{-1/2}d$. The ratio becomes $(u^\top v)^2/(u^\top u)$. By the Cauchy–Schwarz inequality, it is at most $v^\top v$, with equality when $u$ points along $v$. Substituting back gives $w\propto S_W^{-1}d$. In code, solve the system rather than constructing an inverse. The direction has no unique scale or sign.

If the class means coincide, the numerator is zero in every direction. If the scatter is singular, this inverse-based derivation does not apply; the handling of that case is a modeling and numerical choice.

Fit with labels; transform new points with the saved direction

After fitting the direction, we can score a new point using its measurements and the saved weights. We also use the same score origin as during training. Here, zero is halfway between the two class averages, at (0, 0), so the new point (8, 1.2) gets score 1.2.

01Training foldUse training features and training labels to estimate class means and the projection.
02Saved transformationKeep the fitted weights and chosen origin. Apply them to validation features without refitting.
03EvaluationUse validation labels to assess the complete prediction pipeline. Reserve independent data for the final assessment.
The labels belong to fitting. An LDA projection fitted before cross-validation would already have used the held-out labels. Put it inside the pipeline being evaluated.
Python: fit the direction and compare it with scikit-learn
import numpy as np
from sklearn.decomposition import PCA
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis

X = np.array([[-4, -2], [-4, -1], [4, -2], [4, -1],
              [-4,  1], [-4,  2], [4,  1], [4,  2]], dtype=float)
y = np.repeat([0, 1], 4)  # A = 0, B = 1.
means = np.array([X[y == c].mean(axis=0) for c in [0, 1]])
residuals = X - means[y]  # Center each row at its own class mean.
SW = residuals.T @ residuals
w = np.linalg.solve(SW, means[1] - means[0])
w /= np.linalg.norm(w)  # Fix a convenient score scale.
origin = (means[0] + means[1]) / 2

def transform(new):
    return (np.asarray(new) - origin) @ w

print(SW)                      # [[128, 0], [0, 2]]
print(w)                       # [0, 1]
print(transform([[8, 1.2]]))    # [1.2]

pca = PCA(n_components=1).fit(X)
print(pca.explained_variance_ratio_)  # About [0.8649].
lda = LinearDiscriminantAnalysis(solver="eigen", n_components=1).fit(X, y)
reference_direction = lda.scalings_[:, 0].copy()
reference_direction /= np.linalg.norm(reference_direction)
# Compare axes, allowing an arbitrary sign.
np.testing.assert_allclose(np.outer(w, w),
                           np.outer(reference_direction, reference_direction), atol=1e-10)
# Coordinate scale and origin can differ across implementations.
np.testing.assert_array_equal(lda.predict(X), y)  # Training check, not a test score.

The NumPy calculation uses a unit-length direction. Library transforms can choose a different scale or origin; compare the learned axis, or align the coordinates, before treating different score values as a bug.

A projection still needs a classification rule

The projection turns each point into a score, but a score alone does not assign a class. We need a threshold or another rule. In this symmetric example, a threshold of zero separates the observed A and B points.

The probabilistic LDA classifier makes an additional assumption: each class forms a Gaussian, or bell-shaped, cloud. The clouds can have different centers, but share the same covariance. LDA combines these distributions with class priors to make predictions, giving a straight-line decision boundary. Fisher's separation calculation does not require Gaussian data. Either method still needs to be checked on new data.

Optional: how class priors affect the decision rule

Let $\Sigma$ be the shared covariance and $q_A,q_B$ the class priors. Find the classifier weights $\beta$ by solving $\Sigma\beta=\mu_B-\mu_A$. The difference between the two class log scores is

$$\ell_B(x)-\ell_A(x)=\beta^\top\!\left(x-\frac{\mu_A+\mu_B}{2}\right)+\log\frac{q_B}{q_A}.$$

Choose B when this difference is positive. If the priors are equal, the logarithm is zero and the boundary lies halfway between the projected means. A larger prior for B moves the boundary so more points are assigned to B. The size of the classifier weights matters relative to the prior term, so the covariance estimate and how it is scaled also affect the boundary.

Why C classes give at most C − 1 directions

With several classes, LDA still looks for directions that separate class averages while accounting for spread inside each class. Two class averages can be separated along one line; three can spread across at most a plane. In general, $C$ class averages give at most $C-1$ independent directions. With $D$ input features, the output can have at most $\min(D,C-1)$ directions, and sometimes fewer.

That is why two classes give at most one LDA direction. Asking for more cannot create new ways to separate their averages. An LDA explained-variance percentage describes its separating directions; it is not the same as the percentage of total data variation reported by PCA.

Where to be careful

Scikit-learn solver choices for reduction

If you need to project points with transform, use svd (which does not support shrinkage) or eigen (which does). lsqr supports shrinkage but does not provide transform. The n_components setting limits the number of projected coordinates; it does not limit how predict classifies a point. See the API documentation when choosing a configuration.

Optional implementation: two features, two classes

This optional C++ example fits the Fisher direction and uses it to score new points. It solves a 2 × 2 system, scales the direction to length 1, and puts score zero halfway between the class averages. It stops with an error if the scatter matrix cannot be solved; adding regularization would be a separate change.

C++17: compute means, scatter and the projection
#include <array>
#include <cmath>
#include <stdexcept>
#include <vector>
using Point = std::array<double, 2>;
struct FisherProjection { Point origin, direction; };

FisherProjection fit_fisher(const std::vector<Point>& X,
                            const std::vector<int>& y) {
    if (X.size() != y.size() || X.empty())
        throw std::invalid_argument("matching nonempty X and y required");
    std::array<std::size_t, 2> count{0, 0};
    for (std::size_t i = 0; i < X.size(); ++i) {
        if (y[i] != 0 && y[i] != 1)
            throw std::invalid_argument("labels must be 0 or 1");
        for (double v : X[i]) if (!std::isfinite(v))
            throw std::invalid_argument("finite features required");
        ++count[y[i]];
    }
    if (count[0] < 2 || count[1] < 2)
        throw std::invalid_argument("need at least two rows in each class");
    std::array<Point, 2> mean{};
    for (std::size_t i = 0; i < X.size(); ++i)
        for (int j = 0; j < 2; ++j) mean[y[i]][j] += X[i][j] / count[y[i]];
    double a = 0, b = 0, c = 0;
    for (std::size_t i = 0; i < X.size(); ++i) {
        double dx = X[i][0] - mean[y[i]][0], dy = X[i][1] - mean[y[i]][1];
        a += dx*dx; b += dx*dy; c += dy*dy;
    }
    if (!std::isfinite(a) || !std::isfinite(b) || !std::isfinite(c))
        throw std::overflow_error("scatter overflow");
    // Rescale the 2x2 system; a common scalar does not change its direction.
    double scale = std::fmax(a, c);
    if (!(scale > 0)) throw std::invalid_argument("zero within-class scatter");
    a /= scale; b /= scale; c /= scale;
    double det = a*c - b*b;
    if (!(det > 1e-12))
        throw std::invalid_argument("singular or ill-conditioned scatter");
    Point d{mean[1][0] - mean[0][0], mean[1][1] - mean[0][1]};
    Point u{(c*d[0] - b*d[1])/det, (a*d[1] - b*d[0])/det};
    double length = std::hypot(u[0], u[1]);
    if (!std::isfinite(length)) throw std::overflow_error("direction overflow");
    if (!(length > 0)) throw std::invalid_argument("identical class means");
    return {{mean[0][0]/2 + mean[1][0]/2, mean[0][1]/2 + mean[1][1]/2},
            {u[0]/length, u[1]/length}};
}

double transform(const FisherProjection& model, const Point& x) {
    for (double v : x) if (!std::isfinite(v))
        throw std::invalid_argument("finite new point required");
    double z = (x[0] - model.origin[0])*model.direction[0]
             + (x[1] - model.origin[1])*model.direction[1];
    if (!std::isfinite(z)) throw std::overflow_error("score overflow");
    return z;
}

With the eight rows above and labels {0, 0, 0, 0, 1, 1, 1, 1}, fit_fisher returns direction (0, 1), and transform(model, {8, 1.2}) returns 1.2. The determinant cutoff is a simple numerical guard for this teaching implementation, not an automatic regularization method.

References and further reading