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 |
|---|---|
|
0.0% † |
|
0.0% † |
|
0.0% |
|
1.8% |
|
12.0% |
|
15.0% |
|
17.0% |
|
18.5% |
|
18.8% |
|
19.2% |
|
27.5% |
|
29.8% |
|
33.8% |
|
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:
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': [...]}]}