Aritmética finita y fuentes del error

Contenidos

Sistemas decimal y binario

En el sistema decimal el número $105.8125$ significa:

$$105.8125=1 \cdot 10^2+5\cdot 10^0+8\cdot10^{-1}+1\cdot10^{-2}+2\cdot10^{-3}+5\cdot10^{-4}$$

Es decir, es una suma cuyos términos son potencias de $10$ y utiliza $10$ dígitos $0,1,\ldots,9$.

Los ordenadores usan el sistema binario porque así los únicos dígitos que se almacenan son el $0$ y el $1$. En sistema binario los números se representan como sumas de potencias de $2$:

$$(105.8125)_{10}=2^6+2^5+2^3+2^0+2^{-1}+2^{-2}+2^{-4}=(1101001.1101)_2 \qquad (1)$$

Conversión decimal a binario

Para convertir la parte entera, dividimos por $2$ de forma repetida. Los restos de estas divisiones son los dígitos en base $2$, de menos a más significativos.

$$ \begin{array}{lrrrrrrrr} Cocientes & 105 & 52 & 26 & 13 & 6 & 3 & 1\\ Restos & 1 & 0 & 0 & 1 & 0 & 1 & \swarrow & \end{array} $$

El número $(105)_{10}$ en sistema binario es $(1101001)_2$.

Nota La función de python bin realiza esta conversión.

In [1]:
bin(105)
Out[1]:
'0b1101001'

Ejercicio 1a

Escribir una función de2bi_a(x) que transforme la parte entera de un número decimal x a binario.

La salida de la función será un string

  • Empieza con inicializando el string s = ''
  • Añade elementos a la izquierda del string con s = (str(d)) + s donde d es 0 o 1

Aplicarla al número x = 105.8125 y al x = 310.671875

NOTA: pueden ser útiles las siguientes funciones

  • np.fix(x): redondea x hacia 0.
  • a//b calcula el cociente de la división entera.
  • a%b calcula el resto de la división entera.

La estructura del programa en el fichero será

import numpy as np

def de2bi_a(x):
    ...  # <- escribe aquí tu código

def main():
    x = 105.8125
    binary1 = de2bi_a(x)
    print('La parte entera de ', x, '   en binario es ', binary1)

if __name__ == "__main__":
    main()

Así podrás importar la función debi_a del fichero Ejercicio1a.py sin que se ejecute otra vez el código contenido en main

In [2]:
%run Ejercicio1a
La parte entera de  105.8125    en binario es  1101001
La parte entera de  310.671875  en binario es  100110110

Para convertir a binario la parte decimal, multiplicamos por $2$, sustraemos la parte entera, que será nuestro dígito binario y repetimos el proceso.

$$ \begin{array}{lrrrr} Fraccionaria & 0.8125& 0.625 & 0.25 & 0.5 & 0.\\ Entera & & 1 & 1 & 0 & 1 \end{array} $$

Ejercicio 1b

Escribir una función de2bi_b(x) que transforme la parte fraccionaria de un número en base $10$ a base $2$.

La salida se almacenará en un string

  • Empieza con inicializando el string s = ''
  • Añade elementos a la derecha del string con s = s + (str(d)) donde d es 0 o 1

Limitar el número de dígitos fraccionarios en binario a 23 (truncamiento).

Aplicarla a los números x = 105.8125, x = 310.671875 y x = 14.1.

Usando las dos funciones de2bi_a(x) y de2bi_b(x) escribir el número x en binario para los dos ejemplos anteriores

La estructura del programa en el fichero será

import numpy as np
import Ejercicio1a as ej1a

def de2bi_b(x):
    ...   # <-- escribe aquí tu código           

def main():
    x = 105.8125
    binary1 = ej1a.de2bi_a(x)
    binary2 = de2bi_b(x)
    print('La parte fraccionaria de ', x, ' en binario es ', '0.'+binary2)
    print('El número ', x, 'en binario es ', binary1+'.'+binary2)

if __name__ == "__main__":
    main()
In [3]:
%run Ejercicio1b
La parte fraccionaria de  105.8125  en binario es  0.1101
El número  105.8125 en binario es  1101001.1101

La parte fraccionaria de  310.671875  en binario es  0.101011
El número  310.671875 en binario es  100110110.101011

La parte fraccionaria de  14.1  en binario es  0.00011001100110011001100
El número  14.1 en binario es  1110.00011001100110011001100

Conversión binario a decimal

De acuerdo con $(1)$ para convertir $(1101001.1101)_2$ a base $10$

In [4]:
1*(2**6)+1*(2**5)+0*(2**4)+1*(2**3)+0*(2**2)+0*(2**1)+1*(2**0)+1*(2**-1)+1*(2**-2)+0*(2**-3)+1*(2**-4)
Out[4]:
105.8125

Representación de enteros

Si tenemos $m$ dígitos o bits de memoria podemos almacenar $2^m$ números diferentes en binario.

Enteros positivos

Si sólo tomamos enteros positivos podríamos representar enteros con valores entre

$$ (00\ldots 00)_2=(0)_{10} \quad \mathrm{y} \quad (11\ldots 11)_2=(2^m-1)_{10}.$$

Por ejemplo, para $m=3$ bits podríamos representar los enteros del $0$ al $7$.

$$ \begin{array}{|c|c|} \hline Base \; 10 & Base \; 2\\ \hline \mathtt{0} & \mathtt{000}\\ \mathtt{1} & \mathtt{001}\\ \mathtt{2} & \mathtt{010}\\ \mathtt{3} & \mathtt{011}\\ \mathtt{4} & \mathtt{100}\\ \mathtt{5} & \mathtt{101}\\ \mathtt{6} & \mathtt{110}\\ \mathtt{7} & \mathtt{111}\\ \hline \end{array} $$

Enteros con signo

Si queremos representar enteros con signo reservaremos el primer bit para el signo y entonces tendremos espacio para $m-1$ cifras significativas. Es decir $2^{m-1}-1$ números positivos, otros tantos negativos y dos ceros, uno con signo positivo y otro negativo. El rango de números representados sería de $[-2^{m-1}+1, 2^{m-1}-1]$. Si $m=3$ podremos representar números de $-3$ a $3$.

$$ \begin{array}{|c|c|} \hline Base \; 10 & Base \; 2\\ \hline \mathtt{-3} & \mathtt{111}\\ \mathtt{-2} & \mathtt{110}\\ \mathtt{-1} & \mathtt{101}\\ \mathtt{-0} & \mathtt{100}\\ \mathtt{+0} & \mathtt{000}\\ \mathtt{+1} & \mathtt{001}\\ \mathtt{+2} & \mathtt{010}\\ \mathtt{+3} & \mathtt{011}\\ \hline \end{array} $$

El primer bit es el signo, $0$ para los positivos y $1$ para los negativos.

Ejemplo

Calcular cómo se almacenará $(-80)_{10}$ si usamos representación binaria con una precisión de 8-bits con signo.

Solución: como es un número negativo, la representación es la misma que en el caso anterior pero el primer bit es el signo y como es negativo, es $1$. Es decir, $(-80)_{10}=(11010000)_2$

Representación al complemento de dos

Una forma de evitar la doble representación del cero es usar la representación al complemento de dos: los números negativos se construyen tomando los dígitos de los positivos y cambiando los $0$ a $1$ y los $1$ a $0$ y sumándole $1$. De esta manera la suma de un número y su opuesto es siempre $0$.

$$ \begin{array}{|c|c|} \hline Base \; 10 & Base \; 2\\ \hline \mathtt{-4} & \mathtt{100}\\ \mathtt{-3} & \mathtt{101}\\ \mathtt{-2} & \mathtt{110}\\ \mathtt{-1} & \mathtt{111}\\ \mathtt{0} & \mathtt{000}\\ \mathtt{1} & \mathtt{001}\\ \mathtt{2} & \mathtt{010}\\ \mathtt{3} & \mathtt{011}\\ \hline \end{array} $$

Ejemplo

Para representar $(-2)_{10}$ empezamos convirtiendo $(2)_{10}$ a binario $(010)_2$

  • invertimos sus dígitos $\rightarrow (101)_2$
  • le sumamos $001$ (tener en cuenta que en binario $0+0=0 \quad 0+1=1 \quad 1+1=10$) $\rightarrow (110)_2$

Si sumamos $(010)_2+(110)_2=(000)_2$

En este caso, el primer bit de los negativos es $1$ y el de los positivos es $0$. Hemos representado enteros en el rango de $[-2^{m-1},2^{m-1}-1]$.

Ejemplo

Calcular cómo se almacenará $(-80)_{10}$ si usamos representación binaria representación al complemento de dos con una precisión de 8-bits.

Solución: como $(80)_{10}=(01010000)_2$

  • en el número anterior cambiamos los $0$ por $1$ y viceversa $\rightarrow 10101111 $
  • y sumándole $1$ ya tenemos la representación $(-80)_{10}=(1011000)_2$

Representación sesgada

Otra forma es la representación sesgada. Los números negativos se representan con valores positivos consecutivos, empezando por el menor negativo, y los positivos toman los valores siguientes. La representación de los números la obtenemos añadiendo el sesgo $2^{m-1}$ al número $x_r=x+2^{m-1}\in[0,2^m-1]$.

$$ \begin{array}{|c|c|} \hline Base \; 10 & Base \; 2\\ \hline \mathtt{-4} & \mathtt{000}\\ \mathtt{-3} & \mathtt{001}\\ \mathtt{-2} & \mathtt{010}\\ \mathtt{-1} & \mathtt{011}\\ \mathtt{0} & \mathtt{100}\\ \mathtt{1} & \mathtt{101}\\ \mathtt{2} & \mathtt{110}\\ \mathtt{3} & \mathtt{111}\\ \hline \end{array} $$

De nuevo vemos que hemos representado enteros en el rango de $[-2^{m-1},2^{m-1}-1]$.

¿De cual de las formas descritas se almacenan los enteros? La mayor parte de las máquinas almacenan los enteros con la representación del complemento al dos y los exponentes de los números en punto flotante, que son enteros, con representación sesgada. ¿Por qué? Hay varios motivos. Uno de ellos es que los números con representación sesgada son más fáciles de comparar. Los códigos comparan frecuentemente números reales, y se empieza comparando el exponente y sólo si son iguales se compara la mantisa.

Representación de enteros en Python

Las variables numéricas en Python pueden ser integers, floating point y complex numbers. Las variables booleanas son un subtipo de los enteros. Los enteros no tienen precisión límite en Python 3.

Por ejemplo, en representación sesgada el mayor entero viene dado por $2^{m-1}-1$. Si $m=64$ es el número de bits el máximo entero sería

In [5]:
m = 64
print(2**(m-1)-1)
print(type(2**(m-1)-1))
9223372036854775807
<class 'int'>

Si $m=128$ fuera el número de bits el máximo entero sería

In [6]:
m = 128
print(2**(m-1)-1)
print(type(2**(m-1)-1))
170141183460469231731687303715884105727
<class 'int'>

Para $m=256$

In [7]:
m = 256
print(2**(m-1)-1)
print(type(2**(m-1)-1))
57896044618658097711785492504343953926634992332820282019728792003956564819967
<class 'int'>

Representación de los números reales

En el caso de los números reales se utiliza una representación en punto flotante en base $\beta=2$.

$$x_r=(-1)^s \cdot m \cdot \beta^e=(-1)^s \cdot (a_0.a_1a_2\ldots a_t) \cdot \beta^e$$

donde:

  • $s$ es el signo. Se almacena un $1$ si el número es negativo y $0$ si es positivo.
  • $m$ es la mantisa que si está normalizada tiene un valor $1\leq m < \beta$ y el primer dígito ha de ser distinto de $0$. Este dígito en base dos solo puede ser $1$ y por lo tanto, en una mantisa normalizada siempre es $a_0=1$, no es necesario almacenarlo y ganamos un bit. Esto se llama técnica del bit escondido.
  • $e$ es el exponente que es un entero con signo y utiliza representación binaria sesgada.

Los números se almacenan en "palabras" de 32 bits (precisión sencilla), 64 bits (precisión doble), o incluso 128 bits (precisión cuádruple). En casi todas las máquinas, la precisión en Python para el tipo float es la precisión doble. En el caso de precisión doble los bits se reparten:

  • 1 bit para el signo.
  • 11 bits para el exponente.
  • 52 bits para la mantisa.
In [8]:
%run Doble_precision.py

Si tenemos $11$ bits para el exponente con signo, significa que tenemos espacio para $2^{11}=2048$ números en formato binario. Si empezamos en el cero, el $ 0 \le E \le 2047 $.

El primer valor $00000000000$ lo reservamos para cero y números desnormalizados y el último $11111111111$ para Inf y NaN. Así que el rango de valores que puede tomar el exponente queda reducido a $ 1 \le E \le 2046 $.

Y como el sesgo es $2^{m-1}-1=2^{10}-1=1024-1=1023$ estos valores representan a los exponentes $ -1022 \le E - 1023 \le 1023 $.

Por lo tanto el exponente máximo es $e_{max}=1023$, y el mínimo $e_{min}=-1022$. Si dividimos por el valor mínimo:

$$\frac{1}{x_{min}}=\frac{1}{m\beta^{e_{min}}}=\frac{1}{m\beta^{-1022}}=\frac{1}{m}\beta^{1022}<\frac{1}{m}\beta^{1023}$$

y no se supera el valor máximo (overflow).


Ejercicio 2

Escribir un programa que calcule la representación de los números los números 105.8125, 120.875, 7.1 y -1.41 en precisión simple, según la norma IEEE 754, utilizando las funciones del ejercicio 1. Considerar únicamente el caso en el que la parte entera es mayor que cero. Redondear por truncamiento.

Nota: Recordar que en precisión simple se utilizan 32 bits, de los cuales, uno es para el signo, 8 para el exponente y el resto para la mantisa. El sesgo del exponente es 127.

In [9]:
%run Ejercicio2
105.8125 --->   1101001.1101
[Signo]:   0  [Exponente]:  10000101  [Mantisa]:  10100111010000000000000
 
120.875  --->   1111000.111
[Signo]:   0  [Exponente]:  10000101  [Mantisa]:  11100011100000000000000
 
7.1      --->   111.00011001100110011001100
[Signo]:   0  [Exponente]:  10000001  [Mantisa]:  11000110011001100110011
 
-1.41    --->   1.01101000111101011100001
[Signo]:   1  [Exponente]:  01111111  [Mantisa]:  01101000111101011100001

Las características del tipo float en nuestra máquina nos las da la orden sys.float_info

In [10]:
import sys
sys.float_info
Out[10]:
sys.float_info(max=1.7976931348623157e+308, max_exp=1024, max_10_exp=308, min=2.2250738585072014e-308, min_exp=-1021, min_10_exp=-307, dig=15, mant_dig=53, epsilon=2.220446049250313e-16, radix=2, rounds=1)

Vemos que el número de dígitos de la mantisa, mant_dig es 53 (52 bits pero 53 dígitos debido al bit escondido). Es decir, el float que usa Python por defecto es el de 64 bits.

Pero si observamos esta salida dice

max_exp=1024 y min_exp=-1021

que no concuerda con lo dicho arriba.

Pero si ejecutamos

help(sys.float_info)

entre otras cosas obtenemos

| max_exp | DBL_MAX_EXP -- maximum int e such that radix**(e-1) is representable

| min_exp | DBL_MIN_EXP -- minimum int e such that radix**(e-1) is a normalized float


Ejercicio 3

El mayor número normalizado que puede representar python en precisión doble será, en representación binaria

$$(+1)\cdot (1.11\ldots 11) \cdot 2^{1023}$$

Como habíamos dicho, el primer $1$ no hace falta guardarlo y nos quedan 52 bits donde almacenamos los unos de $0.11\ldots 11$. Por lo tanto, en decimal este número será:

$$(1+1\cdot 2^{-1}+1\cdot 2^{-2}+1\cdot 2^{-3}+\cdots+1\cdot 2^{-52})\cdot 2^{1023}$$

Hacer un programa Ejercicio4a.py que calcule esta suma. Como norma general, conviene sumar los elementos de la serie de menores a mayores (más adelante veremos por qué). Su valor tiene que coincidir con el valor obtenido con la orden python sys.float_info.max. Llamar sal al valor de salida.

In [11]:
import sys

%run Ejercicio3
print ('%.16e' % sal)      # forma exponencial con 16 decimales
1.7976931348623157e+308
In [12]:
sys.float_info.max
Out[12]:
1.7976931348623157e+308

Comprobamos que, efectivamente, son iguales

In [13]:
print(sal == sys.float_info.max)
True

El valor real positivo mínimo representable en punto flotante de forma normalizada es

$$(+1)\cdot (1.00\ldots 00) \cdot 2^{-1022}$$
In [14]:
sal = 2**-1022
print('%.16e' % sal)
2.2250738585072014e-308

Su valor tiene que coincidir con orden el valor de salida de python sys.float_info.min.

In [15]:
sys.float_info.min
Out[15]:
2.2250738585072014e-308

Mayor entero representable exactamente de forma normalizada tal que todos los enteros positivos menores se pueden almacenar de forma exacta en forma normalizada

El mayor entero consecutivo, utilizando el formato en punto flotante, del que podemos almacenar todos los dígitos significativos y que, por tanto, podemos almacenar de forma exacta en binario podría ser:

$$(+1)\cdot (1.11\ldots 11) \cdot 2^{52}$$

Es decir, tenemos $53$ cifras de precisión en binario. El entero siguiente:

$$(+1)\cdot (1.00\ldots 00) \cdot 2^{53}$$

también podemos almacenarlo de forma exacta. Pero el siguiente a este ya no porque necesitaríamos un bit más a la derecha en la mantisa. Este entero es

In [16]:
print(2**53)
9007199254740992

Todos los enteros menores se pueden almacenar de forma exacta. Si contamos, vemos que tiene 16 dígitos. Por lo tanto, podemos almacenar todos los enteros con 15 dígitos decimales y casi todos los enteros con 16 dígitos de forma exacta.

Pero, podría decirse, el número

$$(+1)\cdot (1.11\ldots 11) \cdot 2^{62}$$

es más grande y también es entero. Sin embargo sus $10$ últimas cifras serían forzosamente $0$ porque no las podemos almacenar y desde el punto de vista de la precisión no cuentan porque no les podemos dar el valor que queramos.

Valores desnormalizados

¿Qué sucede con los valores de los exponentes fuera del rango $[-1022,1023]$?

Si el exponente es menor que $-1022$ , el número es desnormalizado o cero y estamos usando para los bits correspondientes al exponente el valor especial $00000000000$. Entonces ya no se asume que el bit escondido es $1$ sino $0$.

In [17]:
x = 0.5*(2**-1023)   
print(x)               # desnormalizado
print(1/x)             # su inverso
5.562684646268003e-309
inf
In [18]:
print(2**-1080 )       # Underflow
0.0

El menor valor desnormalizado es

$$(+1)(0.000\ldots 01)\times 2^{-1022}$$

es decir, $2^{-1022-52}$

In [19]:
num_min = 2.**(-1022-52)
print(num_min)
5e-324

Si el valor es menor, devuelve es Underflow y devuelve cero.

In [20]:
2.**(-1022-53)    # Underflow
Out[20]:
0.0

Si el exponente es mayor que $1023$ devuelve OverflowError.

In [21]:
2.**1024          # Overflow
---------------------------------------------------------------------------
OverflowError                             Traceback (most recent call last)
/tmp/ipykernel_5429/1756102542.py in <module>
----> 1 2.**1024          # Overflow

OverflowError: (34, 'Numerical result out of range')

Precisión de la máquina

Vamos a calcular el número más pequeño que se puede representar después de $1$ utilizando la representación binaria en punto flotante con doble precisión:

El número real $1$ en representación en punto flotante de doble precisión normalizada (con $52$ ceros en la mantisa) es

$$(+1)\cdot (1.00\ldots 00) \cdot 2^{0}$$

y no podemos representar, de forma exacta en punto flotante, un número real entre los dos. La diferencia entre $1$ y $1+\epsilon$ es

$$\epsilon=(+1)\cdot (0.00\ldots 01) \cdot 2^{0}$$

que pasado a decimal es

In [22]:
1*2.**(-52)
Out[22]:
2.220446049250313e-16

o lo que es lo mismo

In [23]:
sys.float_info.epsilon
Out[23]:
2.220446049250313e-16

Este valor, el menor número $\epsilon$ tal que $1+\epsilon>1$ se llama precisión de la máquina y es una cota del error relativo que cometemos al almacenar cuanlquier número real en punto flotante, en este caso, en doble precisión.

Como, en doble precisión, $\epsilon\approx 2.22\cdot 10^{-16}$ se puede decir que corresponde, aproximadamente, a $16$ cifras decimales.

Si queremos el número más pequeño comparable con $x$ hay que sumarle un valor eps

In [24]:
x = 1.
eps = np.spacing(x)
print(eps)
2.220446049250313e-16
In [25]:
x = 10.
eps = np.spacing(x)
print(eps)
1.7763568394002505e-15
In [26]:
x = 100.
eps = np.spacing(x)
print(eps)
1.4210854715202004e-14

Y vemos que la diferencia entre dos números consecutivos representables de forma exacta en punto flotante, aumenta conforme nos alejamos del cero. Por ello, la densidad de números representados en punto flotante no es uniforme. Es mayor cerca del cero y menor a medida que nos alejamos.

Resultados especiales

Algunas operaciones dan resultados especiales

In [27]:
x = 1.e200
y = x*x
print(y)
inf
In [28]:
y/y
Out[28]:
nan

nan significa "not a number". Su aparición significa, en general, que algo ha ido mal con el programa y/o que se ha realizado una operación no válida.

Overflow. Ocurre cuando el resultado de la operación realizada es finita pero el resultado supera el rango de reales que pueden ser representados.

In [29]:
x = 1.e300
x**2
---------------------------------------------------------------------------
OverflowError                             Traceback (most recent call last)
/tmp/ipykernel_5429/3996900289.py in <module>
      1 x = 1.e300
----> 2 x**2

OverflowError: (34, 'Numerical result out of range')

Aproximación y error de redondeo

Redondeo

A menudo, para un número real $x$, no exite una representación exacta en punto flotante y cae entre dos números racionales consecutivos $x^- \lt x \lt x^+$. Como representación de $x$ elegiremos uno de los dos dependiendo del método de redondeo usado. IEEE propone 5 sistemas de redondeo:

  • Hacia arriba.
  • Hacia abajo.
  • Hacia cero ("truncamiento").
  • Hacia el más alejado de cero más cercano.
  • Hacia el par más cercano.

Este último método de redondeo es el más utilizado.

Pérdida de dígitos

Supongamos que estamos usando un sistema de representación decimal que almacena 3 cifras significativas y queremos sumar los números $a=123$ y $b=1.25$. El resultado es $s=124.25$ pero como solo podemos almacenar tres cifras redondeamos, por ejemplo, a $s_r=124$. Hemos perdido cifras y estamos cometiendo un error. En general, cualquier operación aritmética conlleva errores de redondeo.

Ejemplo

Si realizamos la operación $3\times 0.1 \times 100$:

In [30]:
3*0.1*100
Out[30]:
30.000000000000004

El resultado no es $30$ debido al error de redondeo. Estos se pueden propagar y afectar hasta invalidar los resultados. En el caso anterior el error se debe a que, como vimos al principio, el número $x=0.1$ en base dos es periódico y por ello su representación en el ordenador no es exacta.

Ejemplo

Si calculamos $$\sum_{k=1}^{10000} 0.1$$

In [31]:
suma = 0.
for i in range(10000):
    suma += 0.1
print ('%.16f'% suma)      # En forma decimal con 16 decimales
1000.0000000001588205

y el error absoluto es

In [32]:
abs(suma-1000.)
Out[32]:
1.588205122970976e-10

Ejemplo

Si sumamos o restamos dos números muy diferentes en magnitud se pierde exactitud debido a los errores de redondeo. Por ejemplo

In [33]:
a = 1.e+9
epsilon = 1.e-8

suma = a
for i in range(10000):
    suma += epsilon
print('%.16e'% suma)       # En forma exponencial con 16 decimales
1.0000000000000000e+09

Sin embargo el resultado debería haber sido

In [34]:
suma = a + 10000*epsilon
print('%.16e'% suma)
1.0000000000001000e+09

Ejemplo

Vamos a sumar los $N$ primeros términos de la serie armónica $$\sum_{n=1}^N \frac{1}{n}$$

El resultado teórico para 1000 términos sería

In [35]:
from scipy import special
N = 10000
suma = special.polygamma(0,N+1)-special.polygamma(0,1)
print('%.20f'% suma)
9.78760603604438372827

Empezando la suma por el término $n=1$

In [36]:
suma1 = 0.;
for n in range(1,N+1):
    suma1 += 1./n
print('%.20f'% suma1)
9.78760603604434820113
In [37]:
error1 = abs(suma1 - suma)
print(error1)
3.552713678800501e-14

Empezando por el término final

In [38]:
suma2 = 0.;
for n in range(N,0,-1):
    suma2 += 1./n
print('%.20f'% suma2)
9.78760603604438550462

La diferencia entre ambos resultados es

In [39]:
error2 = abs(suma2 - suma)
print(error2)
1.7763568394002505e-15

Es mayor el error en el caso primero porque los términos de la sucesión son cada vez más pequeños mientras que la suma es cada vez más grande. Por lo tanto, conforme avanzamos en la suma se producirá pérdida de dígitos por sumar números de magnitud muy distinta.

Error de cancelación

Al restar números muy parecidos usando aritmética finita donde, obviamente usamos la misma precisión, se produce el error de cancelación. Un ejemplo, suponiendo base decimal y precisión 7, si restamos dos números muy parecidos:

\begin{array}{cc} & \mathtt{1.453417}\\ - & \mathtt{1.453416}\\ \hline & \mathtt{0.000001} \end{array}

pasamos de tener precisión 7 a tener precisión 1 porque sólo nos queda una cifra significativa. Podemos añadir las demás cifras como ceros, pero realmente desconocemos el valor que tienen, porque tanto el minuendo como el sustraendo se supone que son aproximaciones de números almacenados o resultados de operaciones cuyo número de cifras reales pueden ser muchas más.

Ejemplo

Si usamos la fórmula

$$\sqrt{x^2+\epsilon^2}-x \quad \mathrm{con} \quad x \gg \epsilon$$

tendremos un error de cancelación. Es decir, si usamos esta fórmula, tendremos un error grande

In [40]:
import numpy as np
x  = 1000000.
ep = 0.01
a  = np.sqrt(x**2 + ep**2) - x
print(a)
1.1641532182693481e-10

En este caso el error de calcelación se puede evitar utilizando una expresión matemática equivalente

$$\sqrt{x^2+\epsilon^2}-x=\frac{(\sqrt{x^2+\epsilon^2}-x)(\sqrt{x^2+\epsilon^2}+x)}{\sqrt{x^2+\epsilon^2}+x}=\frac{\epsilon^2}{\sqrt{x^2+\epsilon^2}+x}\approx \frac{\epsilon^2}{\sqrt{x^2}+x}=\frac{\epsilon^2}{2x} $$
In [41]:
b = ep**2 / (np.sqrt(x**2 + ep**2) + x)
print(b)
5e-11

O bien

In [42]:
b = ep**2 / (2*x)
print(b)
5e-11

El error relativo cometido al calcularlo con la primera expresión es muy grande

In [43]:
Er = abs((a - b)/b)
print(Er)
1.328306436538696

Además, este ejemplo muestra que expresiones matemáticamente equivalentes no son, necesariamente, equivalentes desde el punto de vista computacional. Y viceversa, que expresiones matemáticas que no son iguales, pueden tener el mismo resultado en el ordenador y ser, por tanto, computacionalmente equivalentes.

Ejemplo

Al resolver la ecuación de segundo grado

$$x^2+10^{8}x+1=0$$

utilizando la fórmula

$$x_1=\frac{-b+\sqrt{b^2-4ac}}{2a}\qquad (1)$$

como

$$a=1,\,b=10^{8},\:c=1\qquad b^{2}\gg 4ac$$

Y es de esperar que tengamos un error de cancelación. La solución que obtenemos es

In [44]:
a = 1.; b = 10.**8; c = 1.
x11 = (-b + np.sqrt(b**2 - 4*a*c)) / (2*a)
print(x11)
-7.450580596923828e-09

Pero al sustituir en el polinomio para calcular el residuo

In [45]:
residuo11 = a*x11**2 + b*x11 + c
print(residuo11)
0.2549419403076172

es muy grande comparado con la solución. No es una buena solución porque deberíamos haber obtenido

$$ax_{1}^{2}+bx_{1}+c\approx0$$

y tenemos

$$ax_{1}^{2}+bx_{1}+c\approx0.255$$

Reescribimos la fórmula

$$x_1=\frac{-b+\sqrt{b^2-4ac}}{2a}=\frac{1}{2a}\left(\sqrt{b^2-4ac}-b\right)$$

Multiplicamos y dividimos por la diferencia

$$x_1=\frac{1}{2a}\left(\sqrt{b^2-4ac}-b\right)\frac{\sqrt{b^2-4ac}+b}{\sqrt{b^2-4ac}+b}=\frac{1}{2a}\frac{\left(\sqrt{b^2-4ac}-b\right)\left(\sqrt{b^2-4ac}+b\right)}{\sqrt{b^2-4ac}+b}$$

como $(x+y)(x-y)=x^2-y^2$

$$x_1=\frac{1}{2a}\frac{(b^2-4ac)-b^2}{\sqrt{b^2-4ac}+b}=\frac{1}{2a}\frac{-4ac}{\sqrt{b^2-4ac}+b}$$

Y la fórmula siguiente es equivalente a (1) y no tenemos el problema del error de cancelación

$$x_1=\frac{-2c}{\sqrt{b^2-4ac}+b}$$

Como $b^{2}\gg 4ac$ podemos aproximar con $\sqrt{b^2-4ac}\approx \sqrt{b^2} \approx b$ y

$$x_1=\frac{-2c}{b+b}=-\frac{c}{b}$$
In [46]:
x12 = -c/b
residuo12 = a*x12**2 + b*x12 + c 
print(x12)
print(residuo12)
-1e-08
1.1102230246251565e-16

Ahora el residuo es mucho más pequeño que la solución


Ejemplo

Al resolver la ecuación de segundo grado

$$x^2-10^{8}x+1=0$$

utilizando la fórmula

$$x_2=\frac{(-b)-\sqrt{b^2-4ac}}{2a}\qquad (2)$$

como

$$a=1,\,b=-10^{8},\:c=1\qquad b^{2}\gg 4ac$$

como $b$ es negativo, $-b$ es positivo y $-\sqrt{b^2-4ac}$ es negativo y con un valor próximo a $-b$ se producirá un error de cancelación. La solución que obtenemos es

In [47]:
a = 1.; b = -10.**8; c = 1
x21 = (- b - np.sqrt(b**2 - 4*a*c)) / (2*a)
print(x21)
7.450580596923828e-09

Pero al sustituir en el polinomio

In [48]:
residuo21 = a*x21**2 + b*x21 + c
print(residuo21)
0.2549419403076172

es relativamente grande.

Reescribimos la fórmula

$$x_2=\frac{(-b)-\sqrt{b^2-4ac}}{2a}=\frac{1}{2a}\left((-b)-\sqrt{b^2-4ac}\right)$$

Teniendo en cuenta que si $b$ es negativo $-b$ es positivo y que $-\sqrt{b^2-4ac}$ es siempre negativo, la fórmula $(-b)-\sqrt{b^2-4ac}$ con $b^{2}\gg4ac$ dará un error de cancelación.

Multiplicamos y dividimos por la suma

$$x_2=\frac{1}{2a}\left((-b)-\sqrt{b^2-4ac}\right)\frac{(-b)+\sqrt{b^2-4ac}}{(-b)+\sqrt{b^2-4ac}}=\frac{1}{2a}\frac{\left((-b)-\sqrt{b^2-4ac}\right)\left((-b)+\sqrt{b^2-4ac}\right)}{(-b)+\sqrt{b^2-4ac}}$$

Como $(x+y)(x-y)=x^2-y^2$

$$x_2=\frac{1}{2a}\frac{(-b)^2-(b^2-4ac)}{(-b)+\sqrt{b^2-4ac}}=\frac{1}{2a}\frac{4ac}{(-b)+\sqrt{b^2-4ac}}$$

Y la fórmula siguiente es equivalente matemáticamente a la fórmula (2) pero no tiene el problema del error de cancelación (el denominador es la suma de dos números positivos porque $b$ es negativo)

$$x_2=\frac{2c}{(-b)+\sqrt{b^2-4ac}}$$

Podemos simplificar la fórmula teniendo en cuenta que como $b^{2}\gg4ac$ podemos decir que $\sqrt{b^2-4ac}\approx \sqrt{b^2} = |b|=-b$

$$x_2\approx\frac{2c}{(-b)+(-b)}=-\frac{c}{b}$$
In [49]:
x22 = -c/b
residuo22 = a*x22**2 + b*x22 + c
print(x22)
print(residuo22)
1e-08
1.1102230246251565e-16

Fuentes de error

Hay dos tipos de métodos numéricos:

  • Métodos directos. Llegamos a la solución en un número finito de pasos. Por ejemplo, la solución de una ecuación de segundo grado que acabamos de ver.
  • Métodos iterativos. Generamos una secuencia de soluciones aproximadas que tienden a la solución exacta.

Dependiendo de la fuente podemos tener:

  1. Errores de redondeo. Afecta a todos los métodos si usamos aritmética finita.
  2. Errores del método. Solo afecta a los métodos iterativos, no a los directos.
  3. Errores en los datos. Afectan a todos los métodos.

Error de truncamiento

Es un error del método. Es el error que obtenemos cuando aplicamos un método iterativo. En lugar de generar infinitos términos de la sucesión que tiende a la solución exacta, calculamos solo un número finito de elementos de la sucesión y asumimos que la solución tiene un error pero que es "suficientemente buena."

Ejemplo

Tenemos que

$$e^x=\sum_{n=0}^{\infty}\frac{x^n}{n!}$$

Si calculamos un número infinito de términos (y usamos precisión infinita) tendremos la solución exacta. Pero si solo calculamos un número finito de términos tendremos un error de truncamiento.

Aproximemos el valor de $e$, usando $x=1$, con un número finito de términos de la suma, por ejemplo, $50$

In [50]:
suma = 0.
x = 1.
fact = 1.

for n in range(100):
    suma += x**n / fact
    fact *= n+1
    if n in np.array([5,10,15,20,40,60,80,100])-1:
        error   = abs(np.exp(1.) - suma)
        print("Número de términos        %i" % (n+1))
        print("Error                     %.16e\n" % error)
Número de términos        5
Error                     9.9484951257120535e-03

Número de términos        10
Error                     3.0288585284310443e-07

Número de términos        15
Error                     8.1490370007486490e-13

Número de términos        20
Error                     4.4408920985006262e-16

Número de términos        40
Error                     4.4408920985006262e-16

Número de términos        60
Error                     4.4408920985006262e-16

Número de términos        80
Error                     4.4408920985006262e-16

Número de términos        100
Error                     4.4408920985006262e-16

Vemos que primero el error disminuye y luego se estanca. ¿Por qué? Si estuviéramos usando aritmética infinita el error disminuiría con las iteraciones. Pero como estamos usando aritmética finita, el error, en este caso, está limitado por el espaciado entre números representables en la zona de la solución $e$ que es

In [51]:
Ea = np.spacing(np.exp(1.))
print (Ea)
4.440892098500626e-16

Calculemos el menor número de términos necesarios para obtener el número $e$ con el menor error posible.

In [52]:
Ea = np.spacing(np.exp(1.))
n = 0
Sum = 1.
fact = 1.

itermax = 100
error   = np.abs(np.exp(1.) - Sum)

while (error > Ea and n < itermax):
    n += 1
    Sum += 1. / fact
    fact *= n+1
    error = np.abs(np.exp(1.) - Sum)
    
print("Número de iteraciones     %i" % n)
print("Error                     %.16e" % error)
print("Espaciado alrededor de e  %.16e" % Ea)
Número de iteraciones     17
Error                     4.4408920985006262e-16
Espaciado alrededor de e  4.4408920985006262e-16

Y aunque sabemos que con $17$ iteraciones todavía cometemos error, este ya no es apreciable por la máquina y aunque realicemos más iteraciones este error computado no disminuye.


Ejercicio 4

Teniendo en cuenta que

$$\log (1+x) = \dfrac{x^1}{1}-\dfrac{x^2}{2}+\dfrac{x^3}{3}-\dfrac{x^4}{4}+\dfrac{x^5}{5}-\cdots \quad \mathrm{para} \quad |x|\lt 1$$

calcular el número de términos necesarios para calcular $\log 1.5$ con el menor error posible.

In [53]:
%run Ejercicio4
print("Número de iteraciones            %i" % n)
print("Error                            %.16e" % error)
print("Espaciado alrededor de ln(1.5)   %.16e" % Ea)
Número de iteraciones            45
Error                            5.5511151231257827e-17
Espaciado alrededor de ln(1.5)   5.5511151231257827e-17

Ejercicio 5

Escribe un programa que calcule el menor número de términos necesarios para obtnener $f(x_0)$ con el menor error posible.

(a)

$$\mathrm{sen} x=\sum_{n=0}^{\infty}\frac{(-1)^n}{(2n+1)!}x^{2n+1}=x-\frac{x^3}{6}+\frac{x^5}{120}-\cdots\quad \quad x_0=\frac{\pi}{3}$$

(b)

$$\cos x=\sum_{n=0}^{\infty}\frac{(-1)^n}{(2n)!}x^{2n}=1-\frac{x^2}{2}+\frac{x^4}{24}-\cdots\quad \quad x_0=\frac{\pi}{4}$$

(c)

$$\mathrm{arcsen} x=\sum_{n=0}^{\infty}\frac{(2n)!}{4^n(n!)^2(2n+1)}x^{2n+1}=x+\frac{x^3}{6}+\frac{3x^5}{40}+\cdots\quad \quad x_0=0.7$$
In [54]:
%run Ejercicio5
-----------------------  a   -----------------------

Número de iteraciones         8
Error                         1.1102230246251565e-16
Espaciado alrededor de f(x0)  1.1102230246251565e-16
f(x0)                         8.6602540378443860e-01

-----------------------  b   -----------------------

Número de iteraciones         8
Error                         1.1102230246251565e-16
Espaciado alrededor de f(x0)  1.1102230246251565e-16
f(x0)                         7.0710678118654757e-01

-----------------------  c   -----------------------

Número de iteraciones         40
Error                         1.1102230246251565e-16
Espaciado alrededor de f(x0)  1.1102230246251565e-16
f(x0)                         7.7539749661075297e-01

Referencias