Source code for skcriteria.base

#!/usr/bin/env python
# -*- coding: utf-8 -*-

# Copyright (c) 2016-2017, Cabral, Juan; Luczywo, Nadia
# All rights reserved.

# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

# * Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.

# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.

# * Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.

# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

# =============================================================================
# FUTURE & DOCS
# =============================================================================

from __future__ import unicode_literals


__doc__ = """Module containing the basic functionality
for the data representation used inside Scikit-Criteria.

"""

__all__ = ['Data']


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

import sys
import abc
from collections import Mapping

import six

import numpy as np

from tabulate import tabulate

from .utils.doc_inherit import InheritableDocstrings
from .utils.acc_property import AccessorProperty
from .validate import (CRITERIA_STR,
                       DataValidationError,
                       validate_data, iter_equal)
from .plot import DataPlotMethods


# =============================================================================
# CONSTANTS
# =============================================================================

TABULATE_PARAMS = {
    "headers": "firstrow",
    "numalign": "center",
    "stralign": "center",
}


# =============================================================================
# DATA
# =============================================================================

class MetaData(Mapping):

    def __init__(self, data):
        self._data = data

    def __getitem__(self, k):
        return self._data[k]

    def __iter__(self):
        return iter(self._data)

    def __len__(self):
        return len(self._data)

    def __getattr__(self, n):
        try:
            return self._data[n]
        except KeyError:
            raise AttributeError(n)

    def __dir__(self):
        return list(self._data)

    def __unicode__(self):
        return self.to_str()

    def __bytes__(self):
        encoding = sys.getdefaultencoding()
        return self.__unicode__().encode(encoding, 'replace')

    def __str__(self):
        """Return a string representation for a particular Object

        Invoked by str(df) in both py2/py3.
        Yields Bytestring in Py2, Unicode String in py3.
        """
        if six.PY3:
            return self.__unicode__()
        return self.__bytes__()

    def __repr__(self):
        return str(self)

    def to_str(self):
        return "MetaData(" + ", ".join(self) + ")"


[docs]class Data(object): """Multi-Criteria data representation. This make easy to manipulate: - The matrix of alternatives. (``mtx``) - The array with the sense of optimality of every criteria (``criteria``). - Optional weights of the criteria (``weights``) - Optional names of the alternatives (``anames``) and the criteria (``cnames``) - Optional metadata (``meta``) """ def __init__(self, mtx, criteria, weights=None, anames=None, cnames=None, meta=None): # validate and store all data self._mtx, self._criteria, self._weights = validate_data( mtx, criteria, weights) # validate alternative names self._anames = ( anames if anames is not None else ["A{}".format(idx) for idx in range(len(mtx))]) if len(self._anames) != len(self._mtx): msg = "{} names given for {} alternatives".format( len(self._anames), len(self._mtx)) raise DataValidationError(msg) # validate criteria names self._cnames = ( cnames if cnames is not None else ["C{}".format(idx) for idx in range(len(criteria))]) if len(self._cnames) != len(self._criteria): msg = "{} names for given {} criteria".format( len(self._cnames), len(self._criteria)) raise DataValidationError(msg) self._meta = MetaData(meta or {}) def _iter_rows(self): direction = map(CRITERIA_STR.get, self._criteria) title = ["ALT./CRIT."] if self._weights is None: cstr = zip(self._cnames, direction) criteria = ["{} ({})".format(n, c) for n, c in cstr] else: cstr = zip(self._cnames, direction, self._weights) criteria = ["{} ({}) W.{}".format(n, c, w) for n, c, w in cstr] yield title + criteria for an, row in zip(self._anames, self._mtx): yield [an] + list(row) def __eq__(self, obj): return ( isinstance(obj, Data) and self._meta == obj.meta and iter_equal(self._mtx, obj._mtx) and iter_equal(self._criteria, obj._criteria) and iter_equal(self._weights, obj._weights)) def __ne__(self, obj): return not self == obj def __unicode__(self): return self.to_str() def __bytes__(self): encoding = sys.getdefaultencoding() return self.__unicode__().encode(encoding, 'replace') def __str__(self): """Return a string representation for a particular Object Invoked by str(df) in both py2/py3. Yields Bytestring in Py2, Unicode String in py3. """ if six.PY3: return self.__unicode__() return self.__bytes__() def __repr__(self): return str(self) def _repr_html_(self): return self.to_str(tablefmt="html")
[docs] def to_str(self, **params): """String representation of the Data object. Parameters ---------- kwargs : Parameters to configure `tabulate <https://bitbucket.org/astanin/python-tabulate>`_ Return ------ str : String representation of the Data object. """ params.update({ k: v for k, v in TABULATE_PARAMS.items() if k not in params}) rows = self._iter_rows() return tabulate(rows, **params)
[docs] def raw(self): """Return a (mtx, criteria, weights, anames, cnames) tuple""" return self.mtx, self.criteria, self.weights, self.anames, self.cnames
@property def anames(self): """Names of the alternatives as tuple of string.""" return tuple(self._anames) @property def cnames(self): """Names of the criteria as tuple of string.""" return tuple(self._cnames) @property def mtx(self): """Alternative matrix as 2d numpy.ndarray.""" return self._mtx.copy() @property def criteria(self): """Sense of optimality of every criteria""" return self._criteria.copy() @property def weights(self): """Relative importance of the criteria or None if all the same""" return None if self._weights is None else self._weights.copy() @property def meta(self): """Dict-like metadata""" return self._meta # ---------------------------------------------------------------------- # Add plotting methods to DataFrame plot = AccessorProperty(DataPlotMethods, DataPlotMethods)
# ============================================================================= # DECISION MAKER # ============================================================================= class BaseSolverMeta(abc.ABCMeta, InheritableDocstrings): pass @six.add_metaclass(BaseSolverMeta) class BaseSolver(object): def __init__(self, mnorm, wnorm): from . import norm self._mnorm = norm.get(mnorm, mnorm) self._wnorm = norm.get(wnorm, wnorm) if not hasattr(self._mnorm, "__call__"): msg = "'mnorm' must be a callable or a string in {}. Found {}" raise TypeError(msg.format(norm.NORMALIZERS.keys(), mnorm)) if not hasattr(self._wnorm, "__call__"): msg = "'wnorm' must be a callable or a string in {}. Found {}" raise TypeError(msg.format(norm.NORMALIZERS.keys(), wnorm)) def __eq__(self, obj): return isinstance(obj, type(self)) and self.as_dict() == obj.as_dict() def __ne__(self, obj): return not self == obj def __str__(self): cls_name = type(self).__name__ data = sorted(self.as_dict().items()) data = ", ".join( "{}={}".format(k, v) for k, v in data) return "<{} ({})>".format(cls_name, data) def __repr__(self): return str(self) def as_dict(self): """Create a simply :py:class:`dict` representation of the object. Notes ----- ``x.as_dict != dict(x)`` """ return {"mnorm": self._mnorm.__name__, "wnorm": self._wnorm.__name__} def preprocess(self, data): """Normalize the alternative matrix and weight vector. Creates a new instance of data by aplying the normalization function to the alternative matrix and the weights vector containded inside the given data. Parameters ---------- data : :py:class:`skcriteria.Data` A data to be Preprocessed Returns ------- :py:class:`skcriteria.Data` A new instance of data with the ``mtx`` attributes normalized with ``mnorm`` and ``weights`` normalized with wnorm. ``anames`` and ``cnames`` are preseved """ nmtx = self._mnorm(data.mtx, criteria=data.criteria, axis=0) nweights = ( self._wnorm(data.weights, criteria=data.criteria) if data.weights is not None else np.ones(data.criteria.shape)) return Data(mtx=nmtx, criteria=data.criteria, weights=nweights, anames=data.anames, cnames=data.cnames) def decide(self, data, criteria=None, weights=None, **kwargs): """Execute the Solver over the given data. Parameters ---------- data : :py:class:`skcriteria.Data` or array_like :py:class:`skcriteria.Data` instance; or a alternative matrix (2d array_like) `n` rows and `m` columns, where n is the number of alternatives and `m` is the number of criteria. criteria : None or array_like, optional If data is :py:class:`skcriteria.Data` must be ``None``. Otherwise must be a 1d array_like with `m` elements (number of criteria); only the values ``-1`` (for minimization) and ``1`` (maximization) are allowed. weights : None or array_like, optional - If data is :py:class:`skcriteria.Data` must be ``None``. - If data is 2d array_like and weights are ``None`` all the criteria has the same weight. - If data is 2d array_like and weights are 1d array_like with `m` elements (number of criteria); the i-nth element represent the importance of the i-nth criteria. kwargs : optional keywords arguments for the solve method Returns ------- :py:class:`object` Check the documentation of ``make_result()`` """ if isinstance(data, Data): if (criteria, weights) != (None, None): raise ValueError("If 'data' is instance of Data, 'criteria' " "and 'weights' must be None") else: if criteria is None: raise ValueError("If 'data' is not instance of Data you must " "provide a 'criteria' array") data = Data(data, criteria, weights) pdata = self.preprocess(data) result = self.solve(pdata, **kwargs) return self.make_result(data, *result) @abc.abstractmethod def solve(self, pdata): """Execute the multi-criteria method. Parameters ---------- data : :py:class:`skcriteria.Data` Preprocessed Data. Returns ------- :py:class:`object` object or tuple of objects with the raw result data. """ return NotImplemented @abc.abstractmethod def make_result(self, rdata): return NotImplemented @property def mnorm(self): """Normalization function for the alternative matrix.""" return self._mnorm @property def wnorm(self): """Normalization function for the weights vector.""" return self._wnorm