Rank reversal and stability

The problem

Remove an option nobody was going to choose, re-rank the ones that remain, and their order can change. This is not a bug in any implementation; it is a property of methods that measure options against each other.

Measured over 400 random 5×4 problems, removing the last-placed option:

Method

Rank reversal rate

simple_scoring

0.0% †

wpm

0.0% †

spotis

0.0%

saw, waspas, marcos

1.8%

aras

12.0%

copras

15.0%

codas

17.0%

electre

18.5%

edas

18.8%

topsis

19.2%

weighted_scoring, mabac

27.5%

promethee

29.8%

vikor

33.8%

cocoso

49.0%

† Two other methods measure zero here, for reasons that do not generalise. simple_scoring ignores weights and normalisation, which makes it stable and unusable on mixed units. wpm divides by the column maximum, which the last-placed option rarely sets in this all-benefit setup. Only SPOTIS is reversal-free by construction.

Reproduce with python benchmarks/reversal.py. Rates are setup-dependent; see the README for why saw looks so stable in this particular experiment.

Why it happens

WITH the rejected option:     Option 1 0.7048  >  Option 2 0.6982
WITHOUT it:                   Option 2 0.6452 >  Option 1 0.6300

Dropping Option 4 — the cheapest option, and the one nobody would buy — shrank the price span from 1.20 to 0.65, lifting Option 2’s normalised price score from 0.417 to 0.769. A rejected option was defining the scale.

SPOTIS

SPOTIS measures every option against a fixed ideal point built from bounds you set in advance, never against the other options:

\[d_{ij} = \frac{|S_{ij} - S^*_j|}{|S_j^{max} - S_j^{min}|} \qquad d(A_i, S^*) = \sum_j w_j \, d_{ij}\]

Because d_ij depends only on option i, nothing another option does can move it. No addition or removal can reorder the rest.

The guarantee needs bounds

Without bounds, SPOTIS falls back to observed min/max — which move when the option set moves, defeating the point. mcdakit warns and reports it:

from mcdakit import Criterion, rank

criteria = [
    Criterion("Price", 0.6, "cost", bounds=(2.0, 4.0)),
    Criterion("Quality", 0.4, "benefit", bounds=(0, 10)),
]
matrix = [[2.75, 7.0], [2.90, 8.5], [3.40, 9.0]]

result = rank(matrix, criteria, method="spotis")
result.reversal_free   # True — every criterion carried bounds
result.warnings        # empty; populated when bounds are missing

In a pipeline that depends on the guarantee, insist:

import warnings
from mcdakit import BoundsWarning
warnings.simplefilter("error", BoundsWarning)

Checking any method

from mcdakit import reversal_check

labels = ["A", "B", "C"]
reversal_check(matrix, criteria, method="topsis", labels=labels)
# {'reversed': True, 'cases': [{'removed': ..., 'before': [...], 'after': [...]}]}