API reference

Core types

Core input and output types.

Everything the caller builds by hand lives here. The types validate at construction rather than at scoring time, so a mistake is reported where it was made — a ragged matrix says it is ragged, instead of surfacing later as “setting an array element with a sequence” from deep inside numpy.

exception mcdakit.types.McdaError[source]

Bases: ValueError

Raised when an input cannot describe a well-formed decision problem.

class mcdakit.types.Criterion(name, weight=1.0, direction='benefit', bounds=None, preference_shape='usual', q=0.0, p=0.0, s=0.0)[source]

Bases: object

One column of the decision matrix.

Parameters:
  • name (str) – Human label, used in results and messages. Need not be unique, though duplicate names make a sensitivity report harder to read.

  • weight (float) – Relative importance. Any non-negative scale: weights are normalised to sum to one wherever a method needs them to, so 40/30/20/10 and 0.4/0.3/0.2/0.1 are the same input.

  • direction (str) – "benefit" when larger values are better (quality, capacity), "cost" when smaller values are better (price, lead time).

  • bounds (tuple | None) – (lo, hi) for the range this criterion can take, independent of the options currently on the table — a budget ceiling, a rating scale, an acceptable delivery window. Optional for every method except SPOTIS, whose reversal-freedom depends on them; see mcdakit.methods.spotis.spotis().

  • preference_shape (str) – PROMETHEE preference function and its thresholds. Ignored by the other methods. q is the indifference threshold, p the preference threshold, s the Gaussian parameter.

  • q (float) – PROMETHEE preference function and its thresholds. Ignored by the other methods. q is the indifference threshold, p the preference threshold, s the Gaussian parameter.

  • p (float) – PROMETHEE preference function and its thresholds. Ignored by the other methods. q is the indifference threshold, p the preference threshold, s the Gaussian parameter.

  • s (float) – PROMETHEE preference function and its thresholds. Ignored by the other methods. q is the indifference threshold, p the preference threshold, s the Gaussian parameter.

name: str
weight: float = 1.0
direction: str = 'benefit'
bounds: tuple | None = None
preference_shape: str = 'usual'
q: float = 0.0
p: float = 0.0
s: float = 0.0
property is_cost: bool
class mcdakit.types.Decision(matrix, criteria, labels=None)[source]

Bases: object

A decision matrix, its criteria and its option labels.

matrix is options (rows) by criteria (columns), holding the raw figures as measured — euros, days, ratings. Cost criteria are not pre-inverted by the caller; the methods handle direction themselves.

Parameters:
matrix: ndarray
criteria: tuple
labels: tuple
property weights: ndarray

Weights as given, unnormalised.

property normalized_weights: ndarray

Weights scaled to sum to one.

property directions: list
property names: list
property n_options: int
property n_criteria: int
without(index)[source]

The same problem with one option removed.

The operation rank reversal is defined over: drop a loser, re-rank the survivors, and see whether their order held.

Parameters:

index (int)

Return type:

Decision

class mcdakit.types.Result(decision, method, scores, reversal_free=False, warnings=())[source]

Bases: object

The outcome of ranking one decision under one method.

scores is in the original option order and always reads higher-is-better, whatever sign convention the underlying method uses internally. ranking is sorted best first.

Parameters:
decision: Decision
method: str
scores: ndarray
reversal_free: bool = False
warnings: tuple = ()
property ranks: ndarray

Competition rank per option, in the original option order.

property ranking: list

[(label, score), ...] best first.

property order: list

Option labels best first.

property winner: str
property winner_index: int
score_of(label)[source]
Parameters:

label (str)

Return type:

float

rank_of(label)[source]
Parameters:

label (str)

Return type:

int

Ranking

The scoring entry points: rank() and compare_methods().

mcdakit.ranking.METHODS = ('simple_scoring', 'weighted_scoring', 'saw', 'topsis', 'vikor', 'electre', 'promethee', 'copras', 'waspas', 'wpm', 'aras', 'cocoso', 'codas', 'edas', 'mabac', 'marcos', 'spotis')

Every registered method name — built-in and plugin alike. A live view of the registry, not a snapshot.

mcdakit.ranking.as_decision(matrix, criteria=None, labels=None)[source]

Accept either a Decision or the pieces to build one.

Parameters:
Return type:

Decision

mcdakit.ranking.score(decision, method='weighted_scoring', normalization=None, **kwargs)[source]

Raw scores for one method, without wrapping them in a Result.

Returns (scores, reversal_free, messages). Mostly useful internally — the sensitivity analysis re-scores the same problem hundreds of times and has no use for the surrounding object.

Dispatch goes through the registry, so a method registered by a third-party package is reached the same way a built-in is. Nothing here branches on a method’s name; what a method needs, it declares.

Parameters:
mcdakit.ranking.rank(matrix, criteria=None, method='weighted_scoring', labels=None, normalization=None, **kwargs)[source]

Rank the options in a decision matrix.

Parameters:
  • matrix – Options (rows) by criteria (columns), holding raw figures as measured. Cost criteria are handled by the direction on each Criterion; do not pre-invert them. A Decision may be passed instead, in which case criteria and labels must be omitted.

  • method (str) – Any registered method name; see mcdakit.methods.names(). The default, weighted_scoring, is the least surprising; spotis is the one whose ranking cannot reverse.

  • criteria (Sequence[Criterion] | None)

  • labels (Sequence[str] | None)

  • normalization (str | None)

Returns:

With .winner, .ranking, .scores, and — for SPOTIS — .reversal_free and any .warnings.

Return type:

Result

Examples

>>> from mcdakit import Criterion, rank
>>> criteria = [
...     Criterion("Price", weight=0.6, direction="cost"),
...     Criterion("Quality", weight=0.4),
... ]
>>> result = rank([[3.0, 8], [2.0, 6]], criteria, labels=["A", "B"])
>>> result.winner
'B'
mcdakit.ranking.compare_methods(matrix, criteria=None, labels=None, methods=None, **kwargs)[source]

Rank the same problem under every method.

Returns {method_name: Result}. When the methods disagree, that disagreement is the finding: it says the options are close enough that modelling choices decide the outcome, and collapsing it into a single number would hide the most useful thing on the table.

See agreement() for a one-line summary of the same output.

Parameters:
Return type:

dict

mcdakit.ranking.agreement(results)[source]

How much a compare_methods() mapping actually agrees.

winners counts how many methods picked each option; consensus is the option most methods picked, or None when two or more options tie for the lead; tied lists the joint leaders; unanimous says whether every method agreed on the whole order, not merely the winner.

A tie returns None rather than an arbitrary pick. Reporting one of two equally-supported options as the consensus would conceal exactly the disagreement this function exists to surface, and a caller reading consensus alone would never learn the vote was split.

Parameters:

results (dict)

Return type:

dict

mcdakit.ranking.reversal_check(matrix, criteria=None, method='weighted_scoring', labels=None, **kwargs)[source]

Does dropping a losing option change the order of the survivors?

Removes each non-winning option in turn, re-ranks what is left, and compares the survivors’ order with their order in the full problem. This is the question behind the whole package: a ranking that changes when an option nobody chose is taken off the table was never really a ranking of the others.

Returns {"reversed": bool, "cases": [...]}, where each case names the option removed, the order before, and the order after.

Parameters:
Return type:

dict

Language-model assistance

Opt-in: reached as from mcdakit.ai import propose_criteria, not from the package namespace, so that using a model is a visible choice in the importing module. No extra dependency is required.

Language-model assistance, with every suggestion checked before it counts.

An LLM is useful in MCDA for the part that is genuinely hard to automate: turning a described problem into a structured one. Naming plausible criteria, proposing a direction for each, and drafting a first set of weights are tasks where a model’s breadth helps and where a wrong answer is cheap to spot.

It is not useful as an authority. A model that returns weights returns them with the same confidence whether they are considered or invented, and nothing in the output distinguishes the two. A package whose stated purpose is to refuse answers it cannot stand behind must not make an exception here.

So this module inverts the usual arrangement. The model proposes; the package verifies, and reports what the proposal is worth:

  • every suggestion is parsed into the package’s own validated types, so a malformed or out-of-range answer is rejected rather than absorbed;

  • proposed weights are run through sensitivity(), so the caller is told whether the recommendation would even survive the model being somewhat wrong;

  • the same question is asked more than once and the answers compared, so a model that is guessing can be distinguished from one that is consistent;

  • nothing is applied automatically. Every function returns a proposal the caller must choose to accept.

The provider is injected rather than imported. mcdakit acquires no LLM dependency: the caller supplies any callable mapping a prompt to a string, which may wrap any vendor’s client, a local model, or a fixture in a test.

>>> from mcdakit.ai import propose_criteria
>>> reply = '''[{"name": "Price", "direction": "cost", "weight": 0.6},
...              {"name": "Quality", "direction": "benefit", "weight": 0.4}]'''
>>> proposal = propose_criteria("choosing a supplier", ask=lambda p: reply)
>>> [c.name for c in proposal.criteria]
['Price', 'Quality']
>>> proposal.accepted
False

Because the model is injected, this module is deterministic under test and adds no network dependency to the package.

exception mcdakit.ai.AiError[source]

Bases: McdaError

A model’s reply could not be turned into a valid proposal.

class mcdakit.ai.AiProposal(criteria, rejected=(), agreement=None, raw=(), accepted=False)[source]

Bases: object

Criteria a model suggested, and what the package makes of them.

Variables:
  • criteria (tuple) – Validated Criterion objects. A suggestion that failed validation never reaches this list.

  • rejected (tuple) – Suggestions that were discarded, each with the reason. Reported rather than dropped: a model that proposed six criteria of which two were malformed is a different signal from one that proposed four.

  • agreement (float | None) – When the model was asked more than once, the share of criteria names common to every reply. Low agreement indicates the model is improvising, and is worth more than any single answer.

  • accepted (bool) – Always False. A proposal is a suggestion; applying it is the caller’s decision, made by constructing a Decision from it.

Parameters:
criteria: tuple
rejected: tuple = ()
agreement: float | None = None
raw: tuple = ()
accepted: bool = False
class mcdakit.ai.ComparisonProposal(judgements, weights, criteria, consistency_ratio, consistent, agreement=None, accepted=False)[source]

Bases: object

Pairwise judgements a model made, and whether they cohere.

The value of routing an LLM through pairwise comparison rather than asking for weights directly is that the answer is checkable. Saying price matters three times quality, quality three times delivery, and price twice delivery is a contradiction, and Saaty’s consistency ratio detects it without anyone knowing what the right weights were.

Variables:
  • consistency_ratio (float) – Saaty’s CI/RI. At or below CONSISTENCY_LIMIT (0.10) the judgements hold together.

  • consistent (bool) – Whether that threshold was met. Reported, never enforced: an inconsistent panel of judgements is a reason to ask again, and the caller decides.

Parameters:
judgements: dict
weights: ndarray
criteria: tuple
consistency_ratio: float
consistent: bool
agreement: float | None = None
accepted: bool = False
class mcdakit.ai.PanelProposal(participants, personas, criteria, labels, spread, rejected=(), accepted=False)[source]

Bases: object

A panel of simulated experts, and how far apart they were.

Human expert panels are expensive to convene, hard to assemble across disciplines, and — because the same panel cannot be run twice — not reproducible. A simulated panel is reproducible by construction and costs a prompt, at the price of being a model’s impression of an expert rather than an expert.

That trade is only acceptable if the output is scrutinised, so the participants returned here are ordinary Participant objects and go through the same group_rank() and disagreement() machinery as a real panel. A simulated panel that agrees suspiciously closely is itself a finding, and spread is what shows it.

Variables:
  • participants (tuple) – One per persona, ready for group_rank().

  • spread (float) – Mean standard deviation of the scores across personas. Near zero means the personas did not actually differ, and the panel added nothing over asking once.

Parameters:
participants: tuple
personas: tuple
criteria: tuple
labels: tuple
spread: float
rejected: tuple = ()
accepted: bool = False
class mcdakit.ai.WeightProposal(weights, criteria, stability=<factory>, disagrees_with=(), agreement=None, accepted=False)[source]

Bases: object

Weights a model suggested, checked against the data.

Variables:
  • weights (numpy.ndarray) – Normalised to sum to one.

  • stability (dict) – The sensitivity() report obtained using these weights. This is the point of the exercise: a model’s weights may be plausible and still produce a recommendation that a small correction overturns, and the caller should know that before relying on them.

  • disagrees_with (tuple) – Objective schemes whose weights order the criteria differently. A model that ranks importance unlike every data-driven scheme is not necessarily wrong, but the divergence is worth surfacing.

Parameters:
weights: ndarray
criteria: tuple
stability: dict
disagrees_with: tuple = ()
agreement: float | None = None
accepted: bool = False
mcdakit.ai.critique_weights(weights, criteria, matrix)[source]

Assess weights from any source against the data.

Separated from propose_weights() so that weights elicited from a person, taken from a previous study, or produced by a model this package never saw can be held to the same standard. Returns the sensitivity report and the objective schemes that order the criteria differently.

Parameters:
Return type:

dict

mcdakit.ai.narrate(result, ask, sensitivity_report=None)[source]

Turn a result into a paragraph, and check the model kept to the figures.

The model is given the computed facts and asked to write them up. It is not asked to analyse anything: everything in the prompt has already been calculated, so the only failure available to it is misstating a number — and that is checkable.

Every numeric literal in the reply is therefore compared against the figures supplied. Any that does not appear is listed in unsupported_numbers, which is the thing worth reading before pasting the text into a report.

Returns:

text the paragraph, facts what the model was given, unsupported_numbers any figure in the text that was not among them, and faithful whether that list is empty.

Return type:

dict

Parameters:
mcdakit.ai.propose_comparisons(problem, criteria, ask, samples=1)[source]

Ask a model to compare criteria pairwise, and check it contradicts itself no more than Saaty allows.

Asking for pairwise judgements rather than for weights directly is the point: is price more important than quality, and by how much is a question with a checkable answer, whereas a weight vector is not. The model’s replies are assembled into a reciprocal matrix and put through ahp_weights(), so a model that answers inconsistently is detected by arithmetic rather than by inspection.

Parameters:
Return type:

ComparisonProposal

mcdakit.ai.propose_criteria(problem, ask, n=5, samples=1)[source]

Ask a model to suggest criteria for a described problem.

Parameters:
  • problem (str) – A description in plain language.

  • ask (Callable[[str], str]) – Any callable mapping a prompt to the model’s reply. The package imposes no vendor and acquires no dependency.

  • samples (int) – How many times to ask. Above one, the replies are compared and the share of criteria common to all is reported as agreement. Asking twice costs twice as much and is usually worth it: a model that names different criteria each time is improvising, which no single reply would reveal.

  • n (int)

Returns:

Validated criteria, the suggestions that failed validation and why, and the agreement across replies. Nothing is applied.

Return type:

AiProposal

mcdakit.ai.propose_weights(problem, criteria, ask, matrix=None, samples=1)[source]

Ask a model to weight known criteria, and check the answer.

Where matrix is supplied, the proposed weights are used to rank the problem and the result is passed through sensitivity(), so the caller learns not only what the model suggested but whether the resulting recommendation is fragile. The weights are additionally compared against the objective schemes of mcdakit.weighting; any that order the criteria differently are named in disagrees_with.

Parameters:
Return type:

WeightProposal

mcdakit.ai.simulate_panel(problem, criteria, personas, labels, ask)[source]

Ask a model to score the problem once per expert persona.

Each persona is prompted separately and scores every option on every criterion, producing a Participant. The result is an ordinary panel: combine it with group_rank(), which reports both aggregations and where the personas disagreed.

A persona whose reply is malformed or the wrong shape is discarded with its reason rather than silently dropped, since a panel of three that became a panel of two is a different panel.

Parameters:
  • personas (Sequence[str]) – Short role descriptions, for example "cost analyst" or "sustainability officer". Distinct roles are the point; identical ones produce identical scores and a spread of zero, which the result flags.

  • problem (str)

  • criteria (Sequence[Criterion])

  • labels (Sequence[str])

  • ask (Callable[[str], str])

Providers

Ready-made ask callables. These speak each vendor’s HTTP API through the standard library, so no provider SDK is required and NumPy remains the only runtime dependency.

Ready-made ask callables for the common language-model providers.

mcdakit.ai takes any Callable[[str], str], which is deliberately the smallest interface that can work and commits the package to no vendor. The cost of that generality is boilerplate: every user writes the same adapter, looks up where their SDK buries the reply text, and gets no retry when a rate limit arrives mid-run.

These adapters remove that work without spending the package’s independence. No provider SDK is required. Each adapter speaks the vendor’s HTTP API directly through urllib from the standard library, so

pip install mcdakit

is enough to reach OpenAI, Anthropic, Google or a local model. NumPy remains the only runtime dependency. Where a vendor SDK is installed it is not used: one code path is easier to trust than two that must agree.

from mcdakit.ai import propose_criteria
from mcdakit.ai_providers import anthropic, ollama

ask = anthropic()                 # reads ANTHROPIC_API_KEY
ask = ollama("llama3")            # local, no key, nothing leaves the machine
proposal = propose_criteria("choosing a supplier", ask=ask)

Every adapter returns a plain function, so it can be wrapped, logged, cached or replaced by a fixture in a test exactly like a hand-written one.

exception mcdakit.ai_providers.ProviderError[source]

Bases: McdaError

A provider could not be reached, or refused the request.

mcdakit.ai_providers.anthropic(model='claude-sonnet-5', *, api_key=None, base_url='https://api.anthropic.com/v1', temperature=0.0, max_tokens=2048, timeout=60.0, retries=3)[source]

An ask callable backed by Anthropic’s messages API.

Reads ANTHROPIC_API_KEY unless api_key is given.

Parameters:
Return type:

Callable[[str], str]

mcdakit.ai_providers.google(model='gemini-2.0-flash', *, api_key=None, base_url='https://generativelanguage.googleapis.com/v1beta', temperature=0.0, max_tokens=2048, timeout=60.0, retries=3)[source]

An ask callable backed by Google’s Gemini API.

Reads GOOGLE_API_KEY, then GEMINI_API_KEY, unless api_key is given.

Parameters:
Return type:

Callable[[str], str]

mcdakit.ai_providers.load_env(path='.env', *, override=False)[source]

Read KEY=value lines from a file into the environment.

Keeping API keys in a .env file rather than in source is the usual practice, and the parsing is short enough that requiring python-dotenv for it would cost more than it saves. Searches the given path, then each parent directory, so it works from anywhere in a project.

from mcdakit.ai_providers import anthropic, load_env

load_env()            # reads ./.env, or the nearest one above it
ask = anthropic()     # ANTHROPIC_API_KEY now set

Understands KEY=value, export KEY=value, quoted values, blank lines and # comments. A variable already set in the environment is left alone unless override is true — the real environment should win over a file in a checkout, or deploying becomes surprising.

Returns the names it set, and their values, so a caller can see what came from the file. Returns an empty mapping when no file is found: a missing .env is the normal case in production, not an error.

Never commit the file. .env is in this project’s .gitignore for that reason; do the same in yours.

Parameters:
Return type:

dict

mcdakit.ai_providers.ollama(model='llama3', *, base_url='http://localhost:11434', temperature=0.0, timeout=120.0, retries=3)[source]

An ask callable backed by a local Ollama server.

No API key and no network: the problem description, the criteria and the measurements stay on the machine. That matters for decisions involving commercially sensitive data, which is a large share of real MCDA work.

The timeout is longer than the hosted default because a local model on CPU is markedly slower than an API call.

ask = ollama("llama3")
ask = ollama("mixtral", base_url="http://gpu-box:11434")
Parameters:
Return type:

Callable[[str], str]

mcdakit.ai_providers.openai(model='gpt-4o', *, api_key=None, base_url='https://api.openai.com/v1', temperature=0.0, max_tokens=2048, timeout=60.0, retries=3)[source]

An ask callable backed by OpenAI’s chat completions API.

Reads OPENAI_API_KEY unless api_key is given.

temperature defaults to 0. The module asks a model the same question more than once to measure whether it is consistent, and a temperature above zero would make the replies differ for reasons that have nothing to do with the model’s confidence.

Parameters:
Return type:

Callable[[str], str]

mcdakit.ai_providers.openai_compatible(model, base_url, *, api_key=None, temperature=0.0, max_tokens=2048, timeout=60.0, retries=3, provider='OpenAI-compatible server')[source]

An ask callable for any server exposing /chat/completions.

That interface has become the common denominator, so this one adapter reaches vLLM, LM Studio, llama.cpp’s server, Together, Groq, OpenRouter, DeepSeek, Mistral and most others — including models running on your own machine, where api_key is usually unnecessary.

ask = openai_compatible("mistral-7b", "http://localhost:8000/v1")
Parameters:
Return type:

Callable[[str], str]

Explaining a result

Why did this option win?

A ranking is a conclusion, and a conclusion nobody can interrogate is difficult to act on or to defend. Transparency is increasingly treated in the decision-analysis literature as a precondition for trusting a computed result rather than as a presentational extra.

This module answers two questions about a ranking the package has already produced:

  • Which criteria carried the result? Each criterion’s contribution to an option’s score, and how much of the score it accounts for.

  • Why did A beat B? The margin between two options, decomposed by criterion, showing which criteria favoured the winner, which worked against it, and whether any single one was decisive.

Two ways of attributing a score are used, because only one of them is exact and it does not apply everywhere:

exact

The method is a weighted sum of normalised values, so an option’s score is the sum of its per-criterion contributions. Nothing is approximated and the parts add up to the whole. Applies to weighted_scoring, saw, simple_scoring and spotis.

leave_one_out

The method is not additive — TOPSIS is a ratio of distances, VIKOR takes a maximum, ELECTRE and PROMETHEE compare options pairwise — so no exact decomposition exists. Each criterion’s importance is instead measured by removing it and re-scoring: the contribution is how much the score changes in its absence. These values are attributions, not components, and do not sum to the score.

Which was used is reported on every result, because presenting an approximation as though it were a decomposition would be the same overstatement the rest of this package exists to avoid.

>>> from mcdakit import Criterion, rank
>>> from mcdakit.explain import explain
>>> criteria = [Criterion("Price", 0.6, "cost"), Criterion("Quality", 0.4)]
>>> result = rank([[3.0, 8.0], [2.0, 6.0]], criteria, labels=["A", "B"])
>>> report = explain(result)
>>> report.basis
'exact'
class mcdakit.explain.Contribution(criterion, value, weight, contribution, share)[source]

Bases: object

One criterion’s part in one option’s score.

Parameters:
criterion: str
value: float
weight: float
contribution: float
share: float
class mcdakit.explain.Explanation(option, method, score, basis, contributions, dominant)[source]

Bases: object

Why one option scored what it did.

Variables:
  • basis (str) – "exact" when the contributions sum to the score, or "leave_one_out" when they are attributions obtained by removing each criterion in turn. The distinction is reported rather than smoothed over: only the first is a decomposition.

  • dominant (str) – The criterion contributing most. For a close decision this is the one worth checking first.

Parameters:
option: str
method: str
score: float
basis: str
contributions: tuple
dominant: str
class mcdakit.explain.Margin(winner, loser, method, margin, basis, rows, decisive=None)[source]

Bases: object

Why one option beat another, criterion by criterion.

Parameters:
winner: str
loser: str
method: str
margin: float
basis: str
rows: tuple
decisive: str | None = None
mcdakit.explain.compare(result, winner, loser)[source]

Explain why one option outranked another.

The margin between two options is decomposed by criterion, so a reader can see which criteria favoured the winner and which worked against it. A criterion is reported as decisive when reversing its contribution alone would reverse the outcome — the case where a single disputed judgement determines the recommendation, and the one most worth knowing about.

Parameters:
Return type:

Margin

mcdakit.explain.explain(result, option=None)[source]

Explain how one option’s score arose.

Parameters:
  • result (Result) – A Result from rank().

  • option (str | None) – Which option to explain. Defaults to the winner, since that is the one a reader will question first.

Returns:

Per-criterion contributions, and whether they are an exact decomposition or attributions obtained by leaving each criterion out.

Return type:

Explanation

Group decisions

Decisions made by several people.

Two people scoring the same options rarely agree, and the interesting question is not only “what did the group decide?” but “how much did they disagree, and does the answer survive that disagreement?” A consensus ranking that hides a 6-2 split is the same failure as a point estimate that hides its error bars.

There are two honest ways to combine several stakeholders, and they answer different questions:

Aggregate the scores, then rank once (aggregate_scores()). Treats the group as one better-informed judge: average what each person thought each option was worth, then run a method over the averages. Right when the participants are measuring the same thing and their differences are noise.

Rank separately, then combine the rankings (aggregate_rankings()). Treats each participant as a voter with their own legitimate view. Right when the differences are real preferences rather than measurement error, and when you do not want one person’s outlying score to drag the average.

They can disagree, and when they do that is a finding about the group, not a defect. group_rank() reports both by default.

class mcdakit.group.Participant(name, matrix, weight=1.0)[source]

Bases: object

One stakeholder’s view of the same decision.

Parameters:
  • name (str) – Who this is. Used in the disagreement report.

  • matrix – Their scores. Must cover the same options and criteria as everyone else, in the same order — see group_rank() for why.

  • weight (float) – How much this participant counts. Equal by default. A chair with a casting vote, or a domain expert weighted above a generalist, is expressed here rather than by duplicating their row.

mcdakit.group.aggregate_rankings(participants, criteria, method='weighted_scoring', labels=None, **kwargs)[source]

Rank for each participant separately, then combine the rankings.

Borda count over each participant’s ordering: an option placed first by someone earns n - 1 points from them, second earns n - 2, and so on, weighted by that participant’s weight. Ties share the average of the positions they span, so a tie cannot invent a preference nobody expressed.

Unlike aggregate_scores() this is insensitive to how far apart the scores were — only to the order each person put them in. That is a weakness when the magnitudes are meaningful and a strength when one participant’s outlying score would otherwise drag the mean.

Returns {"scores", "order", "per_participant"}, where per_participant maps each name to their own Result.

Parameters:
Return type:

dict

mcdakit.group.aggregate_scores(participants, criteria, labels=None)[source]

Average the participants’ scores into one decision matrix.

The weighted mean, cell by cell. Treats the group as a single better-informed judge, which is the right model when the participants are estimating the same underlying quantity and differ only by noise.

It is the wrong model when they hold genuinely different preferences: an average of 9 and 2 is 5.5, a value neither person would recognise, and nothing in the resulting ranking records that they disagreed. Pair it with disagreement(), which is what group_rank() does.

Parameters:
Return type:

Decision

mcdakit.group.disagreement(participants, criteria, labels=None)[source]

How far apart the participants were, per option and per criterion.

Reported as the standard deviation of the scores each cell received, unweighted — the question is how much the people differed, not how much the chair’s opinion outweighs the rest.

A mean of 5.5 that everybody agreed on and one that averages 9 and 2 are the same number and completely different findings. This is what tells them apart.

Returns overall, by_option, by_criterion, and most_contested naming the option and criterion with the widest spread.

Parameters:
Return type:

dict

mcdakit.group.group_rank(participants, criteria, method='weighted_scoring', labels=None, **kwargs)[source]

Rank a decision made by several people, and say how much they agreed.

Runs both aggregations — averaging the scores, and combining each participant’s ranking — because they answer different questions and can disagree. When they do, that disagreement is a finding about the group: it means the answer depends on whether you treat the participants as co-estimators of one truth or as voters with distinct views.

Returns:

result

A Result from ranking the averaged scores. The headline answer.

borda

The rank-aggregated view, from aggregate_rankings().

agree

Whether the two aggregations chose the same winner.

disagreement

The spread report from disagreement().

per_participant

Each participant’s own Result.

unanimous

Whether every participant, ranking alone, picked the same winner.

Return type:

dict

Parameters:

Examples

>>> from mcdakit import Criterion
>>> from mcdakit.group import Participant, group_rank
>>> criteria = [Criterion("Price", 0.5, "cost"), Criterion("Quality", 0.5)]
>>> alice = Participant("Alice", [[2.0, 8.0], [3.0, 6.0]])
>>> bob = Participant("Bob", [[2.0, 5.0], [3.0, 9.0]])
>>> out = group_rank([alice, bob], criteria, labels=["A", "B"])
>>> out["agree"]
True

Sensitivity analysis

How far the weights can move before the winner changes.

A ranking is only as good as its weights, and weights are guessed. The question that follows immediately — and that most MCDA tools never answer — is how far someone would have to disagree with those guesses before the answer changes. A winner that survives a 30% shift in every weight is a different finding from one that flips at 4%, even when both are printed as “first”.

mcdakit.sensitivity.FRAGILE_THRESHOLD = 0.1

Below this tolerance the decision is fragile enough to say so out loud.

mcdakit.sensitivity.ROBUST_THRESHOLD = 0.25

At or above this, the winner is robust to any plausible re-weighting.

mcdakit.sensitivity.BISECTION_STEPS = 40

Bisection steps. 40 halvings take the interval to roughly 1e-12 of its original width, far below any weight difference that could matter.

mcdakit.sensitivity.sensitivity(result)[source]

Per-criterion weight tolerance for a ranking.

For each criterion, how far its weight must move — up or down, as a share of the total weight — before a different option wins, and which option takes over. The smallest such move across all criteria is the decision’s overall tolerance.

Returns:

  • dict –

    overall

    Smallest tolerance across the criteria, or None if no single weight change can unseat the winner.

    level

    "fragile" under 10%, "moderate" under 25%, "robust" at or above, "immovable" when nothing flips it.

    weakest

    Name of the criterion carrying that smallest tolerance.

    rows

    One dict per criterion: name, weight, increase, decrease, tolerance, direction, flips_to.

  • Which *way* the weight has to move matters as much as how far (“Quality)

  • can move by 7.5%” is unusable without knowing that means down.

  • Only one weight moves at a time. A simultaneous shift in several could

  • unseat the winner sooner, so this is an upper bound on stability, not a

  • guarantee — a “robust” result is robust against single-criterion

  • disagreement, which is the form disagreement usually takes in practice.

Parameters:

result (Result)

Return type:

dict

Examples

>>> from mcdakit import Criterion, rank, sensitivity
>>> criteria = [Criterion("Price", 0.6, "cost"), Criterion("Quality", 0.4)]
>>> result = rank([[3.0, 8], [2.0, 6]], criteria, labels=["A", "B"])
>>> sensitivity(result)["level"] in {
...     "fragile", "moderate", "robust", "immovable"}
True

Weighting

Deriving criterion weights from the data.

Weights are the softest input in any MCDA model. ahp_weights() elicits them from a person, which is the right approach when someone genuinely holds a view about what matters. Often nobody does, or the analyst wants a starting point that is not an opinion — and the field’s answer is to read the weights off the decision matrix itself.

The premise of every scheme here: a criterion on which all the options score alike cannot separate them, so it carries no information and deserves little weight. They differ in how they measure that, and in whether they account for criteria that duplicate each other.

>>> import numpy as np
>>> from mcdakit import entropy_weights
>>> matrix = np.array([[5.0, 1.0], [5.0, 9.0], [5.0, 5.0]])
>>> w = entropy_weights(matrix)
>>> bool(w[0] < 1e-9), round(float(w[1]), 4)
(True, 1.0)

The first criterion is identical for every option, so it gets no weight at all. That is the point.

These are objective in the sense of being computed rather than asserted, not in the sense of being right. A criterion can be uninformative in the current shortlist and still be the one that matters — a budget everyone happens to meet is not thereby unimportant. Treat these as a defensible default and a check on elicited weights, never as a replacement for knowing what the decision is about.

mcdakit.weighting.WEIGHTINGS: dict = {'critic': (<function critic_weights>, 'CRITIC; weight by contrast and independence from other criteria.'), 'entropy': (<function entropy_weights>, 'Shannon entropy; weight by how much a criterion separates the options.'), 'equal': (<function equal_weights>, 'Every criterion equally important.'), 'std': (<function std_weights>, 'Standard deviation; weight by spread.')}

Name to (function, summary). Insertion-ordered; plugins append.

mcdakit.weighting.available_weightings()[source]

{name: summary} for every registered scheme.

Return type:

dict

mcdakit.weighting.critic_weights(matrix, directions=None)[source]

CRITIC — CRiteria Importance Through Intercriteria Correlation.

Weights by contrast and independence: a criterion earns weight for varying a lot, and loses it for saying the same thing as the others.

\[C_j = \sigma_j \sum_k (1 - r_{jk})\]

where \(\sigma_j\) is the standard deviation of the min-max normalised column and \(r_{jk}\) the correlation between columns j and k.

This is what distinguishes it from std_weights(): two criteria measuring nearly the same thing — list price and total cost, say — would each get full weight under a variance-only scheme, effectively counting that dimension twice. CRITIC splits it between them.

Diakoulaki, Mavrotas and Papayannakis, Determining objective weights in multiple criteria problems: the CRITIC method, Computers & Operations Research 22(7), 1995.

Parameters:

directions (Sequence[str] | None)

Return type:

ndarray

mcdakit.weighting.derive_weights(matrix, scheme='entropy', directions=None)[source]

Weights from a decision matrix, by name or by callable.

Returns one weight per criterion, summing to one.

Parameters:
  • directions (Sequence[str] | None) – Optional "benefit"/"cost" per criterion. Supplying them orients the matrix first, which matters for critic_weights(), where the sign of a correlation depends on it: a cost and a benefit criterion that move together look opposed until one is flipped.

  • scheme (str)

Return type:

ndarray

Examples

>>> from mcdakit import derive_weights
>>> matrix = [[2.0, 8.0], [3.0, 6.0], [4.0, 4.0]]
>>> w = derive_weights(matrix, "equal")
>>> [round(float(x), 3) for x in w]
[0.5, 0.5]
mcdakit.weighting.entropy_weights(matrix, directions=None)[source]

Shannon entropy weights.

Normalise each column to a probability distribution, measure its entropy, and weight by how far that falls short of maximum. A column where every option scores alike has maximum entropy and therefore zero weight; a column that separates the options sharply has low entropy and high weight.

\[p_{ij} = \frac{x_{ij}}{\sum_i x_{ij}} \qquad e_j = -k \sum_i p_{ij} \ln p_{ij} \qquad w_j \propto 1 - e_j\]

with \(k = 1/\ln m\) so that \(e_j \in [0, 1]\).

Values are shifted to be non-negative first, since the logarithm requires it and a criterion measured on a scale that goes negative — a temperature, a profit-or-loss — is otherwise unusable.

Shannon (1948); the MCDA application is due to Zeleny (1982).

Parameters:

directions (Sequence[str] | None)

Return type:

ndarray

mcdakit.weighting.equal_weights(matrix, directions=None)[source]

Every criterion equally important.

The honest default when nobody has expressed a preference, and the baseline the others should be compared against: if an objective scheme does not beat equal weights on your problem, it is adding complexity rather than information.

Parameters:

directions (Sequence[str] | None)

Return type:

ndarray

mcdakit.weighting.get_weighting(name)[source]

Look up a scheme by name, or raise listing what is available.

Parameters:

name (str)

Return type:

Callable

mcdakit.weighting.register_weighting(name, function=None, *, summary='', replace=False)[source]

Register a weighting scheme. Usable as a decorator.

A scheme takes (matrix, directions) and returns one weight per criterion. It need not normalise — derive_weights() does that — but it must return non-negative, finite values.

Parameters:
mcdakit.weighting.std_weights(matrix, directions=None)[source]

Standard-deviation weights.

Weight each criterion by how much the options vary on it. The simplest objective scheme, and the easiest to explain: a criterion everyone scores the same tells you nothing, so it gets nothing.

Unlike entropy_weights() it is sensitive to the units a criterion is measured in — a price in euros varies more than the same price in thousands — so it suits matrices already on comparable scales, such as ratings, better than raw mixed units.

Parameters:

directions (Sequence[str] | None)

Return type:

ndarray

mcdakit.WEIGHTINGS

Registered weighting schemes, as {name: (function, summary)}. Use available_weightings() for the readable form.

AHP

AHP — deriving weights from pairwise comparisons.

Saaty, The Analytic Hierarchy Process, McGraw-Hill, 1980.

Weights are the softest input in any MCDA model, and typing them directly is the worst way to produce them: people cannot reliably say a criterion is worth 0.35 rather than 0.40, but they can say price matters somewhat more than quality. AHP turns those judgements into weights and — the part that makes it worth the extra questions — measures whether the judgements contradict each other.

mcdakit.ahp.SAATY_SCALE = {0.1111111111111111: 'Extremely less important', 0.14285714285714285: 'Very strongly less important', 0.2: 'Strongly less important', 0.3333333333333333: 'Moderately less important', 1.0: 'Equally important', 3.0: 'Moderately more important', 5.0: 'Strongly more important', 7.0: 'Very strongly more important', 9.0: 'Extremely more important'}

Saaty’s fundamental scale, as ratio and meaning.

mcdakit.ahp.RANDOM_INDEX = {1: 0.0, 2: 0.0, 3: 0.58, 4: 0.9, 5: 1.12, 6: 1.24, 7: 1.32, 8: 1.41, 9: 1.45, 10: 1.49, 11: 1.51, 12: 1.48, 13: 1.56, 14: 1.57, 15: 1.59}

the average consistency index of a randomly filled n x n reciprocal matrix. The consistency ratio is CI / RI, which is what makes it comparable across problem sizes.

Type:

Saaty’s random consistency index by matrix order

mcdakit.ahp.CONSISTENCY_LIMIT = 0.1

above this, at least one judgement contradicts the others badly enough that the derived weights should not be relied on.

Type:

Saaty’s rule of thumb

mcdakit.ahp.comparison_matrix(n, judgements)[source]

Build a reciprocal matrix from {(i, j): ratio} judgements.

Only the upper triangle need be given: setting (i, j) to 3 sets (j, i) to 1/3 automatically, which is what makes the matrix reciprocal and the exercise tractable — n(n-1)/2 questions rather than n². Unjudged pairs default to 1 (equally important).

Parameters:
Return type:

ndarray

mcdakit.ahp.priorities(matrix)[source]

Principal eigenvector by row geometric mean, normalised to sum to one.

The geometric mean is Saaty’s standard approximation and — unlike an iterative eigensolver — always returns something sensible for the small, well-formed matrices this is used on. It also keeps the numpy-only rule: no scipy needed.

Parameters:

matrix (ndarray)

Return type:

ndarray

mcdakit.ahp.consistency_ratio(matrix, weights=None)[source]

Saaty’s consistency ratio, CI / RI.

Zero for a perfectly consistent set of judgements — one where saying price is 3x quality and quality is 3x delivery is matched by saying price is 9x delivery. Returns 0.0 for orders below three, where a single judgement has nothing to contradict.

Parameters:
Return type:

float

mcdakit.ahp.ahp_weights(judgements, names=None)[source]

Weights from pairwise comparisons, with their consistency ratio.

judgements is either a full reciprocal matrix, or a {(i, j): ratio} mapping of the upper triangle — in which case names (or an explicit n) is needed to size it.

Returns {"weights", "consistency_ratio", "consistent", "names"}.

The consistency ratio is reported, never enforced. An inconsistent matrix is a signal to revisit the judgements, not an error: sometimes the inconsistency is real and the analyst wants to proceed anyway, and taking that decision away from them would be presumptuous.

Examples

>>> from mcdakit import ahp_weights
>>> out = ahp_weights({(0, 1): 3, (0, 2): 9, (1, 2): 3},
...                   names=["Price", "Quality", "Delivery"])
>>> round(out["consistency_ratio"], 6)
0.0
>>> [round(float(w), 4) for w in out["weights"]]
[0.6923, 0.2308, 0.0769]
Parameters:

names (Sequence[str] | None)

Return type:

dict

class mcdakit.ahp.AhpResult(scores, weights, local_priorities, names, labels, consistency, inconsistent)[source]

Bases: object

A full AHP hierarchy, and how coherent the judgements behind it were.

Variables:
  • scores (numpy.ndarray) – One priority per alternative, summing to one. Higher is better.

  • weights (numpy.ndarray) – The criterion weights derived from the criteria comparison matrix.

  • local_priorities (numpy.ndarray) – Alternatives by criteria: each column is the alternatives’ priorities under one criterion.

  • consistency (dict) – {"criteria": ratio, criterion_name: ratio, ...}. Reported for every matrix separately, because a hierarchy is only as sound as its least coherent judgement set and an average would hide exactly that.

  • inconsistent (tuple) – Names of the matrices exceeding CONSISTENCY_LIMIT. Empty when every judgement set holds together.

Parameters:
scores: ndarray
weights: ndarray
local_priorities: ndarray
names: tuple
labels: tuple
consistency: dict
inconsistent: tuple
property ranking: list

[(label, score), ...] best first.

property winner: str
mcdakit.ahp.ahp_rank(criteria_comparisons, alternative_comparisons, names=None, labels=None)[source]

Rank alternatives by pairwise comparison throughout the hierarchy.

This is AHP as Saaty defined it, and it differs from every other method in this package in what it asks for. There is no decision matrix: instead the alternatives are compared against each other, pair by pair, separately under each criterion. That is what makes the method usable on criteria nobody can measure — a supplier’s reputation, the quality of a design — where asking is A better than B on this, and by how much is answerable and asking for a number is not.

The price is quadratic: \(n(n-1)/2\) judgements per criterion, plus the criteria themselves. The compensation is that the judgements can be checked. A person who says A is three times B, B three times C, and then A twice C has contradicted themselves, and the consistency ratio detects it without anyone knowing what the right answer was.

Parameters:
  • criteria_comparisons – A reciprocal matrix comparing the criteria, or a {(i, j): ratio} mapping of its upper triangle.

  • alternative_comparisons (Sequence) – One comparison matrix per criterion, in the same order as the criteria, each comparing the alternatives under that criterion.

  • names (Sequence[str] | None) – Criterion and alternative names, for the report.

  • labels (Sequence[str] | None) – Criterion and alternative names, for the report.

Returns:

With the ranking, the weights, and a consistency ratio for every matrix. Inconsistency is reported and never enforced: whether a contradiction is fatal is the analyst’s judgement, not the library’s.

Return type:

AhpResult

Examples

>>> import numpy as np
>>> from mcdakit import ahp_rank
>>> criteria = np.array([[1.0, 3.0], [1 / 3, 1.0]])
>>> per_criterion = [
...     np.array([[1.0, 5.0], [1 / 5, 1.0]]),
...     np.array([[1.0, 1 / 3], [3.0, 1.0]]),
... ]
>>> result = ahp_rank(criteria, per_criterion, labels=["A", "B"])
>>> result.winner
'A'

:func:~mcdakit.ahp_weights uses pairwise comparison to obtain criterion weights, which then feed a method that scores a numeric decision matrix. :func:~mcdakit.ahp_rank is the full method: alternatives are compared pairwise under each criterion too, so no decision matrix is needed at all.

The extension contract

The extension contract: what a ranking method is.

A method is an object that turns a Decision into one score per option, higher meaning better. Everything a method needs to say about itself — its name, whether it wants the matrix oriented for it, whether it can promise reversal-freedom — it says here, declaratively, so the library never has to special-case it by name.

Writing one:

from mcdakit import Method, ScoringContext, register
import numpy as np

class Median(Method):
    name = "median"
    summary = "Median score across criteria, ignoring weights."

    def score(self, ctx: ScoringContext) -> np.ndarray:
        return np.median(ctx.data, axis=1)

register(Median())

See Extending mcdakit for the full guide.

class mcdakit.methods.base.Method[source]

Bases: object

Base class for a ranking method.

Subclasses set name and implement score(). Everything else has a working default.

Variables:
  • name (str) – The string callers pass as method=. Must be unique across the registry; lowercase with underscores by convention.

  • summary (str) – One line describing the method, shown by available().

  • citation (str) – Where the method comes from. This is a research-adjacent library and will be read by people who know the papers — give them the reference.

  • wants (mcdakit.methods.base.Wants) – Which form of the matrix score() receives. See Wants.

  • reversal_free (bool) – Whether the method is rank-reversal-free by construction. Only SPOTIS sets this among the built-ins. A method whose guarantee is conditional should leave this False and return a MethodResult saying so per call.

name: str = ''
summary: str = ''
citation: str = ''
wants: Wants = 'oriented'
reversal_free: bool = False
requires_positive: bool = False

Whether the method is defined only for strictly positive values. WASPAS divides by them and raises them to fractional powers; COPRAS normalises by a column total. Declaring the restriction lets the conformance suite check what the method actually promises rather than demanding a score for an input on which the method has no meaning.

normalization: str | None = None

The normalisation this method is defined with — a name from mcdakit.normalization, or None for a method that does not normalise. Callers override it with rank(..., normalization=...), which is a real modelling choice: the ranking can change. Read it in score() through ctx.normalize(data).

score(ctx)[source]

Score every option. Higher must mean better.

Return either an array of one score per option, or a MethodResult when there is a caveat to report.

A method whose natural measure is smallest-is-best — VIKOR’s Q, SPOTIS’s distance — must negate before returning, and say so in its docstring. Getting this backwards produces a confident, exactly inverted ranking, which is the worst failure mode available here.

Parameters:

ctx (ScoringContext)

validate(decision)[source]

Raise if this method cannot handle the problem. Optional.

Called before score(). Use it to refuse a problem outright — a method needing at least three options, say. Prefer a MethodResult warning for degradations that are merely regrettable rather than fatal.

Parameters:

decision (Decision)

Return type:

None

class mcdakit.methods.base.MethodResult(scores, reversal_free=False, warnings=())[source]

Bases: object

What a method returns when a bare score vector is not enough.

Returning a plain np.ndarray from Method.score() is equivalent to returning MethodResult(scores), and is what almost every method should do. Use this form to report a caveat the caller ought to see.

Variables:
  • scores (numpy.ndarray) – One score per option, higher is better.

  • reversal_free (bool) – True only if this ranking genuinely cannot change when an option is added or removed. Never set it optimistically: it is the strongest claim this library makes.

  • warnings (tuple) – Messages describing any way the result fell short of what the method normally promises.

Parameters:
scores: ndarray
reversal_free: bool = False
warnings: tuple = ()
class mcdakit.methods.base.ScoringContext(data, weights, decision, options=<factory>, _normalization=None, _read=<factory>)[source]

Bases: object

Everything a method is given when asked to score.

Bundling these into one object rather than a long parameter list is what lets the contract grow — a future field is additive, and no existing method’s signature has to change to receive it.

Variables:
  • data (np.ndarray) – The decision matrix, oriented or raw according to the method’s Method.wants.

  • weights (np.ndarray) – Criterion weights as the caller gave them, unnormalised. Methods that need them to sum to one should normalise, and must handle a total of zero; normalized_weights does both.

  • decision (Decision) – The original problem, for the rare method that needs something not surfaced here. Reach for this last: depending on it couples a method to the shape of Decision.

  • options (dict) – Keyword arguments the caller passed through rank(..., **options).

Parameters:
  • data (np.ndarray)

  • weights (np.ndarray)

  • decision (Decision)

  • options (dict)

  • _normalization (str | None)

  • _read (set)

data: np.ndarray
weights: np.ndarray
decision: Decision
options: dict
property consumed: bool

Whether anything has read options yet.

property read_directions: bool

Whether the method inspected criterion directions itself.

property opts: dict

The caller’s keyword arguments, marked as consumed.

A method that accepts rank(..., v=0.5)-style options must read them through this rather than options directly, so that an option nobody accepts is reported instead of vanishing.

property criteria: tuple

The Criterion objects, in column order.

These carry direction, so a method reading them to decide cost-versus-benefit has the same double-handling risk as directions. Reading a criterion’s bounds, preference_shape or name is safe and common; only its direction is the trap.

property directions: list

"benefit" or "cost" per criterion, in column order.

Reading this while Method.wants is Wants.ORIENTED is almost always a bug: the cost columns have already been flipped for you, so handling direction again inverts them back. mcdakit.rank() raises if it sees that combination — see Wants.

property labels: tuple
property bounds: list

(lo, hi) or None per criterion, in column order.

property n_options: int
property n_criteria: int
normalize(data=None)[source]

Apply the normalisation in force for this call.

The caller’s normalization= if they gave one, otherwise the method’s own default. Use this rather than normalising inline, so a method’s scheme stays swappable and every zero-column guard lives in one place.

Parameters:

data (ndarray | None)

Return type:

ndarray

property normalization

the caller’s override, or the method’s.

Type:

The scheme in force

property normalized_weights: ndarray

Weights scaled to sum to one, or uniform if the total is zero.

A Decision refuses all-zero weights, so the fallback only fires for a method invoked on a hand-built context.

class mcdakit.methods.base.Wants(*values)[source]

Bases: Enum

Which form of the decision matrix a method wants to be handed.

Most methods assume larger is better and want cost criteria already flipped for them; a few — SPOTIS above all — must see the figures as measured, because they resolve direction themselves against fixed bounds.

ORIENTED = 'oriented'

Cost columns mirrored via max + min - x so higher is better throughout. The default, and what every classical method expects.

RAW = 'raw'

The matrix exactly as the caller supplied it. The method is then responsible for reading ctx.directions itself.

The registry

The method registry: how names resolve to implementations.

Methods are looked up by name at call time rather than hardcoded into rank(), so a method defined outside this package is a first-class citizen — indistinguishable from a built-in once registered.

Two ways in. Register directly, for a method defined in your own program:

from mcdakit import register
register(MyMethod())

Or declare an entry point, for a method you distribute as a package. It is then discovered automatically the first time anything asks the registry a question, with no import or call needed by the user:

# in your pyproject.toml
[project.entry-points."mcdakit.methods"]
my_method = "my_package.methods:MyMethod"

The entry point may name either a Method subclass (instantiated with no arguments) or an already-built instance.

mcdakit.methods.registry.ENTRY_POINT_GROUP = 'mcdakit.methods'

The entry-point group third-party packages advertise methods under.

exception mcdakit.methods.registry.DuplicateMethodError[source]

Bases: McdaError

A second method tried to claim a name already in use.

exception mcdakit.methods.registry.PluginLoadError[source]

Bases: UserWarning

A package advertised a method that could not be loaded.

A warning rather than an exception: one broken plugin must not stop the library — and every other plugin — from working. Its own category so a strict caller can promote it with warnings.simplefilter("error", PluginLoadError).

mcdakit.methods.registry.available()[source]

{name: summary} for every registered method.

The human-facing counterpart to names() — what to print when someone asks what this installation can do.

Return type:

dict

mcdakit.methods.registry.get(name)[source]

Look up a method by name.

Raises:

McdaError – With the available names listed, since a typo is the overwhelmingly likely cause.

Parameters:

name (str)

Return type:

Method

mcdakit.methods.registry.has(name)[source]

Whether a method of this name is registered.

Parameters:

name (str)

Return type:

bool

mcdakit.methods.registry.methods()[source]

Iterate over the registered method instances.

Return type:

Iterator

mcdakit.methods.registry.names()[source]

Every registered method name, in registration order.

Return type:

tuple

mcdakit.methods.registry.register(method, *, replace=False)[source]

Add a method to the registry, and return it.

Parameters:
  • method (Method) – An instance, not a class — methods may carry configuration, and registering an instance keeps that possibility open.

  • replace (bool) – Overwrite an existing registration of the same name. Off by default: silently shadowing a method would make rank(..., method="topsis") mean different things in different processes, which is exactly the kind of ambiguity this library exists to remove.

Raises:

McdaError – If method is not a Method, has no name, or the name is taken and replace is false.

Return type:

Method

mcdakit.methods.registry.unregister(name)[source]

Remove a method. Raises if it was not registered.

Parameters:

name (str)

Return type:

None

At the top level these are re-exported under names that read better next to the rest of the API:

mcdakit.METHODS

Every registered method name — built-in and plugin alike. A live view of the registry rather than a snapshot, so a method registered at any point is immediately visible. Behaves like the tuple it reads as: in, iteration, indexing, len and == all work.

mcdakit.get_method(name)

Alias of mcdakit.methods.registry.get().

mcdakit.has_method(name)

Alias of mcdakit.methods.registry.has().

mcdakit.method_names()

Alias of mcdakit.methods.registry.names().

mcdakit.BUILTIN_METHODS

The seventeen Method classes this package ships, in reading order: simplest first, the reversal-free one last.

Conformance testing

A conformance suite for method authors.

Writing a ranking method is easy; writing one that is correct is not, and the failure modes are quiet. A method whose scores read smallest-is-best produces a confident, exactly inverted ranking. One that divides by a zero-variance criterion produces NaN on some inputs and not others. Neither announces itself.

This module is the check to run before publishing a method. It is deliberately about properties every sound method shares, not about any particular algorithm — so it can tell you that something is wrong without knowing what you implemented.

Use it in your own test suite:

from mcdakit.testing import check_method

def test_my_method_conforms():
    check_method(MyMethod())

or from a terminal:

python -m mcdakit.testing my_package.methods:MyMethod

The suite is a floor, not a ceiling. Passing it means your method is well-behaved, not that its arithmetic matches the paper — for that you still need a worked example with values verified outside your own code.

exception mcdakit.testing.ConformanceError[source]

Bases: AssertionError

A method violated a property every sound method should hold.

mcdakit.testing.check_method(method, *, strict=True)[source]

Assert that method upholds every property a sound method should.

Parameters:
  • method (Method) – The instance to check.

  • strict (bool) – When True (the default) a missing summary or citation is a failure. Set False while prototyping.

Raises:

ConformanceError – Listing every property that failed, not merely the first.

Return type:

None

Examples

>>> import numpy as np
>>> from mcdakit import Method
>>> from mcdakit.testing import check_method
>>> class Total(Method):
...     name = "doctest_total"
...     summary = "Weighted total."
...     citation = "n/a"
...     def score(self, ctx):
...         return ctx.data @ ctx.normalized_weights
>>> check_method(Total())
mcdakit.testing.conformance_report(method, *, strict=True)[source]

Run every check and return {label: None | message}.

Unlike check_method() this never raises, so it can be used to show a table of what passed and what did not.

Parameters:
Return type:

dict

Methods

SPOTIS — Stable Preference Ordering Towards Ideal Solution.

Dezert, Tchamova, Han and Tacnet, The SPOTIS Rank Reversal Free Method for Multi-Criteria Decision-Making Support, FUSION 2020.

The one method here that cannot reverse rank. Every other method in this package measures options against each other in some way — TOPSIS against the best and worst present, VIKOR against the observed range, weighted scoring against the observed span — so removing an option moves the yardstick and can reorder the options that remain. SPOTIS measures each option against a fixed ideal point derived from bounds the analyst sets in advance. Nothing an option does can move another option’s score, so no addition or removal can change the order of the rest.

exception mcdakit.methods.spotis.BoundsWarning[source]

Bases: UserWarning

A SPOTIS ranking fell back to observed data instead of fixed bounds.

Its own category so callers can turn it into an error with warnings.simplefilter("error", BoundsWarning) — worth doing in any pipeline where the reversal-freedom guarantee is the reason SPOTIS was chosen.

mcdakit.methods.spotis.spotis(data, weights, directions, bounds=None, warn=True)[source]

Rank-reversal-free scoring, returned negated so higher is better.

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

S* is the ideal point: the best value each criterion could take, read off the bounds rather than off the options — hi for a benefit criterion, lo for a cost. The denominator is the bounded span, also fixed. So d_ij depends only on option i, and every other option is irrelevant to it.

SPOTIS’s native distance is smallest-is-best. This function returns -d so that, like every other method here, larger scores rank higher. Scores are therefore in [-1, 0], where 0 is an option sitting exactly on the ideal point. Read the sign before comparing these numbers with published SPOTIS tables, which report the positive distance.

Parameters:
  • data (ndarray) – Raw, un-oriented matrix. Unlike the other methods, SPOTIS handles direction itself through the choice of ideal point, and orienting the matrix first would break the correspondence with the bounds.

  • bounds (Sequence | None) – One (lo, hi) pair per criterion, or None for a criterion whose range is not known.

  • weights (ndarray)

  • directions (Sequence[str])

  • warn (bool)

Returns:

reversal_free is False when any criterion had to fall back to observed min/max, along with a message naming which. In that case the method is not reversal-free — the fallback bounds move when the option set moves, which is exactly the mechanism SPOTIS exists to avoid. Silently returning a number that looks authoritative would be the worst failure this package could have, so the shortfall is both warned about and reported in the return value.

Return type:

(scores, reversal_free, messages)

class mcdakit.methods.spotis.Spotis[source]

Bases: Method

SPOTIS as a registered method.

The one built-in that declares Wants.RAW: it must see the figures as measured, because it resolves criterion direction itself against the fixed bounds rather than against the other options.

reversal_free is True as a statement about the method, but each call reports its own verdict — a problem with missing bounds comes back reversal_free=False with a warning, because the guarantee is a property of the inputs as much as of the algorithm.

name: str = 'spotis'
summary: str = 'Rank-reversal-free distance to a fixed ideal point.'
citation: str = 'Dezert, Tchamova, Han and Tacnet (2020)'
wants: Wants = 'raw'
reversal_free: bool = True
score(ctx)[source]

Score every option. Higher must mean better.

Return either an array of one score per option, or a MethodResult when there is a caveat to report.

A method whose natural measure is smallest-is-best — VIKOR’s Q, SPOTIS’s distance — must negate before returning, and say so in its docstring. Getting this backwards produces a confident, exactly inverted ranking, which is the worst failure mode available here.

Parameters:

ctx (ScoringContext)

Return type:

MethodResult

TOPSIS — Technique for Order of Preference by Similarity to Ideal Solution.

Hwang and Yoon, Multiple Attribute Decision Making: Methods and Applications, Springer, 1981.

mcdakit.methods.topsis.topsis(data, weights, normalization='vector')[source]

Closeness to the ideal solution, in [0, 1], higher is better.

Vector-normalise (r_ij = x_ij / sqrt(sum_i x_ij^2)), weight, then take each option’s Euclidean distance to the best and worst attainable points. The score is d- / (d+ + d-): the share of an option’s total distance that lies on the good side.

Normalisation is vector normalisation, Hwang and Yoon’s original choice. This is worth stating because it is not universal: pymcdm defaults to min-max normalisation here, and the two give genuinely different scores on the same data. Neither is wrong — normalisation is a modelling decision, not a preprocessing detail — but comparing numbers across libraries without knowing which was used will mislead.

Both ideal points are read off the options present, which is why TOPSIS reverses rank at a measurable rate — around 32% in this package’s own benchmark when the last-placed option is removed. That is inherent to the method, not a defect in this implementation; use spotis when the ranking must be stable under a changing shortlist.

Parameters:
Return type:

ndarray

class mcdakit.methods.topsis.Topsis[source]

Bases: Method

TOPSIS as a registered method.

name: str = 'topsis'
summary: str = 'Closeness to the ideal solution, by vector normalisation.'
citation: str = 'Hwang and Yoon (1981)'
normalization: str | None = 'vector'

The normalisation this method is defined with — a name from mcdakit.normalization, or None for a method that does not normalise. Callers override it with rank(..., normalization=...), which is a real modelling choice: the ranking can change. Read it in score() through ctx.normalize(data).

score(ctx)[source]

Score every option. Higher must mean better.

Return either an array of one score per option, or a MethodResult when there is a caveat to report.

A method whose natural measure is smallest-is-best — VIKOR’s Q, SPOTIS’s distance — must negate before returning, and say so in its docstring. Getting this backwards produces a confident, exactly inverted ranking, which is the worst failure mode available here.

Parameters:

ctx (ScoringContext)

Return type:

ndarray

PROMETHEE II — Preference Ranking Organization METHod for Enrichment Evaluation.

Brans and Vincke, A Preference Ranking Organisation Method: The PROMETHEE Method for Multiple Criteria Decision-Making, Management Science 31(6), 1985.

mcdakit.methods.promethee.preference_degree(difference, shape='usual', q=0.0, p=0.0, s=0.0)[source]

How strongly one option is preferred over another on one criterion.

Brans and Vincke define six shapes. The default, usual, treats any difference however small as total preference — so 4200 beats 4201 exactly as decisively as it beats 9000. That is right for a rating out of ten and wrong for money and days, which is why the others exist.

usual

Type I

step at zero

ushape

Type II

indifferent up to q, then total

vshape

Type III

linear from zero to p

level

Type IV

indifferent to q, half up to p, then total

linear

Type V

indifferent to q, linear to p, then total

gaussian

Type VI

smooth, governed by s

difference is (this option) minus (the other) after cost criteria have been oriented, so a difference at or below zero is never a preference.

Parameters:
Return type:

float

mcdakit.methods.promethee.preference_matrix(data, weights, shapes=None)[source]

Aggregated preference of every option over every other.

shapes is one (shape, q, p, s) tuple per criterion; omitting it uses the usual criterion throughout.

Normalising by the weight total keeps the flows in [-1, 1] whatever scale the weights were entered on, so 40/30/20/10 and 0.4/0.3/0.2/0.1 produce identical flows rather than differing by a factor of a hundred.

Parameters:
Return type:

ndarray

mcdakit.methods.promethee.flows(matrix)[source]

(positive, negative, net) flows from a preference matrix.

The positive flow is how much an option outranks the field, the negative flow how much the field outranks it, and the net flow their difference — PROMETHEE II’s complete ranking.

Parameters:

matrix (ndarray)

mcdakit.methods.promethee.promethee(data, weights, shapes=None)[source]

Net flows, in [-1, 1], higher is better.

Parameters:
Return type:

ndarray

mcdakit.methods.promethee.shapes_from(criteria)[source]

(shape, q, p, s) per criterion, or None if all are the default.

Returning None when nothing was configured keeps PROMETHEE on its simple path, so a problem that never touched preference functions scores bit-identically to one built before they existed.

Return type:

tuple | None

class mcdakit.methods.promethee.Promethee[source]

Bases: Method

PROMETHEE II as a registered method.

Reads each criterion’s preference_shape, q, p and s off the problem, so the six preference functions need no extra plumbing at the call site.

name: str = 'promethee'
summary: str = 'PROMETHEE II net flows, with six preference-function shapes.'
citation: str = 'Brans and Vincke (1985)'
score(ctx)[source]

Score every option. Higher must mean better.

Return either an array of one score per option, or a MethodResult when there is a caveat to report.

A method whose natural measure is smallest-is-best — VIKOR’s Q, SPOTIS’s distance — must negate before returning, and say so in its docstring. Getting this backwards produces a confident, exactly inverted ranking, which is the worst failure mode available here.

Parameters:

ctx (ScoringContext)

Return type:

ndarray

VIKOR — VIseKriterijumska Optimizacija I Kompromisno Resenje.

Opricovic and Tzeng, Compromise solution by MCDM methods: A comparative analysis of VIKOR and TOPSIS, European Journal of Operational Research 156(2), 2004.

mcdakit.methods.vikor.vikor(data, weights, v=0.5)[source]

Compromise ranking, returned negated so higher is better.

VIKOR’s native Q measures regret, so the smallest Q is the best option. Every method in this package reads higher-is-better, so -Q is returned. Scores are therefore in [-1, 0].

S is group utility (the weighted sum of normalised regret) and R is individual regret (its maximum). v trades one against the other: v = 1 maximises group utility alone, v = 0 minimises the worst single criterion, and the customary 0.5 weighs them equally.

Parameters:
Return type:

ndarray

class mcdakit.methods.vikor.Vikor[source]

Bases: Method

VIKOR as a registered method.

Accepts v through rank(..., v=...): the weight given to group utility against worst-case regret.

name: str = 'vikor'
summary: str = 'Compromise ranking between group utility and worst-case regret.'
citation: str = 'Opricovic and Tzeng (2004)'
score(ctx)[source]

Score every option. Higher must mean better.

Return either an array of one score per option, or a MethodResult when there is a caveat to report.

A method whose natural measure is smallest-is-best — VIKOR’s Q, SPOTIS’s distance — must negate before returning, and say so in its docstring. Getting this backwards produces a confident, exactly inverted ranking, which is the worst failure mode available here.

Parameters:

ctx (ScoringContext)

Return type:

ndarray

ELECTRE I — ELimination Et Choix Traduisant la REalite.

Roy, Classement et choix en presence de points de vue multiples: la methode ELECTRE, RIRO 8, 1968.

mcdakit.methods.electre.electre(data, weights, normalization='vector')[source]

Net outranking flow — how many options i outranks, minus how many outrank i. Higher is better; scores are whole numbers.

Option i outranks j when the criteria favouring i carry enough weight (concordance) and no criterion is against it too strongly (discordance). Both thresholds are the mean of their matrix, the usual choice when the analyst has not set them by hand.

The scores are counts, so ties are common on small problems — three options can easily each outrank exactly one other. A tie here means ELECTRE genuinely does not separate them, not that information was lost.

Parameters:
Return type:

ndarray

class mcdakit.methods.electre.Electre[source]

Bases: Method

ELECTRE I as a registered method.

name: str = 'electre'
summary: str = 'ELECTRE I outranking; net concordance/discordance flows.'
citation: str = 'Roy (1968)'
normalization: str | None = 'vector'

The normalisation this method is defined with — a name from mcdakit.normalization, or None for a method that does not normalise. Callers override it with rank(..., normalization=...), which is a real modelling choice: the ranking can change. Read it in score() through ctx.normalize(data).

score(ctx)[source]

Score every option. Higher must mean better.

Return either an array of one score per option, or a MethodResult when there is a caveat to report.

A method whose natural measure is smallest-is-best — VIKOR’s Q, SPOTIS’s distance — must negate before returning, and say so in its docstring. Getting this backwards produces a confident, exactly inverted ranking, which is the worst failure mode available here.

Parameters:

ctx (ScoringContext)

Return type:

ndarray

Additive scoring methods: simple, weighted, and SAW.

mcdakit.methods.scoring.simple_scoring(data, weights=None)[source]

Sum of raw scores per option, ignoring weights entirely.

Useful only as a baseline and when every criterion is on the same scale. On mixed units it is meaningless — a price in thousands drowns a rating out of ten — and weighted_scoring should be used instead. weights is accepted and ignored so every method shares one signature.

Parameters:
Return type:

ndarray

mcdakit.methods.scoring.weighted_scoring(data, weights, normalization='minmax')[source]

Weighted sum over min-max normalised scores.

Each criterion is first scaled to [0, 1] across the options being compared, then weighted. Without that step the criterion with the largest numbers dominates regardless of its weight: a price spanning ~900 against a quality rating spanning ~3 means a 40% price weight behaves like 99%.

r_ij = (x_ij - min_j) / (max_j - min_j), then sum_j w_j * r_ij.

A criterion every option scores identically carries no information; it is given a flat 1.0 rather than dividing by zero.

Parameters:
Return type:

ndarray

mcdakit.methods.scoring.saw(data, weights, normalization='max')[source]

Simple Additive Weighting (Churchman and Ackoff, 1954).

r_ij = x_ij / max_j, then sum_j w_j * r_ij. Differs from weighted_scoring in normalising by the column maximum alone rather than the observed range, which keeps ratios between values intact but means the worst option is not pinned to zero.

Parameters:
Return type:

ndarray

class mcdakit.methods.scoring.SimpleScoring[source]

Bases: Method

Unweighted sum of the oriented scores.

name: str = 'simple_scoring'
summary: str = 'Unweighted sum of scores. A baseline; ignores weights.'
score(ctx)[source]

Score every option. Higher must mean better.

Return either an array of one score per option, or a MethodResult when there is a caveat to report.

A method whose natural measure is smallest-is-best — VIKOR’s Q, SPOTIS’s distance — must negate before returning, and say so in its docstring. Getting this backwards produces a confident, exactly inverted ranking, which is the worst failure mode available here.

Parameters:

ctx (ScoringContext)

Return type:

ndarray

class mcdakit.methods.scoring.WeightedScoring[source]

Bases: Method

Weighted sum over min-max normalised scores.

name: str = 'weighted_scoring'
summary: str = 'Weighted sum over min-max normalised scores.'
normalization: str | None = 'minmax'

The normalisation this method is defined with — a name from mcdakit.normalization, or None for a method that does not normalise. Callers override it with rank(..., normalization=...), which is a real modelling choice: the ranking can change. Read it in score() through ctx.normalize(data).

score(ctx)[source]

Score every option. Higher must mean better.

Return either an array of one score per option, or a MethodResult when there is a caveat to report.

A method whose natural measure is smallest-is-best — VIKOR’s Q, SPOTIS’s distance — must negate before returning, and say so in its docstring. Getting this backwards produces a confident, exactly inverted ranking, which is the worst failure mode available here.

Parameters:

ctx (ScoringContext)

Return type:

ndarray

class mcdakit.methods.scoring.Saw[source]

Bases: Method

Simple Additive Weighting.

name: str = 'saw'
summary: str = 'Simple Additive Weighting; normalises by the column maximum.'
citation: str = 'Churchman and Ackoff (1954)'
normalization: str | None = 'max'

The normalisation this method is defined with — a name from mcdakit.normalization, or None for a method that does not normalise. Callers override it with rank(..., normalization=...), which is a real modelling choice: the ranking can change. Read it in score() through ctx.normalize(data).

score(ctx)[source]

Score every option. Higher must mean better.

Return either an array of one score per option, or a MethodResult when there is a caveat to report.

A method whose natural measure is smallest-is-best — VIKOR’s Q, SPOTIS’s distance — must negate before returning, and say so in its docstring. Getting this backwards produces a confident, exactly inverted ranking, which is the worst failure mode available here.

Parameters:

ctx (ScoringContext)

Return type:

ndarray

COPRAS — COmplex PRoportional ASsessment.

Zavadskas, Kaklauskas and Sarka, The new method of multicriteria complex proportional assessment of projects, Technological and Economic Development of Economy 1(3), 1994.

mcdakit.methods.copras.copras(data, weights, directions)[source]

Proportional assessment of benefit against cost. Higher is better.

Sum-normalise each column, weight it, and total the benefit and cost contributions separately:

\[S^{+}_i = \sum_{j \in benefit} w_j r_{ij} \qquad S^{-}_i = \sum_{j \in cost} w_j r_{ij} \qquad Q_i = S^{+}_i + \frac{S^{-}_{min} \sum_i S^{-}_i} {S^{-}_i \sum_i (S^{-}_{min} / S^{-}_i)}\]

The second term is the point of the method: it is inversely proportional to an alternative’s cost total, so a smaller cost raises the score. An implementation that adds \(S^{-}\) directly would reward expense instead of penalising it, and would still produce a plausible-looking ranking.

Unlike most methods here, COPRAS resolves criterion direction itself and so takes the matrix as measured. Values must be non-negative, which sum-normalisation requires; the method is defined for ratio-scale data.

With no cost criterion the second term is undefined and the method reduces to a weighted sum, which is what is returned.

Parameters:
Return type:

ndarray

class mcdakit.methods.copras.Copras[source]

Bases: Method

COPRAS as a registered method.

name: str = 'copras'
summary: str = 'Complex proportional assessment of benefit against cost.'
citation: str = 'Zavadskas, Kaklauskas and Sarka (1994)'
wants: Wants = 'raw'
score(ctx)[source]

Score every option. Higher must mean better.

Return either an array of one score per option, or a MethodResult when there is a caveat to report.

A method whose natural measure is smallest-is-best — VIKOR’s Q, SPOTIS’s distance — must negate before returning, and say so in its docstring. Getting this backwards produces a confident, exactly inverted ranking, which is the worst failure mode available here.

Parameters:

ctx (ScoringContext)

Return type:

ndarray

WASPAS — Weighted Aggregated Sum Product Assessment.

Zavadskas, Turskis, Antucheviciene and Zakarevicius, Optimization of weighted aggregated sum product assessment, Elektronika ir Elektrotechnika 122(6), 2012.

mcdakit.methods.waspas.waspas(data, weights, directions, lambda_=0.5)[source]

A blend of the weighted sum and weighted product models.

\[Q_i = \lambda \sum_j w_j r_{ij} + (1 - \lambda) \prod_j r_{ij}^{\,w_j}\]

where \(r\) is linearly normalised — divided by the column maximum for a benefit criterion, and the column minimum divided by the value for a cost criterion.

The two halves fail differently, which is the method’s point. The sum is fully compensatory: a very poor score on one criterion can be offset by good scores elsewhere. The product is not, because a near-zero value drags the whole product down however good the rest are. Blending them at \(\lambda = 0.5\) splits the difference; \(\lambda = 1\) recovers the weighted sum and \(\lambda = 0\) the weighted product.

Like COPRAS, the method resolves criterion direction itself and takes the matrix as measured. Values must be positive: the cost normalisation divides by them, and the product raises them to fractional powers.

Parameters:
Return type:

ndarray

mcdakit.methods.waspas.wpm(data, weights, directions)[source]

The weighted product model, also called MEW.

\[S_i = \prod_j r_{ij}^{\,w_j}\]

Bridgman, Dimensional Analysis (1922); Miller and Starr, Executive Decisions and Operations Research (1969).

Where the weighted sum lets a strength pay for a weakness, the product does not: one value near zero drags the whole score down however good the rest are. That non-compensatory behaviour is the reason to choose it, and it matters most when a criterion represents something disqualifying rather than merely undesirable — a supplier who cannot meet a safety threshold is not redeemed by a low price.

This is waspas() at \(\lambda = 0\), and is implemented as that call so the two cannot drift apart. It is exposed separately because the method has its own name and literature, and nobody searching for WPM would think to look for it inside a WASPAS parameter.

Parameters:
Return type:

ndarray

class mcdakit.methods.waspas.Waspas[source]

Bases: Method

WASPAS as a registered method.

Accepts lambda_ through rank(..., lambda_=...): the weight given to the sum against the product.

name: str = 'waspas'
summary: str = 'Blend of the weighted sum and weighted product models.'
citation: str = 'Zavadskas, Turskis, Antucheviciene and Zakarevicius (2012)'
wants: Wants = 'raw'
requires_positive: bool = True

Whether the method is defined only for strictly positive values. WASPAS divides by them and raises them to fractional powers; COPRAS normalises by a column total. Declaring the restriction lets the conformance suite check what the method actually promises rather than demanding a score for an input on which the method has no meaning.

score(ctx)[source]

Score every option. Higher must mean better.

Return either an array of one score per option, or a MethodResult when there is a caveat to report.

A method whose natural measure is smallest-is-best — VIKOR’s Q, SPOTIS’s distance — must negate before returning, and say so in its docstring. Getting this backwards produces a confident, exactly inverted ranking, which is the worst failure mode available here.

Parameters:

ctx (ScoringContext)

Return type:

ndarray

class mcdakit.methods.waspas.Wpm[source]

Bases: Method

The weighted product model as a registered method.

Takes no lambda_: fixing it at zero is what distinguishes this from Waspas.

name: str = 'wpm'
summary: str = 'Weighted product model; a poor criterion cannot be paid for.'
citation: str = 'Bridgman (1922); Miller and Starr (1969)'
wants: Wants = 'raw'
requires_positive: bool = True

Whether the method is defined only for strictly positive values. WASPAS divides by them and raises them to fractional powers; COPRAS normalises by a column total. Declaring the restriction lets the conformance suite check what the method actually promises rather than demanding a score for an input on which the method has no meaning.

score(ctx)[source]

Score every option. Higher must mean better.

Return either an array of one score per option, or a MethodResult when there is a caveat to report.

A method whose natural measure is smallest-is-best — VIKOR’s Q, SPOTIS’s distance — must negate before returning, and say so in its docstring. Getting this backwards produces a confident, exactly inverted ranking, which is the worst failure mode available here.

Parameters:

ctx (ScoringContext)

Return type:

ndarray

ARAS — Additive Ratio Assessment.

Zavadskas and Turskis (2010).

The method takes the matrix as measured and resolves criterion direction itself, so it is never handed an oriented matrix.

mcdakit.methods.aras.aras(data, weights, directions)[source]

ARAS — Additive Ratio ASsessment. Higher is better.

An optimal alternative is constructed from the best value available on each criterion, appended to the matrix, and every alternative is scored as a ratio of its weighted sum to the optimum’s. The result is therefore a degree of utility in (0, 1], where 1 is attainable only by matching the best value on every criterion at once.

Zavadskas and Turskis, A new additive ratio assessment (ARAS) method in multicriteria decision-making, Technological and Economic Development of Economy 16(2), 2010.

Parameters:

directions (Sequence[str])

Return type:

ndarray

class mcdakit.methods.aras.Aras[source]

Bases: Method

ARAS as a registered method.

name: str = 'aras'
summary: str = 'Additive ratio assessment against a constructed optimum.'
citation: str = 'Zavadskas and Turskis (2010)'
wants: Wants = 'raw'
requires_positive: bool = True

Whether the method is defined only for strictly positive values. WASPAS divides by them and raises them to fractional powers; COPRAS normalises by a column total. Declaring the restriction lets the conformance suite check what the method actually promises rather than demanding a score for an input on which the method has no meaning.

score(ctx)[source]

Score every option. Higher must mean better.

Return either an array of one score per option, or a MethodResult when there is a caveat to report.

A method whose natural measure is smallest-is-best — VIKOR’s Q, SPOTIS’s distance — must negate before returning, and say so in its docstring. Getting this backwards produces a confident, exactly inverted ranking, which is the worst failure mode available here.

Parameters:

ctx (ScoringContext)

Return type:

ndarray

COCOSO — Combined Compromise Solution.

Yazdani et al. (2019).

The method takes the matrix as measured and resolves criterion direction itself, so it is never handed an oriented matrix.

mcdakit.methods.cocoso.cocoso(data, weights, directions, lambda_=0.5)[source]

COCOSO — COmbined COmpromise SOlution. Higher is better.

Three aggregation strategies are computed — an arithmetic mean of the weighted sum and weighted product, a sum of relative scores, and a balanced compromise — and combined. Using three rather than one is the method’s premise: an alternative that leads on all three is robust to the choice of aggregation, and one that leads on only a single strategy is visibly not.

Yazdani, Zarate, Zavadskas and Turskis, A combined compromise solution (CoCoSo) method for multi-criteria decision-making problems, Management Decision 57(9), 2019.

Parameters:
class mcdakit.methods.cocoso.Cocoso[source]

Bases: Method

COCOSO as a registered method.

name: str = 'cocoso'
summary: str = 'Combined compromise of three aggregation strategies.'
citation: str = 'Yazdani, Zarate, Zavadskas and Turskis (2019)'
wants: Wants = 'raw'
score(ctx)[source]

Score every option. Higher must mean better.

Return either an array of one score per option, or a MethodResult when there is a caveat to report.

A method whose natural measure is smallest-is-best — VIKOR’s Q, SPOTIS’s distance — must negate before returning, and say so in its docstring. Getting this backwards produces a confident, exactly inverted ranking, which is the worst failure mode available here.

Parameters:

ctx (ScoringContext)

Return type:

ndarray

CODAS — Combinative Distance-based Assessment.

Keshavarz Ghorabaee et al. (2016).

The method takes the matrix as measured and resolves criterion direction itself, so it is never handed an oriented matrix.

mcdakit.methods.codas.codas(data, weights, directions, tau=0.02)[source]

CODAS — COmbinative Distance-based ASsessment. Higher is better.

Alternatives are ranked by Euclidean distance from the worst possible point, with Taxicab distance used to separate any pair whose Euclidean distances are within tau of each other. The threshold exists because Euclidean distance alone leaves near-ties that are arbitrary rather than meaningful.

Keshavarz Ghorabaee, Zavadskas, Turskis and Antucheviciene, A new combinative distance-based assessment (CODAS) method for multi-criteria decision-making, Economic Computation and Economic Cybernetics Studies and Research 50(3), 2016.

Parameters:
class mcdakit.methods.codas.Codas[source]

Bases: Method

CODAS as a registered method.

name: str = 'codas'
summary: str = 'Euclidean distance from the worst point, Taxicab breaking ties.'
citation: str = 'Keshavarz Ghorabaee et al. (2016)'
wants: Wants = 'raw'
score(ctx)[source]

Score every option. Higher must mean better.

Return either an array of one score per option, or a MethodResult when there is a caveat to report.

A method whose natural measure is smallest-is-best — VIKOR’s Q, SPOTIS’s distance — must negate before returning, and say so in its docstring. Getting this backwards produces a confident, exactly inverted ranking, which is the worst failure mode available here.

Parameters:

ctx (ScoringContext)

Return type:

ndarray

EDAS — Evaluation based on Distance from Average Solution.

Keshavarz Ghorabaee et al. (2015).

The method takes the matrix as measured and resolves criterion direction itself, so it is never handed an oriented matrix.

mcdakit.methods.edas.edas(data, weights, directions)[source]

EDAS — Evaluation based on Distance from Average Solution.

Higher is better. Each alternative is measured by how far it rises above the column average and how far it falls below it, rather than against an ideal. That suits problems where the alternatives are broadly comparable and an ideal point would be a fiction.

Keshavarz Ghorabaee, Zavadskas, Olfat and Turskis, Multi-criteria inventory classification using a new method of evaluation based on distance from average solution, Informatica 26(3), 2015.

Parameters:

directions (Sequence[str])

Return type:

ndarray

class mcdakit.methods.edas.Edas[source]

Bases: Method

EDAS as a registered method.

name: str = 'edas'
summary: str = 'Distance above and below the average solution.'
citation: str = 'Keshavarz Ghorabaee et al. (2015)'
wants: Wants = 'raw'
score(ctx)[source]

Score every option. Higher must mean better.

Return either an array of one score per option, or a MethodResult when there is a caveat to report.

A method whose natural measure is smallest-is-best — VIKOR’s Q, SPOTIS’s distance — must negate before returning, and say so in its docstring. Getting this backwards produces a confident, exactly inverted ranking, which is the worst failure mode available here.

Parameters:

ctx (ScoringContext)

Return type:

ndarray

MABAC — Multi-Attributive Border Approximation area Comparison.

Pamucar and Cirovic (2015).

The method takes the matrix as measured and resolves criterion direction itself, so it is never handed an oriented matrix.

mcdakit.methods.mabac.mabac(data, weights, directions)[source]

MABAC — Multi-Attributive Border Approximation area Comparison.

Higher is better, and the sign is meaningful: a positive total places an alternative in the upper approximation area, above the geometric mean of the field, and a negative total places it below. Unlike a closeness coefficient, the zero point is interpretable.

Pamucar and Cirovic, The selection of transport and handling resources in logistics centers using Multi-Attributive Border Approximation area Comparison (MABAC), Expert Systems with Applications 42(6), 2015.

Parameters:

directions (Sequence[str])

Return type:

ndarray

class mcdakit.methods.mabac.Mabac[source]

Bases: Method

MABAC as a registered method.

name: str = 'mabac'
summary: str = 'Distance from the border approximation area; sign is meaningful.'
citation: str = 'Pamucar and Cirovic (2015)'
wants: Wants = 'raw'
score(ctx)[source]

Score every option. Higher must mean better.

Return either an array of one score per option, or a MethodResult when there is a caveat to report.

A method whose natural measure is smallest-is-best — VIKOR’s Q, SPOTIS’s distance — must negate before returning, and say so in its docstring. Getting this backwards produces a confident, exactly inverted ranking, which is the worst failure mode available here.

Parameters:

ctx (ScoringContext)

Return type:

ndarray

MARCOS — Measurement of Alternatives and Ranking according to COmpromise Solution.

Stevic et al. (2020).

The method takes the matrix as measured and resolves criterion direction itself, so it is never handed an oriented matrix.

mcdakit.methods.marcos.marcos(data, weights, directions)[source]

MARCOS — Measurement of Alternatives and Ranking according to COmpromise Solution. Higher is better.

Both an ideal and an anti-ideal alternative are appended to the matrix, and each alternative is scored by its utility relative to the two at once. Bounding the problem from both ends is what distinguishes it from methods referencing only the ideal.

Stevic, Pamucar, Puska and Chatterjee, Sustainable supplier selection in healthcare industries using a new MCDM method: MARCOS, Computers & Industrial Engineering 140, 2020.

Parameters:

directions (Sequence[str])

Return type:

ndarray

class mcdakit.methods.marcos.Marcos[source]

Bases: Method

MARCOS as a registered method.

name: str = 'marcos'
summary: str = 'Utility relative to both an ideal and an anti-ideal alternative.'
citation: str = 'Stevic, Pamucar, Puska and Chatterjee (2020)'
wants: Wants = 'raw'
score(ctx)[source]

Score every option. Higher must mean better.

Return either an array of one score per option, or a MethodResult when there is a caveat to report.

A method whose natural measure is smallest-is-best — VIKOR’s Q, SPOTIS’s distance — must negate before returning, and say so in its docstring. Getting this backwards produces a confident, exactly inverted ranking, which is the worst failure mode available here.

Parameters:

ctx (ScoringContext)

Return type:

ndarray

Normalisation

Normalisation schemes, and how to add your own.

Normalisation is not a preprocessing detail. Putting a price spanning 900 and a rating spanning 3 onto a common scale is a modelling decision: which scheme you pick changes the numbers, sometimes the ranking, and — as the rank-reversal literature shows — how stable that ranking is when the option set changes. Two methods that differ only in normalisation are two different methods.

So the schemes are named, documented, cited and swappable, rather than hardcoded inside each algorithm:

>>> from mcdakit import rank, Criterion
>>> criteria = [Criterion("A", 1.0), Criterion("B", 1.0)]
>>> a = rank([[1.0, 9.0], [9.0, 1.0]], criteria, method="topsis")
>>> b = rank([[1.0, 9.0], [9.0, 1.0]], criteria, method="topsis",
...          normalization="minmax")
>>> a.method, b.method
('topsis', 'topsis')

Every scheme takes a matrix that is already oriented — larger is better — and returns one of the same shape. A column carrying no information (every option scoring alike, or all zeros) is given a constant rather than a division by zero: it cannot discriminate, and saying so with a flat value is honest where a NaN would poison the whole ranking.

Adding one is the same shape as adding a method:

from mcdakit import register_normalization

@register_normalization("softmax", summary="Exponential, sums to one.")
def softmax(data):
    shifted = np.exp(data - data.max(axis=0))
    return shifted / shifted.sum(axis=0)
mcdakit.normalization.NORMALIZATIONS: dict = {'max': (<function max_normalization>, 'x / max; preserves ratios, worst option not pinned to zero.'), 'minmax': (<function minmax_normalization>, '(x - min) / (max - min); observed range onto [0, 1].'), 'sum': (<function sum_normalization>, 'x / sum(x); each value as a share of the column total.'), 'vector': (<function vector_normalization>, 'x / sqrt(sum(x^2)); unit-insensitive, preserves ratios.')}

Name to (function, summary). Insertion-ordered; plugins append.

mcdakit.normalization.available_normalizations()[source]

{name: summary} for every registered scheme.

Return type:

dict

mcdakit.normalization.get_normalization(name)[source]

Look up a scheme by name, or raise listing what is available.

Parameters:

name (str)

Return type:

Callable

mcdakit.normalization.max_normalization(data)[source]

x / max — SAW’s scheme.

Keeps ratios intact and does not pin the worst option to zero. More stable than min-max under option removal, because only the maximum matters and the option removed is usually not it.

Parameters:

data (ndarray)

Return type:

ndarray

mcdakit.normalization.minmax_normalization(data)[source]

(x - min) / (max - min) — the observed range mapped onto [0, 1].

The most intuitive scheme and the most reversal-prone: the best option always scores 1 and the worst always 0, so removing either rescales everyone else. This is the mechanism behind most of the rank reversal this package measures.

Parameters:

data (ndarray)

Return type:

ndarray

mcdakit.normalization.normalize_weights(weights)[source]

Scale weights to sum to one, or return None if the total is zero.

Every method needs this, and each carried its own copy of the guard. None rather than a fallback keeps the decision with the caller: the methods return a flat zero vector, which says “nothing here can be ranked” rather than inventing preferences nobody expressed.

Going through mcdakit.rank() the zero case cannot arise — a Decision refuses all-zero weights. The guard is for the algorithm functions, which are public and callable on hand-built arrays.

Parameters:

weights (ndarray)

Return type:

ndarray | None

mcdakit.normalization.register_normalization(name, function=None, *, summary='', replace=False)[source]

Register a normalisation scheme. Usable as a decorator.

Parameters:
  • name (str) – What callers pass as normalization=.

  • function (Callable | None) – Takes an oriented matrix, returns one of the same shape. Omit to use as a decorator.

  • replace (bool) – Overwrite an existing scheme of this name. Off by default, for the same reason methods refuse it: silently shadowing one would make the same call mean different things in different processes.

  • summary (str)

mcdakit.normalization.sum_normalization(data)[source]

x / sum(x) — each column becomes a share of its total.

Used by COPRAS and several outranking methods. Every option influences every other’s score, so it is sensitive to the option set.

Parameters:

data (ndarray)

Return type:

ndarray

mcdakit.normalization.vector_normalization(data)[source]

x / sqrt(sum(x^2)) — Hwang and Yoon’s original choice for TOPSIS.

Preserves the ratios between values and is insensitive to the units the column was measured in. Because the denominator depends on every value in the column, adding or removing an option changes every other option’s normalised score.

Hwang and Yoon, Multiple Attribute Decision Making, 1981.

Parameters:

data (ndarray)

Return type:

ndarray

Constants

mcdakit.FRAGILE_THRESHOLD

0.10. Below this weight tolerance, sensitivity() calls a decision fragile: a re-weighting smaller than a tenth of the total unseats the winner.

mcdakit.ROBUST_THRESHOLD

0.25. At or above this, the winner survives any plausible single-criterion disagreement and is reported as robust.

mcdakit.CONSISTENCY_LIMIT

0.10. Saaty’s rule of thumb: above this consistency ratio the pairwise judgements contradict each other badly enough that the derived weights should not be relied on. Reported, never enforced.

mcdakit.RANDOM_INDEX

Saaty’s random consistency index by matrix order — the average consistency index of a randomly filled n × n reciprocal matrix. Dividing by it is what makes the consistency ratio comparable across problem sizes.

mcdakit.SAATY_SCALE

The fundamental 1–9 comparison scale, as {ratio: meaning}.

mcdakit.NORMALIZATIONS

Registered normalisation schemes, as {name: (function, summary)}. Use available_normalizations() for the readable form.

Orientation

Turning cost criteria into benefit criteria.

Every classical method in this package is written for benefit criteria — larger is better. Real decision matrices are mostly not: price and lead time are the two most common criteria in procurement and both are costs. Without a correction, a buyer entering genuine figures gets the most expensive, slowest option ranked first: a confident and completely inverted answer.

Flipping once, here, rather than inside each algorithm keeps the method implementations untouched and gives the correction a single place to be right.

mcdakit.orientation.orient(data, directions)[source]

Mirror every cost column so that higher is better throughout.

The transform is max + min - x, which reflects the column about its own midpoint. Two properties make it the right choice here:

  • it preserves the spacing between values, so methods that care about intervals see the same structure they would have seen on a benefit criterion;

  • it keeps everything non-negative when the input was, which TOPSIS’s and ELECTRE’s vector normalisation and SAW’s division by the column maximum all rely on.

Note it is not value-preserving across different option sets: the midpoint depends on which options are present. That dependence is one of the mechanisms behind rank reversal, and is precisely what SPOTIS avoids by working from fixed bounds instead — see mcdakit.methods.spotis.spotis().

Parameters:
Return type:

ndarray