FIX MLP squared_error loss now matches the gradient for multi-output targets - #34830
FIX MLP squared_error loss now matches the gradient for multi-output targets#34830akshath-raj wants to merge 2 commits into
Conversation
β¦targets `squared_loss` averaged over outputs while `_backprop` computes the gradient of a per-sample squared error summed over outputs, so `_loss_grad_lbfgs` returned a loss and a gradient belonging to different functions. The analytical gradient was exactly `n_outputs` times the numerical gradient of the returned loss, which means lbfgs line-searched on one function while stepping along the gradient of another. Reduce over outputs with `.sum()`, matching `poisson_loss`, `log_loss` and `binary_log_loss`, and matching the canonical loss-link derivative documented in `_backprop`. The gradient is unchanged, so `sgd` and `adam` are bit-for-bit unaffected; only `lbfgs`, which consumes the loss, changes. Add a gradient check for the regressor path, which `test_gradient` did not cover as it only exercises `MLPClassifier`. Closes scikit-learn#8349 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018aJAK4ocimEcxyzk1YgWBr
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018aJAK4ocimEcxyzk1YgWBr
|
Thank you for opening your first pull request to scikit-learn! π To help get your contribution reviewed, please make sure that:
|
|
Marking this as a draft. Re-reading the pull request checklist, it says not to open a PR for an issue where the discussion has not settled on an explicit resolution plan, and that is the case here. The thread on #8349 has been quiet since 2017, and the comment I left there puts forward two candidate fixes rather than one, so there is a decision to make before this is ready for a full review. It is here as a concrete, testable version of that analysis rather than a request for review. Happy to leave it parked until a maintainer weighs in. If scaling the gradient instead of the loss is the preferred route, I will rework it, and if the whole approach is unwanted I will close it. One correction to the description while I am here: the test runs I quoted were against a nightly build of |
Reference Issues/PRs
Closes #8349. Detailed evidence and the comparison of the two candidate fixes are in this comment.
What does this implement/fix? Explain your changes.
_loss_grad_lbfgsreturns a(loss, grad)pair, and lbfgs line-searches on the loss while stepping along the gradient. For multi-output regression withsquared_errorthese are not the same function.squared_lossreduces over outputs with.mean(), while_backpropcomputesdeltas[last] = activations[-1] - yand divides only bysw_sum. That is the canonical loss-link derivative of a per-sample squared error summed over outputs, so the loss isn_outputstimes smaller than the function the gradient belongs to.Central-difference check of the returned pair against itself:
n_outputsβg β numβ / βnumβThe factor is exact in all 24 configurations I tried (
hidden_layer_sizes(5,)and(6, 4),alpha0 and 1e-3, 3 seeds), so it is not a conditioning artefact.squared_lossis the only loss that does this.poisson_loss,log_lossandbinary_log_lossall reduce with.sum(), and all of them pass the same check. This PR makessquared_lossuse.sum()too.The gradient is not touched, so
sgdandadamproduce bit-for-bit identical coefficients. Onlylbfgs, which actually consumes the loss, is affected. The alternative fix, scalingdeltas[last]down byn_outputs, also restores consistency, but it changes the effective learning rate for every solver and needs loss-specific special-casing inside_backprop, so I did not take it. Happy to switch if maintainers prefer it.Introduce yourself
I have been using scikit-learn for several years. It was one of the first libraries I worked with when I started doing AI and data analysis work, and I have kept using it since. Over that time I have made a point of understanding the libraries I depend on rather than treating them as black boxes, and I decided I would like to help with development instead of only consuming it. This is my first contribution here. I went through the open issues looking for numerical correctness bugs, found that this one still reproduces on
mainnine years after it was reported, and worked it up.AI usage disclosure
I used AI assistance for:
I reviewed all of it before pushing. I have read the change, the test and the changelog entry, I understand why summing over outputs is the correct reduction here, and I can explain any part of it on request. Every number quoted in this description came from running the code against a build of
mainrather than being asserted, and all of it is reproducible from what is written above.Any other comments?
This does not improve predictive accuracy, and I would rather say so than oversell it. Over 12 seeds on multi-output regression, test MSE improved on 6 of 12. Excluding one outlier seed the mean is marginally worse, 0.03258 against 0.03369. The case for the change is correctness: right now the optimiser follows a gradient that does not belong to the objective it is evaluating.
There is one behaviour change to weigh.
loss_,loss_curve_andbest_loss_scale byn_outputsfor multi-outputsquared_error. Sincetolis compared against loss changes, convergence becomes effectivelyn_outputstimes stricter, so lbfgs tends to run longer, median 536 against 594 iterations over 15 seeds. A changelog entry is included. Let me know if this also warrants achanged-modelsnote.On testing:
test_gradientonly exercisesMLPClassifier, whose loss already uses.sum(), which is why the regressor path went unchecked for so long. This PR addstest_gradient_regressor, parametrised overlossandn_outputs.mainforsquared_errorwithn_outputs2 and 3, and passes forpoissonand for single output, so it is specific to the defect.pytest --pyargs sklearn.neural_network: 91 to 97 passed (6 new cases), 2 skipped, no regressions.mlp or MLP or neural: 222 passed, 18 skipped.MLPRegressorandMLPClassifierdoctests are unchanged, verified against a pristine control, since the regressor example is single output and the two reductions coincide there.