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:

  1. Solving integer programs (MIP) - only at root node

  2. Using certain solvers (CP-SAT, some OR-Tools backends)

  3. Solution is infeasible or unbounded

  4. 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

  1. 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
    
  2. 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")
    
  3. 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)
    
  4. 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