1classdef LineOptSolver < handle
2 % LineOptSolver Main line-opt solver. Minimizes a penalized scalar
3 % objective (constraints as penalties) over the decision variables,
4 % aggregating across scenarios,
using the self-contained
5 % opt.de.DifferentialEvolution engine (numpy-exact RNG) or an analytic/FD
6 % projected-gradient path. Mirrors native-Python LineOptSolver.
12 freeVariables % cell of opt.DecisionVariable (post-freeze)
13 evaluators % cell of opt.LineEvaluator
15 caches % cell of containers.Map
19 convergenceHistory = [];
22 lqnSensCache % Map valuesKey -> sensitivity Map (or [])
27 function obj = LineOptSolver(problem, options)
28 obj.problem = problem;
30 obj.fixedValueMap = containers.Map(
'KeyType',
'char',
'ValueType',
'any');
31 fixed = problem.getFixedVariables();
32 for k = 1:numel(fixed)
33 obj.fixedValueMap(fixed{k}{1}.getName()) = fixed{k}{2};
36 % Explicit layer freezing: variables whose layer
is in frozenLayers
37 % are held at the model
's current parameter value (moved from free
38 % to fixed) instead of being optimized. See freezeLayers.
39 freeVars = problem.getVariables();
40 [freeVars, fixed] = obj.freezeLayers(freeVars, fixed);
41 obj.freeVariables = freeVars;
43 obj.evaluators = {opt.LineEvaluator(problem.getModel(), freeVars, fixed)};
44 scen = problem.getScenarios();
45 w = zeros(1, 1 + numel(scen));
48 obj.evaluators{end+1} = opt.LineEvaluator(scen{i}{1}, freeVars, fixed);
51 obj.scenarioWeights = w;
52 obj.lqnSensCache = containers.Map('KeyType
', 'char', 'ValueType
', 'any
');
55 function [freeVars, fixed] = freezeLayers(obj, freeVars, fixed)
56 % Partition variables by the frozenLayers option (LQN only). A
57 % variable whose layer set (DecisionVariable.getLayer) intersects
58 % the frozen set is moved from free to fixed, held at its current
59 % model parameter value (DecisionVariable.currentValue). If the
60 % current value cannot be read the variable is dropped from the
61 % optimization, leaving the model's built-in value untouched.
62 % No-op
for a flat model or when no layers are frozen.
63 frozen = obj.opt.frozenLayers;
64 if isempty(frozen),
return; end
65 model = obj.problem.getModel();
66 if ~opt.Layered.isLayered(model),
return; end
69 for k = 1:numel(fixed), already{end+1} = fixed{k}{1}.getName(); end %#ok<AGROW>
71 for i = 1:numel(freeVars)
73 layers = var.getLayer(model);
74 if ~isempty(layers) && any(ismember(layers, frozen))
75 if any(strcmp(var.getName(), already))
78 value = var.currentValue(model);
80 fixed{end+1} = {var, value}; %#ok<AGROW>
81 obj.fixedValueMap(var.getName()) = value;
83 %
else: drop; the model keeps its built-in value
85 kept{end+1} = var; %#ok<AGROW>
91 function result = solve(obj)
96 obj.convergenceHistory = [];
97 obj.caches = cell(1, numel(obj.evaluators));
98 for i = 1:numel(obj.evaluators)
99 obj.caches{i} = containers.Map(
'KeyType',
'char',
'ValueType',
'any');
101 obj.lqnSensCache = containers.Map(
'KeyType',
'char',
'ValueType',
'any');
103 obj.deadline = obj.opt.timeLimit;
105 bounds = obj.evaluators{1}.getBounds();
107 result = obj.buildEmptyResult();
110 if obj.shouldUseGradient()
111 result = obj.solveGradient(bounds);
113 result = obj.solveEvolution(bounds);
117 function result = solveEvolution(obj, bounds)
118 low = bounds(:, 1).
';
119 high = bounds(:, 2).';
120 if isempty(obj.opt.seed)
121 seed = mod(int64(feature('timing','cpucount')), 2^31);
125 de = opt.de.DifferentialEvolution(@(x) obj.objectiveFunction(x), low, high, ...
126 obj.opt.strategy, obj.opt.popsize, obj.opt.maxIterations, ...
127 obj.opt.mutationLow, obj.opt.mutationHigh, obj.opt.recombination, obj.opt.tol, seed);
128 de.callback = @(bx, nit) obj.deCallback(nit);
133 resX = obj.engineBestVector(r);
136 if strcmp(ME.identifier, 'LineOpt:TimeLimit')
138 if isempty(obj.bestX)
139 result = obj.buildEmptyResult(); return;
141 resX = obj.bestX; resFun = obj.bestValue;
146 solveTime = toc(obj.startTime);
147 result = obj.buildResult(resX, resFun, solveTime);
148 if timedOut, result.terminatedBy = 'time_limit'; end
151 function stop = deCallback(obj, nit)
152 obj.iterations = nit;
153 obj.convergenceHistory(end+1) = obj.bestValue;
154 stop = toc(obj.startTime) >= obj.deadline;
157 function x = engineBestVector(obj, r)
158 if ~isempty(obj.bestX) && obj.bestValue <= r.fun
165 function total = objectiveFunction(obj, x)
166 if toc(obj.startTime) >= obj.deadline
167 error('LineOpt:TimeLimit', 'time limit reached');
169 values = obj.evaluators{1}.decodeVariables(x);
170 allValues = obj.mergeValues(values);
171 objective = obj.problem.getObjective();
172 pw = obj.opt.penaltyWeight;
173 cons = obj.problem.getConstraints();
174 scenarioValues = zeros(1, numel(obj.evaluators));
175 for i = 1:numel(obj.evaluators)
176 res = obj.evaluators{i}.evaluateValuesWithCache(values, obj.caches{i});
180 value = objective.evaluateWithPenalty(res, allValues, pw);
181 for c = 1:numel(cons)
182 value = value + cons{c}.evaluate(res, allValues) * pw;
184 scenarioValues(i) = value;
186 total = obj.aggregateScenarios(scenarioValues);
187 if total < obj.bestValue
188 obj.bestValue = total;
193 function av = mergeValues(obj, values)
194 % merge: start from fixed, overlay values
195 av = containers.Map(
'KeyType',
'char',
'ValueType',
'any');
196 fk = keys(obj.fixedValueMap);
197 for i = 1:numel(fk), av(fk{i}) = obj.fixedValueMap(fk{i}); end
199 for i = 1:numel(vk), av(vk{i}) = values(vk{i}); end
202 function total = aggregateScenarios(obj, values)
203 if numel(values) == 1
204 total = values(1);
return;
206 if strcmp(obj.opt.scenarioAggregation,
'mean')
207 total = sum(obj.scenarioWeights .* values) / sum(obj.scenarioWeights);
213 function tf = shouldUseGradient(obj)
214 if strcmp(obj.opt.optimizer, 'gradient'), tf = true; return; end
215 if strcmp(obj.opt.optimizer, 'evolution'), tf = false; return; end
216 tf = obj.allContinuous();
219 function tf = allContinuous(obj)
220 % Continuous (differentiable) types, incl. the continuous LQN knobs
221 % host demand and think time.
222 vars = obj.freeVariables;
223 if isempty(vars), tf = false; return; end
225 for i = 1:numel(vars)
226 vt = vars{i}.getVariableType();
227 if ~any(strcmp(vt, {
'service_rate',
'routing', ...
228 'host_demand',
'think_time'}))
234 function result = solveGradient(obj, bounds)
235 dim = size(bounds, 1);
236 nStart = max(1, obj.opt.gradientRestarts);
237 if isempty(obj.opt.seed), sd = 0; else, sd = obj.opt.seed; end
238 rs = RandStream('mt19937ar', 'Seed', sd);
239 starts = {0.5 * ones(1, dim)};
240 for s = 2:nStart, starts{end+1} = rand(rs, 1, dim); end %#ok<AGROW>
241 best = []; bestFun = inf; timedOut =
false;
242 for si = 1:numel(starts)
243 if toc(obj.startTime) >= obj.deadline, timedOut =
true;
break; end
245 xx = obj.projectedGradientDescent(starts{si});
246 fx = obj.objectiveFunction(xx);
247 if isfinite(fx) && fx < bestFun, bestFun = fx; best = xx; end
249 if strcmp(ME.identifier,
'LineOpt:TimeLimit'), timedOut =
true;
break;
else, rethrow(ME); end
252 if isempty(best) || (~isempty(obj.bestX) && obj.bestValue < bestFun)
253 if ~isempty(obj.bestX), best = obj.bestX; bestFun = obj.bestValue;
254 elseif isempty(best), result = obj.buildEmptyResult();
return; end
256 solveTime = toc(obj.startTime);
257 result = obj.buildResult(best, bestFun, solveTime);
258 if timedOut, result.terminatedBy =
'time_limit'; end
261 function x = projectedGradientDescent(obj, x0)
262 x = min(max(x0, 0), 1);
263 f = obj.objectiveFunction(x);
264 for iter = 1:obj.opt.maxIterations
265 g = obj.objectiveGradient(x);
266 if norm(g) < 1e-9,
break; end
267 step = 1.0; improved =
false;
269 xn = min(max(x - step * g, 0), 1);
270 fn = obj.objectiveFunction(xn);
271 if isfinite(fn) && fn < f - 1e-12
272 x = xn; f = fn; improved =
true;
break;
276 if ~improved,
break; end
280 function g = objectiveGradient(obj, x)
281 % Gradient of the penalized objective in encoded space. For a
282 % LayeredNetwork the source
is selected by lqnGradient:
'fd'
283 % (whole-model finite difference, robust
default),
'partial_sens'
284 % (SolverLN per-layer partial derivatives, cheap/biased), or
285 %
'partial_plus_fd' (partial with a periodic full-FD correction).
286 % A flat network always finite-differences. All paths fall back to
287 % finite differences, which always work.
288 if obj.evaluators{1}.isLayered
289 mode = obj.opt.lqnGradient;
290 if any(strcmp(mode, {
'partial_sens',
'partial_plus_fd'}))
291 obj.gradCalls = obj.gradCalls + 1;
292 refresh = max(1, obj.opt.fdRefresh);
293 % partial_plus_fd periodically replaces the biased partial
294 % direction with the correct whole-model finite difference.
295 if ~(strcmp(mode, 'partial_plus_fd') && mod(obj.gradCalls, refresh) == 0)
296 g = obj.lqnAnalyticGradient(x);
297 if ~isempty(g), return; end
300 g = obj.finiteDifferenceGradient(x);
303 g = obj.finiteDifferenceGradient(x);
306 function g = finiteDifferenceGradient(obj, x)
307 % Central finite-difference gradient of the penalized scalar
308 % objective. Works for any model or solver (for a LayeredNetwork
309 % each perturbed evaluation re-solves the whole ensemble, giving
310 % the correct total derivative). One-sided differences near an
311 % infeasible/unstable boundary where a two-sided value
is non-finite.
318 xp(i) = min(1.0, x(i) + h);
319 xm(i) = max(0.0, x(i) - h);
320 fp = obj.objectiveFunction(xp);
321 fm = obj.objectiveFunction(xm);
322 if isfinite(fp) && isfinite(fm) && xp(i) > xm(i)
323 g(i) = (fp - fm) / (xp(i) - xm(i)); continue;
325 if isempty(f0), f0 = obj.objectiveFunction(x); end
326 if isfinite(fp) && isfinite(f0) && xp(i) > x(i)
327 g(i) = (fp - f0) / (xp(i) - x(i));
328 elseif isfinite(fm) && isfinite(f0) && x(i) > xm(i)
329 g(i) = (f0 - fm) / (x(i) - xm(i));
336 function g = lqnAnalyticGradient(obj, x)
337 % Partial-sensitivity gradient for a LayeredNetwork ([] to fall back
338 % to the whole-model finite difference). Assembles d(objective)/dx
339 % from SolverLN's per-layer WITHIN-LAYER service-rate derivatives,
340 % WITHOUT re-solving per parameter: (i) read d(layer metric)/d(rate)
341 % at the variable's host-layer row, (ii)
map each layer metric to
342 % the LQN node metric it approximates and take d(penalized scalar)/
343 % d(that node metric) by cheap metric-space finite differences,
344 % (iii) chain through d(rate)/d(demand) = -1/D^2 and the linear
345 % decode. Returns [] when multi-scenario, any variable
is not a
346 % host-demand variable, or the sensitivity table
is unavailable.
347 % BIASED (omits cross-layer coupling); fd/partial_plus_fd correct it.
349 vars = obj.freeVariables;
350 if numel(obj.evaluators) ~= 1 || isempty(vars), return; end
351 for i = 1:numel(vars)
352 if ~strcmp(vars{i}.getVariableType(),
'host_demand') ...
353 || ~ismethod(vars{i},
'sensKey')
358 values = obj.evaluators{1}.decodeVariables(x);
359 allValues = obj.mergeValues(values);
360 res = obj.evaluators{1}.evaluateValuesWithCache(values, obj.caches{1});
361 if ~res.feasible,
return; end
363 skey = opt.LineEvaluator.valuesKey(values);
364 if isKey(obj.lqnSensCache, skey)
365 sens = obj.lqnSensCache(skey);
367 sens = obj.evaluators{1}.evaluateLayeredSensitivities(values);
368 obj.lqnSensCache(skey) = sens;
370 if isempty(sens),
return; end
372 objective = obj.problem.getObjective();
373 cons = obj.problem.getConstraints();
374 pw = obj.opt.penaltyWeight;
376 model = obj.problem.getModel();
377 kinds = {
'Tput',
'RespT',
'QLen',
'Util'};
379 grad = zeros(1, numel(x));
380 for i = 1:numel(vars)
382 kv = var.sensKey(model);
383 if isempty(kv) || ~isKey(sens, kv)
384 % No sensitivity row: leave 0 (other components still make
385 % progress; the FD modes cover it fully).
389 targets = var.sensMetricTargets(model);
391 for kk = 1:numel(kinds)
393 dmetric_drate = row.(kind);
394 if dmetric_drate == 0,
continue; end
395 mkey = targets.(kind);
396 dS_dmetric = obj.scalarMetricDerivative(res, allValues, ...
397 kind, mkey, objective, cons, pw, h);
398 dS_drate = dS_drate + dS_dmetric * dmetric_drate;
400 demand = allValues(var.getName());
402 rateJac = var.rateJacobian(demand);
406 grad(i) = dS_drate * rateJac * var.decodeJacobian(x(i));
411 function d = scalarMetricDerivative(obj, res, allValues, kind, mkey, ...
412 objective, cons, pw, h)
413 % d(penalized scalar)/d(node metric[kind][mkey]) by central FD in
414 % metric space (pure arithmetic, no solving).
416 if isempty(mkey),
return; end
417 m = obj.metricMapFor(res, kind);
418 if isempty(m) || ~isKey(m, mkey),
return; end
421 fp = obj.scalarObjective(res, allValues, objective, cons, pw);
423 fm = obj.scalarObjective(res, allValues, objective, cons, pw);
425 d = (fp - fm) / (2.0 * h);
428 function v = scalarObjective(~, res, allValues, objective, cons, pw)
429 v = objective.evaluateWithPenalty(res, allValues, pw);
430 for c = 1:numel(cons)
431 v = v + cons{c}.evaluate(res, allValues) * pw;
435 function m = metricMapFor(~, res, kind)
437 case 'RespT', m = res.responseTimes;
438 case 'QLen', m = res.queueLengths;
439 case 'Tput', m = res.throughputs;
440 case 'Util', m = res.utilizations;
445 function result = buildResult(obj, x, objectiveValue, solveTime)
446 result = opt.OptimizationResult();
447 result.objectiveValue = objectiveValue;
448 vals = obj.evaluators{1}.decodeVariables(x);
450 for i = 1:numel(vk), result.variableValues(vk{i}) = vals(vk{i}); end
451 result.iterations = obj.iterations;
452 result.solveTime = solveTime;
454 for i = 1:numel(obj.evaluators), evals = evals + obj.evaluators{i}.getEvaluationCount(); end
455 result.modelEvaluations = evals;
456 result.convergenceHistory = obj.convergenceHistory;
458 allValues = obj.mergeValues(result.variableValues);
459 objective = obj.problem.getObjective();
460 allCons = [objective.getConstraints(), obj.problem.getConstraints()];
461 result.feasible =
true;
462 for i = 1:numel(obj.evaluators)
463 er = obj.evaluators{i}.evaluateValuesWithCache(result.variableValues, obj.caches{i});
464 if ~er.feasible, result.feasible =
false;
continue; end
465 for c = 1:numel(allCons)
466 viol = allCons{c}.evaluate(er, allValues);
468 nm = allCons{c}.getName();
469 prev = 0.0;
if isKey(result.constraintViolations, nm), prev = result.constraintViolations(nm); end
470 result.constraintViolations(nm) = max(viol, prev);
471 result.feasible =
false;
475 elapsed = toc(obj.startTime);
476 if elapsed >= obj.opt.timeLimit
477 result.terminatedBy =
'time_limit';
478 elseif obj.iterations >= obj.opt.maxIterations
479 result.terminatedBy =
'iterations';
481 result.terminatedBy =
'convergence';
485 function result = buildEmptyResult(obj) %#ok<MANU>
486 result = opt.OptimizationResult();
487 result.objectiveValue = 0.0;
488 result.feasible =
true;
489 result.terminatedBy =
'empty';