# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: light
#       format_version: '1.5'
#       jupytext_version: 1.14.4
#   kernelspec:
#     display_name: Python 3 (ipykernel)
#     language: python
#     name: python3
# ---

# ---   
#  <img align="left" width="75" height="75"  src="https://upload.wikimedia.org/wikipedia/en/c/c8/University_of_the_Punjab_logo.png"> 
#
# <h1 align="center">Department of Data Science</h1>
# <h1 align="center">Course: Tools and Techniques for Data Science</h1>
#
# ---
# <h3><div align="right">Instructor: <NAME>, Ph.D.</div></h3>    

# <h1 align="center">Lecture 2.12</h1>

# ## _Advance-Functions.ipynb_

# ## Review of Python Functions
# <img align="center" width="500" height="300"  src="images/func1.png" > 

# ## Learning agenda of this notebook
# 1. Anonymous / Lambda Functions
# 2. Using lambda function as argument to other functions
# 3. Using Lambda Function with built-in `map()` function
# 4. Using Lambda Function with built-in `filter()` function
# 5. Using Lambda Function with built-in `reduce()` function
# 6. Using Lambda Function with built-in `sorted()` function
# 7. Bonus
#     - The `zip()` function
#     - Iterators and Generators

# ## 1. Lambda / Anonymous Functions
# The syntax of defining a lambda function is:**```lambda [arg1 [,arg2,.....argn]]:expression```**
#
# <img align="center" width="800" height="400"  src="images/lambda.png" > 

# ### a. Example 1: A function that is passed one argument and it returns its square
# - Let us do this example step by step to completely understand the process of writing lambda functions

def square1(num):
    result = num**2
    return result


rv = square1(5)
rv


# **Let us try to shrink the above function**

def square2(num):
    return num**2


rv = square2(5)
rv


# **Although not a good programming style, however, we can write this in a single line**

def square3(num): return num**2


rv = square3(5)
rv

# **This is the form a function that a lambda expression intends to replicate. A lambda expression can then be written as:**

lambda num: num**2

# Note how we get a function back. We can assign this function to a label:

square4 = lambda num: num**2

print(square4)      
print(type(square4))

rv = square4(5)
rv

# ### b. Example 2: A function that is passed two arguments and it returns their sum
# ```
# def mysum2(a, b):
#    return a+b
#
# rv = mysum2(5, 7)
# rv
# ```

# +
mysum2 = lambda a, b: a + b   

rv = mysum2(5.3, 7)
rv

# -

# ### c. Example 3: A function that is passed three arguments and it returns their sum
# ```
# def mysum3(a, b, c):
#    return a+b+c
#
# rv = mysum3(5, 7, 9)
# rv
# ```

# +
mysum3 = lambda a, b, c: a + b + c  

rv = mysum3(5.5, 6.3, 2.7)         # return type corresponds to the expression
rv
# -

# ### d. Example 4: A function that is passed one argument and it returns True if it is even and False otherwise

even = lambda x: x % 2 == 0
rv = even(3)         # return type corresponds to the expression
rv

# ### e. Example 5: A function that is passed a string and returns the first character of that string

func = lambda s: s[0]
rv = func("Hello")
rv

# ### f. Example 6: A function that is passed a string, it returns the reverse of that string

# +
func = lambda s: s[::-1]

rv = func("Hello")
rv
# -

# ### g. Example 7: A function that is passed a list and it returns the length of the list

# +
func = lambda arg: len(arg)

list1 = ['Learning', 'is', 'fun', 'with', 'arif']
rv = func(list1)
rv


# -

# >**The real usage of Python Lambda functions is actually passing them as arguments to other functions like `map()`,`filter()` and `reduce()`**

# ## 2. Using Lambda Function as Argument to other Functions

# **Three regular function definitions and their calling convention**

# +
# Some basic functions that receives two arguments and return their sum, diff, mul
def myadd(a, b):
    return a + b

def mysub(a, b):
    return a - b

def mymul(a, b):
    return a * b

# Calling above functions
rv1 = myadd(8,2)
rv2 = mysub(8, 2)
rv3 = mymul(8, 2)
rv1, rv2, rv3
# -

# **We can write above three functions as lambda functions**

myadd = lambda a, b: a+b
mysub = lambda a, b: a-b
mymul = lambda a, b: a*b


# **Now, let us write a calculator function that other than receiving two arguments, also receives a function name which specifies the operation that needs to be performed on the arguments**

def mycalc(op, a, b):
    return op(a,b)


# +
rv1 = mycalc(myadd, 8, 2)
rv2 = mycalc(mysub, 8, 2)
rv3 = mycalc(mymul, 8, 2)

rv1, rv2, rv3
# -

# **A more elegant way of writing above code**

# +
rv1 = mycalc(lambda a, b: a + b, 8, 2)
rv2 = mycalc(lambda a, b: a - b, 8, 2)
rv3 = mycalc(lambda a, b: a - b, 8, 2)

rv1, rv2, rv3
# -

# ## 3. Using Lambda Function as Argument to built-in `map()` Function
# - The ```map(aFunction, *iterables)``` function simply returns a map object after applying  `aFunction()` to all the elements of `iterable(s)`. Later you can type cast the map object to appropriate data structure
# - The original iterable(s) remains unchanged. 

help(map)

# ### a. Example 1: 
# **Given a list of numbers, suppose we want to create a new list in which every element is the square every item of the given list**

# **Option 1: We can do this using a simple loop**

# +
mylist = [5, 7, 2, 6, 9]

mylist_squared = []    # create an empty list
for a in mylist:
    mylist_squared.append(a**2)   # append new item at the end of the newly created list

print("Original list: ", mylist)
print("List with items squared: ", mylist_squared)
# -

# **Option 2: We can do this using `map()` function and passing an appropriate regular function as its first argument**

# +
mylist = [5, 7, 2, 6, 9]

def sqr(x):
    return x ** 2


map_object = map(sqr, mylist)
mylist_squared = list(map_object)

print("Original list: ", mylist)
print("List with items squared: ", mylist_squared)
# -

# - We passed a user defined function `sqr(x)`, to the `map` function, along with the list of items on which to apply that function
# - `map()` function calls `sqr()` function on each list item and collects all the return values into a map object, which is type casted to a list
# - Since `map()` expects a function to be passed in, so this is where we can also use lambda functions as shown below

# **Let us use Lambda Function as key argument to `map()` function to perform the above task**

# +
mylist = [5, 7, 2, 6, 9]

map_object = map(lambda x: x ** 2 , mylist)

mylist_squared = list(map_object)

print("Original list: ", mylist)
print("List with items squared: ", mylist_squared)
# -

# **Let us do this with List comprehension**

mylist = [5, 7, 2, 6, 9]
newlist = [i**2 for i in mylist]
newlist

# ### b. Example 2: 
# **Given a list of numbers, suppose we want to create a new list in which every element is the remainder once the original list element is divided by 5**

# +
mylist = [74, 85, 14, 23, 56, 32, 45 ]

map_object = map(lambda num: num%5, mylist)

remainders = list(map_object)

print("Original list: ", mylist)
print("List of remainders: ", remainders)
# -





# ### c. Example 3: 
# **Suppose we want to add two lists**

# +
mylist1 = [4, 8, 3, 2]
mylist2 = [3, 1, 2, 6]

result = map(lambda a, b: a + b, mylist1, mylist2) #two arguments are passed to lambda func (one from each list)
result = list(result)

print("Sum of the two lists: ", result)
# -

# ### d. Example 4: 
# **Given a list of strings, suppose we want to create another list that contains the length of each string in the list**

# +
list1 = ('Mujahid', '<NAME>', 'Kakamanna', 'Maaz')

result = map(lambda a: len(a), list1)
result = list(result)

print("Length of Strings in list1: ", result)
# -

# ## 4. Using Lambda Function as Argument to built-in `filter()` Function
# ```
# filter(function or None, iterable)
# ```
# - The `filter()` function offers a convenient way to filter out all the elements of an iterable, for which the function returns true.
# - If function argument is None, return the items that are itself True.
# - The filter object contains only those items of iterable for which  `function(item)` returns True. 
# - The original iterable remains unchanged. 
# - The filter object can be converted to a list using the `list()` function

# ### a. Example 1: 
# **A very basic usage of `filter()`, that returns the True elements of a list**
# - In Python, the following objects are considered false:
#     >- Constants like `None` and `False`
#     >- Numeric types having values: 0, 0.0, 0j
#     >- Empty sequences and collections like `""`, `()`, `[]`, `{}`, `set()`, and `range(0)`

# +
mylist = [5, 0, -3, {}, False, 0.0, True, 9, 0j, (), None, 8]

result = filter(None, mylist)
print(result)
print(list(result))
# -

# ### b. Example 2: 
# **Suppose we want to extract even numbers from a list**

# +
numbers = [1, 5, 4, 6, 8, 11, 3, 12]

result = filter(lambda x: x%2 == 0 , numbers)

result = list(result)

print("Even numbers in the list are: ", result)
# -

# **Let us do it using List Comprehension**

numbers = [1, 5, 4, 6, 8, 11, 3, 12]
newlist = [i for i in numbers if i%2 == 0]
newlist

# ### c. Example 3: 
# **Suppose we want to extract negative numbers from a list**

# +
numbers = [25, -3, -8, 17, 3, 8, -3, 6, -7, 0]

result = filter(lambda x: x<0, numbers)

result = list(result)

print("Negative numbers in the list are: ", result)
# -

# ### d. Example 4: 
# **Suppose we want to extract vowels from a list of alphabets**

# +
characters = ['i', 'z', 'b', 'a', 'd', 'f', 't', 'e','w', 'x']

vowels = ['a', 'e', 'i', 'o', 'u']
    
result = filter(lambda x: x in vowels, characters)

result = list(result)    
print("Vowels in the list are: ", result)
# -

# ## 5. Using Lambda Function as Argument to built-in `reduce()` Function
# - The `reduce()` works differently than `map()` and `filter()`. It does not return a new iterable based on the function and iterable we've passed. Instead, it returns a single value.
# ```
# reduce(func, sequence[, initial])
# ```
# <img align="right" width="300" height="300"  src="images/reduce.png" > 
#
# - If seq = `[ s1, s2, s3, ... , sn ]`, then calling `reduce(func, seq)` works like this:
#     - Apply `func` argument to the first two items in the iterable and generate a partial result, i.e. `func(s1,s2)`
#     - The list on which `reduce()` works looks now like this: `[ func(s1, s2), s3, ... , sn ]`
#     - In the next step the function will be applied on the previous result and the third element of the list, i.e. `func(func(s1, s2),s3)`
#     - The list looks like this now: `[ function(function(s1, s2),s3), ... , sn ]`
#     - It continues like this until just one element is left and return this element as the result of `reduce()`
# - If initial is present, it is placed before the items of the sequence in the calculation, and serves as a default when the sequence is empty. The optional argument `initial` when provided is used as the 0th element
# - In Python 3.x, if you need to use `reduce()`, then you first have to import the function into your current scope using an import statement in one of the following ways:
#     - `import functools` and then use fully-qualified names like functools.reduce().
#     - `from functools import reduce` and then call reduce() directly.
#     

# ### a. Example 1: 
# **Suppose given a list of numbers and we want to get the accumulative sum of all the numbers in that list**

# **Option 1: We can do this using a simple loop**

# +
numbers = [47, 11, 42, 13]
total = 0

for i in numbers:
    total += i

total
# -

# **Option 2: We can do this using `reduce()` function and passing an appropriate regular function as its first argument. Remember this has to be a function that receives two arguments**

# +
#Example: Note the call to reduce(), 
# applies myadd() to the items in the numbers list to compute their accumulative sum    

from functools import reduce
def myadd(a,b):
    return a+b

numbers = [47, 11, 42, 13]

rv = reduce(myadd, numbers)
rv
# -

# **Option 3: Let us use Lambda Function as first argument to `reduce()` function to perform the above task**

# +
# Example: Use lambda function for above task
from functools import reduce
numbers = [47, 11, 42, 13]

rv = reduce(lambda x,y: x+y, numbers)
rv
# -

# **Let us now understand the initial argument to `reduce()` function**
# ```
# reduce(func, sequence[, initial])
# ```

# +
# Example: Use of initial argument of reduce() function
from functools import reduce
numbers = [47, 11, 42, 13]

rv = reduce(lambda x,y:x+y, numbers, 10)
rv
# -

# ### b. Example 2: 
# **Multiplying all numeric values of a list with each other**

# +
from functools import reduce
numbers = [1, 2, 3, 4, 5]

rv = reduce(lambda x,y: x*y, numbers)
rv
# -

# ### c. Example 3: 
# **Finding minimum or maximum number from a list of numbers**

# +
numbers = [-10, -20, -93, -4, -5]


rv1 = reduce(lambda a, b: a if a > b else b, numbers, 0)


rv1

# +
from functools import reduce
numbers = [10, 20, 93, 4, 5]


rv1 = reduce(lambda a, b: a if a < b else b, numbers)
rv2 = reduce(lambda a, b: a if a > b else b, numbers)

rv1, rv2
# -

# ### d. Example 4: 
# **Checking if ALL values in an iterable are true**

# +
from functools import reduce
mylist = [0, 0, 1, 0, 0]

rv = reduce(lambda a, b: bool(a and b), mylist)
rv

# -

# ### e. Example 5: 
# **Checking if ANY value in an iterable is true**

# +
from functools import reduce
mylist = [0, 0, 1, 0, 0]

rv = reduce(lambda a, b: bool(a | b), mylist)
rv

# -

# ## 6. Using Lambda Function as Argument to built-in `sorted()` Function
# - We have already seen the use of `sorted()` function in our previous session of Tuples.
# - The only required argument to `sorted()` function is an iterable. 
# - It sorts the items of the given iterable in ascending order (by default) and returns the sorted iterable as a list, without modifying the original iterable.
# ```
# sorted(iterable, key=None, reverse=False)
# ```
# We also have seen the use of `key` argument, where we can pass a function that is applied once to each element of the iterable before sorting it.

# ### a. Example 1: 
# **Consider a tuple of strings `('abcz', 'xyza', 'bas', 'arif')`. If we pass this tuple to `sorted()` function, it will sort the list alphabatically.**

# +
t1 = ('abcz', 'xyza', 'bas', 'arif')

rv = sorted(t1)

print("Sorted tuple: ", rv)
print("Original tuple remains as such: ", t1)


# -

# **Suppose, we want to sort the above tuple by last character of strings within the tuple so that the output is like : `('xyza', 'arif', 'bas', 'abcz')`. We can do this by passing an appropriate regular function to `key` argument of the `sorted()` function**

# +
def last(s):
    return s[-1]

t1 = ('abcz', 'xyza', 'bas', 'arif')
rv = sorted(t1, key=last)

print("Sorted tuple: ", rv)
print("Original tuple remains as such: ", t1)
# -

# **Let us use Lambda Function as `key` argument to `sorted()` function to perform the above task**

# +
t1 = ('abcz', 'xyza', 'bas', 'arif')

rv = sorted(t1, key = lambda arg : arg[-1])

print("Sorted tuple: ", rv)
print("Original tuple remains as such: ", t1)
# -

# ### b. Example 2: 
# **Suppose given a list in which each element is a two valued tuple `[(4, 30), (6, 15), (1, 25), (9, 8)]`. If we call `sorted()` function on this list it will sort it based on the first element of each tuple.**

# **Simple sorting of above list using `sorted()` function, will sort by first element**

# +
mylist = [(4, 30), (6, 15), (1, 25), (9, 8)]


mylist_sorted = sorted(mylist)

mylist_sorted

# -



# **But we want to sort the above list based on the second element of each tuple, so that the output is like: `[(9, 8), (6, 15), (1, 25), (4, 30)]`. We can do this by passing an appropriate regular function to `key` argument of the `sorted()` function**

# +
mylist = [(4, 30), (6, 15), (1, 25), (9, 8)]

def func(item):
    return item[1]

mylist_sorted = sorted(mylist, key = func)

mylist_sorted
# -



# **Let us use Lambda Function as `key` argument to `sorted()` function to perform the above task**

# +
mylist = [(4, 30), (6, 15), (1, 25), (9, 8)]

mylist_sorted = sorted(mylist, key = lambda element:element[1])

mylist_sorted
# -



# ## 7.  Bonus

# ### a. The `zip()` Function
# - The `zip(*iterables)` function returns an iterator of tuples, where the i-th tuple contains the i-th element from each of the argument sequences or iterables. 
# - The iterator stops when the shortest input iterable is exhausted. 
# - With a single iterable argument, it returns an iterator of 1-tuples. With no arguments, it returns an empty iterator.

# +
x = [1,2,3]
y = [4,5,6]

zip_object = zip(x ,y)

mylist = list(zip_object)
mylist

# +
x = [1, 2, 3, 4]
y = [5, 6, 7, 8]
z = [9, 10, 11, 12]

zip_object = zip(x,y,z)

mylist = list(zip_object)
mylist
# -

# **The iterator stops when the shortest input iterable is exhausted.**

# +
x = [1,2,3]
y = [4,5,6,7,8]

zip_object = zip(x,y)

mylist = list(zip_object)
mylist
# -

# **With a single iterable argument, it returns an iterator of 1-tuples.**

# +
x = [1, 2, 3, 4]

zip_object = zip(x)

mylist = list(zip_object)
mylist
# -

# **With no arguments, it returns an empty iterator.**

# +
zip_object = zip()

mylist = list(zip_object)
mylist
# -



# ### b. Iterators and Generators
# **Iterators:**
# - In our previous session of `for` loops, we discussed the concept of `Iterator` object, which is used to iterate over iterable objects like lists and tuples.
# - We also discussed the `iter()` function, which is passed an iterable (list, tuple, ...) and it returns an iterator for that iterable object. 
# - We also discussed the `next()` function, which is passed the iterator object, and each time it is called it returns the next item of that iterator object.

# **Example: Iterator Objects**

mylist = ['banana', 'mango', 'grapes']
a = iter(mylist)
a

print(next(a))
print(next(a))
print(next(a))

next(a)



# **Generators:**
# - Today let me tell you as how to write a generator function in Python. A generator is a function that can send back a value and then later resume its execution from where it left off. 
# - A generator function allows us to generate a sequence of values over time. 
# - The main difference between a regular function and a generator function is that instead of using a `return` statement, the generator function uses the `yield` statement
# - So once a generator function is called, it don't actually return a value and then exit, rather it automatically suspend and resume its execution and state around the last point of value generation. 

# **Example 1: Writing a Hello World Generator Function to understand the `yield` keyword**

def mygenerator():
    print('First item')
    yield 10

    print('Second item')
    yield 20

    print('Last item')
    yield 30


gen = mygenerator() 
gen

print(next(gen))

print(next(gen))

print(next(gen))





# **Example 2: Writing a Generator Function that returns cubes of numbers**

def gencubes(n):
    for num in range(n):
        yield num**3


for x in gencubes(5):
    print(x)

# >**The main advantage of using a generator function over the iterator is that elements are generated dynamically. Since the next item is generated only after the first is consumed, it is more memory efficient than the iterator.**



# ## Check your Concepts
#
# Try answering the following questions to test your understanding of the topics covered in this notebook:
#
# 1. What is a function?
# 2. What are the benefits of using functions?
# 3. What are some built-in functions in Python?
# 4. How do you define a function in Python? Give an example.
# 5. What is the body of a function?
# 6. When are the statements in the body of a function executed?
# 7. What is meant by calling or invoking a function? Give an example.
# 8. What are function arguments? How are they useful?
# 9. How do you store the result of a function in a variable?
# 10. What is the purpose of the `return` keyword in Python?
# 11. Can you return multiple values from a function?
# 12. Can a `return` statement be used inside an `if` block or a `for` loop?
# 13. Can the `return` keyword be used outside a function?
# 14. What is scope in a programming region? 
# 15. How do you define a variable inside a function?
# 16. What are local & global variables?
# 17. Can you access the variables defined inside a function outside its body? Why or why not?
# 18. What do you mean by the statement "a function defines a scope within Python"?
# 19. Do for and while loops define a scope, like functions?
# 20. Do if-else blocks define a scope, like functions?
# 21. What are optional function arguments & default values? Give an example.
# 22. Why should the required arguments appear before the optional arguments in a function definition?
# 23. How do you invoke a function with named arguments? Illustrate with an example.
# 24. Can you split a function invocation into multiple lines?
# 25. Write a function that takes a number and rounds it up to the nearest integer.
# 26. What is a docstring? Why is it useful?
# 27. How do you display the docstring for a function?
# 28. What are *args and **kwargs? How are they useful? Give an example.
# 29. Can you define functions inside functions? 
# 30. What is function closure in Python? How is it useful? Give an example.
# 31. What is recursion? Illustrate with an example.
# 32. Can functions accept other functions as arguments? Illustrate with an example.
# 33. Can functions return other functions as results? Illustrate with an example.
# 34. What are decorators? How are they useful?
# 35. Implement a function decorator which prints the arguments and result of wrapped functions.
# 36. What are some in-built decorators in Python?
# 37. Can you invoke a function inside the body of another function? Give an example.
# 38. What is the single responsibility principle, and how does it apply while writing functions?
# 39. What some characteristics of well-written functions?
# 40. Can you use if statements or while loops within a function? Illustrate with an example.
# 41. Compare the use of lambda functions in sorted(), map(), filter(), reduce(), and accumulate() functions and their different use cases.
# 42. Check out the use of command line arguments in Python using `sys.argv[]`, and `getopt.getopt()`
# 43. Decorators can be thought of as functions which modify the functionality of another function. They help to make your code shorter and more "Pythonic". Search and write down sample code snippets to understand Decorators in Python.



StarcoderdataJupyterScriptsDedupFiltered