fuzzytool 0.1.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.
- fuzzytool/__init__.py +55 -0
- fuzzytool/anfis.py +141 -0
- fuzzytool/cluster.py +231 -0
- fuzzytool/datasets.py +104 -0
- fuzzytool/defuzz.py +84 -0
- fuzzytool/ftransform.py +79 -0
- fuzzytool/inference/__init__.py +6 -0
- fuzzytool/inference/mamdani.py +93 -0
- fuzzytool/inference/tsk.py +77 -0
- fuzzytool/membership.py +149 -0
- fuzzytool/norms.py +105 -0
- fuzzytool/rules.py +34 -0
- fuzzytool/sets.py +210 -0
- fuzzytool/type2/__init__.py +27 -0
- fuzzytool/type2/inference.py +122 -0
- fuzzytool/type2/reduction.py +83 -0
- fuzzytool/type2/sets.py +96 -0
- fuzzytool/viz.py +118 -0
- fuzzytool-0.1.0.dist-info/METADATA +126 -0
- fuzzytool-0.1.0.dist-info/RECORD +22 -0
- fuzzytool-0.1.0.dist-info/WHEEL +4 -0
- fuzzytool-0.1.0.dist-info/licenses/LICENSE +21 -0
fuzzytool/ftransform.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""F-transform — the fuzzy transform of Perfilieva.
|
|
2
|
+
|
|
3
|
+
The F-transform projects a function onto a *fuzzy partition* of its domain. Over
|
|
4
|
+
a uniform partition by triangular basis functions ``A_1..A_n`` that satisfy the
|
|
5
|
+
Ruspini condition (they sum to 1 everywhere on ``[a, b]``):
|
|
6
|
+
|
|
7
|
+
* the **direct** transform reduces the data to ``n`` components, each a
|
|
8
|
+
membership-weighted average of the samples falling under one basis function;
|
|
9
|
+
* the **inverse** transform reconstructs an approximation
|
|
10
|
+
``f̂(x) = Σ_k F_k · A_k(x)``.
|
|
11
|
+
|
|
12
|
+
With few components the round trip smooths/denoises a signal; with many it
|
|
13
|
+
approximates it closely. Pure NumPy.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
_EPS = 1e-12
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FTransform:
|
|
24
|
+
"""A triangular F-transform over a uniform fuzzy partition of ``[a, b]``.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
a: left end of the domain.
|
|
28
|
+
b: right end of the domain (``b > a``).
|
|
29
|
+
n_basis: number of basis functions / components (``>= 2``).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, a: float, b: float, n_basis: int) -> None:
|
|
33
|
+
if b <= a:
|
|
34
|
+
raise ValueError(f"need a < b, got ({a}, {b})")
|
|
35
|
+
if n_basis < 2:
|
|
36
|
+
raise ValueError("need n_basis >= 2")
|
|
37
|
+
self.a, self.b = float(a), float(b)
|
|
38
|
+
self.nodes = np.linspace(a, b, n_basis) # basis centers
|
|
39
|
+
self.h = self.nodes[1] - self.nodes[0] # uniform spacing
|
|
40
|
+
self.components_: np.ndarray | None = None
|
|
41
|
+
|
|
42
|
+
def basis(self, x) -> np.ndarray:
|
|
43
|
+
"""Triangular basis matrix ``A`` of shape ``(len(x), n_basis)``.
|
|
44
|
+
|
|
45
|
+
Columns form a partition of unity on ``[a, b]`` (each row sums to 1).
|
|
46
|
+
"""
|
|
47
|
+
x = np.asarray(x, dtype=float)
|
|
48
|
+
return np.clip(1.0 - np.abs(x[:, None] - self.nodes[None, :]) / self.h, 0.0, 1.0)
|
|
49
|
+
|
|
50
|
+
def direct(self, x, y) -> np.ndarray:
|
|
51
|
+
"""Direct F-transform: the ``n_basis`` components from samples ``(x, y)``."""
|
|
52
|
+
x = np.asarray(x, dtype=float)
|
|
53
|
+
y = np.asarray(y, dtype=float).ravel()
|
|
54
|
+
a = self.basis(x)
|
|
55
|
+
comp = (a.T @ y) / np.fmax(a.sum(axis=0), _EPS)
|
|
56
|
+
self.components_ = comp
|
|
57
|
+
return comp
|
|
58
|
+
|
|
59
|
+
def inverse(self, x, components: np.ndarray | None = None) -> np.ndarray:
|
|
60
|
+
"""Inverse F-transform: reconstruct values at ``x`` from components."""
|
|
61
|
+
comp = self.components_ if components is None else np.asarray(components, float)
|
|
62
|
+
if comp is None:
|
|
63
|
+
raise RuntimeError("no components; call direct first or pass them in")
|
|
64
|
+
return self.basis(x) @ comp
|
|
65
|
+
|
|
66
|
+
def fit(self, x, y) -> FTransform:
|
|
67
|
+
"""Compute and store the direct transform of ``(x, y)``; returns ``self``."""
|
|
68
|
+
self.direct(x, y)
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def smooth(self, x) -> np.ndarray:
|
|
72
|
+
"""Convenience: direct-then-inverse at ``x`` (requires a prior fit)."""
|
|
73
|
+
return self.inverse(x)
|
|
74
|
+
|
|
75
|
+
def __repr__(self) -> str:
|
|
76
|
+
return f"FTransform([{self.a}, {self.b}], n_basis={len(self.nodes)})"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = ["FTransform"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Mamdani fuzzy inference.
|
|
2
|
+
|
|
3
|
+
The engine knows nothing about specific membership functions, connectives, or
|
|
4
|
+
defuzzifiers: t-norm, s-norm, implication, aggregation and defuzzification are
|
|
5
|
+
all resolved by name (or supplied as callables) and applied uniformly. Adding a
|
|
6
|
+
behavior means registering a function in :mod:`fuzzytool.norms` or
|
|
7
|
+
:mod:`fuzzytool.defuzz`, never editing this loop.
|
|
8
|
+
|
|
9
|
+
Pipeline per call: evaluate each rule's antecedent to a firing strength →
|
|
10
|
+
shape its consequent term over the output universe via the *implication*
|
|
11
|
+
operator → *aggregate* the shaped sets per output variable → *defuzzify*.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Callable
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from ..defuzz import get_defuzzifier
|
|
21
|
+
from ..norms import get_snorm, get_tnorm
|
|
22
|
+
from ..rules import Rule
|
|
23
|
+
from ..sets import Antecedent, Proposition, Variable
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Mamdani:
|
|
27
|
+
"""A Mamdani inference system.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
tnorm: t-norm for AND in antecedents (default ``"min"``).
|
|
31
|
+
snorm: s-norm for OR in antecedents (default ``"max"``).
|
|
32
|
+
implication: how a firing strength shapes its consequent set —
|
|
33
|
+
``"min"`` (clip) or ``"prod"`` (scale).
|
|
34
|
+
aggregation: s-norm combining shaped sets per output (default ``"max"``).
|
|
35
|
+
defuzz: defuzzification method (default ``"centroid"``).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
tnorm: str | Callable = "min",
|
|
41
|
+
snorm: str | Callable = "max",
|
|
42
|
+
implication: str = "min",
|
|
43
|
+
aggregation: str | Callable = "max",
|
|
44
|
+
defuzz: str | Callable = "centroid",
|
|
45
|
+
) -> None:
|
|
46
|
+
self.tnorm = get_tnorm(tnorm)
|
|
47
|
+
self.snorm = get_snorm(snorm)
|
|
48
|
+
if implication not in ("min", "prod"):
|
|
49
|
+
raise ValueError("implication must be 'min' or 'prod'")
|
|
50
|
+
self.implication = implication
|
|
51
|
+
self.aggregation = get_snorm(aggregation)
|
|
52
|
+
self.defuzz = get_defuzzifier(defuzz)
|
|
53
|
+
self.rules: list[Rule] = []
|
|
54
|
+
self._outputs: dict[str, Variable] = {}
|
|
55
|
+
|
|
56
|
+
def rule(self, antecedent: Antecedent, consequent: Proposition,
|
|
57
|
+
weight: float = 1.0) -> Mamdani:
|
|
58
|
+
"""Add ``IF antecedent THEN output is term`` and return ``self``."""
|
|
59
|
+
if not isinstance(consequent, Proposition):
|
|
60
|
+
raise TypeError("Mamdani consequent must be a `variable[term]` proposition")
|
|
61
|
+
self.rules.append(Rule(antecedent, consequent, weight))
|
|
62
|
+
self._outputs[consequent.variable.name] = consequent.variable
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def __call__(self, **inputs: float):
|
|
66
|
+
"""Run inference. Returns a float for one output, else a dict by name."""
|
|
67
|
+
if not self.rules:
|
|
68
|
+
raise RuntimeError("no rules defined")
|
|
69
|
+
# Aggregated output membership over each output variable's universe.
|
|
70
|
+
agg = {name: np.zeros_like(var.universe)
|
|
71
|
+
for name, var in self._outputs.items()}
|
|
72
|
+
|
|
73
|
+
for r in self.rules:
|
|
74
|
+
firing = float(r.antecedent.eval(inputs, self.tnorm, self.snorm)) * r.weight
|
|
75
|
+
if firing <= 0.0:
|
|
76
|
+
continue
|
|
77
|
+
var = r.consequent.variable
|
|
78
|
+
shape = var.terms[r.consequent.term](var.universe)
|
|
79
|
+
if self.implication == "min":
|
|
80
|
+
shaped = np.minimum(firing, shape)
|
|
81
|
+
else: # prod
|
|
82
|
+
shaped = firing * shape
|
|
83
|
+
agg[var.name] = self.aggregation(agg[var.name], shaped)
|
|
84
|
+
|
|
85
|
+
crisp = {name: self.defuzz(self._outputs[name].universe, y)
|
|
86
|
+
for name, y in agg.items()}
|
|
87
|
+
return next(iter(crisp.values())) if len(crisp) == 1 else crisp
|
|
88
|
+
|
|
89
|
+
def __repr__(self) -> str:
|
|
90
|
+
return f"Mamdani(rules={len(self.rules)}, outputs={sorted(self._outputs)})"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
__all__ = ["Mamdani"]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Takagi-Sugeno-Kang (TSK) fuzzy inference.
|
|
2
|
+
|
|
3
|
+
Consequents are crisp functions of the inputs rather than fuzzy sets, so there
|
|
4
|
+
is no defuzzification: the output is the firing-weighted average of the rule
|
|
5
|
+
consequents. A consequent may be
|
|
6
|
+
|
|
7
|
+
* a number — zero-order (Sugeno constant), e.g. ``5.0``;
|
|
8
|
+
* a mapping ``{"const": b0, "x": b1, ...}`` — first-order linear in the inputs;
|
|
9
|
+
* any callable ``f(**inputs) -> float`` — arbitrary.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from numbers import Real
|
|
16
|
+
from typing import Callable
|
|
17
|
+
|
|
18
|
+
from ..norms import get_snorm, get_tnorm
|
|
19
|
+
from ..rules import Rule
|
|
20
|
+
from ..sets import Antecedent
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _as_consequent_fn(consequent) -> Callable[..., float]:
|
|
24
|
+
if isinstance(consequent, Real):
|
|
25
|
+
return lambda **_: float(consequent)
|
|
26
|
+
if isinstance(consequent, Mapping):
|
|
27
|
+
bias = float(consequent.get("const", 0.0))
|
|
28
|
+
coefs = {k: float(v) for k, v in consequent.items() if k != "const"}
|
|
29
|
+
return lambda **xs: bias + sum(c * xs[k] for k, c in coefs.items())
|
|
30
|
+
if callable(consequent):
|
|
31
|
+
return consequent
|
|
32
|
+
raise TypeError("TSK consequent must be a number, a coefficient mapping, "
|
|
33
|
+
"or a callable")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TSK:
|
|
37
|
+
"""A (zero- or first-order) Takagi-Sugeno inference system.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
tnorm: t-norm for AND in antecedents (default ``"min"``).
|
|
41
|
+
snorm: s-norm for OR in antecedents (default ``"max"``).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, tnorm: str | Callable = "min",
|
|
45
|
+
snorm: str | Callable = "max") -> None:
|
|
46
|
+
self.tnorm = get_tnorm(tnorm)
|
|
47
|
+
self.snorm = get_snorm(snorm)
|
|
48
|
+
self.rules: list[Rule] = []
|
|
49
|
+
self._fns: list[Callable[..., float]] = []
|
|
50
|
+
|
|
51
|
+
def rule(self, antecedent: Antecedent, consequent, weight: float = 1.0) -> TSK:
|
|
52
|
+
"""Add ``IF antecedent THEN output = consequent`` and return ``self``."""
|
|
53
|
+
self.rules.append(Rule(antecedent, consequent, weight))
|
|
54
|
+
self._fns.append(_as_consequent_fn(consequent))
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def __call__(self, **inputs: float) -> float:
|
|
58
|
+
"""Run inference, returning the firing-weighted average output."""
|
|
59
|
+
if not self.rules:
|
|
60
|
+
raise RuntimeError("no rules defined")
|
|
61
|
+
num = 0.0
|
|
62
|
+
den = 0.0
|
|
63
|
+
for r, fn in zip(self.rules, self._fns):
|
|
64
|
+
w = float(r.antecedent.eval(inputs, self.tnorm, self.snorm)) * r.weight
|
|
65
|
+
if w <= 0.0:
|
|
66
|
+
continue
|
|
67
|
+
num += w * fn(**inputs)
|
|
68
|
+
den += w
|
|
69
|
+
if den == 0.0:
|
|
70
|
+
raise ValueError("no rule fired for the given inputs")
|
|
71
|
+
return num / den
|
|
72
|
+
|
|
73
|
+
def __repr__(self) -> str:
|
|
74
|
+
return f"TSK(rules={len(self.rules)})"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = ["TSK"]
|
fuzzytool/membership.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Membership functions.
|
|
2
|
+
|
|
3
|
+
Every membership function (MF) is a *callable* mapping a crisp value (scalar or
|
|
4
|
+
NumPy array) to a membership degree in ``[0, 1]``. The :class:`MembershipFunction`
|
|
5
|
+
Protocol is the only contract the rest of the library relies on: the inference
|
|
6
|
+
engine never inspects a concrete MF type, so a new shape = a new callable, with
|
|
7
|
+
no changes to the core (mirroring the "one variant = one impl" design of the
|
|
8
|
+
sibling project ``turboswarm``).
|
|
9
|
+
|
|
10
|
+
The lowercase factory functions (:func:`tri`, :func:`trap`, :func:`gauss`,
|
|
11
|
+
:func:`gbell`, :func:`sigmoid`) are the public API; they return small classes so
|
|
12
|
+
the parameters stay introspectable for visualization and serialization.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Protocol, runtime_checkable
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@runtime_checkable
|
|
23
|
+
class MembershipFunction(Protocol):
|
|
24
|
+
"""A callable ``x -> degree`` with membership degrees in ``[0, 1]``."""
|
|
25
|
+
|
|
26
|
+
def __call__(self, x: np.ndarray) -> np.ndarray: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Triangular:
|
|
30
|
+
"""Triangular MF with feet at ``a``/``c`` and peak at ``b``."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, a: float, b: float, c: float) -> None:
|
|
33
|
+
if not a <= b <= c:
|
|
34
|
+
raise ValueError(f"triangular requires a <= b <= c, got {(a, b, c)}")
|
|
35
|
+
self.a, self.b, self.c = float(a), float(b), float(c)
|
|
36
|
+
|
|
37
|
+
def __call__(self, x):
|
|
38
|
+
x = np.asarray(x, dtype=float)
|
|
39
|
+
left = np.divide(x - self.a, self.b - self.a, out=np.zeros_like(x),
|
|
40
|
+
where=self.b != self.a)
|
|
41
|
+
right = np.divide(self.c - x, self.c - self.b, out=np.zeros_like(x),
|
|
42
|
+
where=self.c != self.b)
|
|
43
|
+
# Degenerate edges: a flat shoulder where two points coincide.
|
|
44
|
+
left = np.where(self.b == self.a, (x >= self.a).astype(float), left)
|
|
45
|
+
right = np.where(self.c == self.b, (x <= self.c).astype(float), right)
|
|
46
|
+
return np.clip(np.minimum(left, right), 0.0, 1.0)
|
|
47
|
+
|
|
48
|
+
def __repr__(self) -> str:
|
|
49
|
+
return f"tri({self.a}, {self.b}, {self.c})"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Trapezoidal:
|
|
53
|
+
"""Trapezoidal MF; flat top between ``b`` and ``c``."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, a: float, b: float, c: float, d: float) -> None:
|
|
56
|
+
if not a <= b <= c <= d:
|
|
57
|
+
raise ValueError(f"trapezoid requires a <= b <= c <= d, got {(a, b, c, d)}")
|
|
58
|
+
self.a, self.b, self.c, self.d = map(float, (a, b, c, d))
|
|
59
|
+
|
|
60
|
+
def __call__(self, x):
|
|
61
|
+
x = np.asarray(x, dtype=float)
|
|
62
|
+
left = np.divide(x - self.a, self.b - self.a, out=np.ones_like(x),
|
|
63
|
+
where=self.b != self.a)
|
|
64
|
+
right = np.divide(self.d - x, self.d - self.c, out=np.ones_like(x),
|
|
65
|
+
where=self.d != self.c)
|
|
66
|
+
return np.clip(np.minimum.reduce([left, np.ones_like(x), right]), 0.0, 1.0)
|
|
67
|
+
|
|
68
|
+
def __repr__(self) -> str:
|
|
69
|
+
return f"trap({self.a}, {self.b}, {self.c}, {self.d})"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Gaussian:
|
|
73
|
+
"""Gaussian MF centered at ``c`` with spread ``sigma``."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, c: float, sigma: float) -> None:
|
|
76
|
+
if sigma <= 0:
|
|
77
|
+
raise ValueError(f"gauss requires sigma > 0, got {sigma}")
|
|
78
|
+
self.c, self.sigma = float(c), float(sigma)
|
|
79
|
+
|
|
80
|
+
def __call__(self, x):
|
|
81
|
+
x = np.asarray(x, dtype=float)
|
|
82
|
+
return np.exp(-0.5 * ((x - self.c) / self.sigma) ** 2)
|
|
83
|
+
|
|
84
|
+
def __repr__(self) -> str:
|
|
85
|
+
return f"gauss({self.c}, {self.sigma})"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class GeneralizedBell:
|
|
89
|
+
"""Generalized bell MF: ``1 / (1 + |(x - c) / a|^(2b))``."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, a: float, b: float, c: float) -> None:
|
|
92
|
+
if a == 0:
|
|
93
|
+
raise ValueError("gbell requires a != 0")
|
|
94
|
+
self.a, self.b, self.c = float(a), float(b), float(c)
|
|
95
|
+
|
|
96
|
+
def __call__(self, x):
|
|
97
|
+
x = np.asarray(x, dtype=float)
|
|
98
|
+
return 1.0 / (1.0 + np.abs((x - self.c) / self.a) ** (2.0 * self.b))
|
|
99
|
+
|
|
100
|
+
def __repr__(self) -> str:
|
|
101
|
+
return f"gbell({self.a}, {self.b}, {self.c})"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Sigmoid:
|
|
105
|
+
"""Sigmoidal MF: ``1 / (1 + exp(-a (x - c)))``."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, a: float, c: float) -> None:
|
|
108
|
+
self.a, self.c = float(a), float(c)
|
|
109
|
+
|
|
110
|
+
def __call__(self, x):
|
|
111
|
+
x = np.asarray(x, dtype=float)
|
|
112
|
+
return 1.0 / (1.0 + np.exp(-self.a * (x - self.c)))
|
|
113
|
+
|
|
114
|
+
def __repr__(self) -> str:
|
|
115
|
+
return f"sigmoid({self.a}, {self.c})"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --- factory shortcuts (public API) ---------------------------------------
|
|
119
|
+
|
|
120
|
+
def tri(a: float, b: float, c: float) -> Triangular:
|
|
121
|
+
"""Triangular MF: feet at ``a``/``c``, peak at ``b``."""
|
|
122
|
+
return Triangular(a, b, c)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def trap(a: float, b: float, c: float, d: float) -> Trapezoidal:
|
|
126
|
+
"""Trapezoidal MF: shoulders ``a``/``d``, flat top ``b``..``c``."""
|
|
127
|
+
return Trapezoidal(a, b, c, d)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def gauss(c: float, sigma: float) -> Gaussian:
|
|
131
|
+
"""Gaussian MF: center ``c``, spread ``sigma``."""
|
|
132
|
+
return Gaussian(c, sigma)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def gbell(a: float, b: float, c: float) -> GeneralizedBell:
|
|
136
|
+
"""Generalized bell MF: width ``a``, slope ``b``, center ``c``."""
|
|
137
|
+
return GeneralizedBell(a, b, c)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def sigmoid(a: float, c: float) -> Sigmoid:
|
|
141
|
+
"""Sigmoidal MF: slope ``a``, inflection ``c``."""
|
|
142
|
+
return Sigmoid(a, c)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
__all__ = [
|
|
146
|
+
"MembershipFunction",
|
|
147
|
+
"Triangular", "Trapezoidal", "Gaussian", "GeneralizedBell", "Sigmoid",
|
|
148
|
+
"tri", "trap", "gauss", "gbell", "sigmoid",
|
|
149
|
+
]
|
fuzzytool/norms.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Fuzzy connectives: t-norms (AND) and s-norms / t-conorms (OR).
|
|
2
|
+
|
|
3
|
+
Connectives are plain vectorized callables ``(a, b) -> result`` operating
|
|
4
|
+
elementwise on membership degrees. They are pluggable: the inference engines
|
|
5
|
+
look them up by name through :func:`get_tnorm` / :func:`get_snorm`, so adding a
|
|
6
|
+
connective means registering one function — the engine never changes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Callable, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
Degree = np.ndarray
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class Norm(Protocol):
|
|
20
|
+
"""A binary connective on membership degrees."""
|
|
21
|
+
|
|
22
|
+
def __call__(self, a: Degree, b: Degree) -> Degree: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# --- t-norms (fuzzy AND) ---------------------------------------------------
|
|
26
|
+
|
|
27
|
+
def t_min(a, b):
|
|
28
|
+
"""Minimum (Gödel) t-norm."""
|
|
29
|
+
return np.minimum(a, b)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def t_prod(a, b):
|
|
33
|
+
"""Product (algebraic) t-norm."""
|
|
34
|
+
return np.asarray(a) * np.asarray(b)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def t_lukasiewicz(a, b):
|
|
38
|
+
"""Łukasiewicz t-norm: ``max(0, a + b - 1)``."""
|
|
39
|
+
return np.maximum(0.0, np.asarray(a) + np.asarray(b) - 1.0)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --- s-norms / t-conorms (fuzzy OR) ---------------------------------------
|
|
43
|
+
|
|
44
|
+
def s_max(a, b):
|
|
45
|
+
"""Maximum (Gödel) s-norm."""
|
|
46
|
+
return np.maximum(a, b)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def s_probor(a, b):
|
|
50
|
+
"""Probabilistic OR (algebraic sum): ``a + b - a*b``."""
|
|
51
|
+
a, b = np.asarray(a), np.asarray(b)
|
|
52
|
+
return a + b - a * b
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def s_lukasiewicz(a, b):
|
|
56
|
+
"""Łukasiewicz s-norm: ``min(1, a + b)``."""
|
|
57
|
+
return np.minimum(1.0, np.asarray(a) + np.asarray(b))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_TNORMS: dict[str, Callable] = {
|
|
61
|
+
"min": t_min,
|
|
62
|
+
"prod": t_prod,
|
|
63
|
+
"product": t_prod,
|
|
64
|
+
"lukasiewicz": t_lukasiewicz,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_SNORMS: dict[str, Callable] = {
|
|
68
|
+
"max": s_max,
|
|
69
|
+
"probor": s_probor,
|
|
70
|
+
"sum": s_probor,
|
|
71
|
+
"lukasiewicz": s_lukasiewicz,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_tnorm(name: str | Callable) -> Callable:
|
|
76
|
+
"""Resolve a t-norm by name (or pass a callable through unchanged)."""
|
|
77
|
+
if callable(name):
|
|
78
|
+
return name
|
|
79
|
+
try:
|
|
80
|
+
return _TNORMS[name]
|
|
81
|
+
except KeyError:
|
|
82
|
+
raise ValueError(f"unknown t-norm {name!r}; options: {sorted(_TNORMS)}") from None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_snorm(name: str | Callable) -> Callable:
|
|
86
|
+
"""Resolve an s-norm by name (or pass a callable through unchanged)."""
|
|
87
|
+
if callable(name):
|
|
88
|
+
return name
|
|
89
|
+
try:
|
|
90
|
+
return _SNORMS[name]
|
|
91
|
+
except KeyError:
|
|
92
|
+
raise ValueError(f"unknown s-norm {name!r}; options: {sorted(_SNORMS)}") from None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def complement(a):
|
|
96
|
+
"""Standard fuzzy complement (NOT): ``1 - a``."""
|
|
97
|
+
return 1.0 - np.asarray(a)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"Norm",
|
|
102
|
+
"t_min", "t_prod", "t_lukasiewicz",
|
|
103
|
+
"s_max", "s_probor", "s_lukasiewicz",
|
|
104
|
+
"get_tnorm", "get_snorm", "complement",
|
|
105
|
+
]
|
fuzzytool/rules.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Fuzzy rules shared by the inference engines.
|
|
2
|
+
|
|
3
|
+
A :class:`Rule` pairs an antecedent expression with a consequent and an optional
|
|
4
|
+
weight. The *consequent* is interpreted by the engine: Mamdani expects a
|
|
5
|
+
:class:`~fuzzytool.sets.Proposition` (``output is term``); TSK expects a
|
|
6
|
+
constant or a linear coefficient vector.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .sets import Antecedent
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Rule:
|
|
19
|
+
"""``IF antecedent THEN consequent`` with an optional firing ``weight``."""
|
|
20
|
+
|
|
21
|
+
antecedent: Antecedent
|
|
22
|
+
consequent: Any
|
|
23
|
+
weight: float = 1.0
|
|
24
|
+
|
|
25
|
+
def __post_init__(self) -> None:
|
|
26
|
+
if not 0.0 <= self.weight <= 1.0:
|
|
27
|
+
raise ValueError(f"rule weight must be in [0, 1], got {self.weight}")
|
|
28
|
+
|
|
29
|
+
def __repr__(self) -> str:
|
|
30
|
+
w = "" if self.weight == 1.0 else f" (w={self.weight})"
|
|
31
|
+
return f"IF {self.antecedent} THEN {self.consequent}{w}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = ["Rule"]
|