Skip to main content

simplex_trace/
sensitivity.rs

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