Всем привет! В этой небольшой статье хочу поделиться своим первым опытом работы с ML-моделями.

С чего все началось?

В начале 3 семестра я попал на проект ВУЗа, связанный с НС. Прошел курс по сеткам, пробежался по Pytorch и приступил к задачам на проекте. В процессе своего спринта решил параллельно изучать классический ML, где собственно выяснил, что "Hello world!" в мире машинного обучения является работа с датасетом титаник (предсказать выжил ли пассажир или нет). После этого ознакомился с Kaggle и полетел!

Titanic - Machine Learning from Disaster

При открытии "компетитив" сразу же наткнулся на тот самый кораблик и приступил к работе. Код писал в Jupyter-ноутбук.

Импортируем библиотеки

import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, precision_score, recall_score, confusion_matrix, roc_auc_score, ConfusionMatrixDisplay

Читаем наши данные

train = pd.read_csv("/kaggle/input/titanic/train.csv")
test  = pd.read_csv("/kaggle/input/titanic/test.csv")

train.head()
.head()
.head()

Разведочный анализ данных (EDA)

features = ['PassengerId', 'Pclass', 'Sex', 'Age', 'SibSp', 'Parch', 'Cabin', 'Embarked']
target = 'Survived'

train_set = train[features + [target]].copy()
test_set = test[features].copy() 

plt.figure(figsize=(12, 5))

plt.subplot(1, 2, 1)  
survival_by_sex = train_set.groupby('Sex')['Survived'].mean() * 100
plt.bar(['Male', 'Female'], survival_by_sex.values, color=['blue', 'red'])
plt.title('Survival by Sex')
plt.ylabel('Survival %')

plt.subplot(1, 2, 2) 
survival_by_pclass = train_set.groupby('Pclass')['Survived'].mean() * 100
plt.bar(['1st', '2nd', '3rd'], survival_by_pclass.values, color=['gold', 'silver', 'brown'])
plt.title('Survival by Class')
plt.ylabel('Survival %')

plt.tight_layout()
plt.show()

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

Анализ выживших по полу и классу
Анализ выживших по полу и классу

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

Обрабатываем пропуски в данных

train_set['Age'] = train_set['Age'].fillna(train_set['Age'].mean())
train_set['Sex'] = train_set['Sex'].map({'male': 0, 'female': 1})
# заполняем пропуски модой
train_set['Embarked'] = train_set['Embarked'].fillna(train_set['Embarked'].mode()[0])
# объединяю имеющийся датасет с one-hot-encoding, разделил embarked на embarked_c, embarkded_q и _s
train_set = pd.concat([train_set, pd.get_dummies(train_set['Embarked'], prefix='Embarked', dtype=int)], axis=1)
# удаляю
train_set = train_set.drop(['Embarked'], axis=1)
# создаю столбик, который указывает на наличие кабины у людей, проверяю с помощью notnal(не является ли nan?), который возвращается true/false (1/0 с помощью astype(int))
train_set['HasCabin'] = train_set['Cabin'].notna().astype(int)
# удаляю cabin
train_set = train_set.drop(['Cabin'], axis=1)

У некоторых пассажиров был пропущен возраст, поэтому заполняет пропуски средним значением по всем пассажирам. Колонка "Sex" содержала значения "male" и "female" - категориальные признаки, с которыми обычным модели не очень дружат. Переведем их в вещественные значения, а именно 0 и 1 (осуществил бинарное кодирование). Далее "Embarked" заполняем модой и производим one-hot-encoding. Суть метода: каждая уникальная категория становится отдельным бинарным признаком, принимающим значение 1, если объект принадлежит к этой категории, и 0 — если нет. В нашем случаи это будет выглядеть так:

Пример "OHE"
Пример "OHE"

После этого удаляю обычную колонку "Embarked". Перейдем к колонке "Cabin". В этом столбце достаточно много пропусков (около 70%). В начале думал просто удалить эту колонку, но при удалении мы теряем большое количество информации, которая может повлиять на результат нашей модели, поэтому принял решение извлечь какую-то пользу из этой колонки, а именно провел проверку на наличие информации о каюте. Преобразуем сложные категориальные признаки в бинарные ('C85', 'C123', 'E46' в 1 и 0). Для этого создал новую колонку "HasCabin", а предыдущую удаляем.

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

test_set['Age'] = test_set['Age'].fillna(test_set['Age'].mean())
test_set['Sex'] = test_set['Sex'].map({'male': 0, 'female': 1})
test_set['Embarked'] = test_set['Embarked'].fillna(test_set['Embarked'].mode()[0])
test_set = pd.concat([test_set, pd.get_dummies(test_set['Embarked'], prefix='Embarked', dtype=int)], axis=1)
test_set = test_set.drop(['Embarked'], axis=1)
test_set['HasCabin'] = test_set['Cabin'].notna().astype(int)
test_set = test_set.drop(['Cabin'], axis=1)

Валидация

X_full = train_set.drop(['Survived', 'PassengerId'], axis=1)
y_full = train_set['Survived']

X_train, X_val, y_train, y_val = train_test_split(
    X_full, y_full, 
    test_size=0.2,  
    random_state=42,
    stratify=y_full  
)

Для валидации модели я подготовил данные, выделив отдельно признаки и целевую переменную. Затем разделил датасет на обучающую (80%) и валидационную (20%) выборки с сохранением исходного соотношения выживших и погибших. Это позволяет обучать модель на одной части данных, а проверять её качество — на другой, ранее не виденной модели.

Построение RandomForest

test_passenger_ids = test_set['PassengerId']
X_test_kaggle = test_set.drop(['PassengerId'], axis=1)

model = RandomForestClassifier(n_estimators=200, class_weight='balanced', random_state=42)
model.fit(X_train, y_train)

y_pred = model.predict(X_val)
y_pred_prob = model.predict_proba(X_val)[:, 1]

acc = accuracy_score(y_val, y_pred)
precision = precision_score(y_val, y_pred, zero_division=0)
recall = recall_score(y_val, y_pred, zero_division=0)
confm = confusion_matrix(y_val, y_pred)
roc_auc = roc_auc_score(y_val, y_pred_prob)

fig, axes = plt.subplots(1, 3, figsize=(15, 4))

disp = ConfusionMatrixDisplay(confusion_matrix=confm, display_labels=['Погиб', 'Выжил'])
disp.plot(ax=axes[0], cmap='Blues')
axes[0].set_title('Матрица ошибок (валидация)')

print(f'Accuracy:  {acc:.3f}')
print(f'Precision: {precision:.3f}')
print(f'Recall:    {recall:.3f}')
print(f'AUC-ROC:   {roc_auc:.3f}')

Подготовил данные для отправки на Kaggle (об этом чуть ниже будет написано), сохранив идентификаторы пассажиров, и обучил RandomForestClassifier с балансировкой классов на тренировочных данных. Затем оценил модель на валидационной выборке, рассчитав ключевые метрики качества: точность, полноту, прецизионность и AUC-ROC, а также визуализировал матрицу ошибок для анализа распределения правильных и ошибочных предсказаний модели.

Результат RandomForest
Результат RandomForest

Построение LogisticRegression

X = test_set.drop(['Survived'], axis=1)
y = test_set['Survived']

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

model = LogisticRegression(class_weight='balanced', random_state=42, max_iter=1000)
model.fit(X_train, y_train)

y_pred = model.predict(X_test)
lg_y_pred_prob = model.predict_proba(X_test)[:, 1]

acc = accuracy_score(y_test, y_pred)
precision = precision_score(y_test, y_pred, zero_division=0)
recall = recall_score(y_test, y_pred, zero_division=0)
conf_matr = confusion_matrix(y_test, y_pred)
log_roc_auc = roc_auc_score(y_test, lg_y_pred_prob)

fig, axes = plt.subplots(1, 3, figsize=(15, 4))

disp1 = ConfusionMatrixDisplay(confusion_matrix=conf_matr, display_labels=['Погиб', 'Выжил'])
disp1.plot(ax=axes[0], cmap='Blues')
axes[0].set_title('Стандартная матрица ошибок')
disp1.plot
plt.show()

print(f'Accuracy: {acc:.3f}')
print(f'Precision: {precision:.3f}')
print(f'Recall: {recall:.3f}')
print(f'AUC: {log_roc_auc:.3f}')

Все аналогично, но используем другую модель.

Результат LogisticRegression
Результат LogisticRegression

Анализ результатов

Сравнительный анализ показывает компромисс между двумя подходами к классификации:

Random Forest демонстрирует более консервативную стратегию:

  • Общая точность выше на 1.7% (0.821 против 0.804)

  • Исключительно высокий Precision (0.828) означает, что 83% предсказанных выживших действительно выжили — модель очень осторожна в положительных предсказаниях

  • Однако низкий Recall (0.716) указывает на проблему: каждый четвертый реальный выживший был ошибочно классифицирован как погибший

  • Матрица ошибок подтверждает: всего 11 ложных отрицаний против 21 у логистики

Логистическая регрессия реализует более чувствительный подход:

  • Высокий Recall (0.811) показывает, что модель находит более 80% всех выживших

  • Лучший AUC-ROC (0.879 против 0.858) свидетельствует о более качественном вероятностном ранжировании

  • Модель совершает иной тип ошибок: меньше ложных отрицаний (14 против 21), но больше ложных срабатываний

Random Forest минимизирует ошибки первого рода (ложные надежды), а логистическая регрессия — ошибки второго рода (пропущенные жизни).

Оформление решения для kaggle

res = pd.DataFrame({
    'PassengerId': test_passenger_ids,  # сохранённые ранее ID
    'Survived': y_pred_kaggle,  # предсказания на реальных тестовых данных
})

# Сохранение для загрузки на Kaggle
res.to_csv('submission.csv', index=False)

Создаем новый датафрейм с двумя столбцами: идентификаторы пассажиров и предсказанный статус выживания, а затем сохраняем результат в CSV-файл для загрузки на Kaggle. Модель выбираем опираясь на свой взгляд после анализа всех вариантов решений.

Итог

Есть и другие варианты решение, где будут уделять больше внимания обработке данных:

  1. Извлечение титула из имени (MrMrsMissMasterRare).

  2. Создание FamilySize (SibSp + Parch + 1).

  3. Флаг IsAlone (FamilySize == 1).

  4. Палуба (Deck) из первой буквы Cabin.

  5. И тд.

Так же можно рассмотреть варианты с другими моделями: Catboost, lightgbm, xgboost и другие.

Для первой работы с сетом я решил опираться на свои мысли и не углубляться в чужие решения, чтобы увидеть результат своих действий.

Как-то так выглядит титаник в 2026 году. Буду рад услышать критику и мнение со стороны.

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