Kaggle і Titanic — ще одне рішення задачі за допомогою Python

Хочу поділитися досвідом роботи з завданням відомого конкурсу машинного навчання від Kaggle. Цей конкурс позиціонується як конкурс для початківців, а у мене якраз не було майже ніякого практичного досвіду у цій галузі. Я трохи знав теорію, але з реальними даними справи майже не мав і з пітоном щільно не працював. У результаті, витративши пару передноворічних вечорів, набрав 0.80383 (перша чверть рейтингу).



Загалом ця стаття для початківців від вже почав.



Титанік

Включаємо підходящу для роботи музику і починаємо дослідження.



Конкурс «титаніка» був вже неодноразово відзначений на Хабре. Особливо хотілося б відзначити останню статтю з списку — виявляється, що дослідження даних може бути не менш інтригуючим ніж хороший детективний роман, а відмінний результат (0.81340) можна отримати не тільки Random Forest класифікатором.



Також хотілося б відзначити статтю про іншому конкурсі. З неї можна зрозуміти яким саме чином повинен працювати мозок дослідника і що більша частина часу повинна бути приділена попереднього аналізу та обробці даних.



Інструментарій

Для вирішення завдання я використовую Python-стек технологій. Цей підхід не є єдино можливим: існують R, Matlab, Mathematica, Azure Machine Learning, Apache Weka, Java-ML і я думаю список можна ще довго продовжувати. Використання Python має ряд переваг: бібліотек дійсно багато і вони відмінної якості, а оскільки більшість з них являють собою обгортки над C-кодом вони ще й досить швидкі. До того ж побудована модель може бути легко запущена в експлуатацію.

Повинен зізнатися, що я не дуже великий прихильник скриптових нестрого-типізованих мов, але багатство бібліотек python не дозволяє його як-небудь ігнорувати.

Запускати всі будемо під Linux (Ubuntu 14.04). Знадобляться: python 2.7, seaborn, matplotlib, sklearn, xgboost, pandas. Взагалі обов'язкові тільки pandas і sklearn, а інші потрібні для ілюстрації.

Під Linux бібліотеки для Python можна встановлювати двома способами: штатним пакетним (deb) менеджером або через питоновскую утиліту pip.

Встановлення deb-пакетами простіше і швидше, але найчастіше бібліотеки там застарілі (стабільність понад усе).

# Установка буде проведена в /usr/lib/python2.7/dist-packages/
$ sudo apt-get install python-matplotlib


Установка пакетів через pip довше (буде здійснена компіляція), але з неї можна розраховувати на отримання свіжих версій пакетів.

# Установка буде проведена в /usr/local/lib/python2.7/dist-packages/
$ sudo pip install matplotlib


Так яким саме чином краще встановлювати пакети? Я використовую компроміс: масивні і вимагають безлічі залежностей для складання NumPy SciPy ставлю з DEB-пакетів.

$ sudo apt-get install python 
$ sudo apt-get install python-pip
$ sudo apt-get install python-numpy
$ sudo apt-get install python-scipy
$ sudo apt-get install ipython


А інші, більш легкі пакети, встановлюю через pip.

$ sudo pip install pandas 
$ sudo pip install matplotlib==1.4.3
$ sudo pip install skimage
$ sudo pip install sklearn
$ sudo pip install seaborn
$ sudo pip install statsmodels
$ sudo pip install xgboost


Якщо я щось забув — то усі потрібні пакети як правило легко обчислюються і встановлюються аналогічним способом.

Користувачам інших платформ необхідно зробити аналогічні дії по установці пакетів. Але існує варіант гораздно простіше: вже є прекомпилированные дистрибутиви з пітоном та майже усіма потрібними бібліотеками. Сам я їх не пробував, але вони виглядають обнадійливо.



Дані

Завантажити вихідні дані завдання і подивимося що ж нам видали.

$ wc -l train.csv test.csv 
892 train.csv
419 test.csv
1311 total


Даних прямо скажемо не дуже багато — всього 891 пасажир train-вибіркою, і 418 в test-вибіркою (одна строчка йде на заголовок зі списком полів).

Відкриємо train.csv в будь-якому табличному процесорі (я використовую LibreOffice Calc) щоб візуально подивитися дані.

$ libreoffice --calc train.csv


Бачимо наступне:
  • Вік заповнений не у всіх
  • Квитки мають якийсь дивний і неконсистентний формат
  • В іменах є титул (міс, містер, місіс і т. д.)
  • Номери кают прописані мало у кого (тут є леденить душу історія чому)
  • існуючих номерах кают мабуть прописаний код палуби (так і є, як виявилося
  • Також, згідно статті, в номері каюти зашифрована сторона
  • Сортуємо по імені. Видно, що багато подорожували сім'ями, і видно масштаб трагедії — часто сім'ї виявилися розділені, вижила лише частина
  • Сортуємо по квитку. Видно, що за одним кодом квитка подорожувало одразу кілька людей, причому часто — з різними прізвищами. Побіжний погляд начебто показує, що люди з однаковим номером квитка часто поділяють одну й ту ж доля.
  • У частині пасажирів не проставлений порт посадки


Начебто приблизно все ясно, переходимо непостредственно до роботи з даними.

Завантаження даних

Щоб не зашумлять дальшейший код відразу наведу всі використовувані імпорти:
Шапка скрипта# coding=utf8

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import xgboost as xgb
import re
import seaborn as sns
from sklearn.linear_model import LinearRegression
from sklearn.linear_model import LogisticRegression
from sklearn.linear_model import SGDClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.grid_search import GridSearchCV
from sklearn.сусідів import KNeighborsClassifier
from sklearn.naive_bayes import GaussianNB
from sklearn.svm import SVC
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.cross_validation import StratifiedKFold
from sklearn.cross_validation import KFold
from sklearn.cross_validation import cross_val_score
from sklearn.preprocessing import StandardScaler
from sklearn import metrics

pd.set_option('display.width', 256)


Напевно велика частина моєї мотивації до написання цієї статті викликана захопленням від роботи з пакетом pandas. Я знав про існування цієї технології, але не міг навіть уявити наскільки приємно з нею працювати. Pandas — це Excel в командному рядку зручною функціональністю по вводу/виводу і обробки табличних даних.

Завантажуємо обидві вибірки.

train_data = pd.read_csv("data/train.csv")
test_data = pd.read_csv("data/test.csv")


Збираємо обидві вибірки (train-вибірку і test-вибірку) в одну сумарну all-вибірку.

all_data = pd.concat([train_data, test_data])


Навіщо це робити, адже в test-вибіркою відсутнє поле з результатирующим прапором виживання? Повна вибірка корисна для обчислення статистики по всім іншим полям (середні, медіани, квантили, мінімуми і максимуми), а також зв'язку між цими полями. Тобто, враховуючи статистику тільки за train-вибіркою, ми фактично ігноруємо частина дуже корисною для нас інформації.

Аналіз даних

Аналіз даних в Python можна здійснювати кількома способами, наприклад:

  • Вручну готувати дані і виводити через matplotlib
  • Використовувати все готове seaborn
  • Використовувати текстовий вивід з угрупованням pandas


Ми спробуємо всі три, але спочатку запустимо найпростіший текстовий варіант. Виведемо статистику виживаності залежно від класу і статі.

print("===== survived by class and sex")
print(train_data.groupby(["Pclass", "Sex"])["Survived"].value_counts(normalize=True))

Результат===== survived by class and sex
Pclass Sex Survived
1 female 1 0.968085
0 0.031915
male 0 0.631148
1 0.368852
2 female 1 0.921053
0 0.078947
male 0 0.842593
1 0.157407
3 female 0 0.500000
1 0.500000
male 0 0.864553
1 0.135447
dtype: float64



Бачимо, що в шлюпки дійсно садили спочатку жінок — шанс жінки на виживаність становить 96.8%, 92.1% і 50% залежно від класу квитка. Шанс на виживання чоловіки гораздно нижче і становить відповідно 36.9%, 15.7% і 13.5%.

З допомогою pandas швидко порахуємо зведення за всіма числовим полям обох вибірок — окремо по чоловіках і жінкам.

describe_fields = ["Age", "Fare", "Pclass", "SibSp", "Parch"]

print("===== train: males")
print(train_data[train_data["Sex"] == "male"][describe_fields].describe())

print("===== test: males")
print(test_data[test_data["Sex"] == "male"][describe_fields].describe())

print("===== train: females")
print(train_data[train_data["Sex"] == "female"][describe_fields].describe())

print("===== test: females")
print(test_data[test_data["Sex"] == "female"][describe_fields].describe())

Результат===== train: males
Age Fare Pclass SibSp Parch
count 453.000000 577.000000 577.000000 577.000000 577.000000
mean 30.726645 25.523893 2.389948 0.429809 0.235702
std 14.678201 43.138263 0.813580 1.061811 0.612294
min 0.420000 0.000000 1.000000 0.000000 0.000000
25% 21.000000 7.895800 2.000000 0.000000 0.000000
50% 29.000000 10.500000 3.000000 0.000000 0.000000
75% 39.000000 26.550000 3.000000 0.000000 0.000000
max 80.000000 512.329200 3.000000 8.000000 5.000000
===== test: males
Age Fare Pclass SibSp Parch
count 205.000000 265.000000 266.000000 266.000000 266.000000
mean 30.272732 27.527877 2.334586 0.379699 0.274436
std 13.389528 41.079423 0.808497 0.843735 0.883745
min 0.330000 0.000000 1.000000 0.000000 0.000000
25% 22.000000 7.854200 2.000000 0.000000 0.000000
50% 27.000000 13.000000 3.000000 0.000000 0.000000
75% 40.000000 26.550000 3.000000 1.000000 0.000000
max 67.000000 262.375000 3.000000 8.000000 9.000000
===== train: females
Age Fare Pclass SibSp Parch
count 261.000000 314.000000 314.000000 314.000000 314.000000
mean 27.915709 44.479818 2.159236 0.694268 0.649682
std 14.110146 57.997698 0.857290 1.156520 1.022846
min 0.750000 6.750000 1.000000 0.000000 0.000000
25% 18.000000 12.071875 1.000000 0.000000 0.000000
50% 27.000000 23.000000 2.000000 0.000000 0.000000
75% 37.000000 55.000000 3.000000 1.000000 1.000000
max 63.000000 512.329200 3.000000 8.000000 6.000000
===== test: females
Age Fare Pclass SibSp Parch
count 127.000000 152.000000 152.000000 152.000000 152.000000
mean 30.272362 49.747699 2.144737 0.565789 0.598684
std 15.428613 73.108716 0.887051 0.974313 1.105434
min 0.170000 6.950000 1.000000 0.000000 0.000000
25% 20.500000 8.626050 1.000000 0.000000 0.000000
50% 27.000000 21.512500 2.000000 0.000000 0.000000
75% 38.500000 55.441700 3.000000 1.000000 1.000000
max 76.000000 512.329200 3.000000 8.000000 9.000000



Видно, що з середнім та перцентилями все в цілому рівненько. Але у чоловіків по вибірках відрізняються максимуми за віком і по вартості квитка. У жінок в обох вибірках також є відмінність у максимумі віку.

Збірка дайджесту за даними

Зберемо невеликий дайджест по повній вибірці — він буде потрібен для подальшого перетворення вибірок. Зокрема нам потрібні значення які будуть підставлені замість відсутніх, а також різні довідники для перекладу текстових числові значення. Справа в тому, що багато класифікатори можуть працювати тільки з числами, тому якимось чином ми повинні перевести категоріальні ознаки в числові, але незалежно від способу перетворення нам будуть потрібні довідники цих значень.

class DataDigest:

def __init__(self):
self.ages = None
self.fares = None
self.titles = None
self.cabins = None
self.families = None
self.tickets = None

def get_title(name):
if pd.isnull(name):
return "Null"

title_search = re.search(' ([A-Za-z]+)\.', name)
if title_search:
return title_search.group(1).lower()
else:
return "Ні"


def get_family(row):
last_name = row["Name"].split(",")[0]
if last_name:
family_size = 1 + row["Parch"] + row["SibSp"]
if family_size > 3:
return "{0}_{1}".format(last_name.lower(), family_size)
else:
return "nofamily"
else:
return "unknown"


data_digest = DataDigest()
data_digest.ages = all_data.groupby("Sex")["Age"].median()
data_digest.fares = all_data.groupby("Pclass")["Fare"].median()
data_digest.titles = pd.Index(test_data["Name"].apply(get_title).unique())
data_digest.families = pd.Index(test_data.apply(get_family, axis=1).unique())
data_digest.cabins = pd.Index(test_data["Cabin"].fillna("unknown").unique())
data_digest.tickets = pd.Index(test_data["Ticket"].fillna("unknown").unique())


Невелике пояснення по полях дайджесту:
  • ages — довідник медіан віку в залежності від статі;
  • fares — довідник медіан вартості квитків залежно від класу квитка;
  • titles — довідник назв;
  • families — довідник ідентифікаторів сімейств (прізвище + кількість членів сім'ї);
  • cabins — довідник ідентифікаторів кают;
  • tickets — довідник ідентифікаторів квитків.


Довідники для відновлення відсутніх даних (медіани) ми будуємо за комбінованої вибірки. А ось довідники для перекладу категоріальних ознак — тільки за тестовими даними. Ідея полягала в наступному: припустимо у train-наборі у нас є прізвище «Іванов», а в test-наборі цього прізвища немає. Знання всередині класифікатора про те що «Іванов» вижив (або не вижив) ніяк не допоможе в оцінці test-набору, оскільки в test-наборі цього прізвища все одно немає. Тому в довідник додаємо лише ті прізвища, які є в test-наборі. Ще більш правильним способом буде додавати в довідник тільки перетин ознак (тільки ті ознаки, які є в обох наборах) — я спробував, але результат верифікації погіршився на 3 відсотки.

Виділяємо ознаки

Тепер нам потрібно виділити ознаки. Як вже було сказано — багато класифікатори вміють працювати тільки з числами, тому нам потрібно:

  • Перевести категорії числове подання
  • Виділити неявні ознаки, тобто ті, які явно не задані (титул, палуба)
  • щось зробити з відсутніми значеннями


Є два способи перетворення категоріального ознаки в числовий. Можемо розглянути задачу на прикладі підлоги пасажира.

У першому варіанті ми просто змінюємо пів на деяке число, наприклад, ми можемо замінити female на 0, а male на 1 (круглячок і паличка — дуже зручно запам'ятовувати). Такий варіант не збільшує число ознак, однак всередині ознаки для його значень тепер з'являється відношення «більше» і «менше». У разі коли значень багато, таке несподіване властивість ознаки не завжди бажано і може призвести до проблем у геометричних класифікаторах.

Другий варіант перетворення — завести дві колонки «sex_male» і «sex_female». У разі чоловічої статі ми будемо присвоювати sex_male=1, sex_female=0. У разі жіночої статі навпаки: sex_male=0, sex_female=1. Відносин «більше»та«менше» тепер ми уникаємо, однак тепер у нас стало більше ознак, а чим більше ознак тим більше даних для тренування класифікатора нам потрібно — ця проблема відома як «прокляття розмірності». Особливо складною стає ситуація коли значень ознак дуже багато, наприклад ідентифікатори квитків, в таких випадках можна наприклад відкинути рідко зустрічаються значення підставивши замість них який-небудь спеціальний тег — таким чином зменшивши підсумкове кількість ознак після розширення.

Невеликий спойлер: ми робимо ставки в першу чергу на класифікатор Random Forest. По-перше всі так роблять, а по-друге він не вимагає розширення ознак, стійкий до масштабу значень ознак і розраховується швидко. Незважаючи на це, ознаки ми готуємо в загальному універсальному вигляді оскільки головна поставлена перед собою мету — дослідити принципи роботи з sklearn і можливості.

Таким чином якісь категоріальні ознаки ми замінюємо на числа, якісь розширюємо, які-то і замінюємо і розширюємо. Ми не економимо на числі ознак, оскільки далі ми завжди можемо вибрати, які саме з них будуть брати участь у роботі.

В більшості посібників і прикладів з мережі вихідні набори даних дуже вільно модифікуються: вихідні колонки замінюються новими значеннями, непотрібні колонки видаляються і т. д. В цьому немає ніякої необхідності поки у нас є достатня кількість оперативної пам'яті: завжди краще додавати нові ознаки до набору ніяк не змінюючи існуючі дані, оскільки pandas згодом завжди дозволить нам вибрати тільки потрібні.

Створюємо метод для перетворення наборів даних.

def get_index(item, index):
if pd.isnull(item):
return -1

try:
return index.get_loc(item)
except KeyError:
return -1


def munge_data(data, digest):
# Age - заміна пропусків на медіану залежно від статі
data["AgeF"] = data.apply(lambda r: digest.ages[r["Sex"]] if pd.isnull(r["Age"]) else r["Age"], axis=1)

# Fare - заміна пропусків на медіану у залежності від класу
data["FareF"] = data.apply(lambda r: digest.fares[r["Pclass"]] if pd.isnull(r["Fare"]) else r["Fare"], axis=1)

# Gender - заміна
genders = {"male": 1, "female": 0}
data["SexF"] = data["Sex"].apply(lambda s: genders.get(s))

# Gender - розширення
gender_dummies = pd.get_dummies(data["Sex"], prefix="SexD", dummy_na=False)
data = pd.concat([data, gender_dummies], axis=1)

# Embarkment - заміна
embarkments = {"U": 0, "S": 1, "С": 2, "Q": 3}
data["EmbarkedF"] = data["Embarked"].fillna("U").apply(lambda e: embarkments.get(e))

# Embarkment - розширення
embarkment_dummies = pd.get_dummies(data["Embarked"], prefix="EmbarkedD", dummy_na=False)
data = pd.concat([data, embarkment_dummies], axis=1)

# Кількість родичів на борту
data["RelativesF"] = data["Parch"] + data["SibSp"]

# Людина-одинак?
data["SingleF"] = data["RelativesF"].apply(lambda r: 1 if r == 0 else 0)

# Deck - заміна
decks = {"U": 0, "A": 1, "B": 2, "С": 3, "D": 4, "Е": 5, "F": 6, "G": 7, "T": 8}
data["DeckF"] = data["Cabin"].fillna("U").apply(lambda c: decks.get(c[0], -1))

# Deck - розширення
deck_dummies = pd.get_dummies(data["Cabin"].fillna("U").apply(lambda c: c[0]), prefix="DeckD", dummy_na=False)
data = pd.concat([data, deck_dummies], axis=1)

# Titles - розширення
title_dummies = pd.get_dummies(data["Name"].apply(lambda (n): get_title(n)), prefix="TitleD", dummy_na=False)
data = pd.concat([data, title_dummies], axis=1)

# амена текстів на індекс з відповідного довідника або -1, якщо значення в довіднику немає (розширювати не будемо)
data["CabinF"] = data["Cabin"].fillna("unknown").apply(lambda c: get_index(c, digest.cabins))

data["TitleF"] = data["Name"].apply(lambda (n): get_index(get_title(n), digest.titles))

data["TicketF"] = data["Ticket"].apply(lambda t: get_index(t, digest.tickets))

data["FamilyF"] = data.apply(lambda r: get_index(get_family®, digest.families), axis=1)

# для статистики
age_bins = [0, 5, 10, 15, 20, 25, 30, 40, 50, 60, 70, 80, 90]
data["AgeR"] = pd.cut(data["Age"].fillna(-1), bins=age_bins).astype(object)

return data


Невелике пояснення по додаванню нових ознак:
  • додаємо власний індекс каюти
  • додаємо власний індекс палуби (вирізаємо з номера каюти)
  • додаємо власний індекс квитка
  • додаємо власний індекс титулу (вирізаємо з імені)
  • додаємо власний індекс ідентифікатора сім'ї (формуємо з прізвища та чисельності сімейства)


Загалом, ми додаємо в ознаки взагалі все, що приходить в голову. Видно що деякі ознаки дублюють один одного (наприклад розширення і заміна підлоги), деякі явно корелюють один з одним (клас квитка і вартість квитка), деякі явно безглузді (навряд чи порт посадки впливає на виживаність). Розбиратися з усім цим будемо пізніше — коли будемо проводити відбір ознак для навчання.

Перетворимо обидва наявних набору і також знову створюємо об'єднаний набір.

train_data_munged = munge_data(train_data, data_digest)
test_data_munged = munge_data(test_data, data_digest)
all_data_munged = pd.concat([train_data_munged, test_data_munged])


Хоча ми націлилися на використання Random Forest хочеться спробувати і інші класифікатори. А з ними є следующеая проблема: багато класифікатори чутливі до масштабу ознак. Іншими словами, якщо у нас є одна ознака зі значеннями [-10,5] і другий ознака зі значеннями [0,10000] то однакова в процентному відношенні помилка на обох ознаках буде приводити до великої відмінності в абсолютному значенні і класифікатор буде трактувати другий ознака як більш важливий.

Щоб уникнути цього ми наводимо всі числові (а інших у нас вже немає) ознаки до однаковою шкалою [-1,1] і нульовим середнім значенням. Зробити це в sklearn можна дуже просто.

scaler = StandardScaler()
scaler.fit(all_data_munged[predictors])

train_data_scaled = scaler.transform(train_data_munged[predictors])
test_data_scaled = scaler.transform(test_data_munged[predictors])


Спочатку ми обчислюємо коефіцієнти масштабування (знову знадобився повний набір), а потім масштабується обидва набору індивідуально.

Вибір ознак

Ну і прийшов момент коли ми можемо відібрати ті ознаки, з якими будемо працювати далі.

predictors = ["Pclass",
"AgeF",
"TitleF",
"TitleD_mr", "TitleD_mrs", "TitleD_miss", "TitleD_master", "TitleD_ms", 
"TitleD_col", "TitleD_rev", "TitleD_dr",
"CabinF",
"DeckF",
"DeckD_U", "DeckD_A", "DeckD_B", "DeckD_C", "DeckD_D", "DeckD_E", "DeckD_F", "DeckD_G",
"FamilyF",
"TicketF",
"SexF",
"SexD_male", "SexD_female",
"EmbarkedF",
"EmbarkedD_S", "EmbarkedD_C", "EmbarkedD_Q",
"FareF",
"SibSp", "Parch",
"RelativesF",
"SingleF"]


Просто ставимо коментар на непотрібних і запускаємо навчання. Які саме не потрібні — вирішувати вам.

Ще раз аналіз

Оскільки тепер у нас з'явилася колонка в якій прописаний діапазон в який потрапляє вік пасажира — оцінимо виживання в залежності від віку (діапазону).

print("===== survived by age")
print(train_data.groupby(["AgeR"])["Survived"].value_counts(normalize=True))

print("===== survived by gender and age")
print(train_data.groupby(["Sex", "AgeR"])["Survived"].value_counts(normalize=True))

print("===== survived by class and age")
print(train_data.groupby(["Pclass", "AgeR"])["Survived"].value_counts(normalize=True))

Результат===== survived by age
AgeR Survived
(0, 5] 1 0.704545
0 0.295455
(10, 15] 1 0.578947
0 0.421053
(15, 20] 0 0.656250
1 0.343750
(20, 25] 0 0.655738
1 0.344262
(25, 30] 0 0.611111
1 0.388889
(30, 40] 0 0.554839
1 0.445161
(40, 50] 0 0.616279
1 0.383721
(5, 10] 0 0.650000
1 0.350000
(50, 60] 0 0.595238
1 0.404762
(60, 70] 0 0.764706
1 0.235294
(70, 80] 0 0.800000
1 0.200000
dtype: float64
===== survived by gender and age
Sex AgeR Survived
female (0, 5] 1 0.761905
0 0.238095
(10, 15] 1 0.750000
0 0.250000
(15, 20] 1 0.735294
0 0.264706
(20, 25] 1 0.755556
0 0.244444
(25, 30] 1 0.750000
0 0.250000
(30, 40] 1 0.836364
0 0.163636
(40, 50] 1 0.677419
0 0.322581
(5, 10] 0 0.700000
1 0.300000
(50, 60] 1 0.928571
0 0.071429
(60, 70] 1 1.000000
male (0, 5] 1 0.652174
0 0.347826
(10, 15] 0 0.714286
1 0.285714
(15, 20] 0 0.870968
1 0.129032
(20, 25] 0 0.896104
1 0.103896
(25, 30] 0 0.791667
1 0.208333
(30, 40] 0 0.770000
1 0.230000
(40, 50] 0 0.781818
1 0.218182
(5, 10] 0 0.600000
1 0.400000
(50, 60] 0 0.857143
1 0.142857
(60, 70] 0 0.928571
1 0.071429
(70, 80] 0 0.800000
1 0.200000
dtype: float64
===== survived by class and age
Pclass AgeR Survived
1 (0, 5] 1 0.666667
0 0.333333
(10, 15] 1 1.000000
(15, 20] 1 0.800000
0 0.200000
(20, 25] 1 0.761905
0 0.238095
(25, 30] 1 0.684211
0 0.315789
(30, 40] 1 0.755102
0 0.244898
(40, 50] 1 0.567568
0 0.432432
(50, 60] 1 0.600000
0 0.400000
(60, 70] 0 0.818182
1 0.181818
(70, 80] 0 0.666667
1 0.333333
2 (0, 5] 1 1.000000
(10, 15] 1 1.000000
(15, 20] 0 0.562500
1 0.437500
(20, 25] 0 0.600000
1 0.400000
(25, 30] 0 0.580645
1 0.419355
(30, 40] 0 0.558140
1 0.441860
(40, 50] 1 0.526316
0 0.473684
(5, 10] 1 1.000000
(50, 60] 0 0.833333
1 0.166667
(60, 70] 0 0.666667
1 0.333333
3 (0, 5] 1 0.571429
0 0.428571
(10, 15] 0 0.571429
1 0.428571
(15, 20] 0 0.784615
1 0.215385
(20, 25] 0 0.802817
1 0.197183
(25, 30] 0 0.724138
1 0.275862
(30, 40] 0 0.793651
1 0.206349
(40, 50] 0 0.933333
1 0.066667
(5, 10] 0 0.812500
1 0.187500
(50, 60] 0 1.000000
(60, 70] 0 0.666667
1 0.333333
(70, 80] 0 1.000000
dtype: float64



Бачимо, що шанси на виживання великі у дітей до 5 років, а вже в літньому віці шанс вижити падає з віком. Але це не відноситься до жінок — у жінки шанс на виживання великий в будь-якому віці.

Спробуємо візуалізацію від seaborn — дуже красиві картинки вона дає, хоча я більше звик до тексту.

sns.pairplot(train_data_munged, vars=["AgeF", "Pclass", "SexF"], hue="Survived", dropna=True)
sns.plt.show()




Красиво, але наприклад кореляція в парі «клас-пол» не дуже наочна.

Оцінимо важливість наших ознак алгоритмом SelectKBest.

selector = SelectKBest(f_classif, k=5)
selector.fit(train_data_munged[predictors], train_data_munged["Survived"])

scores = -np.log10(selector.pvalues_)

plt.bar(range(len(predictors)), scores)
plt.xticks(range(len(predictors)), predictors, rotation='vertical')
plt.show()




Ось вам стаття з описом того, як саме він це робить. У параметрах SelectKBest можна вказати і інші стратегії.

В принципі, ми і так вже все знаємо — дуже важливий підлогу. Титули важливі — але у них сильна коррелляция з підлогою. Важливий клас квитка і якимось чином палуба «F».

Оцінка класифікації

Перед тим як починати запуск будь-якої класифікації, нам потрібно зрозуміти яким чином ми будемо оцінювати її. У випадку з конкурсами Kaggle все дуже просто: ми просто читаємо їх правила. У разі Титаніка оцінкою буде служити ставлення правильних оцінок класифікатора до загального числа пасажирів. Іншими словами ця оцінка називається accuracy.

Але перш ніж відправляти результат класифікації за test-вибіркою на оцінку в Kaggle, нам непогано б спочатку самим зрозуміти хоча б приблизне якість роботи нашого класифікатора. Зрозуміти це ми зможемо тільки використовуючи train-вибірку, оскільки тільки вона містить промарковані дані. Але залишається питання — як саме?

Часто в прикладах можна побачити щось подібне:

classifier.fit(train_X, train_y)
predict_y = classifier.predict(train_X)
return metrics.accuracy_score(train_y, predict_y)


Тобто ми навчаємо класифікатор на train-наборі, після чого на ньому ж його і перевіряємо. Безсумнівно, в якійсь мірі, це дає певну оцінку якості роботи класифікатора, але в цілому цей підхід невірний. Класифікатор повинен описувати не дані на якому його тренували, а якусь модель яка ці дані згенерувала. В іншому випадку класифікатор відмінно підлаштовується під train-вибірку, при перевірці на ній же показує відмінні результати, проте при перевірці на якомусь іншому наборі даних з тріском зливається. Що і називається overfitting.

Правильним підходом буде поділ наявного train-набору на деяку кількість шматків. Ми можемо взяти кілька з них, натренувати на них класифікатор, після чого перевірити його роботу на що залишилися. Виробляти цей процес можна кілька разів просто тасуючи шматки. У sklearn цей процес називається кросс-валідацією.

Можна вже уявити в голові цикли які будуть розділяти дані, проводити навчання та оцінювання, але фішка в тому, що все що потрібно для реалізації цього в sklearn — це визначити стратегію.

cv = StratifiedKFold(train_data["Survived"], n_folds=3, shuffle=True, random_state=1)


Тут ми визначаємо досить складний процес: тренувальні дані будуть розділені на три шматки, причому записи будуть потрапляти в кожен шматок випадковим чином (щоб нівелювати можливу залежність від порядку), крім того стратегія відстежить щоб ставлення класів в кожному шматку було приблизно рівним. Таким чином ми будемо виробляти три виміри по шматках 1+2 vs 3, 1+3 vs 2, 2+3 vs 1 — після цього зможемо отримати середню оцінку акуратності класифікатора (що буде характеризувати якість роботи), а також дисперсію оцінки (що буде характеризувати стабільність його роботи).

Класифікація

Тепер протестуємо роботу різних класифікаторів.

KNeighborsClassifier:
alg_ngbh = KNeighborsClassifier(n_neighbors=3)
scores = cross_val_score(alg_ngbh, train_data_scaled, train_data_munged["Survived"], cv=cv, n_jobs=-1)
print("Accuracy (k-сусідів): {}/{}".format(scores.mean(), scores.std()))


SGDClassifier:
alg_sgd = SGDClassifier(random_state=1)
scores = cross_val_score(alg_sgd, train_data_scaled, train_data_munged["Survived"], cv=cv, n_jobs=-1)
print("Accuracy (sgd): {}/{}".format(scores.mean(), scores.std()))


SVC:
alg_svm = SVC(C=1.0)
scores = cross_val_score(alg_svm, train_data_scaled, train_data_munged["Survived"], cv=cv, n_jobs=-1)
print("Accuracy (svm): {}/{}".format(scores.mean(), scores.std()))


GaussianNB:
alg_nbs = GaussianNB()
scores = cross_val_score(alg_nbs, train_data_scaled, train_data_munged["Survived"], cv=cv, n_jobs=-1)
print("Accuracy (naive bayes): {}/{}".format(scores.mean(), scores.std()))


LinearRegression:
def linear_scorer(estimator, x, y):
scorer_predictions = estimator.predict(x)

scorer_predictions[scorer_predictions > 0.5] = 1
scorer_predictions[scorer_predictions <= 0.5] = 0

return metrics.accuracy_score(y, scorer_predictions)

alg_lnr = LinearRegression()
scores = cross_val_score(alg_lnr, train_data_scaled, train_data_munged["Survived"], cv=cv, n_jobs=-1,
scoring=linear_scorer)
print("Accuracy (linear regression): {}/{}".format(scores.mean(), scores.std()))



Метод linear_scorer потрібен оскільки LinearRegression — це регресія повертає будь-яке дійсне число. Відповідно ми поділяємо шкалу кордоном 0.5 і наводимо будь числа до двох класів — 0 і 1.

LogisticRegression:
alg_log = LogisticRegression(random_state=1)
scores = cross_val_score(alg_log, train_data_scaled, train_data_munged["Survived"], cv=cv, n_jobs=-1,
scoring=linear_scorer)
print("Accuracy (logistic regression): {}/{}".format(scores.mean(), scores.std()))


RandomForestClassifier:
alg_frst = RandomForestClassifier(random_state=1, n_estimators=500, min_samples_split=8, min_samples_leaf=2)
scores = cross_val_score(alg_frst, train_data_scaled, train_data_munged["Survived"], cv=cv, n_jobs=-1)
print("Accuracy (random forest): {}/{}".format(scores.mean(), scores.std()))


У мене вийшло приблизно такAccuracy (k-сусідів): 0.698092031425/0.0111105442611
Accuracy (sgd): 0.708193041526/0.0178870678457
Accuracy (svm): 0.693602693603/0.018027360723
Accuracy (naive bayes): 0.791245791246/0.0244349506813
Accuracy (linear regression): 0.805836139169/0.00839878201296
Accuracy (logistic regression): 0.806958473625/0.0156323100754
Accuracy (random forest): 0.827160493827/0.0063488824349


Алгоритм Random Forest переміг і дисперсія в нього непогана — здається він стабільний.

Ще краще

Ніби все добре і можна відправляти результат, але залишився один мутний момент: у кожного класифікатора є свої параметри — як нам зрозуміти, що ми вибрали найкращий варіант? Без сумніву можна довго сидіти і перебирати параметри вручну — але що якщо поручить цю роботу комп'ютера?

alg_frst_model = RandomForestClassifier(random_state=1)
alg_frst_params = [{
"n_estimators": [350, 400, 450],
"min_samples_split": [6, 8, 10],
"min_samples_leaf": [1, 2, 4]
}]
alg_frst_grid = GridSearchCV(alg_frst_model, alg_frst_params, cv=cv, refit=True, verbose=1, n_jobs=-1)
alg_frst_grid.fit(train_data_scaled, train_data_munged["Survived"])
alg_frst_best = alg_frst_grid.best_estimator_
print("Accuracy (random forest auto): {} with params {}"
.format(alg_frst_grid.best_score_, alg_frst_grid.best_params_))


Виходить ще краще!Accuracy (random forest auto): 0.836139169473 with params {'min_samples_split': 6, 'n_estimators': 350, 'min_samples_leaf': 2}


Підбір можна зробити ще тонше при наявності часу і бажання — змінивши параметри, або використовуючи іншу стратегію підбору, наприклад, RandomizedSearchCV.

Пробуємо xgboost

Всі хвалять xgboost — давайте спробуємо і його.

ald_xgb_model = xgb.XGBClassifier()
ald_xgb_params = [
{"n_estimators": [230, 250, 270],
"max_depth": [1, 2, 4],
"learning_rate": [0.01, 0.02, 0.05]}
]
alg_xgb_grid = GridSearchCV(ald_xgb_model, ald_xgb_params, cv=cv, refit=True, verbose=1, n_jobs=1)
alg_xgb_grid.fit(train_data_scaled, train_data_munged["Survived"])
alg_xgb_best = alg_xgb_grid.best_estimator_
print("Accuracy (xgboost auto): {} with params {}"
.format(alg_xgb_grid.best_score_, alg_xgb_grid.best_params_))


Чомусь тренування зависала при використанні всіх ядер, тому я обмежився одним потоком (n_jobs=1), але і в однопоточному режимі тренування і класифікація xgboost працює дуже швидко.

Результат теж непоганийAccuracy (xgboost auto): 0.835016835017 with params {'n_estimators': 270, 'learning_rate': 0.02, 'max_depth': 2}


Результат

Класифікатор обраний, розраховані параметри — залишилося сформувати результат і відправити на перевірку в Kaggle.

alg_test = alg_frst_best

alg_test.fit(train_data_scaled, train_data_munged["Survived"])

predictions = alg_test.predict(test_data_scaled)

submission = pd.DataFrame({
"PassengerId": test_data["PassengerId"],
"Survived":predictions
})

submission.to_csv("titanic-submission.csv", index=False)


Взагалі варто відзначити кілька моментів у подібних змаганнях, які здалися мені цікавими:

  • Боротьба у верхівці йде на соті долі відсотків — тому вирішує навіть одна правильна чи неправильна класифікація з верифікаційного набору. Також на результат впливають випадкові модифікації параметрів алгоритму;
  • Кращий результат при локальній крос-валідації не гарантує кращого результату при перевірці верифікаційного набору. Буває що реалізація начебто розумною гіпотези покращує локальний результат крос-валідації і погіршує результат перевірки на Kaggle;
  • Вищеперелічений два пункти призводять до третього — скрипт і набір для відправки на верифікацію повинні знаходиться під управлінням будь-якої системи управління версіями — у разі прориву в рахунку потрібно обов'язково коммититься із зазначенням отриманого результату в описі коміта;
  • Саме змагання дещо штучно. У реальному світі у вас немає верифікаційного набору — тільки розмічена вибірка на якій можна проводити крос-валідацію, остаточне якість роботи алгоритму оцінюється як правило побічно, наприклад, за кількістю скарг користувачів;
  • Не дуже зрозуміла максимальна ступінь якості оцінки яку можна отримати машинним навчанням на завданні — розмір навчального набору не дуже високий, а сам процес неочевидний. Насправді — що таке процес у разі втечі пасажирів з корабля? Цілком імовірно, що не всі засаджені в вижили човни, також як цілком ймовірно, що не всі неусаженные в човни загинули — частина процесу повинна бути дуже хаотична і повинна чинити опір якого-небудь моделювання.


Глава, в якій автор одночасно здивувався і просвітлення

Переглядаючи топ конкурсантів, не можна не помітити людей які набрали 1 (всі відповіді правильні) — причому у деяких це вийшло з першої ж спроби.



На розум приходить наступний варіант: хтось зареєстрував учетку з якої почав перебором підбирати (дозволяється не більше 10 спроб у день) правильні відповіді. Якщо я правильно розумію це є різновидом завдань про зважуванні.

Однак подумавши ще трохи, не можна не посміхнутися своїй здогадці: ми говоримо про завдання, відповіді якої вже давно відомі! У самому справі, загибель Титаніка була потрясінням для сучасників, і цій події були присвячені фільми, книги і документалістика. І швидше за все десь є повний поіменний перелік пасажирів Титаніка з описом їх долі. Але до машинного навчання це вже не відноситься.

Однак з цього можна і треба зробити висновок, який я збираюся застосувати в наступних конкурсах — необов'язково (якщо це не заборонено правилами конкурсу) обмежуватися тільки цими які видав організатор. Наприклад, за відомим часу і місця можна виявити погодні умови, стан ринків цінних паперів, курси валют, є день святковим — іншими словами можна одружити дані від організаторів з якими доступними наборами публічних даних, які можуть допомогти в описі характеристик моделі.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.