Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Lecture 01: Python primer

University of Lausanne

Course: Deep Learning for Solving and Estimating Dynamic Models in Economics and Finance
Script reference: Front matter and Appendix~E — function definitions, scope, *args/**kwargs
Notebook role: primer (pre-course self-study; skip if you write Python every day)
Author: Simon Scheidegger

Python Basics 7: Functions

The function objects in Python allow you to give a name to a series of expressions that may or may not return a result. To syntax for defining a function in Python is as follows:

def function_name(some_inputs):
    some expressions
    return something (optional)

Functions with no inputs that return nothing

Let’s start first with functions that have no inputs and return nothing.

def print_hello():
    """
    Prints hello.
    """
    print('Hello there!')

First, let’s run this:

print_hello()
Hello there!

See for yourself that the type of print_hello is a function:

type(print_hello)
function

The other thing that I want you to notice is the text in the triple quotes below the function definition. This is called the docstring of the function. You use it to document what the function does so that other people know how to use it. The docstring is what the help() function sees. Check this out:

help(print_hello)
Help on function print_hello in module __main__:

print_hello()
    Prints hello.

Finally, let’s see if print_hello() returns anything. Let’s try to grab whatever it returns and print it.

res = print_hello()
print('Res is: ', res)
Hello there!
Res is:  None

Alright! Now you see why None is useful. A function that returns nothing, returns None.

Questions

  • Modify the function so that it prints hellow there 5 times. Make sure you also modify the docstring to reflect the new behavior.

Real functions

When I am talking about numerical functions, I mean things like f(x)=x2f(x) = x^2 or g(x)=sin(x)g(x) = \sin(x) and so on. These functions typically take a single input that is a real number and they return also a single input which is a real number. Here are some examples:

def square(x):
    """
    Calculates the square of ``x``.
    
    Arguments:
    x     -   The real number you wish to square
    
    Returns: The square of ``x``.
    """
    return x ** 2
help(square)
Help on function square in module __main__:

square(x)
    Calculates the square of ``x``.
    
    Arguments:
    x     -   The real number you wish to square
    
    Returns: The square of ``x``.

square(2)
4
square(23.0)
529.0

Because real functions are used so often, there is actually a shortcut. It is called lambda functions. To define a lambda function, the syntax is:

func_name = lambda inputs: single_expression_you_want_to_return

Here is the square function in a single line:

alt_square = lambda x: x ** 2
alt_square(2)
4
alt_square(23.0)
529.0

You will see me using both.

Let’s finish this section by evaluating the square function on all numbers from 0 to 100 and putting the result in a list. This is a very commonly occuring process required for plotting functions.

# The xs on which you want to evaluate square(x)
xs = range(0, 101)
# The corresponding ys (empty list to be grown gradually)
ys = []
# Loop over all the x's
for x in xs:
    # Evaluate the function at x:
    y = square(x)
    # Add the value to the list
    ys.append(y)
print(ys)
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, 144, 169, 196, 225, 256, 289, 324, 361, 400, 441, 484, 529, 576, 625, 676, 729, 784, 841, 900, 961, 1024, 1089, 1156, 1225, 1296, 1369, 1444, 1521, 1600, 1681, 1764, 1849, 1936, 2025, 2116, 2209, 2304, 2401, 2500, 2601, 2704, 2809, 2916, 3025, 3136, 3249, 3364, 3481, 3600, 3721, 3844, 3969, 4096, 4225, 4356, 4489, 4624, 4761, 4900, 5041, 5184, 5329, 5476, 5625, 5776, 5929, 6084, 6241, 6400, 6561, 6724, 6889, 7056, 7225, 7396, 7569, 7744, 7921, 8100, 8281, 8464, 8649, 8836, 9025, 9216, 9409, 9604, 9801, 10000]

Now, there is a simpler way to do this using what is known as a list generator. This is a rather advanced Python construct, but because I cannot resist using it, I am going to show you here how it works. Basically, it is mirroring the mathematical definitions of a set. Here it is:

ys = [square(x) for x in xs]
print(ys)
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, 144, 169, 196, 225, 256, 289, 324, 361, 400, 441, 484, 529, 576, 625, 676, 729, 784, 841, 900, 961, 1024, 1089, 1156, 1225, 1296, 1369, 1444, 1521, 1600, 1681, 1764, 1849, 1936, 2025, 2116, 2209, 2304, 2401, 2500, 2601, 2704, 2809, 2916, 3025, 3136, 3249, 3364, 3481, 3600, 3721, 3844, 3969, 4096, 4225, 4356, 4489, 4624, 4761, 4900, 5041, 5184, 5329, 5476, 5625, 5776, 5929, 6084, 6241, 6400, 6561, 6724, 6889, 7056, 7225, 7396, 7569, 7744, 7921, 8100, 8281, 8464, 8649, 8836, 9025, 9216, 9409, 9604, 9801, 10000]

Questions

  • Write a function that calculates the mean of a list of a number. Try it out on the xs.

# Your code here

Functions with many arguments

You can have as many arguments as you want in a function. Here is a function that calculates the p norm of a vector. The p norm of the vector x=(x1,,xN)\mathbf{x} = (x_1,\dots,x_N) is defined to be:

xp:=(i=1Nxip)1p.\parallel \mathbf{x}\parallel_p := \left(\sum_{i=1}^Nx_i^p\right)^{\frac{1}{p}}.

Here it is:

def norm(x, p):
    """
    Calculates the ``p``-norm of the vector ``x``.
    
    Arguments:
     x     -     A list of numbers.
     p     -     A positive number.
    """
    res = 0.0
    for x_i in x:
        res += x_i ** p
    return res ** (1 / p)

Let’s try this:

x = [1, 2, 3]
norm(x, 2)
3.7416573867739413
norm(x, 3)
3.3019272488946263
norm(x, 100)
3.0

Now, let’s try to run the same thing without specifying the p input:

norm(x)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
/tmp/ipykernel_22265/2267588480.py in <module>
----> 1 norm(x)

TypeError: norm() missing 1 required positional argument: 'p'

We get the error that the p argument of the function is missing. However, p=2 is the most common choice because it corresponds to the standard Euclidean norm. We can rewrite the function so that by default p=2. Such arguments are called default arguments. Here is how:

def norm(x, p=2):
    """
    Calculates the ``p``-norm of the vector ``x``.
    
    Arguments:
     x     -     A list of numbers.
     p     -     A positive number (default p=2)
    """
    res = 0.0
    for x_i in x:
        res += x_i ** p
    return res ** (1 / p)

Now you can call norm(x):

norm(x)
3.7416573867739413

If you want to use another p, you can do:

norm(x, 3.0)
3.3019272488946263

or (more intuitively):

norm(x, p=3.0)
3.3019272488946263

Alirght, this is nice! Let’s now try to break our function. We can break it in various ways. First, we can break it by passing a nonpositive p. Here you go:

norm(x, p=0)
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
/tmp/ipykernel_22265/3750166826.py in <module>
----> 1 norm(x, p=0)

/tmp/ipykernel_22265/2132562949.py in norm(x, p)
     10     for x_i in x:
     11         res += x_i ** p
---> 12     return res ** (1 / p)

ZeroDivisionError: division by zero

or this one:

norm(x, p=-1)
0.5454545454545455

In the first case, we get an error message. In the second case we do not get any error message. However, the assumption that p is positive is clearly violated. To force the function to give us an error message when its assumptions are violated, we can use assert statements. Here is how:

def norm(x, p=2):
    """
    Calculates the ``p``-norm of the vector ``x``.
    
    Arguments:
     x     -     A list of numbers.
     p     -     A positive number (default p=2)
    """
    # Check that the function assiumptions are satisfied
    # Turn p into a float even if it is not one
    p = float(p) 
    # Ensures taht p is positive
    assert p > 0, 'p must be positive (p = {0:1.2f})'.format(p)
    # The code that calculates the p-norm
    res = 0.0
    for x_i in x:
        res += x_i ** p
    return res ** (1 / p)

Here is how this works. If you give it the right p, then it just works:

norm(x, p=2)
3.7416573867739413

Here is what happens with a bad p:

norm(x, p=-1)
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
/tmp/ipykernel_22265/911542282.py in <module>
----> 1 norm(x, p=-1)

/tmp/ipykernel_22265/3955484933.py in norm(x, p)
     11     p = float(p)
     12     # Ensures taht p is positive
---> 13     assert p > 0, 'p must be positive (p = {0:1.2f})'.format(p)
     14     # The code that calculates the p-norm
     15     res = 0.0

AssertionError: p must be positive (p = -1.00)

Of course, we can still break the function in various ways:

norm(['a', 'b'], p=2)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
/tmp/ipykernel_22265/777857029.py in <module>
----> 1 norm(['a', 'b'], p=2)

/tmp/ipykernel_22265/3955484933.py in norm(x, p)
     15     res = 0.0
     16     for x_i in x:
---> 17         res += x_i ** p
     18     return res ** (1 / p)

TypeError: unsupported operand type(s) for ** or pow(): 'str' and 'float'

The norm of a list of characters does not make sense. To add a check on whether or not the input x is a valid vector, we would have to wait until we introduce numerical arrays.

Questions

  • The infinity norm is defined to be:

    x=max1iNxi.\parallel\mathbf{x}\parallel = \max_{1\le i\le N} |x_i|.

    Modify the code of the norm(x,p) function so that when p = math.inf, it uses gives back the right result.

import math # for math.inf
# Your code here
# Try this out:
norm(x,p=math.inf)
1.0

Organizing your code in functions

Remember the previous hands-on activity in which we wrote code to calculate the sum of an infinite series to a given tolerance. If we wanted to change the series we had to modify the code by hand. I will now show you how you can organize the code that we wrote in a function that accepts as an input any series you want. The thing that you need to mediate a bit on is that the function calculate_series that we will create has an input a function a which can give you the result of the sequence ana_n you are summing for any nn, i.e., a(n) calculates ana_n. Study this code:

def calculate_series(a, max_iter=100000, epsilon=1e-5, verbose=True):
    """
    Calculate a given series to a desired tolerance.
    
    Arguments
    a         -   A function that specifies the sequence you want to sum.
    max_iter  -   The maximum number of iterations, a positive integer 
                  (default max_iter=10000).
    epsilon   -   The tolerance, a positive float (default epsilon=1e-5).
    verbose   -   A boolean specifying whether or not you want to print
                  something about the progress of the function
                  (default verbose=True).
    
    Returns:
        An estimate of the sum of a(n) for n = 0 to infinty.
    """
    # Check assumptions
    # There is a way to check if a is a function, but it is a bit advanced
    # max_iter must be a positive integer
    assert isinstance(max_iter, int), 'max_inter must be an integer'
    assert max_iter > 0, 'max_iter must be positive'
    # epsilon must be a positive float
    assert isinstance(epsilon, float), 'epsilon must be a float'
    assert epsilon > 0, 'epsilon must be positive'
    # The result
    res = 0.0
    # Start a counter
    n = 0
    # Start the loop
    while n <= max_iter:
        # Compute the new term
        a_n = a(n)
        # Check if the absolute value of the new term is smaller than the tolerance
        if abs(a_n) < epsilon:
            # If it is indeed smaller, exit the loop
            # you do this with the command
            if verbose:
                print('*** Converged in {0:d} iterations! ***'.format(n+1))
            break
        # Otherwise we just add the new term to our running sum
        res += a_n
        # Print something about the current iteration
        if verbose and n % 10000 == 0:
            print('Current iteration n = {0:10d}, sum so far: {1:1.12f}'.format(n, res))
        # and increase the counter
        n += 1
        if verbose and n == max_iter + 1:
            print('*** Stopped when maximum number of iterations ({0:d}) were reached! ***'.format(max_iter))
    return res

Here is the help of the function we just wrote:

help(calculate_series)
Help on function calculate_series in module __main__:

calculate_series(a, max_iter=100000, epsilon=1e-05, verbose=True)
    Calculate a given series to a desired tolerance.
    
    Arguments
    a         -   A function that specifies the sequence you want to sum.
    max_iter  -   The maximum number of iterations, a positive integer 
                  (default max_iter=10000).
    epsilon   -   The tolerance, a positive float (default epsilon=1e-5).
    verbose   -   A boolean specifying whether or not you want to print
                  something about the progress of the function
                  (default verbose=True).
    
    Returns:
        An estimate of the sum of a(n) for n = 0 to infinty.

Here is how to use it:

# First define a sequence
def a(n):
    return (-1) ** n / (2 * n + 1)
# Now run this:
calculate_series(a)
Current iteration n =          0, sum so far: 1.000000000000
Current iteration n =      10000, sum so far: 0.785423160898
Current iteration n =      20000, sum so far: 0.785410662772
Current iteration n =      30000, sum so far: 0.785406496453
Current iteration n =      40000, sum so far: 0.785404413241
*** Converged in 50001 iterations! ***
0.7853931633974454

And here is an even faster way to do the same thing using lambda functions:

calculate_series(lambda n: (-1) ** n / (2 * n + 1))
Current iteration n =          0, sum so far: 1.000000000000
Current iteration n =      10000, sum so far: 0.785423160898
Current iteration n =      20000, sum so far: 0.785410662772
Current iteration n =      30000, sum so far: 0.785406496453
Current iteration n =      40000, sum so far: 0.785404413241
*** Converged in 50001 iterations! ***
0.7853931633974454

Questions

  • Modify the code above so that you sum the series:

    n=012n,\sum_{n=0}^\infty\frac{1}{2^n},
  • Modify the code above so that max_iter=1000 and epsilon=1e-2.

  • Modify the code above so that the function does not print anything.