Mathematics in Python

I heard that the other day Dr. Wright said something in class about how keen some programming language was, and Kent spoke up with "What about Python?", or words to that effect. I thought I'd put up a little something saying a little bit about how incredibly keen Python is, for your benefit - if you're interested in programming then it's definitely something you should know about, and if you're not interested in programming it seems like an even better idea that you should hear a little bit about Python, cuz if you're going to be forced to learn some programming language for some purpose someday Python would be an excellent choice, one reason being it's so easy.

What's so great about Python? Well, I know a couple of programming languages and have a fairly good idea how a much larger number work, and it's clear to me that Python simply has a much higher power/ease of use ratio than anything else out there. Professional programmers use it for all sorts of things. You can go here for a description of some of the many things I've used it for - on this page I'm going to say a little bit about doing mathematical programming in Python. (No, it's not a substitute for things like Maple and Mathematica. Note as well that this page is not a tutorial in Python programming, the point is just to pique your interest. You can download Python for free at www.python.org; it comes with a very nice tutorial. Or you can ask me (Dr. Ullrich) about Python programming issues, or find lots of help on the internet and in many books on the topic (you can find links to many of these resources at www.python.org).)

There's no reason anyone should take my word for what's a good programming language. But Eric Raymond is a huge name in open-source programming - go here for excerpts from an article by him on what's so great about Python (also a link to the original article). Whether I'm qualified to have an opinion or not, here's a few comments on what I personally find so keen about the language:

Vectors and Matrices

One of the things that makes Python code easy to write is that it's "untyped". (Well, technically the variables are untyped, but the values are typed...) This means you can say things like
x = 2
print x
x = [1, 2, 3]
print x
without needing to "declare" the variable x as an integer or a "list"; the first "print x" above prints "2", and the second prints "[1, 2, 3]", no problem. We're going to use Python list objects for vectors.

Well, Python list objects are not vectors, they're lists. So for example if you say

x = [1, 2, 3]
print x + x
what gets printed is the list [1, 2, 3, 1, 2, 3], which is a reasonable way to "add" two lists, useful for the things we use lists for, but it's not the way we want to add two vectors. We're going to "wrap" Python list objects in a Vector class, and then teach Vectors the right way to add themselves.

We create a Python class using the keyword "class" (hmm). The first thing we need to do is give the Vector class a __init__ "method", which will allow us to insert some data into a Vector object when we construct it:

class Vector:
  
  def __init__(self, data)
    self.data = data
That's a valid and totally useless Vector class. We create a Vector object by calling "Vector" as a function, passing a list of data:
class Vector:
  
  def __init__(self, data):
    self.data = data
    
x = Vector([1, 2, 3])    
(there are tricks you could use to make "Vector(1, 2, 3)" do the same thing as "Vector([1,2,3])", but never mind that for now...)

If you execute the code above then x is a Vector object, which has a field "data" equal to the list [1, 2, 3]; if you say

print x.data
at this point you'll see a printout of the list [1, 2, 3]. But saying "print x" will not give what you want, instead it prints something like "<__main__.Vector instance at f1ae10>", not very helpful. Well, so far we haven't said what we want to happen when we print a Vector object, so Python just tells us it's a Vector. We can fix that by adding a __repr__ method:
class Vector:
  
  def __init__(self, data):
    self.data = data
    
  def __repr__(self):
    return repr(self.data)  
    
x = Vector([1, 2, 3])    
print x
That __repr__ method tells Python that when we print a Vector object we actually want to print the data contained in the Vector, and now the "print x" prints "[1, 2, 3]", which is more like it. (Technical note that you should ignore at this point...)

Of course we want to be able to add vectors; right now if we say

class Vector:
  
  def __init__(self, data):
    self.data = data
    
  def __repr__(self):
    return repr(self.data)  
    
x = Vector([1, 2, 3])    
print x + x
we don't get [2, 4, 6] or [1, 2, 3, 1, 2, 3], instead we get an error message, because we haven't told Python how Vectors should be added.

This is where the keen part starts. We say how Vectors should be added by giving the class a __add__ method:

class Vector:
  
  def __init__(self, data):
    self.data = data
    
  def __repr__(self):
    return repr(self.data)  
    
  def __add__(self, other):
  #note that there are much better ways to write this
  #code, here we're trying to write self-explanatory code
  #instead of "good" code
  
    data = [] #start with an empty list
    
    for j in range(len(self.data)):
    #that's Python for "for j = 0 to len(self.data) - 1 do:
    
      data.append(self.data[j] + other.data[j])
      
    #so far data is the list the the resulting Vector
    #should contain - now we wrap it into a Vector:
    
    return Vector(data)  
    
x = Vector([1, 2, 3])    
print x + x
Now the "print x+x" prints [2, 4, 6]. Hooray, we can add Vectors.

Hmm, all those comments make the code look a lot more complicated than it really is - of course comments are a Good Thing, but these comments are just intended for readers who have no idea at all how the language works, not the sort of comment you'd include in actual code. If you leave out the comments it's just

class Vector:
  
  def __init__(self, data):
    self.data = data
    
  def __repr__(self):
    return repr(self.data)  
    
  def __add__(self, other):
    data = []
    for j in range(len(self.data)):
      data.append(self.data[j] + other.data[j])
    return Vector(data)  
    
x = Vector([1, 2, 3])    
print x + x
That's all there is to making Vectors that know how to add themselves.

Of course there's a lot more you'd want vectors to be able to do, like you should be able to subtract them, multiply them by scalars, etc. You can do those things by adding various methods to the Vector class; here we're just trying to illustrate a few things, not develop actual working code, so let's leave Vector for now and go on to Matrix, where we illustrate another extremely keen and powerful aspect of Python programming.

Matrices

In the previous section we illustrated what's called "operator overloading" in Python - we "overloaded" the + operator by giving Vector an __add__ method. In this section we're going to illustrate another aspect of Python that makes it very useful in mathematical and other programming, namely "polymorphism". (In this section we assume that the Vector class is as it was at the end of the previous section.)

So far the entries in our vectors have been numbers. But they don't have to be! Look at the __add__ method of the Vector class again:

  def __add__(self, other):
    data = []
    for j in range(len(self.data)):
      data.append(self.data[j] + other.data[j])
    return Vector(data)
The part that actually does the addition is the "self.data[j] + other.data[j]", which adds one entry of one Vector (self) to an entry of another Vector (other). You might think that self.data[j] has to be a number, but it doesn't have to be, this is going to work as long as self.data[j] and other.data[j] are any sort of value, as long as Python knows what their sum should be.

First a silly but useless example for illustration, then a useful example. Python adds strings by concatenating them: 'Hello ' + 'World' evaluates to 'Hello World'. In particular, Python does have a notion of the sum of two strings, and that means we should be able to add Vectors whose components are strings. And it turns out we can: if we say

x = Vector(['Hello ', 'silly '])
y = Vector(['World', 'example'])
print x + y
then the elements of x and y get added componentwise, and x + y gets printed as ['Hello World', 'silly example']. (The components of a Vector don't have to be the same type, for example
x = Vector(['Hello ', 1])
y = Vector(['World', 2])
print x + y
prints ['Hello World', 3].)

I can't really imagine why anyone would want to do that, add Vectors whose components were strings. But hmm, the components of a Vector can be anything that Python knows how to add, and Python knows how to add Vectors. So the components of a Vector can themselves be Vectors, which gives us a way to represent matrices! If you add two Vectors that have Vectors for components the components should get added correctly, giving us the sum of two matrices (regarding a matrix, or a 2x2 array, as a sequence of sequences of numbers.) Let's see if this works:

x = Vector([Vector([1, 2]), Vector([3, 4])])
print x
print x + x
Yup. The "print x" prints [[1, 2], [3, 4]], and then the "print x + x" prints [[2, 4], [6, 8]], just as we'd want.

This is "polymorphism": Python code doesn't care what things actually are, as long as they're things that can perform the operations that it wants to perform on them.

It's also where things get so keen I just can't stand it: we wrote a loop to add Vectors, now you'd think we'd need to write a double loop to add matrices, but no, Vector addition automatically works for adding matrices, as long as we represent the matrices as Vectors whose components are also Vectors.

Of course having to write "Vector([Vector([1, 2]), Vector([3, 4])])" to define a matrix is a pain. We could try just "Vector([[1, 2], [3, 4]])" instead, but that doesn't work because then we get a Vector whose components are bare Python list objects instead of Vectors. (Quiz: If we say

x = Vector([[1, 2], [3, 4]])
print x + x
what will the result look like? There's earlier in this document...)

Fixing the "Vector([Vector([1, 2]), Vector([3, 4])])" problem leads to our final topic: inheritance (also known as "subclassing".) We're going to say

class Matrix(Vector)
That means Matrix is a "subclass" of Vector: a Matrix object will act exactly like a Vector object except for any behavior we explicitly change by writing a new method. Here Vectors act the way we want Matrices to act, except for the initialization - we want to be able to say Matrix([[1, 2], [3, 4]]) to construct the matrix in the previous example, so we override (rewrite) the __init__ method.

The new __init__ method assumes that data is a list of lists, and it uses calls to Vector() to convert it to a list of Vectors (As with the __add__ method above, note that there are much "better" ways the new __init__ could be written:)

class Matrix(Vector):
  def __init__(self, data):
    newdata = []
    for v in data:
      newdata.append(Vector(v))
    self.data = newdata
    
x = Matrix([[1, 2], [3, 4]])
print x + x
Sure enough that works, prints [[1, 2], [3, 4]].

If you're not impressed I suspect that you've never tried to write code that does the same thing in languages like C, Pascal or Fortran (if you have done that and you're still not impressed let me know.)


That's all I have to say for most of the readers I had in mind when writing this. A few more comments for readers who know what "functional programming" is. Python is not Lisp, but it does include things like lambda, map, filter, etc:

For example, here's the "better" way to write Matrix.__init__() mentioned above:

class Matrix(Vector):
  def __init__(self, data):
    self.data = map(Vector, data)
    
x = Matrix([[1, 2], [3, 4]])
print x + x
Again, for those "advanced" readers: map() takes two (or more) arguments, a function and a list to apply the function to. Vector is not a function, but Vector (I mean the class Vector itself, not instances of the class) is a "callable object", which means you can use it anywhere a function is expected. Another illustration of one of the things that makes Python so powerful: It doesn't care what things are, as long as those things know how to do what they're asked to do! (Ie here map() doesn't care whether the function is really a function or not, as long as it's callable. Yes, you can make your own objects callable, by giving them a __call__ method.)

Hmm, does this mean that the second argument to map() could be a Vector() instead of a list? Not with the present Vector class, but yes if we make instances of Vector into "list-like" objects by adding a __getitem__ method and a __len__ method:

class Vector:
  
  def __init__(self, data):
    self.data = data
    
  def __repr__(self):
    return repr(self.data)  
    
  def __add__(self, other):
    return Vector(map(lambda x, y: x+y, self, other))

  def __getitem__(self, index):
    return self.data[index]

  def __len__(self):
    return len(self.data)

x = Vector([1,2,3])
y = map(lambda x: x**2, x)
print y
That prints [1, 4, 9]: Having a __getitem__ means that a Vector can be indexed; the way we defined it implies that if x is a Vector then x[0] is the same as x.data[0]. The __len__ method specifies what the "len" of a Vector should be, and the class having both methods makes it sufficiently list-like that we can pass it to map. (Note that the fact that Vector has become "list-like" is why I could say map(lambda x, y: x+y, self, other) in the revised __add__ method, instead of map(lambda x, y: x+y, self.data, other.data))

Amazing.