<jupyter_start><jupyter_text>Table of Contents1&nbsp;&nbsp;In this notebook2&nbsp;&nbsp;Function definition2.1&nbsp;&nbsp;Error management2.2&nbsp;&nbsp;Testing functions2.3&nbsp;&nbsp;Lambda functions3&nbsp;&nbsp;Packages3.1&nbsp;&nbsp;Some examples with numpy4&nbsp;&nbsp;Exercises  More Python basics**<NAME> - 2020**---  In this notebook- we look at how to define and test *functions*, and how to think in terms of error management.- we discuss Python packages and start looking at some notable Python packages or libraries.  Function definitionPython code is better structured by defining functions.The basic syntaxis is  *def name_function: ---  return(---)*Let us look at a first example: we will create a function that establishes if two integers are relative primes (i.e., that their maximum common divisor is 1). We will create several iterations of the function.Let us start with a first simple implementation:<jupyter_code>def rel_primes(x,y):
    # We can include a description of the function using a string immediately below the function
    ''' Receives two numbers and returns True if both are relative 
        primes or False otherwise
    
    '''
    
    z = min(x,y)                       # Assign to z the minimum from a and b
    for i in range(2,z) :               # run over all numbers between 2 and z (!)
        if (x%i)==0 and (y%i==0):      # if a number divides both a, b (i.e. the residual in both cases is zero) 
                                       # they are not relative primes ...
            return False               # ... in this case, return is False. This gets the flow out of the function
    return True                        # finally, if the program gets to this point, a,b are relative primes                      

# Note that the following line of code is outside the scope of the function
rel_primes(18,123) # This should be false, as 3 divides both<jupyter_output><empty_output><jupyter_text>Check with other cases that the function works as it should. In Python, when the code within a function is executed, a new 'environment' is created. Every object/function that is defined within a function only lives within the function. So for example, tha variables x,y,z above are not accessible in the following line<jupyter_code>print(x,y,z) # this generates an error<jupyter_output><empty_output><jupyter_text>However, functions *can* access variables and functions defined outside themselves. This is useful (as will be seen further below), but is sometimes a source of confusion (particualrly regarding variables).  In the above definition of 'rel_prime, apart from the funcional part, we included a help description. At any point this information can be retrieved with *?* after the name of a function.<jupyter_code>rel_primes?<jupyter_output><empty_output><jupyter_text>What happens when we test the function with values that are not integers?<jupyter_code>rel_primes('a',1) # this gives an error
rel_primes(1.2,1.4)
rel_primes(3,4.2)<jupyter_output><empty_output><jupyter_text>Error management Note that sometimes we get an error and sometimes we do not. Moreover, the error is not very informative of what happened. We can create our own error messages. In what follows we create a function that 'wraps' the previous one, while providing some error management.<jupyter_code>def rel_primes2(a,b):
    
    # First we check if the inputs have the right type. Recall the type function we look at on the first notebook.
    
    if type(a)!=int:
        raise TypeError("Both numbers must be integers")

    if type(b)!=int:
        raise TypeError("Both numbers must be integers")
    
    # If no error is raised up to here, we call the original function.
        
    return rel_primes(a,b)  #Note that we can call functions we have defined before

print('This one works: 18 and 123 are both integers (and not relative primes)')
rel_primes2(18,123)

rel_primes2('a',123) # Here we raise an error as we defined it<jupyter_output><empty_output><jupyter_text>You can check that errors are raised in other cases (for example if you provide complex numbers or floats).The other very common type of error to be raised is ValueError (i.e. raise ValueError(...)). This means that a given input has a value outside the accepted domain.It is possible also to check if a call to a function produces an error using the keword 'try', so taht any error can be managed by the programmer. This can be useful when one wants to allow<jupyter_code>a=1.2
try:
    p=rel_primes2(a,1)
except:
    print('There would be an error because ',a,' is not integer.')
    print('But with try the error is caught here. We can then assign the value we want to p, for example -1')
    p=-1
    
p<jupyter_output><empty_output><jupyter_text>Testing functionsAn important part of coding is testing. It entails designing a sequence of checks to evaluate the behaviour of a function.The statement *assert* might be very useful for this purpose. It raises an error if a result is False.<jupyter_code>#Some basic testing

assert rel_primes2(15,28),  'Failed with two large relative primes'
assert rel_primes2(2,3),  'Failed with two small relative primes'
assert not rel_primes2(15,25),  'Failed with two small numbers that are not relative primes'
assert not rel_primes2(2,4),  'Failed with two small numbers that are not relative primes'<jupyter_output><empty_output><jupyter_text>Note that we performed 4 tests. The last one fails and illustrates that the function defined above is not working properly. When the test fails, the associated fail message is displayed.We proceed to fix the error (which is located on the rel_prime function). Run the code below and then run the tests again.<jupyter_code>def rel_primes(a,b):
    # This is a corrected version
    ''' Receives two numbers and returns True if both are relative 
        primes or False otherwise
    
    '''
    
    for i in range(2,min(a,b)+1) :       # The error was here
        if (a%i)==0 and (b%i==0):                                             
            return False               
    return True<jupyter_output><empty_output><jupyter_text>What happened? We have fixed the error on the rel_primes function (we were not including the las element in the cycle). Since the function rel_primes2 calls rel_prime, this one gets fixed as well. This helps it pass all the tests.Note we have learnt something very important in addition: in jupyter, code depends on the order of *execution*, not the order in which the code is written.**Remark:** In more professional settings, the preferred form of testing is via unittesting. If you want to learn more about it, read the [Python documentation on unittest](https://docs.python.org/3/library/unittest.html)  Lambda functionsWe had seen that to define a function in Python, we use the command def followed by the name of the function, arguments and colon. There is an alternative in the form of lambda functions, that is useful to define inline functions.The syntax is *name_function = lambda (vars): operations *Here is an example where we implement the same function twice.<jupyter_code>def square(x):
    return x*x

square2 = lambda y: y*y
assert square(100)==square2(100)<jupyter_output><empty_output><jupyter_text>Lambda functions are very convenient for short tasks. In particular it is an easy way to encapsulate one line instructions in a function. Note, though that it is very hard  Some final observations: - Functions are not forced to return a value (sometimes these are called *procedures*)- More testing tools are provided on the [package]( 2. Packages) *unitest*   Packages Python comes with many functions already defined. Some of them come as part of the standard language (we have encountered some of them). However, the real power of Python comes from sets of functions put together in *packages*. Here are some of the ones we will use in this course (and are very useful in finance):- **math** : some mathematical functions - **numpy** : vector and matrix capabilities and operations- **scipy** : numerical scientific computing including integration, fixed points, solving ODEs, optimisation, …)- **matplotlib** : plots- **pandas** : database access and manipulation, and more plots routines- **statutils**: Some statistical tools including test of hypothesisSome packages we will not use but sare very useful in finance include:- **keras**: Keras is a high-level neural networks library- **sklearn**: A library with tools for data mining and data analysisHere and in the nest notebook, we look at *numpy* and *math*. We will learn about the following packages while making applications ion finance.Packages must be imported into the kernel we are excuting. By convention all imports should be done at the start of the program, and in the Jupyter case at the start of the notebook.<jupyter_code># By convention this should be placed at the top of the file. But it can be used anywhere 
import math           # import the math package
import numpy as np    # import the numpy package and create an alias for it 'np'
from math import sin, exp  # import only the functions sin and exp that are located on the math package<jupyter_output><empty_output><jupyter_text>After running the above code, we can use **all** functions on the math package. Here are some examples:<jupyter_code>x = sin(2*math.pi) # Note we can use the function sin, but the constant pi has to be called from the math package  as was not explicitly imported
y = math.log(exp(-5))
print(x,'\n',y)<jupyter_output>-2.4492935982947064e-16 
 -5.0<jupyter_text>Some examples with numpyLet us now look at numpy. Numpy is a scientific library taht has been optimised to perform vector and matrix operations.We start by looking at how to create numpy objects. We can either transform another structure (for example a list) using the function *array*, or wwe can use one of the functions producing directly an array. Here are some examples:<jupyter_code>a = np.array([3,4,5])  # an array with the numbers 3,4,5  
b = np.arange(3,6)     # an arary with the numbers 3,4,5
c = np.linspace(3,5,3) # an array with the numbers 3.,4.,5.

print('a:',a,' b:',b, 'c:',c)
print(type(a))
a<jupyter_output>a: [3 4 5]  b: [3 4 5] c: [3. 4. 5.]
<class 'numpy.ndarray'><jupyter_text>Note that the above objects are of class *array*, which was defined in the package numpy. Observe also how the result is printed if no print function is invoked. Let us look at some simple operations before arrays:<jupyter_code>#Most operations are done piecewisely:
print('1. a==b:', a==b)
print('2. a+b:', a+b)
print('3. a*b:', a*b)
print('4. a/b:', a/b)
print('5. a^b:', a**b)
print('6. a==c:', a==c)<jupyter_output>1. a==b: [ True  True  True]
2. a+b: [ 6  8 10]
3. a*b: [ 9 16 25]
4. a/b: [1. 1. 1.]
5. a^b: [  27  256 3125]
6. a==c: [ True  True  True]<jupyter_text>Note the difference with respect to lists: the operator *+* means vector addition. Note also that most operations like '*' and '/' are defined pointwise.Since operations are mostly pointwise, the sizes of vectors need to coincide or an error will be raised.<jupyter_code>a2 = np.arange(10)
print(a2,b)
a2*b # This raises an error<jupyter_output>[0 1 2 3 4 5 6 7 8 9] [3 4 5]<jupyter_text>We also have some vector and matrix operations. We can, for example, find the dot product of two vectors of the same size with the operator *@*<jupyter_code># Dot product of a and b
a_dot_b = a@b
# An alternative way of calculating it
a_dot_b2 = (a*b).sum()     # Sum is a method available for arrays
print(a_dot_b, a_dot_b2)<jupyter_output>50 50<jupyter_text>We will frequently make use of the possibility to generate (pseudo) random numbers following some given distributions. Numpy allows for this through its sub-module *random*. We can generate (pseudo-)random arrays and matrices of a given size. Here are some examples using the current prefered way of calling the generator function.<jupyter_code>#Random numbers

#Initialise generator
rng = np.random.default_rng()

#Uniform random numbers
print('Uniform  2x2')
c = rng.random((2,2)) # This creates a matrix of 2 x 2 of independent U[0,1] random numbers
print(c)
print (c.shape)
print(c.size)

print("====")

print('Gaussian 5x3')
c = rng.standard_normal((5,3)) # This creates a matrix of 5 x 3 of independent standard Gaussian random numbers
print(c)
print (c.shape)
print(c.size)<jupyter_output>Uniform  2x2
[[0.38413807 0.64674961]
 [0.41950206 0.83199279]]
(2, 2)
4
====
Gaussian 5x3
[[ 0.68876918  0.3062188   0.00140815]
 [ 0.84759065  0.61574714 -0.75982766]
 [ 0.14129741  0.47213727  1.0314703 ]
 [ 0.33233245  1.29890285 -0.04449101]
 [ 1.22477055  0.12483862 -0.60219068]]
(5, 3)
15<jupyter_text>This produces arrays in dimension 2 or matrices. We can also see the second use of operator '@': matrix (and matrix-vector) multiplication:<jupyter_code>c@a # This is the result of multiplying the matrix c (Gaussian matrix) and the vector a<jupyter_output><empty_output><jupyter_text>The random generator can produce samples from different distributions: look at the help for rng.normal, rng.lognormal, rng.exponential....<jupyter_code>rng.normal(5,25,30)<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.08048503220917014, 'fraction_numerical': 0.02993558165971959, 'mean_word_length': 4.160735236605397, 'pattern_counts': {'":': 0, '<': 69, '<?xml version=': 0, '>': 69, 'https://': 1, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 5, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '18', 'dir': 'jupyter-structured-clean-dedup', 'id': '95951', 'license': 'MIT', 'n_tokens_mistral': 3994, 'n_tokens_neox': 3740, 'n_words': 1780, 'path': '.ipynb_checkpoints/2. More Python basics-checkpoint.ipynb', 'repo_name': 'Jinsong-UCL/MATH0094_python', 'size': '13213'}