Saltar al contenido

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

EjemploLas tres operaciones de texto que se usan todo el tiempo
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']
El orden de la cadena importa

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

EjemploCuanto texto sucio hay, y como se ve
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

EjemploConvertir a numero cuando la columna trae basura
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

Ejemploastype("category") para columnas con pocos valores repetidos
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

Paso a pasoNormalizar texto: que hace cada eslabon de la cadena
  1. 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)
  2. strip() quita los espacios de los extremos. Los de adentro no los toca: 'bruno juarez' con dos espacios seguiria igual.
    clientes["nombre"].str.strip()
  3. title() pone en mayuscula la primera letra de cada palabra. Ahora los cuatro nombres son comparables entre si.
    clientes["nombre"].str.strip().str.title()
  4. 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
  5. 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

EjemploLa cadena completa: texto limpio y numeros de verdad
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

AttributeError: Can only use .str accessor with string values

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.

ValueError: could not convert string to float: 'treinta'

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.

IntCastingNaNError: Cannot convert non-finite values (NA or inf) to integer

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.

(sin error) el conteo de categorías da más de las que hay

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.

(sin error) astype(str) sobre una columna con NaN

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

¿Normalizar en su lugar o en una columna nueva?
  • 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

Texto y tipos
ExpresiónQué haceCuándo
s.str.strip()Quita espacios de los extremosSiempre, antes que nada
s.str.lower() / .upper()Cambia mayúsculasCorreos, códigos, claves de comparación
s.str.title()Mayúscula inicial por palabraNombres propios que se van a mostrar
s.str.replace(a, b, regex=False)Reemplaza textoSímbolos de moneda, separadores de miles
s.str.contains(x, na=False)Series de boolBuscar; na=False evita que los ausentes rompan
pd.to_numeric(s, errors="coerce")Series numéricaCuando el dtype es texto y debería ser número
s.astype("Int64")Entero que admite ausentesEnteros con huecos
s.astype("category")Series categóricaPocos valores repetidos; declara el dominio
s.cat.categoriesLos valores permitidosVer qué dominio quedó declarado

Ponete a prueba

1. ¿Qué hace s.str.strip()?Nivel 1 · Recordar
2. ¿Para qué sirve errors="coerce" en pd.to_numeric?Nivel 1 · Recordar
3. s = pd.Series([" ana perez ", "JUAN GOMEZ"])
¿Qué devuelve s.str.strip().str.title().to_list()?Nivel 2 · Predecir la salida
4. Una columna numérica leída con read_csv aparece como float64 y tiene 2 NaN. Corrés pd.to_numeric(col, errors="coerce"). ¿Qué pasa?Nivel 2 · Predecir la salida
5. Querés normalizar los nombres para poder agrupar por cliente. Las cuatro corren. ¿Cuál conviene?Nivel 3 · Elegir entre dos casi correctas
6. ¿Cuándo conviene astype("category")?Nivel 3 · Elegir entre dos casi correctas
7. Este código normaliza y después cuenta clientes por ciudad. Da 11 ciudades cuando el archivo tiene 10. ¿Por qué?
clientes = pd.read_csv("clientes_crudos.csv")
clientes["ciudad"] = clientes["ciudad"].str.title()
print(clientes["ciudad"].nunique(dropna=False))
Nivel 4 · Encontrar el defecto

Prácticas de esta lección