# © 2025 Nokia
# Licensed under the BSD 3-Clause License
# SPDX-License-Identifier: BSD-3-Clause
import matplotlib
import matplotlib.pyplot as plt
from scipy.integrate import trapezoid
from .fuzzy_set import FuzzySet
[docs]
class FuzzyNumber(FuzzySet):
"""
A *Fuzzy Number (FN)* is Fuzzy Set :math:`A = (U, \\mu)` such that:
- :math:`U = \\mathbb{R}`;
- :math:`\\mu` is a :math:`\\mathbb{R}` is a piecewise continuous
function;
- :math:`\\mu` is convex;
- :math:`\\mu` is normalized (i.e., :math:`\\mu(\\mathbb{R}) = [0, 1]`).
As such, a fuzzy number models a fuzzy interval of real numbers.
The :py:class:`FuzzySet` class assumes a *discrete* Fuzzy Set.
Therefore, the :py:class:`FuzzyNumber` takes a list of
:math:`{(x_1, y_1), ..., (x_n, y_n)}` points enough to describe
:math:`\\mu` such that:
- :math:`x_1 \\le ... \\le x_n`;
- :math:`\\mu(x_1) = 0` and :math:`\\mu(x_n) = 0`;
- :math:`\\forall x \\le x_1 = 0`;
- :math:`\\forall x \\ge x_n = 0`;
- for all :math:`i \\in \\{1, ..., n-1\\}`,
for all :math:`x \\in [x_i, x_{i+1}]`,
:math:`\\mu` matches the linear function that traverses
:math:`(x_i, y_i)` and :math:`(x_{i+1}, y_{i+1})`.
*Example:*
>>> import matplotlib.pyplot as plt
>>> from fuzzy_set import FuzzyNumber
>>> a1 = FuzzyNumber(dict(
... enumerate([0, 0.6, 0.8, 1, 1, 0.9, 0.75, 0.4, 0]))
... )
>>> a1.plot(label="$a_1$", marker=".", with_xis=False, with_ei=True)
<matplotlib.collections.PathCollection object at ...>
>>> plt.legend()
<matplotlib.legend.Legend object at ...>
>>> plt.grid()
"""
def __init__(self, mu: dict[float, float]):
"""
Constructor.
Args:
mu (dict[float, float]): The membership function. Each (key, value)
pair corresponds to an element of the fuzzy set.
Raises:
ValueError: if mu is not corresponding to a piecewise
linear function or if it is not normalized.
Example:
>>> a1 = FuzzyNumber(
... dict(enumerate([0, 0.6, 0.8, 1, 1, 0.9, 0]))
... ) # Convex
>>> a2 = FuzzyNumber(
... dict(enumerate([0, 0.1, 0.8, 1, 1, 0.9, 0]))
... ) # Not Convex
Traceback (most recent call last):
...
ValueError: ...
...
"""
super().__init__(mu, is_continuous=True)
if not self.is_normalized():
raise ValueError("Not normalized")
self.x1 = self.x2 = self.x3 = self.x4 = None
self._init_xis()
self._check_convexity(mu)
def _init_xis(self):
i = 0
x_prev = y_prev = None
for x in sorted(self):
y = self.m(x)
if y < 0:
raise ValueError("Negative image")
if i == 0: # _
if y > 0: # _/
if x_prev is None:
raise ValueError("Invalid first element")
self.x1 = x_prev
i = 1
elif i == 1: # /
if y < y_prev:
raise ValueError("Not normalized")
elif y == 1.0: # /-
self.x2 = self.x3 = x
i = 2
elif i == 2: # -
if y < 1.0: # --
self.x3 = x_prev
i = 3
elif i == 3: # \
if y > y_prev:
raise ValueError("Not convex")
elif y == 0: # \_
self.x4 = x
i = 4
elif i == 4: # _
if self.m(x) != 0:
raise ValueError("Not convex")
x_prev = x
y_prev = y
assert self.x1 is not None
assert self.x2 is not None
assert self.x3 is not None
assert self.x4 is not None
assert self[self.x1] == 0.0, (self.x1, self[self.x1])
assert self[self.x2] == 1.0, (self.x2, self[self.x2])
assert self[self.x3] == 1.0, (self.x3, self[self.x3])
assert self[self.x4] == 0.0, (self.x4, self[self.x4])
def _check_convexity(self, points: dict[float, float]):
from scipy.interpolate import interp1d
from pprint import pformat
x0 = y0 = x1 = y1 = y1 = None
for (i, (x2, y2)) in enumerate(sorted(points.items())):
if i > 1:
# (x1, y1) must be above [(x0, y0), (x2, y2)]
y1_ = interp1d([x0, x2], [y0, y2])(x1)
if not (y1 >= y1_):
raise ValueError(pformat(locals()))
if i > 0:
(x0, y0) = (x1, y1)
(x1, y1) = (x2, y2)
[docs]
def support(self) -> tuple:
"""
Computes the `support
<https://en.wikipedia.org/wiki/Fuzzy_set#Crisp_sets_related_to_a_fuzzy_set>`__
of this fuzzy number, denoted by :math:`A^{>0}`.
Returns:
The interval of reals corresponding
to the support of this fuzzy number.
"""
return (self.x1, self.x4)
[docs]
def core(self) -> tuple:
"""
Computes the `core
<https://en.wikipedia.org/wiki/Fuzzy_set#Crisp_sets_related_to_a_fuzzy_set>`__
of this fuzzy number, denoted by :math:`A^{=1}`.
Returns:
The interval of reals corresponding
to the core of this fuzzy number.
"""
return (self.x2, self.x3)
[docs]
def ei_l(self) -> float:
"""
Computes the `Expected Interval Lower Bound
<https://www.atlantis-press.com/article/2302.pdf>`__,
formally defined by:
:math:`\\text{EI}_L(A) = \\int_{0}^1 A_L(\\alpha).d\\alpha`
where:
:math:`A_L(\\alpha) = \\inf({x \\in \\mathbb{R}
: \\mu_A(x) \\ge \\alpha})`
Intuitively, :math:`\\text{EI}_L(A)` is the
:math:`\\ell \\in \\mathbb{R}` value such that
:math:`\\mu_A(\\ell)` equals average value of the left
arm of this Fuzzy Number.
Returns:
The Expected Interval Lower Bound.
"""
(xs, ys) = zip(*(
(x, self[x])
for x in sorted(self.keys())
if self.x1 <= x <= self.x2
))
return trapezoid(xs, x=ys)
[docs]
def ei_u(self) -> float:
"""
Computes the `Expected Interval Upper Bound
<https://www.atlantis-press.com/article/2302.pdf>`__,
formally defined by:
:math:`\\text{EI}_L(A) = \\int_{0}^1 A_U(\\alpha).d\\alpha`.
where:
:math:`A_U(\\alpha) = \\sup({x \\in \\mathbb{R}
: \\mu_A(x) \\ge \\alpha})`
Intuitively, :math:`\\text{EI}_L(A)` is the :math:`r \\in \\mathbb{R}`
value such that :math:`\\mu_A(r)` equals average value of the right
arm of this Fuzzy Number.
Returns:
The Expected Interval Upper Bound.
"""
(xs, ys) = zip(*(
(x, self[x])
for x in sorted(self.keys(), reverse=True)
if self.x3 <= x <= self.x4
))
return trapezoid(xs, x=ys)
[docs]
def ei(self) -> tuple:
"""
Computes the `Expected Interval
<https://www.atlantis-press.com/article/2302.pdf>`__,
formally defined by:
:math:`\\text{EI}(A) = [\\text{EI}_L(A), \\text{EI}_U(A)]`.
See also the
:py:meth:`FuzzyNumber.ei_l`,
:py:meth:`FuzzyNumber.ei_u`,
:py:meth:`FuzzyNumber.ev` methods.
Returns:
The Expected Interval.
"""
return (self.ei_l(), self.ei_u())
[docs]
def ev(self, q: float = 0.5) -> float:
"""
Computes the `(Weighed) Expected Value
<https://www.atlantis-press.com/article/2302.pdf>`__
of this fuzzy number, formally defined as
the middle of the `Expected Interval
<https://www.atlantis-press.com/article/2302.pdf>`__:
:math:`\\text{EV}(A)
= q \\cdot \\text{EI}_L(A) + (1 - q) \\cdot \\text{EI}_U(A)`
See also the
:py:meth:`FuzzyNumber.ei_l`,
:py:meth:`FuzzyNumber.ei_u`,
:py:meth:`FuzzyNumber.ei` methods.
Args:
q (float): A value in :math:`[0, 1]`.
Default to ``0.5``.
Returns:
The weighted expected value of this fuzzy number.
"""
assert 0 <= q <= 1
return q * self.ei_l() + (1 - q) * self.ei_u()
[docs]
def width(self) -> float:
"""
Computes the `width
<https://www.atlantis-press.com/article/2302.pdf>`__
of this fuzzy number, formally defined as
the middle of the `Expected Interval
<https://www.atlantis-press.com/article/2302.pdf>`__:
:math:`\\text{w}(A) = \\text{EI}_U(A) - \\text{EI}_L(A))`
See also the
:py:meth:`FuzzyNumber.ei_l`,
:py:meth:`FuzzyNumber.ei_u`,
:py:meth:`FuzzyNumber.ei` methods.
Returns:
The width of this fuzzy number.
"""
return self.ei_u() - self.ei_l()
[docs]
def plot(
self,
*args,
ax=None,
with_xis: bool = False,
with_ei: bool = False,
**kwargs
) -> matplotlib.collections.PathCollection:
"""
Plots this Fuzzy Number.
Args:
cls, args: See the :py:func:`plt.plot` function.
ax (matplotlib.axes.Axes): The axes of the figure where
this :py:class:`FuzzySet` instance must be plotted.
Pass `None` if not needed.
with_xis (bool): Pass `True` to display the support
and the core of this Fuzzy Number. See also:
- the :py:meth:`FuzzyNumber.support` method;
- the :py:meth:`FuzzyNumber.core` method.
Returns:
The resulting corresponding plot.
"""
if ax is None:
ax = plt.gca()
kwargs.pop("with_xis", None)
kwargs.pop("with_ei", None)
label = kwargs.pop("label", "")
kwargs.pop("marker", None)
p = super().plot(*args, ax=ax, marker=".", label=label, **kwargs)[0]
color = kwargs.pop("color", p.get_color())
# Transition phase points
if with_xis:
xs = [self.x1, self.x2, self.x3, self.x4]
ys = [self[self.x1], self[self.x2], self[self.x3], self[self.x4]]
p = ax.scatter(
xs, ys,
*args,
color=color, marker="o", label=f"{label}.{{x1, x2, x3, x3}}",
**kwargs
)
# Expected interval
if with_ei:
(ei_l, ei_u) = self.ei()
ev = self.ev()
xs = [ei_l, ev, ei_u]
ys = [self.m(x) for x in xs]
if "marker" in kwargs:
kwargs.pop("marker")
p = ax.scatter(
xs, ys,
*args,
color=color, marker="^", label=f"EI({label})",
**kwargs
)
return p