Multi-Objective Optimization

This tutorial covers true multi-objective optimization using NSGA-II/NSGA-III algorithms to find Pareto-optimal solutions when optimizing linkages with competing objectives.

When to Use Multi-Objective Optimization

Use multi-objective optimization when you have competing goals that cannot be combined into a single metric without losing important trade-off information:

  • Path accuracy vs. transmission angle: A linkage that follows a path perfectly might have poor force transmission characteristics.

  • Compactness vs. range of motion: Smaller mechanisms may have limited travel.

  • Speed vs. smoothness: Fast motion may introduce jerky accelerations.

Unlike weighted-sum approaches (combining objectives into one score), multi-objective optimization returns the entire Pareto front—the set of solutions where improving one objective necessarily worsens another.

Installation

Multi-objective optimization requires the pymoo library:

pip install pylinkage[moo]

Basic Example

Let’s optimize a four-bar linkage for both path accuracy and transmission angle:

import pylinkage as pl
from pylinkage.actuators import Crank
from pylinkage.components import Ground
from pylinkage.dyads import RRRDyad
from pylinkage.simulation import Linkage
from pylinkage.optimization import (
    multi_objective_optimization,
    kinematic_minimization,
)


# Create the linkage
def create_fourbar():
    A = Ground(0.0, 0.0, name="A")
    D = Ground(3.0, 0.0, name="D")
    crank = Crank(anchor=A, radius=1.0, angular_velocity=0.31, name="Crank")
    output = RRRDyad(
        anchor1=crank.output, anchor2=D,
        distance1=3.0, distance2=2.0, name="Output",
    )
    return Linkage([A, D, crank, output])


# Objective 1: Minimize path error
@kinematic_minimization
def path_error(loci, **kwargs):
    """Distance from output path to target circle."""
    output_path = [step[-1] for step in loci]
    target_center = (4, 1)
    target_radius = 1.5

    error = 0.0
    for x, y in output_path:
        dist = ((x - target_center[0])**2 + (y - target_center[1])**2)**0.5
        error += (dist - target_radius)**2
    return error / len(output_path)


# Objective 2: Minimize transmission angle deviation from 90 degrees
@kinematic_minimization
def transmission_penalty(loci, linkage=None, **kwargs):
    """Penalize poor transmission angles (coupler-rocker angle far from 90°)."""
    analysis = linkage.analyze_transmission()
    return analysis.max_deviation


# Run multi-objective optimization
linkage = create_fourbar()
bounds = pl.generate_bounds(linkage.get_constraints())

results = multi_objective_optimization(
    objectives=[path_error, transmission_penalty],
    linkage=linkage,
    bounds=bounds,
    objective_names=["Path Error", "Transmission Penalty"],
    n_generations=30,
    pop_size=40,
    seed=42,
    verbose=False,
)

print(f"Found {len(results)} Pareto-optimal solutions")

Understanding the Pareto Front

Like every optimizer, multi_objective_optimization returns an Ensemble; here its members are the non-dominated solutions, and it carries one score column per objective, named as requested:

# Iterate over solutions
for member in results:
    print(f"Scores: {member.scores}")
    print(f"Constraints: {member.dimensions}")

# Scores as numpy arrays, one per objective
print(f"Objectives: {list(results.scores)}")
path_errors = results.scores["Path Error"]  # shape (n_solutions,)

# Rank along one objective
most_accurate = results.rank("Path Error")[0]
print(f"Most accurate path: {most_accurate.scores}")

The front’s own tools live on ParetoFront; to_pareto_front() converts the Ensemble when you need them:

pareto = results.to_pareto_front()

# Get scores as one array, shape (n_solutions, n_objectives)
scores = pareto.scores_array()

# Number of objectives
print(f"Objectives: {pareto.n_objectives}")

Pareto Dominance

A solution dominates another if it is at least as good in all objectives and strictly better in at least one:

sol_a = pareto[0]
sol_b = pareto[1]

if sol_a.dominates(sol_b):
    print("Solution A is strictly better than B")
else:
    print("Solutions are non-dominated (trade-offs)")

Visualizing Trade-Offs

Plot the Pareto front to understand the trade-offs:

2D Pareto Front

import matplotlib.pyplot as plt

# Default 2D plot
fig = pareto.plot()
plt.savefig("pareto_front.png")
plt.show()

# Custom styling
fig = pareto.plot(s=100, alpha=0.8, c='blue')
plt.show()

With three objectives, pass three indices — plot(objective_indices=(0, 1, 2)) — for a 3D scatter; see the three-objective example below.

Selecting a Solution

Best Compromise

Select a balanced solution using normalized weighted sum:

# Equal weight on all objectives
best = pareto.best_compromise()

# Custom weights (prioritize path error)
best = pareto.best_compromise(weights=[0.8, 0.2])

# Apply to linkage
linkage.set_constraints(best.dimensions)
linkage.set_coords(best.initial_positions)
pl.show_linkage(linkage)

Filtering Solutions

Reduce the Pareto front to a manageable subset using crowding distance:

# Keep 10 well-distributed solutions
filtered = pareto.filter(max_solutions=10)

print(f"Reduced from {len(pareto)} to {len(filtered)} solutions")

Hypervolume Indicator

Measure the quality of the Pareto front:

# Reference point should be worse than all solutions
reference = scores.max(axis=0) * 1.1

hv = pareto.hypervolume(reference_point=reference)
print(f"Hypervolume: {hv}")

Advanced Usage

Combining with Transmission Analysis

Use the built-in transmission angle analysis:

@kinematic_minimization
def transmission_objective(loci, linkage=None, **kwargs):
    """Use built-in transmission analysis."""
    analysis = linkage.analyze_transmission()
    # Minimize deviation of the mean from the ideal 90 degrees
    return abs(90 - analysis.mean_angle)


@kinematic_minimization
def worst_transmission(loci, linkage=None, **kwargs):
    """Minimize the worst transmission angle."""
    analysis = linkage.analyze_transmission()
    # Return how far the worst angle is from acceptable range
    if analysis.min_angle < 40:
        return 40 - analysis.min_angle
    if analysis.max_angle > 140:
        return analysis.max_angle - 140
    return 0

Three-Objective Example

@kinematic_minimization
def compactness(loci, **kwargs):
    """Minimize mechanism bounding box area."""
    all_points = [p for step in loci for p in step]
    bbox = pl.bounding_box(all_points)
    width = bbox[1] - bbox[3]  # max_x - min_x
    height = bbox[2] - bbox[0]  # max_y - min_y
    return width * height


results = multi_objective_optimization(
    objectives=[path_error, transmission_penalty, compactness],
    linkage=create_fourbar(),
    bounds=bounds,
    objective_names=["Path Error", "Transmission", "Size"],
    algorithm="nsga2",
    n_generations=30,
    pop_size=40,
    seed=42,
    verbose=False,
)

# 3D visualization
fig = results.to_pareto_front().plot(objective_indices=(0, 1, 2))

Algorithm Selection

NSGA-II (Default)

Best for 2-3 objectives:

results = multi_objective_optimization(
    objectives=[path_error, transmission_penalty],
    linkage=create_fourbar(),
    bounds=bounds,
    algorithm="nsga2",  # Default
    n_generations=20,
    pop_size=40,
    seed=42,
    verbose=False,
)

NSGA-III

Better for many objectives (4+); it spreads the population over reference directions instead of crowding distance:

results = multi_objective_optimization(
    objectives=[path_error, transmission_penalty, compactness],
    linkage=create_fourbar(),
    bounds=bounds,
    algorithm="nsga3",
    n_generations=20,
    pop_size=40,
    seed=42,
    verbose=False,
)
print(f"NSGA-III kept {len(results)} solutions")

Reproducibility

Set a random seed for reproducible results:

results = multi_objective_optimization(
    objectives=[path_error, transmission_penalty],
    linkage=linkage,
    bounds=bounds,
    seed=42,
)

Comparison: Single vs Multi-Objective

Approach

When to Use

Trade-offs

Weighted Sum

Clear priority between objectives

Single solution; weights are subjective

Multi-Objective

Exploring trade-offs; no clear priority

Multiple solutions; requires selection

Troubleshooting

Empty Pareto front:

  • All candidate solutions may be unbuildable

  • Widen the search bounds

  • Increase population size

Poor convergence:

  • Increase n_generations

  • Increase pop_size

  • Check that objectives have similar scales

Slow optimization:

  • Reduce pop_size and n_generations

  • Simplify fitness functions

  • Use fewer simulation steps

Next Steps