4.6. Fundamentos de modelado estadístico#

Esta es la última lección de la unidad y el objetivo es demostrar algunos fundamentos del análisis estadístico por modelos.

Comenzaremos por construir manualmente una regresión lineal bivariada, luego veremos cómo se realiza el procedimiento con scipy y finalmente, veremos cómo ejecutar un modelo de mínimos cuadrados ordinarios multivariado utilizando statsmodels.

Importante

Para esta libreta, deberás tener instalado statsmodels además de las librerías que se utilizaron en esta lección. Si no lo has hecho, puedes agregar la librería a tu entorno con uv add statsmodels. Esta librería también ya está preinstalada en colab.

4.6.1. Librerías#

import pandas as pd
import scipy.stats as stats
import matplotlib.pyplot as plt
import seaborn as sns
import statsmodels.api as sm
print('librerías cargadas')
librerías cargadas

4.6.2. Datos#

url = 'https://raw.githubusercontent.com/raphaelvallat/pingouin/refs/heads/main/src/pingouin/datasets/penguins.csv'

df = pd.read_csv(url)
df.head()
species island bill_length_mm bill_depth_mm flipper_length_mm body_mass_g sex
0 Adelie Biscoe 37.8 18.3 174.0 3400.0 female
1 Adelie Biscoe 37.7 18.7 180.0 3600.0 male
2 Adelie Biscoe 35.9 19.2 189.0 3800.0 female
3 Adelie Biscoe 38.2 18.1 185.0 3950.0 male
4 Adelie Biscoe 38.8 17.2 180.0 3800.0 male

4.6.3. Regresión Lineal Bivariada (A mano)#

En la regresión lineal, se pretende predecir el valor de la variable Y con base en la ecuación de la recta:

\(y \sim \beta_0 + \beta_1X + \epsilon\)

Es importante tener en claro que \(\beta_0\) es el intercepto, donde la línea cruza la ordenada, y \(\beta_1\) es la pendiente de la recta. Finalmente, ya que a menos que la relación entre X y Y sea perfecta, siempre va a haber un error, que representa epsilon.

Para calcular la pendiente y el intercepto las fórmulas son:

\(\text{Pendiente} =\beta_1 = \frac{\sum(x_i-\bar{x})(y_i-\bar{y})}{\sum(x_i-\bar{x})^2}\)

\(\text{Intercepto} = \beta_0 = \bar{y} - \beta_1\bar{x}\)

Y finalmente, podemos predecir los valores de Y con base en los de X por medio de la siguiente fórmula:

\(y_{pred} = \beta_0 + \beta_1 x\)

A continuación pondremos estas fórmulas en una sola función, que devolverá el intercepto y la pendiente:

def linreg(x: pd.Series, y: pd.Series):
  "x, y deben ser tipo pd.Series"
  x_media = x.mean() # media x
  y_media = y.mean() # media y
  numerador = sum(     # numerador de b1
      (x-x_media) * (y-y_media) 
      # restamos cada valor de x su media y cada valor de y su media también
      # obtenemos el producto
  )
  # recuerda que sum es la suma.
  # el numerador va a ser la suma de productos
  denominador = sum(   # denominador de b1
      (x-x_media)**2 # restamos la media de X a cada valor de X y los elevamos al cuadrado
  )
  # el denominador es la suma del cuadrado de las diferencias de x y su media.
  beta_1 = numerador/denominador # pendiente
  beta_0 = y_media - beta_1 * x_media # intercepto
  return beta_0, beta_1
print("Función linreg definida")
Función linreg definida

4.6.3.1. Ejercicio#

Revisa línea por línea la función y prueba en una celda de código limpia cada línea para entender qué hace. Utiliza cualquier variable de nuestros datos (cualquier columna).

4.6.3.2. Provemos la función#

Utilizaremos ahora esta función que creamos con dos variables, bill_length_mm y body_mass_g, y veremos si existe linealidad entre ellas.

datos_completos = df.dropna(subset=['bill_length_mm', 'body_mass_g'])
x = datos_completos['bill_length_mm']
y = datos_completos['body_mass_g']
intercepto, pendiente = linreg(x, y)
print('Intercepto', intercepto, 'pendiente', pendiente)
Intercepto 362.3067216539207 pendiente 87.41527705287552

Con estos valores y utilizando la fórmula de \(y_{pred}\) calcularemos ahora los valores predichos para body_mass_g que es nuestra variable Y.

y_pred = intercepto + pendiente * x
print(y_pred)
0      3666.604194
1      3657.862667
2      3500.515168
3      3701.570305
4      3754.019471
          ...     
338    4488.307799
340    4453.341688
341    4768.036685
342    4313.477244
343    4724.329047
Name: bill_length_mm, Length: 342, dtype: float64

Fantástico, tenemos ahora un arreglo de datos con los valores predichos, visualicemos la regresión y los residuos usando matplotlib.

plt.scatter(x, y, label='Observado') 
# dispersión simple
plt.plot(x, y_pred, label='Predicho') 
# línea de la regresión
plt.xlabel('X') # formato
plt.ylabel('Y') # formato
plt.title('Regresión lineal') # formato
plt.legend() # formato
plt.show()
../_images/1e8da12c0de3a1712e91f34a08908a8698e5b073f5156fd5a1bbf38aebf8b20c.png

Muy bien, ahora, como comentamos en una lección previa, esta tarea es tan usual que muy rara vez tenenmos que hacerla a mano, scipy y seaborn ya tienen funciones para analizar estos datos.

4.6.4. Regresión lineal bivariada (real)#

Utilicemos el poder de las librerías para realizar la regresión.

result = stats.linregress(x, y)
result
LinregressResult(slope=np.float64(87.41527705287551), intercept=np.float64(362.30672165392116), rvalue=np.float64(0.59510982443763), pvalue=np.float64(3.808282842018129e-34), stderr=np.float64(6.40198532585216), intercept_stderr=np.float64(283.3452325948302))

scipy confirma que nuestros cálculos estaban bien, pero también nos da el valor p y el coeficiente de correlación r. Ahora visualicemos los residuos con seaborn.

sns.regplot(x=x, y=y)
<Axes: xlabel='bill_length_mm', ylabel='body_mass_g'>
../_images/5a0c7f7ec929b464de2cecb6c45753b7637dd35e1a9f37cd4362fedd3ae05284.png

4.6.5. Regresión multivariada#

Para modelos más complejos necesitamos utilizar statsmodels para modelar la interacción de más variables. Contemplemos la siguiente fórmula de regresión:

\(peso \sim pico + especie\)

En este caso necesitamos especificar un modelo más complejo, utilizaremos el modelo de mínimos cuadrados ordinarios.

Observa en la sección de librerías que importamos statsmodels.api como sm.

datos_completos = df.dropna(subset=['bill_length_mm', 'species', 'body_mass_g'])
y = datos_completos['body_mass_g']
X = datos_completos[['bill_length_mm', 'species']]
# X es el conjunto de predictores

# Necesitamos generar variables "dummy" para cada especie
# es decir, una columna por cada especie con 0 y 1.
X = (
    pd.get_dummies( # obtiene los dummies de la variable X 
        X, 
        columns=['species'], 
        drop_first=True
    )
    .astype(float)
)
# Para poder centrar el modelo requerimos añadir una constante.
X = sm.add_constant(X)
modelo = sm.OLS(y, X) # define al modelo
resultado = modelo.fit() # ajusta los datos
print(resultado.summary())
                            OLS Regression Results                            
==============================================================================
Dep. Variable:            body_mass_g   R-squared:                       0.783
Model:                            OLS   Adj. R-squared:                  0.781
Method:                 Least Squares   F-statistic:                     406.3
Date:                Mon, 07 Jul 2025   Prob (F-statistic):          1.03e-111
Time:                        21:58:10   Log-Likelihood:                -2510.6
No. Observations:                 342   AIC:                             5029.
Df Residuals:                     338   BIC:                             5044.
Df Model:                           3                                         
Covariance Type:            nonrobust                                         
=====================================================================================
                        coef    std err          t      P>|t|      [0.025      0.975]
-------------------------------------------------------------------------------------
const               153.7397    268.901      0.572      0.568    -375.191     682.670
bill_length_mm       91.4358      6.887     13.276      0.000      77.889     104.983
species_Chinstrap  -885.8121     88.250    -10.038      0.000   -1059.401    -712.223
species_Gentoo      578.6292     75.362      7.678      0.000     430.391     726.867
==============================================================================
Omnibus:                        1.884   Durbin-Watson:                   2.503
Prob(Omnibus):                  0.390   Jarque-Bera (JB):                1.899
Skew:                           0.179   Prob(JB):                        0.387
Kurtosis:                       2.928   Cond. No.                         618.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.

Importante

Cuando definimos el modelo agregamos los términos y y X. y se denomina «Endog», la variable endógena, la que queremos predecir. X se denomina «Exog», las variables exógenas, las predictoras.

Este concepto es clave y va a ser necesario en esta libreta más adelante.

Como puedes ver el código es bastante complejo, pero no te preocupes, existe una forma más sencilla de realizar esta misma regresión. La sintaxis de fórmula permite que especifiquemos modelos con mucha mayor facilidad y es más versátil para análisis complejos como se verá más adelante.

modelo = sm.OLS.from_formula(
    'body_mass_g ~ bill_length_mm + species', 
    df
)
resultado = modelo.fit()
print(resultado.summary())
                            OLS Regression Results                            
==============================================================================
Dep. Variable:            body_mass_g   R-squared:                       0.783
Model:                            OLS   Adj. R-squared:                  0.781
Method:                 Least Squares   F-statistic:                     406.3
Date:                Mon, 07 Jul 2025   Prob (F-statistic):          1.03e-111
Time:                        22:00:05   Log-Likelihood:                -2510.6
No. Observations:                 342   AIC:                             5029.
Df Residuals:                     338   BIC:                             5044.
Df Model:                           3                                         
Covariance Type:            nonrobust                                         
========================================================================================
                           coef    std err          t      P>|t|      [0.025      0.975]
----------------------------------------------------------------------------------------
Intercept              153.7397    268.901      0.572      0.568    -375.191     682.670
species[T.Chinstrap]  -885.8121     88.250    -10.038      0.000   -1059.401    -712.223
species[T.Gentoo]      578.6292     75.362      7.678      0.000     430.391     726.867
bill_length_mm          91.4358      6.887     13.276      0.000      77.889     104.983
==============================================================================
Omnibus:                        1.884   Durbin-Watson:                   2.503
Prob(Omnibus):                  0.390   Jarque-Bera (JB):                1.899
Skew:                           0.179   Prob(JB):                        0.387
Kurtosis:                       2.928   Cond. No.                         618.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.

Los resultados son exactamente iguales pero no tenemos que quitar los NaN, ni agregar constantes, ni separar a los dummies, nada de eso, porque todo está en nuestra ecuación.

La fórmula tiene dos partes, a la izquierda de ~ está la variable objetivo, la que queremos predecir con el modelo, a la derecha está la expresión de la regresión, en un modelo sumativo, como el que se muestra en el ejemplo, separamos cada variable predictora con +, esto significa que cada predictor es independiente y no hay términos de interacción.

La sintaxis de fórmula se derivó de la notación matemática de la regresión y se usa en múltiples lenguaje y librerías, aunque cada uno tiene sus particularidades.

Puedes leer más de la sintaxis de fórmula, para statsmodels aquí y para R en este enlace.

4.6.5.1. Análisis post-hoc de la regresión#

Cuando analizamos el resultado de un modelo de regresión existen múltiples datos en los nos fijamos para corroborar supuestos y analizar la bondad de ajuste del modelo, exploraremos algunos de los más utilizados.

4.6.5.1.1. VIF#

El VIF o Variance Inflation Factor es un análisis que nos permite identificar la colinealidad entre los predictores, en este caso bill_length_mm y species.

Para realizar este análisis lo que hacemos es extraer los predictores y aplicar la funcion VIF como se muestra a continuación.

from statsmodels.stats.outliers_influence import variance_inflation_factor

X = (
    resultado # el resultado de modelo.fit()
    .model    # el modelo subyacente
    .exog     # el arreglo de datos de las predictoras
    [:, 1:]   # en el slice el primer ":" quiere decir todas las filas
              # el segundo término "1:" quiere decir todas las columnas
              # menos la primera, es decir, el intercepto.
)
nombres = (
    resultado   # el resultado de modelo.fit()
    .model      # el modelo subyacente
    .exog_names # nombre de las variables predictoras (X)
    [1:]        # todos menos el primero, que es el intercepto.
)

vif = pd.DataFrame(   # para guardar el resultado
    columns=['VIF'],  # con una columna del VIF
    index=nombres     # con el nombre de las variables
)  
for indice in range(len(nombres)): # la primera variable es 0, la segunda 1, y así suscesivamente
    nombre = nombres[indice]  
    vif.loc[nombre] = variance_inflation_factor(X, indice)
    # calculamos el vif para cada variable, pero como el VIF es para todas las predictoras
    # necesitamos pasar toda la X como argumento y el índice de la variable en el arreglo.

vif
VIF
species[T.Chinstrap] 1.704471
species[T.Gentoo] 2.205853
bill_length_mm 2.910323

Como podemos ver el VIF es aceptable e indica una baja probabilidad de multicolinariedad entre los predictores.

Nota

En el ejemplo de arriba, observa como casi al final del código dice vif.loc, analicémos qué significa. vif es un dataframe, un arreglo de datos, loc es un atributo que permite acceder a los valores de la tabla con base en la posición de fila y columna. df.loc[fila, columna], podemos pasar valores específicos, por ejemplo, df.loc[0, 'body_masS_g'] nos va a dar el peso del primer pingüino en el arreglo, pero las columnas son opcionales, es decir, df.loc[0] regresa toda la información del primer pingüino.

En el ejemplo del VIF lo que estamos es pasando la fila que queremos, es decir el nombre de la variable, porque está en el índice.

4.6.5.1.2. Gráfico de residuos#

Para corroborar supuestos de homocedasticidad y normalidad en residos aplicamos estos gráficos, podemos utilizar seaborn, aunque statsmodels puede generar algunos más especializados con ayuda de matplotlib.

ajustado = resultado.fittedvalues # los valores ajustados de EXOG
residuos = resultado.resid     # el valor real de ENDOG 
                                # menos los valores predichos por el modelo
                                # es decir, los errores.
sns.residplot(x=ajustado, y=residuos)
plt.ylabel('Residuos')
plt.xlabel('Ajustado')
Text(0.5, 0, 'Ajustado')
../_images/9d4a510dcb8c13a49a06762c86dc8572747863d170a87f3ed66b6aeda5a46099.png

Podemos ver dos conglomerados con claridad, veamos usemos un diagrama de dispersión con color por especie

sns.scatterplot(x=ajustado, y=residuos, hue=df['species'])
plt.ylabel('Residuos')
plt.xlabel('Ajustado')
Text(0.5, 0, 'Ajustado')
../_images/47f3b3007b65449872ed3768b33445e6e67b999df1a999c953fe6441935fa8be.png

Efectivamente hay separación entre las especies, como habíamos visto ya previamente, en varios aspectos Adelie y Chinstrap se mueven juntos.

4.6.5.1.3. Gráficos parciales de la regresión#

Siven para conocer la pendiente del modelo para cada predictor.

sm.graphics.plot_partregress_grid(resultado)
plt.show() # intenta quitar esto y ve el resultado.
../_images/6ad13a61c501fbf33859c5bbca11de4c4ebfe823eca437c7f74e398283968abd.png

En los ejes se ve la leyenda e(variable | X), esto son los valores explicados por la variable en cuestión dada la constante en los demás términos.

4.6.5.1.4. Influencia de outliers#

Veamos un gráfico de influencia con criterio de Cooks.

sm.graphics.influence_plot(resultado, criterion='cooks')
plt.show()
../_images/f926c637e292f4a9156318054fbb9411bf9a142bc736cb9ef977ba1b824534c1.png

como podemos ver existen algunos pingüinos atíticos que ejercen una mayor influencia en el modelo, por ejemplo el 169, que podemos analizar con df.loc.

df.loc[169] # Pingüino 169
species              Chinstrap
island                   Dream
bill_length_mm            58.0
bill_depth_mm             17.8
flipper_length_mm        181.0
body_mass_g             3700.0
sex                     female
Name: 169, dtype: object
4.6.5.1.4.1. Ejercicio#

Intenta analizar qué de este pingüino pudiera estar generando esta influencia tan grande.

4.6.5.2. Recursos adicionales#

Revisa la documentación de statsmodels para conocer otras funciones relacionadas, específiametne esta página.

4.6.6. Conclusión#

Es posible realizar múltiples tipos de inferencia estadística desde básica hasta compleja en python, y dado que python es uno de los principales lenguajes en el mundo para el análisis de datos, muchos de los problemas que podemos encontrar ya han sido implementados y podemos simplificar nuestros flujos de trabajo, por ejemplo, con scipy y statsmodels.

Con esto concluimos la unidad 4, en la siguiente unidad veremos cómo realizar análisis específicos con más profundiad, incluidos aquellos que no vimos en esta unidad, como ANOVA, pruebas pareadas o modelos lineales generalizados.