Skip to content

Commit 2d8cec3

Browse files
committed
fix the solver-3 log determinant for negative LU pivots and report det(B) <= 0
1 parent b3eef65 commit 2d8cec3

2 files changed

Lines changed: 131 additions & 4 deletions

File tree

‎stan/math/mix/functor/laplace_marginal_density_estimator.hpp‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -900,13 +900,27 @@ struct LUSolver {
900900
* @brief Compute log determinant from LU factorization.
901901
*
902902
* @note This uses the diagonal of the combined LU matrix produced by Eigen
903-
* (equivalently the diagonal of U). It does not account for the sign of the
904-
* permutation; callers assume `det(B) > 0` in the Laplace correction.
903+
* (equivalently the diagonal of U). B = I + Sigma * W is not symmetric, so
904+
* with partial pivoting the diagonal of U can have negative entries also
905+
* when `det(B) > 0`. The sign of `det(B)` is the sign of the permutation
906+
* times the signs of the diagonal entries of U.
905907
*
906-
* @return Sum of log of the LU diagonal entries.
908+
* @return log(det(B)) = sum(log(abs(diag(U))))
909+
* @throws std::domain_error if `det(B)` is not positive. Then
910+
* `Sigma^-1 + W` is not positive definite at the final iterate, for example
911+
* at a saddle point, and the Laplace approximation is not defined.
907912
*/
908913
double compute_log_determinant() const {
909-
return lu.matrixLU().diagonal().array().log().sum();
914+
const auto& u_diag = lu.matrixLU().diagonal();
915+
const bool odd_sign = (lu.permutationP().determinant() < 0)
916+
!= ((u_diag.array() < 0.0).count() % 2 == 1);
917+
if (odd_sign || (u_diag.array() == 0.0).any()) {
918+
throw std::domain_error(
919+
"laplace_marginal_density: det(I + Sigma * W) is not positive at "
920+
"the final iterate, so the negative Hessian of the log density is "
921+
"not positive definite there (for example at a saddle point).");
922+
}
923+
return u_diag.array().abs().log().sum();
910924
}
911925

912926
/**
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
#include <stan/math.hpp>
2+
#include <stan/math/mix.hpp>
3+
#include <test/unit/util.hpp>
4+
5+
#include <gtest/gtest.h>
6+
#include <cmath>
7+
#include <stdexcept>
8+
#include <string>
9+
#include <tuple>
10+
11+
namespace {
12+
struct normal_likelihood {
13+
template <typename Theta>
14+
auto operator()(const Theta& theta, const Eigen::VectorXd& y, double s,
15+
std::ostream* pstream) const {
16+
return stan::math::normal_lpdf(y, theta, s);
17+
}
18+
};
19+
20+
// theta(0) has two Cauchy observations y, which make log p(theta | y)
21+
// bimodal in theta(0); theta(1) has one normal observation z.
22+
struct cauchy_normal_likelihood {
23+
template <typename Theta>
24+
auto operator()(const Theta& theta, const Eigen::VectorXd& y, double s,
25+
double z, double sz, std::ostream* pstream) const {
26+
return stan::math::cauchy_lpdf(y, theta(0), s)
27+
+ stan::math::normal_lpdf(z, theta(1), sz);
28+
}
29+
};
30+
31+
struct fixed_covariance {
32+
Eigen::MatrixXd operator()(const Eigen::MatrixXd& K,
33+
std::ostream* pstream) const {
34+
return K;
35+
}
36+
};
37+
} // namespace
38+
39+
// Solver 3 factorizes B = I + K W with partial pivoting. Here the rows are
40+
// swapped and U has a negative diagonal entry although det(B) = 226 > 0. The
41+
// log determinant used to be sum(log(diag(U))), which is NaN. For a normal
42+
// likelihood the Laplace approximation is exact, so all solvers must return
43+
// log N(y | 0, K + s^2 I).
44+
TEST(laplace_marginal_tol, all_solvers_exact_with_negative_lu_pivot) {
45+
Eigen::MatrixXd K(2, 2);
46+
K << 1.0, 1.5, 1.5, 4.0;
47+
Eigen::VectorXd y(2);
48+
y << 0.3, -0.2;
49+
const double s = std::sqrt(0.1);
50+
const Eigen::MatrixXd B
51+
= Eigen::MatrixXd::Identity(2, 2) + K / (s * s); // W = I / s^2
52+
Eigen::PartialPivLU<Eigen::MatrixXd> lu(B);
53+
ASSERT_LT(lu.matrixLU().diagonal().minCoeff(), 0.0);
54+
ASSERT_GT(B.determinant(), 0.0);
55+
const double exact = stan::math::multi_normal_lpdf(
56+
y, Eigen::VectorXd::Zero(2),
57+
(K + s * s * Eigen::MatrixXd::Identity(2, 2)).eval());
58+
Eigen::VectorXd theta_0 = Eigen::VectorXd::Zero(2);
59+
for (int solver : {1, 2, 3}) {
60+
SCOPED_TRACE("solver = " + std::to_string(solver));
61+
const double lml = stan::math::laplace_marginal_tol<false>(
62+
normal_likelihood{}, std::forward_as_tuple(y, s), 2, fixed_covariance{},
63+
std::make_tuple(K),
64+
std::make_tuple(theta_0, 1e-12, 1000, solver, 1000, false), nullptr);
65+
EXPECT_NEAR(exact, lml, 1e-8);
66+
}
67+
}
68+
69+
// Started near the saddle point (0, 0) of log p(theta | y), the Newton
70+
// iteration of solver 3 converges to it. There Sigma^-1 + W is not positive
71+
// definite and the Laplace approximation is not defined. This must be
72+
// reported as such: laplace_marginal_tol() used to return NaN or a finite
73+
// value, and laplace_latent_solve_tol() threw from cholesky_decompose().
74+
// Started away from the saddle point, solver 3 finds a maximum.
75+
TEST(laplace_marginal_tol, solver_3_saddle_point_throws) {
76+
Eigen::MatrixXd K(2, 2);
77+
K << 4.0, 1.0, 1.0, 1.0;
78+
Eigen::VectorXd y(2);
79+
y << -3.0, 3.0;
80+
const double s = 0.3;
81+
const double z = 0.0;
82+
const double sz = 0.1;
83+
const std::string reason = "det(I + Sigma * W) is not positive";
84+
Eigen::VectorXd theta_near(2);
85+
theta_near << 0.01, 3.0;
86+
auto ops_near = std::make_tuple(theta_near, 1e-10, 100, 3, 100, false);
87+
try {
88+
stan::math::laplace_marginal_tol<false>(
89+
cauchy_normal_likelihood{}, std::forward_as_tuple(y, s, z, sz), 2,
90+
fixed_covariance{}, std::make_tuple(K), ops_near, nullptr);
91+
FAIL() << "laplace_marginal_tol: expected std::domain_error";
92+
} catch (const std::domain_error& e) {
93+
EXPECT_NE(std::string(e.what()).find(reason), std::string::npos)
94+
<< e.what();
95+
}
96+
try {
97+
stan::math::laplace_latent_solve_tol(
98+
cauchy_normal_likelihood{}, std::forward_as_tuple(y, s, z, sz), 2,
99+
fixed_covariance{}, std::make_tuple(K), ops_near, nullptr);
100+
FAIL() << "laplace_latent_solve_tol: expected std::domain_error";
101+
} catch (const std::domain_error& e) {
102+
EXPECT_NE(std::string(e.what()).find(reason), std::string::npos)
103+
<< e.what();
104+
}
105+
Eigen::VectorXd theta_away(2);
106+
theta_away << 0.3, 3.0;
107+
auto [mean, chol] = stan::math::laplace_latent_solve_tol(
108+
cauchy_normal_likelihood{}, std::forward_as_tuple(y, s, z, sz), 2,
109+
fixed_covariance{}, std::make_tuple(K),
110+
std::make_tuple(theta_away, 1e-10, 100, 3, 100, false), nullptr);
111+
EXPECT_NEAR(2.9385, mean(0), 1e-4);
112+
EXPECT_NEAR(0.0097, mean(1), 1e-4);
113+
}

0 commit comments

Comments
 (0)