Monday, May 23, 2011

Rounding to significant digits in Ruby

A few years ago, I built a Rails application which estimated the nutritional information of entire recipes by mapping the individual ingredients to food records in the USDA nutritional database, then summing up the nutritional values across all the foods.

This is a surprisingly tricky task. In many cases there's a lot of hand-waving involved. How much meat do you get from an 8 pound turkey? How do you calculate the amount of food consumed versus the amount measured before cooking? What if the USDA only measured the nutrients for a raw sample, but you eat it cooked?

Clearly, many of these nutritional values are going to be ballpark figures, so it wouldn't make sense to display them out to the tenth decimal place. But that's what you'd get by default after doing floating-point multiplication and division: A lot of spurious digits.

Why not just round everything to, say, two decimal places, and display that? As an example, consider that one set of nutritional info may work out to contain 548.667 calories and 0.384503 mg of vitamin B6. A fractional calorie is just noise; nobody is going to worry about whether their lunch has an extra 2/3 of a calorie, even if the number is accurate. But 0.38 mg of B6 is about a quarter of the daily allowance; we can't afford to ignore those fractional milligrams.

This is where significant figures AKA significant digits come in. I only want to preserve the digits furthest to the left -- the most significant -- regardless of the location of the decimal point. I searched for a way to do this in Ruby, but rather than finding a solution, I found that many people confuse rounding to N significant digits with rounding to N decimal places. These are not the same thing.

548.667 rounded to 2 decimal places is 548.67
548.667 rounded to 2 significant digits is 550.0

So how can this be done without converting the number to a string and scanning it? Scientific notation. This notation expresses a number in terms of a fixed-point number and an exponent, and is actually similar to the way floating point numbers are represented internally. sprintf can write this format, and to_f can read it. Because normalized scientific notation always places exactly one digit to the left of the decimal point, specifying a precision of N-1 in sprintf's format string will round a number to N significant digits. A format of "%.1e" will give us 2 significant digits.

irb(main):001:0> a = 548.667
=> 548.667
irb(main):002:0> b = sprintf("%.1e", a)
=> "5.5e+02"
irb(main):003:0> b.class
=> String
irb(main):004:0> c = b.to_f
=> 550.0
This does what I want it to, so why not add a method to Float?

irb(main):006:0* class Float
irb(main):007:1>   def sigfig(digits)
irb(main):008:2>     sprintf("%.#{digits - 1}e", self).to_f
irb(main):009:2>   end
irb(main):010:1> end
=> nil
irb(main):011:0> a.sigfig(2)
=> 550.0
irb(main):012:0> d = 0.38450
=> 0.3845
irb(main):013:0> d.sigfig(2)
=> 0.38 

That works. But for the purpose of display, I'd really like to suppress the trailing decimal point and zero when the number has been rounded to an integer. The precision of the number 550 isn't clear (it could be ones or tens), but displaying it as 550.0 implies more precision than we have. I'll create a method which is designed specifically for output.

irb(main):001:0> a = 548.667
=> 548.667
irb(main):002:0> class Float
irb(main):003:1>   def sigfig_to_s(digits)
irb(main):004:2>     f = sprintf("%.#{digits - 1}e", self).to_f
irb(main):005:2>     i = f.to_i
irb(main):006:2>     (i == f ? i : f).to_s
irb(main):007:2>   end
irb(main):008:1> end
=> nil
irb(main):009:0> a.sigfig_to_s(2)
=> "550"

There's probably a better way to do this; I still tend to speak Ruby with a C accent and I may have missed a shortcut. But it does achieve my goal of reasonably clean output for tables full of Floats. Here's a page snippet from one of the sites which uses this code.

5 comments:

  1. Thanks for the post, it helped me head in the right direction. I basically needed to do what you did, except only keep 2 sig digs if the number is < 1 but keep all sig digs to the left of the decimal if the number >=1.

    I ended up modifying your code like so:
    class Float
    def smart_round_to_s(digits=2)
    f = if digits <= 0
    self.to_f.round
    elsif self.to_f < 1
    sprintf("%.#{digits - 1}e", self).to_f
    else
    sprintf( "%.#{digits}f", self ).to_f
    end
    i = f.to_i
    (i == f ? i : f).to_s
    end
    end

    Thanks again!

    ReplyDelete
  2. 1.15 rounded to 2 significant digits is 1.2.

    Your code gives 1.1. I hope you didn't use it in production.

    ReplyDelete
  3. 548.667 rounded to 2 significant digits is 550.0

    This is wrong! 2 sigfigs would 550 (550.0 is 4 sigfigs)

    ReplyDelete
  4. 548.667 rounded to 2 significant digits is 550.0

    This is wrong! 2 sigfigs would 550 (550.0 is 4 sigfigs)

    ReplyDelete
  5. You can also do it with some simple math
    n = 3199
    n.round(1-Math.log(n, 10).floor) # => 3200

    also works for floor (note you still use "floor" in parentheses)
    n.floor(1-Math.log(n, 10).floor) # => 3100

    ReplyDelete