====== 07 - Análisis de la normalidad y normalización de datos ======
===== Análisis de la normalidad =====
En muchos casos, necesitamos comprobar que los datos recabados proceden de una población con distribución con una distribución normal. Para comprobarlo, podemos varias estrategias (a menudo usadas conjuntamente).
Vamos a usar un dataset con datos del pueblo !Kung San, que vive en el desierto de Kalahari entre Botsuana, Namibia y Angola.
{{ :clase:ia:saa:6_mejorar_datos:kung_san.zip |}}
df = pd.read_csv('./kung_san.csv')
df.info()
RangeIndex: 544 entries, 0 to 543
Data columns (total 5 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 Unnamed: 0 544 non-null int64
1 height 544 non-null float64
2 weight 544 non-null float64
3 age 544 non-null float64
4 male 544 non-null int64
dtypes: float64(3), int64(2)
memory usage: 21.4 KB
Seleccionamos sólo los pesos de las mujeres mayores de 15 años:
datos = df[(df.age > 15) & (df.male ==0)]
peso = datos['weight']
==== Métodos gráficos ====
Podemos mostrar la forma de la distribución con **seaborn**:
figure = plt.figure(figsize=(15, 5))
axes = figure.add_subplot()
axes.set_title('Distribución peso mujeres mayores de 15 años')
axes.set_xlabel('peso')
axes.set_ylabel('Densidad de probabilidad')
_ = sns.histplot(x = peso, axes = axes, kde = True, stat='density', bins=30)
{{ :clase:ia:saa:6_mejorar_datos:output.png?400 |}}
Además, podemos sobreponer en el gráfico una distribución normal con la misma media y desviación estándar de la población estudiada:
# Histograma + curva normal teórica
# ==============================================================================
# Valores de la media (mu) y desviación típica (sigma) de los datos
mu, sigma = stats.norm.fit(peso)
# Valores teóricos de la normal en el rango observado
x_hat = np.linspace(min(peso), max(peso), num=100)
y_hat = stats.norm.pdf(x_hat, mu, sigma)
# Gráfico
figure = plt.figure(figsize=(15, 5))
axes = figure.add_subplot()
axes.set_title('Distribución peso mujeres mayores de 15 años')
axes.set_xlabel('peso')
axes.set_ylabel('Densidad de probabilidad')
_ =axes.plot(x_hat, y_hat, linewidth=2, label='normal')
_ = sns.histplot(x = peso, axes = axes, kde = True, stat='density', bins=30)
_ = axes.legend()
{{ :clase:ia:saa:6_mejorar_datos:output2.png?400 |}}
Para simplificar, podemos mostrar únicamente las dos funciones de distribución (la teórica y la prática):
# Comparar funciones de distribución
# ==============================================================================
# Valores de la media (mu) y desviación típica (sigma) de los datos
mu, sigma = stats.norm.fit(peso)
# Valores teóricos de la normal en el rango observado
x_hat = np.linspace(min(peso), max(peso), num=100)
y_hat = stats.norm.pdf(x_hat, mu, sigma)
# Gráfico
figure = plt.figure(figsize=(15, 5))
axes = figure.add_subplot()
axes.set_title('Distribución peso mujeres mayores de 15 años')
axes.set_xlabel('peso')
axes.set_ylabel('Densidad de probabilidad')
_ =axes.plot(x_hat, y_hat, linewidth=2, label='normal')
_ = sns.kdeplot(x = peso, axes = axes)
_ = axes.legend()
{{ :clase:ia:saa:6_mejorar_datos:output3.png?400 |}}
Otro gráfico bastante usado son los gráficos de cuantiles teóricos, que comparan los cuantiles de la distribución observada con los cuantiles teóricos de una distribución normal con la misma media y desviación estándar que los datos. Cuanto más se aproximen los datos a una normal, más alineados están los puntos entorno a la recta.
figure = plt.figure(figsize=(10, 5))
axes = figure.add_subplot()
#fit = True para estandarizar los datos, line = q para ajustar la línea a los cuartiles
sm.qqplot(peso, fit = True, line = "q", alpha = 0.4, ax = axes)
axes.set_title('Gráfico Q-Q del peso mujeres mayores de 15 años', fontsize = 10,
fontweight = "bold")
axes.tick_params(labelsize = 7)
{{ :clase:ia:saa:6_mejorar_datos:output4.png?400 |}}
==== Métodos analíticos ====
Los estadísticos de asimetría (Skewness) y curtosis pueden emplearse para detectar desviaciones de la normalidad. Un valor de curtosis y/o coeficiente de asimetría entre -1 y 1, es generalmente considerada una ligera desviación de la normalidad. Entre -2 y 2 hay una evidente desviación de la normal pero no extrema.
Podemos utilizar la librería **scipy** para calcular los coeficientes de asimetría:
from scipy import stats
print('Kursotis:', stats.kurtosis(peso))
print('Skewness:', stats.skew(peso))
Kursotis: 0.05524614843093856
Skewness: 0.032122514283202334
==== Contraste de hipótesis ====
Los test Shapiro-Wilk test y D'Agostino's K-squared test son dos de los test de hipótesis más empleados para analizar la normalidad. En ambos, se considera como hipótesis nula que los datos proceden de una distribución normal.
El test de Shapiro-Wilk se desaconseja cuando se dispone de muchos datos (más de 50) por su elevada sensibilidad a pequeñas desviaciones de la normal.
# Shapiro-Wilk test
# ==============================================================================
shapiro_test = stats.shapiro(peso)
shapiro_test
ShapiroResult(statistic=0.9963726997375488, pvalue=0.9239704012870789)
# D'Agostino's K-squared test
# ==============================================================================
k2, p_value = stats.normaltest(peso)
print(f"Estadístico = {k2}, p-value = {p_value}")
Estadístico = 0.19896549779904893, p-value = 0.9053055672511008
Cuando estos test se emplean con la finalidad de verificar las condiciones de métodos paramétricos, por ejemplo un t-test o un ANOVA, es importante tener en cuenta que, al tratarse de p-values, cuanto mayor sea el tamaño de la muestra más poder estadístico tienen y más fácil es encontrar evidencias en contra de la hipótesis nula de normalidad. Al mismo tiempo, cuanto mayor sea el tamaño de la muestra, menos sensibles son los métodos paramétricos a la falta de normalidad. Por esta razón, es importante no basar las conclusiones únicamente en el p-value del test, sino también considerar la representación gráfica y el tamaño de la muestra.
==== Consecuencias de la falta de normalidad ====
El hecho de no poder asumir la normalidad influye principalmente en los test de hipótesis paramétricos (t-test, anova,…) y en los modelos de regresión. Las principales consecuencias de la falta de normalidad son:
* Los estimadores mínimo-cuadráticos no son eficientes (de mínima varianza).
* Los intervalos de confianza de los parámetros del modelo y los contrastes de significancia son solamente aproximados y no exactos.
Los test estadísticos expuestos requieren que la población de la que procede la muestra tenga una distribución normal, no la muestra en sí. Si la muestra se distribuye de forma normal, se puede aceptar que así lo hace la población de origen. En el caso de que la muestra no se distribuya de forma normal pero se tenga certeza de que la población de origen sí lo hace, entonces, puede estar justificado aceptar los resultados obtenidos por los contrastes paramétricos como válidos.
La utilización de transformaciones para lograr que los datos se ajusten a una distribución normal es en muchas ocasiones la solución más natural a la falta de normalidad, ya que existen gran cantidad de parámetros biológicos que tienen una distribución asimétrica, y que se convierten en aproximadamente simétricas al transformarlas mediante el logaritmo.
Otra solución cuando los datos no siguen una distribución normal es utilizar **pruebas no paramétricas**, que son aquellas que no presuponen una distribución de probabilidad para los datos.
===== Normalización de datos =====
Cuando tratamos con características en ML, el rango de éstas es diferente. Por ejemplo, podemos tener una variable A cuyos valores vayan del 0 - 1.000.000 y otra variable B con valores comprendidos entre 0 - 1. En este caso, nuestro modelo le dará más peso a la variable A que a la B, con lo que podría causar un problema de sesgo.
La **normalización** es el proceso de igualar los rangos de todas las variables numéricas.
Aunque la normalización es un proceso bastante habitual (incluso recomendable según modelos), no siempre es aconsejable, ya que constituye una pérdida de información inmediata y puede ser perjudicial en algunos casos.
Existen 2 técnicas básicas para normalizar los datos:
* **Normalización**
* Normalización estandarizada (**estandarización**)
Para los ejemplos que veremos a continuación vamos a utilizar el dataset **wine** de **sklearn**. Este dataset clasifica los vinos en 3 clases según una serie de características.
import pandas as pd
from sklearn.datasets import load_wine
data = load_wine()
df = pd.DataFrame(data = data.data, columns = data.feature_names)
df['class'] = data.target
Como siempre, antes de hacer cualquier transformación con los datos, vamos a separar los conjuntos de entrenamiento y test.
X = df.drop(columns = 'class', axis = 1)
y = df['class']
X_train, X_test, y_train, y_test = train_test_split(X, y, train_size = 0.7)
==== Normalización ====
La fórmula para normalizar los datos es bastante sencilla:
;#;
$\displaystyle X_{norm} = \frac{x - x_{min}}{x_{max} - x_{min}} $
;#;
Vamos a normalizar la columna **alcohol** de nuestro dataset.
Primero veamos el rango original de nuestros datos:
X_train['alcohol'].describe()
count 124.000000
mean 12.942339
std 0.829526
min 11.030000
25% 12.290000
50% 12.975000
75% 13.622500
max 14.830000
Name: alcohol, dtype: float64
Como vemos, nuestros datos tienen el rango [11.03, 14.83].
Aplicamos la fórmula anterior para normalizar nuestros datos:
max = X_train['alcohol'].max()
min = X_train['alcohol'].min()
normalized_alcohol=(X_train['alcohol']-min)/(max-min)
normalized_alcohol.describe()
count 124.000000
mean 0.506176
std 0.220841
min 0.000000
25% 0.342105
50% 0.500000
75% 0.682237
max 1.000000
Name: alcohol, dtype: float64
El rango de la nueva columna normalizada es [0, 1].
Si queremos normalizar todo el dataset:
normalized_X_train = (X_train - X_train.min()) / (X_train.max() - X_train.min())
=== Usando librerías de Python ===
Podemos utilizar el transformador [[https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.MinMaxScaler.html|MinMaxScaler]] de **sklearn**.
from sklearn import preprocessing
min_max_scaler = preprocessing.MinMaxScaler()
Vamos a normalizar la misma columna que antes (**alcohol**).
Hay que tener en cuenta que el método **fit_transform()** (o el método **fit()**) de **MinMaxScaler** espera como parámetro un array del tipo (n_samples, n_features) y devuelve un array con la misma dimensión
normalized_alcohol = min_max_scaler.fit_transform(X_train[['alcohol']])
X_train['normalized_alcohol'] = normalized_alcohol
X_train['normalized_alcohol'].describe()
count 124.000000
mean 0.503247
std 0.218296
min 0.000000
25% 0.331579
50% 0.511842
75% 0.682237
max 1.000000
Name: normalized_alcohol, dtype: float64
Si queremos normalizar todo el dataset:
X_train_scaled = min_max_scaler.fit_transform(X_train)
# Tenemos que volver a crear el dataframe, ya que MinMaxScaler.fit_transform devuelve numpy array
normalized_X_train = pd.DataFrame(X_train_scaled)
==== Estandarización ====
En este caso, aplicamos la fórmula:
;#;
$\displaystyle X_{standard} = \frac{X - \mu}{\sigma} $
;#;
Aplicado sobre la columna alcohol:
alcohol_mean = X_train['alcohol'].mean()
alcohol_std = X_train['alcohol'].std()
normalized_alcohol=(X_train['alcohol']-alcohol_mean)/alcohol_std
normalized_alcohol.describe()
count 1.240000e+02
mean 1.200294e-14
std 1.000000e+00
min -2.506927e+00
25% -8.174763e-01
50% 1.155041e-01
75% 8.373031e-01
max 1.729308e+00
Name: alcohol, dtype: float64
En este caso, el rango no es [0, 1]. Al estandarizar, lo que hacemos es hacer que nuestra distribución tenga media 0 (o lo más cercano posible a 0) y desviación estándar 1.
Si queremos estandarizar todo el dataset:
normalized_X_train = (X_train - X_train.mean()) / X_train.std()
=== Usando librerías de Python ===
Igual que con la normalización, vamos a utilizar una clase de **sklearn**: [[https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.StandardScaler.html|StandardScaler]].
from sklearn import preprocessing
standard_scaler = preprocessing.StandardScaler()
Normalizando la columna alcohol:
standarized_alcohol = standard_scaler.fit_transform(X_train[['alcohol']])
X_train['normalized_alcohol'] = standarized_alcohol
X_train['normalized_alcohol'].describe()
count 1.240000e+02
mean 8.810157e-16
std 1.004057e+00
min -2.517097e+00
25% -8.207926e-01
50% 1.159727e-01
75% 8.406999e-01
max 1.736323e+00
Name: normalized_alcohol, dtype: float64
Normalizar todo el dataset:
X_train_scaled = standard_scaler.fit_transform(X_train)
# Tenemos que volver a crear el dataframe, ya que MinMaxScaler.fit_transform devuelve numpy array
normalized_X_train = pd.DataFrame(X_train_scaled)
===== Referencias =====
[[https://www.cienciadedatos.net/documentos/pystats06-analisis-normalidad-python.html|Analisis de normalidad con Python by Joaquín Amat Rodrigo, available under a Attribution 4.0 International (CC BY 4.0)]]
===== Ejercicios =====
** Ejercicio 1 **
Carga el dataset [[https://scikit-learn.org/stable/modules/generated/sklearn.datasets.load_wine.html|wine]] de sklearn. Este dataset contiene una serie de registros de tipos de vino (0, 1 o 2) según varias características (alcohol, magnesio, intensidad del color...).
Utiliza la función [[https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.train_test_split.html|train_test_split]] para separar tus datos en entrenamiento y test (30%).
** Ejercicio 2 **
Crea un clasificador KNN con los siguientes parámetros (no te preocupes ahora por el significado de los mismos, ya los veremos más adelante):
model_knn = KNeighborsClassifier(n_neighbors=5, weights='uniform')
Entrena el modelo (con su método **fit()**), y muestra su exactitud (accuracy) ejecutando lo siguiente:
y_pred_rf = model_knn.predict(X_test)
print(model_knn.__class__.__name__, float("{0:.4f}".format(accuracy_score(y_test, y_pred_rf))))
** Ejercicio 3 **
Normaliza todas las columnas del dataset y comprueba ahora la exactitud del modelo.
** Ejericicio 4 **
Haz lo mismo que en el ejercicio anterior, pero estandarizando ahora las columnas en lugar de normalizándolas. ¿Qué técnica ha resultado mejor de las tres?