"""This module implements fuzzifiers used in mulearn.
"""
import numpy as np
from scipy.optimize import curve_fit
import warnings
from sklearn.utils.validation import check_X_y, check_array, check_is_fitted
import copy
def _safe_exp(r):
with np.errstate(over="raise"):
try:
return np.exp(r)
except FloatingPointError:
return 1
[docs]class Fuzzifier:
"""Base class for fuzzifiers.
The base class for fuzzifiers is Fuzzifier: it exposes a basic constructor
which is called from the subclasses, and two methods `get_membership`
(returning the membership function inferred from data) and `get_profile`
computing information exploitable in order to visualize the fuzzifier
in graphical form."""
[docs] def __init__(self):
"""Create an instance of :class:`Fuzzifier`."""
self.sq_radius_05 = None
self.x_to_sq_dist = None
self.r_to_mu = None
def _get_r_to_mu(self):
r"""Build membership function in feature space.
Return a function that transforms the square distance between
center of the learnt sphere and the image of a point in data
space into a membership degree.
**Note** This function is meant to be called internally by the
`get_membership` method in the base `Fuzzifier` class.
:returns: function -- function mapping square distance to membership.
"""
check_is_fitted(self, ["r_to_mu"])
return self.r_to_mu
[docs] def get_membership(self):
"""Return the induced membership function.
:raises: NotFittedError if `fit` has not been called
:returns: function -- the induced membership function
"""
r_to_mu = self._get_r_to_mu()
return lambda x: r_to_mu(self.x_to_sq_dist(np.array(x)))
[docs] def get_profile(self, X):
r"""Return information about the learnt membership function profile.
The profile of a membership function $\mu: X \rightarrow [0, 1]$ is
intended here as the associated function $p: \mathbb R^+ \rightarrow
[0, 1]$ still returning membership degrees, but considering its
arguments in the feature space. More precisely, if `X` contains the
values $x_1, \dots, x_n$, $R^2$ is the function mapping any point in
data space into the squared distance between its image and the center
$a$ of the learnt fuzzy set in feature space, the function
`get_profile` computes the following information about $p$:
* a list $r_\mathrm{data} = [ R^2(x_i), i = 1, \dots, n]$ containing
the distances between the images of the points in `X` and $a$;
* a list $\tilde{r}_\mathrm{data}$ containing 200 possible
distances between $a$ and the image of a point in data space, evenly
distributed between $0$ and $\max r_{\mathrm{data}}$;
* a list $e = [\hat\mu(r_i), r_i \in \tilde{r}_{\mathrm{data}}]$
gathering the profile values for each element in
$\tilde{r}_{\mathrm{data}}$.
This information can be used in order to graphically show the
membership profile, which is always plottable, whereas the membership
function isn't mostly of the time (unless the elements in `X` are
either one- or bidimensional vectors).
:param X: Vectors in data space.
:type X: iterable of `float` vectors having the same length
:returns: list -- $[r_{\mathrm{data}}, \tilde{r}_\mathrm{data}, e]$.
"""
rdata = list(map(self.x_to_sq_dist, X))
rdata_synth = np.linspace(0, max(rdata) * 1.1, 200)
estimate = list(map(self._get_r_to_mu(), rdata_synth))
return [rdata, rdata_synth, estimate]
def __str__(self):
"""Return the string representation of a fuzzifier."""
return self.__repr__()
def __eq__(self, other):
"""Check fuzzifier equality w.r.t. other objects."""
return type(self) == type(other)
def __ne__(self, other):
"""Check fuzzifier inequality w.r.t. other objects."""
return not self == other
def __hash__(self):
"""Generate hashcode for a fuzzifier."""
return hash(self.__repr__())
@staticmethod
def __nonzero__():
"""Check if a fuzzifier is non-null."""
return True
def __getstate__(self):
"""Return a serializable description of the fuzzifier."""
d = copy.deepcopy(self.__dict__)
if 'x_to_sq_dist' in d:
del d['x_to_sq_dist']
return d
def __setstate__(self, d):
"""Ensure fuzzifier consistency after deserialization."""
self.__dict__ = d
[docs]class CrispFuzzifier(Fuzzifier):
"""Crisp fuzzifier.
Fuzzifier corresponding to a crisp (classical) set: membership is always
equal to either $0$ or $1$."""
default_profile = "fixed"
[docs] def __init__(self, profile=default_profile):
r"""Create an instance of :class:`CrispFuzzifier`.
:param profile: method to be used in order to build the fuzzifier
profile: `'fixed'` relies on the radius of the sphere defining
the fuzzy set core, while `'infer'` fits a generic threshold
function on the provided examples.
:type profile: str
"""
super().__init__()
self.profile = profile
self.name = 'Crisp'
self.latex_name = '$\\hat\\mu_{\\text{crisp}}$'
[docs] def fit(self, X, y):
r"""Fit the fuzzifier on training data.
:param X: Vectors in data space.
:type X: iterable of `float` vectors having the same length
:param y: membership degrees of the values in `X`.
:type y: vector of floats having the same length of `X`
:raises: ValueError if self.profile is not set either to `'fixed'` or
to `'infer'`.
The fitting process is done considering a threshold-based membership
function, in turn corresponding to a threshold-based profile of the
form
.. math::
p(r) = \begin{cases} 1 & \text{if $r \leq r_\text{crisp}$,} \\
0 & \text{otherwise.} \end{cases}
The threshold $r_\text{crisp}$ is set to the learnt square radius of
the sphere when the `profile` attribute of the class have been set to
`'fixed'`, and induced via interpolation of `X` and `y` attributes when
it is has been set to `'infer'`.
"""
check_array(X)
check_X_y(X, y)
if self.profile == "fixed":
self.r_to_mu = lambda r: 1 if r <= self.sq_radius_05 else 0
elif self.profile == "infer":
R = np.fromiter(map(self.x_to_sq_dist, X), dtype=float)
def r_to_mu(r, sq_radius_05):
result = np.ones(len(r))
result[r > sq_radius_05] = 0
return result
p_opt, _ = curve_fit(r_to_mu, R, y,
bounds=((0,), (np.inf,)))
if p_opt[0] < 0:
raise ValueError("Profile fit returned a negative parameter")
self.r_to_mu = lambda r: r_to_mu([r], *p_opt)[0]
else:
raise ValueError("'profile' parameter should either be equal to "
f"'fixed' or 'infer' (provided: {self.profile})")
def __repr__(self):
"""Return the python representation of the fuzzifier."""
if self.profile != self.default_profile:
return f"CrispFuzzifier(profile={self.profile})"
else:
return "CrispFuzzifier()"
[docs]class LinearFuzzifier(Fuzzifier):
"""Crisp fuzzifier.
Fuzzifier corresponding to a fuzzy set whose membership in feature space
linearly decreases from 1 to 0."""
default_profile = "fixed"
[docs] def __init__(self, profile=default_profile):
r"""Create an instance of :class:`LinearFuzzifier`.
:param profile: method to be used in order to build the fuzzifier
profile: `'fixed'` relies on the radius of the sphere defining
the fuzzy set core, while `'infer'` fits the profile function on the
provided examples.
:type profile: str
"""
super().__init__()
self.profile = profile
self.name = 'Linear'
self.latex_name = '$\\hat\\mu_{\\text{lin}}$'
[docs] def fit(self, X, y):
r"""Fit the fuzzifier on training data.
:param X: Vectors in data space.
:type X: iterable of `float` vectors having the same length
:param y: membership degrees of the values in `X`.
:type y: vector of floats having the same length of `X`
:raises: ValueError if self.profile is not set either to `'fixed'` or
to `'infer'`.
The fitting process is done considering a membership function
linearly decreasing from $1$ to $0$, in turn corresponding to a profile
having the general form
.. math::
p(r) = \begin{cases} 1 & \text{if $r \leq r_1$,} \\
l(r) & \text{if $r_1 < r \leq r_0$,} \\
0 & \text{otherwise.} \end{cases}
The free parameters are chosen in order to guarantee continuity;
moreover, when the `profile` attribute of the class have been set to
`'fixed'` the membership profile will be equal to 0.5 when $r$ is equal
to the learnt square radius of the sphere, and induced via
interpolation of `X` and `y` when it is has been set to `'infer'`.
"""
check_array(X)
check_X_y(X, y)
R = np.fromiter(map(self.x_to_sq_dist, X), dtype=float)
sq_radius_1_guess = np.median([self.x_to_sq_dist(x)
for x, mu in zip(X, y) if mu >= 0.99])
if self.profile == 'fixed':
def r_to_mu(R_arg, sq_radius_1):
return [np.clip(1 - 0.5 *
(r - sq_radius_1) /
(self.sq_radius_05 - sq_radius_1),
0, 1)
for r in R_arg]
p_opt, _ = curve_fit(r_to_mu, R, y,
p0=(sq_radius_1_guess,),
bounds=((0,), (np.inf,)))
elif self.profile == 'infer':
def r_to_mu(R_arg, sq_radius_1, sq_radius_0):
return [np.clip(1 - (sq_radius_1 - r) /
(sq_radius_1 - sq_radius_0), 0, 1)
for r in R_arg]
p_opt, _ = curve_fit(r_to_mu, R, y,
p0=(sq_radius_1_guess, 10 * self.sq_radius_05),
bounds=((0, 0), (np.inf, np.inf,)))
else:
raise ValueError("'profile' parameter should be equal to "
"'fixed' or 'infer' (provided value: {profile})")
if min(p_opt) < 0:
raise ValueError('Profile fitting returned a negative parameter')
self.r_to_mu = lambda r: r_to_mu([r], *p_opt)[0]
def __repr__(self):
"""Return the python representation of the fuzzifier."""
if self.profile != self.default_profile:
return f"LinearFuzzifier(profile={self.profile})"
else:
return "LinearFuzzifier()"
[docs]class ExponentialFuzzifier(Fuzzifier):
"""Exponential fuzzifier.
Fuzzifier corresponding to a fuzzy set whose membership in feature space
exponentially decreases from 1 to 0."""
default_profile = "fixed"
default_alpha = -1
[docs] def __init__(self, profile=default_profile, alpha=default_alpha):
r"""Create an instance of :class:`ExponentialFuzzifier`.
:param profile: method to be used in order to build the fuzzifier
profile: `'fixed'` relies on the radius of the sphere defining
the fuzzy set core, `'infer'` fits the profile function on the
provided examples, and `'alpha'` allows for manually setting the
exponential decay via the `alpha` parameter.
:type profile: str
:param alpha: fixed exponential decay of the fuzzifier.
:type alpha: float
"""
super().__init__()
self.profile = profile
self.alpha = alpha
self.name = "Exponential"
self.latex_name = r"$\hat\mu_{\text{exp}}$"
[docs] def fit(self, X, y):
r"""Fit the fuzzifier on training data.
:param X: Vectors in data space.
:type X: iterable of `float` vectors having the same length
:param y: membership degrees of the values in `X`.
:type y: vector of floats having the same length of `X`
:raises: ValueError if self.profile is not set either to `'fixed'`,
`'infer'`, or `'alpha'`.
In this fuzzifier, the function that transforms the square distance
between the center of the learnt sphere and the image of a point in
the original space into a membership degree has the form
.. math::
\mu(r) = \begin{cases} 1 & \text{if $r \leq r_1$,} \\
e(r) & \text{otherwise,}
\end{cases}
where $e$ is an exponential function decreasing from 1 to 0. The
shape of this function is chosen so that the membership profile will be
equal to 0.5 when $r$ is equal to the learnt square radius of the
sphere, and induced via interpolation of `X` and `y` when it is has
been set to `'infer'`; finally, when the parameter is set to `'alpha'`
the exponential decay of $e$ is manually set via the `alpha` parameter
of the class constructor.
"""
check_array(X)
check_X_y(X, y)
if self.alpha > 0 and self.profile != "alpha":
raise ValueError(f"'alpha' value is specified, but 'profile' "
f"is set to '{self.profile}'")
if self.profile == "alpha":
if self.alpha < 0 or self.alpha > 1:
raise ValueError("alpha must be set to a float between 0 and 1 "
"when 'profile' is 'alpha'")
r_1_guess = np.median([self.x_to_sq_dist(x)
for x, mu in zip(X, y) if mu >= 0.99])
s_guess = (self.sq_radius_05 - r_1_guess) / np.log(2)
R = np.fromiter(map(self.x_to_sq_dist, X), dtype=float)
if self.profile == "fixed":
def r_to_mu(R_data, sq_radius_1):
return [np.clip(_safe_exp(-(r - sq_radius_1) /
(self.sq_radius_05 - sq_radius_1)
* np.log(2)), 0, 1) for r in R_data]
with warnings.catch_warnings():
warnings.simplefilter("ignore")
p_opt, _ = curve_fit(r_to_mu, R, y, p0=(r_1_guess,),
maxfev=2000, bounds=((0,), (np.inf,)))
self.r_to_mu = lambda r: r_to_mu([r], *p_opt)[0]
elif self.profile == "infer":
def r_to_mu(R_data, r_1, s):
return [np.clip(_safe_exp(-(r - r_1) / s), 0, 1)
for r in R_data]
p_opt, _ = curve_fit(r_to_mu, R, y, p0=(r_1_guess, s_guess),
# bounds=((0, 0), (np.inf, np.inf)),
maxfev=2000)
self.r_to_mu = lambda r: r_to_mu([r], *p_opt)[0]
elif self.profile == "alpha":
r_sample = map(self.x_to_sq_dist, X)
q = np.percentile([s - self.sq_radius_05 for s in r_sample
if s > self.sq_radius_05], 100 * self.alpha)
def r_to_mu(R_data, sq_radius_1):
return [np.clip(_safe_exp(np.log(self.alpha) /
q * (r - sq_radius_1)), 0, 1)
for r in R_data]
p_opt, _ = curve_fit(r_to_mu, R, y, p0=(r_1_guess,),
bounds=((0,), (np.inf,)))
self.r_to_mu = lambda r: r_to_mu([r], *p_opt)[0]
else:
raise ValueError("'profile' parameter should be equal to "
"'infer', 'fixed' or 'alpha' "
f"(provided value: {self.profile})")
def __repr__(self):
obj_repr = "ExponentialFuzzifier("
if self.profile != self.default_profile:
obj_repr += f", profile={self.profile}"
if self.alpha != self.default_alpha:
obj_repr += f", alpha={self.alpha}"
if obj_repr.endswith(", "):
return obj_repr + ")"
else:
return "ExponentialFuzzifier()"
[docs]class QuantileConstantPiecewiseFuzzifier(Fuzzifier):
"""Quantile-based constant piecewise fuzzifier.
Fuzzifier corresponding to a fuzzy set with a piecewise constant membership
function, whose steps are defined according to the quartiles of the squared
distances between images of points and center of the learnt sphere."""
[docs] def __init__(self):
r"""Create an instance of :class:`QuantileConstantPiecewiseFuzzifier`"""
super().__init__()
self.name = 'QuantileConstPiecewise'
self.latex_name = '$\\hat\\mu_{\\text{q\\_const}}$'
[docs] def fit(self, X, y):
"""Fit the fuzzifier on training data.
:param X: Vectors in data space.
:type X: iterable of `float` vectors having the same length
:param y: membership degrees of the values in `X`.
:type y: vector of floats having the same length of `X`
The piecewise membership function is built so that its steps are chosen
according to the quartiles of square distances between images of the
points in `X` center of the learnt sphere.
"""
check_array(X)
check_X_y(X, y)
R = np.fromiter(map(self.x_to_sq_dist, X), dtype=float)
sq_radius_1 = np.median([self.x_to_sq_dist(x)
for x, mu in zip(X, y) if mu >= 0.99])
external_dist = [r - sq_radius_1
for r in R if r > sq_radius_1]
if external_dist:
m = np.median(external_dist)
q1 = np.percentile(external_dist, 25)
q3 = np.percentile(external_dist, 75)
else:
m = q1 = q3 = 0
def r_to_mu(r):
return 1 if r <= sq_radius_1 \
else 0.75 if r <= sq_radius_1 + q1 \
else 0.5 if r <= sq_radius_1 + m \
else 0.25 if r <= sq_radius_1 + q3 \
else 0
self.r_to_mu = r_to_mu
def __repr__(self):
return "QuantileConstantPiecewiseFuzzifier()"
[docs]class QuantileLinearPiecewiseFuzzifier(Fuzzifier):
"""Quantile-based linear piecewise fuzzifier.
Fuzzifier corresponding to a fuzzy set with a piecewise linear membership
function, whose steps are defined according to the quartiles of the squared
distances between images of points and center of the learnt sphere."""
[docs] def __init__(self):
r"""Create an instance of :class:`QuantileLinearPiecewiseFuzzifier`."""
super().__init__()
self.name = 'QuantileLinPiecewise'
self.latex_name = '$\\hat\\mu_{\\text{q\\_lin}}$'
[docs] def fit(self, X, y):
"""Fit the fuzzifier on training data.
:param X: Vectors in data space.
:type X: iterable of `float` vectors having the same length
:param y: membership degrees of the values in `X`.
:type y: vector of floats having the same length of `X`
The piecewise membership function is built so that its steps are chosen
according to the quartiles of square distances between images of the
points in `X` center of the learnt sphere.
"""
check_array(X)
check_X_y(X, y)
R = np.fromiter(map(self.x_to_sq_dist, X), dtype=float)
sq_radius_1 = np.median([self.x_to_sq_dist(x)
for x, mu in zip(X, y) if mu >= 0.99])
external_dist = [r - sq_radius_1
for r in R if r > sq_radius_1]
if external_dist:
m = np.median(external_dist)
q1 = np.percentile(external_dist, 25)
q3 = np.percentile(external_dist, 75)
mx = np.max(external_dist)
else:
m = q1 = q3 = mx = 0
def r_to_mu(r):
ssd = sq_radius_1
return 1 if r <= ssd \
else (-r + ssd) / (4 * q1) + 1 if r <= ssd + q1 \
else (-r + ssd + q1) / (4 * (m - q1)) + 3 / 4 if r <= ssd + m \
else (-r + ssd + m) / (4 * (q3 - m)) + 1 / 2 if r <= ssd + q3 \
else (-r + ssd + q3) / (4 * (mx - q3)) + 1 / 4 if r <= ssd + mx\
else 0
self.r_to_mu = r_to_mu
def __repr__(self):
return "QuantileLinearPiecewiseFuzzifier()"