#!/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
# =============================================================================
"""VIKOR method."""
# =============================================================================
# IMPORTS
# =============================================================================
from ..utils import hidden
with hidden():
import warnings
import numpy as np
from ._agg_base import RankResult, SKCDecisionMakerABC
from ..utils import doc_inherit, rank
from ..preprocessing.scalers import matrix_scale_by_cenit_distance
# =============================================================================
# VIKOR
# =============================================================================
def _scale(matrix, objectives):
"""Scale columns in matrix to [0,1], indicating distance to Zenith.
See Also
--------
skcriteria.preprocessing.scalers.matrix_scale_by_cenit_distance :
Underlying scaler, without the meaningful warning.
Warnings
--------
UserWarning:
Division by zero may occur during scaling if any criterion has
identical values across all alternatives, or there is identical
group utility or individual regret across all alternatives.
"""
# matrix_scale_by_cenit_distance maps zenith to 1.
# We want the opposite so we invert objectives
objectives = np.asarray(objectives, dtype=float) * -1
with np.errstate(divide="warn"):
with warnings.catch_warnings(record=True) as w:
result = matrix_scale_by_cenit_distance(matrix, objectives)
if len(w) > 0:
warnings.warn(
"Some criteria (or distance) was equal in all alternatives, "
"so it was ignored. Inspect the Decision Matrix for criteria "
"with a single value, or the distances r_k, s_k in result.extra_ "
"to know which."
)
return np.nan_to_num(result)
[docs]
class VIKOR(SKCDecisionMakerABC):
"""The VIKOR Method for Multi-Criteria Decision Making.
VIKOR (VIseKriterijumska Optimizacija I Kompromisno Resenje)
introduces the concept of a compromise solution, which is a feasible
solution that is closest to the ideal, and represents a balance
between the majority rule (group utility) and the individual regret
of the opponent.
The method evaluates alternatives by converting an n-criteria
decision problem into a bi-criteria one using the Manhattan distance
(:math:`S_k`, or group utility) and the Chebyshev distance
(:math:`R_k`, or individual regret). These are then combined into
a single aggregated score (:math:`Q_k`) using a weight factor
:math:`v` that reflects the decision-making strategy: emphasis on
group utility (high :math:`v`) or individual regret (low :math:`v`).
VIKOR allows the identification of a single compromise solution if
the following two conditions are met:
- Acceptable advantage:
The best-ranked alternative is sufficiently better than the
second.
- Acceptable stability:
The best-ranked alternative must also be the best in at least
one of the original distance metrics.
Otherwise, it identifies a set of compromise solutions.
Parameters
----------
v : float, optional, default=0.5
The strategy weight that reflects the decision-making tendency.
`v = 0` gives full weight to the Chebyshev distance (individual
regret), `v = 1` gives full weight to the Manhattan distance
(group utility), and `v = 0.5` balances both.
Must satisfy `0 <= v <= 1`.
use_compromise_set : bool, optional, default=True
If True, all alternatives within the identified compromise set
are ranked equally at the top position (rank 1).
If False, only the best :math:`Q_k` remains at the top rank, and
it is up to the user to examine the compromise set afterwards.
Warnings
--------
UserWarning:
Division by zero may occur during scaling if any criterion has
identical values across all alternatives, or there is identical
group utility or individual regret across all alternatives.
References
----------
:cite:p:`opricovic2004compromise`
"""
_skcriteria_parameters = ["v", "use_compromise_set"]
def __init__(self, *, v=0.5, use_compromise_set=True):
self._use_compromise_set = bool(use_compromise_set)
self._v = float(v)
if not (self._v >= 0 and self._v <= 1):
raise ValueError(f"'v' must be 0 <= v <= 1. Found {self._v}")
@property
def v(self):
"""The strategy weight for VIKOR."""
return self._v
@property
def use_compromise_set(self):
"""Whether to use the compromise set in ranking."""
return self._use_compromise_set
@doc_inherit(SKCDecisionMakerABC._make_result)
def _make_result(self, alternatives, values, extra):
return RankResult(
method="VIKOR",
alternatives=alternatives,
values=values,
extra=extra,
)
@doc_inherit(SKCDecisionMakerABC._evaluate_data)
def _evaluate_data(self, matrix, objectives, weights, **kwargs):
# (a): Scale the matrix by distance to zenith
matrix_scaled = _scale(matrix, objectives) * weights
# (b): Compute Manhattan distance (S) and Chebyshev distance (R)
distances_matrix = np.column_stack(
(np.sum(matrix_scaled, axis=1), np.max(matrix_scaled, axis=1))
)
# Scaling to minimize distances
distances_matrix_scaled = _scale(distances_matrix, [-1, -1])
# (c): Compute Q: weighted sum of our distances with weights [v, 1-v]
q_k = np.dot(distances_matrix_scaled, [self.v, 1 - self.v])
# (d): Rank them
rank_q_k = rank.rank_values(q_k, reverse=False)
# (e): Check if solution is acceptable
chosen_qs = np.where(rank_q_k == 1)[0] # Possibly many qs with rank 1
best_q_value = q_k[chosen_qs[0]]
dq = 1 / (len(matrix) - 1)
qs_with_acceptable_advantage = np.where(q_k - best_q_value < dq)[0]
has_acceptable_advantage = (
# chosen_qs always have acc. adv., therefore same len <=> same qs
len(qs_with_acceptable_advantage)
== len(chosen_qs)
)
# They must also be the best solution of one of the original distances
bests = np.any(distances_matrix_scaled == 0, axis=1).nonzero()[0]
has_acceptable_stability = set(chosen_qs).issubset(set(bests))
if has_acceptable_stability and has_acceptable_advantage:
# Our solution was good
compromise_set = chosen_qs
elif not has_acceptable_stability and has_acceptable_advantage:
# When unstable, top 2 ranks are chosen
compromise_set = np.where(rank_q_k <= 2)[0]
else:
# If all fails, include all that would have acceptable advantage
compromise_set = qs_with_acceptable_advantage
# Reorder ranking so alternatives in compromise set tie at rank 1
if self.use_compromise_set:
max_compromise_rank = np.max(rank_q_k[compromise_set])
rank_q_k = np.where(
rank_q_k <= max_compromise_rank,
1,
rank_q_k - max_compromise_rank + 1,
)
extra = {
"r_k": distances_matrix[:, 1],
"s_k": distances_matrix[:, 0],
"q_k": q_k,
"acceptable_advantage": bool(has_acceptable_advantage),
"acceptable_stability": bool(has_acceptable_stability),
"compromise_set": compromise_set,
}
return rank_q_k, extra