Введение (с чего всё началось)
Началось всё с того, что я открыл для себя 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 часов;
хочу ещё отработать несколько идей, связанных с генерацией синтетических признаков.
StriganovSergey
Подскажите, пожалуйста, а на каком железе работает "от 2 до 5 часов" и существует ли возможность ускорить обработку увеличивая обьем оперативной памяти, количество видеокарт и ядер процессоров? ( понятно, что алгоритмически ускорить, наверно, можно, но я не понимаю на каком железе все это запускаете)
vgorbatikov Автор
Я тестил на своём рабочем ноуте с процессором Intel(R) Core(TM) i7-7500U CPU @ 2.70GHz 2.90 GHz и 16,0 GB RAM. В коде я установил лимит "used_ram_limit": "3gb" - можно увеличить. Также Optuna позволяет настроить параллельные процессы. Но я двигаюсь по другому пути - 75% успешного результата записит от правильной обработки признаков и лишь 25% успеха зависит от правильной комбинации гиперпараметров. Optuna подбирает гиперпараметры.