Skip to content

API reference

Selector

Bases: SelectorMixin, BaseEstimator

Multi-objective feature selection with a scikit-learn interface.

Runs a multi-objective algorithm minimizing (classification error %, number of selected features), then picks one solution from the Pareto front according to strategy.

Parameters:

Name Type Description Default
algorithm (mofs - rfga, nsga2)

Search algorithm. MOFS-RFGA (Xue, Zhu & Neri, 2023) is the ReliefF-guided hybrid; NSGA-II is the classic baseline.

"mofs-rfga"
pop_size int

Population size N.

60
max_evals int

Budget in objective-function evaluations (maxFEs).

5000
strategy (knee, min_error, min_features)

How to pick the final subset from the Pareto front: "knee" = best normalized trade-off, "min_error" = most accurate, "min_features" = smallest subset.

"knee"
sc array - like

Precomputed feature scores for MOFS-RFGA (defaults to built-in ReliefF). Ignored by NSGA-II.

None
random_state int

Seed for reproducibility.

None
verbose bool
False

Attributes:

Name Type Description
pareto_front_ ndarray of shape (n_solutions, 2)

Objective values [error %, subset size] of the final Pareto front.

pareto_masks_ ndarray of shape (n_solutions, n_features)

Binary masks of the Pareto-front solutions.

support_ ndarray of shape (n_features,)

Boolean mask of the selected subset (per strategy).

result_ Result

Full algorithm result.

n_evals_ int

Evaluations actually consumed.

Source code in src/moofs/selection.py
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
class MOFSSelector(SelectorMixin, BaseEstimator):
    """Multi-objective feature selection with a scikit-learn interface.

    Runs a multi-objective algorithm minimizing (classification error %,
    number of selected features), then picks one solution from the Pareto
    front according to ``strategy``.

    Parameters
    ----------
    algorithm : {"mofs-rfga", "nsga2"}, default="mofs-rfga"
        Search algorithm. MOFS-RFGA (Xue, Zhu & Neri, 2023) is the
        ReliefF-guided hybrid; NSGA-II is the classic baseline.
    pop_size : int, default=60
        Population size N.
    max_evals : int, default=5000
        Budget in objective-function evaluations (maxFEs).
    strategy : {"knee", "min_error", "min_features"}, default="knee"
        How to pick the final subset from the Pareto front:
        "knee" = best normalized trade-off, "min_error" = most accurate,
        "min_features" = smallest subset.
    sc : array-like, optional
        Precomputed feature scores for MOFS-RFGA (defaults to built-in
        ReliefF). Ignored by NSGA-II.
    random_state : int, optional
        Seed for reproducibility.
    verbose : bool, default=False

    Attributes
    ----------
    pareto_front_ : ndarray of shape (n_solutions, 2)
        Objective values [error %, subset size] of the final Pareto front.
    pareto_masks_ : ndarray of shape (n_solutions, n_features)
        Binary masks of the Pareto-front solutions.
    support_ : ndarray of shape (n_features,)
        Boolean mask of the selected subset (per ``strategy``).
    result_ : moofs.Result
        Full algorithm result.
    n_evals_ : int
        Evaluations actually consumed.
    """

    def __init__(self, algorithm="mofs-rfga", pop_size=60, max_evals=5000,
                 strategy="knee", sc=None, random_state=None, verbose=False):
        self.algorithm = algorithm
        self.pop_size = pop_size
        self.max_evals = max_evals
        self.strategy = strategy
        self.sc = sc
        self.random_state = random_state
        self.verbose = verbose


    def _more_tags(self):
        return {"requires_y": True, "allow_nan": False}

    def _get_support_mask(self):
        check_is_fitted(self, "support_")
        return self.support_


    @staticmethod
    def _pick(F, strategy):
        if strategy == "min_error":
            return int(np.lexsort((F[:, 1], F[:, 0]))[0])
        if strategy == "min_features":
            return int(np.lexsort((F[:, 0], F[:, 1]))[0])
        if strategy == "knee":
            fmin = F.min(axis=0)
            span = F.max(axis=0) - fmin
            span[span == 0] = 1.0
            Fn = (F - fmin) / span
            return int(np.argmin(Fn.sum(axis=1)))
        raise ValueError(f"Unknown strategy: {strategy!r}")


    def fit(self, X, y):
        """Run the multi-objective search on (X, y)."""
        if self.algorithm not in _ALGORITHMS:
            raise ValueError(
                f"algorithm must be one of {sorted(_ALGORITHMS)}, "
                f"got {self.algorithm!r}")

        if isinstance(X, pd.DataFrame):
            Xdf = X.reset_index(drop=True)
            self.feature_names_in_ = np.asarray(X.columns, dtype=object)
        else:
            X = np.asarray(X)
            Xdf = pd.DataFrame(X, columns=[f"x{i}" for i in range(X.shape[1])])
        ydf = pd.Series(np.asarray(y)).reset_index(drop=True)
        self.n_features_in_ = Xdf.shape[1]

        problem = FeatureSelectionProblem(Xdf, ydf)
        cls = _ALGORITHMS[self.algorithm]
        kwargs = dict(pop_size=self.pop_size, max_evals=self.max_evals,
                      seed=self.random_state, verbose=self.verbose)
        if self.algorithm == "mofs-rfga" and self.sc is not None:
            kwargs["sc"] = self.sc
        algo = cls(problem, **kwargs)

        self.result_ = algo.run()
        self.n_evals_ = self.result_.n_evals
        self.pareto_front_ = self.result_.F
        self.pareto_masks_ = np.array(
            [np.asarray(s.x, dtype=int) for s in self.result_.front])

        idx = self._pick(self.pareto_front_, self.strategy)
        self.support_ = self.pareto_masks_[idx].astype(bool)
        self.selected_objectives_ = self.pareto_front_[idx]
        return self


    def get_feature_names_out(self, input_features=None):
        check_is_fitted(self, "support_")
        if input_features is None:
            input_features = getattr(
                self, "feature_names_in_",
                np.array([f"x{i}" for i in range(self.n_features_in_)],
                         dtype=object))
        return np.asarray(input_features, dtype=object)[self.support_]

fit(X, y)

Run the multi-objective search on (X, y).

Source code in src/moofs/selection.py
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def fit(self, X, y):
    """Run the multi-objective search on (X, y)."""
    if self.algorithm not in _ALGORITHMS:
        raise ValueError(
            f"algorithm must be one of {sorted(_ALGORITHMS)}, "
            f"got {self.algorithm!r}")

    if isinstance(X, pd.DataFrame):
        Xdf = X.reset_index(drop=True)
        self.feature_names_in_ = np.asarray(X.columns, dtype=object)
    else:
        X = np.asarray(X)
        Xdf = pd.DataFrame(X, columns=[f"x{i}" for i in range(X.shape[1])])
    ydf = pd.Series(np.asarray(y)).reset_index(drop=True)
    self.n_features_in_ = Xdf.shape[1]

    problem = FeatureSelectionProblem(Xdf, ydf)
    cls = _ALGORITHMS[self.algorithm]
    kwargs = dict(pop_size=self.pop_size, max_evals=self.max_evals,
                  seed=self.random_state, verbose=self.verbose)
    if self.algorithm == "mofs-rfga" and self.sc is not None:
        kwargs["sc"] = self.sc
    algo = cls(problem, **kwargs)

    self.result_ = algo.run()
    self.n_evals_ = self.result_.n_evals
    self.pareto_front_ = self.result_.F
    self.pareto_masks_ = np.array(
        [np.asarray(s.x, dtype=int) for s in self.result_.front])

    idx = self._pick(self.pareto_front_, self.strategy)
    self.support_ = self.pareto_masks_[idx].astype(bool)
    self.selected_objectives_ = self.pareto_front_[idx]
    return self

Algorithms

Bases: Algorithm

MOFS-RFGA in the unified API.

Parameters:

Name Type Description Default
sc array-like of float

Feature-score vector (higher = better). If None, ReliefF scores are computed automatically from problem.X and problem.y.

None
D_init int

Upper bound on the number of features selected at initialisation; defaults to n_var.

None
Source code in src/moofs/algorithms/mofs_rfga.py
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
class MOFSRFGA(Algorithm):
    """MOFS-RFGA in the unified API.

    Parameters
    ----------
    sc : array-like of float, optional
        Feature-score vector (higher = better). If None, ReliefF scores are
        computed automatically from ``problem.X`` and ``problem.y``.
    D_init : int, optional
        Upper bound on the number of features selected at initialisation;
        defaults to ``n_var``.
    """

    name = "MOFS-RFGA"

    def __init__(self, problem, pop_size=60, max_evals=20000, sc=None,
                 D_init=None, interpretation="figure", seed=None,
                 verbose=False):
        super().__init__(problem, pop_size, max_evals, seed, verbose)
        if sc is None:
            sc = relieff(problem.X, problem.y)
        self.sc = np.asarray(sc, dtype=float)
        self.D_init = D_init if D_init is not None else problem.n_var
        if interpretation not in ("figure", "pseudocode"):
            raise ValueError("interpretation must be 'figure' or 'pseudocode'")
        self.interpretation = interpretation


    def _initial_population(self):
        pop = []
        for _ in range(self.pop_size):
            x = np.zeros(self.problem.n_var, dtype=int)
            R = self.rng.integers(1, max(2, self.D_init))
            for _ in range(R):
                i, j = self.rng.choice(len(self.sc), size=2, replace=False)
                x[i if self.sc[i] >= self.sc[j] else j] = 1
            x = ensure_nonempty(x, self.rng)
            pop.append(self.evaluate(x))
        return pop


    def _tournament_parents(self, pop, k=3):
        parents = []
        for _ in range(k):
            i, j = self.rng.choice(len(pop), size=2, replace=False)
            a, b = pop[i], pop[j]
            if dominates(a.F, b.F):
                parents.append(a)
            elif dominates(b.F, a.F):
                parents.append(b)
            else:
                parents.append(a if self.rng.random() < 0.5 else b)
        return parents

    def _crossover_3_to_1(self, p1, p2, p3):
        L1, L2, L3 = p1 & p2, p1 & p3, p2 & p3
        O = np.logical_or(L1, np.logical_or(L2, L3)).astype(int)
        S3 = L1 & L2 & L3                       # genes selected 3 times
        S2 = O ^ S3                             # genes selected exactly twice
        S1 = np.logical_or(p1, np.logical_or(p2, p3)).astype(int) ^ S3 ^ S2
        remove_better = self.interpretation == "pseudocode"
        if self.rng.random() < 0.5:
            cand = np.where(S2 == 1)[0]
            if len(cand) > 0:
                picks = self.rng.choice(cand, size=min(2, len(cand)),
                                        replace=False)
                key = max if remove_better else min
                target = key(picks, key=lambda d: self.sc[d])
                O[target] = 0
        else:
            cand = np.where(S1 == 1)[0]
            if len(cand) > 0:
                picks = self.rng.choice(cand, size=min(2, len(cand)),
                                        replace=False)
                key = min if remove_better else max
                target = key(picks, key=lambda d: self.sc[d])
                O[target] = 1
        return O

    def _mutation(self, o):
        o = o.copy()
        if self.rng.random() < 0.5:
            cand = np.where(o == 1)[0]
            if len(cand) > 0:
                picks = self.rng.choice(cand, size=min(2, len(cand)),
                                        replace=False)
                worst = min(picks, key=lambda d: self.sc[d])
                o[worst] = 0
        else:
            cand = np.where(o == 0)[0]
            if len(cand) > 0:
                picks = self.rng.choice(cand, size=min(2, len(cand)),
                                        replace=False)
                best = max(picks, key=lambda d: self.sc[d])
                o[best] = 1
        return o


    def _run(self):
        pop = self._initial_population()
        pop = environmental_selection(pop, self.pop_size)
        gen = 0
        while self.budget_left():
            offspring = []
            while len(offspring) < self.pop_size and self.budget_left():
                p1, p2, p3 = self._tournament_parents(pop)
                c = self._crossover_3_to_1(p1.x, p2.x, p3.x)
                c = self._mutation(c)
                c = ensure_nonempty(c, self.rng)
                offspring.append(self.evaluate(c))
            pop = environmental_selection(pop + offspring, self.pop_size)
            gen += 1
            self.log(f"gen={gen} evals={self.n_evals}")
        return self._result(pop)

Bases: Algorithm

NSGA-II for binary multi-objective feature selection.

Parameters:

Name Type Description Default
pc float

Crossover probability.

0.9
pm float

Per-gene mutation probability; defaults to 1/D.

None
crossover (single_point, uniform)
"single_point"
Source code in src/moofs/algorithms/nsga2.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
class NSGA2(Algorithm):
    """NSGA-II for binary multi-objective feature selection.

    Parameters
    ----------
    pc : float, default=0.9
        Crossover probability.
    pm : float, optional
        Per-gene mutation probability; defaults to 1/D.
    crossover : {"single_point", "uniform"}, default="single_point"
    """

    name = "NSGA-II"

    def __init__(self, problem, pop_size=60, max_evals=20000, pc=0.9, pm=None,
                 crossover="single_point", seed=None, verbose=False):
        super().__init__(problem, pop_size, max_evals, seed, verbose)
        self.pc = pc
        self.pm = pm if pm is not None else 1.0 / problem.n_var
        self._crossover = (uniform_crossover if crossover == "uniform"
                           else single_point_crossover)

    def _initial_population(self):
        pop = []
        for _ in range(self.pop_size):
            x = (self.rng.random(self.problem.n_var) < 0.5).astype(int)
            x = ensure_nonempty(x, self.rng)
            pop.append(self.evaluate(x))
        return pop

    def _sort_population(self, population):
        """Hook overridden by NSGA-II/SDR."""
        return environmental_selection(population, self.pop_size)

    def _run(self):
        pop = self._initial_population()
        pop = self._sort_population(pop)
        gen = 0
        while self.budget_left():
            offspring = []
            while len(offspring) < self.pop_size and self.budget_left():
                p1 = binary_tournament(pop, self.rng)
                p2 = binary_tournament(pop, self.rng)
                c1, c2 = self._crossover(p1.x, p2.x, self.rng, pc=self.pc)
                for c in (c1, c2):
                    if len(offspring) >= self.pop_size or not self.budget_left():
                        break
                    c = bitflip_mutation(c, self.rng, pm=self.pm)
                    c = ensure_nonempty(c, self.rng)
                    offspring.append(self.evaluate(c))
            pop = self._sort_population(pop + offspring)
            gen += 1
            self.log(f"gen={gen} evals={self.n_evals}")
        return self._result(pop)

Problem

Bases: Problem

Wrapper-based multi-objective feature selection problem.

Parameters:

Name Type Description Default
X DataFrame

Feature matrix.

required
y Series

Target labels.

required
estimator sklearn classifier

Defaults to KNeighborsClassifier(n_neighbors=3) as in the papers.

None
n_splits int

Number of CV folds.

3
random_state int

Seed of the K-Fold shuffling (fixed so that f1 is deterministic and cacheable).

64
cache bool

Memoize evaluations. Cache hits still increment n_evals so that FE-based stopping criteria stay comparable across algorithms.

True
Source code in src/moofs/core/problem.py
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
class FeatureSelectionProblem(Problem):
    """Wrapper-based multi-objective feature selection problem.

    Parameters
    ----------
    X : pandas.DataFrame
        Feature matrix.
    y : pandas.Series
        Target labels.
    estimator : sklearn classifier, optional
        Defaults to ``KNeighborsClassifier(n_neighbors=3)`` as in the papers.
    n_splits : int, default=3
        Number of CV folds.
    random_state : int, default=64
        Seed of the K-Fold shuffling (fixed so that f1 is deterministic and
        cacheable).
    cache : bool, default=True
        Memoize evaluations. Cache hits still increment ``n_evals`` so that
        FE-based stopping criteria stay comparable across algorithms.
    """

    def __init__(self, X, y, estimator=None, n_splits=3, random_state=64,
                 cache=True):
        super().__init__(n_var=X.shape[1], n_obj=2, encoding="binary")
        self.X = X
        self.y = y
        self.estimator = estimator
        self.n_splits = n_splits
        self.random_state = random_state
        self._cache = {} if cache else None

    def _make_estimator(self):
        if self.estimator is None:
            return KNeighborsClassifier(n_neighbors=3)
        from sklearn.base import clone
        return clone(self.estimator)

    def _evaluate(self, mask):
        mask = np.asarray(mask).astype(int)
        n_selected = int(mask.sum())
        if n_selected == 0:
            # Empty subset: worst possible error, zero features.
            return np.array([100.0, 0.0])

        key = tuple(mask)
        if self._cache is not None and key in self._cache:
            return self._cache[key].copy()

        Xs = self.X.iloc[:, mask.astype(bool)]
        kf = KFold(n_splits=self.n_splits, shuffle=True,
                   random_state=self.random_state)
        errors = []
        for tr, te in kf.split(Xs):
            clf = self._make_estimator()
            clf.fit(Xs.iloc[tr], self.y.iloc[tr])
            pred = clf.predict(Xs.iloc[te])
            errors.append(np.mean(np.asarray(self.y.iloc[te]) != pred))
        F = np.array([float(np.mean(errors)) * 100.0, float(n_selected)])

        if self._cache is not None:
            self._cache[key] = F.copy()
        return F

Metrics

Quality indicators for multi-objective solution sets, PlatEMO-compatible.

The IGD, HV and coverage definitions follow the PlatEMO implementations used in the MOFS literature (Tian et al., PlatEMO, IEEE CIM 2017), so values are directly comparable with published tables:

  • igd: mean of the minimum distances from each reference point (IGD.m).
  • hv: exact 2-D hypervolume after PlatEMO normalization (HV.m): objectives scaled by 1.1 * (max(PF) - fmin), reference point (1, 1).
  • coverage: weak-dominance set coverage (Coverage.m).

All functions accept 2-D objective arrays, lists of Solution, or a Result.

compare(results, reference_front=None)

Metric table for a set of results.

Parameters:

Name Type Description Default
results dict

Mapping name -> Result (or front).

required
reference_front array - like

Reference front; defaults to the non-dominated union of all results.

None

Returns:

Type Description
DataFrame

One row per algorithm: IGD, HV, NFS, best error, smallest subset.

Source code in src/moofs/metrics.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
def compare(results, reference_front=None):
    """Metric table for a set of results.

    Parameters
    ----------
    results : dict
        Mapping ``name -> Result`` (or front).
    reference_front : array-like, optional
        Reference front; defaults to the non-dominated union of all results.

    Returns
    -------
    pandas.DataFrame
        One row per algorithm: IGD, HV, NFS, best error, smallest subset.
    """
    ref = (merge_reference_front(*results.values())
           if reference_front is None else _as_F(reference_front))
    rows = []
    for name, res in results.items():
        F = _as_F(res)
        rows.append({
            "algorithm": name,
            "IGD": igd(ref, F),
            "HV": hv(F, ref),
            "NFS": nfs(F),
            "best_error_%": float(F[:, 0].min()) if len(F) else np.nan,
            "min_subset_size": int(F[:, 1].min()) if len(F) else -1,
        })
    return (pd.DataFrame(rows)
            .sort_values("IGD")
            .reset_index(drop=True))

coverage(A, B)

Set coverage SC(A, B),

Fraction of solutions in B that are weakly dominated by (i.e. no better in any objective than) at least one solution in A.

Source code in src/moofs/metrics.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
def coverage(A, B):
    """Set coverage SC(A, B), 

    Fraction of solutions in B that are weakly dominated by (i.e. no better
    in any objective than) at least one solution in A.
    """
    FA = _as_F(A)
    FB = _as_F(B)
    if len(FB) == 0:
        return 0.0
    count = sum(
        1 for fb in FB if any(np.all(fa <= fb) for fa in FA)
    )
    return count / len(FB)

gd(reference_front, front)

Generational Distance (lower is better).

Source code in src/moofs/metrics.py
44
45
46
47
48
49
50
51
def gd(reference_front, front):
    """Generational Distance (lower is better)."""
    R = _as_F(reference_front)
    A = _as_F(front)
    if len(A) == 0:
        return np.inf
    d = np.sqrt(((A[:, None, :] - R[None, :, :]) ** 2).sum(axis=2))
    return float(d.min(axis=1).mean())

hv(front, reference_front)

Hypervolume, PlatEMO definition (higher is better).

Objectives are normalized by fmin = min(min(front), 0) and fmax = max(reference_front) with a 1.1 scaling factor; points beyond the (1, 1) reference point are discarded; the exact 2-D hypervolume of the remaining non-dominated points is returned.

Source code in src/moofs/metrics.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def hv(front, reference_front):
    """Hypervolume, PlatEMO definition (higher is better).

    Objectives are normalized by ``fmin = min(min(front), 0)`` and
    ``fmax = max(reference_front)`` with a 1.1 scaling factor; points beyond
    the (1, 1) reference point are discarded; the exact 2-D hypervolume of
    the remaining non-dominated points is returned.
    """
    A = _as_F(front)
    R = _as_F(reference_front)
    if len(A) == 0 or len(R) == 0:
        return 0.0
    if A.shape[1] != 2:
        raise NotImplementedError("hv is implemented for 2 objectives.")
    fmin = np.minimum(A.min(axis=0), 0.0)
    fmax = R.max(axis=0)
    span = (fmax - fmin) * 1.1
    span[span == 0] = 1.0
    An = (A - fmin) / span
    An = An[~np.any(An > 1.0, axis=1)]
    if len(An) == 0:
        return 0.0
    keep = [i for i in range(len(An))
            if not any(dominates(An[j], An[i]) for j in range(len(An)) if j != i)]
    An = An[keep]
    An = An[np.argsort(An[:, 0])]
    score, prev = 0.0, 1.0
    for f1, f2 in An:
        if f2 < prev:
            score += (1.0 - f1) * (prev - f2)
            prev = f2
    return float(score)

igd(reference_front, front)

Inverted Generational Distance, PlatEMO definition (lower is better).

Source code in src/moofs/metrics.py
34
35
36
37
38
39
40
41
def igd(reference_front, front):
    """Inverted Generational Distance, PlatEMO definition (lower is better)."""
    R = _as_F(reference_front)
    A = _as_F(front)
    if len(A) == 0:
        return np.inf
    d = np.sqrt(((R[:, None, :] - A[None, :, :]) ** 2).sum(axis=2))
    return float(d.min(axis=1).mean())

merge_reference_front(*fronts)

Reference ("true") Pareto front: non-dominated union of several fronts.

This follows the protocol of the MOFS literature: the fronts of all algorithms are merged and non-dominated sorted; the first front is treated as the reference.

Source code in src/moofs/metrics.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def merge_reference_front(*fronts):
    """Reference ("true") Pareto front: non-dominated union of several fronts.

    This follows the protocol of the MOFS literature: the fronts of all
    algorithms are merged and non-dominated sorted; the first front is
    treated as the reference.
    """
    Fs = [_as_F(f) for f in fronts if len(_as_F(f)) > 0]
    if not Fs:
        return np.empty((0, 2))
    all_F = np.unique(np.vstack(Fs), axis=0)
    keep = [i for i in range(len(all_F))
            if not any(dominates(all_F[j], all_F[i])
                       for j in range(len(all_F)) if j != i)]
    return all_F[keep]

nfs(front)

Number of Feature Subsets: distinct solutions in the front.

Source code in src/moofs/metrics.py
125
126
127
128
def nfs(front):
    """Number of Feature Subsets: distinct solutions in the front."""
    A = _as_F(front)
    return len(np.unique(A, axis=0))

spacing(front)

Schott's spacing metric (lower = more uniform distribution).

Source code in src/moofs/metrics.py
131
132
133
134
135
136
137
138
139
def spacing(front):
    """Schott's spacing metric (lower = more uniform distribution)."""
    A = _as_F(front)
    if len(A) < 2:
        return 0.0
    d = np.abs(A[:, None, :] - A[None, :, :]).sum(axis=2)
    np.fill_diagonal(d, np.inf)
    di = d.min(axis=1)
    return float(np.sqrt(((di - di.mean()) ** 2).sum() / (len(A) - 1)))

Plotting

Pareto-front visualization helpers (matplotlib).

Every function accepts a Result, a list of Solution or a 2-D objective array, returns the matplotlib Axes for further styling, and never calls plt.show() — the caller stays in control.

plot_fronts(results, ax=None, reference=False)

Overlay several Pareto fronts for comparison.

Parameters:

Name Type Description Default
results dict

Mapping name -> Result (or front).

required
reference bool

Also draw the merged reference front as a dashed line.

False
Source code in src/moofs/plotting.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def plot_fronts(results, ax=None, reference=False):
    """Overlay several Pareto fronts for comparison.

    Parameters
    ----------
    results : dict
        Mapping ``name -> Result`` (or front).
    reference : bool, default=False
        Also draw the merged reference front as a dashed line.
    """
    if ax is None:
        _, ax = plt.subplots(figsize=(8, 5.5))
    for i, (name, res) in enumerate(results.items()):
        F = _as_F(res)
        order = np.argsort(F[:, 1])
        F = F[order]
        c = _COLORS[i % len(_COLORS)]
        m = _MARKERS[i % len(_MARKERS)]
        ax.scatter(F[:, 1], F[:, 0], label=name, color=c, marker=m, s=45,
                   alpha=0.9, zorder=3)
        ax.plot(F[:, 1], F[:, 0], color=c, alpha=0.3, linewidth=1, zorder=2)
    if reference:
        R = merge_reference_front(*results.values())
        R = R[np.argsort(R[:, 1])]
        ax.plot(R[:, 1], R[:, 0], "k--", linewidth=1.2, alpha=0.7,
                label="Reference front", zorder=1)
    _style(ax)
    ax.legend()
    return ax

plot_pareto_front(result, label=None, ax=None, color=None, marker='o', annotate=False)

Scatter plot of one Pareto front (error % vs. subset size).

Parameters:

Name Type Description Default
result Result, list of Solution, or ndarray
required
label str

Legend label (defaults to the algorithm name if available).

None
ax Axes
None
annotate bool

Write the subset size next to each point.

False
Source code in src/moofs/plotting.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def plot_pareto_front(result, label=None, ax=None, color=None, marker="o",
                      annotate=False):
    """Scatter plot of one Pareto front (error % vs. subset size).

    Parameters
    ----------
    result : Result, list of Solution, or ndarray
    label : str, optional
        Legend label (defaults to the algorithm name if available).
    ax : matplotlib.axes.Axes, optional
    annotate : bool, default=False
        Write the subset size next to each point.
    """
    F = _as_F(result)
    if label is None:
        label = getattr(result, "algorithm", None)
    if ax is None:
        _, ax = plt.subplots(figsize=(7, 5))
    order = np.argsort(F[:, 1])
    F = F[order]
    ax.scatter(F[:, 1], F[:, 0], label=label, color=color or _COLORS[0],
               marker=marker, s=45, zorder=3)
    ax.plot(F[:, 1], F[:, 0], color=color or _COLORS[0], alpha=0.35,
            linewidth=1, zorder=2)
    if annotate:
        for f1, f2 in F[:, [0, 1]]:
            ax.annotate(f"{int(f2)}", (f2, f1), textcoords="offset points",
                        xytext=(5, 5), fontsize=8)
    _style(ax)
    if label:
        ax.legend()
    return ax

plot_selector(selector, ax=None)

Plot a fitted MOFSSelector front and highlight the chosen subset.

Source code in src/moofs/plotting.py
84
85
86
87
88
89
90
91
92
93
94
95
def plot_selector(selector, ax=None):
    """Plot a fitted ``MOFSSelector`` front and highlight the chosen subset."""
    from sklearn.utils.validation import check_is_fitted
    check_is_fitted(selector, "support_")
    ax = plot_pareto_front(selector.pareto_front_,
                           label=selector.algorithm, ax=ax)
    err, size = selector.selected_objectives_
    ax.scatter([size], [err], s=180, facecolors="none",
               edgecolors="#D85A30", linewidths=2, zorder=4,
               label=f"selected ({selector.strategy})")
    ax.legend()
    return ax