Skip to content

Commit 331207f

Browse files
committed
refactor(portfolio): extract common MOEA/D logic to base class and fix linting
1 parent 7ec6c60 commit 331207f

4 files changed

Lines changed: 200 additions & 242 deletions

File tree

src/portfolio/moead.py

Lines changed: 25 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ def generate_pareto_front(
3636
"""
3737
MOEA/D with decomposition, stable normalisation, and optional adaptive bounds.
3838
"""
39-
num_assets = len(kwargs["expected_returns"])
4039
num_objectives = len(self.problem.objectives)
4140

4241
# ── 1. Weight vectors & neighbourhood ────────────────────────────────
@@ -47,44 +46,10 @@ def generate_pareto_front(
4746
# ── 2. Initialize Normalisation ──────────────────────────────────────
4847
self._init_normalization(kwargs)
4948

50-
def repair(w):
51-
if repair_method == "euclidean":
52-
return self._repair(w)
53-
elif repair_method == "simple":
54-
return self._repair_simple(w)
55-
else:
56-
return w
57-
58-
# ── 3. Build candidate pool and assign best to each weight vector ─────
59-
n_extra = max(2 * num_points, 3 * num_assets)
60-
candidates = []
61-
for j in range(num_assets):
62-
w = np.zeros(num_assets)
63-
w[j] = 1.0
64-
candidates.append(w)
65-
candidates.append(np.ones(num_assets) / num_assets)
66-
candidates.extend(np.random.dirichlet(np.ones(num_assets), n_extra))
67-
cand_arr = np.array(candidates)
68-
69-
# Evaluate and normalise candidates
70-
cand_f = np.array(
71-
[list(self.problem.evaluate_all(w, **kwargs).values()) for w in cand_arr]
49+
# ── 3. Initial Population ─────────────────────────────────────────────
50+
population, f_phys, f_norm = self._initial_sampling(
51+
weight_vectors, reference_type, **kwargs
7252
)
73-
cand_fn = np.array([self._normalise(f) for f in cand_f])
74-
75-
# Initial assignment
76-
population = np.empty((num_points, num_assets))
77-
f_phys = np.empty((num_points, num_objectives))
78-
f_norm = np.empty((num_points, num_objectives))
79-
80-
for k, wv in enumerate(weight_vectors):
81-
g_vals = np.array(
82-
[self._get_scalar_value(fn, wv, reference_type) for fn in cand_fn]
83-
)
84-
best = int(np.argmin(g_vals))
85-
population[k] = cand_arr[best]
86-
f_phys[k] = cand_f[best]
87-
f_norm[k] = cand_fn[best]
8853

8954
# ── 4. Evolution ──────────────────────────────────────────────────────
9055
history = []
@@ -97,57 +62,35 @@ def repair(w):
9762
nb_idx = neighbors[i]
9863
p1_idx, p2_idx = np.random.choice(nb_idx, 2, replace=False)
9964

100-
# Crossover
101-
if crossover_operator == "sbx":
102-
offspring = self._sbx_crossover(
103-
population[p1_idx], population[p2_idx]
104-
)
105-
offspring = repair(offspring)
106-
elif crossover_operator == "simplex":
107-
offspring = self._simplex_crossover(
108-
population[p1_idx], population[p2_idx]
109-
)
110-
elif crossover_operator == "ldc":
111-
offspring = self._ldc_crossover(
112-
population[p1_idx], population[p2_idx]
113-
)
114-
elif crossover_operator == "none":
115-
offspring = population[p1_idx].copy()
116-
else:
117-
raise ValueError(f"Unknown crossover: {crossover_operator}")
118-
119-
# Mutation
120-
if mutation_operator == "polynomial":
121-
offspring = self._polynomial_mutation(offspring)
122-
offspring = repair(offspring)
123-
elif mutation_operator == "simplex":
124-
offspring = self._simplex_mutation(offspring, eta=mutation_eta)
125-
elif mutation_operator == "none":
126-
pass
127-
else:
128-
raise ValueError(f"Unknown mutation: {mutation_operator}")
65+
# Generate offspring using shared operators
66+
offspring = self._apply_genetic_operators(
67+
population[p1_idx],
68+
population[p2_idx],
69+
crossover_operator,
70+
mutation_operator,
71+
mutation_eta,
72+
repair_method,
73+
)
12974

13075
# Evaluate offspring
13176
off_f = np.array(
13277
list(self.problem.evaluate_all(offspring, **kwargs).values())
13378
)
13479
off_fn = self._normalise(off_f)
13580

136-
# Scalar scores and neighborhood update
137-
count = 0
138-
for j in nb_idx:
139-
if count >= nr:
140-
break
141-
142-
wv_j = weight_vectors[j]
143-
curr_g = self._get_scalar_value(f_norm[j], wv_j, reference_type)
144-
off_g = self._get_scalar_value(off_fn, wv_j, reference_type)
145-
146-
if off_g < curr_g:
147-
population[j] = offspring
148-
f_phys[j] = off_f
149-
f_norm[j] = off_fn
150-
count += 1
81+
# Update neighborhood using shared logic
82+
self._update_neighborhood(
83+
offspring,
84+
off_f,
85+
off_fn,
86+
nb_idx,
87+
nr,
88+
weight_vectors,
89+
population,
90+
f_phys,
91+
f_norm,
92+
reference_type,
93+
)
15194

15295
# ── 5. Adaptive Normalisation ─────────────────────────────────────
15396
if adaptive != "none":

src/portfolio/moead_awa.py

Lines changed: 25 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ def generate_pareto_front(
2727
"""
2828
MOEA/D-AWA with optional adaptive bounds and weight adjustment.
2929
"""
30-
num_assets = len(kwargs["expected_returns"])
3130
num_objectives = len(self.problem.objectives)
3231

3332
# ── 1. Weight vectors & neighbourhood ────────────────────────────────
@@ -38,42 +37,10 @@ def generate_pareto_front(
3837
# ── 2. Initialize Normalisation ──────────────────────────────────────
3938
self._init_normalization(kwargs)
4039

41-
def repair(w):
42-
if repair_method == "euclidean":
43-
return self._repair(w)
44-
elif repair_method == "simple":
45-
return self._repair_simple(w)
46-
else:
47-
return w
48-
4940
# ── 3. Initial Population ─────────────────────────────────────────────
50-
candidates = []
51-
for j in range(num_assets):
52-
w = np.zeros(num_assets)
53-
w[j] = 1.0
54-
candidates.append(w)
55-
candidates.append(np.ones(num_assets) / num_assets)
56-
candidates.extend(
57-
np.random.dirichlet(np.ones(num_assets), max(100, 2 * num_points))
58-
)
59-
cand_arr = np.array(candidates)
60-
cand_f = np.array(
61-
[list(self.problem.evaluate_all(w, **kwargs).values()) for w in cand_arr]
41+
population, f_phys, f_norm = self._initial_sampling(
42+
weight_vectors, reference_type, **kwargs
6243
)
63-
cand_fn = np.array([self._normalise(f) for f in cand_f])
64-
65-
population = np.empty((num_points, num_assets))
66-
f_phys = np.empty((num_points, num_objectives))
67-
f_norm = np.empty((num_points, num_objectives))
68-
69-
for k, wv in enumerate(weight_vectors):
70-
g_vals = np.array(
71-
[self._get_scalar_value(fn, wv, reference_type) for fn in cand_fn]
72-
)
73-
best = int(np.argmin(g_vals))
74-
population[k] = cand_arr[best]
75-
f_phys[k] = cand_f[best]
76-
f_norm[k] = cand_fn[best]
7744

7845
# Elite Population (EP) for AWA
7946
ep_weights = population.copy()
@@ -91,48 +58,35 @@ def repair(w):
9158
nb_idx = neighbors[i]
9259
p1_idx, p2_idx = np.random.choice(nb_idx, 2, replace=False)
9360

94-
# Generate offspring
95-
if crossover_operator == "sbx":
96-
offspring = self._sbx_crossover(
97-
population[p1_idx], population[p2_idx]
98-
)
99-
offspring = repair(offspring)
100-
elif crossover_operator == "simplex":
101-
offspring = self._simplex_crossover(
102-
population[p1_idx], population[p2_idx]
103-
)
104-
elif crossover_operator == "ldc":
105-
offspring = self._ldc_crossover(
106-
population[p1_idx], population[p2_idx]
107-
)
108-
else:
109-
offspring = population[p1_idx].copy()
110-
111-
if mutation_operator == "polynomial":
112-
offspring = self._polynomial_mutation(offspring)
113-
offspring = repair(offspring)
114-
elif mutation_operator == "simplex":
115-
offspring = self._simplex_mutation(offspring, eta=mutation_eta)
61+
# Generate offspring using shared operators
62+
offspring = self._apply_genetic_operators(
63+
population[p1_idx],
64+
population[p2_idx],
65+
crossover_operator,
66+
mutation_operator,
67+
mutation_eta,
68+
repair_method,
69+
)
11670

71+
# Evaluate offspring
11772
off_f = np.array(
11873
list(self.problem.evaluate_all(offspring, **kwargs).values())
11974
)
12075
off_fn = self._normalise(off_f)
12176

122-
# Update neighbors
123-
count = 0
124-
for j in nb_idx:
125-
if count >= nr:
126-
break
127-
wv_j = weight_vectors[j]
128-
curr_g = self._get_scalar_value(f_norm[j], wv_j, reference_type)
129-
off_g = self._get_scalar_value(off_fn, wv_j, reference_type)
130-
131-
if off_g < curr_g:
132-
population[j] = offspring
133-
f_phys[j] = off_f
134-
f_norm[j] = off_fn
135-
count += 1
77+
# Update neighbors using shared logic
78+
self._update_neighborhood(
79+
offspring,
80+
off_f,
81+
off_fn,
82+
nb_idx,
83+
nr,
84+
weight_vectors,
85+
population,
86+
f_phys,
87+
f_norm,
88+
reference_type,
89+
)
13690

13791
# ── 5. Update Elite Population (EP) ───────────────────────────
13892
# Check if offspring is non-dominated by current EP
@@ -290,20 +244,3 @@ def repair(w):
290244
if kwargs.get("record_history", False):
291245
return metrics, population, weight_vectors, history
292246
return metrics, population, weight_vectors
293-
294-
def _get_non_dominated_indices(self, objectives):
295-
num_solutions = len(objectives)
296-
if num_solutions <= 1:
297-
return np.ones(num_solutions, dtype=bool)
298-
299-
is_efficient = np.ones(num_solutions, dtype=bool)
300-
for i in range(num_solutions):
301-
if is_efficient[i]:
302-
c = objectives[i]
303-
# A solution j is dominated by c if all(c <= j) and any(c < j)
304-
# Set is_efficient[j] to False if c dominates j
305-
is_dominated = np.all(c <= objectives, axis=1) & np.any(
306-
c < objectives, axis=1
307-
)
308-
is_efficient[is_dominated] = False
309-
return is_efficient

0 commit comments

Comments
 (0)