Source code for axisfuzzy.core.fuzzarray

#  Copyright (c) yibocat 2025 All Rights Reserved
#  Python: 3.12.7
#  Date: 2025/8/18 18:23
#  Author: yibow
#  Email: yibocat@yeah.net
#  Software: AxisFuzzy
"""
High-level Fuzznum container built on a Struct-of-Arrays (SoA) backend.

This module exposes the :class:`Fuzzarray` class, which provides ndarray-like API
semantics for collections of fuzzy numbers while delegating storage and
bulk computation to a specialized :class:`FuzzarrayBackend` implementation.

Overview
--------
- Fuzzarray is the main container for high-dimensional fuzzy numbers.
- It supports efficient batch operations and slicing, similar to numpy arrays.
- All storage and vectorized computation is delegated to a backend, which is
  selected based on the fuzzy number type (`mtype`).

Notes
-----
- The backend system enables high performance by avoiding per-element Python object overhead.
- Operator overloading is supported for elementwise arithmetic and comparison.
- Specialized vectorized operations can be registered for each fuzzy type.

Examples
--------
.. code-block:: python

    from axisfuzzy.core.fuzznums import fuzznum
    from axisfuzzy.core.fuzzarray import fuzzarray

    # Create a 1D Fuzzarray by broadcasting a Fuzznum
    a = fuzznum(mtype='qrofn', q=2, md=0.6, nmd=0.3)
    arr = fuzzarray(a, shape=(3,))

    # Create from a list of Fuzznum
    arr2 = fuzzarray([fuzznum(md=0.1, nmd=0.2), fuzznum(md=0.2, nmd=0.3), fuzznum(md=0.3, nmd=0.4)])

    # Elementwise arithmetic
    result = arr + arr2

    # Comparison yields boolean ndarray
    mask = arr > arr2
    print(mask)
"""

from typing import Optional, Union, Any, Tuple, Iterator, Dict
import numpy as np

from ..config import get_config
from ..utils import deprecated

from .fuzznums import Fuzznum
from .backend import FuzzarrayBackend
from .registry import get_registry_fuzztype


[docs] class Fuzzarray: """ High-performance fuzzy array using Struct of Arrays (SoA) architecture. The Fuzzarray provides an ndarray-like interface for collections of fuzzy numbers while delegating memory layout and vectorized computations to a backend class (see :class:`FuzzarrayBackend`). Parameters ---------- data : array-like, Fuzznum or None, optional Input content used to initialize the array. Accepted types: - None : create an empty backend (requires ``shape``). - Fuzznum : broadcast a single fuzznum to fill the target shape. - list/tuple/numpy.ndarray of Fuzznum : element-wise initialization. backend : FuzzarrayBackend, optional Pre-constructed backend instance. When provided, ``data`` is ignored. mtype : str, optional Membership type name (e.g. ``'qrofn'``). If omitted, the default from configuration is used. q : int, optional q-rung parameter for q-rung based mtypes. If omitted, default is used. shape : tuple of int, optional Target logical shape for the array (required when ``data`` is None). **kwargs Additional backend/mtype-specific keyword arguments. Examples -------- .. code-block:: python from axisfuzzy.core.fuzznums import fuzznum from axisfuzzy.core.fuzzarray import fuzzarray # Create a 2D Fuzzarray from a list of lists arr = fuzzarray([[fuzznum(md=0.1, nmd=0.2), fuzznum(md=0.2, nmd=0.3)], [fuzznum(md=0.3, nmd=0.4), fuzznum(md=0.4, nmd=0.5)]]) # Indexing returns Fuzznum or Fuzzarray print(arr[0, 1]) # Fuzznum print(arr[0:1]) # Fuzzarray # Elementwise operations arr2 = arr + 0.1 # Broadcasting supported if operation registered # Copy arr3 = arr.copy() """ def __init__(self, data: Optional[Union[np.ndarray, list, tuple, Fuzznum, "Fuzzarray"]] = None, backend: Optional[FuzzarrayBackend] = None, mtype: Optional[str] = None, q: Optional[int] = None, shape: Optional[Tuple[int, ...]] = None, **kwargs): """ Initialize Fuzzarray with either data or existing backend. Parameters ---------- data : array-like or Fuzznum or None, optional Input data to populate the Fuzzarray. backend : FuzzarrayBackend, optional If provided, used directly as the storage backend. mtype : str, optional Membership type string. q : int, optional q-rung parameter. shape : tuple of int, optional Desired shape when constructing from no data or a scalar. **kwargs : dict Extra parameters forwarded to backend constructor. """ # This attribute will hold a reference to the original array if this is a transpose self._transposed_of: Optional['Fuzzarray'] = None # Direct backend assignment - fast path if backend is not None: self._backend = backend self._mtype = backend.mtype self._q = backend.q self._kwargs = backend.kwargs return else: backend, mtype, q = self._build_backend_from_data(data, shape, mtype, q, **kwargs) self._backend = backend self._mtype = mtype self._q = q self._kwargs = kwargs # # Construct from data # self._mtype = get_config().DEFAULT_MTYPE if mtype is None else mtype # self._q = q if q is not None else get_config().DEFAULT_Q # self._kwargs = kwargs # self._backend = self._create_backend_from_data(data, shape) def _build_backend_from_data(self, data: Optional[Union[np.ndarray, list, tuple, Fuzznum, "Fuzzarray"]], shape: Optional[Tuple[int, ...]], mtype: Optional[str], q: Optional[int], **kwargs) -> Tuple[FuzzarrayBackend, str, int]: registry = get_registry_fuzztype() if mtype is None: if data is not None: if isinstance(data, Union[Fuzznum, Fuzzarray]): mtype = data.mtype elif isinstance(data, Union[np.ndarray, list, tuple]): data_array = np.asarray(data) if data_array.size > 0: mtype = data_array.flatten()[0].mtype else: mtype = get_config().DEFAULT_MTYPE else: mtype = mtype.lower() if q is None: if data is not None: if isinstance(data, Union[Fuzznum, Fuzzarray]): q = data.q elif isinstance(data, Union[np.ndarray, list, tuple]): data_array = np.asarray(data) if data_array.size > 0: q = data_array.flatten()[0].q else: q = get_config().DEFAULT_Q else: q = int(q) backend_cls = registry.get_backend(mtype) if backend_cls is None: raise ValueError(f"No backend registered for mtype '{mtype}'") # Case 1: data is None → 默认空数组 if data is None: shape = shape or (0,) backend = backend_cls(shape=shape, q=q, **kwargs) return backend, mtype, q # Case 2: data is already a Fuzzarray if isinstance(data, Fuzzarray): # copy backend backend = data.backend.copy() return backend, data.mtype, data.q # Case 3: Single Fuzznum if isinstance(data, Fuzznum): shape = shape or () # default scalar backend = backend_cls(shape=shape, q=data.q, **kwargs) for idx in np.ndindex(shape): backend.set_fuzznum_data(idx, data) return backend, mtype, data.q # Case 3: list/tuple/ndarray of Fuzznum if isinstance(data, (list, tuple, np.ndarray)): if not isinstance(data, np.ndarray): data = np.array(data, dtype=object) # 空 → shape 汲取自 data.shape,默认为 (0,) if data.size == 0: shape = shape or data.shape backend = backend_cls(shape=shape, q=q, **kwargs) return backend, mtype, q shape = shape or data.shape if data.shape != shape: try: data = data.reshape(shape) except ValueError: raise ValueError(f"Cannot reshape array of size {data.size} into shape {shape}") flat = data.flatten() if not all(isinstance(item, Fuzznum) for item in flat): raise TypeError(f"All elements must be Fuzznum, found {type(flat[0])}") q = flat[0].q backend = backend_cls(shape=shape, q=q, **kwargs) for idx in np.ndindex(shape): backend.set_fuzznum_data(idx, data[idx]) return backend, mtype, q # Unsupported raise TypeError(f"Unsupported data type for Fuzzarray creation: {type(data)}") # ========================= Properties ========================= @property def backend(self) -> FuzzarrayBackend: """Access to the underlying backend.""" return self._backend @property def md(self) -> np.ndarray: return self._backend.mds @property def nmd(self) -> np.ndarray: return self._backend.nmds @property def shape(self) -> Tuple[int, ...]: """Shape of the fuzzy array.""" return self._backend.shape @property def ndim(self) -> int: """Number of dimensions.""" return len(self._backend.shape) @property def size(self) -> int: """Total number of elements.""" return self._backend.size @property def mtype(self) -> str: """fuzzy type of fuzzy numbers.""" return self._mtype @property def q(self) -> Optional[int]: """Q-rung parameter (if applicable).""" return self._q @property def kwargs(self) -> Dict[str, Any]: """Additional parameters for the fuzzy type.""" return self._kwargs @property def dtype(self): """Data type (always object for compatibility).""" return object # ========================= Indexing & Access ========================= def __len__(self) -> int: """Length of the first dimension.""" if self.ndim == 0: raise TypeError("len() of unsized object") return self.shape[0] def __getitem__(self, key) -> Union['Fuzznum', 'Fuzzarray']: """ Index or slice the Fuzzarray. - Scalar indexing returns a lightweight Fuzznum view. - Slice / ndarray-style indexing returns a new Fuzzarray (view when backend supports it). Parameters ---------- key : int, tuple, slice or other valid numpy-style index Indexing key. Returns ------- Fuzznum | Fuzzarray Single-element view or a new Fuzzarray for slices. """ def _is_scalar_index(k) -> bool: if isinstance(k, (int, np.integer)): return True elif isinstance(k, tuple): return all(isinstance(i, (int, np.integer)) for i in k) return False if _is_scalar_index(key): # Scalar index: Returns a single Fuzznum view return self._backend.get_fuzznum_view(key) else: # Slice index: return a new Fuzzarray sliced_backend = self._backend.slice_view(key) return Fuzzarray(backend=sliced_backend) def __setitem__(self, key, value): """ Assign a Fuzznum to specified location(s). Parameters ---------- key : index Target location to set. value : Fuzznum Value to assign. Raises ------ TypeError If ``value`` is not a Fuzznum. ValueError If ``value`` has mismatched ``mtype`` or ``q``. """ if isinstance(value, Fuzznum): if value.mtype != self._mtype: raise ValueError(f"Mtype mismatch: expected '{self._mtype}', got '{value.mtype}'") if value.q != self.q: raise ValueError(f"Q parameter mismatch: expected q={self.q}, got q={value.q}") self._backend.set_fuzznum_data(key, value) else: raise TypeError(f"Can only assign Fuzznum or Fuzzarray objects, got {type(value)}") def __delitem__(self, key): raise NotImplementedError("Fuzzarray does not support item deletion.") def __contains__(self, item: Any) -> bool: """ Test membership of a Fuzznum in the array. Parameters ---------- item : Any Object to test for containment. Returns ------- bool True if a matching element exists in the array; False otherwise. Notes ----- - Only objects that are instances of :class:`Fuzznum` with matching ``mtype`` and ``q`` are considered. """ if not isinstance(item, Fuzznum): return False if item.mtype != self._mtype or item.q != self.q: return False for idx in np.ndindex(self.shape): fuzznum_view = self._backend.get_fuzznum_view(idx) if fuzznum_view == item: return True return False def __iter__(self) -> Iterator: """Iterate over the fuzzy array.""" if self.ndim == 0: raise TypeError("Fuzzarray iteration is not supported for zero-dimensional arrays.") for i in range(self.shape[0]): yield self[i] # ========================= Core Operations =========================
[docs] def execute_vectorized_op(self, op_name: str, other=None): """ Execute a vectorized operation using the registered operation handlers. The method queries the global operation registry for the named operation implementation for this Fuzzarray's ``mtype``. If a backend/vectorized specialization exists it is used; otherwise a fallback element-wise path is taken. Parameters ---------- op_name : str Operation name (e.g. ``'add'``, ``'mul'``, ``'gt'``). other : Fuzzarray, Fuzznum, scalar, ndarray or None, optional Second operand. Returns ------- Fuzzarray or numpy.ndarray Result of the vectorized operation. Comparison operations yield boolean numpy arrays; arithmetic operations yield Fuzzarray. """ from .operation import get_registry_operation, OperationMixin from .triangular import OperationTNorm registry = get_registry_operation() op = registry.get_operation(op_name, self.mtype) if op is None: raise NotImplementedError( f"Operation '{op_name}' not registered for mtype '{self.mtype}'") # Get t-norm configuration norm_type, params = registry.get_default_t_norm_config() tnorm = OperationTNorm(norm_type=norm_type, q=self.q or get_config().DEFAULT_Q, **params) # --- Dispatcher Logic --- # Check if the concrete `OperationMixin` subclass has overridden the # `execute_fuzzarray_op` method with a specialized implementation. # This is done by comparing the method's function object to the one # from the base `OperationMixin` class. has_fuzzarray_impl = ( hasattr(op, '_execute_fuzzarray_op_impl') and getattr(op, '_execute_fuzzarray_op_impl').__func__ is not OperationMixin._execute_fuzzarray_op_impl ) if has_fuzzarray_impl: # Path 1: Use the specialized implementation. This path typically # does not involve `np.vectorize` and might be optimized for speed. # Caching is usually handled within the specialized implementation if needed. return op.execute_fuzzarray_op(self, other, tnorm) else: # Path 2: Fallback to a generic element-wise operation. # This uses `np.vectorize` to apply the Fuzznum-level operation # to each element of the Fuzzarray. Caching is handled at the Fuzznum level. return self._fallback_vectorized_op(op, other, tnorm)
def _fallback_vectorized_op(self, operation, other, tnorm): """ Generic element-wise fallback for operations without specialized vectorized implementations. This method applies the per-element Fuzznum operation across all indices and reconstructs a result container. It is slower than a specialized backend implementation and intended as a portability fallback. Parameters ---------- operation : OperationMixin Operation handler object obtained from the registry. other : Fuzzarray, Fuzznum, scalar or None Second operand for the operation. tnorm : OperationTNorm T-norm configuration passed to per-element executions. Returns ------- Fuzzarray or numpy.ndarray Resulting container (Fuzzarray for arithmetic, ndarray of bool for comparisons). """ # For now, use numpy vectorize as fallback def element_op(index): elem1 = self._backend.get_fuzznum_view(index) if other is None: # Unary operation result_dict = elem1.get_strategy_instance().execute_operation( operation.get_operation_name(), None) else: if isinstance(other, Fuzzarray): elem2 = other._backend.get_fuzznum_view(index) result_dict = elem1.get_strategy_instance().execute_operation( operation.get_operation_name(), elem2.get_strategy_instance()) elif isinstance(other, Fuzznum): result_dict = elem1.get_strategy_instance().execute_operation( operation.get_operation_name(), other.get_strategy_instance()) elif isinstance(other, (int, float)): result_dict = elem1.get_strategy_instance().execute_operation( operation.get_operation_name(), other) else: raise TypeError(f"Unsupported operand type: {type(other)}") return result_dict results = [] for idx in np.ndindex(self.shape): results.append(element_op(idx)) # Check if this is a comparison operation (returns bool) if operation.get_operation_name() in ['gt', 'lt', 'ge', 'le', 'eq', 'ne']: bool_results = np.array([r.get('result', False) for r in results]) return bool_results.reshape(self.shape) else: # Create new Fuzzarray from results # This is inefficient but works as fallback new_backend = self._backend.copy() for idx, result in zip(np.ndindex(self.shape), results): new_fuzznum = Fuzznum(mtype=self.mtype, **self._kwargs).create(**result) new_backend.set_fuzznum_data(idx, new_fuzznum) return Fuzzarray(backend=new_backend) # ========================= Operator Overloads ========================= # These methods overload standard Python operators to enable intuitive # arithmetic and comparison operations directly on Fuzzarray objects. # They delegate the actual operation logic to the `dispatcher.operate` function, # which handles type dispatching and calls `execute_vectorized_op` internally. def __add__(self, other: Any) -> 'Fuzzarray': """Implements the addition operator (+).""" from .dispatcher import operate return operate('add', self, other) def __radd__(self, other: Any) -> 'Fuzzarray': """Implements the reverse addition operator (+).""" from .dispatcher import operate return operate('add', self, other) def __sub__(self, other: Any) -> 'Fuzzarray': """Implements the subtraction operator (-).""" from .dispatcher import operate return operate('sub', self, other) def __mul__(self, other: Any) -> 'Fuzzarray': """Implements the multiplication operator (*).""" from .dispatcher import operate return operate('mul', self, other) def __rmul__(self, other: Any) -> 'Fuzzarray': """Implements the reverse multiplication operator (*).""" from .dispatcher import operate return operate('mul', self, other) def __truediv__(self, other: Any) -> 'Fuzzarray': """Implements the true division operator (/).""" from .dispatcher import operate return operate('div', self, other) def __pow__(self, power: Any, modulo: Optional[Any] = None) -> 'Fuzzarray': """Implements the power operator (**).""" from .dispatcher import operate return operate('pow', self, power) def __gt__(self, other: Any) -> np.ndarray: """Implements the greater than operator (>). Returns a boolean NumPy array.""" from .dispatcher import operate return operate('gt', self, other) def __lt__(self, other: Any) -> np.ndarray: """Implements the less than operator (<). Returns a boolean NumPy array.""" from .dispatcher import operate return operate('lt', self, other) def __ge__(self, other: Any) -> np.ndarray: """Implements the greater than or equal to operator (>=). Returns a boolean NumPy array.""" from .dispatcher import operate return operate('ge', self, other) def __le__(self, other: Any) -> np.ndarray: """Implements the less than or equal to operator (<=). Returns a boolean NumPy array.""" from .dispatcher import operate return operate('le', self, other) def __eq__(self, other: Any) -> np.ndarray: """Implements the equality operator (==). Returns a boolean NumPy array.""" from .dispatcher import operate return operate('eq', self, other) def __ne__(self, other: Any) -> np.ndarray: """Implements the inequality operator (!=). Returns a boolean NumPy array.""" from .dispatcher import operate return operate('ne', self, other) def __and__(self, other): """Overloads the and operator (&). intersection operation. """ from .dispatcher import operate return operate('intersection', self, other) def __or__(self, other): from .dispatcher import operate return operate('union', self, other) def __invert__(self): """Overloads the invert operator (~). Complement operation. """ from .dispatcher import operate return operate('complement', self, None) def __lshift__(self, other): """Overloads the left shift operator (<<). Denotes the left implication operation: self <- other """ from .dispatcher import operate return operate('implication', other, self) def __rshift__(self, other): """Overloads the shift operator (>>). Denotes the right implication operation: self -> other """ from .dispatcher import operate return operate('implication', self, other) def __xor__(self, other): """Overloads the xor operator (^). Denotes the symmetric difference operation. """ from .dispatcher import operate return operate('symdiff', self, other)
[docs] def equivalent(self, other): """ Calculate the equivalence level between two fuzzy numbers Corresponding to the "if and only if" operation in classical logic, it represents the degree to which two fuzzy propositions are equivalent to each other. """ from .dispatcher import operate return operate('equivalence', self, other)
def __matmul__(self, other): """Implements matrix multiplication (@).""" from .dispatcher import operate return operate('matmul', self, other) # ========================= Utility Methods =========================
[docs] def copy(self) -> 'Fuzzarray': """Create a deep copy""" # The copy method in the backend already creates new data arrays. copied_backend = self._backend.copy() # The new Fuzzarray is a standalone object, so it has no _transposed_of reference. return Fuzzarray(backend=copied_backend)
def __repr__(self) -> str: if self.size == 0: return f"Fuzzarray([], mtype='{self.mtype}', q={self.q}, shape={self.shape})" formatted = self._backend.format_elements() # Key: prefix='Fuzzarray(' makes the continued line indentation align with the desired left side. array_str = np.array2string( formatted, separator=' ', formatter={'object': lambda x: x}, # type: ignore prefix='Fuzzarray(', max_line_width=100 ) return f"Fuzzarray({array_str}, mtype='{self.mtype}', q={self.q}, shape={self.shape})" def __str__(self) -> str: if self.size == 0: return "[]" formatted = self._backend.format_elements() return np.array2string( formatted, separator=' ', formatter={'object': lambda x: x}, # type: ignore prefix='', max_line_width=90 ) def __format__(self, format_spec: str = "") -> Any: formatted = self._backend.format_elements(format_spec) return np.array2string( formatted, separator=' ', formatter={'object': lambda x: x}, # type: ignore prefix='', max_line_width=80 ) def __bool__(self) -> bool: if self.size > 1: raise ValueError( "The truth value of a Fuzzarray with more than one element is ambiguous. " "Use .any() or .all()" ) if self.size == 1: # For a single element array, its truthiness is determined by the Fuzznum itself. # Fuzznum.__bool__ is True, so this will be True. return bool(self[0]) # For a 0-element array return False def __getstate__(self) -> Dict[str, Any]: """For pickling""" return { 'mtype': self.mtype, 'q': self.q, 'kwargs': self.kwargs, 'backend_state': self.backend.__dict__ # A simple way, might need refinement } def __setstate__(self, state: Dict[str, Any]): """For unpickling""" self._mtype = state['mtype'] self._q = state['q'] self._kwargs = state['kwargs'] registry = get_registry_fuzztype() backend_cls = registry.get_backend(self._mtype) # Reconstruct backend from its state backend_state = state['backend_state'] shape = backend_state['shape'] self._backend = backend_cls(shape=shape, q=self._q, **self._kwargs) # Restore component arrays for key, value in backend_state.items(): if isinstance(value, np.ndarray): setattr(self._backend, key, value)
# ================================= Factory function ================================= # @deprecated(message="Please use 'fuzzyarray' instead.") # def fuzzarray(data=None, # backend: Optional[FuzzarrayBackend] = None, # mtype: Optional[str] = None, # q: Optional[int] = None, # shape: Optional[Tuple[int, ...]] = None, # **mtype_kwargs) -> Fuzzarray: # """ # Factory function to create Fuzzarray instances. # # Parameters # ---------- # data : array-like or Fuzznum or None, optional # Input data to populate the returned Fuzzarray. If ``backend`` is provided, # this argument is ignored. # backend : FuzzarrayBackend, optional # Pre-constructed backend instance. If provided, ``data`` is ignored and # the returned Fuzzarray directly wraps this backend. # mtype : str, optional # Membership type name (e.g. ``'qrofn'``). Required if ``data`` is None and # ``backend`` is not provided. # q : int, optional # q-rung parameter for q-rung based mtypes. # shape : tuple of int, optional # Desired shape when constructing from scalars or empty data. # **mtype_kwargs : dict # Additional mtype-specific parameters forwarded to Fuzzarray. # # Returns # ------- # Fuzzarray # New Fuzzarray instance constructed from the provided inputs. # # Notes # ----- # - If ``backend`` is provided, it is used directly and ``data`` is ignored. # - If ``data`` is provided, it is used to construct a new backend. # - If neither ``data`` nor ``backend`` is provided, an empty Fuzzarray is created. # # Examples # -------- # .. code-block:: python # # from axisfuzzy.core.fuzznums import fuzznum # from axisfuzzy.core.fuzzarray import fuzzarray # # # Create from data # arr = fuzzarray([fuzznum(md=0.1, nmd=0.2), fuzznum(md=0.2, nmd=0.3)]) # print(arr) # # # Create from backend (more efficient) # from axisfuzzy.core.backend import QROFNBackend # backend = QROFNBackend() # # Populate backend with data... # arr2 = fuzzarray(backend=backend) # print(arr2) # """ # return Fuzzarray(data=data, backend=backend, mtype=mtype, q=q, shape=shape, **mtype_kwargs)