Skip to content

Commit 319dd36

Browse files
committed
Test: Add classic NLLS problems to check convergence
Some problems are commented out until more solvers come.
1 parent 7b21291 commit 319dd36

File tree

5 files changed

+540
-2
lines changed

5 files changed

+540
-2
lines changed

include/tinyopt/optimizers/optimizer.h

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,16 @@ class Optimizer {
178178
// Add warning at compilation
179179
// TODO #pragma message("Your function cannot be auto-differentiated,
180180
// using numerical differentiation")
181+
OutputType out;
182+
out.num_diff_used = true;
181183
if constexpr (SolverType::FirstOrder) {
182184
auto loss = diff::CreateNumDiffFunc1(x, cost_or_acc);
183-
return OptimizeAcc(x, loss, max_iters);
185+
out = OptimizeAcc(x, loss, max_iters);
184186
} else {
185187
auto loss = diff::CreateNumDiffFunc2(x, cost_or_acc);
186-
return OptimizeAcc(x, loss, max_iters);
188+
out = OptimizeAcc(x, loss, max_iters);
187189
}
190+
return out;
188191
}
189192
#else
190193
else {

include/tinyopt/output.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ struct Output {
118118
/// Final H, excluding any damping (only saved if options.save.H = true)
119119
std::optional<H_t> final_H;
120120

121+
/// True if numerical derivatives were used
122+
bool num_diff_used = false;
123+
121124
/** @} */
122125

123126
/**

tests/CMakeLists.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ add_executable(tinyopt_test_optimizers optimizers.cpp)
2727
target_link_libraries(tinyopt_test_optimizers PRIVATE ${THIRDPARTY_TEST_LIBS} tinyopt)
2828
add_test_target(tinyopt_test_optimizers)
2929

30+
add_executable(tinyopt_test_optimize_easy optimize_easy.cpp)
31+
target_link_libraries(tinyopt_test_optimize_easy PRIVATE ${THIRDPARTY_TEST_LIBS} tinyopt)
32+
add_test_target(tinyopt_test_optimize_easy)
33+
34+
add_executable(tinyopt_test_optimize_hard optimize_hard.cpp)
35+
target_link_libraries(tinyopt_test_optimize_hard PRIVATE ${THIRDPARTY_TEST_LIBS} tinyopt)
36+
add_test_target(tinyopt_test_optimize_hard)
37+
3038
add_executable(tinyopt_test_sparse sparse.cpp)
3139
target_link_libraries(tinyopt_test_sparse PRIVATE ${THIRDPARTY_TEST_LIBS} tinyopt)
3240
add_test_target(tinyopt_test_sparse)

tests/optimize_easy.cpp

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
// Copyright 2026 Julien Michot.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
#include <cmath>
5+
6+
#include <Eigen/Eigen>
7+
8+
#if CATCH2_VERSION == 2
9+
#include <catch2/catch.hpp>
10+
#else
11+
#include <catch2/catch_approx.hpp>
12+
#include <catch2/catch_test_macros.hpp>
13+
#endif
14+
15+
#include <tinyopt/tinyopt.h>
16+
17+
#include <tinyopt/diff/gradient_check.h>
18+
#include <tinyopt/diff/num_diff.h>
19+
#include <tinyopt/optimize.h>
20+
#include <tinyopt/optimizers/optimizer.h>
21+
22+
using Catch::Approx;
23+
24+
using namespace tinyopt;
25+
using namespace tinyopt::diff;
26+
using namespace tinyopt::optimizers;
27+
using namespace tinyopt::solvers;
28+
29+
/**
30+
* UNIT TEST: Rosenbrock Function (The "Banana" Function)
31+
* -------------------------------------------------------
32+
* Formula: f(x, y) = (a - x)^2 + b(y - x^2)^2
33+
* Global minimum at (a, a^2). Usually a=1, b=100.
34+
* Difficulty: Narrow, curved valley that is easy to find but hard to converge to the minimum.
35+
*/
36+
void test_rosenbrock_convergence() {
37+
TINYOPT_LOG("ROSENBROCK");
38+
// Starting point
39+
Vec2 x(-1.2, 1.0);
40+
41+
auto loss = [&](const auto &v, auto &grad, auto &H) {
42+
double x_val = v(0);
43+
double y_val = v(1);
44+
45+
double term1 = 1.0 - x_val;
46+
double term2 = y_val - x_val * x_val;
47+
48+
if constexpr (!traits::is_nullptr_v<decltype(grad)>) {
49+
// First derivatives
50+
grad(0) = -2.0 * term1 - 400.0 * x_val * term2;
51+
grad(1) = 200.0 * term2;
52+
53+
// Hessian (Second derivatives)
54+
H(0, 0) = 2.0 - 400.0 * y_val + 1200.0 * x_val * x_val;
55+
H(0, 1) = -400.0 * x_val;
56+
H(1, 0) = -400.0 * x_val;
57+
H(1, 1) = 200.0;
58+
}
59+
60+
return term1 * term1 + 100.0 * term2 * term2;
61+
};
62+
63+
REQUIRE(CheckGradient(x, loss, 1e-5));
64+
65+
using Optimizer = Optimizer<SolverLM<Mat2>>;
66+
Optimizer::Options options;
67+
options.log.print_x = true;
68+
options.max_iters = 200;
69+
options.min_rerr_dec = 0;
70+
options.max_consec_failures = 20;
71+
72+
Optimizer optimizer(options);
73+
const auto &out = optimizer(x, loss);
74+
75+
REQUIRE(out.Succeeded());
76+
REQUIRE(out.Converged());
77+
// Minimum should be at (1, 1)
78+
REQUIRE(x(0) == Approx(1.0).margin(1e-5));
79+
REQUIRE(x(1) == Approx(1.0).margin(1e-5));
80+
}
81+
82+
/**
83+
* UNIT TEST: Plateau Function (Easom-like Flat Surface)
84+
* -------------------------------------------------------
85+
* Formula: f(x, y) = -cos(x)cos(y)exp(-((x-pi)^2 + (y-pi)^2))
86+
* Difficulty: The function is nearly zero (flat plateau) everywhere except near the minimum.
87+
* Converging here requires the optimizer to handle very small gradients.
88+
*/
89+
void test_plateau_convergence() {
90+
TINYOPT_LOG("PLATEAU");
91+
const double PI = std::acos(-1.0);
92+
Vec2 x(3.0, 3.0); // Start close to the dip
93+
94+
auto loss = [&](const auto &v, auto &grad, auto &H) {
95+
double dx = v(0) - PI;
96+
double dy = v(1) - PI;
97+
double ex = exp(-(dx * dx + dy * dy));
98+
double cx = cos(v(0));
99+
double cy = cos(v(1));
100+
double sx = sin(v(0));
101+
double sy = sin(v(1));
102+
103+
double cost = 1.0 - (cx * cy * ex);
104+
105+
if constexpr (!traits::is_nullptr_v<decltype(grad)>) {
106+
// Gradient of cost:
107+
// d/dx [-cx * cy * ex] = -[(-sx)*cy*ex + cx*cy*ex*(-2*dx)]
108+
// = cy*ex*(sx + 2*dx*cx)
109+
double g0 = cy * ex * (sx + 2.0 * dx * cx);
110+
double g1 = cx * ex * (sy + 2.0 * dy * cy);
111+
112+
grad(0) = g0;
113+
grad(1) = g1;
114+
115+
// For Levenberg-Marquardt, we want the Hessian of a sum of squares.
116+
// If cost = r^2, then H approx 2 * J^T * J.
117+
// Since we are returning the total cost directly, the Hessian of 'cost'
118+
// should be provided. For the Easom function, the curvature is very low
119+
// on the plateau, so the full analytical Hessian is preferred.
120+
121+
H(0, 0) = cy * ex * (cx - 4.0 * dx * sx + (2.0 - 4.0 * dx * dx) * cx);
122+
H(1, 1) = cx * ex * (cy - 4.0 * dy * sy + (2.0 - 4.0 * dy * dy) * cy);
123+
H(0, 1) = ex * (sx + 2.0 * dx * cx) * (sy + 2.0 * dy * cy);
124+
H(1, 0) = H(0, 1);
125+
}
126+
127+
return cost;
128+
};
129+
130+
REQUIRE(CheckGradient(x, loss, 1e-5));
131+
132+
using Optimizer = Optimizer<SolverLM<Mat2>>;
133+
Optimizer::Options options;
134+
options.solver.damping_init = 1e-6;
135+
options.log.print_x = true;
136+
// options.min_error = 0;
137+
138+
Optimizer optimizer(options);
139+
const auto &out = optimizer(x, loss);
140+
141+
REQUIRE(out.Succeeded());
142+
// Global minimum at (PI, PI)
143+
REQUIRE(x(0) == Approx(PI).margin(1e-4));
144+
REQUIRE(x(1) == Approx(PI).margin(1e-4));
145+
}
146+
147+
/**
148+
* UNIT TEST: Powell Singular Function
149+
* -------------------------------------------------------
150+
* Formula: f = (x1 + 10x2)^2 + 5(x3 - x4)^2 + (x2 - 2x3)^4 + 10(x1 - x4)^4
151+
* Difficulty: The Hessian is singular at the solution (0,0,0,0).
152+
* Tests the optimizer's ability to handle ill-conditioned matrices.
153+
*/
154+
void test_powell_singular_convergence() {
155+
TINYOPT_LOG("POWELL");
156+
// Starting point
157+
Vec4 x(3.0, -1.0, 0.0, 1.0);
158+
159+
auto loss = [&](const auto &v, auto &grad, auto &H) {
160+
double x1 = v(0), x2 = v(1), x3 = v(2), x4 = v(3);
161+
162+
double t1 = x1 + 10.0 * x2;
163+
double t2 = x3 - x4;
164+
double t3 = x2 - 2.0 * x3;
165+
double t4 = x1 - x4;
166+
167+
if constexpr (!traits::is_nullptr_v<decltype(grad)>) {
168+
grad.setZero();
169+
grad(0) = 2.0 * t1 + 40.0 * std::pow(t4, 3);
170+
grad(1) = 20.0 * t1 + 4.0 * std::pow(t3, 3);
171+
grad(2) = 10.0 * t2 - 8.0 * std::pow(t3, 3);
172+
grad(3) = -10.0 * t2 - 40.0 * std::pow(t4, 3);
173+
174+
// Full Analytical Hessian -> JtJ approx is too slow to converge
175+
H.setZero();
176+
// Second derivatives of (x1 + 10x2)^2
177+
H(0, 0) = 2.0;
178+
H(0, 1) = 20.0;
179+
H(1, 0) = 20.0;
180+
H(1, 1) = 200.0;
181+
// Second derivatives of 5(x3 - x4)^2
182+
H(2, 2) += 10.0;
183+
H(2, 3) += -10.0;
184+
H(3, 2) += -10.0;
185+
H(3, 3) += 10.0;
186+
// Second derivatives of (x2 - 2x3)^4
187+
double d3 = 12.0 * t3 * t3;
188+
H(1, 1) += d3;
189+
H(1, 2) += -2.0 * d3;
190+
H(2, 1) += -2.0 * d3;
191+
H(2, 2) += 4.0 * d3;
192+
// Second derivatives of 10(x1 - x4)^4
193+
double d4 = 120.0 * t4 * t4;
194+
H(0, 0) += d4;
195+
H(0, 3) += -d4;
196+
H(3, 0) += -d4;
197+
H(3, 3) += d4;
198+
}
199+
200+
return t1 * t1 + 5.0 * t2 * t2 + std::pow(t3, 4) + std::pow(t4, 4) * 10.0;
201+
};
202+
203+
REQUIRE(CheckGradient(x, loss, 1e-5));
204+
205+
using Optimizer = Optimizer<SolverLM<Mat4>>;
206+
Optimizer::Options options;
207+
options.max_iters = 200;
208+
options.max_consec_failures = 0;
209+
options.min_error = 1e-30;
210+
options.min_rerr_dec = 1e-30;
211+
options.log.print_x = true;
212+
options.solver.damping_init = 1e-1;
213+
214+
Optimizer optimizer(options);
215+
const auto &out = optimizer(x, loss);
216+
217+
REQUIRE(out.Succeeded());
218+
// Minimum should be at (0, 0, 0, 0)
219+
for (int i = 0; i < 4; ++i) {
220+
REQUIRE(std::abs(x(i)) < 1e-3);
221+
}
222+
}
223+
224+
TEST_CASE("tinyopt_optimizer_nlls_easy") {
225+
test_rosenbrock_convergence();
226+
test_plateau_convergence();
227+
test_powell_singular_convergence();
228+
}

0 commit comments

Comments
 (0)