<jupyter_start><jupyter_text>What is a 'Program' **A program is a sequence of instructions that specifies how to perform a computation.**Details will depend on the kind of computation, but typically the following basic instructions are involved:- *input*: Get data.- *output*: Display data.- *math*: Perform mathematical operations.- *conditional execution*: Check for conditions and selectively run specific code.- *repetition*: Perform some action repeatedly. Consider the following task:    > Compute the difference between largest and smallest number from a list of numbers. How to 'translate' this into a 'program'?- Define problem, and specific task.- Develop a computational strategy, an [*algorithm*](https://en.wikipedia.org/wiki/Algorithm), that solves this task.- Implement this algorithm in a programming language.- Test it!Implementation, and thus the specific programming language, is only one of the steps in the problem solving context! > Compute the difference between largest and smallest number from a list of numbers.Steps:- Find largest number $x_\text{max}$ in list.- Find smallest number $x_\text{min}$ in list.- Compute $x_\text{max}-x_\text{min}$. A possible **algorithm** for finding the largest number $x_\text{max}$ in list of numbers:- **Initialization**: Initialize variable $x_\text{max}$ with a starting value $x_\text{0}$: $x_\text{max}$ = $x_\text{0}$.- **Repeat** for every number in the list:    - **If** current number $x_i>x_\text{max}$, **then**:        - update $x_\text{max}$: $x_\text{max} = x_i$- **Output**: Value of $x_\text{max}$. What value would you choose for $x_0$.Does this algorithm produce the correct result for lists containing *any* kind of 'numbers' or only under certain conditions? Beware of **implicit assumptions** and **resulting limitations**! Programs are specified in *formal languages*, that is, languages that are designed for specific applications.For example, the notation of mathematics is a formal language for denoting relationships among numbers and symbols. **Programming languages are formal languages designed to express computations.**The structure of statements in formal languages tends to follow strict syntax rules.These rules define the basic language elements, so-called *tokens*, and how those tokens can be combined.The process of reading and 'decoding' the structure of a sentence is called *parsing*.Formal languages are designed to be *unambiguous*, *concise* and *literal*.This allows the meaning of a computer program to be understood entirely by analysis of its tokens and structure.See the [first chapter](http://greenteapress.com/thinkpython2/html/thinkpython2002.html) of the [Think Python](http://greenteapress.com/wp/think-python-2e/) book for a discussion of differences between *formal* and *natural* languages.  Basic Programming Concepts in Python The remainder of this notebook provides a very quick introduction to basic important programming constructs, and how these are used in Python. Python has an extensive [official documentation](https://docs.python.org/3/), including a very detailed [tutorial](https://docs.python.org/3/tutorial/index.html).Alternatively, [Python 101](https://python101.pythonlibrary.org) provides an in-depth introduction to general programming in python.  Running Python & Basic Vocabulary Python is in interpreted language.The Python *interpreter* is a program that reads (parses) and executes Python code.For an interactive console click [here](https://repl.it/languages/python3), and see the [introduction notebook](https://github.com/cohmathonc/biosci670/blob/master/IntroductionComputationalMethods/00_CompWorkingEnv.ipynb) for installation option.In Jupyter Notebooks, code is written and executed in *code cells* like the following:<jupyter_code># This is a comment
print("Hello world!") # this is an inline comment

a = 1
b = a + 9
my_very_long_and_descriptive_variable_name = 10
print(b + my_very_long_and_descriptive_variable_name)<jupyter_output><empty_output><jupyter_text>This little example alread includes many essential components of a *program*:- **value**: E.g. letter or number, `"Hello world!"`, `1`.- **operator**: Special symbols that represent computations. E.g. addition `+`, assignment `=`.- **variable**: A 'placeholder' for a value. E.g.`a`, `b`, `my_very_long_and_descriptive_variable_name`.- **expression**: A combination of values, operators and variables. E.g. `a + 9`.- **statement**: A unit of code that has an effect, like creating a variable or displaying a value. E.g. `print()`.The Python interpreter:- *evaluates* an expression, i.e. finds its value.- *executes* a statement, i.e. performs the set of instructions defined by the statement.  Basic Arithmetic Besides the standard arithmetic operations, Python 3 distinguishes division "/" and integer division "//".<jupyter_code>print(4 + 3)  # addition
print(4 - 3)  # subtraction
print(4 * 3)  # multiplication
print(4 / 3)  # division (here, the types are widened to 'float' automatically) 
print(4 % 3)  # modulus
print(4 ** 3) # exponent
print(4 // 3) # integer division<jupyter_output><empty_output><jupyter_text>The evaluation of expressions with more than one operator follows the *order of operations*:1. parentheses2. exponentiation3. multiplication, division before addition, subtraction4. operators of the same precedence are evaluated from left to right<jupyter_code>print( 2*(5-1), 2*5-1    )
print( 2**2-1 , 2**(2-1) )
print( 1/2/3  , 1/(2/3)  )<jupyter_output><empty_output><jupyter_text>Data Types A *data type* or *type* constrains the possible value of an expression, defines its meaning and the operations that can be done the data. - **Numeric Types**: Integral (Integer, Booleans), Real, Complex- **Sequences**: Strings, Typles, Lists- **Mappings**: Dictionaries- **Callable**: Functions, Methods, ClassesSee the documentation for details on all [built-in types](https://docs.python.org/3/library/stdtypes.html). And this [figure](https://commons.wikimedia.org/wiki/File:Python_3._The_standard_type_hierarchy.png) for an overview of the Python 3 Type hierarchy.  Basic Numeric Types Python has three basic **numeric types**: *integers*, *floating point numbers*, and *complex numbers*.<jupyter_code>print(type(1))      # an integer number
print(type(1.1))    # a floating point number
print(type(1 + 1j)) # a complex number<jupyter_output><empty_output><jupyter_text>Numbers can be cast into another type. Such a conversion may "widen" or "narrow" the original type. - A *widening* conversion changes a value to a data type that supports any possible value of the original data, and this preserves the source value. - A *narrowing* conversion changes a value to a data type that may not support some of the possible values of the original data type.<jupyter_code>print(int(1.1))   # cast float to int
print(float(1))   # cast int to float
print(bool(1))    # cast int to boolean

print(complex(1)) # cast int to complex

# Try to convert a complex number to integer!<jupyter_output><empty_output><jupyter_text>Python supports mixed arithmetic, i.e. arithmetic operations can be applied to operands of different numeric types. In this case, the operand with the "narrower" type is "widened" to that of the other.<jupyter_code>print(type(1+1.1)) # integer + floating number -> float
print(type(1-1.1)) # integer - floating number -> float
print(type(1*1.1)) # integer * floating number -> float
print(type(1/1.1)) # integer / floating number -> float

print(type((1+1j) + 1.1)) # complex + floating number -> complex<jupyter_output><empty_output><jupyter_text>The following operators allow **comparisons** between numeric types. Results of comparisons are of **boolean type**.<jupyter_code>print( 1.1 <  1 ) # strictly less than
print( 1 <= 1 ) # less than or equal
print( 1 >  1 ) # strictly greater than
print( 1 >= 1 ) # greater than or equal
print( "1" == "2" ) # equal (NOTE: different from assignment operator '=')
print( 1 != 1 ) # not equal<jupyter_output><empty_output><jupyter_text>The boolean type can take values `True` and  `False` and is a subtype of integer.<jupyter_code>print(type(1 <= 1))
print(int(True))  # True equivalent to int(1)
print(type(False))
print(int(False)) # False equivalent to int(0)<jupyter_output><empty_output><jupyter_text>Boolean types, and thus comparisons like the ones above, can be combined using `and`, `or`, `not`:<jupyter_code>is_it_true = (2 * 6 <= 10) and (32 / 8 >= 4) or not (5 ** 2 < 25)
print(is_it_true)
#                False     and      True     or not      False  
#               (          False           ) or (     True      )<jupyter_output><empty_output><jupyter_text>StringsStrings can be created in several different ways:<jupyter_code>string_1 = "I'm a string!"
string_2 = 'This is another string'
string_3 = '''and this is         
              a 
              multiline string'''

print(string_1)
print(string_2)
print(string_3)<jupyter_output><empty_output><jupyter_text>Strings can also be created from numbers, by casting a numeric data type to a string data type.<jupyter_code>number = 123
number_as_string = str(number)

print(type(number))
print(type(number_as_string))
print(number_as_string)<jupyter_output><empty_output><jupyter_text>Strings composed of numbers can be converted to an integer datatype. But only that.<jupyter_code>number_2 = int("1233")
print(type(number_2))

# Try to convert
# 1) '1.2' to float
# 2) 'ABC' to int<jupyter_output><empty_output><jupyter_text>**String formatting** allows you to insert/substitute values into a base string. We will use this later, for example to monitor progress of computations.<jupyter_code>day = "Tuesday"
lecture = 4
duration = 1.5

# insert string in string
string_1 = "Today is %s." %day
print(string_1)

# insert integer in string
string_2 = "This is the %ith lecture." %lecture
print(string_2)

# insert multiple items in string
string_3 = "Today is %s, and this is the %ith lecture." %(day, lecture)
print(string_3)

# add a float
string_4 = "Today is %s, and this is the %ith lecture which lasts for %fh." \
            %(day, lecture, duration)
print(string_4)

# and a little nicer:
string_5 = "Today is %s, and this is the %ith lecture which lasts for %.1fh." \
            %(day, lecture, duration)
print(string_5)
string_6 = "Today is {1}, and this is the {0}ith lecture which lasts for {2}h."\
            .format(lecture, day, duration)
print(string_6)<jupyter_output><empty_output><jupyter_text>Above method is often referred to as "printf" formatting due to the name of the function of the C programming language that popularized this formatting style.Python also supports another formatting method that is described [here](https://docs.python.org/3/tutorial/inputoutput.html).  Lists, Tuples, Dictionaries*Lists*, *tuples* and *dictionaries* are different kinds of "containers" that allow collecting and organizing information.   ListsPython **list**s are ordered containers that can take elements of different types.<jupyter_code>my_list_empty = []     # this is an empty list, alternatively use 'list()'
print(my_list_empty)

my_list_1 = [1,2,3,4,5]
my_list_2 = ["a", "b", "c"]
my_list_3 = [1, "two", 3, "four", 5]

print(my_list_1)
print(my_list_2)
print(my_list_3)<jupyter_output><empty_output><jupyter_text>In fact, lists can contain objects of any type, also other lists.<jupyter_code>nested_list = [my_list_1, my_list_2]
print(nested_list)<jupyter_output><empty_output><jupyter_text>Lists can be extended and combined.<jupyter_code># append element to list
print("my_list_1 before: ", my_list_1)
my_list_1.append(6)
print("my_list_1 after: ", my_list_1)

# extend list
extended_list = my_list_2 + my_list_3
print(extended_list)<jupyter_output><empty_output><jupyter_text>We can access elements of a list by specifying the index of the element of interest.<jupyter_code>print(my_list_1[0])  # first element
print(my_list_1[1])  # second element
print(my_list_1[-1]) # last element
print(my_list_1[-2]) # second last element<jupyter_output><empty_output><jupyter_text>We can also extract multiple elements from a list; this is called *slicing*.<jupyter_code>print(my_list_1[0:2])    # first 2 elements
print(my_list_1[0:5:2])  # every second element of the first 5 elements<jupyter_output><empty_output><jupyter_text>Python uses *zero-indexing*, i.e. the first element always has index 0. Therefore, if the list has N elements, the last element is at position N-1.<jupyter_code>N = len(my_list_1)                     # len() gives length of list
print("Length of list:          ", N)
print("Last element (method 1): ", my_list_1[N-1])
print("Last element (method 2): ", my_list_1[-1])<jupyter_output><empty_output><jupyter_text>We can easily check whether a list contains a specific element:<jupyter_code>is_two_in_list = 'two' in my_list_3
print(my_list_3)
print(is_two_in_list)<jupyter_output><empty_output><jupyter_text>Python distinguishes, *mutable* and *immutable* types, that is types whose value can be changed after creation and types that do not allow this.Lists are *mutable*:<jupyter_code>print(my_list_1)
my_list_1[0] = 100
print(my_list_1)<jupyter_output><empty_output><jupyter_text>Tuples **Tuples** are similar to lists, but they are *immutable*. Tuples are created with parentheses, rather than square brackets.<jupyter_code>my_tuple = (1, 2, 3, 4, 5)
print(my_tuple[2:5])

# now, try to change an element in the tuple<jupyter_output><empty_output><jupyter_text>We have introduced strings before. Strings behave very similarly to tuples of characters!---**Exercise (1):**Create a string and:1.  access individual characters in the string2.  extract a substring of more than 1 character length2.  concatenate two strings3. try to change one of the string's characters---  DictionariesA python **dictionary** is a mapping. It links *keys* to *values*, so that any value can be accessed by a specific key. Keys can be of any immutable type (e.g. numeric or strings), values can be of any type. Within a dictionary, key-value pairs are unordered.<jupyter_code>my_dict_empty = {}  # an empty dictionary; alternatively dict()
my_dict = {1 : "one", 
           2 : "two",
           3 : "three"}
print(my_dict)

my_dict[10] = "ten" # add an item to a dictionary
print(my_dict)

print(my_dict[1])   # access an item in a dictionary<jupyter_output><empty_output><jupyter_text>Keys and values in a dictionary can be accessed and retrieved as lists:<jupyter_code>print(my_dict.keys())
print(my_dict.values())

# this allows you to check if a given key is available
a_key = 1
is_key_in_dict = a_key in my_dict.keys()
print("is key in dict:           ", is_key_in_dict)
print("value associated to key : ", my_dict[a_key])<jupyter_output><empty_output><jupyter_text>Like lists, dictionaries can be nested. ---**Exercise (2):**Create a nested dictionary and access an object from the 'inner' dictionary.---  Mutable vs. immutable data types There are some subtle consequences resulting from data types being mutable or not. Being aware of those will help you avoid unexpected behavior when using mutable types such as lists.<jupyter_code>list_1 = [1, 'two', 3, 'four', 5]
list_2 = list_1                    # we assign list_1 to a new variable list_2
print('list_1 before: ',list_1)
print('list_2 before: ',list_2)

list_1.append('six')               # append an additional element to list_1 
print('list_1 after: ',list_1)     # this is expected
print('list_2 after: ',list_2)     # this may surprise you

list_1[2] = 'three'                # change an element in list_1
print('list_1 after: ',list_1)     # again, this is expected
print('list_2 after: ',list_2)     #<jupyter_output><empty_output><jupyter_text>We saw that the 'copy' that we created of `list_1` is not a copy but instead just a different name for the same object. We can confirm this by comparing the 'identity' of those two variables. Python has an `id()` function that returns the 'identity' of an object. This identity has to be unique and constant for this object during its lifetime.<jupyter_code>print('id(list_1): ',id(list_1))
print('id(list_2): ',id(list_2))
# -> list_1 and list_2 refer to the same object<jupyter_output><empty_output><jupyter_text>If instead of a reference, you would like a true copy of a mutable object, you need to use a dedicated function that creates a copy of the memory representation of this object. All objects of mutable type have an in-built `copy()` function that provides this functionality.<jupyter_code>list_3 = list_1.copy()                # here we create a copy of list_1
print('list_1 before: ', list_1)
print('list_3 before: ',list_3)

list_1.append(7)
print('list_1 after: ',list_1)
print('list_3 after: ',list_3)

print('id(list_1): ', id(list_1))     # list_1 still has the same id
print('id(list_3): ', id(list_3))     # list_3 is an entirely new object<jupyter_output><empty_output><jupyter_text>Dictionaries, the other mutable type that we have introduce before, show the same behavior. Besides allowing to change already existing content, mutable types also provide methods for adding or removing objects, such as `append()`, `pop()`, `extend()`. Immutable types do not have those or similar methods.We have seen previously that the  `+` operator can be used to concatenate lists, tuples and strings. In contrast to `append()` or `extend()`, this operation always creates new objects. *Which* type should I use *when*?*   Use *mutable* objects when you need to change the size of the object. Changes are 'cheap'.*   Use *immutable* objects when you need to ensure that the object will always stay the same. Changes are 'expensive' because a new object is created.  Control Flows So far, we have learned the basic data types, and how to interact and manipulate them. This is sufficient for elementary computations, but most computational tasks require  some way of encoding logic.  Conditional Statements & Selective Code ExecutionPython's **if/elif/else** statements check whether a condition is `True` or `False` and allow code to be executed selectively, depending on the the outcome of these checks.<jupyter_code>var1 = 1
var2 = 3
if var1 < var2:
    print("Variable 1 < variable 2")<jupyter_output><empty_output><jupyter_text>---**Python cares about whitespaces!**Note that we indented the code inside the if statement. This is very important!The indentation level indicates the beginning and end of a code 'block' in Python.Any code line that is part of the block *must* start at the same indentation level.This is fairly unique among programming languages; most languages use parantheses, braces or specific keywords for indicating beginning and end of code blocks.--- `if` can be combined with `elif` and `else` to define (multiple) alternative scenarios subject to specific conditions, as well as a scenario for the case that none of the conditions is fulfilled.<jupyter_code>var1 = 1
var2 = 1
if var1 == var2:
    print("Variable 1 == variable 2")
elif var1 < var2:
    print("Variable 1 < variable 2")
    print("Since this line is part of the elif block it must start at the same indentation level!")  
else:
    print("Variable 1 > variable 2")<jupyter_output><empty_output><jupyter_text>---**Exercise (3):**Construct an if/else statement that additionally checks whether `var1` is a factor 2 (or more) smaller than `var2`. *Hint*: You can use multiple `elif` statements in the same if/else block. What role does the order of `elif` statements play?---  LoopsLoops are used to perform a set of operations repeatedly. Two types of loops exist in Python, the **for loop** and the **while loop**.  The for loopAs the name suggests, the `for` loop can be used to iterate over something a certain number of times. Iteration requires an object that is *iterable*. All the 'container' types introduced before (`list`, `tuple`, `dict`) are iterable.<jupyter_code>for i in [0,1,2,3,4]:
    print(i)             # note the indentation again!<jupyter_output><empty_output><jupyter_text>You don't have to define a list manually every time you want to write a loop!Python has a function that provides an iterator over integer numbers:<jupyter_code>print( range(10) )          # this means, start from 0, iterate 10 times, 
                            # equivalent to range(0,10)
print( list(range(10)) )    # this gives a list of integers from 1-9

print( list(range(0,10,2))) # you can also define a stepsize<jupyter_output><empty_output><jupyter_text>This gives you an easy way to define a loop over integer numbers:<jupyter_code># instead of
# for i in [0, 1, 2, 3, 4]:

for i in range(5):
    print(i)<jupyter_output><empty_output><jupyter_text>We can iterate over *any* list, even if it contains objects that are not integer numbers:<jupyter_code>my_list = [1, 'two', 3, 'four', 100, ['another', 'list'] ]

for item in my_list:
    print(item)<jupyter_output><empty_output><jupyter_text>It is often useful to have access to both the position of the current item in e.g. a list, and to *the* item itself. This can be achieved in multiple ways:<jupyter_code># iterate over index and access list using index
print("Method 2:")
for i in range(len(my_list)):
    print(i, my_list[i])
  
# iterate over index and list items simultaneously
print("Method 2:")
for i, item in enumerate(my_list):
    print(i, item)<jupyter_output><empty_output><jupyter_text>The while LoopInstead of iterating for a pre-specified number of times (or number of elements), the `while` loop continues 'looping' until a certain condition is met.<jupyter_code>i = 0
while i < 10:
    print(i)
    i += 1        # this is a shorthand for i = i + 1
    
# What would happen if we remove the last line that increments i?<jupyter_output><empty_output><jupyter_text>We can 'break out' of while loops and skip iterations subject to conditionals:<jupyter_code>i = 0

while i < 10:
    if i == 3:
        i += 1
        continue     # if i==3, we increment i and immediately start a new iteration
                     # -> print statement not executed
    print(i)
    if i == 5:       # we stop the loop if i==5
        break
    i += 1<jupyter_output><empty_output><jupyter_text>Functions *Functions* allow you to package parts of your program into reusable units.  We have already used some functions, such as `print()`,  `len()`, and seen these structures may process some input and may return some output. Functions are defined using the `def` keyword, and called using their name followed by parentheses `()`:<jupyter_code>def my_function():                    # the 'def' keyword defines a function
    print("This is my first function")
  
my_function()                         # call function named 'my_function'<jupyter_output><empty_output><jupyter_text>Note that the code block that defines a function is indented, just as code blocks for conditionals and loops. A function can have *arguments*, this is the information given as input to the function. Also, all functions return something. You can specify a specific  *return value* using the `return` keyword. If no return value is defined, the function will return `None`. This is the case in `my_function()` above.<jupyter_code># function arguments a, b
# return value a + b

def add(a, b):
    return a + b

sum_1_2 = add(1,2)            # function arguments are identified by their order
print(sum_1_2)<jupyter_output><empty_output><jupyter_text>Functions accept two different types of arguments, *regular* and *keyword* arguments.A function argument becomes a *keyword* argument when a default value is declared in the function definition.This makes this argument *optional*.<jupyter_code># function arguments a, b, c
# b, c have default values
# return value a + b + c

def add_mixed_arguments(a, b=1, c=2):    
    return a + b + c

# call function only with required argument
sum_1 = add_mixed_arguments(1)
print(sum_1)

# call function with required argument and one keyword argument
sum_2 = add_mixed_arguments(1, c=10)
print(sum_2)

# What will happen when you call the function only with keyword arguments?<jupyter_output><empty_output><jupyter_text>Similar to mathematical functions, the **names of function arguments** are just **symbols** that are used to refer to the values that are provided to the function when it is called.You can choose whatever names you like (subject to some syntax rules) for the function arguments.These names (and the values to which they link to) exist only in the definition block of the function and can only be used there.You can think of function arguments as *'local variables'* that only exist in the definition block of the functions.<jupyter_code>def sum_diff(a, b):
    my_diff = a - b
    my_sum  = a + b
    return my_sum

sum_from_function = sum_diff(1, 5)  
print( sum_from_function )
#print( my_diff )                 # check if you can also access 'my_diff'
                                  # which has been defined in the scope of 
                                  # the function 'sum_diff()'<jupyter_output><empty_output><jupyter_text>To return multiple objects from a function, you can wrap those objects in a container, or simply use the `return` keyword followed by a list of variable names. This will return a tuple that contains the listed objects.<jupyter_code>def sum_diff(a, b):
    my_diff = a - b
    my_sum  = a + b
    return my_sum, my_diff

answer = sum_diff(1, 5)  
print( answer )

sum_from_function, diff_from_function = sum_diff(1, 5)
print( sum_from_function )
print( diff_from_function )<jupyter_output><empty_output><jupyter_text>When your functions become more complex, you will want to make sure that you and possibly others can easily understand what the function does.In Python, functions are best documented using a `docstring`, that is a triple quoted `"""` possibly multiline string right after the `def` statement. For more information about docstrings, see the official guide on [docstring conventions](https://www.python.org/dev/peps/pep-0257/).<jupyter_code>def sum_diff(a, b):
    """Returns the sum and difference of variables a and b."""
    my_diff = a - b
    my_sum  = a + b
    return my_sum, my_diff<jupyter_output><empty_output><jupyter_text>*When* should I use functions?* Probably, whenever, you find yourself writing (or copy & pasting) code that you have already written before and now want to use in a different context.*Why* should I use functions?* Isolating meaningful 'functional units' of code increases readability.* Fixing an error in an isolated piece of code is much easier than finding and modifying pieces of code spread over multiple scripts.   'Everything is an Object in Python' You may come across the statement that 'everything is an object in Python'.Python is an [object-oriented](https://en.wikipedia.org/wiki/Object-oriented_programming) programming (OOP) language.The concepts of *class* and *object* are central to OOPs: A *class* provides the definition for a general type of object by specifying the attributes and methods that any such object should have. An *object* is a concrete realization, a so-called *instance*, of a class. *Objects* may contain data in the form of *attributes* and procedures in the form of *methods*.'Everything is an object in Python' means that anything that can be used as a value (int, str, float, functions, modules, etc) is implemented as objects.These objects may have *attributes* (values associated with them) and *methods* (functions associated with them).We are not interested in details of OOP here, but it is important to know how to access an object's methods and attributes.<jupyter_code>a_float = 1.1
print(type(a_float))        # '1.1' is an instance of class 'float'

print(a_float.is_integer()) # every object of type 'float',
                            # i.e. every instance of the 'float' class
                            # has a method 'is_integer' that acts on the object itself

1.1.is_integer()            # this function can even be called like this
a_list = [1,2,"three"]
print(type(a_list))         # a_list is an instance of class 'list'

a_list.append('4')          # the 'list' class defines a method 'append'
                            # which takes another object and appends it to the list object       
print(a_list)<jupyter_output><empty_output><jupyter_text>Methods and attributes can be accessed via the `.` notation, i.e. by combining `object_name` + `.` + `method_name()` or `attribute_name`.  Libraries Until now, we have only worked with very basic functions and types.Suppose we want to compute the square root of 4, $\sqrt{4}$. Usually, such a function is called `sqrt` or similar. Try this...<jupyter_code>sqrt(4)<jupyter_output><empty_output><jupyter_text>The error means that python does not know any object of name 'sqrt'.To understand how Python identifies functions we need to introduce the concept of *namespaces* and revisit *scopes*. A *namespace* is a system that ensures all the names in a program are unique and can be used without any conflict. Python implements namespaces as dictionaries, i.e. as name-to-object maps where each object is identified by a unique name.Multiple namespaces can use the same name and map it to a different object, for example:- *Local Namespace*: Includes names defined locally inside a function. This namespace is created when a function is called and ceases to exist when the function returns.- *Global Namespace*: Includes names that are used in a project. For example, the namespace of this notebook.- *Built-in Namespace*: Includes *built-in* [functions](https://docs.python.org/3/library/functions.html), [types](https://docs.python.org/3/library/stdtypes.html), [keywords](https://docs.python.org/3/reference/lexical_analysis.htmlkeywords) and [exceptions](https://docs.python.org/3/library/exceptions.html). We can inspect *namespaces* using the `dir()` function.<jupyter_code>dir()                # global namespace
#dir(__builtins__)   # built-in names<jupyter_output><empty_output><jupyter_text>In this notebook, all names listed in the *global namespace* and *built-in namespace* are known to the interpreter.The 'square-root' function is not among them. To make this function available, we need to declare such a function in the namespace, as we did with the `sum_diff()` function above, or *import* it from a source outside of this notebook. Python comes with a large set of predefined functions ([Standard Library](https://docs.python.org/3/library/index.html)) that are automatically installed, but not 'built-in', i.e. not immediately available in any namespace.These functions are grouped by topic into individual *modules* that consist of a file in which those functions are defined. In fact, *any* importable python file is a module!How can the functions defined in a *module* be made available in the current global namespace?<jupyter_code>import math
print(math)<jupyter_output><empty_output><jupyter_text>We imported the *math* module into the global namespace.The first print statement confirms that `math` is a module that is 'built-in' the standard Python distribution. Nevertheless, we need to import it into our namespace explicitly.We can now call the function `sqrt()` that is defined in this module.<jupyter_code>print(math.sqrt(4))<jupyter_output><empty_output><jupyter_text>Try calling `sqrt()` directly, as we did before. This will still fail.The reason for this is that we have to tell the interpreter where to find the `sqrt()` function. Currently, the 'path' to this function is in the `math` module under the name `sqrt()`, so: `math.sqrt()`.This is verbose, and sometimes a more concise way of calling functions can be convenient. For example, we could import the function `sqrt` from the module `math` explicitly into the main namespace.<jupyter_code>from math import sqrt     # import single function from module

print(sqrt(4))<jupyter_output><empty_output><jupyter_text>It is also possible to import all functions from a module into the current namespace using the '*' wildcard.<jupyter_code>from math import *         # import all functions from module

print(sqrt(9))<jupyter_output><empty_output><jupyter_text>However, this contaminates the namespace with many new function names, which can have undesired sideeffects.A possible complication is *shadowing* due to redefinition of a name in the namespace.<jupyter_code>sqrt = 5
print(sqrt(9))<jupyter_output><empty_output><jupyter_text>The safest approach is to only import those function that are actually needed, or to import an entire module by its name. Function calls that be shortened by using alternative shortened names for imported libraries. For example a library for numeric computing that we will be using is frequently imported in this way:<jupyter_code>import numpy as np           # import using short name

print(np.ones( (5,5) ))<jupyter_output><empty_output><jupyter_text>'Introspection' and help *Introspection* in computing is the ability to examine the type or the properties of objects at runtime. Python provides a few built-in functions that allow you to do this.We have used of one of those functions before, the `type()` function, to identify the precise type of several numperic types<jupyter_code>print( type("test") )
print( type(7) )
print( type(None) )
print( type([]) )
print( type(()) )
print( type({}) )<jupyter_output><empty_output><jupyter_text>Another useful function is `dir()`. We have used this function before to inspect the names (functions, variables, etc) defined in a namespace, such as the global namespace or a module.You can also use it to discover methods (functions) of objects. For example, applied to a string, it will give you all the methods available for objects of type string.<jupyter_code>my_string = "This is a String."
dir(my_string)<jupyter_output><empty_output><jupyter_text>Elements in this list that have leading or tailing double underscores `__` are internal attributes or methods of the module; not of interest for now.Let's try one of the other methods that we have discovered:<jupyter_code>print(my_string)
print(my_string.lower())<jupyter_output><empty_output><jupyter_text>Python comes with a help utility. You have access to an interactive help by typing `help()`.<jupyter_code>help()<jupyter_output><empty_output><jupyter_text>Or, you can ask for a description of a specific object `xx` by executing `help(xx)`. This simply prints the docstring of the object `xx`.Note that when calling help on functions you **must not** include the parentheses `()` that you would otherwise use for calling that function. I.e. `help(math.sqrt)`, *not* `help(math.sqrt())`.<jupyter_code>help(math.sqrt)<jupyter_output><empty_output><jupyter_text>A very helpful website for (not only) programming-related questions of all kind is [stackoverflow](https://stackoverflow.com). When you search for a programming-related question or a specific error message on the web, stackoverflow discussions will be among the first hits.Colab even has a built-in functionality to search for discussions on stackoverflow when you encountered an error.<jupyter_code>my_tuple = (1, 2, 3)
my_tuple[2] = 1<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.09200796359499432, 'fraction_numerical': 0.012059158134243459, 'mean_word_length': 4.759377559377559, 'pattern_counts': {'":': 0, '<': 240, '<?xml version=': 0, '>': 243, 'https://': 17, 'lorem ipsum': 0, 'www.': 1, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 4, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '57', 'dir': 'jupyter-structured-clean-dedup', 'id': '16720', 'license': 'CC-BY-4.0', 'n_tokens_mistral': 10239, 'n_tokens_neox': 9852, 'n_words': 4263, 'path': 'IntroductionComputationalMethods/01_IntroPython.ipynb', 'repo_name': 'sbranciamore/biosci670coh-1', 'size': '35160'}