1  Introducción al análisis de datos

estadistica
eda
variables
Autor/a

Wilder Ramírez Delgado

Fecha de publicación

18 de septiembre de 2026

Antes de promediar, hay que saber qué se está midiendo

Un modelo de IA recibe una matriz de números. Pero un int puede ser una edad, un código postal o un identificador; un "sin dato" puede ser un faltante que pandas no reconoce; un -999 puede ser un código de sistema que termina promediado como si fuera un valor real. Ninguno de esos errores produce una excepción: producen resultados equivocados que parecen correctos.

En este capítulo construyes el vocabulario mínimo para evitarlo: dato, variable, observación, población, muestra, parámetro y estadístico. En la parte práctica inspeccionas un dataset con errores sembrados, auditas tipos y faltantes, corriges conversiones y compruebas por simulación que una muestra cambia de una extracción a otra y puede estar sesgada.

Los datos de ejemplo son clientes de una plataforma digital. Son sintéticos: se generan con NumPy dentro del capítulo y se les siembran a propósito edades imposibles, ingresos negativos, faltantes disfrazados, fechas en formatos mezclados e identificadores repetidos.

  • Tiempo estimado de lectura: 40 minutos (unas 2 horas si ejecutas el código y resuelves los ejercicios)
  • Nivel: Básico
  • Conocimientos previos recomendados: Python básico (variables, listas, diccionarios y funciones) y haber cargado alguna vez una tabla con pandas. Es el primer capítulo del libro: no supone ninguno anterior.
  • Tecnologías mencionadas: Python 3.13, pandas 3.0, NumPy 2.5 y Matplotlib.

Objetivos

Al terminar este capítulo vas a ser capaz de:

  • explicar qué son un dato, una variable y una observación, y leer un dataset como una matriz;
  • distinguir los tipos de variables (cualitativas nominales u ordinales, cuantitativas discretas o continuas) y sus escalas de medición;
  • separar el dtype de pandas (cómo se guarda una columna) del tipo estadístico (cómo se interpreta);
  • explorar un dataset con head, info y describe, y detectar tipos mal interpretados, faltantes disfrazados y valores imposibles;
  • diferenciar población de muestra y parámetro de estadístico, y reconocer el sesgo de selección;
  • redactar un diccionario de datos antes de calcular cualquier medida.

Ejecuta las celdas en orden: cada etapa usa variables de las anteriores.

1.1 El vocabulario mínimo del análisis de datos

Dato, variable y observación

  • Dato: un valor registrado o medido. Ejemplos: 23, "Bogotá", "sí", 2500.0.
  • Variable: la característica que se mide y que puede tomar valores distintos (edad, ciudad, ingreso, compra).
  • Observación: una unidad registrada: una persona, una transacción, un día, un sensor en un instante.

En un dataset tabular, filas = observaciones y columnas = variables.

Observación edad ciudad ingreso_mensual compra
Persona A 23 Bogotá 2500 sí

La granularidad (qué representa una fila) decide qué preguntas puedes responder: una tabla por persona y una tabla por transacción describen la misma realidad, pero el mismo promedio significa cosas distintas en cada una.

El dataset como matriz

Con \(N\) observaciones y \(D\) variables, el dataset es una tabla de \(N\) filas y \(D\) columnas: cada fila \(x_i\) es el vector de características de una observación, y cada columna es una variable. En aprendizaje supervisado separas además un vector objetivo \(y\) (lo que quieres predecir) de la matriz de features \(X\).

Un modelo necesita que \(X\) sea numérica, \(X \in \mathbb{R}^{N \times D}\). Las columnas de texto (como la ciudad) primero se tipan y se codifican (Sección 1.2); por eso este capítulo empieza por los tipos de variables.

Tipos de variables

Tipo Subtipo Ejemplos Resúmenes válidos
Cualitativa Nominal (sin orden) ciudad, color, tipo de producto frecuencias, proporciones, moda
Cualitativa Ordinal (con orden) bajo/medio/alto, satisfacción 1–5 lo anterior + mediana, percentiles
Cuantitativa Discreta (conteos) número de compras, número de hijos media, mediana, desviación, cuartiles
Cuantitativa Continua (mediciones) ingreso, tiempo, temperatura media, mediana, desviación, cuartiles
  • Una variable binaria (sí/no, 0/1) es un caso particular de nominal con dos categorías.
  • La edad es continua por naturaleza, pero suele registrarse en años cumplidos (enteros); en ese caso la tratas como discreta. El tipo depende de qué mide la columna y de cómo se registró.
  • Las medidas de la última columna se definen más adelante: media, mediana y moda en el capítulo 2; desviación y cuartiles en los capítulos 3 y 4. Aquí solo te interesa qué tipo de variable admite cada una.

Escalas de medición

  • Nominal: solo igualdad o diferencia (\(A \neq B\)).
  • Ordinal: orden (\(A < B\)), sin distancias interpretables.
  • Intervalo: distancias interpretables, cero arbitrario (°C).
  • Razón: distancias y proporciones interpretables, cero absoluto (peso, tiempo, ingreso).

Frases como “el doble” solo son válidas en escala de razón: 20 °C no es “el doble de calor” que 10 °C.

Tipo estadístico vs dtype

El dtype indica cómo está almacenada una columna (int64, float64, str, datetime64; en pandas 2 el texto aparece como object). El tipo estadístico indica cómo debes interpretarla. No siempre coinciden:

  • un identificador (id_cliente) suele ser int, pero es una etiqueta, no una magnitud;
  • un código postal es un número que se comporta como categoría;
  • una columna numérica aparece como str si trae texto, separadores o símbolos ("1.200", "$100", "sin dato");
  • las fechas llegan como texto hasta que las conviertes con pd.to_datetime.

La pregunta que debes hacerte ante cada columna es: ¿tiene sentido sumar, promediar o medir distancias con ella?

Población y muestra

  • Población: el conjunto completo de unidades sobre el que quieres sacar conclusiones (todos los clientes, todas las ventas del año).
  • Muestra: el subconjunto del que tienes datos. Casi nunca puedes medir toda la población.

Una muestra es útil si es representativa. Cuando la forma de obtenerla deja fuera a ciertos grupos (encuestas solo por web, históricos que solo incluyen a quien ya compró), aparece el sesgo de selección: los resúmenes describen a la muestra, no a la población.

Parámetro y estadístico

Concepto Parámetro (población) Estadístico (muestra)
Media \(\mu\) \(\bar{x} = \frac{1}{n}\sum_{i=1}^{n} x_i\)
Proporción \(p\) \(\hat{p}\)
Varianza \(\sigma^2\) \(s^2\)
Desviación estándar \(\sigma\) \(s\)

El parámetro es fijo y casi siempre desconocido. El estadístico se calcula con la muestra, cambia de una muestra a otra y sirve para estimar el parámetro. En la muestra, \(n\) es el número de observaciones y \(\hat{p}\) la proporción de las que cumplen una condición (por ejemplo, compra == "si").

1.2 Por qué importa en IA y machine learning

Un modelo no sabe si una columna es un identificador o una magnitud: si recibe un número, lo usa como número. Todas las decisiones de preparación de datos dependen del tipo estadístico:

Tipo Preparación típica antes de modelar
Nominal Codificación: one-hot (una columna 0/1 por categoría), target encoding o embeddings si hay muchas categorías
Ordinal Codificación ordenada (bajo=1, medio=2, alto=3) solo si el orden es real
Cuantitativa Escalamiento: estandarización (media 0, desviación 1; capítulo 3) o normalización min-max
Identificador Se excluye de las features; sirve para unir tablas o detectar duplicados
Fecha Se convierte y se derivan variables (antigüedad, día de la semana, estacionalidad)
  • Si tratas un ID como magnitud, el modelo aprende patrones del orden de registro.
  • Un faltante disfrazado ("sin dato", -999) entra al entrenamiento como si fuera un valor real.
  • Un conjunto de entrenamiento es una muestra: si no representa a la población donde vas a usar el modelo, el desempeño en producción se degrada.

Idea central: la calidad de un modelo empieza antes del modelo. Tipar y revisar las variables es la condición para que los números signifiquen lo que crees que significan.

1.3 Flujo recomendado de trabajo

  1. Carga y mira la estructura. shape, columns, head(): ¿qué representa una fila?, ¿hay un identificador?
  2. Compara el dtype con el tipo estadístico. info() y dtypes: ¿hay números o fechas guardados como texto (str) o códigos guardados como int?
  3. Busca los faltantes, reales y disfrazados. isna().sum() solo ve los NaN; revisa value_counts() en busca de "sin dato", "-", -999 y similares.
  4. Revisa rangos y duplicados. describe() para mínimos y máximos imposibles; duplicated() sobre el identificador.
  5. Documenta antes de modelar. Escribe un diccionario de datos: columna, tipo estadístico, rol (feature, objetivo, identificador), problemas encontrados y decisión tomada.

Criterio general: cargar → inspeccionar → tipar y limpiar → revisar rangos y semántica → analizar o modelar.

1.4 Preparar el entorno

%matplotlib inline
%config InlineBackend.figure_format = "retina"
import sys
print("Python:", sys.version.split()[0])
from io import StringIO
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.ticker import MaxNLocator
print("pandas:", pd.__version__, "| numpy:", np.__version__)

SEMILLA = 42  # semilla central del capítulo
rng = np.random.default_rng(SEMILLA)
Python: 3.13.14
pandas: 3.0.5 | numpy: 2.5.1

Funciones de visualización (definidas una sola vez)

Para no interrumpir la lectura de cada etapa con bloques largos de matplotlib, aquí defines una sola vez las funciones de graficación. En cada etapa solo llamas a la que corresponde.

def graficar_faltantes(faltantes_crudo, faltantes_reales, titulo):
    """Faltantes por columna: lo que ve isna() tras read_csv frente a lo que hay de verdad."""
    cols = faltantes_reales.index
    x = np.arange(len(cols))
    w = 0.4
    fig, ax = plt.subplots(figsize=(10, 4))
    b1 = ax.bar(x - w / 2, faltantes_crudo.reindex(cols).values, width=w, label="isna() tras read_csv")
    b2 = ax.bar(x + w / 2, faltantes_reales.values, width=w, hatch="//", edgecolor="white", label="tras normalizar faltantes disfrazados")
    ax.bar_label(b1, fontsize=9)
    ax.bar_label(b2, fontsize=9)
    ax.set_ylim(0, faltantes_reales.max() * 1.2)
    ax.set_xticks(x)
    ax.set_xticklabels(cols, rotation=30, ha="right")
    ax.set_ylabel("Valores faltantes")
    ax.set_title(titulo)
    ax.grid(axis="y", alpha=0.25)
    ax.legend(fontsize=9)
    plt.tight_layout()
    plt.show()


def graficar_rango_plausible(valores, lim_inf, lim_sup, variable, unidad):
    """Puntos de una variable con la franja de valores plausibles; fuera de ella, en rojo."""
    valores = np.asarray(valores, dtype=float)
    fuera = (valores < lim_inf) | (valores > lim_sup)
    rng_jit = np.random.default_rng(SEMILLA)  # generador local, solo para separar los puntos del gráfico
    y = rng_jit.uniform(-0.25, 0.25, size=len(valores))
    fig, ax = plt.subplots(figsize=(10, 3.2))
    ax.axvspan(lim_inf, lim_sup, alpha=0.12, label=f"Rango plausible [{lim_inf}, {lim_sup}]")
    ax.scatter(valores[~fuera], y[~fuera], s=8, alpha=0.4, label="Valores plausibles")
    ax.scatter(valores[fuera], y[fuera], s=40, color="red", label=f"Fuera de rango ({fuera.sum()})")
    ax.set_yticks([])
    ax.set_xlabel(f"{variable} ({unidad})")
    ax.set_title(f"{variable}: el dtype es numérico, pero no todos los valores son posibles")
    ax.legend(fontsize=8, loc="upper center")
    plt.tight_layout()
    plt.show()


def graficar_por_tipo(serie_nominal, serie_discreta, serie_continua):
    """Un gráfico adecuado para cada tipo: barras (nominal), barras por valor (discreta), histograma (continua)."""
    fig, axes = plt.subplots(1, 3, figsize=(13, 3.8), constrained_layout=True)
    conteo = serie_nominal.value_counts()
    axes[0].bar(conteo.index.astype(str), conteo.values)
    axes[0].set_title(f"{serie_nominal.name} (nominal): barras de frecuencia")
    axes[0].tick_params(axis="x", rotation=30)
    axes[0].set_ylabel("Frecuencia")

    conteo_d = serie_discreta.value_counts().sort_index()
    axes[1].bar(conteo_d.index, conteo_d.values, width=0.8)
    axes[1].set_title(f"{serie_discreta.name} (discreta): una barra por valor")
    axes[1].set_xlabel(f"{serie_discreta.name} (solo valores enteros)")
    axes[1].set_ylabel("Frecuencia")
    axes[1].xaxis.set_major_locator(MaxNLocator(integer=True))

    axes[2].hist(serie_continua.dropna() / 1e6, bins=30, edgecolor="white")
    axes[2].set_title(f"{serie_continua.name} (continua): histograma")
    axes[2].set_xlabel(f"{serie_continua.name} (millones)")
    axes[2].set_ylabel("Frecuencia")
    plt.show()


def graficar_medias_muestrales(medias_por_n, mu, titulo):
    """Histogramas de medias muestrales para varios tamaños n, con la media poblacional."""
    fig, ax = plt.subplots(figsize=(9, 4))
    todas = np.concatenate(list(medias_por_n.values())) / 1e6
    bins = np.linspace(todas.min(), todas.max(), 50)  # mismos intervalos para comparar alturas
    for (n_muestra, medias), trama in zip(medias_por_n.items(), ["", "//"]):
        ax.hist(medias / 1e6, bins=bins, alpha=0.55, hatch=trama, edgecolor="white", label=f"n = {n_muestra}")
    ax.axvline(mu / 1e6, color="black", linestyle="--", linewidth=1.5, label=f"μ (parámetro) = {mu / 1e6:.2f} M")
    ax.set_xlabel("Media muestral x̄ del ingreso, en millones (estadístico)")
    ax.set_ylabel("Frecuencia")
    ax.set_title(titulo)
    ax.legend(fontsize=9)
    plt.tight_layout()
    plt.show()


def graficar_sesgo_seleccion(tamanos, medias_aleatorias, medias_sesgadas, mu):
    """Media estimada frente al tamaño de muestra: aleatoria vs sesgada."""
    fig, ax = plt.subplots(figsize=(9, 4))
    ax.plot(tamanos, np.array(medias_aleatorias) / 1e6, marker="o", label="Muestra aleatoria de toda la población")
    ax.plot(tamanos, np.array(medias_sesgadas) / 1e6, marker="s", label="Muestra solo de clientes web")
    ax.axhline(mu / 1e6, color="black", linestyle="--", linewidth=1.5, label=f"μ = {mu / 1e6:.2f} M")
    ax.set_xscale("log")
    ax.set_xticks(tamanos)
    ax.set_xticklabels([f"{t:,}" for t in tamanos])
    ax.set_xlabel("Tamaño de muestra n (escala log)")
    ax.set_ylabel("Media muestral del ingreso (millones)")
    ax.set_title("Más datos reducen el ruido, no el sesgo")
    ax.legend(fontsize=9)
    plt.tight_layout()
    plt.show()

1.5 Desarrollo por etapas

El análisis avanza de lo pequeño a lo realista: primero trabajas con un CSV de 6 filas para ver cada concepto, luego con un CSV de 1000 clientes con errores sembrados para aplicar el flujo completo, y al final con una población simulada para separar parámetro, estadístico y sesgo.

Etapa 1: Cargar un dataset pequeño

El CSV va embebido como texto para que el notebook sea reproducible. Con un archivo real usarías pd.read_csv("ruta/al/archivo.csv").

csv_texto = """id,edad,ciudad,ingreso_mensual,compra,score_texto
1,23,Bogota,2500,si,10
2,35,Medellin,4200,no,8
3,29,Cali,,si,7
4,41,Bogota,6100,no,9
5,18,Barranquilla,1200,si,6
6,35,Cali,4200,si,8n
"""

df = pd.read_csv(StringIO(csv_texto))
print("Forma (filas, columnas):", df.shape)
display(df)
Forma (filas, columnas): (6, 6)
id edad ciudad ingreso_mensual compra score_texto
0 1 23 Bogota 2500.0 si 10
1 2 35 Medellin 4200.0 no 8
2 3 29 Cali NaN si 7
3 4 41 Bogota 6100.0 no 9
4 5 18 Barranquilla 1200.0 si 6
5 6 35 Cali 4200.0 si 8n

Hay 6 observaciones (personas) y 6 columnas: un identificador (id) y 5 variables. Sin calcular nada ya puedes ver dos problemas: un ingreso_mensual vacío (se lee como NaN) y un score_texto con el valor "8n", que no es un número.

Etapa 2: Estructura — filas, columnas, \(X\) y \(y\)

Un modelo supervisado recibe una matriz de features \(X\) y un vector objetivo \(y\). El identificador no pertenece a ninguno de los dos, así que lo dejas fuera.

print("Columnas:", df.columns.tolist())
print("Una fila = una persona. Identificador:", "id")

y = df["compra"]
X = df.drop(columns=["id", "compra"])
print("\nX (features):", X.shape, "->", X.columns.tolist())
print("y (objetivo):", y.shape, "->", y.tolist())

print("\nFila 0 como vector de características:")
print(X.iloc[0].to_dict())
Columnas: ['id', 'edad', 'ciudad', 'ingreso_mensual', 'compra', 'score_texto']
Una fila = una persona. Identificador: id

X (features): (6, 4) -> ['edad', 'ciudad', 'ingreso_mensual', 'score_texto']
y (objetivo): (6,) -> ['si', 'no', 'si', 'no', 'si', 'si']

Fila 0 como vector de características:
{'edad': 23, 'ciudad': 'Bogota', 'ingreso_mensual': 2500.0, 'score_texto': '10'}

\(X\) tiene 6 filas y 4 columnas; \(y\) tiene 6 valores. La fila 0 todavía no es un vector numérico: contiene texto ('Bogota') y un número guardado como texto ('10'). Para llegar a la matriz numérica faltan dos pasos: tipar (etapas siguientes) y codificar (Sección 1.2).

La granularidad cambia el promedio. Si registras la misma información por transacción en lugar de por persona, quien compra más veces aparece en más filas y pesa más en cualquier resumen.

transacciones = pd.DataFrame({
    "id":    [1, 1, 1, 1, 2, 3, 4, 5, 6],
    "monto": [120, 80, 95, 60, 300, 150, 410, 45, 220],
})
trans = transacciones.merge(df[["id", "edad"]], on="id")

print("Filas por persona:", len(df), "| filas por transacción:", len(trans))
print("Edad media por persona:     ", round(df["edad"].mean(), 2))
print("Edad media por transacción: ", round(trans["edad"].mean(), 2))
Filas por persona: 6 | filas por transacción: 9
Edad media por persona:      30.17
Edad media por transacción:  27.78

La persona con id=1 (23 años) tiene 4 transacciones y arrastra el promedio hacia abajo: 30.17 años por persona frente a 27.78 por transacción. Ninguno de los dos números está “mal”; responden preguntas distintas (¿qué edad tiene el cliente típico? frente a ¿qué edad tiene quien hace la compra típica?). Antes de promediar necesitas saber qué es una fila.

Etapa 3: dtype vs tipo estadístico

info() te dice cómo guardó pandas cada columna. El diccionario de datos dice cómo debes interpretarla, y lo construyes a mano, porque ese conocimiento no está en el archivo.

df.info()

diccionario = pd.DataFrame({
    "dtype": df.dtypes.astype(str),
    "tipo_estadistico": [
        "identificador",
        "cuantitativa discreta (años)",
        "cualitativa nominal",
        "cuantitativa continua (moneda)",
        "cualitativa binaria",
        "cuantitativa discreta (guardada como texto)",
    ],
    "rol": ["identificador", "feature", "feature", "feature", "objetivo (y)", "feature"],
})
print()
display(diccionario)
<class 'pandas.DataFrame'>
RangeIndex: 6 entries, 0 to 5
Data columns (total 6 columns):
 #   Column           Non-Null Count  Dtype  
---  ------           --------------  -----  
 0   id               6 non-null      int64  
 1   edad             6 non-null      int64  
 2   ciudad           6 non-null      str    
 3   ingreso_mensual  5 non-null      float64
 4   compra           6 non-null      str    
 5   score_texto      6 non-null      str    
dtypes: float64(1), int64(2), str(3)
memory usage: 480.0 bytes
dtype tipo_estadistico rol
id int64 identificador identificador
edad int64 cuantitativa discreta (años) feature
ciudad str cualitativa nominal feature
ingreso_mensual float64 cuantitativa continua (moneda) feature
compra str cualitativa binaria objetivo (y)
score_texto str cuantitativa discreta (guardada como texto) feature

Dos columnas tienen un dtype que no coincide con su significado: id es int64 pero es una etiqueta, y score_texto es str (texto) aunque su contenido es numérico. pandas guarda lo que encuentra; interpretar cada columna te corresponde a ti.

El diccionario también te obliga a decidir los casos dudosos: si score_texto fuera una calificación de satisfacción del 1 al 10, sería ordinal y no cuantitativa. El tipo no se deduce de los valores sino de qué mide la columna; por eso lo documentas.

Etapa 4: describe() calcula todo lo que puede, tenga sentido o no

print("--- describe() numéricas ---")
print(df.describe().round(2))
print("\n--- describe(exclude='number') ---")
print(df.describe(exclude="number"))
--- describe() numéricas ---
         id   edad  ingreso_mensual
count  6.00   6.00             5.00
mean   3.50  30.17          3640.00
std    1.87   8.54          1866.28
min    1.00  18.00          1200.00
25%    2.25  24.50          2500.00
50%    3.50  32.00          4200.00
75%    4.75  35.00          4200.00
max    6.00  41.00          6100.00

--- describe(exclude='number') ---
        ciudad compra score_texto
count        6      6           6
unique       4      2           6
top     Bogota     si          10
freq         2      4           1

Para las columnas numéricas, describe() calcula conteo, media, desviación estándar, mínimo y cuartiles. Eso produce números correctos y sin significado: promediar identificadores no describe a nadie. En cambio, ingreso_mensual cuenta 5 valores y no 6: el faltante aparece aquí antes que en ningún gráfico. Para las columnas de texto, describe(exclude="number") entrega conteo, número de categorías distintas, la más frecuente (top) y su frecuencia (freq), que es lo que tiene sentido para variables nominales.

mean, std y los percentiles (25%, 50%, 75%) se estudian en los capítulos 2 a 4. Por ahora te basta saber que describe() los calcula para toda columna numérica, tenga sentido o no.

Etapa 5: Números guardados como texto

Una columna numérica guardada como texto no falla: ordena y compara como texto.

print("Máximo de score_texto (como texto):", repr(df["score_texto"].max()))

df["score"] = pd.to_numeric(df["score_texto"], errors="coerce")
print("Máximo de score (como número):     ", df["score"].max())

print("\nValores que no se pudieron convertir:")
print(df.loc[df["score"].isna(), ["id", "score_texto", "score"]])
Máximo de score_texto (como texto): '9'
Máximo de score (como número):      10.0

Valores que no se pudieron convertir:
   id score_texto  score
5   6          8n    NaN

Como texto, "9" > "10" porque se compara carácter a carácter: el máximo “es” 9. Convertido, el máximo es 10. errors="coerce" convierte lo que puede y deja NaN en lo que no ("8n"), así que después de toda conversión cuenta los NaN nuevos: son datos que se perdieron o que hay que corregir en origen.

Ejercicios

  1. Clasifica cada columna de df como cualitativa (nominal u ordinal), cuantitativa (discreta o continua), identificador o fecha, y señala las que están mal tipadas.
  2. Calcula la proporción de observaciones con compra == "si" y explica en una frase por qué ese valor es un estadístico \(\hat{p}\) y no un parámetro.
  1. id: identificador (int64, pero es una etiqueta). edad: cuantitativa discreta. ciudad: nominal. ingreso_mensual: cuantitativa continua. compra: binaria, un caso de nominal. score_texto: cuantitativa discreta guardada como texto. Mal tipadas: id y score_texto. En esta tabla no hay fechas.
  2. \(\hat{p} = 4/6 \approx 0.667\), con (df["compra"] == "si").mean(). Es un estadístico porque lo calculas con estas 6 personas: otras 6 darían otro valor, y la proporción de toda la población (\(p\)) sigue siendo desconocida.

Etapa 6: Un CSV realista — 1000 clientes con errores sembrados

Generas un dataset de 1000 clientes sintéticos: edades enteras entre 18 y 69 años, ingreso mensual con distribución lognormal de mediana 2.5 millones, ciudad y segmento con probabilidades fijas, fecha de alta entre 2021 y 2023, número de compras con distribución de Poisson de media 6 y un 30% de compradores. Después le insertas errores típicos de sistemas reales en una fracción de las filas: texto en columnas numéricas, faltantes disfrazados, códigos especiales, fechas en formatos mezclados, valores imposibles e identificadores duplicados. Por último lo escribes como CSV y lo vuelves a leer, igual que llegaría desde un archivo.

n = 1000

base = pd.DataFrame({
    "id": np.arange(1, n + 1),
    "edad": rng.integers(18, 70, size=n),
    "genero": rng.choice(["M", "F"], size=n),
    "ciudad": rng.choice(["Bogota", "Medellin", "Cali", "Barranquilla", "Otra"], size=n,
                         p=[0.35, 0.25, 0.18, 0.12, 0.10]),
    "ingreso_mensual": rng.lognormal(np.log(2_500_000), 0.45, size=n).round(-3),
    "fecha_alta": (pd.Timestamp("2021-01-01")
                   + pd.to_timedelta(rng.integers(0, 365 * 3, size=n), unit="D")).strftime("%Y-%m-%d"),
    "compra": rng.choice(["si", "no"], size=n, p=[0.3, 0.7]),
    "score_credito": rng.integers(300, 900, size=n),
    "num_compras": rng.poisson(6, size=n),
    "segmento": rng.choice(["A", "B", "C", "VIP"], size=n, p=[0.3, 0.4, 0.25, 0.05]),
})

# Permitir valores mixtos antes de sembrar errores
for col in ["edad", "ingreso_mensual", "score_credito", "num_compras"]:
    base[col] = base[col].astype(object)

def sembrar(col, valor, k):
    idx = rng.choice(n, size=k, replace=False)
    base.loc[idx, col] = valor

sembrar("edad", "veinticinco", 3)          # texto en columna numérica
sembrar("edad", 200, 6)                    # valor imposible
sembrar("ingreso_mensual", "N/A", 25)      # faltante que pandas sí reconoce
sembrar("ingreso_mensual", "sin dato", 20) # faltante disfrazado
sembrar("ingreso_mensual", -500000, 5)     # ingreso negativo
sembrar("fecha_alta", "01/05/2022", 10)    # formato día/mes/año
sembrar("fecha_alta", "enero 2022", 5)     # texto libre
sembrar("score_credito", "alto", 8)        # texto en score numérico
sembrar("num_compras", -999, 30)           # código de sistema = "no disponible"
sembrar("segmento", "-", 18)               # faltante disfrazado en categórica
base.loc[rng.choice(n, size=5, replace=False), "id"] = rng.integers(1, n + 1, size=5)  # ids duplicados

csv_clientes = base.to_csv(index=False)
df_clientes = pd.read_csv(StringIO(csv_clientes))

print("Forma:", df_clientes.shape)
print("\ndtypes tras read_csv:")
print(df_clientes.dtypes)
display(df_clientes.head())
Forma: (1000, 10)

dtypes tras read_csv:
id                 int64
edad                 str
genero               str
ciudad               str
ingreso_mensual      str
fecha_alta           str
compra               str
score_credito        str
num_compras        int64
segmento             str
dtype: object
id edad genero ciudad ingreso_mensual fecha_alta compra score_credito num_compras segmento
0 1 22 F Bogota 1577000.0 2021-05-04 no 856 6 B
1 2 58 F Medellin 3762000.0 2022-09-17 no 810 2 C
2 3 52 F Bogota 1455000.0 2021-07-11 no 546 7 B
3 4 40 F Bogota 2198000.0 2021-12-10 si 740 5 B
4 5 40 M Cali sin dato 2022-03-31 si 318 6 B

edad, ingreso_mensual y score_credito deberían ser numéricas y llegan como str: basta un texto en la columna para que pandas la guarde entera como texto. (En pandas 3 el texto tiene su propio tipo, str; en versiones anteriores aparecía como object.) num_compras sí llega como int64, y eso es más peligroso: el código -999 es un número válido para el dtype y entraría en cualquier promedio sin aviso.

Etapa 7: Faltantes reales y faltantes disfrazados

read_csv ya convierte en NaN algunos marcadores comunes ("", "N/A", "NA", "null"). No reconoce los que cada sistema inventa: "sin dato", "-", -999.

faltantes_crudo = df_clientes.isna().sum()

DISFRAZADOS = ["sin dato", "-", "?"]
df_norm = df_clientes.replace(DISFRAZADOS, np.nan)
df_norm["num_compras"] = df_norm["num_compras"].replace(-999, np.nan)
faltantes_reales = df_norm.isna().sum()

tabla_faltantes = pd.DataFrame({"isna_tras_read_csv": faltantes_crudo,
                                "faltantes_reales": faltantes_reales})
tabla_faltantes = tabla_faltantes[tabla_faltantes.sum(axis=1) > 0]
print(tabla_faltantes)

graficar_faltantes(tabla_faltantes["isna_tras_read_csv"], tabla_faltantes["faltantes_reales"],
                   "Faltantes por columna: lo que ve isna() frente a lo que hay")
                 isna_tras_read_csv  faltantes_reales
ingreso_mensual                  24                44
num_compras                       0                30
segmento                          0                18

Barras agrupadas por columna. En ingreso_mensual, isna() cuenta 24 faltantes y tras normalizar los marcadores hay 44; en num_compras pasa de 0 a 30 y en segmento de 0 a 18.

isna() ve 24 faltantes en ingreso_mensual (los "N/A" que read_csv reconoce) y ninguno en num_compras ni en segmento. Tras normalizar los marcadores, los faltantes reales son 44, 30 y 18. En num_compras y segmento los faltantes eran invisibles: sin revisar value_counts(), el -999 habría bajado la media de compras y "-" habría aparecido como una categoría más. Un faltante disfrazado entra al análisis como un valor falso.

Etapa 8: Convertir tipos y contar lo que se pierde

df_tip = df_norm.copy()
reporte = []
for col in ["edad", "ingreso_mensual", "score_credito"]:
    antes = df_tip[col].isna().sum()
    df_tip[col] = pd.to_numeric(df_tip[col], errors="coerce")
    reporte.append((col, antes, df_tip[col].isna().sum()))

antes = df_tip["fecha_alta"].isna().sum()
df_tip["fecha_alta"] = pd.to_datetime(df_tip["fecha_alta"], format="%Y-%m-%d", errors="coerce")
reporte.append(("fecha_alta", antes, df_tip["fecha_alta"].isna().sum()))

reporte = pd.DataFrame(reporte, columns=["columna", "NaN_antes", "NaN_despues"])
reporte["perdidos_por_conversion"] = reporte["NaN_despues"] - reporte["NaN_antes"]
print(reporte.to_string(index=False))
print("\ndtypes finales:")
print(df_tip[["edad", "ingreso_mensual", "score_credito", "num_compras", "fecha_alta"]].dtypes)
        columna  NaN_antes  NaN_despues  perdidos_por_conversion
           edad          0            3                        3
ingreso_mensual         44           44                        0
  score_credito          0            8                        8
     fecha_alta          0           14                       14

dtypes finales:
edad                      float64
ingreso_mensual           float64
score_credito             float64
num_compras               float64
fecha_alta         datetime64[us]
dtype: object

Cada conversión con errors="coerce" deja un rastro contable: 3 edades escritas con texto, 8 scores con "alto" y 14 fechas que no siguen el formato AAAA-MM-DD pasan a NaN. En ingreso_mensual no se pierde nada nuevo porque sus faltantes ya se habían normalizado en la etapa anterior. En fecha_alta se pierden las fechas en formato día/mes/año y las escritas como texto; son fechas reales que podrías recuperar con reglas de parseo adicionales. Recuperarlas, imputarlas o descartarlas es una decisión que tomas a propósito y dejas documentada.

Etapa 9: Valores imposibles e identificadores duplicados

Un valor puede tener el dtype correcto y ser imposible. Por eso revisas aparte el dominio de la variable: lo que puede valer en la realidad.

edad_fuera = ((df_tip["edad"] < 18) | (df_tip["edad"] > 100)).sum()
ingreso_neg = (df_tip["ingreso_mensual"] < 0).sum()
ids_dup = df_tip["id"].duplicated(keep=False).sum()

print("Edades fuera de [18, 100]:", edad_fuera)
print("Ingresos negativos:       ", ingreso_neg)
print("Filas con id repetido:    ", ids_dup)
print("\nMáximo de edad (el que reportaría describe()):", df_tip["edad"].max())

graficar_rango_plausible(df_tip["edad"].dropna(), 18, 100, "edad", "años")
Edades fuera de [18, 100]: 6
Ingresos negativos:        5
Filas con id repetido:     10

Máximo de edad (el que reportaría describe()): 200.0

Diagrama de puntos de las edades con la franja plausible de 18 a 100 años sombreada. Casi todos los puntos quedan entre 18 y 69 años; seis puntos rojos aparecen aislados en 200.

describe() habría reportado una edad máxima de 200 sin marcarla como error: calcula, no valida. Los 5 ingresos negativos y las 10 filas con id repetido (5 pares) tampoco generan ninguna advertencia. Aquí el identificador cumple su función real, detectar registros duplicados, y por eso no entra como feature.

Ejercicios

  1. Reemplaza las edades fuera de [18, 100] por NaN y recalcula la media de edad. ¿Cuánto cambia?
  2. Decide qué hacer con los id duplicados: ¿son la misma persona registrada dos veces o dos personas con el mismo código? Escribe qué información te haría falta para decidirlo.
  1. Usa df_tip["edad"].where(df_tip["edad"].between(18, 100)). La media pasa de 44.64 a 43.70 años, con 991 edades válidas: seis valores de 200 bastan para subirla casi un año.
  2. Revisa los pares con df_tip[df_tip["id"].duplicated(keep=False)].sort_values("id"). En los cinco pares cambian la edad, la ciudad y la fecha de alta (el id 176, por ejemplo, aparece con 42 años en Cali y con 32 en Barranquilla), lo que apunta a dos personas con el mismo código y no a un registro repetido. Para decidirlo con seguridad necesitarías un dato que identifique a la persona fuera del sistema (documento o correo) o saber cómo se asignan los códigos. Mientras tanto, no borres ninguna de las dos filas.

Etapa 10: Un gráfico adecuado para cada tipo de variable

El tipo estadístico decide también la visualización: barras para categorías, barras por valor para conteos discretos e histograma para mediciones continuas.

ingreso_valido = df_tip["ingreso_mensual"].where(df_tip["ingreso_mensual"] >= 0)
graficar_por_tipo(df_tip["ciudad"], df_tip["num_compras"].dropna().astype(int), ingreso_valido.rename("ingreso_mensual"))

codigos = df_tip["ciudad"].astype("category").cat.codes
print("Ciudad codificada como número (0, 1, 2...) -> 'media' =", round(codigos.mean(), 3))

Tres gráficos: barras de frecuencia por ciudad, con Bogota como la más frecuente (unos 340 clientes); barras por valor de num_compras, de 0 a 18, con el máximo en 5 y 6; e histograma del ingreso mensual en millones, asimétrico a la derecha, con la mayoría entre 1 y 4 millones.

Ciudad codificada como número (0, 1, 2...) -> 'media' = 1.884

Las tres variables son columnas de la misma tabla y cada una pide un gráfico distinto. La “media” de las ciudades codificadas (1.884) es el ejemplo más claro de un número bien calculado y sin significado: con otro orden de las categorías daría otro valor. Para una variable nominal, el resumen válido es la frecuencia y la moda (capítulo 2).

Etapa 11: Población, muestra, parámetro y estadístico

Simulas una población de 50 000 clientes: el 40% compra por web, con un ingreso lognormal de mediana 3.4 millones, y el resto tiene un ingreso lognormal de mediana 2.3 millones. Su media es el parámetro \(\mu\): en la práctica no lo conocerías; aquí lo conoces para poder comparar. Luego tomas muestras y calculas la media de cada una: el estadístico \(\bar{x}\).

rng_pob = rng  # mismo generador central
N = 50_000
es_web = rng_pob.random(N) < 0.40
ingreso_pob = np.where(
    es_web,
    rng_pob.lognormal(np.log(3_400_000), 0.35, size=N),
    rng_pob.lognormal(np.log(2_300_000), 0.35, size=N),
)
mu = ingreso_pob.mean()

muestra = rng_pob.choice(ingreso_pob, size=50, replace=False)
print(f"Parámetro  μ (media poblacional): {mu:,.0f}")
print(f"Estadístico x̄ (una muestra, n=50): {muestra.mean():,.0f}")
print(f"Diferencia: {muestra.mean() - mu:+,.0f}")

medias_por_n = {}
for n_muestra in [50, 500]:
    medias_por_n[n_muestra] = np.array([
        rng_pob.choice(ingreso_pob, size=n_muestra, replace=False).mean() for _ in range(1000)
    ])
    m = medias_por_n[n_muestra]
    print(f"n = {n_muestra:>3}: 1000 medias muestrales entre {m.min():,.0f} y {m.max():,.0f}")

graficar_medias_muestrales(medias_por_n, mu, "El estadístico cambia de muestra en muestra; el parámetro no")
Parámetro  μ (media poblacional): 2,903,726
Estadístico x̄ (una muestra, n=50): 2,694,069
Diferencia: -209,656
n =  50: 1000 medias muestrales entre 2,321,617 y 3,516,324
n = 500: 1000 medias muestrales entre 2,712,149 y 3,102,343

Histogramas superpuestos de 1000 medias muestrales del ingreso para n = 50 y n = 500, con una línea vertical discontinua en μ = 2.90 millones. Ambas nubes se centran en μ y la de n = 500 es mucho más estrecha.

Una sola muestra de 50 clientes da un \(\bar{x}\) que no coincide con \(\mu\) (2,694,069 frente a 2,903,726, unos 210 000 por debajo); esa diferencia se llama variabilidad muestral. Al repetir el muestreo 1000 veces, las medias se reparten alrededor de \(\mu\); con \(n=500\) la nube es mucho más estrecha que con \(n=50\). Lo correcto es decir “la media muestral es …”, no “la media es …”. Cuánto puedes confiar en un \(\bar{x}\) es el tema del capítulo 9 (inferencia estadística).

Ejercicios

  1. Repite la simulación con n = 5 y n = 5000. ¿Qué pasa con el ancho del histograma?
  2. Explica en una frase por qué los coeficientes de un modelo entrenado son estadísticos y no parámetros.
  1. Cambia la lista [50, 500] por [5, 5000]. El ancho de la nube de medias se reduce más o menos en proporción a \(1/\sqrt{n}\): al multiplicar \(n\) por 10, la nube queda unas \(\sqrt{10} \approx 3.2\) veces más estrecha. Con \(n=5\) verás una nube unas tres veces más ancha que con 50, y con \(n=5000\) una unas tres veces más estrecha que con 500.
  2. Porque se calculan con una muestra, el conjunto de entrenamiento: con otro conjunto saldrían otros valores. Los coeficientes “verdaderos”, si existen, serían propiedades de la población y son desconocidos.

Etapa 12: El contraste que importa — más datos no corrigen una muestra sesgada

En la población simulada, el 40% son clientes que compran por web y tienen ingresos más altos. Una encuesta enviada solo por web no ve al otro 60%. Comparas esa muestra con una muestra aleatoria del mismo tamaño.

ingreso_web = ingreso_pob[es_web]
tamanos = [50, 200, 1000, 5000, 15000]
medias_aleatorias = [rng_pob.choice(ingreso_pob, size=t, replace=False).mean() for t in tamanos]
medias_sesgadas = [rng_pob.choice(ingreso_web, size=t, replace=False).mean() for t in tamanos]

tabla_sesgo = pd.DataFrame({
    "n": tamanos,
    "media_aleatoria": np.round(medias_aleatorias, -3),
    "media_solo_web": np.round(medias_sesgadas, -3),
})
tabla_sesgo["error_solo_web_%"] = (100 * (tabla_sesgo["media_solo_web"] - mu) / mu).round(1)
print(f"μ = {mu:,.0f}")
print(tabla_sesgo.to_string(index=False))

graficar_sesgo_seleccion(tamanos, medias_aleatorias, medias_sesgadas, mu)
μ = 2,903,726
    n  media_aleatoria  media_solo_web  error_solo_web_%
   50        2958000.0       3592000.0              23.7
  200        2974000.0       3685000.0              26.9
 1000        2797000.0       3625000.0              24.8
 5000        2892000.0       3605000.0              24.2
15000        2897000.0       3607000.0              24.2

Media muestral del ingreso frente al tamaño de muestra, en escala logarítmica. La muestra aleatoria (círculos) oscila cerca de la línea de μ = 2.90 millones y desde n = 5000 queda pegada a ella; la muestra solo web (cuadrados) se mantiene entre 3.59 y 3.69 millones, por encima de μ, sin acercarse al crecer n.

La muestra aleatoria se acerca a \(\mu\) a medida que crece \(n\), aunque no en línea recta: con \(n=1000\) quedó un 3.7% por debajo, porque el azar todavía pesa. Desde \(n=5000\) queda a menos del 0.5% de \(\mu\). La muestra “solo web” también se estabiliza… alrededor de un valor equivocado: queda entre un 23.7% y un 26.9% por encima de \(\mu\) en todos los tamaños (24.2% con 5000 y con 15 000 registros), y seguir sumando registros no la acerca. El tamaño reduce el ruido; no corrige el sesgo. Lo mismo ocurre con un modelo entrenado con datos de un solo canal, una sola región o un solo periodo: más filas del mismo origen no lo vuelven representativo.

Caso aplicado: el diccionario de datos antes de cualquier análisis

Antes de calcular la primera media sobre df_clientes, dejas por escrito qué es cada columna, cómo llegó y qué se hizo con ella. Es el resultado concreto de una exploración inicial.

tipo_estadistico = {
    "id": "identificador", "edad": "cuantitativa discreta", "genero": "cualitativa nominal",
    "ciudad": "cualitativa nominal", "ingreso_mensual": "cuantitativa continua",
    "fecha_alta": "fecha", "compra": "cualitativa binaria", "score_credito": "cuantitativa discreta",
    "num_compras": "cuantitativa discreta", "segmento": "cualitativa nominal (¿A < B < C < VIP es un orden real?)",
}
rol = {c: "feature" for c in tipo_estadistico}
rol.update({"id": "identificador", "compra": "objetivo (y)", "fecha_alta": "feature (derivar antigüedad)"})
problemas = {
    "id": f"{ids_dup} filas con id repetido",
    "edad": f"texto en la columna; {edad_fuera} valores > 100",
    "ingreso_mensual": f"'N/A', 'sin dato'; {ingreso_neg} negativos",
    "fecha_alta": "formatos mezclados (d/m/a, texto libre)",
    "score_credito": "texto ('alto') en columna numérica",
    "num_compras": "código -999 = no disponible",
    "segmento": "'-' como faltante",
}

diccionario_clientes = pd.DataFrame({
    "dtype_crudo": df_clientes.dtypes.astype(str),
    "dtype_final": df_tip.dtypes.astype(str),
    "tipo_estadistico": pd.Series(tipo_estadistico),
    "rol": pd.Series(rol),
    "faltantes_reales": df_tip.isna().sum(),
    "problemas": pd.Series(problemas),
}).reindex(df_clientes.columns).fillna({"problemas": "—"})
display(diccionario_clientes)
dtype_crudo dtype_final tipo_estadistico rol faltantes_reales problemas
id int64 int64 identificador identificador 0 10 filas con id repetido
edad str float64 cuantitativa discreta feature 3 texto en la columna; 6 valores > 100
genero str str cualitativa nominal feature 0 —
ciudad str str cualitativa nominal feature 0 —
ingreso_mensual str float64 cuantitativa continua feature 44 'N/A', 'sin dato'; 5 negativos
fecha_alta str datetime64[us] fecha feature (derivar antigüedad) 14 formatos mezclados (d/m/a, texto libre)
compra str str cualitativa binaria objetivo (y) 0 —
score_credito str float64 cuantitativa discreta feature 8 texto ('alto') en columna numérica
num_compras int64 float64 cuantitativa discreta feature 30 código -999 = no disponible
segmento str str cualitativa nominal (¿A < B < C < VIP es un or... feature 18 '-' como faltante

La tabla reúne en una sola vista lo que encontraste en las etapas 6 a 9: tres columnas numéricas que llegaron como texto, str (edad, ingreso_mensual, score_credito), 44 faltantes reales en ingreso_mensual, 30 en num_compras, 18 en segmento y 14 fechas que no se pudieron convertir. La columna problemas es la que más pesa: dice qué falta decidir antes de calcular cualquier medida.

Plantilla para describir un dataset. Puedes describir cualquier dataset con estos cinco puntos:

  1. Unidad de observación. Qué representa una fila y cuántas hay.
  2. Variables. Tipo estadístico, unidad y rol (feature, objetivo, identificador, metadato).
  3. Calidad. Faltantes reales (incluidos los disfrazados), tipos mal leídos, valores imposibles, duplicados.
  4. Decisiones. Qué se convirtió, qué se marcó como faltante, qué se excluyó y por qué.
  5. Representatividad. De qué población proviene la muestra y qué grupos podrían faltar.

Así queda para df_clientes:

“df_clientes tiene 1000 filas, una por cliente (salvo los id repetidos), y 9 variables más un identificador. El objetivo es compra (binaria). Se encontraron faltantes disfrazados en ingreso_mensual ("sin dato"), num_compras (-999) y segmento ("-"), edades de 200 años, ingresos negativos e identificadores repetidos. Se normalizaron los faltantes, se convirtieron los tipos contando los valores perdidos y se contaron los valores imposibles (6 edades de 200 años y 5 ingresos negativos), que quedan pendientes de revisión. Limitación: los datos provienen de un solo sistema de registro; no se sabe si representan a todos los clientes.”

Errores que conviene nombrar

  1. Tratar un identificador como magnitud. La “media del id” no describe a nadie.
  2. Confiar en isna() para contar faltantes. Solo ve NaN; los faltantes disfrazados ("sin dato", -999, "-") pasan como datos.
  3. Convertir con errors="coerce" sin contar. Los valores que no se pudieron convertir desaparecen en silencio.
  4. Aceptar un dtype numérico como garantía. Una edad de 200 es un int válido.
  5. Promediar códigos de categorías. El resultado depende del orden de codificación.
  6. Olvidar la granularidad. Un promedio por transacción no es un promedio por cliente.
  7. Llamar “la media” a una media muestral. Es un estadístico; otra muestra daría otro valor.
  8. Creer que más datos corrigen el sesgo. Solo reducen el ruido alrededor del valor sesgado.

1.6 Guía rápida: ¿qué hacer con cada tipo de variable?

Tipo Resumen válido Gráfico Preparación para un modelo
Nominal Frecuencias, proporciones, moda Barras One-hot / embeddings
Ordinal Lo anterior + mediana, percentiles Barras ordenadas Codificación ordenada
Cuantitativa discreta Media, mediana, desviación, cuartiles Barras por valor o histograma Escalamiento
Cuantitativa continua Media, mediana, desviación, cuartiles Histograma, boxplot Escalamiento
Identificador Conteo de únicos y duplicados — Excluir de las features
Fecha Mínimo, máximo, frecuencia por periodo Serie temporal Derivar variables (antigüedad, mes, día)

Regla de comunicación: antes de reportar un resumen, indica la variable, su tipo, su unidad y cuántos valores válidos lo sostienen.

1.7 Preguntas de comprensión

  1. Tienes una tabla con una fila por transacción y quieres conocer la edad típica de tus clientes. ¿Qué haces antes de promediar?
  2. Una columna codigo_postal llega como int64. ¿Qué tipo estadístico tiene y qué harías con ella antes de modelar?
  3. ¿Por qué num_compras era más peligrosa que edad, si su dtype era el correcto?
  4. ¿En qué escala se mide la temperatura en °C? ¿Puedes decir que 30 °C es un 50% más que 20 °C?
  5. Una empresa entrena un modelo de riesgo solo con clientes a los que ya les aprobó un crédito. ¿Qué problema tiene la muestra y por qué sumar más clientes del mismo origen no lo resuelve?
  1. Cambia la granularidad: agrupa por cliente para tener una fila por persona y promedia ahí. En la etapa 2, la misma tabla dio 30.17 años por persona y 27.78 por transacción.
  2. Es nominal: el número es una etiqueta y promediarlo no tiene sentido. Guárdala como texto (así no pierdes los ceros iniciales) y, para un modelo, codifícala o agrúpala por región.
  3. Porque -999 es un número: no cambia el dtype, isna() no lo ve y entra en el promedio sin aviso. edad, en cambio, llegó como str por el texto "veinticinco", y ese dtype ya era una alarma.
  4. En escala de intervalo: el cero es arbitrario. Puedes decir que hace 10 grados más, pero no que hace un 50% más de calor. En kelvin, que es una escala de razón, sí podrías hablar de proporciones.
  5. Tiene sesgo de selección: no ve a los clientes rechazados, que son justo aquellos cuyo riesgo le interesa estimar. Como en la etapa 12, más filas del mismo origen reducen el ruido alrededor de un valor sesgado; para corregirlo necesitas información de la población completa o cambiar cómo se obtiene la muestra.

1.8 Referencias para profundizar

  • Stevens, S. S. (1946). On the theory of scales of measurement. Science, 103(2684), 677–680. El origen de las escalas nominal, ordinal, de intervalo y de razón.
  • Wickham, H. (2014). Tidy data. Journal of Statistical Software, 59(10), 1–23. Cómo organizar una tabla para que cada fila sea una observación y cada columna una variable.
  • Lohr, S. L. (2021). Sampling: Design and Analysis (3.ª ed.). Chapman & Hall/CRC. Población, muestra, representatividad y sesgo de selección.
  • Documentación de pandas, Working with missing data, y la lista de marcadores que read_csv reconoce como faltantes en el parámetro na_values de pandas.read_csv.

1.9 Lo que debes llevarte de este capítulo

Un número puede estar bien calculado y aun así responder otra pregunta, si los datos están mal tipados o mal muestreados. Antes del primer promedio necesitas saber qué es una fila, qué tipo tiene cada variable, qué cuenta como faltante y de qué población viene la muestra.

  • Un dataset es una matriz: filas = observaciones, columnas = variables. Separas \(X\), \(y\) y el identificador. La granularidad cambia el significado de un promedio: 30.17 años por persona frente a 27.78 por transacción.
  • El tipo estadístico (nominal, ordinal, discreta, continua) y la escala de medición deciden qué resúmenes, gráficos y preparación admite cada variable. El dtype solo dice cómo está guardada.
  • isna() solo ve NaN: en los 1000 clientes contó 24 faltantes en ingreso_mensual cuando había 44, y ninguno en num_compras (-999) ni en segmento ("-").
  • Después de cada conversión con errors="coerce" cuentas lo que se perdió (aquí, 3 edades, 8 scores y 14 fechas), y un dtype numérico no garantiza valores posibles (6 edades de 200 años, 5 ingresos negativos).
  • El parámetro es fijo; el estadístico cambia de una muestra a otra, y menos cuanto mayor es \(n\).
  • Más datos reducen el ruido, no el sesgo: la muestra solo web quedó cerca de un 24% por encima de \(\mu\) aun con 15 000 registros.
  • El diccionario de datos (tipo, rol, problemas y decisiones de cada columna) es lo primero que produces, antes de cualquier análisis.

1.10 Puente hacia los siguientes capítulos

Con las variables tipadas y los faltantes identificados, ya puedes resumir. En el capítulo 2 calculas el centro de una variable —media, mediana y moda— y ves por qué el tipo de variable decide cuál es válida. En el capítulo 3 mides cuánto varían los datos alrededor de ese centro.