Obteniendo la precisión intrínseca de un float

Cuando usamos números en coma flotante, no solemos pararnos a pensar en la precisión de sus valores más allá de escoger entre float o double, y a veces ni eso. Sólo queremos una variable que pueda cambiar de forma más o menos continua, pero nunca tenemos en cuenta ese "más o menos". En realidad estas variables sólo pueden cambiar a saltos, dado que su representación en memoria es finita.

Si atendemos a la especificación IEEE 754, podemos entender el significado de cada bit en la representación de coma flotante.

En ella, encontramos un espacio reservado para el signo y otro para el exponente (y su signo). Nada nuevo, ya que estos valores son enteros. Es la mantisa la que debería ser continua, pero que no lo es dada su representación finita. Fijado un signo y un exponente, se comporta como un número entero.

Cuando en ciencias nos enfrentamos a variables que pueden tomar valores continuos (distancias, temperaturas, luminosidades, etc.), los valores siempre deben venir acompañados con una incertidumbre. Si por ejemplo, medimos con una regla el ancho de un folio, no podemos decir que mide 21 cm a secas, debemos acompañarlo con la precisión de la regla, 21,0 ± 0,1 cm.

Al trabajar con medidas así presentadas, hay que aplicar ciertas reglas aritméticas. Si por ejemplo, sumamos o restamos dos medidas, las incertidumbres deberán sumarse también. Si las multiplicamos se aplica una fórmula algo más complicada, pero del todo lógica.

Sin embargo esta lógica nunca ha sido implementada en la circuitería de las unidades de coma flotante de nuestros ordenadores, por lo que tendremos que construirla nosotros mismos.

Si tras algunos cálculos tenemos un resultado final, su dígito menos significativo (el de más a la derecha) nunca es preciso, ya que hemos tenido que redondear un número real (continuo) para que quepa en una representación binaria de longitud dada. De este modo, la incertidumbre en este resultado nunca puede ser menor que el valor de esa cifra menos significativa (tal como si valiera 1 y no 0, claro).

Un pequeño código en C que obtiene esta incertidumbre mínima, debida a la representación podría ser el siguiente:

float errf(float x) {
    float nearest = x;
    void *vnp = &nearest;
    int  *inp = (int *)vnp;
    *inp ^= 1;
    return fabsf(x-nearest);
}

Esta pequeña función cambia el último dígito de la representación (el menos significativo), lo que da como resultado el siguiente número representable (dado un signo y un exponente) si este dígito era un 0, o el anterior si era un 1.

En realidad deberíamos encontrar la manera de obtener ambos (el anterior y el siguiente) y quedarnos con el intervalo más grande centrado en x, pero esto tampoco es trivial, ya que el exponente puede cambiar y nos hace depender de la representación concreta que nuestro procesador tenga de los números en coma flotante. Una implementación que me ha funcionado hasta donde puedo ver es la siguiente:

float nextf(float x) {
	// Los dos primeros bits indican los signos
	int signBit = 8 * sizeof(float) - 2;

	float next = x;
	void *nextVoidP = &next;
	int  *nextIntP = (int *) nextVoidP;

	int i;
	int mask = 1;
	int rack = 1;
	for (i = 0; i < signBit && (*nextIntP & rack) != 0; i++) {
		// Hay un 1 en esa posición
		rack <<= 1;
		mask += rack;
	}

	*nextIntP ^= mask;
	return next;
}

Para encontrar el siguiente número representable (o el anterior si x es negativo). Para encontrar el anterior (o el siguiente si x es negativo):

float prevf(float x) {
	// Los dos primeros bits indican los signos
	int signBit = 8 * sizeof(float) - 2;

	float prev = x;
	void *prevVoidP = &prev;
	int  *prevIntP = (int *) prevVoidP;

	int i;
	int mask = 1;
	int rack = 1;
	for (i = 0; i < signBit && (*prevIntP & rack) == 0; i++) {
		// Hay un 0 en esa posición
		rack <<= 1;
		mask += rack;
	}

	*prevIntP ^= mask;
	return prev;
}

Haciendo operaciones con incertidumbres

El problema con este tema, es que si uno quiere operar con valores con incertidumbres, éstas han de tenerse en cuenta. Si por ejemplo, sumamos tres veces 0,5 -que tiene un error intrínseco de errf(0,5) = 0,000000060, las incertidumbres también han de sumarse, por lo que nos quedaríamos con 1,50000000 ± 0,00000018, aún teniendo 1,5 un error intrínseco de errf(1,5) = 0,00000012.

La multiplicación, la división o la aplicación de una función arbitraria también tienen sus reglas para operar con incertidumbres. Es algo que deberíamos tener en cuenta en cada operación que hacemos: Cuando se encadenan muchas, como ocurre con frecuencia, la incertidumbre acumulada puede crecer bastante rápido, y deberíamos estar preparados para descartar un resultado cuando sobrepasa límites inaceptables.