Saltar al contenido

lección 6

Tipos que mienten

Ordenas por importe y el 9 sale por encima del 100: qué pasa cuando una columna es texto sin que lo sepas, y por qué se arregla al leer.

⏱ 55 min

Ordenas la tabla de pedidos por importe de mayor a menor para ver los diez más grandes. Arriba sale un pedido de 9 €. Más abajo, el de 1.200. El de 100 aparece en el último puesto. Vuelves a ejecutarlo y pasa lo mismo.

No es un fallo de pandas ni un problema de la ordenación: esa columna no contiene números, contiene texto, y el texto se ordena letra a letra igual que en un diccionario. Los tres problemas anteriores estaban en el valor de una celda —que faltaba, que estaba repetido, que se salía—. Este está en el envoltorio, y es el único que hace que la aritmética deje de ser aritmética.

Sigue la línea ámbar: el 9 es el primero como texto y el penúltimo como número. Y el 100, que es el mínimo de la izquierda, está en la mitad de la derecha.

La comparación de texto va carácter a carácter y se detiene en el primero que difiere. Al comparar «9» con «100» mira el 9 contra el 1, decide que el 9 es mayor y ya no sigue: la longitud no interviene. Por eso el máximo de esa columna es un pedido de 9 €.

## Una celda mala convierte la columna entera

Una columna de pandas es homogénea: todas sus celdas son del mismo tipo. Así que basta una celda que no sea un número para que la columna entera pase a ser texto, y pandas lo decide solo al leer el fichero, sin preguntar. El tipo que le pone se llama object, y significa «texto, o una mezcla de cosas».

Los culpables son siempre los mismos y caben en una lista: una celda que dice «pendiente» o «N/A», el punto de los miles, el símbolo del euro, un guion para decir que no hay dato, un espacio que nadie ve. Con 400.000 filas correctas y una así, la columna es texto y todas las cuentas cambian de significado.

1import pandas as pd
2
3pedidos = pd.DataFrame({
4 "pedido_id": [1, 2, 3, 4],
5 "importe": ["1.234,56", "890,00", "pendiente", "45,20"],
6 "fecha": ["03/04/2024", "15/04/2024", "28/04/2024", "01/05/2024"],
7 "cp": ["08001", "28004", "41013", "03001"],
8})
9
10print(pedidos.dtypes)

El primer print de cualquier tabla nueva, antes de calcular nada.

Un .dtypes antes de cualquier cuenta cuesta un segundo y ahorra la mitad de los problemas de este tema. Y la señal de alarma concreta que hay que buscar: una columna que debería ser numérica y sale como object. Ahí hay una celda con algo dentro, y merece la pena encontrarla antes de convertir nada.

Conviene tener claro desde el principio qué no arregla un tipo. Convertir bien una columna no valida su contenido: un importe convertido a número puede seguir siendo un error de teclado, y una fecha convertida puede seguir siendo el día equivocado. El tipo es el envoltorio, y lo de dentro es el trabajo de las tres lecciones anteriores. Lo que consigue este paso es que las cuentas signifiquen lo que crees, no que los valores sean ciertos.

## Tres síntomas de un número que es texto

El primero ya lo has visto: el orden sale raro. El segundo es que sumar concatena, porque sumar dos textos en Python es pegarlos uno detrás de otro. Y el tercero es el único amable de los tres: comparar con un número lanza un error y para el programa.

1import pandas as pd
2
3importe = pd.Series(["9", "100", "25", "1200", "8", "340"])
4
5print("Ordenado:", importe.sort_values(ascending=False).tolist())
6print("Maximo:", importe.max(), "| Minimo:", importe.min())
7print("Suma:", importe.sum())
8
9try:
10 importe > 100
11except TypeError as e:
12 print("Comparar con 100:", e)

Los tres síntomas seguidos, en seis valores.

## astype frente a to_numeric

Para convertir hay dos caminos y se comportan de forma opuesta. astype revienta con el primer valor que no puede convertir y lanza un ValueError que nombra solo ese: «could not convert string to float: pendiente». Sirve cuando quieres que un tipo mal puesto pare el proceso, y es incómodo en una tabla grande, porque te lo enseña de uno en uno.

to_numeric con errors igual a coerce hace lo contrario: convierte lo que puede y pone un hueco donde no puede. No falla nunca, lo cual es cómodo y peligroso a la vez, porque una columna con el 30% de basura se convierte sin una sola queja.

Elegir entre los dos no es cuestión de gusto: depende de dónde estés. En un proceso que se ejecuta cada noche interesa que un formato nuevo pare la carga y avise a alguien, porque si se convierte con coerce el proceso sigue en verde escribiendo huecos y el problema se descubre semanas después, dentro de un informe. Explorando una tabla por primera vez interesa lo contrario: coerce te enseña todo lo raro de golpe en lugar de irte parando en el primer valor de cada clase.

1import pandas as pd
2
3importe = pd.Series(["1234.56", "890.00", "pendiente", "45.20"])
4
5try:
6 importe.astype(float)
7except ValueError as e:
8 print("astype:", e)
9
10convertido = pd.to_numeric(importe, errors="coerce")
11print("coerce:", convertido.tolist())
12
13# Lo importante: QUE ha tragado el coerce. Nunca se da por bueno sin mirarlo.
14fallidos = importe[convertido.isna() & importe.notna()]
15print("No se pudo convertir:", int(len(fallidos)), "->", fallidos.tolist())

Las dos líneas de abajo son las que casi nadie escribe.

Y aquí hay una conexión directa con lo que ya sabes de los huecos, porque acabas de crear huecos nuevos y toca decidir qué significan. Los del coerce son el mejor caso posible: sabes por qué faltan, porque tienes el valor original escrito. «Pendiente» no significa que falte el importe; significa que el pedido existe y no se ha cobrado, que es un dato. Convertirlo en un hueco y luego rellenarlo con la mediana sería inventarse un cobro.

Un coerce sin mirar lo que ha tragado es una pérdida de datos silenciosa. Hazlo siempre en dos pasos: convierte, cuenta cuántos huecos ha creado, y mira la lista de valores que no ha entendido. Si el recuento no se parece a lo que esperabas, no sigas: ahí hay un formato que no habías previsto y lo estás tirando a la basura fila por fila.

## Limpiar antes de convertir, y en el orden correcto

Un importe escrito a la española lleva el punto como separador de miles y la coma como decimal, justo al revés de lo que espera Python. Hay que quitar el símbolo de la moneda, quitar los puntos y cambiar la coma por un punto, y ese orden no es indiferente.

Los dos puntos salen del mismo texto, «1.234,56», cambiando el orden de dos líneas de código. Ninguno de los dos da error.
1import pandas as pd
2
3bruto = pd.Series(["1.234,56 EUR", "890,00 EUR", "45,20 EUR"])
4sin_moneda = bruto.str.replace(" EUR", "", regex=False)
5
6# BIEN: primero se van los puntos de los miles, luego la coma pasa a punto.
7bien = sin_moneda.str.replace(".", "", regex=False).str.replace(",", ".", regex=False)
8print("Bien:", pd.to_numeric(bien).tolist())
9
10# MAL: quitando la coma primero. Convierte sin dar ningun error.
11mal = sin_moneda.str.replace(",", "", regex=False)
12print("Mal :", pd.to_numeric(mal, errors="coerce").tolist())

Dos órdenes, dos resultados, cero errores.

## Fechas: 03/04/2024 no dice qué día es

Esa cadena es el 3 de abril o el 4 de marzo, y el dato no contiene la respuesta. Depende de quién lo escribió: un sistema español pone el día primero y uno estadounidense pone el mes. Lo que decide en pandas es el argumento dayfirst, y eso lo convierte en una decisión que tomas tú, no en un detalle de sintaxis.

Las dos filas son los mismos cuatro pedidos y suman lo mismo. Arriba son un mes; abajo, cuatro meses distintos, uno de ellos a siete meses de distancia.

Los cuatro pedidos del dibujo son del 3, el 5, el 6 y el 11 de abril. Leídos como día y mes son 3.400 € en abril. Leídos como mes y día se reparten en marzo, mayo, junio y noviembre. Nadie va a ver un error: va a ver un negocio con ventas flojas repartidas por el año, y va a tomar decisiones sobre eso.

Se decide preguntando de dónde sale el fichero, y se comprueba mirando los datos: si existe algún valor cuyo primer número pase de 12, el formato es día primero; si existe alguno cuyo segundo número pase de 12, es mes primero. Si aparecen los dos casos, la columna tiene dos formatos mezclados y no hay ningún argumento que la salve: hay que separarla en dos y convertir cada trozo.

1import pandas as pd
2
3fechas = pd.Series(["03/04/2024", "15/04/2024"])
4
5# Declarar el formato entero es mejor que declarar dayfirst: dice exactamente
6# lo que esperas, y lo que no case se convierte en NaT en vez de adivinarse.
7dia_mes = pd.to_datetime(fechas, format="%d/%m/%Y")
8mes_dia = pd.to_datetime(fechas, format="%m/%d/%Y", errors="coerce")
9
10print("dd/mm:", dia_mes.dt.strftime("%Y-%m-%d").tolist())
11print("mm/dd:", mes_dia.dt.strftime("%Y-%m-%d").tolist())
12print("Sin convertir en mm/dd:", int(mes_dia.isna().sum()))

La segunda fecha es la que delata el formato de toda la columna.

Con fechas, errors igual a coerce da NaT en silencio, y eso se lleva por delante cosas que no parecen errores: un 31/02 que nunca existió, una fecha con el año en dos cifras, una celda que traía un guion. Cuenta los NaT después de convertir y compáralos con los huecos que había antes. Es la misma comprobación que con los números, y se olvida más porque una fecha parece más inofensiva.

Y una consecuencia del primer diagrama que vuelve aquí: una columna de fechas guardada como texto también se ordena alfabéticamente. Con el formato 28/12/2023 el orden alfabético no tiene nada que ver con el cronológico, y con el formato 2023-12-28 coincide exactamente. No es casualidad: por eso existe el formato ISO 8601, que pone el año primero justo para que ordenar como texto ordene bien.

## El cero de delante que desaparece

Un código postal leído como número deja de ser un código postal. El 08001 se convierte en 8001, y lo mismo les pasa a las nueve provincias españolas cuyo código empieza por cero. Lo peor no es que se vea raro: es lo que ocurre al usar sus dos primeros dígitos, que es la forma normal de saber la provincia.

El marco está en el mismo sitio en las dos filas. Lo que se ha movido son los dígitos, y con ellos la provincia.
1import io
2import pandas as pd
3
4CSV = "cp,ciudad\n08001,Barcelona\n28004,Madrid\n03001,Alicante\n"
5
6sin_declarar = pd.read_csv(io.StringIO(CSV))
7declarado = pd.read_csv(io.StringIO(CSV), dtype={"cp": str})
8
9print("Sin declarar:", sin_declarar["cp"].tolist())
10print("Declarado: ", declarado["cp"].tolist())
11print("Provincia sin declarar:", sin_declarar["cp"].astype(str).str[:2].tolist())
12print("Provincia declarado: ", declarado["cp"].str[:2].tolist())

La diferencia es un argumento en la lectura.

Lo que esto pide es un criterio para saber qué columnas de dígitos no son números: códigos postales, teléfonos, números de documento, referencias de producto, cuentas bancarias. Y hay una prueba de una línea que funciona siempre: si la media de una columna no significa nada, esa columna no es un número. La media de unos códigos postales da 20.004,75 y no quiere decir absolutamente nada.

Y la consecuencia va más allá del código postal, porque estos son los identificadores con los que se cruzan tablas. Si en una tabla la clave llega como texto y en otra como número, el cruce no empareja nada y no da ningún error: devuelve la tabla con las columnas nuevas vacías. Es el mismo silencio que ya conoces del cruce que multiplicaba filas, con el signo cambiado: allí aparecían filas de más, aquí no aparece ninguna.

## Decimales: 0.1 más 0.2 no es 0.3

Un decimal en un ordenador se guarda en binario, y en binario hay números sencillos en base diez que no se pueden escribir de forma exacta, igual que en base diez no se puede escribir un tercio con un número finito de decimales. Lo que se guarda es el valor más cercano que cabe, y sumar dos aproximaciones da una aproximación: 0.1 más 0.2 da 0.30000000000000004.

1import pandas as pd
2
3print("0.1 + 0.2 =", 0.1 + 0.2)
4print("Es igual a 0.3:", 0.1 + 0.2 == 0.3)
5
6lineas = pd.Series([0.1, 0.2, 19.99, 4.85, 0.1])
7print("Suma:", lineas.sum())
8print("Cuadra con 25.24:", lineas.sum() == 25.24)
9print("Con tolerancia:", abs(lineas.sum() - 25.24) < 0.005)
10
11centimos = pd.Series([10, 20, 1999, 485, 10])
12print("En centimos:", int(centimos.sum()), "| cuadra:", int(centimos.sum()) == 2524)

Ejecútalo antes de creerte cualquier cuadre hecho con el igual.

Con dinero eso tiene dos consecuencias prácticas. La primera es que un cuadre escrito con el operador igual falla sin que nada esté mal: la suma de las líneas y el total de la factura difieren en una cantidad que no existe en euros. La segunda es que el error se acumula al ir sumando, así que cuanto más larga es la operación más se aleja del valor exacto. Las dos salidas son guardar el dinero en céntimos enteros, o comparar con una tolerancia declarada.

Con dinero, nunca compares con el operador igual. Y ten cuidado con lo que va acumulando: sumar 0.1 diez veces no da 1.0, da 0.9999999999999999. Sobre cientos de miles de líneas el error no se hace grande —sigue siendo minúsculo— pero es suficiente para que una comprobación exacta falle todos los días y para que alguien acabe desactivándola, que es lo peor que podría pasarle.

## Booleanos que son texto

Una columna de sí o no llega escrita de todas las formas imaginables: «True», «true», «1», «sí», «S», «Y», vacía. Y tiene una trampa propia que hay que ver una vez: en Python, cualquier cadena que no esté vacía es verdadera, así que bool aplicado al texto «False» devuelve True. Convertir la columna con astype de golpe la pone entera a verdadero, salvo las celdas vacías.

1import pandas as pd
2
3activo = pd.Series(["True", "false", "1", "si", "", "False"])
4
5print("bool del texto False:", bool("False"))
6print("astype(bool):", activo.astype(bool).tolist())
7print("Activos segun astype:", int(activo.astype(bool).sum()))
8
9MAPA = {"true": True, "1": True, "si": True, "s": True,
10 "false": False, "0": False, "no": False, "n": False}
11limpio = activo.str.strip().str.lower().map(MAPA)
12
13print("Con el mapa:", limpio.tolist())
14# Se cuentan los True, no se rellena el hueco: un valor desconocido no es un no.
15print("Activos segun el mapa:", int((limpio == True).sum()))
16print("No reconocidos:", int(limpio.isna().sum()))

Dos formas de convertir la misma columna. Dan 5 y 3.

Y la lección de fondo no es la sintaxis del diccionario: es la diferencia entre convertir enumerando y convertir a ciegas. Un mapa explícito declara qué formas aceptas y deja fuera el resto, y lo que queda fuera aparece como hueco, que es una pregunta para ti. Una conversión a ciegas no deja preguntas: rellena todo con algo y nunca te enteras de que en esa columna ha aparecido una forma nueva de escribir sí.

## Los tipos se declaran al leer

Casi todo lo de esta lección se evita en la línea donde se lee el fichero. read_csv acepta el tipo de cada columna, qué columnas son fechas, cuál es el separador decimal y el de los miles, y qué textos hay que tratar como huecos. Declararlo ahí son cuatro argumentos, y ahorran media lección de conversiones a mano.

1import io
2import pandas as pd
3
4CSV = ("cp;fecha;importe;activo\n"
5 "08001;03/04/2024;1.234,56;si\n"
6 "28004;15/04/2024;890,00;no\n")
7
8pedidos = pd.read_csv(
9 io.StringIO(CSV),
10 sep=";",
11 dtype={"cp": str}, # el cero de delante se queda
12 parse_dates=["fecha"], # fecha de verdad, no texto
13 dayfirst=True, # y declarando como se lee
14 decimal=",", # coma decimal a la espanola
15 thousands=".", # punto de los miles
16)
17
18print(pedidos.dtypes.to_string())
19print("Suma:", round(float(pedidos["importe"].sum()), 2))

La versión declarada de todo lo anterior.

Declarar los tipos es escribir un contrato sobre la tabla, y un contrato tiene una ventaja que ninguna conversión posterior tiene: se puede incumplir. Si el mes que viene llega un fichero con un texto nuevo en la columna de importes, la lectura declarada falla o deja un hueco que tú estás contando, y alguien se entera. Si los tipos se arreglan después, con un coerce cada vez, el formato nuevo entra sin ruido y las cuentas siguen devolviendo un número.

  • Declara los tipos al leer, no los arregles después. Es la única forma que no depende de acordarte, y la única que sigue funcionando cuando el fichero crece.
  • Después de cualquier conversión, cuenta los huecos que ha creado y mira qué valores no entendió. Dos líneas, y es la diferencia entre convertir y perder datos.
  • Di qué has declarado cuando cuentes el resultado. «He leído el código postal como texto y las fechas como día/mes» es una frase corta que permite a otra persona detectar una decisión equivocada antes de que se convierta en una conclusión.
  • Si una columna de dígitos no se puede promediar, es texto. Códigos, teléfonos, documentos y referencias. Es el criterio más rápido que existe y casi nunca falla.

Con esto ya están los cuatro problemas que tiene cualquier tabla del mundo real: lo que falta, lo que está repetido, lo que se sale y lo que no es del tipo que dice ser. Los has visto de uno en uno, con tablas pequeñas y con un solo problema cada vez. Una tabla de verdad los trae todos juntos, mezclados, y entonces aparece la pregunta que ninguna de las cuatro lecciones contesta: en qué orden se tocan, y qué decisión hay que dejar escrita en cada paso.

Regístrate para guardar tu progreso.