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/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"]
@@ -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"]