Linear and Quadratic Discriminant Analysis › Implementation in NumPy and scikit-learn
Machine Learning from ScratchAll chapters Linear and Quadratic Discriminant Analysis › Implementation in NumPy and scikit-learnSection view · Sections in this chapter7 / 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 implement both classifiers from first principles using NumPy, and then compare the results with scikit-learn's reference implementations. The fitting and scoring functions are defined first, then exercised on synthetic data. The from-scratch implementation is not intended to replace the library version; it is intended to make the mechanism auditable. In particular, it shows where the covariance inverse, the log-determinant, and the prior enter the score, and why numerical implementations avoid forming explicit matrix inverses whenever possible.
We compare two noisy two-dimensional Gaussian populations: the original unequal-covariance case and a shared-covariance case. Each has 80 training observations and 400 held-out observations per class. Separate random generators preserve the original unequal-covariance sample when we add the shared case.
Core LDA and QDA from NumPy primitives
The fitting routines accept a finite two-dimensional feature array X of shape and a one-dimensional label array y of length . Labels may be noncontiguous numbers or strings; the sorted classes array maps score columns back to those labels. Both routines require at least two classes and finite, positive-definite estimated covariances. Finite feature inputs can still overflow during fitting; both routines reject nonfinite fitted state with FloatingPointError. The sample-count conditions below are necessary, not sufficient, for positive definiteness.
LDA divides pooled within-class scatter by . It solves once, where the rows of are the class means. The rows of are the weights from the derivation, and the length- intercept vector contains . Prediction evaluates X @ coef.T + intercept, an score matrix for new observations, with no covariance solve during prediction. After a finiteness check, a Cholesky step checks numerical positive definiteness before that solve.
QDA estimates one unbiased covariance per class and stores its Cholesky factor , satisfying , plus its log-determinant. To evaluate the Mahalanobis distance, the scoring routine solves by forward substitution. Here has shape ; summing its squared entries down each column yields the squared distances. This triangular solve avoids an explicit inverse and reuses the factors fitted once per class.
PythonIn [12]Expand
import numpy as np
def feature_array(X, n_features=None):
X = np.asarray(X, dtype=float)
if X.ndim != 2 or X.shape[0] == 0 or X.shape[1] == 0:
raise ValueError("X must be a nonempty two-dimensional feature array.")
if not np.isfinite(X).all():
raise ValueError("X must contain only finite values.")
if n_features is not None and X.shape[1] != n_features:
raise ValueError("X has the wrong number of features.")
return X
def training_data(X, y):
X = feature_array(X)
y = np.asarray(y)
if y.ndim != 1 or len(y) != len(X):
raise ValueError("y must be one-dimensional with one label per row.")
if y.dtype.kind not in "biufUS":
raise ValueError("Use finite numeric labels or strings.")
if y.dtype.kind in "biuf" and not np.isfinite(y).all():
raise ValueError("Class labels must be finite.")
classes, counts = np.unique(y, return_counts=True)
if len(classes) < 2:
raise ValueError("At least two classes are required.")
return X, y, classes, counts
def class_priors(counts, priors):
if priors is None:
return counts / counts.sum()
priors = np.asarray(priors, dtype=float)
if (
priors.shape != counts.shape
or not np.isfinite(priors).all()
or np.any(priors <= 0)
or not np.isclose(priors.sum(), 1.0)
):
raise ValueError(
"priors must be positive and sum to one in classes order."
)
return priors / priors.sum()
def require_finite_model(*arrays):
if any(not np.isfinite(array).all() for array in arrays):
raise FloatingPointError(
"Model calculations produced nonfinite values; check feature scales."
)
def fit_lda(X, y, priors=None):
X, y, classes, counts = training_data(X, y)
N, d = X.shape
K = len(classes)
if N - K < d:
raise ValueError("Unregularized LDA requires N - K >= d.")
means = np.empty((K, d))
scatter = np.zeros((d, d))
for k, c in enumerate(classes):
Xc = X[y == c]
means[k] = Xc.mean(axis=0)
centered = Xc - means[k]
scatter += centered.T @ centered
covariance = scatter / (N - K)
require_finite_model(means, covariance)
require_finite_model(
np.linalg.cholesky(covariance)
) # Check the factor too.
priors = class_priors(counts, priors)
coef = np.linalg.solve(covariance, means.T).T # K x d
intercept = -0.5 * np.sum(means * coef, axis=1) + np.log(priors)
require_finite_model(coef, intercept)
return dict(
classes=classes,
means=means,
priors=priors,
covariance=covariance,
coef=coef,
intercept=intercept,
)
def lda_score(X, model):
X = feature_array(X, model["coef"].shape[1])
return X @ model["coef"].T + model["intercept"]
def fit_qda(X, y, priors=None):
X, y, classes, counts = training_data(X, y)
K, d = len(classes), X.shape[1]
if np.any(counts <= d):
raise ValueError(
"Unregularized QDA requires more than d rows per class."
)
means = np.empty((K, d))
covariances = np.empty((K, d, d))
factors = np.empty_like(covariances)
logdets = np.empty(K)
for k, c in enumerate(classes):
Xc = X[y == c]
means[k] = Xc.mean(axis=0)
centered = Xc - means[k]
covariances[k] = centered.T @ centered / (len(Xc) - 1)
require_finite_model(means[k], covariances[k])
factors[k] = np.linalg.cholesky(covariances[k])
logdets[k] = 2 * np.log(np.diag(factors[k])).sum()
require_finite_model(factors[k], logdets[k])
return dict(
classes=classes,
means=means,
covariances=covariances,
priors=class_priors(counts, priors),
factors=factors,
logdets=logdets,
)
def qda_score(X, model):
X = feature_array(X, model["means"].shape[1])
n, d = X.shape
scores = np.empty((n, len(model["classes"])))
for k, L in enumerate(model["factors"]):
diff = X - model["means"][k]
z = np.empty((d, n))
# Forward substitution solves every observation's triangular system.
for j in range(d):
z[j] = (diff[:, j] - L[j, :j] @ z[:j]) / L[j, j]
maha = np.sum(z**2, axis=0)
scores[:, k] = (
-0.5 * maha - 0.5 * model["logdets"][k] + np.log(model["priors"][k])
)
return scoresBoth fits check the sample-count prerequisites before estimating covariances. These checks raise ValueError. Computed means and covariances must be finite before factorization, and factors, log-determinants, and LDA coefficients and intercepts must be finite before a model is returned; otherwise the routines raise FloatingPointError. A Cholesky step raises LinAlgError when it detects a failure of numerical positive definiteness. If factorization fails, check numerical range, feature scales, rank, and conditioning before choosing a remedy. Shrinkage toward a positive-definite target, as discussed earlier, can make a singular empirical estimate invertible. The 80 observations per class and two features in this example avoid the sample-count obstruction, but those counts alone do not guarantee positive definiteness. The finiteness and factorization checks test these numerical conditions for the actual estimates. Its quadratic form comes from a triangular-system solve, and its log-determinant comes from the factor's diagonal.
Comparing with scikit-learn
We now generate training and test data, fit both from-scratch classifiers and the scikit-learn equivalents, and report test accuracy and prediction agreement. The comparison has two purposes: it checks that our implementation recovers the same decision rule, and it separates the statistical question (which classifier fits this data better?) from the numerical question (do the implementations agree?).
PythonIn [13]Expand
rng = np.random.default_rng(11)
n_train, n_test = 80, 400
mean_0_vec = np.array([0.0, 0.0])
mean_1_vec = np.array([3.5, 2.5])
cov_0_mat = np.array([[1.0, 0.3], [0.3, 1.2]])
cov_1_mat = np.array([[2.8, 1.3], [1.3, 1.6]])
X0_train = rng.multivariate_normal(mean_0_vec, cov_0_mat, size=n_train)
X1_train = rng.multivariate_normal(mean_1_vec, cov_1_mat, size=n_train)
X0_test = rng.multivariate_normal(mean_0_vec, cov_0_mat, size=n_test)
X1_test = rng.multivariate_normal(mean_1_vec, cov_1_mat, size=n_test)
X_train = np.vstack([X0_train, X1_train])
y_train = np.concatenate(
[np.zeros(n_train, dtype=int), np.ones(n_train, dtype=int)]
)
X_test = np.vstack([X0_test, X1_test])
y_test = np.concatenate(
[np.zeros(n_test, dtype=int), np.ones(n_test, dtype=int)]
)
lda_model = fit_lda(X_train, y_train)
qda_model = fit_qda(X_train, y_train)
scores_lda = lda_score(X_test, lda_model)
scores_qda = qda_score(X_test, qda_model)
pred_lda = lda_model["classes"][np.argmax(scores_lda, axis=1)]
pred_qda = qda_model["classes"][np.argmax(scores_qda, axis=1)]
from sklearn.discriminant_analysis import (
LinearDiscriminantAnalysis,
QuadraticDiscriminantAnalysis,
)
lda_ref = LinearDiscriminantAnalysis().fit(X_train, y_train)
qda_ref = QuadraticDiscriminantAnalysis().fit(X_train, y_train)
pred_lda_ref = lda_ref.predict(X_test)
pred_qda_ref = qda_ref.predict(X_test)The shared-covariance experiment uses its own generator and the same means, class counts, and train/test sizes. Both classes now use cov_0_mat; their overlap and finite samples still make classification uncertain. We fit and evaluate each method before displaying any results.
PythonIn [14]Expand
shared_rng = np.random.default_rng(12)
shared_X_train = np.vstack(
[
shared_rng.multivariate_normal(mean_0_vec, cov_0_mat, size=n_train),
shared_rng.multivariate_normal(mean_1_vec, cov_0_mat, size=n_train),
]
)
shared_X_test = np.vstack(
[
shared_rng.multivariate_normal(mean_0_vec, cov_0_mat, size=n_test),
shared_rng.multivariate_normal(mean_1_vec, cov_0_mat, size=n_test),
]
)
shared_lda = fit_lda(shared_X_train, y_train)
shared_qda = fit_qda(shared_X_train, y_train)
shared_pred_lda = shared_lda["classes"][
np.argmax(lda_score(shared_X_test, shared_lda), axis=1)
]
shared_pred_qda = shared_qda["classes"][
np.argmax(qda_score(shared_X_test, shared_qda), axis=1)
]
shared_lda_ref = LinearDiscriminantAnalysis().fit(shared_X_train, y_train)
shared_qda_ref = QuadraticDiscriminantAnalysis().fit(shared_X_train, y_train)
shared_pred_lda_ref = shared_lda_ref.predict(shared_X_test)
shared_pred_qda_ref = shared_qda_ref.predict(shared_X_test)
comparison_rows = []
for case, truth, fitted_pairs in [
(
"Unequal",
y_test,
[("LDA", pred_lda, pred_lda_ref), ("QDA", pred_qda, pred_qda_ref)],
),
(
"Shared",
y_test,
[
("LDA", shared_pred_lda, shared_pred_lda_ref),
("QDA", shared_pred_qda, shared_pred_qda_ref),
],
),
]:
for method, primitive, reference in fitted_pairs:
comparison_rows.append(
(
case,
method,
float((primitive == truth).mean()),
float((reference == truth).mean()),
float((primitive == reference).mean()),
)
)ConsoleOut [15]Expand
Covariance Model Scratch Reference Agreement Unequal LDA 0.907 0.907 1.000 Unequal QDA 0.917 0.917 1.000 Shared LDA 0.976 0.976 1.000 Shared QDA 0.976 0.976 1.000
Each row compares accuracy on 800 held-out observations and label agreement between the primitive and reference implementation. The default reference LDA uses its SVD solver; our LDA precomputes linear coefficients with a solve, while our QDA stores Cholesky factors and uses forward substitution. For these full-rank samples in scikit-learn 1.8.0, the reference LDA SVD path uses the same within-class normalization as our LDA. The default unregularized QDA SVD path uses the same class-covariance normalization as our QDA. Other LDA solvers can use a different covariance normalization, so changing solvers requires checking that convention again.
Agreement and accuracy answer different questions. Agreement measures whether the implementations return the same labels on these observations; it does not prove identical behavior for every input. Accuracy compares those labels with the generated test classes. The unequal-covariance population admits a Gaussian QDA Bayes rule, whereas LDA imposes a shared covariance. The shared-covariance population satisfies LDA's covariance assumption, while QDA estimates two covariances without a more appropriate population model. The displayed accuracies describe these two finite samples; neither covariance condition guarantees which fitted classifier will win on another held-out sample.
Key Parameters
The main parameters that affect how the fitted LDA and QDA models behave are listed below. These settings belong to the scikit-learn estimators; the primitive fits implement only the unregularized covariance estimates and an optional priors array in sorted classes order. Both primitive fits store class means and covariance arrays. LDA also stores precomputed weights (coef) and intercepts; QDA stores Cholesky factors and log-determinants. These are different return dictionaries.
priors: Class prior probabilities (default:None, which uses the observed class frequencies). Set them explicitly when the deployment population differs from the training sample, because the prior enters each class score. Changing prior ratios changes score differences and can move, create, or remove decision boundaries; identical class densities can still give a constant winning class.shrinkage(LDA, scikit-learn): Covariance regularization (default:None). A value in blends the pooled covariance toward a scaled identity target, while'auto'applies the Ledoit-Wolf estimate. It is available only with the'lsqr'or'eigen'solver.solver(LDA, scikit-learn): Factorization method (default:'svd'). With the defaultstore_covariance=False, the'svd'path avoids forming the covariance explicitly; it cannot be combined withshrinkage. Use'lsqr'or'eigen'when shrinkage is needed.reg_param(QDA, scikit-learn's default'svd'solver): regularization strength in (default:0.0). It blends the retained empirical covariance eigenvalues, the squared singular values divided by , toward one. When the SVD contains a full feature-space basis, this corresponds to blending the class covariance toward identity. With , the compact SVD lacks feature directions, so this parameter cannot restore full rank; the installed 1.8 implementation raises an error. In version 1.8,solver='eigen'with a suitableshrinkagevalue supplies a full-covariance alternative.store_covariance(LDA, scikit-learn): For the'svd'solver, whether to compute and retain the weighted within-class covariance (default:False). The'lsqr'and'eigen'solvers always compute and store it. Enable it with'svd'when covariance diagnostics such as eigenvalues or condition numbers are part of the analysis.
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!