Проблема потерявшихся и бездомных животных стоит особенно остро в крупных городах. Актуальной задачей является поиск хозяев, социализация, и в случае не нахождения оных, пристройство животного в новые руки. Наиболее близким переводом на русский, отражающим деятельность центра, думаю, будет термин "приют для животных", который осуществляет поиск новых или старых хозяев, а также привлечение добровольцев для временной передержки и помощи. Данной центр является самым большим в США приютом для животных, проводящий политику "no-kill" (животные не усыпляются по прошествии какого-то времени нахождения в приюте).
Наборы данных взят непосредственно с сайта центра Austin Animal Center, 19 апреля 2018 года (датасет обновляется ежедневно и ведется с 2013 года). Данные были доступны по ссылкам на сайте центра: раз и два, если не получается скачать (у меня несколько последних дней не открывались, возможно из-за РКН), то Яндекс-диск: Outcomes и Intakes
Датасет содержит информацию о более чем 80 тысяч животных, присутствуют несколько категорий - собаки, кошки, птицы и остальные. Есть данные о возрасте, имени (если есть), времени поступлении в приют, и так далее. Данные содержатся в двух таблицах, рассмотрим подробнее:
Таблица outcomes:
Таблица intakes: Часть данных повторяется, помимо этого:
Задачей данного проекта является поиск закономерностей и прогнозирование судьбы животных, попавших в приют. Понимание того, от каких признаков зависит пристройство животного, во-первых, могло бы позволить центру лучше прогнозировать ресурсы, во-вторых, возможно, проводить какие-то социальные кампании.
import warnings
warnings.filterwarnings('ignore')
%matplotlib inline
from matplotlib import pyplot as plt
import seaborn as sns
import os
import re
import numpy as np
import pandas as pd
from sklearn.metrics import classification_report, f1_score, make_scorer
from sklearn.preprocessing import StandardScaler, OneHotEncoder, LabelEncoder, FunctionTransformer
from sklearn.model_selection import GridSearchCV, train_test_split, StratifiedKFold, validation_curve, learning_curve
from sklearn.ensemble import RandomForestClassifier
sns.set_style('whitegrid')
outcomes = pd.read_csv('Austin_Animal_Center_Outcomes.csv', sep=',', index_col=False)
outcomes.columns
Переименуем сразу столбцы для более удобного обращения к таблице.
outcomes.rename(columns={'Animal ID': 'animal_id', 'Name': 'name', 'DateTime':'outcome_date', 'MonthYear':'outcome_monthyear', \
'Date of Birth':'date_of_birth','Outcome Type':'outcome_type', 'Outcome Subtype':'outcome_subtype', \
'Animal Type':'animal_type','Sex upon Outcome':'outcome_sex','Age upon Outcome':'outcome_age', \
'Breed':'breed', 'Color':'color'}, inplace=True)
Посмотрим на первые строчки таблицы и простую статистику по данным, а также на число уникальных значений в признаках.
outcomes.head(3)
outcomes.info()
outcomes.describe(include = ['object', 'int64', 'float64'])
Посмотрим на распределение целевой переменной по категориям:
outcomes['outcome_type'].value_counts()
Видно, что целевая переменная не сбалансирована по классам.
Работаем с временными признаками: приводим даты к временному формату, исправляем и переименовываем "MonthYear", сортируем по дате:
outcomes['outcome_date'] = outcomes['outcome_date'].apply(pd.to_datetime)
outcomes['date_of_birth'] = outcomes['date_of_birth'].apply(pd.to_datetime)
outcomes = outcomes.sort_values(by='outcome_date')
outcomes['outcome_monthyear'] = outcomes['outcome_date'].apply(lambda x: x.year*100+x.month)
Выкидываем столбец 'outcome_subtype', заполняем пропуски в именах и создаем бинарный признак is_name, выкидываем строчки с пропущенными значениями в 'outcome_sex', 'outcome_age' и 'outcome_type':
outcomes.drop(['outcome_subtype'], axis=1, inplace=True)
outcomes['name'] = outcomes['name'].fillna('Unknown').astype(str)
outcomes['is_name'] = outcomes['name'].apply(lambda x: 1 if x != 'Unknown' else 0)
outcomes.dropna(inplace=True)
Удалим все записи с повторяющимся animal_id, предварительно создав колонку с признаком is_uniq (по умолчанию drop_duplicates оставляет первую из дублированных записей, а так как мы отсортировали таблицу по дате, это будет запись о первом пристройстве животного):
# проверям animal_id на повторы, параметр keep=False означает что мы ищем все записи, встречающиеся больше одного раза
# (если оставить keep по умолчанию, то первая запись из дублированных не будет отмечаться как True)
outcomes['is_uniq'] = outcomes['animal_id'].duplicated(keep=False).map({False:1, True:0})
# выбрасываем дублированные записи кроме первого встреченного раза
outcomes.drop_duplicates(['animal_id'], inplace=True, keep='first')
Помимо таблицы Outcomes, в нашем распоряжении есть данные о поступлении животных в центр, посмотрим, сможем ли мы взять что-нибудь полезное из нее.
intakes = pd.read_csv('Austin_Animal_Center_Intakes.csv', sep=',', index_col=False)
Также переименуем columns:
intakes.rename(columns={'Animal ID': 'animal_id', 'Name': 'name', 'DateTime':'intake_date', 'MonthYear':'intake_monthyear', \
'Date of Birth':'date_of_birth','Intake Type':'intake_type', 'Intake Condition':'intake_condition', \
'Animal Type':'animal_type','Sex upon Intake':'intake_sex','Age upon Intake':'intake_age', \
'Breed':'breed', 'Color':'color', 'Found Location':'found_location'}, inplace=True)
intakes.head(3)
Попробуем понять, какая информация, которой не было в первой таблице, интересна. На первый взгляд кажется полезной информация о времени, когда животное поступило в центр, о месте где его нашли, состоянии и типе (Type и Condition) на момент поступления, поле (кастрированный/стерилизованный на момент поступления или нет), возрасте. Объединим таблицы, взяв из данных о поступлении интересные нам и предварительно выкинув дубликаты и переименовав/преобразовав данные о дате.
intakes['intake_date'] = intakes['intake_date'].apply(pd.to_datetime)
intakes = intakes.sort_values(by='intake_date')
intakes['intake_monthyear'] = intakes['intake_date'].apply(lambda x: x.year*100+x.month)
intakes.drop_duplicates(['animal_id'], inplace=True)
data = outcomes.merge(intakes[['animal_id', 'intake_condition', 'intake_type', 'found_location', 'intake_sex',\
'intake_monthyear', 'intake_date', 'intake_age']], how='inner', left_on='animal_id', right_on='animal_id')
data.info()
Данные о точном времени поступления, рождения и пристройстве оставим, возможно они еще пригодятся для генерирования новых признаков. Создаем списки признаков по категориям для более удобной работы:
cat_features = ['name', 'color','breed','animal_type','outcome_sex', \
'intake_condition', 'intake_type', 'found_location', 'intake_sex',]
month_features = ['outcome_monthyear', 'intake_monthyear']
time_features = ['outcome_date','date_of_birth', 'intake_date']
age_features = ['outcome_age', 'intake_age']
bin_features = ['is_name','is_uniq']
plt.figure(figsize=(12,6))
ax = sns.countplot(data['outcome_type'])
ax.set_xlabel('Outcome Type', fontsize=18)
ax.set_ylabel('Count', fontsize=18)
ax.set_yticklabels(ax.get_yticklabels(), rotation=0)
plt.show;
Видим, что распределение классов целевой переменной сильно несбалансированное.
plt.figure(figsize=(12,6))
data['date_of_birth'].value_counts().sort_values().plot.line();
plt.figure(figsize=(12,6))
data['outcome_date'].value_counts().resample('D').sum().plot.line();
plt.figure(figsize=(12,6))
data['intake_date'].value_counts().resample('D').sum().plot.line();
Ясно видны годовые пики активности в деятельности центра.
Сначала посмотрим на распределения бинарных и категориальных признаков (с числом категорий < 10) и целевой переменной:
def plot_cat(feature, loc='best', yscale='linear'):
plt.figure(figsize=(12,6))
plt.xlabel(feature, fontsize=12)
ax = sns.countplot(data[feature], hue=data['outcome_type'])
ax.set(yscale=yscale)
ax.legend(loc=loc)
plot_cat('intake_sex')
plot_cat('outcome_sex')
Из этих двух предыдущих графиков видно, что большую часть поступающих животных стерилизуют (Это обычная практика в приютах/центрах для животных).
plot_cat('intake_condition', loc=1)
Видно, что больные и раненные животные чаще подвергаются эвтаназии, чем найденные в здоровом состоянии.
plot_cat('intake_type')
К сожалению, почти всех поступивших диких животных усыпляют.
plot_cat('animal_type', loc=1)
Интересное наблюдение: кошек гораздо реже, чем собак, возвращают владельцам.
plot_cat('is_uniq', loc=2)
plot_cat('is_name')
Животных с именами гораздо чаще возвращают владельцу (интерпретируемо, так как если известно имя, то скорее всего на кошке или собаке есть медальон с именем и адресом хозяина, или просто известно чье это животное). Что интереснее - животных с именами чаще и пристраивают новым владельцам. (Вешайте медальоны с адресом на кошек и собак!)
Теперь посмотрим на распределения топ-10 пород, цвета и места где нашли, в зависимости от целевой переменной:
outcome_types = list(set(data['outcome_type'].values))
def find_top10(feature):
out_dict={}
for i in outcome_types:
out_dict[i] = list(data[data['outcome_type'] == i][feature].value_counts().head(10).keys())
return out_dict
def plot_top10(feature):
for idx, outcome in enumerate(outcome_types):
top_feature_list = find_top10(feature)[outcome]
data_x = data[data[feature].apply(lambda x: x in top_feature_list)][data['outcome_type']==outcome][feature]
order=data_x.value_counts().index
plt.figure(figsize=(16,4))
plt.xticks(rotation=75, fontsize=12)
ax = 'ax{}'.format(idx)
ax = sns.countplot(data_x, order=order)
ax.set_title(outcome, fontsize=12)
ax.set_xlabel(xlabel='')
#ax.tick_params(rotation=75, labelsize=12)
plot_top10('breed')
В топе возвращаемых владельцу - породы собак, диких животных в основном выпускают или подвергают эвтаназии (видимо, пораненных), неоижданно в топе пристраиваемых и умерших порода кошки "обычная домашняя".
plot_top10('color')
Каких-то особо интересных закономерностей нет, кроме того, что везде преобладают окрасы черный, черно-белый и коричневый.
plot_top10('found_location')
Тоже особо интересного ничего нет, кроме того, что почти всех домашних животных находят в городе Austin.
plot_top10('outcome_age')
Интересные закономерности, пристраивают в основном щенков и котят, возвращают взрослых животных.
Особенностью данных являются разнородные признаки с преобладанием категориальных, причем с большим количеством значений. Наблюдаются некоторые зависимости между признаками и целевой переменной, например, животных с именами забирают больше, найденных диких больше подвергают эвтаназии, и так далее. Однако, то, что кастрированные и стерилизованные животные составляют большинство при пристройстве, означает лишь что, что большинство животных попавших в приют, как известно, стерилизуют, так что не все зависимости полезны.
У нас задача многоклассовой классификации, причем с несбалансированными классами. Accuracy (просто доля верных ответов) сразу отпадает, так как нам будут важны результаты классификации по различным классам. Это удобно смотреть по таблице classification report, которая выводит результаты presicion (точность, насколько точно класс отделяется от других), recall (полнота, насколько хорошо, т.е "полно" мы находим этот класс) и f1 меру (среднее гармоническое между точностью и полнотой) по всем классам. Вот f1 меру и будем использовать (с микроусреднением по классам из-за несбалансированности целевой переменной) для измерения результатов моделей, периодически сверяясь с classification report.
Особенностью данных является разные категориальные признаки, соотвественно нам подойдут модели, которые умеют работать с категориальными признаками, и возможно, с большим количеством значений. Я думаю, нам подойдет случайный лес, возможно, линейные модели и градиентный бустинг. Выберем случайный лес за его простоту, качество и интерпретируемость.
Первым делом, преобразуем данные о возрасте.
Ясно видно, что возраст животных в 'outcome_age' и 'intake_age' дан приблизительно, да и к тому же в разных единицах измерения (2 weeks, 1 year и так далее), и является на данный момент категориальными и неуопрядоченным. (Модель не будет "знать" что месяц меньше года и т.д). Для более точного анализа будет лучше вычислить возраст напрямую (на момент пристройства животного), используя данные в столбце 'date_of_birth'.
data['outcome_age'] = round((data['outcome_date'] - data['date_of_birth'])/np.timedelta64(1,'W'),2)
data['intake_age'] = round((data['intake_date'] - data['date_of_birth'])/np.timedelta64(1,'W'),2)
Посмотрим, что получилось:
data['outcome_age'].describe()
data['intake_age'].describe()
Есть записи с отрицательным возрастом, видимо, в записях о дне рождения были ошибки, заменим такие записи нулем:
data[data['outcome_age']<0]['outcome_age'] = 0
data[data['intake_age']<0]['intake_age'] = 0
target = data['outcome_type']
map_dir = {'Adoption':0, 'Died':1, 'Disposal':2, 'Euthanasia':3, 'Missing': 4, \
'Relocate':5,'Return to Owner':6, 'Rto-Adopt':7, 'Transfer':8}
map_rev = {0:'Adoption', 1:'Died', 2:'Disposal', 3:'Euthanasia', 4:'Missing', \
5:'Relocate', 6:'Return to Owner', 7:'Rto-Adopt', 8:'Transfer'}
y_ = target.map(map_dir)
y = y_.values
y_.value_counts()
Категориальные признаки преобразуем с помощью LabelEncoder, так как лес не любит слишком много признаков (а их получится много если будем использовать технику One Hot Encoder).
def lab_encoder(df, columns):
for col in columns:
label_encoder = LabelEncoder()
df[col] = label_encoder.fit_transform(df[col])
return df
data_cat = data[cat_features].copy()
data_le = lab_encoder(data_cat, cat_features)
Соединим преобразованные признаки с бинарными и с возрастом в неделях.
data_rf = pd.concat([data_le, data[age_features], data[bin_features], data[month_features]], axis=1)
Разделим выборки на обучающую и отложенную, так как классы не сбалансированы, будем использовать параметр stratify:
X_train_rf, X_holdout_rf, y_train_rf, y_holdout_rf = train_test_split(data_rf, y, test_size=0.3,
random_state=17, stratify=y)
В параметрах случайного леса укажем class_weight='balanced'. Для оценивания качества модели будем использовать f1_score, для этого создадим scorer из metrics.f1_score, укажем микроусреднение по классам. При разбиенни по фолдам в кроссвалидации будем учитывать дисбаланс классов с помощью StratifiedKFold.
rf = RandomForestClassifier(class_weight='balanced')
skf = StratifiedKFold(n_splits=3, shuffle=True, random_state=17)
f1_scorer = make_scorer(f1_score, average='micro')
rf_params={'n_estimators':[100, 150, 300], 'min_samples_leaf':[2,3,5]}
grid_rf = GridSearchCV(rf, rf_params, n_jobs=-1, cv=skf, verbose=1, scoring=f1_scorer)
%%time
grid_rf.fit(X_train_rf, y_train_rf)
print ('Best score: ' , grid_rf.best_score_)
print ('Best params: ' , grid_rf.best_params_)
print ('Test std mean: ' , np.array(grid_rf.cv_results_['std_test_score']).mean())
Посмотрим на важность признаков с точки зрения леса:
feat_importance = pd.DataFrame(X_train_rf.columns, columns = ['features'])
feat_importance['value'] = grid_rf.best_estimator_.feature_importances_
feat_importance.sort_values('value')[::-1]
Признак "время пребывания в приюте"
Выше мы видели, что временные признаки intake_month и outcome_month важны, попробуем скомбинировать их создав новый признак.
data['time_in'] = round((data['outcome_date'] - data['intake_date'])/np.timedelta64(1,'D'),2)
Проверим (вдруг опять ошибки в данных):
data['time_in'].describe()
Заменим кривые значения нулями:
data[data['time_in']<0]['time_in'] = 0
Признак цвет + порода
Предположение: так как цвет и порода неплохо оцениваются моделью, сделаем на основе этих двух признаков новый.
data['color_breed'] = data['color'] + ' ' + data['breed']
Признак "есть mix в названии породы", признак "помесь двух пород"
Интуитивное предположение: может быть, для пристройства важна чистопородность кошки или собаки. Эту информацию можно извлечь из колонки 'breed': Mix - не чистопородное животное, запись двух пород через слэш - помесь этих двух пород.
data['is_mix'] = data['breed'].apply(lambda x: 1 if 'mix' in x.lower() else 0)
data['crossbreed'] = data['breed'].apply(lambda x: 1 if '/' in x else 0)
Поссмотрим, что получилось:
data.sample(5)
columns = cat_features + ['color_breed']
data_le_new = lab_encoder(data, columns)
data_rf_new = pd.concat([data_le_new[columns], data['crossbreed'], data['is_mix'], data['time_in'], \
data['outcome_monthyear'], data['outcome_age'], data[bin_features]], axis=1)
X_train, X_holdout, y_train, y_holdout = train_test_split(data_rf_new, y, test_size=0.3,
random_state=17, stratify=y)
rf = RandomForestClassifier(class_weight='balanced')
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=17)
f1_scorer = make_scorer(f1_score, average='micro')
rf_params={'n_estimators':[100, 150, 300], 'min_samples_leaf':[2,3,5]}
grid_rf = GridSearchCV(rf, rf_params, n_jobs=-1, cv=skf, verbose=1, scoring=f1_scorer)
%%time
grid_rf.fit(X_train, y_train)
print ('Best score: ' , grid_rf.best_score_)
print ('Best params: ' , grid_rf.best_params_)
print ('Test std mean: ' , np.array(grid_rf.cv_results_['std_test_score']).mean())
feat_importance = pd.DataFrame(X_train.columns, columns = ['features'])
feat_importance['value'] = grid_rf.best_estimator_.feature_importances_
feat_importance.sort_values('value')[::-1]
Как мы видим, качество модели на кросс-валидации подросло! С новым признаком "время, проведенное в приюте", мы также угадали. Также сочетание 'цвет + порода' неплох (можно было бы попробовать использовать на этом признаке TfIdf). А вот дворняги это, помеси или чистопородные, не очень важно.
def plot_with_err(x, data, **kwargs):
mu, std = data.mean(1), data.std(1)
lines = plt.plot(x, mu, '-', **kwargs)
plt.fill_between(x, mu - std, mu + std, edgecolor='none',
facecolor=lines[0].get_color(), alpha=0.2)
Построим кривые валидации, будем менять сложность модели изменяя параметры.
f1_scorer = make_scorer(f1_score, average='micro')
rf_val = RandomForestClassifier(class_weight='balanced', random_state=17)
Посмотрим на зависимость от числа деревьев.
n = np.linspace(50, 500, 5).astype(int)
val_train, val_test = validation_curve(rf_val, X_train, y_train,
'n_estimators', n, cv=skf,
scoring=f1_scorer, n_jobs=-1)
plot_with_err(n, val_train, label='training scores')
plot_with_err(n, val_test, label='validation scores')
plt.xlabel('n_estimators'); plt.ylabel('f1_score')
plt.legend();
Посмотрим на зависимость от числа объектов в листе.
n = np.linspace(1, 10, 10).astype(int)
val_train, val_test = validation_curve(rf_val, X_train, y_train,
'min_samples_leaf', n, cv=skf,
scoring=f1_scorer, n_jobs=-1)
plot_with_err(n, val_train, label='training scores')
plot_with_err(n, val_test, label='validation scores')
plt.xlabel('min_samples_leaf'); plt.ylabel('f1_score')
plt.legend();
Усложнение модели не приводит к росту качества.
Построим обучающие кривые.
def plot_learning_curve(min_samples_leaf=2, n_estimators=300):
train_sizes = np.linspace(0.05, 1, 20)
rf_learn = RandomForestClassifier(class_weight='balanced', min_samples_leaf=min_samples_leaf, n_estimators = n_estimators)
N_train, val_train, val_test = learning_curve(rf_learn, X_train, y_train, train_sizes=train_sizes, cv=skf,
scoring=f1_scorer, n_jobs=-1)
plot_with_err(N_train, val_train, label='training scores')
plot_with_err(N_train, val_test, label='validation scores')
plt.xlabel('Training Set Size'); plt.ylabel('f1 score')
plt.legend()
plot_learning_curve()
Видим рост на кросс-валидации при увеличении датасета, возможно новые данные помогут.
Сделаем оценку на отложенной выборке, используя лучшую модель из предыдущего шага.
best_rf = grid_rf.best_estimator_
y_predict = best_rf.predict(X_holdout)
report = classification_report(y_holdout, y_predict)
print(report, '\n', map_rev)
Как мы видим, модель все-таки не очень хорошо различает малочисленные классы, но качество на отложенной выборке хорошее.
Итак, мы видим, что хоть модель и старалась оптимизировать f1 score, малочисленные классы (потерявшиеся животные, выпущенные в другом месте и возвращенные владельцу взявшему на адаптацию) она различает очень плохо. Вообще, с точки зрения применения, нам важнее всего предсказать, найдутся ли у данного животного хозяева, или возьмут ли его в новую семью. Возможно, удастся улучшить результат, использую данные не только о породе, но и о размере, пушистости, характере, и так далее, часть этих данных тяжело, но возможно, извлекается из данных о породе.
Данные для исследования не очень чистые, много пропусков, дубликатов и неверных значений. На основе вычищенных данных построена модель для многоклассовой классификации (пристройства, возврата, и т.д) животного из приюта. Конкретно пристройство и возврат владельцу она предсказывает неплохо, но хотелось бы улучшить качество на малочисленных классах, возможно использование более интересных признаков поможет.