#!/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-2025 QuatroPe
# All rights reserved.
# =============================================================================
# DOCS
# =============================================================================
"""SPOTIS method."""
# =============================================================================
# IMPORTS
# =============================================================================
from ..utils import hidden
with hidden():
import numpy as np
from ._agg_base import RankResult, SKCDecisionMakerABC
from ..core import Objective
from ..utils import doc_inherit, rank
# =============================================================================
# SPOTIS
# =============================================================================
[docs]
def spotis(matrix, weights, bounds, isp):
"""Execute SPOTIS method."""
min_bounds = bounds[:, 0]
max_bounds = bounds[:, 1]
# Calculate alternatives distances to ISP
normalized_distance = np.abs((matrix - isp) / (max_bounds - min_bounds))
# Scores by weighted sum
scores = np.sum(normalized_distance * weights, axis=1)
return rank.rank_values(scores), {"score": scores}
[docs]
class SPOTIS(SKCDecisionMakerABC):
r"""The Stable Preference Ordering Towards Ideal Solution (SPOTIS) method.
The SPOTIS method is a multi-criteria decision analysis method that is
exempt of rank reversal. The method is rank reversal free because the
preference ordering established from the score matrix of the MCDM problem
does not require relative comparisons between the alternatives, but only
comparisons with respect to the ideal solution (ISP) chosen by the
MCDM designer.
References
----------
:cite:p:`dezert2020spotis`
"""
_skcriteria_parameters = []
@doc_inherit(SKCDecisionMakerABC._evaluate_data)
def _evaluate_data(self, matrix, weights, bounds, isp, **kwargs):
extra = {"bounds": bounds, "isp": isp}
rank, method_extra = spotis(matrix, weights, bounds, isp)
extra.update(method_extra)
return rank, extra
[docs]
def evaluate(self, dm, *, bounds=None, isp=None):
"""Validate the decision matrix and calculate a ranking.
Parameters
----------
dm: :py:class:`skcriteria.data.DecisionMatrix`
Decision matrix on which the ranking will be calculated.
bounds: array-like, optional
The bounds of the problem. If not provided, they will be calculated
from the matrix.
isp: array-like, optional
The ideal solution point (ISP), if not provided, it will be
calculated from the bounds.
Raises
------
ValueError:
- If bounds are provided and the matrix has values out of the
bounds.
- If ISP is provided and the ISP has values out of the bounds
(either given or calculated from the matrix).
- If bounds or ISP have an invalid shape.
Returns
-------
:py:class:`skcriteria.data.RankResult`
Ranking.
"""
numpy_matrix = dm.matrix.to_numpy()
if bounds is None:
bounds = self._bounds_from_matrix(numpy_matrix)
else:
bounds = np.asarray(bounds)
self._validate_bounds(bounds, numpy_matrix)
if isp is None:
isp = self._isp_from_bounds(bounds, dm.iobjectives.to_numpy())
else:
isp = np.asarray(isp)
self._validate_isp(isp, bounds)
return self._evaluate_dm(dm, bounds=bounds, isp=isp)
@doc_inherit(SKCDecisionMakerABC._make_result)
def _make_result(self, alternatives, values, extra):
return RankResult(
"SPOTIS",
alternatives=alternatives,
values=values,
extra=extra,
)
def _bounds_from_matrix(self, matrix):
"""Calculate the bounds of the problem from the matrix."""
min_bounds = np.min(matrix, axis=0).reshape(-1, 1)
max_bounds = np.max(matrix, axis=0).reshape(-1, 1)
return np.hstack((min_bounds, max_bounds))
def _isp_from_bounds(self, bounds, objectives):
"""Calculate the reference or nominal Ideal Solution Point (ISP) \
from the bounds and objectives."""
row_indexs = np.arange(bounds.shape[0])
col_indexs = [
0 if obj == Objective.MIN.value else 1 for obj in objectives
]
isp = bounds[row_indexs, col_indexs]
return isp
def _validate_bounds(self, bounds, matrix):
if bounds.shape != (matrix.shape[1], 2):
raise ValueError(
f"Invalid shape for bounds. It must be (n_criteria, 2). \
Got: {bounds.shape}."
)
min_bounds, max_bounds = bounds[:, 0], bounds[:, 1]
within_bounds = (matrix >= min_bounds) & (matrix <= max_bounds)
if not np.all(within_bounds):
raise ValueError(
"The matrix values must be within the provided bounds."
)
def _validate_isp(self, isp, bounds):
if isp.shape[0] != bounds.shape[0]:
raise ValueError(
f"Invalid shape for Ideal Solution Point (ISP). It must \
have the same number of criteria as the bounds. \
Got: {isp.shape}."
)
min_bounds, max_bounds = bounds[:, 0], bounds[:, 1]
if not np.all(isp >= min_bounds) or not np.all(isp <= max_bounds):
raise ValueError(
"The isp values must be within the provided bounds."
)