Indice / pandas: calidad y limpieza / 5.2
Tipos y texto
Un archivo CSV no tiene tipos: todo es texto hasta que alguien decide otra cosa. Esa decisión la toma pandas al leer, se equivoca seguido, y corregirla es la mitad del trabajo de limpieza.
Para qué sirve
Después de diagnosticar sabés qué está mal. Este es el primer paso para arreglarlo, y son dos trabajos que van juntos: dejar el texto comparable y convertir a número lo que tiene que ser número.
Los dos importan por la misma razón: sin ellos, las funciones que vienen después
mienten. Un groupby por nombre cuenta dos veces a la misma persona si
uno de los dos registros tiene un espacio de más. Una media falla, o peor, no falla y
te da cualquier cosa, si la columna es texto.
ELI5Explicado facil
Imaginate una caja de fichas de clientes escritas a mano. Algunas dicen «ANA PEREZ», otras «ana perez», otras « Ana Perez » con espacios. Para vos es obvio que es la misma persona; para la computadora son tres personas distintas, porque compara letra por letra.
Normalizar es reescribir todas las fichas con la misma regla. Y convertir tipos es darse cuenta de que la ficha que dice «treinta» en el casillero de la edad no sirve para hacer cuentas, aunque un humano la entienda perfecto.
El ejemplo mínimo
import pandas as pd
nombres = pd.Series([" ana perez ", "JUAN GOMEZ", "María Díaz"])
print("como vienen:")
print(nombres.to_list())
print()
print("strip() quita los espacios de los extremos:")
print(nombres.str.strip().to_list())
print()
print("lower() pasa todo a minusculas:")
print(nombres.str.strip().str.lower().to_list())
print()
print("title() deja mayuscula inicial en cada palabra:")
print(nombres.str.strip().str.title().to_list())
como vienen:
[' ana perez ', 'JUAN GOMEZ', 'María Díaz']
strip() quita los espacios de los extremos:
['ana perez', 'JUAN GOMEZ', 'María Díaz']
lower() pasa todo a minusculas:
['ana perez', 'juan gomez', 'maría díaz']
title() deja mayuscula inicial en cada palabra:
['Ana Perez', 'Juan Gomez', 'María Díaz']
strip() antes que title(), siempre. Al revés funciona
igual acá, pero cuando entra lower() o comparaciones de por medio, dejar
los espacios para el final es una fuente de errores silenciosos.
Sobre datos de verdad
Cuánto texto sucio hay
import pandas as pd
clientes = pd.read_csv("clientes_crudos.csv", dtype=str, keep_default_na=False)
nombre_ok = clientes["nombre"].str.strip().str.title()
correo_ok = clientes["correo"].str.strip().str.lower()
sucio_nombre = clientes["nombre"] != nombre_ok
sucio_correo = clientes["correo"] != correo_ok
print("nombres sin normalizar:", int(sucio_nombre.sum()))
print("correos sin normalizar:", int(sucio_correo.sum()))
print()
muestra = pd.DataFrame({
"como_vino": clientes.loc[sucio_nombre, "nombre"],
"normalizado": nombre_ok[sucio_nombre],
}).head(4)
print(muestra.to_string(index=False))
print()
print("Ojo: 'Ana Perez' y 'ana perez ' son dos claves distintas para pandas.")
print("Sin normalizar, un groupby por nombre los cuenta como dos personas.")
nombres sin normalizar: 16
correos sin normalizar: 10
como_vino normalizado
bruno juárez Bruno Juárez
nahuel vega Nahuel Vega
gabriela ubeda Gabriela Ubeda
elena medina Elena Medina
Ojo: 'Ana Perez' y 'ana perez ' son dos claves distintas para pandas.
Sin normalizar, un groupby por nombre los cuenta como dos personas.
26 valores de 240 vienen mal escritos. No es un problema estético: son 26 oportunidades de contar dos veces a la misma persona, o de no encontrarla al buscarla.
Convertir a número
import pandas as pd
inventario = pd.read_csv("inventario_productos.csv")
print("dtypes tal como se leen:")
print(inventario.dtypes.to_string())
print()
cantidad = pd.to_numeric(inventario["cantidad"], errors="coerce")
precio = pd.to_numeric(inventario["precio_unitario"], errors="coerce")
print("cantidad ->", cantidad.dtype, "| ausentes:", int(cantidad.isna().sum()))
print("precio ->", precio.dtype, "| ausentes:", int(precio.isna().sum()))
print()
print("Aca read_csv ya las habia leido como numero: to_numeric no cambio nada.")
print("Pasa cuando la basura son tokens que pandas ya reconoce (NaN, N/A).")
print("Con clientes_crudos.csv es al reves: la palabra 'treinta' deja la")
print("columna como texto y ahi to_numeric es imprescindible.")
print()
print("Lo que quedo como NaN:")
perdidos = inventario.loc[cantidad.isna() | precio.isna(),
["codigo", "cantidad", "precio_unitario"]]
print(perdidos.to_string(index=False))
dtypes tal como se leen:
codigo str
producto str
categoria str
cantidad float64
precio_unitario float64
cantidad -> float64 | ausentes: 1
precio -> float64 | ausentes: 2
Aca read_csv ya las habia leido como numero: to_numeric no cambio nada.
Pasa cuando la basura son tokens que pandas ya reconoce (NaN, N/A).
Con clientes_crudos.csv es al reves: la palabra 'treinta' deja la
columna como texto y ahi to_numeric es imprescindible.
Lo que quedo como NaN:
codigo cantidad precio_unitario
P004 NaN 432.95
P011 44.0 NaN
P030 113.0 NaN
Este caso enseña algo que no se suele decir: a veces
to_numeric no hace falta. Si la basura que trae la columna son
tokens que pandas ya reconoce como ausentes, la columna llega numérica sola. Hace
falta cuando hay texto de verdad adentro, como la palabra treinta de
clientes_crudos.csv. La forma de saberlo es mirar el
dtype, no adivinar.
Declarar un dominio con category
import pandas as pd
inventario = pd.read_csv("inventario_productos.csv")
categoria = inventario["categoria"]
print("valores distintos:", categoria.nunique(), "sobre", len(categoria), "filas")
print(categoria.value_counts().head(4).to_string())
print()
como_texto = categoria.memory_usage(deep=True)
como_categoria = categoria.astype("category").memory_usage(deep=True)
print(f"como texto : {como_texto:>6} bytes")
print(f"como category : {como_categoria:>6} bytes")
print()
print("Lo importante no es la memoria sino que category declara un dominio:")
print(list(categoria.astype("category").cat.categories))
print("Si aparece un valor nuevo, se vuelve NaN en vez de colarse en silencio.")
valores distintos: 11 sobre 35 filas
categoria
Accesorios 5
Componentes 4
Equipos 4
Energia 4
como texto : 2146 bytes
como category : 800 bytes
Lo importante no es la memoria sino que category declara un dominio:
['Accesorios', 'Almacenamiento', 'Audio', 'Cables', 'Componentes', 'Energia', 'Equipos', 'Impresion', 'Pantallas', 'Perifericos', 'Redes']
Si aparece un valor nuevo, se vuelve NaN en vez de colarse en silencio.
El ahorro de memoria es lo de menos. Lo valioso es que category
declara qué valores son posibles. Si el archivo del mes que viene
trae una categoría nueva, no se cuela: aparece como ausente y te enterás.
Lo mismo, paso a paso
- Los nombres como vienen del archivo. Tres de estos cinco tienen espacios de mas o estan en minusculas, y uno esta vacio.
clientes = pd.read_csv("clientes_crudos.csv", dtype=str) - strip() quita los espacios de los extremos. Los de adentro no los toca: 'bruno juarez' con dos espacios seguiria igual.
clientes["nombre"].str.strip() - title() pone en mayuscula la primera letra de cada palabra. Ahora los cuatro nombres son comparables entre si.
clientes["nombre"].str.strip().str.title() - Lo que normalizar NO arregla: el nombre vacio sigue vacio. Limpiar texto y decidir que hacer con los ausentes son dos trabajos distintos.
clientes["nombre"].str.strip() == "" # sigue habiendo 2 - Y por que importa: antes de normalizar, un groupby por nombre contaba 'bruno juarez ' y 'Bruno Juárez' como dos personas distintas.
clientes["nombre"].nunique() # baja al normalizar
La cadena completa
import pandas as pd
clientes = pd.read_csv("clientes_crudos.csv", dtype=str, keep_default_na=False)
limpio = clientes.assign(
nombre=clientes["nombre"].str.strip().str.title(),
correo=clientes["correo"].str.strip().str.lower(),
ciudad=clientes["ciudad"].str.strip(),
edad=pd.to_numeric(clientes["edad"].str.strip()
.replace(["", "NaN", "N/A", "null", "-"], None),
errors="coerce"),
)
print(limpio.dtypes.to_string())
print()
print(limpio.head(3).to_string(index=False))
print()
print("edad ausente tras la conversion:", int(limpio["edad"].isna().sum()))
print("nombres que quedaron distintos :",
int((clientes["nombre"] != limpio["nombre"]).sum()))
id str
nombre str
correo str
edad float64
ciudad str
fecha_alta str
id nombre correo edad ciudad fecha_alta
C0001 José Paz jose.paz@example.com 62.0 Salta 2025-02-20
C0002 Lucía Peña lucia.pena@example.com 48.0 Salta 2025-01-05
C0003 Santiago Herrera santiago.herrera@example.com 40.0 Salta 2024-05-12
edad ausente tras la conversion: 7
nombres que quedaron distintos : 16
Dónde se rompe
Causa: estás usando .str.algo sobre una columna que pandas
leyó como numérica.
Arreglo: fijate el dtype primero. Si de verdad querés
tratarla como texto, .astype(str) - pero acordate de que ahí los NaN
se vuelven la cadena "nan", que después parece un dato válido.
Causa: astype(float) o to_numeric sin
errors="coerce".
Arreglo: este error es tu amigo la primera vez, porque te dice
exactamente qué valor lo rompió. Corrélo sin coerce para enterarte, y
recién después poné coerce sabiendo qué estás mandando a NaN.
Causa: astype(int) sobre una columna con ausentes.
Arreglo: decidí primero qué hacés con los ausentes, o usá
astype("Int64") con I mayúscula, que sí los admite.
Causa: normalizaste con title() pero sin
strip(), así que "Salta" y " Salta" siguen
siendo dos valores distintos.
Arreglo: strip() primero, siempre. Este error no avisa
nunca: solo infla los conteos.
Causa: los ausentes se convierten en la cadena literal
"nan", que a partir de ahí es un dato como cualquier otro.
Arreglo: resolvé los ausentes antes de convertir a texto, o leé el
archivo con dtype=str, keep_default_na=False si nunca los quisiste
convertir.
Acá hay que elegir
- En su lugar (
df["nombre"] = df["nombre"].str.strip()) deja la tabla limpia y liviana, pero perdés el valor original: si después alguien pregunta «¿cómo estaba escrito?», no hay respuesta. - En una columna nueva (
nombre_normalizado) conserva la evidencia y te deja comparar, al costo de una columna más.
Regla práctica: si el archivo original queda guardado, normalizá en su lugar. Si estás haciendo la limpieza sobre el original, guardate la columna cruda - sale barato y evita discusiones.
Recetario
| Expresión | Qué hace | Cuándo |
|---|---|---|
s.str.strip() | Quita espacios de los extremos | Siempre, antes que nada |
s.str.lower() / .upper() | Cambia mayúsculas | Correos, códigos, claves de comparación |
s.str.title() | Mayúscula inicial por palabra | Nombres propios que se van a mostrar |
s.str.replace(a, b, regex=False) | Reemplaza texto | Símbolos de moneda, separadores de miles |
s.str.contains(x, na=False) | Series de bool | Buscar; na=False evita que los ausentes rompan |
pd.to_numeric(s, errors="coerce") | Series numérica | Cuando el dtype es texto y debería ser número |
s.astype("Int64") | Entero que admite ausentes | Enteros con huecos |
s.astype("category") | Series categórica | Pocos valores repetidos; declara el dominio |
s.cat.categories | Los valores permitidos | Ver qué dominio quedó declarado |