Significant Figures Woes

My Laboratory recently needed to produce a report for a new method detection limit (MDL). The MDL is, effectively, the smallest value of an compound that an instrument can reliably distinguish from zero. This is an important value for environmental purposes; we want to be able to demonstrate that we can detect toxic compounds at levels as close to zero as possible.

While preparing our report, we determined that our process was capable of producing MDLs to three significant figures. We took our measurements for two different analytical methods, performed our calculations, and came up with two values:

mdl_1 <- 1.72307562596959 
mdl_2 <- 1.20482925259075

When I generated the report, I chose to use the signif function to reduce those values to just the significant figures. According to the documentation in ?signif, I expected this would “round the values in its first argument to the specified number of significant digits.” Here’s what I got:

signif(mdl_1, 3)
## [1] 1.72
signif(mdl_2, 3)
## [1] 1.2

For mdl_1, I got the three significant figures I was expecting, but I only got two significant figures for mdl_2. That may not seem like a big deal, but 1.2 does not give us the credit our process deserves for being able to detect our compound. signif robbed us of an entire order of magnitude in our precision.

There’s a certain difficulty in reporting trailing zeros, and in all the reading I’ve done on the topic, it really boils down to trailing zeros may or may not be significant, depending on where you learned your significant figures rules. But as a matter of principle, a trailing zero is significant when the measurement process can demonstrate that level of precision.

In the current application, I know that that trailing zero is significant, I just need to tell R to give it back to me somehow.

Generalizing Significant Figures Reporting

We can make reporting significant figures a little more robust with a couple of simple concepts.

First, let’s define the precision of a measurement to be the smallest decimal position in which we have confidence in the measurement. For our purposes, we can say the MDLs are precise to the hundredth place. We can denote precision as an integer, and for convenience, we’ll use the same value we would pass to the round function. For us, the MDLs have precision 2–if we were to use round, we’d use round(mdl_1, 2). Similarly, we can represent precision to the left of the decimal place as negative integers, with precision to the ones place denoted by a 0. (round(x, 0) rounds x to the nearest integer).

Second, we recognize that leading zeros are never significant. This lets us utilize logarithms to determine what the place of the first significant figure is. Importantly, log10 will return a low-ball estimate of the (negative) precision value of the first significant figure. From there, we just need to count out the remaining significant figures.

Let’s walk through this with mdl_1 = 1.7230756.

## [1] 0.2363043

This tells us that the mdl_1 is more than 0 but less than 10. Taking the ceiling of this value gives us a starting point for counting out significant figures. We want three significant figures, so we do

ceiling(log10(mdl_1)) - 3
## [1] -2

Scientifically, this means the terminal digit in our result should be at the 1e-2 place, or the hundredth place. As it turns out, round looks at rounding positions backwards, so we need to multiply this result by -1.

precision <- (ceiling(log10(mdl_1)) - 3) * -1
## [1] 2

At last, we can use this to print out the value we want. We’ll need to use either sprintf or format, because the print method for numeric objects cuts off trailing zeros.

sprintf(paste0("%0.", precision, "f"), mdl_1)
## [1] "1.72"
format(round(mdl_1, precision), nsmall = precision)
## [1] "1.72"
sprintf(paste0("%0.", precision, "f"), mdl_2)
## [1] "1.20"
format(round(mdl_2, precision), nsmall = precision)
## [1] "1.20"

Wrapping It Into a Function

This is easily placed into a function as follows:

sigfig <- function(x, sigfig){
  precision <- (ceiling(log10(x)) - sigfig) * -1
  sprintf(paste0("%0.", precision, "f"), x)

sigfig(mdl_1, 3)
## [1] "1.72"
sigfig(mdl_2, 3)
## [1] "1.20"

This also has the benefit of working with various vectorizations.

sigfig(c(mdl_1, mdl_2), 3)
## [1] "1.72" "1.20"
sigfig(c(mdl_1, mdl_2), 
       sigfig = c(2, 3))
## [1] "1.7"  "1.20"


This process works pretty well for cases where the terminal digit is to the right of the decimal point. It requires a little extra handling to deal with cases where the terminal digit is to the left of the decimal point. This is the kind of thing I really ought to bundle into a package.

There are some other benefits to approaching significant figures like this. Primarily, the work of determining how many significant figures will result from a calculation of measured values can be automated.

One Last Observation

You can trick yourself into believing that signif is doing what you want it to do if you put mdl_1 and mdl_2 into a vector.

signif(c(mdl_1, mdl_2), 3)
## [1] 1.72 1.20

However, the trailing zero is appearing there because the print.numeric method likes to print numeric values with the same number of decimal places. This is why I recommend reporting your significant figures using a character value–it gives you control over the result and avoids unpleasant surprises.