Clase 6 — Data Cleansing#

Python y Políticas Públicas


Contenidos#

  1. ¿Por qué limpiar datos?

  2. Valores faltantes: detección, eliminación e imputación

  3. Duplicados e inconsistencias

  4. Conversión y corrección de tipos de datos

  5. Limpieza de strings

  6. Outliers

  7. Ejercicio integrador: limpiar un dataset real

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

%matplotlib inline
pd.set_option('display.max_columns', None)
import matplotlib as mpl
import matplotlib.pyplot as plt

# --- Paleta de identidad del curso ---
C = ['#2A6496', '#E07B3F', '#3D9970', '#8E5EA2', '#C0A830', '#637A8A']

mpl.rcParams.update({
    'figure.figsize'       : (10, 5),
    'font.size'            : 11,
    'axes.titlesize'       : 12,
    'axes.titleweight'     : 'normal',
    'axes.spines.top'      : False,
    'axes.spines.right'    : False,
    'legend.frameon'       : False,
    'axes.prop_cycle'      : mpl.cycler(color=C),
    'figure.dpi'           : 110,
})

def tit(ax, t, **kw):
    """Título sin negrita, alineado a la izquierda."""
    ax.set_title(t, loc='left', fontweight='normal', **kw)

1. ¿Por qué limpiar datos?#

En el sector público, los datos provienen de múltiples fuentes: formularios en papel escaneados, sistemas legados, carga manual, distintos organismos con criterios distintos. Es muy común encontrar:

  • Valores faltantes (el campo quedó vacío)

  • Duplicados (el mismo registro cargado dos veces)

  • Inconsistencias («Buenos Aires», «Bs. As.», «BsAs», «BUENOS AIRES» para la misma provincia)

  • Tipos incorrectos (fechas guardadas como texto, números con comas en lugar de puntos)

  • Valores imposibles (edad = 200, porcentaje = 150%)

Regla de oro: no modificar los datos originales. Siempre trabajar sobre una copia y documentar cada decisión de limpieza.

# Dataset simulado con problemas típicos
data_sucia = {
    'id': [1, 2, 3, 4, 5, 5, 6, 7, 8, 9, 10, 11, 12],
    'nombre': ['María López', 'juan perez', 'ANA GARCIA', 'Carlos Ruiz', 'LUCIA MARTINEZ',
                'LUCIA MARTINEZ', 'Pedro Gómez', None, 'Rosa Silva', 'Miguel Torres',
                'Laura Díaz', 'Fernando Paz', 'Carmen Vega'],
    'provincia': ['Buenos Aires', 'Bs. As.', 'CORDOBA', 'córdoba', 'Santa fe',
                  'Santa fe', 'santa fe', 'Mendoza', 'mendoza', 'TUCUMAN',
                  'Tucumán', 'Salta', 'salta'],
    'edad': [34, 28, 250, 45, 31, 31, -5, 52, 29, 38, 44, 61, 27],
    'ingreso': ['45000', '82000', '31000', None, '95000',
                '95000', '28000', '67,500', '41000', '120000',
                '55000', '78000', '33000'],
    'fecha_registro': ['2023-03-15', '15/04/2023', '2023-05-20', '2023-06-10', '20-07-2023',
                       '20-07-2023', '2023-08-05', '2023-09-12', '2023-10-01', '01/11/2023',
                       '2023-11-28', '2023-12-15', '2024-01-08'],
    'programa': ['AUH', 'auh', 'AUH', 'Progresar', 'Progresar',
                 'Progresar', 'potenciar trabajo', 'Potenciar Trabajo', 'AUH', 'Alimentar',
                 'alimentar', 'Alimentar', 'AUH'],
}

df_original = pd.DataFrame(data_sucia)
df = df_original.copy()  # siempre trabajar sobre una copia

print("Dataset original con problemas:")
df
Dataset original con problemas:
id nombre provincia edad ingreso fecha_registro programa
0 1 María López Buenos Aires 34 45000 2023-03-15 AUH
1 2 juan perez Bs. As. 28 82000 15/04/2023 auh
2 3 ANA GARCIA CORDOBA 250 31000 2023-05-20 AUH
3 4 Carlos Ruiz córdoba 45 None 2023-06-10 Progresar
4 5 LUCIA MARTINEZ Santa fe 31 95000 20-07-2023 Progresar
5 5 LUCIA MARTINEZ Santa fe 31 95000 20-07-2023 Progresar
6 6 Pedro Gómez santa fe -5 28000 2023-08-05 potenciar trabajo
7 7 None Mendoza 52 67,500 2023-09-12 Potenciar Trabajo
8 8 Rosa Silva mendoza 29 41000 2023-10-01 AUH
9 9 Miguel Torres TUCUMAN 38 120000 01/11/2023 Alimentar
10 10 Laura Díaz Tucumán 44 55000 2023-11-28 alimentar
11 11 Fernando Paz Salta 61 78000 2023-12-15 Alimentar
12 12 Carmen Vega salta 27 33000 2024-01-08 AUH

2. Valores faltantes#

pandas representa los valores faltantes como NaN (Not a Number). La estrategia depende del contexto:

  • Eliminar: si son pocas filas y no sesga el análisis.

  • Imputar: reemplazar con media, mediana, moda u otro valor.

  • Dejar como está: si el faltante es informativo en sí mismo.

# Detectar nulos
print("Nulos por columna:")
print(df.isnull().sum())
print(f"\nTotal de celdas: {df.size}")
print(f"Celdas con nulo: {df.isnull().sum().sum()}")
Nulos por columna:
id                0
nombre            1
provincia         0
edad              0
ingreso           1
fecha_registro    0
programa          0
dtype: int64

Total de celdas: 91
Celdas con nulo: 2
# Eliminar filas con algún nulo
df_sin_nulos = df.dropna()
print(f"Filas originales: {len(df)}, después de dropna: {len(df_sin_nulos)}")

# Eliminar filas con nulo en columnas específicas
df_sin_nulos_nombre = df.dropna(subset=['nombre'])
print(f"Filas sin nulo en 'nombre': {len(df_sin_nulos_nombre)}")

# Rellenar nulos con un valor
df['nombre'] = df['nombre'].fillna('Sin nombre')
df['ingreso_num'] = pd.to_numeric(df['ingreso'].str.replace(',', '.'), errors='coerce')

# Imputar con la mediana (más robusta que la media para ingresos)
mediana_ingreso = df['ingreso_num'].median()
df['ingreso_num'] = df['ingreso_num'].fillna(mediana_ingreso)
print(f"\nMediana de ingreso usada para imputar: ${mediana_ingreso:,.0f}")
Filas originales: 13, después de dropna: 11
Filas sin nulo en 'nombre': 12

Mediana de ingreso usada para imputar: $50,000

3. Duplicados e inconsistencias#

# Detectar duplicados
print(f"Filas duplicadas: {df.duplicated().sum()}")
print("\nFilas duplicadas:")
print(df[df.duplicated(keep=False)])  # keep=False: muestra todas las copias
Filas duplicadas: 1

Filas duplicadas:
   id          nombre provincia  edad ingreso fecha_registro   programa  \
4   5  LUCIA MARTINEZ  Santa fe    31   95000     20-07-2023  Progresar   
5   5  LUCIA MARTINEZ  Santa fe    31   95000     20-07-2023  Progresar   

   ingreso_num  
4      95000.0  
5      95000.0  
# Eliminar duplicados (conservar el primero)
df = df.drop_duplicates()
print(f"Filas después de eliminar duplicados: {len(df)}")

# Duplicados por columnas específicas (mismo id)
df = df.drop_duplicates(subset=['id'], keep='first')
print(f"Filas después de deduplicar por id: {len(df)}")
Filas después de eliminar duplicados: 12
Filas después de deduplicar por id: 12

4. Limpieza de strings#

Las columnas de texto son las más propensas a inconsistencias. pandas tiene .str para operaciones vectorizadas sobre strings.

# Normalizar: eliminar espacios, unificar case, reemplazar abreviaturas
print("Provincias antes:", df['provincia'].unique())

# Paso 1: strip + lower
df['provincia'] = df['provincia'].str.strip().str.lower()

# Paso 2: mapa de corrección
mapa_provincias = {
    'bs. as.': 'buenos aires',
    'cordoba': 'córdoba',
    'santa fe': 'santa fe',
    'tucuman': 'tucumán',
}
df['provincia'] = df['provincia'].replace(mapa_provincias)

# Paso 3: title case
df['provincia'] = df['provincia'].str.title()

print("Provincias después:", df['provincia'].unique())
Provincias antes: ['Buenos Aires' 'Bs. As.' 'CORDOBA' 'córdoba' 'Santa fe' 'santa fe'
 'Mendoza' 'mendoza' 'TUCUMAN' 'Tucumán' 'Salta' 'salta']
Provincias después: ['Buenos Aires' 'Córdoba' 'Santa Fe' 'Mendoza' 'Tucumán' 'Salta']
# Normalizar columna 'programa'
print("Programas antes:", df['programa'].unique())

mapa_programas = {
    'auh': 'AUH',
    'potenciar trabajo': 'Potenciar Trabajo',
    'alimentar': 'Alimentar',
    'progresar': 'Progresar',
}
df['programa'] = df['programa'].str.strip().str.lower().replace(mapa_programas)

# Para los que ya están correctos después del lower, no se reemplazan — capitalizar manualmente
correcto = {'auh': 'AUH', 'progresar': 'Progresar', 'potenciar trabajo': 'Potenciar Trabajo', 'alimentar': 'Alimentar'}
df['programa'] = df['programa'].str.strip().str.lower().map(correcto).fillna(df['programa'])

# Forma más robusta:
df['programa'] = df['programa'].str.strip().str.lower().str.title()
df['programa'] = df['programa'].replace({'Auh': 'AUH'})

print("Programas después:", df['programa'].unique())
Programas antes: ['AUH' 'auh' 'Progresar' 'potenciar trabajo' 'Potenciar Trabajo'
 'Alimentar' 'alimentar']
Programas después: ['AUH' 'Progresar' 'Potenciar Trabajo' 'Alimentar']

5. Conversión y corrección de tipos de datos#

# Convertir strings a fechas (pandas infiere el formato automáticamente)
print("Fechas originales:", df['fecha_registro'].tolist())

df['fecha_registro'] = pd.to_datetime(df['fecha_registro'], dayfirst=False, errors='coerce')

print("\nFechas convertidas:")
print(df[['nombre', 'fecha_registro']].to_string())

# Extraer componentes de fecha
df['anio_registro'] = df['fecha_registro'].dt.year
df['mes_registro']  = df['fecha_registro'].dt.month
Fechas originales: ['2023-03-15', '15/04/2023', '2023-05-20', '2023-06-10', '20-07-2023', '2023-08-05', '2023-09-12', '2023-10-01', '01/11/2023', '2023-11-28', '2023-12-15', '2024-01-08']

Fechas convertidas:
            nombre fecha_registro
0      María López     2023-03-15
1       juan perez            NaT
2       ANA GARCIA     2023-05-20
3      Carlos Ruiz     2023-06-10
4   LUCIA MARTINEZ            NaT
6      Pedro Gómez     2023-08-05
7       Sin nombre     2023-09-12
8       Rosa Silva     2023-10-01
9    Miguel Torres            NaT
10      Laura Díaz     2023-11-28
11    Fernando Paz     2023-12-15
12     Carmen Vega     2024-01-08
# Convertir a categoría (eficiente en memoria para variables con pocas categorías)
df['provincia'] = df['provincia'].astype('category')
df['programa']  = df['programa'].astype('category')

print(df.dtypes)
id                         int64
nombre                    object
provincia               category
edad                       int64
ingreso                   object
fecha_registro    datetime64[ns]
programa                category
ingreso_num              float64
anio_registro            float64
mes_registro             float64
dtype: object

6. Outliers#

Los outliers pueden ser errores de carga o valores válidos pero extremos. En políticas públicas, siempre investigar antes de eliminar.

# Detectar valores imposibles
print("Valores de edad:")
print(df['edad'].describe())
print(f"\nEdades fuera de rango (18-100): {df[(df['edad'] < 18) | (df['edad'] > 100)]['edad'].tolist()}")
Valores de edad:
count     12.000000
mean      52.833333
std       64.194071
min       -5.000000
25%       28.750000
50%       36.000000
75%       46.750000
max      250.000000
Name: edad, dtype: float64

Edades fuera de rango (18-100): [250, -5]
# Estrategia: marcar como NaN los valores imposibles
df.loc[(df['edad'] < 18) | (df['edad'] > 100), 'edad'] = np.nan
print(f"Después de corrección, nulos en edad: {df['edad'].isnull().sum()}")
Después de corrección, nulos en edad: 2
# Método IQR para detectar outliers estadísticos
np.random.seed(42)
ingresos_sample = np.concatenate([
    np.random.lognormal(11, 0.5, 100),
    [5_000_000, 8_000_000]  # outliers extremos
])

s = pd.Series(ingresos_sample)
Q1 = s.quantile(0.25)
Q3 = s.quantile(0.75)
IQR = Q3 - Q1

outliers = s[(s < Q1 - 1.5 * IQR) | (s > Q3 + 1.5 * IQR)]
print(f"Q1: ${Q1:,.0f}, Q3: ${Q3:,.0f}, IQR: ${IQR:,.0f}")
print(f"Outliers detectados: {len(outliers)}")
print(outliers.values)
Q1: $44,556, Q3: $77,232, IQR: $32,676
Outliers detectados: 7
[ 128221.55283858  131874.55486213  151166.79249562  129187.26317484
  130917.39462522 5000000.         8000000.        ]

7. Ejercicio integrador#

Ejecutar el pipeline completo de limpieza y documentar cada paso.

# Pipeline de limpieza documentado
df_clean = df_original.copy()
log = []  # registro de cada paso

# 1. Eliminar duplicados
n_antes = len(df_clean)
df_clean = df_clean.drop_duplicates(subset=['id'], keep='first')
log.append(f"Eliminados {n_antes - len(df_clean)} duplicados por id")

# 2. Normalizar provincia
mapa_prov = {'bs. as.': 'buenos aires', 'cordoba': 'córdoba', 'tucuman': 'tucumán'}
df_clean['provincia'] = (df_clean['provincia'].str.strip().str.lower()
                         .replace(mapa_prov).str.title())
log.append("Normalizada columna 'provincia'")

# 3. Normalizar programa
df_clean['programa'] = df_clean['programa'].str.strip().str.lower().str.title().replace({'Auh': 'AUH'})
log.append("Normalizada columna 'programa'")

# 4. Convertir ingreso a numérico
df_clean['ingreso'] = pd.to_numeric(df_clean['ingreso'].str.replace(',', '.'), errors='coerce')
n_nulos_ingreso = df_clean['ingreso'].isnull().sum()
df_clean['ingreso'] = df_clean['ingreso'].fillna(df_clean['ingreso'].median())
log.append(f"Convertido 'ingreso' a numérico; {n_nulos_ingreso} nulos imputados con mediana")

# 5. Corregir edades imposibles
n_edades_inv = ((df_clean['edad'] < 18) | (df_clean['edad'] > 100)).sum()
df_clean.loc[(df_clean['edad'] < 18) | (df_clean['edad'] > 100), 'edad'] = np.nan
log.append(f"Marcadas {n_edades_inv} edades imposibles como NaN")

# 6. Convertir fecha
df_clean['fecha_registro'] = pd.to_datetime(df_clean['fecha_registro'], dayfirst=False, errors='coerce')
log.append("Convertida 'fecha_registro' a datetime")

# 7. Completar nombre nulo
df_clean['nombre'] = df_clean['nombre'].fillna('Sin nombre')
log.append("Rellenados nombres nulos con 'Sin nombre'")

print("=== LOG DE LIMPIEZA ===")
for i, paso in enumerate(log, 1):
    print(f"{i}. {paso}")

print(f"\nDataset final: {df_clean.shape[0]} filas × {df_clean.shape[1]} columnas")
df_clean
=== LOG DE LIMPIEZA ===
1. Eliminados 1 duplicados por id
2. Normalizada columna 'provincia'
3. Normalizada columna 'programa'
4. Convertido 'ingreso' a numérico; 1 nulos imputados con mediana
5. Marcadas 2 edades imposibles como NaN
6. Convertida 'fecha_registro' a datetime
7. Rellenados nombres nulos con 'Sin nombre'

Dataset final: 12 filas × 7 columnas
id nombre provincia edad ingreso fecha_registro programa
0 1 María López Buenos Aires 34.0 45000.0 2023-03-15 AUH
1 2 juan perez Buenos Aires 28.0 82000.0 NaT AUH
2 3 ANA GARCIA Córdoba NaN 31000.0 2023-05-20 AUH
3 4 Carlos Ruiz Córdoba 45.0 45000.0 2023-06-10 Progresar
4 5 LUCIA MARTINEZ Santa Fe 31.0 95000.0 NaT Progresar
6 6 Pedro Gómez Santa Fe NaN 28000.0 2023-08-05 Potenciar Trabajo
7 7 Sin nombre Mendoza 52.0 67.5 2023-09-12 Potenciar Trabajo
8 8 Rosa Silva Mendoza 29.0 41000.0 2023-10-01 AUH
9 9 Miguel Torres Tucumán 38.0 120000.0 NaT Alimentar
10 10 Laura Díaz Tucumán 44.0 55000.0 2023-11-28 Alimentar
11 11 Fernando Paz Salta 61.0 78000.0 2023-12-15 Alimentar
12 12 Carmen Vega Salta 27.0 33000.0 2024-01-08 AUH

Ejercicios#

Ejercicio 1#

El siguiente dataset tiene múltiples problemas. Identificá al menos 5 y escribí el código para corregirlos:

df_ej = pd.DataFrame({
    'municipio': ['La Plata', 'la plata', 'ROSARIO', 'rosario', 'Córdoba', 'córdoba'],
    'poblacion': ['750000', '750000', '1.200.000', '1200000', '1500000', None],
    'tasa_desempleo': [8.5, 8.5, 7.2, 7.2, -1.0, 250.0],
    'anio': ['2023', '2023', '2022', 2022, '2023', '2023'],
})
df_ej = pd.DataFrame({
    'municipio': ['La Plata', 'la plata', 'ROSARIO', 'rosario', 'Córdoba', 'córdoba'],
    'poblacion': ['750000', '750000', '1.200.000', '1200000', '1500000', None],
    'tasa_desempleo': [8.5, 8.5, 7.2, 7.2, -1.0, 250.0],
    'anio': ['2023', '2023', '2022', 2022, '2023', '2023'],
})

# Tu solución aquí