01 · Punto de partida
Toda operación miente un poco
En el Tema 39 viste el formato: el double es una grilla finita de 53 bits donde 0,1 ni siquiera existe. Ahora viene el primo operativo: cada vez que sumás, restás, multiplicás, dividís o sacás raíz, el resultado matemático casi nunca cae justo sobre la grilla, y el hardware lo redondea al double más cercano. Un error chico, sí: como mucho medio paso de grilla. Pero tu simulación hace millones de esas operaciones seguidas (Temas 42–43).
La buena noticia: ese error tiene contrato escrito (IEEE 754): acotado, predecible y casi siempre de “redondeo correcto”. Entender el contrato es lo que separa al que depura del que reza.
- ¿Cuánto puede mentir una sola operación, en el peor caso?
- ¿Qué significa “redondeo al par” y cuándo lo notás?
- ¿Por qué
1/3y2,675no son lo que parecen? - ¿Cómo mido el error sin salir de Python?
02 · Definición
El contrato: medio ulp por operación
ulp
El paso de la grilla en tu zona: ulp(1,0) ≈ 2,2e−16, ulp(1e16) = 2,0. Todo error se mide en ulps, no en decimales.
Error ≤ ½ ulp
fl(a ∘ b) es el vecino más próximo al real. El redondeo es correcto: ninguna otra grilla está más cerca.
Al par en el empate
Si el real cae justo a mitad de dos doubles, se elige el de mantisa par. Sin sesgo sistemático hacia arriba.
| Operación exacta | Lo que guarda el double | Error |
|---|---|---|
1 / 3 | 0,3333333333333333148… | ≈ +0,16 ulp |
0,1 escrito | 0,10000000000000000555… | ≈ +0,4 ulp |
2⁵³ + 1 entero | 9007199254740992 (el par) | 1 entero = 0,5 ulp local |
03 · Anatomía
Representación, operación y empate
“Redondeo” se usa para tres momentos distintos. Mezclarlos es la mitad de los bugs de punto flotante:
- Redondeo de representación
- Al escribir
0.1o2.675ya redondeás: el literal se convierte al vecino más cercano. El error nace antes de operar. (Tema 39.) - Redondeo de operación
- Cada
+ − * / sqrtcalcula exacto por dentro (con bits extra) y redondea una sola vez al final. Error ≤ ½ ulp por operación, ni más ni menos. - Empate al par
- El caso borde “justo al medio” se resuelve hacia la mantisa par:
2⁵³+1 → 2⁵³y2⁵³+3 → 2⁵³+4. Por eso los enteros grandes se vuelven salteados (laboratorio).
Bien redondeado
Una vez, al final
El hardware opera con precisión extendida interna y redondea solo al guardar: a*b+c con fma tiene ½ ulp, sin redondeo intermedio.
Mal redondeado
Dos veces (doble redondeo)
Pasar por float32 y luego a float64 redondea dos veces: el resultado puede caer en el vecino equivocado por 1 ulp. Precisión intermedia ≠ gratis.
04 · Ejemplos
Tres redondeos que verás en tu carrera
- 1
1/3no existe.El real 0,333… cae entre dos doubles: Python guarda
0,3333333333333333148. Error de 0,16 ulp, invisible hasta que lo restás de sí mismo escalado. - 2
2,675se imprime 2,67.El literal ya es
2,67499999999999982…: redondear a 2 decimales da 2,67 aunque en el pizarrón “debería” dar 2,68. No es bug delround: es el vecino. - 3
2⁵³+1 == 2⁵³.Más allá de
9007199254740992el paso es 2: los impares no existen y redondean al par más cercano. Contadores e IDs enteros gigantes no van enfloat.
05 · Demostración en Python
Medir el medio ulp
Python expone la grilla: math.ulp te da el paso local y Fraction te da el valor exacto para comparar. Tres líneas y ves el contrato en vivo.
Python en tu navegador. Cambiá el 1/3 por 0,1 o 2,675 y mirá en qué vecino cae cada uno.
import math
from fractions import Fraction
x = 1 / 3
exacto = Fraction(1, 3)
print(repr(x))
print("ulp en 1/3:", math.ulp(x))
print("error en ulps:", float(Fraction(x) - exacto) / math.ulp(x))
Verás el vecino …3148 y un error de una fracción de ulp: redondeo correcto, ni más ni menos.
El par que se come los impares
base = 2 ** 53
for n in [base - 1, base, base + 1, base + 2, base + 3]:
print(n, "->", float(n) == float(n + 0.0), int(float(n)) - n)
06 · Exploración
Laboratorio: los enteros que desaparecen
Movete alrededor de 2⁵³ = 9007199254740992: debajo todos los enteros existen; encima el paso es 2 y los impares redondean al par más cercano. Cada barra es el error |fl(N) − N|: 0 = existe, 1 = redondeado al vecino.
fl(N) frente a N
error = |fl(N) − N| ≤ 1
Debajo de 2⁵³ todo existe; encima, la mitad redondea.
La curva es el error de cada entero N: 0 donde el entero existe en la grilla, 1 donde colapsa al par vecino. Pasado 2⁵³ el patrón 0-1-1-0 se repite.
Preguntas para explorar
- Con inicio −4 y 32 valores: ¿desde qué N el error deja de ser 0? ¿Qué tiene de especial ese N?
- Subí el inicio a +40: ¿qué fracción de enteros es representable? ¿Qué pasa con
N+1 == N? - Si un ID o un reloj de simulación vive en
floatpasado 2⁵³, ¿qué eventos se pisan?
Ver respuestas sugeridas
- En 2⁵³: hasta
2⁵³−1todo existe (ulp = 1); desde2⁵³el paso es 2 y los impares redondean (½ ulp local = 1 entero). - Solo la mitad (~50 %): los impares colapsan al par.
float(N+1) == float(N)esTruepara N impar ≥ 2⁵³: el +1 desaparece. - Dos eventos con IDs o tiempos consecutivos caen en el mismo double: el orden se pierde y los
==mienten. Relojes e IDs van en enteros (Tema 39).
07 · Comprensión
Confusiones frecuentes
«Redondeo y truncamiento son lo mismo»
No: redondear elige el vecino más cercano (error ≤ ½ ulp); truncar corta hacia cero (error hasta 1 ulp y con sesgo). El hardware redondea; truncás vos al convertir a int. Lo separamos en el Tema 41.
«El error es aleatorio y se cancela solo»
El redondeo es determinista y a veces sistemático (siempre hacia el mismo vecino). Que se cancele hay que demostrarlo con análisis, no suponerlo: la suma de mil redondeos chicos puede derivar (Tema 42).
«Con más dígitos impresos hay más precisión»
Imprimir 20 decimales no crea información: pasado el ulp 15–16, el resto son dígitos del vecino, no del valor real. Precisión la da la grilla, no el print.
«Si el error es ½ ulp, nunca importa»
Una operación no; un millón sí, y una resta de casi-iguales lo amplifica brutalmente (cancelación, Tema 43). El ½ ulp es el ladrillo: la pared la construye tu algoritmo.
08 · Práctica guiada
Ejercicios con Python
Ejercicio 1: medir tu primer ulp
Mostrá que el paso en 1,0 es ε y que 1/3 vive a una fracción de ulp del real.
import math
from fractions import Fraction
print(math.ulp(1.0))
err = float(Fraction(1 / 3) - Fraction(1, 3))
print(repr(1 / 3), err / math.ulp(1 / 3))
Ver solución razonada
ulp(1,0) ≈ 2,22e−16 y el cociente da ≈ +0,16: el vecino correcto por encima del real. Si te da negativo cerca de −0,5…+0,5, sigue siendo redondeo correcto: el signo dice de qué lado cayó.
Ejercicio 2: el impar que no existe
Verificá que 2⁵³+1 colapsa al par y que 2⁵³+3 salta al par siguiente.
base = 2 ** 53
print(int(float(base + 1)) - (base + 1))
print(int(float(base + 3)) - (base + 3))
print(float(base + 1) == float(base))
Ver solución
−1 +1 True: el +1 redondea hacia abajo (al par base) y el +3 hacia arriba (al par base+4). Empate al par en acción, no capricho.
Ejercicio 3: redondear a mano
Escribí vecinos(x) que devuelva los dos doubles adyacentes y el error de x en ulps usando Fraction como verdad.
Ver una posible respuesta
import math
from fractions import Fraction
def vecinos(x, num=1, den=10):
exacto = Fraction(num, den)
u = math.ulp(float(x))
err_ulp = float(Fraction(float(x)) - exacto) / u
abajo = math.nextafter(float(x), float("-inf"))
arriba = math.nextafter(float(x), float("inf"))
return abajo, float(x), arriba, err_ulp
print(vecinos(0.1))
Verás los dos vecinos de 0,1 y un error ≈ +0,4 ulp: el literal ya nació redondeado antes de tu primera operación.
09 · Síntesis
Ideas para recordar
- Cada operación fl() redondea al vecino más cercano: error ≤ ½ ulp.
- El error relativo por operación está acotado por ε/2 ≈ 1,1e−16.
- El empate se resuelve al par: sin sesgo hacia arriba.
- Redondear ≠ truncar: el hardware redondea, el int() trunca.
- Medí con
ulpyFraction; IDs y relojes grandes van en enteros.
En el próximo tema, el hermano sesgado del redondeo: el error de truncamiento, cuando cortamos decimales a propósito.