734 lines
24 KiB
Python
734 lines
24 KiB
Python
|
import numpy as np
|
||
|
|
||
|
import pytest
|
||
|
|
||
|
from scipy import linalg
|
||
|
|
||
|
from sklearn.base import clone
|
||
|
from sklearn._config import config_context
|
||
|
from sklearn.utils import check_random_state
|
||
|
from sklearn.utils._testing import assert_array_equal
|
||
|
from sklearn.utils._testing import assert_array_almost_equal
|
||
|
from sklearn.utils._testing import assert_allclose
|
||
|
from sklearn.utils._testing import assert_almost_equal
|
||
|
from sklearn.utils._array_api import _convert_to_numpy
|
||
|
from sklearn.utils._testing import _convert_container
|
||
|
|
||
|
from sklearn.datasets import make_blobs
|
||
|
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
|
||
|
from sklearn.discriminant_analysis import QuadraticDiscriminantAnalysis
|
||
|
from sklearn.discriminant_analysis import _cov
|
||
|
from sklearn.covariance import ledoit_wolf
|
||
|
from sklearn.cluster import KMeans
|
||
|
|
||
|
from sklearn.covariance import ShrunkCovariance
|
||
|
from sklearn.covariance import LedoitWolf
|
||
|
|
||
|
from sklearn.preprocessing import StandardScaler
|
||
|
|
||
|
# Data is just 6 separable points in the plane
|
||
|
X = np.array([[-2, -1], [-1, -1], [-1, -2], [1, 1], [1, 2], [2, 1]], dtype="f")
|
||
|
y = np.array([1, 1, 1, 2, 2, 2])
|
||
|
y3 = np.array([1, 1, 2, 2, 3, 3])
|
||
|
|
||
|
# Degenerate data with only one feature (still should be separable)
|
||
|
X1 = np.array(
|
||
|
[[-2], [-1], [-1], [1], [1], [2]],
|
||
|
dtype="f",
|
||
|
)
|
||
|
|
||
|
# Data is just 9 separable points in the plane
|
||
|
X6 = np.array(
|
||
|
[[0, 0], [-2, -2], [-2, -1], [-1, -1], [-1, -2], [1, 3], [1, 2], [2, 1], [2, 2]]
|
||
|
)
|
||
|
y6 = np.array([1, 1, 1, 1, 1, 2, 2, 2, 2])
|
||
|
y7 = np.array([1, 2, 3, 2, 3, 1, 2, 3, 1])
|
||
|
|
||
|
# Degenerate data with 1 feature (still should be separable)
|
||
|
X7 = np.array([[-3], [-2], [-1], [-1], [0], [1], [1], [2], [3]])
|
||
|
|
||
|
# Data that has zero variance in one dimension and needs regularization
|
||
|
X2 = np.array(
|
||
|
[[-3, 0], [-2, 0], [-1, 0], [-1, 0], [0, 0], [1, 0], [1, 0], [2, 0], [3, 0]]
|
||
|
)
|
||
|
|
||
|
# One element class
|
||
|
y4 = np.array([1, 1, 1, 1, 1, 1, 1, 1, 2])
|
||
|
|
||
|
# Data with less samples in a class than n_features
|
||
|
X5 = np.c_[np.arange(8), np.zeros((8, 3))]
|
||
|
y5 = np.array([0, 0, 0, 0, 0, 1, 1, 1])
|
||
|
|
||
|
solver_shrinkage = [
|
||
|
("svd", None),
|
||
|
("lsqr", None),
|
||
|
("eigen", None),
|
||
|
("lsqr", "auto"),
|
||
|
("lsqr", 0),
|
||
|
("lsqr", 0.43),
|
||
|
("eigen", "auto"),
|
||
|
("eigen", 0),
|
||
|
("eigen", 0.43),
|
||
|
]
|
||
|
|
||
|
|
||
|
def test_lda_predict():
|
||
|
# Test LDA classification.
|
||
|
# This checks that LDA implements fit and predict and returns correct
|
||
|
# values for simple toy data.
|
||
|
for test_case in solver_shrinkage:
|
||
|
solver, shrinkage = test_case
|
||
|
clf = LinearDiscriminantAnalysis(solver=solver, shrinkage=shrinkage)
|
||
|
y_pred = clf.fit(X, y).predict(X)
|
||
|
assert_array_equal(y_pred, y, "solver %s" % solver)
|
||
|
|
||
|
# Assert that it works with 1D data
|
||
|
y_pred1 = clf.fit(X1, y).predict(X1)
|
||
|
assert_array_equal(y_pred1, y, "solver %s" % solver)
|
||
|
|
||
|
# Test probability estimates
|
||
|
y_proba_pred1 = clf.predict_proba(X1)
|
||
|
assert_array_equal((y_proba_pred1[:, 1] > 0.5) + 1, y, "solver %s" % solver)
|
||
|
y_log_proba_pred1 = clf.predict_log_proba(X1)
|
||
|
assert_allclose(
|
||
|
np.exp(y_log_proba_pred1),
|
||
|
y_proba_pred1,
|
||
|
rtol=1e-6,
|
||
|
atol=1e-6,
|
||
|
err_msg="solver %s" % solver,
|
||
|
)
|
||
|
|
||
|
# Primarily test for commit 2f34950 -- "reuse" of priors
|
||
|
y_pred3 = clf.fit(X, y3).predict(X)
|
||
|
# LDA shouldn't be able to separate those
|
||
|
assert np.any(y_pred3 != y3), "solver %s" % solver
|
||
|
|
||
|
clf = LinearDiscriminantAnalysis(solver="svd", shrinkage="auto")
|
||
|
with pytest.raises(NotImplementedError):
|
||
|
clf.fit(X, y)
|
||
|
|
||
|
clf = LinearDiscriminantAnalysis(
|
||
|
solver="lsqr", shrinkage=0.1, covariance_estimator=ShrunkCovariance()
|
||
|
)
|
||
|
with pytest.raises(
|
||
|
ValueError,
|
||
|
match=(
|
||
|
"covariance_estimator and shrinkage "
|
||
|
"parameters are not None. "
|
||
|
"Only one of the two can be set."
|
||
|
),
|
||
|
):
|
||
|
clf.fit(X, y)
|
||
|
|
||
|
# test bad solver with covariance_estimator
|
||
|
clf = LinearDiscriminantAnalysis(solver="svd", covariance_estimator=LedoitWolf())
|
||
|
with pytest.raises(
|
||
|
ValueError, match="covariance estimator is not supported with svd"
|
||
|
):
|
||
|
clf.fit(X, y)
|
||
|
|
||
|
# test bad covariance estimator
|
||
|
clf = LinearDiscriminantAnalysis(
|
||
|
solver="lsqr", covariance_estimator=KMeans(n_clusters=2, n_init="auto")
|
||
|
)
|
||
|
with pytest.raises(ValueError):
|
||
|
clf.fit(X, y)
|
||
|
|
||
|
|
||
|
@pytest.mark.parametrize("n_classes", [2, 3])
|
||
|
@pytest.mark.parametrize("solver", ["svd", "lsqr", "eigen"])
|
||
|
def test_lda_predict_proba(solver, n_classes):
|
||
|
def generate_dataset(n_samples, centers, covariances, random_state=None):
|
||
|
"""Generate a multivariate normal data given some centers and
|
||
|
covariances"""
|
||
|
rng = check_random_state(random_state)
|
||
|
X = np.vstack(
|
||
|
[
|
||
|
rng.multivariate_normal(mean, cov, size=n_samples // len(centers))
|
||
|
for mean, cov in zip(centers, covariances)
|
||
|
]
|
||
|
)
|
||
|
y = np.hstack(
|
||
|
[[clazz] * (n_samples // len(centers)) for clazz in range(len(centers))]
|
||
|
)
|
||
|
return X, y
|
||
|
|
||
|
blob_centers = np.array([[0, 0], [-10, 40], [-30, 30]])[:n_classes]
|
||
|
blob_stds = np.array([[[10, 10], [10, 100]]] * len(blob_centers))
|
||
|
X, y = generate_dataset(
|
||
|
n_samples=90000, centers=blob_centers, covariances=blob_stds, random_state=42
|
||
|
)
|
||
|
lda = LinearDiscriminantAnalysis(
|
||
|
solver=solver, store_covariance=True, shrinkage=None
|
||
|
).fit(X, y)
|
||
|
# check that the empirical means and covariances are close enough to the
|
||
|
# one used to generate the data
|
||
|
assert_allclose(lda.means_, blob_centers, atol=1e-1)
|
||
|
assert_allclose(lda.covariance_, blob_stds[0], atol=1)
|
||
|
|
||
|
# implement the method to compute the probability given in The Elements
|
||
|
# of Statistical Learning (cf. p.127, Sect. 4.4.5 "Logistic Regression
|
||
|
# or LDA?")
|
||
|
precision = linalg.inv(blob_stds[0])
|
||
|
alpha_k = []
|
||
|
alpha_k_0 = []
|
||
|
for clazz in range(len(blob_centers) - 1):
|
||
|
alpha_k.append(
|
||
|
np.dot(precision, (blob_centers[clazz] - blob_centers[-1])[:, np.newaxis])
|
||
|
)
|
||
|
alpha_k_0.append(
|
||
|
np.dot(
|
||
|
-0.5 * (blob_centers[clazz] + blob_centers[-1])[np.newaxis, :],
|
||
|
alpha_k[-1],
|
||
|
)
|
||
|
)
|
||
|
|
||
|
sample = np.array([[-22, 22]])
|
||
|
|
||
|
def discriminant_func(sample, coef, intercept, clazz):
|
||
|
return np.exp(intercept[clazz] + np.dot(sample, coef[clazz]))
|
||
|
|
||
|
prob = np.array(
|
||
|
[
|
||
|
float(
|
||
|
discriminant_func(sample, alpha_k, alpha_k_0, clazz)
|
||
|
/ (
|
||
|
1
|
||
|
+ sum(
|
||
|
[
|
||
|
discriminant_func(sample, alpha_k, alpha_k_0, clazz)
|
||
|
for clazz in range(n_classes - 1)
|
||
|
]
|
||
|
)
|
||
|
)
|
||
|
)
|
||
|
for clazz in range(n_classes - 1)
|
||
|
]
|
||
|
)
|
||
|
|
||
|
prob_ref = 1 - np.sum(prob)
|
||
|
|
||
|
# check the consistency of the computed probability
|
||
|
# all probabilities should sum to one
|
||
|
prob_ref_2 = float(
|
||
|
1
|
||
|
/ (
|
||
|
1
|
||
|
+ sum(
|
||
|
[
|
||
|
discriminant_func(sample, alpha_k, alpha_k_0, clazz)
|
||
|
for clazz in range(n_classes - 1)
|
||
|
]
|
||
|
)
|
||
|
)
|
||
|
)
|
||
|
|
||
|
assert prob_ref == pytest.approx(prob_ref_2)
|
||
|
# check that the probability of LDA are close to the theoretical
|
||
|
# probabilties
|
||
|
assert_allclose(
|
||
|
lda.predict_proba(sample), np.hstack([prob, prob_ref])[np.newaxis], atol=1e-2
|
||
|
)
|
||
|
|
||
|
|
||
|
def test_lda_priors():
|
||
|
# Test priors (negative priors)
|
||
|
priors = np.array([0.5, -0.5])
|
||
|
clf = LinearDiscriminantAnalysis(priors=priors)
|
||
|
msg = "priors must be non-negative"
|
||
|
|
||
|
with pytest.raises(ValueError, match=msg):
|
||
|
clf.fit(X, y)
|
||
|
|
||
|
# Test that priors passed as a list are correctly handled (run to see if
|
||
|
# failure)
|
||
|
clf = LinearDiscriminantAnalysis(priors=[0.5, 0.5])
|
||
|
clf.fit(X, y)
|
||
|
|
||
|
# Test that priors always sum to 1
|
||
|
priors = np.array([0.5, 0.6])
|
||
|
prior_norm = np.array([0.45, 0.55])
|
||
|
clf = LinearDiscriminantAnalysis(priors=priors)
|
||
|
|
||
|
with pytest.warns(UserWarning):
|
||
|
clf.fit(X, y)
|
||
|
|
||
|
assert_array_almost_equal(clf.priors_, prior_norm, 2)
|
||
|
|
||
|
|
||
|
def test_lda_coefs():
|
||
|
# Test if the coefficients of the solvers are approximately the same.
|
||
|
n_features = 2
|
||
|
n_classes = 2
|
||
|
n_samples = 1000
|
||
|
X, y = make_blobs(
|
||
|
n_samples=n_samples, n_features=n_features, centers=n_classes, random_state=11
|
||
|
)
|
||
|
|
||
|
clf_lda_svd = LinearDiscriminantAnalysis(solver="svd")
|
||
|
clf_lda_lsqr = LinearDiscriminantAnalysis(solver="lsqr")
|
||
|
clf_lda_eigen = LinearDiscriminantAnalysis(solver="eigen")
|
||
|
|
||
|
clf_lda_svd.fit(X, y)
|
||
|
clf_lda_lsqr.fit(X, y)
|
||
|
clf_lda_eigen.fit(X, y)
|
||
|
|
||
|
assert_array_almost_equal(clf_lda_svd.coef_, clf_lda_lsqr.coef_, 1)
|
||
|
assert_array_almost_equal(clf_lda_svd.coef_, clf_lda_eigen.coef_, 1)
|
||
|
assert_array_almost_equal(clf_lda_eigen.coef_, clf_lda_lsqr.coef_, 1)
|
||
|
|
||
|
|
||
|
def test_lda_transform():
|
||
|
# Test LDA transform.
|
||
|
clf = LinearDiscriminantAnalysis(solver="svd", n_components=1)
|
||
|
X_transformed = clf.fit(X, y).transform(X)
|
||
|
assert X_transformed.shape[1] == 1
|
||
|
clf = LinearDiscriminantAnalysis(solver="eigen", n_components=1)
|
||
|
X_transformed = clf.fit(X, y).transform(X)
|
||
|
assert X_transformed.shape[1] == 1
|
||
|
|
||
|
clf = LinearDiscriminantAnalysis(solver="lsqr", n_components=1)
|
||
|
clf.fit(X, y)
|
||
|
msg = "transform not implemented for 'lsqr'"
|
||
|
|
||
|
with pytest.raises(NotImplementedError, match=msg):
|
||
|
clf.transform(X)
|
||
|
|
||
|
|
||
|
def test_lda_explained_variance_ratio():
|
||
|
# Test if the sum of the normalized eigen vectors values equals 1,
|
||
|
# Also tests whether the explained_variance_ratio_ formed by the
|
||
|
# eigen solver is the same as the explained_variance_ratio_ formed
|
||
|
# by the svd solver
|
||
|
|
||
|
state = np.random.RandomState(0)
|
||
|
X = state.normal(loc=0, scale=100, size=(40, 20))
|
||
|
y = state.randint(0, 3, size=(40,))
|
||
|
|
||
|
clf_lda_eigen = LinearDiscriminantAnalysis(solver="eigen")
|
||
|
clf_lda_eigen.fit(X, y)
|
||
|
assert_almost_equal(clf_lda_eigen.explained_variance_ratio_.sum(), 1.0, 3)
|
||
|
assert clf_lda_eigen.explained_variance_ratio_.shape == (
|
||
|
2,
|
||
|
), "Unexpected length for explained_variance_ratio_"
|
||
|
|
||
|
clf_lda_svd = LinearDiscriminantAnalysis(solver="svd")
|
||
|
clf_lda_svd.fit(X, y)
|
||
|
assert_almost_equal(clf_lda_svd.explained_variance_ratio_.sum(), 1.0, 3)
|
||
|
assert clf_lda_svd.explained_variance_ratio_.shape == (
|
||
|
2,
|
||
|
), "Unexpected length for explained_variance_ratio_"
|
||
|
|
||
|
assert_array_almost_equal(
|
||
|
clf_lda_svd.explained_variance_ratio_, clf_lda_eigen.explained_variance_ratio_
|
||
|
)
|
||
|
|
||
|
|
||
|
def test_lda_orthogonality():
|
||
|
# arrange four classes with their means in a kite-shaped pattern
|
||
|
# the longer distance should be transformed to the first component, and
|
||
|
# the shorter distance to the second component.
|
||
|
means = np.array([[0, 0, -1], [0, 2, 0], [0, -2, 0], [0, 0, 5]])
|
||
|
|
||
|
# We construct perfectly symmetric distributions, so the LDA can estimate
|
||
|
# precise means.
|
||
|
scatter = np.array(
|
||
|
[
|
||
|
[0.1, 0, 0],
|
||
|
[-0.1, 0, 0],
|
||
|
[0, 0.1, 0],
|
||
|
[0, -0.1, 0],
|
||
|
[0, 0, 0.1],
|
||
|
[0, 0, -0.1],
|
||
|
]
|
||
|
)
|
||
|
|
||
|
X = (means[:, np.newaxis, :] + scatter[np.newaxis, :, :]).reshape((-1, 3))
|
||
|
y = np.repeat(np.arange(means.shape[0]), scatter.shape[0])
|
||
|
|
||
|
# Fit LDA and transform the means
|
||
|
clf = LinearDiscriminantAnalysis(solver="svd").fit(X, y)
|
||
|
means_transformed = clf.transform(means)
|
||
|
|
||
|
d1 = means_transformed[3] - means_transformed[0]
|
||
|
d2 = means_transformed[2] - means_transformed[1]
|
||
|
d1 /= np.sqrt(np.sum(d1**2))
|
||
|
d2 /= np.sqrt(np.sum(d2**2))
|
||
|
|
||
|
# the transformed within-class covariance should be the identity matrix
|
||
|
assert_almost_equal(np.cov(clf.transform(scatter).T), np.eye(2))
|
||
|
|
||
|
# the means of classes 0 and 3 should lie on the first component
|
||
|
assert_almost_equal(np.abs(np.dot(d1[:2], [1, 0])), 1.0)
|
||
|
|
||
|
# the means of classes 1 and 2 should lie on the second component
|
||
|
assert_almost_equal(np.abs(np.dot(d2[:2], [0, 1])), 1.0)
|
||
|
|
||
|
|
||
|
def test_lda_scaling():
|
||
|
# Test if classification works correctly with differently scaled features.
|
||
|
n = 100
|
||
|
rng = np.random.RandomState(1234)
|
||
|
# use uniform distribution of features to make sure there is absolutely no
|
||
|
# overlap between classes.
|
||
|
x1 = rng.uniform(-1, 1, (n, 3)) + [-10, 0, 0]
|
||
|
x2 = rng.uniform(-1, 1, (n, 3)) + [10, 0, 0]
|
||
|
x = np.vstack((x1, x2)) * [1, 100, 10000]
|
||
|
y = [-1] * n + [1] * n
|
||
|
|
||
|
for solver in ("svd", "lsqr", "eigen"):
|
||
|
clf = LinearDiscriminantAnalysis(solver=solver)
|
||
|
# should be able to separate the data perfectly
|
||
|
assert clf.fit(x, y).score(x, y) == 1.0, "using covariance: %s" % solver
|
||
|
|
||
|
|
||
|
def test_lda_store_covariance():
|
||
|
# Test for solver 'lsqr' and 'eigen'
|
||
|
# 'store_covariance' has no effect on 'lsqr' and 'eigen' solvers
|
||
|
for solver in ("lsqr", "eigen"):
|
||
|
clf = LinearDiscriminantAnalysis(solver=solver).fit(X6, y6)
|
||
|
assert hasattr(clf, "covariance_")
|
||
|
|
||
|
# Test the actual attribute:
|
||
|
clf = LinearDiscriminantAnalysis(solver=solver, store_covariance=True).fit(
|
||
|
X6, y6
|
||
|
)
|
||
|
assert hasattr(clf, "covariance_")
|
||
|
|
||
|
assert_array_almost_equal(
|
||
|
clf.covariance_, np.array([[0.422222, 0.088889], [0.088889, 0.533333]])
|
||
|
)
|
||
|
|
||
|
# Test for SVD solver, the default is to not set the covariances_ attribute
|
||
|
clf = LinearDiscriminantAnalysis(solver="svd").fit(X6, y6)
|
||
|
assert not hasattr(clf, "covariance_")
|
||
|
|
||
|
# Test the actual attribute:
|
||
|
clf = LinearDiscriminantAnalysis(solver=solver, store_covariance=True).fit(X6, y6)
|
||
|
assert hasattr(clf, "covariance_")
|
||
|
|
||
|
assert_array_almost_equal(
|
||
|
clf.covariance_, np.array([[0.422222, 0.088889], [0.088889, 0.533333]])
|
||
|
)
|
||
|
|
||
|
|
||
|
@pytest.mark.parametrize("seed", range(10))
|
||
|
def test_lda_shrinkage(seed):
|
||
|
# Test that shrunk covariance estimator and shrinkage parameter behave the
|
||
|
# same
|
||
|
rng = np.random.RandomState(seed)
|
||
|
X = rng.rand(100, 10)
|
||
|
y = rng.randint(3, size=(100))
|
||
|
c1 = LinearDiscriminantAnalysis(store_covariance=True, shrinkage=0.5, solver="lsqr")
|
||
|
c2 = LinearDiscriminantAnalysis(
|
||
|
store_covariance=True,
|
||
|
covariance_estimator=ShrunkCovariance(shrinkage=0.5),
|
||
|
solver="lsqr",
|
||
|
)
|
||
|
c1.fit(X, y)
|
||
|
c2.fit(X, y)
|
||
|
assert_allclose(c1.means_, c2.means_)
|
||
|
assert_allclose(c1.covariance_, c2.covariance_)
|
||
|
|
||
|
|
||
|
def test_lda_ledoitwolf():
|
||
|
# When shrinkage="auto" current implementation uses ledoitwolf estimation
|
||
|
# of covariance after standardizing the data. This checks that it is indeed
|
||
|
# the case
|
||
|
class StandardizedLedoitWolf:
|
||
|
def fit(self, X):
|
||
|
sc = StandardScaler() # standardize features
|
||
|
X_sc = sc.fit_transform(X)
|
||
|
s = ledoit_wolf(X_sc)[0]
|
||
|
# rescale
|
||
|
s = sc.scale_[:, np.newaxis] * s * sc.scale_[np.newaxis, :]
|
||
|
self.covariance_ = s
|
||
|
|
||
|
rng = np.random.RandomState(0)
|
||
|
X = rng.rand(100, 10)
|
||
|
y = rng.randint(3, size=(100,))
|
||
|
c1 = LinearDiscriminantAnalysis(
|
||
|
store_covariance=True, shrinkage="auto", solver="lsqr"
|
||
|
)
|
||
|
c2 = LinearDiscriminantAnalysis(
|
||
|
store_covariance=True,
|
||
|
covariance_estimator=StandardizedLedoitWolf(),
|
||
|
solver="lsqr",
|
||
|
)
|
||
|
c1.fit(X, y)
|
||
|
c2.fit(X, y)
|
||
|
assert_allclose(c1.means_, c2.means_)
|
||
|
assert_allclose(c1.covariance_, c2.covariance_)
|
||
|
|
||
|
|
||
|
@pytest.mark.parametrize("n_features", [3, 5])
|
||
|
@pytest.mark.parametrize("n_classes", [5, 3])
|
||
|
def test_lda_dimension_warning(n_classes, n_features):
|
||
|
rng = check_random_state(0)
|
||
|
n_samples = 10
|
||
|
X = rng.randn(n_samples, n_features)
|
||
|
# we create n_classes labels by repeating and truncating a
|
||
|
# range(n_classes) until n_samples
|
||
|
y = np.tile(range(n_classes), n_samples // n_classes + 1)[:n_samples]
|
||
|
max_components = min(n_features, n_classes - 1)
|
||
|
|
||
|
for n_components in [max_components - 1, None, max_components]:
|
||
|
# if n_components <= min(n_classes - 1, n_features), no warning
|
||
|
lda = LinearDiscriminantAnalysis(n_components=n_components)
|
||
|
lda.fit(X, y)
|
||
|
|
||
|
for n_components in [max_components + 1, max(n_features, n_classes - 1) + 1]:
|
||
|
# if n_components > min(n_classes - 1, n_features), raise error.
|
||
|
# We test one unit higher than max_components, and then something
|
||
|
# larger than both n_features and n_classes - 1 to ensure the test
|
||
|
# works for any value of n_component
|
||
|
lda = LinearDiscriminantAnalysis(n_components=n_components)
|
||
|
msg = "n_components cannot be larger than "
|
||
|
with pytest.raises(ValueError, match=msg):
|
||
|
lda.fit(X, y)
|
||
|
|
||
|
|
||
|
@pytest.mark.parametrize(
|
||
|
"data_type, expected_type",
|
||
|
[
|
||
|
(np.float32, np.float32),
|
||
|
(np.float64, np.float64),
|
||
|
(np.int32, np.float64),
|
||
|
(np.int64, np.float64),
|
||
|
],
|
||
|
)
|
||
|
def test_lda_dtype_match(data_type, expected_type):
|
||
|
for solver, shrinkage in solver_shrinkage:
|
||
|
clf = LinearDiscriminantAnalysis(solver=solver, shrinkage=shrinkage)
|
||
|
clf.fit(X.astype(data_type), y.astype(data_type))
|
||
|
assert clf.coef_.dtype == expected_type
|
||
|
|
||
|
|
||
|
def test_lda_numeric_consistency_float32_float64():
|
||
|
for solver, shrinkage in solver_shrinkage:
|
||
|
clf_32 = LinearDiscriminantAnalysis(solver=solver, shrinkage=shrinkage)
|
||
|
clf_32.fit(X.astype(np.float32), y.astype(np.float32))
|
||
|
clf_64 = LinearDiscriminantAnalysis(solver=solver, shrinkage=shrinkage)
|
||
|
clf_64.fit(X.astype(np.float64), y.astype(np.float64))
|
||
|
|
||
|
# Check value consistency between types
|
||
|
rtol = 1e-6
|
||
|
assert_allclose(clf_32.coef_, clf_64.coef_, rtol=rtol)
|
||
|
|
||
|
|
||
|
def test_qda():
|
||
|
# QDA classification.
|
||
|
# This checks that QDA implements fit and predict and returns
|
||
|
# correct values for a simple toy dataset.
|
||
|
clf = QuadraticDiscriminantAnalysis()
|
||
|
y_pred = clf.fit(X6, y6).predict(X6)
|
||
|
assert_array_equal(y_pred, y6)
|
||
|
|
||
|
# Assure that it works with 1D data
|
||
|
y_pred1 = clf.fit(X7, y6).predict(X7)
|
||
|
assert_array_equal(y_pred1, y6)
|
||
|
|
||
|
# Test probas estimates
|
||
|
y_proba_pred1 = clf.predict_proba(X7)
|
||
|
assert_array_equal((y_proba_pred1[:, 1] > 0.5) + 1, y6)
|
||
|
y_log_proba_pred1 = clf.predict_log_proba(X7)
|
||
|
assert_array_almost_equal(np.exp(y_log_proba_pred1), y_proba_pred1, 8)
|
||
|
|
||
|
y_pred3 = clf.fit(X6, y7).predict(X6)
|
||
|
# QDA shouldn't be able to separate those
|
||
|
assert np.any(y_pred3 != y7)
|
||
|
|
||
|
# Classes should have at least 2 elements
|
||
|
with pytest.raises(ValueError):
|
||
|
clf.fit(X6, y4)
|
||
|
|
||
|
|
||
|
def test_qda_priors():
|
||
|
clf = QuadraticDiscriminantAnalysis()
|
||
|
y_pred = clf.fit(X6, y6).predict(X6)
|
||
|
n_pos = np.sum(y_pred == 2)
|
||
|
|
||
|
neg = 1e-10
|
||
|
clf = QuadraticDiscriminantAnalysis(priors=np.array([neg, 1 - neg]))
|
||
|
y_pred = clf.fit(X6, y6).predict(X6)
|
||
|
n_pos2 = np.sum(y_pred == 2)
|
||
|
|
||
|
assert n_pos2 > n_pos
|
||
|
|
||
|
|
||
|
@pytest.mark.parametrize("priors_type", ["list", "tuple", "array"])
|
||
|
def test_qda_prior_type(priors_type):
|
||
|
"""Check that priors accept array-like."""
|
||
|
priors = [0.5, 0.5]
|
||
|
clf = QuadraticDiscriminantAnalysis(
|
||
|
priors=_convert_container([0.5, 0.5], priors_type)
|
||
|
).fit(X6, y6)
|
||
|
assert isinstance(clf.priors_, np.ndarray)
|
||
|
assert_array_equal(clf.priors_, priors)
|
||
|
|
||
|
|
||
|
def test_qda_prior_copy():
|
||
|
"""Check that altering `priors` without `fit` doesn't change `priors_`"""
|
||
|
priors = np.array([0.5, 0.5])
|
||
|
qda = QuadraticDiscriminantAnalysis(priors=priors).fit(X, y)
|
||
|
|
||
|
# we expect the following
|
||
|
assert_array_equal(qda.priors_, qda.priors)
|
||
|
|
||
|
# altering `priors` without `fit` should not change `priors_`
|
||
|
priors[0] = 0.2
|
||
|
assert qda.priors_[0] != qda.priors[0]
|
||
|
|
||
|
|
||
|
def test_qda_store_covariance():
|
||
|
# The default is to not set the covariances_ attribute
|
||
|
clf = QuadraticDiscriminantAnalysis().fit(X6, y6)
|
||
|
assert not hasattr(clf, "covariance_")
|
||
|
|
||
|
# Test the actual attribute:
|
||
|
clf = QuadraticDiscriminantAnalysis(store_covariance=True).fit(X6, y6)
|
||
|
assert hasattr(clf, "covariance_")
|
||
|
|
||
|
assert_array_almost_equal(clf.covariance_[0], np.array([[0.7, 0.45], [0.45, 0.7]]))
|
||
|
|
||
|
assert_array_almost_equal(
|
||
|
clf.covariance_[1],
|
||
|
np.array([[0.33333333, -0.33333333], [-0.33333333, 0.66666667]]),
|
||
|
)
|
||
|
|
||
|
|
||
|
def test_qda_regularization():
|
||
|
# The default is reg_param=0. and will cause issues when there is a
|
||
|
# constant variable.
|
||
|
|
||
|
# Fitting on data with constant variable triggers an UserWarning.
|
||
|
collinear_msg = "Variables are collinear"
|
||
|
clf = QuadraticDiscriminantAnalysis()
|
||
|
with pytest.warns(UserWarning, match=collinear_msg):
|
||
|
y_pred = clf.fit(X2, y6)
|
||
|
|
||
|
# XXX: RuntimeWarning is also raised at predict time because of divisions
|
||
|
# by zero when the model is fit with a constant feature and without
|
||
|
# regularization: should this be considered a bug? Either by the fit-time
|
||
|
# message more informative, raising and exception instead of a warning in
|
||
|
# this case or somehow changing predict to avoid division by zero.
|
||
|
with pytest.warns(RuntimeWarning, match="divide by zero"):
|
||
|
y_pred = clf.predict(X2)
|
||
|
assert np.any(y_pred != y6)
|
||
|
|
||
|
# Adding a little regularization fixes the division by zero at predict
|
||
|
# time. But UserWarning will persist at fit time.
|
||
|
clf = QuadraticDiscriminantAnalysis(reg_param=0.01)
|
||
|
with pytest.warns(UserWarning, match=collinear_msg):
|
||
|
clf.fit(X2, y6)
|
||
|
y_pred = clf.predict(X2)
|
||
|
assert_array_equal(y_pred, y6)
|
||
|
|
||
|
# UserWarning should also be there for the n_samples_in_a_class <
|
||
|
# n_features case.
|
||
|
clf = QuadraticDiscriminantAnalysis(reg_param=0.1)
|
||
|
with pytest.warns(UserWarning, match=collinear_msg):
|
||
|
clf.fit(X5, y5)
|
||
|
y_pred5 = clf.predict(X5)
|
||
|
assert_array_equal(y_pred5, y5)
|
||
|
|
||
|
|
||
|
def test_covariance():
|
||
|
x, y = make_blobs(n_samples=100, n_features=5, centers=1, random_state=42)
|
||
|
|
||
|
# make features correlated
|
||
|
x = np.dot(x, np.arange(x.shape[1] ** 2).reshape(x.shape[1], x.shape[1]))
|
||
|
|
||
|
c_e = _cov(x, "empirical")
|
||
|
assert_almost_equal(c_e, c_e.T)
|
||
|
|
||
|
c_s = _cov(x, "auto")
|
||
|
assert_almost_equal(c_s, c_s.T)
|
||
|
|
||
|
|
||
|
@pytest.mark.parametrize("solver", ["svd", "lsqr", "eigen"])
|
||
|
def test_raises_value_error_on_same_number_of_classes_and_samples(solver):
|
||
|
"""
|
||
|
Tests that if the number of samples equals the number
|
||
|
of classes, a ValueError is raised.
|
||
|
"""
|
||
|
X = np.array([[0.5, 0.6], [0.6, 0.5]])
|
||
|
y = np.array(["a", "b"])
|
||
|
clf = LinearDiscriminantAnalysis(solver=solver)
|
||
|
with pytest.raises(ValueError, match="The number of samples must be more"):
|
||
|
clf.fit(X, y)
|
||
|
|
||
|
|
||
|
def test_get_feature_names_out():
|
||
|
"""Check get_feature_names_out uses class name as prefix."""
|
||
|
|
||
|
est = LinearDiscriminantAnalysis().fit(X, y)
|
||
|
names_out = est.get_feature_names_out()
|
||
|
|
||
|
class_name_lower = "LinearDiscriminantAnalysis".lower()
|
||
|
expected_names_out = np.array(
|
||
|
[
|
||
|
f"{class_name_lower}{i}"
|
||
|
for i in range(est.explained_variance_ratio_.shape[0])
|
||
|
],
|
||
|
dtype=object,
|
||
|
)
|
||
|
assert_array_equal(names_out, expected_names_out)
|
||
|
|
||
|
|
||
|
@pytest.mark.parametrize("array_namespace", ["numpy.array_api", "cupy.array_api"])
|
||
|
def test_lda_array_api(array_namespace):
|
||
|
"""Check that the array_api Array gives the same results as ndarrays."""
|
||
|
xp = pytest.importorskip(array_namespace)
|
||
|
|
||
|
X_xp = xp.asarray(X)
|
||
|
y_xp = xp.asarray(y3)
|
||
|
|
||
|
lda = LinearDiscriminantAnalysis()
|
||
|
lda.fit(X, y3)
|
||
|
|
||
|
array_attributes = {
|
||
|
key: value for key, value in vars(lda).items() if isinstance(value, np.ndarray)
|
||
|
}
|
||
|
|
||
|
lda_xp = clone(lda)
|
||
|
with config_context(array_api_dispatch=True):
|
||
|
lda_xp.fit(X_xp, y_xp)
|
||
|
|
||
|
# Fitted-attributes which are arrays must have the same
|
||
|
# namespace than the one of the training data.
|
||
|
for key, attribute in array_attributes.items():
|
||
|
lda_xp_param = getattr(lda_xp, key)
|
||
|
assert hasattr(lda_xp_param, "__array_namespace__")
|
||
|
|
||
|
lda_xp_param_np = _convert_to_numpy(lda_xp_param, xp=xp)
|
||
|
assert_allclose(
|
||
|
attribute, lda_xp_param_np, err_msg=f"{key} not the same", atol=1e-3
|
||
|
)
|
||
|
|
||
|
# Check predictions are the same
|
||
|
methods = (
|
||
|
"decision_function",
|
||
|
"predict",
|
||
|
"predict_log_proba",
|
||
|
"predict_proba",
|
||
|
"transform",
|
||
|
)
|
||
|
|
||
|
for method in methods:
|
||
|
result = getattr(lda, method)(X)
|
||
|
with config_context(array_api_dispatch=True):
|
||
|
result_xp = getattr(lda_xp, method)(X_xp)
|
||
|
assert hasattr(
|
||
|
result_xp, "__array_namespace__"
|
||
|
), f"{method} did not output an array_namespace"
|
||
|
|
||
|
result_xp_np = _convert_to_numpy(result_xp, xp=xp)
|
||
|
|
||
|
assert_allclose(
|
||
|
result,
|
||
|
result_xp_np,
|
||
|
err_msg=f"{method} did not the return the same result",
|
||
|
atol=1e-6,
|
||
|
)
|