Source code for ex_fuzzy.fuzzy_sets

"""
Fuzzy Sets Module for Ex-Fuzzy Library

This module contains the core fuzzy set classes and functionality for the ex-fuzzy library.
It implements Type-1, Type-2, and General Type-2 fuzzy sets with their associated 
membership functions and operations.

Main Components:
    - FUZZY_SETS enum: Defines the types of fuzzy sets supported
    - Membership function implementations (trapezoidal, triangular, gaussian)
    - Fuzzy set classes: FS, IVFS, gaussianFS, gaussianIVFS, categoricalFS, etc.
    - fuzzyVariable class: Container for linguistic variables with multiple fuzzy sets
    - Validation and statistical testing methods for fuzzy variables

The module supports both numerical computation and provides interfaces for
PyTorch tensors when available, making it suitable for both traditional fuzzy
logic applications and modern machine learning workflows.
"""
import enum
import warnings
from typing import Generator

import numpy as np
import pandas as pd

# You dont require torch to use this module, however, we need to import it to give support in case you feed these methods with torch tensors.
# Torch is imported lazily only when needed to avoid expensive imports

def _get_torch():
    """Lazy import of torch to avoid expensive imports when not needed."""
    try:
        import torch
        return torch
    except ImportError:
        return None

def _is_torch_tensor(x):
    """Check if x is a torch tensor without importing torch unless necessary."""
    # First check the type name to avoid torch import if not needed
    if type(x).__name__ != 'Tensor':
        return False
    
    # Only import torch if we have a potential tensor
    torch = _get_torch()
    if torch is None:
        return False
    
    return isinstance(x, torch.Tensor)


[docs] class FUZZY_SETS(enum.Enum): """ Enumeration defining the types of fuzzy sets supported by the library. This enum is used throughout the library to specify which type of fuzzy set should be created or used in operations. Example: >>> fz_type = FUZZY_SETS.t1 >>> if fz_type == FUZZY_SETS.t1: ... print("Using Type-1 fuzzy sets") """ #: Type-1 fuzzy sets with crisp membership functions. t1 = 'Type 1' #: Type-2 interval fuzzy sets with upper and lower membership bounds. t2 = 'Type 2' #: General Type-2 fuzzy sets with full secondary membership functions. gt2 = 'General Type 2'
def trapezoidal_membership(x: np.array, params: list[float], epsilon=10E-5) -> np.array: """ Compute trapezoidal membership function values. This function computes the membership degree for input values using a trapezoidal membership function defined by four parameters representing the trapezoid vertices. Args: x (np.array): Input values in the fuzzy set domain for which to compute membership params (list[float]): Four numbers [a, b, c, d] defining the trapezoid: - a: left foot (membership starts rising) - b: left shoulder (membership reaches 1.0) - c: right shoulder (membership starts falling) - d: right foot (membership reaches 0.0) epsilon (float, optional): Width of the shift applied to degenerate edges (see Note). Defaults to 10E-5 (1e-4). Returns: np.array: Membership values in [0, 1] for each input value Example: >>> x = np.array([0, 1, 2, 3, 4, 5]) >>> params = [1, 2, 3, 4] # trapezoid from 1 to 4, flat from 2 to 3 >>> membership = trapezoidal_membership(x, params) >>> print(membership) # [0, 0, 1, 1, 0, 0] Note: Special case: if a == d (singleton), returns 1.0 for exact matches, 0.0 otherwise. This handles degenerate trapezoids that collapse to single points. Vertical edges: if a == b (the left edge is vertical) the function evaluates with ``a - epsilon`` instead of ``a``, and if c == d it evaluates with ``d + epsilon`` instead of ``d``. The membership is then exactly 1.0 at the edge itself (``x == a`` or ``x == d``) and falls to 0.0 over ``epsilon`` outside it, instead of dividing by zero. The shift only exists inside this function: the ``membership_parameters`` stored in the fuzzy set, and anything exported from them, keep the original, unshifted ``a`` and ``d``. The PyTorch objectives of the genetic fits apply the same 1e-4 shift, so both routes agree. """ if isinstance(x, list): return list(trapezoidal_membership(np.array(x, dtype=float), params, epsilon)) a, b, c, d = params # Special case: a singleton trapezoid if a == d: # If they are numpy arrays, we need to use the numpy function if isinstance(x, np.ndarray): return np.equal(x, a).astype(float) if _is_torch_tensor(x): torch = _get_torch() return torch.eq(x, a).float() if b == a: a -= epsilon if c == d: d += epsilon aux1 = (x - a) / (b - a) aux2 = (d - x) / (d - c) if _is_torch_tensor(x): torch = _get_torch() return torch.clamp(torch.min(aux1, aux2), 0.0, 1.0) if isinstance(x, np.ndarray): return np.clip(np.minimum(aux1, aux2), 0.0, 1.0) elif isinstance(x, pd.Series): return np.clip(np.minimum(aux1, aux2), 0.0, 1.0) else: # Single value return np.clip(min(aux1, aux2), 0.0, 1.0) def _gaussian2(x, params: list[float]) -> np.array: """ Gaussian membership functions. Args: mean: real number, mean of the gaussian function. amplitude: real number. standard_deviation: std of the gaussian function. """ mean, standard_deviation = params return np.exp(- ((x - mean) / standard_deviation) ** 2)
[docs] class FS(): """ Base class for Type-1 fuzzy sets (Zadeh fuzzy sets). This class implements the fundamental Type-1 fuzzy set with crisp membership functions. It serves as the base class for more specialized fuzzy set types like triangular, gaussian, and categorical fuzzy sets. Attributes: name (str): The linguistic name of the fuzzy set (e.g., "low", "medium", "high") membership_parameters (list[float]): Parameters defining the membership function domain (list[float]): Two-element list defining the universe of discourse [min, max] Example: >>> fs = FS("medium", [1, 2, 3, 4], [0, 5]) # Trapezoidal fuzzy set >>> membership = fs.membership(2.5) >>> print(membership) # Should be 1.0 (fully in the set) Note: This class uses trapezoidal membership functions by default. For other shapes, use specialized subclasses like gaussianFS or triangularFS. """
[docs] def __init__(self, name: str, membership_parameters: list[float], domain: list[float]=None) -> None: """ Initialize a Type-1 fuzzy set. Args: name (str): Linguistic name for the fuzzy set membership_parameters (list[float]): Four parameters [a, b, c, d] defining the trapezoidal membership function where: - a: left foot (membership starts rising from 0) - b: left shoulder (membership reaches 1.0) - c: right shoulder (membership starts falling from 1.0) - d: right foot (membership reaches 0) domain (list[float]): Two-element list [min, max] defining the universe of discourse for this fuzzy set Example: >>> fs = FS("medium", [2, 3, 7, 8], [0, 10]) >>> # Creates a trapezoidal set: rises from 2-3, flat 3-7, falls 7-8 """ self.name = name self.domain = domain self.membership_parameters = membership_parameters
[docs] def membership(self, x: np.array) -> np.array: """ Compute membership degrees for input values. This method calculates the membership degree(s) for the given input value(s) using the trapezoidal membership function defined by this fuzzy set's parameters. Args: x (np.array): Input value(s) for which to compute membership degrees. Can be a single value, list, or numpy array. Returns: np.array: Membership degree(s) in the range [0, 1]. Shape matches input. Example: >>> fs = FS("medium", [2, 3, 7, 8], [0, 10]) >>> print(fs.membership(5)) # 1.0 (in flat region) >>> print(fs.membership(2.5)) # 0.5 (on rising slope) >>> print(fs.membership([1, 5, 9])) # [0.0, 1.0, 0.0] """ return trapezoidal_membership(x, self.membership_parameters)
[docs] def type(self) -> FUZZY_SETS: """ Return the fuzzy set type identifier. Returns: FUZZY_SETS: The type identifier (FUZZY_SETS.t1 for Type-1 fuzzy sets) """ return FUZZY_SETS.t1
[docs] def __call__(self, x: np.array) -> np.array: """ Calling the Fuzzy set returns its membership. Args: x: input values in the fuzzy set referencial domain. Returns: membership of the fuzzy set. """ return self.membership(x)
[docs] def __str__(self) -> str: """ Returns the name of the fuzzy set, its type and its parameters. Returns: string. """ return f'{self.name} ({self.type().name}) - {self.membership_parameters}'
[docs] def shape(self) -> str: """ Returns the shape of the fuzzy set. Returns: string. """ return 'trapezoid'
class triangularFS(FS): def __init__(self, name: str, membership_parameters: list[float], domain: list[float]) -> None: super().__init__(name, membership_parameters, domain) def shape(self) -> str: return 'triangular'
[docs] class categoricalFS(FS):
[docs] def __init__(self, name: str, category) -> None: """ Creates a categorical fuzzy set. It gives 1 to the category and 0 to the rest. Use it when the variable is categorical and the categories are known, so that rule inference systems can naturally support both crisp and fuzzy variables. Args: name: string. categories: list of strings. Possible categories. """ self.name = name self.category = category # A categorical set has no numerical domain or membership parameters. self.domain = None self.membership_parameters = None
[docs] def membership(self, x: np.array) -> np.array: """ Computes the membership of a point or vector of elements. Args: x: input values in the referencial domain. """ if isinstance(x, np.ndarray): res = np.equal(x, self.category).astype(float) elif _is_torch_tensor(x): torch = _get_torch() res = torch.eq(x, self.category).float() elif isinstance(x, list): res = [1.0 if elem == self.category else 0.0 for elem in x] elif isinstance(x, pd.Series): res = x.apply(lambda elem: 1.0 if elem == self.category else 0.0) else: # Single value res = 1.0 if x == self.category else 0.0 return res
[docs] def type(self) -> FUZZY_SETS: """ Returns the corresponding fuzzy set type according to FUZZY_SETS enum. """ return FUZZY_SETS.t1
[docs] def __str__(self) -> str: """ Returns the name of the fuzzy set, its type and its parameters. Returns: string. """ return f'Categorical set: {self.name}, type 1 output'
[docs] def shape(self) -> str: """ Returns the shape of the fuzzy set. Returns: string. """ return 'categorical'
class IVFS(FS): """ Interval-Valued Fuzzy Set (Type-2 Fuzzy Set) class. This class implements Type-2 fuzzy sets using interval-valued membership functions. Each point in the domain has an interval [lower, upper] representing the uncertainty in the membership degree, providing a more flexible representation than Type-1 sets. Attributes: name (str): Linguistic name of the fuzzy set domain (list[float]): Universe of discourse [min, max] secondMF_lower (list[float]): Parameters for the lower membership function secondMF_upper (list[float]): Parameters for the upper membership function lower_height (float): Height of the lower membership function Example: >>> ivfs = IVFS("medium", [2,3,7,8], [1,2,8,9], [0,10], 0.8) >>> membership = ivfs.membership(5) >>> print(membership) # Returns [lower_membership, upper_membership] Note: The upper membership function should be contained within the lower membership function to maintain mathematical consistency of Type-2 fuzzy sets. """ def __init__(self, name: str, secondMF_lower: list[float], secondMF_upper: list[float], domain: list[float], lower_height=1.0) -> None: """ Initialize an Interval-Valued Fuzzy Set. Args: name (str): Linguistic name for the fuzzy set secondMF_lower (list[float]): Four parameters [a,b,c,d] for lower trapezoidal membership function (outer boundary) secondMF_upper (list[float]): Four parameters [a,b,c,d] for upper trapezoidal membership function (inner boundary) domain (list[float]): Two-element list [min, max] defining universe of discourse lower_height (float, optional): Maximum height of lower membership function. Defaults to 1.0. Raises: ValueError: If membership function parameters are inconsistent or invalid Example: >>> ivfs = IVFS("high", [6,7,9,10], [6.5,7.5,8.5,9.5], [0,10], 0.9) """ self.name = name self.domain = domain if not (secondMF_lower[0] >= secondMF_upper[0]): raise ValueError('First term membership incoherent') if not (secondMF_lower[0] <= secondMF_lower[1] <= secondMF_lower[2] <= secondMF_lower[3]): raise ValueError('Lower memberships incoherent.') if not (secondMF_upper[0] <= secondMF_upper[1] <= secondMF_upper[2] <= secondMF_upper[3]): raise ValueError('Upper memberships incoherent.') if not (secondMF_lower[3] <= secondMF_upper[3]): raise ValueError('Final term memberships incoherent.') self.secondMF_lower = secondMF_lower self.secondMF_upper = secondMF_upper self.lower_height = lower_height def membership(self, x: np.array) -> np.array: """ Computes the iv-membership of a point or a vector. Args: x: input values in the fuzzy set referencial domain. Returns: iv-membership of the fuzzy set. """ lower = trapezoidal_membership( x, self.secondMF_lower) * self.lower_height upper = trapezoidal_membership(x, self.secondMF_upper) return np.stack([lower, upper], axis=-1) def type(self) -> FUZZY_SETS: """ Returns the corresponding fuzzy set type according to FUZZY_SETS enum: (t2) """ return FUZZY_SETS.t2 def __str__(self) -> str: """ Returns the name of the fuzzy set, its type and its parameters. Returns: string. """ return f'{self.name} ({self.type().name}) - {self.secondMF_lower} - {self.secondMF_upper}' class categoricalIVFS(IVFS): """ Class to define a iv fuzzy set with categorical membership. """ def __init__(self, name: str, category) -> None: """ Creates a categorical iv fuzzy set. It gives 1 to the category and 0 to the rest. Use it when the variable is categorical and the categories are known, so that rule inference systems can naturally support both crisp and fuzzy variables. Args: name: string. categories: list of strings. Possible categories. """ self.name = name self.category = category # A categorical set has no numerical domain or membership parameters. self.domain = None self.membership_parameters = None def membership(self, x: np.array) -> np.array: """ Computes the membership of a point or vector of elements. Args: x: input values in the referencial domain. """ if isinstance(x, np.ndarray): res = np.equal(x, self.category).astype(float) res = np.stack([res, res], axis=-1) elif _is_torch_tensor(x): torch = _get_torch() res = torch.eq(x, self.category).float() res = torch.stack([res, res], axis=-1) elif isinstance(x, list): res = [1.0 if elem == self.category else 0.0 for elem in x] res = np.stack([res, res], axis=-1) elif isinstance(x, pd.Series): res = x.apply(lambda elem: 1.0 if elem == self.category else 0.0) res = np.stack([res, res], axis=-1) else: # Single value res = 1.0 if x == self.category else 0.0 res = np.array([res, res]) return res def type(self) -> FUZZY_SETS: """ Returns the corresponding fuzzy set type according to FUZZY_SETS enum. """ return FUZZY_SETS.t2 def __str__(self) -> str: """ Returns the name of the fuzzy set, its type and its parameters. Returns: string. """ return f'Categorical set: {self.name}, type 2 output' def shape(self) -> str: """ Returns the shape of the fuzzy set. Returns: string. """ return 'categorical' class GT2(FS): """ Class to define a gt2 fuzzy set. """ MAX_RES_SUPPORT = 4 # Number of decimals supported in the secondary def __init__(self, name: str, secondary_memberships: dict[float, FS], domain: list[float], significant_decimals: int, alpha_cuts: list[float] = [0.2, 0.4, 0.5, 0.7, 0.9, 1.0], unit_resolution: float = 0.2) -> None: """ Creates a GT2 fuzzy set. Args: name: string. secondary_memberships: list of fuzzy sets. Secondary membership that maps original domain to [0, 1] significant_decimals: number of decimals used to index the secondary memberships. domain: list of two numbers. Limits of the domain if the fuzzy set. alpha_cuts: list of real numbers. Alpha cuts of the fuzzy set. unit_resolution: real number. Resolution of the primary membership function. """ self.name = name self.domain = domain self.secondary_memberships = secondary_memberships self.alpha_cuts = alpha_cuts self.iv_secondary_memberships = {} self.unit_resolution = unit_resolution og_keys = list(self.secondary_memberships.keys()) self.significant_decimals = significant_decimals self.domain_init = int( float(og_keys[0]) * 10**self.significant_decimals) formatted_keys = [('%.' + str(self.significant_decimals) + 'f') % xz for xz in np.array(list(self.secondary_memberships.keys()))] for og_key, formatted_key in zip(og_keys, formatted_keys): secondary_memberships[formatted_key] = self.secondary_memberships.pop( og_key) self.sample_unit_domain = np.arange( 0, 1 + self.unit_resolution, self.unit_resolution) for ix, alpha_cut in enumerate(alpha_cuts): level_memberships = {} array_level_memberships = np.zeros( (len(secondary_memberships.items()), 2)) for jx, (x, fs) in enumerate(secondary_memberships.items()): alpha_primary_memberships = fs.membership( self.sample_unit_domain) alpha_membership = alpha_primary_memberships >= alpha_cut try: b = self.sample_unit_domain[np.argwhere( alpha_membership)[0]][0] c = self.sample_unit_domain[np.argwhere( alpha_membership)[-1]][0] except IndexError: # If no numbers are bigger than alpha, then it is because of rounding errors near 0. So, we fix this manually. b = 0 c = 0 alpha_cut_interval = [b, c] level_memberships[x] = alpha_cut_interval array_level_memberships[jx, :] = np.array(alpha_cut_interval) self.iv_secondary_memberships[alpha_cut] = array_level_memberships def membership(self, x: np.array) -> np.array: """ Computes the alpha cut memberships of a point. Args: x: input values in the fuzzy set referencial domain. Returns: np array samples x alpha_cuts x 2 """ x = np.array(x) # locate the x in the secondary membership formatted_x = ( x * 10**self.significant_decimals).astype(int) - self.domain_init # Once the x is located we compute the function for each alpha cut alpha_cut_memberships = [] for ix, alpha in enumerate(self.alpha_cuts): ivfs = np.squeeze( np.array(self.iv_secondary_memberships[alpha][formatted_x])) alpha_cut_memberships.append(np.squeeze(ivfs)) alpha_cut_memberships = np.array(alpha_cut_memberships) # So that the result is samples x alpha_cuts x 2 return np.swapaxes(alpha_cut_memberships, 0, 1) def type(self) -> FUZZY_SETS: return FUZZY_SETS.gt2 def _alpha_reduction(self, x) -> np.array: """ Computes the type reduction to reduce the alpha cuts to one value. Args: x: array with the values of the inputs. Returns: array with the memberships of the consequents for each sample. """ if len(x.shape) == 3: formatted = np.expand_dims(np.expand_dims( np.array(self.alpha_cuts), axis=1), axis=0) else: formatted = self.alpha_cuts return np.sum(formatted * x, axis=-1) / np.sum(self.alpha_cuts) def alpha_reduction(self, x) -> np.array: """ Computes the type reduction to reduce the alpha cuts to one value. Args: x: array with the values of the inputs. Returns: array with the memberships of the consequents for each sample. """ return self._alpha_reduction(x)
[docs] class gaussianIVFS(IVFS): """ Gaussian Interval-Valued (Type-2) Fuzzy Set Implementation. This class implements a Gaussian interval-valued fuzzy set with two Gaussian membership functions (upper and lower) representing the uncertainty boundaries. This allows for modeling higher-order uncertainty in fuzzy systems. Attributes: secondMF_lower (list): Parameters [mean, std] for the lower membership function secondMF_upper (list): Parameters [mean, std] for the upper membership function name (str): Human-readable name for the fuzzy set universe_size (int): Size of the universe of discourse Example: >>> lower_params = [5.0, 1.0] >>> upper_params = [5.0, 1.5] >>> iv_gauss = gaussianIVFS(lower_params, upper_params, "Medium", 10) >>> membership = iv_gauss.membership(np.array([4.0, 5.0, 6.0])) >>> print(membership.shape) # (3, 2) - lower and upper bounds Note: The interval-valued membership function provides both lower and upper bounds for each input, enabling Type-2 fuzzy reasoning. """
[docs] def __init__(self, name: str, secondMF_lower: list[float], secondMF_upper: list[float], domain: list[float], lower_height=1.0) -> None: """ Initialize a Gaussian interval-valued fuzzy set. Args: name (str): Linguistic name for the fuzzy set secondMF_lower (list[float]): [mean, std] of the lower membership function secondMF_upper (list[float]): [mean, std] of the upper membership function domain (list[float]): Two-element list [min, max] defining universe of discourse lower_height (float, optional): Maximum height of lower membership function. Defaults to 1.0. """ self.name = name self.domain = domain self.secondMF_lower = secondMF_lower self.secondMF_upper = secondMF_upper self.lower_height = lower_height
[docs] def membership(self, input: np.array) -> np.array: """ Computes the Gaussian interval-valued membership values for input points. Returns both lower and upper membership bounds for each input value, enabling Type-2 fuzzy set operations. Args: input (np.array): Input values in the fuzzy set domain Returns: np.array: Array of shape (n, 2) with [lower, upper] bounds for each input Example: >>> iv_gauss = gaussianIVFS([0, 1], [0, 1.5], "Zero", 5) >>> values = iv_gauss.membership(np.array([0.0, 1.0])) >>> print(values.shape) # (2, 2) - 2 inputs, 2 bounds each """ lower = _gaussian2(input, self.secondMF_lower) * self.lower_height upper = _gaussian2(input, self.secondMF_upper) return np.stack([lower, upper], axis=-1)
[docs] def type(self) -> FUZZY_SETS: """ Returns the type of the fuzzy set. Returns: FUZZY_SETS: Always returns FUZZY_SETS.t2 for Type-2 fuzzy sets """ return FUZZY_SETS.t2
[docs] def shape(self) -> str: """ Returns the shape of the fuzzy set. Returns: str: Always returns 'gaussian' """ return 'gaussian'
[docs] class gaussianFS(FS): """ Gaussian Type-1 Fuzzy Set Implementation. This class implements a Gaussian fuzzy set with bell-shaped membership function. Gaussian fuzzy sets are characterized by their mean and standard deviation, providing smooth membership transitions ideal for continuous variables. Attributes: membership_parameters (list): [mean, standard_deviation] defining the Gaussian curve name (str): Human-readable name for the fuzzy set universe_size (int): Size of the universe of discourse Example: >>> params = [5.0, 1.5] # mean=5, std=1.5 >>> gauss_fs = gaussianFS(params, "Medium", 10) >>> membership = gauss_fs.membership(np.array([4.0, 5.0, 6.0])) >>> print(membership) # [0.606, 1.0, 0.606] Note: The membership function follows the formula: μ(x) = exp(-0.5 * ((x - mean) / std)^2) """
[docs] def membership(self, input: np.array) -> np.array: """ Computes the Gaussian membership values for input points. The membership function implements the Gaussian curve formula using the mean and standard deviation parameters. Args: input (np.array): Input values in the fuzzy set domain Returns: np.array: Membership values in range [0, 1] Example: >>> gauss_fs = gaussianFS([0, 1], "Zero", 5) >>> values = gauss_fs.membership(np.array([-1, 0, 1])) >>> print(values) # [0.606, 1.0, 0.606] """ return _gaussian2(input, self.membership_parameters)
[docs] def type(self) -> FUZZY_SETS: """ Returns the type of the fuzzy set. Returns: FUZZY_SETS: Always returns FUZZY_SETS.t1 for Type-1 fuzzy sets """ return FUZZY_SETS.t1
[docs] def shape(self) -> str: """ Returns the shape of the fuzzy set. Returns: str: Always returns 'gaussian' """ return 'gaussian'
#: Operators of a condition on a linguistic variable (see :class:`fuzzyVariable`): ``is`` one term, #: ``at_least`` / ``at_most`` a term of the ordered partition, or ``any_of`` a named compound of terms. CONDITION_OPERATORS = ('is', 'at_least', 'at_most', 'any_of') _OPERATOR_TEXT = {'is': '', 'at_least': 'at least ', 'at_most': 'at most '} def ordinal_alphabet_size(n_terms: int) -> int: """ Number of conditions a variable with ``n_terms`` ordered terms offers when ordinal conditions are on. The ``n_terms`` single terms, then ``at least t_j`` and ``at most t_j`` for ``j = 1 .. n_terms - 2``: ``at least t_0`` is always true (don't care) and ``at least t_(n-1)`` is ``t_(n-1)`` itself, and the same holds for ``at most``, so those are not offered. A variable with fewer than 3 terms keeps its ``n_terms``. """ return n_terms + 2 * (n_terms - 2) if n_terms >= 3 else n_terms def decode_ordinal_condition(code: int, n_terms: int) -> tuple: """ ``(operator, term_index)`` of a condition code of a variable with ``n_terms`` terms. Codes ``0 .. n_terms - 1`` are the single terms (``'is'``); the next ``n_terms - 2`` are ``'at_least'`` terms ``1 .. n_terms - 2``; the last ``n_terms - 2`` are ``'at_most'`` terms ``1 .. n_terms - 2``. """ if not 0 <= code < ordinal_alphabet_size(n_terms): raise ValueError(f'Condition code {code} is outside 0..{ordinal_alphabet_size(n_terms) - 1} ' f'for a variable with {n_terms} terms.') if code < n_terms: return 'is', int(code) extra = code - n_terms if extra < n_terms - 2: return 'at_least', int(extra + 1) return 'at_most', int(extra - (n_terms - 2) + 1) def encode_ordinal_condition(operator: str, term: int, n_terms: int) -> int: """Inverse of :func:`decode_ordinal_condition`.""" if operator not in CONDITION_OPERATORS[:3]: raise ValueError(f'Unknown operator {operator!r}; use one of {list(CONDITION_OPERATORS[:3])}.') if operator == 'is': if not 0 <= term < n_terms: raise ValueError(f'Term {term} is outside 0..{n_terms - 1}.') return int(term) if not 1 <= term <= n_terms - 2: raise ValueError(f"'{operator}' is offered for the terms 1..{n_terms - 2} of a variable with {n_terms} " f'terms; got {term}.') return int(n_terms + (term - 1) + (0 if operator == 'at_least' else n_terms - 2)) def ordinal_memberships(terms: np.ndarray) -> np.ndarray: """ Memberships of every condition of an ordinal variable, from the memberships of its terms. Args: terms: ``(n_terms, ...)`` memberships of the ordered terms; trailing axes (samples, and the lower/upper axis of interval Type-2 sets) are carried along. Returns: ``(ordinal_alphabet_size(n_terms), ...)``: the terms, then ``at least t_j`` and ``at most t_j`` for ``j = 1 .. n_terms - 2``. A union of terms has the bounded sum ``min(1, sum of their memberships)`` (the Lukasiewicz t-conorm). On a strong partition (memberships adding up to 1) this is the exact complement of the other terms, a shouldered trapezoid; on a partition whose memberships add up to more than 1 it saturates at 1; for interval Type-2 sets the lower and upper bounds are summed apart. """ n_terms = terms.shape[0] if n_terms < 3: return terms at_most = np.minimum(1.0, np.cumsum(terms, axis=0)[1:n_terms - 1]) at_least = np.minimum(1.0, np.cumsum(terms[::-1], axis=0)[::-1][1:n_terms - 1]) return np.concatenate([terms, at_least, at_most], axis=0) def compound_memberships(terms: np.ndarray, sets: list) -> np.ndarray: """ Memberships of named compound terms: the bounded sum ``min(1, sum of the listed terms)`` of each set. Args: terms: ``(n_terms, ...)`` memberships of the terms (as in :func:`ordinal_memberships`). sets: one sequence of term indexes per compound. The terms need not be contiguous: a union such as ``[last, first]`` is two shoulders of the axis, one at each end. Returns: ``(len(sets), ...)``. """ return np.stack([np.minimum(1.0, terms[list(indexes)].sum(axis=0)) for indexes in sets], axis=0) def has_extra_conditions(variable) -> bool: """Whether a variable offers conditions beyond its single terms (ordinal ones or named compound terms).""" return bool(getattr(variable, 'ordinal', False)) or bool(getattr(variable, 'compound', ())) def condition_count(variable) -> int: """Number of conditions (term codes) a variable offers: its terms, plus the extra ones if it has them.""" if has_extra_conditions(variable): return variable.n_conditions() names = getattr(variable, 'linguistic_variable_names', None) # also for variable-like objects without __len__ return len(names()) if names is not None else len(variable) def condition_label(variable, code: int) -> str: """Text of condition ``code`` of ``variable``: the term name, ``at least <term>``, ``at most <term>`` or the name of a compound term.""" if has_extra_conditions(variable): return variable.condition_names()[int(code)] return variable.linguistic_variable_names()[code] def _term_position(fuzzy_set): """Where a Type-1 term sits on the axis (centre of its core or mean), or None if unknown.""" parameters = getattr(fuzzy_set, 'membership_parameters', None) if parameters is None: return None parameters = np.asarray(parameters, dtype=float).ravel() if isinstance(fuzzy_set, FS) and not isinstance(fuzzy_set, gaussianFS) and len(parameters) == 4: return float((parameters[1] + parameters[2]) / 2) if isinstance(fuzzy_set, gaussianFS): return float(parameters[0]) return None _FORBIDDEN_NAME_WORDS = ('AND', 'IS', 'WITH', 'THEN') def check_condition_options(ordinal_conditions, compound_terms) -> None: """ Reject a malformed ``ordinal_conditions`` or ``compound_terms`` argument of an estimator. ``ordinal_conditions`` is a bool or a collection (list, tuple, set, array) of features, by name or index; ``compound_terms`` is None or a mapping from a feature to a list of ``(name, [terms])``. """ if not (ordinal_conditions is None or isinstance(ordinal_conditions, (bool, np.bool_))): if isinstance(ordinal_conditions, (str, bytes, dict)) or not hasattr(ordinal_conditions, '__iter__'): raise ValueError('ordinal_conditions must be True, False or a collection of features (names or indexes), ' f'got {ordinal_conditions!r}.') if compound_terms is not None and not hasattr(compound_terms, 'items'): raise ValueError('compound_terms must be None or a mapping from a feature (name or index) to a list of ' f'(name, [terms]), got {type(compound_terms).__name__}.') def conditions_requested(ordinal_conditions, compound_terms) -> bool: """Whether any ordinal or compound condition is asked for (False, None, an empty collection: none).""" if isinstance(ordinal_conditions, (bool, np.bool_)): ordinal = bool(ordinal_conditions) else: ordinal = ordinal_conditions is not None and len(list(ordinal_conditions)) > 0 return ordinal or bool(compound_terms) def _resolve_feature(key, names: list, what: str) -> int: """Index of the feature ``key`` (a name in one of the ``names`` lists, or an integer index).""" if isinstance(key, (int, np.integer)) and not isinstance(key, (bool, np.bool_)): if not 0 <= int(key) < len(names[0]): raise ValueError(f'{what}: feature index {int(key)} is outside 0..{len(names[0]) - 1}.') return int(key) for candidates in names: if key in list(candidates): return list(candidates).index(key) raise ValueError(f'{what}: unknown feature {key!r}; use one of {list(names[0])} or an index.') def resolve_features(selection, names: list, what: str = 'ordinal_conditions') -> set: """ Indexes of the features in ``selection``, a collection of names or indexes. Args: selection: collection of feature names or indexes. names: lists of names to match, each with one name per feature (for example the variable names and the column names of the data); the first one is shown in errors. """ out = set() for key in selection: out.add(_resolve_feature(key, names, what)) return out def resolve_compound_entries(entries, term_names: list, variable_name, ordinal_sets: dict = None, existing: tuple = ()) -> tuple: """ Validate the compound terms of one variable and return them as ``((name, (term indexes...)), ...)``. Args: entries: ``[(name, [terms])...]`` with each term a term name or an index. term_names: names of the variable's terms. variable_name: for the error messages. ordinal_sets: ``{name: set of term indexes}`` of the ordinal conditions the variable offers, if any. existing: compounds the variable already has (same name and set is accepted again). Raises: ValueError: for fewer than 2 distinct terms, every term of the variable, an unknown term, a name that is not usable or already taken, a set listed twice, or the set of an ordinal condition. """ n_terms = len(term_names) out = list(existing) new_names = set() taken_names = set(map(str, term_names)) | set((ordinal_sets or {}).keys()) for entry in entries: try: name, terms = entry except (TypeError, ValueError): raise ValueError(f'Variable {variable_name!r}: a compound term is (name, [terms]); got {entry!r}.') from None if isinstance(terms, (str, bytes)) or not hasattr(terms, '__iter__'): raise ValueError(f'Variable {variable_name!r}: the terms of compound {name!r} must be a list of term ' f'names or indexes, got {terms!r}.') if not isinstance(name, str) or not name.strip() or name != name.strip(): raise ValueError(f'Variable {variable_name!r}: a compound term needs a non-empty name without ' f'leading or trailing spaces, got {name!r}.') words = name.split() if (any(ch in name for ch in ';\n\r()') or any(word in _FORBIDDEN_NAME_WORDS for word in words) or name.startswith(('at least ', 'at most '))): raise ValueError(f"Variable {variable_name!r}: compound name {name!r} cannot contain ';', line breaks, " "parentheses or the words AND, IS, WITH, THEN, nor start with 'at least' or 'at most' " '(the rule text must stay readable back).') indexes = [] for term in terms: if isinstance(term, (int, np.integer)) and not isinstance(term, (bool, np.bool_)): if not 0 <= int(term) < n_terms: raise ValueError(f'Variable {variable_name!r}: compound {name!r} lists term index {int(term)}, ' f'outside 0..{n_terms - 1}.') indexes.append(int(term)) elif term in list(map(str, term_names)) or term in list(term_names): indexes.append([str(t) for t in term_names].index(str(term))) else: raise ValueError(f'Variable {variable_name!r}: compound {name!r} lists unknown term {term!r}; ' f'the terms are {list(term_names)}.') if len(set(indexes)) != len(indexes): raise ValueError(f'Variable {variable_name!r}: compound {name!r} lists a term twice.') if len(indexes) < 2: raise ValueError(f'Variable {variable_name!r}: compound {name!r} needs at least 2 distinct terms ' '(one term is already a condition).') if len(indexes) == n_terms: raise ValueError(f'Variable {variable_name!r}: compound {name!r} lists every term, so it is always true ' '(a don\'t care).') key = tuple(sorted(indexes)) if name in new_names: raise ValueError(f'Variable {variable_name!r}: compound name {name!r} is used twice; the name of a ' 'compound term must be unique within its variable.') new_names.add(name) same_name = [item for item in existing if item[0] == name] if same_name: if same_name[0][1] == key: continue # the same compound again (a refit over variables that already have it) raise ValueError(f'Variable {variable_name!r}: compound name {name!r} is already used by another ' 'compound term of the variable; names must be unique within a variable.') if name in taken_names: raise ValueError(f'Variable {variable_name!r}: compound name {name!r} is already the name of a term or of ' 'an ordinal condition of the variable; names must be unique within a variable.') for other_name, other_key in out: if other_key == key: raise ValueError(f'Variable {variable_name!r}: compounds {other_name!r} and {name!r} list the same terms.') for ordinal_name, ordinal_key in (ordinal_sets or {}).items(): if tuple(sorted(ordinal_key)) == key: raise ValueError(f'Variable {variable_name!r}: compound {name!r} covers the same terms as the ordinal ' f'condition {ordinal_name!r}, which the variable already has.') out.append((name, key)) return tuple(out) def _ordinal_sets(term_names: list) -> dict: """``{condition name: set of term indexes}`` of the ordinal conditions of a variable with these terms.""" n_terms = len(term_names) sets = {} for code in range(n_terms, ordinal_alphabet_size(n_terms)): operator, term = decode_ordinal_condition(code, n_terms) sets[_OPERATOR_TEXT[operator] + str(term_names[term])] = ( set(range(term, n_terms)) if operator == 'at_least' else set(range(term + 1))) return sets def enable_conditions(variables: list, ordinal_conditions=True, compound_terms=None, names: list = None) -> list: """ Copies of ``variables`` that offer ordinal and/or named compound conditions, ready for the search. Args: variables: list of :class:`fuzzyVariable`. ordinal_conditions: ``True`` for ``at least`` / ``at most`` conditions on every variable that can have them (categorical variables and variables with fewer than 3 terms keep single terms); a collection of features (names or indexes) for those features only, which must then be able to have them; ``False`` or ``None`` for none. compound_terms: None, or a mapping from a feature (name or index) to ``[(name, [terms])...]``, each entry a new condition whose membership is the bounded sum of the listed terms (names or indexes). names: further lists of feature names to match features against (the variable names always are). Returns: a list of the same length: variables that gain nothing are returned as they are, and the originals are never changed (the copies share their fuzzy sets). Raises: ValueError: for general Type-2 sets, an unknown feature or term, and every invalid compound term (see :func:`resolve_compound_entries`). """ check_condition_options(ordinal_conditions, compound_terms) all_names = [[variable.name for variable in variables]] + [list(item) for item in (names or [])] if isinstance(ordinal_conditions, (bool, np.bool_)) or ordinal_conditions is None: explicit = set() everywhere = bool(ordinal_conditions) else: explicit = resolve_features(ordinal_conditions, all_names, 'ordinal_conditions') everywhere = False compound = {} for key, entries in (compound_terms or {}).items(): feature = _resolve_feature(key, all_names, 'compound_terms') if feature in compound: raise ValueError(f'compound_terms: feature {all_names[0][feature]!r} is given twice.') compound[feature] = list(entries) out = [] for feature, variable in enumerate(variables): want_ordinal = (everywhere or feature in explicit) if want_ordinal and feature in explicit and not variable.ordinal_eligible(strict=True): raise ValueError(f'ordinal_conditions: variable {variable.name!r} cannot offer at least / at most ' 'conditions (it needs 3 or more terms that are not categorical).') if want_ordinal and not variable.ordinal_eligible(strict=True): want_ordinal = False # a bool True skips such variables quietly entries = compound.get(feature, ()) if entries and variable.fs_type == FUZZY_SETS.gt2: raise ValueError('Compound terms support Type-1 and interval Type-2 sets, not general Type-2 sets.') ordinal = bool(variable.ordinal) or want_ordinal if ordinal == bool(variable.ordinal) and not entries: out.append(variable) continue copy = fuzzyVariable(variable.name, variable.linguistic_variables, variable.units) copy._set_conditions(ordinal, entries, existing=variable.compound) out.append(copy) return out def enable_ordinal_conditions(variables: list) -> list: """ Copies of ``variables`` that offer ``at least`` / ``at most`` conditions (see :func:`enable_conditions`). Variables that cannot have them are returned as they are: categorical ones (their categories are not ordered) and those with fewer than 3 terms. A variable that already has them is returned as it is. Raises: ValueError: for general Type-2 sets, whose memberships are not a number or an interval per term. """ return enable_conditions(variables, True)
[docs] class fuzzyVariable(): """ Fuzzy Variable Container and Management Class. This class represents a fuzzy variable composed of multiple fuzzy sets (linguistic variables). It provides methods to compute memberships across all fuzzy sets and manage the linguistic terms of the variable. Attributes: linguistic_variables (list): List of fuzzy sets that define the variable name (str): Name of the fuzzy variable units (str): Units of measurement (optional, for display purposes) fs_type (FUZZY_SETS): Type of fuzzy sets (t1 or t2) Example: >>> # Create fuzzy sets for temperature >>> low_temp = gaussianFS("Low", [15, 5], [0, 50]) >>> medium_temp = gaussianFS("Medium", [25, 5], [0, 50]) >>> high_temp = gaussianFS("High", [35, 5], [0, 50]) >>> >>> # Create fuzzy variable >>> temp_var = fuzzyVariable("Temperature", [low_temp, medium_temp, high_temp], "°C") >>> memberships = temp_var.compute_memberships([20, 25, 30]) # or temp_var([20, 25, 30]) >>> print(memberships.shape) # (3, 3) - (fuzzy sets, inputs) Note: All fuzzy sets in the variable must be of the same type (t1 or t2). The terms are ordered as they are listed, from low to high. With ``ordinal_conditions=True`` the variable also offers the conditions ``at least t_j`` and ``at most t_j`` (``j = 1 .. n - 2``), whose membership is the bounded sum ``min(1, sum of the memberships of the terms they cover)``; see :func:`ordinal_alphabet_size`. A condition is named by an integer code: the term index for a single term, then the ``at least`` codes, then the ``at most`` ones, then the named compound terms (:meth:`condition`). ``compound_terms=[(name, [terms])...]`` adds one condition per entry, whose membership is the bounded sum of the listed terms, which need not be contiguous (a union that wraps around, such as ``[last, first]``, is two shoulders, one at each end of the axis). Off by default, and then nothing about the variable changes. """ #: Class default, so that variables pickled before ordinal conditions existed load as they were. ordinal = False #: Named compound terms, ``((name, (term indexes...)), ...)``; empty unless asked for. compound = ()
[docs] def __init__(self, name: str, fuzzy_sets: list[FS], units: str = None, ordinal_conditions: bool = False, compound_terms: list = None) -> None: """ Creates a fuzzy variable with the specified fuzzy sets. Args: name (str): Name of the fuzzy variable fuzzy_sets (list[FS]): List of fuzzy sets that comprise the linguistic variables units (str, optional): Units of the fuzzy variable for display purposes ordinal_conditions (bool, optional): offer ``at least`` / ``at most`` conditions over the ordered terms (default False). Needs 3 or more Type-1 or interval Type-2 terms that are not categorical. compound_terms (list, optional): ``[(name, [terms])...]``, each entry one more condition whose membership is the bounded sum of the listed terms (names or indexes): at least 2 distinct terms and not all of them; the sets must differ from each other and from the ordinal conditions of the variable. Raises: ValueError: If fuzzy_sets is empty or contains mixed types, or ``ordinal_conditions`` is asked of a variable that cannot have them Example: >>> fs1 = gaussianFS([0, 1], "Low", 10) >>> fs2 = gaussianFS([5, 1], "High", 10) >>> var = fuzzyVariable("Speed", [fs1, fs2], "m/s") """ self.linguistic_variables = [] self.name = name self.units = units for ix, fs in enumerate(fuzzy_sets): self.linguistic_variables.append(fs) self.fs_type = self.linguistic_variables[0].type() if ordinal_conditions: if not self.ordinal_eligible(strict=True): raise ValueError('ordinal_conditions needs at least 3 terms that are not categorical ' f'(variable {name!r}).') if ordinal_conditions or compound_terms: self._set_conditions(bool(ordinal_conditions), compound_terms or ())
def _set_conditions(self, ordinal: bool, compound_entries, existing: tuple = ()) -> None: """Validate and set the ordinal flag and the compound terms (``existing`` ones are kept).""" if self.fs_type == FUZZY_SETS.gt2: raise ValueError('Ordinal and compound conditions support Type-1 and interval Type-2 sets, not general ' 'Type-2 sets.') term_names = self.linguistic_variable_names() ordinal_sets = _ordinal_sets(term_names) if ordinal and len(term_names) >= 3 else {} compound = resolve_compound_entries(compound_entries, term_names, self.name, ordinal_sets, tuple(existing)) for compound_name, key in compound: # the existing ones too: an ordinal flag may be new for ordinal_name, ordinal_key in ordinal_sets.items(): if tuple(sorted(ordinal_key)) == key: raise ValueError(f'Variable {self.name!r}: compound {compound_name!r} covers the same terms as ' f'the ordinal condition {ordinal_name!r}, which the variable already has.') self.compound = compound if ordinal and not self.ordinal: self.ordinal = True self._warn_if_unordered()
[docs] def ordinal_eligible(self, strict: bool = False) -> bool: """ Whether this variable can offer ordinal conditions: 3 or more ordered, non-categorical Type-1 or interval Type-2 terms. With ``strict`` a general Type-2 variable raises instead of returning False. """ if self.fs_type == FUZZY_SETS.gt2: if strict: raise ValueError('Ordinal conditions support Type-1 and interval Type-2 sets, not general Type-2 sets.') return False if isinstance(self.linguistic_variables[0], (categoricalFS, categoricalIVFS)): return False return len(self.linguistic_variables) >= 3
def _warn_if_unordered(self) -> None: """Warn when the terms are not listed from low to high, which is what ``at least`` / ``at most`` read.""" positions = [_term_position(term) for term in self.linguistic_variables] if None not in positions and any(b < a for a, b in zip(positions, positions[1:])): warnings.warn(f'The terms of variable {self.name!r} are not listed from low to high; ' "'at least' and 'at most' follow the listed order.", stacklevel=3) def _ordinal_size(self) -> int: """Codes of the terms and the ordinal conditions (the compound terms come after them).""" n_terms = len(self.linguistic_variables) return ordinal_alphabet_size(n_terms) if self.ordinal else n_terms
[docs] def n_conditions(self) -> int: """Number of conditions (term codes) the variable offers: its terms, then the ordinal ones if enabled, then the named compound terms.""" return self._ordinal_size() + len(self.compound)
[docs] def condition(self, code: int) -> tuple: """ ``(operator, reference)`` of a condition code. The operator is ``'is'`` (reference: term index), ``'at_least'`` or ``'at_most'`` (reference: term index ``t_j``) or ``'any_of'`` (reference: position of the compound in ``self.compound``). """ code = int(code) size = self._ordinal_size() if code >= size: if code >= size + len(self.compound): raise ValueError(f'Condition code {code} is outside 0..{self.n_conditions() - 1}.') return 'any_of', code - size if not self.ordinal: if not 0 <= code < len(self.linguistic_variables): raise ValueError(f'Condition code {code} is outside 0..{len(self.linguistic_variables) - 1}.') return 'is', int(code) return decode_ordinal_condition(code, len(self.linguistic_variables))
[docs] def condition_code(self, operator: str, term) -> int: """Code of the condition ``operator`` + term index (inverse of :meth:`condition`); for ``'any_of'`` ``term`` is the name of the compound term (or its position).""" if operator == 'any_of': names = [name for name, _ in self.compound] position = names.index(term) if term in names else term if not isinstance(position, (int, np.integer)) or not 0 <= position < len(names): raise ValueError(f'Variable {self.name!r} has no compound term {term!r}; it has {names}.') return self._ordinal_size() + int(position) if not self.ordinal and operator != 'is': raise ValueError(f"Variable {self.name!r} has no ordinal conditions; cannot encode '{operator}'.") return encode_ordinal_condition(operator, term, len(self.linguistic_variables))
[docs] def condition_terms(self, code: int) -> list: """Indexes of the terms a condition covers: one for ``is``, ``t_j .. t_(n-1)`` for ``at_least t_j``, ``t_0 .. t_j`` for ``at_most t_j``, the listed terms (low to high) for ``any_of``.""" operator, term = self.condition(code) if operator == 'is': return [term] if operator == 'at_least': return list(range(term, len(self.linguistic_variables))) if operator == 'any_of': return list(self.compound[term][1]) return list(range(term + 1))
[docs] def condition_names(self) -> list: """Text of every condition, by code: the term names, then ``at least <term>``, then ``at most <term>``, then the names of the compound terms. Without extra conditions this is :meth:`linguistic_variable_names`.""" names = self.linguistic_variable_names() if not self.ordinal and not self.compound: return names out = list(names) for code in range(len(names), self._ordinal_size()): operator, term = self.condition(code) out.append(_OPERATOR_TEXT[operator] + str(names[term])) return out + [name for name, _ in self.compound]
[docs] def condition_memberships(self, terms: np.ndarray) -> np.ndarray: """Memberships of every condition from those of the terms, ``(n_terms, ...)`` to ``(n_conditions, ...)``.""" parts = [terms] if self.ordinal: parts.append(ordinal_memberships(terms)[terms.shape[0]:]) if self.compound: parts.append(compound_memberships(terms, [indexes for _, indexes in self.compound])) return np.concatenate(parts, axis=0) if len(parts) > 1 else terms
[docs] def append(self, fs: FS) -> None: """ Appends a fuzzy set to the fuzzy variable. Args: fs: FS. Fuzzy set to append. """ self.linguistic_variables.append(fs)
[docs] def linguistic_variable_names(self) -> list: """ Returns the name of the linguistic variables. Returns: list of strings. """ return [fs.name for fs in self.linguistic_variables]
[docs] def get_linguistic_variables(self) -> list[FS]: """ Returns the name of the linguistic variables. Returns: list of strings. """ return self.linguistic_variables
[docs] def compute_memberships(self, x: np.array) -> list: """ Computes the membership to each of the FS in the fuzzy variables. Args: x: numeric value or array. Computes the membership to each of the FS in the fuzzy variables. Returns: list of floats. Membership to each of the FS in the fuzzy variables. With ordinal conditions there is one more row per extra condition, after the terms (see :func:`ordinal_memberships` and :func:`compound_memberships`). """ res = self._term_memberships(x) if self.ordinal or self.compound: res = self.condition_memberships(res) return res
def _term_memberships(self, x: np.array) -> np.ndarray: """Memberships to the terms only, whatever the conditions on offer.""" res = [] # Categorical variables, and sets without a domain, are not clipped. domain = getattr(self.linguistic_variables[0], 'domain', None) if domain is not None: x = np.clip(x, domain[0], domain[1]) for fuzzy_set in self.linguistic_variables: res.append(fuzzy_set.membership(x)) return np.array(res)
[docs] def domain(self) -> list[float]: """ Returns the domain of the fuzzy variable. Returns: list of floats, or None for a categorical variable. """ return self.linguistic_variables[0].domain
[docs] def fuzzy_type(self) -> FUZZY_SETS: """ Returns the fuzzy type of the domain Returns: the type of the fuzzy set present in the fuzzy variable. """ return self.fs_type
def _permutation_validation(self, mu_A:np.array, mu_B:np.array, p_value_need:float=0.05) -> bool: """ Validates the fuzzy variable using permutation test to check if the fuzzy sets are statistically different. Args: mu_A: np.array. Memberships of the first fuzzy set. mu_B: np.array. Memberships of the second fuzzy set. Returns: bool. True if the fuzzy sets are statistically different, False otherwise. """ from scipy.stats import permutation_test # Perform permutation test result = permutation_test( (mu_A, mu_B), lambda x, y: np.mean(np.abs(x - y)) / (np.sqrt(np.var(x, ddof=1) * np.var(y, ddof=1)) + 1e-10), n_resamples=1000 ) statistic, p_value, null_distribution = result.statistic, result.pvalue, result.null_distribution return p_value < p_value_need
[docs] def validate(self, X, verbose:bool=False) -> bool: """ Validates the fuzzy variable. Checks that all the fuzzy sets have the same type and domain. A categorical variable is valid by definition: it has one crisp fuzzy set per category, so there is nothing to check. The properties below are only defined for numerical Type-1 variables, over crisp memberships along a numerical domain, and an interval or general Type-2 variable answers with one membership interval per sample instead of a single value. Args: X: np.array. Input data to validate the fuzzy variable. Returns: bool. True if the fuzzy variable is valid, False otherwise. Raises: NotImplementedError: if the fuzzy variable is numerical and not Type-1. """ if any(fset.shape() == 'categorical' for fset in self.linguistic_variables): if verbose: print('Fuzzy variable ' + self.name + ' is categorical: valid by definition.') return True if self.fs_type != FUZZY_SETS.t1: raise NotImplementedError( 'Fuzzy variable validation is only implemented for Type-1 fuzzy sets, ' f'but {self.name} uses {self.fs_type.name}.') if len(self.linguistic_variables) == 0: return False # Get the fuzzy sets memberships memberships = self._term_memberships(X) memberships = np.array(memberships) cond1 = True # Property 1: Only one of the fuzzy sets memberships can be 1 at the same time if np.equal(memberships, 1).sum(axis=0).max() > 1: cond1 = False if not cond1 and verbose: print('Property 1 violated: More than one fuzzy set has a membership of 1 at the same time.') # Property 2: All fuzzy sets are fuzzy numbers is fullfilled if they are trapezoidal or gaussian cond2 = all([fs.shape() in ['trapezoid', 'triangular', 'gaussian'] for fs in self.linguistic_variables]) if not cond2 and verbose: print('Property 2 violated: Not all fuzzy sets are fuzzy numbers (trapezoidal or gaussian).') # Property 3: At least one fuzzy set must non zero in every point of the domain cond3 = np.any(memberships > 0, axis=0).all() if not cond3 and verbose: print('Property 3 violated: At least one fuzzy set must be non-zero in every point of the domain.') # Property 4 (a fuzzy set can only grow where the previous one decreases) is not checked: # between two samples that straddle a peak, a set looks like it is still growing, so the # sampled memberships flag well ordered partitions as violations. # Property 5: The smallest fuzzy set must be the first one and the biggest fuzzy set must be the last one. The smallest should start at the left of the domain and the biggest should end at the right of the domain. cond5 = (self._term_memberships(self[0].domain[0])[0] >= 0.99) and (self._term_memberships(self[0].domain[1])[-1] >= 0.99) if not cond5 and verbose: print('Property 5 violated: The smallest fuzzy set must be the first one and the biggest fuzzy set must be the last one. The smallest should start at the left of the domain and the biggest should end at the right of the domain.') # Property 6: The population of the fuzzy sets-induced memberships must be statistically different from each other cond6 = True if len(self.linguistic_variables) > 1: for i in range(len(self.linguistic_variables) - 1): # Wilcoxon signed-rank test is a paired difference test if not self._permutation_validation(memberships[i, :], memberships[i + 1, :], p_value_need=0.05): cond6 = False break if not cond6 and verbose: print('Property 6 violated: The fuzzy sets must be statistically different from each other. Used permutation test to check this. (' + str(i) + ',' + str(i+1) + ')') valid = cond1 and cond2 and cond3 and cond5 and cond6 if verbose and valid: print('Fuzzy variable ' + self.name + ' is valid.') return valid
[docs] def __str__(self) -> str: """ Returns the name of the fuzzy variable, its type and its parameters. Returns: string. """ return f'{self.name} ({self.fs_type.name}) - {self.linguistic_variable_names()}'
[docs] def __getitem__(self, item) -> FS: """ Returns the corresponding fs. Args: item: int. Index of the FS. Returns: FS. The corresponding FS. """ return self.linguistic_variables[item]
[docs] def __setitem__(self, item: int, elem: FS) -> None: """ Sets the corresponding fs. Args: item: int. Index of the FS. elem: FS. The FS to set. """ self.linguistic_variables[item] = elem
[docs] def __iter__(self) -> Generator[FS, None, None]: """ Returns the corresponding fs. Args: item: int. Index of the FS. Returns: FS. The corresponding FS. """ for fs in self.linguistic_variables: yield fs
[docs] def __len__(self) -> int: """ Returns the number of linguistic variables. Returns: int. Number of linguistic variables. """ return len(self.linguistic_variables)
[docs] def __call__(self, x: np.array) -> list: """ Computes the membership to each of the FS in the fuzzy variables. Args: x: numeric value or array. Returns: list of floats. Membership to each of the FS in the fuzzy variables. """ return self.compute_memberships(x)