Warm Up: Introduction to Python Tutorial

There are a few basic data types in Python including strings, ints, floats, lists, dictionaries. Uncomment and play around with these:

In [1]:
#type(5)
#type('bob')
#type(1.)
#type([])
#type({})
In [2]:
# This is a list of strings:
some_names = ['angie', 'kofi', 'sarah']
print some_names
['angie', 'kofi', 'sarah']
In [3]:
# This is a dictionary of ages for each of the names above
the_crew   = {'angie': 27, 'kofi': 30, 'sarah': 33}
print the_crew
{'sarah': 33, 'kofi': 30, 'angie': 27}
In [4]:
# we can iterate over the list of strings with a for loop:
for name in some_names:
    print name
angie
kofi
sarah
In [5]:
# we can also iterate using the location of the element in the list to access each element:
for i in range(len(some_names)):
    print some_names[i]
angie
kofi
sarah
In [6]:
# let's say I don't like short names. I'll start out making an empty list to store the names I like.
names_I_like = []
In [7]:
# we can append to a list of names longer than say 4 letters as follows:
for name in some_names:
    # check if the length of the name has 5 or more letters
    if len(name) >= 5:
        names_I_like.append(name)
In [8]:
print names_I_like
['angie', 'sarah']

I can use LaTeX to typeset equations within the Notebook. Remember the dot product of two vectors? Let's say that $a = [a_1, a_2,\cdots, a_n]$ and $b = [b_1, b_2, \cdots, b_n]$ \begin{equation} a \cdot b = \sum\limits_{i=1}^{n}a_i\times b_i \end{equation}

Let's say that a = [2,2,3] and b = [4,-2,1.3] Can you compute their dot product here? Write a function that takes as input any vectors a and b, and returns their dot product.

To write a function that computes the dot product of two vectors, we'll need to understand the summation operator as corresponding to an iteration. We could use a for loop here:

In [9]:
def dotproduct(a,b):
    c = 0
    assert len(a) == len(b) # the lenths better be equal!
    for i in range(len(a)):
        # print i, a[i], b[i], a[i]*b[i] # uncomment me to make sure you understand what is happening here.
        c  += a[i]*b[i]
    return c
In [10]:
# let's test it out... pretty basic huh?
a = [2,-2,3]
b = [4,1,4]
print dotproduct(a,b)
18

We also described the product between two arbitrary matrices A, B. Suppose A has n rows by k columns, B has k rows by m columns. Why do the rules of matrix multiplication exist? That is, why do we multiply the rows of A by the columns of B? Make sure that you understand this. The process can be described: \begin{equation} A \cdot B = (AB){ij} = \sum\limits{k=1}^{m}A{ik}B{kj} \end{equation} We discussed how the matrix product was repeating the dot product between the rows of A and the columns of B. Can you formalize this as an algorithm? In class we saw a correct algorithm but there was a typo in the implementation. Algorithms are closely related to data structures and data types and this deserves some unique attention...

One way to define matrices is with nested lists: Let's define a list A containing two lists inside of it, each representing A's rows:

In [11]:
A = [[2,1],[4,3]]
print A
[[2, 1], [4, 3]]

We'll define a list B containing two lists inside of it, each representing B's rows:

In [12]:
B = [[3,-1,3], [2,-2,2]]
print B
[[3, -1, 3], [2, -2, 2]]
In [13]:
# Uncomment below and run to make sure you understand what is going on here...
#print A
#print B
for row in A:
    print row
[2, 1]
[4, 3]

Good story bro... How do we access the columns of A or B? We want to do this in a way that isn't tedious. One solution is to use the built in zip function:

In [14]:
print zip(*A)
[(2, 4), (1, 3)]
In [15]:
print zip(*B)
[(3, 2), (-1, -2), (3, 2)]
In [16]:
# we can now access the columns of A or B as follows:
for col in zip(*B):
    print col
(3, 2)
(-1, -2)
(3, 2)

We want to take the dot product of the rows in A and the columns in B. This gives us the components of C but we want to store them in the proper location of the new matrix C which we are defining using lists. Can you modify the code below to do this? Hint: how will you define C? It's a bit tricker given our choice of lists.

In [17]:
# Some code to get you started:
for row in A:
    for col in zip(*B):
        sum = dotproduct(row,col)
        print sum
8
-4
8
18
-10
18

Using lists may not be the most natural way to work numerically using Python. We moved quickly to numpy, a library that supports a different object, the numpy array (or matrix).

In [18]:
import numpy as np

A = np.matrix(A)
B = np.matrix(B)

Uncomment this and play around with it to make sure you are familiar with numpy arrays.

In [19]:
print A
# checking the dimension of the object
#A.shape
# number of rows: 
#A.shape[0]
# number of cols: 
#A.shape[1]
# look at all rows for the 0th column
#A[:,0]
# look at all columns for the 0th row
#A[0,:]
# look at a specific component of the matrix indexed by row and column
#A[1,1]
[[2 1]
 [4 3]]

We can index the rows or columns of A very naturally:

In [20]:
for i in range(A.shape[0]):
    print A[i,:]
[[2 1]]
[[4 3]]
In [21]:
# let's look at the columns:
for j in range(B.shape[1]):
    print B[:,j]
[[3]
 [2]]
[[-1]
 [-2]]
[[3]
 [2]]

In class I gave you this code which was correct, although there was a typo in my implementation. Here it was:

In [22]:
def bad_mmult(A,B):
    for i in range(len(A[:,0])):
        for j in range(len(B[0,:])):
            sum = 0
            # here is where we apply the dot product
            for k in range(len(A[0,:])):
                sum += A[i,k]*B[k,j]
            C[i,j] = sum
    return C

Did you find the bug? We never defined the matrix C when we switched to numpy. Hiding in plain sight...

In [23]:
# The correct code should read:
def mmult(A,B):
    C = np.zeros([A.shape[0],B.shape[1]])
    for i in range(A.shape[0]):
        for j in range(B.shape[1]):
            sum = 0
            # here is where we apply the dot product
            for k in range(A.shape[1]):
                sum += A[i,k]*B[k,j]
            C[i,j] = sum
    return C

Let's test it out:

In [24]:
C = mmult(A,B)
print C
[[  8.  -4.   8.]
 [ 18. -10.  18.]]

Let's compare to the built-in function to verify our result is correct:

In [25]:
mmult(A,B) == np.dot(A,B)
Out[25]:
matrix([[ True,  True,  True],
        [ True,  True,  True]], dtype=bool)

We are interested in analyzing computation a bit more rigorously. How does the number of steps that an algorithm performs change as a function of the size of the input?

One way to characterize this is with Landau's notation. Before we introduce this, let's motivate the idea a bit more intuitively.

How does the number of steps that our multiplier performs change as a function of the size of the matrices A, and B? Let's time our code and run some experiments. Before we see the output, what kind of function do we expect to describe the relationship between input size and running time for our algorithm? (is it linear, quadratic, cubic? quartic?)

In [26]:
# define a variable for the size of the matrices. 
size = 10
# Generate some random matrices to multiply (they can be square without loss of generality):
A = np.random.randn(size,size)
B = np.random.randn(size,size)
# print A, B
In [27]:
import timeit
start_time = timeit.default_timer()
C = mmult(A,B)
elapsed = timeit.default_timer() - start_time
print "that took ", elapsed, "seconds"
that took  0.00101685523987 seconds

Can we iterate over many sizes? Define an empty list for the time it will take:

In [28]:
times = []
sizes = []
In [29]:
# let's try multiplying matrices from size 1x1 to 120x120
for i in range(120):
    size = i+1
    A = np.random.randn(size,size)
    B = np.random.randn(size,size)
    start_time = timeit.default_timer()
    C = mmult(A,B)
    elapsed = timeit.default_timer() - start_time
    #print "size = ", size, "it took ", elapsed, "seconds"
    times.append(elapsed)
    sizes.append(size)
In [30]:
# let's import the matrix plotting library to plot some results
import matplotlib.pyplot as plt
# this lets us display figures nicely within the Notebook
%matplotlib inline
In [31]:
# here is how we plot
plt.scatter(sizes, times)
plt.plot(sizes, times)
# label the x and y axes
plt.ylabel("running time (seconds)")
plt.xlabel("input dimension of matrices")
plt.title("running time as a function of input size")
Out[31]:
<matplotlib.text.Text at 0x108abd350>
/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/site-packages/matplotlib/collections.py:590: FutureWarning: elementwise comparison failed; returning scalar instead, but in the future will perform elementwise comparison
  if self._edgecolors == str('face'):

Based on what you know now, how can you describe the 'trick' we used to efficiently compute the Fibonnaci numbers?

\begin{equation} fib(n) = fib(n-2) + fib(n-1) \end{equation}
In [32]:
def badfib(n):
   if n == 1:
      return 1
   elif n == 0:
      return 0
   else:
      return badfib(n-1) + badfib(n-2)

Why is this so slow? Exactly how slow is it? Will you grow old and die before the computer finishes?

In [33]:
n = 100
# print "the ", n,"th fibonacci number is: ", badfib(n)
In [34]:
fibTable = {1:1, 2:1}

def goodfib(n):
   if n <= 2:
      return 1
   if n in fibTable:
      return fibTable[n]
   else:
      fibTable[n] = goodfib(n-1) + goodfib(n-2)
      return fibTable[n]
In [35]:
n = 200
print "the ", n,"th fibonacci number is: ", goodfib(n)
the  200 th fibonacci number is:  280571172992510140037611932413038677189525
In [ ]: