#!/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
# =============================================================================
"""Tie breaker estrategies for eliminating ties in rankings."""
# =============================================================================
# IMPORTS
# =============================================================================
import warnings
import numpy as np
import pandas as pd
from .agg import RankResult
from .core import SKCMethodABC
from .utils.bunch import Bunch
# =============================================================================
# WARNINGS
# =============================================================================
[docs]
class TieUnresolvedWarning(UserWarning):
"""Warning for when ties remain unresolved after a tie-breaker is \
applied."""
pass
# =============================================================================
# CLASS
# =============================================================================
[docs]
class FallbackTieBreaker(SKCMethodABC):
"""Decision maker that breaks ties in rankings using a fallback \
decision maker.
This class takes a primary decision maker that may produce tied rankings
and uses a fallback decision maker to break those ties. If the fallback
decision maker also produces ties and force=True, it uses the
:py:attr:`~skcriteria.agg.RankResult.untied_rank_`
property to ensure a complete ranking without ties.
Parameters
----------
dmaker : decision maker
Primary decision maker that implements the `evaluate()` method.
This decision maker may produce rankings with ties.
untier : decision maker
Fallback decision maker used to break ties. It will be applied only
to the tied alternatives from the primary decision maker.
force : bool, default True
If True, when the fallback decision maker also produces ties, uses
the :py:attr:`~skcriteria.agg.RankResult.untied_rank_` property to
force a complete ranking without ties.
If False, allows the final ranking to have ties if the fallback fails
to break them completely.
Examples
--------
>>> from skcriteria import mkdm
>>> from skcriteria.agg import simple
>>>
>>> # Create a decision matrix
>>> dm = mkdm([[1, 2], [3, 4]], [1, 1], ["max", "max"])
>>>
>>> # Primary decision maker
>>> primary = simple.WeightedSum()
>>>
>>> # Fallback decision maker for tie breaking
>>> fallback = simple.WeightedProduct()
>>>
>>> # Create fallback tie breaker
>>> tie_breaker = FallbackTieBreaker(primary, fallback)
>>>
>>> # Evaluate
>>> result = tie_breaker.evaluate(dm)
"""
_skcriteria_dm_type = "fallback_tie_breaker"
_skcriteria_parameters = ["dmaker", "untier", "force"]
def __init__(self, dmaker, untier, *, force=True):
# Validate that both decision makers implement the evaluate method
if not (hasattr(dmaker, "evaluate") and callable(dmaker.evaluate)):
raise TypeError("'dmaker' must implement 'evaluate()' method")
if not (hasattr(untier, "evaluate") and callable(untier.evaluate)):
raise TypeError("'untier' must implement 'evaluate()' method")
self._dmaker = dmaker
self._untier = untier
self._force = bool(force)
def __repr__(self):
"""Return string representation of the FallbackTieBreaker instance.
Returns
-------
str
String representation showing the primary decision maker,
fallback decision maker, and force parameter.
"""
name = self.get_method_name()
dec_repr = repr(self._dmaker)
untier_repr = repr(self._untier)
force = self._force
return (
f"<{name} dmaker={dec_repr}, untier={untier_repr}, force={force}>"
)
# PROPERTIES ==============================================================
@property
def dmaker(self):
"""Primary decision maker.
Returns
-------
decision maker
The primary decision maker instance used for initial ranking.
"""
return self._dmaker
@property
def untier(self):
"""Fallback decision maker for breaking ties.
Returns
-------
decision maker
The fallback decision maker instance used to break ties
from the primary decision maker.
"""
return self._untier
@property
def force(self):
"""Whether to force complete untying using \
:py:attr:`~skcriteria.agg.RankResult.untied_rank_`.
Returns
-------
bool
True if forced untying is enabled, False otherwise.
"""
return self._force
# LOGIC ===================================================================
def _generate_tied_groups(self, orank):
"""Generate groups of alternatives that share the same rank value.
This method identifies all alternatives that have tied rankings
and yields them grouped by their rank value.
Parameters
----------
orank : RankResult
Original ranking result from the primary decision maker.
Yields
------
tuple of (int, list)
A tuple containing the rank value and a list of alternatives
that share that rank.
"""
# Get all unique rank values
values = set(orank.values)
series = orank.to_series()
# For each rank value, find all alternatives with that rank
for rank_value in sorted(values):
alts = list(series[series == rank_value].index)
yield rank_value, alts
def _get_relative_rank_series(self, dm, alts, untier, last_assigned_rank):
"""Calculate relative ranks for a group of tied alternatives.
For a given group of tied alternatives, this method either assigns
the next sequential rank (if only one alternative) or uses the
fallback decision maker to break ties within the group.
Parameters
----------
dm : DecisionMatrix
The decision matrix containing all alternatives and criteria.
alts : list
List of alternative indices that are tied.
untier : decision maker
The fallback decision maker to use for breaking ties.
last_assigned_rank : int
The last rank value that was assigned to maintain sequential
ranking.
Returns
-------
pandas.Series
Series containing the relative ranks for the tied alternatives,
indexed by alternative names.
"""
if len(alts) == 1:
# Single alternative, assign next sequential rank
relative_rank_dict = {alts[0]: last_assigned_rank + 1}
else:
# Multiple tied alternatives, use fallback to break ties
sub_dm = dm.loc[alts] # Extract submatrix for tied alternatives
sub_rank = untier.evaluate(sub_dm) # Apply fallback decision maker
# Adjust ranks to maintain sequential ordering
relative_values = sub_rank.values + last_assigned_rank
relative_rank_dict = dict(zip(alts, relative_values))
relative_rank_series = pd.Series(relative_rank_dict)
return relative_rank_series
def _get_rank_method_name(self, orank, untier):
"""Construct the method name for the tie-broken ranking.
Creates a descriptive method name that shows both the original
ranking method and the fallback method used for tie breaking.
Parameters
----------
orank : RankResult
Original ranking result containing the method name.
untier : decision maker
The fallback decision maker instance.
Returns
-------
str
Formatted method name showing the combination of methods used.
"""
omethod = orank.method
untier_name = untier.get_method_name()
method_name = f"{omethod}+FallbackTieBreaker({untier_name})"
return method_name
def _patch_extra(self, orank, untier, forced):
"""Create enhanced metadata dictionary for the tie-broken result.
Combines the original ranking metadata with additional information
about the tie-breaking process.
Parameters
----------
orank : RankResult
Original ranking result containing existing metadata.
untier : decision maker
The fallback decision maker instance.
forced : bool
Whether forced untying was applied to eliminate remaining ties.
Returns
-------
dict
Enhanced metadata dictionary containing original information
plus tie-breaking details.
"""
# Start with original metadata
extra = orank.extra_.to_dict()
# Add tie-breaking specific information
extra["fallback_tiebreaker"] = Bunch(
"fallback_tiebreaker",
{
"original_method": orank.method,
"fallback_method": untier.get_method_name(),
"original_values": orank.values,
"forced": forced,
},
)
return extra
[docs]
def evaluate(self, dm):
"""Evaluate the decision matrix using the fallback tie-breaking \
approach.
This method first applies the primary decision maker to get an initial
ranking. If ties exist, it systematically applies the fallback decision
maker to each group of tied alternatives to break the ties.
Parameters
----------
dm : DecisionMatrix
Decision matrix to evaluate containing alternatives and criteria.
Returns
-------
result : skcriteria.agg.RankResult
Ranking result with ties broken using the fallback decision maker.
If force=True and ties still remain, uses
:py:attr:`~skcriteria.agg.RankResult.untied_rank_` to ensure
a complete ranking without any ties.
"""
# Store instance variables as locals for efficiency
dmaker = self._dmaker
untier = self._untier
force = self._force
# Get initial ranking from primary decision maker
orank = dmaker.evaluate(dm)
# Early return if no ties exist in the original ranking
if not orank.has_ties_:
return orank
# Initialize variables for tie-breaking process
last_assigned_rank = 0
untied_rank_series = None
# Process each group of tied alternatives
for rank, alts in self._generate_tied_groups(orank):
# Get relative ranks for this tied group
relative_rank_series = self._get_relative_rank_series(
dm, alts, untier, last_assigned_rank
)
# Update the last assigned rank for sequential ordering
last_assigned_rank = np.max(relative_rank_series)
# Concatenate with previous results
untied_rank_series = pd.concat(
[untied_rank_series, relative_rank_series],
)
# Reorder to match original alternative ordering
untied_rank_series_sorted = untied_rank_series[orank.alternatives]
# Build method name and metadata
method_name = self._get_rank_method_name(orank, untier)
extra = self._patch_extra(orank, untier, forced=False)
# Create the tie-broken ranking result
untied_rank = RankResult(
method=method_name,
alternatives=orank.alternatives,
values=untied_rank_series_sorted.values,
extra=extra,
)
# Check if ties were successfully broken
if not untied_rank.has_ties_:
# Success: All ties resolved by fallback decision maker
return untied_rank
# Ties still remain after fallback decision maker
elif force:
# Force complete untying using untied_rank_ property
forced_values = untied_rank.untied_rank_
extra = self._patch_extra(orank, untier, forced=True)
untied_rank = RankResult(
method=method_name,
alternatives=orank.alternatives,
values=forced_values,
extra=extra,
)
return untied_rank
else:
# Warning: Ties remain and force=False
warnings.warn(
f"Ties still remain after applying fallback decision maker "
f"'{untier.get_method_name()}'. Consider setting force=True "
f"or using a different fallback method.",
TieUnresolvedWarning,
stacklevel=2,
)
return untied_rank