LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
pfqn_nre.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_PFQN_PFQN_NRE_H
6#define LINE_API_PFQN_PFQN_NRE_H
7
8/**
9 * @file
10 * @ingroup api_pfqn
11 * Norlund-Rice inversion of the normalizing constant on a SADDLE-TILTED
12 * contour, with a second-order Edgeworth correction.
13 *
14 * Templated port of matlab/src/api/pfqn/pfqn_nre.m. Two corrections separate
15 * it from pfqn_nrl and pfqn_nrp, which Laplace-approximate the same integral
16 * on the untilted contour X = 1:
17 *
18 * 1. The integrand is invariant under t -> t + c*1, since h is homogeneous
19 * of degree sum(N) in the class variables and that degree cancels
20 * against exp(-i N t). The redundant direction is quotiented out, so the
21 * integral is (R-1)-dimensional; pfqn_nrl and pfqn_nrp integrate over R
22 * dimensions and let the substitution Jacobian supply curvature along
23 * the null direction, which is an artifact of the change of variables.
24 * 2. The contour radii are tilted per class to the saddle point, the X
25 * solving X_r dlog(h)/dX_r = N_r, so the origin is a stationary point of
26 * the phase. On X = 1 it is not, which is the leading bias of nrl / nrp.
27 *
28 * NO COMPLEX ARITHMETIC. Every integrand evaluation sits at real positive
29 * demands, so unlike pfqn_nrl this routine only needs pfqn_lldsingle. It is
30 * still gated on num_traits<T>::has_transcendental: the cumulant generating
31 * function, the Gaussian curvature term and the tilt all take logs and exps.
32 *
33 * OVERFLOW CEILING. The cumulant generating function is log(G) of the tilted
34 * single-class model, and this port's pfqn_lldsingle accumulates G itself
35 * rather than its logarithm (the MATLAB reference switches to the log domain).
36 * A double instantiation therefore loses the saddle search once log G passes
37 * ~709; a Real<D> instantiation does not.
38 *
39 * WARNINGS. This layer has no warning channel, so the two conditions the
40 * reference warns about -- a saddle search that runs out of iterations and a
41 * non-positive Edgeworth correction -- take the same fallback silently: the
42 * current estimate, and the bare saddlepoint term respectively.
43 *
44 * COST is O(I R^2 + R^4) evaluations of a single-class LLD constant, hence
45 * polynomial in the class count. The fourth-cumulant tensor caps the port at
46 * 8 classes, as in the reference.
47 */
48
49#include <cmath>
50#include <cstddef>
51#include <map>
52#include <vector>
53
57#include "line/num/number.h"
58#include "line/util/error.h"
59#include "line/util/linalg.h"
60#include "line/util/matrix.h"
61
62namespace line {
63namespace pfqn {
64
65namespace detail {
66
67/** Finite-difference step; the reference is flat over [5e-3,5e-2]. */
68template <class T>
69inline T nre_hstep() {
70 return num_traits<T>::from_rational(2, 100);
71}
72
73/** Beyond 8 classes the fourth-cumulant tensor is no longer affordable. */
74inline constexpr std::size_t nre_max_dim() { return 7; }
75
76/**
77 * Cumulant generating function of the tilted single-class model, memoised on
78 * the finite-difference stencil around the current expansion point.
79 */
80template <class T>
81class NreCgf {
82public:
83 NreCgf(const Matrix<T>& L, int Nt, const Matrix<T>& alpha, std::size_t d)
84 : L_(L), Nt_(Nt), alpha_(alpha), d_(d), vbase_(d, num_traits<T>::from_int(0)) {}
85
86 /** Move the expansion point, which invalidates every memoised value. */
87 void reset(const std::vector<T>& v) {
88 vbase_ = v;
89 cache_.clear();
90 }
91
92 /** Value at vbase + off*hstep. */
93 T at(const std::vector<int>& off) {
94 const typename std::map<std::vector<int>, T>::const_iterator it = cache_.find(off);
95 if (it != cache_.end()) return it->second;
96 const T h = nre_hstep<T>();
97 std::vector<T> v(d_);
98 for (std::size_t r = 0; r < d_; ++r)
99 v[r] = vbase_[r] + num_traits<T>::from_int(off[r]) * h;
100 const T y = at_point(v);
101 cache_.insert(std::make_pair(off, y));
102 return y;
103 }
104
105 /** log of the single-class LLD constant at the class tilt X = [exp(v),1]. */
106 T at_point(const std::vector<T>& v) const {
107 using std::exp;
108 using std::log;
109 const std::size_t M = L_.rows(), R = L_.cols();
110 Matrix<T> Lx(M, 1, num_traits<T>::from_int(0));
111 for (std::size_t i = 0; i < M; ++i) {
112 T acc = num_traits<T>::from_int(0);
113 for (std::size_t r = 0; r < R; ++r)
114 acc += L_(i, r) * (r < d_ ? exp(v[r]) : num_traits<T>::from_int(1));
115 Lx(i, 0) = acc;
116 }
117 const NcResult<T> res = pfqn_lldsingle(Lx, Nt_, alpha_);
118 return log(res.G);
119 }
120
121private:
122 const Matrix<T>& L_;
123 int Nt_;
124 const Matrix<T>& alpha_;
125 std::size_t d_;
126 std::vector<T> vbase_;
127 std::map<std::vector<int>, T> cache_;
128};
129
130/** Offset vector with sgn at coordinate a. */
131inline std::vector<int> nre_unitoff(std::size_t d, std::size_t a, int sgn) {
132 std::vector<int> off(d, 0);
133 off[a] = sgn;
134 return off;
135}
136
137/** Mixed second difference of the cumulant generating function. */
138template <class T>
139T nre_second_diff(NreCgf<T>& cgf, std::size_t d, std::size_t a, std::size_t b) {
140 std::vector<int> pp(d, 0), pm(d, 0), mp(d, 0), mm(d, 0);
141 pp[a] += 1;
142 pp[b] += 1;
143 pm[a] += 1;
144 pm[b] -= 1;
145 mp[a] -= 1;
146 mp[b] += 1;
147 mm[a] -= 1;
148 mm[b] -= 1;
149 const T h = nre_hstep<T>();
150 return (cgf.at(pp) - cgf.at(pm) - cgf.at(mp) + cgf.at(mm)) /
151 (num_traits<T>::from_int(4) * h * h);
152}
153
154/**
155 * Cholesky factor of a symmetric matrix, or `false` if it is not positive
156 * definite. Stands in for the reference's min(eig(Sigma)) <= 0 test: a
157 * symmetric matrix admits a Cholesky factorization exactly when its smallest
158 * eigenvalue is positive, and the factor also gives log det as 2 sum log l_ii
159 * without forming the determinant.
160 */
161template <class T>
162bool nre_chol(const Matrix<T>& A, Matrix<T>& Lo) {
163 using std::sqrt;
164 const std::size_t n = A.rows();
165 const T zero = num_traits<T>::from_int(0);
166 Lo = Matrix<T>(n, n, zero);
167 for (std::size_t i = 0; i < n; ++i) {
168 for (std::size_t j = 0; j <= i; ++j) {
169 T acc = A(i, j);
170 for (std::size_t k = 0; k < j; ++k) acc -= Lo(i, k) * Lo(j, k);
171 if (i == j) {
172 if (!(acc > zero)) return false;
173 Lo(i, i) = sqrt(acc);
174 } else {
175 Lo(i, j) = acc / Lo(j, j);
176 }
177 }
178 }
179 return true;
180}
181
182} // namespace detail
183
184/**
185 * The reference's `[lG,G,lGs,vsad]`.
186 *
187 * `lG - lGs` is the Edgeworth correction, so a caller wanting the plain
188 * saddlepoint estimate reads `lGs` rather than re-deriving it. `vsad` is empty
189 * on the shortcut arms that never solve a saddle point, matching the
190 * reference's empty `[]`.
191 */
192template <class T>
194 T lG; ///< log G, Edgeworth correction included
195 T lGs; ///< log of the saddlepoint term alone
196 std::vector<T> vsad; ///< the tilt actually used, empty when none was solved
197};
198
199/**
200 * Saddle-tilted Edgeworth approximation of log G for a limited load-dependent
201 * model: the full form of the reference's outputs, named alike in the JAR and
202 * the native python port.
203 *
204 * @param L0 (M x R) demands
205 * @param N (R) population
206 * @param Z (R) think times, empty for zero
207 * @param alpha0 (M x Ntot) load-dependent rates, empty for all ones
208 * @param vfix tilt to use instead of solving the saddle-point equation, empty
209 * for the standard estimator. Supplying the tilt obtained at a
210 * nearby population makes numerator and denominator of a ratio
211 * share one expansion point, the Tierney-Kadane arrangement.
212 */
213template <class T>
214PfqnNreResult<T> pfqn_nre_full(const Matrix<T>& L0, const std::vector<T>& N,
215 const std::vector<T>& Z, const Matrix<T>& alpha0,
216 const std::vector<T>& vfix) {
218 "pfqn_nre requires transcendental arithmetic (a saddlepoint expansion of a "
219 "coefficient-extraction integral)");
220 using std::log;
221 using std::sqrt;
222 const std::size_t M0 = L0.rows(), R = L0.cols();
223 if (N.size() != R) throw InputError("pfqn_nre: L and N disagree on the class count");
224 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
225
226 T Ntsum = zero, Zsum = zero;
227 for (std::size_t r = 0; r < R; ++r) Ntsum += N[r];
228 for (std::size_t r = 0; r < Z.size(); ++r) Zsum += Z[r];
229 if (Ntsum < zero) throw InputError("pfqn_nre: negative population");
230 if (Ntsum == zero) return PfqnNreResult<T>{zero, zero, std::vector<T>()};
231 const int Nt = static_cast<int>(num_traits<T>::to_double(Ntsum) + 0.5);
232 const std::size_t Ntot = static_cast<std::size_t>(Nt);
233
234 // Append the delay as an infinite-server station, and trim the rate matrix
235 // so that every rate used downstream is positive.
236 const std::size_t M = M0 + (Zsum > zero ? 1 : 0);
237 Matrix<T> L(M, R, zero);
238 for (std::size_t i = 0; i < M0; ++i)
239 for (std::size_t r = 0; r < R; ++r) L(i, r) = L0(i, r);
240 Matrix<T> alpha(M, Ntot, one);
241 for (std::size_t i = 0; i < M0 && i < alpha0.rows(); ++i)
242 for (std::size_t k = 0; k < Ntot; ++k)
243 alpha(i, k) = k < alpha0.cols() ? alpha0(i, k) : one;
244 if (Zsum > zero) {
245 if (Z.size() != R) throw InputError("pfqn_nre: Z has the wrong length");
246 for (std::size_t r = 0; r < R; ++r) L(M0, r) = Z[r];
247 for (std::size_t k = 0; k < Ntot; ++k)
248 alpha(M0, k) = num_traits<T>::from_int(static_cast<long>(k) + 1);
249 }
250
251 if (M == 1) {
252 std::vector<int> Ni(R, 0);
253 for (std::size_t r = 0; r < R; ++r)
254 Ni[r] = static_cast<int>(num_traits<T>::to_double(N[r]) + 0.5);
255 const T lGone = num_traits<T>::from_double(pfqn_gld(L, Ni, alpha).lG);
256 return PfqnNreResult<T>{lGone, lGone, std::vector<T>()};
257 }
258
259 // Scale demands into [0,1] per class; the residual factor is exact by
260 // homogeneity of the integrand.
261 T lGscale = zero;
262 for (std::size_t r = 0; r < R; ++r) {
263 T m = zero;
264 for (std::size_t i = 0; i < M; ++i)
265 if (L(i, r) > m) m = L(i, r);
266 if (!(m > zero)) m = one;
267 for (std::size_t i = 0; i < M; ++i) L(i, r) = L(i, r) / m;
268 lGscale += N[r] * log(m);
269 }
270
271 if (R == 1) {
272 // coefficient extraction is the identity in a single class
273 const T lGone = num_traits<T>::from_double(pfqn_lldsingle(L, Nt, alpha).lG) + lGscale;
274 return PfqnNreResult<T>{lGone, lGone, std::vector<T>()};
275 }
276
277 const std::size_t d = R - 1; // dimension of the quotient torus
278 if (d > detail::nre_max_dim())
279 throw UnsupportedError(
280 "pfqn_nre: pfqn_nre is limited to 8 classes, use nrl or clw beyond that");
281
282 std::vector<T> Nd(d);
283 for (std::size_t a = 0; a < d; ++a) Nd[a] = N[a];
284 detail::NreCgf<T> cgf(L, Nt, alpha, d);
285 const T h = detail::nre_hstep<T>();
286 const std::vector<int> origin(d, 0);
287
288 // ---- saddle point: minimise the convex F(v) = K(v) - Nd*v ----
289 // A tilt supplied by the caller is used as given, so that a ratio of two
290 // constants can be expanded about one common point rather than two.
291 std::vector<T> vbase(d, zero);
292 const T tol = num_traits<T>::from_double(1e-10);
293 bool converged = false;
294 const bool tilt_given = !vfix.empty();
295 if (tilt_given) {
296 if (vfix.size() < d)
297 throw InputError(
298 "pfqn_nre: the supplied tilt must have one entry per quotient dimension (R-1)");
299 for (std::size_t a = 0; a < d; ++a) vbase[a] = vfix[a];
300 converged = true;
301 }
302 for (int it = 0; !tilt_given && it < 100; ++it) {
303 cgf.reset(vbase);
304 std::vector<T> grad(d);
305 Matrix<T> hess(d, d, zero);
306 for (std::size_t a = 0; a < d; ++a)
307 grad[a] = (cgf.at(detail::nre_unitoff(d, a, 1)) - cgf.at(detail::nre_unitoff(d, a, -1))) /
308 (num_traits<T>::from_int(2) * h) -
309 Nd[a];
310 for (std::size_t a = 0; a < d; ++a)
311 for (std::size_t b = 0; b < d; ++b) hess(a, b) = detail::nre_second_diff(cgf, d, a, b);
312 std::vector<T> step = solve(hess, grad);
313 for (std::size_t a = 0; a < d; ++a) step[a] = -step[a];
314
315 T F0 = cgf.at(origin);
316 for (std::size_t a = 0; a < d; ++a) F0 -= Nd[a] * vbase[a];
317 T tau = one;
318 std::vector<T> vtry(d);
319 while (tau > tol) {
320 T obj = zero;
321 for (std::size_t a = 0; a < d; ++a) vtry[a] = vbase[a] + tau * step[a];
322 obj = cgf.at_point(vtry);
323 for (std::size_t a = 0; a < d; ++a) obj -= Nd[a] * vtry[a];
324 if (obj <= F0) break;
325 tau = tau / num_traits<T>::from_int(2);
326 }
327 T stepNorm = zero;
328 for (std::size_t a = 0; a < d; ++a) {
329 const T delta = tau * step[a];
330 vbase[a] += delta;
331 stepNorm += delta * delta;
332 }
333 // Newton converges to the root of the DIFFERENCED gradient, whose own
334 // O(hstep^2) bias puts any absolute gradient target out of reach.
335 if (sqrt(stepNorm) < tol) {
336 converged = true;
337 break;
338 }
339 }
340 // NO WARNING CHANNEL in this layer: the reference warns here and returns
341 // its current estimate anyway, so the estimate is what the port returns.
342 (void)converged;
343
344 // ---- cumulants of the tilted distribution at the saddle ----
345 cgf.reset(vbase);
346 const T K0 = cgf.at(origin);
347 Matrix<T> Sigma(d, d, zero);
348 for (std::size_t a = 0; a < d; ++a)
349 for (std::size_t b = 0; b < d; ++b) Sigma(a, b) = detail::nre_second_diff(cgf, d, a, b);
350 for (std::size_t a = 0; a < d; ++a)
351 for (std::size_t b = a + 1; b < d; ++b) {
352 const T sym = (Sigma(a, b) + Sigma(b, a)) / num_traits<T>::from_int(2);
353 Sigma(a, b) = sym;
354 Sigma(b, a) = sym;
355 }
356 Matrix<T> chol;
357 if (!detail::nre_chol(Sigma, chol))
358 throw NumericError(
359 "pfqn_nre: the tilted covariance is singular, a class has no demand at any station");
360 T logdet = zero;
361 for (std::size_t a = 0; a < d; ++a) logdet += num_traits<T>::from_int(2) * log(chol(a, a));
362
363 std::vector<T> k3(d * d * d, zero);
364 for (std::size_t a = 0; a < d; ++a)
365 for (std::size_t b = 0; b < d; ++b)
366 for (std::size_t c = 0; c < d; ++c) {
367 T acc = zero;
368 for (int s = 0; s < 8; ++s) {
369 const int s1 = 1 - 2 * ((s >> 0) & 1);
370 const int s2 = 1 - 2 * ((s >> 1) & 1);
371 const int s3 = 1 - 2 * ((s >> 2) & 1);
372 std::vector<int> off(d, 0);
373 off[a] += s1;
374 off[b] += s2;
375 off[c] += s3;
376 acc += num_traits<T>::from_int(s1 * s2 * s3) * cgf.at(off);
377 }
378 k3[(a * d + b) * d + c] = acc / (num_traits<T>::from_int(8) * h * h * h);
379 }
380
381 std::vector<T> k4(d * d * d * d, zero);
382 for (std::size_t a = 0; a < d; ++a)
383 for (std::size_t b = 0; b < d; ++b)
384 for (std::size_t c = 0; c < d; ++c)
385 for (std::size_t e = 0; e < d; ++e) {
386 T acc = zero;
387 for (int s = 0; s < 16; ++s) {
388 const int s1 = 1 - 2 * ((s >> 0) & 1);
389 const int s2 = 1 - 2 * ((s >> 1) & 1);
390 const int s3 = 1 - 2 * ((s >> 2) & 1);
391 const int s4 = 1 - 2 * ((s >> 3) & 1);
392 std::vector<int> off(d, 0);
393 off[a] += s1;
394 off[b] += s2;
395 off[c] += s3;
396 off[e] += s4;
397 acc += num_traits<T>::from_int(s1 * s2 * s3 * s4) * cgf.at(off);
398 }
399 k4[((a * d + b) * d + c) * d + e] =
400 acc / (num_traits<T>::from_int(16) * h * h * h * h);
401 }
402
403 // ---- second-order Edgeworth factor, see the header ----
404 const Matrix<T> S = inverse(Sigma);
405 T rho4 = zero;
406 for (std::size_t a = 0; a < d; ++a)
407 for (std::size_t b = 0; b < d; ++b)
408 for (std::size_t c = 0; c < d; ++c)
409 for (std::size_t e = 0; e < d; ++e)
410 rho4 += k4[((a * d + b) * d + c) * d + e] * S(a, b) * S(c, e);
411 std::vector<T> u(d, zero);
412 for (std::size_t c = 0; c < d; ++c)
413 for (std::size_t a = 0; a < d; ++a)
414 for (std::size_t b = 0; b < d; ++b) u[c] += S(a, b) * k3[(a * d + b) * d + c];
415 T rhoA = zero;
416 for (std::size_t c = 0; c < d; ++c)
417 for (std::size_t e = 0; e < d; ++e) rhoA += u[c] * S(c, e) * u[e];
418 // staged contraction of k3 against three copies of Sigma^{-1}
419 std::vector<T> T1(d * d * d, zero), T2(d * d * d, zero);
420 for (std::size_t i = 0; i < d; ++i)
421 for (std::size_t b = 0; b < d; ++b)
422 for (std::size_t c = 0; c < d; ++c) {
423 T acc = zero;
424 for (std::size_t a = 0; a < d; ++a) acc += S(i, a) * k3[(a * d + b) * d + c];
425 T1[(i * d + b) * d + c] = acc;
426 }
427 for (std::size_t i = 0; i < d; ++i)
428 for (std::size_t j = 0; j < d; ++j)
429 for (std::size_t c = 0; c < d; ++c) {
430 T acc = zero;
431 for (std::size_t b = 0; b < d; ++b) acc += S(j, b) * T1[(i * d + b) * d + c];
432 T2[(i * d + j) * d + c] = acc;
433 }
434 T rhoB = zero;
435 for (std::size_t i = 0; i < d; ++i)
436 for (std::size_t j = 0; j < d; ++j)
437 for (std::size_t k = 0; k < d; ++k) {
438 T acc = zero;
439 for (std::size_t c = 0; c < d; ++c) acc += S(k, c) * T2[(i * d + j) * d + c];
440 rhoB += k3[(i * d + j) * d + k] * acc;
441 }
442 T corr = one + rho4 / num_traits<T>::from_int(8) -
443 (num_traits<T>::from_int(3) * rhoA + num_traits<T>::from_int(2) * rhoB) /
445 // Same as above: a non-positive correction falls back on the bare
446 // saddlepoint term, which is the reference's behaviour after its warning.
447 if (!(corr > zero)) corr = one;
448
449 T lGs = K0 - num_traits<T>::from_double(0.5 * static_cast<double>(d) *
450 std::log(2.0 * 3.14159265358979323846)) -
451 logdet / num_traits<T>::from_int(2) + lGscale;
452 for (std::size_t a = 0; a < d; ++a) lGs -= Nd[a] * vbase[a];
453 return PfqnNreResult<T>{lGs + log(corr), lGs, vbase};
454}
455
456/**
457 * Saddle-tilted Edgeworth approximation of log G for a limited load-dependent
458 * model.
459 *
460 * @param L0 (M x R) demands
461 * @param N (R) population
462 * @param Z (R) think times, empty for zero
463 * @param alpha0 (M x Ntot) load-dependent rates, empty for all ones
464 */
465template <class T>
466T pfqn_nre(const Matrix<T>& L0, const std::vector<T>& N, const std::vector<T>& Z,
467 const Matrix<T>& alpha0) {
468 return pfqn_nre_full(L0, N, Z, alpha0, std::vector<T>()).lG;
469}
470
471} // namespace pfqn
472} // namespace line
473
474#endif // LINE_API_PFQN_PFQN_NRE_H
InputError(const std::string &what)
Definition error.h:39
std::size_t cols() const
Definition matrix.h:90
std::size_t rows() const
Definition matrix.h:89
NumericError(const std::string &what)
Definition error.h:45
UnsupportedError(const std::string &what)
Definition error.h:51
The exception types the port throws.
Dense linear algebra over the templated number type: products, identity, inverse, and powers.
Dense matrix and non-owning view.
NcResult< T > pfqn_lldsingle(const Matrix< T > &L, int N, const Matrix< T > &mu)
Exact normalizing constant of a SINGLE-CLASS closed network whose stations are LIMITED load dependent...
T pfqn_nre(const Matrix< T > &L0, const std::vector< T > &N, const std::vector< T > &Z, const Matrix< T > &alpha0)
Saddle-tilted Edgeworth approximation of log G for a limited load-dependent model.
Definition pfqn_nre.h:466
PfqnNreResult< T > pfqn_nre_full(const Matrix< T > &L0, const std::vector< T > &N, const std::vector< T > &Z, const Matrix< T > &alpha0, const std::vector< T > &vfix)
Saddle-tilted Edgeworth approximation of log G for a limited load-dependent model: the full form of t...
Definition pfqn_nre.h:214
NcResult< T > pfqn_gld(const Matrix< T > &L, const std::vector< int > &N, const Matrix< T > &mu)
Exact normalizing constant of a closed product-form network whose stations may be load dependent (gen...
Definition pfqn_gld.h:150
Matrix< T > inverse(const Matrix< T > &A)
Inverse by LU with one factorization and n back substitutions.
Definition linalg.h:72
std::vector< T > solve(const Matrix< T > &A, const std::vector< T > &b)
Convenience: solve Ax = b, leaving A and b untouched.
Definition lu.h:158
Number-type abstraction for the templated API port.
Shared scalar machinery for the integration / asymptotic members of the pfqn family (pfqn_le,...
Exact normalizing constant of a closed product-form network whose stations may be load dependent (gen...
Exact normalizing constant of a SINGLE-CLASS closed network whose stations are LIMITED load dependent...
The reference's [lG,G,lGs,vsad].
Definition pfqn_nre.h:193
std::vector< T > vsad
the tilt actually used, empty when none was solved
Definition pfqn_nre.h:196
T lG
log G, Edgeworth correction included
Definition pfqn_nre.h:194
T lGs
log of the saddlepoint term alone
Definition pfqn_nre.h:195