¿Necesito más decimales? 1 de 2

¡Saludos visitante! si deseas comentar o hacer una pregunta sobre este post por favor dirígete a la nueva dirección en http://variabletecnica.com.ve. La página que estás leyendo dejará de estar disponible el 15/11/2015. Gracias, y disculpa las molestias 🙂 A principios de 2009 me tocó revisar un caso extraño con un formulario en Asp.NET; debido a…


¡Saludos visitante! si deseas comentar o hacer una pregunta sobre este post por favor dirígete a la nueva dirección en http://variabletecnica.com.ve. La página que estás leyendo dejará de estar disponible el 15/11/2015. Gracias, y disculpa las molestias 🙂

A principios de 2009 me tocó revisar un caso extraño con un formulario en Asp.NET; debido a mi contrato de privacidad en la empresa para la que trabajo no puedo dar detalles de mi problema, pero les puedo dar un ejemplo simple en javascript antes de explicar el problema potencial con los tipos de dato de punto flotante. Puedes probar esto directamente en la consola de tu depurador javascript favorito (el mío: firefox+firebug):

var nNumero = 0.1;
var nResultado = 1 - nNumero*9 - nNumero;
alert("nResultado = " + nResultado.toString());

Antes de ejecutar el código podemos suponer que el cálculo de la línea 2 dará como resultado exactamente cero, pero al ejecutar el código vemos el mensaje «nResultado = -2.7755575615628914e-17» (a lo largo de este post usaré el punto como separador decimal); esto es «casi» cero (recuerdas la Notación Científica, ¿no?), pero «casi» no es suficiente. Lo curioso es que mientras escribía este post intenté cambiar un poco el código a ver que pasaba, y adivinen que muestra el mensaje de ésta versión modificada:

var nNumero = 0.1;
var nResultado = 1 - nNumero - nNumero*9;
alert("nResultado = " + nResultado.toString());

El mensaje ahora dice «nResultado = 0«… y ahora te estarás preguntando ¿Y qué pasó con las propiedades asociativa y conmutativa? ¡solo cambiaron de posición dos operandos! ¡debería dar el mismo resultado! y te pones a sacar cuentas con lápiz y papel. Vamos a descartar que estemos calculando mal, y probemos con ésta operación más simple:

var nResultado = 0.1+0.2;
alert("nResultado = " + nResultado.toString());

El resultado de esto debe ser obviamente 0.3, pero al ejecutar obtenemos: «nResultado = 0.30000000000000004«. Ya debes haber pillado el problema potencial de esto: si tienes if(nResultado==0.3) la respuesta será false. Alguna vez mi profesor de pascal dijo en clase «con los números flotantes (float) solo se puede comparar usando ‘<‘ o ‘>‘, nunca igualdad o diferencia, eso casi nunca funcionará», y hasta hace poco creí que era solo por simples errores de redondeo.

La Raíz del Problema

Suficiente misterio, vamos a ver la causa del problema comenzando con un ejemplo del mundo real: intentemos hacer la operación 1 – 1/3 – 1/3 – 1/3 sin usar matemática simbólica (i.e. sin operar con las fracciones directamente):

$latex 1 – \frac{1}{3} – \frac{1}{3} – \frac{1}{3} $

$latex = 1 – 0.3333 – 0.3333 -0.3333 $

$latex = 0.001 $

El error en el cálculo anterior ocurre debido a que la fracción $latex \frac{1}{3} $ no se puede representar exactamente en base 10, sin importar el número de dígitos luego del punto decimal. Por ejemplo: la fracción $latex \frac{3}{4} $ se puede representar exactamente en base 10 como:

$latex \frac{3}{4} = \frac{7}{10^1} + \frac{5}{10^2} = 0.75 $

Mientras que la fracción $latex \frac{1}{3} $ solo puede ser aproximada:

$latex \frac{1}{3} = \frac{3}{10^1}+ \frac{3}{10^2} + \frac{3}{10^3} …$

Veamos otros números que no pueden ser representados exactamente en base 10:

$latex \frac{1}{3} = \frac{3}{10^1}+ \frac{3}{10^2} + \frac{3}{10^3} …$

$latex \sqrt{2} = \frac{1}{10^0} + \frac{4}{10^1}+ \frac{1}{10^2} + \frac{4}{10^3} …$

$latex \pi = \frac{3}{10^0}+ \frac{1}{10^1}+ \frac{4}{10^2}+ \frac{1}{10^3} … $

Bien, esto ocurre con fracciones irracionales en base 10, pero ¿Cómo explica que 0.1 + 0.2 != 0.3? bueno, los tipos de dato de coma flotante se representan internamente mediante una Mantisa y un Exponente, ambos en formato binario (base 2). La explicación detallada del método de almacenamiento binario es interesante, pero para no extenderme vamos directo al grano: Solo los números reales que se puedan reescribir como una suma de fracciones binarias pueden ser representados exactamente en punto flotante. Esto es, un número X > 0 es representable exactamente en coma flotante si y solo si:

$latex \exists {a_1, a_2 … a_k, N} \in \mathbb{N} \diagup X = N + \sum_{i=j}^k {\dfrac{a_i}{2^i}} $

La misma expresión se cumple para X < 0 haciendo el cambio de signo apropiado a los términos a la derecha de la igualdad.

Veamos un par de ejemplos; $latex \dfrac{1}{2} $ en base 10 equivale en binario a:

$latex \dfrac{1}{2}_{[10]} = 0.1_{[2]} = \frac{1}{10^1} $

Y aquí va otro: $latex \dfrac{3}{4} $ en base 10 equivale en binario a:

$latex \dfrac{3}{4}_{[10]} = 0.11_{[2]} = \frac{1}{2^1} + \frac{1}{2^2} $

Ambos números pueden ser representados exactamente en binario, por lo que es seguro hacer operaciones y comparaciones con ellos. Pero si intentamos hacer lo mismo con 0.1 y 0.2 nos llevamos una sorpresa inesperada:

$latex \dfrac{1}{10}_{[10]} = 0.00011001100110011001100… _{[2]}$

$latex \dfrac{2}{10}_{[10]} = 0.00110011001100110011001…_{[2]} $

En base 10 los números 0.1 y 0.2 son racionales, ¡pero en base 2 son irracionales! o más exactamente son «binarios no periódicos» (prueba con ésta calculadora de bases). La mayoría de los lenguajes de programación realizan las operaciones de punto flotante a nivel de bits, por lo que están restringidos a cierta cantidad de cifras a la derecha del punto «binario»: si usamos solo los primeros 5 bits de ambos números y ejecutamos la suma el resultado que obtendremos será:

$latex 0.1_{[10]} + 0.2_{[10]} \approx 0.00011_{[2]}+0.00110_{[2]} = 0.01001_{[2]} \approx 0.281249_{[10]} $

Y claro, si usamos más cifras a la derecha del punto binario el resultado será más cercano a 0.3 (a veces por encima y a veces por debajo), pero como solo podremos usar una cantidad finita de bits éste resultado nunca será igual (de hecho 0.3 tampoco es exactamente representable en binario).

¿Debemos usar más Decimales?

Respondiendo al título del post: no, no necesitas trabajar con más decimales para evitar errores de redondeo, lo que necesitas es cuidar la forma en que haces las operaciones para evitar el error. Y no será tan fácil como usar alguna función de redondeo, eso simplemente no funcionará porque el problema no está en el código sino en la naturaleza de los números en punto flotante.

Vamos a probar un último fragmento de código javascript para ver porqué redondear los resultados en punto flotante no corrige nuestro problema:

var nValor = 0.1;

//El valor 0.1 falla a partir del 18º decimal,
//almacenando 0.1000000000000000055511151...
alert(&quot;Resultado = &quot; + nValor.toPrecision(25));

//Intentamos redondear nValor a 2 decimales
//Recuerda que Math.round() devuelve siempre un entero
//por lo que debemos multiplicar y dividir por 100
nValor *= 100;
nValor = Math.round(nValor);  //&quot;esperamos&quot; 10
nValor /= 100;                //&quot;esperamos&quot; 0.10

//Pero obtenemos 0.1000000000000000055511151...
alert(&quot;Resultado = &quot; + nValor.toPrecision(25));

Puede ser muy frustrante encontrarse inadvertidamente con problemas de redondeo sin saber de donde vienen…

En la segunda parte de este post dejaremos a un lado los números en punto flotante y usaremos números con punto fijo (como el Decimal de .Net o BigDecimal de Java) eliminando de raíz el problema de la representación en binario. Esto en principio evitará las «sorpresas» mencionadas en este post, con el costo de procesamiento que requiere este tipo de datos, que en general es más lento que con punto flotante. Veremos algunas consideraciones importantes a la hora de trabajar con cantidades que requieren exactitud, como en cálculos de porcentajes y totales.

Referencias

Cita del día

Principio de la Ira: La vehemencia con que usted reacciona ante una información es inversamente proporcional a la exactitud de la misma. Ley de Murphy


Soluciones claras y simples



Ing. Industrial, dedicado a la programación en ASP.NET+VB, SQL y Javascript+AJAX; y un poco de Android, Kotling, y Unity 🙂
Valencia, Venezuela



¿QUIERES APOYARME?

¿Te ha sido de ayuda alguno de mis artículos? Generar contenido técnico requiere de tiempo y esfuerzo. Con tu colaboración me puedes ayudar a mantener mi blog activo y actualizado. Si quieres y puedes apoyarme has clic aquí:

https://paypal.me/roimergarcia


ENTRADAS RECIENTES