simplex_trace/sensitivity.rs
1use std::fmt;
2
3use crate::num::Rational;
4use crate::problem::{Expansion, Problem, Relation, Sense, Sign};
5use crate::trace::write_table;
6
7/// Sensitivity analysis of an optimal solution, read from its final basis.
8///
9/// Everything is expressed in terms of the original problem (its own sense, constraints and
10/// variables), whatever method produced the solution.
11///
12/// ```
13/// use simplex_trace::{solve, Method, Options, Problem, Relation, ratio};
14///
15/// // Wyndor Glass Co.: max 3x1 + 5x2
16/// let problem = Problem::maximize([3, 5])
17/// .subject_to([1, 0], Relation::Le, 4)
18/// .subject_to([0, 2], Relation::Le, 12)
19/// .subject_to([3, 2], Relation::Le, 18);
20/// let solution = solve(&problem, Method::Simplex, &Options::default()).unwrap();
21/// let s = solution.sensitivity.unwrap();
22///
23/// // One more unit of resource 2 is worth 3/2, as long as b2 stays within [6, 18].
24/// assert_eq!(s.constraints[1].shadow_price, ratio(3, 2));
25/// assert_eq!(s.constraints[1].rhs_range.lower, Some(ratio(6, 1)));
26/// assert_eq!(s.constraints[1].rhs_range.upper, Some(ratio(18, 1)));
27/// // (2, 6) stays optimal while c1 is within [0, 15/2].
28/// assert_eq!(s.variables[0].cost_range.upper, Some(ratio(15, 2)));
29/// print!("{s}");
30/// ```
31#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
32#[derive(Clone, Debug, PartialEq, Eq)]
33#[non_exhaustive]
34pub struct Sensitivity {
35 /// Basic columns of the basis the analysis is read from, one per non-redundant row, by
36 /// name: a variable, a slack (`s2`), or a part of a free or non-positive variable (`x1+`,
37 /// `x1-`).
38 pub basic_variables: Vec<String>,
39 /// The analysis is read from the final tableau of the trace (minus the rows of redundant
40 /// equalities), so every [`Source`] points at one of its cells.
41 ///
42 /// `false` only with Big-M, when an artificial variable is left in the basis at zero in a
43 /// row that is not redundant: that is not a basis of the real problem. The artificial is
44 /// then pivoted out and the real objective re-optimized from there, which only takes
45 /// degenerate pivots (any other pivot would improve an optimal solution), so the
46 /// solution is the same but the basis differs, and no [`Source`] is given.
47 pub from_final_tableau: bool,
48 /// One entry per constraint of the problem, in order.
49 pub constraints: Vec<ConstraintSensitivity>,
50 /// One entry per original variable, in order.
51 pub variables: Vec<VariableSensitivity>,
52 /// Some basic variable is zero. Shadow prices are then only valid in one direction
53 /// (the right-hand-side range has an end at the current value) and may not be unique.
54 /// A free variable at zero does not count: it can move both ways.
55 pub degenerate: bool,
56 /// Some nonbasic column (variable or slack) has a zero reduced cost: other optimal
57 /// bases, and usually other optimal solutions, exist. The column of a free variable whose
58 /// other column is basic does not count: it only splits the same value differently.
59 pub alternative_optima: bool,
60}
61
62/// Sensitivity of one constraint.
63#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
64#[derive(Clone, Debug, PartialEq, Eq)]
65#[non_exhaustive]
66pub struct ConstraintSensitivity {
67 /// Shadow price `y_i`: the change of the optimal objective per unit increase of the
68 /// right-hand side `b_i`, valid while `b_i` stays within [`rhs_range`](Self::rhs_range).
69 ///
70 /// This is the rate of change of `z` itself (as in Hillier & Lieberman or Excel), so it
71 /// is positive when raising `b_i` increases `z`, also when minimizing. LINDO's "dual
72 /// price" is the improvement instead: the same value when maximizing, negated when
73 /// minimizing.
74 pub shadow_price: Rational,
75 /// Unused amount at the optimum: `b_i - a_i x` for `<=`, `a_i x - b_i` for `>=`, and 0
76 /// for `=`.
77 pub slack: Rational,
78 /// The constraint holds with equality at the optimum (`slack == 0`).
79 pub binding: bool,
80 /// Values of `b_i` (all else equal) for which the final basis stays feasible, hence
81 /// optimal, with the same shadow prices. A free variable may change sign inside the range
82 /// (its two columns trade places, which keeps the prices). A single point `[b_i, b_i]` for
83 /// a redundant
84 /// equality and for the equalities it depends on: moving any of them alone makes the
85 /// constraints inconsistent.
86 pub rhs_range: Interval,
87 /// An equality that is a linear combination of other constraints, removed from the
88 /// basis. Its shadow price is reported as 0 (it is not unique), and its right-hand side
89 /// cannot change alone without making the problem infeasible.
90 pub redundant: bool,
91 /// Where the shadow price can be read in the final tableau, if anywhere.
92 pub source: Option<Source>,
93}
94
95/// Sensitivity of one original variable.
96#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
97#[derive(Clone, Debug, PartialEq, Eq)]
98#[non_exhaustive]
99pub struct VariableSensitivity {
100 pub name: String,
101 pub value: Rational,
102 /// The variable (or one of its columns, if it is free or non-positive) is in the final
103 /// basis.
104 pub basic: bool,
105 /// `z_j - c_j = sum_i y_i a_ij - c_j`, which is `z_j - c_j` in the final tableau for a
106 /// non-negative variable (0 if basic). For a nonbasic variable it is how much its cost
107 /// must improve before it is worth producing. A non-positive variable's column `x-` shows
108 /// it negated; a free variable's columns show `+-` it, and at an optimum it is 0.
109 pub reduced_cost: Rational,
110 /// Values of the cost `c_j` (all else equal) for which the final basis, and so the
111 /// current solution, stays optimal.
112 pub cost_range: Interval,
113}
114
115/// A closed interval whose ends may be infinite (`None`).
116///
117/// With the `serde` feature: `{"lower": "6", "upper": null}` for `[6, +inf)`.
118#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
119#[derive(Clone, Debug, PartialEq, Eq)]
120#[non_exhaustive]
121pub struct Interval {
122 /// Lower end, `None` for `-inf`.
123 pub lower: Option<Rational>,
124 /// Upper end, `None` for `+inf`.
125 pub upper: Option<Rational>,
126}
127
128impl Interval {
129 pub fn contains(&self, value: &Rational) -> bool {
130 self.lower.as_ref().is_none_or(|l| l <= value)
131 && self.upper.as_ref().is_none_or(|u| value <= u)
132 }
133
134 /// `value + [lower_delta, upper_delta]`.
135 fn around(
136 value: &Rational,
137 lower_delta: Option<Rational>,
138 upper_delta: Option<Rational>,
139 ) -> Self {
140 Interval {
141 lower: lower_delta.map(|d| value + d),
142 upper: upper_delta.map(|d| value + d),
143 }
144 }
145}
146
147impl fmt::Display for Interval {
148 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149 match &self.lower {
150 Some(l) => write!(f, "[{l}, ")?,
151 None => write!(f, "(-inf, ")?,
152 }
153 match &self.upper {
154 Some(u) => write!(f, "{u}]"),
155 None => write!(f, "+inf)"),
156 }
157 }
158}
159
160/// A cell of the final tableau's objective row.
161///
162/// The shadow price of a constraint equals the `z_j - c_j` entry under the column of its
163/// slack (`<=`), its surplus (`>=`, negated) or, with Big-M, the constant part of the entry
164/// under its artificial variable (`=`). It is negated once more if the constraint was
165/// multiplied by -1 to make its right-hand side non-negative. The two-phase method drops the
166/// artificial columns, so for its equalities there is no such cell.
167#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
168#[derive(Clone, Debug, PartialEq, Eq)]
169#[non_exhaustive]
170pub struct Source {
171 /// Column index in the final tableau.
172 pub column: usize,
173 /// Column name, e.g. `"s2"`.
174 pub name: String,
175 /// The shadow price is the negated cell.
176 pub negated: bool,
177}
178
179impl fmt::Display for Sensitivity {
180 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181 let mut table = vec![
182 [
183 "constraint",
184 "shadow price",
185 "slack",
186 "rhs range",
187 "read from",
188 ]
189 .map(String::from)
190 .to_vec(),
191 ];
192 for (i, c) in self.constraints.iter().enumerate() {
193 let source = match &c.source {
194 Some(s) if s.negated => format!("-({})", s.name),
195 Some(s) => s.name.clone(),
196 None if c.redundant => "redundant".to_string(),
197 None => "computed".to_string(),
198 };
199 table.push(vec![
200 format!("#{}", i + 1),
201 c.shadow_price.to_string(),
202 c.slack.to_string(),
203 c.rhs_range.to_string(),
204 source,
205 ]);
206 }
207 write_table(f, &table)?;
208 writeln!(f)?;
209
210 let mut table = vec![
211 ["variable", "value", "reduced cost", "cost range"]
212 .map(String::from)
213 .to_vec(),
214 ];
215 for v in &self.variables {
216 table.push(vec![
217 v.name.clone(),
218 v.value.to_string(),
219 v.reduced_cost.to_string(),
220 v.cost_range.to_string(),
221 ]);
222 }
223 write_table(f, &table)?;
224
225 if self.degenerate {
226 writeln!(
227 f,
228 "\nnote: degenerate basis, shadow prices hold in one direction only"
229 )?;
230 }
231 if self.alternative_optima {
232 writeln!(
233 f,
234 "\nnote: a nonbasic column has zero reduced cost, other optima exist"
235 )?;
236 }
237 Ok(())
238 }
239}
240
241/// Which cells of the final tableau hold the shadow prices.
242pub(crate) enum Cells {
243 /// The final tableau is the analyzed basis; it still shows the artificial columns or not.
244 FinalTableau { artificial_columns: bool },
245 /// The analyzed basis is not the one of the final tableau.
246 None,
247}
248
249/// The standard form the solver started from (see `Tableau::standard_form`).
250pub(crate) struct StandardForm<'a> {
251 pub rows: &'a [Vec<Rational>],
252 pub rhs: &'a [Rational],
253 pub names: &'a [String],
254 pub artificial: &'a [bool],
255}
256
257/// Computes the sensitivity of the optimal basis `basis` (non-artificial column indices, one
258/// per kept row) from scratch: `B^-1` is rebuilt from the starting columns, so the result
259/// does not depend on which method reached the basis. `removed` lists redundant equalities,
260/// which have no row in the final tableau. `cells` tells where the shadow prices can be
261/// read, if anywhere. The tableau is that of `expansion.problem` (non-negative columns); the
262/// report speaks about the variables of `original`.
263///
264/// Returns `None` if the basis matrix is singular, which would be a solver bug.
265pub(crate) fn analyze(
266 original: &Problem,
267 expansion: &Expansion,
268 start: StandardForm<'_>,
269 basis: &[usize],
270 removed: &[usize],
271 cells: Cells,
272) -> Option<Sensitivity> {
273 let problem = &expansion.problem;
274 let n = problem.num_vars();
275 let m = problem.constraints.len();
276 let a = start.rows;
277 // Artificial columns come last; everything before them is a real column.
278 let real = start
279 .artificial
280 .iter()
281 .position(|&art| art)
282 .unwrap_or(start.artificial.len());
283 let kept: Vec<usize> = (0..m).filter(|i| !removed.contains(i)).collect();
284 if kept.len() != basis.len() {
285 return None;
286 }
287
288 let b_matrix: Vec<Vec<Rational>> = kept
289 .iter()
290 .map(|&i| basis.iter().map(|&j| a[i][j].clone()).collect())
291 .collect();
292 // binv[r][k]: r indexes the basis position, k the kept constraint.
293 let binv = inverse(b_matrix)?;
294 let cost = |j: usize| {
295 if j < n {
296 problem.objective[j].clone()
297 } else {
298 Rational::zero()
299 }
300 };
301
302 // Basic values and simplex multipliers (w = c_B B^-1), in the normalized rows.
303 let xb: Vec<Rational> = binv
304 .iter()
305 .map(|row| row.iter().zip(&kept).map(|(v, &i)| v * &start.rhs[i]).sum())
306 .collect();
307 let mut w = vec![Rational::zero(); m];
308 for (k, &i) in kept.iter().enumerate() {
309 w[i] = basis
310 .iter()
311 .enumerate()
312 .map(|(r, &j)| cost(j) * &binv[r][k])
313 .sum();
314 }
315 // Reduced costs z_j - c_j of the real columns.
316 let d: Vec<Rational> = (0..real)
317 .map(|j| (0..m).map(|i| &w[i] * &a[i][j]).sum::<Rational>() - cost(j))
318 .collect();
319 let mut row_of = vec![None; real];
320 for (r, &j) in basis.iter().enumerate() {
321 row_of[j] = Some(r);
322 }
323 let nonbasic: Vec<usize> = (0..real).filter(|&j| row_of[j].is_none()).collect();
324 // Row r of the final tableau, column l: (B^-1 A)[r][l].
325 let alpha = |r: usize, l: usize| -> Rational {
326 kept.iter()
327 .enumerate()
328 .map(|(k, &i)| &binv[r][k] * &a[i][l])
329 .sum()
330 };
331
332 // A removed row r is a combination of kept equalities: A_r = lambda B^-1 A_K with
333 // lambda = A_r[basis] B^-1. Those with lambda_k != 0 cannot change their rhs alone.
334 let mut pinned = vec![false; kept.len()];
335 for &r in removed {
336 for (k, pin) in pinned.iter_mut().enumerate() {
337 let lambda: Rational = basis
338 .iter()
339 .enumerate()
340 .map(|(row, &j)| &a[r][j] * &binv[row][k])
341 .sum();
342 *pin |= !lambda.is_zero();
343 }
344 }
345
346 let values: Vec<Rational> = (0..n)
347 .map(|j| row_of[j].map_or_else(Rational::zero, |r| xb[r].clone()))
348 .collect();
349 // Columns of free variables: they have no sign of their own to protect, so reaching zero
350 // only means the variable changes sign (its twin column takes over, same prices).
351 let mut free_column = vec![false; real];
352 for (j, cols) in expansion.parts.iter().enumerate() {
353 if original.sign(j) == Sign::Free {
354 for &(col, _) in cols {
355 free_column[col] = true;
356 }
357 }
358 }
359
360 let constraints = problem
361 .constraints
362 .iter()
363 .enumerate()
364 .map(|(i, c)| {
365 // Rows with a negative right-hand side were multiplied by -1.
366 let flip = if c.rhs.is_negative() {
367 -Rational::one()
368 } else {
369 Rational::one()
370 };
371 let activity: Rational = c.coeffs.iter().zip(&values).map(|(a, x)| a * x).sum();
372 let slack = match c.relation {
373 Relation::Le => &c.rhs - &activity,
374 Relation::Ge => &activity - &c.rhs,
375 Relation::Eq => Rational::zero(),
376 };
377
378 let point = || Interval::around(&c.rhs, Some(Rational::zero()), Some(Rational::zero()));
379 let Some(k) = kept.iter().position(|&kept| kept == i) else {
380 return ConstraintSensitivity {
381 shadow_price: Rational::zero(),
382 binding: true,
383 slack,
384 rhs_range: point(),
385 redundant: true,
386 source: None,
387 };
388 };
389
390 // Raising the normalized rhs by delta moves the basic values by delta * B^-1 e_k;
391 // the basis stays feasible while they are all >= 0 (free variables aside).
392 let mut lower: Option<Rational> = None;
393 let mut upper: Option<Rational> = None;
394 for (r, x) in xb.iter().enumerate() {
395 let u = &binv[r][k];
396 if u.is_zero() || free_column[basis[r]] {
397 continue;
398 }
399 let limit = -x / u;
400 if u.is_positive() {
401 lower = Some(lower.map_or(limit.clone(), |l| l.max(limit)));
402 } else {
403 upper = Some(upper.map_or(limit.clone(), |v| v.min(limit)));
404 }
405 }
406 let rhs_range = if pinned[k] {
407 point()
408 } else if flip.is_positive() {
409 Interval::around(&c.rhs, lower, upper)
410 } else {
411 Interval::around(&c.rhs, upper.map(|v| -v), lower.map(|v| -v))
412 };
413
414 // The column of the slack/surplus (+-e_i) or artificial (+e_i) has z_j - c_j =
415 // +-w_i (the artificial's cost only adds an `M` part).
416 let slack_column = (n..real)
417 .find(|&j| !a[i][j].is_zero())
418 .map(|j| (j, a[i][j].clone()));
419 let artificial_column = || {
420 (real..start.names.len())
421 .find(|&j| !a[i][j].is_zero())
422 .map(|j| (j, Rational::one()))
423 };
424 let column = match cells {
425 Cells::FinalTableau { artificial_columns } => {
426 slack_column.or_else(|| artificial_columns.then(artificial_column).flatten())
427 }
428 Cells::None => None,
429 };
430 let source = column.map(|(j, sign)| Source {
431 column: j,
432 name: start.names[j].clone(),
433 negated: (sign * &flip).is_negative(),
434 });
435
436 ConstraintSensitivity {
437 shadow_price: &flip * &w[i],
438 binding: slack.is_zero(),
439 slack,
440 rhs_range,
441 redundant: false,
442 source,
443 }
444 })
445 .collect();
446
447 // `f * column` for a column that enters an original variable negated or not.
448 let signed = |negated: bool, v: &Rational| if negated { -v } else { v.clone() };
449 let variables = expansion
450 .parts
451 .iter()
452 .enumerate()
453 .map(|(j, cols)| {
454 let c = &original.objective[j];
455 // Raising c_j by delta raises the cost of each column by f * delta (f = -1 for a
456 // negated column). That moves every nonbasic z_l - c_l by delta * g_l, where g_l
457 // collects f * alpha[r][l] over the basic columns (row r) and -f if l is itself a
458 // column of the variable. The basis stays optimal while they keep the sign the
459 // sense requires.
460 let mut lower: Option<Rational> = None;
461 let mut upper: Option<Rational> = None;
462 for &l in &nonbasic {
463 let mut g = Rational::zero();
464 for &(col, negated) in cols {
465 if let Some(r) = row_of[col] {
466 g += signed(negated, &alpha(r, l));
467 } else if col == l {
468 g -= signed(negated, &Rational::one());
469 }
470 }
471 if g.is_zero() {
472 continue;
473 }
474 let limit = -&d[l] / &g;
475 let is_lower = match problem.sense {
476 Sense::Maximize => g.is_positive(),
477 Sense::Minimize => g.is_negative(),
478 };
479 if is_lower {
480 lower = Some(lower.map_or(limit.clone(), |v| v.max(limit)));
481 } else {
482 upper = Some(upper.map_or(limit.clone(), |v| v.min(limit)));
483 }
484 }
485 let (first, negated) = cols[0];
486 VariableSensitivity {
487 name: original.var_name(j),
488 value: cols
489 .iter()
490 .map(|&(col, neg)| signed(neg, &values[col]))
491 .sum(),
492 basic: cols.iter().any(|&(col, _)| row_of[col].is_some()),
493 reduced_cost: signed(negated, &d[first]),
494 cost_range: Interval::around(c, lower, upper),
495 }
496 })
497 .collect();
498 // The two columns of a free variable are opposite: when one is basic the other has a zero
499 // reduced cost that only describes splitting the same value differently.
500 let mut twin = vec![None; real];
501 for (j, cols) in expansion.parts.iter().enumerate() {
502 if original.sign(j) == Sign::Free {
503 twin[cols[0].0] = Some(cols[1].0);
504 twin[cols[1].0] = Some(cols[0].0);
505 }
506 }
507 let alternative_optima = nonbasic
508 .iter()
509 .any(|&l| d[l].is_zero() && !twin[l].is_some_and(|t: usize| row_of[t].is_some()));
510
511 Some(Sensitivity {
512 basic_variables: basis.iter().map(|&j| start.names[j].clone()).collect(),
513 from_final_tableau: matches!(cells, Cells::FinalTableau { .. }),
514 constraints,
515 variables,
516 degenerate: (0..xb.len()).any(|r| xb[r].is_zero() && !free_column[basis[r]]),
517 alternative_optima,
518 })
519}
520
521/// Gauss-Jordan inverse of a square matrix, or `None` if it is singular.
522fn inverse(mut a: Vec<Vec<Rational>>) -> Option<Vec<Vec<Rational>>> {
523 let size = a.len();
524 let mut inv: Vec<Vec<Rational>> = (0..size)
525 .map(|i| {
526 (0..size)
527 .map(|j| {
528 if i == j {
529 Rational::one()
530 } else {
531 Rational::zero()
532 }
533 })
534 .collect()
535 })
536 .collect();
537 for col in 0..size {
538 let pivot = (col..size).find(|&r| !a[r][col].is_zero())?;
539 a.swap(col, pivot);
540 inv.swap(col, pivot);
541 let p = a[col][col].clone();
542 for v in a[col].iter_mut().chain(inv[col].iter_mut()) {
543 *v /= &p;
544 }
545 for r in 0..size {
546 if r == col || a[r][col].is_zero() {
547 continue;
548 }
549 let factor = a[r][col].clone();
550 for c in 0..size {
551 let (sub_a, sub_inv) = (&factor * &a[col][c], &factor * &inv[col][c]);
552 a[r][c] -= sub_a;
553 inv[r][c] -= sub_inv;
554 }
555 }
556 }
557 Some(inv)
558}