Начнем с подключения необходимых библиотек и модулей:
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]]
Самый очевидный способ формирования признакового описания текстов — векторизация. Простой способ заключается в подсчёте, сколько раз встретилось каждое слово в тексте. Получаем вектор длиной в количество уникальных слов, встречающихся во всех объектах выборки. В таком векторе много нулей, поэтому его удобнее хранить в разреженном виде.
Пусть у нас имеется коллекция текстов \( 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 (*T*erm *F*requency–*I*nverse *D*ocument *F*requency) преобразование текста.
Рассмотрим коллекцию текстов \( D \). Для каждого уникального слова \( t \) из документа \( d \in D \) вычислим следующие величины:
Тогда для каждой пары (слово, текст) \( (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'))
Можно также считать дату первой и последней транзакциями пользователей, среднее время между транзакциями и прочее.