Introduction to Numpy and Scipy
[2]:
import numpy as np
import scipy.special
In this lesson, you will learn about Numpy, arguably the most important package for scientific computing, and Scipy, a package containing lots of goodies for scientific computing, like special functions and numerical integrators.
A very brief introduction to Numpy arrays
The central object for Numpy and Scipy is the ndarray
, commonly referred to as a “Numpy array.” This is an array object that is convenient for scientific computing. We will go over it in depth in the next lesson, but for now, let’s just create some Numpy arrays and see how operators work on them.
Just like with type conversions with lists, tuples, and other data types we’ve looked at, we can convert a list to a Numpy array using
np.array()
Note that above we imported the Numpy package “as np
”. This is for convenience; it allow us to use np
as a prefix instead of numpy
. Numpy is in very widespread use, and the convention is to use the np
abbreviation.
[3]:
# Create a Numpy array from a list
my_ar = np.array([1, 2, 3, 4])
# Look at it
my_ar
[3]:
array([1, 2, 3, 4])
We see that the list has been converted, and it is explicitly shown as an array. It has several attributes and lots of methods. The most important attributes are probably the data type of its elements and the shape of the array.
[4]:
# The data type of stored entries
my_ar.dtype
[4]:
dtype('int64')
[5]:
# The shape of the array
my_ar.shape
[5]:
(4,)
There are also lots of methods. The one I use most often is astype()
, which converts the data type of the array.
[6]:
my_ar.astype(float)
[6]:
array([1., 2., 3., 4.])
There are many others. For example, we can compute summary statistics about the entries in the array.
[7]:
print(my_ar.max())
print(my_ar.min())
print(my_ar.sum())
print(my_ar.mean())
print(my_ar.std())
4
1
10
2.5
1.118033988749895
Importantly, Numpy arrays can be arguments to Numpy functions. In this case, these functions do the same operations as the methods we just looked at.
[8]:
print(np.max(my_ar))
print(np.min(my_ar))
print(np.sum(my_ar))
print(np.mean(my_ar))
print(np.std(my_ar))
4
1
10
2.5
1.118033988749895
Other ways to make Numpy arrays
There are many other ways to make Numpy arrays besides just converting lists or tuples. Below are some examples.
[9]:
# How long our arrays will be
n = 10
# Make a Numpy array of length n filled with zeros
np.zeros(n)
[9]:
array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0.])
[10]:
# Make a Numpy array of length n filled with ones
np.ones(n)
[10]:
array([1., 1., 1., 1., 1., 1., 1., 1., 1., 1.])
[11]:
# Make an empty Numpy array of length n without initializing entries
# (while it initially holds whatever values were previously in the memory
# locations assigned, ones will be displayed)
np.empty(n)
[11]:
array([1., 1., 1., 1., 1., 1., 1., 1., 1., 1.])
[12]:
# Make a Numpy array filled with zeros the same shape as another Numpy array
my_ar = np.array([[1, 2], [3, 4]])
np.zeros_like(my_ar)
[12]:
array([[0, 0],
[0, 0]])
We will talk more about the np.random
submodule later in the course, but for now, we will show that you can make an array full of random numbers and use this array in our examples going further.
[13]:
# Make an array of random numbers between zero and one
np.random.seed(3252)
rand_array = np.random.random(24)
rand_array
[13]:
array([0.72363619, 0.80625687, 0.75507222, 0.47529264, 0.21808614,
0.10797734, 0.8419304 , 0.26319505, 0.56976174, 0.51605265,
0.36873593, 0.06029085, 0.26310492, 0.01178828, 0.89250854,
0.84298439, 0.8336817 , 0.1566005 , 0.21741298, 0.39289867,
0.8466088 , 0.74271579, 0.06722195, 0.98538097])
Slicing Numpy arrays
We can slice Numpy arrays like lists and tuples. Here are a few examples.
[14]:
# Reversed array
rand_array[::-1]
[14]:
array([0.98538097, 0.06722195, 0.74271579, 0.8466088 , 0.39289867,
0.21741298, 0.1566005 , 0.8336817 , 0.84298439, 0.89250854,
0.01178828, 0.26310492, 0.06029085, 0.36873593, 0.51605265,
0.56976174, 0.26319505, 0.8419304 , 0.10797734, 0.21808614,
0.47529264, 0.75507222, 0.80625687, 0.72363619])
[15]:
# Every 5th element, starting at index 3
rand_array[3::5]
[15]:
array([0.47529264, 0.56976174, 0.01178828, 0.21741298, 0.98538097])
[16]:
# Entries 10 to 20
rand_array[10:21]
[16]:
array([0.36873593, 0.06029085, 0.26310492, 0.01178828, 0.89250854,
0.84298439, 0.8336817 , 0.1566005 , 0.21741298, 0.39289867,
0.8466088 ])
Fancy indexing
Numpy arrays also allow fancy indexing, where we can slice out specific values. For example, say we wanted indices 1, 19, and 6 (in that order) from rand_array
. We just index with a list of the indices we want.
[17]:
rand_array[[1, 19, 6]]
[17]:
array([0.80625687, 0.39289867, 0.8419304 ])
Instead of a list, we could also use a Numpy array.
[18]:
rand_array[np.array([1, 19, 6])]
[18]:
array([0.80625687, 0.39289867, 0.8419304 ])
As a very nice feature, we can use Boolean indexing with Numpy arrays. Say we only want the random numbers greater than 0.7. We an use the >
operator to find out which entries in the array are in fact greater than 0.7.
[19]:
rand_array > 0.7
[19]:
array([ True, True, True, False, False, False, True, False, False,
False, False, False, False, False, True, True, True, False,
False, False, True, True, False, True])
This gives a Numpy array of True
s and False
s. We can then use this array of Booleans to index which entries in the array we want to keep.
[20]:
# Just slice out the big ones
rand_array[rand_array > 0.7]
[20]:
array([0.72363619, 0.80625687, 0.75507222, 0.8419304 , 0.89250854,
0.84298439, 0.8336817 , 0.8466088 , 0.74271579, 0.98538097])
If we want to know the indices where the values are high, we can use the np.where()
function.
[21]:
np.where(rand_array > 0.7)
[21]:
(array([ 0, 1, 2, 6, 14, 15, 16, 20, 21, 23]),)
Numpy arrays are mutable
Yes, Numpy arrays are mutable. Let’s look at some consequences.
[22]:
# Make an array
my_ar = np.array([1, 2, 3, 4])
# Change an element
my_ar[2] = 6
# See the result
my_ar
[22]:
array([1, 2, 6, 4])
Now, let’s try working attaching another variable to the Numpy array.
[23]:
# Attach a new variable
my_ar2 = my_ar
# Set an entry using the new variable
my_ar2[3] = 9
# Does the original change? (yes.)
my_ar
[23]:
array([1, 2, 6, 9])
Let’s see how messing with Numpy in functions affects things.
[24]:
# Re-instantiate my_ar
my_ar = np.array([1, 2, 3, 4]).astype(float)
# Function to normalize x (note that /= works with mutable objects)
def normalize(x):
x /= np.sum(x)
# Pass it through a function
normalize(my_ar)
# Is it normalized even though we didn't return anything? (Yes.)
my_ar
[24]:
array([0.1, 0.2, 0.3, 0.4])
So, be careful when writing functions. What you do to your Numpy array inside the function will happen outside of the function as well. Always remember that:
Numpy arrays are mutable.
Slices of Numpy arrays are views, not copies
A very important distinction between Numpy arrays and lists is that slices of Numpy arrays are views into the original Numpy array, NOT copies.
[25]:
# Make list and array
my_list = [1, 2, 3, 4]
my_ar = np.array(my_list)
# Slice out of each
my_list_slice = my_list[1:-1]
my_ar_slice = my_ar[1:-1]
# Mess with the slices
my_list_slice[0] = 9
my_ar_slice[0] = 9
# Look at originals
print(my_list)
print(my_ar)
[1, 2, 3, 4]
[1 9 3 4]
Messing with an element of a slice of a Numpy array messes with that element in the original! This is not the case with lists. Remember this!
Slices of Numpy arrays are views, not copies.
Fortunately, you can make a copy of an array using the np.copy()
function.
[26]:
# Make a copy
rand_array_copy = np.copy(rand_array)
# Mess with an entry
rand_array_copy[10] = 2000
# Check equality
np.allclose(rand_array, rand_array_copy)
[26]:
False
So, messing with an entry in the copy did not affect the original.
Mathematical operations with arrays
Mathematical operations on arrays are done elementwise to all elements.
[27]:
# Divide one array be another
np.array([5, 6, 7, 8]) / np.array([1, 2, 3, 4])
[27]:
array([5. , 3. , 2.33333333, 2. ])
[28]:
# Multiply by scalar
-4 * rand_array
[28]:
array([-2.89454476, -3.22502748, -3.02028886, -1.90117055, -0.87234457,
-0.43190937, -3.36772162, -1.05278019, -2.27904696, -2.06421061,
-1.47494371, -0.2411634 , -1.05241968, -0.0471531 , -3.57003416,
-3.37193757, -3.3347268 , -0.62640202, -0.86965191, -1.5715947 ,
-3.38643522, -2.97086315, -0.26888778, -3.94152387])
[29]:
# Raise to power
rand_array**2
[29]:
array([5.23649337e-01, 6.50050142e-01, 5.70134051e-01, 2.25903092e-01,
4.75615653e-02, 1.16591066e-02, 7.08846805e-01, 6.92716325e-02,
3.24628441e-01, 2.66310340e-01, 1.35966185e-01, 3.63498667e-03,
6.92241987e-02, 1.38963448e-04, 7.96571494e-01, 7.10622684e-01,
6.95025179e-01, 2.45237178e-02, 4.72684032e-02, 1.54369369e-01,
7.16746467e-01, 5.51626742e-01, 4.51879002e-03, 9.70975652e-01])
Indexing 2D Numpy arrays
Numpy arrays need not be one-dimensional. We’ll create a two-dimensional Numpy array by reshaping our rand_array
array from having shape (24,)
to having shape (6, 4)
. That is, it will become an array with 6 rows and 4 columns.
[30]:
# New 2D array using the reshape() method
rand_array_2d = rand_array.reshape((6, 4))
# Look at it
rand_array_2d
[30]:
array([[0.72363619, 0.80625687, 0.75507222, 0.47529264],
[0.21808614, 0.10797734, 0.8419304 , 0.26319505],
[0.56976174, 0.51605265, 0.36873593, 0.06029085],
[0.26310492, 0.01178828, 0.89250854, 0.84298439],
[0.8336817 , 0.1566005 , 0.21741298, 0.39289867],
[0.8466088 , 0.74271579, 0.06722195, 0.98538097]])
Notice that it is represented as an array made out of a list of lists. If we had a list of lists, we would index it like this:
list_of_lists[i][j]
[31]:
# Make list of lists
list_of_lists = [[1, 2], [3, 4]]
# Pull out value in first row, second column
list_of_lists[0][1]
[31]:
2
Though this will work with Numpy arrays, this is not how Numpy arrays are indexed. They are indexed much more conveniently.
[32]:
rand_array_2d[0, 1]
[32]:
0.8062568709113288
We essentially have a tuple in the indexing brackets. Now, say we wanted row with index 2 (remember that indexing starting at 0).
[33]:
rand_array_2d[2, :]
[33]:
array([0.56976174, 0.51605265, 0.36873593, 0.06029085])
We can use Boolean indexing as before.
[34]:
rand_array_2d[rand_array_2d > 0.7]
[34]:
array([0.72363619, 0.80625687, 0.75507222, 0.8419304 , 0.89250854,
0.84298439, 0.8336817 , 0.8466088 , 0.74271579, 0.98538097])
Note that this gives a one-dimensional array of the entries greater than 0.7. If we wanted indices where this is the case, we can again use np.where()
.
[35]:
np.where(rand_array_2d > 0.7)
[35]:
(array([0, 0, 0, 1, 3, 3, 4, 5, 5, 5]), array([0, 1, 2, 2, 2, 3, 0, 0, 1, 3]))
This tuple of Numpy arrays is how we would pull those values out using fancy indexing.
[36]:
rand_array_2d[(np.array([0, 1, 1, 2, 4, 5]), np.array([3, 2, 3, 3, 3, 0]))]
[36]:
array([0.47529264, 0.8419304 , 0.26319505, 0.06029085, 0.39289867,
0.8466088 ])
Numpy arrays can be of arbitrary integer dimension, and these principles extrapolate to 3D, 4D, etc., arrays.
Concatenating arrays
We can tape two arrays together if we like. The np.concatenate()
function accomplishes this. We simply have to pass it a tuple containing the Numpy arrays we want to concatenate.
[37]:
another_rand_array = np.random.random(10)
combined = np.concatenate((rand_array, another_rand_array))
# Look at it
combined
[37]:
array([0.72363619, 0.80625687, 0.75507222, 0.47529264, 0.21808614,
0.10797734, 0.8419304 , 0.26319505, 0.56976174, 0.51605265,
0.36873593, 0.06029085, 0.26310492, 0.01178828, 0.89250854,
0.84298439, 0.8336817 , 0.1566005 , 0.21741298, 0.39289867,
0.8466088 , 0.74271579, 0.06722195, 0.98538097, 0.76464333,
0.21468332, 0.46706424, 0.43516852, 0.218179 , 0.85635848,
0.11666964, 0.19284475, 0.97663249, 0.51806187])
Numpy has useful mathematical functions
So far, we have not done much mathematics with Python. We have done some adding and division, but nothing like computing a logarithm or cosine. The Numpy functions also work elementwise on the arrays when it is intuitive to do so. That is, they apply the function to each entry in the array. Check it out.
[38]:
# Exponential
np.exp(rand_array)
[38]:
array([2.06191712, 2.23950951, 2.12776518, 1.60848483, 1.2436942 ,
1.1140225 , 2.32084282, 1.30108047, 1.7678458 , 1.67540119,
1.44590573, 1.06214543, 1.30096321, 1.01185803, 2.44124594,
2.32329025, 2.30177762, 1.1695283 , 1.24285727, 1.48126829,
2.33172609, 2.10163537, 1.06953283, 2.67883224])
[39]:
# Cosine
np.cos(2*np.pi*rand_array)
[39]:
array([-0.16489218, 0.34615756, 0.03186428, -0.98797431, 0.19917961,
0.77855165, 0.54602806, -0.08281198, -0.90546344, -0.99491776,
-0.67873582, 0.9291022 , -0.08224763, 0.99725823, 0.78046395,
0.55156407, 0.50189441, 0.55373776, 0.20332268, -0.78199416,
0.57041499, -0.04575207, 0.91212083, 0.99578438])
[40]:
# Square root
np.sqrt(rand_array)
[40]:
array([0.85066809, 0.89791808, 0.86894891, 0.68941471, 0.46699694,
0.32859906, 0.91756766, 0.51302539, 0.75482564, 0.71836805,
0.6072363 , 0.24554195, 0.51293754, 0.10857383, 0.9447267 ,
0.91814181, 0.91306172, 0.39572782, 0.46627565, 0.6268163 ,
0.92011347, 0.8618096 , 0.25927195, 0.99266357])
We can even do some matrix operations (which are obviously not done elementwise), like dot products.
[41]:
np.dot(rand_array, rand_array)
[41]:
8.279227344296817
Numpy also has useful attributes, like np.pi
(which we already snuck into a code cell above).
[42]:
np.pi
[42]:
3.141592653589793
Scipy has even more useful functions (in modules)
Scipy actually began life as a library of special functions that operate on Numpy arrays. For example, we can compute an error function using the scipy.special
module, which contains lots of special functions. Note that you often have to individually import the Scipy module you want to use, for example with
import scipy.special
[43]:
scipy.special.erf(rand_array)
[43]:
array([0.69386995, 0.74580509, 0.71440432, 0.49852153, 0.24223752,
0.12136752, 0.7662166 , 0.29026648, 0.57962151, 0.53449285,
0.39796154, 0.0679486 , 0.29017159, 0.01330103, 0.79312234,
0.76680147, 0.7616034 , 0.17527083, 0.24151311, 0.42154482,
0.76880477, 0.70644679, 0.07573775, 0.83654318])
There are many Scipy submodules which give plenty or rich functionality for scientific computing. You can check out the Scipy docs to learn about all of the functionality. In my own work, I use the following extensively.
scipy.special
: Special functions.scipy.stats
: Functions for statistical analysis.scipy.optimize
: Numerical optimization.scipy.integrate
: Numerical solutions to differential equations.scipy.interpolate
: Smooth interpolation of functions.
We will use scipy.stats
and scipy.optimize
in the second half of the course.
Numpy and Scipy are highly optimized
Importantly, Numpy and Scipy routines are often fast. To understand why, we need to think a bit about how your computer actually runs code you write.
Interpreted and compiled languages
We have touched on the fact that Python is an interpreted language. This means that the Python interpreter reads through your code, line by line, translates the commands into instructions that your computer’s processor can execute, and then these are executed. It also does garbage collection, which manages memory usage in your programs for you. As an interpreted language, code is often much easier to write, and development time is much shorter. It is often easier to debug. By contrast, with compiled languages (the dominant ones being Fortran, C, and C++), your entire source code is translated into machine code before you ever run it. When you execute your program, it is already in machine code. As a result, compiled code is often much faster than interpreted code. The speed difference depends largely on the task at hand, but there is often over a 100-fold difference.
First, we’ll demonstrate the difference between compiled and interpreted languages by looking at a function to sum the elements of an array. Note that Python is dynamically typed, so the function below works for multiple data types, but the C function works only for double precision floating point numbers.
[44]:
# Python code to sum an array and print the result to the screen
print(sum(my_ar))
17
/* C code to sum an array and print the result to the screen */
#include <stdio.h>
void sum_array(double a[], int n);
void sum_array(double a[], int n) {
int i;
double sum=0;
for (i = 0; i < n; i++){
sum += a[i];
}
printf("%g\n", sum);
}
The C code won’t even execute without another function called main
to call it. You should notice the difference in complexity of the code. Interpreted code is very often much easier to write!
Numpy and Scipy use compiled code!
Under the hood, when you call a Numpy or Scipy function, or use one of the methods, the Python interpreter passes the arrays into pre-compiled functions. (They are usually C or Fortran functions.) That means that you get to use an interpreted language with near-compiled speed! We can demonstrate the speed by comparing an explicit sum of elements of an array using a Python for
loop versus Numpy. We will use the np.random
module to generate a large array of random numbers. We then use the
%timeit
magic function to time the execution of the sum of the elements of the array.
[45]:
# Make array of 10,000 random numbers
x = np.random.random(10000)
# Sum with Python for loop
def python_sum(x):
x_sum = 0.0
for y in x:
x_sum += y
return x_sum
# Test speed
%timeit python_sum(x)
1.01 ms ± 40.5 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
Now we’ll do the same test with the Numpy implementation.
[46]:
%timeit np.sum(x)
7.13 μs ± 150 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
Wow! We went from a millisecond to microseconds!
Word of advice: use Numpy and Scipy
If you are writing code and you think to yourself, “This seems like a pretty common things to do,” there is a good chance the someone really smart has written code to do it. If it’s something numerical, there is a good chance it is in Numpy or Scipy. Use these packages. Do not reinvent the wheel. It is very rare you can beat them for performance, error checking, etc.
Furthermore, Numpy and Scipy are very well tested (and we learned the importance of that in the test-driven development lessons). In general, you do not need to write unit tests for well-established packages. Obviously, if you use Numpy or Scipy within your own functions, you still need to test what you wrote.
Computing environment
[47]:
%load_ext watermark
%watermark -v -p numpy,scipy,jupyterlab
Python implementation: CPython
Python version : 3.12.4
IPython version : 8.25.0
numpy : 1.26.4
scipy : 1.13.1
jupyterlab: 4.0.13