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.
Files changed (46) hide show
  1. bulanik/__init__.py +66 -0
  2. bulanik/_linalg.py +135 -0
  3. bulanik/_validate.py +93 -0
  4. bulanik/aggregate.py +226 -0
  5. bulanik/anfis.py +873 -0
  6. bulanik/inference/__init__.py +49 -0
  7. bulanik/inference/_common.py +255 -0
  8. bulanik/inference/mamdani.py +338 -0
  9. bulanik/inference/rules.py +708 -0
  10. bulanik/inference/sugeno.py +339 -0
  11. bulanik/inference/tsukamoto.py +207 -0
  12. bulanik/lumix.py +510 -0
  13. bulanik/mcdm/__init__.py +94 -0
  14. bulanik/mcdm/_common.py +230 -0
  15. bulanik/mcdm/bwm.py +195 -0
  16. bulanik/mcdm/dematel.py +274 -0
  17. bulanik/mcdm/fuzzy.py +463 -0
  18. bulanik/mcdm/fuzzy_ranking.py +541 -0
  19. bulanik/mcdm/outranking.py +236 -0
  20. bulanik/mcdm/ranking.py +512 -0
  21. bulanik/mcdm/results.py +192 -0
  22. bulanik/mcdm/scoring.py +806 -0
  23. bulanik/mcdm/weighting.py +1015 -0
  24. bulanik/numbers/__init__.py +66 -0
  25. bulanik/numbers/_base.py +230 -0
  26. bulanik/numbers/it2.py +469 -0
  27. bulanik/numbers/iv_neutrosophic.py +369 -0
  28. bulanik/numbers/iv_orthopair.py +605 -0
  29. bulanik/numbers/orthopair.py +594 -0
  30. bulanik/numbers/threepart.py +795 -0
  31. bulanik/numbers/triangular.py +552 -0
  32. bulanik/plot/__init__.py +83 -0
  33. bulanik/plot/_style.py +605 -0
  34. bulanik/plot/inference.py +262 -0
  35. bulanik/plot/mcdm.py +364 -0
  36. bulanik/plot/numbers.py +1067 -0
  37. bulanik/plot/sets.py +319 -0
  38. bulanik/py.typed +0 -0
  39. bulanik/scales.py +245 -0
  40. bulanik/sets.py +668 -0
  41. bulanik/sezgi.py +702 -0
  42. bulanik/type2.py +1093 -0
  43. bulanik-0.3.0.dist-info/METADATA +113 -0
  44. bulanik-0.3.0.dist-info/RECORD +46 -0
  45. bulanik-0.3.0.dist-info/WHEEL +4 -0
  46. 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
+ ]