Clase 6 — Data Cleansing#
Python y Políticas Públicas
Contenidos#
¿Por qué limpiar datos?
Valores faltantes: detección, eliminación e imputación
Duplicados e inconsistencias
Conversión y corrección de tipos de datos
Limpieza de strings
Outliers
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í