3.2. Introduction to Python#

This chapter introduces programming concepts and how those concepts are expressed in the Python programming language. Keep in mind that learning to program and learning the Python programming language are different things. Learning to program involves learning to break problems down into computational steps and synthesizing those steps into an algorithm, independently of what programming language, if any, one is using. To learn a programming language is to learn how those computational steps are expressed in the syntax and idioms of a specific coding system.

Learning to program and learning a programming language both take time and effort. A chapter like this isn’t enough to teach you everything about Python if you already know how to program in another language, and it certainly isn’t enough to teach you how to program if you haven’t programmed before. Many good books and online tutorials exist for those purposes. What we hope to accomplish in this chapter is highlight basic facts about programming and the Python programming language so that you know what to anticipate in the chapters that follow. This way you can begin to follow and make changes to the code portions of the notebooks. Obsvering the code in this book can be your first step towards writing programs yourself.

3.2.1. Expressions, types, variables, and statements#

The fundamental building block of code is an expression, which is a construct in the programming language that evaluates to a value. We can put an expression in a code cell in a notebook like this, and the Python interpreter will read that expression, evaluate it, and display the result. There are many kinds of expressions, but the easiest to understand are mathematical expressions, since they look mainly the same as they do in mathematical writing. Here are a few examples.

5
7+3
(27-9)/17

Values have different types. A type is a set of values associated together by how they are stored in computer memory and what operations are defined for them. Integers are real numbers and are represented by different types in Python. We can see the type of an expression by using the type function.

type(3+7)
type(24/7)

The type int is obviously short for integer. The real number type is called float–that is, floating point–in reference to how fractional values are stored in computer memory. Notice that in these two cases, the type itself is the value: the result of evaluating type(3+7) is the type of the result of 3+7, not the result of 3+7 itself.

Not all values are numeric. Other type of values include str (string, for text values) and bool (boolean, for logical values).

'jupyter notebook'
type('jupyter notebook')
3 < 7
type(3 < 7)

Values can be stored in variables so that they can be reused.

x = 7 + 3
x * 12

In the first line of that cell, 7 + 3 is an expression, as in earlier examples, but the line as a whole, x = 7 + 3, is not an expression but a statement. This means it does not result in a value but rather has a side effect, which means it makes the Python interpreter do something like display a plot, print something to the output, or, in this case, store a value in a variable. This is important because it means x = 7 + 3 is not a comparison (Is x equal to 7 + 3?) but an assignment (Store the result of 7+3 in variable x!). To test if two expressions evaluate to the same value, we use == instead of =.

x == 12

For a lot of purposes your knowledge of variables from math can guide you in understanding variables in Python or other code. But the difference between comparison and assignment bring up an important difference: just as variables can be assigned, they can also be reassigned. In fact, they can be used and reassigned in the same statement.

x = x + 1
x

Mathematically \(x = x + 1\) makes little sense, apart from always being false. (Can you think of any algebraic structure in which an element is equal to itself plus the multiplicative identity? I can’t.) But in programming it means, “Take the value that x used to be, add 1 to that value, and store that value back in variable x. To put a fancy word to it, variables in Python are mutable.

As a final observation about expressions and statements, notice that in each of the cells so far the output has been the value that results from the last expression. If we want to display more information, we can use the print() function, which has the side effect of displaying something to output. This function takes a string as its input. We can make a bigger string from smaller strings using +, which in the context of strings does string concatenation. Most non-string values can be turned to strings using the str() function.

print('At first x is ' + str(x))
x = x + 1
print('Now x is ' + str(x))
x = x * 25
print('Finally, x is ' + str(x))

3.2.2. Conditionals and while loops#

If a computational task is complicated enough that we want to use a computer for it, then it almost certainly requires some sort of decision-making and repetition. The most basic construct for making a decision in an algorithm is a conditional, which uses the Python keyword if.

if x < 12 :
    print('Hello')

Since x (from earlier) is not less than 12, the print statement wasn’t executed, and this cell had no output. We can make this into a decision between alternatives using else.

if x < 12 :
    print('Hello')
else :
    print('Aloha')

We can make a multiway branch by using any number of elif (short for else if) clauses in our conditional statement.

if x < 12 :
    print('Hello')
elif x < 100 :
    print('Howdy')
elif x < 350 :
    print('Ni hao')
else :
    print('Aloha')

Repetition is implemented using a loop, which executes a statement (the body of the loop) as long as a condition (the guard of the loop) is true. Each execution of the body is an iteration of the loop.

y = 10
while y > 0 :
    print('There are ' + str(y) + ' iterations left.')
    y -= 1
    print('(Deep breath)')

Notice several things in this example: The body of the loop consists of three statements; however, these three statements are treated as one compound statement because they are all indented together. The statement y -= 1 is a convenient shorthand for y = y - 1. We can similarly use y += 1 to mean increment y by one, etc. Finally, the loop did not stop as soon as y was no longer greater than 0 (when y -= 1 was executed the final time) but rather when it finished the iteration, printing (Deep breath) one last time. This is because the guard is checked only between iterations—it is not continuously checked while the body is executed.

If we wanted to quit the loop in the middle of an iteration, we can do that using a break statement, which we can put inside a conditional. In the following example, we use this instead of the guard and simply make the guard itself to be True.

y = 10
while True :
    print('There are ' + str(y) + ' iterations left.')
    y -= 1
    if y <= 0 :
        break
    print('(Deep breath)')

To see how well you follow this, make sure you can explain, (1) Why did we invert the guard from y > 10 to the y <= 10? and, (2) Why didn’t the output end with (Deep breath) as in the original loop?

3.2.3. Functions#

You already know about functions from mathematics. With just a little bit of Python syntax, you can make mathematical functions that fit right into the Python coding we’ve seen. Let’s take

\(f(x, y) = 2 x^2 - 3x + \frac{y}{x}\)

If we replace the = for a : and throw in the Python keywords def and return (because we are defining a function to return a certain value as its result), we get

def f(x, y) :
    return 2 * x**2 - 3 * x + y/x

Now we can call or apply the function to some parameters (or arguments), which all together makes an expression. (The ** operator does exponentiation.)

f(5, 3)

That’s well and good mathematically. But in programming, a function can be much more than that: it’s a way to package up a chunk of code, give the chunk of code a name, specify a variable to hold the input to that chunk of code, and indicate what that chunk of code’s output is, based on what is computed from the input when the chunk of code is executed. This allows that chunk of code to be executed many times from any context. (And we promise that we’ll never use the phrase chunk of code from this point on.)

def greatest_common_divisor(a, b) :
    while b > 0 :
        r = a % b    # The % operator is called "modulo" or "mod". It compute a remainder
        a = b
        b = r
    return a
greatest_common_divisor(24, 18)
greatest_common_divisor(100, 75)
def mad_lib(exclamation, adverb, plural_noun, verb):
    print('"' + exclamation + '!" he said ' + adverb + '. "I didn\'t know ' + plural_noun + 
          ' could ' + verb + '."')
mad_lib('Wow', 'warily', 'cardinals', 'yodel')

3.2.4. Anonymous (lambda) Functions#

In Python, functions are values, just like integers, floats, bools, and strings. This means that we can store them in variables. The following statement assigns the variable g to store the same function as is already referred to by function f:

g = f

The result is that g is an alias for f:

g(12, 7)

Since functions are values, we can make a function value without a name at all—an anonymous function. The syntax for an anonymous function begins with the keyword lambda which, for historical reasons, is used to introduce function definitions; followed by a list of parameters; a colon; and then an expression for computing the value to be returned. Here is a simple example that encapsulates the expression \(x^2\) as a function, but does not give that function a name.

lambda x:x**2

Since this value is a function, it can be called. Here the anonymous function is again, but this time immediately applied to a parameter:

(lambda x:x**2)(-3)

Make sure you understand that lambda is not the name of the function—it has no name at all. Moreover, there is no keyword return—the entire body of the function is simply the expression whose value is to be returned.

We can store the result of a lambda expression in a variable. This gives the function a name, so once we have done that the function is no longer anonymous, and we can call the function by name as we usually would.

h = lambda x:x**2
h(-3)

Some find the usage above to be a compact way to introduce a new, simple function, but it doesn’t have any real advantage over the following (you can judge for yourself what’s more readable or intuitive):

def h(x) : return x**2

The real advantage to functions as values is that functions can be passed to other functions and, if useful, returned from other functions. Take the idea of function composition. If you have two functions \(f\) and \(g\), you can compose them into a new function \(g \circ f\) that feeds the result of \(f\) into function \(g\) as its parameter. (In order for this to work, the codomain/return type of \(f\) must match the domain/parameter type of \(g\).)

\[ g \circ f (x) = g(f(x)) \]

For example, if \(f(x) = x^2\) and \(g(x) = 3 x\), then \(g \circ f(x) = 3 x^2\).

The following function is the equivalent of the function composition operator, \(\circ\), in that it takes two functions are returns a new function made by composing the two given functions.

def compose(g, f) :
    return lambda x : g(f(x))
def a(x) : return x**2
def b(x) : return 3 * x
b_a = compose(b, a)
b_a(5)

In writing compose, we used both the regular function form with def and this new form using lambda. This is because even though compose makes and returns an anonymous function, it itself is not anonymous.

3.2.5. Lists and other indexables and iterables#

For collections of data, we need more than variables that hold a single value. The code in the chapters that follow makes use of various specialized data structures, but for right now we’ll observe how to use the simplest container in Python, which is the list data structure. A list is an ordered sequence of values. We can express a list in Python by, well, listing a bunch of values, separated by commas and surrounded by square brackets. More precisely we don’t need to list values but rather expressions that the values result from.

[5, 7 + 8, "geoduck", 14 < 3, 12 / 5]

A list also is a value, just of a different type. We can store list values in variables.

z = [5, 7 + 8, "geoduck", 14 < 3, 12 / 5]
type(z)

Since a list is a linear sequence, we can refer to and retrieve a list’s components by index. Python lists are indexed from 0, which means that what normal people might call the “first” element is, for our purpose, the “zeroth” element. The len() function tells us the number of elements in the list.

print(z[0])
print(z[1])
print(z[len(z) - 1])

Notice that zero-based indexing means that the last element is in position one-less-than-the-length. The length of the list itself is not a valid index.

z[len(z)]

The most straightforward interpretation of a list for mathematical purposes is as a sequence of numbers. Using Python pieces we know so far, we can write a function that computes simple statistics for a list:

def simple_stats(xx) :
    min = xx[0]  # The minimum value we've seen so far
    max = xx[0]  # The maximum value we've seen so far
    sum = xx[0]  # The sum of all the numbers we've seen so far
    i = 1        # What does the variable i mean?
    while i < len(xx) :
        # x[i] is the current number we're looking at.
        # Add it to our running sum
        sum += xx[i]
        # If it's less than the minimum-so-far, then it's the new minimum-so-far
        if xx[i] < min :
            min = xx[i]
        # Similarly with maximum
        if xx[i] > max:
            max = xx[i]
        i += 1
    print('The sum of the numbers is ' + str(sum))
    print('The average of the numbers is ' + str(sum/len(xx)))
    print('The greatest number is ' + str(max))
    print('The least number is ' + str(min))
simple_stats([2,6,9,12,17,1,25,14])

The function simple_stats() doesn’t have a return statement. This means when it is called, the call doesn’t evaluate to a value—it just has side effects, namely printing results to output.

To answer our question, “What does the variable i mean?”, it means several things:

  • i is the number of values we’ve processed so far.

  • i is the index of the next value we’ll look at.

  • min is the minimum of all the values in indices 0 (inclusive) through i (exclusive).

  • i is one more than the number of iterations completed.

We can also modify a list by writing to a position or add to it by using the append() function.

z[3] = 'Yoda'
z
z.append(42)
z

You’ll notice that append() is used differently from other functions we’ve seen since the list we’re appending appears to the left of the function name, separated by a dot, rather than in the parentheses. The value being appended is in the parentheses. It’s a different kind of function, but that’s a story we’ll have to come back to later.

But the real power of Python indexing is that you can specify a range (a sublist or a substring) or count from the back of the list using negative indices. See how well you can infer the rules of indexing from these examples.

aa = ['A','B','C','D','E','F','G']
aa[-1]
aa[-3]
aa[2:5]
aa[1:5]
aa[:3]
aa[3:]
aa[-3:]
aa[:-3]

The fact that lists have components that can be referred to using square brackets means that lists are indexable. Strings are also indexable.

ss = 'Jupyter Notebook'
ss[5]

Lists and strings (and many other Python containers) share another useful property: They are iterable, which means they can be used in special constructs designed to iterate over the components. One construct is a for loop, an alternative to the while loop that works on iterables:

for a in z:
    print(a)

The way to interpret this is that the variable a ranges over the values in the list z. In each iteration, a takes on a different value in the list. Observe the usefulness of iterables in this simpler version of our simple_stats() function from before, this time just computing the average.

def average(xx) :
    sum = 0
    for x in xx :
        sum += x
    return sum / len(xx)
average([4,72,3,10,5,63])

To see this on strings (the function upper() converts text to uppercase):

def cheer(name) :
    for c in name :
        c = c.upper()
        if c in 'AEFHILMNORSX' :  # Letters whose names begin with a vowel sound
            print('Give me an ' + c)
        else :
            print('Give me a ' + c)
    print("What's that spell?")
    print(name)
    
cheer('Python')

We snuck in some new Python in that last example—using in to see if the character c occurs in our list (actually a string) of letters whose names begin with a vowel sound. It turns out that in can be used both in a for loop to iterate over an iterable, but also in an expression to test whether an element exists somewhere in an iterable.

One more iterable is particularly useful: the range() function, which returns an iterable over numbers in the range indicated in the parameters to the function. If you give the function one number, it is interpreted as an exclusive upperbound, with 0 as the inclusive lower bound:

for i in range(4) :
    print('Say hello, ' + str(i))

Or you can specify both an (inclusive) lower bound and an (exclusive) upper bound.

print('My favorite primes are')
for i in range(11,14) :
    print(i)
print('... well... except for 12.')

Finally, you can specify an increment:

for i in range(2, 9, 2) :
    print(i)
print('Whom do we appreciate?')
for i in range(5, 0, -1) :
    print('T-minus ' + str(i) + ' seconds')
print('Lift off')

Together with len(), ranges provide us with another idiom for iterating over the values in a list, if for some reason we don’t want to use the list as an iterable directly. For example, suppose we have a list of the coefficients of the polynomial \(3 + 4x - 7x^2 + 5x^3\). The following function evaluates a polynomial, given a list of coefficients and an \(x\) value:

def evaluate_polynomial(coefficients, x) :
    result = 0
    for i in range(len(coefficients)) :
        result += coefficients[i] * x**i
    return result
        
evaluate_polynomial([3,4,-7,5], 2)

How many meanings can you find for the variable i?

3.2.6. Thinking through algorithms#

Here is a function that takes a list yy and another value x and determines the first index in yy that contains x. If x doesn’t exist anywhere in the list, then the function returns the special value None.

def bounded_linear_search(yy, x) :
    i = 0
    found_it = False
    # As long as we have more positions to inspect and we haven't found what we're looking for
    while i < len(yy) and not found_it :
        # If i's the one we're looking for...
        if yy[i] == x :
            # ... great! We found it.
            found_it = True            
        # Otherwise ...
        else :
            # move on to the next one, if any.
            i += 1
    if found_it :
        return i
    else :
        return None
zz = [4,2,8,7,12,16,1,9,11]
print(bounded_linear_search(zz, 7))
print(bounded_linear_search(zz,5))

Here is another function that does something similar, but assumes the list is sorted from least to greatest. Because of this assumption it doesn’t start at the beginning but instead looks in the middle. Depending on whether the value in the middle is too big or too small, it “throws away” either the top half or the bottom half of the list (or, if the value is just right, it returns that index). To do this, the algorithm keeps track of an upper and lower bound of the range of indices in the list where the searched value could be—in other words, it has determined that the searched value can’t be outside that range. (The // operator does integer division. For positive integers, this means rounding down to the nearest integer. We do this because indices need to be integers.)

def binary_search(yy, x):
    lower_bound = 0
    upper_bound = len(yy)
    mid = (lower_bound + upper_bound) // 2
    # As long as the range is more than just one position...
    while upper_bound - lower_bound > 1 :
        # ...if the midpoint is the value we're looking for...
        if yy[mid] == x :
            # ...then collapse the range to include just that position
            lower_bound = mid
            upper_bound = mid+1
        # ...otherwise, if the midpoint is too big...
        elif yy[mid] > x :
            # ...then throw away the top half of the range
            upper_bound = mid
        # ...otherwise, the only option left is that the midpoint is too small, so..
        else :
            assert yy[mid] < x 
            # ...throw away the bottom half of the range.
            lower_bound = mid + 1
        # Recompute the midpoint index
        mid = (lower_bound + upper_bound) // 2

    # Check that the range isn't empty and that the midpoint is what we're looking for
    if upper_bound - lower_bound == 1 and yy[mid] == x :
        return mid
    else :
        return None
    

(assert means “I am certain of this proposition, may my program crash if I am wrong.”)

zz = [1,4,5,17,19,25,33,34,37,40,44,58,63]
print(binary_search(zz, 17))
print(binary_search(zz, 43))

How do these algorithms differ? Is one better than the other? We have already observed that the second algorithm makes a stronger assumption about its input than the first one does. It takes a very different strategy, which wouldn’t be correct if we didn’t make that stronger assumption. Thinking carefully about code comes down to two things: Correctness and efficiency.

We can reason about an algorithm’s efficiency by identifying what must be true of the state of the computation at each step. The state of the computation is captured by the value of the variables, and this ties in with what we’ve already alluded to when talking about the meaning of variables. What does the variable i mean in bounded_linear_search? Similar to simple_stats,

  • i is the number of values we’ve processed so far.

  • i is the index of the next value we’ll look at.

  • i is the number of iterations completed.

But especially i represents the algorithm’s progress in searching for the indicated value x (call it the search value). That is, i marks the boundary between two sections of the list: The indices less than i are known not to contain the search value; if the search value is anywhere in the list at all, it must be in an index i or greater.

  • For all indices \(j\), where \(0 \leq j < \mathtt{i}\), \(\mathtt{yy}[j] \neq \mathtt{x}\)

(We use typewriter font for variables that actually occur in the code. Since \(j\) is not a variable in the code, it gets the usual italic font.)

Here’s a new version of bounded_linear_search with the comments taken away but where we assert this last claim about i. Practically speaking this defeats the whole purpose of bounded_linear_search since this check does the same work as the entire algorithm, but we’re doing it here for demonstration purposes.

def bounded_linear_search(yy, x) :
    i = 0
    found_it = False
    while i < len(yy) and not found_it :
        assert not x in yy[:i]   # x is nowhere in y between 0 and i
        if yy[i] == x :
            found_it = True            
        else :
            i += 1
    if found_it :
        return i
    else :
        return None
zz = [4,2,8,7,12,16,1,9,11]
print(bounded_linear_search(zz, 7))
print(bounded_linear_search(zz,5))

For binary_search, the variables lower_bound and upper_bound define the region in the list where the search value might be. Here’s a new version that asserts that the search value isn’t anywhere outside that range.

def binary_search(yy, x):
    lower_bound = 0
    upper_bound = len(yy)
    mid = (lower_bound + upper_bound) // 2
    while upper_bound - lower_bound > 1 :
        assert not x in yy[:lower_bound] and not x in yy[upper_bound:]
        if yy[mid] == x :
            lower_bound = mid
            upper_bound = mid+1
        elif yy[mid] > x :
            upper_bound = mid
        else :
            assert yy[mid] < x 
            lower_bound = mid + 1
        mid = (lower_bound + upper_bound) // 2

    if upper_bound - lower_bound == 1 and yy[mid] == x :
        return mid
    else :
        return None
zz = [1,4,5,17,19,25,33,34,37,40,44,58,63]
print(binary_search(zz, 17))
print(binary_search(zz, 43))

But the real difference between these two algorithms can be seen by looking at their efficiency. The simplest way to observe the computational cost of an algorithm is to count the number iterations of its loop (assuming it’s only got one…). In this third version of bounded_linear_search, we display the number of iterations before the function returns.

def bounded_linear_search(yy, x) :
    i = 0
    found_it = False
    while i < len(yy) and not found_it :
        assert not x in yy[:i]   # x is nowhere in y between 0 and i
        if yy[i] == x :
            found_it = True            
        else :
            i += 1
    print(str(i) + ' iterations')
    if found_it :
        return i
    else :
        return None

Of course, the number of iterations depends on when the algorithm finds the search value, and the worst case in terms of cost is if it doesn’t occur in the list at all. Let’s observe the number of iterations if we search for a value in increasingly large lists which don’t contain that value.

bounded_linear_search([5]*8, 9)
bounded_linear_search([5]*16, 9)
bounded_linear_search([5]*32, 9)
bounded_linear_search([5]*64, 9)
bounded_linear_search([5]*128, 9)
bounded_linear_search([5]*256, 9)

The number of iterations equals the size of the list, but perhaps you could have predicted that, since each iterations inspects one position in the list. Let’s do a similar experiment on binary_search. Since that function doesn’t have a variable like i that tracks with the number of iterations, we need to add such a variable.

def binary_search(yy, x):
    lower_bound = 0
    upper_bound = len(yy)
    mid = (lower_bound + upper_bound) // 2
    i = 0
    while upper_bound - lower_bound > 1 :
        if yy[mid] == x :
            lower_bound = mid
            upper_bound = mid+1
        elif yy[mid] > x :
            upper_bound = mid
        else :
            assert yy[mid] < x 
            lower_bound = mid + 1
        mid = (lower_bound + upper_bound) // 2
        i += 1
        
    print(str(i) + ' iterations')
    if upper_bound - lower_bound == 1 and yy[mid] == x :
        return mid
    else :
        return None
binary_search([5]*8, 9)
binary_search([5]*16, 9)
binary_search([5]*32, 9)
binary_search([5]*64, 9)
binary_search([5]*128, 9)
binary_search([5]*256, 9)

Not only is this algorithm starkly more efficient, we can mathematically quantify the difference in efficiency. The worst-case number of iterations of binary_search tracks with the base-two logarithm of the length of the list. And this, too, makes sense: In each iteration of binary_search we throw away half of the remaining range of the list, and so it takes \(\log_2 n\) steps to whittle a list of length \(n\) down to a trivial range.

(We said the number iterations “tracks with” the base-two logarithm of the length of the list. How does the number of iterations relate to the length of the list precisely?)

3.2.7. Python libraries#

In practical programming it rarely makes sense to develop all your code from scratch. Many common problems have already been solved, and their solutions have been collected for reuse. Libraries, organized collections of reusable code, act as extensions of the programming language itself, and an important part of mastering any programming language is learning the libraries that come standard with the language and the widely-used libraries that have been developed by the language’s community of users. This section explains the fundamentals of libraries and related concepts like modules and packages by showing how to manage code with your own libraries and demonstrating parts of Python’s standard library. Later sections highlight other libraries that are useful to know for other chapters in this book.

Suppose that you frequently find yourself writing code that makes use of geometric formulas, such as the circumference or area of a circle or the volume of a sphere or cylinder. As you know by now, the natural thing to do is not to replicate the code for each formula every time you need it but to encapsulate the formula into a function—not only for convenience (avoid re-coding the formula) but also for readability (the code now has a name).

But if you have several notebooks or applications that make use of the same formulas, then pasting those function definitions into cells in each new notebook creates unnecessary clutter. Instead, collect those function definitions into a separate Python file.

The folder that contains this notebook also has a file geometry.py consisting of the definitions for circumference_circle, area_circle, volume_sphere, and volume_cylinder. Note that this is not a notebook, which would have a .ipynb file extension. You should be able to look at the file in your browser but not execute its code that way.

We can, however, make the code there available for use in this notebook. First, we import the code in that file:

import geometry

Now we can call one of the functions.

geometry.volume_sphere(3.8)

Notice that we call the function by the qualified name geometry.volume_sphere instead of the simple name volume_sphere as it appears in the file itself. The prefix geometry. indicates that volume_sphere is a part of the module geometry. Formally, a module is a collection of code contained in a Python file.

There are a variety of ways to use import statements. For example, the following import statement grabs only the function area_circle, and the subsequent code doesn’t need to qualify the name when it is used.

from geometry import area_circle
area_circle(5.7)

If you have a large number of related modules, it’s natural that you would collect them into one or more folders in the file system. In Python terminology, a folder of Python files constitutes a package, which, in turn, can have subpackages in subfolders. A large collection of modules, often organized into many packages, is called a library.

The standard library comprises the packages that are an integral part of any Python installation. You can see what the standard library offers by perusing its documentation or working through any comprehensive Python tutorial. For our purposes, consider these two examples.

The math package provides an abundance of functions and constants for familiar mathematical tasks.

import math
# least common multiple of a collection of integers
print(math.gcd(120, 96))
# 'floor' of (greatest integer less than) a float
print(math.floor(26.7))
# arctangent (result in radians)
print(math.atan(2.3))
# e (Euler's number) raised to a given power
print(math.exp(5.8))
# tau (mathematical constant equal to two pi)
print(math.tau)

The random package contains functions for generating random numbers.

import random
# Pick an integer uniformly distributed within a given range.
print(random.randint(3,17))
# Pick a float from a Gaussian distribution with a given mean and standard deviation
print(random.gauss(.6, 3.5))
xx = [2,7,3,8,14,9,63]
# Permute a sequence
random.shuffle(xx)
print(xx)
# Pick an element from a sequence
print(random.choice(xx))

The packages in Python’s standard library are extensive, and yet are just the beginning of the tools available in libraries published by other organizations. In contrast to the basic mathematical functions provided by the math package, the scipy library from scipy.org supports more advanced operations like fast Fourier transform, solving systems of linear equations, and interpolation.

3.2.8. Classes and objects#

Programming is all about data and operations on data. We have seen that types define categories of data and that functions encapsulate algorithms that operate on data. Types range from primitive types like int and float to complicated data structures likes lists.

In Python (and most other modern programming languages), classes are a language feature by which a programmer can define new types. Moreover, classes allow data and functionality to be encapsulated together.

Suppose we want to make a type for complex numbers (actually Python has a complex number type, but suppose we’re hard-core do-it-yourselfers). A value of this type is composed of two parts, the so-called “real” and “imaginary” parts, each of which are float-valued. The following Complex class defines a type for values like this.

# Class to define a complex number type, first try
class Complex :
    def __init__(self, a, b) :
        self.real_part = a
        self.imaginary_part = b

Before explaining this code, let’s see how it is used to make the value \(3.5 + 2.7 i\).

z = Complex(3.5, 2.7)
print(z)
print(type(z))
print(str(z.real_part) + ' + ' + str(z.imaginary_part) + 'j')

Here is how to understand all of this:

  • The variable z stores a value whose type is Complex.

  • That value has two components called real_part and imaginary_part, and those components each have type float.

  • We use . to retrieve a component—hence z.real_part means “retrieve the component called real_part from the Complex value stored in variable z.”

  • The class Complex has a special function __init__ that initializes the components of a Complex value. Inside the __init__ function, the value whose components are being initialized is referred to by the parameter self.

  • A value of a class type is called an object. It is also an instance of that class. Making a new instance of a class an initializing its components is called instantiating that class.

  • The expression Complex(3.5, 2.7) was used to instantiate the Complex class. This not only made a new instance of the class but also called the __init__ function. The new instance was passed to __init__ as self and the values 3.5 and 2.7 were passed as the other parameters to __init__.

Now that we’ve made this class and an instance of the class, we can make functions for complex operations

def magnitude(z) :
    return (z.real_part**2 + z.imaginary_part**2)**.5

def add_complex(z, w) :
    return Complex(z.real_part + w.real_part, z.imaginary_part + w.imaginary_part)

def mult_complex(z, w) :
    return Complex(z.real_part * w.real_part - z.imaginary_part * w.imaginary_part,
                  z.real_part * w.imaginary_part + z.imaginary_part * w.real_part)

That works just fine for a simple class like Complex. But what makes objects distinctive is that they can contain not only variables but also functions. A function that is a component of a class is called a method. In this second version of the Complex class we include methods like the functions defined in the previous cell.

# Class to define a complex number type, first try
class Complex :
    def __init__(self, a, b) :
        self.real_part = a
        self.imaginary_part = b
    
    def magnitude(self) :
        return (self.real_part**2 + self.imaginary_part**2)**.5

    def add(self, other) :
        return Complex(self.real_part + other.real_part, self.imaginary_part + other.imaginary_part)

    def mult(self, other) :
        return Complex(self.real_part * other.real_part - self.imaginary_part * other.imaginary_part,
                      self.real_part * other.imaginary_part + self.imaginary_part * other.real_part)

Since the methods are components—or attributes—of the Complex objects, we refer to them using . notation:

z = Complex(2.1, 4.7)
w = Complex(-5.2, 8.5)
y = z.add(w)
print(str(y.real_part) + ' + ' + str(y.imaginary_part) + 'i')

In the expression z.add(w), notice that add has only one actual parameter, w, which in fact is assigned to the formal parameter other. The parameter self is assigned the object that the method is called on, z.

The variable components of a class (called data attributes or instance variables) can be modified. This mutates the state of the object.

class Hummingbird :
    def __init__(self) :
        self.alive = True
        self.energy = 5
        
    def feed(self) :
        if self.alive :
            self.energy += 3

    def fly(self) :
        if self.alive :
            self.energy -= 1
        if self.energy <= 0 :
            self.alive = False

    def show(self) :
        if self.alive:
            return "-%^'"  # That's supposed to be an ASCII hummingbird. Use your imagination
        else :
            return "_o___"  # Same hummingbird, deceased (ran out of energy)

A Hummingbird object has two data attributes, indicating whether it is alive and how much energy it has left. The hummingbird can feed, that is, take a sip from a feeder or flower, which makes it gain energy, and fly, which consumes energy. These methods modify the data attribute energy, but only if the hummingbird is alive. If the hummingbird keeps flying without getting a chance to feed, it will run out of energy and die. The method show returns a string with very bad ASCII art representing a live or dead hummingbird.

h = Hummingbird()
print(h.show())
h.fly()
print(h.show())
h.feed()
print(h.show())
h.fly()
print(h.show())
h.fly()
print(h.show())
h.fly()
print(h.show())
h.fly()
print(h.show())
h.fly()
print(h.show())
h.fly()
print(h.show())
h.fly()
print(h.show())

Apologies to all hummingbird lovers. (Or you can consider this as an encouragement to plant some hummingbird-friendly flowers and keep your feeder full.)

For a better way to visualize mutable objects, consider a class representing a car to be used in a simulation. First we need to import a few pieces from the libraries matplotlib, svgpath2mpl and svgpathtools. You’ll get an error when you execute that cell if your Python installation doesn’t have those libraries. The section “Other Libraries” below talks through how to install libraries you don’t already have.

import matplotlib as mpl
import matplotlib.pyplot as plt
%matplotlib inline
from svgpath2mpl import parse_path
from svgpathtools import svg2paths

Details in the class Car below are explained in the comments. (This class is based on code used to produce the animation in the notebook “Random Number Simulation of a Drive Thru” in the “Probability Models” chapter. Thanks to Ziling Zong who wrote the original code.)

class Car:
    # To make a new car, indicate the coordinates of the car's initial position,
    # the angle (in degrees from the positive x axis) that the car is facing,
    # and the color of the car. Color is a string, for example "red"
    def __init__(self, x, y, degree, color):   
        # Set the instance variables. Notice that the first four instance variables
        # have the same names as the parameters
        self.x = x  
        self.y = y
        self.degree = degree
        self.color = color
        # The instance variable car_marker is used for the graphical display
        # and requires a little bit of processing
        car_path, attributes = svg2paths("car_outline.svg")
        self.car_marker = parse_path(attributes[0]['d'])
        self.car_marker.vertices -= self.car_marker.vertices.mean(axis=0)
        
    # Move the car a certain number of pixels in its current direction
    def move(self, amount):  
        # Convert the angle the car is facing. We need radians.
        radians = self.degree * math.pi / 180
        # Use trig functions to compute the new x and y coordinates
        self.x += amount * math.cos(radians)
        self.y -= amount * math.sin(radians)
    
    # Turn the car a certain number of degrees. Positive degrees steers to the
    # left, negative to the right
    def turn(self, amount) :
        self.degree += amount
        
    # Display an image of the car on a background
    def show_car(self) :
        building = "background.png"
        fig, ax = plt.subplots()
        background = plt.imread(building)
        ax.imshow(background)

        transformed_marker = self.car_marker.transformed(mpl.transforms.Affine2D().rotate_deg(self.degree))
        transformed_marker = transformed_marker.transformed(mpl.transforms.Affine2D().scale(1,1))
        plt.plot(self.x, self.y, marker=transformed_marker, markersize=35, color = self.color)

Let’s make a red car facing due east (0 degrees), starting at point (100, 550).

car = Car(100, 550, 0, 'red')

The background image shows a drive-thru restaurant with two drive-thru lanes. The point (100, 550) was chosen because that puts the car at the beginning of the outer lane.

car.show_car()

Let’s nudge the car ahead and then see what it looks like.

car.move(75)
car.show_car()

Now let’s steer and move so that the car begins to navigate the lane.

car.turn(15)
car.move(75)
car.show_car()

It looks like the car is crashing into the drive-thru speaker. Oh well. If you run the previous cell seven more times, you’ll see that after running over the speaker the car more or less rounds the east side of the restaurant without hitting anything else.

In a new series of cells, make a new car with a color of your choice and see if you can steer the car safely through the lane using calls to move and turn (as well as show_car to track your progress).

3.2.9. Section on numpy#

The NumPy library is a widely-used package for numerical computing in Python. Several other packages for data analysis, scientific computing, machine learning, and the like make use of NumPy.

import numpy as np

The key part of the NumPy library is the array. If you learned programming in a language like C or Java, you are already familiar with arrays; for the purposes of Python, we can think of them as being like lists except that they make more efficient use of computer memory. Mathematically, they are the natural way to represent vectors and matrices.

Observe by example. There are several ways to make an array. We can make an array from a list.

aa = np.array([1,2,4,5,6])
print(type(aa))
print(aa)

We can make an “empty” array—or, really, an array of all zeros:

bb = np.zeros(10)
print(bb)

We can make an array that contains all the values in a range:

cc = np.arange(5,15,.75)
print(cc)

Let’s look carefully at that function above, arange. The name come froms “array range”—it’s not the English word arrange. It is based on the standard range function we saw earlier. The three parameters are the (inclusive) start, (exclusive) end, and step size.

Another function to make a new array has a subtle but important difference from arange. The linspace function takes an inclusive beginning and ending value, but, instead of step size, it takes a number of values. The values themselves are uniformly spread through the range.

dd = np.linspace(5, 15, 10)
print(dd)
ee = np.linspace(5, 15, 25)
print(ee)

The function’s name means “linear space,” and it is especially useful in making graphs, since it can make an array to contain the x values at which to plot points. You can increase the graph’s resolution by increasing the third parameter to the function.

An important concept with arrays is their shape. For all the arrays we have made so far, the shape is just the size or number of elements.

print(aa.shape)
print(bb.shape)
print(cc.shape)
print(dd.shape)
print(ee.shape)

The reason why the shape is formatted that way is because these arrays have only one dimension—but we can make arrays of any number of dimensions. Here we make a 4-by-6 array of all zeros:

mm = np.zeros((4,6))
print(mm.shape)
print(mm)

Arrays also can be reshaped.

ff = np.arange(0,24)
print(ff)
gg = ff.reshape((2,12))
print(gg)
hh = ff.reshape((6,4))
print(hh)

The NumPy library provides a wide range of tools for indexing into an array. First notice that the following give the same result.

print(hh[2][3])
print(hh[2,3])

The second of these opens the door to various ways to take a slice of a two-dimensional array.

# Grab a row
print(hh[1,:])
# Grab a column---although it will be formatted like a row
print(hh[:,1])
# Grab everything but the outer rows and columns
print(hh[1:6,1:4])

Just as arrays are the natural representation of vectors, two-dimensional arrays are the natural representation of matrices. NumPy arrays support a full range of vector and matrix operations, many of which can be used through the same symbols as plain old arithmetic. To conclude, think through these examples:

# Make two vectors of the same shape
v1 = np.array([1,3,7,9])
v2 = np.array([2,12,8,1])

# We can add two vectors
print(v1 + v2)

# The * symbol does component-wise multiplication
print(v1 * v2)

# The @ symbol does matrix multiplication, but for two
# vectors that amount to the dot product
print(v1 @ v2)

# We can make @ give us the cross product by reshaping
# the vectors into single-column or single-row matrices.
# The result is a matrix
m = v1.reshape((4,1)) @ v2.reshape((1,4))
print(m)

# Mixing arrays and numbers in arithmetic operations 
# performs component-wise arithmetic with the given scalar
print(v1 + 5)
print(1/m)

3.2.10. Trouble shooting libraries#

As you work through code found in other chapters of this book, you may encounter notebooks that use libraries not installed on your system. Running this code will result in an error like ModuleNotFound. Depending on your platform or installation, this problem may turn out to be easy to fix. You can search for the missing Python library on the Web. Most widely-used libraries have accompanying websites that include installation instructions, usually involving the pip tool. For example, the seaborn library for statistical data visualization is used by several of this book’s chapters, and it can be installed by running the following command in commandline interface (“Terminal” on Mac, “Powershell” on MS Windows):

pip install seaborn

Of course, all of this is just the beginning of Python and its libraries for math, data science, and related fields. The next chapter follows up by showing Python as a tool for working with data.