Customizing NumPy's Playground: How `__array_wrap__()` Makes User-Defined Classes Shine


generic.__array_wrap__() in NumPy

In NumPy, generic.__array_wrap__() is a method that provides a mechanism for custom classes to interact with NumPy's universal functions (ufuncs) and similar operations. It allows these classes to control the output type when they are used as operands in these functions.

Scalars and __array_wrap__()

Scalars in NumPython are fundamental numeric data types like integers, floats, or booleans. These built-in types don't typically define __array_wrap__(). However, __array_wrap__() becomes relevant when you create custom classes that represent numerical data and want them to integrate seamlessly with NumPy operations.

How it Works

When a NumPy ufunc or a function using the __array_wrap__ mechanism operates on an array containing objects of a custom class, the following steps occur:

  1. Input Handling
    NumPy checks if any input objects define the __array_wrap__() method.
  2. Priority Determination
    If such objects are found, NumPy compares their __array_priority__ attributes (if defined). The class with the highest priority wins.
  3. Wrapping
    The winning class's __array_wrap__() method is called with the result of the operation (which might be a scalar or an array, depending on the context). This method can:
    • Return the result unchanged (if the output type is already desired).
    • Convert the result to an instance of the custom class (providing control over the output type).

Benefits of __array_wrap__()

  • Interoperability
    It facilitates smooth integration of custom classes with the NumPy ecosystem, enabling them to participate in ufunc operations seamlessly.
  • Custom Output Types
    It allows you to tailor the output type of NumPy operations to your custom class, ensuring consistency and potentially offering additional functionality.
import numpy as np

class MyNumber:
    def __init__(self, value):
        self.value = value

    def __array_wrap__(self, result):
        return MyNumber(result)  # Wrap the result in a MyNumber instance

    def __add__(self, other):
        if isinstance(other, MyNumber):
            return MyNumber(self.value + other.value)
        else:
            return NotImplemented  # Handle other operand types

# Create custom numbers
a = MyNumber(2)
b = MyNumber(3)

# Use with NumPy (assuming MyNumber has appropriate arithmetic methods)
c = np.add(a, b)  # c will be of type MyNumber

# Custom addition for MyNumber objects
print(a + b)  # Calls the custom __add__ method of MyNumber


import numpy as np

class ComplexNumber:
    def __init__(self, real, imag):
        self.real = real
        self.imag = imag

    def __repr__(self):
        return f"{self.real} + {self.imag}j"

    def __array_wrap__(self, result):
        # Handle scalars and arrays as results
        if np.isscalar(result):
            return ComplexNumber(result.real, result.imag)  # Wrap scalar result
        else:
            return np.array([ComplexNumber(r, i) for r, i in zip(result.real, result.imag)], dtype=object)

    def __add__(self, other):
        if isinstance(other, (ComplexNumber, np.complex)):
            return ComplexNumber(self.real + other.real, self.imag + (other.imag if hasattr(other, 'imag') else 0))
        else:
            return NotImplemented

# Create complex numbers
z1 = ComplexNumber(2, 3)
z2 = ComplexNumber(4, 1)

# Use with NumPy ufuncs
result = np.add(z1, z2)
print(result)  # Output: ComplexNumber(6.0 + 4.0j) (array of ComplexNumber objects)

# Custom addition for ComplexNumber objects
print(z1 + z2)  # Output: ComplexNumber(6.0 + 4.0j) (using custom __add__)

# Error handling with non-compatible operand
try:
    print(z1 + 5)  # Raises TypeError
except TypeError as e:
    print(f"Unsupported operand type for addition: {e}")

This example incorporates the following improvements:

  • Error Handling
    The code includes a try-except block to gracefully handle cases where the operand is not compatible with ComplexNumber addition.
  • Custom Addition
    The __add__() method of ComplexNumber handles addition with both ComplexNumber objects and standard complex numbers from NumPy's np.complex type.
  • Handles Scalars and Arrays
    The __array_wrap__() method now checks if the result is a scalar or an array and wraps accordingly.


Subclassing ndarray (for Extensive Integration)

  • If you need your custom class to behave very similarly to a NumPy array and participate in most NumPy operations, consider subclassing ndarray. This provides a high degree of integration, but it's a more complex approach and requires careful implementation to ensure proper behavior.

Using a Registered Data Type (for Specific Data Types)

  • If your custom class represents a specific data type that might be widely used, you can explore registering it as a custom NumPy data type using numpy.from_dtype(). This allows NumPy to recognize and handle your data type seamlessly, but it requires a deeper understanding of NumPy's internal workings.

Customizing Operations with Functions (for Simpler Use Cases)

  • In some cases, you might be able to achieve the desired behavior by creating custom functions that operate on your custom class objects. This can be simpler than using __array_wrap__() if your needs are more focused on specific operations rather than broad integration with NumPy.

Choosing the Right Approach

The best approach depends on the level of integration and customization you require:

  • Simpler Customization
    Custom functions for targeted operations when full __array_wrap__() functionality isn't necessary.
  • Specific Data Type Handling
    Registering a custom data type if it represents a widely used format.
  • High Integration
    Subclassing ndarray for extensive interaction with NumPy operations.