В этой статье я выскажу свою точку зрения на то, что из себя представляют категориальные признаки. Расскажу про способы работы с ними, которыми пользуюсь сам как антифрод-аналитик в Каруне.

Категориальный признак (categorical feature) — это признак, который содержит в себе какую-либо метку (свойство), описывающую этот признак. При этом, категориальные признаки не измеряются в непрерывной шкале, в отличие от непрерывных признаков (continuous features).

Категориальные признаки могут содержать фиксированный набор значений.

К примеру, признак RGB содержит значения: Red (красный), Green (зелёный) и Blue (голубой).

Также они могут содержать набор значений, который может со временем меняться.

Например, номера поликлиник в городе: 321, 213, 2 и так далее. Город может построить новую поликлинику, и таким образом она добавится в ваш список.

Если категориальный признак принимает только два значения, его называют бинарным (True / False, Да / Нет, Зеленый / Красный и так далее).

Более подробно можно прочитать по ссылке.

Большая часть классических ML-моделей использует в своей работе числовые признаки, в связи с этим следует уметь корректно представлять категориальные значения числами.

Можно ли не утруждать себя при работе с такими признаками?

Ответ: и да, и нет. Вы действительно можете использовать какие-то базовые приёмы работы с ними, но рискуете потерять в качестве модели.

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

# import library
import pandas as pd

# create dataframe
df = pd.DataFrame()
df['fruit'] = ['apple','orange','melon']
df['price'] = [59,70,63]
df['label'] = [1, 1, 0]

Для обучения возьмем, к примеру, lightgbm.

Если мы напрямую попытаемся обучить модель, то получим ошибку:

# import library
import lightgbm as lgb

# choose train data and label
x = df[['fruit','price']]
y = df['label']

# train model
model = lgb.LGBMClassifier()
model.fit(x,y)
Вывод
Вывод

Конкретно при работе с lightgbm мы можем указать, что используем категориальные признаки напрямую, и код отработает без ошибки:

# change column type
df['fruit'] = df['fruit'].astype('category')

# choose train data and label
x = df[['fruit','price']]
y = df['label']

# train model
fit_params={'categorical_feature': 'auto'}
model = lgb.LGBMClassifier()
model.fit(x,y, **fit_params)

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

1 - не все алгоритмы могут работать с признаками, оформленными на естественном языке;

2 - вы не можете настраивать их под ваши конкретные задачи;

3 - зачастую это — black box (вы не знаете, что происходит с вашими признаками).

Если вам интересно, то по ссылке, в пункте Categorical Feature Support, можно прочитать, как lightgbm работает с категориальными признаками.

Базовые приёмы работы с категориальными признаками

Чтобы было более наглядно, я расскажу о них на примере. Представим, что у нас есть набор данных о кандидатах на работу, состоящий из категориальных признаков. В дальнейшем мы хотим предсказать, насколько будущий сотрудник сможет хорошо справляться с работой. Но сейчас наша задача — подготовить данные для отправки в модель:

# create dataframe
df = pd.DataFrame({'Candidate_id': ['100', '53', '17', '35'],
                   'Tie_color': ['Red', 'Black', 'Purple','Black'],
                   'Family_status': ['Married','Married', 'Not married',
                                     'Married'],
                   'Level_of_education': ['School', 'College', 'University',
                                          'University'],
                   'Car_model': ['Toyota','Lexus','BMW','Toyota']})

# show data
df.head()
Вывод
Вывод

1. Удаление признака

Самое простое действие, которое вы можете сделать с категориальным признаком — удалить его. Это может оказаться очень хорошим решением, если вы уверены, что признак будет бесполезен для вашей модели. Однако, к сожалению или к счастью, вы вряд ли будете уверены в этом на 100%, пока не попробуете обучить свою модель с этим признаком.

В нашем кейсе кажется, что признаки Candidate_id и Tie_color будут бесполезны для модели, так как:

Candidate_id — уникальное id, такие признаки, как правило, не содержат полезную информацию. По ним лишь собирают наборы данных для дальнейшего обучения.

Tie_color — вряд ли цвет галстука сильно влияет на работоспособность сотрудника, поэтому удалим и этот признак.

# delete features
df.drop(['Candidate_id', 'Tie_color'], axis=1, inplace=True)

2. Бинаризация признака

Да, это довольно банально, но проверьте, вдруг ваш признак имеет всего два значения. Если это так, то вам повезло — ваш признак бинарный, присвойте для одного значения 1, а для другого 0.

Бинаризация признака хорошо подходит в случаях, когда признак выражен на естественном языке и имеет всего 2 категории. Например: мужчина / женщина, зеленый / красный.

Family_status в данном случае может выступить как бинарный признак. Вы можете присвоить ему соответствующие значения так:

# transform data
df['Family_status_encoded'] = [1 if i == 'Married' else 0 for i in df['Family_status']]

3. Ordinal Encoding

Данный метод полезен, если вы работаете с признаком, который имеет естественную порядковую связь. Например, уровень образования.

Ordinal encoder присваивает значениям целые числа от 0 (0, 1, 2 и так далее).

При этом имейте в виду, что лучше напрямую указать этот порядок.

Если мы просто возьмём и используем Ordinal encoder на колонке Level_of_education, то получим не тот результат, на который рассчитывали:

# import library
from sklearn.preprocessing import OrdinalEncoder

# define ordinal encoding
encoder = OrdinalEncoder()

# transform data
df['Level_of_education encoded'] = encoder.fit_transform(df[['Level_of_education']])

# show features
df[['Level_of_education','Level_of_education encoded']].head()
Вывод
Вывод

Как видно на картинке выше, School (школе) присвоилось значение 1, а College (колледжу) 0, что является логически неверным. Мы же хотим видеть следующее:

0 - Школа, 1 - Колледж, 2- Университет.

Чтобы избежать таких проблем, укажем признаки напрямую в OrdinalEncoder, по возрастанию значимости:

# create ordered list of categories
education = ['School', 'College', 'University']

# define ordinal encoding
encoder = OrdinalEncoder(categories = [education])

# transform data
df['Level_of_education_encoded'] = encoder.fit_transform(df[['Level_of_education']])

# show features
df[['Level_of_education','Level_of_education_encoded']].head()
ВыводоOne-Hot Encoding
Вывод
  1. One-Hot Encoding

One-Hot Encoding создает новые столбцы, в которых указывается:

  • присутствует значение - 1;

  • отсутствует - 0.

Количество новых столбцов будет равно количеству уникальных значений признака. Также вместо One-Hot Encoding вы можете использовать pd.get_dummies(). Эти 2 метода очень похожи.

Легче всего понять, как это работает, можно с помощью визуализации:

Принцип работы One-Hot Encoding
Принцип работы One-Hot Encoding

На данной картинке слева показаны марки автомобилей, справа — то, как отработает One-Hot Encoder.

Возьмем справа колонку Сar_model_BMW, в строках 0, 1, 3 стоит - 0, это значит, что люди в этих строках не являются владельцами BMW. В отличие от строки 2, там стоит - 1, следовательно, человек в строке 2 является владельцем BMW.

В коде это выглядит следующим образом:

# import library
from sklearn.preprocessing import OneHotEncoder

# define one hot encoding
encoder = OneHotEncoder(sparse=False)

# transform data
onehot_columns = encoder.fit_transform(df[['Car_model']])

# concat dataframes
df = pd.concat([df, pd.DataFrame(onehot_columns)], axis=1)

# get new encoded column names
encoded_columns_name = encoder.get_feature_names_out(['Car_model'])

# rename columns after encoding
dict_column_names = {0: encoded_columns_name[0],
                     1: encoded_columns_name[1],
                     2: encoded_columns_name[2]}
df.rename(columns=dict_column_names,
          inplace=True)

# show features
df[['Car_model','Car_model_BMW', 'Car_model_Lexus', 'Car_model_Toyota']].head()
Вывод
Вывод

Важный нюанс! При таком подходе возникает мультиколлинеарность. Это может плохо сказаться на качестве вашей модели, особенно если вы используете линейную регрессию. Чтобы решить эту проблему, достаточно просто удалить первый закодированный столбец, в нашем случае — это Car_model_BMW. Такой процесс вам следует повторить для каждого признака, который вы преобразовали. Для этого вы можете просто добавить аргумент drop='first' в функцию OneHotEncoder():

# better way to define one hot encoding
encoder = OneHotEncoder(sparse=False, drop='first')


Вывод по базовым приёмам:

По итогу из начального набора данных:

Изначальный набор данных
Изначальный набор данных

Мы получили следующий:

Итоговый набор данных
Итоговый набор данных

С таким набором данных вы уже спокойно можете использовать любые Ml-алгоритмы, при этом контролируя, как именно представляются ваши категориальные признаки.

"Продвинутый" приём работы с категориальными признаками

Слово "продвинутый" я взял в кавычки, потому что вам не потребуются какие-то углубленные знания в области Data science. Скорее вам понадобится воображение, ваша логика и в целом все ваши накопленные за жизнь знания.

Взгляните глубже в свои категориальные признаки. Возможно, они хранят больше информации, чем вам кажется.

Покажу пример на наборе данных из  Kaggle Titanic: Machine Learning from Disaster

Фото взято из  Kaggle Titanic: Machine Learning from Disaster 


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

# import libraries
from catboost import CatBoostClassifier
from catboost.datasets import titanic
from sklearn.metrics import roc_auc_score
import pandas as pd

def get_dataframe() -> pd.DataFrame:
    """Получаем датафрейм, удаляем строки с пропущенными значениями, 
    перемешиваем строки"""
    #load dataframe
    titanic_df = titanic()[0]
    #drop nan values
    titanic_df = titanic_df.dropna()
    #shuffle data
    titanic_df = titanic_df.sample(frac=1, random_state=42)

    return titanic_df

titanic_df = get_dataframe()
titanic_df.head()
Вывод
Вывод

Под категориальные признаки можно отнести следующие колонки: ['Name', 'Sex','Ticket', 'Cabin', 'Embarked']. Сделаем из них список cat_features(он нам далее понадобится). Также сделаем тренировочный и тестовый наборы данных:

def get_train_test(titanic_df: pd.DataFrame) -> (pd.DataFrame, pd.DataFrame, 
                                                 pd.Series, pd.Series):
    """Получаем тренировочные и тестовые наборы данных"""
    #choose train test
    train_df = titanic_df[:150]
    test_df = titanic_df[150:]
    #get target features
    train_labels = train_df['Survived']
    test_labels = test_df['Survived']
    train_df.drop(['Survived'], axis=1, inplace=True)
    test_df.drop(['Survived'], axis=1, inplace=True)
    return train_df, test_df, train_labels, test_labels

cat_features = ['Name', 'Sex','Ticket', 'Cabin', 'Embarked']
train_df, test_df, train_labels, test_labels = get_train_test(titanic_df)

Теперь обучим CatBoostClassifier, передав ему наш список категориальных признаков, и узнаем качество полученной модели:

# initialize CatBoostClassifier
model_v1 = CatBoostClassifier(random_seed=42)

# fit model
model_v1.fit(train_df, train_labels, cat_features=cat_features)

# get score
predictions = model_v1.predict_proba(test_df)
print(f"ROC_AUC_score = {roc_auc_score(test_labels, predictions[:,1])}")
Вывод
Вывод

Отличный результат!

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

Снова выгрузим набор данных:

titanic_df = get_dataframe()

И начнём углубляться в наши признаки.

Из имени мы можем получить обращение к человеку. Создадим отдельный признак 'Title' в который добавим обращение. Также удалим обращение из имени.

Логика создания признака "Title"
Логика создания признака "Title"
# Get Title from Name
titanic_df["Title"] = [i.split(",")[1].split(".")[0].strip() for i in titanic_df["Name"]]
titanic_df['Name'] = [i.split(",")[0] + ',' + i.split(".")[1] for i in titanic_df['Name']]

Продолжим работу с обращением. С его помощью мы узнаем, замужем девушка или нет, так как к девушке обращаются Mrs, если она замужем :

# Get maried status
titanic_df['Is_married'] = [1 if i=='Mrs' else 0 for i in titanic_df['Title']]

Закончим с именем и перейдем к признаку 'Cabin'. Буква в начале обозначала палубу, где располагалась каюта, за которой следовал номер комнаты:

Логика создания признака "Cabin_letter"
Логика создания признака "Cabin_letter"
# replace the Cabin number by the type of cabin
titanic_df["Cabin_letter"] = [i[0] for i in titanic_df['Cabin']]

В итоге наш набор данных будет выглядеть следующим образом:

titanic_df.head()
Вывод с дополнением
Вывод с дополнением

Теперь опять получим тестовый и тренировочный наборы, предварительно создав список cat_features с добавлением новых колонок:

cat_features = ['Name', 'Sex','Ticket', 'Cabin', 'Embarked', 'Title', 'Is_married','Cabin_letter']
train_df, test_df, train_labels, test_labels = get_train_test(titanic_df)

Обучим новую модель и замерим её качество:

# initialize CatBoostClassifier
model_v2 = CatBoostClassifier(random_seed=42)

# fit model
model_v2.fit(train_df, train_labels, cat_features=cat_features)

# get score
predictions = model_v2.predict_proba(test_df)
print(f"ROC_AUC_score = {roc_auc_score(test_labels, predictions[:,1])}")
Вывод
Вывод

Вывод по "продвинутому" приёму:

Как видно из результата, мы смогли улучшить нашу метрику на 9.2%, и это круто! Мы сделали это, не меняя какие-либо гиперпараметры, мы не искали и не добавляли новых данных, и так далее. Мы просто глубже погрузились в наши признаки.

Как дополнение, хочу сказать, что бывают случаи, когда полезно трактовать числовые входные значения как категориальные.

Пример: допустим, у нас есть признак день недели. Очень важно понимать, что день недели в данном случае не отражает непрерывную шкалу, а является просто индексом, и лучше подходить к такому признаку как к категориальному, потому что в разных странах разное начало недели:

в США - это воскресенье;

в Египте - это суббота;

в России - понедельник.

Попробуйте внимательно изучить ваши числовые признаки, возможно, они будут лучше смотреться как категориальные. В случае с днями недели, если в вашем наборе данных присутствуют различные страны, скорее всего, будет лучше рассмотреть этот признак как категориальный и использовать для него One-Hot encoder:

Более корректное представление признака "день недели" для дальнейшей работы с ним
Более корректное представление признака "день недели" для дальнейшей работы с ним

Общий вывод:

В результате проделанной работы мы с вами узнали, что из себя представляют категориальные признаки, познакомились с базовыми приёмами работы с ними и применили "продвинутый" приём.

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

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


  1. CrazyElf
    25.10.2023 13:48
    +2

    Некоторые вещи гораздо проще и, мне кажется, понятнее делаются:

    [1 if i == 'Married' else 0 for i in df['Family_status']]
    

    Можно заменить на:

    (df['Family_status'] == 'Married').astype(int)
    


  1. economist75
    25.10.2023 13:48

    Включение в данные для обучения ФИО пассажиров (Name) приводит к тому что ML-модель просто "заучивает" выживаемость и показывает непозволительно высокий ROC AUC 0,8. Это переобучение, которое даст страшные ошибки при запросе несуществующих пассажиров. Мы ~все там были~ и смотрели фильм, и точно знаем что быть женщиной/ребенком на Титанике было полезно и сильно повышало шансы выжить. Но это само себе обусловливает меньшие ROC AUC, чем в статье (условно скажем, на уровне 0,65). Все-таки влияли и другие факторы - номер палубы (близость к верхней со шлюпками), наличие родни, которая могла вытащить из-за стола и убедить срочно сваливать итд. Ну и наличие мужа делало пассажирку более опытной в жизненных ситуациях, тут в статье сделано правильно.

    Так вот, чистка ФИО от мусора с выносом замужем/незамужем/разведена/вдова в отдельный признак улучшило модель, но не лишило ее переобучения.

    ВЫВОД: Персональные признаки: ФИО пассажиров, номера их билетов итп включать в обучение нельзя, поскольку модель "зазубрит" данные, а это будет не ML, а простая аналитика (Excel c включенным Автофильтром). Современные ML-либы, платформы и фреймворки обязаны предупреждать аналитиков об исключении таких признаков.