Введение (с чего всё началось)

Началось всё с того, что я открыл для себя Kaggle. В частности, я принимаю участие в публичном соревновании Spaceship Titanic. Это более "молодая" версия классического Титаника. На момент написания этой статьи в соревновании принимают участие 2737 человек (команд). Код, продемонстированный в этой статье, позволил мне занять 697-е место в публичном рейтинге со второго сабмита. Я знаю, что он не идеален и работаю над этим.

Данные

Тренировочный датасет доступен по ссылке. Для того, чтобы его скачать, нужно стать участником соревнования. Кроме тренировочного датасета доступен тестовый датасет. По понятным причинам в нём отсутствует колонка с таргетом. Также присутствует пример выгрузки для сабмита (sample submission).

Анализ данных и подготовка признаков

Для анализа данных я использую Pandas Profiling и SweetWiz. Это очень мощные библиотеки, экономят массу времени.

Пример формирования отчёта с помощью Pandas Profiling

profile_train = ProfileReport(train_data, title="Train Data Profiling Report")
profile_train.to_file("train_profile.html")
profile_train.to_widgets()

Пример формирования отчёта с помощью SweetWiz

train_report = sv.analyze(train_data)

train_report.show_html(filepath='TRAIN_REPORT.html', 
            open_browser=True, 
            layout='widescreen', 
            scale=None)

train_report.show_notebook( w=None, 
                h=None, 
                scale=None,
                layout='widescreen',
                filepath=None)

Тренировочные данные сбалансированы по таргету, без дубликатов, присутствует определённое количество нулевых значений (я их не удаляю), также наблюдается корелляция по числовым признакам.

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

# Вытаскиваю номер палубы из номера каюты
def get_deck(cabin):
    if cabin is None:
        return None
    if isinstance(cabin, float):
        return None
    return cabin.split("/")[0]

#print(get_deck('F/1534/S'))
#print(get_deck("G/1126/P"))

train_data['deck'] = train_data.apply(lambda x: get_deck(x.Cabin), axis=1)
test_data['deck'] = test_data.apply(lambda x: get_deck(x.Cabin), axis=1)

# Вытаскиваю отдельно параметр side из номера кабины
def get_side(cabin):
    if cabin is None:
        return None
    if isinstance(cabin, float):
        return None
    return cabin.split("/")[2]

#print(get_side('F/1534/S'))
#print(get_side('G/1126/P'))

train_data['side'] = train_data.apply(lambda x: get_side(x.Cabin), axis=1)
test_data['side'] = test_data.apply(lambda x: get_side(x.Cabin), axis=1)

После этого удаляю из тренировочного датасета все поля, не являющиеся полезными для обучения модели и разделяю числовые и категориальные признаки

num_cols = ['Age','RoomService', 'FoodCourt','ShoppingMall', 'Spa', 'VRDeck']
cat_cols = ['HomePlanet', 'CryoSleep', 'Destination', 'VIP', 'deck', 'side']

Настройка pipelines

Pipelines - это один из самых мощных современных инструментов, используемых в проектах машинного обучения. Они значительно облегчают процесс подготовки данных и экономят кучу времени. Да, нужно приложить определённые усилия для того, чтобы понять, как они настраиваются и работают, изучить документацию. Но потраченное время непременно принесёт профит. Кроме того, в сети есть очень много полезных статей (кукбуков) по настройке пайплайнов. Мне, например, очень сильно помогла вот эта статья.

Я создал отдельно Pipeline для числовых признаков и Pipeline для категориальных признаков

num_pipeline = Pipeline(steps=[
    ('impute', SimpleImputer(strategy='median')),
    ('scale',StandardScaler())
])
cat_pipeline = Pipeline(steps=[
    ('impute', SimpleImputer(strategy='most_frequent')),
    ('one-hot',OneHotEncoder(handle_unknown='ignore', sparse=False))
])

Для числовых признаков использовал SimpleImputer, заполняющий пропуски медианными значениями, для категориальных признаков пропуски заполняются наиболее часто втречающимися значениями. Кроме того, применил StandardScaler и OneHotEncoder. Кстати, в официальной документации scikit-learn есть прекрасная статья, в которой предоставлен сравнительный анализ многих скейлеров.

На основе полученных пайплайнов собираю ColumnTransformer.

col_trans = ColumnTransformer(transformers=[
    ('num_pipeline',num_pipeline,num_cols),
    ('cat_pipeline',cat_pipeline,cat_cols)
    ],
    remainder='drop',
    n_jobs=-1)

Настройка Pipelines и Optuna

Optuna - это очень мощный фреймворк, позволяющий автоматизировать подбор гиперпараметров для модели в процессе машинного обучения. Для того, чтобы нормально настроить и начать использовать в проекте, нужно потратить определённое время. Благо, на официальном сайте разработчиков есть обширная документация и видео, облегчающие процесс обучения. Ниже код, который я использую в своём проекте.

def objective(trial):    
    # Список гиперпараметров для перебора (для CatBoostClassifier)
    param = {
        "objective": trial.suggest_categorical("objective", ["Logloss", "CrossEntropy"]),
        "colsample_bylevel": trial.suggest_float("colsample_bylevel", 0.01, 0.1),
        "depth": trial.suggest_int("depth", 1, 12),
        "boosting_type": trial.suggest_categorical("boosting_type", ["Ordered", "Plain"]),
        "bootstrap_type": trial.suggest_categorical(
            "bootstrap_type", ["Bayesian", "Bernoulli", "MVS"]
        ),
        "used_ram_limit": "3gb",
    }

    if param["bootstrap_type"] == "Bayesian":
        param["bagging_temperature"] = trial.suggest_float("bagging_temperature", 0, 10)
    elif param["bootstrap_type"] == "Bernoulli":
        param["subsample"] = trial.suggest_float("subsample", 0.1, 1)
    
    # Определяю модель машинного обучения, которой передаются гиперпараметры
    estimator = CatBoostClassifier(**param, verbose=False)

    # Прикручиваю пайплайны
    clf_pipeline = Pipeline(steps=[
            ('col_trans', col_trans),
            ('model', estimator)
        ])
    # Код для вычисления метрики качества.
    # В этом проекте я вычисляю Accuracy методом кросс-валидации
    accuracy = cross_val_score(clf_pipeline, features_train, target_train, cv=3, scoring= 'accuracy').mean()
    return accuracy

# Инициализирую подбора гиперпараметров.
# Можно сохранять все промежуточные результаты в БД SQLLite (этот код я закомментировал)
#study = optuna.create_study(direction="maximize", study_name="CBC-2023-01-14-14-30", storage='sqlite:///db/CBC-2023-01-14-14-30.db')
study = optuna.create_study(direction="maximize", study_name="CBC-2023-01-14-14-30")
# Запускаю процесс подбора гиперпараметров
study.optimize(objective, n_trials=300)
# Вывожу на экран лучший результат
print(study.best_trial)

Несколько слов о градиентном бустинге

В переменную estimator можно сохранять любую ML модель. Я экспериментировал с DecisionTreeClassifier, RandomForestClassifier, LogisticRegression, но более-менее существенных результатов в соревнованиях смог добиться после того, как начал использовать модели градиентного бустинга. Разобраться в материале мне очень помогла вот эта статья. Я экспериментировал с LGBMClassifier, XGBClassifier, CatBoostClassifier. В прилагаемом примере использован CatBoostClassifier.

Заключение

Код, который у меня получился на текущий момент, доступен на GitHub. Он не идеален. Я продолжаю его совершенствовать (также как и свои навыки). Например,

  • он довольно медленный. Процесс перебора может длиться от 2 до 5 часов;

  • хочу ещё отработать несколько идей, связанных с генерацией синтетических признаков.

Комментарии (2)


  1. StriganovSergey
    23.01.2023 11:05

    Подскажите, пожалуйста, а на каком железе работает "от 2 до 5 часов" и существует ли возможность ускорить обработку увеличивая обьем оперативной памяти, количество видеокарт и ядер процессоров? ( понятно, что алгоритмически ускорить, наверно, можно, но я не понимаю на каком железе все это запускаете)


    1. vgorbatikov Автор
      23.01.2023 11:39

      Я тестил на своём рабочем ноуте с процессором Intel(R) Core(TM) i7-7500U CPU @ 2.70GHz 2.90 GHz и 16,0 GB RAM. В коде я установил лимит "used_ram_limit": "3gb" - можно увеличить. Также Optuna позволяет настроить параллельные процессы. Но я двигаюсь по другому пути - 75% успешного результата записит от правильной обработки признаков и лишь 25% успеха зависит от правильной комбинации гиперпараметров. Optuna подбирает гиперпараметры.