Class 4: The Scientific Stack - Part I: numpy

Scientific Python

Introduction

The scientific Python stack

The onion-like scientific stack of Python is composed of the most important packages used in the Python scientific world. Here’s a quick overview of the most important names in the stack, before we dive in deep:

  1. IPython: A REPL, much like the command window in MATLAB. Lets you write Python on the fly, debug and check the performance of your code, and much (much!) more.

  2. Jupyter: Notebooks designed for data exploration. Write code and see its output immediately, with useful rich text (Markdown) annotations to accompany it (just like this notebook!).

  3. NumPy: Standard implementation of multi-dimensional arrays in Python.

  4. pandas: Tabular data manipulation.

  5. SciPy: Implements advanced algorithms widely used in scientific programming, including advanced linear algebra, data structures, curve fitting, signal processing, modeling and more.

  6. matplotlib: A cornerstone for data visualization in Python.

  7. seaborn: Smart visualizations of tabular data.

This scientific stack is one of the main reasons that empowered Python in recent years to the prominent role it has today. Throughout the course you will become familiar with all of the aforementioned tools, and more.

NumPy

NumPy is the de-facto implementation of arrays in Python. They’re very similar to MATLAB arrays (and to other implementations in other programming languages).

Although numpy is used extensively in the Python eco-system, it doesn’t come with the Python standard library, which means you have to install it first. Activate your environment and run pip install numpy from the command line (inside VSCode, for example). Only then you can follow the rest of the class.

import numpy as np  # standard alias for numpy

l = [1, 2, 3]
array = np.array(l)  # obviously same as np.array([1, 2, 3])
array
array([1, 2, 3])

The created array variable is a numpy array. Notice how numpy has to take existing data, in this case in the form of a list (l), and convert it into an array. In MATLAB everything is an array all the time so we don’t think of this conversion, but here the conversion happens explicitly when you call np.array() on some collection of data.

Let’s continue to examine the basic properties of this array:

# Check the dimensionality of the array:
print("Shape:\t", array.shape)
print("NDim:\t", array.ndim)
Shape:	 (3,)
NDim:	 1

We’ve instantiated an array by calling the np.array() function on an iterable. This will create a one-dimensional numpy array, or vector. This is the first important difference from MATLAB. In MATLAB, all arrays are by default n-dimensional. If you write:

a = 1
a(:, :, :, :, :, 1) = 2

then you just received a 6-dimensional array. NumPy doesn’t allow that. The dimensions of an array are generally the dimensions it had when it was created. You can always use reshape(), like MATLAB, to define the exact number of dimensions you wish to have. You can also use newaxis() to add an axis, as we’ll see below. But these options still keep the number of elements in the array the same, they just re-order them.

This means that a 2x4 array can be reshaped into a 4x2 array, or a 2x2x2 one, but not 3x3x3.

The second difference is the idea of vectors.

print(f"The number of dimensions is {array.ndim}.")
print(f"The shape is {array.shape}.")
The number of dimensions is 1.
The shape is (3,).

As you see, ndim returns the number of dimensions. The shape is returned as a tuple, the length of which is the number of dimensions, while the values represent the number of elements in each dimensions. shape is very similar to the size() function of MATLAB.

Note that:

a = np.array(1)  # wrong instatiation! Don't use it, instead write np.array([1])
print("Wrong instantiation results:")
print("Shape: ", a.shape)
print("NDim: ", a.ndim)
Wrong instantiation results:
Shape:  ()
NDim:  0

So be mindful to pass an iterable.

Creating arrays with more than one dimension might look odd at a first glance:

arr_2d = np.array([[1, 2, 3], [4, 5, 6]])
arr_2d
array([[1, 2, 3],
       [4, 5, 6]])
print(f"The number of dimensions is {arr_2d.ndim}.")
print(f"The shape is {arr_2d.shape}.")
print(f"The len of the array is {len(arr_2d)} - which corresponds to the shape of the 0th dimension.")
The number of dimensions is 2.
The shape is (2, 3).
The len of the array is 2 - which corresponds to the shape of the 0th dimension.

As we see, each list was considered a single “row” (dimension number 0) in the final array, very much like MATLAB, although the syntax can be confusing at first. Here’s another slightly confusing example:

c = np.array([[[1], [2]], [[3], [4]]])
c
array([[[1],
        [2]],

       [[3],
        [4]]])
c.shape
(2, 2, 1)

Or, alternatively:

c2 = np.array([1, 2, 3, 4]).reshape((2, 2, 1))
c2.shape
(2, 2, 1)

Getting Help

NumPy has a ton of features, and to get around it we can use lookfor and the ? sign, besides the official reference on the internet:

np.lookfor("Create array")
Search results for 'create array'
---------------------------------
numpy.memmap
    Create a memory-map to an array stored in a *binary* file on disk.
numpy.diagflat
    Create a two-dimensional array with the flattened input as a diagonal.
numpy.char.array
    Create a `chararray`.
numpy.fromiter
    Create a new 1-dimensional array from an iterable object.
numpy.partition
    Return a partitioned copy of an array.
numpy.from_dlpack
    Create a NumPy array from an object implementing the ``__dlpack__``
numpy.rec.fromarrays
    Create a record array from a (flat) list of arrays
numpy.ctypeslib.as_array
    Create a numpy array from a ctypes array or POINTER.
numpy.ma.diagflat
    Create a two-dimensional array with the flattened input as a diagonal.
numpy.ma.make_mask
    Create a boolean mask from an array.
numpy.rec.fromfile
    Create an array from binary file data
numpy.rec.fromstring
    Create a record array from binary data
numpy.lib.Arrayterator
    Buffered iterator for big arrays.
numpy.rec.fromrecords
    Create a recarray from a list of records in text form.
numpy.ctypeslib.as_ctypes
    Create and return a ctypes object from a numpy array.  Actually
numpy.ma.mrecords.fromarrays
    Creates a mrecarray from a (flat) list of masked arrays.
numpy.ma.mvoid.__new__
    Create a new masked array from scratch.
numpy.ma.MaskedArray.__new__
    Create a new masked array from scratch.
numpy.ma.mrecords.fromtextfile
    Creates a mrecarray from data stored in the file `filename`.
numpy.array
    array(object, dtype=None, *, copy=True, order='K', subok=False, ndmin=0,
numpy.asarray
    Convert the input to an array.
numpy.ndarray
    ndarray(shape, dtype=float, buffer=None, offset=0,
numpy.recarray
    Construct an ndarray that allows field access using attributes.
numpy.chararray
    chararray(shape, itemsize=1, unicode=False, buffer=None, offset=0,
numpy.exp
    Calculate the exponential of all elements in the input array.
numpy.pad
    Pad an array.
numpy.asanyarray
    Convert the input to an ndarray, but pass ndarray subclasses through.
numpy.cbrt
    Return the cube-root of an array, element-wise.
numpy.copy
    Return an array copy of the given object.
numpy.diag
    Extract a diagonal or construct a diagonal array.
numpy.exp2
    Calculate `2**p` for all `p` in the input array.
numpy.fmax
    Element-wise maximum of array elements.
numpy.fmin
    Element-wise minimum of array elements.
numpy.load
    Load arrays or pickled objects from ``.npy``, ``.npz`` or pickled files.
numpy.modf
    Return the fractional and integral parts of an array, element-wise.
numpy.rint
    Round elements of the array to the nearest integer.
numpy.sort
    Return a sorted copy of an array.
numpy.sqrt
    Return the non-negative square-root of an array, element-wise.
numpy.array_equiv
    Returns True if input arrays are shape consistent and all elements equal.
numpy.dtype
    Create a data type object.
numpy.expm1
    Calculate ``exp(x) - 1`` for all elements in the array.
numpy.isnan
    Test element-wise for NaN and return result as a boolean array.
numpy.isnat
    Test element-wise for NaT (not a time) and return result as a boolean array.
numpy.log10
    Return the base 10 logarithm of the input array, element-wise.
numpy.log1p
    Return the natural logarithm of one plus the input array, element-wise.
numpy.power
    First array elements raised to powers from second array, element-wise.
numpy.ufunc
    Functions that operate element by element on whole arrays.
numpy.choose
    Construct an array from an index array and a list of arrays to choose from.
numpy.nditer
    Efficient multi-dimensional iterator object to iterate over arrays.
numpy.maximum
    Element-wise maximum of array elements.
numpy.minimum
    Element-wise minimum of array elements.
numpy.swapaxes
    Interchange two axes of an array.
numpy.full_like
    Return a full array with the same shape and type as a given array.
numpy.ones_like
    Return an array of ones with the same shape and type as a given array.
numpy.bitwise_or
    Compute the bit-wise OR of two arrays element-wise.
numpy.datetime64
    If created from a 64-bit integer, it represents an offset from
numpy.empty_like
    Return a new array with the same shape and type as a given array.
numpy.rec.array
    Construct a record array from a wide-variety of objects.
numpy.frombuffer
    Interpret a buffer as a 1-dimensional array.
numpy.zeros_like
    Return an array of zeros with the same shape and type as a given array.
numpy.asarray_chkfinite
    Convert the input to an array, checking for NaNs or Infs.
numpy.bitwise_and
    Compute the bit-wise AND of two arrays element-wise.
numpy.bitwise_xor
    Compute the bit-wise XOR of two arrays element-wise.
numpy.float_power
    First array elements raised to powers from second array, element-wise.
numpy.diag_indices
    Return the indices to access the main diagonal of an array.
numpy.ma.exp
    Calculate the exponential of all elements in the input array.
numpy.nested_iters
    Create nditers for use in nested loops
numpy.ma.sqrt
    Return the non-negative square-root of an array, element-wise.
numpy.ma.log10
    Return the base 10 logarithm of the input array, element-wise.
numpy.put_along_axis
    Put values into the destination array by matching 1d index and data slices.
numpy.chararray.tolist
    a.tolist()
numpy.ma.choose
    Use an index array to construct a new array from a list of choices.
numpy.ma.maximum
    Element-wise maximum of array elements.
numpy.ma.minimum
    Element-wise minimum of array elements.
numpy.savez_compressed
    Save several arrays into a single file in compressed ``.npz`` format.
numpy.matlib.rand
    Return a matrix of random values with given shape.
numpy.datetime_as_string
    Convert an array of datetimes into an array of strings.
numpy.ma.mrecords.MaskedRecords.__new__
    Create a new masked array from scratch.
numpy.ma.ones_like
    Return an array of ones with the same shape and type as a given array.
numpy.ma.bitwise_or
    Compute the bit-wise OR of two arrays element-wise.
numpy.ma.frombuffer
    Interpret a buffer as a 1-dimensional array.
numpy.ma.zeros_like
    Return an array of zeros with the same shape and type as a given array.
numpy.ma.bitwise_and
    Compute the bit-wise AND of two arrays element-wise.
numpy.ma.bitwise_xor
    Compute the bit-wise XOR of two arrays element-wise.
numpy.ma.make_mask_none
    Return a boolean mask of the given shape, filled with False.
numpy.core.tests.test_umath.interesting_binop_operands
    Helper to create "interesting" operands to cover common code paths:
numpy.ma.tests.test_subclassing.MSubArray.__new__
    Create a new masked array from scratch.
numpy.core._multiarray_umath.clip
    Clip (limit) the values in an array.
numpy.core.tests.test_overrides._new_duck_type_and_implements
    Create a duck array type and implements functions.
numpy.ma.tests.test_subclassing.SubMaskedArray.__new__
    Create a new masked array from scratch.
numpy.ma.mrecords.fromrecords
    Creates a MaskedRecords from a list of records.
numpy.f2py.tests.test_array_from_pyobj.Array.has_shared_memory
    Check that created array shares data with input array.
numpy.core._multiarray_umath.empty_like
    Return a new array with the same shape and type as a given array.
numpy.core._dtype._construction_repr
    Creates a string repr of the dtype, excluding the 'dtype()' part
numpy.abs
    Calculate the absolute value element-wise.
numpy.add
    Add arguments element-wise.
numpy.cos
    Cosine element-wise.
numpy.log
    Natural logarithm, element-wise.
numpy.mod
    Returns the element-wise remainder of division.
numpy.sin
    Trigonometric sine, element-wise.
numpy.tan
    Compute tangent element-wise.
numpy.ceil
    Return the ceiling of the input, element-wise.
numpy.conj
    Return the complex conjugate, element-wise.
numpy.cosh
    Hyperbolic cosine, element-wise.
numpy.fabs
    Compute the absolute values element-wise.
numpy.fmod
    Returns the element-wise remainder of division.
numpy.less
    Return the truth value of (x1 < x2) element-wise.
numpy.log2
    Base-2 logarithm of `x`.
numpy.lib.recfunctions.require_fields
    Casts a structured array to a new dtype using assignment by field-name.
numpy.sign
    Returns an element-wise indication of the sign of a number.
numpy.sinh
    Hyperbolic sine, element-wise.
numpy.tanh
    Compute hyperbolic tangent element-wise.
numpy.equal
    Return (x1 == x2) element-wise.
numpy.floor
    Return the floor of the input, element-wise.
numpy.frexp
    Decompose the elements of x into mantissa and twos exponent.
numpy.hypot
    Given the "legs" of a right triangle, return its hypotenuse.
numpy.isinf
    Test element-wise for positive or negative infinity.
numpy.ldexp
    Returns x1 * 2**x2, element-wise.
numpy.trunc
    Return the truncated value of the input, element-wise.
numpy.arccos
    Trigonometric inverse cosine, element-wise.
numpy.arcsin
    Inverse sine, element-wise.
numpy.arctan
    Trigonometric inverse tangent, element-wise.
numpy.around
    Evenly round to the given number of decimals.
numpy.divide
    Divide arguments element-wise.
numpy.divmod
    Return element-wise quotient and remainder simultaneously.
numpy.core._multiarray_umath.datetime_as_string
    Convert an array of datetimes into an array of strings.
numpy.source
    Print or write to a file the source code for a NumPy object.
numpy.square
    Return the element-wise square of the input.
numpy.arccosh
    Inverse hyperbolic cosine, element-wise.
numpy.arcsinh
    Inverse hyperbolic sine element-wise.
numpy.arctan2
    Element-wise arc tangent of ``x1/x2`` choosing the quadrant correctly.
numpy.arctanh
    Inverse hyperbolic tangent element-wise.
numpy.deg2rad
    Convert angles from degrees to radians.
numpy.degrees
    Convert angles from radians to degrees.
numpy.greater
    Return the truth value of (x1 > x2) element-wise.
numpy.rad2deg
    Convert angles from radians to degrees.
numpy.radians
    Convert angles from degrees to radians.
numpy.signbit
    Returns element-wise True where signbit is set (less than zero).
numpy.spacing
    Return the distance between x and the nearest adjacent number.
numpy.copysign
    Change the sign of x1 to that of x2, element-wise.
numpy.diagonal
    Return specified diagonals.
numpy.isfinite
    Test element-wise for finiteness (not infinity and not Not a Number).
numpy.multiply
    Multiply arguments element-wise.
numpy.negative
    Numerical negative, element-wise.
numpy.subtract
    Subtract arguments, element-wise.
numpy.heaviside
    Compute the Heaviside step function.
numpy.logaddexp
    Logarithm of the sum of exponentiations of the inputs.
numpy.nextafter
    Return the next floating-point value after x1 towards x2, element-wise.
numpy.not_equal
    Return (x1 != x2) element-wise.
numpy.left_shift
    Shift the bits of an integer to the left.
numpy.less_equal
    Return the truth value of (x1 <= x2) element-wise.
numpy.logaddexp2
    Logarithm of the sum of exponentiations of the inputs in base-2.
numpy.logical_or
    Compute the truth value of x1 OR x2 element-wise.
numpy.nan_to_num
    Replace NaN with zero and infinity with large finite numbers (default
numpy.reciprocal
    Return the reciprocal of the argument, element-wise.
numpy.bitwise_not
    Compute bit-wise inversion, or bit-wise NOT, element-wise.
numpy.einsum_path
    Evaluates the lowest cost contraction order for an einsum expression by
numpy.histogram2d
    Compute the bi-dimensional histogram of two data samples.
numpy.logical_and
    Compute the truth value of x1 AND x2 element-wise.
numpy.logical_not
    Compute the truth value of NOT x element-wise.
numpy.logical_xor
    Compute the truth value of x1 XOR x2, element-wise.
numpy.right_shift
    Shift the bits of an integer to the right.
numpy.floor_divide
    Return the largest integer smaller or equal to the division of the inputs.
numpy.ma.abs
    Calculate the absolute value element-wise.
numpy.ma.add
    Add arguments element-wise.
numpy.ma.cos
    Cosine element-wise.
numpy.ma.log
    Natural logarithm, element-wise.
numpy.ma.mod
    Returns the element-wise remainder of division.
numpy.ma.sin
    Trigonometric sine, element-wise.
numpy.ma.tan
    Compute tangent element-wise.
numpy.greater_equal
    Return the truth value of (x1 >= x2) element-wise.
numpy.ma.ceil
    Return the ceiling of the input, element-wise.
numpy.fft.ifft
    Compute the one-dimensional inverse discrete Fourier Transform.
numpy.ma.cosh
    Hyperbolic cosine, element-wise.
numpy.ma.fabs
    Compute the absolute values element-wise.
numpy.ma.fmod
    Returns the element-wise remainder of division.
numpy.ma.less
    Return the truth value of (x1 < x2) element-wise.
numpy.ma.log2
    Base-2 logarithm of `x`.
numpy.ma.sinh
    Hyperbolic sine, element-wise.
numpy.ma.tanh
    Compute hyperbolic tangent element-wise.
numpy.busdaycalendar
    A business day calendar object that efficiently stores information
numpy.fft.ifftn
    Compute the N-dimensional inverse discrete Fourier Transform.
numpy.ma.equal
    Return (x1 == x2) element-wise.
numpy.ma.floor
    Return the floor of the input, element-wise.
numpy.ma.hypot
    Given the "legs" of a right triangle, return its hypotenuse.
numpy.ma.arccos
    Trigonometric inverse cosine, element-wise.
numpy.ma.arcsin
    Inverse sine, element-wise.
numpy.ma.arctan
    Trigonometric inverse tangent, element-wise.
numpy.ma.divide
    Divide arguments element-wise.
numpy.ma.arccosh
    Inverse hyperbolic cosine, element-wise.
numpy.ma.arcsinh
    Inverse hyperbolic sine element-wise.
numpy.ma.arctan2
    Element-wise arc tangent of ``x1/x2`` choosing the quadrant correctly.
numpy.ma.arctanh
    Inverse hyperbolic tangent element-wise.
numpy.ma.greater
    Return the truth value of (x1 > x2) element-wise.
numpy.lib.recfunctions.unstructured_to_structured
    Converts an n-D unstructured array into an (n-1)-D structured array.
numpy.ma.multiply
    Multiply arguments element-wise.
numpy.ma.negative
    Numerical negative, element-wise.
numpy.ma.subtract
    Subtract arguments, element-wise.
numpy.ma.conjugate
    Return the complex conjugate, element-wise.
numpy.ma.not_equal
    Return (x1 != x2) element-wise.
numpy.ma.remainder
    Returns the element-wise remainder of division.
numpy.ma.empty_like
    empty_like(prototype, dtype=None, order='K', subok=True, shape=None)
numpy.ma.less_equal
    Return the truth value of (x1 <= x2) element-wise.
numpy.ma.logical_or
    Compute the truth value of x1 OR x2 element-wise.
numpy.ma.tests.test_subclassing.SubArray
    ndarray(shape, dtype=float, buffer=None, offset=0,
numpy.ma.logical_and
    Compute the truth value of x1 AND x2 element-wise.
numpy.ma.logical_not
    Compute the truth value of NOT x element-wise.
numpy.ma.logical_xor
    Compute the truth value of x1 XOR x2, element-wise.
numpy.ma.true_divide
    Divide arguments element-wise.
numpy.ma.floor_divide
    Return the largest integer smaller or equal to the division of the inputs.
numpy.ma.greater_equal
    Return the truth value of (x1 >= x2) element-wise.
numpy.core.tests.test_function_base.PhysicalQuantity2
    ndarray(shape, dtype=float, buffer=None, offset=0,
numpy.lib.tests.test_stride_tricks.SimpleSubClass
    ndarray(shape, dtype=float, buffer=None, offset=0,
numpy.core.tests.test_multiarray.TestArrayPriority.Foo
    ndarray(shape, dtype=float, buffer=None, offset=0,
numpy.core.tests.test_multiarray.TestArrayPriority.Bar
    ndarray(shape, dtype=float, buffer=None, offset=0,
numpy.ma.tests.test_subclassing.ComplicatedSubArray
    ndarray(shape, dtype=float, buffer=None, offset=0,
numpy.lib.tests.test_stride_tricks.VerySimpleSubClass
    ndarray(shape, dtype=float, buffer=None, offset=0,
numpy.random.RandomState.rand
    Random values in a given shape.
numpy.random.Generator.permuted
    Randomly permute `x` along axis `axis`.
np.con*?

Creating Arrays

np.arange(10)  # similar to MATLAB's 0:9 (and to Python's range())
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
np.arange(start=1, stop=8, step=3)  # MATLAB's 1:3:8
array([1, 4, 7])
np.zeros((2, 3))  # notice that the shape is a tuple
array([[0., 0., 0.],
       [0., 0., 0.]])
np.zeros_like(arr_2d)
array([[0, 0, 0],
       [0, 0, 0]])
np.ones((1, 4), dtype='int64')  # 2-d array with the dtype argument
array([[1, 1, 1, 1]])
np.full((4, 2), 7)
array([[7, 7],
       [7, 7],
       [7, 7],
       [7, 7]])
np.linspace(0, 10, 3)  # start, stop, number of points (endpoint as a keyword argument)
array([ 0.,  5., 10.])
np.linspace(0, 10, 3, endpoint=False, dtype=np.float32)  # float32 is "single" in MATLAB-speak. float64, which is the default, is "double"
array([0.       , 3.3333333, 6.6666665], dtype=float32)
np.eye(3, dtype='uint8')
array([[1, 0, 0],
       [0, 1, 0],
       [0, 0, 1]], dtype=uint8)
np.diag([19, 20])
array([[19,  0],
       [ 0, 20]])

Some more interesting features:

np.array([True, False, False]) # A logical mask
array([ True, False, False])
np.array([1+2j]).dtype # Complex numbers
dtype('complex128')
np.array(['a', 'bc', 'd', 'e'])
# No such thing as a cell array, arrays can contain strings.
array(['a', 'bc', 'd', 'e'], dtype='<U2')

* <U2 are strings containing maximum 2 unicode letters

Arrays can be heterogeneous, although usually this is discouraged. To make an hetetogeneous array, you have to specify its data type as object, or np.object:

np.array([1, 'b', True, {'b': 1}], dtype=object)
array([1, 'b', True, {'b': 1}], dtype=object)

The last few examples showed us that NumPy arrays are a superset of MATLAB’s matrices, as they also contain the cell array functionality within them.

When multiplying NumPy arrays, multiplication is done cell-by-cell (elementwise), rather than by the rules of linear algebra.

You can still multiply vectors and matrices using one of two options:

  • The @ operator (preferred): arr1 @ arr2

  • arr1.dot(arr2)

Just remember that the default behavior is different than MATLAB’s.

Note

NumPy does contain a matrix-like array - np.matrix('1 2; 3 4'), which behaves like a linear algebra 2D matrix (see np.matrix), but its use is discouraged.

More on Datatypes

While numpy doesn’t require you to actively think of the data type of each of your arrays, it’s a useful habit to have. The existing data types in numpy are:

np.sctypes
{'int': [numpy.int8, numpy.int16, numpy.int32, numpy.int64],
 'uint': [numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64],
 'float': [numpy.float16, numpy.float32, numpy.float64, numpy.float128],
 'complex': [numpy.complex64, numpy.complex128, numpy.complex256],
 'others': [bool, object, bytes, str, numpy.void]}

Many array creation and handling methods have a dtype keyword argument, that allow you to specify the required output’s data type easily.

Python will issue a RuntimeWarning whenever an “unsafe” conversion of data type will occur, for example from np.float64 to np.float32.

NumPy Arrays vs. Lists

You might have asked yourselves by this point what’s the difference between lists and numpy arrays. In other words, if Python already has an array-like data structure, why does it need another one?

Lists are truly amazing, but their flexibility comes at a cost. You won’t notice it when you’re writing some short script, but when you start working with real datasets you notice that their performance is lacking.

Naive iteration over lists is good enough when the lists contain up to a few thousands of elements, but somewhere after this invisible threshold you will start to notice the runtimes of your app turning very slow.

Why are lists slower?

Lists are slower due to their implementation. Because a list has to be heterogeneous, a list is actually an object containing references to other objects, which are the elements of the data contained in the list. This nesting can go on even deeper, since elements of lists can be other lists, or other complex data types.

Iterating over such a complicated objects contains a lot of “overhead”, i.e. time the interpreter tries to figure out what is it actually facing - is this current value an integer? A float? A dictionary of tuples?

Numpy arrays versus lists

This is where NumPy arrays come into the picture. They require the contained data to be homogeneous (disregarding the “object” datatype), leading to a simpler structure of the underlying implementation: A NumPy array is an object with a pointer to a contiguous block of data in the memory.

NumPy forces the elements in its array to be homogeneous, by means of “upcasting”:

arr = np.array([1, 2, 3.14, 4])
arr  # upcasted to float, not "object", since it's much slower
array([1.  , 2.  , 3.14, 4.  ])

This homogeneity and contiguity allows NumPy to use “vectorized” functions, which are simply functions that can iterate in C code over the array, as opposed to regular functions which have to use the Python iteration rules.

This means that while in essence the following two pieces of code are identical, the performance gain is due to the loop being done in C rather than in Python (this is the case in MATLAB as well):

%%timeit
python_array = list(range(1_000_000))
for item in python_array:
    item += 1
99 ms ± 501 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
%%timeit 
numpy_array = np.arange(1_000_000)
numpy_array += 1  # inside, a C loop is adding 1 to each item.

# Two orders of magnitude improvement for a pretty small (1M elements) array. 
# This is approximately the size of a 1024x1024 pixel image.
926 µs ± 15.8 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

If you recall the first class, you’ll remember that we mentioned how Python code has to be transpiled into C code, which is then compiled to machine code. NumPy arrays take a “shortcut” here, are are quickly compiled to efficient C code without the Python overhead. When using vectorized NumPy operations, the loop Python does in the backstage is very similar to a loop that a C programmer would have written by hand.

Another small but significant benefit of NumPy arrays is smaller memory footprint:

from sys import getsizeof

python_array = list(range(1_000_000))
list_size = getsizeof(python_array) / 1e6  # in MB
print(f"Python list size (1M elements, MB): {list_size}")

numpy_array = np.arange(1_000_000, dtype=np.int32)
numpy_size = numpy_array.nbytes / 1e6
print(f"Numpy array size (1M elements, MB): {numpy_size}")
Python list size (1M elements, MB): 8.000056
Numpy array size (1M elements, MB): 4.0

Lower memory usage can help us pack larger array in the computer’s working memory (RAM), and it will also improve the performance of the underlying code.

Indexing and Slicing

Indexing numpy arrays is very much what you’d expect it to be.

a = np.arange(10)
a[0], a[3], a[-2]  # Python indexing is always done with square brackets
(0, 3, 8)

The beautiful reverse slicing works here as well:

a[::-1]
array([9, 8, 7, 6, 5, 4, 3, 2, 1, 0])
arr_2d
array([[1, 2, 3],
       [4, 5, 6]])
arr_2d[0, 2]  # first row, third column
3
arr_2d[0, :]  # first row, all columns
array([1, 2, 3])
arr_2d[0]  # first item in the first dimension, and all of its corresponding elemets
# Similar to arr_2d[0, :]
array([1, 2, 3])
a[1::2]
array([1, 3, 5, 7, 9])
a[:2]  # last index isn't included
array([0, 1])

View vs. Copy

In Python, slicing creates a view of an object, not a copy:

b = a[::2]
b
array([0, 2, 4, 6, 8])
b[0] = 100
b
array([100,   2,   4,   6,   8])
a  # a is also changed!
array([100,   1,   2,   3,   4,   5,   6,   7,   8,   9])

Copying is done with copy():

a_copy = a[::-1].copy()
a_copy
array([  9,   8,   7,   6,   5,   4,   3,   2,   1, 100])
a_copy[1] = 200
a_copy
array([  9, 200,   7,   6,   5,   4,   3,   2,   1, 100])
a  # unchanged
array([100,   1,   2,   3,   4,   5,   6,   7,   8,   9])

This behavior allows NumPy to save time and memory. It will usually go unnoticed during day-to-day use. Occasionally, however, it can result in ugly bugs which are hard to locate.

Let’s look at a summary of 2D indexing:

Numpy indexing

Aggregation

arr = np.random.random(1_000)
sum(arr)  # The native Python sum works on numpy arrays, but its use is discouraged in this context
492.6043817490305

sum() is a built-in Python function, capable of working on lists and other native Python data structures. NumPy has its own sum functions: You can either write np.sum(arr), or use arr.sum():

%timeit sum(arr)
%timeit np.sum(arr)
%timeit arr.sum()
101 µs ± 2.33 µs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
4.7 µs ± 50.7 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
2.16 µs ± 38.8 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)

Keep in mind that the built-in sum() and numpy’s np.sum() aren’t identical. np.sum() by default calculates the sum of all axes, returning a single number for multi-dimensional arrays.

In MATLAB, this behavior is replicated with sum(arr(:)).

Likewise, min() and max() also have two “competing” versions:

%timeit min(arr)  # Native Python
%timeit np.min(arr)
%timeit arr.min()
77.9 µs ± 1.82 µs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
4.76 µs ± 106 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
2.1 µs ± 54.4 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)

Calculating the min of an array will, again, result in a single number:

arr2 = np.random.random((4, 4, 4))
arr2.min()
0.00454646640123324

If you wish to calculate it across an axis, use the axis keyword:

arr2.min(axis=0)
array([[0.15900428, 0.02053518, 0.1857301 , 0.12830137],
       [0.09593663, 0.1354069 , 0.00454647, 0.29075972],
       [0.0165928 , 0.10542028, 0.04569207, 0.15146912],
       [0.19517666, 0.05739145, 0.30359431, 0.39849339]])

2D output (axis number 0 was “dropped”)

arr2.max(axis=(0, 1))
array([0.98974462, 0.86770668, 0.93195716, 0.98097637])

1D output (the two first axes were summed over)

Many other aggregation functions exist, including:

- np.var
- np.std
- np.argmin\argmax
- np.median
- ...

Most of them have an object-oriented version, i.e. arr.var(), and a procedural version, i.e. np.var(arr).

Fancy Indexing

In NumPy, indexing an array with a different array is called “fancy” indexing. Perhaps confusingly, it creates a copy, not a view.

basic_array = np.arange(10)
basic_array
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
mask = basic_array % 2 == 0
mask
array([ True, False,  True, False,  True, False,  True, False,  True,
       False])
b = basic_array[mask]  # same as basic_array[basic_array % 2 == 0]
b
array([0, 2, 4, 6, 8])

MATLAB veterans shouldn’t be surprised from this feature, but if you haven’t seen it before make sure to have a firm grasp on it, as it’s a very powerful technique.

b[:] = 999
print(f"basic array: {basic_array}")
print(f"b: {b}")
basic array: [0 1 2 3 4 5 6 7 8 9]
b: [999 999 999 999 999]

Note basic_array was not changed.

float_array = np.arange(start=0, stop=20, step=1.5)
float_array
array([ 0. ,  1.5,  3. ,  4.5,  6. ,  7.5,  9. , 10.5, 12. , 13.5, 15. ,
       16.5, 18. , 19.5])
float_array[[1, 2, 5, 5, 10]]  # copy, not a view. Meaning that the resulting array is a new
# instance of the original array, independent of it, in a different location in memory.
array([ 1.5,  3. ,  7.5,  7.5, 15. ])

Counting the number of values that satisfy some condition can be done using np.count_nonzero() or np.sum():

basic_array = np.arange(10)
count_nonzero_result = np.count_nonzero(basic_array % 2 == 0)
sum_result = np.sum(basic_array % 2 == 0)

print(f"np.count_nonzero result: {count_nonzero_result}")
print(f"np.sum result: {sum_result}")
# In the latter case, True is 1 and False is 0
np.count_nonzero result: 5
np.sum result: 5

If we wish to add more conditions in to the mix, we can use the &, |, ~, ^ operators (and, or, not, xor):

((basic_array % 2 == 1) & (basic_array != 3)).sum()  # uneven values that are different from 3
4

Note that numpy uses the &, |, ~, ^ operators for element-by-element comparison, while the reserved and and or keywords evaluate the entire object:

np.sum((basic_array % 2 == 1) and (basic_array != 3))   # doesn't work, "and" isn't used this way here
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[59], line 1
----> 1 np.sum((basic_array % 2 == 1) and (basic_array != 3))   # doesn't work, "and" isn't used this way here

ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()

Sorting

Again we face a condition in which Python has its own sort functions, but numpy’s np.sort() are much better suited for arrays. We can either sort in-place, or have a new object back:

# Return a new object:
arr = np.array([ 3, 4, 1, 8, 10, 2, 4])
print(np.sort(arr))

# Sort in-place:
arr.sort()
print(arr)
[ 1  2  3  4  4  8 10]
[ 1  2  3  4  4  8 10]

The default implementation is a quicksort but other sorting algorithms can be found as well.

# np.argsort will return the indices of the sorted array:
arr = np.array([ 3, 4, 1, 8, 10, 2, 4])
print("Sorted indices: {}".format(np.argsort(arr)))

# Usage is as follows:
arr[np.argsort(arr)]
Sorted indices: [2 5 0 1 6 3 4]
array([ 1,  2,  3,  4,  4,  8, 10])

Concatenation

Concatenation (and splitting) in numpy works as follows:

x = np.array([1, 2, 3])
y = np.array([3, 2, 1])
np.concatenate([x, y])  # concatenates along the first axis
array([1, 2, 3, 3, 2, 1])
# Concatenate more than two arrays
z = [99, 99, 99]
np.concatenate([x, y, z])  # notice how the function argument is an iterable!
array([ 1,  2,  3,  3,  2,  1, 99, 99, 99])
# 2D - along the last axis
twod_1 = np.array([[0, 1, 2],
                   [3, 4, 5]])
twod_2 = np.array([[6, 7, 8],
                   [9, 10, 11]])
np.concatenate((twod_1, twod_2), axis=-1)
array([[ 0,  1,  2,  6,  7,  8],
       [ 3,  4,  5,  9, 10, 11]])

np.vstack() and np.hstack() are also an option:

sim1 = np.array([0, 1, 2])
sim2 = np.array([[30, 40, 50],
                 [60, 70, 80]])
np.vstack((sim1, sim2))
array([[ 0,  1,  2],
       [30, 40, 50],
       [60, 70, 80]])

Splitting up arrays is also quite easy:

arr = np.arange(16).reshape((4, 4))  # see the tuple? Shapes of arrays always come in tuples
arr
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11],
       [12, 13, 14, 15]])
upper, lower = np.split(arr, [3])  # splits at the fourth line (of the first axis, by default)
print(upper)
print('---')
print(lower)
[[ 0  1  2  3]
 [ 4  5  6  7]
 [ 8  9 10 11]]
---
[[12 13 14 15]]

Familiarization Exercise

Create two random 3D arrays, at least 3x3x3 in size. The first should have random integers between 0 and 100, while the second should contain floating points numbers drawn from the normal distribution, with mean -1 and standard deviation of 2

  1. What are the default datatypes of integer and floating-point arrays in NumPy?

  1. Center the distribution of the first integer array around 0, with minimal and maximal values ranging between -1 and 1.

  1. Calculate the mean and standard deviation of each array along the last axis, and the sum along the second axis. Use the object-oriented implementation of these functions.

Iteration Exercise

Find and return the indices of the two random arrays only where both elements satisfy -0.5 <= value <= 0.5. Do so in at least two distinct ways. These can include element-wise iteration, masked-arrays, and more.

Time the execution of the script using the IPython %%timeit magic if you have this tool near at hand.

Broadcasting Exercise

  1. Create a vector with length 100, filled with ones, and a 2D zero-filled array with its last dimension with size of 100.

  1. Using np.tile(), add the vector and the array.

  1. Did you have to use np.tile() in this case? What feature of numpy allows this?

  1. What happens to the non-tiled addition when the first dimension of the matrix is 100? Why?

  1. Bonus: How can one add the matrix (in its new shape) and vector without np.tile()?