• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

SMTorg / smt-optim / 28686250335

03 Jul 2026 10:35PM UTC coverage: 79.634% (-7.5%) from 87.112%
28686250335

Pull #13

github

web-flow
Merge a13ea2d3d into f08badaa8
Pull Request #13: Feat/multi objective

307 of 771 new or added lines in 13 files covered. (39.82%)

3 existing lines in 1 file now uncovered.

3957 of 4969 relevant lines covered (79.63%)

1.59 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

14.66
/src/smt_optim/utils/multi_obj.py
1
from functools import partial
2✔
2
from typing import Callable
2✔
3

4
import numpy as np
2✔
5
from scipy.spatial.distance import cdist
2✔
6

7
from moocore import hypervolume as moocore_hv
2✔
8
from pymoo.core.problem import Problem as PymooProblem
2✔
9

10

11
def get_pareto_mask(Y: np.ndarray) -> np.ndarray:
2✔
12

NEW
13
    n = Y.shape[0]
×
NEW
14
    is_pareto = np.ones(n, dtype=bool)
×
15

NEW
16
    for i in range(n):
×
NEW
17
        if not is_pareto[i]:
×
NEW
18
            continue
×
19

20
        # A point is dominated if another point is <= in all objectives
21
        # and strictly < in at least one
NEW
22
        dominates = np.all(Y <= Y[i], axis=1) & np.any(Y < Y[i], axis=1)
×
23

24
        # If any point dominates i -> i is not Pareto
NEW
25
        if np.any(dominates):
×
NEW
26
            is_pareto[i] = False
×
27

NEW
28
    return is_pareto
×
29

30

31
def get_pareto_front(Y: np.ndarray) -> np.ndarray:
2✔
32
    """
33
    Return the non-dominated objective vectors from ``Y``.
34

35
    Parameters
36
    ----------
37
    Y : np.ndarray
38
        Array of shape ``(n_samples, n_objectives)`` containing objective
39
        values for each sample.
40

41
    Returns
42
    -------
43
    np.ndarray
44
        Array of shape ``(n_pareto, n_objectives)`` containing the
45
        non-dominated objective vectors (the Pareto front).
46

47
    Notes
48
    -----
49
    Assumes a minimization problem for all objectives and no constraints.
50
    """
51

NEW
52
    pareto_mask = get_pareto_mask(Y)
×
NEW
53
    pareto = Y[pareto_mask]
×
NEW
54
    return pareto
×
55

56

57
def get_pf_from_dataset(dataset, ctol: float = 1e-4, fid: int = -1) -> np.ndarray:
2✔
58

NEW
59
    data_dict = dataset.export_as_dict()
×
NEW
60
    obj = data_dict["obj"]
×
NEW
61
    rscv = data_dict["rscv"]
×
NEW
62
    fidelity = data_dict["fidelity"]
×
63

NEW
64
    feas_mask = (rscv <= ctol)
×
65

NEW
66
    if fid == -1:
×
NEW
67
        fid = np.max(fidelity)
×
68

NEW
69
    fid_mask = fidelity[feas_mask] == fid
×
70

NEW
71
    pareto_front = get_pareto_front(obj[feas_mask][fid_mask])
×
72

NEW
73
    return pareto_front
×
74

75

76

77
def hypervolume_2d(pf: np.ndarray, ref: np.ndarray) -> float:
2✔
78
    """
79
    Compute the 2D hypervolume indicator  he hypervolume of the Pareto front.
80

81
    Parameters
82
    ----------
83
    pf: np.ndarray of shape (num_points, 2)
84
        Pareto front.
85

86
    ref: np.ndarray of shape (2, )
87
        Reference objective values.
88

89
    Returns
90
    -------
91
    float
92
        Hypervolume indicator value.
93

94
    Notes:
95
        Assume both objective are minimized.
96
    """
97

NEW
98
    if pf.shape[1] != 2 or ref.shape[0] != 2:
×
NEW
99
        raise Exception("Current hypervolume implementation is only for bi-objective optimization.")
×
100

NEW
101
    sorted_idx = np.argsort(pf[:, 0])
×
NEW
102
    sorted_pf = pf[sorted_idx]
×
103

NEW
104
    hv = 0.
×
NEW
105
    prev_f2 = ref[1]
×
106

NEW
107
    for idx in range(sorted_pf.shape[0]):
×
108

NEW
109
        f1 = sorted_pf[idx, 0]
×
NEW
110
        f2 = sorted_pf[idx, 1]
×
111

NEW
112
        width = ref[0] - f1
×
NEW
113
        height = prev_f2 - f2
×
114

NEW
115
        if width > 0 and height > 0:
×
NEW
116
            hv += width * height
×
117

NEW
118
        prev_f2 = min(prev_f2, f2)
×
119

NEW
120
    return hv
×
121

122

123
def hypervolume(pf: np.ndarray, ref: np.ndarray) -> float:
2✔
124
    """
125
    Compute the hypervolume indicator of the Pareto front.
126

127
    Uses the `moocore` implementation:
128
    https://multi-objective.github.io/moocore/python/reference/generated/moocore.hypervolume.html
129

130
    Parameters
131
    ----------
132
    pf: np.ndarray of shape (num_points, 2)
133
        Pareto front.
134

135
    ref: np.ndarray of shape (2, )
136
        Reference objective values.
137

138
    Returns
139
    -------
140
    float
141
        Hypervolume indicator value.
142

143
    Notes:
144
        Assume both objective are minimized.
145
    """
146

NEW
147
    return moocore_hv(pf, ref=ref)
×
148

149

150
def spacing(pf: np.ndarray) -> float:
2✔
151
    """
152
    Compute the spacing indicator of the Pareto front (Schott, 1995). A lower value is better.
153

154
    Parameters
155
    ----------
156
    pf: np.ndarray of shape (num_points, num_objectives)
157
        Pareto front.
158

159
    Returns
160
    -------
161
    float
162
        Spacing indicator value.
163

164
    Notes:
165
        Assume both objective are minimized.
166
    """
167

NEW
168
    num_pf = pf.shape[0]
×
169

NEW
170
    if num_pf <= 1:
×
NEW
171
        return np.nan
×
172

NEW
173
    distances = cdist(pf, pf, "cityblock")
×
NEW
174
    np.fill_diagonal(distances, np.inf)
×
175

NEW
176
    d1 = np.min(distances, axis=1)
×
NEW
177
    d1_mean = np.mean(d1)
×
178

NEW
179
    value = np.sqrt(1/(num_pf-1) * np.sum((d1_mean - d1) ** 2))
×
180

NEW
181
    return value
×
182

183

184
class PymooStateWrapper(PymooProblem):
2✔
185

186
    def __init__(self, state):
2✔
187

NEW
188
        self.state = state
×
189

NEW
190
        self.state.scale_dataset(False)
×
NEW
191
        self.state.build_models()
×
192

NEW
193
        prob = self.state.problem
×
194

195

NEW
196
        l_bounds = []
×
NEW
197
        u_bounds = []
×
NEW
198
        for idx, var in enumerate(prob.design_space.design_variables):
×
NEW
199
            l_bounds.append(var.lower)
×
NEW
200
            u_bounds.append(var.upper)
×
201

NEW
202
        self.l_bounds = np.array(l_bounds)
×
NEW
203
        self.u_bounds = np.array(u_bounds)
×
204

NEW
205
        self.f_callables = []
×
NEW
206
        self.g_callables = []
×
NEW
207
        self.h_callables = []
×
208

NEW
209
        for o_id, o_config in enumerate(prob.obj_configs):
×
NEW
210
            self.f_callables.append(
×
211
                lambda x, f=self.state.obj_models[o_id].predict_values: f(x).ravel()
212
            )
213

NEW
214
        for c_id, c_config in enumerate(prob.cstr_configs):
×
NEW
215
            if c_config.equal is not None:
×
NEW
216
                self.h_callables.append(
×
217
                    lambda x, f=self.state.cstr_models[c_id].predict_values, val=c_config.equal: f(x).ravel() - val
218
                )
219

220
            else:
NEW
221
                if c_config.lower is not None:
×
NEW
222
                    self.g_callables.append(
×
223
                        lambda x, f=self.state.cstr_models[c_id].predict_values, val=c_config.lower: val - f(x).ravel()
224
                    )
225

NEW
226
                if c_config.upper is not None:
×
NEW
227
                    self.g_callables.append(
×
228
                        lambda x, f=self.state.cstr_models[c_id].predict_values, val=c_config.upper: f(x).ravel() - val
229
                    )
230

NEW
231
        super().__init__(n_var=prob.num_dim,
×
232
                         n_obj=prob.num_obj,
233
                         n_eq_constr=len(self.h_callables),
234
                         n_ieq_constr=len(self.g_callables),
235
                         xl=self.l_bounds,
236
                         xu=self.u_bounds,)
237

238

239
    def _evaluate(self, x, out, *args, **kwargs):
2✔
240

NEW
241
        num_pt = x.shape[0]
×
242

NEW
243
        x_scaled = (x - self.l_bounds)/(self.u_bounds - self.l_bounds)
×
244

NEW
245
        out["F"] = np.full((num_pt, self.n_obj), np.nan)
×
246

NEW
247
        if self.n_eq_constr > 0:
×
NEW
248
            out["H"] = np.empty((num_pt, self.n_eq_constr))
×
249

NEW
250
        if self.n_ieq_constr > 0:
×
NEW
251
            out["G"] = np.empty((num_pt, self.n_ieq_constr))
×
252

NEW
253
        for o_idx in range(self.n_obj):
×
NEW
254
            out["F"][:, o_idx] = self.f_callables[o_idx](x_scaled).ravel()
×
255

NEW
256
        for h_idx in range(self.n_eq_constr):
×
NEW
257
            out["H"][:, h_idx] = self.h_callables[h_idx](x_scaled).ravel()
×
258

NEW
259
        for g_idx in range(self.n_ieq_constr):
×
NEW
260
            out["G"][:, g_idx] = self.g_callables[g_idx](x_scaled).ravel()
×
261

262

263
def purity(pf: np.ndarray, ref_pf: np.ndarray) -> float:
2✔
264
    """
265
    Compute the purity indicator of the Pareto front.
266
    Purity is the ratio of points in the obtained Pareto front that are not
267
    strictly dominated by any point in the reference Pareto front.
268

269
    Parameters
270
    ----------
271
    pf: np.ndarray of shape (num_points, num_objectives)
272
        Pareto front approximation.
273
    ref_pf: np.ndarray of shape (num_ref_points, num_objectives)
274
        Reference Pareto front.
275

276
    Returns
277
    -------
278
    float
279
        Purity indicator value (between 0.0 and 1.0).
280
    """
NEW
281
    if len(pf) == 0:
×
NEW
282
        return 0.0
×
NEW
283
    dominated = 0
×
NEW
284
    for p in pf:
×
285
        # A point is dominated if any point in ref_pf is <= in all dims and < in at least one
NEW
286
        is_dom = np.any(np.all(ref_pf <= p, axis=1) & np.any(ref_pf < p, axis=1))
×
NEW
287
        if is_dom:
×
NEW
288
            dominated += 1
×
NEW
289
    return 1.0 - dominated / len(pf)
×
290

291

292
def gamma_spread(pf: np.ndarray) -> float:
2✔
293
    """
294
    Compute the Gamma-spread (Maximum Spread) indicator of the Pareto front.
295
    It measures the maximum Euclidean distance between any two points in the
296
    Pareto front.
297

298
    Parameters
299
    ----------
300
    pf: np.ndarray of shape (num_points, num_objectives)
301
        Pareto front.
302

303
    Returns
304
    -------
305
    float
306
        Maximum spread indicator value.
307
    """
NEW
308
    if len(pf) <= 1:
×
NEW
309
        return np.nan
×
310
    
NEW
311
    from scipy.spatial.distance import pdist
×
NEW
312
    distances = pdist(pf)
×
NEW
313
    if len(distances) == 0:
×
NEW
314
        return np.nan
×
NEW
315
    return float(np.max(distances))
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc