bulanik 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bulanik/__init__.py +66 -0
- bulanik/_linalg.py +135 -0
- bulanik/_validate.py +93 -0
- bulanik/aggregate.py +226 -0
- bulanik/anfis.py +873 -0
- bulanik/inference/__init__.py +49 -0
- bulanik/inference/_common.py +255 -0
- bulanik/inference/mamdani.py +338 -0
- bulanik/inference/rules.py +708 -0
- bulanik/inference/sugeno.py +339 -0
- bulanik/inference/tsukamoto.py +207 -0
- bulanik/lumix.py +510 -0
- bulanik/mcdm/__init__.py +94 -0
- bulanik/mcdm/_common.py +230 -0
- bulanik/mcdm/bwm.py +195 -0
- bulanik/mcdm/dematel.py +274 -0
- bulanik/mcdm/fuzzy.py +463 -0
- bulanik/mcdm/fuzzy_ranking.py +541 -0
- bulanik/mcdm/outranking.py +236 -0
- bulanik/mcdm/ranking.py +512 -0
- bulanik/mcdm/results.py +192 -0
- bulanik/mcdm/scoring.py +806 -0
- bulanik/mcdm/weighting.py +1015 -0
- bulanik/numbers/__init__.py +66 -0
- bulanik/numbers/_base.py +230 -0
- bulanik/numbers/it2.py +469 -0
- bulanik/numbers/iv_neutrosophic.py +369 -0
- bulanik/numbers/iv_orthopair.py +605 -0
- bulanik/numbers/orthopair.py +594 -0
- bulanik/numbers/threepart.py +795 -0
- bulanik/numbers/triangular.py +552 -0
- bulanik/plot/__init__.py +83 -0
- bulanik/plot/_style.py +605 -0
- bulanik/plot/inference.py +262 -0
- bulanik/plot/mcdm.py +364 -0
- bulanik/plot/numbers.py +1067 -0
- bulanik/plot/sets.py +319 -0
- bulanik/py.typed +0 -0
- bulanik/scales.py +245 -0
- bulanik/sets.py +668 -0
- bulanik/sezgi.py +702 -0
- bulanik/type2.py +1093 -0
- bulanik-0.3.0.dist-info/METADATA +113 -0
- bulanik-0.3.0.dist-info/RECORD +46 -0
- bulanik-0.3.0.dist-info/WHEEL +4 -0
- bulanik-0.3.0.dist-info/licenses/LICENSE +21 -0
bulanik/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""bulanik: easy-to-use fuzzy numbers, fuzzy inference and fuzzy MCDM.
|
|
2
|
+
|
|
3
|
+
The package root re-exports the fuzzy number types of [`bulanik.numbers`][bulanik.numbers]
|
|
4
|
+
so that the most common objects are one import away:
|
|
5
|
+
|
|
6
|
+
- [`TFN`][bulanik.numbers.TFN], [`TrFN`][bulanik.numbers.TrFN]: triangular and trapezoidal numbers.
|
|
7
|
+
- [`IFN`][bulanik.numbers.IFN], [`PFN`][bulanik.numbers.PFN], [`FFN`][bulanik.numbers.FFN],
|
|
8
|
+
[`QROFN`][bulanik.numbers.QROFN]: orthopair numbers (q = 1, 2, 3, any q).
|
|
9
|
+
- [`IVIFN`][bulanik.numbers.IVIFN], [`IVPFN`][bulanik.numbers.IVPFN], [`IVFFN`][bulanik.numbers.IVFFN],
|
|
10
|
+
[`IVQROFN`][bulanik.numbers.IVQROFN]: their interval-valued versions.
|
|
11
|
+
- [`SphericalFN`][bulanik.numbers.SphericalFN], [`PictureFN`][bulanik.numbers.PictureFN],
|
|
12
|
+
[`NeutrosophicFN`][bulanik.numbers.NeutrosophicFN]: three-part numbers.
|
|
13
|
+
- [`IVNeutrosophicFN`][bulanik.numbers.IVNeutrosophicFN]: interval neutrosophic numbers.
|
|
14
|
+
- [`IT2TrFN`][bulanik.numbers.IT2TrFN]: interval type-2 trapezoidal numbers.
|
|
15
|
+
|
|
16
|
+
Inference, MCDM, aggregation, sets, scales and the optional LumiX / sezgi bridges
|
|
17
|
+
live in submodules (`bulanik.inference`, `bulanik.mcdm`, `bulanik.aggregate`,
|
|
18
|
+
`bulanik.sets`, `bulanik.scales`, `bulanik.lumix`, `bulanik.sezgi`).
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
__version__: The installed package version string.
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
>>> import bulanik
|
|
25
|
+
>>> bulanik.TFN(1, 2, 4).score()
|
|
26
|
+
2.3333
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from .numbers import (
|
|
30
|
+
FFN,
|
|
31
|
+
IFN,
|
|
32
|
+
IT2TrFN,
|
|
33
|
+
IVFFN,
|
|
34
|
+
IVIFN,
|
|
35
|
+
IVNeutrosophicFN,
|
|
36
|
+
IVPFN,
|
|
37
|
+
IVQROFN,
|
|
38
|
+
PFN,
|
|
39
|
+
QROFN,
|
|
40
|
+
TFN,
|
|
41
|
+
NeutrosophicFN,
|
|
42
|
+
PictureFN,
|
|
43
|
+
SphericalFN,
|
|
44
|
+
TrFN,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__version__ = "0.3.0"
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"TFN",
|
|
51
|
+
"TrFN",
|
|
52
|
+
"QROFN",
|
|
53
|
+
"IFN",
|
|
54
|
+
"PFN",
|
|
55
|
+
"FFN",
|
|
56
|
+
"IVQROFN",
|
|
57
|
+
"IVIFN",
|
|
58
|
+
"IVPFN",
|
|
59
|
+
"IVFFN",
|
|
60
|
+
"SphericalFN",
|
|
61
|
+
"PictureFN",
|
|
62
|
+
"NeutrosophicFN",
|
|
63
|
+
"IVNeutrosophicFN",
|
|
64
|
+
"IT2TrFN",
|
|
65
|
+
"__version__",
|
|
66
|
+
]
|
bulanik/_linalg.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
r"""Small pure-Python dense linear algebra: Cholesky solve and ridge least squares.
|
|
2
|
+
|
|
3
|
+
Private helpers of [`ANFIS`][bulanik.anfis.ANFIS] training (not public API, no
|
|
4
|
+
dependency on NumPy): `solve_spd` solves a symmetric positive definite system by
|
|
5
|
+
Cholesky factorisation, `normal_equations` forms $A^T A$ and $A^T y$ for a
|
|
6
|
+
design matrix, and `lstsq` combines them into a ridge least-squares solve,
|
|
7
|
+
$(A^T A + \lambda I)\theta = A^T y$.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
|
|
15
|
+
__all__ = ["inverse", "lstsq", "normal_equations", "solve_spd"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def inverse(a: Sequence[Sequence[float]]) -> list[list[float]]:
|
|
19
|
+
"""Inverse of a square matrix by Gauss-Jordan elimination with partial pivoting.
|
|
20
|
+
|
|
21
|
+
Raises ValueError when ``a`` is not square or not finite, or is singular,
|
|
22
|
+
i.e. a pivot satisfies ``|pivot| <= 1e-12 * max|a_ij|``.
|
|
23
|
+
"""
|
|
24
|
+
n = len(a)
|
|
25
|
+
if n == 0 or any(len(row) != n for row in a):
|
|
26
|
+
raise ValueError("inverse needs a non-empty square matrix")
|
|
27
|
+
m = [[float(v) for v in row] + [1.0 if i == j else 0.0 for j in range(n)] for i, row in enumerate(a)]
|
|
28
|
+
if not all(math.isfinite(v) for row in m for v in row):
|
|
29
|
+
raise ValueError("inverse needs finite entries")
|
|
30
|
+
scale = max(abs(v) for row in m for v in row[:n])
|
|
31
|
+
for c in range(n):
|
|
32
|
+
p = max(range(c, n), key=lambda r: abs(m[r][c]))
|
|
33
|
+
if abs(m[p][c]) <= 1e-12 * scale:
|
|
34
|
+
raise ValueError("matrix is singular")
|
|
35
|
+
m[c], m[p] = m[p], m[c]
|
|
36
|
+
piv = m[c][c]
|
|
37
|
+
m[c] = [v / piv for v in m[c]]
|
|
38
|
+
for r in range(n):
|
|
39
|
+
if r != c and m[r][c] != 0.0:
|
|
40
|
+
f = m[r][c]
|
|
41
|
+
m[r] = [v - f * w for v, w in zip(m[r], m[c])]
|
|
42
|
+
return [row[n:] for row in m]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def solve_spd(a: Sequence[Sequence[float]], b: Sequence[float]) -> list[float]:
|
|
46
|
+
"""Solve ``a x = b`` for a symmetric positive definite ``a`` (Cholesky).
|
|
47
|
+
|
|
48
|
+
Raises ValueError when ``a`` is not square, not symmetric, not finite or
|
|
49
|
+
not (numerically) positive definite, i.e. a Cholesky pivot is <= 0.
|
|
50
|
+
"""
|
|
51
|
+
n = len(a)
|
|
52
|
+
if n == 0 or any(len(row) != n for row in a):
|
|
53
|
+
raise ValueError("solve_spd needs a non-empty square matrix")
|
|
54
|
+
if len(b) != n:
|
|
55
|
+
raise ValueError(f"right-hand side has {len(b)} entries, matrix is {n}x{n}")
|
|
56
|
+
m = [[float(v) for v in row] for row in a]
|
|
57
|
+
rhs = [float(v) for v in b]
|
|
58
|
+
if not all(math.isfinite(v) for row in m for v in row) or not all(map(math.isfinite, rhs)):
|
|
59
|
+
raise ValueError("solve_spd needs finite entries")
|
|
60
|
+
for i in range(n):
|
|
61
|
+
for j in range(i):
|
|
62
|
+
if abs(m[i][j] - m[j][i]) > 1e-12 * max(1.0, abs(m[i][j]), abs(m[j][i])):
|
|
63
|
+
raise ValueError("solve_spd needs a symmetric matrix")
|
|
64
|
+
# In-place lower Cholesky factor L (row-major), a = L L^T.
|
|
65
|
+
low = [[0.0] * n for _ in range(n)]
|
|
66
|
+
for j in range(n):
|
|
67
|
+
lj = low[j]
|
|
68
|
+
d = m[j][j] - sum(v * v for v in lj[:j])
|
|
69
|
+
if not d > 0.0:
|
|
70
|
+
raise ValueError("matrix is not positive definite (Cholesky pivot <= 0)")
|
|
71
|
+
ljj = math.sqrt(d)
|
|
72
|
+
lj[j] = ljj
|
|
73
|
+
for i in range(j + 1, n):
|
|
74
|
+
li = low[i]
|
|
75
|
+
li[j] = (m[i][j] - sum(li[k] * lj[k] for k in range(j))) / ljj
|
|
76
|
+
# Forward substitution L z = b, then back substitution L^T x = z.
|
|
77
|
+
z = [0.0] * n
|
|
78
|
+
for i in range(n):
|
|
79
|
+
li = low[i]
|
|
80
|
+
z[i] = (rhs[i] - sum(li[k] * z[k] for k in range(i))) / li[i]
|
|
81
|
+
x = [0.0] * n
|
|
82
|
+
for i in range(n - 1, -1, -1):
|
|
83
|
+
x[i] = (z[i] - sum(low[k][i] * x[k] for k in range(i + 1, n))) / low[i][i]
|
|
84
|
+
return x
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def normal_equations(
|
|
88
|
+
rows: Sequence[Sequence[float]], y: Sequence[float]
|
|
89
|
+
) -> tuple[list[list[float]], list[float]]:
|
|
90
|
+
"""``(A^T A, A^T y)`` for the design matrix ``A`` given by ``rows``."""
|
|
91
|
+
if not rows:
|
|
92
|
+
raise ValueError("lstsq needs at least one row")
|
|
93
|
+
if len(rows) != len(y):
|
|
94
|
+
raise ValueError(f"lstsq got {len(rows)} rows but {len(y)} targets")
|
|
95
|
+
p = len(rows[0])
|
|
96
|
+
gram = [[0.0] * p for _ in range(p)]
|
|
97
|
+
aty = [0.0] * p
|
|
98
|
+
for row, target in zip(rows, y):
|
|
99
|
+
if len(row) != p:
|
|
100
|
+
raise ValueError("lstsq rows must all have the same length")
|
|
101
|
+
r = [float(v) for v in row]
|
|
102
|
+
t = float(target)
|
|
103
|
+
if not math.isfinite(t) or not all(map(math.isfinite, r)):
|
|
104
|
+
raise ValueError("lstsq needs finite rows and targets")
|
|
105
|
+
for i in range(p):
|
|
106
|
+
ri = r[i]
|
|
107
|
+
if ri == 0.0:
|
|
108
|
+
continue
|
|
109
|
+
aty[i] += ri * t
|
|
110
|
+
gi = gram[i]
|
|
111
|
+
for j in range(i, p):
|
|
112
|
+
gi[j] += ri * r[j]
|
|
113
|
+
for i in range(p):
|
|
114
|
+
for j in range(i):
|
|
115
|
+
gram[i][j] = gram[j][i]
|
|
116
|
+
return gram, aty
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def lstsq(
|
|
120
|
+
rows: Sequence[Sequence[float]], y: Sequence[float], ridge: float = 1e-8
|
|
121
|
+
) -> list[float]:
|
|
122
|
+
"""Ridge least squares: solve ``(A^T A + ridge I) theta = A^T y``.
|
|
123
|
+
|
|
124
|
+
A positive ``ridge`` makes rank-deficient designs (a constant column,
|
|
125
|
+
duplicate rows) solvable; the solution then tends to the minimum-norm
|
|
126
|
+
least-squares solution as ``ridge -> 0``. ``ridge=0`` is the plain normal
|
|
127
|
+
equation and raises ValueError when ``A^T A`` is singular.
|
|
128
|
+
"""
|
|
129
|
+
ridge = float(ridge)
|
|
130
|
+
if not (math.isfinite(ridge) and ridge >= 0.0):
|
|
131
|
+
raise ValueError(f"ridge must be finite and >= 0, got {ridge}")
|
|
132
|
+
gram, aty = normal_equations(rows, y)
|
|
133
|
+
for i in range(len(gram)):
|
|
134
|
+
gram[i][i] += ridge
|
|
135
|
+
return solve_spd(gram, aty)
|
bulanik/_validate.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Shared validation helpers (private).
|
|
2
|
+
|
|
3
|
+
Argument checking used across the package: finite numbers, values in
|
|
4
|
+
$[0, 1]$ (with a tolerance of $10^{-12}$ for rounding), method-name resolution
|
|
5
|
+
and weight vectors. Nothing here is public API; the helpers define the error
|
|
6
|
+
messages that the public functions raise.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from typing import Sequence
|
|
13
|
+
|
|
14
|
+
TOL = 1e-12
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _num(v: float) -> str:
|
|
18
|
+
"""Short form of ``v`` that still round-trips (``%g`` when exact, else repr)."""
|
|
19
|
+
short = f"{v:g}"
|
|
20
|
+
return short if float(short) == v else repr(v)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def finite(x: float, name: str) -> float:
|
|
24
|
+
"""Return ``x`` as a float; raise ValueError on NaN or infinity."""
|
|
25
|
+
if isinstance(x, (bool, str)):
|
|
26
|
+
raise TypeError(f"{name} must be a number, got {type(x).__name__} {x!r}")
|
|
27
|
+
x = float(x)
|
|
28
|
+
if not math.isfinite(x):
|
|
29
|
+
raise ValueError(f"{name} must be finite, got {x}")
|
|
30
|
+
return x
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def unit(x: float, name: str) -> float:
|
|
34
|
+
"""Return ``x`` clamped into [0, 1] when it is outside by at most TOL."""
|
|
35
|
+
x = finite(x, name)
|
|
36
|
+
if x < 0.0:
|
|
37
|
+
if x < -TOL:
|
|
38
|
+
raise ValueError(f"{name} must be in [0, 1], got {x}")
|
|
39
|
+
return 0.0
|
|
40
|
+
if x > 1.0:
|
|
41
|
+
if x > 1.0 + TOL:
|
|
42
|
+
raise ValueError(f"{name} must be in [0, 1], got {x}")
|
|
43
|
+
return 1.0
|
|
44
|
+
return x
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def choose(
|
|
48
|
+
name: str | None,
|
|
49
|
+
valid: Sequence[str],
|
|
50
|
+
what: str,
|
|
51
|
+
owner: str,
|
|
52
|
+
default: str | None = None,
|
|
53
|
+
) -> str:
|
|
54
|
+
"""Resolve a method name, defaulting when None; list valid names on error."""
|
|
55
|
+
if name is None:
|
|
56
|
+
if default is None:
|
|
57
|
+
raise ValueError(f"no {what} given for {owner}; valid: {', '.join(valid)}")
|
|
58
|
+
return default
|
|
59
|
+
if name not in valid:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"unknown {what} '{name}' for {owner}; valid: {', '.join(valid)}"
|
|
62
|
+
)
|
|
63
|
+
return name
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check_weights(
|
|
67
|
+
weights: Sequence[float] | None, n: int, normalize: bool = False
|
|
68
|
+
) -> list[float]:
|
|
69
|
+
"""Validate ``n`` weights (None means equal weights 1/n).
|
|
70
|
+
|
|
71
|
+
Without ``normalize`` the weights must sum to 1 within 1e-9; the weights
|
|
72
|
+
are returned as given (callers such as :func:`bulanik.aggregate.wa` rescale
|
|
73
|
+
them to sum to exactly 1). With ``normalize`` they are divided by their sum.
|
|
74
|
+
"""
|
|
75
|
+
if n < 1:
|
|
76
|
+
raise ValueError("need at least one weight")
|
|
77
|
+
if weights is None:
|
|
78
|
+
return [1.0 / n] * n
|
|
79
|
+
if len(weights) != n:
|
|
80
|
+
raise ValueError(f"expected {n} weights, got {len(weights)}")
|
|
81
|
+
w = [finite(x, "weight") for x in weights]
|
|
82
|
+
if any(x < 0 for x in w):
|
|
83
|
+
raise ValueError("weights must be non-negative")
|
|
84
|
+
total = sum(w)
|
|
85
|
+
if not math.isfinite(total): # each weight is finite but their sum overflowed
|
|
86
|
+
raise ValueError("weights are too large: their sum overflows a float")
|
|
87
|
+
if normalize:
|
|
88
|
+
if total <= 0:
|
|
89
|
+
raise ValueError("weights must not all be zero")
|
|
90
|
+
return [x / total for x in w]
|
|
91
|
+
if abs(total - 1.0) > 1e-9:
|
|
92
|
+
raise ValueError(f"weights must sum to 1, got {total}")
|
|
93
|
+
return w
|
bulanik/aggregate.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
r"""Weighted aggregation: weighted average (WA), weighted geometric (WG), experts.
|
|
2
|
+
|
|
3
|
+
[`wa`][bulanik.aggregate.wa] and [`wg`][bulanik.aggregate.wg] combine fuzzy numbers
|
|
4
|
+
of one type with non-negative weights summing to 1, using the type's own
|
|
5
|
+
operators (`scale` / `add` for the average, `power` / `mul` for the geometric
|
|
6
|
+
mean). [`aggregate_experts`][bulanik.aggregate.aggregate_experts] applies one of
|
|
7
|
+
them to a stack of expert rating matrices, cell by cell.
|
|
8
|
+
|
|
9
|
+
All functions require the inputs to be of one class (and rung, for orthopair
|
|
10
|
+
numbers); mixing types raises `TypeError`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from typing import Sequence
|
|
17
|
+
|
|
18
|
+
from ._validate import check_weights, choose
|
|
19
|
+
from .numbers import FuzzyNumber, PictureFN
|
|
20
|
+
from .numbers.threepart import _picture_aggregate
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _prepare(
|
|
24
|
+
values: Sequence[FuzzyNumber],
|
|
25
|
+
weights: Sequence[float] | None,
|
|
26
|
+
ops: str,
|
|
27
|
+
normalize: bool,
|
|
28
|
+
) -> tuple[list[FuzzyNumber], list[float]]:
|
|
29
|
+
"""Validate, then return the inputs with a positive weight and those weights."""
|
|
30
|
+
values = list(values)
|
|
31
|
+
if not values:
|
|
32
|
+
raise ValueError("cannot aggregate an empty list of values")
|
|
33
|
+
first = values[0]
|
|
34
|
+
if not isinstance(first, FuzzyNumber):
|
|
35
|
+
raise TypeError(f"cannot aggregate {type(first).__name__}; need fuzzy numbers")
|
|
36
|
+
for v in values[1:]:
|
|
37
|
+
first.same_kind(v)
|
|
38
|
+
first._check_ops(ops)
|
|
39
|
+
w = check_weights(weights, len(values), normalize)
|
|
40
|
+
total = math.fsum(w) # within 1e-9 of 1 already; make it exactly 1
|
|
41
|
+
pairs = [(v, x / total) for v, x in zip(values, w) if x > 0.0]
|
|
42
|
+
return [v for v, _ in pairs], [x for _, x in pairs]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fold(
|
|
46
|
+
values: Sequence[FuzzyNumber],
|
|
47
|
+
weights: Sequence[float] | None,
|
|
48
|
+
ops: str,
|
|
49
|
+
normalize: bool,
|
|
50
|
+
kind: str,
|
|
51
|
+
) -> FuzzyNumber:
|
|
52
|
+
vals, w = _prepare(values, weights, ops, normalize)
|
|
53
|
+
if not vals: # unreachable for valid weights; kept as a guard
|
|
54
|
+
raise ValueError("weights must not all be zero") # pragma: no cover
|
|
55
|
+
if isinstance(vals[0], PictureFN):
|
|
56
|
+
return _picture_aggregate(vals, w, kind) # scale(k < 1) is not closed for pictures
|
|
57
|
+
if kind == "wa":
|
|
58
|
+
out = vals[0].scale(w[0], ops)
|
|
59
|
+
for v, x in zip(vals[1:], w[1:]):
|
|
60
|
+
out = out.add(v.scale(x, ops), ops)
|
|
61
|
+
else:
|
|
62
|
+
out = vals[0].power(w[0], ops)
|
|
63
|
+
for v, x in zip(vals[1:], w[1:]):
|
|
64
|
+
out = out.mul(v.power(x, ops), ops)
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def wa(
|
|
69
|
+
values: Sequence[FuzzyNumber],
|
|
70
|
+
weights: Sequence[float] | None = None,
|
|
71
|
+
*,
|
|
72
|
+
ops: str = "algebraic",
|
|
73
|
+
normalize: bool = False,
|
|
74
|
+
) -> FuzzyNumber:
|
|
75
|
+
r"""Weighted average $\sum_i w_i A_i$ with the type's own operators.
|
|
76
|
+
|
|
77
|
+
Computed as `scale` of each number by its weight, folded with `add`. For
|
|
78
|
+
triangular numbers this is the componentwise weighted mean of the points;
|
|
79
|
+
for intuitionistic numbers it is the classic IFWA operator
|
|
80
|
+
$\left(1 - \prod_i (1 - \mu_i)^{w_i},\; \prod_i \nu_i^{w_i}\right)$.
|
|
81
|
+
Picture numbers use the closed form
|
|
82
|
+
$\left(1 - \prod_i (1 - \mu_i)^{w_i},\; \prod_i \eta_i^{w_i},\; \prod_i \nu_i^{w_i}\right)$,
|
|
83
|
+
because scaling by a weight below 1 is not closed for them.
|
|
84
|
+
|
|
85
|
+
`weights=None` means equal weights $1/n$. Weights must be non-negative and
|
|
86
|
+
sum to 1 unless `normalize=True` (then they are divided by their sum);
|
|
87
|
+
weights that sum to 1 only within $10^{-9}$ are accepted and silently
|
|
88
|
+
rescaled to sum to exactly 1. Inputs with a weight of 0 are ignored.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
values: Fuzzy numbers of one type (and rung).
|
|
92
|
+
weights: One weight per value, or `None` for equal weights.
|
|
93
|
+
ops: Operator set of the type, `"algebraic"` or (orthopair) `"einstein"`.
|
|
94
|
+
normalize: Divide the weights by their sum instead of requiring sum 1.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The aggregated number, of the type of the inputs.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
ValueError: If `values` is empty, the number of weights differs, a
|
|
101
|
+
weight is negative or not finite, the weights do not sum to 1 (and
|
|
102
|
+
`normalize` is false) or are all zero (with `normalize`), or `ops`
|
|
103
|
+
is unsupported.
|
|
104
|
+
TypeError: If the values are not fuzzy numbers or differ in type.
|
|
105
|
+
|
|
106
|
+
References:
|
|
107
|
+
Xu (2007), IEEE Transactions on Fuzzy Systems 15(6): intuitionistic
|
|
108
|
+
fuzzy aggregation operators. Wei (2017) for the picture closed forms
|
|
109
|
+
(retracted by the publisher in 2025; the closed forms are tested
|
|
110
|
+
against the code, not the paper).
|
|
111
|
+
|
|
112
|
+
Examples:
|
|
113
|
+
>>> from bulanik import TFN, IFN
|
|
114
|
+
>>> from bulanik.aggregate import wa
|
|
115
|
+
>>> wa([TFN(1, 2, 4), TFN(2, 3, 5)])
|
|
116
|
+
TFN(l=1.5, m=2.5, u=4.5)
|
|
117
|
+
>>> r = wa([IFN(0.6, 0.3), IFN(0.5, 0.4)], [2, 1], normalize=True)
|
|
118
|
+
>>> round(r.mu, 4), round(r.nu, 4)
|
|
119
|
+
(0.5691, 0.3302)
|
|
120
|
+
>>> wa([TFN(1, 2, 4), TFN(2, 3, 5)], [0.5, 0.2])
|
|
121
|
+
Traceback (most recent call last):
|
|
122
|
+
...
|
|
123
|
+
ValueError: weights must sum to 1, got 0.7
|
|
124
|
+
"""
|
|
125
|
+
return _fold(values, weights, ops, normalize, "wa")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def wg(
|
|
129
|
+
values: Sequence[FuzzyNumber],
|
|
130
|
+
weights: Sequence[float] | None = None,
|
|
131
|
+
*,
|
|
132
|
+
ops: str = "algebraic",
|
|
133
|
+
normalize: bool = False,
|
|
134
|
+
) -> FuzzyNumber:
|
|
135
|
+
r"""Weighted geometric mean $\prod_i A_i^{w_i}$ with the type's own operators.
|
|
136
|
+
|
|
137
|
+
Computed as `power` of each number by its weight, folded with `mul`. For
|
|
138
|
+
positive triangular numbers this is the componentwise weighted geometric
|
|
139
|
+
mean of the points. Weights, `normalize` and `ops` behave as in
|
|
140
|
+
[`wa`][bulanik.aggregate.wa]. Picture numbers use closed forms
|
|
141
|
+
$\left(\prod_i (\mu_i + \eta_i)^{w_i} - \prod_i \eta_i^{w_i},\;
|
|
142
|
+
\prod_i \eta_i^{w_i},\; 1 - \prod_i (1 - \nu_i)^{w_i}\right)$.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
values: Fuzzy numbers of one type (and rung).
|
|
146
|
+
weights: One weight per value, or `None` for equal weights.
|
|
147
|
+
ops: Operator set of the type, `"algebraic"` or (orthopair) `"einstein"`.
|
|
148
|
+
normalize: Divide the weights by their sum instead of requiring sum 1.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
The aggregated number, of the type of the inputs.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
ValueError: As for [`wa`][bulanik.aggregate.wa]; additionally, for
|
|
155
|
+
triangular numbers with a negative lowest point.
|
|
156
|
+
TypeError: If the values are not fuzzy numbers or differ in type.
|
|
157
|
+
|
|
158
|
+
References:
|
|
159
|
+
Xu and Yager (2006), International Journal of General Systems 35(4):
|
|
160
|
+
intuitionistic fuzzy geometric aggregation operators.
|
|
161
|
+
|
|
162
|
+
Examples:
|
|
163
|
+
>>> from bulanik import TFN
|
|
164
|
+
>>> from bulanik.aggregate import wg
|
|
165
|
+
>>> r = wg([TFN(1, 2, 4), TFN(2, 3, 5)])
|
|
166
|
+
>>> round(r.l, 4), round(r.m, 4), round(r.u, 4)
|
|
167
|
+
(1.4142, 2.4495, 4.4721)
|
|
168
|
+
"""
|
|
169
|
+
return _fold(values, weights, ops, normalize, "wg")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def aggregate_experts(
|
|
173
|
+
ratings: Sequence[Sequence[Sequence[FuzzyNumber]]],
|
|
174
|
+
weights: Sequence[float] | None = None,
|
|
175
|
+
*,
|
|
176
|
+
method: str = "wa",
|
|
177
|
+
ops: str = "algebraic",
|
|
178
|
+
normalize: bool = False,
|
|
179
|
+
) -> list[list[FuzzyNumber]]:
|
|
180
|
+
"""Aggregate expert ratings indexed ``[expert][alternative][criterion]``.
|
|
181
|
+
|
|
182
|
+
For every alternative-criterion cell the experts' numbers are combined with
|
|
183
|
+
[`wa`][bulanik.aggregate.wa] or [`wg`][bulanik.aggregate.wg] using the
|
|
184
|
+
expert weights.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
ratings: One matrix per expert; each is a list (alternatives) of lists
|
|
188
|
+
(criteria) of fuzzy numbers, all of the same shape and type.
|
|
189
|
+
weights: One weight per expert, or `None` for equal weights.
|
|
190
|
+
method: `"wa"` (default) or `"wg"`.
|
|
191
|
+
ops: Operator set of the number type.
|
|
192
|
+
normalize: Divide the expert weights by their sum instead of requiring sum 1.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
The `[alternative][criterion]` matrix of aggregated numbers.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
ValueError: If `method` is unknown, there are no experts, the matrices
|
|
199
|
+
differ in shape, or the weights are invalid (see
|
|
200
|
+
[`wa`][bulanik.aggregate.wa]).
|
|
201
|
+
TypeError: If the numbers differ in type.
|
|
202
|
+
|
|
203
|
+
Examples:
|
|
204
|
+
>>> from bulanik import TFN
|
|
205
|
+
>>> from bulanik.aggregate import aggregate_experts
|
|
206
|
+
>>> a, b = TFN(1, 2, 4), TFN(2, 3, 5)
|
|
207
|
+
>>> experts = [[[a, b]], [[b, a]]] # 2 experts, 1 alternative, 2 criteria
|
|
208
|
+
>>> aggregate_experts(experts)
|
|
209
|
+
[[TFN(l=1.5, m=2.5, u=4.5), TFN(l=1.5, m=2.5, u=4.5)]]
|
|
210
|
+
"""
|
|
211
|
+
method = choose(method, ("wa", "wg"), "method", "aggregate_experts")
|
|
212
|
+
fn = wa if method == "wa" else wg
|
|
213
|
+
if not ratings:
|
|
214
|
+
raise ValueError("need at least one expert")
|
|
215
|
+
check_weights(weights, len(ratings), normalize) # also when there are no cells
|
|
216
|
+
n_alt, n_crit = len(ratings[0]), len(ratings[0][0]) if ratings[0] else 0
|
|
217
|
+
for r in ratings:
|
|
218
|
+
if len(r) != n_alt or any(len(row) != n_crit for row in r):
|
|
219
|
+
raise ValueError("expert rating matrices must all have the same shape")
|
|
220
|
+
return [
|
|
221
|
+
[
|
|
222
|
+
fn([r[i][j] for r in ratings], weights, ops=ops, normalize=normalize)
|
|
223
|
+
for j in range(n_crit)
|
|
224
|
+
]
|
|
225
|
+
for i in range(n_alt)
|
|
226
|
+
]
|