Sensitivity Analysis¶
Learn how to use shadow prices and reduced costs for sensitivity analysis.
Overview¶
Sensitivity analysis helps you understand:
Shadow Prices: How much the objective improves if a constraint is relaxed by one unit
Reduced Costs: How much a variable’s coefficient must improve before it enters the basis
Note
Sensitivity information availability depends on the solver:
Gurobi: Full support for LP and QP
CPLEX: Full support for LP and QP
OR-Tools: Limited support
GLPK: Basic support for LP
CP-SAT: Not applicable (constraint programming)
Shadow Prices (Dual Values)¶
What Are Shadow Prices?¶
The shadow price (or dual value) of a constraint represents the rate of change in the objective value per unit relaxation of the constraint.
Interpretation:
Positive shadow price: Relaxing the constraint improves the objective
Zero shadow price: Constraint is not binding (has slack)
Negative shadow price: Depends on minimization vs. maximization
For minimization problems:
Positive shadow price: Increasing RHS increases cost
Negative shadow price: Increasing RHS decreases cost
For maximization problems:
Positive shadow price: Increasing RHS increases profit
Negative shadow price: Increasing RHS decreases profit
Accessing Shadow Prices¶
# Get shadow price for a constraint
shadow_price = solution.get_shadow_price("capacity_constraint")
if shadow_price is not None:
print(f"Shadow price: ${shadow_price:.2f}")
else:
print("Shadow prices not available from solver")
Example: Resource Value¶
from lumix import LXModel, LXVariable, LXConstraint, LXLinearExpression
# Production model with resource constraints
production = LXVariable[Product, float]("production").from_data(products)
capacity = (
LXConstraint[Resource]("capacity")
.expression(
LXLinearExpression()
.add_term(production, lambda p, r: p.usage[r.id])
)
.le()
.rhs(lambda r: r.capacity)
.from_data(resources)
.indexed_by(lambda r: r.id)
)
model = LXModel("production").add_variable(production).add_constraint(capacity)
# ... set objective ...
solution = optimizer.solve(model)
# Analyze resource values
for resource in resources:
shadow_price = solution.get_shadow_price(f"capacity[{resource.id}]")
if shadow_price and shadow_price > 0:
print(f"{resource.name}:")
print(f" Shadow price: ${shadow_price:.2f} per unit")
print(f" → Adding 1 unit increases profit by ${shadow_price:.2f}")
Interpreting Shadow Prices¶
Binding Constraints (non-zero shadow price):
def analyze_bottlenecks(solution, resources):
"""Identify bottleneck resources."""
bottlenecks = []
for resource in resources:
constraint_name = f"capacity[{resource.id}]"
shadow_price = solution.get_shadow_price(constraint_name)
if shadow_price and abs(shadow_price) > 0.01:
bottlenecks.append((resource, shadow_price))
# Sort by value
bottlenecks.sort(key=lambda x: abs(x[1]), reverse=True)
print("Bottleneck Resources:")
for resource, price in bottlenecks[:5]:
print(f" {resource.name}: ${price:.2f}/unit")
print(f" Current capacity: {resource.capacity}")
print(f" Value of +100 units: ${price * 100:.2f}")
Non-binding Constraints (zero shadow price):
def find_slack_resources(solution, resources):
"""Find resources with excess capacity."""
slack_resources = []
for resource in resources:
constraint_name = f"capacity[{resource.id}]"
shadow_price = solution.get_shadow_price(constraint_name)
if shadow_price is not None and abs(shadow_price) < 0.01:
slack_resources.append(resource)
print(f"Resources with slack capacity: {len(slack_resources)}")
for resource in slack_resources:
print(f" {resource.name}: not fully utilized")
Reduced Costs¶
What Are Reduced Costs?¶
The reduced cost of a variable represents how much its objective coefficient must improve before it becomes profitable to use that variable in the solution.
Interpretation:
Zero reduced cost: Variable is in the basis (non-zero in solution)
Non-zero reduced cost: Variable is at its bound
For minimization problems:
Positive reduced cost: Variable is at lower bound
Negative reduced cost: Variable is at upper bound
Amount is cost reduction needed to make variable attractive
For maximization problems:
Negative reduced cost: Variable is at lower bound
Positive reduced cost: Variable is at upper bound
Amount is profit increase needed to make variable attractive
Accessing Reduced Costs¶
# Get reduced cost for a variable
reduced_cost = solution.get_reduced_cost("production[product_A]")
if reduced_cost is not None:
print(f"Reduced cost: ${reduced_cost:.2f}")
else:
print("Reduced costs not available from solver")
Example: Product Profitability¶
def analyze_product_profitability(solution, products):
"""Analyze which products should be produced."""
production_values = solution.get_mapped(production)
for product in products:
var_name = f"production[{product.id}]"
quantity = production_values.get(product.id, 0)
reduced_cost = solution.get_reduced_cost(var_name)
print(f"{product.name}:")
print(f" Production: {quantity}")
if quantity > 0.01:
print(f" Status: In production")
elif reduced_cost is not None:
if reduced_cost > 0:
print(f" Status: Not produced")
print(f" Profit must increase by ${reduced_cost:.2f} to produce")
else:
print(f" Status: At upper bound")
Sensitivity Ranges¶
RHS Sensitivity¶
Estimate the range over which shadow prices are valid:
def estimate_rhs_range(solution, constraint_name, current_rhs, step_size=10):
"""
Estimate valid range for RHS changes.
Note: This is approximate. For exact ranges, use solver-specific APIs.
"""
base_objective = solution.objective_value
shadow_price = solution.get_shadow_price(constraint_name)
if shadow_price is None:
return None
# Estimate range (simplified)
# In practice, you'd re-solve with perturbed RHS
print(f"Constraint: {constraint_name}")
print(f"Current RHS: {current_rhs}")
print(f"Shadow price: ${shadow_price:.2f}")
print(f"Estimated objective if RHS +{step_size}: ${base_objective + shadow_price * step_size:.2f}")
print(f"Estimated objective if RHS -{step_size}: ${base_objective - shadow_price * step_size:.2f}")
Objective Coefficient Sensitivity¶
def analyze_coefficient_sensitivity(solution, variable_name, current_coeff):
"""Analyze sensitivity to objective coefficient changes."""
reduced_cost = solution.get_reduced_cost(variable_name)
if reduced_cost is None:
return
print(f"Variable: {variable_name}")
print(f"Current coefficient: {current_coeff}")
if abs(reduced_cost) < 0.01:
print("Status: In basis (actively used)")
print("Coefficient can decrease slightly before leaving basis")
else:
print(f"Status: At bound (not actively used)")
print(f"Coefficient must improve by {abs(reduced_cost):.2f} to enter basis")
What-If Analysis¶
Simple What-If Scenarios¶
Use shadow prices for quick estimates:
def what_if_capacity_increase(solution, resource_name, increase):
"""Estimate impact of capacity increase."""
constraint_name = f"capacity[{resource_name}]"
shadow_price = solution.get_shadow_price(constraint_name)
if shadow_price is None:
print("Shadow price not available")
return
current_objective = solution.objective_value
estimated_new_objective = current_objective + shadow_price * increase
print(f"What-if: Increase {resource_name} capacity by {increase} units")
print(f"Current objective: ${current_objective:,.2f}")
print(f"Estimated new objective: ${estimated_new_objective:,.2f}")
print(f"Estimated improvement: ${shadow_price * increase:,.2f}")
# Calculate ROI if capacity has a cost
capacity_cost = 1000 # Example: $1000 per unit
total_cost = increase * capacity_cost
benefit = shadow_price * increase
if total_cost > 0:
roi = (benefit / total_cost) * 100
print(f"Cost of capacity: ${total_cost:,.2f}")
print(f"Estimated ROI: {roi:.1f}%")
Multi-Scenario Analysis¶
For more accurate analysis, re-solve:
def compare_scenarios(base_model, resource, capacity_increases):
"""Compare multiple capacity scenarios."""
results = []
for increase in capacity_increases:
# Clone model and modify capacity
scenario_model = base_model.copy() # Implement model cloning
# Modify constraint RHS...
# Solve scenario
scenario_solution = optimizer.solve(scenario_model)
results.append({
'increase': increase,
'objective': scenario_solution.objective_value,
'solve_time': scenario_solution.solve_time,
})
# Compare results
base_objective = results[0]['objective']
print(f"Scenario Analysis: {resource.name} Capacity")
print(f"{'Increase':<10} {'Objective':<15} {'Improvement':<15} {'Time':<10}")
print("-" * 60)
for r in results:
improvement = r['objective'] - base_objective
print(f"{r['increase']:<10} ${r['objective']:<14,.2f} ${improvement:<14,.2f} {r['solve_time']:<10.3f}s")
Practical Applications¶
Resource Planning¶
def recommend_capacity_investments(solution, resources, budget):
"""Recommend capacity investments given budget constraint."""
# Collect shadow prices
investments = []
for resource in resources:
constraint_name = f"capacity[{resource.id}]"
shadow_price = solution.get_shadow_price(constraint_name)
if shadow_price and shadow_price > 0:
# Calculate investment attractiveness
cost_per_unit = resource.expansion_cost # From data model
value_per_unit = shadow_price
roi = value_per_unit / cost_per_unit if cost_per_unit > 0 else 0
investments.append({
'resource': resource,
'shadow_price': shadow_price,
'cost_per_unit': cost_per_unit,
'roi': roi,
})
# Sort by ROI
investments.sort(key=lambda x: x['roi'], reverse=True)
print(f"Investment Recommendations (Budget: ${budget:,.2f})")
print(f"{'Resource':<20} {'Shadow Price':<15} {'Cost/Unit':<12} {'ROI':<10}")
print("-" * 70)
total_spent = 0
recommended = []
for inv in investments:
if total_spent >= budget:
break
# Simplified: invest in 10-unit increments
units = min(10, (budget - total_spent) / inv['cost_per_unit'])
if units >= 1:
cost = units * inv['cost_per_unit']
benefit = units * inv['shadow_price']
recommended.append({
'resource': inv['resource'].name,
'units': units,
'cost': cost,
'benefit': benefit,
})
total_spent += cost
print(f"{inv['resource'].name:<20} ${inv['shadow_price']:<14.2f} ${inv['cost_per_unit']:<11.2f} {inv['roi']:<10.2%}")
print(f"\nRecommended Investments:")
for rec in recommended:
print(f" {rec['resource']}: +{rec['units']:.0f} units (${rec['cost']:,.2f}) → benefit ${rec['benefit']:,.2f}")
Product Portfolio Optimization¶
def analyze_product_portfolio(solution, products):
"""Analyze and recommend product mix changes."""
print("Product Portfolio Analysis")
print(f"{'Product':<20} {'Quantity':<12} {'Reduced Cost':<15} {'Recommendation'}")
print("-" * 80)
for product in products:
var_name = f"production[{product.id}]"
quantity = solution.variables.get(var_name, 0)
reduced_cost = solution.get_reduced_cost(var_name)
if quantity > 0.01:
recommendation = "Keep in portfolio"
elif reduced_cost and reduced_cost > 0:
if reduced_cost < 10:
recommendation = f"Consider if profit +${reduced_cost:.2f}"
else:
recommendation = "Not competitive"
else:
recommendation = "Review"
rc_str = f"${reduced_cost:.2f}" if reduced_cost else "N/A"
print(f"{product.name:<20} {quantity:<12.2f} {rc_str:<15} {recommendation}")
Solver-Specific Features¶
Gurobi Sensitivity Analysis¶
# Gurobi provides detailed sensitivity information
# Access via solver-specific attributes (if using Gurobi directly)
# Example: SA RHS ranges
# model.SAObjUp, model.SAObjLow (requires solver-specific access)
CPLEX Sensitivity Analysis¶
# CPLEX provides ranges for coefficients and RHS
# Access via CPLEX-specific APIs
Limitations¶
When Shadow Prices Are Not Available¶
Shadow prices may not be available when:
Solving integer programs (MIP) - only at root node
Using certain solvers (CP-SAT, some OR-Tools backends)
Solution is infeasible or unbounded
Solver settings disable sensitivity analysis
shadow_price = solution.get_shadow_price("capacity")
if shadow_price is None:
print("Shadow price not available")
print("Possible reasons:")
print(" - Integer variables in model")
print(" - Solver doesn't support sensitivity")
print(" - Solution is not optimal")
Range Validity¶
Shadow prices are only valid within a certain range:
# Shadow price assumes small changes
# For large changes, re-solve the model
def validate_sensitivity_range(change_magnitude, typical_rhs):
"""Check if change is within reasonable sensitivity range."""
# Rule of thumb: changes < 10% of RHS
max_reasonable_change = 0.1 * typical_rhs
if change_magnitude > max_reasonable_change:
print(f"Warning: Change ({change_magnitude}) may be outside valid range")
print(f"Recommend re-solving for accurate results")
return False
return True
Best Practices¶
Check Availability
shadow_price = solution.get_shadow_price("constraint") if shadow_price is not None: # Use shadow price pass else: # Fall back to re-solving for sensitivity
Validate LP Relaxation
For MIP, shadow prices come from LP relaxation:
if model_has_integer_variables: print("Note: Shadow prices from LP relaxation") print("May not reflect integer variable impacts")
Small Changes Only
# Shadow prices valid for small changes max_safe_change = current_capacity * 0.05 # 5% change if proposed_change > max_safe_change: # Re-solve instead solution_new = optimizer.solve(modified_model)
Cross-Validate with Re-solving
# Estimate with shadow price estimated_benefit = shadow_price * change # Validate by re-solving actual_solution = optimizer.solve(modified_model) actual_benefit = actual_solution.objective_value - baseline_objective difference = abs(estimated_benefit - actual_benefit) if difference > 0.01 * abs(estimated_benefit): print(f"Warning: Estimate differs from actual by {difference:.2f}")
Next Steps¶
Accessing Solutions - Learn about accessing solution values
Goal Programming Solutions - Work with goal programming solutions
Solution Mapping - Map solutions to ORM models
Solution Module API - Full API reference