<jupyter_start><jupyter_text>Scientific Programming in Python <NAME>-Rhein-Sieg University of Applied SciencesSankt Augustin, Germany  Rounding Numbers  (Reporting the correct number of significant figures.) Sources1. https://docs.python.org/3/library/functions.htmlround2. https://docs.python.org/3/library/decimal.htmldecimal.Decimal3. https://realpython.com/python-rounding/the-decimal-class4. https://docs.python.org/3/library/math.html5. https://docs.python.org/3/library/decimal.html6. https://numpy.org/doc/stable/reference/generated/numpy.around.html7. https://numpy.org/doc/stable/reference/generated/numpy.ceil.html8. https://numpy.org/doc/stable/reference/generated/numpy.floor.html9. https://docs.python.org/3.1/library/string.htmlgrammar-token-precision   Don't Scroll Down Yet...**Question**: How would you round -2.45 to the first decimal place?  Conceptual ideas- Critical thinking is needed when rounding numbers whose last digit is a **5** since it lies in the middle of the basic number series  (i.e. 0 -- 1, 2, 3, 4 -- 5 -- 6, 7, 8, 9 -- 10)- The act of rounding can introduce a bias into your numbers (e.g. when working with a large data set).Consider 11 numbers that range from 0.5 -- 10.5 in 1.0 increments    - round them to the ones place, and    - then take their mean.    We will use that statistics library since it allows us to round half up and down.<jupyter_code>import statistics
input_numbers = [number+0.5 for number in range(0, 11)]

print(f'Unrounded Numbers: {input_numbers}')
print(f'Average: {statistics.mean(input_numbers)}')<jupyter_output><empty_output><jupyter_text>Rounding the number up:<jupyter_code>round_up = [number for number in range(1, 12)]

print(f'Rounded Up Numbers: {round_up}')
print(f'Mean: {statistics.mean(round_up)}')<jupyter_output><empty_output><jupyter_text>Rounding the number down:<jupyter_code>round_down = [number for number in range(0, 11)]

print(f'Rounded Down Numbers: {round_down}')
print(f'Mean: {statistics.mean(round_down)}')<jupyter_output><empty_output><jupyter_text>Thus, we observe that:1. Rouding Up leads to a **mean of 6**, while2. Rounding Down leads to a **mean of 5**.**Therefore, the approach that we use to round numbers may result in a bias.**  Conceptual approaches for rounding numbers 1. Bias methods**Round Half Up** (i.e. bias towards **more positive**)1.  7.6 $\rightarrow$ 82. **7.5 $\rightarrow$ 8**3. 7.4 $\rightarrow$ 74. -7.6 $\rightarrow$ -85. **-7.5 $\rightarrow$ -7**6. -7.4 $\rightarrow$ -7**Round Half Down** (i.e. bias towards **more negative**)1.  7.5 $\rightarrow$  72. -7.5 $\rightarrow$ -8**Round Half To Zero** (i.e. bias towards **zero**)1.  7.5 $\rightarrow$  72. -7.5 $\rightarrow$ -7**Round Half Away from Zero** (i.e. bias towards **$\pm \infty$**)1. 7.5 $\rightarrow$  82. -7.5 $\rightarrow$ -8Additional information:https://en.wikipedia.org/wiki/Rounding  2. Unbias methods (i.e. free from positive/negative bias and bias toward/away from zero)**Round Half To Even** (i.e. zero is considered evenly distributed)1.  7.5 $\rightarrow$  82.  8.5 $\rightarrow$  83. -7.5 $\rightarrow$ -84. -8.5 $\rightarrow$ -8**Round Half To Odd** (i.e. zero is considered evenly distributed)1.  7.5 $\rightarrow$  72.  8.5 $\rightarrow$  93. -7.5 $\rightarrow$ -74. -8.5 $\rightarrow$ -9 ![rounding](00_images/rounding_bias.png)  Different approaches within Python  1. Python's built-in function 'round' (with examples)Using the numbers that are exemplified in the above figure:<jupyter_code>example_numbers = [-4.5, -3.5, -0.5, 0.5, 3.5, 4.5]

for number in example_numbers:
    number_rounded = round(number)

    print(f'The number {number} becomes {number_rounded}.')<jupyter_output><empty_output><jupyter_text>**Thus**, Python3's built-in **`round`** function uses **Round Half To Even**What happens if we push it slightly over 4.5?<jupyter_code>print(round(4.51))<jupyter_output><empty_output><jupyter_text>So, the behavior is has it should be. **Notice** how `round` simply returns the integer value of the float above.Alternatively, one can specify the number of decimal places using **`round(number[, ndigits])`**.- However, this does odd things to the number of digits that are returned (i.e. significant figures).Let's round a list of floats to the second decimal place (i.e. to have 1 sigfig):<jupyter_code>numbers_demo_expected = [-0.055, 0.055]

for number in numbers_demo_expected:
    number_rounded = round(number, 2)
    
    print(f'When rounded to 2 decimals, the number {number} becomes {number_rounded}.')<jupyter_output><empty_output><jupyter_text>The results are as expected.<jupyter_code>numbers_demo_unexpected = [10, 0.055E-9]

for number in numbers_demo_unexpected:
    number_rounded = round(number, 2)
    
    print(f'When rounded to 2 decimals, the number {number} becomes {number_rounded}.')<jupyter_output><empty_output><jupyter_text>Notice two things:1. **10**, which as no decimals initially and thus a questionable number of significant figures, stays as 10 (which is good)2. The function `round` returned **0.0** for **0.055E-9**    - the scientifically correct answer is **0.06E-9** (or 0.6E-10 or 6.E-11, depending on the converntion)    - which means we **lost** some data information **Furthermore**, notice the following weird result when we round 2.675 to two decimal places.<jupyter_code>print( round(2.675, 2) )<jupyter_output><empty_output><jupyter_text>The `round(2.675, 2)` should result in **2.68** (i.e. rounding up). Instead we obtained **2.67**!Two more examples of weirdness that helps us understand what is happening:<jupyter_code>0.1 + 0.1 + 0.1<jupyter_output><empty_output><jupyter_text>In the following, we are adding numbers that should result in zero:<jupyter_code>(0.1 + 0.1 + 0.1) - 0.3<jupyter_output><empty_output><jupyter_text>Why?Reason: **floating-point representation error** (https://docs.python.org/3/tutorial/floatingpoint.html)This is not a Python issue, but has to do with how computers store floating-point numbers in memory.**Take-home message:**- From a perspective of being exact and correct, this is something that one needs to be aware of -- particularly when significant figures are important -- when developing code for scientific usage.  2. Python's math library (with examples)- https://docs.python.org/3/library/math.html- math.ceil (ceiling): **Round Up**- math.floor: **Round Down**Both functions return only an **integer** value.<jupyter_code>import math

example_numbers = [-4.5, -3.5, -0.5, 0.5, 3.5, 4.5]
for number in example_numbers:
    number_rounded = math.ceil(number)

    print(f'The number {number} becomes {number_rounded}.')
for number in example_numbers:
    number_rounded = math.floor(number)

    print(f'The number {number} becomes {number_rounded}.')<jupyter_output><empty_output><jupyter_text>3. The decimal library- https://docs.python.org/3/library/decimal.html    - "The decimal module provides support for fast correctly-**rounded decimal floating point arithmetic**."    - Can be passed a number that is either a float (e.g. `3.14`, or a string (e.g. `'3.14'`)    - Rounding is done with `decimal.Decimal.quantize`    - Returns a `decimal.Decimal` objectRounding options:1. ROUND_CEILING $\rightarrow$ Round towards positive infinity2. ROUND_FLOOR $\rightarrow$ Round towards negative infinity 3. ROUND_DOWN $\rightarrow$ Round towards zero4. ROUND_UP $\rightarrow$ Rounding away from zero5. ROUND_HALF_UP $\rightarrow$ Rounding half away from zero6. ROUND_HALF_DOWN $\rightarrow$ Rounding half towards zero7. ROUND_HALF_EVEN $\rightarrow$ Rounding half to even8. ROUND_05UP $\rightarrow$ Rounding towards zero, unless the last digit is 0 or 5 then round away from zero<jupyter_code>import decimal
from decimal import Decimal<jupyter_output><empty_output><jupyter_text>Notice that the default rounding method is **ROUND_HALF_EVEN**.Let's first consider how `decimal.Decimal` handles- **floats** and- **strings**<jupyter_code>Decimal(3.14)
Decimal('3.14')
type(Decimal('3.14'))<jupyter_output><empty_output><jupyter_text>Now let's look at what happens if we do some methematics (i.e. add the number to itself).We should get:3.14 + 3.14 = 6.28<jupyter_code>Decimal(3.14) + Decimal(3.14)
Decimal('3.14') + Decimal('3.14')<jupyter_output><empty_output><jupyter_text>Therefore, using the string input approach with **`decimal.Decimal`** **corrects** the **floating-point representation error**.  Rounding with decimals library- done using ```quantized```:    - ```Decimal(value="0", context=None).quantize(exp, rounding=None, context=None)```        Decimal().quantize('0') --> ones place (e.g. 7)        Decimal().quantize('0.0') --> ones and first decimal place (e.g. 7.1)        Decimal().quantize('0.00') --> ones and first decimal place (e.g. 7.11) Let's see what the **default settings** of `decimal` are:<jupyter_code>decimal.getcontext()
help(decimal.Decimal.quantize)<jupyter_output><empty_output><jupyter_text>In our first example, let do the following:1. round to the one's place (i.e. `quantize(Decimal('0.')`), and2. pass the number as a **string** (using an f-string statment).<jupyter_code>example_numbers = [-4.5, -3.5, -0.5, 0.5, 3.5, 4.5]

for number in example_numbers:
    number_rounded = Decimal(f'{number}').quantize(Decimal('0.'))

    print(f'The number {number} becomes {number_rounded}.')<jupyter_output><empty_output><jupyter_text>Thus, **`decimal.Decimal.quantized`** is **ROUND_HALF_EVEN** due to its default settings.Let's be more exact and not "hide" what is going on and specify the rounding directly:<jupyter_code>example_numbers = [-4.5, -3.5, -0.5, 0.5, 3.5, 4.5]

for number in example_numbers:
    number_rounded = Decimal(value=f'{number}').quantize(Decimal(value='0.'), rounding="ROUND_HALF_EVEN")

    print(f'The number {number} becomes {number_rounded}.')<jupyter_output><empty_output><jupyter_text>Now **change** the **number of decimal places** to return.1 decimal place:<jupyter_code>example_demo = [-4.55, -4.65, 4.55, 4.65]

for number in example_demo:
    number_rounded = Decimal(value=f'{number}').quantize(Decimal(value='0.0'), rounding="ROUND_HALF_EVEN")

    print(f'The number {number} becomes {number_rounded}.')<jupyter_output><empty_output><jupyter_text>2 decimal places:<jupyter_code>example_demo = [-4.055, -4.065, 4.055, 4.065]

for number in example_demo:
    number_rounded = Decimal(value=f'{number}').quantize(Decimal(value='0.00'), rounding="ROUND_HALF_EVEN")

    print(f'The number {number} becomes {number_rounded}.')<jupyter_output><empty_output><jupyter_text>Now let's change our rounding method to **rounding half up** (i.e. rounding half away from zero).<jupyter_code>example_numbers = [-4.5, -3.5, -0.5, 0.5, 3.5, 4.5]

for number in example_numbers:
    number_rounded = Decimal(value=f'{number}').quantize(Decimal(value='0.'), rounding="ROUND_HALF_UP")

    print(f'The number {number} becomes {number_rounded}.')<jupyter_output><empty_output><jupyter_text>Modifying the default settings via `getcontext()`:<jupyter_code>decimal.getcontext().rounding = decimal.ROUND_HALF_UP
print(decimal.getcontext())
example_numbers = [-4.5, -3.5, -0.5, 0.5, 3.5, 4.5]

for number in example_numbers:
    number_rounded = Decimal(value=f'{number}').quantize(Decimal(value='0.'), rounding=None)

    print(f'The number {number} becomes {number_rounded}.')<jupyter_output><empty_output><jupyter_text>**Side note**: earlier we saw that ```round(2.675, 2)``` resulted in **2.67**, not the correct value of **2.68**. Let's see what `Decimal.quantize` does:<jupyter_code>Decimal(value="2.675").quantize(Decimal(value="0.00"))<jupyter_output><empty_output><jupyter_text>Therefore, we obtain the exact answer now (i.e. without the influence of floating point representation error).<!--  PrecisionLet now change the default **precision**. Precision here seem to be how many **digits** (i.e. not decimal places) are **available** for a number (default value is 28). **Note** that a number doesn't need to use as 28 digits, but they are there if needed.Decimal('11') / Decimal('7')Let's verify the precision by computing how characters the number has, which includes the decimal point:len(str(Decimal('11') / Decimal('7')))decimal.getcontext().prec=3Decimal('11') / Decimal('7')**Be careful**: This doesn't always alter the default settings of decimal. On my installed Jupyter-notebook (bug?), the default decimals revert back to 28. However, on Colaboratory the above command does alter the default settings.Decimal('11') / Decimal('7')decimal.getcontext()-->  4. The Numpy library(For consideration in the future when we do cover Numpy)- Rounding is done using `numpy.around(a, decimals=0, out=None)`https://numpy.org/doc/stable/reference/generated/numpy.around.html<jupyter_code>import numpy<jupyter_output><empty_output><jupyter_text>Round to the ones place:<jupyter_code>numpy.around(1.500, decimals=0)<jupyter_output><empty_output><jupyter_text>Round to the first decimal place:<jupyter_code>numpy.around(1.550, decimals=1)<jupyter_output><empty_output><jupyter_text>Round to the second decimal place:<jupyter_code>numpy.around(1.555, decimals=2)
example_numbers = [-4.5, -3.5, -0.5, 0.5, 3.5, 4.5]

for number in example_numbers:
    number_rounded = numpy.around(number)

    print(f'The number {number} becomes {number_rounded}.')
example_demo = [-4.55, -4.65, -0.05, 0.05, 4.55, 4.65]

for number in example_demo:
    number_rounded = numpy.around(number, decimals=1)
    
    print(f'The number {number} becomes {number_rounded}.')<jupyter_output><empty_output><jupyter_text>Thus, **`numpy.around`** function is using the **round to even method**.How about if we want to rounding up (ceiling) and down (floor) to the one's place? Numpy has that too.Rounding up using `numpy.ceil` (https://numpy.org/doc/stable/reference/generated/numpy.ceil.html):<jupyter_code>for number in example_demo:
    number_rounded = numpy.ceil(number)
    
    print(f'The number {number} becomes {number_rounded}.')<jupyter_output><empty_output><jupyter_text>Rounding down using `numpy.floor` (https://numpy.org/doc/stable/reference/generated/numpy.floor.html):<jupyter_code>for number in example_demo:
    number_rounded = numpy.floor(number)
    
    print(f'The number {number} becomes {number_rounded}.')<jupyter_output><empty_output><jupyter_text>5. Rounding (maybe) by formating print statements containing floatshttps://docs.python.org/3.1/library/string.htmlgrammar-token-precision- `g`: General format    - fixed point or exponential, depending on the number's magnitude<jupyter_code>print(f'{3.4500045:g}, {3.5500055:g}, {300000000005:g}')<jupyter_output><empty_output><jupyter_text>- `f`: Fixed point    - unclear rounding<jupyter_code>demo_numbers = [3.44, 3.56, -3.44, -3.56, 300000000005.44]
for number in demo_numbers:
    print(f'{number:0.1f}')<jupyter_output><empty_output><jupyter_text>What happens if we change `1f` to `3f`?<jupyter_code>for number in demo_numbers:
    print(f'{number:0.3f}')<jupyter_output><empty_output><jupyter_text>Considering significant figures, we are **introducing new information** when it is originally **not present**.<jupyter_code>example_numbers = [-4.5, -3.5, -0.5, 0.5, 3.5, 4.5]

for number in example_numbers:
    print(f'The number {number} becomes {number:0.0f}.')<jupyter_output><empty_output><jupyter_text>Seems to be **round to even**.However, consider the following:<jupyter_code>example_demo = [-4.55, -4.65, -0.05, 0.05, 4.55, 4.65]

for number in example_demo:
    print(f'The number {number} becomes {number:0.1f}.')<jupyter_output><empty_output><jupyter_text>Now the behavour of formatting stings is **round to odd**. - `e`: Exponent notation    - rounding to odd number<jupyter_code>print(f'{3.45:0.1e}, {3.55:0.1e}, {355000000000:0.1e}')<jupyter_output><empty_output><jupyter_text>However, I'm not sure why 355000000000 -> 3.6e+11 and not 3.5e+11<jupyter_code>example_demo = [-4.55, -4.65, -0.05, 0.05, 4.55, 4.65]

for number in example_demo:
    print(f'The number {number} becomes {number:0.1e}.')<jupyter_output><empty_output><jupyter_text>Notice how **0.05** went to **5.0e-02** (i.e. a **gain of 1 significant figure**).Even worse number reporting is in the examples below:1. 15 becomes 15.00 (i.e. a gain of significant figure)2. 1.555e-9 becomes 0.00 (ie. a loss of significant figure)<jupyter_code>number_list = [1.55, 15, 1.555E-9]

for number in number_list:
    print(f'When rounded to 2 decimals, the number {number} becomes {number:0.2f}.')<jupyter_output><empty_output><jupyter_text>Let's revisit what happens with `g`:<jupyter_code>for number in number_list:
    print(f'When using general formating, the number {number} becomes {number:0.2g}.')<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.14130955231027054, 'fraction_numerical': 0.04123773042853723, 'mean_word_length': 5.30052790346908, 'pattern_counts': {'":': 0, '<': 151, '<?xml version=': 0, '>': 155, 'https://': 17, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 4, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '37', 'dir': 'jupyter-structured-clean-dedup', 'id': '269226', 'license': 'MIT', 'n_tokens_mistral': 6039, 'n_tokens_neox': 5559, 'n_words': 1576, 'path': 'rounding.ipynb', 'repo_name': 'karlkirschner/2020_Scientific_Programming', 'size': '16723'}