Jak wybrać punkt odcięcia, bazując na uwarunkowaniach biznesowych?

wybór punktu odcięcia, klasyfikacja, cut off, zaawansowana anlityka, data science

W ostatnich wpisach pokazywałem jak wybrać punkt odcięcia przy założeniu, że wszystkie popełniane błędy kosztują nas tyle samo. W poniższym wpisie przedstawiam metodę biorącą pod uwagę uwarunkowania biznesowe.

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.

Tego zbioru używałem przy okazji kilku innych wpisów (przewidywanie defaultu wśród posiadaczy kart kredytowych i kategoryzacja zmiennych z użyciem drzewa decyzyjnego).

Małe założenie: wprawdzie zbiór dotyczy procesu wydawania karty kredytowej, lecz ja dla uproszczenia będę traktować go jak proces udzielania kredytu gotówkowego do którego jest mi znacznie bliżej (jeśli chodzi o doświadczenie zawodowe).

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. Zdefiniowanie uwarunkowań biznesowych.

3.1. Jakie decyzje podejmują pracownicy banku i jakie są ich konsekwencje?

Poprzez uwarunkowania mam tu na myśli zyski i straty wynikające z poszczególnych decyzji sprzedażowych. Należy zatem przełożyć język biznesu na statystyki, jakimi posługujemy się w uczeniu maszynowym.

Zdefiniujmy sobie zatem akcje podejmowane przy udzielaniu kredytu/wydawaniu karty kredytowej i ich konsekwencje. Poniższe wartości zapewne różnią się od ich rzeczywistych odpowiedników. Nie ma to jednak większego znaczenia, gdyż celem tego wpisu jest ukazanie przykładowego procesu optymalizacji z użyciem punktu odcięcia.

Załóżmy zatem, że pracownik banku rozpatrujący wniosek o wydanie karty kredytowej może podjąć 2 decyzje:

  1. Wydanie karty kredytowej osobie wnioskującej.
  2. Odmowa wydania karty kredytowej osobie wnioskującej.

Dodatkowo każda z powyższych decyzji może się okazać słuszna lub nie. Na wyjściu mamy zatem cztery przypadki:

  1. Pracownik banku wydaje kartę kredytową osobie wnioskującej i dana osoba regularnie wywiązuje się ze zobowiązania kredytowego.
  2. Pracownik banku wydaje kartę kredytową osobie wnioskującej i dana osoba nie wywiązuje się ze zobowiązania kredytowego.
  3. Pracownik banku nie wydaje karty kredytowej osobie wnioskującej, która wywiązywałaby się ze zobowiązania kredytowego.
  4. Pracownik banku nie wydaje karty kredytowej osobie wnioskującej, która nie wywiązywałaby się ze zobowiązania kredytowego.

Jak być może się domyślasz, powyższe punkty odpowiadają elementom widniejącym na macierzy pomyłek. Są to kolejno: TN, FN, FP, TP. Po utworzeniu modelu i jego walidacji będziemy wiedzieć, ile akcji danego typu podejmą pracownicy banku bazujący na zbudowanym modelu. To czego jeszcze potrzebujemy, to znać oszacowanie kosztów poszczególnych decyzji.

3.2. Czy to wszystko, czego potrzebujemy do oszacowania zysku i straty?

Odpowiedź jest prosta: nie. Należałoby wziąć również pod uwagę takie elementy jak m.in.:

  • Koszty niewykorzystanych szans - dotyczy głównie FP, a więc dobrego klienta, któremu nie wydamy karty. Tracimy nie tylko potencjalny zysk z udzielonego kredytu, ale również szansę budowania długofalowej relacji, z której mogłaby wynikać np. "dosprzedaż" innych produktów bankowych.
  • Recovery Rate - mówi o tym, jaką część zobowiązania, z którego klient się nie wywiąże, uda się bankowi odzyskać.
  • Loss Given Default - mówi o tym jaką część zobowiązania, z którego klient się nie wywiąże, bank straci (LGD i RR sumują się do 1).
  • Exposure At Default - wartość ekspozycji kredytowej w momencie niewykonania zobowiązania.
  • Pozostałe zmienne zależne, m.in. koszty windykacji klientów, koszty udzielenia pożyczki, obsługi klienta, etc.
3.3. Ile kosztują bank poszczególne decyzje?

Będę oceniać koszty decyzji, a więc im mniej, tym lepiej. Wartości, jakimi będę się posługiwać, będą pochodziły z przedziału <-100, 100>. Wartość maksymalna (100) oznacza całkowitą stratę (100% zobowiązania tracimy), a wartość minimalna (-100), to 100% zysku z pożyczonych pieniędzy (wartości oczekiwanej pożyczki).

Oczywiście szanse na wystąpienie obu skrajnych zdarzeń różnią się znacząco. Ciężko sobie wyobrazić sytuację, w której bank udzieli pożyczki z RRSO = 100% - nie ma zatem szans na taki zysk. Sytuacje, w których bank straci 100% udzielonej pożyczki, są częstsze (np. defaulty związane z kradzieżą tożsamości - dochodzenia policji i sprawy sądowe często kończą się w "ślepej uliczce", bez wykrycia winnego).

  1. True Negative - udzielamy kredytu, a klient go spłaca z odsetkami. Zakładam, że średni koszt = -10 (bank zyskuje 10%).
  2. True Positive - wykrycie nierzetelnego klienta sprawia, że nic nie stracimy, lecz też nic nie zyskujemy (przynajmniej w teorii; patrz punkt 3.2)). Koszt = 0.
  3. False Negative - najgorszy z rozpatrywanych scenariuszy. Zakładam średni koszt = 75 (bank traci 75% z wartości oczekiwanej kredytu).
  4. False Positive - prócz niewykorzystanych szans (ponownie patrz punkt 3.2) koszt = 0.

W skrócie: rozpatrzę tylko udzielone kredyty.

Na podstawie powyższych punktów łatwo stwierdzić, że potrzebujemy niemal 8 dobrze udzielonych kredytów, by "zasypać dołek" wynikający z decyzji o udzieleniu kredytu osobie, która nie wywiąże się ze zobowiązania.

Widać również, że w celu minimalizacji kosztów (maksymalizacji zysków) będziemy chcieli maksymalizować liczbę TN i minimalizować liczbę FN. Pozostałe decyzje nie są ważne (oczywiście w tym rozpatrywanym przypadku, który nie bierze pod uwagę wszystkich czynników - patrz punkt 3.2. ;)).

4. 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. 😉

5. Poszukiwanie suboptymalnego punktu odcięcia.

Napisałem "suboptymalnego", bo nie zamierzam prześwietlić wszystkich możliwych przypadków (dla wartości ciągłych jest to niewykonalne). Sprawdzę 101 przypadków <0, 1.0> z krokiem 0.01.

In [6]:
punkty_odciecia = np.arange(0, 1.01, 0.01)
In [7]:
wyniki = {}
for punkt in punkty_odciecia:
    tn, fp, fn, tp = confusion_matrix(y_te, prawd_rl>punkt).ravel()
    koszt = tn * -15 + fn * 75
    wyniki[punkt] = koszt
# Wyniki zapisuję w serii danych.
wyniki = pd.Series(wyniki)

Sprawdzam na której pozycji znajduje się najniższa wartość kosztu.

In [8]:
najnizszy_koszt = wyniki.iloc[wyniki.argmin()]
najlepszy_punkt_odciecia = wyniki.iloc[wyniki.argmin():wyniki.argmin()+1].index[0]
print('Najmniejszy koszt wynosi: {}. Odpowiadający mu punkt odcięcia wynosi: {}.'.format(najnizszy_koszt, najlepszy_punkt_odciecia))
Najmniejszy koszt wynosi: -11715. Odpowiadający mu punkt odcięcia wynosi: 0.23.

5. Walidacja modelu i podsumowanie wyników.

5.1. Analiza wykresu: koszt vs punkt odcięcia.

In [9]:
plt.figure(figsize = (12, 7))
ax_1 = sns.lineplot(x = punkty_odciecia, y = wyniki, color = '#eb6c6a')
ax_1.text(0.23 + 0.03, wyniki.loc[0.23] + 1000, 'nowy punkt odcięcia = 0.23\nkoszt = {}'.format(wyniki.loc[0.23]))
ax_1.axvline(0.23, linestyle = '--', color = 'grey', linewidth = 1.4)
ax_1.text(0.5 + 0.03, 11000, 'bazowy punkt odcięcia = 0.5\nkoszt = {}'.format(wyniki.loc[0.5]))
ax_1.axvline(0.5, linestyle = '--', color = 'grey', linewidth = 1.4)
ax_1.axhline(wyniki.loc[0.5], linestyle = '--', color = 'grey', linewidth = 1.4)
ax_1.axhline(0, linestyle = '--', color = 'grey', linewidth = 1.4)
ax_1.text(0.8, 1000, 'granica rentowności banku')
ax_1.axhline(wyniki.loc[0.23], linestyle = '--', color = 'grey', linewidth = 1.4)
ax_1.plot(0.5, wyniki.loc[0.5], "or", color = '#eb6c6a')
ax_1.plot(0.23, wyniki.loc[0.23], "or", color = '#eb6c6a')
ax_1.set_xlabel('Punkt odcięcia')
ax_1.set_ylabel('Koszt')
plt.title('Punkt odcięcia vs koszt udzielenia kredytu')
plt.show()

Zgodnie z powyższych decyzja o przyjęciu punktu odcięcia na poziomie 0.5 byłaby katastroficzna w skutkach. Punkt odcięcia = 0.23 pozwoli nam zminimalizować straty, przy maksymalizacji zysków banku.

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

In [10]:
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 tn, fp, fn, tp
5.2. Weryfikacja macierzy pomyłek.
In [11]:
# Dla przypomnienia.
pd.DataFrame([['TN', 'FP'], ['FN', 'TP']], 
             columns = ['predicted negatives', 'predicted positives'], 
             index = ['actual negatives', 'actual positives'])
Out[11]:
predicted negatives predicted positives
actual negatives TN FP
actual positives FN TP
In [12]:
tn, fp, fn, tp = podsumowanie_modelu(0.23)
Raport klasyfikacji:
              precision    recall  f1-score   support

           0       0.88      0.69      0.77      3938
           1       0.40      0.68      0.51      1210

    accuracy                           0.69      5148
   macro avg       0.64      0.68      0.64      5148
weighted avg       0.76      0.69      0.71      5148


Macierz pomyłek:
                  predicted negatives  predicted positives
actual negatives                 2711                 1227
actual positives                  386                  824

W przeciwieństwie to przykładu opisywanego w poprzednim wpisie nie ma tu mowy o równym rozkładzie błędów. Widać, że udało się osiągnąć to, co zakładałem (minimalizację FN, maksymalizację TN).

5.3. Ocena średniej rentowności.

Ocenię rentowność (na zbiorze testowym, którego użyłem do wyboru punktu odciecia), jaką osiąga model poprzez wyznaczenie średnią wartość kosztu dla udzielanych kredytów.

In [13]:
liczba_kredytow = tn + fn
print('Liczba udzielonych kredytów:', liczba_kredytow)
print('Liczba wnioskow:', tn + fn + fp + tp)
print('Osiągnięta rentowność: {}%'.format(np.abs((tn * -15 + fn * 75)/liczba_kredytow).round(2)))
Liczba udzielonych kredytów: 3097
Liczba wnioskow: 5148
Osiągnięta rentowność: 3.78%

6. Podsumowanie

To koniec dzisiejszego wpisu. Być może osiągnięta rentowność na poziomie 3.78% nie zachwyca, lecz proszę, pamiętaj, że to jedynie przykład i wiele elementów nie zostało wziętych pod uwagę. Poza tym sam model nie jest idealny i właśnie nad jego poprawą będę się skupiać w kolejnych wpisach. Będę wykorzystywać zaawansowane metody modelowania, by poprawić osiągniętą powyżej rentowność.

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 (StartupStockPhotos)

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 🙂

2 Komentarze

Dodaj komentarz

Twój adres email nie zostanie opublikowany.


*