How to Implement Multiplication of Objects with Numpy Arrays from the Left Hand Side?

Viewed 195

If one wants to multiply an arbitrary object from the left hand side with a np.ndarray, one gets into a problem.

The problem is, that numpy.ndarray.__mul__ calls the right hand sides' __rmul__ element-wise if the right hand side is a so far unknown type.

To see the problem, you can copy paste the following code and run it from the command line or a SDK. You can step with the debugger through it. Comments help you and guide you through it ...

import numpy as np


class Zora(object):

    def __init__(self, array):
        self._array = array
        self._values_field_name = array.dtype.names[-1]

    @property
    def a(self):
        return self._array

    def __repr__(self):
        return repr(self._array)

    def __mul__(self, other):
        result = self.copy()
        result *= other
        return result

    def __imul__(self, other):
        self._array[self._values_field_name] *= other
        return self

    def __rmul__(self, other):
        return self * other

    def copy(self):
        return self.__class__(self.a.copy())

    @classmethod
    def create(cls, fields, codes, data):
        array = np.array([(*c, d) for c, d in zip(codes, data)], dtype=fields)
        return cls(array)


if __name__ == '__main__':

    # Let's create a Zora dataset with  scenarios
    scenarios = 7

    fields = np.dtype([('LegalEntity', np.unicode_, 32), ('Division', np.unicode_, 32),
                       ('Scenarios', np.float64, (scenarios, ))])

    legal_entities = ['A', 'A', 'B', 'B', 'C']
    divisions = ['a', 'b', 'a', 'b', 'b']

    codes = list(zip(legal_entities, divisions))

    data = np.random.uniform(0., 1., (len(codes), scenarios))

    zora = Zora.create(fields, codes, data)

    # The dataset looks like the following
    print(zora)

    # We can multiply it from the left with scalars ...
    z = zora * 2
    print(zora * 2)

    # ... and with column vectors, for example ...
    # ... for this we generate a columns vector with some weights ...
    numrows = zora.a.shape[0]

    weights = np.expand_dims(np.array(list(range(numrows))), 1)
    # ... the weights ...
    print(weights)

    # ... left side multiplication works fine too with this
    print(zora * weights)

    # Let's show inplace multiplication ...
    # Which we apply on a copy, so that we can still compare ...
    z = zora.copy()
    z *= 2

    # Is pretty fine too, ...
    print(zora)
    print(z)

    # Now it becomes a bit special ...
    # ... when multiplying from the left.
    # It works fine with a scalar..
    z = 2 * zora
    print(z)

    # But becomes special with np.ndarrays ...
    print('-------------------------------------')
    print('-------------------------------------')
    print('The following result ...')
    z = weights * zora
    print(z)

    # which is not the same, but should, as ...
    print('-------------------------------------')
    print('... should be the same as this one ...')

    z = zora * weights
    print(z)

    # We got a list of arrays, where for each i-th array
    # the corresponding i-th weight has been used for
    # multiplication
    #
    # This has to do with numpy's implementation of calling
    # __rmul__ from the right hand side within the __mul__
    # from the np.ndarray ...
    #
    # The same is true for all other __r{...}__ methods
1 Answers

Use Numpy Hook __array_ufunc__

Numpy allows you to circumvent this problem by properly defining a __array_ufunc__ method on your object.

The following method added to the Zora class above, does the job. It is easy to generalize it for all binary ufunctions, including non commutative operations too. But I show this one only, not to make things to complicated.

def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
    lhs, rhs = inputs
    return rhs * lhs

Hence, the following will show the expected results ...

import numpy as np


class Zora(object):

    def __init__(self, array):
        self._array = array
        self._values_field_name = array.dtype.names[-1]

    @property
    def a(self):
        return self._array

    def __repr__(self):
        return repr(self._array)

    def __mul__(self, other):
        result = self.copy()
        result *= other
        return result

    def __imul__(self, other):
        self._array[self._values_field_name] *= other
        return self

    def __rmul__(self, other):
        return self * other

    def copy(self):
        return self.__class__(self.a.copy())

    def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
        lhs, rhs = inputs
        return rhs * lhs

    @classmethod
    def create(cls, fields, codes, data):
        array = np.array([(*c, d) for c, d in zip(codes, data)], dtype=fields)
        return cls(array)


if __name__ == '__main__':

    # Let's create a Zora dataset with  scenarios
    scenarios = 7

    fields = np.dtype([('LegalEntity', np.unicode_, 32), ('Division', np.unicode_, 32),
                       ('Scenarios', np.float64, (scenarios, ))])

    legal_entities = ['A', 'A', 'B', 'B', 'C']
    divisions = ['a', 'b', 'a', 'b', 'b']

    codes = list(zip(legal_entities, divisions))

    data = np.random.uniform(0., 1., (len(codes), scenarios))

    zora = Zora.create(fields, codes, data)

    # The dataset looks like the following
    print(zora)

    # We can multiply it from the left with scalars ...
    z = zora * 2
    print(zora * 2)

    # ... and with column vectors, for example ...
    # ... for this we generate a columns vector with some weights ...
    numrows = zora.a.shape[0]

    weights = np.expand_dims(np.array(list(range(numrows))), 1)
    # ... the weights ...
    print(weights)

    # ... left side multiplication works fine too with this
    print(zora * weights)

    # Let's show inplace multiplication ...
    # Which we apply on a copy, so that we can still compare ...
    z = zora.copy()
    z *= 2

    # Is pretty fine too, ...
    print(zora)
    print(z)

    # Now it becomes a bit special ...
    # ... when multiplying from the left.
    # It works fine with a scalar..
    z = 2 * zora
    print(z)

    # But becomes special with np.ndarrays ...
    print('-------------------------------------')
    print('-------------------------------------')
    print('The following result ...')
    z = weights * zora
    print(z)

    # which is now the same ...
    print('-------------------------------------')
    print('... should be the same as this one ...')

    z = zora * weights
    print(z)
Related