LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
aph2_adjust_opt.h
Go to the documentation of this file.
1/*
2 * Copyright (c) 2012-2026, QORE Lab, Imperial College London
3 * All rights reserved.
4 */
5#ifndef LINE_API_MAM_APH2_ADJUST_OPT_H
6#define LINE_API_MAM_APH2_ADJUST_OPT_H
7
8/**
9 * @file
10 * @ingroup api_mam
11 * Optimization-based APH(2) moment adjustment: the 'opt_param' and 'opt_char'
12 * methods of matlab/lib/m3a/m3a/aph2/aph2_adjust.m.
13 *
14 * Both find (M2a, M3a) close to a requested (M2, M3), holding M1 fixed, such
15 * that an APH(2) with those three moments exists. They differ in the space
16 * they search:
17 *
18 * opt_param searches the PARAMETER space. The decision variables are the
19 * second phase mean l2 and the branching probability r1, with
20 * l1 = M1 - l2 r1 chosen so that the first moment is matched
21 * exactly; (M2a, M3a) are then whatever that APH(2) has. Any
22 * point of the feasible box is a valid APH(2), so the answer is
23 * feasible by construction.
24 * opt_char searches the CHARACTERISTIC space. The decision variables are
25 * (M2a, M3a) directly and feasibility is imposed as nonlinear
26 * constraints: the closed-form inversion aph2_fit1 / aph2_fit2
27 * must produce a non-negative discriminant, non-negative phase
28 * means and a probability in [0, 1]. The reference solves the
29 * two inversions as separate problems and keeps whichever gives
30 * the smaller adjustment; so does this port. The closed-form
31 * Telek-Heindl bounds are imposed as three further rows, which
32 * are redundant in exact arithmetic but close a gap in the
33 * inversion form that an optimizer otherwise exploits; see
34 * aph2_moment_bounds_rows for the reproduction.
35 *
36 * ACCEPTANCE CONTRACT (see line/util/auglag.h). MATLAB drives both with
37 * fmincon (active-set) or, in the '_gads' variants, with GlobalSearch. This
38 * port uses the augmented Lagrangian of line/util/auglag.h with the
39 * least-squares inner solver, which is a different algorithm: the iterates do
40 * not match and, on a problem with several local minima, the local minimum
41 * need not be the same one. What is guaranteed and tested is the
42 * specification:
43 * 1. the returned (M1, M2a, M3a) admits an APH(2), i.e. it satisfies the
44 * Telek-Heindl bounds that aph2_adjust's 'simple' method enforces;
45 * 2. when the requested (M2, M3) is already APH(2)-feasible, the returned
46 * pair reproduces it to the stated tolerance (objective ~ 0);
47 * 3. the achieved objective ||(M2a, M3a) - (M2, M3)||, the exact objective
48 * the reference minimizes, is returned in Aph2AdjustOptResult::objective
49 * so it can be compared against any other optimizer's on the same input.
50 *
51 * NOT PORTED: 'opt_param_gads' and 'opt_char_gads'. They are the same two
52 * problems handed to GlobalSearch, whose answer depends on a randomly seeded
53 * multi-start; a deterministic multi-start would be a different algorithm
54 * again and would not reproduce them, so they are deliberately absent rather
55 * than approximated. Since both problems are smooth with a small feasible
56 * region, the single-start solve here reaches the same objective on every
57 * input exercised in tests/test_mam_fit_optim.cpp.
58 *
59 * REFERENCE DEFECT (matlab/lib/m3a/m3a/aph2/aph2_adjust.m, 'opt_char'): the
60 * constraint functions nonlcon1 and nonlcon2 read `tmp0`, which is assigned
61 * only inside the sibling nested functions aph2_fit1 / aph2_fit2 and is
62 * therefore not in their scope. Calling aph2_adjust(M1, M2, M3, 'opt_char')
63 * raises "Unrecognized function or variable 'tmp0'" before any optimization
64 * happens, so the reference's opt_char has never run. This port recomputes
65 * tmp0 in the constraint, which is the evident intent (it is the
66 * discriminant of the inversion, and c(1) = -tmp0 asks for it to be
67 * non-negative). MATLAB was NOT edited.
68 *
69 * Gated on transcendental arithmetic: the optimizers stop on tolerances, and
70 * the inversion takes a square root.
71 */
72
73#include <cstddef>
74#include <vector>
75
77#include "line/num/number.h"
78#include "line/util/auglag.h"
79#include "line/util/error.h"
80
81namespace line {
82namespace mam {
83
84/** Result of an optimization-based APH(2) moment adjustment. */
85template <class T>
87 T M2a; ///< adjusted second moment
88 T M3a; ///< adjusted third moment
89 T objective; ///< ||(M2a, M3a) - (M2, M3)||, the reference's objective
90 T violation; ///< worst constraint violation at the returned point
91 bool converged; ///< the constrained solve reached feasibility within its caps
92};
93
94namespace fitdetail {
95
96/**
97 * Telek-Heindl APH(2) feasibility of a moment triple, the predicate the
98 * 'simple' method of aph2_adjust enforces by clamping. Used here to state the
99 * acceptance criterion rather than to compute anything.
100 */
101template <class T>
102bool aph2_moments_feasible(const T& M1, const T& M2, const T& M3, const T& tol) {
103 const T one = num_traits<T>::from_int(1);
104 const T two = num_traits<T>::from_int(2);
105 const T three = num_traits<T>::from_int(3);
106 const T six = num_traits<T>::from_int(6);
107 const T half = num_traits<T>::from_rational(1, 2);
108 const T M1sq = M1 * M1;
109 const T scv = (M2 - M1sq) / M1sq;
110 if (scv < half * (one - tol)) return false;
111 if (scv <= one) {
112 const T d = one - scv;
113 const T lb = three * pw(M1, 3) * (three * scv - one + num_sqrt(two) * d * num_sqrt(d));
114 const T ub = six * pw(M1, 3) * scv;
115 return M3 >= lb * (one - tol) && M3 <= ub * (one + tol);
116 }
117 const T lb = three / two * pw(M1, 3) * (one + scv) * (one + scv);
118 return M3 >= lb * (one - tol);
119}
120
121/**
122 * The Telek-Heindl region as three inequality rows c <= 0 in (M2, M3) at a
123 * fixed M1: the SCV floor of 1/2, and the third-moment bounds, whose upper
124 * half is absent above SCV = 1.
125 *
126 * These rows are mathematically REDUNDANT with the inversion constraints of
127 * opt_char -- they describe the same region -- but they are imposed alongside
128 * them because the inversion form does not describe it as a CLOSED set, and an
129 * optimizer will find the gap. Reproduction, for M1 = 1 and the target
130 * (M2, M3) = (1.6, 2.0): the inversion constraints alone are minimized at
131 * (M2a, M3a) = (1.99794, 1.99983) with objective 0.398, well below the 1.4735
132 * of any point of the true region, by driving the branching probability to
133 * p1 = -2.5e-9 while the second phase mean diverges (l2 = 645). Every
134 * inversion constraint is then satisfied to 2.5e-9, which fmincon's default
135 * ConstraintTolerance of 1e-6 would accept outright, yet the returned moment
136 * pair admits no APH(2) at all: at p1 = 0 exactly the distribution is
137 * exponential and M3 must be 6 M1^3, not 2. The bounds below close that gap.
138 */
139template <class T>
140std::vector<T> aph2_moment_bounds_rows(const T& M1, const T& xM2, const T& xM3) {
141 const T zero = num_traits<T>::from_int(0);
142 const T one = num_traits<T>::from_int(1);
143 const T two = num_traits<T>::from_int(2);
144 const T three = num_traits<T>::from_int(3);
145 const T six = num_traits<T>::from_int(6);
146 const T half = num_traits<T>::from_rational(1, 2);
147
148 const T M1sq = M1 * M1;
149 const T scv = (xM2 - M1sq) / M1sq;
150 std::vector<T> c(3, zero);
151 c[0] = half - scv;
152 if (scv <= one) {
153 const T d = one - scv;
154 const T lb = three * pw(M1, 3) * (three * scv - one + num_sqrt(two) * d * num_sqrt(d));
155 const T ub = six * pw(M1, 3) * scv;
156 c[1] = lb - xM3;
157 c[2] = xM3 - ub;
158 } else {
159 const T lb = three / two * pw(M1, 3) * (one + scv) * (one + scv);
160 c[1] = lb - xM3;
161 c[2] = zero;
162 }
163 return c;
164}
165
166/**
167 * The APH(2) inversion of aph2_adjust's nested aph2_fit1 / aph2_fit2:
168 * given (M1, M2, M3), the phase means l1, l2 and the continuation probability
169 * p1. `swapped` selects fit2, which exchanges the two roots.
170 *
171 * Returns the discriminant tmp0 as well, because the constraint set needs it.
172 * The square root is taken of max(tmp0, 0): during the search the optimizer
173 * does visit points with a negative discriminant, where the inversion is not
174 * real; there the constraint -tmp0 <= 0 is what carries the information, and
175 * clamping keeps l1, l2, p1 finite so the other three constraints stay
176 * evaluable. The denominator 6 M2 - 12 M1^2 vanishes at the exponential point
177 * M2 = 2 M1^2; its magnitude is clamped to degen so that the constraint
178 * functions remain bounded there, which no reference does because no
179 * reference evaluates them at all (see the header note).
180 */
181template <class T>
182struct Aph2Inversion {
183 T tmp0;
184 T l1;
185 T l2;
186 T p1;
187};
188
189template <class T>
190Aph2Inversion<T> aph2_invert(const T& M1, const T& xM2, const T& xM3, bool swapped,
191 const T& degen) {
192 const T zero = num_traits<T>::from_int(0);
193 const T two = num_traits<T>::from_int(2);
194 const T three = num_traits<T>::from_int(3);
195 const T six = num_traits<T>::from_int(6);
196 const T eight = num_traits<T>::from_int(8);
197 const T nine = num_traits<T>::from_int(9);
198 const T twelve = num_traits<T>::from_int(12);
199
200 Aph2Inversion<T> r;
201 r.tmp0 = eight * pw(M1, 3) * xM3 / three - three * M1 * M1 * xM2 * xM2 -
202 two * M1 * xM2 * xM3 + two * pw(xM2, 3) + xM3 * xM3 / nine;
203 const T root = r.tmp0 > zero ? num_sqrt(r.tmp0) : zero;
204 const T tmp1 = three * root;
205 const T tmp2 = xM3 - three * M1 * xM2;
206 T tmp3 = six * xM2 - twelve * M1 * M1;
207 if (num_abs(tmp3) < degen) tmp3 = tmp3 < zero ? T(-degen) : degen;
208 if (!swapped) {
209 r.l1 = (tmp2 + tmp1) / tmp3;
210 r.l2 = (tmp2 - tmp1) / tmp3;
211 } else {
212 r.l1 = (tmp2 - tmp1) / tmp3;
213 r.l2 = (tmp2 + tmp1) / tmp3;
214 }
215 if (r.l2 == zero)
216 r.p1 = zero;
217 else
218 r.p1 = (M1 - r.l1) / r.l2;
219 return r;
220}
221
222} // namespace fitdetail
223
224/**
225 * aph2_adjust, method 'opt_param': adjust (M2, M3) by searching the APH(2)
226 * parameter space with M1 matched exactly.
227 *
228 * @param M1,M2,M3 the requested moments
229 * @param feastol the strict-inequality slack of the reference (MATLAB 1e-6),
230 * used as the lower bound on l2 and in the constraint
231 * l2 r1 <= M1 - feastol that keeps l1 positive
232 * @param degentol the slack that keeps r1 away from 0 and 1 (MATLAB 1e-8);
233 * set it to zero to allow the degenerate exponential
234 */
235template <class T>
236Aph2AdjustOptResult<T> aph2_adjust_opt_param(const T& M1, const T& M2, const T& M3,
237 const T& feastol, const T& degentol) {
239 "aph2_adjust_opt_param requires transcendental arithmetic");
240 using std::sqrt;
241 const T zero = num_traits<T>::from_int(0);
242 const T one = num_traits<T>::from_int(1);
243 const T two = num_traits<T>::from_int(2);
244 const T six = num_traits<T>::from_int(6);
245 const T half = num_traits<T>::from_rational(1, 2);
246 if (M1 <= zero) throw InputError("aph2_adjust_opt_param: non-positive first moment");
247
248 // moments of the APH(2) with phase means l1 = M1 - l2 r1 and l2, and
249 // continuation probability r1 (aph2_adjust's fun2)
250 auto moments = [M1, two, six](const std::vector<T>& x, T& xM2, T& xM3) {
251 const T l2 = x[0];
252 const T r1 = x[1];
253 const T l1 = M1 - l2 * r1;
254 xM2 = two * l1 * l1 + two * r1 * l1 * l2 + two * r1 * l2 * l2;
255 xM3 = six * fitdetail::pw(l1, 3) + six * r1 * l1 * l1 * l2 + six * r1 * l1 * l2 * l2 +
256 six * r1 * fitdetail::pw(l2, 3);
257 };
258
259 auto resid = [moments, M2, M3](const std::vector<T>& x) {
260 T xM2 = num_traits<T>::from_int(0);
261 T xM3 = num_traits<T>::from_int(0);
262 moments(x, xM2, xM3);
263 std::vector<T> r(2);
264 r[0] = M2 - xM2;
265 r[1] = M3 - xM3;
266 return r;
267 };
268
269 // box bounds and the reference's nonlcon3, all as inequality rows
270 auto g = [feastol, degentol, one, M1](const std::vector<T>& x) {
271 std::vector<T> gv(4);
272 gv[0] = feastol - x[0]; // l2 >= feastol
273 gv[1] = degentol - x[1]; // r1 >= degentol
274 gv[2] = x[1] - (one - degentol); // r1 <= 1 - degentol
275 gv[3] = x[0] * x[1] - M1 + feastol; // l2 r1 <= M1 - feastol
276 return gv;
277 };
278
279 std::vector<T> x0(2);
280 x0[0] = M1; // the reference's x0 = [M1, 1/2], which fits M1 exactly
281 x0[1] = half;
282
284 opt.ctol = num_traits<T>::from_double(1e-12);
285 const AugLagResult<T> sol = auglag_ls(resid, std::size_t(2), NoConstraints<T>(), g, x0, opt);
286
288 moments(sol.x, r.M2a, r.M3a);
289 const T d2 = M2 - r.M2a;
290 const T d3 = M3 - r.M3a;
291 r.objective = sqrt(T(d2 * d2 + d3 * d3));
292 r.violation = sol.violation;
293 r.converged = sol.violation <= opt.ctol;
294 return r;
295}
296
297/** aph2_adjust_opt_param with the MATLAB defaults feastol = 1e-6, degentol = 1e-8. */
298template <class T>
299Aph2AdjustOptResult<T> aph2_adjust_opt_param(const T& M1, const T& M2, const T& M3) {
300 return aph2_adjust_opt_param(M1, M2, M3, T(num_traits<T>::from_double(1e-6)),
302}
303
304/**
305 * aph2_adjust, method 'opt_char': adjust (M2, M3) by searching the moment
306 * space subject to APH(2) invertibility.
307 *
308 * Two constrained problems are solved, one per branch of the inversion, and
309 * the one with the smaller adjustment is returned, exactly as the reference
310 * intends.
311 *
312 * @param M1,M2,M3 the requested moments
313 * @param postol the strict-positivity slack applied to the phase means
314 * (MATLAB 1e-6)
315 * @param degen magnitude below which the inversion denominator
316 * 6 M2 - 12 M1^2 is clamped; see the note on aph2_invert
317 */
318template <class T>
319Aph2AdjustOptResult<T> aph2_adjust_opt_char(const T& M1, const T& M2, const T& M3, const T& postol,
320 const T& degen) {
322 "aph2_adjust_opt_char requires transcendental arithmetic");
323 using std::sqrt;
324 const T zero = num_traits<T>::from_int(0);
325 if (M1 <= zero) throw InputError("aph2_adjust_opt_char: non-positive first moment");
326
327 auto resid = [M2, M3](const std::vector<T>& x) {
328 std::vector<T> r(2);
329 r[0] = x[0] - M2;
330 r[1] = x[1] - M3;
331 return r;
332 };
333
335 bool have_best = false;
336
337 for (int branch = 0; branch < 2; ++branch) {
338 const bool swapped = branch == 1;
339 auto g = [M1, swapped, postol, degen](const std::vector<T>& x) {
340 const fitdetail::Aph2Inversion<T> inv =
341 fitdetail::aph2_invert(M1, x[0], x[1], swapped, degen);
342 std::vector<T> gv(6);
343 gv[0] = -inv.tmp0; // non-negative discriminant
344 gv[1] = -inv.l1 + postol; // positive first phase mean
345 gv[2] = -inv.l2 + postol; // positive second phase mean
346 gv[3] = -inv.p1; // non-negative branching probability
347 gv[4] = -x[0]; // M2 >= 0, the reference's lb
348 gv[5] = -x[1]; // M3 >= 0, the reference's lb
349 const std::vector<T> th = fitdetail::aph2_moment_bounds_rows(M1, x[0], x[1]);
350 for (std::size_t k = 0; k < th.size(); ++k) gv.push_back(th[k]);
351 return gv;
352 };
353
354 std::vector<T> x0(2);
355 x0[0] = M2;
356 x0[1] = M3;
358 opt.ctol = num_traits<T>::from_double(1e-12);
359 const AugLagResult<T> sol =
360 auglag_ls(resid, std::size_t(2), NoConstraints<T>(), g, x0, opt);
361
363 cand.M2a = sol.x[0];
364 cand.M3a = sol.x[1];
365 const T d2 = cand.M2a - M2;
366 const T d3 = cand.M3a - M3;
367 cand.objective = sqrt(T(d2 * d2 + d3 * d3));
368 cand.violation = sol.violation;
369 cand.converged = sol.violation <= opt.ctol;
370
371 // a branch that could not be made feasible is not a candidate at all
372 if (!cand.converged && have_best) continue;
373 if (!have_best || (cand.converged && !best.converged) ||
374 (cand.converged == best.converged && cand.objective < best.objective)) {
375 best = cand;
376 have_best = true;
377 }
378 }
379 return best;
380}
381
382/** aph2_adjust_opt_char with the MATLAB default postol = 1e-6 and degen = 1e-10. */
383template <class T>
384Aph2AdjustOptResult<T> aph2_adjust_opt_char(const T& M1, const T& M2, const T& M3) {
385 return aph2_adjust_opt_char(M1, M2, M3, T(num_traits<T>::from_double(1e-6)),
387}
388
389} // namespace mam
390} // namespace line
391
392#endif // LINE_API_MAM_APH2_ADJUST_OPT_H
Augmented Lagrangian method for equality- and inequality-constrained minimization,...
InputError(const std::string &what)
Definition error.h:39
The exception types the port throws.
Scalar helpers shared by the MAP/PH moment-matching headers.
Aph2AdjustOptResult< T > aph2_adjust_opt_param(const T &M1, const T &M2, const T &M3, const T &feastol, const T &degentol)
aph2_adjust, method 'opt_param': adjust (M2, M3) by searching the APH(2) parameter space with M1 matc...
Aph2AdjustOptResult< T > aph2_adjust_opt_char(const T &M1, const T &M2, const T &M3, const T &postol, const T &degen)
aph2_adjust, method 'opt_char': adjust (M2, M3) by searching the moment space subject to APH(2) inver...
T num_abs(const T &v)
Definition number.h:172
AugLagOptions< T > auglag_defaults()
Defaults: rho0 = 10, growth 10, feasibility 1e-10, 50 outer iterations.
Definition auglag.h:81
AugLagResult< T > auglag_ls(R r, std::size_t m, H h, G g, const std::vector< T > &x0, const AugLagOptions< T > &opt)
Augmented Lagrangian with a least-squares objective and levmar as the inner solver.
Definition auglag.h:246
Number-type abstraction for the templated API port.
Tuning of the outer multiplier iteration.
Definition auglag.h:68
Outcome of a constrained solve.
Definition auglag.h:96
std::vector< T > x
best point found
Definition auglag.h:97
T violation
max(|h_i|, max(0, g_j)) at x
Definition auglag.h:99
A constraint map that returns no constraints; the default for h or g.
Definition auglag.h:108
Result of an optimization-based APH(2) moment adjustment.
T M2a
adjusted second moment
T violation
worst constraint violation at the returned point
bool converged
the constrained solve reached feasibility within its caps
T objective
||(M2a, M3a) - (M2, M3)||, the reference's objective
T M3a
adjusted third moment