FIX Inconsistencies between solvers in LinearDiscriminantAnalysis and QuadraticDiscriminantAnalysis - #33626
Conversation
LinearDiscriminantAnalysis solvers
|
Thanks for this PR and the detailed description. Let's see if we can find someone who can help review this. The code changes are simple and make sense. So the reviewer should focus on the methodological aspects. @ogrisel do you have time and enough knowledge about solvers to evaluate this? Who else could we ask to evaluate this? |
|
@agramfort ;) |
|
Regarding the unsolved issue mentioned at the end, I think that it should be possible to make This change would:
This could be added in the present PR, since it fits within "fixing solver inconsistencies". If there is interest it exploring this further change, I can study whether such solution exists. PD. I wonder whether such sample-weighting approach could also be used for the |
|
The following code might help review this contribution. The final import numpy as np
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
rng = np.random.default_rng(0)
n_class0 = 20
n_class1 = 20
n_features = 5
priors = np.array([0.5, 0.5])
#priors = None
X0 = rng.normal(loc=-1.0, scale=1.0, size=(n_class0, n_features))
X1 = rng.normal(loc=1.0, scale=1.2, size=(n_class1, n_features))
X = np.vstack([X0, X1])
y = np.array([0] * n_class0 + [1] * n_class1)
lda_svd = LinearDiscriminantAnalysis(solver="svd", store_covariance=True, priors=priors)
lda_eigen = LinearDiscriminantAnalysis(solver="eigen", priors=priors)
lda_svd.fit(X, y)
lda_eigen.fit(X, y)
decision_svd = lda_svd.decision_function(X)
decision_eigen = lda_eigen.decision_function(X)
np.allclose(decision_svd, decision_eigen) |
|
I realized that the same disagreement existed for the I think that this should also fix #15640 then. |
LinearDiscriminantAnalysis solversLinearDiscriminantAnalysis and QuadraticDiscriminantAnalysis
The thing we need is people who can review the changes on a methodological level (is that a word?). Questions like "is there a reason for things being different in |
|
Thanks @betatim. I understand the situation, there's a lot more work to be done than maintainers available time. Re "is investing a few hours in this PR worth it or is this a nice to have tweak that basically effects no one?", this PR's fix is needed to add something @ogrisel had requested, so I hope it is worth someone's time eventually, although I don't know the balance for you specifically. I just wanted to bump the PR to keep it from oblivion, if there's some potential reviewers you can think wouldn't mind being tagged. Also, always feel free to tag me to review PR's if you think I could be helpful. I've had merged PR's on the discriminant analysis and covariance modules before. |
|
I had a little bit of time and interest so I asked Opus (a LLM) to do some research on this topic and write me some material about:
This was quite an interesting experience. But clearly I am still just a beginner in this topic (LDA and QDA solvers). My take away from reading what the LLM produced is that the formulation in The scikit-learn library is about classification, so using Maximum Likelihood (ML) makes sense. The LLM also points out that other covariance related tools in scikit-learn already use ML, so making the switch here would increase consistency across the library. For LDA it suggests that users will basically not notice this change. However for QDA with imbalanced classes you might see a change (I asked for a minimal reproducer for this, which is at the end of this post). It seems like a percent level change? I think I am 👍 this change. The open questions for me are:
The reason I am not sure on the bug fix vs improvement is that it seems like both approaches ( The coding agent also noticed that Minimal reproducer for QDA
This snippet should work without having to switch git branches, at the cost of implementing things "from scratch". import numpy as np
from sklearn.discriminant_analysis import QuadraticDiscriminantAnalysis
rng = np.random.default_rng(42)
n0, n1 = 4, 60
X0 = rng.multivariate_normal([0.0, 0.0], np.eye(2), size=n0)
X1 = rng.multivariate_normal([2.0, 0.0], 0.5 * np.eye(2), size=n1)
X = np.vstack([X0, X1])
y = np.array([0] * n0 + [1] * n1)
def qda_predict(X_test, X_train, y_train, ddof):
"""Plain-numpy QDA. ddof=1 -> unbiased (current main). ddof=0 -> ML (the PR)."""
classes = np.unique(y_train)
means, inv_covs, log_dets, log_priors = [], [], [], []
for k in classes:
Xk = X_train[y_train == k]
mu = Xk.mean(axis=0)
Xc = Xk - mu
Sigma = (Xc.T @ Xc) / (len(Xk) - ddof)
means.append(mu)
inv_covs.append(np.linalg.inv(Sigma))
_, logdet = np.linalg.slogdet(Sigma)
log_dets.append(logdet)
log_priors.append(np.log(len(Xk) / len(y_train)))
scores = []
for k in range(len(classes)):
m = X_test - means[k]
mahal = np.einsum("ij,jk,ik->i", m, inv_covs[k], m)
scores.append(-0.5 * log_dets[k] - 0.5 * mahal + log_priors[k])
return np.argmax(np.column_stack(scores), axis=1)
unbiased = qda_predict(X, X, y, ddof=1) # what main does
ml = qda_predict(X, X, y, ddof=0) # what the PR does
sklearn_pred = QuadraticDiscriminantAnalysis().fit(X, y).predict(X)
print("sklearn matches the unbiased convention:",
np.array_equal(sklearn_pred, unbiased)) # True on main, False on PR branch
flip_idx = np.where(unbiased != ml)[0]
print(f"On training set: predictions flip on {len(flip_idx)} / {len(y)} points")
print("Flipping rows (X, y_true, main, PR):")
for i in flip_idx:
print(f" X={X[i].round(3)} y={y[i]} main={unbiased[i]} PR={ml[i]}")
xx, yy = np.meshgrid(np.linspace(-3, 5, 200), np.linspace(-3, 4, 200))
grid = np.column_stack([xx.ravel(), yy.ravel()])
boundary_diff = (qda_predict(grid, X, y, 1) != qda_predict(grid, X, y, 0)).sum()
print(f"Decision regions disagree on {boundary_diff} / {len(grid)} grid points "
f"({100 * boundary_diff / len(grid):.1f}% of the input space)")I feel like this was a somewhat useful exercise, but maybe the above is all totally obvious and uninteresting for someone who is a bit more involved in the business of these solvers and estimators? |
|
Thanks for the comment @betatim. Your understanding of the issue is pretty spot on. I expect the users to not notice, except in some edge cases with very few samples, since the difference between What I would highlight, is that the the current default using the unbiased covariance estimate is not only inconsistent with To illustrate this, the code below uses the same example you shared, but to compare The example above makes me think of this as a bug fix: a user would report a bug if they observed different QDA or LDA outcomes with the different solvers in an edge case. Regarding the change in QDA/LDA behavior, I wouldn't worry too much about it because: 1) there doesn't seem to be a "mathematically correct" choice, 2) it should mostly affect edge cases, 3) that behavior already exists in I'll add that, while I think that consistency is good to have for its own sake, my main interest in this change is to enable testing that compares the different solvers, to validate shrinkage with SVD in #33738, which should have a more practical impact. import numpy as np
from sklearn.discriminant_analysis import QuadraticDiscriminantAnalysis
rng = np.random.default_rng(42)
n0, n1 = 4, 60
X0 = rng.multivariate_normal([0.0, 0.0], np.eye(2), size=n0)
X1 = rng.multivariate_normal([2.0, 0.0], 0.5 * np.eye(2), size=n1)
X = np.vstack([X0, X1])
y = np.array([0] * n0 + [1] * n1)
svd_clf = QuadraticDiscriminantAnalysis(solver="svd").fit(X, y)
eigen_clf = QuadraticDiscriminantAnalysis(solver="eigen").fit(X, y)
svd_pred = svd_clf.predict(X)
eigen_pred = eigen_clf.predict(X)
print(
'training predictions agree between solver="svd" and solver="eigen":',
np.array_equal(svd_pred, eigen_pred),
)
flip_idx = np.where(svd_pred != eigen_pred)[0]
print(f"On training set: predictions flip on {len(flip_idx)} / {len(y)} points")
print('Flipping rows (X, y_true, solver="svd", solver="eigen"):')
for i in flip_idx:
print(f' X={X[i].round(3)} y={y[i]} svd={svd_pred[i]} eigen={eigen_pred[i]}')
xx, yy = np.meshgrid(np.linspace(-3, 5, 200), np.linspace(-3, 4, 200))
grid = np.column_stack([xx.ravel(), yy.ravel()])
boundary_diff = (svd_clf.predict(grid) != eigen_clf.predict(grid)).sum()
print(
f"Decision regions disagree on {boundary_diff} / {len(grid)} grid points "
f"({100 * boundary_diff / len(grid):.1f}% of the input space)"
) |
Do you agree that this needs updating? If yes, will you do it? I think I am 👍 making this change and calling it a bug fix/a changelog entry is enough to inform people about this change. I think I've convinced myself that very few people will notice this. So even though I don't like it when code from one version to another changes behaviour (Imagine you are running a system with several dependencies, if each makes some minute changes you end up chasing after weird behaviour a lot), I think we should make this change. More philosophically, while it might be surprising/annoying that different solvers find different solutions I am not so worried about that. I guess my bias is that "different solvers exist because they are good at different things, so they will give different answers sometimes". So my worry is more about changing results for someone who didn't change their code, but probably not an issue here. |
|
Thanks @betatim. I agree that those docs required editing, I pushed the update. And thanks for sharing the perspective on the tradeoffs, this is a useful learning experience. |
|
Hi @betatim, just checking if you're still interested in continuing with this review, after I incorporated the docs changes. |
betatim
left a comment
There was a problem hiding this comment.
Thanks for fixing the docs!
To me this looks like a good improvement.
It would be good to get input from someone with a bit more experience as a second reviewer
|
I've added you to the whitelist so that you can open more than one PR at a time. This fixes the immediate problem, because I think even though this PR has been hanging for a long time, closing it seems like a waste (or might trigger someone else to create a PR implementing the same). So I prefer to keep it open. |
virchan
left a comment
There was a problem hiding this comment.
Thank you for the PR, @dherrera1911!
I don't have permission to push changes to this branch, but I think we can merge once the conflicts are resolved.
|
Thanks @virchan! I forgot to give access to edit the branch, done now. I resolved the conflicts, it was just using the recently renamed |
virchan
left a comment
There was a problem hiding this comment.
Let's merge this! Thank you everyone for your time!
…nd `QuadraticDiscriminantAnalysis` (scikit-learn#33626)
…nd `QuadraticDiscriminantAnalysis` (scikit-learn#33626)
…nd `QuadraticDiscriminantAnalysis` (scikit-learn#33626)
…nd `QuadraticDiscriminantAnalysis` (scikit-learn#33626)
Reference Issues/PRs
Fixes #8212 #15640
Towards #32590
See also #6725, #12662
What does this implement/fix? Explain your changes.
Different solvers produce different results for
LinearDiscriminantAnalysis. I traced this issue to the use of different covariance estimators by the different solvers:_solve_svd()uses the unbiased covariance, and_solve_eigen()uses the maximum likelihood covariance.Note that while
_solve_svd()does not explicitly compute the covariance, the relation between the singular values of the data and eigenvalues of the covariance (#32590) means that it can be seen as implicitly using a covariance. At one point,_solve_svd()dividesXby(n_samples - n_classes), which is implicitly using the unbiased within-class covariance.The core modification of the PR just changes one line in
LinearDiscriminantAnalysis._solve_svd()by changing
(n_samples - n_classes)ton_samples.The PR also includes changes to the testing suite, to test that the two solvers produce the exact same
.coef_and the exact same outputs for.transform(X). These tests pass for this branch, but don't pass inmain, making them regression tests.AI usage disclosure
I used AI assistance for:
Any other comments?
Another possible improvement:
In
_solve_svd()there is normalization by the features standard deviationstd. This operation is:But it looks like that division by
stdmight do nothing (other than cause instabilities), because after dividingXbystd,Vtis also divided bystd, and the two operations cancel each other. I could remove the divisions bystdfrom_solve_svd()in this PR, which might go towards #6725.Additional inconsistency fixed by this PR:
The use of unbiased vs maximum likelihood covariances is not very consistent across the discriminant analysis module (see #15640). A previously unidentified inconsistency is that when using
solver="svd"andstore_covariance=True, the ML covariance was stored, but the unbiased covariance was (implicitly) used to generate the coefficients, leading to internal inconsistency within LDA instances. This PR makes LDA more internally consistent.Unsolved issue:
There is an important unsolved difference between
solver="svd"andsolver="eigen". As mentioned in #15640, there are two ways to compute the within-class covariance:Xcand compute the covariance. This is whatsolver="svd"does.solver="eigen"does.This causes at least one significant difference:
_solve_eigen()weights the class covariances by the class priors, while_solve_svd()does not (it uses the prior implicit in the class sample counts). Thus, the current PR makes the two solvers agree exactly only when the priors match the class frequencies (the default), but they still disagree otherwise (e.g. if the priors are set manually).This last issue is related to #12662.