Сегодня мы рассмотрим проект классификации, а именно построение модели кредитного скоринга, предназначенной для классификации заявок на получение кредита.

Набор данных, который я буду использовать, доступен на Kaggle, его можно найти здесь: https://www.kaggle.com/c/home-credit-default-risk/data. Этот набор данных представляет собой богатую коллекцию, которая включает в себя несколько таблиц, предлагающих многочисленные возможности для разработки признаков.

Первые шаги этого проекта включают загрузку необходимых библиотек, набора данных и проведение начальной проверки.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

# Suppress warnings
import warnings
warnings.filterwarnings('ignore')

# Display all columns in output
pd.set_option('display.max_columns', None)

# Set plot style and define some custom colours
plt.style.use('ggplot')

viva_magenta = '#BE3455'
winery ='#7E212A'
classic_blue ='#0f4c81'

# Read in the datasets
application_test = pd.read_csv("application_test.csv")
application_train = pd.read_csv("application_train.csv")
bureau = pd.read_csv("bureau.csv")

print(application_train.head())
print(application_test.head())

После проверки первых пяти строк наборов данных application_train и application_test стало очевидно, что единственная разница между ними заключается в том, что application_train содержит столбец TARGET, который является двоичной переменной. Основываясь на этом наблюдении, предполагается, что столбец «ЦЕЛЬ» может быть нашей меткой класса. При дальнейшем изучении документации это подозрение подтверждается.

Далее я решил объединить наборы данных application_train и application_test, поскольку некоторые шаги по очистке данных могут быть применимы к обоим наборам данных.

# Append test and train data, and reset index
df = application_train.append(application_test).reset_index(drop=True)

После долгих размышлений я решил добавить в наш набор данных информацию из предыдущих кредитных заявок. Хотя я не претендую на звание банковского эксперта (я скорее эксперт по забавным видео с котиками), я решил, что это может быть ценным дополнением к нашему анализу.

previous_application = pd.read_csv("previous_application.csv",
                                   usecols=['SK_ID_PREV',
                                            'SK_ID_CURR',
                                            'AMT_ANNUITY',
                                            'AMT_APPLICATION',
                                            'CODE_REJECT_REASON',
                                            'SELLERPLACE_AREA'])
# Count previous applications per client
CNT_PREV_APPLICATION = previous_application.groupby('SK_ID_CURR').apply(len).to_frame()
CNT_PREV_APPLICATION.columns=['CNT_PREV_APPLICATION']
CNT_PREV_APPLICATION.reset_index(inplace=True)

previous_application = previous_application.merge(CNT_PREV_APPLICATION,on='SK_ID_CURR')

# Merge previous application information with main dataframe
df = df.merge(previous_application.drop_duplicates(subset='SK_ID_CURR'), 
              on='SK_ID_CURR', 
              suffixes=('_CURR', '_PREV'),
              how='left').copy()

В дополнение к предыдущим заявкам на получение кредита я также решил включить Bureau.csv. набор данных в наш анализ. Доступны дополнительные наборы данных, которые потенциально могут предоставить ценную информацию.

# Merge bureau.csv with main dataframe
bureau.drop(columns=['SK_ID_BUREAU'],inplace=True)

df = df.merge(bureau.drop_duplicates(subset='SK_ID_CURR'), 
              on='SK_ID_CURR', 
              suffixes=('_CURR', '_BUREAU'),
              how='left').copy()

Теперь давайте посмотрим, какой материал у нас есть.

df.shape

356255, 143

# Types of features and counts
df.dtypes.value_counts()

float64 83
int64 40
объект 20

Кажется, что наш объединенный набор данных содержит в общей сложности 143 переменных, из которых 83 — с плавающей запятой, 40 — целочисленные и 20 — категориальные переменные. Нам нужно будет очистить и предварительно обработать данные, прежде чем мы сможем начать построение нашей модели кредитного скоринга.

Давайте посмотрим на наши целевые классы.

# Bar plot displaying target class counts.
plt.figure(figsize=(6, 4))

ax = application_train['TARGET'].value_counts().plot(
    kind='bar', color=classic_blue, linewidth=0.7, edgecolor='white')

pcnt = round(application_train['TARGET'].value_counts(
)/len(application_train['TARGET']), 2)

for i in range(len(pcnt)):
    plt.text(x=i,
             y=application_train['TARGET'].value_counts()[i]/2,
             s=(f"{pcnt[i]*100},%"),
             size=10,
             color='white',
             weight='bold',
             ha='center',
             va='center')

ax.set_xlabel('Target Classes', fontsize=10)
ax.set_ylabel('Count', fontsize=10)
ax.set_title('Class Count in Train Set', fontsize=12)
plt.show()

О, похоже, у нас есть серьезная проблема дисбаланса классов с нашей целевой переменной. Если мы не обратимся к этому должным образом, это может привести к предвзятой модели классификации, которая отдает предпочтение классу большинства.

Давайте взглянем на корреляцию между нашей целевой переменной и функциями в нашем наборе данных.

# plot the correlations of features with target
target_corr = df.corr()['TARGET'].sort_values(ascending=False)
target_corr.drop(index='TARGET', inplace=True)

pos = target_corr.loc[target_corr.values > 0]
neg = target_corr.loc[target_corr.values <= 0]

fig, ax = plt.subplots(1, 1, figsize=(12, 6))

sns.barplot(x=pos.index,
            y=pos.values,
            color=winery,
            width=0.7,
            linewidth=0.7,
            edgecolor='white',
            ax=ax)


sns.barplot(x=neg.index,
            y=neg.values,
            color=classic_blue,
            width=0.7,
            linewidth=0.7,
            edgecolor='white',
            ax=ax,)

ax.set_xticklabels(ax.get_xticklabels(), rotation=90, ha='right', size=8)

plt.suptitle('Variable Correlation with Target', fontsize=12)
plt.tight_layout()
plt.show()

Здесь нет сильной корреляции.

После обширного исследовательского анализа я получил хорошее представление о нашем наборе данных и обнаружил некоторые интригующие закономерности и взаимосвязи. Хотя я не буду вдаваться здесь во все детали, чтобы сосредоточиться на нашем проекте кредитного скоринга, я призываю вас самостоятельно изучить этот богатый и сложный набор данных. Это действительно захватывающе, что мы можем узнать из данных!

Ниже приведены шаги, предпринятые для очистки нашего набора данных. Я также добавил некоторые функции, которые, по моему мнению, могут быть полезны.

def cleanse_df_application(df):
    # Replace error value 'XNA' in gender column
    df['CODE_GENDER'].replace({'XNA': np.nan}, inplace=True)
    
    #Transform boolean categorical features into binery variables
    df['EMERGENCYSTATE_MODE'].replace({'nan':np.nan},inplace=True)

    df['EMPLOYEMENT_ANOMALY'] = (df['DAYS_EMPLOYED'] == 365243).astype(int)
    df['DAYS_EMPLOYED'].replace({365243: np.nan}, inplace=True)
    
    # Replace region ratings that are not 1/2/3
    df['REGION_RATING_CLIENT_W_CITY'].mask(~df['REGION_RATING_CLIENT_W_CITY'].isin([1,2,3]),np.nan,inplace=True)
    df['REGION_RATING_CLIENT'].mask(~df['REGION_RATING_CLIENT'].isin([1,2,3]),np.nan,inplace=True)

    # add features
    df['DAYS_EMPLOYED_PERC'] = df['DAYS_EMPLOYED'] / df['DAYS_BIRTH']  # Percentage of employed years
    df['INCOME_CREDIT_PERC'] = df['AMT_INCOME_TOTAL'] / df['AMT_CREDIT'] # Debt to incom ratio
    df['INCOME_PER_PERSON'] = df['AMT_INCOME_TOTAL'] / df['CNT_FAM_MEMBERS'] # Income per person
    df['ANNUITY_INCOME_PERC'] = df['AMT_ANNUITY_CURR'] / df['AMT_INCOME_TOTAL'] 
    df['PAYMENT_RATE'] = df['AMT_ANNUITY_CURR'] / df['AMT_CREDIT']
    
    return df

df = cleanse_df_application(df)

Теперь мы переходим к следующим важным шагам в этом проекте: предварительной обработке данных, настройке гиперпараметров и построению нашей модели классификации. Засучим рукава!

# seperate our train and test set
train_set=df[~df['TARGET'].isna()]
test_set = df[df['TARGET'].isna()]

Хотя 148 признаков могут показаться устрашающим объемом данных, наши шаги предварительной обработки могут на самом деле привести к тому, что это число увеличится в геометрической прогрессии. Крайне важно выбрать наиболее подходящие функции, прежде чем двигаться дальше. Для этой задачи я использую SelectKBest.

from sklearn.compose import make_column_transformer
from sklearn.preprocessing import OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.feature_selection import SelectKBest,f_classif

# define our X and y for this task
X_selection=train_set.drop(columns='TARGET')
y_selection=train_set['TARGET']

# SelectKBest takes in numerical data only. We need to transform our data first
# categorical variables are first one hot encoded
col_transform = make_column_transformer((OneHotEncoder(sparse=False,
                                                       handle_unknown="ignore"),
                                         X_selection.select_dtypes('object').columns,),
                                        remainder="passthrough")

X_ohe = col_transform.fit_transform(X_selection)

# missing values need to be imputed as well
imputer=SimpleImputer(missing_values=np.nan,strategy='median',add_indicator=True).fit(X_ohe)
X_imputed = imputer.transform(X_ohe)

# scaling data
X_scaled = StandardScaler().fit_transform(X_imputed)

# apply SelectKBest
selector=SelectKBest(score_func=f_classif).fit(X_scaled,y_selection)
X_selectkbest=selector.transform(X_scaled)

# compare results
print('Number of features of original X: ', X_selection.shape[1])
print('Number of features after one-hot-encode: ', X_ohe.shape[1])
print('Number of features after imputation: ', X_imputed.shape[1])
print('Number of features after SelectKBest: ', X_selectkbest.shape[1])
Number of features of original X:  148
Number of features after one-hot-encode:  306
Number of features after imputation:  389
Number of features after SelectKBest:  10

Как видно из приведенного выше результата, после горячего кодирования и вменения количество наших признаков увеличилось до 389. Однако SelectKBest определил из них только 10 наиболее релевантных признаков.

Но что это за особенности?

# Extract feature names so we can actually select them.
features=col_transform.get_feature_names_out().tolist()
diff=len(imputer.indicator_.features_)
for i in range(diff):
        features.append(i)
        
mask = selector.get_support()
k_best_features = np.array(features)[mask]
best_features = [feat.split('__')[1] for feat in k_best_features]
print(best_features)

"NAME_INCOME_TYPE_Working", "DAYS_BIRTH", "DAYS_EMPLOYED", "REGION_RATING_CLIENT", "REGION_RATING_CLIENT_W_CITY", "EXT_SOURCE_1", "EXT_SOURCE_2", "EXT_SOURCE_3", "DAYS_CREDIT", "DAYS_EMPLOYED_PERC"

Теперь у нас есть наши лучшие черты. Прежде чем двигаться дальше, давайте просто рассмотрим корреляции между выбранными нами функциями.

# A heatmap to visualize correlations between selected features
plt.figure(figsize=(10,8))
ax = sns.heatmap(pd.DataFrame(X_selectkbest,columns=best_features).corr(),
                 annot=True, xticklabels=True)
ax.xaxis.tick_top()  
ax.set_xticklabels(ax.get_xticklabels(), rotation=90)  
plt.title('Correlation Between SelectKBest Features', fontsize=12)
plt.show()

Существует два набора тесно связанных функций: DAYS_EMPLOYED_PERC/DAYS_EMPLOYED и REGION_RATING_CLIENT/REGION_RATING_CLIENT_W_CITY.

После дальнейшего исключения ниже приведены наши окончательные выбранные функции.

# Define a list of features selected.
features_selected = [
  'NAME_INCOME_TYPE',
  'DAYS_BIRTH',
  'DAYS_EMPLOYED_PERC',
  'REGION_RATING_CLIENT',
  'DAYS_CREDIT',
  'EXT_SOURCE_1',
  'EXT_SOURCE_2',
  'EXT_SOURCE_3'
                     ]
# Define X and y
X=train_set[features_selected]
y=train_set['TARGET']

Чтобы решить проблему дисбаланса классов, которую мы видели ранее, я буду использовать SMOTE для передискретизации класса меньшинства. Такой подход помогает избежать потери информации, которая может произойти при недостаточной выборке. Для этой задачи хорошо зарекомендовала себя библиотека Imbalanced Learn.

Одним из важных факторов, который следует учитывать, прежде чем приступать к обучению наших моделей, является стоимость, связанная с ложными срабатываниями (FP) и ложными отрицательными результатами (FN). В этом случае, предполагая, что FN в 10 раз дороже, чем FP, нам необходимо соответствующим образом скорректировать нашу оценку. Чтобы решить эту проблему, я создал собственную оценку под названием «error_cost_score», используя функцию make_scorer из библиотеки sklearn.

from sklearn.metrics import confusion_matrix, make_scorer

def error_cost(y, y_pred):
    cm = confusion_matrix(y, y_pred)
    TN, FP, FN, TP = cm.flatten()
    cost = FP + 10 * FN
    return cost

error_cost_score=make_scorer(error_cost, greater_is_better=False)

Чтобы сделать предварительную обработку более эффективной, я использую конвейер. Для настройки гиперпараметров я использую GridSearchCV и создал функцию поиска по сетке, чтобы упростить тестирование нескольких моделей. Кроме того, для отслеживания производительности модели я включил MLflow.

from sklearn.impute import SimpleImputer
from sklearn.model_selection import GridSearchCV,StratifiedKFold

from imblearn.over_sampling import SMOTE
from imblearn.pipeline import Pipeline 

# A function to preprocess and grid search estimater with pipeline
def classifier(model, params):
    # A pipeline aith preprocessing included (one hot encode, impute, SMOTE & scaler)
    pipe = Pipeline(steps=[
        ['one hot encode', make_column_transformer(
            (OneHotEncoder(sparse=False, handle_unknown="ignore"),
             ['NAME_INCOME_TYPE'],),
            remainder="passthrough")
        ],
        ['impute', SimpleImputer(missing_values=np.nan,
                                 strategy='median',
                                 add_indicator=True)],
        ['smote', SMOTE(random_state=42)],
        ['scaler', StandardScaler()],
        ['classifier', model]
    ]
                   )

    stratified_kfold = StratifiedKFold(
        n_splits=5, shuffle=True, random_state=42)

    scores = {'auc': "roc_auc_ovr_weighted",
              'cost_score': error_cost_score}

    clf = GridSearchCV(estimator=pipe,
                       param_grid=params,
                       scoring=scores,
                       refit='cost_score',
                       cv=stratified_kfold,
                       n_jobs=2)
    return clf

Модели-кандидаты, которые я выбираю, — это логистическая регрессия, случайный лес и lightGBM.

Логистическая регрессия

# activate mlflow auto tracking
import mlflow

mlflow.set_tracking_uri("http://127.0.0.1:5000/")

experiment_id = mlflow.create_experiment("credit_scoring")

experiment = mlflow.get_experiment(experiment_id)

mlflow.sklearn.autolog(log_models=True)


# logistic regression
with mlflow.start_run(experiment_id=experiment.experiment_id, run_name='Weighted Logistic Regression') as run:

    from sklearn.linear_model import LogisticRegression

    param_logistic_regression = {'classifier__penalty': ['l2', 'elasticnet'],
                                 'classifier__C': [1, 10],
                                 'classifier__solver': ['lbfgs', 'newton-cholesky']
                                 }

    clf_logistic = classifier(model=LogisticRegression(class_weight='balanced',
                                                                    random_state=42,
                                                                    max_iter=200),
                              params=param_logistic_regression)

    clf_logistic.fit(X_train, y_train)

Случайный лес.

with mlflow.start_run(experiment_id=experiment.experiment_id, run_name='Random Forest') as run:
    from sklearn.ensemble import RandomForestClassifier

    param_forest = {
        'classifier__n_estimators': [50, 100,150],
        'classifier__max_depth': [10, 15],
        'classifier__min_samples_leaf': [100, 200, 500],
        'classifier__max_samples': [0.5, 0.8]
    }

    clf_forest = classifier(model=RandomForestClassifier(class_weight='balanced',
                                                                      random_state=42),
                            params=param_forest)


    clf_forest.fit(X_train, y_train)

СветGBM

with mlflow.start_run(experiment_id=experiment.experiment_id, run_name='LightGBM') as run:
    from lightgbm import LGBMClassifier

    param_lgbm = {'classifier__max_depth': [10,30, 60],
                  'classifier__num_leaves': [20, 50],
                  'classifier__learning_rate': [0.001, 0.01, 0.1],
                  }

    clf_lgbm = classifier(model=LGBMClassifier(objective='binary',
                                               class_weight='balanced',
                                               random_state=42),
                          params=param_lgbm)


    clf_lgbm.fit(X_train, y_train)

Вуаля!

Теперь давайте сравним результаты.

model_dict = {
    'Logistic Regression': clf_logistic,
    'Random Forest': clf_forest,
    'LightGBM': clf_lgbm
}

result = {'Model': [],
          'Error cost score': [],
          'AUC score (mean_test_score)': [],
          'Mean fit time': [],
          'Best params': []}

for model_name, model in model_dict.items():
    result['Model'].append(model_name)
    result['Error cost score'].append(round(model.best_score_, 4))
    result['AUC score (mean_test_score)'].append(
        round(max(model.cv_results_['mean_test_auc']), 4))
    result['Mean fit time'].append(
        np.around(np.average(model.cv_results_['mean_fit_time']), 2))
    result['Best params'].append(model.best_params_)

pd.DataFrame(result).sort_values(by='Error cost score', ascending=False)

Таблица результатов:

Одна модель, кажется, работает особенно хорошо с точки зрения стоимости ошибки: логистическая регрессия. Тем не менее, мы должны более внимательно посмотреть на результаты. Использование матрицы путаницы может обеспечить четкий и прямой обзор производительности моделей. О, пока я этим занимаюсь, я бы хотел также засечь время предсказания.

import time

y_preds = []
predict_times = []

for model in list(model_dict.values()):
    start_time = time.time()
    preds = model.predict(X_test)
    end_time = time.time()
    y_preds.append(preds)
    predict_times.append(round(end_time - start_time,2))

print(predict_times)

[0.07, 1.09, 0.14]

Из трех логистическая регрессия имеет самое быстрое время прогнозирования, а случайный лес — самое медленное.

К матрице путаницы.

# a function to plot confusion matrix heatmap
def confusion_matrix_heatmap(y,y_pred,ax):
    cm = confusion_matrix(y, y_pred)
    TN, FP, FN, TP = cm.flatten()
    strings = np.asarray(['TN', 'FP', 'FN', 'TP'])

    labels = np.asarray(["{0}\n{1}".format(string, value)
                      for string, value in zip(strings.flatten(),
                                               cm.flatten())])
    annot = labels.reshape(cm.shape[0], cm.shape[1])
    sns.heatmap(cm, annot=annot, fmt='', cmap="Blues", ax=ax)
    ax.set_xlabel('Predicted', fontsize=10)
    ax.set_ylabel('Actual', fontsize=10)
    return ax

# plot three confusion matrices for our three models
fig, axs = plt.subplots(1, 3, figsize=(12, 4))

for i, ax in enumerate(axs.flatten()):
    confusion_matrix_heatmap(y_test, y_preds[i], ax)
    ax.set_title(f"{list(model_dict.keys())[i]}, predict time: {predict_times[i]}", fontsize=10)

plt.suptitle('Confusion Matrix', fontsize=12)
plt.tight_layout()
plt.show()

Глядя на диагональные квадраты матриц путаницы, мы видим, что логистическая регрессия имеет наибольшее количество ложных срабатываний (хорошие приложения прогнозируются как плохие), но наименьшее количество ложных отрицательных результатов (плохие приложения прогнозируются как хорошие). С другой стороны, две другие модели были менее «консервативными» и давали больше ложноотрицательных результатов.

Чтобы выразить это в денежном выражении, давайте предположим, что истинный отрицательный результат приносит 1 доллар, ложный положительный результат стоит 1 доллар, ложный отрицательный результат стоит 10 долларов, а истинный положительный результат приносит 10 долларов. Основываясь на этих предположениях, мы можем создать матрицу затрат, которая поможет нам оценить модели.

# a function to draw a cost matrix
def cost_matrix_heatmap(y, y_pred, ax):
    cm = confusion_matrix(y, y_pred)
    TN, FP, FN, TP = cm.flatten()
    costs = [TN, -FP, -10*FN, 10*TP]
    cost_total = FP+10*FN
    strings = np.asarray(['TN', 'FP', 'FN', 'TP'])

    labels = np.asarray(["{0}\n{1}".format(string, value)
                         for string, value in zip(strings.flatten(),
                                                  costs)])

    annot = labels.reshape(cm.shape[0], cm.shape[1])
    sns.heatmap(cm, annot=annot, fmt='', cmap="Blues", ax=ax)
    ax.set_xlabel('Predicted', fontsize=10)
    ax.set_ylabel('Actual', fontsize=10)
    return ax, cost_total

# plot three cost matrices for three models
fig, axs = plt.subplots(1, 3, figsize=(12, 4))

costs = []
for i, ax in enumerate(axs.flatten()):
    fig, cost = cost_matrix_heatmap(y_test, y_preds[i], ax)
    ax.set_title(f"{list(model_dict.keys())[i]}, Total Loss:{cost}", fontsize=10)
    costs.append(cost)

plt.suptitle('Cost Matrix', fontsize=12)
plt.tight_layout()
plt.show()

Из приведенного выше графика совершенно ясно, что логистическая регрессия имеет самую низкую стоимость ошибки из трех моделей. Теперь меня интересует общий доход от каждой модели.

def get_total_revenue(model):
    y_pred = model.predict(X_test)
    cm = confusion_matrix(y_test, y_pred)
    TN, FP, FN, TP = cm.flatten()
    total_revenue = TN - FP - 10 * FN + 10 * TP
    return total_revenue

revenues = [get_total_revenue(model) for model in model_dict.values()]
sorted_models = [model for _, model in sorted(zip(revenues, model_dict.values()), reverse=True)]

plt.figure(figsize=(7,5))
sns.barplot(x=list(model_dict.keys()), 
            y=revenues, 
            edgecolor='white',
            linewidth=0.8,
            order=[list(model_dict.keys())[list(model_dict.values()).index(model)] for model in sorted_models])
plt.xticks(rotation=45, ha='right')
plt.title('Total Revenue by Model',fontsize=12)
plt.show()

Логистическая регрессия кажется явным победителем, постоянно превосходящим другие модели по всем таблицам и показателям.

Что ж, давайте сохраним нашу выигрышную модель и перейдем к построению информационных панелей и развертыванию в облаке.

model=clf_logistic.best_estimator_

import pickle
# save model for creating our dashboard app
with open("model.pkl", "wb") as f: 
    pickle.dump(model, f)

Чтобы создать удобную и понятную панель инструментов, я стремлюсь сделать ее простой и наглядной. Я планирую включить раскрывающийся список для выбора идентификатора клиента, который будет отображать соответствующий результат прогнозирования вместе с ключевыми решающими функциями.

Но чтобы это сработало, мне нужно сначала развернуть API моей модели. Для развертывания API моей модели я выбрал фреймворк FastAPI и приложение Dash и буду использовать Heroku в качестве платформы для хостинга.

Ядро моего кода FastAPI включает в себя загрузку предварительно обученной модели и создание прогнозов на основе входных функций с помощью функции «give_score». Кроме того, я внедрил пользовательский интерфейс Swagger для тестирования модели и управления ею без использования панели инструментов.

import pandas as pd
import pickle
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from client_features import ClientFeatures

with open("trained_pipeline.pkl", "rb") as f:
    model = pickle.load(f)

app = FastAPI()

def custom_openapi():
    if app.openapi_schema:
        return app.openapi_schema
    openapi_schema = get_openapi(
        title="Credit Default Prediction ",
        version="0.0.1",
        description="Classification with Logistic Regression. To try it out, click 'Try it out' under '/score' ",
        routes=app.routes,
    )
    app.openapi_schema = openapi_schema
    return app.openapi_schema

app.openapi = custom_openapi

@app.get("/")
async def root():
    return {"message": "Hello World"}
    
@app.post('/score')
async def give_score(data:ClientFeatures):
    data_dict = data.dict()
    df = pd.DataFrame([data_dict])
    score = model.predict_proba(df)[0][1]
    score = round(score, 2)
    if score < 0.4933:
        status = 'Accepted'
    else:
        status = 'Rejected'

    return {"risk_score": score, 
            'application_status': status}

Возможно, вы заметили в коде класс ClientFeatures. Это класс типа данных Pydantic, который помогает обеспечить правильное форматирование входных данных перед их передачей в модель. Он существует в отдельном файле в той же папке API.

from pydantic import BaseModel

class ClientFeatures(BaseModel):
    NAME_INCOME_TYPE: str
    DAYS_CREDIT: float
    DAYS_BIRTH: int
    DAYS_EMPLOYED_PERC: float
    REGION_RATING_CLIENT: int
    EXT_SOURCE_1: float
    EXT_SOURCE_2: float
    EXT_SOURCE_3: 

Чтобы настроить и запустить API на Heroku, вам понадобится несколько вещей: Procfile, runtime.txt, requirements.txt и, конечно же, ваша надежная обученная модель. Эти файлы позволяют Heroku узнать, в какой среде должен работать ваш API и какие библиотеки ему нужны, чтобы творить чудеса. Совет: используйте команду pip freeze, чтобы убедиться, что у вас есть точные версии всех ваших зависимостей, а затем укажите эти версии в файле requirements.txt. Для получения более подробной информации посетите мой милый репозиторий GitHub: https://github.com/jzpatrao/credit_classification.

Как только это будет сделано, вы можете продолжить создание приложения панели мониторинга. Я использую Даш.

from dash import Dash, dcc, html, Input, Output
import dash_bootstrap_components as dbc
import pandas as pd
import numpy as np
from pathlib import Path
import plotly.graph_objs as go
import requests


# Get the base directory of the current file
BASE_DIR = Path(__file__).resolve(strict=True).parent

# Instantiate the dash app
app = Dash(external_stylesheets=[dbc.themes.LUX])
server = app.server
app.title = "Credit Scoring" 

# Load dataset and saved shap values from pickle
df = pd.read_csv("application_test.csv")

# Define options for a dropdown box that select a client id.
client_options = [{'label': str(x), 'value': x} for x in df['SK_ID_CURR'].unique()]

# Drop down box for client selection
select_client = html.Div([
    dcc.Dropdown(
        id="client_id",
        options=client_options,
        multi=False,
        value=100001
    )
])
    
# Define client profile in detail
client_profile = html.Div([
    dbc.Row([
        html.Span("Income type: ", style={"font-weight": "bold"}),
        html.Div(title='Income type', id='Income type', children=[])
    ]),
    dbc.Row([
        html.Span("Days credit: ", style={"font-weight": "bold"}),
        html.Div(title='Days credit', id='Days credit', children=[])
    ]),
    dbc.Row([
        html.Span("Region rating: ", style={"font-weight": "bold"}),
        html.Div(title='Region rating client', id='Region rating client', children=[])
    ]),
    dbc.Row([
        html.Span("Age: ", style={"font-weight": "bold"}),
        html.Div(title='Days birth', id='Days birth', children=[])
    ]),
    dbc.Row([
        html.Span("Employment length: ", style={"font-weight": "bold"}),
        html.Div(title='Days employed percent', id='Days employed percent', children=[])
    ]),
    dbc.Row([
        html.Span("Ext source 1: ", style={"font-weight": "bold"}),
        html.Div(title='Ext source 1', id='Ext source 1', children=[])
    ]),
    dbc.Row([
        html.Span("Ext source 2: ", style={"font-weight": "bold"}),
        html.Div(title='Ext source 2', id='Ext source 2', children=[])
    ]),
    dbc.Row([
        html.Span("Ext source: ", style={"font-weight": "bold"}),
        html.Div(title='Ext source 3', id='Ext source 3', children=[])
    ])
])

# Define dashboard layout
app.layout = dbc.Container([
    html.H1("Credit Scoring", className='text-center mb-2'), # Page title
    dbc.Row([
        dbc.Col(dbc.Card([
                dbc.CardHeader("Select Client ID",
                               style={"background-color": "#1A85FF", "color": "white", "font-weight": "bold"}),
                select_client,
                dbc.CardBody(dcc.Graph(id="gauge-chart"))
            ], className='mb-4', style={"height": "100%"}), width=5), # Card component with a dropdown box and a gauge chart
        dbc.Col(dbc.Card([
                dbc.CardHeader("Client Profile",
                               style={"background-color": "#1A85FF", "color": "white", "font-weight": "bold"}),
                client_profile
            ], className='mb-4', style={"height": "100%"}), width=5)
    ], className='text-center', align='stretch', justify='center'), # A card components displaying selected client features
], fluid=True,)

# Define inputs in above layout with callbacks.
@app.callback(
    [
        Output(component_id='Income type', component_property='children'),
        Output(component_id='Days credit', component_property='children'),
        Output(component_id='Region rating client',
               component_property='children'),
        Output(component_id='Days birth', component_property='children'),
        Output(component_id='Days employed percent',
               component_property='children'),
        Output(component_id='Ext source 1', component_property='children'),
        Output(component_id='Ext source 2', component_property='children'),
        Output(component_id='Ext source 3', component_property='children')
    ],
    [Input(component_id='client_id', component_property='value')]
)
def get_client_features(client_id):
    data = df[df['SK_ID_CURR'] == client_id]

    Output1 = html.Div(data['NAME_INCOME_TYPE'])
    Output2 = html.Div(data['DAYS_CREDIT'])
    Output3 = html.Div(data['REGION_RATING_CLIENT'])
    Output4 = html.Div(int(data['DAYS_BIRTH']/365))
    Output5 = html.Div(round(data['DAYS_EMPLOYED_PERC'],2))
    Output6 = html.Div(round(data['EXT_SOURCE_1'],2))
    Output7 = html.Div(round(data['EXT_SOURCE_2'],2))
    Output8 = html.Div(round(data['EXT_SOURCE_3'],2))
    return Output1, Output2, Output3, Output4, Output5, Output6, Output7, Output8


@app.callback(Output("gauge-chart", "figure"),
    Input(component_id='client_id', component_property='value')
)

# Define values for above callback outputs
def get_model_prediction(client_id):
    data = df[df['SK_ID_CURR'] == client_id]

    url = "https://oc-project-7-fastapi.herokuapp.com/score"
    payload = {
              "NAME_INCOME_TYPE": data['NAME_INCOME_TYPE'].item(),
              "DAYS_CREDIT": data['DAYS_CREDIT'].item(),
              "DAYS_BIRTH": data['DAYS_BIRTH'].item(),
              "DAYS_EMPLOYED_PERC": data['DAYS_EMPLOYED_PERC'].item(),
              "REGION_RATING_CLIENT": data['REGION_RATING_CLIENT'].item(),
              "EXT_SOURCE_1": data['EXT_SOURCE_1'].item(),
              "EXT_SOURCE_2": data['EXT_SOURCE_2'].item(),
              "EXT_SOURCE_3": data['EXT_SOURCE_3'].item()
            }

    response = requests.post(url, json=payload)
    data_from_api = response.json()
    risk_score = data_from_api["risk_score"]
    status = data_from_api["application_status"]

    fig_gauge = go.Figure(go.Indicator(domain={'x': [0, 1], 'y': [0, 1]},
                                 value=np.around(risk_score, 2),
                                 mode="gauge+number",
                                 title={'text': f"{status}", 'font': {'size': 24}},
                                 gauge={'axis': {'range': [0, 1], 'tickwidth': 2, 'tickcolor': "grey"},
                                        'bar': {'color': "black"},'bordercolor': "black",
                                        'steps': [{'range': [0, 0.4933], 'color': "#1A85FF"}, #using colorblind friendly colors
                                                  {'range': [0.4933, 1], 'color': "#D41159"}],
                                        'threshold': {'line': {'color': "black", 'width': 1}, 'thickness': 1, 'value': 0.4933}}))
    
    return fig_gauge

if __name__ == '__main__':
    app.run_server(debug=True)

При создании приложения Dash вы не должны напортачить с обратными вызовами. Это функции, которые соединяют ввод и вывод вашего приложения, и они должны правильно соответствовать вашему макету. Таким образом, при написании ваших обратных вызовов и любых связанных с ними функций важно правильно продумать детали. Я очень рекомендую очаровательный канал данных на Youtube.

Еще одна вещь, на которую следует обратить внимание, — это URL-адрес, который приложение вызывает для получения прогноза. Это будет API, который мы развернули ранее. Это достигается в этих строках кода выше:

# the url of our deployed model api
url = "https://oc-project-7-fastapi.herokuapp.com/score"

# filtered data (client featres) to send to api
payload = {
              "NAME_INCOME_TYPE": data['NAME_INCOME_TYPE'].item(),
              "DAYS_CREDIT": data['DAYS_CREDIT'].item(),
              "DAYS_BIRTH": data['DAYS_BIRTH'].item(),
              "DAYS_EMPLOYED_PERC": data['DAYS_EMPLOYED_PERC'].item(),
              "REGION_RATING_CLIENT": data['REGION_RATING_CLIENT'].item(),
              "EXT_SOURCE_1": data['EXT_SOURCE_1'].item(),
              "EXT_SOURCE_2": data['EXT_SOURCE_2'].item(),
              "EXT_SOURCE_3": data['EXT_SOURCE_3'].item()
            }

# get the response from api (give_score function)
response = requests.post(url, json=payload)

# format the response in a json dictionary
data_from_api = response.json()

risk_score = data_from_api["risk_score"]
status = data_from_api["application_status"]

Остальная часть приложения похожа на тот, что был в моем API ранее, в том смысле, что ему нужны Procfile, runtime.txt и requirement.txt для работа. Не забудьте также добавить сюда test_set.csv.

После развертывания в Heroku панель инструментов теперь доступна и доступна через веб-браузер. Вот краткий обзор того, как это выглядит:

Видеть? Простой и понятный.

Вы можете поиграть с ним здесь: https://credit-scoring-dashboard-qq.herokuapp.com/

На этом путешествие по этому проекту заканчивается. Всегда есть возможности для дальнейшего совершенствования, и в будущем я могу вернуться к этому проекту, чтобы изучить больше функций разработки и настройки гиперпараметров. Таким образом, этот проект демонстрирует, как разработать и развернуть модель машинного обучения с оптимизированным конвейером и панелью мониторинга для практического варианта использования. В любом случае, спасибо, что присоединились ко мне в этом путешествии и удачной обработки данных!