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

Машинное обучение – это процесс, в котором компьютер учится интерпретировать данные, принимать решения и создавать рекомендации на основе этих данных. В процессе обучения – и позже. 

Если данные, используемые для прогнозирования неправильные, необъективные или неполные, то они могут привести к неточным результатам прогноза.

Подготовка данных – это процесс подготовки «сырых» (необработанных) данных для их дальнейшей обработки и анализа.

Предобработка данных включает следующие процедуры:

  • проверка данных;

  • очистка данных;

  • трансформация данных;

  • трансформация данных;

  • дополнение;

  • оптимизация.

Проверка данных включает выявление:

  • дубликатов, противоречий, ошибок;

  • аномальных наблюдений;

  • пропусков.

Очистка данных содержит:

  • устранение дубликатов, противоречий и ошибок;

  • обработку аномальных наблюдений;

  • обработку пропусков.

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

Заполнять пропуски можно с помощью:

  • нулей;

  • моды, медианы или среднего значения;

  • индикаторных переменных.

Трансформация данных включает:

  • переименование признаков;

  • сортировка, группировка данных;

  • кодирование переменных;

  • нормировка данных.

Дополнение данных подразумевает создание новых признаков, агрегирование признаков.

Оптимизация данных включает:

  •  снижение размерности;

  • выявление и исключение незначительных признаков.

Подготовка данных может занимать до 80 % времени, затрачиваемого на проект машинного обучения. Чтобы оптимизировать этот процесс, важно использовать инструменты, специально разработанные для подготовки данных. 

В данной статье рассмотрим пример, выполненный на языке программирования Python в среде Google Colaboratory.

Для начала необходимо загрузить библиотеки

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

и загрузить файл data.csv, в котором содержатся следующие данные:

df = pd.read_csv('/content/data.csv')

Описание данных

  • children – количество детей в семье;

  • days_employed – общий трудовой стаж в днях;

  • dob_years – возраст клиента в годах;

  • education – уровень образования клиента;

  • education_id – идентификатор уровня образования;

  • family_status – семейное положение;

  • family_status_id – идентификатор семейного положения;

  • gender – пол клиента;

  • income_type – тип занятости;

  • debt – имел ли задолженность по возврату кредитов (1 – имел, 0 – не имел);

  • total_income – ежемесячный доход;

  • purpose – цель получения кредита.

Посмотрим содержание файла, с помощью метода info():

df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 21525 entries, 0 to 21524
Data columns (total 12 columns):
 #   Column            Non–Null Count  Dtype  
–––  ––––––            ––––––––––––––  –––––  
 0   children          21525 non–null  int64  
 1   days_employed    1 1935 non–null  float64
 2   dob_years         21525 non–null  int64  
 3   education         21525 non–null  object 
 4   education_id      21525 non–null  int64  
 5   family_status     21525 non–null  object 
 6   family_status_id  21525 non–null  int64  
 7   gender            21525 non–null  object 
 8   income_type       21525 non–null  object 
 9   debt              21525 non–null  int64  
 10  total_income      19351 non–null  float64
 11  purpose           21525 non–null  object 
dtypes: float64(2), int64(5), object(5)
memory usage: 2.0+ MB

На основе выведенных данных, мы можем сделать вывод, что тип признаков соответствует их смысловому содержанию. Однако можно заметить, что в признаках days_employed и total_income содержатся пропуски значений.

Выявление и обработка аномальных наблюдений

На следующем шаге следует проверить признаки на наличие аномальных значений.

Аномальными наблюдениями называются наблюдения сильно отличающиеся от остальных по исследуемому признаку.

Для визуализации результатов будем использовать построение коробчатой диаграммы или графика «ящик с усами». Построение такого графика свяхано с понятием «квартиль». 

Термин «квартиль» используют для обозначения квантиля кратного ¼.

Квантилем порядка называется значение x такое, что

F(x_\alpha) =\alpha = P(X < x_\alpha) =\alpha

Квартили разбивают выборку на 4 части.

Первый квартиль Q1 порядка 0,25 – число, определяющее ¼ часть упорядоченной выборки, то есть 25% значений упорядоченной выборки меньше Q1, 75% значений – больше Q1.

Второй квартиль Q2 порядка 0,5 или медиана – число, для которого 50 % упорядоченной выборки меньше Q2, а 50% значений – больше Q2.

Третий квартиль Q3 порядка 0,75 – число, для которого 25% значений упорядоченной выборки меньше Q3, 25% значений – больше  Q3.

Межквартильный размах – величина, вычисляемая по формуле:

IQR =Q3 - Q1 

График «ящик с усами» имеет вид:

«Ящик» ограничен квартилями Q1 и Q3, внутри него расположена медиана Q2. «Усы» простираются влево и вправо на расстояние 1,5 * IQR

С помощью методов head() и tail() мы видим, что в переменной days_employed присутствует достаточно много отрицательных значений. 

 

Теперь посмотрим на описательную статистику по всем переменным:

df.describe()

Можно сделать следующие выводы по каждому из признаков:

Значения признака children имеет диапазон от –1 до 20.
Скорее всего имеются аномальные значения.

Значения признака days_employed имеет диапазон от –18 388.9 до 401 755.4.
Скорее всего имеются аномальные значения.

Значения признака dob_years имеет диапазон от 0 до 75.
Скорее всего имеются аномальные значения.

Значения признака debt имеет диапазон от 0 до 1.
Аномальных значений нет.

Значения признака total_income имеет диапазон от 20 667 до 2 265 604.
Аномальных значений нет.

Переменные family_status_id и education_id являются уникальными идентификаторами и не несут смысловой составляющей, поэтому не оцениваем их на наличие аномальности.

Далее проведем анализ аномальных значений по конкретным признакам.
Начнем с признака children. С помощью метода .value_counts() посмотрим уникальные значения и их количество, хранимое в признаке.

Предположим, что аномальные наблюдения являются результатами технических ошибок ввода данных, поэтому заменим значения – 1 и 20 на 1 и 2 соответственно, используя метод .replace().

df['children'] = df['children'].replace(–1,1)
df['children'] = df['children'].replace(20,2)

Следующий признак, который будем рассматривать – days_employed.

Ранее, анализируя описательную статистику, можно было заметить, что в этом признаке есть и аномально большие, и аномально малые наблюдения. Поэтому выявим типы занятости заемщиков с отрицательными значениями переменной.

df[df['days_employed'] < 0]['income_type'].value_counts()

Теперь – с положительными значениями переменной.

df[df['days_employed'] > 0]['income_type'].value_counts()

Заменим отрицательные значения положительными с помощью методов .loc() и .abs().

df.loc[df['days_employed'] < 0, 'days_employed'] = df['days_employed'].abs()

Просмотрим описательную статистику по переменной:

df['days_employed'].describe()

В данном признаке содержатся аномальные наблюдения. Чтобы визуализировать данные, построим график стажа,с помощью метода .hist().

df.hist('days_employed', bins =25)

Проведем анализ максимального стажа в днях и в годах.

max_day = df['days_employed'].max()
max_day

Получим 401755.4

Переведем дни в годы.

max_year = max_day/365
max_year

Получим 1100.69

Можем сделать вывод, что максимальный стаж при 5–дневной рабочей недели более 1100 лет. 

Теперь проанализируем средний и медианный стаж в годах, с помощью методов .mean() и .quantile().

mean_day = df['days_employed'].mean()
mean_year = mean_day/365
mean_year

Получим 183.33

median_day = df['days_employed'].quantile(0.5)
median_year = median_day/365
median_year

Получим 6.01

Видим, что значения среднего и медианного стажа сильно отличаются, требуется дальнейшее исследование.

Проанализируем общее количество, среднее, максимальное и минимальное значения признака days_employed по типам занятости заемщиков.

df_type = df.groupby(['income_type']).agg({'days_employed' : ['count', 'mean', 'max', 'min']})

Делаем вывод, что аномально большие наблюдения стажа характерны для двух типов занятости «безработный» и «пенсионер». Так как группа заемщиков «безработный», «в декрете», «предприниматель» и «студент» представлены малым количеством записей (одной), то их можно удалить из рассмотрения, воспользовавшись методом .drop(), уменьшив размерность признакового пространства.

df.drop(df[df['income_type'] == 'безработный'].index, inplace = True)
df.drop(df[df['income_type'] == 'в декрете'].index, inplace = True)
df.drop(df[df['income_type'] == 'предприниматель'].index, inplace = True)
df.drop(df[df['income_type'] == 'студент'].index, inplace = True)

Построим графики для каждого класса заемщиков, используя методы из библиотек matplotlib и  seaborn. Для удобства разобьем датафрейм df по типу заемщиков.

df1 = df[df['income_type'] == 'госслужащий']
df2 = df[df['income_type'] == 'компаньон']
df3 = df[df['income_type'] == 'пенсионер']
df4 = df[df['income_type'] == 'сотрудник']

Строим общий график для четырех групп заемщиков.

# задаем размер сетки
ax = plt.subplots(2,2, figsize = (18,12))

# определение позиции первого графика
plt.subplot(2,2,1)

# задание графика
ax = sns.histplot(df1['days_employed'], bins = 25, color = 'purple', kde = True)

# подпись графика
ax.set_title('Класс заемщиков: «госслужащий»')

# определение позиции второго графика
plt.subplot(2,2,2)
ax = sns.histplot(df2['days_employed'], bins = 25, color = 'orange', kde = True)

# подпись графика
ax.set_title('Класс заемщиков: «компаньон»')

# определение позиции третьего графика
plt.subplot(2,2,3)
ax = sns.histplot(df3['days_employed'], bins = 25, color = 'green', kde = True)

# подпись графика
ax.set_title('Класс заемщиков: «пенсионер»')

# определение позиции четвертого графика
plt.subplot(2,2,4)
ax = sns.histplot(df4['days_employed'], bins = 25, color = 'red', kde = True)

# подпись графика
ax.set_title('Класс заемщиков: «сотрудник»')

Можно заметить, что в группе «пенсионер» явно имеются аномальные наблюдения. Предположим, что стаж в этой группе представлен не в днях, а в часах.

df.loc[df['income_type'] == 'пенсионер', 'days_employed'] = df['days_employed']/24

Для удобства анализа переведем в стаж в годах. Посмотрим статистику данных после выполненных преобразований.

df['years_employed'] = df['days_employed']/365
df_type1 = df.groupby(['income_type']).agg({'days_employed':['count', 'mean', 'max', 'min']})
df_type1

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

По построенным диаграммам можно сделать, что в  группе «пенсионер» все еще имеются аномальные наблюдения, поэтому необходимо продолжить преобразования.

Для каждого типа занятости вычислим квантили 0,95 и 0,05 стажа.

print(int(df.loc[df['income_type'] == 'госслужащий', 'years_employed'].quantile(0.95)))
print(int(df.loc[df['income_type'] == 'компаньон','years_employed'].quantile(0.95)))
print(int(df.loc[df['income_type'] == 'пенсионер','years_employed'].quantile(0.95)))
print(int(df.loc[df['income_type'] == 'сотрудник', 'years_employed'].quantile(0.95)))

24
16
45
19

print(int(df.loc[df['income_type'] == 'госслужащий','years_employed'].quantile(0.05)))

print(int(df.loc[df['income_type'] == 'компаньон','years_employed'].quantile(0.05)))

print(int(df.loc[df['income_type'] == 'пенсионер','years_employed'].quantile(0.05)))

print(int(df.loc[df['income_type'] == 'сотрудник', 'years_employed'].quantile(0.05)))

0
0
37
0

Проведем графический анализ аномальных наблюдений стажа.

По графику «ящик с усами» видно, что аномальные наблюдения присутствуют во всех группах, кроме «пенсионер».

Процент наблюдений, превышающий квантиль 0,95 велик, при удалении можно потерять часть значимых данных, также вычислим квантили 0,99 стажа.

print(int(df.loc[df['income_type'] == 'госслужащий','years_employed'].quantile(0.99)))
print(int(df.loc[df['income_type'] == 'компаньон', 'years_employed'].quantile(0.99)))
print(int(df.loc[df['income_type'] == 'пенсионер','years_employed'].quantile(0.99)))
print(int(df.loc[df['income_type'] == 'сотрудник','years_employed'].quantile(0.99)))

33
28
45
30

print(int(df.loc[df['income_type'] == 'госслужащий','years_employed'].quantile(0.01)))
print(int(df.loc[df['income_type'] == 'компаньон','years_employed'].quantile(0.01)))
print(int(df.loc[df['income_type'] == 'пенсионер','years_employed'].quantile(0.01)))
print(int(df.loc[df['income_type'] == 'сотрудник','years_employed'].quantile(0.01)))

0
0
37
0

Удалим наблюдения, превышающие квантиль 0,99.

df.drop(df[(df['years_employed'] > 33) & (df['income_type'] == 'госслужащий')].index, inplace = True)

df.drop(df[(df['years_employed'] > 28) & (df['income_type'] == 'компаньон')].index, inplace = True)

df.drop(df[(df['years_employed'] > 30 ) & (df['income_type'] == 'сотрудник')].index, inplace = True)

Построим график «ящик с усами» после преобразований.

 sns.boxplot(x = 'income_type', y = 'years_employed', data = df)

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

Переменная dob_years.

df.groupby(['income_type']).agg({'dob_years' : ['min', 'max']})

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

Посчитаем количество нулевых значений признака по типам занятости с помощью метода .value_counts().

df[df['dob_years'] == 0]['income_type'].value_counts()

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

df.drop(df[df['dob_years'] == 0].index, inplace = True)

Посмотрим, каков минимальный возраст теперь.

df.groupby(['income_type']).agg({'dob_years' : ['min', 'max']})

Теперь проанализируем квантили 0,95 и 0,99:

print(int(df.loc[df['income_type'] == 'госслужащий','dob_years'].quantile(0.95)))
print(int(df.loc[df['income_type'] == 'компаньон', 'dob_years'].quantile(0.95)))
print(int(df.loc[df['income_type'] == 'пенсионер','dob_years'].quantile(0.95)))
print(int(df.loc[df['income_type'] == 'сотрудник', 'dob_years'].quantile(0.95)))

58
57
69
58

print(int(df.loc[df['income_type'] == 'госслужащий','dob_years'].quantile(0.99)))
print(int(df.loc[df['income_type'] == 'компаньон','dob_years'].quantile(0.99)))
print(int(df.loc[df['income_type'] == 'пенсионер','dob_years'].quantile(0.99)))
print(int(df.loc[df['income_type'] == 'сотрудник','dob_years'].quantile(0.99)))

65
64
72
63

А также построим график для анализа аномальных наблюдений.

sns.boxplot(x = 'income_type', y = 'dob_years', data = df)

Определим границы «усов» для поиска аномальных значений.

Q1 = int(df.loc[df['income_type'] == 'сотрудник','dob_years'].quantile(0.25))
Q3 = int(df.loc[df['income_type'] == 'сотрудник', 'dob_years'].quantile(0.75))
IQR = Q3 – Q1
up_S = Q3 + 1.5*IQR
up_S

Получим 72.0

Q1 = int(df.loc[df['income_type'] == 'пенсионер', 'dob_years'].quantile(0.25))
Q3 = int(df.loc[df['income_type'] == 'пенсионер', 'dob_years'].quantile(0.75))
IQR = Q3 – Q1
low_P = Q1 – 1.5*IQR
low_P

Получим 44.0

Q1 = int(df.loc[df['income_type'] == 'компаньон', 'dob_years'].quantile(0.25))
Q3 = int(df.loc[df['income_type'] == 'компаньон', 'dob_years'].quantile(0.75))
IQR = Q3 – Q1
up_K = Q3 + 1.5*IQR
up_K

Получим 71.0

Q1 = int(df.loc[df['income_type'] == 'госслужащий', 'dob_years'].quantile(0.25))
Q3 = int(df.loc[df['income_type'] == 'госслужащий', 'dob_years'].quantile(0.75))
IQR = Q3 – Q1
up_G = Q3 + 1.5*IQR
up_G

Получим 72.0

Вычислим количество значений по каждому типу заемщиков, выходящих за пределы соответствующих «усов».

len(df[(df['dob_years'] > 72) & (df['income_type'] == 'сотрудник')])

Получим 1

len(df[(df['dob_years'] < 44) & (df['income_type'] == 'пенсионер')])

Получим 79

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

df.drop(df[(df['dob_years'] > 72) & (df['income_type'] == 'сотрудник')].index, inplace = True)

df.drop(df[(df['dob_years'] < 44) & (df['income_type'] == 'пенсионер')].index, inplace = True)

df.drop(df[(df['dob_years'] > 71) & (df['income_type'] == 'компаньон')].index, inplace = True)

df.drop(df[(df['dob_years'] > 72) & (df['income_type'] == 'госслужащий')].index, inplace = True)

Посмотрим как изменилась выборка после преобразований.

sns.boxplot(x = 'income_type', y = 'dob_years', data = df)

Исходя из данной диаграммы, можно сделать вывод, что аномальных значений нет.

Теперь перейдем к исследованию переменной gender.

Если мы посмотрим уникальные значения с помощью ранее использованного метода .value_counts(), то увидим, что есть одно наблюдение с неопределенным полом, который можно исключить из выборки.

При изучении переменной family_status также просмотрим уникальные значения. Для уменьшения размерности признакового пространства объединим значения переменной в 2 класса – женат/замужем и холост/не замужем

В переменной total_income можно заметить наличие аномально больших наблюдений. Обработка аномальных значений данной переменной будет схожа с ранее проведенными процедурами нахождения границ «усов». Необходимо также провести анализ квантилей и принять решение по дальнейшей обработке.

Анализ и исключение дубликатов

Следующим этапом является анализ и исключение дубликатов.

Посмотрим уникальные значения признака education

df['education'].unique()

Приведем все элементы к одному регистру с помощью метода .str.lower() и снова просмотрим уникальные значения.

df['education'] = df['education'].str.lower()
df['education'].unique()

Проанализируем наличие дубликатов (без анализа переменной purpose):

df.duplicated().sum()

Получим 71

Удалим все имеющиеся дубликаты, используя метод .drop_duplicates()

Анализ и обработка пропусков

Как было определено ранее, пропуски присутствуют в двух переменных – days_employed и total_income. Анализ и обработка переменой days_employed проведен ранее, в следствие чего мы изменили ее на years_employed. Начнем с рассмотрения переменной total_income.

Создадим датафреймы по каждому классу income_type, так как структура датафрейма df изменилась, мы не можем использовать ранее созданные переменные df1,.. df4

df_S =  df[df['income_type'] == 'сотрудник']
df_P =  df[df['income_type'] == 'пенсионер']
df_K =  df[df['income_type'] == 'компаньон']
df_G =  df[df['income_type'] == 'госслужащий']

Построим гистограммы по каждому типу заемщиков, как были описаны ранее

Для каждого типа найдем медиану и среднее значение.

df_S.agg({'total_income' : ['mean', 'median']})
df_P.agg({'total_income' : ['mean', 'median']})
df_K.agg({'total_income' : ['mean', 'median']})
df_G.agg({'total_income' : ['mean', 'median']})

Заменим пропуски значением медианой, с помощью функции lambda х и метода .fillna().

group = df.groupby('income_type')
df['total_income'] = group.total_income.apply(lambda x: x.fillna(x.median()))

Далее перейдем к анализу переменной years_employed. Определим сколько клиентов начали работать с 18 лет.

df.loc[df['years_employed']> df['dob_years']–18].shape[0]

Получим 1908

И сколько клиентов начали работать с 14 лет.

df.loc[df['years_employed']> df['dob_years']–14].shape[0]

Получим 784


На основе полученных результатов можем сделать вывод, что количество клиентов, начавших работать с 14 лет более чем в 2 раза меньше, чем тех, кто начал работать с 18 лет. Удалим переменную years_employed, так как она является неинформативной.

Также удалим дублирующие столбцы, создав новый датафрейм df_new

df_new = df.drop(['education_id', 'family_status_id', 'days_employed', 'purpose', 'years_employed'], axis = 1)

Просмотрим результат предобработки данных

df_model.info()
<class 'pandas.core.frame.DataFrame'>
Int64Index: 20316 entries, 0 to 21524
Data columns (total 9 columns):
 #   Column         Non–Null Count  Dtype  
–––  ––––––         ––––––––––––––  –––––  
 0   children       20316 non–null  int64  
 1   dob_years      20316 non–null  int64  
 2   education      20316 non–null  object 
 3   family_status  20316 non–null  object 
 4   gender         20316 non–null  object 
 5   income_type    20316 non–null  object 
 6   debt           20316 non–null  int64  
 7   total_income   20316 non–null  float64
 8   purpose_lem    20316 non–null  object 
dtypes: float64(1), int64(3), object(5)
memory usage: 1.5+ MB

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

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


  1. fiksii
    18.03.2024 20:16
    +1

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

    И во-вторых, эта статья явно не тянет на "сложный" уровень.


  1. figerdron
    18.03.2024 20:16

    Спасибо за статью. Мне кажется, что уровень сложности статьи не должна быть "сложный", т.к. я, учащий ml около полгода (не особо интенсивно), смог понять практически всё, описанное в статье. Есть только один вопрос: "Удалим переменную years_employed, так как она является неинформативной". Можно поподробнее, почему двукратная разница между кол-вом клиентов, начавших работать в 14 и 18 лет, делает признак не информативным?