Constraint Relaxation¶
Understand how LumiX transforms hard constraints into soft constraints with deviation variables.
Overview¶
Constraint relaxation is the process of converting a hard constraint (must be satisfied) into a soft constraint (can be violated with a penalty). This is the foundation of goal programming.
The relaxation creates:
Equality constraint with deviation variables
Positive deviation variable (over-achievement)
Negative deviation variable (under-achievement)
Goal instances that serve as the data source for deviations
Relaxation Mathematics¶
Basic Transformation¶
A hard constraint is transformed by adding deviation variables:
Hard Constraint: expr ≤ rhs
Relaxed Form: expr + neg_dev - pos_dev = rhs
neg_dev ≥ 0
pos_dev ≥ 0
Interpretation:
If
expr < rhs:neg_dev > 0,pos_dev = 0(under-achievement)If
expr > rhs:neg_dev = 0,pos_dev > 0(over-achievement)If
expr = rhs:neg_dev = 0,pos_dev = 0(goal achieved)
Constraint Types¶
Different constraint senses require different deviation penalties:
Less-Than-or-Equal (LE: ≤):
Original: expr ≤ rhs
Relaxed: expr + neg_dev - pos_dev = rhs
Minimize: pos_dev (over-achievement is undesired)
Example: overtime ≤ 40 hours
- Want to stay under 40 hours
- Exceeding 40 (pos_dev > 0) is penalized
Greater-Than-or-Equal (GE: ≥):
Original: expr ≥ rhs
Relaxed: expr + neg_dev - pos_dev = rhs
Minimize: neg_dev (under-achievement is undesired)
Example: production ≥ 1000 units
- Want at least 1000 units
- Producing less (neg_dev > 0) is penalized
Equality (EQ: =):
Original: expr = rhs
Relaxed: expr + neg_dev - pos_dev = rhs
Minimize: pos_dev + neg_dev (any deviation is undesired)
Example: budget = 50000
- Want exactly 50000
- Both over and under are penalized
Automatic Relaxation¶
Using .as_goal()¶
LumiX automatically relaxes constraints when you use .as_goal():
from lumix import LXConstraint
from lumix.core.expressions import LXLinearExpression
# Define a hard constraint
demand_constraint = (
LXConstraint[Product]("demand")
.expression(production_expr)
.ge()
.rhs(lambda p: p.demand_target)
.from_data(products)
)
# Mark as goal - automatic relaxation happens!
demand_goal = (
LXConstraint[Product]("demand")
.expression(production_expr)
.ge()
.rhs(lambda p: p.demand_target)
.as_goal(priority=1, weight=1.0) # This triggers relaxation
.from_data(products)
)
# Behind the scenes, LumiX:
# 1. Creates pos_dev and neg_dev variables
# 2. Converts to equality: production + neg_dev - pos_dev = demand_target
# 3. Adds neg_dev to objective (since GE constraint)
What Happens Internally¶
graph LR
A[Original Constraint] -->|as_goal| B[LXGoalMetadata]
B --> C[relax_constraint]
C --> D[Create Goal Instances]
C --> E[Create Deviation Variables]
C --> F[Build Equality Constraint]
D --> E
E --> F
F --> G[RelaxedConstraint]
style A fill:#e1f5ff
style B fill:#fff4e1
style C fill:#ffe1e1
style D fill:#e1ffe1
style E fill:#f0e1ff
style F fill:#e8f4f8
style G fill:#ffe8e8
Manual Relaxation¶
Direct API Usage¶
You can manually relax constraints using the relaxation API:
from lumix.goal_programming import relax_constraint, LXGoalMetadata
from lumix.core.enums import LXConstraintSense
# Create goal metadata
metadata = LXGoalMetadata(
priority=1,
weight=1.0,
constraint_sense=LXConstraintSense.GE
)
# Define constraint (not a goal yet)
demand_constraint = (
LXConstraint[Product]("demand")
.expression(production_expr)
.ge()
.rhs(lambda p: p.demand_target)
.from_data(products)
)
# Manually relax
relaxed = relax_constraint(demand_constraint, metadata)
# Access components
equality_constraint = relaxed.constraint
pos_deviation_var = relaxed.pos_deviation
neg_deviation_var = relaxed.neg_deviation
goal_instances = relaxed.goal_instances
metadata_ref = relaxed.goal_metadata
Batch Relaxation¶
Relax multiple constraints at once:
from lumix.goal_programming import relax_constraints
constraints = [demand_constraint, quality_constraint, cost_constraint]
metadata_map = {
"demand": LXGoalMetadata(priority=1, weight=1.0, constraint_sense=LXConstraintSense.GE),
"quality": LXGoalMetadata(priority=2, weight=1.0, constraint_sense=LXConstraintSense.GE),
"cost": LXGoalMetadata(priority=3, weight=0.5, constraint_sense=LXConstraintSense.LE),
}
relaxed_list = relax_constraints(constraints, metadata_map)
for relaxed in relaxed_list:
print(f"Relaxed: {relaxed.constraint.name}")
print(f" Positive deviation variable: {relaxed.pos_deviation.name}")
print(f" Negative deviation variable: {relaxed.neg_deviation.name}")
print(f" Goal instances: {len(relaxed.goal_instances)}")
Goal Instances¶
Semantic Indexing¶
Deviation variables are indexed by Goal instances, not by the original data. This provides semantic meaning to deviations:
# Each product gets a Goal instance
# Goal instance contains metadata about the goal
for goal in relaxed.goal_instances:
print(f"Goal ID: {goal.id}")
print(f" Constraint: {goal.constraint_name}")
print(f" Priority: {goal.priority}")
print(f" Weight: {goal.weight}")
print(f" Target Value: {goal.target_value}")
print(f" Instance ID: {goal.instance_id}")
Example for indexed constraint:
# Constraint: production[product] >= demand[product]
# Creates goals:
# - Goal("demand_product_A", constraint_name="demand", instance_id="product_A", ...)
# - Goal("demand_product_B", constraint_name="demand", instance_id="product_B", ...)
#
# Deviation variables:
# - pos_dev[Goal("demand_product_A")]
# - pos_dev[Goal("demand_product_B")]
# - neg_dev[Goal("demand_product_A")]
# - neg_dev[Goal("demand_product_B")]
Business Value¶
Goal instances make deviations meaningful:
Instead of:
"neg_dev[0] = 10" (What does this mean?)
You get:
"Route 5 needs 3 additional buses"
"Product A has 20 units excess inventory"
"Department B is 5 hours over overtime limit"
Practical Examples¶
Production Planning¶
from dataclasses import dataclass
@dataclass
class Product:
id: str
demand_target: float
production_cost: float
products = [
Product("A", demand_target=100, production_cost=5),
Product("B", demand_target=150, production_cost=6),
]
# Define production variable
production = (
LXVariable[Product, float]("production")
.continuous()
.bounds(lower=0)
.indexed_by(lambda p: p.id)
.from_data(products)
)
# Goal: Meet demand
demand_goal = (
LXConstraint[Product]("demand")
.expression(
LXLinearExpression()
.add_term(production, coeff=1.0)
)
.ge()
.rhs(lambda p: p.demand_target)
.as_goal(priority=1, weight=1.0)
.from_data(products)
)
# When relaxed, creates:
# - Goal instances: [Goal("demand_A", ...), Goal("demand_B", ...)]
# - Variables: pos_dev[Goal], neg_dev[Goal] for each goal
# - Constraint: production[p] + neg_dev - pos_dev = p.demand_target
Resource Constraints with Goals¶
@dataclass
class Resource:
id: str
capacity: float
target_utilization: float # e.g., 0.8 for 80%
# Hard constraint: Cannot exceed capacity
capacity_hard = (
LXConstraint[Resource]("capacity")
.expression(usage_expr)
.le()
.rhs(lambda r: r.capacity)
.from_data(resources)
# No .as_goal() - stays hard constraint
)
# Soft goal: Target utilization
utilization_goal = (
LXConstraint[Resource]("utilization")
.expression(usage_expr)
.eq() # Want exact target
.rhs(lambda r: r.capacity * r.target_utilization)
.as_goal(priority=2, weight=1.0)
.from_data(resources)
)
# Result:
# - Capacity is hard limit (never exceeded)
# - Utilization can deviate but is penalized
Deviation Variable Details¶
Naming Convention¶
Deviation variables follow a standard naming pattern:
from lumix.goal_programming import get_deviation_var_name
# For a goal named "demand"
pos_name = get_deviation_var_name("demand", "pos") # "demand_pos_dev"
neg_name = get_deviation_var_name("demand", "neg") # "demand_neg_dev"
Variable Bounds¶
Deviation variables are always non-negative continuous variables:
# Automatically created as:
pos_deviation = (
LXVariable[LXGoal, float]("demand_pos_dev")
.continuous()
.bounds(lower=0.0) # Non-negative
.indexed_by(lambda g: g.id)
.from_data(goal_instances)
)
Accessing in Solution¶
Deviation values are available in the solution:
solution = optimizer.solve(model)
# Via goal deviations
deviations = solution.get_goal_deviations("demand")
print(f"Positive deviations: {deviations['pos']}")
print(f"Negative deviations: {deviations['neg']}")
# Via variable access
pos_dev_values = solution.get_variable(relaxed.pos_deviation)
neg_dev_values = solution.get_variable(relaxed.neg_deviation)
Understanding Undesired Deviations¶
Deviation Selection¶
The relaxation automatically determines which deviations to minimize:
# For LE (≤)
undesired = relaxed.goal_metadata.is_pos_undesired() # True
# Minimizes: pos_dev (over-achievement)
# For GE (≥)
undesired = relaxed.goal_metadata.is_neg_undesired() # True
# Minimizes: neg_dev (under-achievement)
# For EQ (=)
undesired_pos = relaxed.goal_metadata.is_pos_undesired() # True
undesired_neg = relaxed.goal_metadata.is_neg_undesired() # True
# Minimizes: both pos_dev and neg_dev
Getting Undesired Variables¶
# Get list of deviation variables to include in objective
undesired_vars = relaxed.get_undesired_variables()
# For GE constraint, returns: [neg_deviation]
# For LE constraint, returns: [pos_deviation]
# For EQ constraint, returns: [pos_deviation, neg_deviation]
Best Practices¶
Mix Hard and Soft Constraints
# Hard constraints for physical limits capacity = ( LXConstraint[Resource]("capacity") .expression(usage_expr) .le() .rhs(lambda r: r.max_capacity) # No .as_goal() ) # Soft goals for targets target = ( LXConstraint[Resource]("target") .expression(usage_expr) .eq() .rhs(lambda r: r.target_usage) .as_goal(priority=1, weight=1.0) )
Choose Appropriate Constraint Sense
# Use GE for minimum requirements min_production.ge().rhs(min_qty).as_goal(priority=1, weight=1.0) # Use LE for maximum limits max_overtime.le().rhs(max_hours).as_goal(priority=2, weight=1.0) # Use EQ only when exact target is truly needed exact_budget.eq().rhs(budget).as_goal(priority=1, weight=1.0)
Understand Goal Semantics
# Goal instances provide meaning for goal in relaxed.goal_instances: # Can identify specific instances that deviated if goal.instance_id == "product_A": print(f"Product A target: {goal.target_value}")
Check Relaxation Results
# Verify relaxation created expected structure relaxed = relax_constraint(constraint, metadata) print(f"Original sense: {metadata.constraint_sense}") print(f"Relaxed to EQ: {relaxed.constraint.sense == LXConstraintSense.EQ}") print(f"Deviation variables: {relaxed.pos_deviation.name}, {relaxed.neg_deviation.name}") print(f"Goal instances: {len(relaxed.goal_instances)}")
Next Steps¶
Objective Building - Learn how deviation variables are used in objectives
Weighted Goal Programming - Apply relaxation in weighted goal programming
Sequential Goal Programming - Apply relaxation in sequential goal programming
Goal Programming Module API - Full API reference
Goal Programming Solutions - Accessing deviations in solutions