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/sets.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Fuzzy sets, linguistic variables, and the rule-antecedent expression tree.
|
|
2
|
+
|
|
3
|
+
A :class:`Variable` is a linguistic variable: a named universe of discourse plus
|
|
4
|
+
a dictionary of *terms* (named membership functions). Indexing a variable with a
|
|
5
|
+
term name (``score["good"]``) returns a :class:`Proposition`, the atom of a
|
|
6
|
+
rule antecedent. Propositions compose with Python operators:
|
|
7
|
+
|
|
8
|
+
score["poor"] | dti["high"] # OR (s-norm)
|
|
9
|
+
score["good"] & dti["low"] # AND (t-norm)
|
|
10
|
+
~dti["high"] # NOT (complement)
|
|
11
|
+
|
|
12
|
+
The result is a small expression tree that the inference engine evaluates
|
|
13
|
+
against crisp inputs to get a firing strength. The same atom doubles as a rule
|
|
14
|
+
*consequent* (``premium["high"]``), so there is a single concept to learn.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Iterable, Mapping
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from . import membership as mf
|
|
24
|
+
from .norms import complement
|
|
25
|
+
|
|
26
|
+
_KINDS = {"triangular": "tri", "tri": "tri", "gauss": "gauss", "gaussian": "gauss"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_it2(term) -> bool:
|
|
30
|
+
"""An IT2 term exposes ``.lower`` and ``.upper`` (duck-typed to avoid an import)."""
|
|
31
|
+
return hasattr(term, "lower") and hasattr(term, "upper")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Antecedent:
|
|
35
|
+
"""Base node of a rule-antecedent expression tree."""
|
|
36
|
+
|
|
37
|
+
def __and__(self, other: Antecedent) -> And:
|
|
38
|
+
return And(self, other)
|
|
39
|
+
|
|
40
|
+
def __or__(self, other: Antecedent) -> Or:
|
|
41
|
+
return Or(self, other)
|
|
42
|
+
|
|
43
|
+
def __invert__(self) -> Not:
|
|
44
|
+
return Not(self)
|
|
45
|
+
|
|
46
|
+
def eval(self, inputs: Mapping[str, float], tnorm, snorm) -> np.ndarray:
|
|
47
|
+
"""Return the (type-1) firing strength given crisp ``inputs``."""
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
|
|
50
|
+
def eval_interval(self, inputs: Mapping[str, float], tnorm, snorm):
|
|
51
|
+
"""Return the firing *interval* ``(lower, upper)`` for IT2 inference.
|
|
52
|
+
|
|
53
|
+
Type-1 terms collapse to a degenerate interval ``(d, d)``, so type-1 and
|
|
54
|
+
IT2 terms may be mixed freely in the same antecedent.
|
|
55
|
+
"""
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Proposition(Antecedent):
|
|
60
|
+
"""An atomic ``variable is term`` clause; also used as a consequent."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, variable: Variable, term: str) -> None:
|
|
63
|
+
if term not in variable.terms:
|
|
64
|
+
raise KeyError(
|
|
65
|
+
f"variable {variable.name!r} has no term {term!r}; "
|
|
66
|
+
f"known terms: {sorted(variable.terms)}"
|
|
67
|
+
)
|
|
68
|
+
self.variable = variable
|
|
69
|
+
self.term = term
|
|
70
|
+
|
|
71
|
+
def eval(self, inputs, tnorm, snorm) -> np.ndarray:
|
|
72
|
+
try:
|
|
73
|
+
x = inputs[self.variable.name]
|
|
74
|
+
except KeyError:
|
|
75
|
+
raise KeyError(f"missing input for variable {self.variable.name!r}") from None
|
|
76
|
+
term = self.variable.terms[self.term]
|
|
77
|
+
if _is_it2(term): # collapse an IT2 term to its mean membership
|
|
78
|
+
lo, hi = term.lower(x), term.upper(x)
|
|
79
|
+
return np.asarray((lo + hi) / 2.0, dtype=float)
|
|
80
|
+
return np.asarray(term(x), dtype=float)
|
|
81
|
+
|
|
82
|
+
def eval_interval(self, inputs, tnorm, snorm):
|
|
83
|
+
try:
|
|
84
|
+
x = inputs[self.variable.name]
|
|
85
|
+
except KeyError:
|
|
86
|
+
raise KeyError(f"missing input for variable {self.variable.name!r}") from None
|
|
87
|
+
term = self.variable.terms[self.term]
|
|
88
|
+
if _is_it2(term):
|
|
89
|
+
return float(term.lower(x)), float(term.upper(x))
|
|
90
|
+
d = float(np.asarray(term(x), dtype=float))
|
|
91
|
+
return d, d
|
|
92
|
+
|
|
93
|
+
def __repr__(self) -> str:
|
|
94
|
+
return f"{self.variable.name} is {self.term}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class And(Antecedent):
|
|
98
|
+
def __init__(self, left: Antecedent, right: Antecedent) -> None:
|
|
99
|
+
self.left, self.right = left, right
|
|
100
|
+
|
|
101
|
+
def eval(self, inputs, tnorm, snorm):
|
|
102
|
+
return tnorm(self.left.eval(inputs, tnorm, snorm),
|
|
103
|
+
self.right.eval(inputs, tnorm, snorm))
|
|
104
|
+
|
|
105
|
+
def eval_interval(self, inputs, tnorm, snorm):
|
|
106
|
+
ll, lu = self.left.eval_interval(inputs, tnorm, snorm)
|
|
107
|
+
rl, ru = self.right.eval_interval(inputs, tnorm, snorm)
|
|
108
|
+
return tnorm(ll, rl), tnorm(lu, ru)
|
|
109
|
+
|
|
110
|
+
def __repr__(self) -> str:
|
|
111
|
+
return f"({self.left} and {self.right})"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Or(Antecedent):
|
|
115
|
+
def __init__(self, left: Antecedent, right: Antecedent) -> None:
|
|
116
|
+
self.left, self.right = left, right
|
|
117
|
+
|
|
118
|
+
def eval(self, inputs, tnorm, snorm):
|
|
119
|
+
return snorm(self.left.eval(inputs, tnorm, snorm),
|
|
120
|
+
self.right.eval(inputs, tnorm, snorm))
|
|
121
|
+
|
|
122
|
+
def eval_interval(self, inputs, tnorm, snorm):
|
|
123
|
+
ll, lu = self.left.eval_interval(inputs, tnorm, snorm)
|
|
124
|
+
rl, ru = self.right.eval_interval(inputs, tnorm, snorm)
|
|
125
|
+
return snorm(ll, rl), snorm(lu, ru)
|
|
126
|
+
|
|
127
|
+
def __repr__(self) -> str:
|
|
128
|
+
return f"({self.left} or {self.right})"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Not(Antecedent):
|
|
132
|
+
def __init__(self, operand: Antecedent) -> None:
|
|
133
|
+
self.operand = operand
|
|
134
|
+
|
|
135
|
+
def eval(self, inputs, tnorm, snorm):
|
|
136
|
+
return complement(self.operand.eval(inputs, tnorm, snorm))
|
|
137
|
+
|
|
138
|
+
def eval_interval(self, inputs, tnorm, snorm):
|
|
139
|
+
# The complement is order-reversing, so the bounds swap.
|
|
140
|
+
lo, hi = self.operand.eval_interval(inputs, tnorm, snorm)
|
|
141
|
+
return complement(hi), complement(lo)
|
|
142
|
+
|
|
143
|
+
def __repr__(self) -> str:
|
|
144
|
+
return f"(not {self.operand})"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Variable:
|
|
148
|
+
"""A linguistic variable: a named universe with named fuzzy-set terms.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
name: identifier; rule inputs are matched to variables by this name.
|
|
152
|
+
universe: ``(low, high)`` range of the universe of discourse.
|
|
153
|
+
terms: optional list of term names to auto-generate evenly across the
|
|
154
|
+
universe, or a mapping ``{name: MembershipFunction}``.
|
|
155
|
+
kind: shape used by auto-generation (``"triangular"`` or ``"gauss"``).
|
|
156
|
+
resolution: number of samples used to discretize the universe for
|
|
157
|
+
Mamdani defuzzification.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
name: str,
|
|
163
|
+
universe: tuple[float, float],
|
|
164
|
+
terms: Iterable[str] | Mapping[str, mf.MembershipFunction] | None = None,
|
|
165
|
+
kind: str = "triangular",
|
|
166
|
+
resolution: int = 501,
|
|
167
|
+
) -> None:
|
|
168
|
+
self.name = name
|
|
169
|
+
self.low, self.high = float(universe[0]), float(universe[1])
|
|
170
|
+
if self.high <= self.low:
|
|
171
|
+
raise ValueError(f"universe must have low < high, got {universe}")
|
|
172
|
+
self.terms: dict[str, mf.MembershipFunction] = {}
|
|
173
|
+
self.universe = np.linspace(self.low, self.high, resolution)
|
|
174
|
+
if isinstance(terms, Mapping):
|
|
175
|
+
for n, m in terms.items():
|
|
176
|
+
self[n] = m
|
|
177
|
+
elif terms is not None:
|
|
178
|
+
self.auto_terms(list(terms), kind=kind)
|
|
179
|
+
|
|
180
|
+
def __getitem__(self, term: str) -> Proposition:
|
|
181
|
+
return Proposition(self, term)
|
|
182
|
+
|
|
183
|
+
def __setitem__(self, term: str, membership: mf.MembershipFunction) -> None:
|
|
184
|
+
if not callable(membership):
|
|
185
|
+
raise TypeError("a term must be a callable membership function")
|
|
186
|
+
self.terms[term] = membership
|
|
187
|
+
|
|
188
|
+
def auto_terms(self, names: list[str], kind: str = "triangular") -> Variable:
|
|
189
|
+
"""Generate evenly-spaced terms named ``names`` across the universe."""
|
|
190
|
+
kind = _KINDS.get(kind)
|
|
191
|
+
if kind is None:
|
|
192
|
+
raise ValueError("kind must be 'triangular' or 'gauss'")
|
|
193
|
+
n = len(names)
|
|
194
|
+
if n < 2:
|
|
195
|
+
raise ValueError("need at least two terms")
|
|
196
|
+
centers = np.linspace(self.low, self.high, n)
|
|
197
|
+
step = (self.high - self.low) / (n - 1)
|
|
198
|
+
for name, c in zip(names, centers):
|
|
199
|
+
if kind == "tri":
|
|
200
|
+
self[name] = mf.tri(c - step, c, c + step)
|
|
201
|
+
else: # gauss
|
|
202
|
+
self[name] = mf.gauss(c, step / 2.0)
|
|
203
|
+
return self
|
|
204
|
+
|
|
205
|
+
def __repr__(self) -> str:
|
|
206
|
+
return (f"Variable({self.name!r}, ({self.low}, {self.high}), "
|
|
207
|
+
f"terms={sorted(self.terms)})")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
__all__ = ["Variable", "Proposition", "Antecedent", "And", "Or", "Not"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Interval type-2 (IT2) fuzzy sets, inference, and type reduction.
|
|
2
|
+
|
|
3
|
+
An IT2 set is bounded by two type-1 membership functions — an upper (UMF) and a
|
|
4
|
+
lower (LMF) — whose gap is the *footprint of uncertainty* (FOU). Membership at a
|
|
5
|
+
point is therefore an interval ``[LMF(x), UMF(x)]`` rather than a single number.
|
|
6
|
+
|
|
7
|
+
IT2 rules reuse the very same operator syntax as type-1 rules (``|``, ``&``,
|
|
8
|
+
``~``); the engines here propagate membership *intervals* through the antecedent
|
|
9
|
+
tree and collapse the result with Karnik-Mendel type reduction.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .inference import IT2TSK, IT2Mamdani
|
|
13
|
+
from .reduction import centroid_it2, karnik_mendel, km_endpoint
|
|
14
|
+
from .sets import (
|
|
15
|
+
IntervalType2MF,
|
|
16
|
+
it2,
|
|
17
|
+
it2_gauss_uncertain_mean,
|
|
18
|
+
it2_gauss_uncertain_std,
|
|
19
|
+
it2_scale,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"IntervalType2MF",
|
|
24
|
+
"it2", "it2_scale", "it2_gauss_uncertain_mean", "it2_gauss_uncertain_std",
|
|
25
|
+
"IT2Mamdani", "IT2TSK",
|
|
26
|
+
"karnik_mendel", "km_endpoint", "centroid_it2",
|
|
27
|
+
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Interval type-2 inference engines.
|
|
2
|
+
|
|
3
|
+
Both engines evaluate each rule's antecedent to a firing *interval*
|
|
4
|
+
``[f_low, f_high]`` (via :meth:`Antecedent.eval_interval`) and then collapse the
|
|
5
|
+
rule base with Karnik-Mendel type reduction, returning the midpoint of the
|
|
6
|
+
type-reduced interval ``[y_l, y_r]``.
|
|
7
|
+
|
|
8
|
+
* :class:`IT2Mamdani` uses **center-of-sets** type reduction: each consequent
|
|
9
|
+
IT2 set is summarized by its centroid interval (computed once and cached), and
|
|
10
|
+
KM combines those centroids weighted by the firing intervals.
|
|
11
|
+
* :class:`IT2TSK` has crisp consequents (numbers, coefficient mappings, or
|
|
12
|
+
callables) and applies KM directly to them.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Callable
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from ..inference.tsk import _as_consequent_fn
|
|
22
|
+
from ..norms import get_snorm, get_tnorm
|
|
23
|
+
from ..rules import Rule
|
|
24
|
+
from ..sets import Antecedent, Proposition, Variable
|
|
25
|
+
from .reduction import centroid_it2, karnik_mendel, km_endpoint
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class IT2Mamdani:
|
|
29
|
+
"""Interval type-2 Mamdani inference (center-of-sets type reduction).
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
tnorm: t-norm for AND in antecedents (default ``"min"``).
|
|
33
|
+
snorm: s-norm for OR in antecedents (default ``"max"``).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, tnorm: str | Callable = "min",
|
|
37
|
+
snorm: str | Callable = "max") -> None:
|
|
38
|
+
self.tnorm = get_tnorm(tnorm)
|
|
39
|
+
self.snorm = get_snorm(snorm)
|
|
40
|
+
self.rules: list[Rule] = []
|
|
41
|
+
self._outputs: dict[str, Variable] = {}
|
|
42
|
+
self._centroids: dict[tuple[int, str], tuple[float, float]] = {}
|
|
43
|
+
|
|
44
|
+
def rule(self, antecedent: Antecedent, consequent: Proposition,
|
|
45
|
+
weight: float = 1.0) -> IT2Mamdani:
|
|
46
|
+
"""Add ``IF antecedent THEN output is term`` and return ``self``."""
|
|
47
|
+
if not isinstance(consequent, Proposition):
|
|
48
|
+
raise TypeError("IT2Mamdani consequent must be a `variable[term]` proposition")
|
|
49
|
+
self.rules.append(Rule(antecedent, consequent, weight))
|
|
50
|
+
self._outputs[consequent.variable.name] = consequent.variable
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def _centroid(self, var: Variable, term: str) -> tuple[float, float]:
|
|
54
|
+
key = (id(var), term)
|
|
55
|
+
if key not in self._centroids:
|
|
56
|
+
self._centroids[key] = centroid_it2(var.terms[term], var.universe)
|
|
57
|
+
return self._centroids[key]
|
|
58
|
+
|
|
59
|
+
def __call__(self, **inputs: float):
|
|
60
|
+
"""Run inference. Returns a float for one output, else a dict by name."""
|
|
61
|
+
if not self.rules:
|
|
62
|
+
raise RuntimeError("no rules defined")
|
|
63
|
+
out: dict[str, float] = {}
|
|
64
|
+
for name, var in self._outputs.items():
|
|
65
|
+
cl, cr, f_low, f_high = [], [], [], []
|
|
66
|
+
for r in self.rules:
|
|
67
|
+
if r.consequent.variable.name != name:
|
|
68
|
+
continue
|
|
69
|
+
lo, hi = r.antecedent.eval_interval(inputs, self.tnorm, self.snorm)
|
|
70
|
+
c_l, c_r = self._centroid(var, r.consequent.term)
|
|
71
|
+
cl.append(c_l)
|
|
72
|
+
cr.append(c_r)
|
|
73
|
+
f_low.append(float(lo) * r.weight)
|
|
74
|
+
f_high.append(float(hi) * r.weight)
|
|
75
|
+
y_l = km_endpoint(np.array(cl), np.array(f_low), np.array(f_high), side="l")
|
|
76
|
+
y_r = km_endpoint(np.array(cr), np.array(f_low), np.array(f_high), side="r")
|
|
77
|
+
out[name] = (y_l + y_r) / 2.0
|
|
78
|
+
return next(iter(out.values())) if len(out) == 1 else out
|
|
79
|
+
|
|
80
|
+
def __repr__(self) -> str:
|
|
81
|
+
return f"IT2Mamdani(rules={len(self.rules)}, outputs={sorted(self._outputs)})"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class IT2TSK:
|
|
85
|
+
"""Interval type-2 Takagi-Sugeno inference (KM over crisp consequents).
|
|
86
|
+
|
|
87
|
+
Consequents follow the same convention as the type-1 :class:`~fuzzytool.inference.tsk.TSK`
|
|
88
|
+
engine: a number, a coefficient mapping ``{"const": b0, "x": b1, ...}``, or a
|
|
89
|
+
callable ``f(**inputs) -> float``.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, tnorm: str | Callable = "min",
|
|
93
|
+
snorm: str | Callable = "max") -> None:
|
|
94
|
+
self.tnorm = get_tnorm(tnorm)
|
|
95
|
+
self.snorm = get_snorm(snorm)
|
|
96
|
+
self.rules: list[Rule] = []
|
|
97
|
+
self._fns: list[Callable[..., float]] = []
|
|
98
|
+
|
|
99
|
+
def rule(self, antecedent: Antecedent, consequent, weight: float = 1.0) -> IT2TSK:
|
|
100
|
+
"""Add ``IF antecedent THEN output = consequent`` and return ``self``."""
|
|
101
|
+
self.rules.append(Rule(antecedent, consequent, weight))
|
|
102
|
+
self._fns.append(_as_consequent_fn(consequent))
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
def __call__(self, **inputs: float) -> float:
|
|
106
|
+
"""Run inference, returning the midpoint of the type-reduced interval."""
|
|
107
|
+
if not self.rules:
|
|
108
|
+
raise RuntimeError("no rules defined")
|
|
109
|
+
pts, f_low, f_high = [], [], []
|
|
110
|
+
for r, fn in zip(self.rules, self._fns):
|
|
111
|
+
lo, hi = r.antecedent.eval_interval(inputs, self.tnorm, self.snorm)
|
|
112
|
+
pts.append(fn(**inputs))
|
|
113
|
+
f_low.append(float(lo) * r.weight)
|
|
114
|
+
f_high.append(float(hi) * r.weight)
|
|
115
|
+
y_l, y_r = karnik_mendel(pts, f_low, f_high)
|
|
116
|
+
return (y_l + y_r) / 2.0
|
|
117
|
+
|
|
118
|
+
def __repr__(self) -> str:
|
|
119
|
+
return f"IT2TSK(rules={len(self.rules)})"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
__all__ = ["IT2Mamdani", "IT2TSK"]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Karnik-Mendel type reduction.
|
|
2
|
+
|
|
3
|
+
Type reduction turns the interval-valued output of an IT2 system into a crisp
|
|
4
|
+
interval ``[y_l, y_r]`` (typically defuzzified as its midpoint). The
|
|
5
|
+
Karnik-Mendel (KM) algorithm finds each endpoint by locating the *switch point*
|
|
6
|
+
where the per-point weight flips between its lower and upper bound.
|
|
7
|
+
|
|
8
|
+
The single primitive :func:`km_endpoint` is reused everywhere: for an IT2 set's
|
|
9
|
+
centroid (points = universe samples, weights = the FOU) and for an IT2 rule base
|
|
10
|
+
(points = consequent centroids, weights = rule firing intervals).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _weighted_mean(points: np.ndarray, w: np.ndarray) -> float:
|
|
19
|
+
total = w.sum()
|
|
20
|
+
if total == 0:
|
|
21
|
+
return float(points.mean()) # nothing fired: neutral fallback
|
|
22
|
+
return float((w * points).sum() / total)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def km_endpoint(points: np.ndarray, lower: np.ndarray, upper: np.ndarray,
|
|
26
|
+
side: str = "l", max_iter: int = 100) -> float:
|
|
27
|
+
"""One endpoint of the type-reduced interval via Karnik-Mendel.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
points: the y-values (e.g. consequent centroids or universe samples).
|
|
31
|
+
lower: per-point weight lower bounds (same shape as ``points``).
|
|
32
|
+
upper: per-point weight upper bounds (same shape as ``points``).
|
|
33
|
+
side: ``"l"`` for the left endpoint ``y_l``, ``"r"`` for the right ``y_r``.
|
|
34
|
+
max_iter: safeguard on the Karnik-Mendel iteration count.
|
|
35
|
+
|
|
36
|
+
Left endpoint uses the *upper* weights to the left of the switch and the
|
|
37
|
+
*lower* weights to its right; the right endpoint mirrors this.
|
|
38
|
+
"""
|
|
39
|
+
points = np.asarray(points, dtype=float)
|
|
40
|
+
lower = np.asarray(lower, dtype=float)
|
|
41
|
+
upper = np.asarray(upper, dtype=float)
|
|
42
|
+
if points.size == 0:
|
|
43
|
+
raise ValueError("no points to type-reduce")
|
|
44
|
+
if side not in ("l", "r"):
|
|
45
|
+
raise ValueError("side must be 'l' or 'r'")
|
|
46
|
+
|
|
47
|
+
order = np.argsort(points)
|
|
48
|
+
p, lo, up = points[order], lower[order], upper[order]
|
|
49
|
+
n = p.size
|
|
50
|
+
if n == 1:
|
|
51
|
+
return float(p[0])
|
|
52
|
+
|
|
53
|
+
idx = np.arange(n)
|
|
54
|
+
w = (lo + up) / 2.0
|
|
55
|
+
y = _weighted_mean(p, w)
|
|
56
|
+
for _ in range(max_iter):
|
|
57
|
+
k = int(np.searchsorted(p, y, side="right")) - 1
|
|
58
|
+
k = min(max(k, 0), n - 2)
|
|
59
|
+
if side == "l":
|
|
60
|
+
w = np.where(idx <= k, up, lo)
|
|
61
|
+
else:
|
|
62
|
+
w = np.where(idx <= k, lo, up)
|
|
63
|
+
y_new = _weighted_mean(p, w)
|
|
64
|
+
if np.isclose(y_new, y):
|
|
65
|
+
return y_new
|
|
66
|
+
y = y_new
|
|
67
|
+
return y
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def karnik_mendel(points, lower, upper) -> tuple[float, float]:
|
|
71
|
+
"""Type-reduce to the interval ``(y_l, y_r)`` over a shared set of points."""
|
|
72
|
+
y_l = km_endpoint(points, lower, upper, side="l")
|
|
73
|
+
y_r = km_endpoint(points, lower, upper, side="r")
|
|
74
|
+
return y_l, y_r
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def centroid_it2(it2mf, universe) -> tuple[float, float]:
|
|
78
|
+
"""Centroid interval ``(c_l, c_r)`` of an IT2 set over ``universe``."""
|
|
79
|
+
x = np.asarray(universe, dtype=float)
|
|
80
|
+
return karnik_mendel(x, it2mf.lower(x), it2mf.upper(x))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
__all__ = ["km_endpoint", "karnik_mendel", "centroid_it2"]
|
fuzzytool/type2/sets.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Interval type-2 (IT2) membership functions.
|
|
2
|
+
|
|
3
|
+
An :class:`IntervalType2MF` carries a lower (LMF) and an upper (UMF) type-1
|
|
4
|
+
membership function. Calling it returns the membership *interval*
|
|
5
|
+
``(lower, upper)``; the engines also call ``.lower(x)`` / ``.upper(x)`` directly.
|
|
6
|
+
|
|
7
|
+
The constructors cover the standard ways of building an FOU from a type-1 set:
|
|
8
|
+
|
|
9
|
+
* :func:`it2` — explicit LMF/UMF;
|
|
10
|
+
* :func:`it2_scale` — height uncertainty (LMF is a scaled-down UMF);
|
|
11
|
+
* :func:`it2_gauss_uncertain_mean` — a Gaussian with mean in ``[c1, c2]``;
|
|
12
|
+
* :func:`it2_gauss_uncertain_std` — a Gaussian with uncertain spread.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from ..membership import Gaussian, MembershipFunction
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IntervalType2MF:
|
|
23
|
+
"""An IT2 set defined by a lower (LMF) and an upper (UMF) type-1 MF.
|
|
24
|
+
|
|
25
|
+
The UMF must dominate the LMF everywhere (``LMF(x) <= UMF(x)``); this is not
|
|
26
|
+
enforced at construction (it would require sampling the universe) but holds
|
|
27
|
+
for every set built through the constructors below.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, lower: MembershipFunction, upper: MembershipFunction) -> None:
|
|
31
|
+
if not callable(lower) or not callable(upper):
|
|
32
|
+
raise TypeError("lower and upper must be callable membership functions")
|
|
33
|
+
self._lower = lower
|
|
34
|
+
self._upper = upper
|
|
35
|
+
|
|
36
|
+
def lower(self, x):
|
|
37
|
+
return np.asarray(self._lower(x), dtype=float)
|
|
38
|
+
|
|
39
|
+
def upper(self, x):
|
|
40
|
+
return np.asarray(self._upper(x), dtype=float)
|
|
41
|
+
|
|
42
|
+
def __call__(self, x):
|
|
43
|
+
return self.lower(x), self.upper(x)
|
|
44
|
+
|
|
45
|
+
def __repr__(self) -> str:
|
|
46
|
+
return f"IT2(lower={self._lower!r}, upper={self._upper!r})"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def it2(lower: MembershipFunction, upper: MembershipFunction) -> IntervalType2MF:
|
|
50
|
+
"""An IT2 set from explicit lower and upper type-1 membership functions."""
|
|
51
|
+
return IntervalType2MF(lower, upper)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def it2_scale(mf: MembershipFunction, scale: float) -> IntervalType2MF:
|
|
55
|
+
"""Height-uncertainty FOU: UMF is ``mf``, LMF is ``scale * mf`` (0 < scale ≤ 1)."""
|
|
56
|
+
if not 0.0 < scale <= 1.0:
|
|
57
|
+
raise ValueError(f"scale must be in (0, 1], got {scale}")
|
|
58
|
+
lower = lambda x: scale * np.asarray(mf(x), dtype=float) # noqa: E731
|
|
59
|
+
return IntervalType2MF(lower, mf)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def it2_gauss_uncertain_mean(c1: float, c2: float, sigma: float) -> IntervalType2MF:
|
|
63
|
+
"""Gaussian with an *uncertain mean* spanning ``[c1, c2]`` (c1 < c2).
|
|
64
|
+
|
|
65
|
+
The UMF is the upper envelope of all Gaussians with mean in ``[c1, c2]``
|
|
66
|
+
(flat-topped at 1 between the means); the LMF is their lower envelope.
|
|
67
|
+
"""
|
|
68
|
+
if c1 >= c2:
|
|
69
|
+
raise ValueError(f"need c1 < c2, got ({c1}, {c2})")
|
|
70
|
+
if sigma <= 0:
|
|
71
|
+
raise ValueError("sigma must be > 0")
|
|
72
|
+
g1, g2 = Gaussian(c1, sigma), Gaussian(c2, sigma)
|
|
73
|
+
mid = (c1 + c2) / 2.0
|
|
74
|
+
|
|
75
|
+
def upper(x):
|
|
76
|
+
x = np.asarray(x, dtype=float)
|
|
77
|
+
return np.where(x < c1, g1(x), np.where(x > c2, g2(x), 1.0))
|
|
78
|
+
|
|
79
|
+
def lower(x):
|
|
80
|
+
x = np.asarray(x, dtype=float)
|
|
81
|
+
return np.where(x <= mid, g2(x), g1(x))
|
|
82
|
+
|
|
83
|
+
return IntervalType2MF(lower, upper)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def it2_gauss_uncertain_std(c: float, sigma_lo: float, sigma_hi: float) -> IntervalType2MF:
|
|
87
|
+
"""Gaussian with an *uncertain spread*: UMF uses the wider σ, LMF the narrower."""
|
|
88
|
+
if not 0 < sigma_lo < sigma_hi:
|
|
89
|
+
raise ValueError(f"need 0 < sigma_lo < sigma_hi, got ({sigma_lo}, {sigma_hi})")
|
|
90
|
+
return IntervalType2MF(Gaussian(c, sigma_lo), Gaussian(c, sigma_hi))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
__all__ = [
|
|
94
|
+
"IntervalType2MF",
|
|
95
|
+
"it2", "it2_scale", "it2_gauss_uncertain_mean", "it2_gauss_uncertain_std",
|
|
96
|
+
]
|
fuzzytool/viz.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Matplotlib visualization helpers.
|
|
2
|
+
|
|
3
|
+
Importing this module requires ``matplotlib`` (an optional dependency; install
|
|
4
|
+
with ``pip install fuzzytool[viz]``). Visualization is a first-class priority of
|
|
5
|
+
the project, mirroring its sibling ``turboswarm``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
15
|
+
from .sets import Variable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _require_mpl():
|
|
19
|
+
try:
|
|
20
|
+
import matplotlib.pyplot as plt # noqa: F401
|
|
21
|
+
except ImportError as exc: # pragma: no cover
|
|
22
|
+
raise ImportError(
|
|
23
|
+
"visualization needs matplotlib; install with `pip install fuzzytool[viz]`"
|
|
24
|
+
) from exc
|
|
25
|
+
return plt
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def plot_variable(variable: Variable, ax=None):
|
|
29
|
+
"""Plot every term's membership function over the variable's universe."""
|
|
30
|
+
plt = _require_mpl()
|
|
31
|
+
if ax is None:
|
|
32
|
+
_, ax = plt.subplots(figsize=(6, 3))
|
|
33
|
+
x = variable.universe
|
|
34
|
+
for name, m in variable.terms.items():
|
|
35
|
+
ax.plot(x, m(x), label=name)
|
|
36
|
+
ax.set_title(variable.name)
|
|
37
|
+
ax.set_xlabel(variable.name)
|
|
38
|
+
ax.set_ylabel("membership")
|
|
39
|
+
ax.set_ylim(-0.05, 1.05)
|
|
40
|
+
ax.legend(loc="best", fontsize="small")
|
|
41
|
+
return ax
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def plot_it2_variable(variable: Variable, ax=None):
|
|
45
|
+
"""Plot an IT2 variable: each term's lower/upper MF with a shaded FOU.
|
|
46
|
+
|
|
47
|
+
Type-1 terms (if any are mixed in) are drawn as a single line.
|
|
48
|
+
"""
|
|
49
|
+
plt = _require_mpl()
|
|
50
|
+
if ax is None:
|
|
51
|
+
_, ax = plt.subplots(figsize=(6, 3))
|
|
52
|
+
x = variable.universe
|
|
53
|
+
for name, term in variable.terms.items():
|
|
54
|
+
if hasattr(term, "lower") and hasattr(term, "upper"):
|
|
55
|
+
lo, up = term.lower(x), term.upper(x)
|
|
56
|
+
line, = ax.plot(x, up, label=name)
|
|
57
|
+
ax.plot(x, lo, color=line.get_color(), alpha=0.7)
|
|
58
|
+
ax.fill_between(x, lo, up, color=line.get_color(), alpha=0.2)
|
|
59
|
+
else:
|
|
60
|
+
ax.plot(x, term(x), label=name)
|
|
61
|
+
ax.set_title(variable.name)
|
|
62
|
+
ax.set_xlabel(variable.name)
|
|
63
|
+
ax.set_ylabel("membership")
|
|
64
|
+
ax.set_ylim(-0.05, 1.05)
|
|
65
|
+
ax.legend(loc="best", fontsize="small")
|
|
66
|
+
return ax
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def control_surface(system, x_var: Variable, y_var: Variable,
|
|
70
|
+
n: int = 41, ax=None):
|
|
71
|
+
"""Plot a system's output as a surface over two input variables.
|
|
72
|
+
|
|
73
|
+
``system`` must be callable as ``system(**{x_var.name: x, y_var.name: y})``
|
|
74
|
+
and return a scalar (single-output Mamdani or TSK).
|
|
75
|
+
"""
|
|
76
|
+
plt = _require_mpl()
|
|
77
|
+
from mpl_toolkits.mplot3d import Axes3D # noqa: F401 (registers 3d proj)
|
|
78
|
+
|
|
79
|
+
xs = np.linspace(x_var.low, x_var.high, n)
|
|
80
|
+
ys = np.linspace(y_var.low, y_var.high, n)
|
|
81
|
+
zz = np.empty((n, n))
|
|
82
|
+
for i, yv in enumerate(ys):
|
|
83
|
+
for j, xv in enumerate(xs):
|
|
84
|
+
zz[i, j] = system(**{x_var.name: float(xv), y_var.name: float(yv)})
|
|
85
|
+
xx, yy = np.meshgrid(xs, ys)
|
|
86
|
+
|
|
87
|
+
if ax is None:
|
|
88
|
+
fig = plt.figure(figsize=(7, 5))
|
|
89
|
+
ax = fig.add_subplot(111, projection="3d")
|
|
90
|
+
ax.plot_surface(xx, yy, zz, cmap="viridis", linewidth=0, antialiased=True)
|
|
91
|
+
ax.set_xlabel(x_var.name)
|
|
92
|
+
ax.set_ylabel(y_var.name)
|
|
93
|
+
ax.set_zlabel("output")
|
|
94
|
+
return ax
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def plot_clusters(X, result, ax=None):
|
|
98
|
+
"""Scatter 2-D data colored by hard label, with cluster centers marked.
|
|
99
|
+
|
|
100
|
+
``result`` is a :class:`~fuzzytool.cluster.ClusterResult`. Point opacity
|
|
101
|
+
encodes the top membership, so fuzzy/boundary points appear fainter.
|
|
102
|
+
"""
|
|
103
|
+
plt = _require_mpl()
|
|
104
|
+
X = np.asarray(X, dtype=float)
|
|
105
|
+
if X.shape[1] != 2:
|
|
106
|
+
raise ValueError("plot_clusters needs 2-D data")
|
|
107
|
+
if ax is None:
|
|
108
|
+
_, ax = plt.subplots(figsize=(5, 5))
|
|
109
|
+
top = result.u.max(axis=0)
|
|
110
|
+
ax.scatter(X[:, 0], X[:, 1], c=result.labels, cmap="tab10",
|
|
111
|
+
alpha=np.clip(top, 0.25, 1.0), s=25)
|
|
112
|
+
ax.scatter(result.centers[:, 0], result.centers[:, 1],
|
|
113
|
+
marker="X", c="black", s=160, edgecolors="white", zorder=3)
|
|
114
|
+
ax.set_title("fuzzy clusters")
|
|
115
|
+
return ax
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
__all__ = ["plot_variable", "plot_it2_variable", "control_surface", "plot_clusters"]
|