Curso de ML EN

Capítulo 3 de 37 · básico

Normalización y estandarización

Qué cubre este capítulo

Aquí hay un problema que ningún algoritmo de este libro te resuelve, porque ocurre antes de que el algoritmo siquiera corra. Tus datos tienen una columna de glucosa que vive en los cientos y una columna de pedigrí de diabetes que vive entre cero y uno. Para un modelo que mide distancias, esas dos columnas no son ciudadanas iguales — la glucosa grita cien veces más fuerte simplemente porque sus números son más grandes, y la columna de pedigrí, que podría traer señal real, queda ahogada. Nadie decidió eso. Es un accidente de unidades, y arruina en silencio cualquier método que sume features o mida qué tan lejos están dos puntos.

El arreglo es poner primero cada feature en una escala común. Este capítulo construye los tres escaladores que de verdad vas a usar — normalización min-max, estandarización y escalado robusto — desde cero en NumPy, cada uno como un par fit/transform. Luego dejamos que scikit-learn construya los mismos tres y confirmamos que llegan a números idénticos hasta la precisión de punto flotante. El punto del capítulo no es ninguna fórmula en particular; es la disciplina alrededor de ellas: ajustas el escalador solo con el conjunto de entrenamiento, aplicas esas estadísticas congeladas al conjunto de prueba, y nunca dejas que la distribución propia de los datos de prueba se filtre en la transformación. Si te equivocas en eso, tu score de validación es ficción.

El dataset es el de diabetes Pima, elegido precisamente porque sus features están en escalas salvajemente distintas. Y para ver por qué importa el escalado, observamos cómo un método basado en distancias — un voto de k-vecinos más cercanos — cambia su respuesta conforme reescalamos los ejes, sin tocar un solo punto de datos.

Un poco de historia

La estandarización no es un invento del machine learning. Se desprende directamente de la idea más vieja de la estadística: la campana de Gauss y el número de desviaciones estándar a las que estás de su centro. Abraham de Moivre escribió la curva normal en 1733 como una aproximación a conteos de volados. Gauss y Laplace la formalizaron a principios del siglo XIX alrededor de la teoría del error de medición. Pero la palabra en la que nos apoyamos llegó después — Karl Pearson acuñó "desviación estándar" en 1893, dándole un solo nombre a la dispersión de una distribución, y con él la jugada natural de medir cualquier valor en unidades de esa dispersión. Resta la media, divide entre la desviación estándar, y obtienes el z-score: cuántas desviaciones estándar por arriba o por abajo del promedio está este punto. Eso es la estandarización, y precede a las computadoras por un siglo. Cuando estandarizamos un feature solo estamos aplicando el z-score columna por columna, de modo que un valor de +2 significa lo mismo — dos desviaciones estándar arriba — sin importar si la columna es glucosa o edad.

El escalado robusto viene de una tradición posterior y más escéptica. John Tukey, en su libro de 1977 sobre análisis exploratorio de datos, argumentó que la media y la desviación estándar se dejan jalonear demasiado fácil por unos cuantos valores extremos, y empujó la mediana y el rango intercuartílico — la caja del boxplot — como resúmenes que un puñado de outliers no puede mover. El escalado robusto es ese argumento convertido en transformación: centra en la mediana, escala por el IQR. La normalización min-max no tiene un padre famoso único; es un hábito de practicantes que viene del cómputo numérico y de las primeras redes neuronales, donde querías cada entrada clavada en una caja fija [0, 1] para que nada se desbordara y nada dominara. Tres transformaciones, tres épocas distintas del mismo instinto: haz los números comparables antes de confiar en una distancia.

La intuición

A la distancia no le importa el significado; le importa la magnitud. Toma dos personas en los datos Pima que difieren en 30 unidades de glucosa y en 0.5 de pedigrí. En términos de distancia al cuadrado, la brecha de glucosa aporta 900 y la de pedigrí aporta 0.25. La diferencia de pedigrí es más de tres mil veces más callada — no porque importe menos, sino porque sus números son más chicos. Corre k-vecinos más cercanos sobre las columnas crudas y "más cercano" significa casi por completo "glucosa parecida". Construiste un modelo de un solo feature por accidente y lo llamaste de ocho dimensiones.

El escalado elimina el accidente. Si reexpresas cada columna en sus propias unidades de dispersión — desviaciones estándar, o fracciones de su rango — entonces un movimiento de una unidad significa la misma cantidad de "sorpresa" en cada columna, y la distancia por fin pesa todos los features por sus méritos. Nada de los datos cambia; los puntos no se mueven en ningún sentido significativo. Lo que cambia es la regla de medir. Esa es toda la idea, y es más fácil de ver con dos features con una brecha de escala grande:

Cada fila es la dispersión de un feature — la línea delgada va del mínimo al máximo, la barra es la mitad central (el rango intercuartílico), la marca oscura es la mediana. Mira lo que un eje compartido les hace. La insulina y la glucosa se estiran a lo ancho de todo; embarazos, BMI y la columna de pedigrí quedan aplastados en una astilla cerca de cero, con su variación real invisible. Una métrica de distancia lee esta gráfica al pie de la letra. Va a escuchar las barras largas e ignorar las cortas.

Las matemáticas

Tres transformaciones, una línea cada una. En todas, xx es una sola columna de features y xx' (o zz) es esa columna después de escalar; cada estadística se calcula a lo largo de la columna, sobre las filas con las que ajustas.

La normalización min-max desliza y comprime la columna para que su valor más chico se vuelva 0 y el más grande se vuelva 1:

x=xxminxmaxxminx' = \dfrac{x - x_{\min}}{x_{\max} - x_{\min}}

Aquí xminx_{\min} y xmaxx_{\max} son el mínimo y el máximo de la columna. Cada valor escalado cae en [0,1][0, 1], con el rango fijado por los dos puntos más extremos — que es exactamente por lo que un solo outlier puede arruinarlo.

La estandarización es el z-score: recentra en la media, reescala por la desviación estándar:

z=xμσz = \dfrac{x - \mu}{\sigma}

μ\mu es la media de la columna y σ\sigma su desviación estándar (forma poblacional, dividiendo entre NN). La salida tiene media 0 y desviación estándar 1, y no está acotada — un outlier genuino se queda lejos en z=6z = 6 en vez de ser doblado dentro de una caja fija.

El escalado robusto conserva la forma del z-score pero mete estadísticas que los outliers no pueden arrastrar:

x=xmedian(x)IQR(x)x' = \dfrac{x - \operatorname{median}(x)}{\operatorname{IQR}(x)}

donde IQR(x)=Q3Q1\operatorname{IQR}(x) = Q_3 - Q_1 es el rango intercuartílico, la brecha entre los percentiles 75 y 25. La mediana y el IQR describen el grueso de los datos y se sacuden las colas, así que unas cuantas lecturas absurdas — una insulina de cero haciendo las veces de valor faltante, por ejemplo — no distorsionan la escala para todos los demás.

En qué es bueno, en qué no

El planteamiento honesto aquí no es ventajas y desventajas del escalado; es cuáles modelos lo necesitan y a cuáles no les importa. Cualquier cosa que mida distancias o sume features lo necesita: k-vecinos más cercanos, k-means, SVMs con kernel RBF, PCA, cualquier cosa con un núcleo euclidiano o de producto punto. Cualquier cosa entrenada por descenso de gradiente también lo necesita, porque features en escalas dispares le dan a la superficie de pérdida contornos estirados, como cañones, por los que el descenso de gradiente baja en zigzag lentamente; el escalado redondea el tazón y el optimizador converge más rápido. Y cualquier modelo con una penalización L1 o L2 lo necesita, porque la penalización se aplica por coeficiente y un feature medido en miles recibe un apretón sistemáticamente distinto que uno medido en fracciones. Para todos estos, los features sin escalar no son solo subóptimos — cambian en silencio lo que el modelo está ajustando.

Luego está el otro bando, al que no le importa en absoluto. Los árboles de decisión, los random forests y los árboles con gradient boosting parten un feature a la vez con umbrales, y un umbral en glucosa > 140 significa lo mismo sin importar si la glucosa está guardada en los cientos o reescalada a 0.7. Cualquier reescalado monotónico deja intacto cada split posible, así que escalar las entradas de un árbol no cambia nada pero te desperdicia la tarde. Esa es la regla rápida que cargo conmigo: si el modelo piensa en distancias, gradientes o penalizaciones, escala; si piensa en umbrales, ni te molestes. Incluso entre los escaladores la elección importa — min-max cuando necesitas un rango acotado duro, estandarización como el default sensato, robusto cuando la columna está llena de outliers que no puedes limpiar.

Los datos

El dataset de diabetes de los indios Pima: 768 pacientes, ocho features médicos y un resultado binario de si cada uno fue diagnosticado con diabetes. Es un favorito exactamente por la razón que lo queremos aquí — los features están en escalas cómicamente distintas. La glucosa y la presión arterial andan en las decenas y los cientos, la insulina llega a los cientos, el BMI en las decenas, la edad en años, los embarazos en un solo dígito, y la función de pedigrí de diabetes es un score sintetizado entre aproximadamente 0 y 2.4. La gráfica de dispersión cruda de arriba es este dataset. En el split de entrenamiento la columna de glucosa tiene una desviación estándar unas 95 veces más grande que la de la columna de pedigrí, que es la brecha de escala alrededor de la cual está construido todo el capítulo.

Lo partimos una vez — 576 de entrenamiento, 192 de prueba, estratificado sobre el resultado, con semilla — y ese split queda fijo para cada número de esta página. Ahora los mismos ocho features después de estandarizar, ajustado sobre el conjunto completo a modo de ilustración:

Los mismos ocho features, los mismos datos, un eje compartido — y ahora cada caja está sobre el cero con un ancho comparable. Las mitades centrales se alinean; las diferencias que quedan son las colas largas, donde puedes leer cuáles features están sesgados (la insulina y el pedigrí todavía se arrastran lejos a la derecha, que es el argumento a favor del escalado robusto en esos dos). Esta es la imagen de "ahora todo es comparable". Una métrica de distancia que recibe estas columnas pesa las ocho en igualdad de condiciones.

Constrúyelo, una función a la vez

Cada escalador son dos funciones pequeñas: un fit que lee una estadística de los datos y un transform que la usa. Separarlas así no es ceremonia — es el mecanismo que te permite ajustar en train y aplicar en test, que es la única parte de este capítulo en la que de verdad puedes equivocarte.

Min-max primero. Ajustar significa registrar el mínimo y el máximo de cada columna; transformar significa deslizar el mínimo a cero y dividir entre el span:

def minmax_fit(X):
    """Learn the per-column min and max — the range of each feature.

    X is (N, D). Returns the two length-D vectors the transform needs. This is
    the only thing min-max "learns", and it learns it from whatever rows you
    hand it — so hand it the training set, never the whole dataset.
    """
    return {"min": X.min(axis=0), "max": X.max(axis=0)}


def minmax_transform(X, stats):
    """Squeeze every column into [0, 1] using a fitted range.

    Subtract the min so the smallest value maps to 0, divide by the span so the
    largest maps to 1. A column with no spread (max == min) would divide by
    zero, so we floor the denominator at 1 and leave that column at 0.
    """
    span = stats["max"] - stats["min"]
    span = np.where(span == 0, 1.0, span)
    return (X - stats["min"]) / span

La única jugada defensiva es poner un piso de 1 al span para una columna constante, de modo que un feature sin dispersión no divida entre cero. La estandarización tiene la misma forma — fit registra la media y la desviación estándar, transform calcula el z-score:

def standardize_fit(X):
    """Learn the per-column mean and standard deviation.

    std uses ddof=0 (the population formula, dividing by N) to match
    scikit-learn's StandardScaler exactly. A zero-variance column gets its
    scale floored at 1 so the transform doesn't divide by zero.
    """
    std = X.std(axis=0)
    std = np.where(std == 0, 1.0, std)
    return {"mean": X.mean(axis=0), "std": std}


def standardize_transform(X, stats):
    """The z-score: subtract the mean, divide by the standard deviation.

    Every column comes out centered at 0 with unit spread, so a value of +2
    means "two standard deviations above this feature's mean" no matter which
    feature it is. That shared meaning is what makes columns comparable.
    """
    return (X - stats["mean"]) / stats["std"]

Uso ddof=0 deliberadamente, la desviación estándar poblacional que divide entre NN, porque eso es lo que hace el StandardScaler de scikit-learn y quiero que las dos implementaciones coincidan hasta el último dígito más adelante. El escalado robusto cambia la media y la std por la mediana y el IQR:

def robust_fit(X):
    """Learn the per-column median and interquartile range (IQR).

    The median is the 50th percentile; the IQR is the 75th minus the 25th, the
    width of the middle half of the data. Neither one moves when a handful of
    points fly off to extreme values, which is what "robust" means here.
    np.percentile uses linear interpolation, matching sklearn's RobustScaler.
    """
    q25, q50, q75 = np.percentile(X, [25, 50, 75], axis=0)
    iqr = q75 - q25
    iqr = np.where(iqr == 0, 1.0, iqr)
    return {"median": q50, "iqr": iqr}


def robust_transform(X, stats):
    """Center on the median, scale by the IQR.

    Same shape as the z-score, but built from statistics that outliers can't
    drag around. The output isn't centered at exactly 0 mean or unit variance;
    it's centered at the median with the middle 50% spanning one unit.
    """
    return (X - stats["median"]) / stats["iqr"]

np.percentile con su interpolación lineal por default es la misma regla que usa el RobustScaler de sklearn, así que de nuevo los números van a coincidir. Tres transformaciones, todas con el mismo esqueleto, diferenciándose solo en qué par de estadísticas leen.

Ahora la función que todo el capítulo existe para justificar. Dado un split de train y uno de test y cualquier par fit/transform, ajusta solo con train y aplica esas estadísticas congeladas a ambos:

def scale_train_test(Xtr, Xte, fit, transform):
    """Fit a scaler on TRAIN, apply it to both splits — no leakage.

    fit sees only Xtr, so the statistics (min/max, mean/std, median/IQR) are
    computed from training rows alone. The exact same fitted numbers rescale
    the test rows. If you instead fit on the full dataset, the test set's own
    distribution bleeds into the transform and your held-out score is a lie.
    """
    stats = fit(Xtr)
    return transform(Xtr, stats), transform(Xte, stats), stats

Lee lo que no hace. Nunca llama fit sobre Xte. El mínimo, la media, la mediana — cada número que la transformación usa — viene únicamente de las filas de entrenamiento, y las filas de prueba se reescalan con esos mismos números como si hubieran llegado de una en una en producción. El atajo tentador es escalar el dataset completo una vez antes de partirlo, porque es una línea menos y el código se ve más limpio. No lo hagas. Eso deja que el mínimo, la media y la dispersión propios del conjunto de prueba se cuelen en la transformación contra la que tu modelo entrena, y tu accuracy de validación se infla en silencio hasta un número que no puedes reproducir con datos que no has visto. Ajusta en train, transforma todo — no hay excepción.

Míralo funcionar

Este es el premio. Abajo hay un voto real de k-vecinos más cercanos sobre dos features de Pima — glucosa a lo largo de abajo, pedigrí de diabetes hacia arriba — para un punto de consulta, el rombo. Sus 15 vecinos más cercanos están encerrados en anillos y conectados a él con rayos; cada punto está coloreado según si ese paciente tuvo diabetes. El rombo se colorea según hacia dónde voten sus vecinos. La escena nunca mueve un solo punto de datos. Todo lo que cambia, cuadro a cuadro, es la regla de medir: empezamos en el espacio crudo, donde la distancia se mide en unidades nativas y la glucosa — 95 veces más ancha que el pedigrí — decide casi todo, y nos deslizamos al espacio estandarizado, donde ambos features se miden en desviaciones estándar y cuentan por igual.

Dale play y observa los rayos. Al principio los vecinos son quienes casualmente comparten la lectura de glucosa de la consulta; el eje de pedigrí queda efectivamente ignorado, así que los 15 más cercanos son una columna de puntos casi vertical. Conforme los ejes se reescalan, los vecinos que eran "cercanos" solo porque su glucosa coincidía van quedando fuera, y los puntos que sí están genuinamente cerca cuando ambos features cuentan van siendo jalados hacia adentro. El conteo del voto en el pie de imagen cambia conforme la membresía se renueva.

El titular está en el pie de imagen. En el espacio crudo los 15 vecinos se dividen 4 con diabetes contra 11 sin ella, y la consulta se clasifica como sin diabetes. Para cuando los ejes están estandarizados, los 15 vecinos de la mismísima consulta se dividen 8 a 7, y la clasificación se voltea a diabetes. Nada del paciente cambió. Ningún modelo se reentrenó. Solo dejamos de permitir que las unidades de la glucosa acallaran todo lo demás, y una predicción basada en distancia se revirtió sola. Ese es el caso completo a favor del escalado de features, comprimido en un solo punto: con datos sin escalar, "más cercano" era una mentira contada por la columna más ruidosa.

Reinicia y avanza cuadro por cuadro para ver el vecindario renovarse gradualmente en lugar de todo de golpe — la membresía cambia en varios puntos a lo largo del deslizamiento, no en un solo salto, porque distintos vecinos cruzan la frontera a distintas escalas.

La implementación completa

El archivo entero, sin librería — los tres escaladores y el split sin fuga, exactamente como los usaron las verificaciones y la animación:

"""Feature scaling, built from scratch.

Three ways to put columns on a common scale, each a fit/transform pair. `fit`
reads a statistic off the data (a min, a mean, a median); `transform` uses that
statistic to rescale. The split is the whole point of the chapter: you fit on
the training set and transform everything with those numbers, so the test set
never leaks its own statistics into the pipeline. Pure NumPy, no ML library
anywhere in this file.

Every function below appears in the chapter one step at a time (the
`# region:` markers are what the book's include directives pull in).
"""

import numpy as np
import pandas as pd


# region: minmax
def minmax_fit(X):
    """Learn the per-column min and max — the range of each feature.

    X is (N, D). Returns the two length-D vectors the transform needs. This is
    the only thing min-max "learns", and it learns it from whatever rows you
    hand it — so hand it the training set, never the whole dataset.
    """
    return {"min": X.min(axis=0), "max": X.max(axis=0)}


def minmax_transform(X, stats):
    """Squeeze every column into [0, 1] using a fitted range.

    Subtract the min so the smallest value maps to 0, divide by the span so the
    largest maps to 1. A column with no spread (max == min) would divide by
    zero, so we floor the denominator at 1 and leave that column at 0.
    """
    span = stats["max"] - stats["min"]
    span = np.where(span == 0, 1.0, span)
    return (X - stats["min"]) / span
# endregion


# region: standardize
def standardize_fit(X):
    """Learn the per-column mean and standard deviation.

    std uses ddof=0 (the population formula, dividing by N) to match
    scikit-learn's StandardScaler exactly. A zero-variance column gets its
    scale floored at 1 so the transform doesn't divide by zero.
    """
    std = X.std(axis=0)
    std = np.where(std == 0, 1.0, std)
    return {"mean": X.mean(axis=0), "std": std}


def standardize_transform(X, stats):
    """The z-score: subtract the mean, divide by the standard deviation.

    Every column comes out centered at 0 with unit spread, so a value of +2
    means "two standard deviations above this feature's mean" no matter which
    feature it is. That shared meaning is what makes columns comparable.
    """
    return (X - stats["mean"]) / stats["std"]
# endregion


# region: robust
def robust_fit(X):
    """Learn the per-column median and interquartile range (IQR).

    The median is the 50th percentile; the IQR is the 75th minus the 25th, the
    width of the middle half of the data. Neither one moves when a handful of
    points fly off to extreme values, which is what "robust" means here.
    np.percentile uses linear interpolation, matching sklearn's RobustScaler.
    """
    q25, q50, q75 = np.percentile(X, [25, 50, 75], axis=0)
    iqr = q75 - q25
    iqr = np.where(iqr == 0, 1.0, iqr)
    return {"median": q50, "iqr": iqr}


def robust_transform(X, stats):
    """Center on the median, scale by the IQR.

    Same shape as the z-score, but built from statistics that outliers can't
    drag around. The output isn't centered at exactly 0 mean or unit variance;
    it's centered at the median with the middle 50% spanning one unit.
    """
    return (X - stats["median"]) / stats["iqr"]
# endregion


# region: no_leak
def scale_train_test(Xtr, Xte, fit, transform):
    """Fit a scaler on TRAIN, apply it to both splits — no leakage.

    fit sees only Xtr, so the statistics (min/max, mean/std, median/IQR) are
    computed from training rows alone. The exact same fitted numbers rescale
    the test rows. If you instead fit on the full dataset, the test set's own
    distribution bleeds into the transform and your held-out score is a lie.
    """
    stats = fit(Xtr)
    return transform(Xtr, stats), transform(Xte, stats), stats
# endregion


def load_data(path="../data/diabetes.csv"):
    """The Pima diabetes set: 8 features on wildly different scales.

    Returns (X, y, feature_names): X is (N, 8) float, y is (N,) int 0/1
    (Outcome), names is the list of feature column names in order.
    """
    df = pd.read_csv(path)
    feature_names = [c for c in df.columns if c != "Outcome"]
    X = df[feature_names].to_numpy(float)
    y = df["Outcome"].to_numpy(int)
    return X, y, feature_names

La versión de librería

No armarías estos a mano en producción, y una vez que has visto las versiones de cuatro líneas entiendes por qué nadie lo hace. El módulo preprocessing de scikit-learn trae los tres como objetos fit/transform con las mismas estadísticas que usamos. Un helper elige el escalador, lo ajusta con el split de entrenamiento y transforma ambos:

def sklearn_scale(Xtr, Xte, kind):
    """Fit one of the three scalers on TRAIN, transform both splits.

    kind is 'minmax', 'standard', or 'robust'. Fitting on Xtr and transforming
    Xte with those fitted statistics is the no-leakage pattern the whole
    chapter is about — the scaler never sees the test rows while learning.
    """
    scaler = {"minmax": MinMaxScaler(),
              "standard": StandardScaler(),
              "robust": RobustScaler()}[kind]
    scaler.fit(Xtr)
    return scaler.transform(Xtr), scaler.transform(Xte)

MinMaxScaler, StandardScaler y RobustScaler calculan exactamente las estadísticas que calculan nuestras funciones fit — por eso el checkpoint del capítulo verifica que nuestros arreglos transformados coincidan con los de sklearn dentro de 1e-9 en ambos splits, para los tres escaladores, antes de escribir cualquier gráfica. Si una fórmula se desvía, la corrida falla ruidosamente en vez de publicar una imagen equivocada. La verdadera razón para usar la librería no es una respuesta distinta; es que un escalador ajustado encaja en un Pipeline, de modo que la disciplina de ajustar-en-train la impone el framework en lugar de tu memoria. El modelo aguas abajo contra el que calificamos todo es un clasificador k-NN fijo, mantenido idéntico entre corridas para que lo único que cambie jamás sea el escalado delante de él:

def knn_accuracy(Xtr, ytr, Xte, yte, k):
    """Test accuracy of a fixed k-NN classifier on whatever scaling it's given.

    The model is identical every time; only the preprocessing upstream changes.
    That isolation is the experiment: any change in this number is the scaling's
    doing, not the model's.
    """
    clf = KNeighborsClassifier(n_neighbors=k)
    clf.fit(Xtr, ytr)
    return float(clf.score(Xte, yte))

Desde cero contra librería

El duelo habitual es un poco distinto para un capítulo de preprocesamiento, porque las transformaciones desde cero y las de librería son numéricamente idénticas por construcción — el checkpoint lo demuestra, así que una gráfica de barras de "nuestro z-score contra el z-score de sklearn" serían dos barras de la misma altura. La comparación que vale la pena hacer es la que todo el capítulo prometió: qué le hace el escalado a un modelo basado en distancias. El mismo clasificador k-NN fijo, el mismo split train/test, cuatro corridas — features crudos, y luego cada escalador delante:

Léela con honestidad, porque los números son honestos. Con features crudos el clasificador k-NN logra 0.745 de accuracy en test — la fracción de los 192 pacientes reservados que etiqueta correctamente. Min-max lo sube a 0.755, y la estandarización a 0.771, una ganancia de 0.026 sobre lo no escalado, que en este split son cinco pacientes extra clasificados bien puramente por cambiar la regla de medir. El escalado robusto empata aquí con los features crudos en 0.745, un recordatorio de que ningún escalador es automáticamente el mejor — en estos datos en particular la versión de mediana/IQR resultó no ayudarle al voto, y solo medirlo me lo dijo. Esto no es un vuelco dramático de diez puntos; las columnas más predictivas de Pima (glucosa, insulina) son también las más anchas, así que la distancia cruda estaba accidentalmente apoyándose en los features correctos y el k-NN sin escalar ya era medio decente. La mejora es modesta y real, y la estandarización es la versión que se la ganó.

La lección más grande no es el tamaño de esta barra en particular. Es que el número se movió siquiera, a partir de una transformación que agregó cero información — sin features nuevos, sin tuning, sin reentrenar, solo un cambio de unidades. En un dataset donde los features ruidosos fueran menos afortunados, esa brecha es la diferencia entre un modelo que funciona y uno que en secreto es unidimensional.

Conclusiones

El escalado de features es la palanca grande más barata de todo el pipeline, y es invisible cuando falta — nada truena, nada avisa, el modelo simplemente escucha en silencio a la columna que tenga los números más grandes. Así que cárgate la regla, no la corazonada. Si el modelo mide distancias (k-NN, k-means, SVMs con RBF, PCA), o aprende por descenso de gradiente (regresión lineal y logística, redes neuronales), o penaliza coeficientes (ridge, lasso), escala los features primero — esos métodos asumen entradas comparables y se portan mal sin ellas. Si el modelo parte con umbrales (árboles de decisión, random forests, gradient boosting), ni te molestes; cualquier reescalado monotónico deja cada split sin cambios y habrás gastado esfuerzo para nada.

Cuando sí escales, la estandarización es el default al que recurro — no acotada, robusta a nada en particular pero bien portada para la mayoría de las columnas, y mantiene los outliers visibles en vez de aplastarlos dentro de una caja. Recurre a min-max cuando un componente aguas abajo genuinamente necesite un rango acotado [0, 1], y al escalado robusto cuando una columna esté plagada de outliers que no puedes limpiar, para que la mediana y el IQR carguen la escala en lugar de unos cuantos valores extremos. Y elijas lo que elijas, ajústalo solo con los datos de entrenamiento y aplica esas estadísticas congeladas a todo lo demás. Ese único hábito — cero estadísticas de test en la transformación — es lo que separa un score de validación en el que puedes confiar de un número que se evapora en cuanto llegan datos reales. Cada método basado en distancias y en gradientes del resto de este libro asume que ya hiciste esto. Ahora ya sabes hacerlo.