Lecture-6
List Comprehension in Python
List comprehension is a more readable and compact way to create new lists using loop.
Instead of using multiple lines with a for loop, list comprehension lets you write the logic in a
single line.
Traditional for loop vs List Comprehension
```python
Traditional approach
squares = [] for x in range(1, 6): squares.append(x**2)
Using list comprehension (shorter, cleaner)
squares = [x**2 for x in range(1, 6)]
General Syntax:
```python [expression for item in iterable if condition]
expression: What to do with each item
iterable: A list, range, or other iterable
condition (optional): Filter items based on a condition
In [20]: # Convert a list of names to lowercase
names = ['Ali', 'ZARA', 'Hamza']
lowercase_names = [name.lower() for name in names]
print(lowercase_names)
['ali', 'zara', 'hamza']
[2, 4, 6]
In [24]: # Filter even numbers
numbers = [1, 2, 3, 4, 5, 6, 7, 8]
evens = [num for num in numbers if num % 2 == 0]
print(evens)
[2, 4, 6, 8]
In [25]: # Example: Extracting domain names from email addresses
emails = [
"ali@example.com",
"sara@university.edu",
"admin@myorg.org",
"info@startup.io"
]
# List comprehension to extract domains
domains = [email.split('@')[1] for email in emails]
print(domains)
['example.com', 'university.edu', 'myorg.org', 'startup.io']
Functions (Some Use Cases)
1. Grade Calculator (Education Analytics)
Uses: if-elif-else, loops, return values.
In [26]: # Write code block of below function; Rule: A grade for score 90 and avove, B for 80 a
def calculate_grade(score):
Cell In[26], line 4
^
SyntaxError: incomplete input
In [ ]: # Example usage
scores = [92, 81, 56, 73, 89]
# Use List Comprehesnion to get list of grades for above scores
grades =
print("Grades:", grades)
Click here for the solution
2. Email Validator Function (Data Cleaning Use Case)
Uses: conditions, string operations, Loops.
In [ ]: # Email Validator Function 'valid_email'; Rule: email having @, dot (.) and at least 6
def valid_email(email):
In [13]: # Test cases
emails = ["john@example.com", "alice@", "invalidemail.com", "sara@mail.org"]
print(email[0],'is', valid_email(email[0]))
# write a loop for checking all the emails, print using F-String
s is Invalid
john@example.com is Invalid
alice@ is Invalid
invalidemail.com is Invalid
sara@mail.org is Invalid
Click here for the solution
3. Text Preprocessing Function (NLP Use Case)
Uses: string handling, chaining operations, real-world pre-ML step.
In [15]: # Text Cleaning Function 'clean_text'; Rule: Remove comas, dots, and remove whitespace
def :
text = # fisrt convert to lower for standardization
text = text.replace().replace() #remove dots and comas, note chained operations
text = #remove spaces on both ends; stripe() function is used
return
['hello world!', 'python is fun', 'clean this']
In [16]: sentences = [" Hello, World!", "Python is Fun.", " Clean This. "]
# create a cleaned list calling clean_text() function, use List Comprehension looping
cleaned =
print(cleaned)
['hello world!', 'python is fun', 'clean this']
Click here for the solution
Design a Solution
🧠 Problem: Smart Water Billing System
A local municipality charges households for water based on usage (in cubic meters) with the
following rules:
First 30 cubic meters → flat rate of 500 PKR
For usage above 30 and up to 60 → 15 PKR per extra cubic meter
For usage above 60 → 20 PKR per extra cubic meter after 60, in addition to the previous
charges.
🔧 Task
Write a Python function calculate_bill(units) that:
Takes the water usage as input,
Applies the above rules, and
Returns the final bill amount.
Also, write a loop that:
Asks the user to enter usage values repeatedly,
Ends when the user enters -1 ,
Prints the bill for each input using the function.
❓ Think and Discuss
What are the conditions involved?
What kind of loop do we need?
Where should we use a function?
What are the edge cases to handle (like exactly 30 or 60 units)?
Answers to Guiding Questions
1. What are the conditions involved?
2. What type of loop will help take multiple inputs?
3. Where should you use a function?
4. What are the edge cases?
In [ ]: # Write code here
✅ Click to Reveal the Solution
Object-Oriented Programming (OOP) in
Python
What is OOP?
Object-Oriented Programming (OOP) is a programming paradigm that organizes software
design around objects rather than functions and logic. An object is an instance of a class, and
it can contain both data (attributes) and functions (methods).
OOP allows you to:
[ ] Group related data and functions together
[ ] Make code reusable and easier to maintain
[ ] Model real-world things clearly in code
Key Concepts in OOP
Class
A class is like a blueprint or template for creating objects.
Object
An object is a specific instance of a class with actual values.
Constructor ( __init__ )
A special method called when an object is created. It sets up the initial state.
Attributes
Variables that belong to an object.
Methods
Functions that are defined inside a class and can be used with objects.
Define Your Own Data Type!
OOP (Object-Oriented Programming) is used when you want to define your own data type
— one that not only holds data but also knows how to work with it.
Python's built-in types (like list , int , str and dict etc.) also have their own data +
methods. These are actually defined using classes behind the scenes.
```python my_list = [1, 2, 3] my_list.append(4) # Using a built-in method
In [26]: # See the class nature of built-in type
print(type(str))
x = 'Hi'
print(type(x))
<class 'type'>
<class 'str'>
What if we want our own type — for example, a Student or a Book ?
That's where OOP helps:
[ ] You can create your own type (class)
[ ] It can hold data (attributes) and
[ ] It can do actions (methods)
Why Use OOP?
✅ Code reusability through classes and objects
✅ Better structure and organization
✅ Makes programs easier to debug and extend
✅ Reflects real-world entities better
✅ Define your own data type for your data
A Simple Example
```python
Define a class
class Book: def init(self, title, author): # Constructor self.title = title # Attribute self.author =
author
def describe(self): # Method
print(f"'{self.title}' is written by {self.author}.")
Create an object
book1 = Book("1984", "George Orwell")
Call a method
book1.describe()
Example-I: Book Class
Class Definition:
[ ] In Python, a special method is a defined function that starts and ends with two
underscores and is invoked automatically when certain conditions are met.
[ ] The init special method, also known as a Constructor, is used to initialize the class with
attributes.
[ ] In Python, built-in classes are named in lower case, but user-defined classes are named
in Camel or Snake case, with the first letter capitalized.
[ ] In Python classes, the term self is a reference to the current object (instance) being used
— it mandatory as the first parameter in instance methods to access or modify object
data.
In [27]: # Define Book class
class Book:
def __init__(self, title, author): # Constructor
self.title = title # Attributes
self.author = author
def describe(self): # Method
print(f"'{self.title}' is written by {self.author}.")
Creating Instances/Objects of a Class:
In [28]: # Create two book objects
book1 = Book("The Alchemist", "Paulo Coelho")
book2 = Book("Python Crash Course", "Eric Matthes")
# Call the describe method
book1.describe()
book2.describe()
'The Alchemist' is written by Paulo Coelho.
'Python Crash Course' is written by Eric Matthes.
In [30]: book1.title
book1.title = 'Alche'
In [31]: book1.title
'Alche'
Out[31]:
Initialize with Default Values or None and set later
In [2]: class Book:
def __init__(self, title=None, author=None):
self.title = title
self.author = author
def describe(self):
if self.title and self.author:
print(f"'{self.title}' is written by {self.author}.")
else:
print("Book information is incomplete.")
# Create without data
book = Book()
book.describe() # Output: Book information is incomplete.
# Set values later
book.title = "Sapiens"
book.author = "Yuval Noah Harari"
book.describe() # Output: 'Sapiens' is written by Yuval Noah Harari.
Book information is incomplete.
'Sapiens' is written by Yuval Noah Harari.
Example-II: Student Class
This example introduces classes, objects, constructors (init), instance variables, and methods, all
in accessible and practical context.
In [33]: # Defining a class named Student
class Student:
def __init__(self, name, roll_number, marks):
# This is the constructor that runs when an object is created
self.name = name # Instance variable for student's name
self.roll_number = roll_number # Instance variable for roll number
self.marks = marks # Instance variable for marks out of 100
def display_info(self):
# Method to display basic info
print(f"Name: {self.name}")
print(f"Roll Number: {self.roll_number}")
print(f"Marks: {self.marks}")
def grade(self):
# Method to calculate and return grade
if self.marks >= 80:
return "Excellent"
elif self.marks >= 60:
return "Good"
else:
return "Needs Improvement"
Creating objects (instances) of the Student class
In [34]: # Creating objects (instances) of the Student class
student1 = Student("Areeba", 101, 92)
student2 = Student("Bilal", 102, 67)
student3 = Student("Faizan", 103, 45)
# Calling methods using the objects
student1.display_info()
print("Grade:", student1.grade())
print("------")
student2.display_info()
print("Grade:", student2.grade())
print("------")
student3.display_info()
print("Grade:", student3.grade())
Name: Areeba
Roll Number: 101
Marks: 92
Grade: Excellent
------
Name: Bilal
Roll Number: 102
Marks: 67
Grade: Good
------
Name: Faizan
Roll Number: 103
Marks: 45
Grade: Needs Improvement
OOP Is Useful in AI and Machine Learning
In Machine Learning, we often:
Build a model
Train it on data
Use it to make predictions
Using Object-Oriented Programming (OOP) helps us organize this process.
Example-III: A Simple Classifier with OOP
In [35]: class SimpleClassifier:
def __init__(self, threshold):
self.threshold = threshold
def predict(self, number):
if number >= self.threshold:
return "High"
else:
return "Low"
# Create a classifier with a threshold of 50
model = SimpleClassifier(threshold=50)
# Predict a value
print(model.predict(70)) # Output: High
print(model.predict(30)) # Output: Low
High
Low
Python Libraries for Data Science and AI/ML
Data Manipulation and Analysis
Numpy Library
NumPy, short for Numerical Python, is a fundamental library for numerical and scientific
computing in Python.
[ ] It provides support for large, multi-dimensional arrays and matrices, along with high-
level functions on these arrays.
[ ] The array object in NumPy is called Array (ndarray). Arrays are frequently used in data
science, where speed and resources are very important.
[ ] NumPy is usually imported under the np alias.
[ ] NumPy Array is usually fixed in size and each element is of the same type.
1D Numpy in Python
In [36]: # pip install numpy #if using first time on your computer
# import numpy library.
import numpy as np
Creating Numpy Array from List using Type Casting:
We can cast a list to Numpy array as follows:
In [37]: # Create a numpy array
a = np.array([5, 1, 7, 3, 4])
a
array([5, 1, 7, 3, 4])
Out[37]:
Each element is of the same type in Numpy Array, in this case integers:
Print the even elements in the given array.
In [39]: arr = np.array([1, 2, 3, 4, 5, 6, 7, 8])
# Enter your code here
arr[1:4]
array([2, 3, 4])
Out[39]:
Indexing Choice using a List
Similarly, we can use a list to select more than one specific index. The list select contains
several values:
In [40]: # Create the index list
select = [0, 2, 3, 5]
select
[0, 2, 3, 5]
Out[40]:
We can use the list as an argument in the brackets. The output is the elements corresponding
to the particular indexes:
In [42]: # Use List to select elements
d = arr[[0, 5, 1]]
d
array([1, 6, 2])
Out[42]:
We can assign the specified elements to a new value. For example, we can assign all these
values to 100 000 as follows:
In [43]: # Assign the specified elements to new value
arr[select] = 100000
arr
array([100000, 2, 100000, 100000, 5, 100000, 7, 8])
Out[43]:
Some Basic Attributes of Numpy Array
Let's review some basic array attributes using the array a :
In [44]: # Create a numpy array by casting [0, 1, 2, 3, 4]
a = np.array([0, 1, 2, 3, 4])
a
array([0, 1, 2, 3, 4])
Out[44]:
The attribute size is the number of elements in the array:
In [45]: # Get the size of numpy array
a.size
5
Out[45]:
The next two attributes will make more sense when we get to higher dimensions but let's
review them.
The attribute ndim represents the number of array dimensions, or the rank of the array. In
this case, 1:
In [46]: # Get the number of dimensions of numpy array
a.ndim
1
Out[46]:
The attribute shape is a tuple of integers indicating the size of the array in each dimension:
In [47]: # Get the shape/size of numpy array
a.shape
(5,)
Out[47]:
Numpy Statistical Functions
In [48]: # Create a numpy array
a = np.array([1, 2, 5, 11])
In [49]: # Get the mean of numpy array
mean = a.mean()
mean
4.75
Out[49]:
In [50]: # Get the standard deviation of numpy array
stdv = a.std()
stdv
3.897114317029974
Out[50]:
In [51]: # Create another numpy array
b = np.array([-1, 2, 3, 4, 5])
b
array([-1, 2, 3, 4, 5])
Out[51]:
In [52]: # Get the biggest value in the numpy array
max_b = b.max()
max_b
5
Out[52]:
In [53]: # Get the smallest value in the numpy array
min_b = b.min()
min_b
-1
Out[53]:
Arithmetic Operations: (Vector Operations/Element-
Wise)
You could use arithmetic operators directly between NumPy arrays. Number of elements need
to be same.
Addition
In [54]: u = np.array([1, 0])
v = np.array([0, 1])
np.add(u, v)
# u + v also works
array([1, 1])
Out[54]:
In [55]: # What about doing this with LIST?
l1 = [1, 0]
l2 = [0, 1]
l1+l2
# l1 + 2 #Not applicable
# l1 * 5 # concat 5 timees
# l1 - l2 #Not applicable
[1, 0, 0, 1]
Out[55]:
This operation is equivalent to vector addition:
Adding Constant
Consider the following array: Adding the constant 1 to each element in the array:
In [56]: # Create a constant to numpy array
u = np.array([1, 2, 3, -1])
u + 2
# Not possible with LIST
array([3, 4, 5, 1])
Out[56]:
Subtraction
Consider the numpy array a and numpy array b:
In [57]: a = np.array([10, 20, 30])
b = np.array([5, 30, 15])
np.subtract(a, b) # a- b is also applicable
array([ 5, -10, 15])
Out[57]:
Multiplication (Element-Wise)
Consider the vector numpy array y :
In [58]: # Create numpy arraya
x = np.array([1, 2])
y = np.array([2, 1])
In [59]: z = np.multiply(x, y) #x*y can also be used
z
array([2, 2])
Out[59]:
We can also multiply every element in the array by a number:
In [60]: # Numpy Array Multiplication
z = np.multiply(y, 2)
z
array([4, 2])
Out[60]:
This is equivalent to multiplying a vector by a scaler:
Division
In [61]: a = np.array([10, 20, 30])
b = np.array([2, 10, 3])
We can divide the two arrays and assign it to c:
In [62]: c = np.divide(a, b) #will give error if no. of elements not same
c
array([ 5., 2., 10.])
Out[62]:
Important: Dot Product: Matrix (Vector)
Multiplication
Not Element-wise Product
The dot product "⋅" is also known as scalar product and is defined as the sum of pairwise
multiplication.
(Matrix product is defined between two matrices. Dot product is defined between two vectors.)
The dot product of the two numpy arrays u and v is given by:
In [ ]: u = np.array([1, 2])
v = np.array([3, 2])
In [ ]: # Calculate the dot product
np.dot(u, v) #1*3 + 2*2
Iterating 1-D Arrays
Iterating means going through elements one by one. If we iterate on a 1-D array it will go
through each element one by one.
In [ ]: #If we execute the numpy array, we get in the array format,
#if we use print function, it gives in the form of a list
arr1 = np.array([1, 2, 3])
print(arr1)
But if you want the elements (not in the form of the list), then you can use for loop:
In [ ]: for x in arr1:
print(x)
2D Numpy in Python
Create a 2D Numpy Array
In [ ]: # Import the libraries
import numpy as np
#import matplotlib.pyplot as plt
In [ ]: a0 = [[11, 12, 13]]
A0 = np.array(a0)
A0
# A[A0 > 11] you can even apply conditions like this
# print(np.where(A0 == 13) #, 66, A0)) #where function
In [ ]: import numpy as np
x = np.array([1, 2, 3, 4, 5, 6])
y = np.array([10, 20, 30, 40, 50, 60])
result = np.where(x > 2 , x , y)
print(result)
#The new array will contain elements from the x array where the condition x > 2 is tru
# and elements from the y array where the condition is false.
Consider the list a , which contains three nested lists each of equal size.
In [ ]: # Create a list
a = [[11, 12, 13], [21, 22, 23], [31, 32, 33]]
a
We can cast this list to a 2D Numpy Array as follows:
In [ ]: # Convert list to Numpy Array
# Every element is the same type
A = np.array(a)
A
We can use the attribute ndim to obtain the number of axes or dimensions, referred to as the
rank.
In [ ]: # Show the numpy array dimensions
# A.ndim
A.ndim
Attribute shape returns a tuple corresponding to the size or number of each dimension.
In [ ]: # Show the numpy array shape
# A.shape
A.shape
The total number of elements in the array is given by the attribute size .
In [ ]: # Show the numpy array size
# A.size
A.size
Accessing Elements of a 2-D Numpy Array
We can use rectangular brackets to access the different elements of the array.
The correspondence between the rectangular brackets and the list and the rectangular
representation is shown in the following figure for a 3x3 array:
We can access the 2nd-row, 3rd column as shown in the following figure:
We simply use the square brackets and the indices corresponding to the element we would like:
In [ ]: # Access the element on the second row and third column
A[1, 2]
We can also use the following notation to obtain the single element: but using one bracket is
better
In [ ]: # Access the element on the second row and third column
A[1][2]
Consider the element shown encircled in the following figure
We can access the element as follows:
In [ ]: # Access the element on the first row and first column
A[0, 0]
We can also use Slicing in numpy arrays. Consider the following figure. We would like to obtain
the first two columns in the first row
This can be done with the following syntax:
In [ ]: # Access the element on the first row and first and second columns
A[0:1, 0:2]
Similarly, we can obtain the first two rows of the 3rd column as follows:
In [ ]: # Access the element on the first and second rows and third column
A[0:2, 2:]
Corresponding to the following figure:
Basic Aithmatic Operations: Element-Wise
We can also add arrays. The process is identical to matrix addition. Matrix addition of X and Y
is shown in the following figure:
The numpy array is given by X and Y
In [ ]: # Create a numpy array X
X = np.array([[1, 0], [0, 1]])
X
In [ ]: # Create a numpy array Y
Y = np.array([[2, 1], [1, 2]])
Y
We can add the numpy arrays as follows.
In [ ]: # Add X and Y
Z = X + Y
Z
Multiplying a numpy array by a scaler is identical to multiplying a matrix by a scaler.
If we multiply the matrix Y by the scaler 2, we simply multiply every element in the matrix by 2,
as shown in the figure.
We can perform the same operation in numpy as follows
In [ ]: # Create a numpy array Y
Y = np.array([[2, 1], [1, 2]])
Y
In [ ]: # Multiply Y with 2
Z = 2 * Y
Z
(Basic) Multiplication of two arrays corresponds to an element-wise product also called
Hadamard product.
Consider matrix X and Y .
The Hadamard product corresponds to multiplying each of the elements in the same position,
i.e. multiplying elements contained in the same color boxes together.
The result is a new matrix that is the same size as matrix Y or X , as shown in the following
figure.
We can perform element-wise product of the array X and Y as follows:
In [ ]: # Create a numpy array Y
Y = np.array([[2, 1], [1, 2]])
Y
In [ ]: # Create a numpy array X
X = np.array([[1, 0], [0, 1]])
X
In [ ]: # Multiply X with Y
Z = X * Y
Z
Important: Matrix Multiplication (Dot Product)
A dot product of a matrix is a basic linear algebra computation used in machine learning
models to complete operations with larger amounts of data more efficiently. It's the result of
multiplying two matrices that have matching rows and columns, such as a 3x2 matrix and a
2x3 matrix.
We can perform Matrix Multiplication with the numpy arrays A and B as follows:
[Matrix Multiplication Rule applies: Multiplication possible only if No. of Columns of 1st Array
must be Equal to No. of Rows of 2nd]
First, we define matrix A and B :
In [ ]: # Create a matrix A
A = np.array([[0, 1, 1], [1, 0, 1]])
A
In [ ]: # Create a matrix B
B = np.array([[1, 1], [1, 1], [-1, 1]])
B
We use the numpy function dot to multiply the arrays together (Matrix Multiplication).
In [ ]: # Calculate the dot product
Z = np.dot(A,B)
Z
In [ ]: # You can Calculate the sine of Z
np.sin(Z)
#np.cos(Z)
#np.tan(Z)
Transposed Matrix
We use the numpy attribute T to calculate the Transposed Matrix.
In [ ]: # Create a matrix C
C = np.array([[1,1],[2,2],[3,3]])
C
In [ ]: # Get the transposed of C
C.T
Numpy Mathematical Functions
numpy.zeros array
numpy.zeros(shape, dtype=float, order='C', *, like=None)
Return a new array of given shape and type, filled with zeros.
In [ ]: np.zeros(5)
In [ ]: np.zeros(5, dtype=int)
In [ ]: np.zeros((5, 3), dtype=int) #Returns a 2D array
Numpy Random Number Genrator
random.rand(x) genrates a list of x numbers all between 0 to 1.0. We will need this in our data
splitting.
In [ ]: #from numpy import random,
import numpy as np
# Generate Random Float from 0 to 1
x = np.random.rand()
x
In [ ]: # Generate a 1-D array containing 5 random floats:
x = np.random.rand(5) #< 0.5
#x = np.random.rand(6) < 0.5 # this will result in a True False list
print(x)
In [ ]: # Generate a 2-D array with 3 rows, each row containing 5 random numbers:
x = np.random.rand(3, 5)
print(x)
In [ ]: #Generate a random integer from 0 to 100:
x = np.random.randint(100)
print(x)
In [ ]: # Generate a 1-D array containing 5 random integers from 0 to 100:
x=np.random.randint(100, size=(1000,1000))
print(x)