Procesamiento, transformación y análisis

El capítulo 5 describió al data loader como el programa que recibe una fuente y deposita un artefacto en dist/. Todo lo que ocurre entre esos dos puntos (cómo se lee el archivo crudo, qué se hace cuando aparecen caracteres corruptos, cómo se corrigen los tipos, cómo se derivan columnas nuevas o se combinan tablas auxiliares) es el tema de este capítulo. No hay una arquitectura distinta: seguimos escribiendo un programa que imprime a stdout. Lo que cambia es el nivel de detalle.

Los ejemplos usan Python con pandas y polars, pero las ideas son portátiles: quien prefiera dplyr en R, Arquero en JavaScript o SQL con DuckDB (tema del capítulo siguiente) reconocerá los mismos patrones. El hilo conductor es countries.csv, el dataset que sostiene casi toda la Parte III de este libro. Lo recorremos en tres fases: exploración para entender qué tenemos, transformación para dejarlo utilizable y análisis para extraer estructura antes de visualizar.


Tres fases, un recorrido iterativo

Exploración, transformación y análisis no se ejecutan una sola vez en ese orden. En la práctica son fases que se alimentan entre sí: el análisis revela un valor atípico que obliga a volver a la limpieza; la limpieza descubre un nuevo patrón de faltantes que merece una exploración específica. La Figura 6.1 representa esa circulación.

Figura 6.1. El flujo entre exploración, transformación y análisis es iterativo. Cada fase puede obligar a retroceder a una anterior antes de producir el artefacto final.

La cultura de mirar los datos antes de modelarlos no es reciente. Tukey formalizó el análisis exploratorio de datos (Exploratory Data Analysis, EDA) hace casi cinco décadas con el argumento de que los resúmenes robustos y los gráficos sencillos revelan estructura que ningún test inferencial captura por sí solo (Tukey, 1977). En paralelo, Wickham propuso el principio de datos ordenados (tidy data): una observación por fila, una variable por columna, un tipo de unidad por tabla (Wickham, 2014). Ambas ideas orientan el recorrido: exploramos antes de transformar, transformamos para que cada fila sea un país y cada columna una magnitud, analizamos sobre esa forma canónica.


Dos bibliotecas, dos modelos

En el ecosistema de Python hay dos candidatos evidentes para manipular tablas. pandas (McKinney, 2010) es la opción histórica: API imperativa, índices explícitos, mutabilidad en el sitio, integración profunda con el resto del ecosistema científico (NumPy, SciPy, scikit-learn, Matplotlib). polars (Vink & Polars Contributors, 2024) es más reciente: motor escrito en Rust, API expresiva basada en expresiones, evaluación perezosa opcional, tipado estricto y ejecución multihilo por defecto.

La tabla siguiente resume las diferencias que más afectan cómo se escribe el código, no cuál es "mejor":

Dimensión pandas polars
Modelo de ejecución Ansioso (eager), imperativo Ansioso o perezoso (lazy) a elección
Tipos Dinámicos, con conversiones implícitas Estrictos, conversiones explícitas
API Índices + métodos encadenados Expresiones pl.col(...) declarativas
Rendimiento típico Un solo hilo; rápido en tablas < 1 GB Multihilo; rápido en tablas grandes
Madurez del ecosistema Muy alta (más de 15 años) En crecimiento; suficiente para ETL moderno

Este capítulo usa pandas como hilo principal porque es lo que emplea src/data/countries.csv.py en el proyecto. En los puntos donde polars cambia de forma la expresión o mejora claramente el resultado, lo mostramos dentro de un bloque expandible. La elección final depende del tamaño de los datos, de la familiaridad del equipo y de si el pipeline necesita evaluación perezosa para no cargarlo todo en memoria. DuckDB en SQL, tema del capítulo 7, es una tercera opción natural cuando la lógica encaja en una consulta.

Políglota por diseño

Un data loader no impone el lenguaje del análisis. Si prefieres dplyr + readr en R, Arquero en JavaScript o una consulta SQL con DuckDB, el contrato con stdout es el mismo y el resultado que llega al navegador es idéntico.


Carga inicial e inspección

El punto de partida es el archivo crudo. src/data/world-data-2023.csv contiene 195 filas y 35 columnas con indicadores de todos los países del mundo: demografía, economía, salud, educación, geografía. La lectura es directa:

import pandas as pd

df = pd.read_csv("src/data/world-data-2023.csv", encoding="utf-8")
print(df.shape)          # (195, 35)
print(df.dtypes.head())  # Country: object, Density (P/Km2): object, ...

Declarar encoding="utf-8" de forma explícita es una costumbre barata que evita sorpresas en Windows, donde read_csv puede caer en cp1252 según la configuración regional. Cuando una columna que parece numérica aparece como object, significa que el texto contiene caracteres que impiden la conversión automática.

Las cuatro preguntas iniciales son siempre las mismas: ¿cuántas filas y columnas hay?, ¿de qué tipo es cada columna?, ¿cómo se ven las primeras observaciones?, ¿qué resume describe? Cada respuesta sugiere el siguiente paso.

Equivalente en polars
import polars as pl

df = pl.read_csv("src/data/world-data-2023.csv")
print(df.shape)
print(df.schema)    # tipos estrictos por columna
print(df.describe())

El destino de todo este recorrido es el archivo countries.csv que ya vive en dist/. Podemos cargarlo aquí mismo con FileAttachment (Observable, Inc., 2026) para ver hacia dónde vamos antes de detallar cómo se construye.

Ese es el resultado final. El resto del capítulo reconstruye paso a paso cómo llegamos ahí partiendo del CSV crudo.


Exploración sistemática: cuatro diagnósticos

Antes de modificar nada conviene levantar un inventario de problemas. Cuatro diagnósticos concretos suelen bastar para un dataset de este tipo.

Caracteres corruptos (encoding)

Varios campos contienen el carácter de reemplazo Unicode U+FFFD (\ufffd). Aparece cuando el archivo fue codificado en una página distinta (por ejemplo, Windows-1252) y re-guardado como UTF-8 sin recuperar los bytes originales. Un reemplazo ya no lleva información: es simbólico de aquí había algo que no pude leer. Detectarlos es un filtro sencillo:

mask = df.apply(lambda s: s.astype(str).str.contains("\ufffd")).any(axis=1)
df.loc[mask, ["Country", "Capital/Major City", "Largest city"]].head()

Las ciudades afectadas en este dataset son reales y reconocibles cuando uno conoce la forma correcta:

Bras������Brasília
Bogot�Bogotá
Chi�����Chișinău
Reykjav��Reykjavík

Una vez perdidos los bytes originales, la corrección no es automática: hay que mapear cada caso a mano o con una tabla externa.

Faltantes por columna

Una inspección numérica temprana del porcentaje de valores nulos por columna revela si el dataset es homogéneamente pobre o si hay campos específicos con mucha más ausencia:

(df.isnull().mean() * 100).sort_values(ascending=False).head(10)

Sobre el resultado ya limpio, la misma cuenta se puede calcular en el navegador y graficar en vivo. Seleccionamos las diez columnas con más huecos:

El salario mínimo o el gasto de bolsillo en salud aparecen vacíos para uno de cada cinco países; la latitud o la población, para ninguno. La distribución de faltantes es informativa por sí sola: indica qué preguntas se pueden responder con confianza y cuáles requieren una estrategia de imputación.

Tipos inconsistentes

read_csv infiere tipos por columna. Cuando una celda trae $1,637,896,000,000 o 45%, la inferencia falla y la columna queda como object. Detectarlas en conjunto es un filtro:

df.select_dtypes(include="object").columns.tolist()
# ['Country', 'Density (P/Km2)', 'GDP', 'Minimum wage', 'CPI', ...]

Las que aparecen aquí y deberían ser numéricas son candidatas a parseo con limpieza previa: quitar símbolos, cambiar coma decimal por punto si es necesario, convertir con pd.to_numeric(errors="coerce").

Consistencia de claves

El dataset se identifica por nombre de país, pero los nombres varían entre fuentes: "Czech Republic" frente a "Czechia", "Ivory Coast" frente a "Côte d'Ivoire", "East Timor" frente a "Timor-Leste". Detectar duplicados literales es trivial:

df["Country"].duplicated().any()    # False

Detectar variantes del mismo país requiere una decisión editorial: ¿qué forma oficial usamos? Esa decisión condiciona cualquier join posterior con tablas auxiliares (continentes, códigos ISO, población histórica). Sin una clave estable, no hay análisis comparado.

Explorar antes de transformar

Un error común es empezar a limpiar de inmediato: renombrar columnas, cambiar tipos, imputar faltantes. Sin un diagnóstico previo, las correcciones son a ciegas y suelen tapar problemas en lugar de resolverlos. Las cuatro preguntas anteriores casi siempre valen la media hora que toman.


Transformación: de crudo a limpio

El data loader src/data/countries.csv.py resuelve los cuatro diagnósticos anteriores y añade dos enriquecimientos (continente, código ISO numérico). La Figura 6.2 muestra el pipeline de ocho pasos. Cada paso es una función independiente con entrada y salida bien definidas, lo cual facilita probar y modificar cualquier fase sin tocar el resto.

Figura 6.2. Pipeline concreto del data loader de países. Cada nodo es una función de src/data/countries.csv.py; la entrada es el CSV crudo, la salida es el artefacto que consume el libro.

Corrección de encoding

La estrategia elegida es un diccionario explícito (columna, valor_corrupto) → valor_correcto con las 25 celdas afectadas. Para cada par, un filtro booleano localiza las filas y sustituye el texto:

ENCODING_FIXES = {
    ("Capital/Major City", "Bras\ufffd\ufffd\ufffd"): "Brasília",
    ("Capital/Major City", "Bogot\ufffd"):            "Bogotá",
    # ... 23 pares más
}

def fix_encoding(df):
    for (col, corrupted), correct in ENCODING_FIXES.items():
        df.loc[df[col] == corrupted, col] = correct
    return df

Herramientas automáticas como ftfy o chardet pueden acertar en algunos casos, pero sobre bytes ya perdidos ninguna adivina con certeza. Un diccionario explícito es auditable y determinista, que es lo que se pide a un pipeline reproducible.

Equivalente en polars
import polars as pl

def fix_encoding(df: pl.DataFrame) -> pl.DataFrame:
    for (col, corrupted), correct in ENCODING_FIXES.items():
        df = df.with_columns(
            pl.when(pl.col(col) == corrupted)
              .then(pl.lit(correct))
              .otherwise(pl.col(col))
              .alias(col)
        )
    return df

Renombrado a snake_case con sufijo de unidad

Los nombres originales combinan saltos de línea, paréntesis y unidades implícitas ("Density\n(P/Km2)", "Co2-Emissions", "GDP"). El renombrado persigue dos objetivos: uniformar el estilo (snake_case en vez de Title Case) y hacer explícita la unidad en el nombre de la columna.

COLUMN_RENAME_MAP = {
    "Density\n(P/Km2)":     "density_per_km2",
    "GDP":                  "gdp_usd",
    "Co2-Emissions":        "co2_emissions_kt",
    "Gross primary education enrollment (%)": "primary_enrollment_pct",
    # ... 31 pares más
}

df = df.rename(columns=COLUMN_RENAME_MAP)

Los sufijos _usd, _pct, _per_km2, _kt son una forma de documentación que viaja con los datos: cualquier lector del CSV resultante sabe las unidades sin consultar un diccionario externo.

Parseo numérico con símbolos

Cuatro grupos de columnas necesitan limpieza previa a la conversión: porcentajes con %, dólares con $, números con comas como separador de miles y columnas con saltos de línea intrusos. Una función auxiliar unifica el tratamiento:

def _strip_and_convert(series, symbols=""):
    cleaned = series.astype(str).str.strip()
    for char in symbols:
        cleaned = cleaned.str.replace(char, "", regex=False)
    cleaned = cleaned.str.replace(",", "", regex=False).str.strip()
    return pd.to_numeric(cleaned, errors="coerce")

df["gdp_usd"]         = _strip_and_convert(df["gdp_usd"], symbols="$")
df["tax_revenue_pct"] = _strip_and_convert(df["tax_revenue_pct"], symbols="%")

errors="coerce" convierte los valores irreconocibles en NaN en lugar de lanzar una excepción. Esa decisión es consciente: preferimos registrar la ausencia a detener el pipeline por una sola celda mal formada.

Equivalente en polars
df = df.with_columns(
    pl.col("gdp_usd")
      .str.replace_all(r"[\$,]", "")
      .cast(pl.Float64, strict=False)
      .alias("gdp_usd")
)

Detección de filas incompletas

Una fila con más de la mitad de sus columnas vacías dice poco. No la eliminamos (preservamos la soberanía de la fuente), pero la marcamos con una columna booleana para que las visualizaciones puedan filtrarla de forma consciente:

GHOST_COUNTRY_THRESHOLD = 0.50

null_pct = df.isnull().sum(axis=1) / df.shape[1]
df["is_ghost_country"] = null_pct > GHOST_COUNTRY_THRESHOLD

El umbral del 50 % es arbitrario y debe documentarse. Sobre el dataset limpio podemos listar los países que lo cruzan:

Son casos especiales por distintas razones: microestados (Ciudad del Vaticano, Nauru), territorios de reconocimiento parcial (Palestina), cambios de nombre recientes (Eswatini, Macedonia del Norte). Conocer la lista ayuda a decidir qué hacer con cada uno en un análisis concreto.

Estandarización de nombres de país

La armonización aplica una tabla pequeña de renombres:

COUNTRY_NAME_FIXES = {
    "Czech Republic":                   "Czechia",
    "Ivory Coast":                      "Côte d'Ivoire",
    "East Timor":                       "Timor-Leste",
    "Democratic Republic of the Congo": "DR Congo",
    # ... 6 pares más
}

for old, new in COUNTRY_NAME_FIXES.items():
    df.loc[df["country"] == old, "country"] = new

Esta fase no es cosmética. Sin una clave estable no hay join con la tabla de continentes, ni con la de códigos ISO, ni con ningún dataset externo de población histórica o comercio bilateral. La estandarización habilita el enriquecimiento del paso siguiente.

Joins pequeños: continente y código ISO

Asignar un continente a cada país es en esencia un left join con una tabla de 195 pares (país, continente). En pandas la operación se escribe de forma concisa con map sobre un diccionario:

CONTINENT_MAP = {
    "Algeria": "Africa",  "Argentina": "South America",
    "China":   "Asia",    "France":    "Europe",
    # ... 191 pares más
}

df["continent"] = df["country"].map(CONTINENT_MAP)

La misma lógica en SQL sería LEFT JOIN countries ON countries.country = continents.country, y en polars un df.join(continents, on="country", how="left"). La elección depende de si la tabla auxiliar cabe como constante en el código (diccionario) o si vive como archivo aparte que conviene tratar como tabla explícita.

El código ISO 3166-1 numérico se agrega con el mismo patrón, pero usando la abreviatura alpha-2 como clave primaria y el nombre del país como respaldo para los siete países que no traen abreviatura en el CSV original:

def _get_numeric(row):
    abbr = row["abbreviation"]
    if pd.notna(abbr) and str(abbr).strip():
        return ALPHA2_TO_ISO_NUMERIC.get(str(abbr).strip())
    return _COUNTRY_NAME_TO_ISO_NUMERIC.get(row["country"])

df["iso_numeric"] = df.apply(_get_numeric, axis=1).astype("Int64")

Ese código numérico será la clave que use el capítulo de mapas (Parte III) para emparejar cada país con su polígono en el GeoJSON del mundo.

Cada función, una transformación pequeña

El pipeline entero cabe en una decena de funciones. Cada una recibe un DataFrame, devuelve otro y reporta a stderr cuántos cambios aplicó. Esa granularidad, heredada del principio de funciones pequeñas del capítulo anterior, permite probar cualquier paso en aislamiento y sustituirlo sin romper el resto.

Una taxonomía clásica clasifica los problemas de datos sucios en faltantes, erróneos, duplicados e inconsistentes (Kim et al., 2003). Las ocho funciones de la Figura 6.2 atacan las cuatro categorías: encoding y estandarización corrigen inconsistencias; el parseo numérico corrige errores de tipo; la columna is_ghost_country documenta la falta; los joins pequeños completan información derivable.


Análisis: cuatro técnicas sobre el dataset limpio

Con el dataset ordenado, la fase de análisis produce los resúmenes y las estructuras que las visualizaciones de la Parte III consumirán. Cuatro técnicas resuelven la mayor parte de los casos de este libro.

Agregación por grupo

La pregunta más común es "¿cómo se comparan los continentes entre sí?". En pandas el patrón es groupby seguido de agg:

(df.groupby("continent")
   .agg(n=("country", "size"),
        gdp_mean=("gdp_usd", "mean"),
        life_median=("life_expectancy", "median"))
   .sort_values("life_median", ascending=False))

El equivalente en el navegador con d3.rollups produce la misma tabla sin salir del bloque reactivo:

El ordenamiento por mediana pone Europa y Oceanía arriba y África abajo, con la cuenta de países anotada en cada barra para evitar la impresión de que todas las regiones tienen la misma base de observaciones.

Estadística descriptiva focalizada

describe entrega cuartiles, media y desviación típica en una sola llamada. Al reducirlo a las variables que nos interesan, el resumen se vuelve legible:

df[["life_expectancy", "fertility_rate", "gdp_usd"]].describe()

La comparación entre media y mediana es una primera señal de asimetría. En el PIB, por ejemplo, la media suele ser varios órdenes de magnitud mayor que la mediana, lo cual indica una distribución con cola pesada a la derecha (unos pocos países concentran mucho del total). Esa asimetría justifica, más adelante, el uso de ejes logarítmicos y de la mediana sobre la media cuando comparamos grupos.

Matriz de correlación

Para detectar relaciones lineales entre variables numéricas, la correlación de Pearson por pares es el primer barrido. En pandas es una línea; en el navegador la calculamos para un subconjunto de variables clave:

vars_clave = ["life_expectancy", "fertility_rate", "gdp_usd",
              "co2_emissions_kt", "physicians_per_thousand", "urban_population"]
df[vars_clave].corr()

La matriz confirma la asociación negativa entre tasa de fertilidad y esperanza de vida (ambas correlacionadas con el PIB por habitante) y una asociación positiva clara entre emisiones de CO₂ y población urbana. Cada celda es una hipótesis de trabajo, no una conclusión: un diagrama de dispersión posterior puede revelar que la relación aparente viene de dos grupos distintos o de un puñado de casos extremos.

Rankings y valores extremos

Las colas son siempre informativas. Un ranking por métrica seleccionable, con un control deslizante para el tamaño de la ventana, permite explorar extremos sin comprometernos con una sola vista:

df.nlargest(10, "gdp_usd")[["country", "continent", "gdp_usd", "population"]]

Reordenar por distintas métricas hace visible lo que antes era una intuición: el PIB concentra valor extremo en tres o cuatro países; la esperanza de vida se distribuye de forma mucho más uniforme; la población esconde dos casos (China e India) que distorsionan cualquier promedio global.

El análisis termina en un artefacto

Lo que se ve aquí como un cuaderno interactivo es, en realidad, el consumo en vivo de un archivo estático. La reactividad del capítulo 3 permite explorar, pero la fuente que estamos inspeccionando es countries.csv, producido una sola vez por el data loader. Cuando el análisis merece publicarse, esa es la forma de hacerlo duradero.


Salida lista para visualizar

El resultado del pipeline es un CSV con 195 filas y 38 columnas, ordenadas en tres bloques: identificadores y geografía (country, continent, capital, coordenadas, iso_numeric), indicadores cuantitativos (de densidad a tasa de desempleo) y banderas de calidad (is_ghost_country). Las visualizaciones de la Parte III consumen este archivo sin volver a limpiar, sin volver a parsear, sin volver a emparejar tablas auxiliares. Ese es el sentido de la separación entre data loader y página: la transformación se paga una vez al construir, no en cada visita.

La forma canónica del dataset encaja con el principio tidy de Wickham (Wickham, 2014): cada fila es un país, cada columna es una variable y no hay tablas anidadas. Sobre esa forma, cualquier gramática de gráficos (Plot en este libro, ggplot2 en R) puede mapear variables a canales visuales sin pasos intermedios (Wickham, 2010).


Puente hacia el capítulo siguiente

Todo el pipeline de este capítulo se puede expresar también en SQL. Los renombrados son SELECT ... AS; los parseos con símbolos, combinaciones de REGEXP_REPLACE y CAST; los joins pequeños, LEFT JOIN; las agregaciones por continente, GROUP BY. El capítulo 7 muestra cómo escribir un data loader que use DuckDB desde un bloque sql de Markdown o desde un archivo .sql.js, aprovechando el mismo contrato con stdout que venimos aplicando desde el capítulo 5.

La elección entre pandas, polars y SQL no es ideológica. Depende del tamaño de los datos, de la familiaridad del equipo, de la naturaleza de la fuente (tabla única frente a varias tablas relacionadas) y de si el pipeline necesita evaluación perezosa. En la Parte III asumiremos que todo lo anterior quedó resuelto: el archivo countries.csv está en dist/, el navegador lo carga con FileAttachment y la discusión se traslada a cómo representar lo que hay dentro.

Bibliografía

Kim, W., Choi, B.-J., Hong, E.-K., Kim, S.-K., & Lee, D. (2003). A taxonomy of dirty data. Data Mining and Knowledge Discovery, 7(1), 81–99. https://doi.org/10.1023/A:1021564703268
McKinney, W. (2010). Data structures for statistical computing in python. Proceedings of the 9th Python in Science Conference, 56–61. https://doi.org/10.25080/Majora-92bf1922-00a
Observable, Inc. (2026). Files [Https://observablehq.com/framework/files].
Tukey, J. W. (1977). Exploratory data analysis. Addison-Wesley.
Vink, R. & Polars Contributors. (2024). Polars: lightning-fast DataFrame library for rust and python [Https://docs.pola.rs/].
Wickham, H. (2010). A Layered Grammar of Graphics. Journal of Computational and Graphical Statistics, 19(1), 3–28. https://doi.org/10.1198/jcgs.2009.07098
Wickham, H. (2014). Tidy data. Journal of Statistical Software, 59(10), 1–23. https://doi.org/10.18637/jss.v059.i10