---
jupytext:
  formats: content_md///md:myst,content///ipynb
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.13.8
kernelspec:
  display_name: Python 3 (ipykernel)
  language: python
  name: python3
---

(managing-data:numpy:array-operations)=
# Array Operations

```{code-cell} ipython3
import numpy as np
```

All Python operators can be applied to NumPy arrays, where **all operations work elementwise**.

## Mathematical Operations

For instance, we can easily add two vectors or two matrices by using the `+` operator.

```{code-cell} ipython3
a = np.array([1, 2, 3])
b = np.array([9, 8, 7])

c = a + b

print(c)
```

```{important}
Because all operations work elementwise, the `*` operator on two-dimensional arrays does NOT multiply the two matrices in the mathematical sense, but simply yields the matrix of elementwise products. Mathematical matrix multiplication will be discussed in [](managing-data:numpy:linear-algebra-functions).
```

NumPy reimplements almost all mathematical functions, like sine, cosine and so on. NumPy's functions take arrays as arguments and apply the mathematical functions elementwise. Have a look at [Mathematical functions](https://numpy.org/doc/stable/reference/routines.math.html) in NumPy's documentation for a list of available functions.

```{code-cell} ipython3
a = np.array([1, 2 , 3])

b = np.sin(a)

print(b)
```

```{hint}
Functions `min` and `amin` are equivalent. The `amin` variant exists to avoid confusion and name conflicts with Python's built-in function `min`. Writing `np.min` is okay.
```

```{important}
Functions `np.min` and `np.minimum` do different things. With `min` we get the smallest value in an array, whereas `minimum` yields the elementwise minimum of two equally sized arrays. With `np.argmin` we get the index (not the value) of the minimal element of an array.
```

## Comparing Arrays

Comparisons work elementwise, too.

```{code-cell} ipython3
a = np.array([1, 2, 3])
b = np.array([-1, 3, 3])

print(a > b)
```

Comparisons result in NumPy arrays of data type `bool`.
The function `np.any` returns `True` if and only if at least one item of the argument is `True`.
The function `np.all` returns `True` if and only if all items of the argument are `True`.

```{code-cell} ipython3
a = np.array([True, True, False])

print(np.any(a))
print(np.all(a))
```

Some of NumPy's functions also are accessible as methods of `ndarray` objects. Examples are `any` and `all`:

```{code-cell} ipython3
a = np.array([True, True, False])

print(a.any())
print(a.all())
```

```{margin}
Due to Python's internal workings for processing logical expressions efficiently it's not possible to redefine `and` and friends via dunder methods. Thus, there's no chance for NumPy to define it's own variant of `and`. There is a dunder method `__and__`, but that implements bitwise 'and' (Python operator `&`), which is something different than logical 'and'.
```

To combine several conditions you might use [`logical_and`](https://numpy.org/doc/stable/reference/generated/numpy.logical_and.html) and friends. Using Python's `and` (and friends) results in an error, because Python tries to convert a NumPy array to a `bool` value and it's not clear how to do this (any or all?).


## Broadcasting

If dimensions of the operands of a binary operation do not fit (short vector plus long vector, for instance) an exception is raised.
But in some cases NumPy uses a technique called *broadcasting* to make dimensions fit by cloning suitable subarrays. Examples:

```{code-cell} ipython3
a = np.array([[1, 2, 3]])    # 1 x 3
b = np.ones((4, 3))          # 4 x 3

c = a + b

print(a, '\n')
print(b, '\n')
print(c)
print(c.shape)
```

```{code-cell} ipython3
a = np.array([[1, 2, 3]])              # 1 x 3
b = np.array([[1], [2], [3], [4]])     # 4 x 1

c = a + b

print(a, '\n')
print(b, '\n')
print(c, '\n')
print(c.shape)
```

```{code-cell} ipython3
a = np.array([1, 2, 3])    #     3 (one-dimensional)
b = np.ones((4, 3))        # 4 x 3

c = a + b

print(a, '\n')
print(b, '\n')
print(c)
print(c.shape)
```

Broadcasting follows two simple rules:
* If one array has fewer dimensions than the other, add dimensions of size 1 till dimensions are equal.
* Compare array sizes in each dimension. If they equal, do nothing. If they differ and one array has size 1 in the dimension under consideration, clone the array with size 1 sufficiently often to fit the other array's size.

Broadcasting makes life much easier. On the one hand it allows for operations where one operand is a scalar:

```{code-cell} ipython3
a = np.array([[1, 2, 3], [4, 5, 6]])    # 2 x 3
b = 7                                   #     1 (one-dimensional)

c = a + b

print(a, '\n')
print(b, '\n')
print(c)
print(c.shape)
```

On the other hand broadcasting allows for efficient column or row operations:

```{code-cell} ipython3
# multiply columns by different values

a = np.array([[1, 2, 3], [4, 5, 6]])    # 2 x 3
b = np.array([[0.5], [2]])              # 2 x 1

c = a * b

print(a, '\n')
print(b, '\n')
print(c)
print(c.shape)
```

For higher-dimensional examples have a look at [Broadcasting](https://numpy.org/doc/stable/user/basics.broadcasting.html) in NumPy's documentation.
