So far, we have primarily used functions that are already built into
R or provided by packages. In this lab, we will learn how
to create functions of our own by combining operations and functions
that we already know. Writing our own functions allows us to turn code
that accomplishes a particular task into a reusable tool that can be
applied to different scenarios.
Much of this comes from the R for Data Science online texts online text https://r4ds.had.co.nz/iteration.html, https://r4ds.hadley.nz/iteration.html, conversations with CS faculty (Prof. Will Rebelsky), and experience (i.e. learn from my mistakes).
\(~\)
\(~\)
%in%: is useful for determining wehter values of x are
also in y. For example c(2,3,4) %in% c(2,5,6) would return
TRUE FALSE FALSE. Sometimes it is useful to use the initial vectors and
only return those values:
x=c(2,3,4)
y=c(2,5,6)
x%in%y #booleans
## [1] TRUE FALSE FALSE
x[x%in%y] #boolean indexing
## [1] 2
which(boolean): returns the indices where the boolean
(T/F) is true
which(x%in%y)
## [1] 1
When creating a function, it is generally easiest to start with code that works for a specific example and then generalize it. Before writing the function, we should think about two things: what information does the function need as input, and what do we want it to return as an output?
A function consists of a few basic pieces:
The general format we will use is:
function_name = function(arguments){
# Body of the function
return(output)
}
The arguments act as placeholders for the values that will be
supplied when we use the function. The body contains the operations we
want to perform, and return() specifies the output that we
want to get back from the function.
Suppose that we frequently work with temperatures recorded in Fahrenheit, but want to report the average temperature in Celsius. Before worrying about creating a function, let’s first write code that accomplishes this for one particular set of temperatures.
temperatures = c(45, 63, 71, 82, 95)
temperatures_c = (temperatures - 32) * 5/9
mean(temperatures_c)
## [1] 21.77778
This code works, but it is specific to the vector
temperatures. If we wanted to perform the same calculation
on several different sets of temperatures, we would have to repeatedly
change temperatures to the new vector and rerun the
code.
Instead, we can generalize this code into a function. To do this, we first need to identify our input and output.
Our input is the vector of Fahrenheit temperatures. This is the information that will change each time we use the function, so it should become an argument. Our output is the average temperature in Celsius.
We can now place our working code inside a function:
mean_celsius = function(temperatures){
temperatures_c = (temperatures - 32) * 5/9
mean_temperature = mean(temperatures_c)
return(mean_temperature)
}
The object temperatures inside the function is now an
argument rather than one specific vector. When we call the function,
R substitutes the value we provide for that argument and
runs the code in the body.
old_temp_values = c(45, 63, 71, 82)
mean_celsius(old_temp_values)
## [1] 18.47222
We can now reuse the same function with completely different inputs
without rewriting the calculation, either defined as new vectors or just
written using c() notation:
mean_celsius(c(90, 95, 100)) #input a new vector argument
## [1] 35
mean_celsius(32) #also works on a numeric input (length-1 vector)
## [1] 0
This is the basic process we will generally use when creating functions:
Once we have a working function, there are a few practices that make them easier to understand, reuse, and debug.
Use informative names. Function and object names
should describe what they represent or do. Function names should also
follow a consistent convention, such as function_name,
FunctionName, functionName or
function.name. Be careful not to overwrite existing
R functions; for example, do not create a new function
named mean or sum.
Example: This code is not very informative as to what the function is trying to accomplish when compared to how we originally wrote it.
f = function(x){
y = (x - 32) * 5/9
z = mean(y)
return(z) }
Avoid unnecessary output. Intermediate objects can make complicated code easier to understand, but we generally do not need to print every intermediate result. The function should return the result that we actually want to use. Here is a bad example that gives us too much output that might reflect what you would do when test-writing a function.
mean_celsius_bad = function(temperatures){
print(temperatures)
temperatures_c = (temperatures - 32) * 5/9
print(temperatures_c)
mean_temperature = mean(temperatures_c)
return(mean_temperature)
}
mean_celsius_bad(c(40, 53, 65, 87))
## [1] 40 53 65 87
## [1] 4.444444 11.666667 18.333333 30.555556
## [1] 16.25
Use comments when they make the code easier to understand. In particular, it can be useful to document what inputs a function expects, what it returns, and what less-obvious lines of code are intended to accomplish.
Test your function. A function that works on the example used to create it may not necessarily work on other reasonable inputs. In this class, informal testing will normally be sufficient. Try the function on several different inputs and check whether the results are what you expect.
For example, we might test mean_celsius() on several different vectors:
mean_celsius(c(32, 32, 32)) # Expected: 0
## [1] 0
mean_celsius(c(212)) # Expected: 100
## [1] 100
mean_celsius(c(32, 50, 68)) # Expected: 10
## [1] 10
In larger programming projects, functions are often checked using formal test suites that include many different inputs and potential edge cases. We will generally not need that level of testing in this course.
Now that we have seen how a function is constructed, we will practice writing functions of our own. Most functions that we write will combine operations and built-in functions that we already know. To start, think about what the inputs and outputs should be.
We will use the term vector function for a function that accepts a vector (or set of vectors) as an input. Depending on what the function is intended to accomplish, its output might be another vector or a single value.
Two useful types of vector functions are mutate functions and summarize functions:
mutate in
dplyr.)summarize in dplyr.)For now, these names are useful for thinking about what kind of object a function takes as its input and what kind of object it returns.
We will start with a simple vector function to practice the syntax.
Question #1:
square_plus_five() that takes a numeric vector as its
argument. For each element, square the value and then add 5. Your
function should return the resulting vector.An example input and expected output are shown below:
## [1] 1.0 2.0 3.0 4.0 5.5
## [1] 6.00 9.00 14.00 21.00 35.25
square_plus_five() a mutate
function or a summarize function? How can you tell from its output?Suppose we have the following vector of exam scores:
exam_scores = c(72, 81, 65, 90, 84, 77)
We can calculate the z-score of every observation using functions that we have already seen:
(exam_scores - mean(exam_scores)) / sd(exam_scores)
## [1] -0.6939506 0.3188422 -1.4816784 1.3316350 0.6564398 -0.1312880
Notice that this code contains several operations, including calls to
the functions mean() and sd(). Functions that
we write ourselves will often make use of other functions inside their
body.
We expect to calculate z-scores several times throughout this course. Rather than rewriting this calculation each time, it makes sense to turn it into a function.
Question #2:
A: Convert the code above into a function named
zScore(). Your function should accept a numeric vector as
its argument and return the z-score corresponding to each
observation.
B: Test your function using
exam_scores. Then test it on a second numeric vector of
your choice.
Sometimes we do not want a value corresponding to every observation. Instead, we want to use the observations in a vector to calculate a single value.
For example, consider the following function:
squaredError = function(x) {
return(sum((x - mean(x))^2, na.rm = TRUE))
}
This function combines several familiar operations. When functions are used inside other functions, it can be helpful to work from the inside out to understand what the code is doing.
In squaredError():
mean(x) calculates the mean of the vector.x - mean(x) calculates each observation’s deviation
from the mean.(x - mean(x))^2 squares each of those deviations.sum() adds the squared deviations together.Although several values are calculated along the way,
squaredError() ultimately returns one
value. We would therefore describe this as a summarize
function.
Functions can also contain logical operations, indexing, and calls to several other functions. For example, recall some of the tools reviewed at the beginning of this lab:
is.na(x)
which(is.na(x))
x %in% y
x[x %in% y]
These operations can be combined inside a function just like the arithmetic operations used in our previous examples.
Question #3:
bothNA()
that takes two vectors of the same length and returns the
positions that contain an NA in both
vectors.You may find some of is.na(), which(),
length(), and logical indexing useful. Remember that the
output of one function can be used as the input to another function.
x = as.numeric(c(1, "a", 3, "four", 5, "six", 7, 6))
x2 = as.numeric(c(3, 7, "four", 2, 5, "six", 1, 2))
bothNA(x, x2)
count_bothNA() that counts the number of positions
that contain NA in both variables using the
bothNA() function from Part B. Verify it
works on vectors x and x2.We have now seen several examples of something called nesting function calls, where the output of one function is used directly as the input to another.
For example, recall that table() returns the frequency
of each unique value in a vector:
animals = c("cat", "dog", "cat", "bird", "dog", "cat")
table(animals)
## animals
## bird cat dog
## 1 3 2
Question #4
A: Create a function named
top_cat() that takes a categorical vector as its argument
and returns the frequency of the most common category. You can
accomplish this using the table() and max()
functions.
B: Test your function using animals. The correct output should be 3.
C: Load the colleges data set below
and use top_cat() to find the number of times the most
common Region shows up. (Answer should be 400)
colleges = read.csv("https://remiller1450.github.io/data/Colleges2019.csv")
Before moving on to a few additional topics, we will start with some brief practice writing functions.
Recall the general format we have been using:
function_name = function(arguments){
# Body
return(output)
}
Question #5A: Write a function named
add_ten() that takes a numeric vector x and
adds 10 to every value. Test your function on the vector below.
review_x = c(4, 8, 15, 16, 23, 42)
Question #5B: Write a function named
range_size() that takes a numeric vector x and
returns the difference between its maximum and minimum values. Test your
function using review_x.
So far, many of the functions we have written have taken a single vector as their input. However, functions can accept as many arguments as necessary to perform their task.
For example, suppose we want to count how many values in a vector are greater than some cutoff. For a particular example, we could write:
x = c(4, 8, 15, 16, 23, 42)
cutoff = 15
sum(x > cutoff)
## [1] 3
If we want to turn this into a reusable function, both x
and cutoff need to be arguments because we may want to
change either one:
count_above = function(x, cutoff){
return(sum(x > cutoff))
}
We can now use different combinations of inputs:
count_above(x = review_x, cutoff = 15)
## [1] 3
count_above(x = review_x, cutoff = 20)
## [1] 2
count_above(x = c(1, 5, 10, 20), cutoff = 7)
## [1] 2
The arguments serve somewhat different purposes. x
provides the data that the function operates on, while
cutoff controls how the function operates on those
data.
Just like the built-in functions we have used, arguments can be supplied by name or by their position:
count_above(x = review_x, cutoff = 15)
## [1] 3
count_above(review_x, 15)
## [1] 3
count_above(15, review_x) # this treats 15 as the vector and review_x as our cutoff - BAD!
## [1] 2
Question #6: Write a function named
between() that takes three arguments: a numeric vector
x, a lower value low, and an upper value
high. The function should return the values of
x that are greater than low and less than
high.
Test your function using review_x, with
low = 10 and high = 30.
Sometimes an argument has a value that we expect to use most of the time. Rather than requiring the user to provide that value every time the function is called, we can give the argument a default value.
Consider the count_above() function:
count_above = function(x, cutoff){
return(sum(x > cutoff))
}
Suppose that we usually want to count values greater than zero. We
can specify 0 as the default value of cutoff
when we define the function:
count_above = function(x, cutoff = 0){
return(sum(x > cutoff))
}
Now consider:
example_values = c(-5, -2, 3, 8, 12)
count_above(example_values)
## [1] 3
count_above(example_values, cutoff = 5)
## [1] 2
If we do not provide cutoff, R uses the
default value of 0. If we provide our own value, the value
we supply replaces the default.
We have already used many built-in functions with default arguments. For example:
mean(example_values)
## [1] 3.2
works without specifying every argument of mean(). We
only need to specify an optional argument when we want behavior that
differs from its default.
Question #7: The function below calculates the mean of a vector:
my_mean = function(x){
return(mean(x))
}
Modify my_mean() so that it has a second argument named
remove_na. Give remove_na a default value of
TRUE, and pass this argument to the na.rm
argument of mean().
Test your function on the following vector both with and without using the default value.
missing_values = c(3, 7, NA, 10, 12)
Question #8: Modify the zScore()
function you created in Question #2 so that it includes
arguments for the mean and standard deviation used in the z-score
calculation. By default, the function should use the sample mean and
sample standard deviation of x, but the user should be able
to specify different values for either argument. Test your function both
using the defaults and using a specified mean and standard
deviation.
So far, we have mostly focused on the inputs and outputs of functions. However, functions can also create objects internally while performing their calculations.
Consider the following function:
square_difference = function(x, y){
difference = x - y
squared = difference^2
return(squared)
}
When we use the function:
square_difference(10, 4)
## [1] 36
R temporarily creates the objects x,
y, difference, and squared while
the function is running. These objects exist in the function’s
local environment.
Try running:
difference
squared
Assuming we have not previously created objects with these names,
R will tell us that they cannot be found. Objects created
inside a function do not automatically become objects in our normal, or
global, environment.
This is useful because we can create intermediate objects that make our functions easier to write and understand without filling our global environment with objects that we only needed temporarily.
Objects inside and outside a function can even have the same name without necessarily referring to the same object.
For example:
number = 10
add_five = function(x){
number = x + 5
return(number)
}
add_five(20)
## [1] 25
number
## [1] 10
The function returns 25, but the original value of
number is still 10. The assignment:
number = x + 5
creates a local object named number while the function
is running. It does not overwrite the number that already
exists in our global environment.
If we want to keep the result of a function, we can return it and then assign that output to an object:
my_result = square_difference(10, 4)
my_result
## [1] 36
Here, difference and squared were created
locally inside the function, but the value returned by the function was
assigned to my_result in the global environment.
Question #9:
a, b, and
result. Then run the code and check your predictions.a = 5
b = 10
math_function = function(a){
b = a^2
result = b + 3
return(result)
}
result = math_function(4)
a
b
result
a
and b in the global environment were not changed by the
objects created inside math_function().One of the advantages of user-defined functions is that we can reuse them in future projects. However, copying and pasting our function definitions into every new R Markdown file defeats some of the purpose of creating reusable functions.
A useful approach is to save functions that we expect to reuse in an
R script and then import them using
source().
The functions we will use for this example are available here. Download this file and save it in the same location as your R Markdown file. (If you haven’t already done so, now is a good time to make a STA230 folder on your computer)
If the file is in your current working directory, we can run all of the R code contained in the file using:
source("Week2functions.R")
If the file is stored somewhere else, you will need to provide the appropriate file path.
Question #10: Import Week2functions.R
using source(). Demonstrate that you were successful by
running the following:
exampleFunction1(Week2functionsTest)
exampleFunction2(Week2functionsTest)
Question #11: Investigate the
Week2Functions file. In your own words, describe what
exampleFunction1() and exampleFunction2()
do.
\(~\)
Functions are particularly useful when we want to perform the same operation repeatedly on different inputs. In the next lab, we will introduce iteration (also called loops), which provides ways to systematically repeat operations without manually running the same code over and over.