# Run this cell to set up packages for lecture.
from lec08_imports import *
Announcements¶
- Lab 2 is due tomorrow at 11:59PM.
- Homework 2 is due Tuesday at 11:59PM.
- Come to office hours to work on assignments - there is a lot of support available!
Agenda¶
- Functions.
- Applying functions to DataFrames.
- Example: Student names.
Reminder: Use the DSC 10 Reference Sheet.
Functions¶
Defining functions¶
- We've learned how to do quite a bit in Python:
- Manipulate arrays, Series, and DataFrames.
- Perform operations on strings.
- Create visualizations.
- But so far, we've been restricted to using existing functions (e.g.
max
,np.sqrt
,len
) and methods (e.g..groupby
,.assign
,.plot
).
Motivation¶
Suppose you drive to a restaurant 🥘 in LA, located exactly 100 miles away.
- For the first 50 miles, you drive at 80 miles per hour.
- For the last 50 miles, you drive at 60 miles per hour.
- Question: What is your average speed throughout the journey?
- 🚨 The answer is not 70 miles per hour! Remember, from Homework 1, you need to use the fact that $\text{speed} = \frac{\text{distance}}{\text{time}}$.
$$\text{average speed} = \frac{\text{total distance}}{\text{total time}} = \frac{50 + 50}{\text{time}_1 + \text{time}_2} \text{ miles per hour}$$
In segment 1, when you drove 50 miles at 80 miles per hour, you drove for $\frac{50}{80}$ hours:
$$\text{speed}_1 = \frac{\text{distance}_1}{\text{time}_1}$$
$$80 \text{ miles per hour} = \frac{50 \text{ miles}}{\text{time}_1} \implies \text{time}_1 = \frac{50}{80} \text{ hours}$$
Similarly, in segment 2, when you drove 50 miles at 60 miles per hour, you drove for $\text{time}_2 = \frac{50}{60} \text{ hours}$.
Then,
$$\text{average speed} = \frac{50 + 50}{\frac{50}{80} + \frac{50}{60}} \text{ miles per hour} $$
$$\begin{align*}\text{average speed} &= \frac{50}{50} \cdot \frac{1 + 1}{\frac{1}{80} + \frac{1}{60}} \text{ miles per hour} \\ &= \frac{2}{\frac{1}{80} + \frac{1}{60}} \text{ miles per hour} \end{align*}$$
Example: Harmonic mean¶
The harmonic mean ($\text{HM}$) of two positive numbers, $a$ and $b$, is defined as
$$\text{HM} = \frac{2}{\frac{1}{a} + \frac{1}{b}}$$
It is often used to find the average of multiple rates.
Finding the harmonic mean of 80 and 60 is not hard:
2 / (1 / 80 + 1 / 60)
68.57142857142857
But what if we want to find the harmonic mean of 80 and 70? 80 and 90? 20 and 40? This would require a lot of copy-pasting, which is prone to error.
It turns out that we can define our own "harmonic mean" function just once, and re-use it multiple times.
def harmonic_mean(a, b):
return 2 / (1 / a + 1 / b)
harmonic_mean(80, 60)
68.57142857142857
harmonic_mean(20, 40)
26.666666666666664
Note that we only had to specify how to calculate the harmonic mean once!
Functions¶
Functions are a way to divide our code into small subparts to prevent us from writing repetitive code. Each time we define our own function in Python, we will use the following pattern.
show_def()
Functions are "recipes"¶
- Functions take in inputs, known as arguments, do something, and produce some outputs.
- The beauty of functions is that you don't need to know how they are implemented in order to use them!
- For instance, you've been using the function
bpd.read_csv
without knowing how it works. - This is the premise of the idea of abstraction in computer science – you'll hear a lot about this if you take DSC 20.
- For instance, you've been using the function
harmonic_mean(20, 40)
26.666666666666664
harmonic_mean(79, 894)
145.17163412127442
harmonic_mean(-2, 4)
-8.0
Parameters and arguments¶
triple
has one parameter, x
.
def triple(x):
return x * 3
When we call triple
with the argument 5, within the body of triple
, x
means 5.
triple(5)
15
We can change the argument we call triple
with – we can even call it with strings!
triple(7 + 8)
45
triple('triton')
'tritontritontriton'
Scope 🩺¶
The names you choose for a function’s parameters are only known to that function (known as local scope). The rest of your notebook is unaffected by parameter names.
def triple(x):
return x * 3
triple(7)
21
Since we haven't defined an x
outside of the body of triple
, our notebook doesn't know what x
means.
x
--------------------------------------------------------------------------- NameError Traceback (most recent call last) Cell In[16], line 1 ----> 1 x NameError: name 'x' is not defined
We can define an x
outside of the body of triple
, but that doesn't change how triple
works.
x = 15
# When triple(12) is called, you can pretend
# there's an invisible line inside the body of x
# that says x = 12.
# The x = 15 above is ignored.
triple(12)
36
Functions can take 0 or more arguments¶
Functions can take any number of arguments. So far, we've created a function with two arguments, harmonic_mean
, and a function with one argument, triple
.
greeting
takes no arguments!
def greeting():
return 'Hi! 👋'
greeting()
'Hi! 👋'
Functions don't run until you call them!¶
The body of a function is not run until you use (call) the function.
Here, we can define where_is_the_error
without seeing an error message.
def where_is_the_error(something):
'''You can describe your function within triple quotes. For example, this function
illustrates that errors don't occur until functions are executed (called).'''
return (1 / 0) + something
It is only when we call where_is_the_error
that Python gives us an error message.
where_is_the_error(5)
--------------------------------------------------------------------------- ZeroDivisionError Traceback (most recent call last) Cell In[22], line 1 ----> 1 where_is_the_error(5) Cell In[21], line 4, in where_is_the_error(something) 1 def where_is_the_error(something): 2 '''You can describe your function within triple quotes. For example, this function 3 illustrates that errors don't occur until functions are executed (called).''' ----> 4 return (1 / 0) + something ZeroDivisionError: division by zero
Example: first_name
¶
Let's create a function called first_name
that takes in someone's full name and returns their first name. Example behavior is shown below.
>>> first_name('Pradeep Khosla')
'Pradeep'
Hint: Use the string method .split
.
General strategy for writing functions:
- First, try and get the behavior to work on a single example.
- Then, encapsulate that behavior inside a function.
'Pradeep Khosla'.split(' ')[0]
'Pradeep'
def first_name(full_name):
'''Returns the first name given a full name.'''
return full_name.split(' ')[0]
first_name('Pradeep Khosla')
'Pradeep'
# What if there are three names?
first_name('Chancellor Pradeep Khosla')
'Chancellor'
Returning¶
- The
return
keyword specifies what the output of your function should be, i.e. what a call to your function will evaluate to. - Most functions we write will use
return
, but usingreturn
is not strictly required.- If you want to be able to save the output of your function to a variable, you must use
return
!
- If you want to be able to save the output of your function to a variable, you must use
- Be careful:
print
andreturn
work differently!
def pythagorean(a, b):
'''Computes the hypotenuse length of a triangle with legs a and b.'''
c = (a ** 2 + b ** 2) ** 0.5
print(c)
x = pythagorean(3, 4)
5.0
# No output – why?
x
# Errors – why?
x + 10
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[30], line 2 1 # Errors – why? ----> 2 x + 10 TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
def better_pythagorean(a, b):
'''Computes the hypotenuse length of a triangle with legs a and b,
and actually returns the result.
'''
c = (a ** 2 + b ** 2) ** 0.5
return c
x = better_pythagorean(3, 4)
x
5.0
x + 10
15.0
Returning¶
Once a function executes a return
statement, it stops running.
def motivational(quote):
return 0
print("Here's a motivational quote:", quote)
motivational('Fall seven times and stand up eight.')
0
Applying functions to DataFrames¶
DSC 10 student data¶
The DataFrame roster
contains the names and lecture sections of all students enrolled in DSC 10 this quarter. The first names are real, while the last names have been anonymized for privacy.
roster = bpd.read_csv('data/roster-anon.csv')
roster
name | section | |
---|---|---|
0 | Allie Sazhma | 11AM |
1 | Amina Igxazd | 10AM |
2 | Jazmine Enesxr | 9AM |
... | ... | ... |
219 | Ismayl Gwuiij | 10AM |
220 | Neil Dkaqgm | 10AM |
221 | Maggie Ldfgau | 9AM |
222 rows × 2 columns
Example: Common first names¶
What is the most common first name among DSC 10 students? (Any guesses?)
roster
name | section | |
---|---|---|
0 | Allie Sazhma | 11AM |
1 | Amina Igxazd | 10AM |
2 | Jazmine Enesxr | 9AM |
... | ... | ... |
219 | Ismayl Gwuiij | 10AM |
220 | Neil Dkaqgm | 10AM |
221 | Maggie Ldfgau | 9AM |
222 rows × 2 columns
- Problem: We can't answer that right now, since we don't have a column with first names. If we did, we could group by it.
- Solution: Use our function that extracts first names on every element of the
'name'
column.
Using our first_name
function¶
Somehow, we need to call first_name
on every student's 'name'
.
roster
name | section | |
---|---|---|
0 | Allie Sazhma | 11AM |
1 | Amina Igxazd | 10AM |
2 | Jazmine Enesxr | 9AM |
... | ... | ... |
219 | Ismayl Gwuiij | 10AM |
220 | Neil Dkaqgm | 10AM |
221 | Maggie Ldfgau | 9AM |
222 rows × 2 columns
roster.get('name').iloc[0]
'Allie Sazhma'
first_name(roster.get('name').iloc[0])
'Allie'
first_name(roster.get('name').iloc[1])
'Amina'
Ideally, there's a better solution than doing this hundreds of times...
.apply
¶
- To apply the function
func_name
to every element of column'col'
in DataFramedf
, use
df.get('col').apply(func_name)
- The
.apply
method is a Series method.- Important: We use
.apply
on Series, not DataFrames. - The output of
.apply
is also a Series.
- Important: We use
- Pass just the name of the function – don't call it!
- Good ✅:
.apply(first_name)
. - Bad ❌:
.apply(first_name())
.
- Good ✅:
roster.get('name')
0 Allie Sazhma 1 Amina Igxazd 2 Jazmine Enesxr ... 219 Ismayl Gwuiij 220 Neil Dkaqgm 221 Maggie Ldfgau Name: name, Length: 222, dtype: object
roster.get('name').apply(first_name)
0 Allie 1 Amina 2 Jazmine ... 219 Ismayl 220 Neil 221 Maggie Name: name, Length: 222, dtype: object
Example: Common first names¶
roster = roster.assign(
first=roster.get('name').apply(first_name)
)
roster
name | section | first | |
---|---|---|---|
0 | Allie Sazhma | 11AM | Allie |
1 | Amina Igxazd | 10AM | Amina |
2 | Jazmine Enesxr | 9AM | Jazmine |
... | ... | ... | ... |
219 | Ismayl Gwuiij | 10AM | Ismayl |
220 | Neil Dkaqgm | 10AM | Neil |
221 | Maggie Ldfgau | 9AM | Maggie |
222 rows × 3 columns
Now that we have a column containing first names, we can find the distribution of first names.
name_counts = (
roster
.groupby('first')
.count()
.sort_values('name', ascending=False)
.get(['name'])
)
name_counts
name | |
---|---|
first | |
Kevin | 4 |
Ryan | 4 |
Noah | 3 |
... | ... |
Hongyu | 1 |
Hriday | 1 |
Zixuan | 1 |
200 rows × 1 columns
Activity¶
Below:
- Create a bar chart showing the number of students with each first name, but only include first names shared by at least two students.
- Determine the proportion of students in DSC 10 who have a first name that is shared by at least two students.
Hint: Start by defining a DataFrame with only the names in name_counts
that appeared at least twice. You can use this DataFrame to answer both questions.
✅ Click here to see the solutions after you've tried it yourself.
shared_names = name_counts[name_counts.get('name') >= 2] # Bar chart. shared_names.sort_values('name').plot(kind='barh', y='name'); # Proportion = # students with a shared name / total # of students. shared_names.get('name').sum() / roster.shape[0]
...
Ellipsis
...
Ellipsis
.apply
works with built-in functions, too!¶
name_counts.get('name')
first Kevin 4 Ryan 4 Noah 3 .. Hongyu 1 Hriday 1 Zixuan 1 Name: name, Length: 200, dtype: int64
# Not necessarily meaningful, but doable.
name_counts.get('name').apply(np.log)
first Kevin 1.39 Ryan 1.39 Noah 1.10 ... Hongyu 0.00 Hriday 0.00 Zixuan 0.00 Name: name, Length: 200, dtype: float64
Aside: Resetting the index¶
In name_counts
, first names are stored in the index, which is not a Series. This means we can't use .apply
on it.
name_counts.index
Index(['Kevin', 'Ryan', 'Noah', 'Kristen', 'Jimmy', 'Felix', 'Edward', 'David', 'Olivia', 'Brandon', ... 'Hailey', 'Hannah', 'Haotian', 'Harrison', 'Helen', 'Henry', 'Hongan', 'Hongyu', 'Hriday', 'Zixuan'], dtype='object', name='first', length=200)
name_counts.index.apply(max)
--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) Cell In[51], line 1 ----> 1 name_counts.index.apply(max) AttributeError: 'Index' object has no attribute 'apply'
To help, we can use .reset_index()
to turn the index of a DataFrame into a column, and to reset the index back to the default of 0, 1, 2, 3, and so on.
# What is the max of an individual string?
name_counts.reset_index().get('first').apply(max)
0 v 1 y 2 o .. 197 y 198 y 199 x Name: first, Length: 200, dtype: object
Example: Shared first names and sections¶
- Suppose you're one of the $\approx$17% of students in DSC 10 who has a first name that is shared with at least one other student.
- Let's try and determine whether someone in your lecture section shares the same first name as you.
- For example, maybe
'Olivia Kcjqla'
wants to see if there's another'Olivia'
in their section.
- For example, maybe
Strategy:
- Which section is
'Olivia Kcjqla'
in? - How many people in that section have a first name of
'Olivia'
?
roster
name | section | first | |
---|---|---|---|
0 | Allie Sazhma | 11AM | Allie |
1 | Amina Igxazd | 10AM | Amina |
2 | Jazmine Enesxr | 9AM | Jazmine |
... | ... | ... | ... |
219 | Ismayl Gwuiij | 10AM | Ismayl |
220 | Neil Dkaqgm | 10AM | Neil |
221 | Maggie Ldfgau | 9AM | Maggie |
222 rows × 3 columns
which_section = roster[roster.get('name') == 'Olivia Kcjqla'].get('section').iloc[0]
which_section
'10AM'
first_cond = roster.get('first') == 'Olivia' # A Boolean Series!
section_cond = roster.get('section') == which_section # A Boolean Series!
how_many = roster[first_cond & section_cond].shape[0]
how_many
1
Another function: shared_first_and_section
¶
Let's create a function named shared_first_and_section
. It will take in the full name of a student and return the number of students in their section with the same first name and section (including them).
Note: This is the first function we're writing that involves using a DataFrame within the function – this is fine!
def shared_first_and_section(name):
# First, find the row corresponding to that full name in roster.
# We're assuming that full names are unique.
row = roster[roster.get('name') == name]
# Then, get that student's first name and section.
first = row.get('first').iloc[0]
section = row.get('section').iloc[0]
# Now, find all the students with the same first name and section.
shared_info = roster[(roster.get('first') == first) & (roster.get('section') == section)]
# Return the number of such students.
return shared_info.shape[0]
shared_first_and_section('Olivia Kcjqla')
1
# This means that there is another Jimmy in the same section as Jimmy Xvngxm.
shared_first_and_section('Jimmy Xvngxm')
2
Now, let's add a column to roster
that contains the values returned by shared_first_and_section
.
roster = roster.assign(shared=roster.get('name').apply(shared_first_and_section))
roster
name | section | first | shared | |
---|---|---|---|---|
0 | Allie Sazhma | 11AM | Allie | 1 |
1 | Amina Igxazd | 10AM | Amina | 1 |
2 | Jazmine Enesxr | 9AM | Jazmine | 1 |
... | ... | ... | ... | ... |
219 | Ismayl Gwuiij | 10AM | Ismayl | 1 |
220 | Neil Dkaqgm | 10AM | Neil | 1 |
221 | Maggie Ldfgau | 9AM | Maggie | 1 |
222 rows × 4 columns
Let's find all of the students who are in a section with someone that has the same first name as them.
roster[(roster.get('shared') >= 2)].sort_values('shared', ascending=False)
name | section | first | shared | |
---|---|---|---|---|
192 | Ryan Oogwno | 11AM | Ryan | 4 |
36 | Ryan Mgetat | 11AM | Ryan | 4 |
41 | Ryan Nwdowi | 11AM | Ryan | 4 |
... | ... | ... | ... | ... |
203 | Felix Fnrqck | 10AM | Felix | 2 |
43 | Jimmy Xvngxm | 11AM | Jimmy | 2 |
107 | Noah Llpjpu | 10AM | Noah | 2 |
21 rows × 4 columns
We can narrow this down to a particular lecture section if we'd like.
one_section_only = (
roster[(roster.get('shared') >= 2) &
(roster.get('section') == '10AM')]
.sort_values('shared', ascending=False)
)
one_section_only
name | section | first | shared | |
---|---|---|---|---|
11 | Noah Qodtvo | 10AM | Noah | 2 |
70 | John Ubarsl | 10AM | John | 2 |
74 | John Paqmwc | 10AM | John | 2 |
85 | Felix Pgqrnv | 10AM | Felix | 2 |
107 | Noah Llpjpu | 10AM | Noah | 2 |
203 | Felix Fnrqck | 10AM | Felix | 2 |
For instance, the above DataFrame preview is telling us that there are 2 Noahs, 2 Johns, and 2 Felixes in the 10AM section.
# All of the names shared by multiple students in the 10AM section.
one_section_only.get('first').unique()
array(['Noah', 'John', 'Felix'], dtype=object)
Sneak peek¶
While the DataFrames on the previous slide contain the info we were looking for, they're not organized very conveniently. For instance, there are two rows containing the fact that there are 2 Noahs in the 10AM lecture section.
Wouldn't it be great if we could create a DataFrame like the one below? We'll see how next time!
section | first | name | shared | |
---|---|---|---|---|
0 | 10AM | Noah | 2 | 2 |
1 | 11AM | Jimmy | 2 | 2 |
2 | 10AM | Ismayl | 1 | 1 |
Activity¶
Find the longest first name in the class that is shared by at least two students in the same section.
Hint: You'll have to use both .assign
and .apply
.
✅ Click here to see the answer after you've tried it yourself.
with_len = roster.assign(name_len=roster.get('first').apply(len)) with_len[with_len.get('shared') >= 2].sort_values('name_len', ascending=False).get('first').iloc[0]
...
Ellipsis
Summary, next time¶
Summary¶
- Functions are a way to divide our code into small subparts to prevent us from writing repetitive code.
- The
.apply
method allows us to call a function on every single element of a Series, which usually comes from.get
ting a column of a DataFrame.
Next time¶
More advanced DataFrame manipulations!