Number.EPSILON y el espaciado de los números en el formato IEEE754

Figura
Figura. En JavaScript 1.7-1.1≠0.6

En este tema intaremos saber por ejemplo por qué 1.75 - 1.125 da exactamente 0.625 y sin embargo 1.7 - 1.1 no da el resultado exacto 0.6. Esto tiene que ver con la precisión del formato IEEE754, donde el espaciado de los números es un concepto básico que debemos conocer.

El valor significativo Number.EPSILON es de ε = 2-52 = 2.220446049250313e-16 y se define como el número más pequeño representable siguiente al 1. Es decir, entre 1 y 1.0000000000000002220446049250313 no podemos representar más números con el formato IEEE754.

Veáse que se corresponden con una fracción con todos ceros y el último bit a uno, dango lugar al formato 1.000···52···001 × 20. Dado que sólo podemos precisar 16 dígitos decimales fraccionarios, el número más pequeño después de 1 será 1.0000000000000002 pues se redondea al inferior tomando 17 dígitos significativos.

La distancia entre 1 y 1+ε la denominamos espaciado. Esa distancia es proporcional al exponente de la forma espaciado = 2n ε siendo n el exponente del número en el formato IEEE754. El exponente define también el rango de valores numéricos [2n, 2n+1). Es un conjunto cerrado por debajo y abierto por encima, con lo que un número k de ese rango cumplirá 2n ≤ k < 2n+1.

Con n=0 el rango será [1, 2) y el espaciado será ε. Con el exponente tomando valores desde -1022 hasta 1023 podemos construir una tabla que resume algunos valores significativos:

Exponente
n
Rango
[2n, 2n+1)
Espaciado
2n-52
-1022[2.2250738585072014e-308, 4.450147717014403e-308)5e-324
-1021[4.450147717014403e-308, 8.900295434028806e-308)1e-323
·········
-2[0.25, 0.5)5.551115123125783e-17
-1[0.5, 1)1.1102230246251565e-16
0[1, 2)2.220446049250313e-16
1[2, 4)4.440892098500626e-16
2[4, 8)8.881784197001252e-16
·········
31[2147483648, 4294967296)4.76837158203125e-7
32[4294967296, 8589934592)9.5367431640625e-7
33[8589934592, 17179869184)0.0000019073486328125
·········
51[2251799813685248, 4503599627370496)0.5
52[4503599627370496, 9007199254740992)1
53[9007199254740992, 18014398509481984)2
·········
1022[4.49423283715579e+307, 8.98846567431158e+307)9.9792015476736e+291
1023[8.98846567431158e+307, Infinity)1.99584030953472e+292

Puede ejecutar el siguiente ejemplo para ver los valores obtenidos en el navegador que esté usando:

Ejemplo: El espaciado de los números

Este ejemplo usa ES6 en modo estricto. Puedes consultar el código JS original de este ejemplo.

Para un número en el rango [1, 2) el espaciado es ε. Los números del rango [1, 1+ε/2] se redondean al inferior 1. Los números (1+ε/2, 1+ε] se redondean al superior 1+ε:

//Número en el rango [1, 2)
let num = 1;
let spacing = Number.EPSILON;
console.log(num + spacing * 1/4); // 1
console.log(num + spacing * 1/2); // 1
console.log(num + spacing * 3/4); // 1.0000000000000002
console.log(num + spacing * 1);   // 1.0000000000000002 
    

Podemos descubrir el exponente de un número k con Floor(log2k), lo que nos permite calcular el espaciado de cualquier número:

let num = 7;
let n = Math.floor(Math.log2(num));
console.log(n); // 2
let spacing = Math.pow(2, n) * Number.EPSILON;
console.log(spacing); // 8.881784197001252e-16
console.log(num + spacing * 1/4); // 7
console.log(num + spacing * 1/2); // 7
console.log(num + spacing * 3/4); // 7.0000000000000001
console.log(num + spacing * 1);   // 7.0000000000000001    
    

Los números en el rango [4503599627370496, 9007199254740992) tienen un espaciado de la unidad. Desde ahí en adelante sólo podemos representar enteros:

let num = 8e15; // 8000000000000000
let n = Math.floor(Math.log2(num));
console.log(n); // 52
let spacing = Math.pow(2, n) * Number.EPSILON;
console.log(spacing); // 1
console.log(num + spacing * 1/4); // 8000000000000000
console.log(num + spacing * 1/2); // 8000000000000000
console.log(num + spacing * 3/4); // 8000000000000001
console.log(num + spacing * 1);   // 8000000000000001
    

El concepto del espaciado es clave para entender el formato IEEE754. En este tema y siguientes recurriremos a esto para explicar algunas cosas.

La precisión de los números en IEEE754

Cuando realizamos operaciones aritméticas podemos encontrarnos resultados que no son el valor decimal esperado. Una de las dudas planteadas era la siguiente:

//¿Por qué 1.7-1.1 no resulta exactamente 0.6?
console.log(1.7-1.1);   // 0.5999999999999999
//Y sin embargo 1.75-1.125 si resulta exactamente 0.625
console.log(1.75-1.125) // 0.625
    

Veamos primero el segundo caso con el resultado exacto. Ambos operandos tiene representación binaria exacta, pues 1.7510 = 1.112 mientras que 1.12510 = 1.0012. Así que la resta será 1.112 - 1.0012 = 0.1012 = 0.62510, con un resultado también exacto.

Los números anteriores son racionales con denominadores potencias de dos, pues 1.75 = 7/4 y 1.125 = 9/8, correspondiéndole una representación binaria exacta.

Los otros números 1.7 = 17/10 y 1.1 = 11/10 son racionales con denominadores que no son potencias de dos, por lo que tendrán una representación binaria con secuencias infinitas. Usando el convertidor IEEE754 obtenemos los formatos que debemos usar para realizar la resta binaria:

1.7 = 1.1011001100110011001100110011001100110011001100110011 × 20 1.1 = 1.0001100110011001100110011001100110011001100110011010 × 20

La resta decimal es 1.7 - 1.1 = 0.6, pero el sistema trabaja con binarios, por lo que debemos hacer esta resta:

 1.1011001100110011001100110011001100110011001100110011 -1.0001100110011001100110011001100110011001100110011010  0.1001100110011001100110011001100110011001100110011001

Tras efectuar la resta el sistema debe conformar un registro IEEE754 con ese binario. Recordando que debe empezar por el bit implícito, hemos de correr la coma un lugar a la derecha (hasta encontrar el primer uno) y tomar exponente negativo tantos lugares como hayamos corrido la coma:

1.0011001100110011001100110011001100110011001100110010 × 2-1

Dado que se usarán 52 bits en la parte fraccionaria, al correr la coma un lugar necesitamos un bit adicional. Como no tenemos ninguno se agregará el cero que aparece resaltado. Obtengamos ahora el valor decimal de ese binario usando una técnica indirecta. Se trata de expresar todo el binario como un número entero y ajustar con el debido exponente. Luego usamos el convertidor binario-decimal para obtener el valor entero y una calculadora con mayor precisión para obtener el valor final:

1.0011001100110011001100110011001100110011001100110010 × 2-1 = 10011001100110011001100110011001100110011001100110010 × 2-52 × 2-1 = 5404319552844594 × 2-53 = 0.59999999999999986677323704498122

Es necesario usar una calculadora como la que viene en Windows que tiene una precisión mayor que la que nos da el formato IEEE754 de JavaScript. Así obtenemos un número con más dígitos decimales que los que puede darnos JavaScript, por lo que el valor de la resta en JavaScript será 0.5999999999999999 usando 16 dígitos decimales con redondeo hacia arriba dado que la fracción 0.6677323704498122 es mayor que 0.5.

Es posible corroborar el valor obtenido con la calculadora usando el método num.toExponential(), que nos da un string con el valor de un número en formato exponencial con precisión de 20 dígitos. Se observa que el número que obtuvimos es como el siguiente aunque ahora con menos dígitos en la fracción:

let n = 0.5999999999999999;
console.log(n);  // 0.5999999999999999
let num = n.toExponential(20);
console.log(num); // 5.99999999999999866773e-1
    

Observe que la diferencia de los formatos de 0.6 y 0.5999999999999999 está en el último dígito:

0.6000000000000000 = 1.0011001100110011001100110011001100110011001100110011 × 2-1 0.5999999999999999 = 1.0011001100110011001100110011001100110011001100110010 × 2-1

Lo importante es entender que esa diferencia es producida por la operación. Cuando nos faltaba un bit a la derecha, JavaScript no podía deducir que ahí debería ir un uno para obtener el resultado exacto. En ese momento de conformar un registro IEEE754 sólo sabe que si le faltan bits por la derecha los rellenará con ceros.

Estas desviaciones en las resultados de operaciones aritméticas pueden complicarse más cuando hay redondeos en el formato IEEE754.

Comparando números en JavaScript

Antes vimos que JavaScript aproxima el binario cuyo valor más se acerca al número a representar. Eso significa un bit de diferencia al final de la fracción de 52 bits del formato, cuyo valor es ε = 2-52 y que tenemos disponible en la propiedad Number.EPSILON.

Recordemos que el espaciado entre dos binarios del formato IEEE754 consecutivos depende del exponente, siendo en general 2n×ε = 2n-52 para un valor numérico en un rango [2n, 2n+1). Cuando n=0 tenemos los valores del rango [1, 2) donde el espaciado es ε.

Como se puede producir una aproximación al bit anterior o posterior, cuando la diferencia sea menor de 2nε/2 = 2n-1ε podremos considerar que ambos números son iguales. Sin embargo cuando realizamos operaciones vamos acumulando errores, o mejor dicho, diferencias de precisión con respecto a un valor real verdadero.

Para un conjunto grande de operaciones las diferencias pueden ser significativas. Por ejemplo, si sumamos 100 veces el número 0.1 debería darnos 10, pero resultará 9.99999999999998:

let k = 0.1, n = 0;
for (let i=0; i<100; i++){
    n += k;
}
console.log(n); // 9.99999999999998
console.log(10 - n); // 1.9539925233402755e-14
console.log(10 === n); // false
console.log(Math.abs(10-n)<=Number.EPSILON); // false
console.log(Math.abs(10-n) / Number.EPSILON); // 88
    

La diferencia acumulada es mayor que ε, de hecho resulta 88ε. ¿Y cómo hacemos entonces comparaciones numéricas en JavaScript? Hemos de partir de la base de que una representación numérica siempre será eso, una representación. Por ejemplo, un número irracional como π tiene infinitos dígitos fraccionarios. Si tomamos cualquier número de decimales sólo podemos decir que ese número es un aproximación a π, pero no exactamente π:

//PI, con la mayor precisión posible que se puede 
//obtener en JavaScript, tiene 15 dígitos fraccionarios
console.log(Math.PI); // 3.141592653589793
let pi = 3.141592;
console.log(Math.PI === pi); // false
    

Lo que podemos hacer es establecer una mínima precisión usando una función para devolvernos si se cumple o no:

function sonIguales(x, y, precision){
    return Math.abs(x-y) < precision;
}
console.log(sonIguales(pi, Math.PI, 1e-6)); // true
    

De igual forma tenemos que trabajar con el formato IEEE754, estableciendo una precisión y realizando las comparaciones con ese límite:

let k = 0.1, n = 0;
for (let i=0; i<100; i++){
    n += k;
}
console.log(sonIguales(n, 10, 1e-6)); // true
    

También podemos usar alguna función de redondeo como toFixed(digitos) que redondea al número de dígitos fraccionarios que indica el argumento. Vea que hay que aplicar ese redondeo a ambos valores a comparar, pues ese método devuelve un string:

function sonIguales(x, y, digitos){
    x = x.toFixed(digitos);
    y = y.toFixed(digitos);
    return x === y;
}
let k = 0.1, n = 0;
for (let i=0; i<100; i++){
    n += k;
}
console.log(sonIguales(n, 10, 14)); // false
console.log(sonIguales(n, 10, 6)); // true
    

Otro método similar que podría usarse es toPrecision(digito) donde el argumento ahora se refiere al número de digitos significativos.

Número de dígitos decimales en el formato IEEE754

En este apartado intentaremos saber cuál el el número mínimo de dígitos decimales para representar un número en el formato IEEE754. Con mínimo queremos decir que por más decimales que pongamos no vamos a obtener una representación IEEE754 distinta del número.

En el tema sobre la precisión de representaciones binarias habíamos dicho que para un registro de coma fija con 8 bits fraccionarios el número mínimo de digitos decimales era 8×log10(2) ≈ 2.408. Como ese número debe ser entero podemos deducir que con 8 bits no podemos precisar más de 2 dígitos decimales fraccionarios. ¿Cuántos son para el formato de coma flotante IEEE754?

Si tenemos 52 bits fraccionarios, el mismo cálculo resulta 52×log10(2) ≈ 15.654. Entonces por aquí obtenemos 15 dígitos decimales que son los mínimos requeribles, lo que quiere decir que si tomamos un número decimal con 15 dígitos fraccionarios y lo convertirmos a IEEE754 y luego lo volvemos a convertir en decimal obtenemos el mismo número. Sin embargo en documentos sobre el tema vemos que el número de dígitos está entre 15 y 17:

Mínimo = Floor(52×log10(2)) = 15 Máximo = Ceil(1+53×log10(2)) = 17

La función Floor() recorta al entero inferior y Ceil() al superior. Esos cálculos obtenidos con JavaScript pueden verse a continuación:

console.log(Math.floor(52*Math.log10(2))); // 15
console.log(Math.ceil(1+53*Math.log10(2))); // 17
    

Por otro lado el formato dice que si un número IEEE754 es convertido a decimal con 17 dígitos fraccionarios y luego volvemos a convertirlo a IEEE754 nos debe dar el mismo formato. Vea que esto es diferente de lo anterior. Lo siguiente esquematiza estas dos reglas. La segunda regla sólo garantiza la conversión desde un IEEE754 a decimal, pero no desde un decimal a IEEE754:

DECIMAL15 ⇒ IEEE75415 ⇒ DECIMAL15 IEEE75417 ⇒ DECIMAL17 ⇒ IEEE75417

Veamos si podemos responder ahora a una de las dudas planteadas:

//Por qué esta resta entre números distintos resulta cero
let m = 0.12345678901234567;
let n = 0.12345678901234566;
console.log(m-n); // 0
    

Búsquemos el formato de números con 15 a 18 dígitos decimales:

DígitosDecimal a convertirFormato IEEE754Reconvertir a decimal
150.1234567890123451.11111001101011011101001101
11010001101111011000101110×2-4
0.123456789012345
160.12345678901234561.11111001101011011101001101
11010001101111011001011001×2-4
0.1234567890123456
170.123456789012345671.11111001101011011101001101
11010001101111011001011110×2-4
0.12345678901234566
180.1234567890123456781.11111001101011011101001101
11010001101111011001011111×2-4
0.12345678901234568

Observe como el número 0.12345678901234567 se convierte a IEEE754 y luego se reconvierte como 0.12345678901234566. Por lo tanto ambos tienen la misma representación y su resta es cero, aclarándose la duda planteada.

Con 15 y 16 dígitos la conversión al formato y reconversión de nuevo a decimal producen el mismo número. Pero desde 17 y superior no sucede así: el número reconvertido no es igual que el original. Pero si podemos asegurar que con 17 dígitos los números reconvertidos desde un formato IEEE754 tras volver a formatearlos nos vuelven a dar el mismo número:

DígitosDecimal a convertirFormato IEEE754Reconvertir a decimal
170.123456789012345661.11111001101011011101001101
11010001101111011001011110×2-4
0.12345678901234566
0.123456789012345681.11111001101011011101001101
11010001101111011001011111×2-4
0.12345678901234568

Observe como entre 0.12345678901234566 y 0.12345678901234568 no podemos representar más números, pues sus binarios son dos consecutivos ...11110 y ...11111.

Más que hablar de número de digitos fraccionarios tendríamos que decir número de dígitos significativos. Un número en base 10 como 12345.6789 tiene 9 dígitos significativos: no podemos quitar ninguno sin afectar al valor. Pero otro como 0.000012345 sólo tiene 5 dígitos significativos, pues podemos expresarlo en formato exponencial como 1.2345×10-5.

Lo mismo podemos aplicar al formato IEEE754. El total de dígitos binarios de todo el número, parte entera y decimal, tiene un máximo de 53 bits, 52 para la parte fraccionaria y 1 para el bit implícito. De ahí se obtienen entre 15 y 17 dígitos significativos. Con esa limitación cuando convirtamos un valor real al formato IEEE754 hemos de tener en cuenta que cuanto más dígitos decimales tengamos en la parte entera menos dígitos podremos usar en la parte fraccionaria.

En este ejemplo con 10 en la parte entera y 5 o 6 en la fraccionaria no tendremos problemas. Pero a partir de ahí perderemos precisión:

//15 dígitos significativos
console.log(1234567890.12345);    // 1234567890.12345
//16 dígitos significativos
console.log(1234567890.123456);   // 1234567890.123456
//17 dígitos significativos
console.log(1234567890.1234566);  // 1234567890.1234567
console.log(1234567890.1234567);  // 1234567890.1234567
console.log(1234567890.1234568);  // 1234567890.1234567
    

Cuando lleguemos al rango de números [4503599627370496, 9007199254740992), que tienen 16 dígitos significativos en la parte entera, no podremos precisar ningún dígito en la parte decimal. No hay representaciones entre dos números de ese rango, por lo que todos los del rango son números enteros. Hablaremos más sobre los enteros en un tema posterior.

Cuántos números puedo representar con IEEE754

El último apartado de este tema lo dedicaremos a saber cuántos números decimales se pueden representar con el formato IEEE754. La parte fraccionaria tiene 52 bits pero incluyendo el bit del signo podemos contar con 53 bits para cada exponente. Con los 11 bits del exponente potencialmente podrían haber 211×253 = 264 valores representables.

Con el exponente todos unos y usando el bit del signo se generan 253 valores. Dos de ellos son para representar ±Infinity con la fracción todos ceros. El resto 253-2 son todos los valores NaN que el formato IEEE754 puede representar. Pero en JavaScript todos estos se representan con un único NaN. Por lo tanto al potencial de valores 264 hemos de quitarle todos los NaN y reemplazarlo por un único NaN. Así que los valores posibles son 264-(253-2)+1 = 264-253+3.

Quitando los 3 valores especiales ±Infinity y NaN tenemos 264-253 valores finitos. De ellos la mitad positivos y la otra mitad negativos. Quitando los dos ceros posibles ±0 tenemos 264-253-2 valores finitos no cero.

Los valores denormalizados se corresponden con exponente todo ceros y fracción distinta de cera. Considerando el signo tendremos entonces 253-2 valores denormalizados, notándose que hemos de restar 2 valores para ±0, pues el cero se representa con un bit de signo y con exponente y fracción todos ceros.

Así que del total de valores finitos no cero nos quedan 264-253-2 - (253-2) = 264-254 valores normalizados.

En la siguiente tabla se resumen estas cantidades:

ValoresCantidad de números
Posibles264-253+3 = 18437736874454810627
No finitos ±Infinity, NaN3
Ceros ±02
Normalizados264-254 = 18428729675200069632
Denormalizados253-2 = 9007199254740990