Логистическая регрессия в задаче тегирования вопросов StackOverflow

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

0. Описание задачи

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

Мы будем использовать небольшую выборку из протеггированных вопросов с сайта StackOverflow размером в 125 тысяч примеров (около 150 Мб, скачайте по этой ссылке).

PS: Можно показать, что такая реализация совсем не эффективная и проще было бы использовать векторизированные вычисления. Для данного датасета так и есть. Но на самом деле подобные реализации используются в жизни, но естественно, написаны они не на Python. Например, в онлайн-моделях прогнозирования CTR юзеру показывается баннер, затем в зависимости от наличия клика происходит обновление параметров модели. В реальной жизни параметров модели может быть несколько сотен миллионов, а у юзера из этих ста миллионов от силы сто или тысяча параметров отличны от нуля, векторизировать такие вычисления не очень эффективно. Обычно все это хранится в огромных кластерах в in-memory базах данных, а обработка пользователей происходит распределенно.

PS2:

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

In [4]:
#pip install watermark
%load_ext watermark

In [9]:
%matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_style("dark")
plt.rcParams['figure.figsize'] = 16, 12
from tqdm import tqdm_notebook
import pandas as pd
from collections import defaultdict

# поменяйте на свой путь
DS_FILE_NAME = 'stackoverflow_sample_125k.tsv'
TAGS_FILE_NAME = 'top10_tags.tsv'

In [10]:
top_tags = []
with open(TAGS_FILE_NAME, 'r') as f:
    for line in f:
        top_tags.append(line.strip())
top_tags = set(top_tags)
print(top_tags)


{'html', 'python', 'php', 'jquery', 'ios', 'c++', 'java', 'c#', 'javascript', 'android'}

1. Многоклассовая логистическая регрессия

Вспомним, как получается логистическая регрессия для двух классов $\left\{0, 1\right\}$, вероятность принадлежности объекта к классу $1$ выписывается по теореме Байеса:

$$\large \begin{array}{rcl} p\left(c = 1 \mid \vec{x}\right) &=& \dfrac{p\left(\vec{x} \mid c = 1\right)p\left(c = 1\right)}{p\left(\vec{x} \mid c = 1\right)p\left(c = 1\right) + p\left(\vec{x} \mid c = 0\right)p\left(c = 0\right)} \\ &=& \dfrac{1}{1 + e^{-a}} \\ &=& \sigma\left(a\right) \end{array}$$

где:

  • $\vec{x}$ – вектор признаков объекта
  • $\sigma$ – обозначение функции логистического сигмоида при скалярном аргументе
  • $a = \log \frac{p\left(\vec{x} \mid c = 1\right)p\left(c = 1\right)}{p\left(\vec{x} \mid c = 0\right)p\left(c = 0\right)} = \sum_{i=0}^M w_i x_i$ – это отношение мы моделируем линейной функцией от признаков объекта и параметров модели

Данное выражение легко обобщить до множества из $K$ классов, изменится только знаменатель в формуле Байеса. Запишем вероятность принадлежности объекта к классу $k$: $$\large \begin{array}{rcl} p\left(c = k \mid \vec{x}\right) &=& \dfrac{p\left(\vec{x} \mid c = k\right)p\left(c = k\right)}{\sum_{i=1}^K p\left(\vec{x} \mid c = i\right)p\left(c = i\right)} \\ &=& \dfrac{e^{z_k}}{\sum_{i=1}^{K}e^{z_i}} \\ &=& \sigma_k\left(\vec{z}\right) \end{array}$$ где:

  • $\sigma_k$ – обозначение функции softmax при векторном аргументе
  • $z_k = \log p\left(\vec{x} \mid c = k\right)p\left(c = k\right) = \sum_{i=0}^M w_{ki} x_i$ – это выражение моделируется линейной функцией от признаков объекта и параметров модели для класса $k$

Для моделирования полного правдоподобия примера мы используем категориальное распределение, а лучше его логарифм (для удобства):

$$\large \begin{array}{rcl} \mathcal{L} = \log p\left({\vec{x}}\right) &=& \log \prod_{i=1}^K \sigma_i\left(\vec{z}\right)^{y_i} \\ &=& \sum_{i=1}^K y_i \log \sigma_i\left(\vec{z}\right) \end{array}$$

Получается хорошо знакомая нам функция cross entropy (если домножить на $-1$). Правдоподобие нужно максимизировать, а, соответственно, перекрестную энтропию нужно минимизировать. Продифференцировав по параметрам модели, мы легко получим правила обновления весов для градиентного спуска, проделайте этот вывод, если вы его не делали (если вы вдруг сдались, то на этом видео есть разбор вывода, понимание этого вам понадобится для дальнейшего выполнения задания; если предпочитаете текст, то и он есть тут и тут):

$$\large \begin{array}{rcl} \frac{\partial \mathcal{L}}{\partial w_{km}} &=& x_m \left(y_k - \sigma_k\left(\vec{z}\right)\right) \end{array}$$

В стандартной формулировке получается, что вектор $\left(\sigma_1, \sigma_2, \ldots, \sigma_K\right)$ образует дискретное вероятностное распределение, т.е. $\sum_{i=1}^K \sigma_i = 1$. Но в нашей постановке задачи каждый пример может иметь несколько тегов или одновременно принадлежать к нескольким классам. Для этого мы немного изменим модель:

  • будем считать, что все теги независимы друг от друга, т.е. каждый исход – это логистическая регрессия на два класса (либо есть тег, либо его нет), тогда вероятность наличия тега у примера запишется следующим образом (каждый тег/класс как и в многоклассовой логрегрессии имеет свой набор параметров): $$\large p\left(\text{tag}_k \mid \vec{x}\right) = \sigma\left(z_k\right) = \sigma\left(\sum_{i=1}^M w_{ki} x^i \right)$$
  • наличие каждого тега мы будем моделировать с помощью распределения Бернулли

Вопрос 1. Ваше первое задание – записать упрощенное выражение логарифма правдоподобия примера с признаками $\vec{x}$. Как правило, многие алгоритмы оптимизации имеют интерфейс для минимизации функции, мы последуем этой же традиции и домножим полученное выражение на $-1$, а во второй части выведем формулы для минимизации полученного выражения.

Варианты ответа:

  1. $\large -\mathcal{L} = -\sum_{i=1}^M y_i \log \sigma\left(z_i\right) + \left(1 - y_i\right) \log \left(1 - \sigma\left(z_i\right)\right)$

2. $\large -\mathcal{L} = -\sum_{i=1}^K y_i \log \sigma\left(z_i\right) + \left(1 - y_i\right) \log \left(1 - \sigma\left(z_i\right)\right)$

  1. $\large -\mathcal{L} = -\sum_{i=1}^K z_i \log \sigma\left(y_i\right) + \left(1 - z_i\right) \log \left(1 - \sigma\left(y_i\right)\right)$
  2. $\large -\mathcal{L} = -\sum_{i=1}^M z_i \log \sigma\left(y_i\right) + \left(1 - z_i\right) \log \left(1 - \sigma\left(y_i\right)\right)$

2. Вывод формулы обновления весов

Вопрос 2.В качестве второго задания вам предоставляется возможность вывести формулу градиента для $-\mathcal{L}$. Какой вид она будет иметь?

Варианты ответа::

  1. $\large -\frac{\partial \mathcal{L}}{\partial w_{km}} = -x_m \left(\sigma\left(z_k\right) - y_k\right)$

2. $\large -\frac{\partial \mathcal{L}}{\partial w_{km}} = -x_m \left(y_k - \sigma\left(z_k\right)\right)$

  1. $\large -\frac{\partial \mathcal{L}}{\partial w_{km}} = \left(\sigma\left(z_k\right)x_m - y_k\right)$
  2. $\large -\frac{\partial \mathcal{L}}{\partial w_{km}} = \left(y_k - \sigma\left(z_k\right)x_m\right)$

3. Реализация базовой модели

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

Как вы могли уже заметить, при обновлении веса $w_{km}$ используется значение признака $x_m$, который равен $0$, если слова с индексом $m$ нет в предложении, и больше нуля, если такое слово есть. В нашем случае, чтобы не пересчитывать bag-of-words самим или с помощью sklearn.feature_extraction.text.CountVectorizer, мы будем идти по словам предложения в порядке их следования. Если какое-то слово встречается несколько раз, то мы добавляем его в аккумулятор со своим весом. В итоге получится то же самое, как если сначала посчитать количество одинаковых слов и домножить на соответствующий вес. Соответственно, при вычислении линейной комбинации $z$ весов модели и признаков примера необходимо учитывать только ненулевые признаки объекта.

Подсказка:

  • если реализовывать вычисление сигмоида так же, как в формуле, то при большом отрицательном значении $z$ вычисление $e^{-z}$ превратится в очень большое число, которое вылетит за допустимые пределы
  • в то же время $e^{-z}$ от большого положительного $z$ будет нулем
  • воспользуйтесь свойствами функции $\sigma$ для того, чтобы пофиксить эту ошибку и реализовать $\sigma$ без риска overflow.

In [11]:
class LogRegressor():
    
    """Конструктор
    
    Параметры
    ----------
    tags_top : list of string, default=tags_top
        список тегов
    """
    def __init__(self, tags=top_tags):      
        # словарь который содержит мапинг слов предложений и тегов в индексы (для экономии памяти)
        # пример: self._vocab['exception'] = 17 означает что у слова exception индекс равен 17
        self._vocab = {}
        
        # параметры модели: веса
        # для каждого класса/тега нам необходимо хранить собственный вектор весов
        # по умолчанию у нас все веса будут равны нулю
        # мы заранее не знаем сколько весов нам понадобится
        # поэтому для каждого класса мы сосздаем словарь изменяемого размера со значением по умолчанию 0
        # пример: self._w['java'][self._vocab['exception']]  содержит вес для слова exception тега java
        self._w = dict([(t, defaultdict(int)) for t in tags])
        
        # параметры модели: смещения или вес w_0
        self._b = dict([(t, 0) for t in tags])
        
        self._tags = set(tags)
    
    """Один прогон по датасету
    
    Параметры
    ----------
    fname : string, default=DS_FILE_NAME
        имя файла с данными
        
    top_n_train : int
        первые top_n_train строк будут использоваться для обучения, остальные для тестирования
        
    total : int, default=10000000
        информация о количестве строк в файле для вывода прогресс бара
    
    learning_rate : float, default=0.1
        скорость обучения для градиентного спуска
        
    tolerance : float, default=1e-16
        используем для ограничения значений аргумента логарифмов
    """
    def iterate_file(self, 
                     fname=DS_FILE_NAME, 
                     top_n_train=100000, 
                     total=125000,
                     learning_rate=0.1,
                     tolerance=1e-16):
        
        self._loss = []
        n = 0
        
        # откроем файл
        with open(fname, 'r') as f:            
            
            # прогуляемся по строкам файла
            for line in tqdm_notebook(f, total=total, mininterval=1):
                pair = line.strip().split('\t')
                if len(pair) != 2:
                    continue                
                sentence, tags = pair
                # слова вопроса, это как раз признаки x
                sentence = sentence.split(' ')
                # теги вопроса, это y
                tags = set(tags.split(' '))
                
                # значение функции потерь для текущего примера
                sample_loss = 0

                # прокидываем градиенты для каждого тега
                for tag in self._tags:
                    # целевая переменная равна 1 если текущий тег есть у текущего примера
                    y = int(tag in tags)
                    
                    # расчитываем значение линейной комбинации весов и признаков объекта
                    # ЗАПОЛНИТЕ ПРОПУСКИ В КОДЕ
                    # z = ...
                    z = self._b[tag] 
                    for word in sentence:
                        # если в режиме тестирования появляется слово которого нет в словаре, то мы его игнорируем
                        if n >= top_n_train and word not in self._vocab:
                            continue
                        if word not in self._vocab:
                            self._vocab[word] = len(self._vocab)
                        # z += ...
                        z += self._w[tag][self._vocab[word]] 
                        
                    # вычисляем вероятность наличия тега
                    # ЗАПОЛНИТЕ ПРОПУСКИ В КОДЕ
                    # sigma = ...
                    sigma = 1/(1 + np.exp(-z)) if z >= 0 else 1 - 1/(1 + np.exp(z))
                    
                    # обновляем значение функции потерь для текущего примера
                    # ЗАПОЛНИТЕ ПРОПУСКИ В КОДЕ
                    # sample_loss += ...
                    sample_loss += -y*np.log(np.max([tolerance, sigma])) if y == 1 else \
                                   -(1 - y)*np.log(1 - np.min([1 - tolerance, sigma]))
                    
                    # если мы все еще в тренировочной части, то обновим параметры
                    if n < top_n_train:
                        # вычисляем производную логарифмического правдоподобия по весу
                        # ЗАПОЛНИТЕ ПРОПУСКИ В КОДЕ
                        # dLdw = ...
                        dLdw = y - sigma

                        # делаем градиентный шаг
                        # мы минимизируем отрицательное логарифмическое правдоподобие (второй знак минус)
                        # поэтому мы идем в обратную сторону градиента для минимизации (первый знак минус)
                        for word in sentence:                        
                            self._w[tag][self._vocab[word]] -= -learning_rate*dLdw
                        self._b[tag] -= -learning_rate*dLdw
                    
                n += 1
                        
                self._loss.append(sample_loss)

In [12]:
# создадим эксемпляр модели и пройдемся по датасету
model = LogRegressor()
model.iterate_file()



Проверим, действительно ли значение отрицательного логарифмического правдоподобия уменьшалось. Так как мы используем стохастический градентный спуск, не стоит ожидать плавного падения функции ошибки. Мы воспользуемся скользящим средним с окном в 10 тысяч примеров, чтобы хоть как-то сгладить график.


In [13]:
plt.plot(pd.Series(model._loss[:-25000]).rolling(10000).mean());



In [14]:
print('Mean of the loss function on the last 10k train samples: %0.2f' % np.mean(model._loss[-35000:-25000]))


Mean of the loss function on the last 10k train samples: 20.04

Вопрос 3. Вычислите среднее значение функции стоимости на последних 10 000 примеров тренировочного набора, к какому из значений ваш ответ ближе всего?

Варианты ответа:

  1. 17.54
  2. 18.64

3. 19.74

  1. 20.84

4. Тестирование модели

В базовой модели первые 100 000 строк используются для обучения, а оставшиеся – для тестирования. Как вы можете заметить, значение отрицательного логарифмического правдоподобия не очень информативно, хоть и позволяет сравнивать разные модели. В качестве четвертого задания вам необходимо модифицировать базовую модель таким образом, чтобы метод iterate_file возвращал значение точности на тестовой части набора данных.

Точность определим следующим образом:

  • считаем, что тег у вопроса присутствует, если спрогнозированная вероятность тега больше 0.9
  • точность одного примера расчитывается как коэффициент Жаккара между множеством настоящих тегов и предсказанных моделью
    • например, если у примера настоящие теги ['html', 'jquery'], а по версии модели ['ios', 'html', 'java'], то коэффициент Жаккара будет равен |['html', 'jquery'] $\cap$ ['ios', 'html', 'java']| / |['html', 'jquery'] $\cup$ ['ios', 'html', 'java']| = |['html']| / |['jquery', 'ios', 'html', 'java']| = 1/4
  • метод iterate_file возвращает среднюю точность на тестовом наборе данных

In [15]:
class LogRegressor():
    
    def __init__(self, tags=top_tags):      
        self._vocab = {}
        self._w = dict([(t, defaultdict(int)) for t in tags])
        self._b = dict([(t, 0) for t in tags])
        self._tags = set(tags)
    
    def iterate_file(self, 
                     fname=DS_FILE_NAME, 
                     top_n_train=100000, 
                     total=125000,
                     learning_rate=0.1,
                     tolerance=1e-16,
                     accuracy_level=0.9):

        self._loss = []
        n = 0
        accuracy = []
        with open(fname, 'r') as f:            
            for line in tqdm_notebook(f, total=total, mininterval=1):
                pair = line.strip().split('\t')
                if len(pair) != 2:
                    continue                
                sentence, tags = pair
                sentence = sentence.split(' ')
                tags = set(tags.split(' '))
                
                sample_loss = 0
                predicted_tags = None
                
                for tag in self._tags:
                    y = int(tag in tags)
                    
                    z = self._b[tag] 
                    for word in sentence:
                        if n >= top_n_train and word not in self._vocab:
                            continue
                        if word not in self._vocab:
                            self._vocab[word] = len(self._vocab)
                        z += self._w[tag][self._vocab[word]] 
                        
                    sigma = 1/(1 + np.exp(-z)) if z >= 0 else 1 - 1/(1 + np.exp(z))
                    
                    sample_loss += -y*np.log(np.max([tolerance, sigma])) if y == 1 else \
                                   -(1 - y)*np.log(1 - np.min([1 - tolerance, sigma]))
                    
                    if n < top_n_train:
                        dLdw = y - sigma

                        for word in sentence:                        
                            self._w[tag][self._vocab[word]] -= -learning_rate*dLdw
                        self._b[tag] -= -learning_rate*dLdw
                    else:
                        if predicted_tags is None:
                            predicted_tags = []
                        if sigma > accuracy_level:
                            predicted_tags.append(tag)
                    
                n += 1
                                        
                self._loss.append(sample_loss)
                if predicted_tags is not None:
                    accuracy.append(len(tags.intersection(predicted_tags))/len(tags.union(predicted_tags)))
            
        return(np.mean(accuracy))

In [16]:
model = LogRegressor()
acc = model.iterate_file()
# выведем полученное значение с точностью до двух знаков
print('%0.2f' % acc)


0.58

Вопрос 4. К какому значению ближе всего полученное значение точности? Варианты ответа:

  1. 0.39
  2. 0.49
  3. 0.59
  4. 0.69

5. $L_2$-регуляризация

В качестве пятого задания вам необходимо добавить в класс LogRegressor поддержку $L_2$-регуляризации. В методе iterate_file должен появиться параметр lmbda=0.01 со значением по умолчанию. С учетом регуляризации новая функция стоимости примет вид:

$$\large \begin{array}{rcl} L &=& -\mathcal{L} + \frac{\lambda}{2} R\left(W\right) \\ &=& -\mathcal{L} + \frac{\lambda}{2} \sum_{k=1}^K\sum_{i=1}^M w_{ki}^2 \end{array}$$

Градиент первого члена суммы мы уже вывели, а для второго он имеет вид:

$$\large \begin{array}{rcl} \frac{\partial}{\partial w_{ki}} \frac{\lambda}{2} R\left(W\right) &=& \lambda w_{ki} \end{array}$$

Если мы на каждом примере будем делать честное обновление всех весов, то все очень замедлится, ведь нам придется на каждой итерации пробегать по всем словам словаря. В ущерб теоретической корректности мы используем грязный трюк: будем регуляризировать только те слова, которые присутствуют в текущем предложении. Не забывайте, что смещение (bias) не регуляризируется. sample_loss тоже должен остаться без изменений.

Замечание:

  • не забудьте, что нужно учитывать регуляризацию слова в градиентном шаге только один раз
  • условимся, что учитываем регуляризацию только при первой встрече слова
  • если бы мы считали сначала bag-of-words, то мы бы в цикле шли по уникальным словам, но т.к. мы этого не делаем, приходится выкручиваться (еще одна жертва богу online-моделей)

In [18]:
# Обновите определение класса LogRegressor
# Ваш код здесь
class LogRegressor():
    
    def __init__(self, tags=top_tags):      
        self._vocab = {}
        self._w = dict([(t, defaultdict(int)) for t in tags])
        self._b = dict([(t, 0) for t in tags])
        self._tags = set(tags)
    
    def iterate_file(self, 
                     fname=DS_FILE_NAME, 
                     top_n_train=100000, 
                     total=125000,
                     learning_rate=0.1,
                     tolerance=1e-16,
                     accuracy_level=0.9,
                     lmbda=0.01):

        self._loss = []
        n = 0
        accuracy = []
        with open(fname, 'r') as f:            
            for line in tqdm_notebook(f, total=total, mininterval=1):
                pair = line.strip().split('\t')
                if len(pair) != 2:
                    continue                
                sentence, tags = pair
                sentence = sentence.split(' ')
                tags = set(tags.split(' '))
                
                sample_loss = 0
                predicted_tags = None
                
                for tag in self._tags:
                    y = int(tag in tags)
                    
                    z = self._b[tag] 
                    for word in sentence:
                        if n >= top_n_train and word not in self._vocab:
                            continue
                        if word not in self._vocab:
                            self._vocab[word] = len(self._vocab)
                        z += self._w[tag][self._vocab[word]] 
                        
                    sigma = 1/(1 + np.exp(-z)) if z >= 0 else 1 - 1/(1 + np.exp(z))
                    
                    sample_loss += -y*np.log(np.max([tolerance, sigma])) if y == 1 else \
                                   -(1 - y)*np.log(1 - np.min([1 - tolerance, sigma]))
                    
                    if n < top_n_train:
                        dLdw = y - sigma

                        for word in sentence:                        
                            self._w[tag][self._vocab[word]] -= -learning_rate*dLdw \
                                                               + learning_rate*lmbda*self._w[tag][self._vocab[word]]                            
                        self._b[tag] -= -learning_rate*dLdw
                    else:
                        if predicted_tags is None:
                            predicted_tags = []
                        if sigma > accuracy_level:
                            predicted_tags.append(tag)
                    
                n += 1
                                        
                self._loss.append(sample_loss)
                if predicted_tags is not None:
                    accuracy.append(len(tags.intersection(predicted_tags))/len(tags.union(predicted_tags)))
            
        return(np.mean(accuracy))

In [19]:
model = LogRegressor()
acc = model.iterate_file()
print('%0.2f' % acc)
plt.plot(pd.Series(model._loss[:-25000]).rolling(10000).mean());


0.53

Вопрос 5. К какому значению ближе всего полученное значение точности? Варианты ответа:

  1. 0.3
  2. 0.35
  3. 0.4
  4. 0.52

6. ElasticNet регуляризация, вывод

Помимо $L_2$ регуляризации, часто используется $L_1$ регуляризация.

$$\large \begin{array}{rcl} L &=& -\mathcal{L} + \frac{\lambda}{2} R\left(W\right) \\ &=& -\mathcal{L} + \lambda \sum_{k=1}^K\sum_{i=1}^M \left|w_{ki}\right| \end{array}$$

Если линейно объединить $L_1$ и $L_2$ регуляризацию, то полученный тип регуляризации называется ElasticNet:

$$\large \begin{array}{rcl} L &=& -\mathcal{L} + \lambda R\left(W\right) \\ &=& -\mathcal{L} + \lambda \left(\gamma \sum_{k=1}^K\sum_{i=1}^M w_{ki}^2 + \left(1 - \gamma\right) \sum_{k=1}^K\sum_{i=1}^M \left|w_{ki}\right| \right) \end{array}$$
  • где $\gamma \in \left[0, 1\right]$

В качестве шестого вопроса вам предлагается вывести формулу градиента ElasticNet регуляризации (не учитывая $-\mathcal{L}$).

Варианты ответа::

  1. $\large \frac{\partial}{\partial w_{ki}} \lambda R\left(W\right) = \lambda \left(2 \gamma w_{ki} + \left(1 - \gamma\right) w_{ki}\right)$

2. $\large \frac{\partial}{\partial w_{ki}} \lambda R\left(W\right) = \lambda \left(2 \gamma \left|w_{ki}\right| + \left(1 - \gamma\right) \text{sign}\left(w_{ki}\right)\right)$

  1. $\large \frac{\partial}{\partial w_{ki}} \lambda R\left(W\right) = \lambda \left(2 \gamma w_{ki} + \left(1 - \gamma\right) \text{sign}\left(w_{ki}\right)\right)$
  2. $\large \frac{\partial}{\partial w_{ki}} \lambda R\left(W\right) = \lambda \left(\gamma w_{ki} + \left(1 - \gamma\right) \text{sign}\left(w_{ki}\right)\right)$

7. Регуляризация ElasticNet , реализация

В качестве седьмой задачи вам предлается изменить класс LogRegressor таким образом, чтобы метод iterate_file принимал два параметра со значениями по умолчанию lmbda=0.0002 и gamma=0.1. Сделайте один проход по датасету с включенной ElasticNet-регуляризацией и заданными значениями по умолчанию и ответьте на вопрос.


In [20]:
class LogRegressor():
    
    def __init__(self, tags=top_tags):      
        self._vocab = {}
        self._w = dict([(t, defaultdict(int)) for t in tags])
        self._b = dict([(t, 0) for t in tags])
        self._tags = set(tags)
    
    def iterate_file(self, 
                     fname=DS_FILE_NAME, 
                     top_n_train=100000, 
                     total=125000,
                     learning_rate=0.1,
                     tolerance=1e-16,
                     accuracy_level=0.9,
                     lmbda=0.0002,
                     gamma=0.1):

        self._loss = []
        n = 0
        accuracy = []
        with open(fname, 'r') as f:            
            for line in tqdm_notebook(f, total=total, mininterval=1):
                pair = line.strip().split('\t')
                if len(pair) != 2:
                    continue                
                sentence, tags = pair
                sentence = sentence.split(' ')
                tags = set(tags.split(' '))
                
                sample_loss = 0
                predicted_tags = None
                
                for tag in self._tags:
                    y = int(tag in tags)
                    
                    z = self._b[tag] 
                    for word in sentence:
                        if n >= top_n_train and word not in self._vocab:
                            continue
                        if word not in self._vocab:
                            self._vocab[word] = len(self._vocab)
                        z += self._w[tag][self._vocab[word]] 
                        
                    sigma = 1/(1 + np.exp(-z)) if z >= 0 else 1 - 1/(1 + np.exp(z))
                    
                    sample_loss += -y * np.log(np.max([tolerance, sigma])) if y == 1 else \
                                   -(1 - y) * np.log(1 - np.min([1 - tolerance, sigma]))
                    
                    if n < top_n_train:
                        dLdw = y - sigma

                        for word in sentence:                        
                            self._w[tag][self._vocab[word]] -= -learning_rate * dLdw \
                                + 2 * learning_rate * lmbda * gamma * self._w[tag][self._vocab[word]] \
                                + learning_rate * lmbda*(1 - gamma) * np.sign(self._w[tag][self._vocab[word]])
                        self._b[tag] -= -learning_rate * dLdw
                    else:
                        if predicted_tags is None:
                            predicted_tags = []
                        if sigma > accuracy_level:
                            predicted_tags.append(tag)
                    
                n += 1
                                        
                self._loss.append(sample_loss)
                if predicted_tags is not None:
                    accuracy.append(len(tags.intersection(predicted_tags))/len(tags.union(predicted_tags)))
            
        return(np.mean(accuracy))

In [21]:
%%time
model = LogRegressor()
acc = model.iterate_file()
print('%0.2f' % acc)
plt.plot(pd.Series(model._loss[:-25000]).rolling(10000).mean());


0.58
Wall time: 16min 21s

Вопрос 7. К какому значению ближе всего полученное значение точности: Варианты ответа:

  1. 0.59
  2. 0.69
  3. 0.79
  4. 0.82

8. Самые важные слова для тега

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


In [22]:
model._vocab_inv = dict([(v, k) for (k, v) in model._vocab.items()])

for tag in model._tags:
    print(tag, ':', ', '.join([model._vocab_inv[k] for (k, v) in 
                               sorted(model._w[tag].items(), 
                                      key=lambda t: t[1], 
                                      reverse=True)[:5]]))


html : html, br, nbsp, amp, span
python : python, def, py, django, np
php : php, x5c, echo, 125, _post
jquery : jquery, ready, ajax, span, val
ios : ios, nsstring, nil, xcode, iphone
c++ : avrf, c++, std, cout, trace
java : println, hibernate, servlet, spring, jsp
c# : xsl, writeline, binding, net, linq
javascript : javascript, x20, 125, 3, ng
android : android, activity, arm, imgsrv, 29297

Вопрос 8. Для многих тегов наличие самого тега в предложении является важным сигналом, у многих сам тег является самым сильным сигналом, что не удивительно. Для каких из тегов само название тега не входит в топ-5 самых важных?

Варианты ответа:

  1. c#
  2. javascript
  3. jquery
  4. android

9. Сокращаем размер словаря

Сейчас количество слов в словаре – 519290, если бы это была выборка из 10 миллионов вопросов с сайта StackOverflow, то размер словаря был бы миллионов 10. Регуляризировать модель можно не только изящно математически, но и топорно, например, ограничить размер словаря. Вам предоставляется возможность внести следующие изменения в класс LogRegressor:

  • добавить в метод iterate_file еще один аргумент со значением по умолчанию update_vocab=True
  • при update_vocab=True разрешать добавлять слова в словарь в режиме обучения
  • при update_vocab=False игнорировать слова не из словаря
  • добавить в класс метод filter_vocab(n=10000), который оставит в словаре только топ-n самых популярных слов, используя данные из train

In [23]:
class LogRegressor():
    
    def __init__(self, tags=top_tags):      
        self._vocab = {}
        self._w = dict([(t, defaultdict(int)) for t in tags])
        self._b = dict([(t, 0) for t in tags])
        self._tags = set(tags)
        self._word_stats = defaultdict(int)
    
    def iterate_file(self, 
                     fname=DS_FILE_NAME, 
                     top_n_train=100000, 
                     total=125000,
                     learning_rate=0.1,
                     tolerance=1e-16,
                     accuracy_level=0.9,
                     lmbda=0.0002,
                     gamma=0.1,
                     update_vocab=True):

        self._loss = []
        n = 0
        accuracy = []
        with open(fname, 'r') as f:            
            for line in tqdm_notebook(f, total=total, mininterval=1):
                pair = line.strip().split('\t')
                if len(pair) != 2:
                    continue                
                sentence, tags = pair
                sentence = sentence.split(' ')
                tags = set(tags.split(' '))
                
                sample_loss = 0
                predicted_tags = None
                
                for tag in self._tags:
                    y = int(tag in tags)
                    
                    z = self._b[tag] 
                    for word in sentence:
                        if n >= top_n_train and word not in self._vocab:
                            continue
                        if word not in self._vocab and update_vocab:
                            self._vocab[word] = len(self._vocab)
                        if word not in self._vocab:
                            continue
                        if update_vocab:
                            self._word_stats[self._vocab[word]] += 1
                        z += self._w[tag][self._vocab[word]] 
                        
                    sigma = 1/(1 + np.exp(-z)) if z >= 0 else 1 - 1/(1 + np.exp(z))
                    
                    sample_loss += -y*np.log(np.max([tolerance, sigma])) if y == 1 else \
                                   -(1 - y)*np.log(1 - np.min([1 - tolerance, sigma]))
                    
                    if n < top_n_train:
                        dLdw = y - sigma

                        for word in sentence:  
                            if word not in self._vocab:
                                continue
                            self._w[tag][self._vocab[word]] -= -learning_rate * dLdw \
                                + 2 * learning_rate * lmbda * gamma * self._w[tag][self._vocab[word]] \
                                + learning_rate * lmbda *(1 - gamma) * np.sign(self._w[tag][self._vocab[word]])
                        self._b[tag] -= -learning_rate * dLdw
                    else:
                        if predicted_tags is None:
                            predicted_tags = []
                        if sigma > accuracy_level:
                            predicted_tags.append(tag)
                    
                n += 1
                                        
                self._loss.append(sample_loss)
                if predicted_tags is not None:
                    accuracy.append(len(tags.intersection(predicted_tags))/len(tags.union(predicted_tags)))
            
        return(np.mean(accuracy))
    
    def filter_vocab(self, n=10000):
        keep_words = set([wid for (wid, wn) in sorted(self._word_stats.items(), 
                                                      key=lambda t: t[1], reverse=True)[:n]])
        self._vocab = dict([(k, v) for (k, v) in self._vocab.items() if v in keep_words])
        for tag in self._tags:
            self._w[tag] = dict([(k, v) for (k, v) in self._w[tag].items() if k in keep_words])

In [24]:
model = LogRegressor()
acc = model.iterate_file(update_vocab=True)
print('%0.2f' % acc)
plt.plot(pd.Series(model._loss[:-25000]).rolling(10000).mean());


0.58

In [25]:
# оставим только топ 10 000 слов
model.filter_vocab(n=10000)

In [26]:
# сделаем еще одну итерацию по датасету, уменьшив скорость обучения в 10 раз
acc = model.iterate_file(update_vocab=False, learning_rate=0.01)
print('%0.2f' % acc)
plt.plot(pd.Series(model._loss[:-25000]).rolling(10000).mean());


0.69

Вопрос 9. К какому значению ближе всего полученное значение точности: Варианты ответа:

  1. 0.48
  2. 0.58
  3. 0.68
  4. 0.78

10. Прогнозирование тегов для новых вопросов

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


In [27]:
class LogRegressor():
    
    def __init__(self, tags=top_tags):      
        self._vocab = {}
        self._w = dict([(t, defaultdict(int)) for t in tags])
        self._b = dict([(t, 0) for t in tags])
        self._tags = set(tags)
        self._word_stats = defaultdict(int)
    
    def iterate_file(self, 
                     fname=DS_FILE_NAME, 
                     top_n_train=100000, 
                     total=125000,
                     learning_rate=0.1,
                     tolerance=1e-16,
                     accuracy_level=0.9,
                     lmbda=0.0002,
                     gamma=0.1,
                     update_vocab=True):

        self._loss = []
        n = 0
        accuracy = []
        with open(fname, 'r') as f:            
            for line in tqdm_notebook(f, total=total, mininterval=1):
                pair = line.strip().split('\t')
                if len(pair) != 2:
                    continue                
                sentence, tags = pair
                sentence = sentence.split(' ')
                tags = set(tags.split(' '))
                
                sample_loss = 0
                predicted_tags = None
                
                for tag in self._tags:
                    y = int(tag in tags)
                    
                    z = self._b[tag] 
                    for word in sentence:
                        if n >= top_n_train and word not in self._vocab:
                            continue
                        if word not in self._vocab and update_vocab:
                            self._vocab[word] = len(self._vocab)
                        if word not in self._vocab:
                            continue
                        if update_vocab:
                            self._word_stats[self._vocab[word]] += 1
                        z += self._w[tag][self._vocab[word]] 
                        
                    sigma = 1/(1 + np.exp(-z)) if z >= 0 else 1 - 1/(1 + np.exp(z))
                    
                    sample_loss += -y*np.log(np.max([tolerance, sigma])) if y == 1 else \
                                   -(1 - y)*np.log(1 - np.min([1 - tolerance, sigma]))
                    
                    if n < top_n_train:
                        dLdw = y - sigma

                        for word in sentence:  
                            if word not in self._vocab:
                                continue
                            self._w[tag][self._vocab[word]] -= -learning_rate * dLdw \
                                + 2 * learning_rate * lmbda * gamma * self._w[tag][self._vocab[word]] \
                                + learning_rate * lmbda *(1 - gamma) * np.sign(self._w[tag][self._vocab[word]])
                        self._b[tag] -= -learning_rate * dLdw
                    else:
                        if predicted_tags is None:
                            predicted_tags = []
                        if sigma > accuracy_level:
                            predicted_tags.append(tag)
                    
                n += 1
                                        
                self._loss.append(sample_loss)
                if predicted_tags is not None:
                    accuracy.append(len(tags.intersection(predicted_tags))/len(tags.union(predicted_tags)))
            
        return(np.mean(accuracy))
    
    def filter_vocab(self, n=10000):
        keep_words = set([wid for (wid, wn) in sorted(self._word_stats.items(), 
                                                      key=lambda t: t[1], reverse=True)[:n]])
        self._vocab = dict([(k, v) for (k, v) in self._vocab.items() if v in keep_words])
        for tag in self._tags:
            self._w[tag] = dict([(k, v) for (k, v) in self._w[tag].items() if k in keep_words])
            
    def predict_proba(self, sentence):
        p = {}
        sentence = sentence.split(' ')
        for tag in self._tags:
            z = self._b[tag]
            for word in sentence:
                if word not in self._vocab:
                    continue
                z += self._w[tag][self._vocab[word]]
            sigma = 1 / (1 + np.exp(-z)) if z >= 0 else 1 - 1 / (1 + np.exp(z))
            p[tag] = sigma
        return p

In [28]:
model = LogRegressor()
acc = model.iterate_file(update_vocab=True)
print('%0.2f' % acc)
model.filter_vocab(n=10000)
acc = model.iterate_file(update_vocab=False, learning_rate=0.01)
print('%0.2f' % acc)


0.58
0.69

In [29]:
sentence = ("I want to improve my coding skills, so I have planned write " +
            "a Mobile Application.need to choose between Apple's iOS or Google's Android." +
            " my background: I have done basic programming in .Net,C/C++,Python and PHP " +
            "in college, so got OOP concepts covered. about my skill level, I just know " +
            "concepts and basic syntax. But can't write complex applications, if asked :(" +
            " So decided to hone my skills, And I wanted to know which is easier to " +
            "learn for a programming n00b. A) iOS which uses Objective C B) Android " + 
            "which uses Java. I want to decide based on difficulty " + 
            "level").lower().replace(',', '')

In [30]:
sorted(model.predict_proba(sentence).items(), 
       key=lambda t: t[1], reverse=True)


Out[30]:
[('ios', 1.0),
 ('php', 0.99999998827582759),
 ('android', 1.2408386595996745e-07),
 ('java', 4.1300296516055823e-14),
 ('html', 0.0),
 ('python', 0.0),
 ('jquery', 0.0),
 ('c++', 0.0),
 ('c#', 0.0),
 ('javascript', 0.0)]

Вопрос 10. Отметьте все теги, ассоциирующиеся с данным вопросом, если порог принятия равен $0.9$. То есть считаем, что вопросу надо поставить некоторый тег, если вероятность его появления, предсказанная моделью, больше или равна 0.9.

Варианты ответа:

  1. android
  2. ios
  3. php
  4. java