LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
solver_ctmc_sens.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_SOLVERS_CTMC_SOLVER_CTMC_SENS_H
6#define LINE_SOLVERS_CTMC_SOLVER_CTMC_SENS_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * Port of `@@SolverCTMC/getSensitivity` and `getSensitivityRanking`: the
12 * parametric sensitivity of a steady-state reward to a scalar model parameter,
13 * following Trivedi and Bobbio (2017), Sec. 9.7.
14 *
15 * WHERE THE ERROR ACTUALLY COMES FROM, which is what the reference's two
16 * methods differ about. Differentiating pi Q = 0 gives
17 * (dpi/dtheta) Q = -pi (dQ/dtheta), one extra solve with the matrix the
18 * stationary solve already factored (`ctmc_sens`, which is EXACT). So the only
19 * approximation is dQ/dtheta, and the reference obtains it by central
20 * differences on the RATES with the state space held fixed -- legitimate
21 * because the state space depends on the topology and the cutoff, never on a
22 * rate value. That is O(h^2) on the generator alone, not on the solve.
23 *
24 * The reference's 'symbolic' method removes even that by solving pi as a
25 * rational function of the rate symbols in a computer-algebra backend and
26 * differentiating it exactly, leaving only the RATE MAP x_e(theta) differenced.
27 * That map is affine in theta in the common cases -- a rate set to theta, or
28 * scaled by it -- and a central difference is exact on an affine map, so the
29 * whole O(h^2) error of 'fd' disappears. It is served here through `api/sym`,
30 * the same line-sage-rest service the reference talks to.
31 *
32 * WHAT THE SYMBOLIC METHOD REFUSES RATHER THAN APPROXIMATES: a theta that
33 * RESHAPES an event's filtration instead of merely scaling it. The chain rule
34 * above assumes each event contributes one rate, so a parameter that changes
35 * the relative weights inside one filtration breaks the premise; the shape is
36 * compared before any round trip and the request is refused by name.
37 *
38 * ANY PERTURBATION THAT RESIZES THE STATE SPACE IS AN ERROR, not something to
39 * paper over: it means theta switched a transition on or off (a rate crossing
40 * zero, or an immediate transition appearing), so the two generators describe
41 * different chains and their difference is meaningless.
42 */
43
44#include <algorithm>
45#include <cmath>
46#include <cstddef>
47#include <functional>
48#include <string>
49#include <vector>
50
51#include <map>
52#include <memory>
53
59#include "line/util/error.h"
60#include "line/util/matrix.h"
61
62namespace line {
63namespace ctmc {
64
65/** The scalar parameter a sensitivity is taken with respect to. */
66template <class T>
68 std::string name;
69 double value = 0.0;
70 /** Apply theta to a COPY of the struct; the original is never mutated. */
71 std::function<void(NetworkStruct<T>&, double)> set;
72 /** Central-difference step; <= 0 takes the reference's max(|theta|,1)*1e-6. */
73 double step = 0.0;
74};
75
76/** What one sensitivity computation returns. */
77template <class T>
78struct CtmcSens {
79 T S = num_traits<T>::from_int(0); ///< d E[r] / d theta, Eq. (9.79)
80 T SS = num_traits<T>::from_int(0); ///< (theta/E[r]) d E[r] / d theta, Eq. (9.80)
81 bool scaled_valid = false; ///< false when E[r] is zero, MATLAB's NaN
82 std::vector<T> dpi, pi;
83};
84
85namespace sens_detail {
86
87/**
88 * Minimum positive rate of each event filtration, and the filtration normalized
89 * by it -- the reference's `eventRates`. An event with no positive rate is
90 * inactive and contributes neither.
91 */
92template <class T>
93struct EventRates {
94 std::vector<double> rate;
95 std::vector<Matrix<T> > shape;
96 std::vector<bool> active;
97};
98
99template <class T>
100EventRates<T> event_rates(const std::vector<Matrix<T> >& F) {
101 EventRates<T> out;
102 out.rate.assign(F.size(), 0.0);
103 out.shape.resize(F.size());
104 out.active.assign(F.size(), false);
105 for (std::size_t e = 0; e < F.size(); ++e) {
106 const symbolic_detail::MinPositive<T> m = symbolic_detail::min_positive(F[e]);
107 if (!m.has) continue;
108 out.rate[e] = num_traits<T>::to_double(m.value);
109 Matrix<T> S(F[e].rows(), F[e].cols(), num_traits<T>::from_int(0));
110 for (std::size_t i = 0; i < S.rows(); ++i)
111 for (std::size_t j = 0; j < S.cols(); ++j) S(i, j) = T(F[e](i, j) / m.value);
112 out.shape[e] = S;
113 out.active[e] = true;
114 }
115 return out;
116}
117
118/** True when two normalized filtrations agree entrywise to `tol`. */
119template <class T>
120bool same_shape(const Matrix<T>& a, const Matrix<T>& b, double tol) {
121 if (a.rows() != b.rows() || a.cols() != b.cols()) return false;
122 for (std::size_t i = 0; i < a.rows(); ++i)
123 for (std::size_t j = 0; j < a.cols(); ++j)
124 if (std::fabs(num_traits<T>::to_double(a(i, j)) - num_traits<T>::to_double(b(i, j))) >
125 tol)
126 return false;
127 return true;
128}
129
130/**
131 * The reference's `symbolicSensitivity`: exact d(pi)/d(x_e) from the backend,
132 * combined with a differenced rate map d(x_e)/d(theta) by the chain rule.
133 *
134 * TWO STEP SIZES ARE IN PLAY AND THEY ARE NOT INTERCHANGEABLE. The rate map is
135 * probed at the coarse `1e-3` step because it is expected to be affine, where a
136 * coarse step is exact and immune to cancellation. If the second difference says
137 * it is NOT affine -- the curvature test below -- the caller's fine step is used
138 * instead, trading that exactness for the smaller truncation error a curved map
139 * needs.
140 */
141template <class T>
142void symbolic_sensitivity(const NetworkStruct<T>& sn, const CtmcOptions& opt,
143 const CtmcSensParam<T>& param, double theta, double h,
144 const CtmcSymbolicOptions& symopt, std::vector<T>& pi_out,
145 std::vector<T>& dpi_out) {
146 const CtmcSymbolicGenerator<T> g = ctmc_symbolic_generator(sn, opt);
147 const std::size_t n = g.space.size();
148 const std::size_t ne = g.symbols.size();
149
150 std::vector<double> rate0(ne, 0.0);
151 for (std::size_t e = 0; e < ne; ++e) rate0[e] = num_traits<T>::to_double(g.rate0[e]);
152
153 const double hrate = std::max(std::fabs(theta), 1.0) * 1e-3;
154 NetworkStruct<T> up = sn, dn = sn;
155 param.set(up, theta + hrate);
156 param.set(dn, theta - hrate);
157 const CtmcGenerator<T> gu = ctmc_get_generator(up, opt);
158 const CtmcGenerator<T> gd = ctmc_get_generator(dn, opt);
159 if (gu.space.size() != n || gd.space.size() != n)
160 throw InputError(
161 "solver_ctmc_sensitivity: perturbing the parameter changed the state-space size, so "
162 "the generators cannot be differenced; this happens when theta switches a transition "
163 "on or off (a zero rate, or an immediate transition appearing)");
164 if (gu.filt.size() != ne || gd.filt.size() != ne)
165 throw InputError(
166 "solver_ctmc_sensitivity: perturbing the parameter changed the number of events");
167
168 EventRates<T> ru = event_rates(gu.filt), rd = event_rates(gd.filt);
169 for (std::size_t e = 0; e < ne; ++e) {
170 if (g.symbols[e].empty()) continue;
171 if (!ru.active[e] || !rd.active[e] || !same_shape(ru.shape[e], g.filt[e], 1e-8) ||
172 !same_shape(rd.shape[e], g.filt[e], 1e-8))
173 throw UnsupportedError(
174 "solver_ctmc_sensitivity: perturbing the parameter reshapes the filtration of "
175 "event " +
176 std::to_string(e + 1) +
177 " rather than scaling it, so the generator is not linear in a single rate per "
178 "event and the symbolic chain rule does not apply; use method 'fd'");
179 }
180
181 double curvature = 0.0, scale = 1.0;
182 for (std::size_t e = 0; e < ne; ++e) {
183 curvature = std::max(curvature, std::fabs(ru.rate[e] + rd.rate[e] - 2.0 * rate0[e]));
184 scale = std::max(scale, std::fabs(rate0[e]));
185 }
186 double step = hrate;
187 if (curvature > 1e-9 * scale) {
188 NetworkStruct<T> up2 = sn, dn2 = sn;
189 param.set(up2, theta + h);
190 param.set(dn2, theta - h);
191 ru = event_rates(ctmc_get_generator(up2, opt).filt);
192 rd = event_rates(ctmc_get_generator(dn2, opt).filt);
193 step = h;
194 }
195 std::vector<double> drate(ne, 0.0);
196 for (std::size_t e = 0; e < ne; ++e) drate[e] = (ru.rate[e] - rd.rate[e]) / (2.0 * step);
197
198 const std::shared_ptr<sym::SymEngine> engine =
199 symbolic_detail::require_engine(symopt, "the 'symbolic' sensitivity");
200 const std::vector<std::string> pi_expr = engine->solveCTMC(g.Q, g.active_symbols()).pi;
201 if (pi_expr.size() != n)
202 throw sym::SymEngineError("solver_ctmc_sensitivity: backend '" + engine->name() +
203 "' returned " + std::to_string(pi_expr.size()) +
204 " entries for a " + std::to_string(n) + " state chain");
205
206 std::map<std::string, double> assignment;
207 for (std::size_t e = 0; e < ne; ++e)
208 if (!g.symbols[e].empty()) assignment[g.symbols[e]] = rate0[e];
209
210 const std::vector<double> pi = engine->eval(pi_expr, assignment);
211 pi_out.assign(n, num_traits<T>::from_int(0));
212 for (std::size_t s = 0; s < n && s < pi.size(); ++s)
213 pi_out[s] = num_traits<T>::from_double(pi[s]);
214
215 dpi_out.assign(n, num_traits<T>::from_int(0));
216 for (std::size_t e = 0; e < ne; ++e) {
217 if (g.symbols[e].empty()) continue;
218 // An event that does not depend on theta contributes a zero term, and
219 // its exact derivative is not worth a round trip.
220 if (drate[e] == 0.0) continue;
221 const std::vector<double> dvals =
222 engine->eval(engine->diff(pi_expr, g.symbols[e], 1), assignment);
223 if (dvals.size() != n)
224 throw sym::SymEngineError("solver_ctmc_sensitivity: backend '" + engine->name() +
225 "' returned " + std::to_string(dvals.size()) +
226 " derivative values for a " + std::to_string(n) +
227 " state chain");
228 for (std::size_t s = 0; s < n; ++s)
229 dpi_out[s] += num_traits<T>::from_double(drate[e] * dvals[s]);
230 }
231}
232
233} // namespace sens_detail
234
235/**
236 * Port of `@@SolverCTMC/getSensitivity`.
237 *
238 * @param reward reward RATE per state; empty returns dpi and pi with S unset
239 * @param method `fd` (the default) or `symbolic`, which needs a backend
240 * @param sn the refreshed network struct
241 * @param opt CTMC options (state-space cutoff, tolerances, method)
242 * @param param the parameter theta being perturbed, and how to set it
243 * @param symopt backend selection, read only by `method='symbolic'`
244 */
245template <class T>
247 const CtmcSensParam<T>& param,
248 const std::vector<T>& reward = std::vector<T>(),
249 const std::string& method = "fd",
250 const CtmcSymbolicOptions& symopt = CtmcSymbolicOptions()) {
251 if (method != "fd" && method != "symbolic")
252 throw InputError("solver_ctmc_sensitivity: unknown method '" + method +
253 "'; expected 'fd' or 'symbolic'");
254 if (!param.set)
255 throw InputError("solver_ctmc_sensitivity: the parameter carries no setter, so theta "
256 "cannot be applied to the model");
257
258 const double theta = param.value;
259 const double h = param.step > 0 ? param.step : std::max(std::fabs(theta), 1.0) * 1e-6;
260
261 CtmcSens<T> out;
262 std::size_t n = 0;
263 if (method == "symbolic") {
264 sens_detail::symbolic_sensitivity(sn, opt, param, theta, h, symopt, out.pi, out.dpi);
265 n = out.pi.size();
266 } else {
268 n = base.chain.space.size();
269
270 NetworkStruct<T> up = sn, dn = sn;
271 param.set(up, theta + h);
272 param.set(dn, theta - h);
275 if (su.chain.space.size() != n || sd.chain.space.size() != n)
276 throw InputError(
277 "solver_ctmc_sensitivity: perturbing the parameter changed the state-space size, "
278 "so the generators cannot be differenced; this happens when theta switches a "
279 "transition on or off (a zero rate, or an immediate transition appearing)");
280
282 const T twoh = num_traits<T>::from_double(2.0 * h);
283 for (std::size_t a = 0; a < n; ++a)
284 for (std::size_t b = 0; b < n; ++b)
285 dQ(a, b) = T((su.chain.Q(a, b) - sd.chain.Q(a, b)) / twoh);
286
287 out.pi = base.pi;
288 out.dpi = mc::ctmc_sens(base.chain.Q, dQ, base.pi);
289 }
290 if (reward.empty()) return out;
291 if (reward.size() != n)
292 throw InputError("solver_ctmc_sensitivity: the reward must have one entry per state");
293
294 // Eq. (9.83) with dr/dtheta = 0: a reward that itself depends on theta needs
295 // the second term, which the reference does not carry either.
296 T er = num_traits<T>::from_int(0);
297 for (std::size_t s = 0; s < n; ++s) {
298 out.S += T(out.dpi[s] * reward[s]);
299 er += T(out.pi[s] * reward[s]);
300 }
301 if (std::fabs(num_traits<T>::to_double(er)) > GlobalConstants::Zero) {
302 out.SS = T(num_traits<T>::from_double(theta) / er * out.S);
303 out.scaled_valid = true;
304 }
305 return out;
306}
307
308/** One row of the ranking table. */
309template <class T>
311 std::string parameter;
312 double value = 0.0;
315 bool scaled_valid = false;
316};
317
318/**
319 * Port of `@@SolverCTMC/getSensitivityRanking`: rank parameters by influence.
320 *
321 * The order is by DESCENDING ABSOLUTE SCALED sensitivity, because the scaled
322 * form is dimensionless and so is the only one comparable across parameters
323 * measured in different units. The SIGN is retained in the table, since it says
324 * whether increasing a parameter helps or hurts.
325 */
326template <class T>
327std::vector<CtmcSensRank<T>> solver_ctmc_sensitivity_ranking(
328 const NetworkStruct<T>& sn, const CtmcOptions& opt,
329 const std::vector<CtmcSensParam<T>>& params, const std::vector<T>& reward) {
330 if (reward.empty())
331 throw InputError("solver_ctmc_sensitivity_ranking: a reward is required to rank "
332 "parameters");
333 std::vector<CtmcSensRank<T>> rows;
334 for (std::size_t l = 0; l < params.size(); ++l) {
335 const CtmcSens<T> r = solver_ctmc_sensitivity(sn, opt, params[l], reward);
336 CtmcSensRank<T> row;
337 row.parameter = params[l].name.empty() ? "theta" + std::to_string(l + 1) : params[l].name;
338 row.value = params[l].value;
339 row.S = r.S;
340 row.SS = r.SS;
341 row.scaled_valid = r.scaled_valid;
342 rows.push_back(row);
343 }
344 // A parameter whose scaled sensitivity is undefined -- E[r] is zero -- sorts
345 // LAST rather than being dropped: it was measured, and its unscaled value
346 // is still reported.
347 std::stable_sort(rows.begin(), rows.end(),
348 [](const CtmcSensRank<T>& a, const CtmcSensRank<T>& b) {
349 if (a.scaled_valid != b.scaled_valid) return a.scaled_valid;
350 if (!a.scaled_valid) return false;
351 return std::fabs(num_traits<T>::to_double(a.SS)) >
352 std::fabs(num_traits<T>::to_double(b.SS));
353 });
354 return rows;
355}
356
357} // namespace ctmc
358} // namespace line
359
360#endif // LINE_SOLVERS_CTMC_SOLVER_CTMC_SENS_H
InputError(const std::string &what)
Definition error.h:39
UnsupportedError(const std::string &what)
Definition error.h:51
A network plus its refreshed NetworkStruct.
Sensitivity of the steady-state distribution of a CTMC to a scalar parameter.
The exception types the port throws.
Dense matrix and non-owning view.
CtmcSens< T > solver_ctmc_sensitivity(const NetworkStruct< T > &sn, const CtmcOptions &opt, const CtmcSensParam< T > &param, const std::vector< T > &reward=std::vector< T >(), const std::string &method="fd", const CtmcSymbolicOptions &symopt=CtmcSymbolicOptions())
Port of @@SolverCTMC/getSensitivity.
CtmcGenerator< T > ctmc_get_generator(const NetworkStruct< T > &sn, const CtmcSolution< T > &d)
Port of @@SolverCTMC/getGenerator.m: the generator, its event filtration and the synchronization list...
std::vector< CtmcSensRank< T > > solver_ctmc_sensitivity_ranking(const NetworkStruct< T > &sn, const CtmcOptions &opt, const std::vector< CtmcSensParam< T > > &params, const std::vector< T > &reward)
Port of @@SolverCTMC/getSensitivityRanking: rank parameters by influence.
CtmcSolution< T > solver_ctmc_analyzer(const NetworkStruct< T > &sn_in, const CtmcOptions &opt)
Port of solver_ctmc_analyzer.m plus the fork-join wrapper of @@SolverCTMC/runAnalyzer....
CtmcSymbolicGenerator< T > ctmc_symbolic_generator(const NetworkStruct< T > &sn, const CtmcOptions &opt, bool invert_symbol=false)
Port of @@SolverCTMC/getSymbolicGenerator.m.
std::vector< T > ctmc_sens(const Matrix< T > &Q, const Matrix< T > &dQ, const std::vector< T > &pi)
Sensitivity of the steady-state distribution of a CTMC to a scalar parameter.
Definition ctmc_sens.h:52
A queueing network and its refreshed NetworkStruct.
Port of solver_ctmc_analyzer.m and the parts of @@SolverCTMC/runAnalyzer.m that surround one solve: t...
Port of @@SolverCTMC/getSymbolicGenerator and getSymbolicSolution.
The SolverCTMC knobs this port honours.
The scalar parameter a sensitivity is taken with respect to.
double step
Central-difference step; <= 0 takes the reference's max(|theta|,1)*1e-6.
std::function< void(NetworkStruct< T > &, double)> set
Apply theta to a COPY of the struct; the original is never mutated.
One row of the ranking table.
What one sensitivity computation returns.
T S
d E[r] / d theta, Eq. (9.79)
std::vector< T > pi
T SS
(theta/E[r]) d E[r] / d theta, Eq. (9.80)
bool scaled_valid
false when E[r] is zero, MATLAB's NaN
std::vector< T > dpi
Everything one CTMC solve produces.
std::vector< T > pi
stationary distribution over chain.space
Backend selection, mirroring options.config.symbolic and its timeout.
static constexpr double Zero
Definition lang_types.h:670
Computer algebra operations LINE needs, as seen by this port.