Skip to main content

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}