Errors and Debugging in RStudio

[This article was first published on Rstats on pi: predict/infer, and kindly contributed to R-bloggers]. (You can report issue about the content on this page here)
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.

Diagnosing and fixing errors in your code can be time-consuming and frustrating. There are two ways you can make your life easier. The first is knowing the tools at your disposal in RStudio to debug errors. RStudio provides a variety of tools to help you diagnose the problem at its source and come up with a solution as quick as possible. The second is knowing how to write functions that return clear yet detailed errors using condition handling. This post will walk through both of these topics so that you can become better at handling errors when writing your own code as well as working with errors in someone else’s code.

Debugging Errors

The following general strategy can be applied to debug an error, as outlined by Hadley Wickham in his Advanced R book:

  1. Google
    • Many times it is a common error with a known solution
  2. Make it repeatable
    • Create a minimal, reproducible example (e.g. reprex) using simple data
    • Note which inputs don’t trigger the error
    • If not already done, write simple tests to reduce chances of creating a new bug
  3. Figure out where the error is
    • Use the “scientific method”
    • Hypothesize, test with experiments, and record results
    • If needed, ask someone else for a second pair of eyes to review
  4. Fix it and test it

These four steps should be followed each time you encounter an unexpected error in a function. Many times, you may not even know what line of code the error is coming from. How can you determine where the code is not behaving? You can follow these general steps to answer this question:

  1. Begin running the code.
  2. Stop the code where you suspect the bug/problem is arising.
  3. Look and/or walk through the code, step-by-step at that point.

This can be done ad-hoc in a separate R script containing the function code, or using several built-in tools in RStudio, including the traceback function and debug mode.

Let’s look at an example function to demonstrate the use of these tools. We’ll create a simple data set with three binary variables, treatment, gender, and outcome. The chifishr::chi_fisher_p function is a simple function that calculates a p-value from either a Chi-squared or Fisher Exact test, depending on if a warning is thrown from the Chi-squared test due to small expected counts leading to poor p-value approximations.

treatment <- tibble::tibble(
  treatment = c(rep("old", 50), rep("new", 50)),
  gender    = c(rep("male", 30), rep("female", 20),
                rep("male", 20), rep("female", 30)),
  outcome   = c(rep("failure", 95), rep("success", 5))
)

# devtools::install_git("https://gitlab.com/scheidec/chifishr")
library(chifishr)

# warning is present, Fisher p-value is returned
chi_fisher_p(treatment, "outcome", "treatment")
## [1] 0.05628449
# no warning is present, Chi-squared p-value returned
chi_fisher_p(treatment, "gender", "treatment")
## [1] 0.07186064

Let’s take a closer look at the code within the chi_fisher_p function to see what is happening:

chi_fisher_p
## function (tbl, var, treatment) 
## {
##     chisq_wrapper <- function(tbl, var, treatment) {
##         var <- tbl %>% dplyr::pull(var) %>% as.factor()
##         treatment <- tbl %>% dplyr::pull(treatment) %>% as.factor()
##         p <- stats::chisq.test(var, treatment)$p.value
##         return(p)
##     }
##     fisher_wrapper <- function(tbl, var, treatment) {
##         var <- tbl %>% dplyr::pull(var) %>% as.factor()
##         treatment <- tbl %>% dplyr::pull(treatment) %>% as.factor()
##         p <- stats::fisher.test(var, treatment)$p.value
##         return(p)
##     }
##     chisq_wrapper <- purrr::quietly(chisq_wrapper)
##     chisq <- chisq_wrapper(tbl, var, treatment)
##     if (length(chisq$warnings) == 0) {
##         return(chisq$result)
##     }
##     else {
##         return(fisher_wrapper(tbl, var, treatment))
##     }
## }
## <bytecode: 0x7fca18277ff0>
## <environment: namespace:chifishr>

First, there are two internal functions defined, chisq_wrapper and fisher_wrapper. These functions pull and store the specified variables from the input tbl as vectors. The chisq.test and fisher.test functions, respectively, are then performed on those vectors and only the numeric p.value result is returned.

The next line wraps the chisq_wrapper function in purrr::quietly, which captures the side effects of a function. Now, when chisq_wrapper is called, it will return a list with components result, output, messages and warnings. This allows the function to check if a warning is present when the Chi-squared test is performed, and return either the Chi-squared test p-value or the Fisher Exact test p-value from the subsequent if-else block.

If we pass in a variable that is not in the input data set, we would expect an error to be thrown:

When an error occurs, the interactive “Show Traceback” feature in RStudio (button shown above) or the traceback() function can be very helpful to debug the source of the error. Both options show the call stack that the code runs through before producing the error returned to the user. In many cases there are multiple nested functions that the code uses underneath the top-level function called.

Working from the bottom to the top, we see in the traceback() output call stack that an internal function, chisq_wrapper() is called within chi_fisher_p(). Since the chisq_wrapper() function was wrapped in purrr::quietly(), the next three functions called, capture_output(), withCallingHandlers(), and .f(...) are internal functions within purrr::quietly() that are used to capture the warnings and messages output from the chisq_wrapper() function.

In line 6 of the traceback, we see the code executes line #5 of the chi_fisher_p() function, which is the first line of the chisq_wrapper() function, where it pulls the var variable from the input tbl. This is where the source of the error is. Lines 7-18 are all function calls that are resulting from the dplyr::pull() function and the fact that we are using the pipe, %>% to pass in the inputs to that function. We see in line 17 that the function is trying to pull a variable from the input data frame, but the string passed in does not match a variable name in that data set, which causes the function to abort and return an error.

An alternative to traceback(), rlang::last_trace() is ordered in the opposite way and shows the hierarchical structure of the call stack:

The output is mostly similar to traceback(), but now it is clearer which packages are calling which function since the package prefixes are shown. It is also much easier to see how the nested functions relate to each other. Notice that although the chisq_wrapper function is not technically a function inside the purrr package, it is evaluated internally (with three colons, :::) from that package due to it being wrapped in purrr::quietly().

Interactive Debugger

If the location of the error from traceback is not enough, try the interactive debugger, which pauses execution of the function and allows you to interactively explore its state. To enter the interactive debugger, you can either use RStudio’s “Rerun with Debug” tool, or use the debug() or debugonce() functions. The “Rerun with Debug” button pops up when an error occurs, below the “Show Traceback” button shown above in our example error.

The debugger puts you in an interactive environment inside the function where you can run code to explore the current state with the inputs used. You’re in the interactive debugger when you get the special prompt: Browse[1]>. Here, we will pass the debugonce() function to open the interactive debugger only the next time that the chi_fisher_p function returns an error and rerun the code that threw the error.

The code for the function you are debugging will open in a new editor window:

Objects in the current environment are now the only objects shown in the Environment pane:

In debug mode, a toolbar containing special commands pops up in the console which can be used in addition to running regular R code:

  • Next: executes the next line in the function
  • Step into: works like Next, but if the next step is a function, it will step into that function to be explored interactively.
  • Finish: finishes execution of the current loop or function
  • Continue: leaves interactive debugger and continues regular execution of the function. This is useful if you’ve fixed the bad state and want to check that the function correctly executes the remaining code
  • Stop: stops debugger, terminates the function and returns to the global workspace

In this example, we we can use the Next button to step through each line of code within the function and find that the function errors out at the line that calls the chisq_wrapper function, as we saw before in the traceback call. We could Step into that intermediate function and follow through to each consecutive call that the traceback output returned to find the source of the top-level error.

A function may also generate an unexpected warning or message. To diagnose where these are thrown, the easiest way is to convert them to errors using options(warn = 2) for warnings or rlang::with_abort(function, "message") for messages and then use the debugging tools described above.

Condition Handling

Now that we know how to locate and fix problems in R code, we should know the recommended ways of writing functions to communicate problems to other users as clearly as possible. This is the job of conditions. There are three types of conditions:

  • Errors - raised by stop() or rlang::abort(), force all execution to terminate
  • Warnings - raised by warning() or rlang::warn(), display potential problems
  • Messages - raised by message() or rlang::inform(), give informative output

Consider an example function, is_prime() that checks if a number input is a prime number, but raises an error when a negative number is input:

is_prime <- function(n) {
  if((n) < 0) {
    stop("n must be positive")
  } else {
    n == 2L || all(n %% 2L:max(2,floor(sqrt(n))) != 0)
  }
}
is_prime(-3)
## Error in is_prime(-3): n must be positive

This is a straightforward error, but it could be more detailed, specifically stating what the value of n passed in was.

tryCatch is a way to inspect condition objects and control what happens when a condition is signaled. Let’s define an error handler to decide what happens when is_prime() fails.

prime_cnd <- tryCatch(error = function(cnd) cnd, is_prime(-1))
str(prime_cnd)
## List of 2
##  $ message: chr "n must be positive"
##  $ call   : language is_prime(-1)
##  - attr(*, "class")= chr [1:3] "simpleError" "error" "condition"

The resulting object from tryCatch() is a list that contains the condition/error returned as well as the function call that raised the error. We can see that the number passed in, -1, is saved in the call element of the list. This is useful information that can help us in writing a more informative error message.

Conditions with a chain of functions

In practice, we usually write functions that call other functions, and it can get confusing if there isn’t an easy way to find the source of the error in nested functions. Let’s define two functions as an example. The first, sim_value will simulate random input values from a normal(0, 1) distribution, but will raise an error if the returned value is negative. The second function, sqrt_value will call sim_value and take the square root of the simulated value.

set.seed(9)

sim_value <- function(){
  val <- rnorm(n = 1, mean = 0, sd = 1)
  if (val < 0){
    stop("Value returned is negative")
  } else {
    val
  }
}

sqrt_value <- function(){
  x <- sim_value()
  sqrt(x)
}

If val is negative in sim_value() the same error is thrown in both functions.

sim_value()
## Error in sim_value(): Value returned is negative
sqrt_value()
## Error in sim_value(): Value returned is negative

Note that the condition returned shows no info about the value of val that caused the error. How can we write more detailed messages when this error is thrown?

Conditions in rlang

rlang’s condition functions make it very easy to add the type of custom metadata we want returned in our conditions. To show this, let’s modify sim_value() to use rlang::abort() instead of stop().

sim_value <- function(){
  val <- rnorm(n = 1, mean = 0, sd = 1)
  if (val < 0){
    rlang::abort(message = "Value returned is negative", 
                 .subclass ="sim_value_error", 
                 val = val)
  } else {
    val
  }
}

Note there are three arguments passed to rlang::abort():

  • message: the error message which is similar to the one passed to stop() in the previous example.
  • .subclass: a subclass of the condition to differentiate errors.
  • val: the particular value that caused the error.

Now use tryCatch() again to inspect the custom condition:

# define an error handler to return the custom error object 
custom_cnd <- tryCatch(error = function(cnd) cnd, sim_value())
# inspect custom_cnd
str(custom_cnd, max.level = 1)
## List of 4
##  $ message: chr "Value returned is negative"
##  $ trace  :List of 4
##   ..- attr(*, "class")= chr "rlang_trace"
##  $ parent : NULL
##  $ val    : num -0.142
##  - attr(*, "class")= chr [1:4] "sim_value_error" "rlang_error" "error" "condition"

We see the metadata value of val as well as the subclass is captured in the function that uses rlang::abort().

Now we can use this to get a more precise message when we call sqrt_value() which calls this function, something like this:

“Can’t calculate value because sim_value() raised an error as val was negative (-1.25)”

We can define an error handler sim_value_handler() to access the values returned in the custom error object thrown by sim_value() and then return a message based on these values.

sim_value_handler <- function(cnd) {
  msg <- "Can't calculate value"
  if (inherits(cnd, "sim_value_error")) {
    msg <- paste0(msg, " as `val` passed to `sim_value()` equals (", cnd$val,")")
  }
  rlang::abort(msg, "sim_value_error")
}

Now if we use sim_value_handler with sim_value() inside sqrt_value(), we can see an example of the modified error message including the value of val that caused the error:

sqrt_value <- function(){
  x <- tryCatch(error = sim_value_handler, sim_value())
  sqrt(x)
}

This simple example shows the several advantages to using rlang instead of base functions for condition handling: the ability to store metadata which can be examined by handler, unhandled errors are automatically saved by abort(), and more detailed messages can be output to the end user.

To leave a comment for the author, please follow the link and comment on their blog: Rstats on pi: predict/infer.

R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.

Never miss an update!
Subscribe to R-bloggers to receive
e-mails with the latest R posts.
(You will not see this message again.)

Click here to close (This popup will not appear again)