Newton optimizer: handle flat directions without producing NaN - #3429
Newton optimizer: handle flat directions without producing NaN#3429SteveBronder wants to merge 2 commits into
Conversation
Fixes #3425. make_negative_definite_and_solve divided the gradient projection by fabs(eigenvalue) with no zero guard. For a target that is flat along a direction the gradient and Hessian are both zero, so the step was 0/0 and the resulting NaN parameters were accepted by the line search and reported by the service as a successful run. - Drop eigen-directions whose magnitude is negligible relative to the largest eigenvalue, as in a pseudo-inverse, so the step is finite. - Reject non-finite step directions, candidate points, and objective values in newton_step instead of accepting them. - Have the newton service return error_codes::SOFTWARE with TERM_LSFAIL when the final log density or parameters are not finite. Adds a flat_target test model plus unit tests at the solve, step, and service layers that reproduce the reported NaN.
| vector_d eigenprojections = eigenvectors.transpose() * g; | ||
| double max_abs_eigenvalue = eigenvalues.cwiseAbs().maxCoeff(); | ||
| double tolerance | ||
| = max_abs_eigenvalue * H.rows() * std::numeric_limits<double>::epsilon(); |
There was a problem hiding this comment.
Where does this number come from? Is machine epsilon the appropriate baseline here? Why does it depend on the number of rows? Maybe this is related to the numerical precision of the solver...
Also you should handle the case where all eigenvalues are zero. Set some minimum absolute tolerance.
There was a problem hiding this comment.
So I'm going to be updating this to be more inline with what Eigen has.
double max_abs_eigenvalue = eigenvalues.cwiseAbs().maxCoeff();
double tolerance = std::fmax(max_abs_eigenvalue * 4 * H.rows()
* std::numeric_limits<double>::epsilon(),
std::numeric_limits<double>::min());I had claude do an explainer on where it got those values from and it gave me the below. I think it is easier to block quote claude here as I think it actually does a decent job of explaining where it got each piece of this.
This is the same cutoff Eigen's rank-revealing decompositions use to decide
whether a pivot or singular value is numerically zero, written out in one
expression. Eigen computes it in two steps: a dimensionless threshold(),
and a per-call multiplication by the largest pivot or singular value.
The bound. Eigen master's threshold() in
Eigen/src/misc/RankRevealingBase.h
reads:
// Higham's backward error bound: ||ΔA||₂ ≤ c·min(m,n)·u·||A||₂.
// The factor of 4 covers the constant c.
return m_usePrescribedThreshold
? m_prescribedThreshold
: NumTraits<Scalar>::epsilon() * RealScalar(4 * (std::min)(self().rows(), self().cols()));That is the backward error bound for a floating-point factorization from
N. J. Higham, Accuracy and Stability of Numerical Algorithms, 2nd ed.,
SIAM, 2002. A perturbation of the input smaller than this bound is
indistinguishable from rounding error in the factorization itself, so a
pivot below it carries no information about rank. Eigen 3.4 uses the same
formula without the factor of 4; the comment there
(Eigen/src/QR/ColPivHouseholderQR.h:382-383) says it "turns out to be
identical to Higham's formula used already in LDLt."
Piece by piece:
| Term in our code | Term in the bound | Where Eigen does it |
|---|---|---|
max_abs_eigenvalue |
‖A‖₂ |
Eigen multiplies threshold() by the largest pivot or singular value at the point of use: abs(m_maxpivot) * threshold() at Eigen/src/QR/ColPivHouseholderQR.h:259 and Eigen/src/LU/FullPivLU.h:334; m_singularValues.coeff(0) * threshold() at Eigen/src/SVD/SVDBase.h:153. For a symmetric matrix the 2-norm is the largest absolute eigenvalue, so eigenvalues.cwiseAbs().maxCoeff() is the same quantity. |
4 |
the constant c |
Eigen master, RankRevealingBase.h: "The factor of 4 covers the constant c." |
H.rows() |
min(m, n) |
Eigen master uses (std::min)(rows(), cols()); 3.4 uses m_qr.diagonalSize(). The Hessian is square, so both equal H.rows(). |
std::numeric_limits<double>::epsilon() |
u, the unit roundoff |
Eigen uses NumTraits<Scalar>::epsilon(), which for double is std::numeric_limits<double>::epsilon(). |
std::fmax(..., std::numeric_limits<double>::min()) |
lower clamp | Eigen/src/SVD/SVDBase.h:153: numext::maxi<RealScalar>(m_singularValues.coeff(0) * threshold(), (std::numeric_limits<RealScalar>::min)()). This is what makes an all-zero Hessian produce a positive cutoff instead of zero. |
abs_eigenvalue <= tolerance drops the direction |
comparison | Eigen counts a pivot toward the rank only when abs(pivot) > premultiplied_threshold (ColPivHouseholderQR.h:262), so <= is treated as zero, matching ours. |
Precedent outside Eigen. The same relative cutoff, dimension times
epsilon times the largest singular value or eigenvalue, is the default in
SciPy's pinvh
(rtol = max(a.shape) * eps, applied to the largest absolute eigenvalue,
which is the symmetric eigendecomposition case we have here),
MATLAB's pinv
(max(size(A)) * eps(norm(A))), and LAPACK's
DGELSD
(singular values S(i) <= RCOND * S(1) are treated as zero, with machine
precision used when RCOND < 0).
I wish there was an easy way to share claude sessions.
| eigenprojections[i] = -eigenprojections[i] / fabs(eigenvalues[i]); | ||
| double abs_eigenvalue = std::fabs(eigenvalues[i]); | ||
| if (abs_eigenvalue <= tolerance) { | ||
| eigenprojections[i] = 0; |
There was a problem hiding this comment.
If target is flat then gradient is zero and what you substitute for the eigenvalue doesn't matter, as long as it's finite. But it's also possible that the target is linear, and if so, the gradient is nonzero while the hessian is still zero. I think you'd want nonzero movement in that case. So instead of zero you should use inverse tolerance. Unlike pseudo-inverse, such "saturating inverse" is continuous.
| eigenprojections[i] = 0; | |
| eigenprojections[i] = -eigenprojections[i] / tolerance; |
| * @return true if all elements are finite | ||
| */ | ||
| template <typename Vec> | ||
| inline bool all_finite(const Vec& v) { |
There was a problem hiding this comment.
This looks like a reimplementation of stan::math::is_scal_finite
|
By the way, and this is a pre-existing issue, but even a simple model like parameters {
vector[3] x;
}
model {
x ~ normal(0, [10,1,0.1]');
}takes 100 iterations, which is completely unreasonable behavior for a Newton solver. Apparently step size is 1.90735e-06 for every iteration so something is very wrong. And that something is
|
That ship may have sailed... Even within math, it's not used by any other code, so I'm not sure why it (or
This sounds like it would be worthy of it's own PR |
For the record, anything inside of math's internal namespace has zero API guarantee aka the math library can change these on a whim with no version notice. This is what Eigen does as well (and partly why upgrading Eigen is such a hassle for us) |
…st normal double Match the premultiplied threshold used by Eigen's rank-revealing decompositions on master: Higham's backward error bound with a factor of 4 covering the constant, and a lower clamp at numeric_limits<double>::min() so an all-zero Hessian yields a positive cutoff. Adds tests for the cutoff boundary and for an all-zero Hessian with a nonzero gradient.
|
Yeah I understand that. These functions were added entirely on their own with no direct usages in |
Submission Checklist
./runTests.py src/test/unitmake cpplintSummary
Fixes #3425.
make_negative_definite_and_solvedivided the gradient projection byfabs(eigenvalue)with no guard against zero or near zero values. This could lead to some directions of the gradient and hessian being flat and causing NaN values to return. Now we check that the absolute of the eigen value is greater than a tolerance defined by the an epsilon scaled by the overall maximum eigenvalue. We reject non-finite step directions, candidate points, and objective values in newton_step instead of accepting them. And the newton service layer not returnserror_codes::SOFTWAREwithTERM_LSFAILwhen the final log density or parameters are not finite.Adds a
flat_targettest model plus unit tests at the solve, step, and service layers that reproduce the reported NaN.Documentation
Updated docs for
make_negative_definite_and_solveto reflect the change.Copyright and Licensing
Please list the copyright holder for the work you are submitting (this will be you or your assignee, such as a university or company): Steve Bronder
By submitting this pull request, the copyright holder is agreeing to license the submitted work under the following licenses: