Source code for lumix.analysis.sensitivity

"""Sensitivity analysis for LumiX optimization models."""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Dict, Generic, List, Optional, Tuple, TypeVar

from ..core.model import LXModel
from ..solution.solution import LXSolution

if TYPE_CHECKING:
    from ..visualization.sensitivity import LXSensitivityPlot

TModel = TypeVar("TModel")


[docs] @dataclass class LXVariableSensitivity: """ Sensitivity analysis results for a variable. Attributes: name: Variable name value: Current value in solution reduced_cost: Reduced cost (opportunity cost) allowable_increase: Maximum increase before basis change (if available) allowable_decrease: Maximum decrease before basis change (if available) is_basic: Whether variable is basic in optimal solution is_at_bound: Whether variable is at its bound """ name: str value: float reduced_cost: Optional[float] = None allowable_increase: Optional[float] = None allowable_decrease: Optional[float] = None is_basic: bool = False is_at_bound: bool = False
[docs] @dataclass class LXConstraintSensitivity: """ Sensitivity analysis results for a constraint. Attributes: name: Constraint name shadow_price: Shadow price (marginal value of relaxation) slack: Slack or surplus value allowable_increase: Maximum RHS increase before basis change (if available) allowable_decrease: Maximum RHS decrease before basis change (if available) is_binding: Whether constraint is binding (tight) is_active: Whether constraint is active at optimum """ name: str shadow_price: Optional[float] = None slack: Optional[float] = None allowable_increase: Optional[float] = None allowable_decrease: Optional[float] = None is_binding: bool = False is_active: bool = False
[docs] class LXSensitivityAnalyzer(Generic[TModel]): """ Sensitivity analysis for optimization models. Analyzes how changes in parameters affect the optimal solution, including: - Shadow prices (dual values) for constraints - Reduced costs for variables - Binding constraints identification - Sensitivity ranges (when available from solver) Examples: Create analyzer and analyze sensitivity:: analyzer = LXSensitivityAnalyzer(model, solution) # Analyze variable sensitivity var_sensitivity = analyzer.analyze_variable("production") print(f"Reduced cost: {var_sensitivity.reduced_cost}") # Analyze constraint sensitivity const_sensitivity = analyzer.analyze_constraint("capacity") print(f"Shadow price: {const_sensitivity.shadow_price}") # Get binding constraints binding = analyzer.get_binding_constraints() for name, sensitivity in binding.items(): print(f"{name}: shadow price = {sensitivity.shadow_price}") # Generate full report print(analyzer.generate_report()) # Get most sensitive parameters sensitive_constraints = analyzer.get_most_sensitive_constraints(top_n=5) """
[docs] def __init__(self, model: LXModel[TModel], solution: LXSolution[TModel]): """ Initialize sensitivity analyzer. Args: model: Optimization model solution: Solution to analyze """ self.model = model self.solution = solution self._variable_sensitivity_cache: Dict[str, LXVariableSensitivity] = {} self._constraint_sensitivity_cache: Dict[str, LXConstraintSensitivity] = {}
[docs] def analyze_variable(self, var_name: str) -> LXVariableSensitivity: """ Analyze sensitivity of a variable. Args: var_name: Variable name Returns: Variable sensitivity information Raises: ValueError: If variable not found in solution """ # Check cache if var_name in self._variable_sensitivity_cache: return self._variable_sensitivity_cache[var_name] # Get variable value if var_name not in self.solution.variables: raise ValueError(f"Variable '{var_name}' not found in solution") var_value = self.solution.variables[var_name] # Handle indexed variables (get first value or 0) if isinstance(var_value, dict): value = next(iter(var_value.values())) if var_value else 0.0 else: value = float(var_value) # Get reduced cost reduced_cost = self.solution.get_reduced_cost(var_name) # Get variable bounds variable = self.model.get_variable(var_name) lower_bound = variable.lower_bound if variable else None upper_bound = variable.upper_bound if variable else None # Determine if at bound is_at_bound = False if lower_bound is not None and abs(value - float(lower_bound)) < 1e-6: is_at_bound = True elif upper_bound is not None and abs(value - float(upper_bound)) < 1e-6: is_at_bound = True # Determine if basic (non-zero reduced cost means non-basic) is_basic = reduced_cost is None or abs(reduced_cost) < 1e-6 sensitivity = LXVariableSensitivity( name=var_name, value=value, reduced_cost=reduced_cost, is_basic=is_basic, is_at_bound=is_at_bound, # Note: allowable_increase/decrease require solver support # These would be populated by solver-specific sensitivity analysis ) # Cache result self._variable_sensitivity_cache[var_name] = sensitivity return sensitivity
[docs] def analyze_constraint(self, constraint_name: str) -> LXConstraintSensitivity: """ Analyze sensitivity of a constraint. Args: constraint_name: Constraint name Returns: Constraint sensitivity information """ # Check cache if constraint_name in self._constraint_sensitivity_cache: return self._constraint_sensitivity_cache[constraint_name] # Get shadow price shadow_price = self.solution.get_shadow_price(constraint_name) # Determine if binding (non-zero shadow price indicates binding) is_binding = shadow_price is not None and abs(shadow_price) > 1e-6 is_active = is_binding # For linear programs, binding = active sensitivity = LXConstraintSensitivity( name=constraint_name, shadow_price=shadow_price, is_binding=is_binding, is_active=is_active, # Note: slack and allowable ranges require solver support # These would be populated by solver-specific sensitivity analysis ) # Cache result self._constraint_sensitivity_cache[constraint_name] = sensitivity return sensitivity
[docs] def analyze_all_variables(self) -> Dict[str, LXVariableSensitivity]: """ Analyze all variables in solution. Returns: Dictionary mapping variable names to sensitivity information """ results = {} for var_name in self.solution.variables: results[var_name] = self.analyze_variable(var_name) return results
[docs] def analyze_all_constraints(self) -> Dict[str, LXConstraintSensitivity]: """ Analyze all constraints in model. Returns: Dictionary mapping constraint names to sensitivity information """ results = {} for constraint in self.model.constraints: results[constraint.name] = self.analyze_constraint(constraint.name) return results
[docs] def get_binding_constraints( self, threshold: float = 1e-6, ) -> Dict[str, LXConstraintSensitivity]: """ Get all binding (tight) constraints. A constraint is binding if its shadow price is non-zero. Args: threshold: Threshold for considering shadow price as non-zero Returns: Dictionary of binding constraints Examples: Get all binding constraints:: binding = analyzer.get_binding_constraints() for name, sens in binding.items(): print(f"{name} is binding with shadow price {sens.shadow_price}") """ all_constraints = self.analyze_all_constraints() return { name: sens for name, sens in all_constraints.items() if sens.shadow_price is not None and abs(sens.shadow_price) > threshold }
[docs] def get_non_basic_variables( self, threshold: float = 1e-6, ) -> Dict[str, LXVariableSensitivity]: """ Get all non-basic variables (with non-zero reduced costs). Args: threshold: Threshold for considering reduced cost as non-zero Returns: Dictionary of non-basic variables """ all_variables = self.analyze_all_variables() return { name: sens for name, sens in all_variables.items() if sens.reduced_cost is not None and abs(sens.reduced_cost) > threshold }
[docs] def get_most_sensitive_constraints( self, top_n: int = 10, ) -> List[Tuple[str, LXConstraintSensitivity]]: """ Get constraints with highest shadow prices (most valuable to relax). Args: top_n: Number of constraints to return Returns: List of (name, sensitivity) tuples sorted by shadow price magnitude Examples: Get most sensitive constraints:: top_constraints = analyzer.get_most_sensitive_constraints(top_n=5) for name, sens in top_constraints: print(f"{name}: ${sens.shadow_price:.2f} per unit relaxation") """ all_constraints = self.analyze_all_constraints() # Filter to those with shadow prices with_shadow_prices = [ (name, sens) for name, sens in all_constraints.items() if sens.shadow_price is not None ] # Sort by absolute shadow price (magnitude) sorted_constraints = sorted( with_shadow_prices, key=lambda x: abs(x[1].shadow_price or 0), reverse=True, ) return sorted_constraints[:top_n]
[docs] def get_most_sensitive_variables( self, top_n: int = 10, ) -> List[Tuple[str, LXVariableSensitivity]]: """ Get variables with highest reduced costs. Args: top_n: Number of variables to return Returns: List of (name, sensitivity) tuples sorted by reduced cost magnitude """ all_variables = self.analyze_all_variables() # Filter to those with reduced costs with_reduced_costs = [ (name, sens) for name, sens in all_variables.items() if sens.reduced_cost is not None ] # Sort by absolute reduced cost sorted_variables = sorted( with_reduced_costs, key=lambda x: abs(x[1].reduced_cost or 0), reverse=True, ) return sorted_variables[:top_n]
[docs] def identify_bottlenecks( self, shadow_price_threshold: float = 0.01, ) -> List[str]: """ Identify bottleneck constraints (binding with high shadow prices). Args: shadow_price_threshold: Minimum shadow price to consider Returns: List of bottleneck constraint names Examples: Identify bottlenecks:: bottlenecks = analyzer.identify_bottlenecks() print(f"Found {len(bottlenecks)} bottlenecks:") for name in bottlenecks: print(f" - {name}") """ binding = self.get_binding_constraints() bottlenecks = [ name for name, sens in binding.items() if sens.shadow_price is not None and abs(sens.shadow_price) >= shadow_price_threshold ] return bottlenecks
[docs] def generate_report( self, include_variables: bool = True, include_constraints: bool = True, include_binding_only: bool = False, top_n: Optional[int] = None, ) -> str: """ Generate comprehensive sensitivity analysis report. Args: include_variables: Include variable sensitivity include_constraints: Include constraint sensitivity include_binding_only: Only show binding constraints top_n: Only show top N most sensitive items Returns: Formatted sensitivity report Examples: Full report:: print(analyzer.generate_report()) Only binding constraints:: print(analyzer.generate_report( include_variables=False, include_binding_only=True )) Top 10 most sensitive:: print(analyzer.generate_report(top_n=10)) """ lines = ["Sensitivity Analysis Report", "=" * 80] # Solution summary lines.append("") lines.append(f"Model: {self.model.name}") lines.append(f"Objective Value: {self.solution.objective_value:,.2f}") lines.append(f"Status: {self.solution.status}") lines.append(f"Solve Time: {self.solution.solve_time:.3f}s") # Constraint sensitivity if include_constraints: lines.append("") lines.append("Constraint Sensitivity (Shadow Prices)") lines.append("=" * 80) if include_binding_only: constraints = self.get_binding_constraints() lines.append(f"Showing {len(constraints)} binding constraints") else: constraints = self.analyze_all_constraints() if top_n is not None: # Get top N by shadow price magnitude sorted_items = sorted( constraints.items(), key=lambda x: abs(x[1].shadow_price or 0), reverse=True, ) constraints = dict(sorted_items[:top_n]) lines.append(f"Showing top {top_n} by shadow price magnitude") lines.append("") lines.append(f"{'Constraint':<30s} {'Shadow Price':>15s} {'Status':>12s}") lines.append("-" * 80) for name, sens in sorted( constraints.items(), key=lambda x: abs(x[1].shadow_price or 0), reverse=True, ): shadow_str = f"{sens.shadow_price:.6f}" if sens.shadow_price else "0.000000" status = "Binding" if sens.is_binding else "Non-binding" lines.append(f"{name:<30s} {shadow_str:>15s} {status:>12s}") # Interpretation lines.append("") lines.append("Interpretation:") lines.append(" • Shadow price = marginal value of relaxing constraint by 1 unit") lines.append(" • Positive = relaxing increases objective (for maximization)") lines.append(" • Binding = constraint is tight at optimum") # Variable sensitivity if include_variables: lines.append("") lines.append("Variable Sensitivity (Reduced Costs)") lines.append("=" * 80) variables = self.analyze_all_variables() if top_n is not None: # Get top N by reduced cost magnitude sorted_items = sorted( variables.items(), key=lambda x: abs(x[1].reduced_cost or 0), reverse=True, ) variables = dict(sorted_items[:top_n]) lines.append(f"Showing top {top_n} by reduced cost magnitude") lines.append("") lines.append(f"{'Variable':<30s} {'Value':>15s} {'Reduced Cost':>15s} {'Status':>12s}") lines.append("-" * 80) for name, sens in sorted( variables.items(), key=lambda x: abs(x[1].reduced_cost or 0), reverse=True, ): value_str = f"{sens.value:.6f}" rc_str = f"{sens.reduced_cost:.6f}" if sens.reduced_cost else "0.000000" status = "At bound" if sens.is_at_bound else "Interior" lines.append(f"{name:<30s} {value_str:>15s} {rc_str:>15s} {status:>12s}") # Interpretation lines.append("") lines.append("Interpretation:") lines.append(" • Reduced cost = opportunity cost of forcing variable to increase") lines.append(" • Zero reduced cost = variable in optimal basis") lines.append(" • Non-zero = variable at bound, not economical to change") # Bottleneck analysis bottlenecks = self.identify_bottlenecks() if bottlenecks: lines.append("") lines.append("Identified Bottlenecks") lines.append("=" * 80) lines.append(f"Found {len(bottlenecks)} bottleneck constraints:") for name in bottlenecks: sens = self.analyze_constraint(name) lines.append(f" • {name}: shadow price = {sens.shadow_price:.6f}") return "\n".join(lines)
[docs] def generate_summary(self) -> str: """ Generate brief sensitivity summary. Returns: Brief summary of key sensitivity metrics """ binding = self.get_binding_constraints() bottlenecks = self.identify_bottlenecks() non_basic = self.get_non_basic_variables() lines = ["Sensitivity Summary", "=" * 80] lines.append(f"Binding constraints: {len(binding)}") lines.append(f"Bottlenecks (high shadow price): {len(bottlenecks)}") lines.append(f"Non-basic variables: {len(non_basic)}") if bottlenecks: lines.append("") lines.append("Top bottlenecks to address:") top_bottlenecks = self.get_most_sensitive_constraints(top_n=3) for name, sens in top_bottlenecks: lines.append(f" • {name}: shadow price = {sens.shadow_price:.4f}") return "\n".join(lines)
[docs] def visualize(self) -> "LXSensitivityPlot[TModel]": """ Create interactive visualization for sensitivity analysis. Requires the visualization extra: pip install lumix-opt[viz] Returns: LXSensitivityPlot instance Examples: Basic usage:: analyzer = LXSensitivityAnalyzer(model, solution) analyzer.visualize().show() Tornado chart:: analyzer.visualize().plot_tornado(top_n=15).show() Export to HTML:: analyzer.visualize().to_html("sensitivity.html") """ from ..visualization import LXSensitivityPlot return LXSensitivityPlot(self)
__all__ = [ "LXSensitivityAnalyzer", "LXVariableSensitivity", "LXConstraintSensitivity", ]