How to use the XLEMOO method as an interactive optimization method¶
XLEMOO combines learnable evolutionary multiobjective optimization with interpretable machine learning (skope-rules) to provide a decision maker (DM) with both optimal solutions and rule-based explanations about the decision variables. This notebook walks through three iterations of interactive optimization on the vehicle crash worthiness problem, inspired by the showcase in Misitano (2024).
Problem recap (Liao et al. 2008): five decision variables $x_1 \dots x_5$ are the thicknesses (mm) of reinforced members, each in $[1, 3]$. The three minimised objectives are $f_1$ (vehicle frontal mass, kg), $f_2$ (collision acceleration experienced by passengers, m/s$^2$), and $f_3$ (toe board intrusion in the offset frontal crash, mm).
Each iteration: the DM provides a reference point, XLEMOO returns a population and a trained skope-rules classifier, and the rules are summarised as variable bounds the DM can inspect to refine the next reference point.
1. Setup¶
from desdeo.emo import algorithms
from desdeo.emo.options.templates import EMOOptions, ReferencePointOptions
from desdeo.explanations import (
complete_bounds_from_population,
extract_skoped_rules,
format_rule_summary,
format_rule_table,
parse_rules_to_variable_bounds,
)
from desdeo.problem import SimulatorEvaluator
from desdeo.problem.testproblems import vehicle_crashworthiness
Build the problem and inspect its structure.
problem = vehicle_crashworthiness()
print("Variables:")
for var in problem.variables:
print(f" {var.symbol}: [{var.lowerbound}, {var.upperbound}]")
print("\nObjectives (all minimized):")
for obj in problem.objectives:
print(f" {obj.symbol}: ideal={obj.ideal}, nadir={obj.nadir}")
Variables: x_1: [1.0, 3.0] x_2: [1.0, 3.0] x_3: [1.0, 3.0] x_4: [1.0, 3.0] x_5: [1.0, 3.0] Objectives (all minimized): f_1: ideal=1600.0, nadir=1700.0 f_2: ideal=6.0, nadir=12.0 f_3: ideal=0.038, nadir=0.3
Three small helpers keep each iteration short.
run_xlemoobuilds and runs an XLEMOO instance for a given reference point.best_solutionreturns the row of the final population with the lowest ASF.print_rulesextracts and renders the rule table from a finished run.
def run_xlemoo(reference_point, seed=42):
options = algorithms.xlemoo_options()
options.template.seed = seed
options.template.n_darwin_per_cycle = 19
options.template.n_learning_per_cycle = 1
options.template.termination.max_generations = 200
options.template.selection.winner_size = 50
options.template.generator.n_points = 50
full_options = EMOOptions(
preference=ReferencePointOptions(preference=reference_point),
template=options.template,
)
solver, extras = algorithms.emo_constructor(emo_options=full_options, problem=problem)
return solver(), extras
def best_solution(result, asf_symbol):
asf_values = result.optimal_outputs[asf_symbol].to_numpy()
best_idx = int(asf_values.argmin())
return (
result.optimal_variables.row(best_idx, named=True),
result.optimal_outputs.row(best_idx, named=True),
float(asf_values[best_idx]),
)
def print_rules(result, extras):
rules, accuracies = extract_skoped_rules(extras.learning_operator.current_ml_model)
if not rules:
print("No rules extracted on the final learning step.")
return
variable_symbols = [v.symbol for v in extras.problem.get_flattened_variables()]
variable_bounds = [(float(v.lowerbound), float(v.upperbound)) for v in extras.problem.get_flattened_variables()]
parsed = parse_rules_to_variable_bounds(rules, accuracies, variable_symbols, variable_bounds)
population = result.optimal_variables[variable_symbols].to_numpy()
parsed = complete_bounds_from_population(parsed, population, variable_symbols)
population_bounds = {
sym: (float(population[:, i].min()), float(population[:, i].max())) for i, sym in enumerate(variable_symbols)
}
summary = format_rule_summary(parsed, variable_symbols, population_bounds)
print(format_rule_table(summary))
# A standalone evaluator the DM can use to score hand-modified decision vectors.
manual_evaluator = SimulatorEvaluator(problem)
def evaluate_manual(values):
out = manual_evaluator.evaluate(
{sym: [val] for sym, val in zip([v.symbol for v in problem.variables], values, strict=True)},
flat=True,
)
return {sym: float(out[sym].item()) for sym in ("f_1", "f_2", "f_3")}
2. A note on reference point selection¶
The achievement scalarizing function (ASF) used by XLEMOO ranks solutions by the worst scaled deviation from the reference point. When the reference point is very aggressive (close to the ideal), the ASF landscape can have a single optimum at a corner of the variable space. In that case the population converges to one identical decision vector and the rule-based explanations become uninformative (every range collapses to a single point).
The original article uses $\bar{\mathbf{z}}_1 = (1610, 6.2, 0.041)$. With the published ideal $(1600, 6.0, 0.038)$ this is so close to the ideal that the global ASF optimum is the lower-bound corner $(1, 1, 1, 1, 1)$, and the search collapses to it. To keep the iteration informative, this notebook starts with a moderately optimistic reference point $\bar{\mathbf{z}}_1 = (1640.0, 6.5, 0.05)$. The DM is then free to tighten in subsequent iterations, knowing roughly how each component trades against the others.
3. Iteration 1¶
Moderately optimistic start: $\bar{\mathbf{z}}_1 = (1640.0, 6.5, 0.05)$.
z_bar_1 = {"f_1": 1640.0, "f_2": 6.5, "f_3": 0.05}
result_1, extras_1 = run_xlemoo(z_bar_1)
asf_symbol = next(c for c in result_1.optimal_outputs.columns if c.startswith("asf"))
x_1, z_1, asf_1 = best_solution(result_1, asf_symbol)
print("Best decision vector:")
for sym in ("x_1", "x_2", "x_3", "x_4", "x_5"):
print(f" {sym} = {x_1[sym]:.5f}")
print("\nObjective vector:")
for sym in ("f_1", "f_2", "f_3"):
print(f" {sym} = {z_1[sym]:.5f}")
print(f"\nASF value: {asf_1:.5f}")
Best decision vector: x_1 = 1.00000 x_2 = 1.61293 x_3 = 1.00000 x_4 = 1.00000 x_5 = 1.00000 Objective vector: f_1 = 1663.13105 f_2 = 7.88787 f_3 = 0.09797 ASF value: 0.23133
The trained skope-rules classifier produces a per-variable bound table:
print_rules(result_1, extras_1)
Variable Rule Lower Acc Rule Upper Acc Pop Lower Pop Upper x_1 1.00000 -1 1.00002 1.000 1.00000 1.00000 x_2 1.61293 -1 1.72720 0.714 1.61293 1.61293 x_3 1.00000 0.513 1.00004 0.500 1.00000 1.00000 x_4 1.00000 -1 1.96868 1.000 1.00000 1.00000 x_5 1.00000 -1 1.55501 1.000 1.00000 1.00000
The DM scans the rule table to see which variables ($x_1 \dots x_5$, member thicknesses) are tightly constrained and which still have wider rule ranges. Where a rule range is wide, the variable has room to move without hurting the ASF much; that is a good place to probe the trade-offs by hand.
# Take the best decision vector and slightly perturb the variable with the widest
# rule range. Here we nudge x_2 toward its upper bound and re-evaluate.
x_1_modified = list(x_1.values())
x_1_modified[1] = min(3.0, x_1_modified[1] + 0.5)
print(f"Modified decision vector: {tuple(round(v, 5) for v in x_1_modified)}")
print(f"Modified objective vector: {evaluate_manual(x_1_modified)}")
Modified decision vector: (1.0, 2.11293, 1.0, 1.0, 1.0)
Modified objective vector: {'f_1': 1664.2920496961583, 'f_2': 7.547918939584797, 'f_3': 0.10672358487002812}
The DM compares the modified objective vector with the best solution and decides on a new reference point for the next iteration. If mass ($f_1$) barely moved while acceleration ($f_2$) or intrusion ($f_3$) shifted, the DM knows mass is loosely coupled to that thickness and can be relaxed.
4. Iteration 2¶
The DM relaxes mass ($f_1$) slightly and asks for a more honest reference for acceleration ($f_2$) and intrusion ($f_3$): $\bar{\mathbf{z}}_2 = (1680.0, 7.66, 0.07)$.
z_bar_2 = {"f_1": 1680.0, "f_2": 7.66, "f_3": 0.07}
result_2, extras_2 = run_xlemoo(z_bar_2)
asf_symbol = next(c for c in result_2.optimal_outputs.columns if c.startswith("asf"))
x_2, z_2, asf_2 = best_solution(result_2, asf_symbol)
print("Best decision vector:")
for sym in ("x_1", "x_2", "x_3", "x_4", "x_5"):
print(f" {sym} = {x_2[sym]:.5f}")
print("\nObjective vector:")
for sym in ("f_1", "f_2", "f_3"):
print(f" {sym} = {z_2[sym]:.5f}")
print(f"\nASF value: {asf_2:.5f}")
Best decision vector: x_1 = 1.00354 x_2 = 3.00000 x_3 = 1.00000 x_4 = 1.25910 x_5 = 3.00000 Objective vector: f_1 = 1677.27272 f_2 = 7.62580 f_3 = 0.06851 ASF value: -0.00567
print_rules(result_2, extras_2)
Variable Rule Lower Acc Rule Upper Acc Pop Lower Pop Upper x_1 1.00354 -1 1.00886 1.000 1.00354 1.00354 x_2 2.95074 1.000 3.00000 -1 3.00000 3.00000 x_3 1.00000 -1 1.01416 1.000 1.00000 1.00000 x_4 1.25910 -1 2.11614 0.857 1.25910 1.25910 x_5 2.99982 1.000 3.00000 -1 3.00000 3.00000
The DM compares the new rule table with the previous iteration's. Variables pinned to a bound (rule lower equal to rule upper) are saturating, while narrow rule bands away from the bounds indicate variables that the algorithm is genuinely tuning. The widest band typically points to the variable with the largest leverage on the active trade-off.
5. Iteration 3¶
Final adjustment: ask for a touch less mass ($f_1$) and a touch less acceleration ($f_2$): $\bar{\mathbf{z}}_3 = (1670.0, 7.61, 0.085)$.
z_bar_3 = {"f_1": 1670.0, "f_2": 7.61, "f_3": 0.085}
result_3, extras_3 = run_xlemoo(z_bar_3)
asf_symbol = next(c for c in result_3.optimal_outputs.columns if c.startswith("asf"))
x_3, z_3, asf_3 = best_solution(result_3, asf_symbol)
print("Best decision vector:")
for sym in ("x_1", "x_2", "x_3", "x_4", "x_5"):
print(f" {sym} = {x_3[sym]:.5f}")
print("\nObjective vector:")
for sym in ("f_1", "f_2", "f_3"):
print(f" {sym} = {z_3[sym]:.5f}")
print(f"\nASF value: {asf_3:.5f}")
Best decision vector: x_1 = 1.00123 x_2 = 3.00000 x_3 = 1.00000 x_4 = 1.30100 x_5 = 1.09198 Objective vector: f_1 = 1669.08873 f_2 = 7.54371 f_3 = 0.08261 ASF value: -0.00909
print_rules(result_3, extras_3)
Variable Rule Lower Acc Rule Upper Acc Pop Lower Pop Upper x_1 1.00118 -1 1.00123 -1 1.00118 1.00123 x_2 2.99793 1.000 3.00000 -1 3.00000 3.00000 x_3 1.00000 -1 1.00005 1.000 1.00000 1.00000 x_4 1.30100 -1 2.11968 1.000 1.30100 1.30101 x_5 1.09195 -1 1.83376 1.000 1.09195 1.09198
To probe the mass-vs-intrusion trade-off, the DM tries lowering each thickness ($x_1 \dots x_5$) toward its rule lower bound and re-evaluates.
x_3_modified = [max(extras_3.problem.variables[i].lowerbound, x_3[f"x_{i + 1}"] - 0.1) for i in range(5)]
print(f"Modified decision vector: {tuple(round(v, 5) for v in x_3_modified)}")
print(f"Modified objective vector: {evaluate_manual(x_3_modified)}")
Modified decision vector: (1.0, 2.9, 1.0, 1.201, 1.0)
Modified objective vector: {'f_1': 1667.6716431357665, 'f_2': 7.396157579364995, 'f_3': 0.08993247738405818}
The qualitative observation from the article carries over: lowering the thicknesses ($x_1 \dots x_5$) barely affects mass ($f_1$) but pushes intrusion ($f_3$) up. The DM keeps the unmodified $\mathbf{x}_3$ as the final solution and notes that the polynomial surrogate may oversimplify the thickness-to-intrusion link, so the problem formulation could merit revisiting.
6. Summary¶
Across three iterations XLEMOO produced both a recommended solution and a rule-based explanation for each one. The rule tables made it visible that (a) most variables ($x_1 \dots x_5$, member thicknesses) converge to a narrow band, (b) thickness changes barely move the mass objective ($f_1$), and (c) the toe-board intrusion ($f_3$) is the most sensitive of the three objectives.
Practical takeaways:
- A moderate starting reference point avoids ASF corner collapse and keeps the rule explanations informative.
- The rule table is most useful when read iteration over iteration: pinned variables and narrowing rule bands are clear progress signals.
- Manually evaluating decision vectors with the rule bounds in hand turns the rules into a sandbox for what-if questions.
- The DM can both pick a final solution and form an opinion about the structure of the underlying problem.
Reference
Misitano, G. (2024). "Exploring the Explainable Aspects and Performance of a Learnable Evolutionary Multiobjective Optimization Method." ACM Transactions on Evolutionary Learning and Optimization 4(1), Article 4.