Lecture Notes 14: Math module and Random, 
      plus Odds and Ends
      
        
      
      
      Modules
      
        As we've seen, we can make functions that can be used again and again.
        
        This lets us write more efficient code!
        
        Another approach is to use modules.
        
        Modules are python files that contain functions that we can import and use in our programs.
        
        There are two important steps in using modules:
        
          - importing the module (from a known location)
- calling the correct function from the correct module
namespaces
            A namespace is like a list of known objects.
            
            A module is simply a namespace that contains definitions from the module (like functions defined in it).
            
            Definitions in the module's code, e.g., variable assignments and function definitions, are placed in the module's namespace. The module is then added to the importing script or module's namespace, so that the importer can access the definitions.
            
        
        
Importing
        
        If a module is placed in a location known to python (like the set of python modules or the 
current working directory), we can simply import it in our code.
        
        
Step 1
        For us, step 1 is to make sure we're either:
        
        
          - using a common python module (like math or random (which are located in a known directory)
          or
          
          - copying the desired module into our current working directory
Step 2
        You can import a module by using the 
import reserved word and the name of the module (usually at the top of the file).
        
        The syntax is:
        
        
import < module name > 
        
        Step 3
        Finally, you can use a module function by using the name of the module and the 
dot operator.
        
        The syntax is:
        
        
< module name > .  < function name and arguments >
        
        In the following wections we'll see the 
math and 
random modules and how to use them.
      
      
      The Math Module
      
        See the functions in the math module
        
        You can also read about it in https://realpython.com/python-math-module/
        
        In the following program, we use three math functions:
        
import math
def main():
  '''Using math-module functions'''
  num1 = 24
  n_sq1 = math.sqrt(num1)
  print(f"the Square root of {num1} is {n_sq1:.2f}")
  print()
  
  num2 = 54
  gcd = math.gcd(num1, num2)
  print(f"the Greatest Common Divisor of {num1} and {num2} is {gcd:.2f}")
  print()
  
  num3 = 2
  n_pow1 = math.pow(n_sq1,2)
  print(f"the Square of {n_sq1} is {n_pow1:.2f}")
  print(f"the Square of {n_sq1} is {n_pow1}")
  print()
  
  print("Some important 'constants' are: ")
  print(" PI: ", math.pi)
  print(" e: ", math.e)
  
if __name__ == "__main__":
  main()
        You can see the example 
 here
        
        The from syntax
        Another way to do it is by using the 
from syntax:
        
            
            using 
from adds only the specified names to the global namespace.
            
            
Modifying the previous example
            
from math import *
def main():
  '''Using math-module functions'''
  num1 = 24
  n_sq1 = sqrt(num1)
  print(f"the Square root of {num1} is {n_sq1:.2f}")
  print()
  
  num2 = 54
  n_gcd = gcd(num1, num2)
  print(f"the Greatest Common Divisor of {num1} and {num2} is {n_gcd:.2f}")
  print()
  
  num3 = 2
  n_pow1 = pow(n_sq1,2)
  print(f"the Square of {n_sq1} is {n_pow1:.2f}")
  print(f"the Square of {n_sq1} is {n_pow1}")
  print()
  
  print("Some important 'constants' are: ")
  print(" PI: ", pi)
  print(" e: ", e)
  
if __name__ == "__main__":
  main()
            you can play with this example 
here
          Note that in main, we're using functions defined in the math module but we don't need to use the math. notation       
          Also, in the python tutor example, you can see that all function definitions were loaded directly into the importing script's namespace
          
          Now try importing ONLY the sqrt function: 
            
            
from math import sqrt
          
           In this class, for now, we'll use the notation: import math
          
          and not 
from math import *
          
          This is because if we import multiple modules, we might have name clashes! 
      
      
      Brief intro to Random variables
      
        A random variable is one whose outcome (the result of sampling the random variable) is unpredictable but whose outcome might follow some known distribution.
        
        Example distributions: 
        
        
          - Uniform distribution: all possible outcomes are equally likely
            
 Examples: fair coin tosses, fair dice rolls.
 See an example here
          - Normal distribution: outcome likelyhoods follow a Bell curve
            
 Examples: random sampling of human heights , the summ of many fair dice rolls.
 See an example here
      The Random Module
      
        You can import the random module by using:
        
        
        And now an example:
        
import random
def main():
  '''Using random-module functions'''
  for i in range(10):
      num = random.random()
      print(f"random number {i:2d} : {num:.3f} ")
  
if __name__ == "__main__":
  main()
        You can run it 
here
        
        the function 
random.random() generates a random number (float) in the range 
[0, 1)
        
        Activity 2 [2 minutes]: 
        
          Try the code in python tutor and write the code and run it in Replit.
          
          Run it a few times? ... What do you notice?
        
        
        
        Activity 3 [2 minutes]: 
        
          Write a program that simulates a coin toss...
          
          it should allow you to run it and:
          
            - half the time it should print the word "Heads"
- half the time it should print the word "Tails"
- the outcome should be random!!
        Now, modify your code to do the following:
        
        
Activity 4 [2 minutes]: 
        
          Write a program that simulates a coin toss and: 
          
          it should:
          
            - run 10 times
- it adds up the number of "Heads"
- it adds up the number of "Tails"
- prints the count of heads as in Heads : 4
- prints the count of tails as in Tails : 6
          Now modify it to run 
1,000,000 times!
        
Using function randint
        You can also indicate a specific range of integer values as a range for your output:
        
import random
def main():
  '''Using random-module functions'''
  for i in range(30):
      num = random.randint(4,13)
      print(f"random number {i:2d} : {num:2d} ")
  
if __name__ == "__main__":
  main()
        You can run it 
here
        
        Activity 5 [2 minutes]: 
        
          Do you notice something strange?
          
            
              (Wait; then Click)
                              
              the top number is INCLUDED!!! 
              
              (damn you, python)
             
          
        
        Note that now, we can have a two-number outcome (that might help with the heads/or/tails program)
        
        
Predictably Random???
        
        As you know, we like to test a program to verify that the decisions we are making are correct. 
        
        However, if the error / bug that we detected is only ocurring for some 
random program states, we might not encounter those issues again until it is too late!
        
        That's why there is a way to specify a 
starting point for random that will create a repeatable pattern of random events.
        
        We call this the 
seed.
        
        
Specifying the random seed
        
        In Python, you can specify a seed and see repeated random events like this:
        
import random
def main():
  '''Using random-module functions'''
  random.seed(123)
  for i in range(30):
      num = random.randint(4,13) 
      print(f"random number {i:2d} : {num:2d} ")
  
if __name__ == "__main__":
  main()
        You can try it out 
here
        
        Note you can pick any integer seed you like.
        
        Read the details 
here
        
        What do we get? We still generate random numbers every time we call any of these functions, but, crucially, once we restart the program, 
they are the same sequence of random numbers!
      
      
      Odds and Ends
      
        
          - Function annotations
- Syntax errors
- Exceptions
- Logical (semantic) errors
Function annotations
        There is a way in python to indicate (annotate) the desired type for input arguments and the desired return type for functions.
        
        The following is a function with the following characteristics:
        
        
          - The function is called foo
- its first argument, called st should be a string
- its second argument, called num should be an integer
- it should return a string
- it should return a string that has the concatenation of the string st, num times, with no whitespaces (" ", "\n", and "\t").
def foo(st:str, num:int) -> str:
    '''returns a string concatenated num times with no whitespaces'''
    st = st.strip().replace(" ", "").replace("\t", "").replace("\n", "")
    return st*num
def main():
  '''Using a function with annotations'''
  my_str = "  Abra \n ca \t dabra   "
  out = foo(my_str,3)
  print(out)
  
if __name__ == "__main__":
  main()
      You can try it 
here
      
      Syntax errors, vs Exceptions, vs Logical errors
      
      We've encounteres several errors so far. 
      You should notice that when running python programs and encountering an error, we get messages that indicate either an syntax error or a series of exceptions.
      
      
Syntax Error
  
      
      These arise when the program is not written using valid syntax. The usual problems are issing symbols or misspelled words.
      
import random
def main():
  '''Using random-module functions'''
  for i in range(30):
      num = random.randint(4,13) 
      print(f"random number {i:2d} : {num:2d} )
  
if __name__ == "__main__":
  main()
        The output is:
        
    
        Exceptions
        
        Errors detected during code execution are called exceptions.
        
        example 1: Divide by zero
        
def main():
  num = 7 * 4 / 0
  print (num)
  
if __name__ == "__main__":
  main()
        The output is:
        
        
         
        example 2: missing variable (unknown term)
        
def main():
  print ( my_str * 8)
  
if __name__ == "__main__":
  main()
        The output is:
        
         
        example 3: exception on operation
        
def main():
  print ( '2' + 2)
  
if __name__ == "__main__":
  main()
        The output is:
        
      
      Here is a 
List of Exceptions
      
      The basic rule is: if it is properly written and you still get an error, it is probably an exception.
      
      
Logical Errors (semantic errors)
      
      Therse arise from a mismatch between expected results and actual results.
      
      This usually means that the programmed logic is NOT the logic we wanted the program to complete.
      
      The following program was written in hopes of getting the following console printout for an input of 
5:
      
0 
0 1 
0 1 2 
0 1 2 3 
0 1 2 3 4 
def main():
  num = int(input("give me an integer: "))
  if num <= 0:
    print ("negative")
  if num >= 0:
    print ("positive")
    for i in range (num):
      for j in range (i):
        print (j, end = " ")
        print()
if __name__ == "__main__":
  main()
        You can try it out 
here
        
        What are the bugs!?
      
      
      
      Homework
      [Due for everyone]  
      Homework 04 (Replit); due on 02/25 before 5 PM
      
      
[Optional] TODO
      
      EXTRA
      
      Recap: Chaining
      
        Remember this problem?:
        
        
          - Set variable my-Str = "⎵⎵he-lloWo-rl⎵d-!"
- Count the number of "tokens" in my-str where a "token" is any Substring Surrounded with either "⎵"or "-"
- return the uppercase first letter of the third token          
Activity 1 [2 minutes]: 
        
          Show and Tell
        
        
        Solution below:
        
        
        
def find_occurrence(phrase: str, sub: str, k: int) -> int:
  '''Finds the k-th occurrence of sub inside phrase'''
  n_spaces = phrase.count(sub)
  idx = 0
  for oc in range(1, n_spaces):
    idx = phrase.find(sub, idx+1) #note the phrase is 'stripped'
    if oc == k:
      return idx
  
def main():
  '''Prints the uppercase first letter of the THIRD token (⎵,-)'''
  #phrase = "  he-lloWo-rl d-!"
  #phrase = "  this is-another-example . "
  phrase = input("Gimme a phrase with ' ' or '-' separators: ")
  phrase = phrase.strip().replace('-'," ")
  print(phrase)
  #let = phrase.find(" ",???) # can't say "second occurrence"
  ind_second_space = find_occurrence(phrase, " ", 2)
  letter = phrase[ind_second_space+1].upper()
  
  print(f"This uppercase first letter of the THIRD token is {letter}")    
if __name__ == "__main__":
  main()
            
 
      
      Splitting and Joining Strings
      
        Another typical task is to separate a string into a list of component substrings, or tokens.
        
        
Quick lookahead: lists
        
        A list is a sequence of elements stored consecutively (like a train). The following is the assignment and printing of one:
        
        
        
        You can try it 
here
        
        split
        The 
split method can achieve this by specifying the character(s) that are to be considered the 
separators between the tokens.
        
        We will learn how to work with lists in a future lecture.
        
        
join
        Another useful task is to join tokens into a longer string.
        
        The format is:
        
        
< separator > . join ( < list of tokens > )
        
        We'll see these after we introduce lists.