Solución - Workshop 2, Stats for AI - Bloque 2

Diagnóstico de regresión: leverage, outliers, observaciones influyentes y multicolinealidad

Autor/a

Alberto Dorantes

Fecha de publicación

30 de septiembre de 2026

Resumen
Solución detallada del Workshop 2 del Bloque 2. Aplicamos, con álgebra matricial, las técnicas de diagnóstico de la Lección 9 (Influential Points) de STAT 462 de PennState al modelo del Challenge 4 del Workshop 1: leverages, residuales estandarizados, residuales eliminados (studentized deleted residuals), DFFITS y distancia de Cook. Después proponemos dos correcciones (eliminar observaciones influyentes y winsorizar) y comparamos los resultados con el modelo original.

1 Preparación: el modelo del Challenge 4 del Workshop 1

Referencia: Lección 9 “Influential Points”, STAT 462 Applied Regression Analysis, PennState.

Reconstruimos las variables y el modelo final del Workshop 1:

E[ROA_i] = b_0 + b_1 Med_i + b_2 Gde_i + b_3 upa_i + b_4 (Med_i \times upa_i) + b_5 (Gde_i \times upa_i) + b_6\, DA_i + b_7\, DA_i^2

import pandas as pd
import numpy as np
import requests
import io
import statsmodels.formula.api as smf
import matplotlib.pyplot as plt

pd.set_option("display.width", 120)
pd.set_option("display.max_columns", 20)
plt.rcParams.update({
    "figure.dpi": 110, "axes.spines.top": False, "axes.spines.right": False,
    "axes.grid": True, "grid.alpha": 0.25, "axes.titleweight": "bold",
    "font.size": 10})
COL = {"Pequeña": "#2a78d6", "Mediana": "#eb6834", "Grande": "#1baf7a"}
MRK = {"Pequeña": "o", "Mediana": "s", "Grande": "^"}
AZUL, NARANJA, GRIS = "#2a78d6", "#eb6834", "#8a8a8a"

headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"}

try:
    # Descargando el dataset de empresas:
    url = "http://www.apradie.com/datos/empresas_2023.csv"
    response = requests.get(url, headers=headers)
    response.raise_for_status()  # Error si la respuesta es 4xx o 5xx
    # Leemos el contenido en un DataFrame usando io.StringIO
    df = pd.read_csv(io.StringIO(response.text))
    print("File 'empresas_2023.csv' downloaded successfully.")
except requests.exceptions.HTTPError as e:
    print(f"HTTP Error occurred: {e}")
    raise
except requests.exceptions.RequestException as e:
    print(f"An error occurred during the request: {e}")
    raise
File 'empresas_2023.csv' downloaded successfully.
df["roa"] = df["utilidad"] / df["activos"]
df["valor_mercado"] = df["precio_accion"] * df["numacciones"]
df["upa"] = (df["utilidad"] / df["numacciones"]) / df["precio_accion"]
df["deuda_activos"] = df["deuda"] / df["activos"]
df["tamano"] = pd.qcut(df["valor_mercado"], q=3,
                       labels=["Pequeña", "Mediana", "Grande"])

# Usamos sólo las empresas con todos los datos del modelo (3 no tienen deuda)
d = df.dropna(subset=["roa", "upa", "deuda_activos"]).reset_index(drop=True)

formula = "roa ~ C(tamano) * upa + deuda_activos + I(deuda_activos**2)"
m_orig = smf.ols(formula, data=d).fit()
print(m_orig.summary())
                            OLS Regression Results                            
==============================================================================
Dep. Variable:                    roa   R-squared:                       0.594
Model:                            OLS   Adj. R-squared:                  0.591
Method:                 Least Squares   F-statistic:                     221.5
Date:                Mon, 05 Oct 2026   Prob (F-statistic):          1.54e-202
Time:                        13:56:31   Log-Likelihood:                 210.64
No. Observations:                1069   AIC:                            -405.3
Df Residuals:                    1061   BIC:                            -365.5
Df Model:                           7                                         
Covariance Type:            nonrobust                                         
============================================================================================
                               coef    std err          t      P>|t|      [0.025      0.975]
--------------------------------------------------------------------------------------------
Intercept                   -0.0684      0.014     -4.804      0.000      -0.096      -0.040
C(tamano)[T.Mediana]         0.0470      0.016      2.878      0.004       0.015       0.079
C(tamano)[T.Grande]          0.1180      0.018      6.651      0.000       0.083       0.153
upa                          0.8425      0.032     26.389      0.000       0.780       0.905
C(tamano)[T.Mediana]:upa     0.2485      0.075      3.303      0.001       0.101       0.396
C(tamano)[T.Grande]:upa      0.0821      0.148      0.557      0.578      -0.207       0.372
deuda_activos                0.0207      0.042      0.496      0.620      -0.061       0.102
I(deuda_activos ** 2)       -0.1148      0.026     -4.438      0.000      -0.166      -0.064
==============================================================================
Omnibus:                      894.969   Durbin-Watson:                   1.936
Prob(Omnibus):                  0.000   Jarque-Bera (JB):            54911.298
Skew:                          -3.412   Prob(JB):                         0.00
Kurtosis:                      37.442   Cond. No.                         28.1
==============================================================================

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

2 Estimación con álgebra matricial

Todas las medidas de diagnóstico de la lección salen de la matriz de diseño X y de la hat matrix H. Construimos X a mano: una columna de unos (intercepto) y una columna por cada variable del modelo.

La columna de unos al inicio de la matriz X es necesaria para que se estime el intercepto (beta0).

Genero las variables de X como arreglos NumPy (d[“columna”].values) a partir de las columnas del dataset. Esto para facilitar las operaciones con álgebra matricial. y generamos X como una matriz a partir de estos arreglos.

med = (d["tamano"] == "Mediana").astype(float).values
gde = (d["tamano"] == "Grande").astype(float).values
upa = d["upa"].values
da  = d["deuda_activos"].values

X = np.column_stack([np.ones(len(d)), med, gde, upa, med*upa, gde*upa, da, da**2])
y = d["roa"].values
nombres = ["Intercepto", "Mediana", "Grande", "upa", "Mediana:upa",
           "Grande:upa", "deuda_activos", "deuda_activos^2"]

n, p = X.shape      # p = número de parámetros (k + 1)
k = p - 1           # k = número de variables X (predictores)
print(f"n = {n}, p = k + 1 = {p}, k = {k}")
n = 1069, p = k + 1 = 8, k = 7

Recordemos que los coeficientes beta OLS (mínimos cuadrados) de una regresión múltiple se estiman como la siguiente multiplicación de matrices:

b = (X'X)^{-1}X'y Como ya tengo la matriz X y la matriz y, puedo fácilmente estimar el vector de coeficientes de beta con operaciones de álgebra matricial:

# Primero calcula la inversa de X'X:
XtX_inv = np.linalg.inv(X.T @ X)
# Calculo el vector de coeficientes b: 
b = XtX_inv @ X.T @ y
# X.T es la transpuesta de X 

# Genero un dataset con los valores de b y con los valores de coeficientes del modelo de regresión 
#   estimado anteriormente con statsmodel de Python:
comp = pd.DataFrame({"b (matricial)": b, "b (statsmodels)": m_orig.params.values},
                    index=nombres)
print(comp.round(6))
                 b (matricial)  b (statsmodels)
Intercepto           -0.068406        -0.068406
Mediana               0.047039         0.047039
Grande                0.117978         0.117978
upa                   0.842516         0.842516
Mediana:upa           0.248500         0.248500
Grande:upa            0.082133         0.082133
deuda_activos         0.020671         0.020671
deuda_activos^2      -0.114831        -0.114831
print("¿Iguales?", np.allclose(b, m_orig.params.values))
¿Iguales? True

Vemos que las estimaciones de los coeficientes calculados con álgebra matricial son idénticos a los estimados con statsmodel.

3 Leverages: valores extremos en X (Lección 9.2)

3.1 Concepto y cálculo

Recordemos la ecuación de las predicciones de y en un modelo de regresión múltiple que tiene más de 1 variable x, representadas en una matriz X (que tiene 1’s en la primera columna):

Y=Xb+\epsilon Esta representación matricial indica que el valor de Y_i es una combinación lineal de cada valor X_i y su coeficiente b_i respectivo:

Y = b_0 + b_1x_1+b_2x_2+...+b_nx_n+\epsilon Entonces, el valor esperado de Y, llamado la predicción \hat{y} es:

\hat{Y} = Xb Esto mismo expresado en los valores puntuales de cada observación de Y:

\hat{y_i} = b_0 + b_1x_1+b_2x_2+...+b_nx_n

Como el vector de coeficientes b se estima como:

b = (X'X)^{-1}X'y

Entonces, puedo representar el valor esperado (predecido) de Y como:

\hat{Y} = X(X'X)^{-1}X'y

Si defino una matriz llamada hat matrix H como:

H = X(X'X)^{-1}X'

Entonces, puedo representar \hat{Y} como:

\hat{Y} = Hy

Se llama así porque la hat matriz H “le pone el gorro” a y: \hat{y} = Hy. Es decir, cada valor predecido de y es una combinación lineal de todos los valores observados de y:

\hat{y}_i = h_{i1}y_1 + h_{i2}y_2 + \dots + h_{ii}y_i + \dots + h_{in}y_n El elemento de la diagonal h_{ii} es el leverage de la observación i: mide cuánto pesa y_i en su propio valor predecido. El leverage sólo depende de las X (no de y), y mide qué tan lejos está la combinación de valores X de la observación respecto del centro de los datos.

Propiedades:

  • 0 \le h_{ii} \le 1
  • \sum_{i=1}^{n} h_{ii} = p = k+1, así que el leverage promedio es \bar{h} = (k+1)/n

Calculamos la hat matrix utilizando álgebra matricial:

H = X @ XtX_inv @ X.T          # Hat matrix: matriz n x n
h = np.diag(H).copy()          # valores leverages que están en la diagonal de H

print("Dimensión de H:", H.shape)
Dimensión de H: (1069, 1069)
print(f"Suma de leverages = {h.sum():.4f}  (debe ser p = {p})")
Suma de leverages = 8.0000  (debe ser p = 8)
print(f"Leverage promedio = {h.mean():.5f}  = p/n = {p/n:.5f}")
Leverage promedio = 0.00748  = p/n = 0.00748
print(f"Leverage mínimo = {h.min():.5f}, máximo = {h.max():.5f}")
Leverage mínimo = 0.00282, máximo = 0.79433
print("¿H y-hat = valores ajustados de statsmodels?",
      np.allclose(H @ y, m_orig.fittedvalues.values))
¿H y-hat = valores ajustados de statsmodels? True

Vemos que el cálculo de H es correcto.

3.2 Regla para identificar leverages altos

La lección propone marcar como valores X inusuales las observaciones con:

h_{ii} > 3\left(\frac{k+1}{n}\right) Que quiere decir que una observación x inusual (valor extremo) es aquella que tiene leverage 3 veces más grande que el promedio.

(y una regla menos estricta es que sea 2 veces más grande que el promedio).

Ordeno las observaciones de acuerdo a su leverage (h) de mayor a menor:

lim_h3 = 3 * p / n
lim_h2 = 2 * p / n
d["h"] = h
print(f"Límite 3p/n = {lim_h3:.4f}: {(h > lim_h3).sum()} observaciones con leverage alto")
Límite 3p/n = 0.0225: 36 observaciones con leverage alto
print(f"Límite 2p/n = {lim_h2:.4f}: {(h > lim_h2).sum()} observaciones con leverage alto")
Límite 2p/n = 0.0150: 67 observaciones con leverage alto
# Despliego las 10 observaciones con leverage más alto: 

cols_ver = ["empresa", "tamano", "roa", "upa", "deuda_activos", "h"]
print(d.sort_values("h", ascending=False)[cols_ver].head(10).round(4).to_string())
    empresa   tamano     roa     upa  deuda_activos       h
125    BBIO   Grande -1.1116 -0.0865         3.1843  0.7943
828     RUN   Grande -0.0968 -0.4626         0.5363  0.1397
743     PBF   Grande  0.2051  0.5494         0.1412  0.1353
699    NVAX  Mediana -0.3152 -0.9935         0.1274  0.1177
555    LCID   Grande -0.3641 -0.3216         0.2447  0.0738
164    BYND  Mediana -0.4415 -0.5952         1.5713  0.0735
496    IRWD  Mediana -2.0070 -0.5293         1.5190  0.0617
926    TCON  Pequeña -0.9177 -1.7004         0.0000  0.0608
455    IBRX  Mediana -0.7181 -0.1081         1.7487  0.0549
812    RIVN   Grande -0.3421 -0.2554         0.3049  0.0507
fig, ax = plt.subplots(figsize=(8, 4))
ax.scatter(np.arange(n), h, s=10, color=np.where(h > lim_h3, NARANJA, AZUL), alpha=0.7)
ax.axhline(lim_h3, color=NARANJA, ls="--", lw=1.2, label=f"3p/n = {lim_h3:.4f}")
ax.axhline(lim_h2, color=GRIS, ls=":", lw=1.2, label=f"2p/n = {lim_h2:.4f}")
for i in np.argsort(h)[-5:]:
    ax.annotate(d.loc[i, "empresa"], (i, h[i]), xytext=(4, 0),
                textcoords="offset points", fontsize=8)
ax.set_xlabel("Observación (índice)"); ax.set_ylabel("Leverage h_ii")
ax.set_title("Leverages: ¿qué empresas tienen valores X inusuales?")
ax.legend(fontsize=8)
plt.tight_layout(); plt.show()

Leverage de cada observación. Las líneas marcan 2p/n y 3p/n

Interpretación: 36 empresas (3.4%) superan el límite de 3p/n = 0.0225. El caso más extremo es BBIO, con un leverage de 0.79: esta sola empresa tiene casi 80% del peso en su propio valor predicho. La razón es su nivel de deuda: su deuda equivale a 3.18 veces sus activos, y en el modelo aparece además al cuadrado (10.1), muy lejos del resto de las empresas. Otras empresas con leverage alto (RUN, PBF, NVAX, BYND) combinan valores de upa y deuda poco comunes para su grupo de tamaño.

La siguiente gráfica muestra el “espacio X” de upa y deuda: los puntos con leverage alto están en la periferia de la nube de datos.

fig, ax = plt.subplots(figsize=(8, 4.5))
alto = h > lim_h3
ax.scatter(d.loc[~alto, "upa"], d.loc[~alto, "deuda_activos"], s=10, alpha=0.35,
           color=AZUL, label="Leverage normal")
ax.scatter(d.loc[alto, "upa"], d.loc[alto, "deuda_activos"], s=28, alpha=0.9,
           color=NARANJA, edgecolor="white", label="Leverage > 3p/n")
for i in np.argsort(h)[-4:]:
    ax.annotate(d.loc[i, "empresa"], (upa[i], da[i]), xytext=(4, 4),
                textcoords="offset points", fontsize=8)
ax.set_xlabel("upa"); ax.set_ylabel("Deuda / Activos")
ax.set_title("Leverage alto = combinación inusual de valores X")
ax.legend(fontsize=8)
plt.tight_layout(); plt.show()

Espacio de las variables X: las observaciones con leverage alto están lejos del centro

Recordemos que un leverage alto sólo indica potencial de influencia. Si la observación sigue el mismo patrón que el resto, su residual será pequeño y no cambiará mucho la regresión.

4 Residuales y residuales estandarizados (Lección 9.3)

4.1 Residuales ordinarios

e = y - \hat{y} = (I - H)\,y

El problema de los residuales ordinarios es que dependen de las unidades de Y y que su varianza no es constante: Var(e_i) = \sigma^2(1 - h_{ii}). Observaciones con leverage alto “jalan” la recta hacia ellas, así que tienden a tener residuales más pequeños.

4.2 Residuales estandarizados (internally studentized residuals)

Dividimos cada residual entre su error estándar estimado:

r_i = \frac{e_i}{\sqrt{MSE\,(1-h_{ii})}}, \qquad MSE = \frac{\sum e_i^2}{n-p}

Como r_i es aproximadamente una variable estándar, una regla práctica es marcar como outlier cualquier observación con |r_i| > 3.

e = y - H @ y
SSE = e @ e
MSE = SSE / (n - p)
r = e / np.sqrt(MSE * (1 - h))
d["e"], d["r"] = e, r

print(f"SSE = {SSE:.4f}, MSE = {MSE:.6f}, raíz(MSE) = {np.sqrt(MSE):.4f}")
SSE = 42.2040, MSE = 0.039778, raíz(MSE) = 0.1994
print(f"Observaciones con |r| > 3: {(np.abs(r) > 3).sum()}")
Observaciones con |r| > 3: 18

5 Residuales eliminados (Lección 9.4)

5.1 Deleted residuals (errores de predicción PRESS)

Un outlier “jala” la regresión hacia él y esconde su propio residual. La solución es eliminar la observación i, estimar el modelo con las n-1 observaciones restantes y predecir y_i con ese modelo:

d_i = y_i - \hat{y}_{(i)}

Afortunadamente no hay que correr n regresiones: se puede demostrar que

d_i = \frac{e_i}{1 - h_{ii}}

5.2 Studentized deleted residuals (externally studentized residuals)

Dividimos el residual eliminado entre su error estándar. El resultado es:

t_i = \frac{d_i}{s(d_i)} = \frac{e_i}{\sqrt{MSE_{(i)}\,(1-h_{ii})}}

donde MSE_{(i)} es el MSE de la regresión sin la observación i, que también se puede calcular sin re-estimar:

MSE_{(i)} = \frac{SSE - \dfrac{e_i^2}{1-h_{ii}}}{n - p - 1}

Los t_i siguen una distribución t con n-p-1 grados de libertad. Marcamos como outliers las observaciones con |t_i| > 3.

d_del = e / (1 - h)                                  # deleted residuals
MSE_i = (SSE - e**2 / (1 - h)) / (n - p - 1)         # MSE sin la obs. i
t = e / np.sqrt(MSE_i * (1 - h))                     # studentized deleted residuals
d["d_del"], d["t"] = d_del, t

print(f"Observaciones con |t| > 3: {(np.abs(t) > 3).sum()}")
Observaciones con |t| > 3: 18
print(d.reindex(d["t"].abs().sort_values(ascending=False).index)[
      ["empresa", "tamano", "roa", "upa", "deuda_activos", "h", "r", "t"]].head(10).round(3).to_string())
    empresa   tamano    roa    upa  deuda_activos      h       r       t
110     AWH  Pequeña -3.011 -0.447          0.455  0.006 -12.832 -13.954
136    BFRG  Pequeña -1.933 -0.270          0.000  0.004  -8.225  -8.497
496    IRWD  Mediana -2.007 -0.529          1.519  0.062  -6.080  -6.185
519      KA  Pequeña -1.529 -0.417          0.060  0.004  -5.581  -5.662
587    LUCD  Pequeña -1.778 -0.770          0.559  0.015  -5.235  -5.302
153     BPT  Pequeña  1.002  0.101          0.000  0.006   4.959   5.015
840    SBEV  Pequeña -1.544 -0.632          0.219  0.008  -4.746  -4.794
69     APLT  Pequeña -1.177 -0.249          0.009  0.004  -4.514  -4.556
699    NVAX  Mediana -0.315 -0.994          0.127  0.118   4.214   4.248
615    MGRM  Pequeña -1.042 -0.161          0.030  0.004  -4.208  -4.242

5.3 Verificación “a fuerza bruta” para una observación

Comprobemos las fórmulas para la empresa con el residual más grande, eliminándola y re-estimando el modelo:

i = int(np.argmax(np.abs(t)))
mask = np.arange(n) != i
Xi, yi = X[mask], y[mask]
b_i = np.linalg.inv(Xi.T @ Xi) @ Xi.T @ yi          # coeficientes sin la obs. i
yhat_i = X[i] @ b_i                                  # predicción de y_i sin usar y_i
e_sin = yi - Xi @ b_i
MSE_sin = e_sin @ e_sin / (n - 1 - p)

print(f"Empresa: {d.loc[i, 'empresa']}")
Empresa: AWH
print(f"d_i  por fórmula = {d_del[i]:.6f}  |  re-estimando = {y[i] - yhat_i:.6f}")
d_i  por fórmula = -2.567243  |  re-estimando = -2.567243
print(f"MSE_(i) fórmula = {MSE_i[i]:.6f}  |  re-estimando = {MSE_sin:.6f}")
MSE_(i) fórmula = 0.033636  |  re-estimando = 0.033636
fig, ax = plt.subplots(figsize=(8, 4))
out = np.abs(t) > 3
ax.scatter(np.arange(n), t, s=10, color=np.where(out, NARANJA, AZUL), alpha=0.7)
ax.axhline(3, color=NARANJA, ls="--", lw=1.2); ax.axhline(-3, color=NARANJA, ls="--", lw=1.2)
for j in np.argsort(-np.abs(t))[:5]:
    ax.annotate(d.loc[j, "empresa"], (j, t[j]), xytext=(4, 0),
                textcoords="offset points", fontsize=8)
ax.set_xlabel("Observación (índice)"); ax.set_ylabel("t_i")
ax.set_title("Outliers en Y: studentized deleted residuals")
plt.tight_layout(); plt.show()

Studentized deleted residuals. Las líneas marcan ±3
fig, ax = plt.subplots(figsize=(6, 4.5))
ax.scatter(r, t, s=10, alpha=0.5, color=AZUL)
lims = [min(r.min(), t.min()), max(r.max(), t.max())]
ax.plot(lims, lims, color=GRIS, ls="--", lw=1, label="t = r")
ax.set_xlabel("Residual estandarizado r_i"); ax.set_ylabel("Studentized deleted residual t_i")
ax.set_title("t_i amplifica a los outliers más extremos")
ax.legend(fontsize=8)
plt.tight_layout(); plt.show()

Comparación de residuales estandarizados vs studentized deleted residuals

Interpretación: 18 empresas tienen |t_i| > 3. El outlier más extremo es AWH (t = -13.96): una empresa pequeña con ROA de -301% (pierde 3 dólares por cada dólar de activos), mucho peor de lo que el modelo predice dado su upa. Otros outliers como IRWD, BFRG y LUCD también son empresas con pérdidas enormes respecto a sus activos. En la gráfica de r vs t se ve que los residuales eliminados son más extremos que los estandarizados para los outliers, porque al quitar la observación el modelo ya no se “acomoda” a ella.

6 Observaciones influyentes (Lección 9.5)

Una observación es influyente si al eliminarla cambian de manera importante los coeficientes o las predicciones. Esto suele pasar cuando una observación es outlier y además tiene leverage alto.

6.1 DFFITS

Mide cuántos errores estándar cambia el valor predicho de la observación i cuando la eliminamos:

DFFITS_i = \frac{\hat{y}_i - \hat{y}_{(i)}}{\sqrt{MSE_{(i)}\,h_{ii}}} = t_i\sqrt{\frac{h_{ii}}{1-h_{ii}}}

La segunda forma muestra que DFFITS combina el tamaño del residual (t_i) con el leverage (h_{ii}). La lección marca como influyente una observación con:

|DFFITS_i| > 2\sqrt{\frac{k+2}{n-k-2}}

dffits = t * np.sqrt(h / (1 - h))
lim_dffits = 2 * np.sqrt((k + 2) / (n - k - 2))
d["dffits"] = dffits
print(f"Límite DFFITS = {lim_dffits:.4f}")
Límite DFFITS = 0.1843
print(f"Observaciones con |DFFITS| > límite: {(np.abs(dffits) > lim_dffits).sum()}")
Observaciones con |DFFITS| > límite: 60
# Verificación con la definición (re-estimando sin la obs. i)
print(f"\nVerificación para {d.loc[i, 'empresa']}: "
      f"fórmula = {dffits[i]:.5f} | definición = "
      f"{(H[i] @ y - yhat_i) / np.sqrt(MSE_sin * h[i]):.5f}")

Verificación para AWH: fórmula = -1.10924 | definición = -1.10924

6.2 Distancia de Cook

Mide cuánto cambian todos los valores predecidos cuando se elimina la observación i:

D_i = \frac{(y_i - \hat{y}_i)^2}{p \times MSE}\left[\frac{h_{ii}}{(1-h_{ii})^2}\right]

Criterios de la lección:

  • D_i > 0.5: vale la pena investigarla, puede ser influyente.
  • D_i > 1: muy probablemente es influyente.
  • Si D_i “sobresale” claramente del resto, probablemente es influyente.

(Otra regla común en la práctica es D_i > 4/n.)

cook = (e**2 / (p * MSE)) * (h / (1 - h)**2)
d["cook"] = cook
print(f"D > 0.5: {(cook > 0.5).sum()} | D > 1: {(cook > 1).sum()} | "
      f"D > 4/n = {4/n:.4f}: {(cook > 4/n).sum()}")
D > 0.5: 0 | D > 1: 0 | D > 4/n = 0.0037: 67
print(f"Distancia de Cook máxima: {cook.max():.4f}")
Distancia de Cook máxima: 0.3036
print(d.sort_values("cook", ascending=False)[
      ["empresa", "tamano", "roa", "upa", "deuda_activos", "h", "t", "dffits", "cook"]
      ].head(10).round(3).to_string())
     empresa   tamano    roa    upa  deuda_activos      h       t  dffits   cook
496     IRWD  Mediana -2.007 -0.529          1.519  0.062  -6.185  -1.586  0.304
699     NVAX  Mediana -0.315 -0.994          0.127  0.118   4.248   1.552  0.296
110      AWH  Pequeña -3.011 -0.447          0.455  0.006 -13.954  -1.109  0.130
926     TCON  Pequeña -0.918 -1.700          0.000  0.061   3.030   0.771  0.074
743      PBF   Grande  0.205  0.549          0.141  0.135  -1.906  -0.754  0.071
164     BYND  Mediana -0.441 -0.595          1.571  0.073   2.508   0.706  0.062
1004    VISL  Pequeña -0.236 -1.144          0.027  0.026   4.075   0.666  0.055
828      RUN   Grande -0.097 -0.463          0.536  0.140   1.641   0.661  0.055
587     LUCD  Pequeña -1.778 -0.770          0.559  0.015  -5.302  -0.646  0.051
136     BFRG  Pequeña -1.933 -0.270          0.000  0.004  -8.497  -0.541  0.034
fig, axes = plt.subplots(2, 1, figsize=(8, 6), sharex=True)
inf = np.abs(dffits) > lim_dffits
axes[0].scatter(np.arange(n), dffits, s=10, color=np.where(inf, NARANJA, AZUL), alpha=0.7)
axes[0].axhline(lim_dffits, color=NARANJA, ls="--", lw=1.2)
axes[0].axhline(-lim_dffits, color=NARANJA, ls="--", lw=1.2)
axes[0].set_ylabel("DFFITS"); axes[0].set_title(f"DFFITS (límite ±{lim_dffits:.3f})")
axes[1].vlines(np.arange(n), 0, cook, color=AZUL, lw=1)
axes[1].axhline(4/n, color=GRIS, ls=":", lw=1.2, label=f"4/n = {4/n:.4f}")
for j in np.argsort(cook)[-4:]:
    axes[1].annotate(d.loc[j, "empresa"], (j, cook[j]), xytext=(4, 0),
                     textcoords="offset points", fontsize=8)
    axes[0].annotate(d.loc[j, "empresa"], (j, dffits[j]), xytext=(4, 0),
                     textcoords="offset points", fontsize=8)
axes[1].set_ylabel("Distancia de Cook"); axes[1].set_xlabel("Observación (índice)")
axes[1].set_title("Distancia de Cook"); axes[1].legend(fontsize=8)
plt.tight_layout(); plt.show()

DFFITS (arriba) y distancia de Cook (abajo) para cada observación

Interpretación:

  • Ninguna observación supera D_i > 0.5, pero dos empresas sobresalen claramente del resto: NVAX (D = 0.31) e IRWD (D = 0.31), seguidas de AWH (D = 0.13). Según el criterio de “sobresale del resto”, son influyentes.
  • 60 observaciones (5.6%) superan el límite de DFFITS.
  • NVAX e IRWD son influyentes porque combinan leverage alto y residual grande. AWH tiene un residual enorme pero leverage moderado. BBIO, en cambio, tiene el leverage más alto pero un residual pequeño: sigue el patrón del modelo y por eso no es influyente (D = 0.016).

6.3 Gráfica resumen: leverage vs residual

Esta gráfica junta los tres conceptos: el eje X es el leverage, el eje Y el residual eliminado, y el tamaño del círculo es la distancia de Cook. Las observaciones influyentes están en las esquinas (leverage alto y residual grande).

fig, ax = plt.subplots(figsize=(8, 5))
ax.scatter(h, t, s=10 + 1500 * cook, alpha=0.45,
           color=np.where(inf, NARANJA, AZUL), edgecolor="white", lw=0.5)
ax.axvline(lim_h3, color=GRIS, ls="--", lw=1)
ax.axhline(3, color=GRIS, ls="--", lw=1); ax.axhline(-3, color=GRIS, ls="--", lw=1)
for j in list(np.argsort(cook)[-6:]) + [int(np.argmax(h))]:
    ax.annotate(d.loc[j, "empresa"], (h[j], t[j]), xytext=(5, 3),
                textcoords="offset points", fontsize=8)
ax.set_xscale("log")
ax.set_xlabel("Leverage h_ii (escala log)"); ax.set_ylabel("Studentized deleted residual t_i")
ax.set_title("Influencia = leverage + residual")
ax.text(lim_h3 * 1.08, -12.5, "leverage alto →", fontsize=8, color=GRIS)
plt.tight_layout(); plt.show()

Gráfica de influencia: leverage vs studentized deleted residual (tamaño = distancia de Cook)

6.4 Verificación con statsmodels

Corroboramos todos nuestros cálculos matriciales con la librería:

infl = m_orig.get_influence()
checks = {
    "leverage h_ii": np.allclose(h, infl.hat_matrix_diag),
    "residual estandarizado r_i": np.allclose(r, infl.resid_studentized_internal),
    "studentized deleted t_i": np.allclose(t, infl.resid_studentized_external),
    "DFFITS": np.allclose(dffits, infl.dffits[0]),
    "Distancia de Cook": np.allclose(cook, infl.cooks_distance[0]),
}
for kk, v in checks.items():
    print(f"{kk:30s}: {'OK' if v else 'DIFERENTE'}")
leverage h_ii                 : OK
residual estandarizado r_i    : OK
studentized deleted t_i       : OK
DFFITS                        : OK
Distancia de Cook             : OK

6.5 Resumen del diagnóstico

resumen = pd.DataFrame({
    "Criterio": ["Leverage > 3p/n", "Leverage > 2p/n", "|r| > 3", "|t| > 3",
                 "|DFFITS| > límite", "Cook > 0.5", "Cook > 4/n"],
    "Límite": [lim_h3, lim_h2, 3, 3, lim_dffits, 0.5, 4/n],
    "Observaciones": [(h > lim_h3).sum(), (h > lim_h2).sum(), (np.abs(r) > 3).sum(),
                      (np.abs(t) > 3).sum(), inf.sum(), (cook > 0.5).sum(), (cook > 4/n).sum()]})
resumen["Límite"] = resumen["Límite"].round(4)
print(resumen.to_string(index=False))
         Criterio  Límite  Observaciones
  Leverage > 3p/n  0.0225             36
  Leverage > 2p/n  0.0150             67
          |r| > 3  3.0000             18
          |t| > 3  3.0000             18
|DFFITS| > límite  0.1843             60
       Cook > 0.5  0.5000              0
       Cook > 4/n  0.0037             67
print("\nObservaciones influyentes (DFFITS) por tamaño:")

Observaciones influyentes (DFFITS) por tamaño:
print(d.loc[inf, "tamano"].value_counts())
tamano
Pequeña    37
Mediana    16
Grande      7
Name: count, dtype: int64

La mayoría de las observaciones influyentes son empresas pequeñas con pérdidas muy grandes respecto a sus activos o a su valor de mercado. Esto es consistente con la distribución del ROA y la upa: tienen colas izquierdas muy largas.

7 Multicolinealidad

Como el modelo tiene interacciones y un término cuadrático, conviene revisar la multicolinealidad. El Factor de Inflación de Varianza de cada variable es:

VIF_j = \frac{1}{1 - R^2_j}

donde R^2_j es el R² de la regresión de X_j contra las demás X. Con álgebra matricial, los VIF son la diagonal de la inversa de la matriz de correlación de las variables X:

R = np.corrcoef(X[:, 1:], rowvar=False)
vif = np.diag(np.linalg.inv(R))

from statsmodels.stats.outliers_influence import variance_inflation_factor
vif_sm = [variance_inflation_factor(X, j) for j in range(1, p)]
print(pd.DataFrame({"VIF (matricial)": vif, "VIF (statsmodels)": vif_sm},
                   index=nombres[1:]).round(3))
                 VIF (matricial)  VIF (statsmodels)
Mediana                    1.590              1.590
Grande                     1.884              1.884
upa                        1.582              1.582
Mediana:upa                1.225              1.225
Grande:upa                 1.339              1.339
deuda_activos              2.839              2.839
deuda_activos^2            2.793              2.793

Todos los VIF son menores a 3 (la regla usual de alerta es VIF > 5 o 10), así que no hay un problema de multicolinealidad. El VIF más alto es el de la deuda y su cuadrado (≈ 2.8), lo cual es natural porque DA y DA^2 están correlacionadas. Si fuera necesario, se puede reducir centrando la variable antes de elevarla al cuadrado:

dac = da - da.mean()
Xc = np.column_stack([med, gde, upa, med*upa, gde*upa, dac, dac**2])
print(pd.Series(np.diag(np.linalg.inv(np.corrcoef(Xc, rowvar=False))),
                index=nombres[1:]).round(3))
Mediana            1.590
Grande             1.884
upa                1.582
Mediana:upa        1.225
Grande:upa         1.339
deuda_activos      1.668
deuda_activos^2    1.640
dtype: float64

8 Propuestas de corrección

8.1 ¿Qué hacer con leverages y outliers?

Antes de eliminar datos hay que preguntarse por qué son extremos:

  1. ¿Es un error de datos? Si fuera así, se corrige o se elimina. Aquí no parece ser el caso: son empresas reales (por ejemplo, biotecnológicas en etapa de investigación como AWH, NVAX o IRWD) con pérdidas muy grandes, o empresas con deuda mayor que sus activos (patrimonio negativo, como BBIO).
  2. ¿Pertenecen a la población que queremos estudiar? Si queremos entender la productividad de empresas “típicas” en operación, las empresas con pérdidas extremas o con más deuda que activos pueden distorsionar las conclusiones.

Proponemos y comparamos dos correcciones:

  • Corrección A - Eliminar observaciones influyentes: quitamos las observaciones con |DFFITS| mayor al límite de la lección. Es la corrección más directa, pero se pierden datos (y podríamos introducir sesgo si eliminamos sistemáticamente a las empresas con pérdidas).
  • Corrección B - Winsorizar: reemplazamos los valores menores al percentil 1 por el valor del percentil 1, y los mayores al percentil 99 por el valor del percentil 99, en las variables continuas del modelo (roa, upa y deuda/activos). Así conservamos todas las empresas, pero limitamos cuánto pueden “jalar” la recta los valores extremos.

En ambos casos mantenemos la clasificación de tamaño original (es un atributo de cada empresa).

8.2 Corrección A: eliminar observaciones influyentes

dA = d.loc[~inf].copy()
m_A = smf.ols(formula, data=dA).fit()
print(f"Se eliminaron {inf.sum()} observaciones; quedan {len(dA)}")
Se eliminaron 60 observaciones; quedan 1009
print(m_A.summary())
                            OLS Regression Results                            
==============================================================================
Dep. Variable:                    roa   R-squared:                       0.742
Model:                            OLS   Adj. R-squared:                  0.741
Method:                 Least Squares   F-statistic:                     412.3
Date:                Mon, 05 Oct 2026   Prob (F-statistic):          1.06e-289
Time:                        13:56:36   Log-Likelihood:                 739.12
No. Observations:                1009   AIC:                            -1462.
Df Residuals:                    1001   BIC:                            -1423.
Df Model:                           7                                         
Covariance Type:            nonrobust                                         
============================================================================================
                               coef    std err          t      P>|t|      [0.025      0.975]
--------------------------------------------------------------------------------------------
Intercept                   -0.0477      0.009     -5.291      0.000      -0.065      -0.030
C(tamano)[T.Mediana]         0.0157      0.010      1.608      0.108      -0.003       0.035
C(tamano)[T.Grande]          0.0974      0.011      8.847      0.000       0.076       0.119
upa                          0.8465      0.024     35.780      0.000       0.800       0.893
C(tamano)[T.Mediana]:upa     0.4419      0.057      7.794      0.000       0.331       0.553
C(tamano)[T.Grande]:upa      0.1298      0.105      1.240      0.215      -0.076       0.335
deuda_activos                0.0095      0.042      0.229      0.819      -0.072       0.091
I(deuda_activos ** 2)       -0.0610      0.048     -1.268      0.205      -0.155       0.033
==============================================================================
Omnibus:                      300.269   Durbin-Watson:                   1.960
Prob(Omnibus):                  0.000   Jarque-Bera (JB):             1343.919
Skew:                          -1.324   Prob(JB):                    1.49e-292
Kurtosis:                       7.996   Cond. No.                         33.0
==============================================================================

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

8.3 Corrección B: winsorizar al 1% y 99%

def winsorizar(s, q_inf=0.01, q_sup=0.99):
    li, ls = s.quantile(q_inf), s.quantile(q_sup)
    return s.clip(lower=li, upper=ls)

dB = d.copy()
for c in ["roa", "upa", "deuda_activos"]:
    li, ls = d[c].quantile(0.01), d[c].quantile(0.99)
    n_cambios = ((d[c] < li) | (d[c] > ls)).sum()
    print(f"{c:15s}: P1 = {li:8.4f}, P99 = {ls:7.4f}, valores ajustados = {n_cambios}")
    dB[c] = winsorizar(d[c])
roa            : P1 =  -1.2383, P99 =  0.3262, valores ajustados = 22
upa            : P1 =  -0.9733, P99 =  0.2676, valores ajustados = 22
deuda_activos  : P1 =   0.0000, P99 =  1.2211, valores ajustados = 11
m_B = smf.ols(formula, data=dB).fit()
print(m_B.summary())
                            OLS Regression Results                            
==============================================================================
Dep. Variable:                    roa   R-squared:                       0.655
Model:                            OLS   Adj. R-squared:                  0.653
Method:                 Least Squares   F-statistic:                     288.0
Date:                Mon, 05 Oct 2026   Prob (F-statistic):          3.42e-240
Time:                        13:56:36   Log-Likelihood:                 407.15
No. Observations:                1069   AIC:                            -798.3
Df Residuals:                    1061   BIC:                            -758.5
Df Model:                           7                                         
Covariance Type:            nonrobust                                         
============================================================================================
                               coef    std err          t      P>|t|      [0.025      0.975]
--------------------------------------------------------------------------------------------
Intercept                   -0.0675      0.013     -5.340      0.000      -0.092      -0.043
C(tamano)[T.Mediana]         0.0377      0.014      2.753      0.006       0.011       0.065
C(tamano)[T.Grande]          0.0957      0.015      6.360      0.000       0.066       0.125
upa                          0.8421      0.028     29.807      0.000       0.787       0.898
C(tamano)[T.Mediana]:upa     0.2113      0.064      3.299      0.001       0.086       0.337
C(tamano)[T.Grande]:upa      0.2394      0.129      1.849      0.065      -0.015       0.493
deuda_activos                0.0961      0.055      1.746      0.081      -0.012       0.204
I(deuda_activos ** 2)       -0.2029      0.059     -3.451      0.001      -0.318      -0.088
==============================================================================
Omnibus:                      374.769   Durbin-Watson:                   1.909
Prob(Omnibus):                  0.000   Jarque-Bera (JB):             2498.239
Skew:                          -1.448   Prob(JB):                         0.00
Kurtosis:                       9.906   Cond. No.                         29.5
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
fig, axes = plt.subplots(1, 3, figsize=(10, 3.5))
for ax, c in zip(axes, ["roa", "upa", "deuda_activos"]):
    ax.boxplot([d[c], dB[c]], vert=True, widths=0.5,
               flierprops=dict(markersize=3, alpha=0.4))
    ax.set_xticks([1, 2]); ax.set_xticklabels(["Original", "Winsorizada"])
    ax.set_title(c, fontsize=10)
plt.tight_layout(); plt.show()

Efecto de winsorizar: los valores extremos se ‘recortan’ a los percentiles 1 y 99

8.4 ¿Mejoró el diagnóstico?

Recalculamos los diagnósticos en los modelos corregidos:

def diagnostico(modelo, nombre):
    inf_m = modelo.get_influence()
    nn, pp = modelo.model.exog.shape
    kk = pp - 1
    hh = inf_m.hat_matrix_diag
    tt = inf_m.resid_studentized_external
    ff = inf_m.dffits[0]
    cc = inf_m.cooks_distance[0]
    return pd.Series({"n": int(nn),
                      "Leverage > 3p/n": (hh > 3*pp/nn).sum(),
                      "|t| > 3": (np.abs(tt) > 3).sum(),
                      "|DFFITS| > límite": (np.abs(ff) > 2*np.sqrt((kk+2)/(nn-kk-2))).sum(),
                      "Cook máx": round(cc.max(), 4),
                      "Leverage máx": round(hh.max(), 4),
                      "|t| máx": round(np.abs(tt).max(), 2)}, name=nombre)

tabla_diag = pd.concat([diagnostico(m_orig, "Original"), diagnostico(m_A, "A: Eliminar"),
                        diagnostico(m_B, "B: Winsorizar")], axis=1)
print(tabla_diag.apply(lambda col: col.map(lambda v: f"{v:g}")).to_string())
                  Original A: Eliminar B: Winsorizar
n                     1069        1009          1069
Leverage > 3p/n         36          39            39
|t| > 3                 18          20            29
|DFFITS| > límite       60          68            79
Cook máx            0.3036      0.0791        0.3641
Leverage máx        0.7943      0.1754        0.1541
|t| máx              13.95        5.39          5.78

Observa que siempre habrá observaciones marcadas: los criterios son relativos al modelo y a los datos que se usan. Al quitar observaciones, otras empresas pasan a verse “extremas”. Por eso no se debe repetir la eliminación hasta que no quede ninguna marcada (acabaríamos eliminando una gran parte de la muestra). Lo importante es que:

  • Con la corrección B, el caso de leverage extremo desaparece: la deuda/activos de BBIO se recorta a 1.22 y el leverage máximo baja de 0.79 a 0.15. El |t| máximo baja de 13.96 a 5.78, porque el ROA de AWH se recorta a -1.24.
  • Ojo: la distancia de Cook máxima no baja con la winsorización (0.38, de NVAX). Además, BBIO pasa a ser influyente (D = 0.27): al recortar su deuda queda en una zona donde su ROA de -111% ya no es consistente con el modelo. Aun así, ninguna observación supera el criterio de D > 0.5 y ya no hay casos con leverage cercano a 1.
  • Con la corrección A, las observaciones más extremas ya no están en la muestra: el |t| máximo baja a 5.40, el leverage máximo a 0.18 y la distancia de Cook máxima a 0.08.

9 Comparación con el Challenge 4 del Workshop 1

def tabla(modelo):
    return (modelo.params.round(4).astype(str) + " (" +
            modelo.pvalues.map(lambda v: f"{v:.3f}") + ")")

comp = pd.concat([tabla(m_orig), tabla(m_A), tabla(m_B)], axis=1)
comp.columns = ["Original (W1-C4)", "A: Eliminar influyentes", "B: Winsorizar 1-99%"]
comp.loc["R²"] = [f"{m.rsquared:.4f}" for m in [m_orig, m_A, m_B]]
comp.loc["n"] = [int(m.nobs) for m in [m_orig, m_A, m_B]]
print("Coeficiente (p-value)")
Coeficiente (p-value)
print(comp.to_string())
                         Original (W1-C4) A: Eliminar influyentes B: Winsorizar 1-99%
Intercept                 -0.0684 (0.000)         -0.0477 (0.000)     -0.0675 (0.000)
C(tamano)[T.Mediana]        0.047 (0.004)          0.0157 (0.108)      0.0377 (0.006)
C(tamano)[T.Grande]         0.118 (0.000)          0.0974 (0.000)      0.0957 (0.000)
upa                        0.8425 (0.000)          0.8465 (0.000)      0.8421 (0.000)
C(tamano)[T.Mediana]:upa   0.2485 (0.001)          0.4419 (0.000)      0.2113 (0.001)
C(tamano)[T.Grande]:upa    0.0821 (0.578)          0.1298 (0.215)      0.2394 (0.065)
deuda_activos              0.0207 (0.620)          0.0095 (0.819)      0.0961 (0.081)
I(deuda_activos ** 2)     -0.1148 (0.000)          -0.061 (0.205)     -0.2029 (0.001)
R²                                 0.5938                  0.7425              0.6552
n                                    1069                    1009                1069
modelos = {"Original": (m_orig, GRIS), "A: Eliminar": (m_A, NARANJA),
           "B: Winsorizar": (m_B, AZUL)}
vars_plot = m_orig.params.index[1:]
etiquetas = nombres[1:]
fig, ax = plt.subplots(figsize=(8, 4.5))
for jj, (nom, (mm, cc)) in enumerate(modelos.items()):
    ci = mm.conf_int().loc[vars_plot]
    ypos = np.arange(len(vars_plot)) + (jj - 1) * 0.22
    ax.errorbar(mm.params[vars_plot], ypos,
                xerr=[mm.params[vars_plot] - ci[0], ci[1] - mm.params[vars_plot]],
                fmt="o", color=cc, ms=5, capsize=2, lw=1.2, label=nom)
ax.axvline(0, color="black", lw=0.8)
ax.set_yticks(np.arange(len(vars_plot))); ax.set_yticklabels(etiquetas)
ax.invert_yaxis()
ax.set_xlabel("Coeficiente (IC 95%)")
ax.set_title("¿Qué tan robustos son los coeficientes?")
ax.legend(fontsize=8, loc="lower right")
plt.tight_layout(); plt.show()

Coeficientes e intervalos de confianza al 95% en las tres versiones del modelo

9.1 Efecto sobre las pendientes de upa por tamaño

def pendientes(mm):
    bb = mm.params
    return {"Pequeña": bb["upa"],
            "Mediana": bb["upa"] + bb["C(tamano)[T.Mediana]:upa"],
            "Grande":  bb["upa"] + bb["C(tamano)[T.Grande]:upa"]}

print(pd.DataFrame({nom: pendientes(mm) for nom, (mm, _) in modelos.items()}).round(4))
         Original  A: Eliminar  B: Winsorizar
Pequeña    0.8425       0.8465         0.8421
Mediana    1.0910       1.2884         1.0535
Grande     0.9246       0.9762         1.0815

9.2 Efecto sobre la relación cuadrática de la deuda

for nom, (mm, cc) in modelos.items():
    b6 = mm.params["deuda_activos"]; b7 = mm.params["I(deuda_activos ** 2)"]
    print(f"{nom:14s}: b6 = {b6:7.4f}, b7 = {b7:7.4f}, máximo en DA = {-b6/(2*b7):.3f}")
Original      : b6 =  0.0207, b7 = -0.1148, máximo en DA = 0.090
A: Eliminar   : b6 =  0.0095, b7 = -0.0610, máximo en DA = 0.078
B: Winsorizar : b6 =  0.0961, b7 = -0.2029, máximo en DA = 0.237
grid = np.linspace(0, 1.3, 200)
upa_med = d["upa"].median()
fig, ax = plt.subplots(figsize=(8, 4.2))
for nom, (mm, cc) in modelos.items():
    nd = pd.DataFrame({"tamano": pd.Categorical(["Pequeña"]*len(grid),
                                                 categories=d["tamano"].cat.categories),
                       "upa": upa_med, "deuda_activos": grid})
    ax.plot(grid, mm.predict(nd), color=cc, lw=2.5, label=nom)
ax.set_xlabel("Deuda / Activos"); ax.set_ylabel("ROA esperado")
ax.set_title("La curvatura de la deuda depende de las observaciones extremas")
ax.legend(fontsize=8)
plt.tight_layout(); plt.show()

Efecto de la deuda sobre el ROA (empresa pequeña con upa mediana) en las tres versiones

9.3 Resumen de la comparación

  • El efecto de upa es prácticamente idéntico en las tres versiones (0.842 - 0.846): es el resultado más estable.
  • La pendiente de upa es mayor para las medianas en las tres versiones, aunque la magnitud de la diferencia cambia (0.22 a 0.46).
  • El efecto no lineal de la deuda es frágil: con winsorización se mantiene (máximo en DA ≈ 0.24), pero al eliminar las observaciones influyentes ningún término de deuda es significativo.
  • El R² sube de 0.594 a 0.655 (winsorizado) y a 0.743 (eliminando influyentes). Esto no significa que el modelo sea “mejor” por sí mismo: al reducir la variabilidad extrema de Y es natural que el R² aumente.
  • Aun después de las correcciones, los residuales tienen colas pesadas (ver Kurtosis y Jarque-Bera en los resúmenes) y, como veremos, su varianza no es constante. Por eso el siguiente paso es usar errores estándar robustos y regresión robusta.

10 Errores estándar robustos y regresión robusta

Hay dos herramientas con nombres parecidos que atacan problemas distintos:

Errores estándar robustos Regresión robusta
¿Qué problema atiende? Heterocedasticidad (varianza del error no constante) Outliers en Y que “jalan” los coeficientes
¿Cambia los coeficientes? No, son los mismos de OLS Sí
¿Qué cambia? Errores estándar, valores t, p-values e intervalos Coeficientes y errores estándar
¿Cómo? Fórmula “sándwich” de la varianza Mínimos cuadrados ponderados iterativos: las observaciones atípicas pesan menos

Usaremos ambas sobre el modelo winsorizado (corrección B), porque la winsorización ya controló los problemas de leverage (valores extremos en X), algo que, como veremos, la regresión robusta por sí sola no resuelve.

10.1 Errores estándar robustos (HC3)

10.1.1 ¿Para qué sirven?

La fórmula de OLS para la varianza de los coeficientes supone que todos los errores tienen la misma varianza \sigma^2 (homocedasticidad):

Var(b) = MSE\,(X'X)^{-1}

Si la varianza del error es diferente entre observaciones (heterocedasticidad), los coeficientes de OLS siguen siendo insesgados, pero los errores estándar están mal calculados y, por lo tanto, los valores t y los p-values no son confiables. Podríamos concluir que un coeficiente es significativo cuando no lo es.

10.1.2 ¿Hay heterocedasticidad en nuestro modelo?

Veamos la dispersión de los residuales por grupo de tamaño y la gráfica de residuales vs valores ajustados:

import statsmodels.api as sm
from statsmodels.stats.diagnostic import het_breuschpagan

res_B = m_B.resid
print("Desviación estándar de los residuales por tamaño (modelo winsorizado):")
Desviación estándar de los residuales por tamaño (modelo winsorizado):
print(res_B.groupby(dB["tamano"], observed=True).std().round(4))
tamano
Pequeña    0.2342
Mediana    0.1253
Grande     0.1078
dtype: float64
bp = het_breuschpagan(res_B, m_B.model.exog)
print(f"\nPrueba de Breusch-Pagan: LM = {bp[0]:.2f}, p-value = {bp[1]:.2e}")

Prueba de Breusch-Pagan: LM = 198.31, p-value = 2.61e-39
fig, ax = plt.subplots(figsize=(8, 4.2))
for g in ["Pequeña", "Mediana", "Grande"]:
    mk = (dB["tamano"] == g).values
    ax.scatter(m_B.fittedvalues[mk], res_B[mk], s=10, alpha=0.4,
               color=COL[g], marker=MRK[g], label=g)
ax.axhline(0, color="black", lw=0.8)
ax.set_xlabel("ROA ajustado"); ax.set_ylabel("Residual")
ax.set_title("Heterocedasticidad: residuales más dispersos en pequeñas")
ax.legend(fontsize=8)
plt.tight_layout(); plt.show()

Residuales vs valores ajustados del modelo winsorizado: la dispersión no es constante

La desviación estándar de los residuales de las empresas pequeñas es más del doble que la de las grandes, y la prueba de Breusch-Pagan rechaza contundentemente la hipótesis nula de homocedasticidad. Los errores estándar de OLS no son confiables.

10.1.3 La fórmula sándwich

Los errores robustos estiman la varianza permitiendo que cada observación tenga su propia varianza, usando su residual al cuadrado como estimación:

Var(b) = \underbrace{(X'X)^{-1}}_{\text{pan}}\;\underbrace{X'\,\Omega\,X}_{\text{relleno}}\;\underbrace{(X'X)^{-1}}_{\text{pan}}

Hay varias versiones según cómo se define la matriz diagonal \Omega:

  • HC0 (White): \Omega_{ii} = e_i^2
  • HC3: \Omega_{ii} = \dfrac{e_i^2}{(1-h_{ii})^2}

HC3 es la más recomendada en muestras con observaciones de leverage alto: divide entre (1-h_{ii})^2 porque, como vimos en la Lección 9.4, las observaciones con leverage alto “jalan” la recta y tienen residuales artificialmente pequeños (es el mismo ajuste que convierte un residual en deleted residual).

Calculamos HC3 con álgebra matricial y lo comparamos con statsmodels:

Xw = m_B.model.exog                 # matriz X del modelo winsorizado
ew = m_B.resid.values               # residuales
XtXi_w = np.linalg.inv(Xw.T @ Xw)
hw = np.diag(Xw @ XtXi_w @ Xw.T)    # leverages

Omega = np.diag(ew**2 / (1 - hw)**2)
V_hc3 = XtXi_w @ (Xw.T @ Omega @ Xw) @ XtXi_w
se_hc3 = np.sqrt(np.diag(V_hc3))

m_B_hc3 = smf.ols(formula, data=dB).fit(cov_type="HC3")
print("¿Igual a statsmodels?", np.allclose(se_hc3, m_B_hc3.bse.values))
¿Igual a statsmodels? True
tab_se = pd.DataFrame({
    "coef": m_B.params,
    "EE OLS": m_B.bse, "p OLS": m_B.pvalues,
    "EE HC3": m_B_hc3.bse, "p HC3": m_B_hc3.pvalues})
tab_se.index = nombres
print(tab_se.round(4))
                   coef  EE OLS   p OLS  EE HC3   p HC3
Intercepto      -0.0675  0.0126  0.0000  0.0146  0.0000
Mediana          0.0377  0.0137  0.0060  0.0118  0.0014
Grande           0.0957  0.0151  0.0000  0.0157  0.0000
upa              0.8421  0.0283  0.0000  0.0480  0.0000
Mediana:upa      0.2113  0.0640  0.0010  0.1272  0.0965
Grande:upa       0.2394  0.1295  0.0647  0.1808  0.1855
deuda_activos    0.0961  0.0550  0.0811  0.0771  0.2127
deuda_activos^2 -0.2029  0.0588  0.0006  0.1063  0.0564
fig, ax = plt.subplots(figsize=(8, 3.8))
ypos = np.arange(len(nombres) - 1)
ax.barh(ypos - 0.18, m_B.bse.values[1:], height=0.34, color=GRIS, label="EE OLS (homocedástico)")
ax.barh(ypos + 0.18, m_B_hc3.bse.values[1:], height=0.34, color=AZUL, label="EE HC3 (robusto)")
ax.set_yticks(ypos); ax.set_yticklabels(nombres[1:]); ax.invert_yaxis()
ax.set_xlabel("Error estándar del coeficiente")
ax.set_title("Errores estándar: OLS vs HC3")
ax.legend(fontsize=8, loc="lower right")
plt.tight_layout(); plt.show()

Errores estándar de OLS vs HC3 (modelo winsorizado)

Interpretación:

  • Los coeficientes son los mismos; sólo cambian los errores estándar.
  • El error estándar de Mediana × upa se duplica (0.065 → 0.130) y su p-value pasa de 0.001 a 0.096: con errores robustos, la diferencia en pendientes entre medianas y pequeñas sólo es significativa al 10%.
  • Grande × upa pasa de p = 0.065 a p = 0.185: no es significativa.
  • Los términos de deuda también pierden significancia: el cuadrático pasa de p = 0.001 a p = 0.057 y el lineal de 0.082 a 0.214.
  • upa y la dummy Grande siguen siendo muy significativas.

Es decir, parte de la significancia que veníamos reportando se debía a suponer varianza constante cuando no la hay. ¿Por qué crece tanto el error de las interacciones? Porque la pendiente de upa de las empresas pequeñas se estima con observaciones muy ruidosas (residuales con el doble de dispersión), y OLS lo ignoraba.

10.2 Regresión robusta (estimadores M)

10.2.1 ¿Qué es y para qué sirve?

OLS minimiza \sum e_i^2. Al elevar al cuadrado, un residual de 1.0 pesa 100 veces más que uno de 0.1: unos cuantos outliers pueden mover toda la recta. La regresión robusta minimiza una función \sum \rho(e_i/s) que crece más lento que el cuadrado para residuales grandes (s es una medida robusta de la escala de los residuales).

En la práctica, esto equivale a una regresión de mínimos cuadrados ponderados donde cada observación recibe un peso w_i según el tamaño de su residual estandarizado u_i = e_i/s:

  • Huber: w_i = 1 si |u_i| \le 1.345; si no, w_i = 1.345/|u_i|. Los outliers pesan menos, pero nunca cero.
  • Tukey (bicuadrada): w_i = \left[1 - (u_i/4.685)^2\right]^2 si |u_i| \le 4.685; si no, w_i = 0. Los outliers extremos se ignoran por completo.

(Las constantes 1.345 y 4.685 hacen que el estimador sea 95% tan eficiente como OLS cuando los errores sí son normales.)

u = np.linspace(-7, 7, 400)
w_hub = np.where(np.abs(u) <= 1.345, 1, 1.345 / np.abs(u))
w_tuk = np.where(np.abs(u) <= 4.685, (1 - (u / 4.685)**2)**2, 0)
fig, ax = plt.subplots(figsize=(8, 3.6))
ax.plot(u, np.ones_like(u), color=GRIS, lw=2.5, label="OLS (todas pesan 1)")
ax.plot(u, w_hub, color=NARANJA, lw=2.5, label="Huber")
ax.plot(u, w_tuk, color=AZUL, lw=2.5, label="Tukey (bicuadrada)")
ax.set_xlabel("Residual estandarizado u = e / s"); ax.set_ylabel("Peso w")
ax.set_title("Regresión robusta: los residuales grandes pesan menos")
ax.legend(fontsize=8)
plt.tight_layout(); plt.show()

Peso que recibe una observación según su residual estandarizado

10.2.2 El algoritmo: mínimos cuadrados ponderados iterativos (IRLS)

Como los pesos dependen de los residuales y los residuales dependen de los coeficientes, se estima de forma iterativa:

  1. Empezar con los coeficientes de OLS: b^{(0)} = (X'X)^{-1}X'y
  2. Calcular residuales e = y - Xb y su escala robusta s = \text{mediana}(|e|)/0.6745 (MAD)
  3. Calcular los pesos w_i con la función de Tukey y formar W = \text{diag}(w_1, \dots, w_n)
  4. Re-estimar con mínimos cuadrados ponderados: b^{(t+1)} = (X'WX)^{-1}X'Wy
  5. Repetir 2-4 hasta que los coeficientes ya no cambien

Lo implementamos con álgebra matricial sobre los datos winsorizados:

yw = m_B.model.endog
b_rob = XtXi_w @ Xw.T @ yw          # paso 1: arrancamos con OLS
c_tukey = 4.685
for it in range(200):
    e_rob = yw - Xw @ b_rob                              # paso 2
    s_rob = np.median(np.abs(e_rob)) / 0.6745
    u_rob = e_rob / s_rob
    w_rob = np.where(np.abs(u_rob) <= c_tukey,
                     (1 - (u_rob / c_tukey)**2)**2, 0)   # paso 3
    W = np.diag(w_rob)
    b_new = np.linalg.inv(Xw.T @ W @ Xw) @ Xw.T @ W @ yw # paso 4
    if np.max(np.abs(b_new - b_rob)) < 1e-8:             # paso 5
        b_rob = b_new
        break
    b_rob = b_new
print(f"Convergió en {it} iteraciones")
Convergió en 101 iteraciones
# Verificación con statsmodels
r_tukey = smf.rlm(formula, data=dB, M=sm.robust.norms.TukeyBiweight()).fit()
r_huber = smf.rlm(formula, data=dB, M=sm.robust.norms.HuberT()).fit()
print(pd.DataFrame({"OLS winsorizado": m_B.params.values,
                    "Tukey (manual)": b_rob,
                    "Tukey (statsmodels)": r_tukey.params.values},
                   index=nombres).round(4))
                 OLS winsorizado  Tukey (manual)  Tukey (statsmodels)
Intercepto               -0.0675          0.0109               0.0109
Mediana                   0.0377         -0.0039              -0.0039
Grande                    0.0957          0.0669               0.0669
upa                       0.8421          0.7410               0.7410
Mediana:upa               0.2113          0.4801               0.4800
Grande:upa                0.2394          0.1341               0.1340
deuda_activos             0.0961         -0.1409              -0.1409
deuda_activos^2          -0.2029          0.0858               0.0858

Nuestra implementación reproduce el resultado de statsmodels. El resumen completo del modelo robusto (Tukey) es:

print(r_tukey.summary())
                    Robust linear Model Regression Results                    
==============================================================================
Dep. Variable:                    roa   No. Observations:                 1069
Model:                            RLM   Df Residuals:                     1061
Method:                          IRLS   Df Model:                            7
Norm:                   TukeyBiweight                                         
Scale Est.:                       mad                                         
Cov Type:                          H1                                         
Date:                Mon, 05 Oct 2026                                         
Time:                        13:56:46                                         
No. Iterations:                    50                                         
============================================================================================
                               coef    std err          z      P>|z|      [0.025      0.975]
--------------------------------------------------------------------------------------------
Intercept                    0.0109      0.006      1.754      0.080      -0.001       0.023
C(tamano)[T.Mediana]        -0.0039      0.007     -0.573      0.567      -0.017       0.009
C(tamano)[T.Grande]          0.0669      0.007      9.067      0.000       0.052       0.081
upa                          0.7410      0.014     53.493      0.000       0.714       0.768
C(tamano)[T.Mediana]:upa     0.4800      0.031     15.286      0.000       0.418       0.542
C(tamano)[T.Grande]:upa      0.1340      0.063      2.112      0.035       0.010       0.258
deuda_activos               -0.1409      0.027     -5.225      0.000      -0.194      -0.088
I(deuda_activos ** 2)        0.0858      0.029      2.978      0.003       0.029       0.142
============================================================================================

If the model instance has been used for another fit with different fit parameters, then the fit options might not be the correct ones anymore .

10.2.3 ¿Qué empresas pesan menos?

dB["peso_tukey"] = np.asarray(r_tukey.weights)
print(f"Empresas con peso 0 (ignoradas): {(dB['peso_tukey'] == 0).sum()}")
Empresas con peso 0 (ignoradas): 70
print(f"Empresas con peso < 0.5:         {(dB['peso_tukey'] < 0.5).sum()}")
Empresas con peso < 0.5:         151
print(f"Peso promedio:                    {dB['peso_tukey'].mean():.3f}")
Peso promedio:                    0.811
print("\nEmpresas con peso < 0.5 por tamaño:")

Empresas con peso < 0.5 por tamaño:
print(dB.loc[dB["peso_tukey"] < 0.5, "tamano"].value_counts())
tamano
Pequeña    92
Mediana    37
Grande     22
Name: count, dtype: int64
print("\nAlgunas empresas con peso 0:")

Algunas empresas con peso 0:
print(dB.loc[dB["peso_tukey"] == 0, ["empresa", "tamano", "roa", "upa", "deuda_activos"]]
      .sort_values("roa").head(8).round(3).to_string())
    empresa   tamano    roa    upa  deuda_activos
110     AWH  Pequeña -1.238 -0.447          0.455
136    BFRG  Pequeña -1.238 -0.270          0.000
89     ASXC  Pequeña -1.238 -0.943          0.096
269    DARE  Pequeña -1.238 -0.973          0.066
572    LIPO  Pequeña -1.238 -0.802          0.038
587    LUCD  Pequeña -1.238 -0.770          0.559
496    IRWD  Mediana -1.238 -0.529          1.221
519      KA  Pequeña -1.238 -0.417          0.060
fig, ax = plt.subplots(figsize=(8, 4.5))
sc = ax.scatter(dB["upa"], dB["roa"], c=dB["peso_tukey"], cmap="Blues", vmin=-0.15, vmax=1,
                s=14, edgecolor="#bbbbbb", lw=0.3)
cb = plt.colorbar(sc, ax=ax); cb.set_label("Peso w (Tukey)")
ax.set_xlabel("upa (winsorizada)"); ax.set_ylabel("ROA (winsorizado)")
ax.set_title("Pesos de la regresión robusta (Tukey)")
plt.tight_layout(); plt.show()

Peso de cada empresa en la regresión robusta (Tukey). Las más claras pesan menos

70 empresas (6.5%) reciben peso cero y 154 pesan menos de 0.5; la mayoría son empresas pequeñas con pérdidas muy grandes. Es el mismo grupo que identificaron los diagnósticos de la Lección 9, pero ahora no tuvimos que elegir un umbral para eliminarlas: el algoritmo decide cuánto pesa cada una.

ImportanteLimitación: la regresión robusta no protege contra leverage

Los estimadores M (Huber, Tukey) asignan los pesos según el residual. Una observación con leverage muy alto “jala” la recta hacia ella, termina con un residual pequeño y conserva su peso completo. Por eso aplicamos la regresión robusta después de winsorizar: la winsorización controla los valores extremos en X (el leverage máximo bajó de 0.79 a 0.15) y la regresión robusta controla los outliers en Y. Existen estimadores que atacan ambos problemas a la vez (MM, LTS), pero están fuera del alcance de este curso.

10.3 Comparación de todas las versiones

def tabla_p(params, pvals):
    return (params.round(4).astype(str) + " (" + pvals.map(lambda v: f"{v:.3f}") + ")")

comp2 = pd.concat([
    tabla_p(m_orig.params, m_orig.pvalues),
    tabla_p(m_B_hc3.params, m_B_hc3.pvalues),
    tabla_p(r_huber.params, r_huber.pvalues),
    tabla_p(r_tukey.params, r_tukey.pvalues)], axis=1)
comp2.columns = ["OLS original", "OLS winsor. + HC3", "Huber (winsor.)", "Tukey (winsor.)"]
comp2.index = nombres
print("Coeficiente (p-value)")
Coeficiente (p-value)
print(comp2.to_string())
                    OLS original OLS winsor. + HC3  Huber (winsor.)  Tukey (winsor.)
Intercepto       -0.0684 (0.000)   -0.0675 (0.000)  -0.0113 (0.094)   0.0109 (0.080)
Mediana            0.047 (0.004)    0.0377 (0.001)   0.0084 (0.248)  -0.0039 (0.567)
Grande             0.118 (0.000)    0.0957 (0.000)   0.0743 (0.000)   0.0669 (0.000)
upa               0.8425 (0.000)    0.8421 (0.000)   0.8269 (0.000)    0.741 (0.000)
Mediana:upa       0.2485 (0.001)    0.2113 (0.097)   0.3248 (0.000)     0.48 (0.000)
Grande:upa        0.0821 (0.578)    0.2394 (0.185)   0.1016 (0.141)    0.134 (0.035)
deuda_activos     0.0207 (0.620)    0.0961 (0.213)  -0.0687 (0.019)  -0.1409 (0.000)
deuda_activos^2  -0.1148 (0.000)   -0.2029 (0.056)  -0.0083 (0.792)   0.0858 (0.003)
modelos2 = {"OLS original": (m_orig, GRIS),
            "OLS winsor. + HC3": (m_B_hc3, NARANJA),
            "Tukey (winsor.)": (r_tukey, AZUL)}
fig, ax = plt.subplots(figsize=(8, 4.5))
for jj, (nom, (mm, cc)) in enumerate(modelos2.items()):
    pr = np.asarray(mm.params)[1:]
    ci = np.asarray(mm.conf_int())[1:]
    ypos = np.arange(len(pr)) + (jj - 1) * 0.22
    ax.errorbar(pr, ypos, xerr=[pr - ci[:, 0], ci[:, 1] - pr],
                fmt="o", color=cc, ms=5, capsize=2, lw=1.2, label=nom)
ax.axvline(0, color="black", lw=0.8)
ax.set_yticks(np.arange(len(nombres) - 1)); ax.set_yticklabels(nombres[1:])
ax.invert_yaxis()
ax.set_xlabel("Coeficiente (IC 95%)")
ax.set_title("¿Qué conclusiones sobreviven?")
ax.legend(fontsize=8, loc="lower right")
plt.tight_layout(); plt.show()

Coeficientes e intervalos de confianza al 95%: OLS original, OLS winsorizado con errores HC3 y regresión robusta de Tukey
grid = np.linspace(0, 1.22, 200)
upa_med = dB["upa"].median()
fig, ax = plt.subplots(figsize=(8, 4.2))
for nom, (mm, cc) in {"OLS original": (m_orig, GRIS), "OLS winsorizado": (m_B, NARANJA),
                      "Huber (winsor.)": (r_huber, "#1baf7a"), "Tukey (winsor.)": (r_tukey, AZUL)}.items():
    bb = np.asarray(mm.params)
    roa_hat = bb[0] + bb[3] * upa_med + bb[6] * grid + bb[7] * grid**2   # empresa pequeña
    ax.plot(grid, roa_hat, color=cc, lw=2.5, label=nom)
ax.axvspan(0.8, 1.22, color="#dddddd", alpha=0.5, lw=0)
ax.set_xlabel("Deuda / Activos"); ax.set_ylabel("ROA esperado")
ax.set_title("Efecto de la deuda según el método")
ax.legend(fontsize=8)
plt.tight_layout(); plt.show()

ROA esperado vs deuda (empresa pequeña con upa mediana) según cada método. La zona sombreada contiene al 2.3% de las empresas con deuda/activos > 0.8

Lectura de la comparación:

  • upa es positiva y muy significativa en todas las versiones (entre 0.74 y 0.84).
  • Mediana × upa: OLS con errores HC3 sólo la detecta al 10% (p = 0.096). La regresión robusta, que no se deja llevar por las empresas pequeñas extremas, la detecta con mucha claridad y con una magnitud mayor (0.34 con Huber y 0.50 con Tukey).
  • Grande × upa sólo es significativa al 5% con Tukey (p = 0.039).
  • Deuda: la forma de la relación no es estable. OLS (original y winsorizado) estima una parábola cóncava (término cuadrático negativo) con un máximo. La regresión de Huber estima una relación prácticamente lineal y negativa, y la de Tukey estima un término cuadrático positivo y significativo (parábola convexa, con un mínimo en DA ≈ 0.8). Este último resultado contradice lo que espera la teoría financiera y, además, en la zona donde la curva vuelve a subir (DA > 0.8) sólo está el 2.3% de las empresas.

11 Modelo final e interpretación

11.1 ¿Cuál es el modelo más adecuado?

Elegimos como modelo final el modelo OLS winsorizado (corrección B) con errores estándar robustos HC3. Las razones son:

  1. Consistencia con la teoría. La teoría financiera (teoría del trade-off de la estructura de capital) predice un efecto cuadrático negativo del apalancamiento: niveles moderados de deuda pueden ayudar a la empresa (financiar proyectos productivos, disciplina en el uso de recursos, beneficio fiscal), pero a partir de cierto punto los costos de la deuda (intereses, riesgo de quiebra, restricciones para invertir) reducen la productividad. El modelo winsorizado estima justamente una parábola cóncava. En cambio, la regresión robusta de Tukey estima un término cuadrático positivo y significativo, que implicaría que, a partir de cierto nivel, más deuda aumenta la productividad; esto no tiene sustento teórico y además se basa en muy pocas empresas.
  2. Representa a la empresa promedio. OLS estima el valor esperado (promedio) del ROA dadas las variables X, usando a todas las empresas. La regresión robusta, en cambio, describe a la empresa típica que sigue el patrón general, porque ignora (peso cero) a 70 empresas y le da poco peso a otras 84. La winsorización conserva a todas las empresas y sólo limita el efecto de los valores más extremos.
  3. Atiende los problemas del diagnóstico. La winsorización controla el leverage (el leverage máximo bajó de 0.79 a 0.15) y reduce la influencia de los outliers; los errores HC3 corrigen la inferencia ante la heterocedasticidad que confirmó la prueba de Breusch-Pagan.

Con este modelo, los coeficientes son los de OLS winsorizado y toda la inferencia (errores estándar, p-values, intervalos y pruebas F) se hace con HC3.

print(m_B_hc3.summary())
                            OLS Regression Results                            
==============================================================================
Dep. Variable:                    roa   R-squared:                       0.655
Model:                            OLS   Adj. R-squared:                  0.653
Method:                 Least Squares   F-statistic:                     120.6
Date:                Mon, 05 Oct 2026   Prob (F-statistic):          3.85e-130
Time:                        13:56:47   Log-Likelihood:                 407.15
No. Observations:                1069   AIC:                            -798.3
Df Residuals:                    1061   BIC:                            -758.5
Df Model:                           7                                         
Covariance Type:                  HC3                                         
============================================================================================
                               coef    std err          z      P>|z|      [0.025      0.975]
--------------------------------------------------------------------------------------------
Intercept                   -0.0675      0.015     -4.622      0.000      -0.096      -0.039
C(tamano)[T.Mediana]         0.0377      0.012      3.195      0.001       0.015       0.061
C(tamano)[T.Grande]          0.0957      0.016      6.090      0.000       0.065       0.127
upa                          0.8421      0.048     17.529      0.000       0.748       0.936
C(tamano)[T.Mediana]:upa     0.2113      0.127      1.662      0.097      -0.038       0.461
C(tamano)[T.Grande]:upa      0.2394      0.181      1.324      0.185      -0.115       0.594
deuda_activos                0.0961      0.077      1.246      0.213      -0.055       0.247
I(deuda_activos ** 2)       -0.2029      0.106     -1.908      0.056      -0.411       0.006
==============================================================================
Omnibus:                      374.769   Durbin-Watson:                   1.909
Prob(Omnibus):                  0.000   Jarque-Bera (JB):             2498.239
Skew:                          -1.448   Prob(JB):                         0.00
Kurtosis:                       9.906   Cond. No.                         29.5
==============================================================================

Notes:
[1] Standard Errors are heteroscedasticity robust (HC3)

11.2 Ecuaciones por grupo de tamaño

bf = m_B_hc3.params
ecs_f = {"Pequeña": (bf["Intercept"], bf["upa"]),
         "Mediana": (bf["Intercept"] + bf["C(tamano)[T.Mediana]"],
                     bf["upa"] + bf["C(tamano)[T.Mediana]:upa"]),
         "Grande":  (bf["Intercept"] + bf["C(tamano)[T.Grande]"],
                     bf["upa"] + bf["C(tamano)[T.Grande]:upa"])}
b6f, b7f = bf["deuda_activos"], bf["I(deuda_activos ** 2)"]
for g, (b0g, b1g) in ecs_f.items():
    print(f"E[ROA | {g:8s}] = {b0g:7.4f} + {b1g:.4f} upa {b6f:+.4f} DA {b7f:+.4f} DA^2")
E[ROA | Pequeña ] = -0.0675 + 0.8421 upa +0.0961 DA -0.2029 DA^2
E[ROA | Mediana ] = -0.0298 + 1.0535 upa +0.0961 DA -0.2029 DA^2
E[ROA | Grande  ] =  0.0282 + 1.0815 upa +0.0961 DA -0.2029 DA^2

E[ROA \mid \text{Pequeña}] = -0.0674 + 0.8418\,upa + 0.0958\,DA - 0.2024\,DA^2 E[ROA \mid \text{Mediana}] = -0.0298 + 1.0591\,upa + 0.0958\,DA - 0.2024\,DA^2 E[ROA \mid \text{Grande}] = 0.0282 + 1.0814\,upa + 0.0958\,DA - 0.2024\,DA^2

11.3 Pruebas de hipótesis conjuntas (con HC3)

Como la interpretación de las interacciones y del efecto cuadrático depende de varios coeficientes a la vez, hacemos pruebas F conjuntas usando la matriz de varianza HC3:

print("1) ¿El efecto de upa depende del tamaño?  H0: b4 = b5 = 0")
1) ¿El efecto de upa depende del tamaño?  H0: b4 = b5 = 0
print(m_B_hc3.f_test("C(tamano)[T.Mediana]:upa = 0, C(tamano)[T.Grande]:upa = 0"))
<F test: F=2.10240733318669, p=0.12267063049621704, df_denom=1.06e+03, df_num=2>
print("\n2) ¿La pendiente de upa es igual en medianas y grandes?  H0: b4 = b5")

2) ¿La pendiente de upa es igual en medianas y grandes?  H0: b4 = b5
print(m_B_hc3.f_test("C(tamano)[T.Mediana]:upa = C(tamano)[T.Grande]:upa"))
<F test: F=0.017401169124772253, p=0.8950777276709787, df_denom=1.06e+03, df_num=1>
print("\n3) ¿La deuda tiene algún efecto sobre el ROA?  H0: b6 = b7 = 0")

3) ¿La deuda tiene algún efecto sobre el ROA?  H0: b6 = b7 = 0
print(m_B_hc3.f_test("deuda_activos = 0, I(deuda_activos ** 2) = 0"))
<F test: F=2.867534281862755, p=0.057279522417288876, df_denom=1.06e+03, df_num=2>
da_med = dB["deuda_activos"].median()
xg = np.linspace(-0.6, 0.27, 100)
fig, ax = plt.subplots(figsize=(8, 4.5))
for g in ["Pequeña", "Mediana", "Grande"]:
    s_ = dB[dB["tamano"] == g]
    ax.scatter(s_["upa"], s_["roa"], s=10, alpha=0.25, color=COL[g], marker=MRK[g])
    b0g, b1g = ecs_f[g]
    ax.plot(xg, b0g + b1g * xg + b6f * da_med + b7f * da_med**2,
            color=COL[g], lw=2.5, label=f"{g}: pendiente = {b1g:.3f}")
ax.set_xlim(-0.6, 0.27); ax.set_ylim(-0.8, 0.4)
ax.axhline(0, color="grey", lw=0.6); ax.axvline(0, color="grey", lw=0.6)
ax.set_xlabel("upa (winsorizada)"); ax.set_ylabel("ROA (winsorizado)")
ax.set_title("Modelo final: efecto de upa por tamaño")
ax.legend(fontsize=8, loc="upper left")
plt.tight_layout(); plt.show()

Modelo final: ROA esperado vs upa por tamaño, con deuda/activos en su mediana

11.4 Efecto de la deuda en el modelo final

da_opt_f = -b6f / (2 * b7f)
print(f"Efecto marginal: dROA/dDA = {b6f:.4f} + 2({b7f:.4f}) DA = {b6f:.4f} {2*b7f:+.4f} DA")
Efecto marginal: dROA/dDA = 0.0961 + 2(-0.2029) DA = 0.0961 -0.4058 DA
print(f"ROA esperado máximo en DA = {da_opt_f:.3f}  (mediana de DA en la muestra = {da_med:.3f})\n")
ROA esperado máximo en DA = 0.237  (mediana de DA en la muestra = 0.231)
for da_ in [0.0, 0.1, 0.237, 0.4, 0.6, 0.8, 1.0]:
    em = b6f + 2 * b7f * da_
    print(f"  DA = {da_:5.3f}: +10pp de deuda => {em*0.10*100:6.2f} pp de ROA")
  DA = 0.000: +10pp de deuda =>   0.96 pp de ROA
  DA = 0.100: +10pp de deuda =>   0.55 pp de ROA
  DA = 0.237: +10pp de deuda =>  -0.00 pp de ROA
  DA = 0.400: +10pp de deuda =>  -0.66 pp de ROA
  DA = 0.600: +10pp de deuda =>  -1.47 pp de ROA
  DA = 0.800: +10pp de deuda =>  -2.29 pp de ROA
  DA = 1.000: +10pp de deuda =>  -3.10 pp de ROA
grid = np.linspace(0, 1.22, 200)
nd = pd.DataFrame({"tamano": pd.Categorical(["Pequeña"] * len(grid),
                                             categories=dB["tamano"].cat.categories),
                   "upa": dB["upa"].median(), "deuda_activos": grid})
pred = m_B_hc3.get_prediction(nd).summary_frame(alpha=0.05)
fig, ax = plt.subplots(figsize=(8, 4.2))
ax.fill_between(grid, pred["mean_ci_lower"], pred["mean_ci_upper"], color=AZUL, alpha=0.2,
                lw=0, label="IC 95% (HC3)")
ax.plot(grid, pred["mean"], color=AZUL, lw=2.5, label="ROA esperado")
ax.axvline(da_opt_f, color="black", ls="--", lw=1)
ax.text(da_opt_f + 0.02, pred["mean"].max() + 0.01, f"Máximo en DA = {da_opt_f:.2f}", fontsize=9)
ax.set_xlabel("Deuda / Activos"); ax.set_ylabel("ROA esperado")
ax.set_title("Efecto cuadrático negativo de la deuda")
ax.legend(fontsize=8, loc="lower left")
plt.tight_layout(); plt.show()

Modelo final: ROA esperado vs deuda/activos (empresa pequeña con upa mediana) con intervalo de confianza al 95% (HC3)

11.5 Interpretación final

El modelo explica el 65.5% de la variabilidad del ROA (R² ajustada = 0.653).

  • upa (b_3 = 0.842, p < 0.001): para una empresa pequeña, un aumento de 0.10 en upa se asocia con +8.4 pp de ROA en promedio, manteniendo constante la deuda. Es el resultado más sólido del análisis: es positivo y muy significativo en todas las versiones del modelo, también con errores HC3.
  • Tamaño (diferencias cuando upa = 0, con la misma deuda): las empresas grandes tienen un ROA esperado 9.6 pp mayor que las pequeñas (b_2 = 0.096, p < 0.001) y las medianas 3.8 pp mayor (b_1 = 0.038, p = 0.002).
  • Interacción tamaño × upa: las pendientes estimadas son mayores en medianas (1.059) y grandes (1.081) que en pequeñas (0.842). Sin embargo, con errores HC3 la diferencia sólo es marginalmente significativa para las medianas (b_4 = 0.217, p = 0.096), no es significativa para las grandes (b_5 = 0.240, p = 0.185), y la prueba conjunta de ambas interacciones no rechaza H_0 (p = 0.12). Además, las pendientes de medianas y grandes son prácticamente iguales (p = 0.92). Conclusión: hay indicios de que la upa tiene un efecto mayor en las empresas medianas y grandes, pero la evidencia no es concluyente una vez que corregimos por heterocedasticidad.
  • Deuda: el término lineal es positivo (b_6 = 0.096) y el cuadrático negativo (b_7 = -0.202): la relación tiene forma de U invertida, consistente con la teoría. El ROA esperado es máximo cuando la deuda representa alrededor del 24% de los activos, prácticamente la mediana de la muestra (23%). Por debajo de ese nivel, más deuda se asocia con un ROA ligeramente mayor; por encima, más deuda se asocia con un ROA cada vez menor: para una empresa con 60% de deuda, 10 pp más de deuda se asocian con 1.5 pp menos de ROA, y con 100% de deuda, con 3.1 pp menos. En cuanto a la significancia, con errores HC3 el término cuadrático es significativo al 10% (p = 0.057) y la prueba conjunta de ambos términos de deuda también (p = 0.058). Es decir, la evidencia apoya la forma cóncava, pero con un nivel de confianza del 90%, no del 95%.

11.6 Comparación con el Challenge 4 del Workshop 1

Conclusión W1 - Challenge 4 (OLS original) Modelo final (winsorizado + HC3) ¿Se mantiene?
Efecto de upa Positivo, 0.84 (p < 0.001) Positivo, 0.84 (p < 0.001) Sí
Grandes vs pequeñas ROA mayor (p < 0.001) ROA mayor (p < 0.001) Sí
Medianas vs pequeñas ROA mayor (p = 0.004) ROA mayor (p = 0.002) Sí
Mediana × upa Positiva, significativa (p = 0.001) Positiva, sólo al 10% (p = 0.096) Parcialmente
Grande × upa No significativa No significativa Sí
Deuda Cóncava, máximo en DA ≈ 0.09, significativa al 1% Cóncava, máximo en DA ≈ 0.24, significativa al 10% Sí la forma; menor certeza
R² 0.594 0.655

Conclusiones:

  1. Las conclusiones sobre upa y sobre las diferencias de ROA por tamaño son robustas: no dependen de las observaciones extremas ni del supuesto de varianza constante.
  2. La interacción entre tamaño y upa, que en el Workshop 1 parecía clara, se debilita: su significancia se apoyaba en suponer varianza constante, cuando en realidad las empresas pequeñas tienen residuales mucho más dispersos.
  3. El efecto cuadrático negativo de la deuda se mantiene, pero el punto óptimo se mueve de 9% a 24% de deuda sobre activos al controlar los valores extremos, y la evidencia es más débil (significativa al 10%).
  4. Los diagnósticos de la Lección 9, la winsorización, los errores robustos y la regresión robusta se complementan: los diagnósticos se calculan primero sobre OLS para entender los datos; los errores robustos se deben reportar siempre que haya heterocedasticidad; y la regresión robusta sirve como prueba de robustez. Al elegir el modelo final no basta con el ajuste estadístico: también importa que los resultados sean consistentes con la teoría y que el modelo responda a la pregunta de interés (en este caso, el efecto en la empresa promedio).