Source code for skcriteria.core.dominance

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# License: BSD-3 (https://tldrlegal.com/license/bsd-3-clause-license-(revised))
# Copyright (c) 2016-2021, Cabral, Juan; Luczywo, Nadia
# Copyright (c) 2022, 2023, 2024 QuatroPe
# All rights reserved.

# =============================================================================
# DOCS
# =============================================================================

"""Dominance helper for the DecisionMatrix object."""

# =============================================================================
# IMPORTS
# =============================================================================

import itertools as it
from collections import OrderedDict

import methodtools

import numpy as np

import pandas as pd

from ..utils import AccessorABC, rank

# =============================================================================
# DOMINANCE ACCESSOR
# =============================================================================


[docs] class DecisionMatrixDominanceAccessor(AccessorABC): """Calculate basic statistics of the decision matrix.""" _default_kind = "dominance" def __init__(self, dm): self._dm = dm @methodtools.lru_cache(maxsize=None) @property def _dominance_cache(self): """Cache of dominance. Compute the dominance is an O(n_C_2) algorithm, so lets use a cache. """ dm = self._dm reverse = dm.minwhere dominance_cache, alts_numpy = {}, {} for a0, a1 in it.combinations(dm.alternatives, 2): for aname in (a0, a1): if aname not in alts_numpy: alts_numpy[aname] = dm.alternatives[aname] dominance_cache[(a0, a1)] = rank.dominance( alts_numpy[a0], alts_numpy[a1], reverse=reverse ) return dominance_cache def _cache_read(self, a0, a1): """Return the entry of the cache. The input returned is the one that relates the alternatives a0 and a1. Since the cache can store the entry with the key (a0, a1) or (a1, a0), a second value is returned that is True if it was necessary to invert the alternatives. """ key = a0, a1 cache = self._dominance_cache entry, key_reverted = ( (cache[key], False) if key in cache else (cache[key[::-1]], True) ) return entry, key_reverted # FRAME ALT VS ALT ======================================================== def _create_frame(self, compute_cell, iname, cname): """Create a data frame comparing two alternatives. The value of each cell is calculated with the "compute_cell" function. """ alternatives = self._dm.alternatives rows = [] for a0 in alternatives: row = OrderedDict() for a1 in alternatives: row[a1] = compute_cell(a0, a1) rows.append(row) df = pd.DataFrame(rows, index=alternatives) df.index.name = iname df.columns.name = cname return df
[docs] def bt(self): """Compare on how many criteria one alternative is better than another. *bt* = better-than. Returns ------- pandas.DataFrame: Where the value of each cell identifies on how many criteria the row alternative is better than the column alternative. """ def compute_cell(a0, a1): if a0 == a1: return 0 centry, ckreverted = self._cache_read(a0, a1) return centry.aDb if not ckreverted else centry.bDa return self._create_frame( compute_cell, iname="Better than", cname="Worse than" )
[docs] def eq(self): """Compare on how many criteria two alternatives are equal. Returns ------- pandas.DataFrame: Where the value of each cell identifies how many criteria the row and column alternatives are equal. """ criteria_len = len(self._dm.criteria) def compute_cell(a0, a1): if a0 == a1: return criteria_len centry, _ = self._cache_read(a0, a1) return centry.eq return self._create_frame( compute_cell, iname="Equals to", cname="Equals to" )
[docs] def dominance(self, *, strict=False): """Compare if one alternative dominates or strictly dominates another \ alternative. In order to evaluate the dominance of an alternative *a0* over an alternative *a1*, the algorithm evaluates that *a0* is better in at least one criterion and that *a1* is not better in any criterion than *a0*. In the case that ``strict = True`` it also evaluates that there are no equal criteria. Parameters ---------- strict: bool, default ``False`` If True, strict dominance is evaluated. Returns ------- pandas.DataFrame: Where the value of each cell is True if the row alternative dominates the column alternative. """ def compute_cell(a0, a1): if a0 == a1: return False centry, ckreverted = self._cache_read(a0, a1) performance_a0, performance_a1 = ( (centry.aDb, centry.bDa) if not ckreverted else (centry.bDa, centry.aDb) ) if strict and centry.eq: return False return performance_a0 > 0 and performance_a1 == 0 iname, cname = ( ("Strict dominators", "Strictly dominated") if strict else ("Dominators", "Dominated") ) dom = self._create_frame(compute_cell, iname=iname, cname=cname) return dom
# COMPARISONS =============================================================
[docs] def compare(self, a0, a1): """Compare two alternatives. It creates a summary data frame containing the comparison of the two alternatives on a per-criteria basis, indicating which of the two is the best value, or if they are equal. In addition, it presents a "Performance" column with the count for each case. Parameters ---------- a0, a1: str Names of the alternatives to compare. Returns ------- pandas.DataFrame: Comparison of the two alternatives by criteria. """ # read the cache and extract the values centry, ckreverted = self._cache_read(a0, a1) performance_a0, performance_a1 = ( (centry.aDb, centry.bDa) if not ckreverted else (centry.bDa, centry.aDb) ) where_aDb, where_bDa = ( (centry.aDb_where, centry.bDa_where) if not ckreverted else (centry.bDa_where, centry.aDb_where) ) eq, eq_where = centry.eq, centry.eq_where criteria = self._dm.criteria alt_index = pd.MultiIndex.from_tuples( [ ("Alternatives", a0), ("Alternatives", a1), ("Equals", ""), ] ) crit_index = pd.MultiIndex.from_product([["Criteria"], criteria]) df = pd.DataFrame( [ pd.Series(where_aDb, name=alt_index[0], index=crit_index), pd.Series(where_bDa, name=alt_index[1], index=crit_index), pd.Series(eq_where, name=alt_index[2], index=crit_index), ] ) df["Performance"] = pd.Series( [performance_a0, performance_a1, eq], index=df.index ) return df
# The dominated ===========================================================
[docs] def dominated(self, *, strict=False): """Which alternative is dominated or strictly dominated by at least \ one other alternative. Parameters ---------- strict: bool, default ``False`` If True, strict dominance is evaluated. Returns ------- pandas.Series: Where the index indicates the name of the alternative, and if the value is is True, it indicates that this alternative is dominated by at least one other alternative. """ dom = self.dominance(strict=strict).any() dom.name = dom.index.name dom.index.name = "Alternatives" return dom
[docs] @methodtools.lru_cache(maxsize=None) def dominators_of(self, a, *, strict=False): """Array of alternatives that dominate or strictly-dominate the \ alternative provided by parameters. Parameters ---------- a : str On what alternative to look for the dominators. strict: bool, default ``False`` If True, strict dominance is evaluated. Returns ------- numpy.ndarray: List of alternatives that dominate ``a``. """ dominance_a = self.dominance(strict=strict)[a] if ~dominance_a.any(): return np.array([], dtype=str) dominators = dominance_a.index[dominance_a] for dominator in dominators: dominators_dominators = self.dominators_of( dominator, strict=strict ) dominators = np.concatenate((dominators, dominators_dominators)) return dominators
[docs] def has_loops(self, *, strict=False): """Retorna True si la matriz contiene loops de dominacia. A loop is defined as if there are alternatives `a0`, `a1` and 'a2' such that "a0 ≻ a1 ≻ a2 ≻ a0" if ``strict=True``, or "a0 ≽ a1 ≽ a2 ≽ a0" if ``strict=False`` Parameters ---------- strict: bool, default ``False`` If True, strict dominance is evaluated. Returns ------- bool: If True a loop exists. Notes ----- If the result of this method is True, the ``dominators_of()`` method raises a ``RecursionError`` for at least one alternative. """ # lets put the dominated alternatives last so our while loop will # be shorter by extracting from the tail alternatives = list(self.dominated(strict=strict).sort_values().index) try: while alternatives: # dame la ultima alternativa (al final quedan las dominadas) alt = alternatives.pop() # ahora dame todas las alternatives las cuales dominan dominators = self.dominators_of(alt, strict=strict) # las alternativas dominadoras ya pasaron por "dominated_by" # por lo cual hay que sacarlas a todas de alternatives alternatives = [a for a in alternatives if a not in dominators] except RecursionError: return True return False