9 Functions
Intended Learning Outcomes
Setup
- Open your
reproresproject - Create a new quarto file called
09-func.qmd - Update the YAML header
- Replace the setup chunk with the one below:
9.1 Custom functions
In addition to the built-in functions and functions you can access from packages, you can also write your own functions (and eventually even packages!).
9.1.1 Structuring a function
The general structure of a function is as follows:
Here is a very simple function. Can you guess what it does?
Let’s make a function that reports p-values in APA format (with “p = [rounded value]” when p >= .001 and “p < .001” when p < .001).
First, we have to name the function. You can name it anything, but try not to duplicate existing functions or you will overwrite them. For example, if you call your function rep, then you will need to use base::rep() to access the normal rep function. Let’s call our p-value function report_p and set up the framework of the function.
9.1.2 Arguments
We need to add one argument, the p-value you want to report. The names you choose for the arguments are private to that argument, so it is not a problem if they conflict with other variables in your script. You put the arguments in the parentheses of function() in the order you want them to default (just like the built-in functions you’ve used before).
9.1.3 Argument defaults
You can add a default value to any argument. If that argument is skipped, then the function uses the default argument. It probably doesn’t make sense to run this function without specifying the p-value, but we can add a second argument called digits that defaults to 3, so we can round p-values to any number of digits.
Now we need to write some code inside the function to process the input arguments and turn them into a returned output. Put the output as the last item in function.
You might also see the returned output inside of the return() function. This does the same thing.
When you run the code defining your function, it doesn’t output anything, but makes a new object in the Environment tab under Functions. Now you can run the function.
9.1.4 Scope
What happens in a function stays in a function. You can change the value of a variable passed to a function, but that won’t change the value of the variable outside of the function, even if that variable has the same name as the one in the function.
9.1.5 Warnings and errors
p? Or if you set p to 1.5 or “a”?
You might want to add a more specific warning and stop running the function code if someone enters a value that isn’t a number. You can do this with the stop() function.
If someone enters a number that isn’t possible for a p-value (0-1), you might want to warn them that this is probably not what they intended, but still continue with the function. You can do this with warning().
report_p <- function(p, digits = 3) {
if (!is.numeric(p)) stop("p must be a number")
if (p <= 0) warning("p-values are normally greater than 0")
if (p >= 1) warning("p-values are normally less than 1")
if (p < .001) {
reported = "p < .001"
} else {
roundp <- round(p, digits)
reported = paste("p =", roundp)
}
reported
}Error in `report_p()`:
! argument "p" is missing, with no default
Error in `report_p()`:
! p must be a number
Warning in report_p(-2): p-values are normally greater than 0
Warning in report_p(2): p-values are normally less than 1
[1] "p < .001"
[1] "p = 2"
9.2 Unit Tests
Unit testing is the practice of checking that your functions provide expected output given specific input. You can do unit testing informally, by running your function in the console and deciding if the output makes sense, or more formally, by recording code that compared the actual and expected output.
Let’s take the example of out report_p function. Let’s check that it gives the expected output of “p = 0.05” when you set p to .05. We’ll use the term object to refer to the output of the function and expected to refer to what the function should produce, as this will map onto the terminology used in more formal unit testing. Here, we can use == to check that they are equivalent.
Now let’s check that you get the expected output for a number that needs to be rounded.
We should check that the digits argument works as expected, too.
You should also check that your function handles incorrect input in the way you want it to. We set the function to give a warning when p is greater than 1 or less than 0, but still return a formatted string.
Warning in report_p(1.5): p-values are normally less than 1
[1] TRUE
9.2.1 Testthat
We can see the warning, but how do we test that it’s what we expect? You can set this up with base R, but at this point it’s easier to move to more formal unit testing with the testthat package. This package provides a lot of function that you can use to test your functions both informally and in package development.
The function expect_equal() is one of the more commonly used functions. The arguments are object and expected (you can probably fuigure out what to put here).
This function does nothing overt when the test passes, but throws an error if the test doesn’t pass, including some explanation of the differences. When you run this in a development environment for creating an R package, it can also provide a summary of the success of all your tests.
Error:
! Expected `object` to equal `expected`.
Differences:
1/1 mismatches
x[1]: "p = 0.05"
y[1]: "p = 0.5"
If you want to check for a specific warning or error, wrap the code in expect_warning() or expect_error().
If your code does produce a warning, now you won’t see anything. If the code doesn’t produce an expected warning, you will get an error report.
Error:
! Expected `object <- report_p(0.05)` to produce warnings.
You can check that the warning is what you expect by setting the second argument to the text. This value is expected to be a regular expression, so if it’s just the literal exact text, set fixed = TRUE.
9.2.2 Test Driven Development
How should your function handle different kinds of input? What if p is a vector of numbers?
Error in `if (p <= 0) ...`:
! the condition has length > 1
This gives an error, so we need to fix the function. Test driven development is a coding philosophy where all development of a function is preceeded by writing a failing unit test. Here, we want the report_p() function to return a vector of formatted strings. So we should write a test that would pass if we achieved this goal.
Error in `if (p <= 0) ...`:
! the condition has length > 1
Error:
! Expected `object` to equal `expected`.
Differences:
Lengths differ: 1 is not 3
Now, we have a very clear goal and way to know when we’ve achieved it. (And if, like me, you tend to go down rabbit holes and get distracted when you code, the failing test will remind you to come back to this.)
Let’s vectorise this function to let it handle input with a length greater than 1. This uses some iteration.
report_p <- function(p, digits = 3) {
if (length(p) > 1) {
reported_vec <- map_chr(p, report_p)
return(reported_vec)
}
if (!is.numeric(p)) stop("p must be a number")
if (p <= 0) warning("p-values are normally greater than 0")
if (p >= 1) warning("p-values are normally less than 1")
if (p < .001) {
reported = "p < .001"
} else {
roundp <- round(p, digits)
reported = paste("p =", roundp)
}
reported
}Now, let’s rerun the test code that failed before.
9.2.3 Deciding what to test
How do you decide what to test? Do you need to test that you get the correct output for p = 0.05 and p = 0.1 and p = 0.15 and p = 0.20 and p = 0.25, ad infinitum? Of course not! But it’s difficult to give you a specific rule; this requires judgement, experience, and knowledge about what you want the function to do.
In general, I try to cover the following categories in my tests:
- Typical, simple input
- Input that should raise a warning
- Input that should raise an error
-
NULL,NAor vector of length 0 input is handled as expected - More complicated input
- Change argument defaults (do each argument separately)
- Combinations of arguments (if they might interact)
Make some more unit tests for report_p(). What happens if p is NULL, NA or c()?
9.3 Iterating your own functions
9.3.1 Build code
First, let’s build up the code that we want to iterate.
9.3.1.1 Simulate and structure data
Create a vector of 20 random numbers drawn from a normal distribution with a mean of 5 and standard deviation of 1 using the rnorm() function and store them in the variable A.
A tibble is a type of table or data.frame. The function tibble::tibble() creates a tibble with a column for each argument. Each argument takes the form column_name = data_vector.
Create a table called dat including two vectors: A that is a vector of 20 random normally distributed numbers with a mean of 5 and SD of 1, and B that is a vector of 20 random normally distributed numbers with a mean of 5.5 and SD of 1.
9.3.1.2 Statistical test
You can run a Welch two-sample t-test by including the two samples you made as the first two arguments to the function t.test. You can reference one column of a table by its names using the format table_name$column_name
Welch Two Sample t-test
data: dat$A and dat$B
t = -1.4715, df = 37.977, p-value = 0.1494
alternative hypothesis: true difference in means is not equal to 0
95 percent confidence interval:
-1.1408729 0.1804332
sample estimates:
mean of x mean of y
4.829928 5.310148
You can also convert the table to long format using the gather function and specify the t-test using the format dv_column~grouping_column.
Welch Two Sample t-test
data: score by group
t = -1.4715, df = 37.977, p-value = 0.1494
alternative hypothesis: true difference in means between group A and group B is not equal to 0
95 percent confidence interval:
-1.1408729 0.1804332
sample estimates:
mean in group A mean in group B
4.829928 5.310148
9.3.1.3 Tidy output
You can use the function broom::tidy() to extract the data from a statistical test in a table format. The example below pipes everything together.
| estimate | estimate1 | estimate2 | statistic | p.value | parameter | conf.low | conf.high | method | alternative |
|---|---|---|---|---|---|---|---|---|---|
| -0.7629601 | 4.725315 | 5.488276 | -2.625878 | 0.0130202 | 32.86047 | -1.354193 | -0.1717276 | Welch Two Sample t-test | two.sided |
In the pipeline above, t.test(score~group, data = _) uses the _ notation to change the location of the piped-in data table from it’s default position as the first argument to a different position.
9.3.1.4 Extract important values
Finally, we can extract a single value from this results table using pull().
9.3.2 Custom function
Next, we can group the code above inside a function.
First, name your function t_sim and wrap the code above in a function with no arguments.
Run it a few times to see what happens.
9.3.2.1 Iterate
Let’s run the t_sim function 1000 times, assign the resulting p-values to a vector called reps, and check what proportion of p-values are lower than alpha (e.g., .05). This number is the power for this analysis.
9.3.2.2 Set seed
You can use the set.seed function before you run a function that uses random numbers to make sure that you get the same random data back each time. You can use any integer you like as the seed.
Make sure you don’t ever use set.seed() inside of a simulation function, or you will just simulate the exact same data over and over again.
9.3.2.3 Add arguments
You can just edit your function each time you want to calculate power for a different sample n, but it is more efficient to build this into your function as an arguments. Redefine t_sim, setting arguments for the mean and SD of group A, the mean and SD of group B, and the number of subjects per group. Give them all default values.
9.3.3 Test your function
Test your function with some different values to see if the results make sense.
Use replicate to calculate power for 100 subjects/group with an effect size of 0.2 (e.g., A: m = 0, SD = 1; B: m = 0.2, SD = 1). Use 1000 replications.
Compare this to power calculated from the power.t.test function.
Two-sample t test power calculation
n = 100
delta = 0.2
sd = 1
sig.level = 0.05
power = 0.2902664
alternative = two.sided
NOTE: n is number in *each* group
Calculate power via simulation and power.t.test for the following tests:
- 20 subjects/group, A: m = 0, SD = 1; B: m = 0.2, SD = 1
- 40 subjects/group, A: m = 0, SD = 1; B: m = 0.2, SD = 1
- 20 subjects/group, A: m = 10, SD = 1; B: m = 12, SD = 1.5
9.4 Exercises
Create a custom function that takes a string of text as input and returns that text in title case (with the first letter of each word uppercase)
Does this function work if you give it a vector or 3 strings? How about if you give it a list of 3 strings? What happens if you give it a non-character data type, like numbers?
Add error checking and appropriate messages for when the user gives this function inappropriate input.
Create some tests for this function to make sure the output is as expected. What kinds of difficult edge cases can you think of?
Glossary
| term | definition |
|---|---|
| argument | A variable that provides input to a function. |
Further Resources
- Chapters 19 of R for Data Science
- Unit tests in Intro to R Packages.