Все курсы > Анализ и обработка данных > Занятие 11
Алгоритмы машинного обучения, как мы знаем, не умеют работать с категориальными данными, выраженными с помощью строковых значений. Для этого строки необходимо закодировать (encode) числами. Сегодня мы рассмотрим основные способы такой кодировки.
Откроем ноутбук к этому занятию⧉
Подготовим простые учебные данные кредитного скоринга.
1 2 3 4 5 6 7 8 9 10 11 12 |
scoring = { 'Name' : ['Иван', 'Николай', 'Алексей', 'Александра', 'Евгений', 'Елена'], 'Age' : [35, 43, 21, 34, 24, 27], 'City' : ['Москва', 'Нижний Новгород', 'Санкт-Петербург', 'Владивосток', 'Москва', 'Екатеринбург'], 'Experience' : [7, 13, 2, 8, 4, 12], 'Salary' : [95, 135, 73, 100, 78, 110], 'Credit_score' : ['Good', 'Good', 'Bad', 'Medium', 'Medium', 'Good'], 'Outcome' : [1, 1, 0, 1, 0, 1] } df = pd.DataFrame(scoring) df |

Про категориальные переменные
Вначале в целом повторим как выявлять и исследовать категориальные переменные.
Методы .info(), .unique(), value_counts()
Начать исследование категориальных переменных можно с изучения типа данных. Для этого подойдут метод .info() или атрибут dtypes.
1 |
df.info() |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 6 entries, 0 to 5 Data columns (total 7 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Name 6 non-null object 1 Age 6 non-null int64 2 City 6 non-null object 3 Experience 6 non-null int64 4 Salary 6 non-null int64 5 Credit_score 6 non-null object 6 Outcome 6 non-null object dtypes: int64(3), object(4) memory usage: 464.0+ bytes |
1 |
df.dtypes |
1 2 3 4 5 6 7 8 |
Name object Age int64 City object Experience int64 Salary int64 Credit_score object Outcome object dtype: object |
При этом категориальные признаки часто могут «прятаться» в типах int и float. В этом случае для их выявления можно изучить распределение данных.
Отдельные категории можно посмотреть с помощью метода .unique().
1 |
df.City.unique() |
1 2 |
array(['Москва', 'Нижний Новгород', 'Санкт-Петербург', 'Владивосток', 'Екатеринбург'], dtype=object) |
С помощью методов .values_counts() библиотеки Pandas и np.unique() библиотеки Numpy можно посмотреть и количество объектов в каждой категории.
1 2 3 |
# метод .value_counts() сортирует категории по количеству объектов # в убывающем порядке df.City.value_counts() |
1 2 3 4 5 6 |
Москва 2 Нижний Новгород 1 Санкт-Петербург 1 Владивосток 1 Екатеринбург 1 Name: City, dtype: int64 |
1 |
np.unique(df.City, return_counts = True) |
1 2 |
(array(['Владивосток', 'Екатеринбург', 'Москва', 'Нижний Новгород', 'Санкт-Петербург'], dtype=object), array([1, 1, 2, 1, 1])) |
Последовательное применение методов .value_counts() и .count() выведет общее количество уникальных категорий.
1 2 |
# посмотрим на общее количество уникальных категорий df.City.value_counts().count() |
1 |
5 |
Выведем категории на графике.
1 2 3 4 5 |
score_counts = df.Credit_score.value_counts() sns.barplot(x = score_counts.index, y = score_counts.values) plt.title('Распределение данных по категориям') plt.ylabel('Количество наблюдений в категории') plt.xlabel('Категории'); |

Тип данных category
Хорошая практика — перевести категориальную переменную в тип данных category. Зачастую (но не всегда, например, если много категорий) это ускоряет работу с категориями и уменьшает использование памяти.
Можно воспользоваться уже знакомым нам методом .astype().
1 |
df = df.astype({'City' : 'category', 'Outcome' : 'category'}) |
Функция pd.Categorical() позволяет прописать, в частности, сами категории, а также указать, есть ли в переданных категориях порядок или нет.
1 2 3 |
df.Credit_score = pd.Categorical(df.Credit_score, categories = ['Bad', 'Medium', 'Good'], ordered = True) |
Воспользуемся атрибутами categories и dtype.
1 |
df.Credit_score.cat.categories |
1 |
Index(['Bad', 'Medium', 'Good'], dtype='object') |
1 |
df.Credit_score.dtype |
1 |
CategoricalDtype(categories=['Bad', 'Medium', 'Good'], ordered=True) |
Атрибут codes выводит коды каждой из категорий (мы воспользуемся этим в дальнейшем при кодировании).
1 |
df.Credit_score.cat.codes |
1 2 3 4 5 6 7 |
0 2 1 2 2 0 3 1 4 1 5 2 dtype: int8 |
Категории можно переименовать.
1 2 3 4 |
df.Outcome = df.Outcome.cat.rename_categories(new_categories = {'Вернул': 'Yes', 'Не вернул': 'No'}) df |

Убедимся, что нужные нам признаки преобразованы в тип category.
1 |
df.info() |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 6 entries, 0 to 5 Data columns (total 7 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Name 6 non-null object 1 Age 6 non-null int64 2 City 6 non-null category 3 Experience 6 non-null int64 4 Salary 6 non-null int64 5 Credit_score 6 non-null category 6 Outcome 6 non-null category dtypes: category(3), int64(3), object(1) memory usage: 806.0+ bytes |
Кардинальность данных
Большое количество уникальных категорий в столбце называется высокой кардинальностью (high cardinality) признака. В частности, потенциально (если бы у нас было больше данных) признак City мог бы обладать высокой кардинальностью.
Ниже мы рассмотрим в каких случаях это может стать нежелательной особенностью данных. Одним из решений могло бы быть создание нового признака, например, региона группирующего несколько городов.
1 2 3 4 5 |
region = np.where(((df.City == 'Екатеринбург') | (df.City == 'Владивосток')), 0, 1) df.insert(loc = 3, column= 'Region', value = region) df |

Дополнительным полезным свойством нового признака будет то, что на основе изначальных данных алгоритм бы не увидел разницы между Москвой и Владивостоком и Москвой и Екатеринбургом (а вполне вероятно, что в данных она есть). В новом же признаке, по сути делящем города по принадлежности к европейской и азиатской частям России, такую разницу выявить получится.
Базовые методы кодирования
Кодирование через cat.codes
Как уже было сказано выше, кодировать категориальную переменную можно через атрибут cat.codes.
1 2 |
df_cat = df.copy() df_cat.Credit_score.cat.codes |
1 2 3 4 5 6 7 |
0 2 1 2 2 0 3 1 4 1 5 2 dtype: int8 |
1 2 |
df_cat.Credit_score = df_cat.Credit_score.astype('category').cat.codes df_cat |

Mapping
Этот способ мы уже применяли на прошлых занятиях. Суть его заключается в том, чтобы передать схему кодирования в виде словаря в функцию map() и применить к соответствующему столбцу.
1 2 3 4 5 6 7 8 9 10 |
df_map = df.copy() # ключами будут старые значения признака # значениями словаря - новые значения признака map_dict = {'Bad' : 0, 'Medium' : 1, 'Good': 2} df_map['Credit_score'] = df_map['Credit_score'].map(map_dict) df_map |

Словарь в функцию map() можно передать и так.
1 2 3 4 5 |
# сделаем еще одну копию датафрейма df_map = df.copy() df_map.Credit_score = df_map.Credit_score.map(dict(Bad = 0, Medium = 1, Good = 2)) df_map |

Label Encoder
Рассмотрим класс LabelEncoder библиотеки sklearn. Этот класс преобразует n категорий в числа от 1 до n. Применим его к целевой переменной (бинарная категориальная переменная).
На вход LabelEncoder принимает только одномерные массивы (например, Series)
1 2 3 4 5 6 7 8 |
from sklearn.preprocessing import LabelEncoder labelencoder = LabelEncoder() df_le = df.copy() df_le.loc[:, 'Outcome'] = labelencoder.fit_transform(df_le.loc[:, 'Outcome']) df_le |

Для категорий, в которых больше двух классов, но нет внутренней иерархии (номинальные данные), этот encoder подходит хуже, потому что построенная на основе преобразованных данных модель может подумать, что между категориями есть иерархия, когда в действительности ее нет.
1 2 3 |
# применим LabelEncoder к номинальной переменной City df_le.loc[:, 'City'] = labelencoder.fit_transform(df_le.loc[:, 'City']) df_le |

Но даже для порядковых категориальных данных этот способ вряд ли подойдет, потому что LabelEncoder не видит порядка в данных.
1 2 3 |
# применим LabelEncoder к номинальной переменной Credit_score df_le.loc[:, 'Credit_score'] = labelencoder.fit_transform(df_le.loc[:, 'Credit_score']) df_le |

1 |
labelencoder.classes_ |
1 |
array(['Bad', 'Good', 'Medium'], dtype=object) |
Как вы видите, на второе место в иерархии LabelEncoder поместил класс Good, что конечно является ошибкой. Таким образом, можно сказать, что LabelEncoder лучше всего справляется с бинарными категориальными данными.
Ordinal Encoder
С порядковыми категориальными данными справится OrdinalEncoder, которому при создании объекта класса можно передать иерархию категорий.
На вход OrdinalEncoder принимает только двумерные массивы.
1 2 3 4 5 6 7 8 9 |
from sklearn.preprocessing import OrdinalEncoder ordinalencoder = OrdinalEncoder(categories = [['Bad', 'Medium', 'Good']]) df_oe = df.copy() # используем метод .to_frame() для преобразования Series в датафрейм df_oe.loc[:, 'Credit_score'] = ordinalencoder.fit_transform(df_oe.loc[:, 'Credit_score'].to_frame()) df_oe |

Убедимся, что иерархия категорий не нарушена.
1 |
ordinalencoder.categories_ |
1 |
[array(['Bad', 'Medium', 'Good'], dtype=object)] |
OneHotEncoding
Как уже было сказано, номинальные данные нельзя заменять числами 1, 2, 3,…, так как алгоритм ML на этапе обучения подумает, что речь идет о порядковых данных. Нужно использовать one-hot encoder. С этим инструментом мы уже познакомились, когда рассматривали основы нейронных сетей.
Класс OneHotEncoder
Вначале применим класс OneHotEncoder библиотеки sklearn.
1 2 3 4 5 6 7 8 9 10 |
df_onehot = df.copy() from sklearn.preprocessing import OneHotEncoder # создадим объект класса OneHotEncoder # параметр sparse = True выдал бы результат в сжатом формате onehotencoder = OneHotEncoder(sparse = False) encoded_df = pd.DataFrame(onehotencoder.fit_transform(df_onehot[['City']])) encoded_df |

1 2 3 4 5 |
import sklearn # в версии sklearn, установленной в Colab, параметр называется sparse # начиная с версии 1.2 он будет называться sparse_out sklearn.__version__ |
1 |
'1.0.2' |
Выведем новые признаки с помощью метода .get_feature_names_out().
1 |
onehotencoder.get_feature_names_out() |
1 2 |
array(['City_Владивосток', 'City_Екатеринбург', 'City_Москва', 'City_Нижний Новгород', 'City_Санкт-Петербург'], dtype=object) |
Используем вывод этого метода, чтобы добавить названия столбцов.
1 2 |
encoded_df.columns = onehotencoder.get_feature_names_out() encoded_df |

Присоединим новые признаки к исходному датафрейму, удалив, разумеется, признак City.
1 2 |
df_onehot = df_onehot.join(encoded_df) df_onehot.drop('City', axis = 1, inplace = True) |
Обратите внимание, на самом деле нам не нужен первый признак (в данном случае, Владивосток). Если его убрать, при «срабатывании» этого признака (наблюдение с индексом три) все остальные признаки будут иметь нули (так мы поймем, что речь идет именно об этом отсутствующем признаке).
1 2 3 4 5 6 7 8 9 10 11 |
df_onehot = df.copy() # чтобы удалить первый признак, используем параметр drop = 'first' onehot_first = OneHotEncoder(drop = 'first', sparse = False) encoded_df = pd.DataFrame(onehot_first.fit_transform(df_onehot[['City']])) encoded_df.columns = onehot_first.get_feature_names_out() df_onehot = df_onehot.join(encoded_df) df_onehot.drop('Outcome', axis = 1, inplace = True) df_onehot |

Функция pd.get_dummies()
Еще один способ — использовать функцию pd.get_dummies() библиотеки Pandas. Применим функцию к столбцу City.
1 2 |
df_dum = df.copy() pd.get_dummies(df_dum, columns = ['City']) |

Уменьшить длину новых столбцов можно через параметры prefix и prefix_sep.
1 |
pd.get_dummies(df_dum, columns = ['City'], prefix = '', prefix_sep = '') |

Опять же, можно не использовать первую dummy-переменную.
1 2 3 4 |
pd.get_dummies(df_dum, columns = ['City'], prefix = '', prefix_sep = '', drop_first = True) |

Библиотека category_encoders
Рассмотрим еще один способ выполнить one-hot encoding через соответствующий инструмент⧉ очень полезной библиотеки category_encoders.
1 2 |
# установим библиотеку !pip install category_encoders |
Импортируем библиотеку и применим класс OneHotEncoder.
1 2 3 4 5 6 7 8 9 |
df_catenc = df.copy() import category_encoders as ce # в параметр cols передадим столбцы, которые нужно преобразовать ohe_encoder = ce.OneHotEncoder(cols = ['City']) # в метод .fit_transform() мы передадим весь датафрейм целиком df_catenc = ohe_encoder.fit_transform(df_catenc) df_catenc |

Что очень удобно, класс OneHotEncoder библиотеки category_encoders вставил новые столбцы сразу в исходный датафрейм и удалил исходный признак.
Сравнение инструментов
Создадим два очень простых датасета из одного признака: один обучающий, второй — тестовый. В первом в этом признаке (назовем его recom) будет три категории: yes, no, maybe. Во втором, только две, yes и no.
1 2 |
train = pd.DataFrame({'recom' : ['yes', 'no', 'maybe']}) train |

1 2 |
test = pd.DataFrame({'recom' : ['yes', 'no', 'yes']}) test |

Теперь применим каждый из приведенных выше инструментов к этим датасетам (напомню, что обучать кодировщик мы должны на обущающей выборке, чтобы избежать утечки данных).
pd.get_dummies()
Функция pd.get_dummies() не «запоминает» категории при обучении.
1 |
pd.get_dummies(train) |

1 |
pd.get_dummies(test) |

При попытке обучить модель будет ошибка.
OHE sklearn
Посмотрим, как с этим справится класс OneHotEncoder библиотеки sklearn.
1 2 3 |
ohe = OneHotEncoder() ohe_model = ohe.fit(train) ohe_model.categories_ |
1 |
[array(['maybe', 'no', 'yes'], dtype=object)] |
1 2 |
train_arr = ohe_model.transform(train).toarray() pd.DataFrame(train_arr, columns = ['maybe', 'no', 'yes']) |

1 2 |
test_arr = ohe_model.transform(test).toarray() pd.DataFrame(test_arr, columns = ['maybe', 'no', 'yes']) |

Мы видим, что этот кодировщик учел отсутствующую в тестовой выборке категорию. Впрочем, в обратном случае, когда категория отсутствует в обучающей выборке, OneHotEncoder не будет иметь возможности правильно закодировать датасеты.
1 2 3 |
ohe = OneHotEncoder() ohe_model = ohe.fit(test) ohe_model.categories_ |
1 |
[array(['no', 'yes'], dtype=object)] |
OHE category_encoders
Попробуем инструмент из библиотеки category_encoders.
1 2 |
ohe_encoder = ce.OneHotEncoder() ohe_encoder.fit(train) |
1 |
OneHotEncoder(cols=['recom']) |
1 2 |
# категория maybe стоит на последнем месте ohe_encoder.transform(test) |

1 2 3 4 |
# убедимся в этом, добавив названия столбцов test_df = ohe_encoder.transform(test) test_df.columns = ohe_encoder.category_mapping[0]['mapping'].index[:3] test_df |

Проблема OHE
У OneHotEncoding есть одна проблема. При высокой кардинальности признака, создается очень много новых столбцов, а сама матрица становится разреженной.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
# закодируем признак с высокой кардинальностью cities = pd.DataFrame(['Москва', 'Екатеринбург', 'Нижний Новгород', 'Челябинск', 'Владивосток', 'Архангельск', 'Выборг', 'Сочи', 'Астрахань', 'Тюмень', 'Томск', 'Краснодар']) pd.get_dummies(cities) |

Способы преодоления этой проблемы будут рассмотрены на курсе ML для продолжающих.
Binning
Некоторые (в частности, мультимодальные) количественные распределения не поддаются трансформации и приведению, например, к нормальному распределению.
Для того чтобы извлечь ценную информацию из таких признаков можно попробовать сделать переменные категориальными, разбив данные на интервалы, которые и будут классами нового признака. Такой подход называется binning или bucketing.
Вновь обратимся к датасету о недвижимости в Бостоне и, в частности, рассмотрим переменную TAX.
1 2 |
boston = pd.read_csv('/content/boston.csv') boston.TAX.hist(); |

Как мы видим, распределение вряд ли можно трансформировать, используя какое-либо преобразование. Применим binning.
На равные интервалы
Подход binning на равные интервалы (binning with equally spaced boundaries) предполагает, что мы берем диапазон от минимального до максимального значений и делим его на нужное нам количество равных частей (если мы хотим получить три интервала, то нам нужно четыре границы).
1 2 3 4 5 |
min_value = boston.TAX.min() max_value = boston.TAX.max() bins = np.linspace(min_value, max_value, 4) bins |
1 |
array([187. , 361.66666667, 536.33333333, 711. ]) |
Создадим названия категорий.
1 |
labels = ['low', 'medium', 'high'] |
Применим функцию pd.cut(). В параметр bins мы передадим интервалы, в labels — названия категорий.
1 2 3 4 5 6 |
boston['TAX_binned'] = pd.cut(boston.TAX, bins = bins, labels = labels, # уточним, что первый интервал должен включать # нижнуюю границу (значение 187) include_lowest = True) |
Посмотрим на результат.
1 |
boston[['TAX', 'TAX_binned']].sample(5, random_state = 42) |

Границы и количество элементов в них можно получить с помощью метода .value_counts().
1 |
boston.TAX.value_counts(bins = 3, sort = False) |
1 2 3 4 |
(186.475, 361.667] 273 (361.667, 536.333] 96 (536.333, 711.0] 137 Name: TAX, dtype: int64 |
Результат этого метода позволяет выявить недостаток подхода binning на равные интервалы. Количество объектов внутри интревалов сильно различается. Преодолеть эту особенность можно с помощью деления по квантилям.
По квантилям
Binning по квантилям (quantile binning) позволяет разделить наблюдения не по значениям признака, а по количеству объектов в интервале. Например, выберем разделение на три части.
1 2 |
# для наглядности вначале найдем интересующие нас квантили np.quantile(boston.TAX, q = [1/3, 2/3]) |
1 |
array([300., 403.]) |
Применим функцию pd.qcut().
1 2 3 4 5 6 7 8 |
boston['TAX_qbinned'], boundaries = pd.qcut(boston.TAX, q = 3, # precision определяет округление precision = 1, labels = labels, retbins = True) boundaries |
1 |
array([187., 300., 403., 711.]) |

1 |
boston.TAX_qbinned.value_counts() |
1 2 3 4 |
low 172 high 168 medium 166 Name: TAX_qbinned, dtype: int64 |
Как вы видите, в данном случае количество объектов примерно одинаковое. Наглядную иллюстрацию двух подходов можно посмотреть здесь⧉.
KBinsDiscretizer
Эти же задачи можно решить с помощью класса KBinsDiscretizer⧉ библиотеки sklearn. Рассмотрим три основных параметра класса:
- параметр strategy определяет как будут делиться интервалы
- на равные части (uniform)
- по квантилям (quantile) или
- так, чтобы значения в каждом кластере относились к центроиду (kmeans)
- параметр encode определяет как закодировать интервалы
- ordinal, т.е. числами от 1 до n интервалов
- one-hot encoding
- количество интервалов n_bins
Применим каждую из стратегий. Так как в категориях заложен порядок, выберем ordinal кодировку.
1 |
from sklearn.preprocessing import KBinsDiscretizer |
strategy = uniform
1 2 3 |
est = KBinsDiscretizer(n_bins=3, encode = 'ordinal', strategy = 'uniform') est.fit(boston[['TAX']]) est.bin_edges_ |
1 2 |
array([array([187. , 361.66666667, 536.33333333, 711. ])], dtype=object) |
1 |
np.unique(est.transform(boston[['TAX']]), return_counts = True) |
1 |
(array([0., 1., 2.]), array([273, 96, 137])) |
strategy = quantile
1 2 3 |
est = KBinsDiscretizer(n_bins=3, encode = 'ordinal', strategy = 'quantile') est.fit(boston[['TAX']]) est.bin_edges_ |
1 |
array([array([187., 300., 403., 711.])], dtype=object) |
1 |
np.unique(est.transform(boston[['TAX']]), return_counts = True) |
1 |
(array([0., 1., 2.]), array([165, 143, 198])) |
strategy = kmeans
1 2 3 |
est = KBinsDiscretizer(n_bins = 3, encode = 'ordinal', strategy = 'kmeans') est.fit(boston[['TAX']]) est.bin_edges_ |
1 2 |
array([array([187. , 338.7198937 , 535.07350433, 711. ])], dtype=object) |
1 |
np.unique(est.transform(boston[['TAX']]), return_counts = True) |
1 |
(array([0., 1., 2.]), array([262, 107, 137])) |
Еще одно сравнение стратегий разделения на интервалы можно посмотреть здесь⧉.
С помощью статистических показателей
Дополнительно замечу, что интервалы можно заполнить каким-либо статистическим показателем. Например, медианой. Для наглядности снова создадим только три интервала и найдем мединное значение внутри каждого из них.
Воспользуемся функцией binned_statistic()⧉ модуля scipy.stats. Функция возвращает медианы каждого из интервалов, границы интервалов, а также к какому из интервалов относится каждое из наблюдений.
Нас будут интересовать метрики и границы интервалов.
1 2 3 4 5 6 7 8 |
from scipy.stats import binned_statistic medians, bin_edges, _ = binned_statistic(boston.TAX, np.arange(0, len(boston)), statistic = 'median', bins = 3) medians, bin_edges |
1 2 |
(array([216. , 147.5, 424. ]), array([187. , 361.66666667, 536.33333333, 711. ])) |
Подставим эти значения в функцию pd.cut().
1 2 3 4 5 6 |
boston['TAX_binned_median'] = pd.cut(boston.TAX, bins = bin_edges, labels = medians, include_lowest = True) boston['TAX_binned_median'].value_counts() |
1 2 3 4 |
216.0 273 424.0 137 147.5 96 Name: TAX_binned_median, dtype: int64 |
Алгоритм Дженкса
Алгоритм естественных границ Дженкса (Jenks natural breaks optimization) делит данные на группы (кластеры) таким образом, чтобы минимизировать отклонение наблюдений от среднего каждого класса (дисперсию внутри классов) и максимизировать отклонение среднего каждого класса от среднего других классов (дисперсию между классами).
Этот алгоритм также можно использовать для определения границ интервалов. Установим библиотеку jenkspy⧉.
1 |
!pip install jenkspy |
Найдем оптимальные границы. Количество интервалов (n_classes) нужно по-прежнему указывать вручную.
1 2 3 4 |
import jenkspy breaks = jenkspy.jenks_breaks(boston.TAX, n_classes = 3) breaks |
1 |
[187.0, 337.0, 469.0, 711.0] |
Подставим интервалы в функцию pd.cut().
1 2 3 4 5 6 |
boston['TAX_binned_jenks'] = pd.cut(boston.TAX, bins = breaks, labels = labels, include_lowest = True) boston['TAX_binned_jenks'].value_counts() |
1 2 3 4 |
low 262 high 137 medium 107 Name: TAX_binned_jenks, dtype: int64 |
Подведем итог
Сегодня мы рассмотрели базовые методы кодирования категориальных переменных, а также стратегии binning/bucketing.