Skip to content

Conversation

@SteveBronder
Copy link
Collaborator

Summary

This PR makes the following changes for the laplace approximation:

  1. Adds a wolfe line search to the Newton solver used in the laplace approximation to improve convergence.
  • The example code provided from Laplace Bug when passing Eigen::Map in tuple of functor arguments #3205 fails on develop. The issue arose that the initial value of 0 for theta started the model in the tail of the distribution. The quick line search we did which only tested half of a newton step was not robust enough for this model to reach convergance. This PR adds a full wolfe line search to the Newton solver used in the laplace approximation to improve convergence in such cases.
    The graphic below shows the difference in estimates of the log likelihood for laplace relative to integrate_1d on the roach test data plotted along the mu and sigma estimates. There is still a bias relative to integrate_1d as mu becomes negative and sigma becomes larger, but it is much nicer than before.
image
  • The main loop for laplace_marginal_density_est is expensive as it requires calculating either a diagonal hessian or block diagonal hessian with 2nd order autodiff. The wolfe line search only requires the gradients of the likelihood with respect to theta. So with that in mind the wolfe line search tries pretty aggressively get the best step size. If our initial step size is successful, we try to keep doubling until we hit a step size where the strong wolfe conditions fail and then return the information for the step right before that failure. If our initial step size does not satisfy strong wolfe then we do a bracketed zoom with cubic interpolation until till we find a step size that satisfies the strong wolfe conditions.
    Tests for the wolfe line search are added to test/unit/math/laplace/wolfe_line_search.hpp.
  1. Fixes bugs in the laplace approximation
  • Fix iteration mismatch between values when line search succeeds
    In the last iteration of the laplace approximation we were returning the negative block diagonal hessian and derived matrices from the previous search. This is fine if the line search in that last step failed. But if the line search succeeds then we need to go back and recalculate the negative block diagonal hessian and it's derived quantities.
  • Breakup diagonal and block hessian functions
    Previously we had one block_hessian function that calculated both the block hessian or the diagonal hessian at runtime. But this function is only used in places where we know at compile time whether we want a block or diagonal hessian. So I split out the two functions to avoid unnecessary runtime branching.
  • barzilai_borwein_step_size
    For an initial step size estimate before each line search we use the Barzilai-Borwein method to get an estimate.
  • Adjoints of ll args only calculated once
    Previously we calculated them eargerly in each laplace iteration. But they are not needed within the inner loop so we wait till we finish the inner search then calculate their adjoints once afterwards.
  • Calculate covariance once at the start and reuse throughout.
    We were calculating the covariance matrix from inside of laplace_density_est, but this required us to then return it from that function and imo looked weird. So I pulled it out and now laplace_marginal_density_est is passed the covariance matrix.
  1. Fixes numerical stability in laplace distributions
    There were a few places where we could use log_sum_exp etc. so I made those changes.
  2. Fixes "bug" in finite difference step size calculation
  • Changed from cube root of epsilon to epsilon^(1/7) for 6th order
    The finite difference method in Stan was previously using stepsize optimzied a 2nd order method. But the code is a 6th order method. I modified finite_diff_stepsize to use epsilon^(1/7) instead of cbrt(epsilon). With this change all of the laplace tests pass with a much higher tolerance for precision.

Tests

All the AD tests now have a tighter tolerance for the laplace approximation.
There are also tests for the wolfe line search in test/unit/math/laplace/wolfe_line_search.hpp.

./runTests.py test/unit/math/laplace

Release notes

Improve laplace approximation with wolfe line search and bug fixes.

Checklist

  • Copyright holder: Steve Bronder

    The copyright holder is typically you or your assignee, such as a university or company. By submitting this pull request, the copyright holder is agreeing to the license the submitted work under the following licenses:
    - Code: BSD 3-clause (https://opensource.org/licenses/BSD-3-Clause)
    - Documentation: CC-BY 4.0 (https://creativecommons.org/licenses/by/4.0/)

  • the basic tests are passing

    • unit tests pass (to run, use: ./runTests.py test/unit)
    • header checks pass, (make test-headers)
    • dependencies checks pass, (make test-math-dependencies)
    • docs build, (make doxygen)
    • code passes the built in C++ standards checks (make cpplint)
  • the code is written in idiomatic C++ and changes are documented in the doxygen

  • the new changes are tested

Copy link
Member

@WardBrian WardBrian left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some low-hanging fruit

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be better to do what is done for e.g. sigmaz.hpp and just convert the test data to hpp files with a const member, rather than include a csv reader into the tests for these functions only

} else {
static_assert(
sizeof(std::decay_t<output_i_t>*) == 0,
"INTERNAL ERROR:(laplace_marginal_lpdf) set_zero_adjoints was "
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we've moved this out of the laplace code, I think this string should be updated

* @param[in, out] output The output whose adjoints will be set to zero
*/
template <typename Output>
inline void set_zero_adjoint(Output&& output) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function name and file name should match: adjoints

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually this code was not even used so I can delete it

} else {
static_assert(
sizeof(std::decay_t<output_i_t>*) == 0,
"INTERNAL ERROR:(laplace_marginal_lpdf) collect_adjoints was "
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar, if these will live in core they shouldn't mention laplace functions (applies to all the functions in this file)

},
std::forward<Args>(args)...);
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: should the internal namespace end here? deep/shallow copy look much more like normal functions than conditional_copy_and_promote does

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's only used in other internal functions so I think it is better to have in internal

@avehtari
Copy link
Member

The current version seems to be more robust than before. I have tested with integrated LOO and leave-one-group-out cross-validation

  • Poisson varying intercept per observation (roaches)
  • beta-binomial with varying intercept and slope with several observations per group. In this case, hessian_block_size = 2. This is also challenging as beta-binomial may have non-log-concave likelihood. solver=1 seems to fail, but fallback to other solvers seems to work great! I got the best behavior wtting solver=2 which then sometimes fallbacks to solver=3

In both cases, laplace_marginal_tol() is fast enough that it woul be possible to use it also in the model block, but at this point I've focused on using them in generated quantities block

I'll continue experimenting with other models

@SteveBronder
Copy link
Collaborator Author

Awesome thank you!

Copy link
Member

@WardBrian WardBrian left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C++ is complicated but manageable -- but someone else (@charlesm93) is gonna need to review the actual algorithmic pieces for correctness

if constexpr (is_any_var_scalar_v<scalar_type_t<CovarArgs>>) {
[&covar_args_refs, &covar_args_adj, &md_est, &R, &s2,
&covariance_function, &msgs]() mutable {
const nested_rev_autodiff nested;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Steve note to self: double check that not having a nested here is fine

namespace internal {

template <std::size_t N, typename Tuple, typename CheckType>
inline constexpr bool is_tuple_type_v
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move to prim/meta / re-use existing programs there

Comment on lines +117 to +118
template <typename Ops>
inline constexpr auto tuple_to_laplace_options(Ops&& ops) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please clean up the manual typechecking here -- I'm not even sure if it is necessary, since the std::gets will raise a compiler error if they're wrong in the happy path

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I think I kind of like leaving these even though it is a bit bulky, std::get would throw a compiler error or the parameters may not match, but these compiler errors will be a little bit nicer for whoever sees them. Though the stanc3 compiler will also block users from seeing.

* @note This helper is currently unused in the Laplace solvers in this file.
*/
template <typename WRootMat>
inline void block_matrix_chol_L(WRootMat& W_root,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused function

* @warning The vectors must have identical size. Non-finite inputs yield the
* safe fallback.
*/
inline double barzilai_borwein_step_size(const Eigen::VectorXd& s,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@charlesm93 the C++ looks fine to me for this function but I'd appreciate someone else putting eyes on the math

* The routine assumes a 1-D bracket [x_left, x_right], together with function
* values and directional derivatives at both endpoints. Internally it:
*
* 1. Normalizes the interval to s ∈ [0, 1] via
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: Is doxygen happy with the unicode etc?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to look for any more of these and switch them to math mode.

Comment on lines 204 to 208
struct Candidate {
Scalar s_;
Scalar value_;
};
Candidate best{0.5, eval(0.5)}; // Start from bisection.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can just be two variables, s_best and value_best

Comment on lines 887 to 888
auto assign_step
= [](WolfeData& out, WolfeData& buf, auto&& e) { out.update(buf, e); };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline

Comment on lines 880 to 885
auto armijo_ok = [&prev, &opt](const Eval& eval) -> bool {
return check_armijo(eval, prev, opt);
};
auto wolfe_ok = [&prev, &opt](const Eval& eval) -> bool {
return check_wolfe(eval, prev, opt);
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete

@SteveBronder
Copy link
Collaborator Author

SteveBronder commented Jan 22, 2026

EDIT: Wrong PR

@SteveBronder
Copy link
Collaborator Author

@WardBrian for that static init failure on jenkins I need to bring back the csv reader so the test is not trying to put all of that data into static memory. I'm pretty sure that is the cause of the error

@WardBrian
Copy link
Member

@SteveBronder the latest couple commits here look like mistakes meant for #3271

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Laplace Bug when passing Eigen::Map in tuple of functor arguments

7 participants