Abrir en Google Colab Abrir en Binder Descargar notebook

Population Stability Index (PSI) - (desviación covariable y de concepto)

Introducción

El índice de estabilidad de la población (PSI) es una métrica para medir cuánto ha cambiado la distribución de un predictor entre dos muestras distintas. Por lo general, PSI se usa para medir la estabilidad de los modelos o las cualidades de sus predictores. Es una métrica que encuentra sus origenes en los modelos de predicciones de riesgo crediticio.

Dadas dos conjuntos de datos: origen y objetivo, el PSI se calculará mediante los siguientes pasos:

  • Se realiza un agrupamiento de los cuantiles de los predictores del conjunto original y objetivo.

  • Se calcular el porcentaje de cada intervalo (Q), que viene dado por

    \[Q = \frac{recuento\;de\;muestras\;en\;intervalo}{número\;total\;de\;muestras}\]
  • Finalmente podemos calcular PSI como:

\[\sum (Q_t - Q_s)*ln(\frac{Q_t}{Q_s})\]

Este índice puede interpretarse de la siguiente manera:

  • PSI < 0.1: No existe un cambio significativo en las características de las muestras.

  • PSI > 0.1 y PSI < 0.2: Hay un cambio moderado en las características de las muestras.

  • PSI > 0.2: Existe un cambio significativo en las características de las muestras.

Ejemplo:

Para visualizar el concepto de desviación, usaremos los datos de IRIS dataset para generar lotes con distribuciones distintas de los datos. Posteriormente veremos como la performance del modelo se degrada y como podríamos detectar este hecho utilizando la métrica PSI. El conjunto de datos de IRIS es parte de la biblioteca sklearn que constan de 3 tipos diferentes de longitud de pétalo y sépalo (Setosa, Versicolour y Virginica), descriptos por la longitud del sépalo, el ancho del sépalo, la longitud del pétalo y el ancho del pétalo:

[1]:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

from sklearn import datasets

iris = datasets.load_iris()
X = iris.data[:,:2]
y = iris.target

Como podemos observar, el conjunto de datos está balanceado, teniendo 50 observaciones para cada uno de los tipos de pétalos disponibles.

[2]:
from collections import Counter

def plot_distribution(y):
    labels, q = zip(*sorted(Counter(y).items()))
    plt.bar(labels, q)
    plt.show()

plot_distribution(y)
../../../../_images/develop_ops_monitoring_code_psi_csi_6_0.svg

Entrenaremos un modelo para resolver el problema. Primero, dividiremos los datos en conjuntos de entrenamiento y validación, como es costrumbre, para luego definir nuestro algoritmo de aprendizaje. En este caso, utilizaremos un simple SVM y lo entrenamos sobre los datos:

[3]:
from sklearn import svm
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, random_state=42, stratify=y)

model = svm.SVC(C=1.0, kernel='linear', gamma=0.5, probability=True)
model = model.fit(X_train, y_train)

Verificamos la performance de nuestro modelo de clasificación:

[4]:
from sklearn.metrics import classification_report
from sklearn.metrics import f1_score

y_pred = model.predict(X_test)
print(classification_report(y_test, y_pred))
print("F1:",f1_score(y_test, y_pred, average='weighted'))
              precision    recall  f1-score   support

           0       1.00      1.00      1.00        16
           1       0.62      0.76      0.68        17
           2       0.69      0.53      0.60        17

    accuracy                           0.76        50
   macro avg       0.77      0.76      0.76        50
weighted avg       0.77      0.76      0.76        50

F1: 0.7566315789473684

Simulando un cambio en la distribución de las clases

La siguiente función nos permitirá alterar la distribución de las observaciones presentes en el set de datos, es decir, generará un nuevo conjunto de datos cuyas proporciones de las observaciones estarán alteradas por el parámetro weights que las especifica. Este parametro es un arreglo donde el primer valor corresponde a la proporción de la clase 1 (Setosa), el segundo a la 2 (Versicolour) y el tercero a la 3 (Virginica).

[5]:
def simulate_samples(nsamples, X_source, y_source, weights):
    totals = np.round(np.array(weights) * nsamples).astype(int)
    indices = np.arange(y_source.size)
    new_indices = []
    for i, c in enumerate(np.unique(y_source)):
        new_indices.extend(np.random.choice(indices[y_source==c], totals[i], replace=True))

    y_new = y_source[new_indices]
    X_new = X_source[new_indices,:]
    return(X_new, y_new)

Generemos un nuevo conjunto de datos con las proporciones 10%, 10% y 80%:

[6]:
n = 50
weights = np.array([0.10, 0.10, 0.80]) # Nuevas distribuciones
X_new, y_new = simulate_samples(n, X_test, y_test, weights)

plot_distribution(y_new)
../../../../_images/develop_ops_monitoring_code_psi_csi_14_0.svg

Efecto

Veamos cual es el efecto en la performance del modelo al cambiar esta distribución:

[7]:
y_new_pred = model.predict(X_new)
print(classification_report(y_new, y_new_pred))
print("F1:",f1_score(y_new, y_new_pred, average='weighted'))

              precision    recall  f1-score   support

           0       1.00      1.00      1.00         5
           1       0.21      1.00      0.34         5
           2       1.00      0.53      0.69        40

    accuracy                           0.62        50
   macro avg       0.74      0.84      0.68        50
weighted avg       0.92      0.62      0.69        50

F1: 0.6853024307518374

Podemos ver que ahora la performance del modelo a decaido. Recuerdemos que la puntuación F1 original era ~0,76. lo que significa que el rendimiento de nuestro modelo se ha deteriorado como consecuncia del cambio de la distribución. Incluso, no necesariamente la performance del modelo pudo haber cambiado.

Instalamos una libreria que tenga la métrica de PSI implementada:

[ ]:
!git clone https://github.com/mwburke/population-stability-index
!mv population-stability-index psi
[8]:
from psi.psi import calculate_psi

psi_train_test = calculate_psi(X_train.flatten(), X_test.flatten(), buckettype='quantiles', buckets=10, axis=1)
psi_train_new = calculate_psi(X_train.flatten(), X_new.flatten(), buckettype='quantiles', buckets=10, axis=1)

print('PSI entre train y test:', np.round(psi_train_test, 2))
print('PSI entre train y el nuevo set de datos:', np.round(psi_train_new, 2))

Distancia entre train y test: 0.1
Distancia entre train y el nuevo set de datos: 0.32