Предобработка данных

Начнем с подключения необходимых библиотек и модулей:

import pandas as pd
import seaborn as sns
from tqdm import tqdm
from sklearn.datasets import fetch_20newsgroups

from sklearn.model_selection import train_test_split
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_squared_error

Работа с текстовыми данными

Как правило, модели машинного обучения действуют в предположении, что матрица «объект-признак» является вещественнозначной, поэтому при работе с текстами сперва для каждого из них необходимо составить его признаковое описание. Для этого широко используются техники векторизации, tf-idf и пр.

Сперва загрузим данные:

data = fetch_20newsgroups(subset='all', categories=['comp.graphics', 'sci.med'])

Данные содержат тексты новостей, которые надо классифицировать на разделы.

data['target_names']

texts = data['data']
target = data['target']

Например:

texts[0]

data['target_names'][target[0]]

Bag-of-words

Самый очевидный способ формирования признакового описания текстов — векторизация. Простой способ заключается в подсчёте, сколько раз встретилось каждое слово в тексте. Получаем вектор длиной в количество уникальных слов, встречающихся во всех объектах выборки. В таком векторе много нулей, поэтому его удобнее хранить в разреженном виде.

Пусть у нас имеется коллекция текстов \( D = \{d_i\}_{i=1}^l \) и словарь всех слов, встречающихся в выборке \( V = \{v_j\}_{j=1}^d. \) В этом случае некоторый текст \( d_i \) описывается вектором \( (x_{ij})_{j=1}^d, \) где $$ x_{ij} = \sum_{v \in d_i} [v = v_j]. $$

Таким образом, текст \( d_i \) описывается вектором количества вхождений каждого слова из словаря в данный текст.

from sklearn.feature_extraction.text import CountVectorizer

vectorizer = CountVectorizer(encoding='utf8', min_df=1)
_ = vectorizer.fit(texts)

Результатом является разреженная матрица.

vectorizer.transform(texts[:1])

print(vectorizer.transform(texts[:1]).indptr)
print(vectorizer.transform(texts[:1]).indices)
print(vectorizer.transform(texts[:1]).data)

Такой способ представления текстов называют мешком слов (bag-of-words).

TF-IDF

Очевидно, что не все слова полезны в задаче прогнозирования. Например, мало информации несут слова, встречающиеся во всех текстах. Это могут быть как стоп-слова, так и слова, свойственные всем текстам выборки (в текстах про автомобили употребляется слово «автомобиль»). Эту проблему решает TF-IDF (*T*erm *F*requency–*I*nverse *D*ocument *F*requency) преобразование текста.

Рассмотрим коллекцию текстов \( D \). Для каждого уникального слова \( t \) из документа \( d \in D \) вычислим следующие величины:

$$ \textrm{tf}(t, d) = \frac{n_{td}}{\sum_{t \in d} n_{td}}, $$ где \( n_{td} \) — количество вхождений слова \( t \) в текст \( d \). $$ \textrm{idf}(t, D) = \log \frac{\left| D \right|}{\left| \{d\in D: t \in d\} \right|}, $$ где \( \left| \{d\in D: t \in d\} \right| \) – количество текстов в коллекции, содержащих слово \( t \).

Тогда для каждой пары (слово, текст) \( (t, d) \) вычислим величину: $$ \textrm{tf-idf}(t,d, D) = \text{tf}(t, d)\cdot \text{idf}(t, D). $$

Отметим, что значение \( \text{tf}(t, d) \) корректируется для часто встречающихся общеупотребимых слов при помощи значения \( \textrm{idf}(t, D) \).

from sklearn.feature_extraction.text import TfidfVectorizer

vectorizer = TfidfVectorizer(encoding='utf8', min_df=1)
_ = vectorizer.fit(texts)

На выходе получаем разреженную матрицу.

vectorizer.transform(texts[:1])

print(vectorizer.transform(texts[:1]).indptr)
print(vectorizer.transform(texts[:1]).indices)
print(vectorizer.transform(texts[:1]).data)

Заметим, что оба метода возвращают вектор длины 32548 (размер нашего словаря).

Заметим, что одно и то же слово может встречаться в различных формах (например, «сотрудник» и «сотрудника»), но описанные выше методы интерпретируют их как различные слова, что делает признаковое описание избыточным. Устранить эту проблему можно при помощи лемматизации и стемминга.

Стемминг

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

Таблица 1. Примеры стемминга

Слово Основа
вагон вагон
вагона вагон
вагоне вагон
вагонов вагон
вагоном вагон
вагоны вагон
важная важн
важнее важн
важнейшие важн
важнейшими важн
важничал важнича
важно важн

Snowball — фрэймворк для написания алгоритмов стемминга (библиотека nltk). Алгоритмы стемминга отличаются для разных языков и используют знания о конкретном языке — списки окончаний для разных чистей речи, разных склонений и т.д. Пример алгоритма для русского языка – Russian stemming.

import nltk
stemmer = nltk.stem.snowball.RussianStemmer()

print(stemmer.stem(u'машинное'), stemmer.stem(u'обучение'))

stemmer = nltk.stem.snowball.EnglishStemmer()

def stem_text(text, stemmer):
    tokens = text.split()
    return ' '.join(map(lambda w: stemmer.stem(w), tokens))

stemmed_texts = []
for t in tqdm(texts[:1000]):
    stemmed_texts.append(stem_text(t, stemmer))

print(texts[0])

print(stemmed_texts[0])

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

Лематизация

Лемматизация — процесс приведения слова к его нормальной форме (лемме):

Лемматизация — процесс более сложный по сравнению со стеммингом. Стеммер просто «режет» слово до основы.

Например, для русского языка есть библиотека pymorphy2.

import pymorphy2
morph = pymorphy2.MorphAnalyzer()

morph.parse('играющих')[0]

Сравним работу стеммера и лемматизатора на примере:

stemmer = nltk.stem.snowball.RussianStemmer()
print(stemmer.stem('играющих'))

print(morph.parse('играющих')[0].normal_form)

Трансформация признаков и целевой переменной

Разберёмся, как может влиять трансформация признаков или целевой переменной на качество модели.

Логарифмирование

Воспользуется датасетом с ценами на дома, с которым мы уже сталкивались ранее (House Prices: Advanced Regression Techniques).

data = pd.read_csv('train.csv')

data = data.drop(columns=["Id"])
y = data["SalePrice"]
X = data.drop(columns=["SalePrice"])

Посмотрим на распределение целевой переменной

plt.figure(figsize=(12, 5))

plt.subplot(1, 2, 1)
sns.distplot(y, label='target')
plt.title('target')

plt.subplot(1, 2, 2)
sns.distplot(data.GrLivArea, label='area')
plt.title('area')
plt.show()

Видим, что распределения несимметричные с тяжёлыми правыми хвостами.

Оставим только числовые признаки, пропуски заменим средним значением.

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=10)

numeric_data = X_train.select_dtypes([np.number])
numeric_data_mean = numeric_data.mean()
numeric_features = numeric_data.columns

X_train = X_train.fillna(numeric_data_mean)[numeric_features]
X_test = X_test.fillna(numeric_data_mean)[numeric_features]

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

Если прологарифмировать целевую переменную, то её распределение станет больше похоже на нормальное:

sns.distplot(np.log(y+1), label='target')
plt.show()

Сравним качество линейной регрессии в двух случаях:

Предупреждение

Не забудем во втором случае взять экспоненту от предсказаний!

model = Ridge()
model.fit(X_train, y_train)
y_pred = model.predict(X_test)

print("Test RMSE = %.4f" % mean_squared_error(y_test, y_pred) ** 0.5)

model = Ridge()
model.fit(X_train, np.log(y_train+1))
y_pred = np.exp(model.predict(X_test))-1

print("Test RMSE = %.4f" % mean_squared_error(y_test, y_pred) ** 0.5)

Попробуем аналогично логарифмировать один из признаков, имеющих также смещённое распределение (этот признак был вторым по важности!)

X_train.GrLivArea = np.log(X_train.GrLivArea + 1)
X_test.GrLivArea = np.log(X_test.GrLivArea + 1)

model = Ridge()
model.fit(X_train[numeric_features], y_train)
y_pred = model.predict(X_test[numeric_features])

print("Test RMSE = %.4f" % mean_squared_error(y_test, y_pred) ** 0.5)

model = Ridge()
model.fit(X_train[numeric_features], np.log(y_train+1))
y_pred = np.exp(model.predict(X_test[numeric_features]))-1

print("Test RMSE = %.4f" % mean_squared_error(y_test, y_pred) ** 0.5)

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

Бинаризация

Мы уже смотрели, как полиномиальные признаки могут помочь при восстановлении нелинейной зависимости линейной моделью. Альтернативный подход заключается в бинаризации признаков. Мы разбиваем ось значений одного из признаков на куски (бины) и добавляем для каждого куска-бина новый признак-индикатор попадения в этот бин.

from sklearn.linear_model import LinearRegression

np.random.seed(36)
X = np.random.uniform(0, 1, size=100)
y = np.cos(1.5 * np.pi * X) + np.random.normal(scale=0.1, size=X.shape)

plt.scatter(X, y)

X = X.reshape((-1, 1))
thresholds = np.arange(0.2, 1.1, 0.2).reshape((1, -1))

X_expand = np.hstack((
    X,
    ((X > thresholds[:, :-1]) & (X <= thresholds[:, 1:])).astype(int)))

from sklearn.model_selection import KFold
from sklearn.model_selection import cross_val_score

-np.mean(cross_val_score(
    LinearRegression(), X, y, cv=KFold(n_splits=3, random_state=123),
    scoring='neg_mean_squared_error'))

-np.mean(cross_val_score(
    LinearRegression(), X_expand, y, cv=KFold(n_splits=3, random_state=123),
    scoring='neg_mean_squared_error'))

Так линейная модель может лучше восстанавливать нелинейные зависимости.

Транзакционные данные

Напоследок посмотрим, как можно извлекать признаки из транзакционных данных.

Транзакционные данные характеризуются тем, что есть много строк, характеризующихся моментов времени и некоторым числом (суммой денег, например). При этом если это банк, то каждому человеку принадлежит не одна транзакция, а чаще всего надо предсказывать некоторые сущности для клиентов. Таким образом, надо получить признаки для пользователей из множества их транзакций. Этим мы и займёмся.

Для примера возьмём данные отсюда. Задача детектирования фродовых клиентов.

customers = pd.read_csv('Retail_Data_Response.csv')
transactions = pd.read_csv('Retail_Data_Transactions.csv')

customers.head()

transactions.head()

transactions.trans_date = transactions.trans_date.apply(
    lambda x: datetime.datetime.strptime(x, '%d-%b-%y'))

Посмотрим на распределение целевой переменной:

customers.response.mean()

Получаем примерно 1 к 9 положительных примеров. Если такие данные разбивать на части для кросс валидации, то может получиться так, что в одну из частей попадёт слишком мало положительных примеров, а в другую — наоборот. На случай такого неравномерного баланса классов есть StratifiedKFold, который бьёт данные так, чтобы баланс классов во всех частях был одинаковым.

from sklearn.model_selection import StratifiedKFold

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

agg_transactions = transactions.groupby('customer_id').tran_amount.agg(
    ['mean', 'std', 'count', 'min', 'max']).reset_index()

data = pd.merge(customers, agg_transactions, how='left', on='customer_id')

data.head()

from sklearn.linear_model import LogisticRegression

np.mean(cross_val_score(
    LogisticRegression(),
    X=data.drop(['customer_id', 'response'], axis=1),
    y=data.response,
    cv=StratifiedKFold(n_splits=3, random_state=123),
    scoring='roc_auc'))

Но каждая транзакция снабжена датой! Можно посчитать статистики только по свежим транзакциям. Добавим их.

transactions.trans_date.min(), transactions.trans_date.max()

agg_transactions = transactions.loc[transactions.trans_date.apply(
    lambda x: x.year == 2014)].groupby('customer_id').tran_amount.agg(
    ['mean', 'std', 'count', 'min', 'max']).reset_index()

data = pd.merge(data, agg_transactions, how='left', on='customer_id', suffixes=('', '_2014'))
data = data.fillna(0)

np.mean(cross_val_score(
    LogisticRegression(),
    X=data.drop(['customer_id', 'response'], axis=1),
    y=data.response,
    cv=StratifiedKFold(n_splits=3, random_state=123),
    scoring='roc_auc'))

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