1function [S, SS, dpi, pi] = getSensitivity(self, param, reward, method)
2% [S, SS, DPI, PI] = GETSENSITIVITY(PARAM, REWARD, METHOD)
4% Parametric sensitivity of a steady-state reward to a scalar model
5% parameter, following Trivedi and Bobbio (2017), Sec. 9.7.
7% PARAM describes the parameter theta and how to set it on the model:
8% param.name identifier used in reports
9% param.value nominal value theta
10% param.set handle (model, value) -> void, applying theta to the model
11% param.step optional finite-difference step,
default value*1e-6
13% METHOD selects how the derivative
is taken:
15%
'fd' (
default) The generator derivative dQ/dtheta
is obtained by central
16% differences on the rate with the state space held fixed. This
is exact
17% to O(step^2) and
requires no symbolic differentiation of the rate
18% assembly; the state space
is unaffected because it depends on the
19% topology and the cutoff, not on rate values. The steady-state
20% sensitivity then follows from one linear solve, see ctmc_sens.
22%
'symbolic' The stationary distribution
is solved as a rational function
23% of the
event rate symbols x1..xE and differentiated exactly with
24% respect to each of them by the computer algebra backend (SAGE.m),
25% then combined by the chain rule
26% d(pi)/d(theta) = sum_e d(pi)/d(x_e) * d(x_e)/d(theta).
27% Only the rate
map x_e(theta)
is still differenced, and that
map is
28% affine in theta in the common cases (a rate set to theta, or scaled by
29% it), where the central difference reproduces it exactly. The whole
30% O(step^2) error of
'fd' comes from differencing through the solve,
31% which
this avoids entirely. Requires a symbolic backend, and refuses
32% rather than approximates when perturbing theta reshapes an
event's
33% filtration instead of merely scaling it.
35% REWARD is either a function handle mapping the state space to a reward rate
36% vector, or a numeric reward rate vector over the states. If omitted, dpi is
37% returned and S is empty.
39% Note: this returns d(E[r])/dtheta with dr/dtheta = 0, i.e. it assumes the
40% reward rates do not themselves depend on theta. Rewards that depend on
41% theta need the second term of Eq. (9.83) and are not handled here.
43% @param param Struct describing the parameter, see above
44% @param reward Reward rate vector or handle over the state space (optional)
45% @param method 'fd
' (default) or 'symbolic
' (optional)
46% @return S Unscaled sensitivity d(E[r])/dtheta, Eq. (9.79)
47% @return SS Scaled sensitivity (theta/E[r]) d(E[r])/dtheta, Eq. (9.80)
48% @return dpi Sensitivity of the steady-state distribution (1 x n)
49% @return pi Steady-state distribution (1 x n)
51% Copyright (c) 2012-2026, Imperial College London
54if ~isstruct(param) || ~isfield(param, 'set
') || ~isfield(param, 'value
')
55 line_error(mfilename, 'param must be a
struct with fields value and set');
60if nargin < 4 || isempty(method)
63if ~any(strcmpi(method, {
'fd',
'symbolic'}))
64 line_error(mfilename, sprintf('unknown method ''%s''; expected ''fd'' or ''symbolic''', method));
68if isfield(param, 'step') && ~isempty(param.step)
71 h = max(abs(theta), 1) * 1e-6;
74% Nominal generator and state space
75[Q, ~, ~] = self.getGenerator();
77space = self.getStateSpace();
80if strcmpi(method, 'symbolic')
81 [dpi, pi] = symbolicSensitivity(self, param, theta, h, n);
83 % Central differences on theta with the state space fixed
84 Qp = perturbedGenerator(self, param, theta + h);
85 Qm = perturbedGenerator(self, param, theta - h);
86 if size(Qp, 1) ~= n || size(Qm, 1) ~= n
87 line_error(mfilename, ['Perturbing the parameter changed the state space size, so the ', ...
88 'generators cannot be differenced. This happens when the parameter switches a ', ...
89 'transition on or off (e.g. a zero rate or an immediate transition).']);
91 dQ = (Qp - Qm) / (2 * h);
94 dpi = ctmc_sens(Q, dQ, pi);
103if isa(reward, 'function_handle')
110 line_error(mfilename, 'reward must have one entry per state');
113% Eq. (9.83) with dr/dtheta = 0
116if abs(Er) > GlobalConstants.Zero
117 SS = (theta / Er) * S;
123function [dpi, pi] = symbolicSensitivity(self, param, theta, h, n)
124% [DPI, PI] = SYMBOLICSENSITIVITY(SELF, PARAM, THETA, H, N)
126% Exact d(pi)/d(x_e) from the computer algebra backend, combined with a
127% differenced rate
map d(x_e)/d(theta) by the chain rule.
129% The split matters: the stationary distribution
is a rational function of the
130% rates of high degree, and differencing through it
is where the O(h^2) error
131% of the 'fd' method comes from. The rate
map, by contrast,
is affine in theta
132% whenever theta
is a rate or scales one, and a central difference
is exact on
133% an affine
map. What
is left
is exact in those cases and no worse otherwise.
135% Symbolic generator and the nominal rate of each event. The symbols scale
136% filtrations that were normalized by their own minimum positive rate, so the
137% nominal value of x_e
is that minimum rate.
138infGen = self.getSymbolicGenerator();
139[~, F] = self.getGenerator();
141[rate0, shape0] = eventRates(F);
143% see _kb/06-solver-catalog.md (CTMC section, symbolic sensitivity) for rationale
144hRate = max(abs(theta), 1) * 1e-3;
145[Qp, Fp] = perturbedGenerator(self, param, theta + hRate);
146[Qm, Fm] = perturbedGenerator(self, param, theta - hRate);
147if size(Qp, 1) ~= n || size(Qm, 1) ~= n
148 line_error(mfilename, ['Perturbing the parameter changed the state space size, so the ', ...
149 'generators cannot be differenced. This happens when the parameter switches a ', ...
150 'transition on or off (e.g. a zero rate or an immediate transition).']);
152if numel(Fp) ~= nEvents || numel(Fm) ~= nEvents
153 line_error(mfilename, 'Perturbing the parameter changed the number of events.');
155[ratep, shapep] = eventRates(Fp);
156[ratem, shapem] = eventRates(Fm);
158 if isempty(shape0{e})
161 if isempty(shapep{e}) || isempty(shapem{e}) || ...
162 ~isequal(size(shapep{e}), size(shape0{e})) || ...
163 max(max(abs(shapep{e} - shape0{e}))) > 1e-8 || ...
164 max(max(abs(shapem{e} - shape0{e}))) > 1e-8
165 line_error(mfilename, [
'Perturbing the parameter reshapes the filtration of event ', ...
166 num2str(e),
' rather than scaling it, so the generator is not linear in a single ', ...
167 'rate per event and the symbolic chain rule does not apply. Use the ''fd'' method ', ...
168 'for this parameter.']);
171% see _kb/06-solver-catalog.md (CTMC section, symbolic sensitivity)
for rationale
172curvature = abs(ratep + ratem - 2 * rate0);
173scale = max(1, max(abs(rate0)));
174if max(curvature) > 1e-9 * scale
175 [~, Fp] = perturbedGenerator(self, param, theta + h);
176 [~, Fm] = perturbedGenerator(self, param, theta - h);
177 ratep = eventRates(Fp);
178 ratem = eventRates(Fm);
179 drate = (ratep - ratem) / (2 * h);
181 drate = (ratep - ratem) / (2 * hRate);
184% Symbolic stationary distribution, then one exact derivative per symbol.
185url = SAGE.resolve(SolverCTMC.symbolicBackend(self));
187 url = SAGE.require();
189symbols = cell(1, nEvents);
191 if ~isempty(shape0{e})
192 symbols{e} = [
'x', num2str(e)];
195active = find(~cellfun(@isempty, symbols));
196piExpr = SAGE.solveCTMC(infGen, symbols(active), url);
197piExpr = SAGE.toExpressionList(piExpr);
199assignment =
struct();
201 assignment.(symbols{e}) = rate0(e);
203pi = SAGE.eval(piExpr, assignment, url);
204pi = reshape(pi, 1, []);
209 % This
event does not depend on theta, so its term
is zero and the
210 % derivative
is not worth a round trip.
213 dExpr = SAGE.diff(piExpr, symbols{e}, 1, url);
214 dvals = SAGE.eval(dExpr, assignment, url);
215 dpi = dpi + drate(e) * reshape(dvals, 1, []);
219function [rates, shapes] = eventRates(F)
220% [RATES, SHAPES] = EVENTRATES(F)
221% Minimum positive rate of each
event filtration, and the filtration
222% normalized by it. An
event with no positive rate contributes neither.
223rates = zeros(1, numel(F));
224shapes = cell(1, numel(F));
232 shapes{e} = Fe / rates(e);
236function [Q, F] = perturbedGenerator(self, param, value)
237% [Q, F] = PERTURBEDGENERATOR(SELF, PARAM, VALUE)
238% Rebuild the generator with theta set to VALUE, on a copy of the model so
239% the caller
's model is left untouched.
241% The hard refresh is required, not defensive: setService and setArrival
242% deliberately leave the cached struct in place, so a copy that inherited a
243% built struct would report the old rate and the difference quotient would
244% silently come out as zero.
246modelCopy = self.model.copy();
247param.set(modelCopy, value);
248modelCopy.refreshStruct(true);
249solverCopy = SolverCTMC(modelCopy, self.getOptions());
250[Q, F] = solverCopy.getGenerator();