Source code for mcdakit.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.
"""

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass

import numpy as np

from .types import McdaError

#: Saaty's fundamental scale, as ratio and meaning.
SAATY_SCALE = {
    1 / 9: "Extremely less important",
    1 / 7: "Very strongly less important",
    1 / 5: "Strongly less important",
    1 / 3: "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 random consistency index by matrix order: 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.
RANDOM_INDEX = {
    1: 0.00,
    2: 0.00,
    3: 0.58,
    4: 0.90,
    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,
}

#: Saaty's rule of thumb: above this, at least one judgement contradicts the
#: others badly enough that the derived weights should not be relied on.
CONSISTENCY_LIMIT = 0.10


[docs] def comparison_matrix(n: int, judgements: dict) -> np.ndarray: """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). """ matrix = np.ones((n, n), dtype=float) for (i, j), value in judgements.items(): if not (0 <= i < n and 0 <= j < n): raise McdaError(f"Judgement ({i}, {j}) is out of range for {n} criteria.") if i == j: raise McdaError("A criterion cannot be compared with itself.") value = float(value) if value <= 0: raise McdaError( f"Judgement ({i}, {j}) must be a positive ratio, got {value}." ) matrix[i, j] = value matrix[j, i] = 1.0 / value return matrix
[docs] def priorities(matrix: np.ndarray) -> np.ndarray: """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. """ matrix = np.asarray(matrix, dtype=float) if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1]: raise McdaError( f"A comparison matrix must be square, got shape {matrix.shape}." ) geometric = np.prod(matrix, axis=1) ** (1.0 / matrix.shape[0]) total = geometric.sum() if total == 0: return np.full(matrix.shape[0], 1.0 / matrix.shape[0]) return geometric / total
[docs] def consistency_ratio(matrix: np.ndarray, weights: np.ndarray | None = None) -> float: """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. """ matrix = np.asarray(matrix, dtype=float) size = matrix.shape[0] if size < 3: return 0.0 if weights is None: weights = priorities(matrix) weighted_sum = matrix @ weights lambda_max = float(np.mean(weighted_sum / weights)) consistency_index = (lambda_max - size) / (size - 1) random_index = RANDOM_INDEX.get(size, 1.60) return consistency_index / random_index if random_index else 0.0
[docs] def ahp_weights(judgements, names: Sequence[str] | None = None) -> dict: """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] """ if isinstance(judgements, dict): if names is None: raise McdaError( "names are required when judgements are given as pairs, so " "the matrix size is known." ) matrix = comparison_matrix(len(names), judgements) else: matrix = np.asarray(judgements, dtype=float) if names is None: names = [f"Criterion {i + 1}" for i in range(matrix.shape[0])] if matrix.shape[0] < 2: raise McdaError( "Pairwise comparison needs at least two criteria; with one there " "is nothing to compare." ) if len(names) != matrix.shape[0]: raise McdaError( f"Got {len(names)} names for a {matrix.shape[0]}x" f"{matrix.shape[1]} comparison matrix." ) weights = priorities(matrix) ratio = consistency_ratio(matrix, weights) return { "weights": weights, "consistency_ratio": ratio, "consistent": ratio <= CONSISTENCY_LIMIT, "names": list(names), "matrix": matrix, }
[docs] @dataclass(frozen=True) class AhpResult: """A full AHP hierarchy, and how coherent the judgements behind it were. Attributes ---------- scores: One priority per alternative, summing to one. Higher is better. weights: The criterion weights derived from the criteria comparison matrix. local_priorities: Alternatives by criteria: each column is the alternatives' priorities under one criterion. consistency: ``{"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: Names of the matrices exceeding :data:`CONSISTENCY_LIMIT`. Empty when every judgement set holds together. """ scores: np.ndarray weights: np.ndarray local_priorities: np.ndarray names: tuple labels: tuple consistency: dict inconsistent: tuple @property def ranking(self) -> list: """``[(label, score), ...]`` best first.""" order = sorted(range(len(self.scores)), key=lambda i: -self.scores[i]) return [(self.labels[i], float(self.scores[i])) for i in order] @property def winner(self) -> str: return self.ranking[0][0] def __str__(self) -> str: lines = ["AHP ranking:"] label_width = max((len(label) for label, _ in self.ranking), default=0) + 2 for position, (label, score) in enumerate(self.ranking, start=1): lines.append(f" {position}. {label:<{label_width}}{score:.4f}") lines.append(" consistency ratios:") name_width = max((len(n) for n in self.consistency), default=0) + 2 for name, ratio in self.consistency.items(): flag = " EXCEEDS 0.10" if ratio > CONSISTENCY_LIMIT else "" lines.append(f" {name:<{name_width}}{ratio:.4f}{flag}") if self.inconsistent: verb = "contradicts" if len(self.inconsistent) == 1 else "contradict" lines.append( f" ! {', '.join(self.inconsistent)} {verb} itself; revisit " f"those judgements before relying on the ranking" if len(self.inconsistent) == 1 else f" ! {', '.join(self.inconsistent)} {verb} themselves; " f"revisit those judgements before relying on the ranking" ) return "\n".join(lines)
[docs] def ahp_rank( criteria_comparisons, alternative_comparisons: Sequence, names: Sequence[str] | None = None, labels: Sequence[str] | None = None, ) -> AhpResult: """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: :math:`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: One comparison matrix per criterion, in the same order as the criteria, each comparing the alternatives under that criterion. names, labels: Criterion and alternative names, for the report. Returns ------- AhpResult 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. 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' """ alternative_comparisons = list(alternative_comparisons) if not alternative_comparisons: raise McdaError( "ahp_rank needs one comparison matrix per criterion; none were given." ) n_criteria = len(alternative_comparisons) if names is None: names = [f"Criterion {j + 1}" for j in range(n_criteria)] names = tuple(str(n) for n in names) if len(names) != n_criteria: raise McdaError( f"Got {len(names)} criterion names for {n_criteria} comparison matrices." ) if isinstance(criteria_comparisons, dict): criteria_matrix = comparison_matrix(n_criteria, criteria_comparisons) else: criteria_matrix = np.asarray(criteria_comparisons, dtype=float) if criteria_matrix.shape != (n_criteria, n_criteria): raise McdaError( f"The criteria comparison matrix is {criteria_matrix.shape}, but " f"{n_criteria} comparison matrices were given, one per criterion." ) weights = priorities(criteria_matrix) consistency = {"criteria": float(consistency_ratio(criteria_matrix, weights))} columns, n_alternatives = [], None for name, comparisons in zip(names, alternative_comparisons): matrix = np.asarray(comparisons, dtype=float) if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1]: raise McdaError( f"The comparison matrix for {name!r} must be square, got " f"shape {matrix.shape}." ) if n_alternatives is None: n_alternatives = matrix.shape[0] elif matrix.shape[0] != n_alternatives: raise McdaError( f"The comparison matrix for {name!r} compares " f"{matrix.shape[0]} alternatives, but earlier matrices " f"compared {n_alternatives}. Every criterion must rank the " f"same alternatives." ) local = priorities(matrix) columns.append(local) consistency[name] = float(consistency_ratio(matrix, local)) if labels is None: labels = [f"Option {i + 1}" for i in range(n_alternatives or 0)] labels = tuple(str(label) for label in labels) if len(labels) != n_alternatives: raise McdaError( f"Got {len(labels)} alternative labels for {n_alternatives} alternatives." ) local_priorities = np.column_stack(columns) scores = local_priorities @ weights inconsistent = tuple( name for name, ratio in consistency.items() if ratio > CONSISTENCY_LIMIT ) return AhpResult( scores=scores.astype(float), weights=weights, local_priorities=local_priorities, names=names, labels=labels, consistency=consistency, inconsistent=inconsistent, )