Машинное обучение, ФКН ВШЭ // Семинар 4

In [1]:
%pylab inline

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
Populating the interactive namespace from numpy and matplotlib

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

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

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

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

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

In [3]:
data['target_names']
Out[3]:
['comp.graphics', 'sci.med']
In [4]:
texts = data['data']
target = data['target']

Например:

In [5]:
texts[0]
Out[5]:
'From: [email protected] (Steve Dyer)\nSubject: Re: Analgesics with Diuretics\nOrganization: S.P. Dyer Computer Consulting, Cambridge MA\n\nIn article <[email protected]> Lawrence Curcio <[email protected]> writes:\n>I sometimes see OTC preparations for muscle aches/back aches that\n>combine aspirin with a diuretic.\n\nYou certainly do not see OTC preparations advertised as such.\nThe only such ridiculous concoctions are nostrums for premenstrual\nsyndrome, ostensibly to treat headache and "bloating" simultaneously.\nThey\'re worthless.\n\n>The idea seems to be to reduce\n>inflammation by getting rid of fluid. Does this actually work? \n\nThat\'s not the idea, and no, they don\'t work.\n\n-- \nSteve Dyer\[email protected] aka {ima,harvard,rayssd,linus,m2c}!spdcc!dyer\n'
In [6]:
data['target_names'][target[0]]
Out[6]:
'sci.med'

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$ описывается вектором количества вхождений каждого слова из словаря в данный текст.

In [7]:
from sklearn.feature_extraction.text import CountVectorizer

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

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

In [8]:
vectorizer.transform(texts[:1])
Out[8]:
<1x32548 sparse matrix of type '<class 'numpy.int64'>'
	with 86 stored elements in Compressed Sparse Row format>
In [9]:
print(vectorizer.transform(texts[:1]).indptr)
print(vectorizer.transform(texts[:1]).indices)
print(vectorizer.transform(texts[:1]).data)
[ 0 86]
[ 3905  3983  4143  4345  4665  4701  4712  5074  5176  5198  5242  5619
  5870  6348  6984  7232  7630  8267  8451  8460  8682  8733  8916  9557
 10811 10812 10901 10933 10971 11312 11488 13133 13226 13463 13866 14726
 14806 15682 15805 15952 16147 18002 18031 18373 18740 18781 18790 18936
 20420 21036 21164 21166 21494 21518 21622 21769 21839 21856 23589 23602
 24556 24592 24803 25502 25513 26464 26474 27021 27398 27518 27940 28199
 28286 28687 29187 29189 29264 29300 29500 29837 30702 31915 32005 32052
 32095 32392]
[2 1 1 1 1 2 2 1 1 1 1 1 1 1 1 1 1 2 2 1 1 1 1 1 1 1 1 1 1 6 2 1 2 1 1 1 1
 2 1 1 1 1 1 1 1 1 1 1 1 1 1 2 1 1 1 1 1 2 1 2 1 2 1 1 1 2 1 1 1 3 2 1 2 1
 2 3 2 1 3 1 1 2 2 1 1 1]

TF-IDF

Ещё один способ работы с текстовыми данными — TF-IDF (Term Frequency–Inverse Document Frequency). Рассмотрим коллекцию текстов $D$. Для каждого уникального слова $t$ из документа $d \in D$ вычислим следующие величины:

  1. Term Frequency – количество вхождений слова в отношении к общему числу слов в тексте: $$\text{tf}(t, d) = \frac{n_{td}}{\sum_{t \in d} n_{td}},$$ где $n_{td}$ — количество вхождений слова $t$ в текст $d$.
  2. Inverse Document Frequency $$\text{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)$ вычислим величину:

$$\text{tf-idf}(t,d, D) = \text{tf}(t, d)\cdot \text{idf}(t, D).$$

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

In [10]:
from sklearn.feature_extraction.text import TfidfVectorizer

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

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

In [11]:
vectorizer.transform(texts[:1])
Out[11]:
<1x32548 sparse matrix of type '<class 'numpy.float64'>'
	with 86 stored elements in Compressed Sparse Row format>
In [12]:
print(vectorizer.transform(texts[:1]).indptr)
print(vectorizer.transform(texts[:1]).indices)
print(vectorizer.transform(texts[:1]).data)
[ 0 86]
[32392 32095 32052 32005 31915 30702 29837 29500 29300 29264 29189 29187
 28687 28286 28199 27940 27518 27398 27021 26474 26464 25513 25502 24803
 24592 24556 23602 23589 21856 21839 21769 21622 21518 21494 21166 21164
 21036 20420 18936 18790 18781 18740 18373 18031 18002 16147 15952 15805
 15682 14806 14726 13866 13463 13226 13133 11488 11312 10971 10933 10901
 10812 10811  9557  8916  8733  8682  8460  8451  8267  7630  7232  6984
  6348  5870  5619  5242  5198  5176  5074  4712  4701  4665  4345  4143
  3983  3905]
[0.02775776 0.030364   0.10357777 0.10097852 0.05551552 0.08913878
 0.0751644  0.05521512 0.02543534 0.07527191 0.05440145 0.04646966
 0.07125018 0.0955189  0.01649287 0.12280349 0.25018259 0.0710195
 0.09802838 0.05646637 0.09712269 0.10057076 0.09482619 0.08113136
 0.04893556 0.09057384 0.23738007 0.11869004 0.18429156 0.12343474
 0.01703927 0.04332515 0.12343474 0.01848065 0.05632337 0.12343474
 0.03769659 0.0854585  0.06358581 0.07172143 0.09057384 0.12343474
 0.0833942  0.10531547 0.08659639 0.08913878 0.01987357 0.08913878
 0.1325496  0.08719619 0.07172143 0.06089811 0.01649287 0.04338328
 0.09689049 0.04826154 0.48428276 0.03809625 0.03886054 0.03418115
 0.11500976 0.11869004 0.10357777 0.08984071 0.12343474 0.04302873
 0.09925063 0.06487218 0.16490298 0.06679843 0.0833942  0.03432907
 0.11869004 0.02634071 0.05392398 0.10946036 0.03071828 0.03099918
 0.02850602 0.15610804 0.03925604 0.10531547 0.0819996  0.10946036
 0.05913563 0.23738007]

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

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

Стемминг

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

Примеры стемминга:

Word Stem
вагон вагон
вагона вагон
вагоне вагон
вагонов вагон
вагоном вагон
вагоны вагон
важная важн
важнее важн
важнейшие важн
важнейшими важн
важничал важнича
важно важн

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

In [13]:
import nltk
stemmer = nltk.stem.snowball.RussianStemmer()
In [14]:
print(stemmer.stem(u'машинное'), stemmer.stem(u'обучение'))
машин обучен
In [15]:
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))
100%|██████████| 1000/1000 [00:04<00:00, 242.80it/s]
In [16]:
print(texts[0])
From: [email protected] (Steve Dyer)
Subject: Re: Analgesics with Diuretics
Organization: S.P. Dyer Computer Consulting, Cambridge MA

In article <[email protected]> Lawrence Curcio <[email protected]> writes:
>I sometimes see OTC preparations for muscle aches/back aches that
>combine aspirin with a diuretic.

You certainly do not see OTC preparations advertised as such.
The only such ridiculous concoctions are nostrums for premenstrual
syndrome, ostensibly to treat headache and "bloating" simultaneously.
They're worthless.

>The idea seems to be to reduce
>inflammation by getting rid of fluid. Does this actually work? 

That's not the idea, and no, they don't work.

-- 
Steve Dyer
[email protected] aka {ima,harvard,rayssd,linus,m2c}!spdcc!dyer

In [17]:
print(stemmed_texts[0])
from: [email protected] (steve dyer) subject: re: analges with diuret organization: s.p. dyer comput consulting, cambridg ma in articl <[email protected]> lawrenc curcio <[email protected]> writes: >i sometim see otc prepar for muscl aches/back ach that >combin aspirin with a diuretic. you certain do not see otc prepar advertis as such. the onli such ridicul concoct are nostrum for premenstru syndrome, ostens to treat headach and "bloating" simultaneously. they'r worthless. >the idea seem to be to reduc >inflamm by get rid of fluid. doe this actual work? that not the idea, and no, they don't work. -- steve dyer [email protected] aka {ima,harvard,rayssd,linus,m2c}!spdcc!dy

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

Лемматизация

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

  • для существительных — именительный падеж, единственное число;
  • для прилагательных — именительный падеж, единственное число, мужской род;
  • для глаголов, причастий, деепричастий — глагол в инфинитиве.

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

In [18]:
import pymorphy2
morph = pymorphy2.MorphAnalyzer()
In [19]:
morph.parse('играющих')[0]
Out[19]:
Parse(word='играющих', tag=OpencorporaTag('PRTF,impf,tran,pres,actv plur,gent'), normal_form='играть', score=0.16666666666666666, methods_stack=((<DictionaryAnalyzer>, 'играющих', 303, 34),))

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

In [20]:
stemmer = nltk.stem.snowball.RussianStemmer()
print(stemmer.stem('играющих'))
игра
In [21]:
print(morph.parse('играющих')[0].normal_form)
играть

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

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

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

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

In [ ]:
!wget https://www.dropbox.com/s/h5x86yvaf384vnt/train.csv
In [23]:
data = pd.read_csv('train.csv')

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

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

In [24]:
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()

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

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

In [25]:
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]

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

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

In [26]:
sns.distplot(np.log(y+1), label='target')
plt.show()

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

  1. Целевая переменная без изменений.
  2. Целевая переменная прологарифмирована.

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

In [27]:
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)
Test RMSE = 32085.7681
In [28]:
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)
Test RMSE = 26649.2742

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

In [29]:
X_train.GrLivArea = np.log(X_train.GrLivArea + 1)
X_test.GrLivArea = np.log(X_test.GrLivArea + 1)
In [30]:
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)
Test RMSE = 31893.8891
In [31]:
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)
Test RMSE = 25935.0780

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

Бинаризация

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

In [32]:
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)
In [33]:
plt.scatter(X, y)
Out[33]:
<matplotlib.collections.PathCollection at 0x11b50b7f0>
In [34]:
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)))
In [35]:
from sklearn.model_selection import KFold
from sklearn.model_selection import cross_val_score
In [36]:
-np.mean(cross_val_score(
    LinearRegression(), X, y, cv=KFold(n_splits=3, random_state=123),
    scoring='neg_mean_squared_error'))
Out[36]:
0.20553980048560808
In [37]:
-np.mean(cross_val_score(
    LinearRegression(), X_expand, y, cv=KFold(n_splits=3, random_state=123),
    scoring='neg_mean_squared_error'))
Out[37]:
0.05580385745900118

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

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

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

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

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

In [ ]:
!wget https://www.dropbox.com/s/zhgyiugyrzjs5xb/Retail_Data_Response.csv
!wget https://www.dropbox.com/s/5xepi2t6d81s9o3/Retail_Data_Transactions.csv
In [39]:
customers = pd.read_csv('Retail_Data_Response.csv')
transactions = pd.read_csv('Retail_Data_Transactions.csv')
In [40]:
customers.head()
Out[40]:
customer_id response
0 CS1112 0
1 CS1113 0
2 CS1114 1
3 CS1115 1
4 CS1116 1
In [41]:
transactions.head()
Out[41]:
customer_id trans_date tran_amount
0 CS5295 11-Feb-13 35
1 CS4768 15-Mar-15 39
2 CS2122 26-Feb-13 52
3 CS1217 16-Nov-11 99
4 CS1850 20-Nov-13 78
In [42]:
transactions.trans_date = transactions.trans_date.apply(
    lambda x: datetime.datetime.strptime(x, '%d-%b-%y'))

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

In [43]:
customers.response.mean()
Out[43]:
0.09398605461940732

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

In [44]:
from sklearn.model_selection import StratifiedKFold

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

In [45]:
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()
Out[45]:
customer_id response mean std count min max
0 CS1112 0 67.466667 19.766012 15 36 105
1 CS1113 0 74.500000 21.254102 20 36 98
2 CS1114 1 75.368421 21.341692 19 37 105
3 CS1115 1 75.409091 18.151896 22 41 104
4 CS1116 1 65.923077 22.940000 13 40 105
In [46]:
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'))
/Users/ekayumov/anaconda/envs/py36/lib/python3.6/site-packages/sklearn/linear_model/logistic.py:432: FutureWarning: Default solver will be changed to 'lbfgs' in 0.22. Specify a solver to silence this warning.
  FutureWarning)
/Users/ekayumov/anaconda/envs/py36/lib/python3.6/site-packages/sklearn/linear_model/logistic.py:432: FutureWarning: Default solver will be changed to 'lbfgs' in 0.22. Specify a solver to silence this warning.
  FutureWarning)
/Users/ekayumov/anaconda/envs/py36/lib/python3.6/site-packages/sklearn/linear_model/logistic.py:432: FutureWarning: Default solver will be changed to 'lbfgs' in 0.22. Specify a solver to silence this warning.
  FutureWarning)
Out[46]:
0.594866904556827

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

In [47]:
transactions.trans_date.min(), transactions.trans_date.max()
Out[47]:
(Timestamp('2011-05-16 00:00:00'), Timestamp('2015-03-16 00:00:00'))
In [48]:
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()
In [49]:
data = pd.merge(data, agg_transactions, how='left', on='customer_id', suffixes=('', '_2014'))
data = data.fillna(0)
In [50]:
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'))
/Users/ekayumov/anaconda/envs/py36/lib/python3.6/site-packages/sklearn/linear_model/logistic.py:432: FutureWarning: Default solver will be changed to 'lbfgs' in 0.22. Specify a solver to silence this warning.
  FutureWarning)
/Users/ekayumov/anaconda/envs/py36/lib/python3.6/site-packages/sklearn/linear_model/logistic.py:432: FutureWarning: Default solver will be changed to 'lbfgs' in 0.22. Specify a solver to silence this warning.
  FutureWarning)
/Users/ekayumov/anaconda/envs/py36/lib/python3.6/site-packages/sklearn/linear_model/logistic.py:432: FutureWarning: Default solver will be changed to 'lbfgs' in 0.22. Specify a solver to silence this warning.
  FutureWarning)
Out[50]:
0.6483871707242365

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