Prosty sposób na wybór odpowiedniego punktu odcięcia – praktyka

punkt odcięcia cut off, klasyfikacja

Wybór odpowiedniego punktu odcięcia wcale nie musi być trudny. Poniżej na przykładzie z sektora finansowego pokazuję jak to zrobić w kilku prostych krokach.

1. Import bibliotek.

In [1]:
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
from sklearn.metrics import roc_auc_score, classification_report, confusion_matrix, accuracy_score, recall_score, precision_score, roc_curve
from statsmodels.discrete.discrete_model import Logit

2. Wczytanie zbioru danych.

In [2]:
x_tr = pd.read_csv('data/x_tr.csv', index_col = 0)
x_va = pd.read_csv('data/x_va.csv', index_col = 0)
x_te = pd.read_csv('data/x_te.csv', index_col = 0)
y_tr = pd.read_csv('data/y_tr.csv', header = None, index_col = 0)
y_va = pd.read_csv('data/y_va.csv', header = None, index_col = 0)
y_te = pd.read_csv('data/y_te.csv', header = None, index_col = 0)

Sprawdzam rozkład zmiennej celu.

In [3]:
y_tr[1].value_counts(normalize = True)
Out[3]:
0    0.765041
1    0.234959
Name: 1, dtype: float64
In [4]:
y_te[1].value_counts(normalize = True)
Out[4]:
0    0.764957
1    0.235043
Name: 1, dtype: float64

76.5% do 23.5% - ewidentnie możemy tu mówić o zbiorze niezbalansowanym. Bardzo dobry zbiór, by zobrazować, jak istotny jest wybór odpowiedniego punktu odcięcia.

3. Budowa modelu.

In [5]:
model_rl = Logit(y_tr, x_tr).fit()
prawd_rl = model_rl.predict(x_te)
Optimization terminated successfully.
         Current function value: 0.479973
         Iterations 7

Nie będę się silić na poprawę wyniku, czy selekcję zmiennych. Nie o to chodzi w tym wpisie. 😉

4. Walidacja modelu.

In [6]:
# Dla przypomnienia.
pd.DataFrame([['TN', 'FP'], ['FN', 'TP']], 
             columns = ['predicted negatives', 'predicted positives'], 
             index = ['actual negatives', 'actual positives'])
Out[6]:
predicted negatives predicted positives
actual negatives TN FP
actual positives FN TP

Buduję roboczą funkcję do podsumowania osiągniętych wyników w zależności od wybranego cut-offu.

In [7]:
def podsumowanie_modelu(punkt_odciecia):
    tn, fp, fn, tp = confusion_matrix(y_te, prawd_rl>punkt_odciecia).ravel()
    specyficznosc = tn / (tn+fp)
    czulosc = tp / (tp + fn)
    print('Raport klasyfikacji:\n{}'.format(classification_report(y_te, prawd_rl>punkt_odciecia)))
    macierz_pomylek = pd.DataFrame(confusion_matrix(y_te, prawd_rl>punkt_odciecia), 
             columns = ['predicted negatives', 'predicted positives'], 
             index = ['actual negatives', 'actual positives'])
    print('\nMacierz pomyłek:\n{}'.format(macierz_pomylek))
    return czulosc, specyficznosc
4.1. Punkt odcięcia równy 0.5.
In [8]:
czulosc_1, specyficznosc_1 = podsumowanie_modelu(0.5)
Raport klasyfikacji:
              precision    recall  f1-score   support

           0       0.81      0.97      0.88      3938
           1       0.75      0.26      0.38      1210

    accuracy                           0.80      5148
   macro avg       0.78      0.61      0.63      5148
weighted avg       0.79      0.80      0.77      5148


Macierz pomyłek:
                  predicted negatives  predicted positives
actual negatives                 3832                  106
actual positives                  900                  310

W skrócie to na co warto zwrócić uwagę, to dysproporcja w błędach I i II rodzaju (900 vs 106). Wynika ona z działania części algorytmów uczenia maszynowego. Out of the box nie są one dostosowane do prób niezbalansowanych.

4.2. Zmiana punktu odcięcia.

Na potrzeby tego ćwiczenia zakładam, że idealny punkt odcięcia leży maksymalnie blisko lewej górnej części wykresu krzywej ROC - tam, gdzie czułość jest największa, przy możliwie największej specyficzności (pisałem o tym w poprzednim wpisie). Będzie to równe założeniu, że oba rodzaje błędu (FP i FN ważą tyle samo, co nie musi być zawsze prawdą).

In [9]:
fpr, tpr, threshold = roc_curve(y_te, prawd_rl)
m = np.argmax(tpr - fpr)
nowy_punkt_odciecia = threshold[m]
In [10]:
czulosc_2, specyficznosc_2 = podsumowanie_modelu(nowy_punkt_odciecia)
Raport klasyfikacji:
              precision    recall  f1-score   support

           0       0.86      0.85      0.86      3938
           1       0.53      0.56      0.55      1210

    accuracy                           0.78      5148
   macro avg       0.70      0.71      0.70      5148
weighted avg       0.79      0.78      0.78      5148


Macierz pomyłek:
                  predicted negatives  predicted positives
actual negatives                 3346                  592
actual positives                  530                  680

5. Porównanie obu podejść.

5.1. Kluczowe statystyki.

Porównanie najważniejszych statystyk dla obu rozważanych punktów odcięcia.

In [11]:
pd.DataFrame({'Punkt odcięcia nr 1':[czulosc_1, specyficznosc_1], 
              'Punkt odcięcia nr 2':[czulosc_2, specyficznosc_2]}, 
             index = ['Czułość', 'Specyficzność']).T.round(2)
Out[11]:
Czułość Specyficzność
Punkt odcięcia nr 1 0.26 0.97
Punkt odcięcia nr 2 0.56 0.85
5.2. Krzywa ROC.

Znajdę teraz miejsce w tablicy threshold, w którym leży punkt 0.5. Dzięki temu naniosę go na wykres.

In [12]:
# poszukuje pozycji dla punktu odcięcia = 0.5
n = np.argmin(np.abs(threshold-0.5))
print('Punkt odcięcia równy ok. 0.5, leży na {} miejscu w tablicy threshold.'.format(n))
Punkt odcięcia równy ok. 0.5, leży na 161 miejscu w tablicy threshold.

Narysuję teraz krzywą ROC dla rozpatrywanego modelu z naniesionymi cut off-ami.

In [13]:
def plot_roc_curve(fpr, tpr, title):
    plt.figure(figsize = (9, 7))
    plt.plot(fpr, tpr, color = '#e64845', label = 'ROC')
    plt.plot(fpr[m], tpr[m], 'o', color = '#2a2a2a', markersize = 12) # cutoff = 0.28 - koło
    plt.plot(fpr[n], tpr[n], 's', color = '#2a2a2a', markersize = 12) # cutoff = 0.5 - kwadrat
    plt.plot([0, 1], [0, 1], color = '#2a2a2a', linestyle = '--')
    plt.xlabel('1 - Specyficzność')
    plt.ylabel('Czułość')
    plt.title(title)
    plt.legend()
    plt.show()

Na poniższym wykresie kółko to optymalny punkt odcięcia (przy założeniu, że oba rodzaje błędów kosztują nas tyle samo), a kwadrat to standardowy cut-off równy 0.5.

In [14]:
plot_roc_curve(fpr, tpr, 'Krzywa ROC')

Myślę, że na powyższych wykresie doskonale widać uzysk wynikający z tej krótiej analizy. 🙂

5.3. Wykres separacji gęstości dla zbudowanego modelu.

Chciałbym Ci pokazać jeszcze jedną rzecz. Istnieje jeszcze jeden wykres, na którym znakomicie widać sens konieczności zmiany pierwotnego punktu odcięcia. 🙂

Buduję maski dla zbioru testowego, dla dobrych złych kredytobiorców.

In [15]:
prawdziwe_0 = y_te == 0
prawdziwe_1 = y_te == 1

Dla przypomnienia sprawdzam, ile wynosi nowy, "optymalny" cut-off.

In [16]:
nowy_punkt_odciecia
Out[16]:
0.2806002365153232
In [17]:
plt.figure(figsize = (12, 7))
#sns.set_palette()
ax_1 = sns.kdeplot(prawd_rl.loc[prawdziwe_0.values], shade = True, color = '#eb6c6a')
ax_2 = sns.kdeplot(prawd_rl.loc[prawdziwe_1.values], shade = True, color = '#6c6aeb')
ax_2.text(nowy_punkt_odciecia + 0.02, 3, 'nowy punkt odcięcia = {}'.format(nowy_punkt_odciecia.round(3)), )
ax_2.axvline(nowy_punkt_odciecia, linestyle = '--', color = 'grey', linewidth = 1.4)
ax_2.text(0.5 + 0.02, 2, 'pierwotny punkt odcięcia = {}'.format(0.5), )
ax_2.axvline(0.5, linestyle = '--', color = 'grey', linewidth = 1.4)
plt.legend(['nowy punkt odcięcia', 'pierwoty punkt odcięcia', 'prawdopodobieństwa prawdziwych 0', 'prawdopodobieństwa prawdziwych 1'])
plt.title('Wykres separacji gęstości klas')
plt.show()

Wykresy gęstości czerwony i niebieski (wybacz moją igonrancję, ale będąc mężczyzną, nie potrafię lepiej nazwać tych kolorów ;)) obrazują rozkład predykcji prawdopodobieństwa, jakie nadał model dla kolejno: prawdziwych 0 i prawdziwych 1. Poniżej kilka wniosków.

  1. Zbudowany model jest modelem o średniej jakości - najwięcej prawdziwych 1 znajduje się w okolicy największej liczby prawdziwych 0 (prawdopodobieństwo ok 0.15 - 0.25). Dobry model powinien nieco lepiej separować obie klasy.
  2. Punkt 0.5 sprawiałby, że model popełniałby niewiele błędów I rodzaju (FP), za to kosztem całej masy błędów II rodzaju (FN). W przypadku banku oznaczałoby to, że nie tracimy wiele na złych predykcjach, za to odrzucamy wiele klientów, na których moglibyśmy zarobić.
  3. Nowy punkt odcięcia (0.281) sprawia, że równoważymy błędy, które popełnia model.

6. Podsumowanie

W kolejnych wpisach będę eksplorować temat wyboru punktu odcięcia i rozważę scenariusz, w którym wiemy, jakie koszty ponosi biznes z tytułu popełnianych błędów: FP i FN, oraz ile bines jest w stanie zarobić na dobrych predykcjach.

Jeśli masz jakieś pytania, to proszę, podziel się nimi w komentarzu pod wpisem - zapraszam do dyskusji. Jeśli artykuł przypadł Ci do gustu, to proszę, podziel się nim w mediach społecznościowych ze swoimi znajomymi. Będę bardzo wdzięczny. 🙂

photo: pixabay.com (Couleur)

Podobał Ci się ten artykuł?

Jeśli tak, to zarejestruj się, by otrzymywać informacje o nowych wpisach. Dodatkowo w prezencie wyślę Ci bezpłatny poradnik 🙂

4 Komentarze

  1. Cześć,

    ponownie dzięki za super wpis. 🙂 Bardzo fajnie opisujesz koncepcje doboru odpowiedniego cut-off; natomiast przekopałem pół Internetu i nie mogę znaleźć satysfakcjonującej odpowiedzi na dość trywialne pytanie: w którym momencie powinniśmy tego punktu szukać?

    Klasyczny workflow wygląda zwykle następująco:
    1. testowanie X algorytmów i tuning hiperparametrów według jakiejś metryki, wybór tego 'najlepszego’.
    2. W zależności od tego, czy dysponujemy kosztami FN / FP oraz zyskami TP / TN – wybór punktu odcięcia w oparciu o minimum tej funkcji kosztu lub (w przypadku braku tych zmiennych) – zmiana punktu odcięcia np. w sposób, jaki omówiłeś w tym wpisie.

    Wartości 1) – i w ogóle wszystko, co używa 'predict’ bazują jednak na domyślnym punkcie odcięcia 0.5, co w świetle całej dyskusji o threshold wydaje się trochę mylące. 🙂

    Czy istnieje jakiś standardowy proces dla tych kroków? A może po prostu w 1. warto te algorrytmy po prostu porównywać maksymalizując z pomocą scoring=’auc’?

    Pozdr!

    A.

    • Cześć Adam. Dziękuję!

      Co do Twojego pytania, to moja perspektywa wygląda następująco: wartości z punktu 1, nie puszą być wartościami całkowitymi. Mogą to być prawdopodobieństwa przypisania obserwacji do jednej z klas. Zazwyczaj używam w tym przypadku AUC (raz korzystałem z Brier Score). Wybór metryki w tym miejscu zależy od naszego celu.

      AUC – w uproszczeniu mówi o prawdopodobieństwie przypisania poprawnej wartości prawdopodobieństwa obserwacjom (jedynki mają mieć wyższe prawdopodobieństwa niż zera).
      Brier Score – tej metryki używałem, gdy chciałem „karać” model za zbytnią pewność w ocenie prawdopodobieństwa i preferować modele dobrze, które dobrze kalibrują swoje predykcje.

      O standardowym procesie dla tych kroków nigdy nie czytałem i muszę przyznać, że nie słyszałem, by ktoś poruszał ten problem.

      Mam nadzieję, że udało mi się nieco wyjaśnić temat. Jeśli nie, to daj znać. 🙂

  2. Cześć! A planujesz wpis opisujący jak dobrać model i zoptymalizować jego hiperparametry jeśli planujemy później wybór punktu odcięcia? 🙂 Czy maksymalizowaną metryką przy tuningu hiperparametrów modelu powinna być wówczas jakaś miara 'probabilistyczna’ – np. Brier Score, a nie klasyczne, oparte na 'twardym’ labelowaniu?

    • Cześć! Mam w planach wpis (albo nawet kilka wpisów) z optymalizacją parametrów z użyciem metod Bayesowskich, a to już całkiem blisko tego o co pytasz.

      „Twarde labelowanie” jest niekorzystne w opisanym przez Ciebie scenariuszu. Nie wiem, czy dobrze rozumiem Twoje pytanie, ale ja każdorazowo separuję tuning hiperparametrów (poprawa ogólnej jakości modelu; w 99.9% używam tu miary związanej z prawdopodobieństwem, np. Brier Score, AUC) od optymalizacji punktu odcięcia (maksymalizacja potencjalnych zysków jakie daje model).

Dodaj komentarz

Twój adres email nie zostanie opublikowany.


*