Source code for mcdakit.methods.promethee

"""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.
"""

from __future__ import annotations

from collections.abc import Sequence

import numpy as np

from .base import Method, ScoringContext


[docs] def preference_degree( difference: float, shape: str = "usual", q: float = 0.0, p: float = 0.0, s: float = 0.0, ) -> float: """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. """ if difference <= 0: return 0.0 if shape == "ushape": return 1.0 if difference > q else 0.0 if shape == "vshape": if p <= 0: return 1.0 return 1.0 if difference > p else difference / p if shape == "level": if difference <= q: return 0.0 if p <= q: return 1.0 return 1.0 if difference > p else 0.5 if shape == "linear": if difference <= q: return 0.0 if p <= q: return 1.0 if difference > p: return 1.0 return (difference - q) / (p - q) if shape == "gaussian": if s <= 0: return 1.0 return 1.0 - float(np.exp(-(difference**2) / (2.0 * s**2))) return 1.0
[docs] def preference_matrix( data: np.ndarray, weights: np.ndarray, shapes: Sequence | None = None, ) -> np.ndarray: """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. """ n_alternatives, n_criteria = data.shape matrix = np.zeros((n_alternatives, n_alternatives)) weight_total = float(np.sum(weights)) or 1.0 for i in range(n_alternatives): for j in range(n_alternatives): if i == j: continue pref_sum = 0.0 for k in range(n_criteria): difference = data[i, k] - data[j, k] if shapes: shape, q, p, s = shapes[k] degree = preference_degree(difference, shape, q, p, s) else: degree = 1.0 if difference > 0 else 0.0 pref_sum += weights[k] * degree matrix[i, j] = pref_sum / weight_total return matrix
[docs] def flows(matrix: np.ndarray): """``(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. """ positive = np.sum(matrix, axis=1) negative = np.sum(matrix, axis=0) return positive, negative, positive - negative
[docs] def promethee( data: np.ndarray, weights: np.ndarray, shapes: Sequence | None = None, ) -> np.ndarray: """Net flows, in ``[-1, 1]``, higher is better.""" return flows(preference_matrix(data, weights, shapes))[2].astype(float)
[docs] def shapes_from(criteria) -> tuple | None: """``(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. """ shapes = [(c.preference_shape, c.q, c.p, c.s) for c in criteria] if all(shape == "usual" for shape, _q, _p, _s in shapes): return None return tuple(shapes)
[docs] class Promethee(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 = "promethee" summary = "PROMETHEE II net flows, with six preference-function shapes." citation = "Brans and Vincke (1985)"
[docs] def score(self, ctx: ScoringContext) -> np.ndarray: return promethee(ctx.data, ctx.weights, shapes_from(ctx.criteria), **ctx.opts)