Week 3#

Exercise 1 - Quadratic functions#

Exercise 1a) Defining the Quadratic class#

Example solution with test block is shown below. Note that while we store the coefficients in a tuple, they can just as well be stored as three seperate float variables.

%matplotlib notebook
class Quadratic:
    def __init__(self, a2, a1, a0):
        self.coeffs = (a2, a1, a0)
        
    def __call__(self, x):
        a2, a1, a0 = self.coeffs
        return a2*x**2 + a1*x + a0

# Test block
import numpy as np
import matplotlib.pyplot as plt
    
f = Quadratic(1, -2, 1)
x = np.linspace(-5, 5, 101)
plt.figure(0, figsize=(7,5))
plt.plot(x, f(x))
plt.show()

Exercise 1b) Pretty printing#

Here we use some string methods to clean up the output somewhat

class Quadratic(Quadratic):
     def __str__(self):
        # Create general string
        expr = "{}x^2 + {}x + {}".format(*self.coeffs)
        # Clean up somewhat
        expr = expr.replace("+ -", "- ") # Prettier signs
        expr = expr.replace(" 1x", " x") # Handle unitary coefficients
        if expr.startswith("1x"):
            expr = expr[1:]
        return expr
    
# Test block
f = Quadratic(2, -1, 3)
print(f)
2x^2 - x + 3

Exercise 1c) Adding together polynomials#

Note that the add method has to create a new Quadratic object.

class Quadratic(Quadratic):
    def __add__(self, other):
        a2, a1, a0 = self.coeffs
        b2, b1, b0 = other.coeffs
        return Quadratic(a2+b2, a1+b1, a0+b0)

    
# Test block
f = Quadratic(1, -2, 1)
g = Quadratic(-1, 6, -3)
h = f + g
print(h)
plt.figure(1, figsize=(7,5))
x = np.linspace(-5, 5, 101)
plt.plot(x, h(x))
plt.show()
0x^2 + 4x - 2

Note that the result becomes a straight line, and so the output when priting also starts with “0x^2”. This isn’t particularly good class design, and we should probably handle it in some way, but we ignore this for now.

Exercise 1d) Finding the roots#

We do not bother with handling the edge case of \(a=0\) here, altough we probably should! Note also that return () returns an empty tuple.

class Quadratic(Quadratic):
    def roots(self):
        """Find the roots of the quadratic function (if any).
        
        The roots are the points where f(x) = 0, a quadratic function
        has 0, 1 or 2 roots. Results are returned as a tuple.
        
        Warning: Does not currently handle the edge case of a=0.
        """
        a, b, c = self.coeffs
        discriminant = b**2 - 4*a*c
        
        # No roots
        if discriminant < 0:
            return ()
        
        # One root
        elif discriminant == 0:
            return (-b/(2*a),)
    
        # Two roots
        else:
            sqrt_disc = np.sqrt(discriminant)
            return ((-b-sqrt_disc)/(2*a), (-b+sqrt_disc)/(2*a))

        
# Test block
print(f"{'f':^15} | {'roots':^10}")
for f in Quadratic(2, -2, 2), Quadratic(1, -2, 1), Quadratic(1, -3, 2):
    print(f"{str(f):>15s} | {str(f.roots()):^10s}")
       f        |   roots   
  2x^2 - 2x + 2 |     ()    
   x^2 - 2x + 1 |   (1.0,)  
   x^2 - 3x + 2 | (1.0, 2.0)

Exercise 1e) Finding the intersection of two quadratic functions#

Finding the intersections between \(f\) and \(g\) means solving the equation where \(f = g\), which can be rewritten as \(f-g = 0\), and we know that \(f-g\) defines a polynomial. So we first make a __sub__ special method so we can compute \(h = f - g\), and then simply call \(h.roots()\)

class Quadratic(Quadratic):
    def __sub__(self, other):
        """Subtract two Quadratic objects giving a new Quadratic object."""
        a2, a1, a0 = self.coeffs
        b2, b1, b0 = other.coeffs
        return Quadratic(a2-b2, a1-b1, a0-b0)
        
    def intersect(self, other):
        diff = self - other
        return diff.roots()

plt.figure(2, figsize=(7,5))
f = Quadratic(1, -2, 1)
g = Quadratic(2, 3, -2)

x = np.linspace(-8, 8, 101)
plt.plot(x, f(x))
plt.plot(x, g(x))

x_intersect = f.intersect(g)
y_intersect = [f(i) for i in x_intersect]
plt.plot(x_intersect, y_intersect, 'x', color='black', markersize=8)
plt.show()

Exercise 2 - A class for general polynomials#

Exercise 2a) Defining the Polynomial class#

Note that when we loop over the dictionary, we write

for n, an in self.coeffs.items()

This is because the .items-method returns a list of (key, value) tuples.

class Polynomial:
    def __init__(self, coeffs):
        self.coeffs = coeffs
    
    def __call__(self, x):
        tot = 0
        for n, an in self.coeffs.items():
            tot += an*x**n
        return tot
    
    def __str__(self):
        # Create list of terms
        terms = [f"{an}x^{n}" for n, an in sorted(self.coeffs.items(), reverse=True)]
        # Join together to form string
        string = " + ".join(terms)
        # Handle special cases for prettier print
        string = string.replace("+ -", "- ")
        string = string.replace("x^0", "")
        string = string.replace("x^1 ", "x ")
        string = string.replace(" 1x", " x")
        if string.startswith("1x"):
            string = string[1:]
        if string.endswith("^1"):
            string = string[:-2]
        return string

coeffs = {0: 1, 5:-1, 10:1}
f = Polynomial(coeffs)
print(f)

plt.figure(3, figsize=(7,5))
x = np.linspace(-1, 1, 101)
plt.plot(x, f(x))
plt.show()
x^10 - x^5 + 1

Exercise 2b): Adding general polynomials together#

Here we give several example solutions. First with the most straight-forward approach:

class Polynomial(Polynomial):
    def __add__(self, other):
        new_coeffs = {}
        
        # Add all terms from first polynomial
        for n, an in self.coeffs.items():
            new_coeffs[n] = an
        
        # Add all terms from second polynomial (careful with collisions)
        for n, bn in other.coeffs.items():
            if n in new_coeffs:
                new_coeffs[n] += bn
            else:
                new_coeffs[n] = bn
        
        # Delete coefficients that cancel out, for efficiency and tidyness
        new_coeffs = {n: cn for n, cn in new_coeffs.items() if cn != 0}
        return Polynomial(new_coeffs)
    
# Test block
f = Polynomial({0:1, 5:-7, 10:1})
g = Polynomial({5:7, 10:1, 15:-3})
print(f+g)
-3x^15 + 2x^10 + 1

An alternative is to use collections.defaultdict, which defaults to 0 if we try to use a key that doesn’t exist.

from collections import defaultdict

class Polynomial(Polynomial):
    def __add__(self, other):
        new_coeffs = defaultdict(int)
        
        # Add all coefficients from both polynomials
        for n, an in self.coeffs.items():
            new_coeffs[n] += an
        for n, bn in other.coeffs.items():
            new_coeffs[n] += bn
            
        # Delete coefficients that cancel out, for efficiency and tidyness
        new_coeffs = {n: an for n, an in new_coeffs.items() if an != 0}
        return Polynomial(new_coeffs)

# Test block
f = Polynomial({0:1, 5:-7, 10:1})
g = Polynomial({5:7, 10:1, 15:-3})
                
print(f+g)
-3x^15 + 2x^10 + 1

Alternatively we could have used a normal dictionary and the .get method with a default-value of 0, i.e.,

for n, an in self.coeffs.items():
            new_coeffs[n] = an
        for n, bn in other.coeffs.items():
            new_coeffs[n] = new_coeffs.get(n, 0) + bn

Exercise 2c) Defining a AddableDictionary class#

Note: Several people have complained about this exercise being confusing. The goal was simply to show how one could extend a built in class to add new functionality to it—and how such extensions could generalize a feature.

We only need to make the add method, which should make a new AddableDictionary object. We first make a copy of self, and then add in the other to it

class AddableDict(dict):
    def __add__(self, other):
        new_dict = AddableDict(self)
        for key, value in other.items():
            if key in new_dict:
                new_dict[key] += value
            else:
                new_dict[key] = value
        return new_dict
    
a = AddableDict({0: 2, 1: 3, 2: 4})
b = AddableDict({0: -1, 1:3, 2: 3, 3: 2})
print(a + b)
{0: 1, 1: 6, 2: 7, 3: 2}

With the AddableDict class, making the Polynomial class is easier:

class Polynomial:
    def __init__(self, coeffs):
        self.coeffs = AddableDict(coeffs)
        
    def __add__(self, others):
        return Polynomial(self.coeffs + other.coeffs)

Exercise 2d) Derivative of a polynomial#

You can solve this with a normal for-loop:

class Polynomial(Polynomial):
    def derivative(self):
        new_coeffs = {}
        for n, an in self.coeffs.items():
            if n != 0:
                new_coeffs[n-1] = an*n
        return Polynomial(new_coeffs)
    
f = Polynomial({10:1, 6:-3, 2:2})
df = f.derivative()
print(df)
10x^9 - 18x^5 + 4x

Or it can be done a bit quicker using a dictionary comprehension:

class Polynomial(Polynomial):
    def derivative(self):
        new_coeffs = {n-1: an*n for n, an in self.coeffs.items() if n != 0}
        return Polynomial(new_coeffs)
    
    
f = Polynomial({10:1, 6:-3, 2:2})
df = f.derivative()
print(df)
10x^9 - 18x^5 + 4x

Exercise 2e) Multiplying polynomials#

Here we need to use nested loops

class Polynomial(Polynomial):
    def __mul__(self, other):
        new_coeffs = {}
        for i, ai in self.coeffs.items():
            for j, bj in other.coeffs.items():
                new_coeffs[i+j] = ai*bj
        return Polynomial(new_coeffs)
        
f = Polynomial({2: 4, 1: 1})
g = Polynomial({3: 3, 0: 1})
print(f*g)
12x^5 + 3x^4 + 4x^2 + x

Exercise 3 — Harmonic Oscillator Wave Function#

Exercise 3a) HOWF and Hermite Polynomials#

class HOWF:
    def __init__(self, n):
        self.n = n
        self.H = self._compute_hermite(n)
        
    def _compute_hermite(self, n): 
        if n == 0:
            return Polynomial({0 : 1})
        elif n == 1:
            return Polynomial({1 : 2})
        else:
            poly1 = Polynomial({1 : 2})
            poly2 = Polynomial({0 : -2*(n-1)})
            return poly1*self._compute_hermite(n-1) + poly2*self._compute_hermite(n-2)
def test_Hermite():
    H0 = lambda x: 1
    H1 = lambda x: 2*x
    H2 = lambda x: 4*x*x - 2
    H3 = lambda x: 8*x**3 - 12*x
    H4 = lambda x: 16*x**4 - 48*x**2 + 12
    H5 = lambda x: 32*x**5 - 160*x**3 + 120*x
    H_table = [H0, H1, H2, H3, H4, H5]
    
    tol = 1e-12
    for n in range(0, 6):
        H = HOWF(n).H
        for x in [0, 0.5, 1.0/3, 1, 3/2, 2]:
            expected = H_table[n](x)
            computed = H(x)
            msg = "The implemented Hermite Polynomial yields unexpected result for n = %d\
            \n\tH(%.2f) = %.13g != %.13g" %(n, x, expected, computed)
            assert abs(expected - computed) < tol, msg
            
test_Hermite() 

Exercise 3b) The quantum harmonic wave function#

The imports are included because there is a known bug in numpy.sqrt(x). It will cry if the x-value is too large.

from numpy import exp, pi
from math import factorial, sqrt


class HOWF(HOWF):
    def __call__(self, chi):
        A = pi**(-1/4)*2**(-self.n/2)/sqrt(factorial(self.n))
        H = self.H(chi)
        return A*H*exp(-chi**2/2.0)
    
    @property
    def energy(self):
        return 2*self.n + 1

Exercise 3c) Visualising the wave functions#

import matplotlib.pyplot as plt
import numpy as np

N = 20
xmax = 8

fig, ax = plt.subplots(figsize=(7, 7))
chi = np.linspace(-xmax, xmax, 300)
y = []
y_tick = []
y_tick_right = []
for n in range(0, N+1):
    psi = HOWF(n)
    plt.plot(chi, psi(chi) + psi.energy)
    y_tick.append(r"$\epsilon_{%d}$" %n)
    y.append(psi.energy)
    y_tick_right.append("$\psi_{%d} (\chi)$" %n)
    
plt.grid()
plt.ylim([0, 2*N + 3])
plt.yticks(y, y_tick)
plt.xlabel(r"$\chi$")
plt.xlim(-xmax, xmax)

# just to get the "labels" on the other side...
ax2 = ax.twinx() 
plt.ylim([0, 2*N + 3])
plt.yticks(y, y_tick_right)
plt.tick_params(axis='y', labelleft='off', labelright='on')

plt.title("Quantum Harmonic Oscillator Wave Functions")
plt.show()

Exercise 4 — Fibonnaci with Memoization#

Exercise 4a) A Fibonacci function#

Here we use an if-test to check for the base-case (\(n=0\) or \(n=1\)), and call the function recursively if \(n>1\):

def fibonacci(n):
    if n < 2:
        return n
    else:
        return fibonacci(n-1) + fibonacci(n-2)

To print out we can use a for-loop. Here we instead use the str.join-method and a list comprehension just to show off:

print(", ".join([str(fibonacci(i)) for i in range(1, 11)]))
1, 1, 2, 3, 5, 8, 13, 21, 34, 55

Exercise 4b) Improving our algorithm#

class Fibonacci:
    def __init__(self):
        self.memory = {0: 1, 1: 1}
        
    def __call__(self, n):
        if n in self.memory:
            return self.memory[n]
        else:
            fn = self(n-1) + self(n-2)
            self.memory[n] = fn
            return fn
fib = Fibonacci()
print(", ".join([str(fib(i)) for i in range(1, 11)]))
fib(100)
1, 2, 3, 5, 8, 13, 21, 34, 55, 89
573147844013817084101

Exercise 4c) Maximum Recursion Depth#

Calling F(100000) gives us the error

RecursionError: maximum recursion depth exceeded while calling a Python object

This is because there are two many function calls that are yet to return simultaneously, this is called the “recursion depth”, and Python has a built in max here. Often because so many simultanous calls are usually due to some bug.

One can theoretically increase the max recursion depth limit in Python, but this is the wrong way to handle our problem. Instead, we should build up our memoization dictionary from the bottom up, rather the top down. We can either do this outside the class, like this:

fib = Fibonacci()

# Build up memoization dictionary
for i in range(2, 100000, 2):
    fib(i)
    
print(len(str(fib(100000))))
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[21], line 7
      4 for i in range(2, 100000, 2):
      5     fib(i)
----> 7 print(len(str(fib(100000))))

ValueError: Exceeds the limit (4300) for integer string conversion; use sys.set_int_max_str_digits() to increase the limit

Here we first create a Fibonacci-object, and then we “train” it on continiously higher numbers. Finally we use it to find the number we actually want.

Note that instead of printing out \(F_{100000}\), we print out the number of digits in the number. We could now for example go back and add an optional key-word argument to the Fibonacci-constructor, that builds up the memoization dictionary and makes it “ready for use”.

We could build this “training” into the constructor of the class as an optional argument, so that if we know we want large Fibonacci-numbers, we specify it when we create our object.

class Fibonacci:
    def __init__(self, n=0):
        self.memory = {0: 1, 1: 1}
        
        if n:
            for i in range(2, n, 2):
                self(i)
        
    def __call__(self, n):
        if n in self.memory:
            return self.memory[n]
        else:
            fn = self(n-1) + self(n-2)
            self.memory[n] = fn
            return fn
        
N = 100000
fib = Fibonacci(N)
print(len(str(fib(N))))
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[23], line 3
      1 N = 100000
      2 fib = Fibonacci(N)
----> 3 print(len(str(fib(N))))

ValueError: Exceeds the limit (4300) for integer string conversion; use sys.set_int_max_str_digits() to increase the limit

Exercise 4d) A memoized factorial class#

class Factorial:
    def __init__(self):
        self.memory = {0: 1}
    
    def __call__(self, n):
        if n in self.memory:
            return self.memory[n]
        else:
            fn = n*self(n-1)
            self.memory[n] = fn
            return fn

As a test, we can write out 52!, the number of possible arrangemenets of a deck of cards:

factorial = Factorial()
print(factorial(52))
print(f"{factorial(52):.1e}")
80658175170943878571660636856403766975289505440883277824000000000000
8.1e+67

Note that when we call factorial(52) twice, we aren’t actually repeating our calculation twice, as the function stores the result for us thanks to memoization. However, the user doesn’t have to think about this, or even know about it. This is another example of encapsulation.