QTM 151 - Introduction to Statistical Computing II

Lecture 07 - Custom Functions and Variable Scope

Danilo Freire

Emory University

I hope you’re having a great day! 😊

Today’s plan 📋

What we will do today:

  • Learn about functions in Python
  • Understand the difference between arguments, parameters, and return values
  • Define functions with def and return
  • Use functions to encapsulate repetitive code
  • Create lambda functions
  • Write a function to calculate the future value of an investment
  • Learn about local and global variables

Functions in Python 🐍

What is a function?

A function is a block of code that performs a specific task

  • Functions are used to organise code, make it readable, and reusable
  • The main idea behind writing and using functions is that, if you have to do the same task multiple times, you can write a function to do that task and then call it whenever you want
  • A (somewhat silly) rule of thumb is that if you do the same task more than three times, you should write a function for it
  • As your code grows, functions will help you keep it maintainable and scalable
  • We have already seen lots of functions in Python
    • print(), np.mean(), plt.hist(), type(), etc
  • These functions are built-in, but you can also create your own functions as we will see today

What is a function?

A function is a block of code that performs a specific task

  • Functions have parameters, which are the variables that the function expects to receive
    • For example, np.random.normal() expects two parameters: the mean (loc) and the standard deviation (scale). Size is an optional parameter
  • Functions can take arguments and return values
    • For example, np.random.normal(0, 1) takes two arguments and returns a random number from a normal distribution with mean 0 and standard deviation 1
  • Functions can also have default arguments, which are optional
    • If you don’t provide a value for a default argument, the function will use the default value
    • Example: np.random.normal() will provide a sample of 1 number with mean 0 and standard deviation 1 if you don’t provide any arguments

Some examples

# Argument: "Hello" 
# Return: Showing the message on screen

print("Hello, "+str("QTM151!"))
Hello, QTM151!
# Argument: ABC
# Return: The type of object, e.g. int, str,
# boolean, float, etc.

type("ABC")
str
# First Argument: np.pi (a numeric value)
# Second Argument: 10 (number of decimals)
# Return: Round the first argument, 
# given the number of decimals in the second argument
import numpy as np

round(np.pi,  10)
3.1415926536
list_fruits = ["Apple","Orange","Pear"]

# Argument: list_fruits
# Return: number of elements in the list
len(list_fruits)
3

So far, so good? 😊

Enter arguments by assignment

  • The most common way to pass arguments to a function is by assignment
  • You can pass arguments by position or by name
  • When you pass arguments by name, you can change the order of the arguments
    • That is the case with many functions in Python, and it makes it easier to remember the arguments
  • You can also use default arguments if you don’t want to pass a specific value

Enter arguments by assignment

# A function that generates a random number
vec_x = np.random.chisquare(df = 2, size = 10)
print(vec_x)
[2.91356908 5.53039839 4.14904763 2.71572947 0.51700164 1.36033337
 4.37985205 1.79457948 0.07138228 0.21360768]
# Another example
vec_y = np.random.normal(loc = 2, scale = 1, size = 10)
print(vec_y)
[-0.0937139   2.93770939  0.52126872  0.63748718  3.00102606  2.90859019
  3.09095035  3.37943664  1.54414445  1.73580673]
vec_z = np.random.uniform(0, 10, 10)
print(vec_z)
[6.65244788 3.00263768 6.60447886 2.74974441 0.25115862 7.78657883
 2.05190411 5.76036847 4.44663225 3.16819163]

What are the parameters, arguments, and return values in these examples? 🤓

Custom functions in Python 🐍

Defining a function

You can define a function using the def keyword

  • You can create your own functions using the def keyword
  • The syntax is as follows:
#---- DEFINE
def my_function(parameter):
    body
    return expression

#---- RUN
my_function(parameter = argument) 

#---- RUN
my_function(argument)
  • The function name should be descriptive, that is, its name should reflect what the function does
  • The parameters are the variables that the function expects to receive
    • In our case, the parameter is parameter (duh! 😅)
  • The body is the code that the function will run
    • Please don’t forget that the body should be indented!
  • The return statement is optional
    • If you don’t provide a return statement, the function will return None
    • So it’s a good practice to always return something!

Let’s create a function!

  • Let’s create a function that solves this equation for any combination of numbers:

\[V=P\left(1+{\frac {r}{n}}\right)^{nt}\]

To know what each parameter means, click here: Appendix 01

def fn_compound_interest(P, r, n, t):
    V = P*(1 + r/n)**(n*t)
    return V

Let’s test our function

  • Now that we have defined our function, we can use it to calculate the future value of an investment
# You can know compute the formula with different values
# Let's see how much one can gain by investing 50k and 100k
# Earning 10% a year for 10 years

V1 = fn_compound_interest(P = 50000, r = 0.10, n = 12, t = 10)
V2 = fn_compound_interest(100000, 0.10, 12, 10)
V3 = fn_compound_interest(r = 0.10, P = 100000, t = 10, n = 12)

print(V1)
print(V2)
print(V3)
135352.0745431122
270704.1490862244
270704.1490862244

Try it yourself! 🤓

  • Now it’s your turn to try it out!
  • Write a function that calculates

\(f(x) = x^2 + 2x + 1\)

  • Test your function with \(x = 2\) and \(x = 3\)
  • Appendix 02

Try it yourself! 🤓

  • Write a function with a parameter numeric_grade
  • Inside the function write an if/else statement for \(grade \ge 55\).
  • If it’s true, then assign status = pass
  • If it’s false, then assign status = fail
  • Return the value of status
  • Test your function with \(numeric\_grade = 60\)
  • Appendix 03

Lambda functions

Lambda functions

  • Lambda functions are short functions, which you can write in one line
  • They can have any number of arguments but only one expression (no return statement)
  • They are used when you need a simple function for a short period of time
  • They are also known as anonymous functions, although you can assign them to a variable
  • Format: my_function = lambda parameters: expression
    • Example: fn_squared = lambda x: x**2
  • More information here

Lambda functions

  • Example: calculate \(x + y + z\) using a lambda function
  • The function will take three arguments: \(x\), \(y\), and \(z\)
fn_sum = lambda x, y, z: x + y + z

result = fn_sum(1, 2, 3)
print(result)
6
fn_v = lambda P, r, n, t: P*(1+(r/n))**(n*t)

result = fn_v(50000, 0.10, 12, 10)
print(result)
135352.0745431122

Try it yourself! 🤓

Boleean + Functions

  • Write a function called fn_iseligible_vote
  • This functions returns a boolean value that checks whether \(age \ge\) 18
  • Test your function with \(age = 20\)
  • Appendix 04

Another one! 🤓

For loop + Functions

  • Create list_ages = [18,29,15,32,6]
  • Write a loop that checks whether above ages are eligible to vote
  • Use the above function
  • Appendix 05

Understanding scope in Python 🧐

What is variable scope?

  • Scope is the area of a programme where a variable is accessible
  • Think of scope as a variable’s “visibility” in different parts of your code
  • Python uses the LEGB rule to determine variable scope:
    • Local: Inside the current function
    • Enclosing: Inside enclosing/nested functions
    • Global: At the top level of the module
    • Built-in: In the built-in namespace
  • The LEGB rule defines the order Python searches for variables

  • It is easier to understand them with an example:
x = 10  # Global scope

def print_x():
    x = 20  # Local scope
    print(x)  # Prints 20 (local)

print_x()

print(x)  # Prints 10 (global)

Global scope

Variables defined outside a function

  • Most variables we have seen so far are in the global scope

  • They are stored in the global namespace and are accessible from anywhere in the code

  • Let’s create a function that sums 3 numbers

  • \(f(x,y,z) = x + y + z\)

  • We will pass the numbers as arguments to the function

# Correct example:
def fn_add_recommended(x,y,z):
    return(x + y + z)

print(fn_add_recommended(x = 1, y = 2, z = 5))
print(fn_add_recommended(x = 1, y = 2, z = 10))
8
13
  • If you do not include the variables as parameters, Python will try to use global variables if they exist
# Incorrect example:
def fn_add_notrecommended(x,y):
    return(x + y + z)

z = 5
print(fn_add_notrecommended(x = 1, y = 2))
z = 10
print(fn_add_notrecommended(x = 1, y = 2)) 
8
13
del z # Remove variable z from global scope
print(fn_add_notrecommended(x = 1, y = 2)) 
NameError: name 'z' is not defined

Local scope

Variables defined inside a function

  • Variables defined inside a function are local to that function
  • They are not accessible outside the function
  • Local variables are destroyed when the function returns
  • If you try to access a local variable outside the function, you will get a NameError
  • They include parameters and variables created inside the function
  • Example:
  • In the code below, x is a local variable to the function print_x()
def print_x():
    x = 20  # Local scope
    print(x)  # Prints 20 (local)

print_x() # Prints 20

print(x)  # NameError: name 'x' is not defined
>>> print_x()
20
>>> print(x)  # NameError: name 'x' is not defined
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'x' is not defined

Local variables supercede global variables

Remember the LEGB rule

# This is an example where we define a quadratic function
# (x,y) are both local variables of the function
# 
# When we call the function, only the arguments matter.
# any intermediate value inside the function

def fn_square(x):
    y = x**2
    return(y)

x = 5
y = -5

print(fn_square(x = 1))

print(x)
print(y)
1
5
-5

Local variables are not stored in the working environment

# The following code assigns a global variable x
# Inside the function

x = 5
y = 4

print("Example 1:")
print(fn_square(x = 10))
print(x)
print(y)

print("Example 2:")
print(fn_square(x = 20))
print(x)
print(y)
Example 1:
100
5
4
Example 2:
400
5
4

Permanent changes to global variables

  • If you want to change a global variable inside a function, you need to use the global keyword
  • The global keyword tells Python that you want to use the global variable, not create a new local variable
def modify_x():
    global x
    x = x + 5

x = 1

modify_x()
print(x)
6
  • Why avoid global?
    • It makes your code harder to follow. It’s not clear where a variable is being changed.
    • It can lead to unexpected side effects if multiple functions modify the same global variable.
    • It’s generally better for functions to receive data as parameters and return results.
  • Use global sparingly, if at all! 😉

Try it out! 🚀

def modify_x():
    global x
    x = x + 5

x = 1

modify_x()
print(x)
6
  • What happens if we run the function modify_x() again?
  • What happens if we add global y inside fn_square?
  • Appendix 06

Built-in scope

Variables defined in Python

  • We have also seen many built-in functions in Python, like print(), len(), sum(), etc
  • They are available in any part of your code, and you don’t need to define them
  • Python has a list of variables that are always available to prevent you from using the same names
  • Most of them are error names
  • Python checks the built-in scope last (after Local, Enclosing, and Global)
print(len("hello"))

m = min([4, 3, 1, 7])
print(m)
5
1
import builtins

# View a list of attributes of a given 
# object with dir()
print(dir(builtins))
['ArithmeticError', 'AssertionError', 'AttributeError', 'BaseException', 'BaseExceptionGroup', 'BlockingIOError', 'BrokenPipeError', 'BufferError', 'BytesWarning', 'ChildProcessError', 'ConnectionAbortedError', 'ConnectionError', 'ConnectionRefusedError', 'ConnectionResetError', 'DeprecationWarning', 'EOFError', 'Ellipsis', 'EncodingWarning', 'EnvironmentError', 'Exception', 'ExceptionGroup', 'False', 'FileExistsError', 'FileNotFoundError', 'FloatingPointError', 'FutureWarning', 'GeneratorExit', 'IOError', 'ImportError', 'ImportWarning', 'IndentationError', 'IndexError', 'InterruptedError', 'IsADirectoryError', 'KeyError', 'KeyboardInterrupt', 'LookupError', 'MemoryError', 'ModuleNotFoundError', 'NameError', 'None', 'NotADirectoryError', 'NotImplemented', 'NotImplementedError', 'OSError', 'OverflowError', 'PendingDeprecationWarning', 'PermissionError', 'ProcessLookupError', 'PythonFinalizationError', 'RecursionError', 'ReferenceError', 'ResourceWarning', 'RuntimeError', 'RuntimeWarning', 'StopAsyncIteration', 'StopIteration', 'SyntaxError', 'SyntaxWarning', 'SystemError', 'SystemExit', 'TabError', 'TimeoutError', 'True', 'TypeError', 'UnboundLocalError', 'UnicodeDecodeError', 'UnicodeEncodeError', 'UnicodeError', 'UnicodeTranslateError', 'UnicodeWarning', 'UserWarning', 'ValueError', 'Warning', 'ZeroDivisionError', '_IncompleteInputError', '__IPYTHON__', '__build_class__', '__debug__', '__doc__', '__import__', '__loader__', '__name__', '__package__', '__spec__', 'abs', 'aiter', 'all', 'anext', 'any', 'ascii', 'bin', 'bool', 'breakpoint', 'bytearray', 'bytes', 'callable', 'chr', 'classmethod', 'compile', 'complex', 'copyright', 'credits', 'delattr', 'dict', 'dir', 'display', 'divmod', 'enumerate', 'eval', 'exec', 'execfile', 'filter', 'float', 'format', 'frozenset', 'get_ipython', 'getattr', 'globals', 'hasattr', 'hash', 'help', 'hex', 'id', 'input', 'int', 'isinstance', 'issubclass', 'iter', 'len', 'license', 'list', 'locals', 'map', 'max', 'memoryview', 'min', 'next', 'object', 'oct', 'open', 'ord', 'pow', 'print', 'property', 'range', 'repr', 'reversed', 'round', 'runfile', 'set', 'setattr', 'slice', 'sorted', 'staticmethod', 'str', 'sum', 'super', 'tuple', 'type', 'vars', 'zip']

Enclosing scope: functions inside functions (Nested Functions)

  • Sometimes, you might define a function inside another function. This is called a nested function (we’ve spoken about this before!)
  • The “enclosing scope” refers to the variables of the outer function that the inner (nested) function can “see” and use
  • This is the ‘E’ in the LEGB rule (Local -> Enclosing -> Global -> Built-in).
  • They are easier to understand once you understand local and global scopes
  • We will not use them much in this course, but they are useful in some cases!

Enclosing scope: accessing outer variables

Here’s how an inner function can access a variable from its enclosing (outer) function:

def outer_function():
    outer_variable = "I'm from the outer function!" # Variable in outer_function's local scope
    
    def inner_function():
        # inner_function can 'see' and use outer_variable
        # because outer_variable is in its enclosing scope.
        print(f"Inner function says: {outer_variable}")
    
    print("Calling inner_function from outer_function...")
    inner_function() # Call the inner function

# Now, let's call the outer_function to see it in action
outer_function()
Calling inner_function from outer_function...
Inner function says: I'm from the outer function!

In this example, outer_variable is “enclosing” for inner_function.

Enclosing scope: what if inner defines its own variable? (shadowing)

If the inner function defines a variable with the same name as one in the outer function, the inner function will use its own local variable. This is called “shadowing”.

def outer_function_shadow():
    message = "This is the OUTER message." # Variable in outer_function_shadow's scope
    
    def inner_function_shadow():
        message = "This is the INNER message!" # This is a NEW, LOCAL variable for inner_function_shadow
                                             # It "shadows" the outer 'message'
        print(f"Inside inner_function_shadow: {message}") 
    
    print(f"Before calling inner, outer message is: {message}")
    inner_function_shadow()
    print(f"After calling inner, outer message is still: {message}") # Unchanged

outer_function_shadow()
Before calling inner, outer message is: This is the OUTER message.
Inside inner_function_shadow: This is the INNER message!
After calling inner, outer message is still: This is the OUTER message.

The message inside inner_function_shadow is a completely separate variable from the message in outer_function_shadow.

Try it out! 🚀

Consider the following code:

#| echo: true
#| eval: false # For students to predict first
a = 10 # Global variable

def func_one():
    b = 20 # Local to func_one
    print(f"Inside func_one: a = {a}, b = {b}")

    def func_two():
        c = 30 # Local to func_two
        # 'a' is global, 'b' is from enclosing scope of func_one
        print(f"Inside func_two: a = {a}, b = {b}, c = {c}")
    
    func_two()
    # print(f"Inside func_one, trying to print c: {c}") # What would happen here?

func_one()
# print(f"Outside all functions: a = {a}")
# print(f"Outside all functions, trying to print b: {b}") # What would happen here?
# print(f"Outside all functions, trying to print c: {c}") # What would happen here?
  1. Before running, predict what each print statement will output, or if it will cause an error.
  2. Uncomment the lines one by one (those trying to print b and c outside their scopes) and observe the errors. Why do they occur?
  3. What is the scope of a, b, and c?

And that’s all for today! 🎉

Have a great day! 😊

Appendix 01: Compound Interest Equation

  • \(V\) is the future value of the investment/loan, including interest
  • \(P\) is the principal investment amount (the initial deposit or loan amount)
  • \(r\) is the annual interest rate (decimal)
  • \(n\) is the number of times that interest is compounded per year
  • \(t\) is the time the money is invested/borrowed for, in years
  • More information

Back to the function

Appendix 02: Quadratic Equation

def fn_quadratic(x):
    f = x**2 + 2*x + 1
    return f

f1 = fn_quadratic(2)
f2 = fn_quadratic(3)

print(f1)
print(f2)
9
16

Back to the exercise

Appendix 03: Pass/Fail Function

def fn_pass_fail(numeric_grade):
    if numeric_grade >= 55:
        status = "pass"
    else:
        status = "fail"
    return status

status = fn_pass_fail(60)
print(status)
pass

Back to the exercise

Appendix 04: Lambda Function

fn_iseligible_vote = lambda age: age >= 18

result = fn_iseligible_vote(20)
print(result)
True

Back to the exercise

Appendix 05: For loop + Function

list_ages = [18,29,15,32,6]

for age in list_ages:
    result = fn_iseligible_vote(age)
    print(f"Age: {age} - Eligible to vote: {result}")
Age: 18 - Eligible to vote: True
Age: 29 - Eligible to vote: True
Age: 15 - Eligible to vote: False
Age: 32 - Eligible to vote: True
Age: 6 - Eligible to vote: False

Back to the exercise

Appendix 06

def modify_x():
    global x
    x = x + 5

x = 1

# Now, running the function 
# will permanently increase x by 5.

modify_x()
print(x)
modify_x()
print(x)
6
11
def fn_square(x):
    global y
    y = x**2
    return(y)

x = 5
y = -5

print("Example 1:")
print(fn_square(x = 10))
print(x)
print(y)
Example 1:
100
5
100

Back to exercise 06

Appendix 07

a = 10 # Global variable

def func_one():
    b = 20 # Local to func_one
    print(f"Inside func_one: a = {a}, b = {b}")

    def func_two():
        c = 30 # Local to func_two
        # 'a' is global, 'b' is from enclosing scope of func_one
        print(f"Inside func_two: a = {a}, b = {b}, c = {c}")
    
    func_two()
    # print(f"Inside func_one, trying to print c: {c}") # What would happen here?

func_one()
print(f"Outside all functions: a = {a}")
Inside func_one: a = 10, b = 20
Inside func_two: a = 10, b = 20, c = 30
Outside all functions: a = 10