Обучение на данных из Википедии

Обучение на данных из Википедии

Здравствуйте и вновь добро пожаловать на занятия по теме «Рекуррентные нейронные сети на языке Python: глубокое обучение, часть 5».

В этой статье мы поговорим о том, как создать модель для дампов данных Википедии. Целью является создание языковой модели, как мы это делали в случае поэзии. В обоих случаях это лишь последовательности слов. Разница в том, что набор данных из Википедии намного больше и содержит больше слов. Мы также посмотрим, сможет ли векторное вложение слов дать осмысленные результаты. Это будет сделано двумя способами: во-первых, мы рассмотрим словесные аналогии, а во-вторых, наглядно представим векторное представление слов на графике. Вы увидите, что с точки зрения рекуррентных нейронных сетей тут не так уж и много нового, мы просто подключим GRU и LSTM, вместо того чтобы пользоваться простым рекуррентным нейроном, как делали это ранее. Большую часть работы составит получение и обработка данных, а не создание языковой модели, что нами уже было сделано.

Сначала обсудим, как получить данные. Нужно перейти по адресу https://dumps.wikimedia.org/ и нажать на Database dumps. Я покажу вам, как это сделать в браузере. Перейдя на сайт, нужно нажать на Database backup dumps. Находим enwiki и нажимаем. Нужно выбрать pages-articles-multistream. Можно загрузить полный набор данных (в данном случае он занимает 13 Гб), а можно – найти pages-articles и загрузить данные по частям. Я загрузил несколько частей, поскольку нет нужды во всех, но вы можете попробовать все. Я уже загружал где-то между одной и десятью частями и даже пробовал использовал часть от первой части, поэтому и у вас нет нужды во всех частях, чтобы получить осмысленный результат. Когда я позже буду визуализировать данные, то использую лишь первую часть.

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

Следующий этап – преобразовать данные в так называемый формат плоских файлов. Для этого мы воспользуемся утилитой wp2txt. Просто перейдите по адресу https://github.com/yohasebe/wp2txt и следуйте инструкциям, указанным в Приложении. Для установки введите команду

sudo gem install wp2txt

Далее перейдите в папку large_files, которая должна находиться рядом с папкой rnn_class, и переместите в неё файлы .bz2. Затем запустите команду

wp2txt –i <bz2 имя_файла>

Она должна извлечь файлы в этой же папке.

И наконец, обсудим теперь, как взять эти текстовые файлы и преобразовать их в правильный для нашей нейронной сети формат. Нужный код, разумеется, находится в файле util.py, вместе с остальным кодом для данных. Нужная нам функция называется get_wikipedia_data. Функция использует два параметра – n_files и n_vocab. Наличие параметра n_files связано с тем, что входных файлов будет много. Даже слишком много, чтобы поместиться в памяти, если вы используете полный набор данных. Я ограничил их количество сотней и меньше, и этого, по всей видимости, оказалось достаточно, чтобы получить осмысленное представление данных. Можно также ограничить размер словаря, который будет намного больше, чем в случае данных для поэзии. У нас будет где-то в районе от 500 тысяч до 1 миллиона слов. Не забывайте, что конечной целью является следующее слово, так что это будет миллион исходящих классов, что очень много, как для исходящих классов. Из-за этого будет трудно получить хорошую точность, да и исходящие весовые коэффициенты окажутся очень большими. Чтобы этого избежать, мы ограничиваем размер словаря параметром n_vocab. Обычно он ограничивается примерно 2000 слов.

Обратите внимание, что эти 2000 слов – это должны быть 2000 самых распространённых слов, а не случайно выбранных. В связи с этим нам нужно сделать кое-что ещё. Прежде всего нужно подсчитать, сколько раз слово встречается в словаре. Далее нам нужно отсортировать этот словарь по убыванию, так чтобы самое распространённое слово шло первым. Это даст нам индексы для первых 2000 слов.

Впрочем, и это ещё не всё, поскольку наша матрица векторного представления слов должна иметь размерность V = 2000, так что индексы слов будут иметь значения от 0 до 2000. Но имеющиеся у нас на данный момент индексы – это 2000 случайных чисел в диапазоне от 0 до 1 000 000, если предположить, что общий размер словаря составляет 1 миллион слов. Вследствие этого нам нужно создать новое отображение от старого индекса слова к новому, где старый индекс слова – это любое число от 0 до 1 000 000, а новый – это число в диапазоне от 0 до 2000. Это также означает, что нам придётся создать и новый словарь индексов.

Если вы будете обучать модель на всём наборе данных, у вас, вероятнее всего, не хватит памяти. Но есть несколько способов обойти это затруднение. Прежде всего, не обязательно загружать все данные в память одновременно. Один из способов – обучаться на каждом отдельном текстовом файле при каждом прохождении цикла в каждый отдельный момент времени. Тогда при каждом прохождении просматривается каждый файл, открывается, все предложения преобразовываются в массив индексов слов, а затем происходит обучение на этих предложениях, после чего открывается следующий файл и так далее. Разумеется, при этом придётся отслеживать словарь отображения индекса слов, чтобы он оставался согласованным для каждого из файлов. Как вы догадываетесь, повторная обработка предложений после каждого открытия файлов будет очень медленной.

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

Ещё один способ – использовать простую базу данных вроде SQLite для хранения массивов индексов слов. В этом случае вы можете хранить весь набор данных в базе данных и случайным образом извлекать строки без необходимости хранить их в памяти. Мы так делать не будем, поскольку это не является предметом данного курса, но если вам интересно изучить подобные методы, то можете обратиться к моему курсу по SQL.

Обучение на данных Википедии, код. Часть 1

Соответствующие файлы называются util.py и wiki.py.

Быстро просмотрим функцию get_wikipedia_data. Она уже описывалась в предыдущей лекции, но сейчас я хочу показать вам реализацию этих идей в коде.

Прежде всего, как и во всех моих курсах, все данные помещаются в соседнюю папку large_files. Мы выбираем все файлы, начинающиеся с «enwiki» и заканчивающиеся на «txt», поскольку именно таков результат работы утилиты wp2txt.

def get_wikipedia_data(n_files, n_vocab):

    prefix = ‘../large_files/’

    input_files = [f for f in os.listdir(prefix) if f.startswith(‘enwiki’) and f.endswith(‘txt’)]

Далее мы отслеживаем предложения и имеем словари преобразования слов в индексы и индексов в слова. У нас есть токены START И END с индексами 0 и 1 соответственно, так что переменная current_idx начинается с 2. Кроме того, мы ведём подсчёт, чтобы знать, какие слова являются наиболее употребимыми.

    # return variables

    sentences = []

    word2idx = {‘START’: 0, ‘END’: 1}

    idx2word = [‘START’, ‘END’]

    current_idx = 2

    word_idx_count = {0: float(‘inf’), 1: float(‘inf’)}

Дополнительно устанавливаем, что если переменная n_files имеет значение None, то берутся все файлы, в противном случае – количество, равное n_files.

    if n_files is not None:

        input_files = input_files[:n_files]

Далее идёт цикл по всем файлам. Поскольку в Википедии есть заголовки, списки и структурирование данных, а они не являются собственно языком, то они убираются.

    for f in input_files:

        print(“reading:”, f)

        for line in open(prefix + f):

            line = line.strip()

            # don’t count headers, structured data, lists, etc…

            if line and line[0] not in (‘[‘, ‘*’, ‘-‘, ‘|’, ‘=’, ‘{‘, ‘}’):

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

                    sentence_lines = line.split(‘. ‘)

                for sentence in sentence_lines:

                    tokens = my_tokenizer(sentence)

Далее идёт цикл по всем токенам. Если токена нет в словаре преобразования слова в индекс, то создаём для него индекс, а переменная current_idx увеличивается на единицу.

                    for t in tokens:

                        if t not in word2idx:

                            word2idx[t] = current_idx

                            idx2word.append(t)

                            current_idx += 1

                        idx = word2idx[t]

                        word_idx_count[idx] = word_idx_count.get(idx, 0) + 1

                    sentence_by_idx = [word2idx[t] for t in tokens]

                    sentences.append(sentence_by_idx)

Следующий блок кода посвящён ограничению словаря вместе с сортировкой его по убыванию, то есть вначале идёт наибольшее значение. Кроме того, создаётся новый словарь индексов word2idx_small с числами от 0 до 2000.

    # restrict vocab size

    sorted_word_idx_count = sorted(word_idx_count.items(), key=operator.itemgetter(1), reverse=True)

    word2idx_small = {}

    new_idx = 0

    idx_new_idx_map = {}

    for idx, count in sorted_word_idx_count[:n_vocab]:

        word = idx2word[idx]

        print(word, count)

        word2idx_small[word] = new_idx

        idx_new_idx_map[idx] = new_idx

        new_idx += 1

Но нам ещё нужен индекс для всех остальных слов, не имеющих индексов. Поэтому создаётся токен UNKNOWN, и все слова, не входящие в первые 2000, становятся UNKNOWN.

    # let ‘unknown’ be the last token

    word2idx_small[‘UNKNOWN’] = new_idx

    unknown = new_idx

Далее идёт код просто для проверки. Мы хотим провести словесные аналогии на некоторых конкретных словах, и нам нужно, чтобы они были в словаре.

    assert(‘START’ in word2idx_small)

    assert(‘END’ in word2idx_small)

    assert(‘king’ in word2idx_small)

    assert(‘queen’ in word2idx_small)

    assert(‘man’ in word2idx_small)

    assert(‘woman’ in word2idx_small)

И последний этап – организовать соответствие между старым и новым словарями индексов.

    # map old idx to new idx

    sentences_small = []

    for sentence in sentences:

        if len(sentence) > 1:

            new_sentence = [idx_new_idx_map[idx] if idx in idx_new_idx_map else unknown for idx in sentence]

            sentences_small.append(new_sentence)

    return sentences_small, word2idx_small

Теперь переходим к файлу wiki.py. Импортируем библиотеки:

import sys

import theano

import theano.tensor as T

import numpy as np

import matplotlib.pyplot as plt

import json

Библиотека json нам нужна для отслеживания отображений слов в индексы.

Наша задача займёт много времени, и хотелось бы узнать, сколько именно, поэтому импортируем функцию datetime. Shuufle – это как обычно, и, конечно, GRU и LSTM. Из файла util.py мы импортируем только функции init_weight и get_wikipedia_data.

from datetime import datetime

from sklearn.utils import shuffle

from gru import GRU

from lstm import LSTM

from util import init_weight, get_wikipedia_data

from brown import get_sentences_with_word2idx_limit_vocab

Итак, создаём класс RNN.

class RNN:

    def __init__(self, D, hidden_layer_sizes, V):

        self.hidden_layer_sizes = hidden_layer_sizes

        self.D = D

        self.V = V

Переходим к функции fit. Поскольку это обучение без учителя, то в качестве аргумента – только X.

    def fit(self, X, learning_rate=10e-5, mu=0.99, epochs=10, show_fig=True, activation=T.nnet.relu, RecurrentUnit=GRU, normalize=True):

        D = self.D

        V = self.V

        N = len(X)

        We = init_weight(V, D)

        self.hidden_layers = []

        Mi = D

        for Mo in self.hidden_layer_sizes:

            ru = RecurrentUnit(Mi, Mo, activation)

            self.hidden_layers.append(ru)

            Mi = Mo

        Wo = init_weight(Mi, V)

        bo = np.zeros(V)

Далее создаём переменные Theano.

        self.We = theano.shared(We)

        self.Wo = theano.shared(Wo)

        self.bo = theano.shared(bo)

        self.params = [self.Wo, self.bo]

        for ru in self.hidden_layers:

            self.params += ru.params

        thX = T.ivector(‘X’)

        thY = T.ivector(‘Y’)

        Z = self.We[thX]

        for ru in self.hidden_layers:

            Z = ru.output(Z)

        py_x = T.nnet.softmax(Z.dot(self.Wo) + self.bo)

        prediction = T.argmax(py_x, axis=1)

        self.predict_op = theano.function(

            inputs=[thX],

            outputs=[py_x, prediction],

            allow_input_downcast=True,

        )

Следующее – функция затрат и градиентный спуск.

        cost = -T.mean(T.log(py_x[T.arange(thY.shape[0]), thY]))

        grads = T.grad(cost, self.params)

        dparams = [theano.shared(p.get_value()*0) for p in self.params]

        dWe = theano.shared(self.We.get_value()*0)

        gWe = T.grad(cost, self.We)

        dWe_update = mu*dWe – learning_rate*gWe

        We_update = self.We + dWe_update

        if normalize:

            We_update /= We_update.norm(2)

        updates = [

            (p, p + mu*dp – learning_rate*g) for p, dp, g in zip(self.params, dparams, grads)

        ] + [

            (dp, mu*dp – learning_rate*g) for dp, g in zip(dparams, grads)

        ] + [

            (self.We, We_update), (dWe, dWe_update)

        ]

        self.train_op = theano.function(

            inputs=[thX, thY],

            outputs=[cost, prediction],

            updates=updates

        )

Теперь мы готовы приступить к основному обучающему циклу.

        costs = []

        for i in xrange(epochs):

            t0 = datetime.now()

            X = shuffle(X)

            n_correct = 0

            n_total = 0

            cost = 0

            for j in xrange(N):

                if np.random.random() < 0.01 or len(X[j]) <= 1:

                    input_sequence = [0] + X[j]

                    output_sequence = X[j] + [1]

                else:

                    input_sequence = [0] + X[j][:-1]

                    output_sequence = X[j]

                n_total += len(output_sequence)

                c, p = self.train_op(input_sequence, output_sequence)

                cost += c

                for pj, xj in zip(p, output_sequence):

                    if pj == xj:

                        n_correct += 1

                if j % 200 == 0:

                    sys.stdout.write(“j/N: %d/%d correct rate so far: %f\r” % (j, N, float(n_correct)/n_total))

                    sys.stdout.flush()

            print(“i:”, i, “cost:”, cost, “correct rate:”, (float(n_correct)/n_total), “time for epoch:”, (datetime.now() – t0))

            costs.append(cost)

        if show_fig:

            plt.plot(costs)

            plt.show()

Обучение на данных Википедии, код. Часть 2

Далее определим функцию train_wikipedia.

def train_wikipedia(we_file=’word_embeddings.npy’, w2i_file=’wikipedia_word2idx.json’, RecurrentUnit=GRU):

    sentences, word2idx = get_sentences_with_word2idx_limit_vocab()

    print(“finished retrieving data”)

    print(“vocab size:”, len(word2idx), “number of sentences:”, len(sentences))

Следующее – создание РНС. Параметры её установим равными, допустим, 50 и 50.

    rnn = RNN(30, [30], len(word2idx))

    rnn.fit(sentences, learning_rate=10e-6, epochs=10, show_fig=True, activation=T.nnet.relu)

Сохраняем файл векторных представлений слов с матрицей векторных представлений, а также текущие представления слов.

    np.save(we_file, rnn.We.get_value())

    with open(w2i_file, ‘w’) as f:

        json.dump(word2idx, f)

Нам интересно нахождение словесных аналогий, поэтому напишем соответствующую функцию.

def find_analogies(w1, w2, w3, we_file=’word_embeddings.npy’, w2i_file=’wikipedia_word2idx.json’):

    We = np.load(we_file)

    with open(w2i_file) as f:

        word2idx = json.load(f)

    king = We[word2idx[w1]]

    man = We[word2idx[w2]]

    woman = We[word2idx[w3]]

    v0 = king – man + woman

Теперь определим расстояния между словами, оба типа. Первая функция – обычное евклидово расстояние.

    def dist1(a, b):

        return np.linalg.norm(a – b)

Второе расстояние – косинусное.

    def dist2(a, b):

        return 1 – a.dot(b) / (np.linalg.norm(a) * np.linalg.norm(b))

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

    for dist, name in [(dist1, ‘Euclidean’), (dist2, ‘cosine’)]:

        min_dist = float(‘inf’)

        best_word = ”

        for word, idx in iteritems(word2idx):

            if word not in (w1, w2, w3):

                v1 = We[idx]

                d = dist(v0, v1)

                if d < min_dist:

                    min_dist = d

                    best_word = word

        print(“closest match by”, name, “distance:”, best_word)

        print(w1, “-“, w2, “=”, best_word, “-“, w3)

Для проверки сначала мы поищем аналогии по словам «король», «мужчина», «женщина». Потом – «Франция», «Париж», «Лондон» – при этом ожидается, что ответом будет что-то вроде «Англия» или «Британия». Затем «Франция», «Париж», «Рим». Ещё один случай – «Париж», «Франция», «Италия».

if __name__ == ‘__main__’:

    train_wikipedia()

    find_analogies(‘king’, ‘man’, ‘woman’, we, w2i)

    find_analogies(‘france’, ‘paris’, ‘london’, we, w2i)

    find_analogies(‘france’, ‘paris’, ‘rome’, we, w2i)

    find_analogies(‘paris’, ‘france’, ‘italy’, we, w2i)

Запустим наш файл и посмотрим, что получится.

Примечательно, что рекуррентная сеть изучила правильный «тип» слов. Так, «император» – монарх, «Германия» и «Британия» – страны, «Берлин» – город. При дальнейшем обучении можно получить более точный ответ.

Упражнение: попробуйте сами с различными гиперпараметрами.

Визуализация векторных представлений слов

Мы напишем код для наглядного представления полученного векторного представления слов и посмотрим на некоторые из этих представлений.

Итак, начинаем с импорта библиотек. Библиотека Json нам нужна для отображения индексов, TSNE – это метод нелинейной визуализации, с ним вы могли познакомиться в курсе «Глубокое обучение, часть 4». Нам также понадобятся PCA и TruncatedSVD. TruncatedSVD использовался в моём курсе по обработке естественных языков и позволил получить ряд любопытных результатов. Вы тоже можете попробовать.

import json

import numpy as np

import matplotlib.pyplot as plt

from sklearn.manifold import TSNE

from sklearn.decomposition import PCA, TruncatedSVD

Основная функция.

def main(we_file=’word_embeddings.npy’, w2i_file=’wikipedia_word2idx.json’, Model=PCA):

    We = np.load(we_file)

    V, D = We.shape

    with open(w2i_file) as f:

        word2idx = json.load(f)

    idx2word = {v:k for k,v in word2idx.iteritems()}

Выведем график для первых двух размерностей.

    model = Model()

    Z = model.fit_transform(We)

    plt.scatter(Z[:,0], Z[:,1])

    for i in xrange(V):

        plt.annotate(s=idx2word[i], xy=(Z[i,0], Z[i,1]))

    plt.show()

if __name__ == ‘__main__’:

main(we_file=’gru_nonorm_part1_word_embeddings.npy’, w2i_file=’gru_nonorm_part1_wikipedia_word2idx.json’, Model=TSNE)

Указанные в функции main файлы находятся на Github, так что вам не обязательно обучать модель, чтобы получить эти данные. Но я всё же рекомендую обучить свою модель и посмотреть, может, получится что-то получше.

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

Мы видим визуализацию векторных представлений слов с использованием метода t-SNE. Увеличим и посмотрим, удастся ли нам найти какие-то интересные закономерности. Любопытно, что года сгруппированы в одном районе. 90-е, 2000-е, 50-е… С понятием времени тут всё отлично.

Выберем какую-нибудь другую случайную область. Интересно – мы обнаружили культуры и языки – «английский», «французский», «португальский» размещены в одной области. «Корейский» и «японский» находятся очень близко друг к другу. Слева то, что касается религии – «христианский», «мусульманский», «исламский», «католический» – все они в одном углу. «Американский» и «канадский» – несколько в стороне.

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

Попробуем другую область. Ряд описательных слов сгруппированы вместе.

Увеличим в области, пониже предыдущей. Тоже интересно – в одном углу видим слова, относящиеся к политике, – «демократический», «федеральный», «верховный» и «коммунистический» расположены в одной и той же области. «Либеральный» и «консервативный» очень близко друг к другу. Слова «промышленный», «частный» и «сельскохозяйственный» также близко друг от друга. «Финансовый», «здравоохранительный», «медицинский», «экологический»… Похоже, это разные отрасли.

Посмотрим ещё в другой области. Видим размещение очень похожих слов – «предполагаемый», «вероятный», «доказанный», «фактический», «обоснованный», «заявленный»…

Похоже, куда бы мы ни двинулись на этой карте, всегда найдётся что-то интересное.

Посмотрим, что в нижнем левом углу. Тоже интересно – все цифры сгруппированы в одной области – 1, 2, 3, 4, 5, 6 и так далее. Ряд чисел, кратных 10, группируются в другом месте. Таким образом, получилось некоторое понятие чисел, записанных словами, и чисел, записанных цифрами, что правильно, поскольку числа, записанные словами, располагаются вместе и далеко от цифр, но всё равно все они находятся в одной области. То есть, они сгруппированы вместе и в то же время отделены друг от друга. А кроме того, есть и численные понятия – «немного», «много», «несколько», показывающие количество и отделённые от чисел.

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

Если посмотреть в центр, то увидим более относящиеся к программированию вещи – «сети», «программы», «языки».

А внизу, похоже, профессии – «учёные», «артисты», «авторы», «писатели», «историки».

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

Ещё есть грамматически схожие слова: «очень», «относительно», «весьма», «крайне», «значительно», «существенно», «вполне», «много», «ведущий», «прямо», «широко», «легко», «близко», «широко», «обобщая». Это всё очень похожие слова, находящиеся в одном месте.

Аналогично – справа вверху. «Есть», «быть», «будет».

В центре, чуть левее неинтересно.

Итак, куда бы мы ни глянули в этом отображении, мы увидим связанные слова. Очень интересно, как t-SNE организует их на основе векторного представления слов.

Настойчиво советую вам самостоятельно посмотреть, какие ещё закономерности вы сможете найти.

Понравилась статья? Поделить с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: