Objectives¶
By the end of this lesson, students will be able to:
- Define and use lambda functions
- Understand how dunder methods work in Python and object equality
- Write and use superclasses and subclasses
- Use methods from
networkxto create and manipulate graphs
Setting up¶
To follow along with the code examples in this lesson, please download the files in the zip folder here:
Make sure to unzip the files after downloading! The following are the main files we will work with:
lesson13.ipynblabcourse.pycourse.pymain.py
Lambda Functions¶
Recall earlier in the quarter when we were learning pandas, we learned that the apply function could take a function as another parameter! Instead of talking about pandas, we will simplify this to write our own apply_fun function that does something similar to a list of values.
apply_fun takes a list of values and another function as a parameter, and returns a new list that is the result of applying the given function to each element in the input list.
def apply_fun(values, function):
# Use a list comprehension to apply function to each value
return [function(v) for v in values]
def times_two(x: int) -> int:
return 2 * x
numbers = [i for i in range(10)]
print('numbers :', numbers)
print('times two:', apply_fun(numbers, times_two))
It’s a bit tedious that we have to write out a whole function called times_two just so we can pass it in as a parameter. Often times the operation we want to apply is simple enough to try to write an expression to pass to apply to each value, rather than defining some named function.
Enter the lambda (commonly called an anonymous function). The idea behind a lambda is to let you specify a function without needing to go through the whole def syntax. This works best for very simple operations. Below is an example showing how to do this with our apply_fun.
def apply_fun(values, function):
# Use a list comprehension to apply function to each value
return [function(v) for v in values]
numbers = [i for i in range(10)]
print('numbers :', numbers)
print('times two:', apply_fun(numbers, lambda v: 2 * v))
Notice instead of passing a function named times_two as the second parameter, we pass
lambda v: 2 * v
The lambda keyword defines a new function without giving it a name (hence, anonymous function). The way to read this is it is a function that takes a single parameter v and it returns the value of 2 * v. This is essentially just shorthand for the previous example, with the benefit that we don’t have to define a whole function called times_two to do this.
By design, it is very difficult to do something more complex than a one-line expression inside a lambda since it is meant to be a super quick-and-easy way of defining functions for simple operations.
Lambdas in Sorting¶
One of the most common places lambdas show up is for sorting values. Suppose we wanted to use our Dog class to make a list of Dogs. Additionally, suppose I wanted to sort them by their name (alphabetically). Recall that the sorted function can take in another function as the key= parameter. Since lambda functions are still functions, we can use them to define the key to sort by!
class Dog:
def __init__(self, name: str) -> None:
self._name: str = name
def get_name(self) -> str:
return self._name
dogs = [Dog('Bella'), Dog('Scout'), Dog('Chester')]
dogs = sorted(dogs, key=lambda d: d.get_name())
# Should print in sorted order!
for dog in dogs:
print(dog.get_name())
Fancy Syntax¶
Last time, we talked about defining how equality is checked with the == operator by writing our own implementation of the __eq__ method. It turns out that most of the common syntax in Python is really just “special methods” that can be defined in your class. Below is a list of common features you might define when writing your own classes. You can “plug in” to the Python syntax of, say, x < y, by implementing a method __lt__ that says what should happen when the less than (lt) symbol is used on your object.
| Syntax | Method Call |
|---|---|
x < y | x.__lt__(y) |
x == y | x.__eq__(y) |
x >= y | x.__ge__(y) |
print(x) | print(x.__str__()) |
x[i] | x.__getitem__(i) |
x[i] = v | x.__setitem__(i, v) |
For example, here is a toy class that implements all of these methods to prove that they get called when you use the syntax shown above.
class SomeClass:
def __lt__(self, other):
print('Calling __lt__')
return False
def __eq__(self, other):
print('Calling __eq__')
return False
def __str__(self):
print('Calling __str__')
return 'SomeString'
def __getitem__(self, i):
print(f'Calling __getitem__ with {i}')
return -1
def __setitem__(self, i, v):
print(f'Calling __setitem__ with {i} and {v}')
return -1
x = SomeClass()
y = SomeClass()
print('Less Than')
print(x < y)
print()
print('Greater Than')
print(x > y)
print()
print('Equal')
print(x == y)
print()
print('Not equal')
print(x != y)
print()
print('Print')
print(x)
print()
print('Bracket Notation')
print(x[0])
x[14] = 4
Implementing comparisons
It turns out that because we implement __lt__ and __eq__ there is no need to implement any other comparison operators (__le__, __gt__, __ge__). This is why when we wrote x > y it can figure it out just from __lt__! For example, x >= y can be implemented as not x.__lt__(y) and x <= y could be implemented as x.__lt__(y) or x.__eq_(y)!
Python providing an ability to define what happens when you use their general syntax on an object is one of the reasons the libraries people write are so popular and easy to learn! They can rely on a lot of the syntax people already know, rather than learning a bunch of specific niche methods.
Inheritance¶
When designing classes, we often find that multiple classes share common attributes and behaviors. For example, think about different types of courses at a university: lecture courses (like CSE 163!), lab courses, seminars, and capstones. They all share basic properties like a course code, credit hours, and instructor, but each type has its own specific characteristics. Writing separate classes for each would lead to a lot of duplicate code.
Inheritance is a powerful object-oriented programming concept that allows us to create new classes based on existing ones. The new class (called a subclass or child class) automatically gets all the attributes and methods from the existing class (called a superclass or parent class). The subclass can add new features or modify existing ones.
Inheritance Syntax¶
For this example, we leave out the doc-strings for readability, but we have kept the type annotations for parameters, returns, and fields.
Let’s revisit our Dog class from the previous lessons:
class Dog:
def __init__(self, name: str) -> None:
self._name = name
def bark(self):
print(self._name + ': Woof')
Now suppose that we want to create a specialized version of the Dog called ServiceDog that also tracks the kind of service that the ServiceDog provides, and whether she’s currently on-duty. Otherwise, the ServiceDog also has a name, which is a property that we can inherit from Dog:
class ServiceDog(Dog):
def __init__(self, name: str, service_type: str) -> None:
# Call the parent class initializer
super().__init__(name)
# Add new attributes specific to ServiceDog
self._service_type: str = service_type
self._on_duty: bool = False
def get_service_type(self) -> str:
return self._service_type
def is_on_duty(self) -> bool:
return self._on_duty
def clock_in(self) -> None:
self._on_duty = True
Let’s unpack the new syntax:
| Line | Meaning |
|---|---|
class ServiceDog(Dog) | Class definition for a subclass requires the superclass name in parentheses. Here, the subclass is ServiceDog and the superclass is Dog |
def __init__(...) | The initializer takes in all the same parameters as the superclass, along with any additional parameters that are unique to the subclass |
super().__init__(name) | Invoke the superclass’s initializer to set the attributes in common |
self._service_type: str = service_type | Any fields that are not defined in the superclass’s initializer should be initialized separately |
def get_service_type(self) -> str: | Any methods that are not defined in the superclass should be defined separately |
A ServiceDog object has access to all the methods from Dog, plus its own specialized methods! This means that a ServiceDog also has a bark method, but Dog does not have a get_service_type or is_on_duty function!
buddy = ServiceDog("Buddy", "guide")
# Methods from Dog work automatically
buddy.bark() # Buddy: Woof
# New methods specific to ServiceDog
print(buddy.get_service_type()) # guide
buddy.clock_in() # Buddy is now on duty as a guide dog.
print(buddy.is_on_duty()) # True
Overriding Methods¶
Sometimes a subclass needs to change the behavior of a method inherited from its parent. This is called method overriding. For example, we might want service dogs to bark differently when they’re on duty:
class Dog:
# ... other methods ...
def bark(self) -> str:
return f"{self._name}: Woof!"
class ServiceDog(Dog):
def __init__(self, name: str, service_type: str) -> None:
# Call the parent class initializer
super().__init__(name)
# Add new attributes specific to ServiceDog
self._service_type: str = service_type
self._on_duty: bool = False
# Functions unique to ServiceDog
def get_service_type(self) -> str:
return self._service_type
def is_on_duty(self) -> bool:
return self._on_duty
def clock_in(self) -> None:
self._on_duty = True
# Overriding the bark function
def bark(self) -> str:
if self._on_duty:
print(f"{self.name}: *alert bark*")
else:
# Call the parent's bark method using super()
super().bark()
When we use the bark() function in ServiceDog, it will use the overridden method:
regular_dog = Dog("Max")
service_dog = ServiceDog("Buddy", "guide")
regular_dog.bark() # Max: Woof
service_dog.bark() # Buddy: Woof (off duty, uses parent's bark)
service_dog.clock_in()
service_dog.bark() # Buddy: *alert bark* (on duty, uses overridden bark)
Typing¶
One of the powerful aspects of inheritance is that a subclass object can be used anywhere the parent class is expected. This is because a ServiceDog is a Dog; it has all the same methods and attributes as its parent, plus more.
def make_dog_bark(dog: Dog) -> None:
"""
Makes any Dog bark.
"""
dog.bark()
regular = Dog("Max")
service = ServiceDog("Buddy", "therapy")
# Both work! ServiceDog is a subtype of Dog
make_dog_bark(regular) # Max: Woof
make_dog_bark(service) # Buddy: Woof
Specificity
Your type annotations should still be as specific as possible! If a function can expect any type of Dog, then it would be appropriate to use Dog as a type annotation (rather than Dog | ServiceDog). However, if it only expects a ServiceDog, it is most appropriate to use ServiceDog as the type annotation.
Food for thought: What happens if you override a method but still want to use the superclass’s version of that method? (Hint: What happened when we used the superclass’s initializer inside the subclass initializer?)
Networks¶
In Take-Home Assessment 4, you will be working with graphs in the networkx Python library to represent a social media friend recommendation algorithm, such as Facebook. This section will introduce you to some of the underlying ideas we will need to understand how graphs work, their uses in data science, and the implementation of this recommendation algorithm.
Graphs¶
A graph is a data structure that represents relationships between entities. It consists of:
- Nodes (also called vertices): The entities themselves
- Edges: Connections between nodes
The degree of a node is the number of edges connected to it.
Graphs are everywhere in computing and data science: transportation systems, web pages linked by URLs, citation neteworks, course prerequisites, food webs, and much more. There are a few main types of graphs:
- Undirected graphs: Edges have no direction. If A connects to B, then B connects to A. Example: Facebook friendships (symmetric relationships).
- Directed graphs (digraphs): Edges have direction. An edge from A to B doesn’t necessarily mean there’s an edge from B to A. Example: Twitter follows (asymmetric relationships).
- Weighted graphs: Edges have associated weights or costs. Example: Road networks where weights represent traffic congestion.
Note that either an undirected or directed graph can have weighted or unweighted edges. A helpful way to think about this is that a graph can be either undirected or directed, and either weighted or unweighted.
The networkx Library¶
networkx is a fairly powerful Python library for creating and analyzing graphs. You can find a full tutorial for how to get started in the Take-Home Assessment 4 spec, or by following the link here. The convention is to import the networkx library as nx, and most of the work that we will do with graphs uses the nx.Graph() object. This initializes an empty graph so that we can add edges and/or nodes.
import networkx as nx
import matplotlib.pyplot as plt # Needed to make the graphs appear
# Create an undirected graph
avengerz = nx.Graph()
# Add individual nodes
avengerz.add_node("Ava")
avengerz.add_node("Bob")
# Or add multiple nodes at once using a list
avengerz.add_nodes_from(["Yelena", "John", "Bucky", "Alexei"])
# Add individual edges
avengerz.add_edge("Alexei", "Yelena")
# Or several at once, using a list of two-element tuples
avengerz.add_edges_from([("Bob", "Yelena"),
("John", "Ava"),
("Bucky", "John"),
("John", "Yelena")])
# Visualize the graph
nx.draw_networkx(avengerz)
# Save figure
plt.savefig('avengerz_graph.png')
# Clear figure so that multiple graphs aren't overlapping
plt.clf()
It is also possible to initialize nodes in a Graph only by using the add_edge or add_edges_from functions:
friends = nx.Graph()
abc = [("Enjolras", "Combeferre"),
("Combeferre", "Prouvaire"),
("Prouvaire", "Feuilly"),
("Feuilly", "Courfeyrac"),
("Courfeyrac", "Bahorel"),
("Bahorel", "Bossuet"),
("Bossuet", "Joly"),
("Joly", "Grantaire")]
friends.add_edges_from(abc)
nx.draw_networkx(friends)
plt.savefig("friends_graph.png")
plt.clf()
For a directed graph, we use DiGraph instead. Also, note that a node can have any type! You can even create nodes with additional attributes by defining them in the form (node, dict) where the dict is a dictionary of the node’s attributes.
# Create a directed graph
rj = nx.DiGraph()
rj.add_nodes_from([("Romeo", {"House": "Montague"}),
("Juliet", {"House": "Capulet"}),
("Mercutio", {"House": "Prince"})])
rj.add_edge("Romeo", "Juliet")
nx.draw_networkx(rj)
plt.savefig("rj_graph.png")
plt.clf()
Here are some functions you may find helpful for both Graphs and DiGraphs:
graph.number_of_nodes()returns the number of nodes in the graphgraph.number_of_edges()returns the number of edges in the graphgraph.nodes()returns a set-like object containing all the nodes in the graphgraph.edges()returns a set-like object containing all the edges in the graphgraph.degree[n]returns the number of edges thatnhasgraph.neighbors(n)returns the “neighbor” nodes ofn, or any node that is connected tonby an edge
Food for thought: When would you want to directed or undirected graphs?
Set Operations¶
When working with graphs and social networks, we frequently need to compare groups of nodes. Python’s set operations are perfect for this! Sets allow us to efficiently find commonalities and differences between collections.
- Intersection (
&) lets us find the elements that appear in both sets - Union (
|) lets us find the elements that appear in either or both sets - Difference (
-) lets us find the elements that appear in the first set but not the second (order matters!)
s1 = {1, 2, 3, 4, 5}
s2 = {3, 4, 5, 6, 7}
print(s1 & s2)
print(s1 | s2)
print(s1 - s2)
⏸️ Pause and 🧠 Think¶
Take a moment to review the following concepts and reflect on your own understanding. A good temperature check for your understanding is asking yourself whether you might be able to explain these concepts to a friend outside of this class.
Here’s what we covered in this lesson:
- Lambda functions
- Dunder methods
- Inheritancee
- Superclasses and subclasses
- Overriding methods
- Typing
- Graphs
- Undirected graphs
- Directed graphs
networkx
- Set operations
Here are some other guiding exercises and questions to help you reflect on what you’ve seen so far:
- In your own words, write a few sentences summarizing what you learned in this lesson.
- What did you find challenging in this lesson? Come up with some questions you might ask your peers or the course staff to help you better understand that concept.
- What was familiar about what you saw in this lesson? How might you relate it to things you have learned before?
- Throughout the lesson, there were a few Food for thought questions. Try exploring one or more of them and see what you find.
In-Class¶
When you come to class, we will work together on writing the class in labcourse.py, which is a subclass of the Course class in course.py. Make sure that you have a way of editing and running these files!
LabCourse¶
Recall the Course class, which represents a course object that has a course code, credit hours, an instructor, and a list of prerequisites:
class Course:
def __init__(self, code: str, credit_hours: int,
instructor: str, prereqs: list[str]) -> None:
self._code: str = code
self._credit_hours: int = credit_hours
self._instructor: str = instructor
self._prereqs: list[str] = prereqs
def get_name(self) -> str:
return self._code
def get_prereqs(self) -> list[str]:
return self._prereqs
def get_credit_hours(self) -> int:
return self._credit_hours
def check_instructor(self, other: "Course") -> bool:
return self._instructor == other._instructor
Task: Update the Course class to include a __str__ method which returns a string representation of the course in the format: "COURSE (X credits)" where COURSE is the name of the course, and X is the number of credit hours in the course.
Task: Define a new class LabCourse that inherits from the Course class and adds the following attributes:
- A field
lab_days, which is a list of strings representing the days that the labs meet, e.g.,["Tuesday", "Thursday"] - A field
lab_materialswhich is a boolean representing whether the lab requires extra materials, e.g.,False - A method
get_lab_daysthat returns the list of strings representing the days that the labs meet - A method
needs_materialswhich returns whether the lab requires extra materials - A method
__str__that overrides the existing__str__function to return a string representation of a lab course in the format:"COURSE (X hours) - Lab: DAYS", whereCOURSEis the name of the course,Xis the number of credit horus, andDAYSis the list of days that the lab meets.
Here’s an example of the usage:
cse164 = LabCourse("CSE 164", 4, "Suh Young Choi", ["CSE 163"],
["Tuesday", "Wednesday"], True)
print(cse164.get_name()) # CSE 164
print(cse164.get_credit_hours()) # 4
print(cse164.get_lab_days()) # ['Tuesday', 'Wednesday']
print(cse164.needs_materials()) # True
print(cse164) # CSE 164 (4 credits) - Lab: Tuesday, Wednesday
Canvas Quiz¶
All done with the lesson? Complete the Canvas Quiz linked here!