Constrained Optimization¶
Real-world problems often come with constraints. You might need to minimize cost while keeping all parameters within valid ranges, or ensure that parameters satisfy certain physical laws or business requirements. Constrained optimization handles these scenarios.
Types of Constraints¶
Bound Constraints¶
The simplest constraint type: each parameter must fall within a specified range.
from scipy import optimize
import numpy as np
# Cost function
def objective(x):
return (x[0] - 3)**2 + (x[1] - 2)**2
# Unconstrained: minimum at (3, 2)
result_unconstrained = optimize.minimize(objective, x0=[0, 0])
print(f"Unconstrained: {result_unconstrained.x}")
# With bounds: each parameter constrained to [0, 2.5]
bounds = [(0, 2.5), (0, 2.5)]
result_bounded = optimize.minimize(objective, x0=[0, 0], bounds=bounds,
method='L-BFGS-B')
print(f"Bounded: {result_bounded.x}")
# The solution is constrained to the feasible region
print(f"Unconstrained value: {objective(result_unconstrained.x):.4f}")
print(f"Bounded value: {objective(result_bounded.x):.4f}")
Key Methods Supporting Bounds:
- L-BFGS-B: Limited memory BFGS with bounds (recommended)
- TNC: Truncated Newton method
- SLSQP: Sequential least squares programming
- differential_evolution: Population-based (global)
- dual_annealing: Simulated annealing variant
Inequality Constraints¶
Constraints of the form \(g(\mathbf{x}) \geq 0\). When the constraint function is non-negative, the constraint is satisfied.
from scipy import optimize
import numpy as np
def objective(x):
"""Minimize x[0]^2 + x[1]^2."""
return x[0]**2 + x[1]**2
# Constraint: x[0] + x[1] >= 2 (equivalently, 2 - x[0] - x[1] <= 0)
# Express as: x[0] + x[1] - 2 >= 0
constraints = {'type': 'ineq', 'fun': lambda x: x[0] + x[1] - 2}
result = optimize.minimize(
objective,
x0=[1, 1],
constraints=constraints,
method='SLSQP'
)
print(f"Optimal x: {result.x}")
print(f"Objective value: {result.fun:.6f}")
print(f"Constraint value (should be >= 0): {x[0] + x[1] - 2:.6f}")
# Verify the solution lies on the constraint boundary
x_opt = result.x
print(f"x[0] + x[1] = {x_opt[0] + x_opt[1]:.6f} (should be ~2)")
Equality Constraints¶
Constraints of the form \(h(\mathbf{x}) = 0\). The constraint must be exactly satisfied.
from scipy import optimize
import numpy as np
def objective(x):
"""Minimize (x[0] - 3)^2 + (x[1] - 2)^2."""
return (x[0] - 3)**2 + (x[1] - 2)**2
# Constraint: x[0]^2 + x[1]^2 = 10 (point lies on circle of radius sqrt(10))
constraints = {'type': 'eq', 'fun': lambda x: x[0]**2 + x[1]**2 - 10}
result = optimize.minimize(
objective,
x0=[2, 2],
constraints=constraints,
method='SLSQP'
)
print(f"Optimal x: {result.x}")
print(f"Objective value: {result.fun:.6f}")
# Verify constraint
x_opt = result.x
constraint_value = x_opt[0]**2 + x_opt[1]**2
print(f"Constraint x[0]^2 + x[1]^2 = {constraint_value:.6f} (should be ~10)")
Constraint Objects: LinearConstraint and NonlinearConstraint¶
For more complex constraint specifications, scipy provides constraint objects that offer better organization and allow multiple constraints:
LinearConstraint¶
Linear constraints of the form \(\mathbf{lb} \leq A\mathbf{x} \leq \mathbf{ub}\).
from scipy import optimize
import numpy as np
def objective(x):
"""Minimize x[0]^2 + x[1]^2."""
return x[0]**2 + x[1]**2
# Linear constraint: 2*x[0] + x[1] <= 4
# Form: A @ x <= b, so A @ x - b <= 0
# Using LinearConstraint: -inf <= A @ x <= 4
A = np.array([[2, 1]]) # Constraint coefficients
lb = -np.inf
ub = 4
constraint = optimize.LinearConstraint(A, lb, ub)
result = optimize.minimize(
objective,
x0=[0, 0],
constraints=constraint,
method='SLSQP'
)
print(f"Optimal x: {result.x}")
print(f"Constraint A @ x = {A @ result.x:.6f} (should be <= 4)")
NonlinearConstraint¶
Nonlinear constraints of the form \(\mathbf{lb} \leq g(\mathbf{x}) \leq \mathbf{ub}\).
from scipy import optimize
import numpy as np
def objective(x):
"""Minimize x[0]^2 + x[1]^2."""
return x[0]**2 + x[1]**2
def constraint_func(x):
"""Nonlinear constraint: x[0]^2 + 2*x[1]^2."""
return np.array([x[0]**2 + 2*x[1]**2])
# Constraint: 0.5 <= x[0]^2 + 2*x[1]^2 <= 4
constraint = optimize.NonlinearConstraint(constraint_func, 0.5, 4)
result = optimize.minimize(
objective,
x0=[1, 1],
constraints=constraint,
method='SLSQP'
)
print(f"Optimal x: {result.x}")
print(f"Constraint value: {constraint_func(result.x)[0]:.6f}")
print(f"(should be in [0.5, 4])")
Practical Example: Portfolio Optimization¶
A classic constrained optimization problem: allocate capital among assets to minimize risk while achieving target return.
import numpy as np
from scipy import optimize
import matplotlib.pyplot as plt
# Expected returns and covariance matrix for 4 assets
expected_returns = np.array([0.05, 0.08, 0.12, 0.10])
cov_matrix = np.array([
[0.01, 0.002, 0.005, 0.003],
[0.002, 0.015, 0.008, 0.006],
[0.005, 0.008, 0.040, 0.010],
[0.003, 0.006, 0.010, 0.025]
])
def portfolio_variance(weights):
"""Calculate portfolio variance (risk)."""
return weights @ cov_matrix @ weights
def portfolio_return(weights):
"""Calculate portfolio return."""
return weights @ expected_returns
# Constraints
def return_constraint(weights):
"""Constraint: return >= 0.08."""
return portfolio_return(weights) - 0.08
constraints = [
{'type': 'eq', 'fun': lambda w: np.sum(w) - 1}, # Sum to 1
{'type': 'ineq', 'fun': return_constraint} # Return >= 0.08
]
# Bounds: each weight in [0, 1]
bounds = [(0, 1)] * 4
# Initial guess: equal weight
w0 = np.array([0.25, 0.25, 0.25, 0.25])
# Optimize
result = optimize.minimize(
portfolio_variance,
w0,
constraints=constraints,
bounds=bounds,
method='SLSQP'
)
print("Optimal Portfolio:")
print(f"Weights: {result.x}")
print(f"Return: {portfolio_return(result.x):.4f}")
print(f"Risk (std): {np.sqrt(portfolio_variance(result.x)):.4f}")
# Explore efficient frontier
target_returns = np.linspace(0.05, 0.12, 20)
variances = []
for target_return in target_returns:
constraints = [
{'type': 'eq', 'fun': lambda w: np.sum(w) - 1},
{'type': 'eq', 'fun': lambda w: portfolio_return(w) - target_return}
]
result = optimize.minimize(
portfolio_variance,
w0,
constraints=constraints,
bounds=bounds,
method='SLSQP'
)
if result.success:
variances.append(portfolio_variance(result.x))
# Plot efficient frontier
fig, ax = plt.subplots(figsize=(10, 6))
risks = np.sqrt(variances)
ax.plot(risks, target_returns[:len(risks)], 'b-', linewidth=2)
ax.scatter(risks, target_returns[:len(risks)], c=target_returns[:len(risks)],
cmap='viridis')
ax.set_xlabel('Risk (Standard Deviation)')
ax.set_ylabel('Return')
ax.set_title('Efficient Frontier')
ax.grid(True, alpha=0.3)
plt.colorbar(ax.collections[0], ax=ax, label='Target Return')
plt.tight_layout()
plt.show()
Handling Multiple Constraints¶
When you have many constraints, using a list or proper constraint objects is clearer:
from scipy import optimize
import numpy as np
def objective(x):
return (x[0] - 3)**2 + (x[1] - 1)**2 + (x[2] - 2)**2
# Multiple constraints
constraints = [
# Equality constraint: x[0] + x[1] = 2
{'type': 'eq', 'fun': lambda x: x[0] + x[1] - 2},
# Inequality constraint: x[2] >= 1
{'type': 'ineq', 'fun': lambda x: x[2] - 1},
# Inequality constraint: x[0] + x[1] + x[2] <= 5
{'type': 'ineq', 'fun': lambda x: 5 - (x[0] + x[1] + x[2])}
]
bounds = [(None, None)] * 3 # No direct bounds
result = optimize.minimize(
objective,
x0=[0, 0, 0],
constraints=constraints,
bounds=bounds,
method='SLSQP'
)
print(f"Optimal x: {result.x}")
print(f"Objective: {objective(result.x):.6f}")
# Verify constraints
x = result.x
print(f"x[0] + x[1] = {x[0] + x[1]:.6f} (should be 2)")
print(f"x[2] = {x[2]:.6f} (should be >= 1)")
print(f"x[0] + x[1] + x[2] = {np.sum(x):.6f} (should be <= 5)")
Understanding Infeasible Constraints¶
Sometimes constraints cannot all be satisfied simultaneously. The optimizer will do its best:
from scipy import optimize
import numpy as np
def objective(x):
return x[0]**2 + x[1]**2
# Infeasible constraints: x[0] + x[1] >= 2 AND x[0] + x[1] <= 1
constraints = [
{'type': 'ineq', 'fun': lambda x: x[0] + x[1] - 2}, # >= 2
{'type': 'ineq', 'fun': lambda x: 1 - (x[0] + x[1])} # <= 1
]
result = optimize.minimize(
objective,
x0=[0, 0],
constraints=constraints,
method='SLSQP'
)
print(f"Success: {result.success}")
print(f"Optimal x: {result.x}")
print(f"x[0] + x[1] = {result.x[0] + result.x[1]:.6f}")
print(f"Message: {result.message}")
Constraint Jacobians¶
For better performance, you can provide gradients of the constraint functions:
from scipy import optimize
import numpy as np
def objective(x):
return (x[0] - 2)**2 + (x[1] - 3)**2
def constraint_func(x):
"""x[0]^2 + x[1]^2 = 10."""
return x[0]**2 + x[1]**2 - 10
def constraint_jac(x):
"""Gradient of constraint."""
return np.array([2*x[0], 2*x[1]])
# Define constraint with Jacobian
constraints = {
'type': 'eq',
'fun': constraint_func,
'jac': constraint_jac
}
result = optimize.minimize(
objective,
x0=[2, 2],
constraints=constraints,
method='SLSQP'
)
print(f"Optimal x: {result.x}")
print(f"Objective: {objective(result.x):.6f}")
Choosing Methods for Constrained Optimization¶
| Method | Bounds | Equality | Inequality | Notes |
|---|---|---|---|---|
| L-BFGS-B | Yes | No | No | Fast, good for bounds only |
| SLSQP | Yes | Yes | Yes | General-purpose, reliable |
| TNC | Yes | No | No | Good for large problems |
| COBYLA | No | Yes | Yes | Robust, no derivatives |
| trust-constr | Yes | Yes | Yes | Modern, good for large-scale |
from scipy import optimize
import numpy as np
def objective(x):
return (x[0] - 3)**2 + (x[1] - 2)**2
def constraint(x):
return x[0] + x[1] - 2 # x[0] + x[1] >= 2
# Choose method based on your constraints
constraints = {'type': 'ineq', 'fun': constraint}
bounds = [(0, 10), (0, 10)]
# SLSQP is most general
result = optimize.minimize(
objective,
x0=[0, 0],
constraints=constraints,
bounds=bounds,
method='SLSQP'
)
print(f"Result: {result.x}")
Summary¶
Key Points:
- Bounds are easiest to implement and handled efficiently by L-BFGS-B
- Inequality constraints (\(g(\mathbf{x}) \geq 0\)) expand what's possible
- Equality constraints (\(h(\mathbf{x}) = 0\)) further restrict the solution space
- SLSQP is the most general-purpose constrained method
- LinearConstraint/NonlinearConstraint provide cleaner syntax for complex problems
- Always verify constraints are satisfied in the final solution
- Check feasibility before optimizing (infeasible constraints = no solution)
Next sections cover specialized optimization for fitting curves to data and finding roots of functions.