Parallel Multi-Start Optimization: Finding Global Optima
BusinessMath Quarterly Series
16 min read
Part 32 of 12-Week BusinessMath Series
What You’ll Learn
- Why single-start optimization can get stuck in local minima
- Using ParallelOptimizer to try multiple starting points simultaneously
- Leveraging Swift Concurrency (async/await) for parallel execution
- Choosing optimal number of starting points
- Analyzing success rates and result distributions
- When multi-start optimization provides the biggest benefit
The Problem
Many optimization problems have multiple local minima:
- Portfolio optimization with transaction costs → discrete jumps create local traps
- Complex business models with discontinuities → gradient methods get stuck
- Non-convex objectives → single-start methods find nearest local minimum, not global
Example: Portfolio optimization starting from equal weights might find a local optimum with Sharpe ratio 0.85. The global optimum with Sharpe ratio 1.12 exists, but gradient-based methods can’t escape the local basin.
Your optimization finds a solution, but not necessarily the best solution.
The Solution
BusinessMath’s ParallelOptimizer runs the same optimization algorithm from multiple random starting points in parallel, then returns the best result found. This dramatically increases the chance of finding the global optimum.
Automatic Parallel Multi-Start Optimization
Business Problem: Optimize portfolio allocation, but don’t get stuck in local minima.
import BusinessMath
import Foundation
let assets = ["US Stocks", "Intl Stocks", "Bonds", "Real Estate"]
let expectedReturns = VectorN([0.10, 0.12, 0.04, 0.09])
let riskFreeRate = 0.03
// Covariance matrix
let covarianceMatrix = [
[0.0400, 0.0150, 0.0020, 0.0180], // US Stocks
[0.0150, 0.0625, 0.0015, 0.0200], // Intl Stocks
[0.0020, 0.0015, 0.0036, 0.0010], // Bonds
[0.0180, 0.0200, 0.0010, 0.0400] // Real Estate
]
// Objective: Maximize Sharpe ratio
let portfolioObjective: @Sendable (VectorN
) -> Double = { weights in let expectedReturn = weights.dot(expectedReturns) var variance = 0.0 for i in 0..
>.budgetConstraint let longOnlyConstraints = MultivariateConstraint
>.nonNegativity(dimension: assets.count) let constraints: [MultivariateConstraint
>] = [budgetConstraint] + longOnlyConstraints // Create parallel multi-start optimizer let parallelOptimizer = ParallelOptimizer
>( algorithm: .inequality, // Use inequality-constrained optimizer numberOfStarts: 20, // Try 20 different starting points maxIterations: 1000, tolerance: 1e-6 ) // Define search region for random starting points let searchRegion = ( lower: VectorN(repeating: 0.0, count: assets.count), upper: VectorN(repeating: 1.0, count: assets.count) ) // Run optimization in parallel (async/await) // Note: Wrap in Task for playground execution Task { do { let result = try await parallelOptimizer.optimize( objective: portfolioObjective, searchRegion: searchRegion, constraints: constraints ) print("Parallel Multi-Start Optimization:") print(" Attempts: \(result.allResults.count)") print(" Converged: \(result.allResults.filter(\.converged).count)") print(" Success rate: \(result.successRate.percent())") print(" Best Sharpe ratio: \((-result.objectiveValue).number())") print("\nBest Solution:") for (asset, weight) in zip(assets, result.solution.toArray()) { if weight > 0.01 { print(" \(asset): \(weight.percent())") } } } catch { print("Optimization failed: \(error)") } }
Comparing Single-Start vs Multi-Start
Pattern: See how multi-start improves over single-start optimization.
// Single-start optimization (baseline)
let singleStartOptimizer = AdaptiveOptimizer
>() let singleResult = try singleStartOptimizer.optimize( objective: portfolioObjective, initialGuess: VectorN.equalWeights(dimension: assets.count), constraints: constraints ) print("Single-Start Result:") print(" Sharpe ratio: \((-singleResult.objectiveValue).number())") print(" Algorithm: \(singleResult.algorithmUsed)") // Multi-start optimization Task { do { let multiStartResult = try await parallelOptimizer.optimize( objective: portfolioObjective, searchRegion: searchRegion, constraints: constraints ) print("\nMulti-Start Result (20 starting points):") print(" Best Sharpe ratio: \((-multiStartResult.objectiveValue).number())") print(" Success rate: \(multiStartResult.successRate.percent())") // Compare let improvement = (-multiStartResult.objectiveValue) / (-singleResult.objectiveValue) - 1.0 print("\nImprovement: \(improvement.percent(1))") } catch { print("Multi-start optimization failed: \(error)") } }
Analyzing Result Distribution
Pattern: Understand variation across different starting points.
Task {
do {
// Run multi-start optimization
let result = try await parallelOptimizer.optimize(
objective: portfolioObjective,
searchRegion: searchRegion,
constraints: constraints
)
// Analyze distribution of objectives found
let objectives = result.allResults.map(\.value)
let sortedObjectives = objectives.sorted()
print("Result Distribution:")
print(" Best: \(sortedObjectives.first?.number() ?? "N/A")")
print(" Median: \(sortedObjectives[sortedObjectives.count / 2].number())")
print(" Worst: \(sortedObjectives.last?.number() ?? "N/A")")
print(" Range: \((sortedObjectives.last! - sortedObjectives.first!).number())")
// Show how many found the global optimum (within 1% of best)
let globalThreshold = sortedObjectives.first! * 1.01
let globalCount = sortedObjectives.filter { $0 <= globalThreshold }.count
print("\nFound global optimum: \(globalCount)/\(sortedObjectives.count) attempts")
print(" (\((Double(globalCount) / Double(sortedObjectives.count)).percent(0)))")
} catch {
print("Analysis failed: \(error)")
}
}
Choosing Number of Starting Points
Pattern: Trade off between solution quality and computation time.
Task {
// Test different numbers of starting points
for numStarts in [5, 10, 20, 50] {
do {
let optimizer = ParallelOptimizer
>( algorithm: .inequality, numberOfStarts: numStarts, maxIterations: 500, tolerance: 1e-5 ) let startTime = Date() let result = try await optimizer.optimize( objective: portfolioObjective, searchRegion: searchRegion, constraints: constraints ) let elapsedTime = Date().timeIntervalSince(startTime) print("\n\(numStarts) starting points:") print(" Best objective: \(result.objectiveValue.number())") print(" Success rate: \(result.successRate.percent())") print(" Time: \(elapsedTime.number(2))s") print(" Time per start: \((elapsedTime / Double(numStarts)).number(2))s") } catch { print("\n\(numStarts) starting points: FAILED") } } }
Rule of Thumb:
- 5-10 starts: Quick exploration, may miss global optimum
- 20-30 starts: Good balance for most problems
- 50-100 starts: Thorough search for critical applications
- More starts: Diminishing returns (20 → 40 often finds same optimum)
How It Works
Parallel Execution with Swift Concurrency
BusinessMath uses Swift’s async/await and TaskGroup to run optimizations in parallel:
// Inside ParallelOptimizer (simplified)
let results = try await withThrowingTaskGroup(
of: (startingPoint: V, result: MultivariateOptimizationResult
).self ) { group in // Add a task for each starting point for start in startingPoints { group.addTask { let result = try ParallelOptimizer.runSingleOptimization( algorithm: algorithm, objective: objective, initialGuess: start, constraints: constraints ) return (startingPoint: start, result: result) } } // Collect all results as they complete var allResults: [(V, MultivariateOptimizationResult
)] = [] for try await (start, result) in group { allResults.append((start, result)) } return allResults } // Find best result (lowest objective value) let best = results.min(by: { $0.1.value < $1.1.value })!
Algorithm Selection
ParallelOptimizer supports multiple base algorithms:
public enum Algorithm {
case gradientDescent(learningRate: Double)
case newtonRaphson
case constrained
case inequality
}
// Choose based on problem characteristics
let optimizer = ParallelOptimizer
>( algorithm: .inequality, // For problems with inequality constraints numberOfStarts: 20 ) // Or use gradient descent for unconstrained problems let unconstrainedOptimizer = ParallelOptimizer
>( algorithm: .gradientDescent(learningRate: 0.01), numberOfStarts: 20 )
Performance Characteristics
Speedup depends on:
- Number of CPU cores: 8-core M3 can run 8 optimizations simultaneously
- Objective function cost: Expensive functions (>10ms) benefit most
- Number of starting points: 20 starts on 8 cores ≈ 2.5× speedup
Typical Results (M3 MacBook Pro, 8 cores):
| Starting Points | Sequential Time | Parallel Time | Speedup |
|---|---|---|---|
| 5 starts | 15s | 5s | 3.0× |
| 10 starts | 30s | 8s | 3.75× |
| 20 starts | 60s | 15s | 4.0× |
| 50 starts | 150s | 35s | 4.3× |
Key Insight: Speedup plateaus around number of physical cores (8 for M3).
When to Use Multi-Start Optimization
Strong Candidates
Use multi-start when:
- Objective has multiple local minima
- Problem is non-convex
- Single-start results vary significantly with initial guess
- Solution quality is critical (willing to spend more time)
Examples:
- Portfolio optimization with transaction costs
- Facility location problems
- Machine learning hyperparameter tuning
- Supply chain network design
Weak Candidates
Don’t use multi-start when:
- Problem is convex (single local minimum = global minimum)
- Objective is very expensive (>1 minute per evaluation)
- Quick approximate solution is acceptable
- Single-start consistently finds good solutions
Examples:
- Simple least-squares regression (convex)
- Linear programming (convex)
- Unconstrained quadratic problems (convex)
Hybrid Approach
Pattern: Use multi-start to find good region, then refine with single-start.
Task {
do {
// Phase 1: Broad search with low accuracy
let explorationOptimizer = ParallelOptimizer
>( algorithm: .gradientDescent(learningRate: 0.01), numberOfStarts: 50, maxIterations: 100, // Low iterations tolerance: 1e-3 // Loose tolerance ) let roughSolution = try await explorationOptimizer.optimize( objective: portfolioObjective, searchRegion: searchRegion, constraints: constraints ) print("Phase 1 (exploration): Sharpe \((-roughSolution.objectiveValue).number())") // Phase 2: Refine best solution with high accuracy let refinementOptimizer = AdaptiveOptimizer
>( maxIterations: 2000, tolerance: 1e-8 ) let finalSolution = try refinementOptimizer.optimize( objective: portfolioObjective, initialGuess: roughSolution.solution, constraints: constraints ) print("Phase 2 (refinement): Sharpe \((-finalSolution.objectiveValue).number())") } catch { print("Hybrid optimization failed: \(error)") } }
Real-World Application
Investment Firm: Quarterly Rebalancing
Company: Mid-sized investment firm managing $2B across 80 assets Challenge: Optimize portfolio quarterly, accounting for:
- Non-linear transaction costs
- Tax-loss harvesting opportunities
- Regulatory diversification requirements
Problem Characteristics:
- 80 variables (asset weights)
- Non-convex objective (transaction costs create discontinuities)
- 50+ constraints (position limits, sector allocations, tax rules)
Single-Start Results (10 trials from different starting points):
- Best Sharpe ratio: 0.94
- Worst Sharpe ratio: 0.78
- Average: 0.85
- High variance suggests local minima problem
Multi-Start Solution:
import BusinessMath
import Foundation
// Simplified 80-asset portfolio problem
let numAssets = 80
let portfolioValue = 2_000_000_000.0 // $2B AUM
// Generate realistic expected returns (4% to 15%, mean ~9%)
let expectedReturns80 = VectorN((0..
) -> Double = { weights in // Expected return let expectedReturn = weights.dot(expectedReturns80) // Portfolio variance var variance = 0.0 for i in 0..
>.budgetConstraint let longOnlyConstraints80 = MultivariateConstraint
>.nonNegativity(dimension: numAssets) // Position limits: no more than 5% per asset (diversification requirement) let positionLimits80 = (0..
>.inequality { w in w[i] - 0.05 // w[i] ≤ 5% } } let allConstraints80 = [budgetConstraint80] + longOnlyConstraints80 + positionLimits80 // Multi-start optimization Task { do { print(String(repeating: "=", count: 70)) print("REAL-WORLD EXAMPLE: 80-ASSET PORTFOLIO REBALANCING") print(String(repeating: "=", count: 70)) print("Portfolio value: $\((portfolioValue / 1_000_000_000).number(1))B") print("Number of assets: \(numAssets)") print("Transaction costs: \(transactionCostBps) bps") print() let robustOptimizer = ParallelOptimizer
>( algorithm: .inequality, numberOfStarts: 30, maxIterations: 1500, tolerance: 1e-6 ) let startTime = Date() let result = try await robustOptimizer.optimize( objective: objectiveWithCosts, searchRegion: ( lower: VectorN(repeating: 0.0, count: numAssets), upper: VectorN(repeating: 0.05, count: numAssets) // Max 5% per asset ), constraints: allConstraints80 ) let elapsedTime = Date().timeIntervalSince(startTime) print("Multi-Start Optimization (30 starts):") print(" Best Sharpe ratio: \((-result.objectiveValue).number())") print(" Success rate: \(result.successRate.percent())") print(" Total time: \((elapsedTime / 60).number(1)) minutes") // Calculate turnover var totalTurnover = 0.0 var numPositions = 0 for i in 0..
0.001 { numPositions += 1 } } print("\nPortfolio Characteristics:") print(" Active positions: \(numPositions)/\(numAssets)") print(" Total turnover: \(totalTurnover.percent(1))") print(" Trading costs: $\((portfolioValue * totalTurnover * transactionCostBps / 10000).currency(0))") // Show top 10 positions let topPositions = result.solution.toArray() .enumerated() .sorted { $0.element > $1.element } .prefix(10) print("\nTop 10 Positions:") for (i, (idx, weight)) in topPositions.enumerated() { print(" \(i+1). Asset \(idx): \(weight.percent(2))") } } catch { print("Robust optimization failed: \(error)") } }
Results:
- Best Sharpe ratio found: 1.08 (14% better than single-start average)
- Consistent across rebalancing periods
- Success rate: 85% of starts converged
- Computation time: 12 minutes (acceptable for quarterly rebalancing)
- Annual alpha improvement: +$16M (0.8% on $2B AUM)
Try It Yourself
Click to expand full playground code
import BusinessMath
import Foundation
let assets = ["US Stocks", "Intl Stocks", "Bonds", "Real Estate"]
let expectedReturns = VectorN([0.10, 0.12, 0.04, 0.09])
let riskFreeRate = 0.03
// Covariance matrix
let covarianceMatrix = [
[0.0400, 0.0150, 0.0020, 0.0180], // US Stocks
[0.0150, 0.0625, 0.0015, 0.0200], // Intl Stocks
[0.0020, 0.0015, 0.0036, 0.0010], // Bonds
[0.0180, 0.0200, 0.0010, 0.0400] // Real Estate
]
// Objective: Maximize Sharpe ratio
let portfolioObjective: @Sendable (VectorN
) -> Double = { weights in let expectedReturn = weights.dot(expectedReturns) var variance = 0.0 for i in 0..
>.budgetConstraint let longOnlyConstraints = MultivariateConstraint
>.nonNegativity(dimension: assets.count) let constraints: [MultivariateConstraint
>] = [budgetConstraint] + longOnlyConstraints // Create parallel multi-start optimizer let parallelOptimizer = ParallelOptimizer
>( algorithm: .inequality, // Use inequality-constrained optimizer numberOfStarts: 20, // Try 20 different starting points maxIterations: 1000, tolerance: 1e-6 ) // Define search region for random starting points let searchRegion = ( lower: VectorN(repeating: 0.0, count: assets.count), upper: VectorN(repeating: 1.0, count: assets.count) ) // Run optimization in parallel (async/await) // Note: Playgrounds require Task wrapper for async code Task { do { let result = try await parallelOptimizer.optimize( objective: portfolioObjective, searchRegion: searchRegion, constraints: constraints ) print("Parallel Multi-Start Optimization:") print(" Attempts: \(result.allResults.count)") print(" Converged: \(result.allResults.filter(\.converged).count)") print(" Success rate: \(result.successRate.percent())") print(" Best Sharpe ratio: \((-result.objectiveValue).number())") print("\nBest Solution:") for (asset, weight) in zip(assets, result.solution.toArray()) { if weight > 0.01 { print(" \(asset): \(weight.percent())") } } } catch { print("Optimization failed: \(error)") } // MARK: - Single-Start vs. Multi-Start Optimization // Single-start optimization (baseline) let singleStartOptimizer = AdaptiveOptimizer
>() let singleResult = try singleStartOptimizer.optimize( objective: portfolioObjective, initialGuess: VectorN.equalWeights(dimension: assets.count), constraints: constraints ) print("Single-Start Result:") print(" Sharpe ratio: \((-singleResult.objectiveValue).number())") print(" Algorithm: \(singleResult.algorithmUsed)") print() // Multi-start optimization do { let multiStartResult = try await parallelOptimizer.optimize( objective: portfolioObjective, searchRegion: searchRegion, constraints: constraints ) print("\nMulti-Start Result (20 starting points):") print(" Best Sharpe ratio: \((-multiStartResult.objectiveValue).number())") print(" Success rate: \(multiStartResult.successRate.percent())") // Compare let improvement = (-multiStartResult.objectiveValue) / (-singleResult.objectiveValue) - 1.0 print("\nImprovement: \((improvement.percent(1)))") } catch { print("Multi-start optimization failed: \(error)") } // MARK: - Analyzing Result Distribution do { // Run multi-start optimization let result = try await parallelOptimizer.optimize( objective: portfolioObjective, searchRegion: searchRegion, constraints: constraints ) // Analyze distribution of objectives found let objectives = result.allResults.map(\.value) let sortedObjectives = objectives.sorted() print("Result Distribution:") print(" Best: \(sortedObjectives.first?.number() ?? "N/A")") print(" Median: \(sortedObjectives[sortedObjectives.count / 2].number())") print(" Worst: \(sortedObjectives.last?.number() ?? "N/A")") print(" Range: \((sortedObjectives.last! - sortedObjectives.first!).number())") // Show how many found the global optimum (within 1% of best) let globalThreshold = sortedObjectives.first! * 1.01 let globalCount = sortedObjectives.filter { $0 <= globalThreshold }.count print("\nFound global optimum: \(globalCount)/\(sortedObjectives.count) attempts") print(" (\((Double(globalCount) / Double(sortedObjectives.count)).percent(0)))") } catch { print("Analysis failed: \(error)") } // MARK: - Choosing Number of Starting Points // Test different numbers of starting points for numStarts in [5, 10, 20, 50] { do { let optimizer = ParallelOptimizer
>( algorithm: .inequality, numberOfStarts: numStarts, maxIterations: 500, tolerance: 1e-5 ) let startTime = Date() let result = try await optimizer.optimize( objective: portfolioObjective, searchRegion: searchRegion, constraints: constraints ) let elapsedTime = Date().timeIntervalSince(startTime) print("\n\(numStarts) starting points:") print(" Best objective: \(result.objectiveValue.number())") print(" Success rate: \(result.successRate.percent())") print(" Time: \(elapsedTime.number(2))s") print(" Time per start: \((elapsedTime / Double(numStarts)).number(2))s") } catch { print("\n\(numStarts) starting points: FAILED") } } // MARK: - Hybrid Approach do { // Phase 1: Broad search with low accuracy let explorationOptimizer = ParallelOptimizer
>( algorithm: .gradientDescent(learningRate: 0.01), numberOfStarts: 50, maxIterations: 100, // Low iterations tolerance: 1e-3 // Loose tolerance ) let roughSolution = try await explorationOptimizer.optimize( objective: portfolioObjective, searchRegion: searchRegion, constraints: constraints ) print("Phase 1 (exploration): Sharpe \((-roughSolution.objectiveValue).number())") // Phase 2: Refine best solution with high accuracy let refinementOptimizer = AdaptiveOptimizer
>( maxIterations: 2000, tolerance: 1e-8 ) let finalSolution = try refinementOptimizer.optimize( objective: portfolioObjective, initialGuess: roughSolution.solution, constraints: constraints ) print("Phase 2 (refinement): Sharpe \((-finalSolution.objectiveValue).number())") } catch { print("Hybrid optimization failed: \(error)") } // MARK: - Real-World Example 80-Asset Portfolio Rebalancing // Simplified 80-asset portfolio problem let numAssets = 80 let portfolioValue = 2_000_000_000.0 // $2B AUM // Generate realistic expected returns (4% to 15%, mean ~9%) let expectedReturns80 = VectorN((0..
) -> Double = { weights in // Expected return let expectedReturn = weights.dot(expectedReturns80) // Portfolio variance var variance = 0.0 for i in 0..
>.budgetConstraint let longOnlyConstraints80 = MultivariateConstraint
>.nonNegativity(dimension: numAssets) // Position limits: no more than 5% per asset (diversification requirement) let positionLimits80 = (0..
>.inequality { w in w[i] - 0.05 // w[i] ≤ 5% } } let allConstraints80 = [budgetConstraint80] + longOnlyConstraints80 + positionLimits80 // Multi-start optimization do { print(String(repeating: "=", count: 70)) print("REAL-WORLD EXAMPLE: 80-ASSET PORTFOLIO REBALANCING") print(String(repeating: "=", count: 70)) print("Portfolio value: $\((portfolioValue / 1_000_000_000).number(1))B") print("Number of assets: \(numAssets)") print("Transaction costs: \(transactionCostBps) bps") print() let robustOptimizer = ParallelOptimizer
>( algorithm: .inequality, numberOfStarts: 30, maxIterations: 1500, tolerance: 1e-6 ) let startTime = Date() let result = try await robustOptimizer.optimize( objective: objectiveWithCosts, searchRegion: ( lower: VectorN(repeating: 0.0, count: numAssets), upper: VectorN(repeating: 0.05, count: numAssets) // Max 5% per asset ), constraints: allConstraints80 ) let elapsedTime = Date().timeIntervalSince(startTime) print("Multi-Start Optimization (30 starts):") print(" Best Sharpe ratio: \((-result.objectiveValue).number())") print(" Success rate: \(result.successRate.percent())") print(" Total time: \((elapsedTime / 60).number(1)) minutes") // Calculate turnover var totalTurnover = 0.0 var numPositions = 0 for i in 0..
0.001 { numPositions += 1 } } print("\nPortfolio Characteristics:") print(" Active positions: \(numPositions)/\(numAssets)") print(" Total turnover: \(totalTurnover.percent(1))") print(" Trading costs: $\((portfolioValue * totalTurnover * transactionCostBps / 10000).currency(0))") // Show top 10 positions let topPositions = result.solution.toArray() .enumerated() .sorted { $0.element > $1.element } .prefix(10) print("\nTop 10 Positions:") for (i, (idx, weight)) in topPositions.enumerated() { print(" \(i+1). Asset \(idx): \(weight.percent(2))") } } catch { print("Robust optimization failed: \(error)") } } // Keep playground alive long enough for async task to complete RunLoop.main.run(until: Date().addingTimeInterval(30))
→ Full API Reference: BusinessMath Docs – Parallel Optimization
Experiments to Try
- Starting Point Sensitivity: Run single-start from 10 different random starting points. How much does the result vary?
- Scaling Study: Compare 5, 10, 20, 50 starting points. When do diminishing returns start?
- Algorithm Comparison: Try different base algorithms (.gradientDescent vs .inequality vs .constrained). Which works best for your problem?
- Search Region Impact: Try narrow vs wide search regions. Does a tighter region around a good initial guess help?
Next Steps
Next Week: Week 10 explores Advanced Optimization Algorithms including L-BFGS for large-scale problems, conjugate gradient methods, and simulated annealing for global optimization.
Case Study Coming: Week 11 includes Real-Time Portfolio Optimization with streaming market data and dynamic constraints.
Series: [Week 9 of 12] | Topic: [Part 5 - Business Applications] | Completed: [4/6]
Topics Covered: Multi-start optimization • Parallel execution • Swift concurrency • Global optimization • Success rate analysis
Playgrounds: [Week 1-9 available] • [Next: Advanced algorithms]
Tagged with: optimization, concurrency