Введение
В то время как космические корабли бороздят. Кругом мы нынче наблюдаем один только ИИ. Он рисует картинки и пишет музыку. Он отвечает на звонки клиентов компаний и решает нерешённые математические задачи. И казалось бы в современном мире уже нет места для старого доброго “машинного обучения” в виде табличек с цифрами. На том же Kaggle в основном идут соревнования на анализ видео, звука и т.д. Соревнования, которые требуют мощных видеокарт и знания самых современных тенденций в построении нейросетей. И где не нужны больше прозорливая интуиция, зоркий взгляд на график и внимательная инженерия из мира таких простых и таких сложных чисел с плавающей точкой. Сферических чисел, плавающих чёрными точками в бесконечном вакууме. Романтизма нету.
Но всё же платформ и соревнований много, иногда где-то да попадаются задачи на старые добрые таблички. Такие задачки, где можно анализировать данные, любоваться на графики, придумывать хитрые фичи и тестировать разные модели. Всё как любят олды, то есть я.
В общем, попался мне недавно Новогодний ML-марафон от Codenrock. Первая задача там была как-раз на старые добрые таблички. И я решил взять в руки шашки (а последний раз я участвовал в соревнованиях, наверное, год назад). Правда, попался мне этот марафон поздновато (все толковые решения я загрузил уже после даты, на которую фиксировали призовые места для выдачи сертификатов). Плюс к тому я не стал решать задачи про next best offer (ничего про это не знаю) и картинки (нет подходящего железа, да и компетенций). В результате ни по задаче, ни по марафону призового сертификата мне не досталось, а кроме сертификатов там ничего и не обещали. Соревновались мы просто ради интереса “и хорошего настроения”. Ну таки да: лучший результат на конец соревнования по старым добрым и казалось бы всеми хорошо изученным “табличкам” (из 166 человек, показавших хоть какой-то результат) – это оказалось весьма приятно. Вот даже первую свою статью решил написать на Хабр, пока вдохновение от результата не выветрилось. Да и полученные знания тоже ещё пока свежи.
Итак, задача о “выживании” ёлки
Нужно было определить, “доживёт” ли ёлка до определённой даты. Среди предоставленных нам признаков есть как явно полезные [а на самом деле может быть и нет]:
-
давно ли срублена ёлка
-
средняя температура в комнате
-
средняя влажность
-
как часто поливают ёлку
Так есть и признаки скорее всего бесполезные [но мало ли что окажется в итоге]:
-
номер подъезда
-
номер этажа (может последний этаж холоднее зимой, например, но нет ли этой информации в других фичах, в той же средней температуре?)
-
вес игрушек (ёлка “устаёт” их держать?)
И есть фичи, польза которых с точки зрения здравого смысла не очевидна – может быть она есть, а может быть и нет:
-
наличие кота (вдруг кот неравнодушен к ёлке и всё время пытается её свалить, что плохо сказывается на её “здоровье”)
-
число детей (гипотеза: больше детей – больше шалостей, в т.ч. с ёлкой, что опять же ей “здоровья” не прибавляет)
-
качество окон (гипотеза: старые деревянные окна вместо новых стеклопакетов – холоднее в квартире, но это уже наверняка учтено в средней температуре комнаты)
-
сторона дома (север, юг, восток, запад – например, на юге больше солнца, а на севере наоборот, а солнце для срубленной ёлки, возможно, не полезное)
По части данных целевая переменная размечена так:
-
1 – да, ёлка выжила
-
0 – нет, ёлка пришла в некондицию
Нужно предсказать на неразмеченной части данных вероятность того, что ёлка выжила. Целевая метрика ROC AUC. То есть нужно не просто предсказать выживет ли ёлка (1 или 0), а ещё и хорошо отранжировать предсказания – постараться правильно отсортировать выжившие и не выжившие ёлки по вероятностям выживания. В общем, предсказываем не просто результат 1 или 0, а его вероятность.
Прежде, чем читать дальше, предлагаю читателю посмотреть полный список колонок. И предположить, исходя из своего опыта занятий Data Science (если таковой есть) и из бытового здравого смысла (ну он то точно у каждого есть) – какие колонки окажутся полезными для модели, а какие нет.
Список всех колонок с кратким описанием
-
apartment_id— уникальный идентификатор квартиры -
building_age_years— возраст дома -
wing— сторона дома: north/south/east/west -
entrance— номер подъезда (1–12) -
floor— этаж (1–30) -
apartment_area_m2— площадь квартиры (м²) -
ceiling_height_m— высота потолка (м) -
window_quality— качество окон: old/normal/new -
heating_type— отопление: central / electric_heater -
corner_apartment— угловая квартира (0/1) -
room_temp_c— средняя температура в комнате с ёлкой (°C) -
humidity_pct— средняя влажность воздуха (%) -
window_ventilation_per_day— сколько раз в день проветривают -
radiator_distance_m— расстояние до батареи (м) -
children_count— количество детей (0–4) -
cat_present— есть ли кот (0/1) -
robot_vacuum— есть ли робот-пылесос (0/1) -
tree_species— порода ёлки: spruce/fir/pine -
tree_height_cm— высота ёлки (см) -
tree_form— форма: dense/normal/sparse -
stand_type— подставка: simple_stand/bucket/water_reservoir/unknown -
cut_days_before_jan1— за сколько дней до 1 января ёлку срубили (0–22) -
potted_tree— в горшке ли ёлка (0/1) -
waterings_per_week— поливов в неделю (0–14) -
mist_spray— опрыскивают ли водой (0/1) -
tinsel_level— уровень мишуры: low/medium/high -
ornaments_weight_kg— вес игрушек (кг) -
led_garland— гирлянда LED или обычная (0/1) -
garland_hours_per_day— часов работы гирлянды в день (0–24) -
survived_to_18jan— цель (указана только в train-датасете): 1 — ёлка дожила до 18 января, 0 — нет
Первый подход к снаряду
Хотя заниматься наукой о данных жутко интересно, но всегда хочется получить хоть какой-то первый результат быстро и без усилий. Для этого у нас есть прекрасный пакет от Яндекса CatBoost, которому можно “скормить” наш датасет без преобразований и даже получить при этом неплохой результат. Кстати, дальнейшая публичная переписка на форуме соревнования показала, что многие использовали в этой (и не только этой) задаче CatBoost. И им же в итоге и ограничились.
Для начала прочитаем train и test и просто посмотрим на количество данных. В данном случае нам повезло и не пришлось угадывать кодировку файла, разделитель полей и разделитель дробной части – все они стандартные. Иначе бы пришлось их указывать параметрами метода csv_read.
import pandas as pd
train = pd.read_csv('data/train.csv')
test = pd.read_csv('data/test.csv')
print(train.shape)
print(test.shape)
(30000, 30)
(18000, 29)
Таким образом, в train у нас 30 тысяч записей и 30 колонок. Это довольно большое число записей и само по себе и относительно числа колонок. Есть надежда, что модель сумеет хорошо обучиться. Конечно, бывают датасеты и с миллионами записей, но практика показывает, что десятков тысяч записей вполне достаточно для обучения.
Подготовим стандартный набор переменных для обучения:
-
вектор
yс одной только целевой колонкой -
матрицу
Xсо всеми колонками, кроме целевой переменной иidквартиры (все колонки с уникальнымиidобычно выбрасывают, ибо там нет полезной информации для обучения)
target = 'survived_to_18jan'
key = 'apartment_id'
X = train.drop(columns=[target, key])
y = train[target]
Запустим же побыстрее наш прекрасный CatBoost.
С примерно стандартными параметрами и кросс-валидацией
from catboost import Pool, cv
# не будем разбираться пока в фичах, будем считать, что всё, что объект - это категориальные
categorical_features = X.dtypes[X.dtypes == 'object'].index.to_list()
train_pool = Pool(data=X, label=y, cat_features=categorical_features)
params = {
'loss_function': 'Logloss',
'iterations': 500,
'custom_metric': 'AUC',
'learning_rate': 0.03,
'depth': 6,
'random_seed': 42,
}
cv_data = cv(params=params,
pool = train_pool,
fold_count=3,
shuffle=True,
partition_random_seed=42,
plot=True,
stratified=True,
verbose=False
)
print(cv_data.loc[cv_data['test-AUC-mean'].values.argmax()][['iterations', 'test-AUC-mean', 'test-AUC-std']])
Скор получается вроде неплохой: test-AUC-mean 0.670850. Но посмотрим внимательно на график обучения. И что же мы там видим, Холмс?
Переобучение, Ватсон!
На графике наблюдается явное переобучение. logloss на train заметно улучшается, а на test он довольно быстро выходит на плато. И при этом сильно отстаёт от train чуть не с первых итераций обучения. Что мы делаем, когда наблюдаем переобучение? Переходим к более простым моделям и используем регуляризацию. В теории можно было бы ещё собрать больше данных, но на соревнованиях у нас нет доступа к источнику данных, работаем с тем, что есть.
Для начала попробуем упростить модель CatBoost. Уменьшим depth от нашего первоначального значения 6 в сторону понижения. Не буду тут приводить все графики, но явное переобучение сохраняется практически вплоть до значения 2. И только на depth=1 картинка становится вполне приличной. Замечу, что при этом нам приходится увеличивать число итераций (до 2000). Модель при такой глубине деревьев (вернее, уже пеньков) учится медленнее (за большее число итераций). Но зато она (вроде бы) практически не переобучается, чего мы и добиваемся, понижая сложность модели.
Скор на кросс-валидации при этом вроде бы даже неплохой: test-AUC-mean 0.671555. Однако моя попытка в этот момент загрузить решение на сайт соревнования дала совсем какой-то плохой результат – где-то в конце рейтинга. И тогда я понял, что халявы как обычно не получится. И нужно уже начинать действовать согласно основному принципу дата-сайентиста: know your data. Чтобы понять данные я обычно рисую графики признаков и смотрю на них глазами, чтобы выявить особенности данных в этих признаках.
Визуализация данных и анализ
Не буду утомлять читателя графиками распределения и зависимости от признака целевой переменной (т.е. выживаемости ёлочки) для всех числовых признаков. Покажу тут только графики признаков, наиболее значимых для моей итоговой модели.
Код для отрисовки нижеследующих графиков
import seaborn as sns
import matplotlib.pyplot as plt
for col in ['cut_days_before_jan1', 'radiator_distance_m', 'room_temp_c', 'humidity_pct']:
data_sort = train.sort_values([col])
data = data_sort[col]
yy = data_sort[target]
fig, ax = plt.subplots()
sns.histplot(data, stat="density", bins=50, alpha=0.2, ax=ax)
ax2 = ax.twinx()
sns.lineplot(x=data, y=yy, ax=ax2);
Итак, что мы видим на графиках?
Во-первых, мы видим, что хотя распределение признаков и выглядит в основном нормальным (“колоколообразным”, если хотите), но оно при этом смещено влево. У каких-то признаков это смещение весьма сильное, а у каких-то – еле заметное. Если мы будем использовать простые модели, например, линейную регрессию, то этот сдвиг может отрицательно повлиять на результат. Есть ли у нас методы против Кости Сапрыкина смещённого распределения? Конечно есть! Об этом – в следующем разделе.
Во-вторых, мы видим, что на краях распределения, там, где у нас мало значений, график зависимости целевой переменной скачет туда-сюда как бешеный. Имеют ли эти скачки какой-то реальный смысл? Или у нас просто мало данных с такими значениями этого признака и это просто случайные флуктуации? По какой всё-таки причине мы не можем быть уверены, какая же у нас зависимость целевой переменной от этого признака при этих значениях?
Небольшое отступление. Когда я работал в одной маленькой психиатрической больнице большой финансовой организации, я занимался там проверкой моделей, которые сделали наши дата-сайентисты. И одним из правил проверки было то, что при фиксации зависимостей в модели, эти зависимости должны соответствовать смыслу предметной области.
Например, из данных следует, что чем больше ежемесячный доход бизнеса, тем больше вероятность, что этот бизнес отдаст взятый кредит (при прочих равных условиях). Но вдруг в каком-то месте этого графика зависимости есть непонятные скачки. Например (цифры условные):
-
бизнес с доходом 10 миллионов в год отдаст долг с вероятностью 70%
-
бизнес с доходом 15 миллионов отдаст с вероятностью 65%
-
бизнес с доходом 20 отдаст с вероятностью 75%
Ну, вот тут явно что-то не то с проседанием графика в точке 15 миллионов/65%. И тогда нужно найти какие-то другие признаки, которые выбивают этот бизнес из тенденции. А зависимость целевой переменной именно от дохода нужно считать более-менее линейной. И не учитывать в модели этот непонятный скачок графика.
Но вернёмся к нашим ёлкам. Посмотрим ещё раз на график зависимости целевой переменной от признака cut_days_before_jan1.
У этого графика в правой части имеется серьёзный выброс, которые сложные модели, например CatBoost, склонны принимать и воспроизводить “как есть”. Они ведь не знают природу признаков и не понимают, что, ну, если подумать, то с точки зрения здравого смысла тут как будет? Чем более давно мы срубили ёлку, тем больше вероятность, что она засохнет. Тут не может быть с точки зрения здравого смысла такого, что до 18 дней она засыхает по одной тенденции, а вот если её срубить 19 или 20 дней назад, то её выживаемость вдруг резко повысится. А если 21 день назад, то зависимость вернётся к былой тенденции. Теоретически, конечно, может быть такое, что 19-20 дней назад ёлки срубили в каком-то определённом хозяйстве, где их потом хранили другим способом и поэтому они лучше сохраняются. И мы обычно никогда не знаем, как оно было на самом деле. Данные нам поступают через несколько промежуточных инстанций. Они предварительно обработаны (и возможно не один раз). Докопаться до их первоисточника, чтобы получить больше информации, бывает сложновато. Особенно если это соревнование и тогда источник данных вам никто не раскроет. Да и вообще в нашем случае данные скорее всего “синтетические”, то есть сгенерированные по неким формулам.
В общем, я склонен считать, что в нашем случае наблюдаемый справа выброс – это случайные флуктуации в данных, которые мы не должны учитывать. Давайте считать наши признаки максимально прямолинейными в плане зависимости от них целевой переменной. И чтобы учесть эту прямолинейность наилучшим образом, используем для обучения линейные модели, например логистическую регрессию. (Об этом ниже.)
Те же рассуждения касаются и других показанных выше признаков (вернее, зависимостей от них целевой переменной):
-
температура в комнате
-
влажность в комнате
-
удалённость ёлки от радиатора отопления
Все эти зависимости явно должны быть (грубо говоря) линейными и не должны иметь каких-то внезапных сюрпризов на графике. При этом график удалённости от радиатора как мы видим сам по себе очень шумный. Наверное, эту величину мерили кто во что горазд. Предположу, что кто-то мерил расстояние от края ёлки, а кто-то от центра ёлки. Кто-то мерил вообще “на глазок”. Плюс было какое-то округление данных до круглых/красивых чисел. И вот такой шумный график в итоге. Видимо, наиболее полезно будет просто провести через эти данные прямую, а не приспосабливаться к скачкам этой кривой.
Тут код для визуализации, логистическая регрессия потом научится на тех же принципах
import seaborn as sns
import matplotlib.pyplot as plt
for col in ['cut_days_before_jan1', 'radiator_distance_m', 'room_temp_c', 'humidity_pct']:
data_sort = train.sort_values([col])
data = data_sort[col]
yy = data_sort[target]
plt.figure()
sns.lineplot(x=data, y=yy);
sns.regplot(x=data, y=yy, scatter=False, robust=True);
Выглядит в целом прекрасно, такая регрессионная линия должна очень хорошо моделировать поведение признака на всём диапазоне. За исключением признака radiator_distance_m, который так напрашивается аппроксимировать чем-то более округлым, чем прямая. Но будем надеяться, что добавление дополнительных признаков, таких как логарифмы, поможет нам в итоге всё-равно всё свести к прямым.
Кроме того, мы же надеемся на то, что у нас будет не одна супер-фича, а, благодаря регуляризации, будет много примерно равнозначных признаков. Которые и компенсируют небольшие погрешности друг-друга, если они даже и есть, эти погрешности.
Здесь я вынужден сделать перерыв. Статья оказалась гораздо длиннее, чем я её задумывал изначально и заняла уже довольно много моего времени. При этом конца ещё не видно – число планируемых глав растёт быстрее, чем я заканчиваю ранее задуманное! Поэтому я решил разделить статью на части. Так я смогу опубликовать хоть что-то законченное, пока я буду возиться с остальным материалом.
В следующих частях будет (и каждый из этих этапов супер важен для итогового результата):
-
подготовка данных
-
инженерия новых признаков
-
отбор признаков
-
маленькие соревновательные хитрости для повышения скора
…И внезапное первое место в рейтинге “по ёлочкам” после окончания соревнования. До самого последнего дня соревнования ничто не предвещало!
Спасибо за чтение. Замечания и предложения всячески приветствуются.
Занимайтесь data science и machine learning, и ваши мозги будут лёгкими и шелковистыми. Хотя эти занятия могут съесть всё ваше свободное время. Но это того стоит.
Автор: CrazyElf


