Python + Keras + LSTM: делаем переводчик текстов за полчаса

    Привет, Хабр.

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



    Для тех, кому интересно как это работает, подробности под катом.

    Примечание: данный проект использования нейронной сети для перевода исключительно учебный, поэтому вопрос «зачем» не рассматривается. Просто для интереса. Я не ставлю целью доказать что тот или иной метод лучше или хуже, просто интересно было проверить, что получится. Метод, используемый ниже, разумеется, упрощенный, но надеюсь никто и не надеется, что мы напишем второй Lingvo за полчаса.

    Сбор данных


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

                Hi.		Hallo!
                Hi.		Grüß Gott!
                Run!	Lauf!
                Wow!	Potzdonner!
                Wow!	Donnerwetter!
                Fire!	Feuer!
                Help!	Hilfe!
                Help!	Zu Hülf!
                Stop!	Stopp!
                Wait!	Warte!
                Go on.	Mach weiter.
                Hello!	Hallo!
                I ran.	Ich rannte.
                I see.	Ich verstehe.
                ...
    

    Файл содержит 192тыс строк и имеет размер 13МБайт. Загружаем текст в память и разбиваем данные на два блока, для английских и немецких слов.

    def read_text(filename):
        with open(filename, mode='rt', encoding='utf-8') as file:
            text = file.read()
            sents = text.strip().split('\n')
            return [i.split('\t') for i in sents]
    
    data = read_text("deutch.txt")
    deu_eng = np.array(data)
    
    deu_eng = deu_eng[:30000,:]
    print("Dictionary size:", deu_eng.shape)
    
    # Remove punctuation 
    deu_eng[:,0] = [s.translate(str.maketrans('', '', string.punctuation)) for s in deu_eng[:,0]] 
    deu_eng[:,1] = [s.translate(str.maketrans('', '', string.punctuation)) for s in deu_eng[:,1]] 
    
    # convert text to lowercase 
    for i in range(len(deu_eng)): 
        deu_eng[i,0] = deu_eng[i,0].lower() 
        deu_eng[i,1] = deu_eng[i,1].lower()
    

    Также мы перевели все слова в нижний регистр и убрали знаки препинания.

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

    Его использование просто проиллюстрировать примером:

    from keras.preprocessing.text import Tokenizer
    from keras.preprocessing.sequence import pad_sequences
    
    s = "To be or not to be"
    eng_tokenizer = Tokenizer()
    eng_tokenizer.fit_on_texts([s])
    
    seq = eng_tokenizer.texts_to_sequences([s])
    seq = pad_sequences(seq, maxlen=8, padding='post')
    print(seq)
    

    Фраза «to be or not to be» будет заменена массивом [1 2 3 4 1 2 0 0], где как не сложно догадаться, 1=to, 2=be, 3=or, 4=not. Эти данные мы уже можем подавать на нейросеть.

    Обучение нейронной сети


    Наши данные в цифровом виде готовы. Разобьем массив на два блока для входных (английские строки) и выходных (немецкие строки) данных. Также подготовим отдельный блок для валидации процесса обучения.

    # split data into train and test set 
    train, test = train_test_split(deu_eng, test_size=0.2, random_state=12)
    
    # prepare training data 
    trainX = encode_sequences(eng_tokenizer, eng_length, train[:, 0])
    trainY = encode_sequences(deu_tokenizer, deu_length, train[:, 1])
    
    # prepare validation data 
    testX = encode_sequences(eng_tokenizer, eng_length, test[:, 0])
    testY = encode_sequences(deu_tokenizer, deu_length, test[:, 1])
    

    Теперь мы можем создать модель нейронной сети и запустить её обучение. Как можно видеть, нейронная сеть содержит слои LSTM, имеющие ячейки памяти. Хотя возможно, заработало бы и на «обычной» сети, желающие могут проверить самостоятельно.

    def make_model(in_vocab, out_vocab, in_timesteps, out_timesteps, n):
        model = Sequential()
        model.add(Embedding(in_vocab, n, input_length=in_timesteps, mask_zero=True))
        model.add(LSTM(n))
        model.add(Dropout(0.3))
        model.add(RepeatVector(out_timesteps))
        model.add(LSTM(n, return_sequences=True))
        model.add(Dropout(0.3))
        model.add(Dense(out_vocab, activation='softmax'))
        model.compile(optimizer=optimizers.RMSprop(lr=0.001), loss='sparse_categorical_crossentropy')
        return model
    
    eng_vocab_size = len(eng_tokenizer.word_index) + 1 
    deu_vocab_size = len(deu_tokenizer.word_index) + 1
    eng_length, deu_length = 8, 8
    model = make_model(eng_vocab_size, deu_vocab_size, eng_length, deu_length, 512)
    
    num_epochs = 40
    model.fit(trainX, trainY.reshape(trainY.shape[0], trainY.shape[1], 1), epochs=num_epochs, batch_size=512, validation_split=0.2, callbacks=None, verbose=1)
    model.save('en-de-model.h5')
    

    Само обучение выглядит примерно так:



    Процесс, как можно видеть, не быстрый, и занимает порядка получаса на Core i7 + GeForce 1060 для набора из 30тыс строк. По окончании обучения (его нужно сделать всего один раз) модель сохраняется в файл, и дальше её можно использовать повторно.

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

    model = load_model('en-de-model.h5')
    
    def get_word(n, tokenizer):
        if n == 0:
            return ""
        for word, index in tokenizer.word_index.items():
            if index == n:
                return word
        return ""
    
    phrs_enc = encode_sequences(eng_tokenizer, eng_length, ["the weather is nice today", "my name is tom", "how old are you", "where is the nearest shop"])
    
    preds = model.predict_classes(phrs_enc)
    print("Preds:", preds.shape)
    print(preds[0])
    print(get_word(preds[0][0], deu_tokenizer), get_word(preds[0][1], deu_tokenizer), get_word(preds[0][2], deu_tokenizer), get_word(preds[0][3], deu_tokenizer))
    print(preds[1])
    print(get_word(preds[1][0], deu_tokenizer), get_word(preds[1][1], deu_tokenizer), get_word(preds[1][2], deu_tokenizer), get_word(preds[1][3], deu_tokenizer))
    print(preds[2])
    print(get_word(preds[2][0], deu_tokenizer), get_word(preds[2][1], deu_tokenizer), get_word(preds[2][2], deu_tokenizer), get_word(preds[2][3], deu_tokenizer))
    print(preds[3])
    print(get_word(preds[3][0], deu_tokenizer), get_word(preds[3][1], deu_tokenizer), get_word(preds[3][2], deu_tokenizer), get_word(preds[3][3], deu_tokenizer))
    

    Результаты


    Теперь собственно, самое любопытное — результаты. Интересно посмотреть, как обучается нейронная сеть и «запоминает» соответствия между английскими и немецкими фразами. Я специально взял 2 фразы попроще и 2 посложнее, чтобы увидеть разницу.

    5 минут обучения

    «the weather is nice today» — «das ist ist tom»
    «my name is tom» — «wie für tom tom»
    «how old are you» — «wie geht ist es»
    «where is the nearest shop» — «wo ist der»

    Как можно видеть, пока «попаданий» немного. Фрагмент фразы «how old are you» нейросеть «спутала» с фразой «how are you» и выдала перевод «wie geht ist es» (как дела?). Во фразе «where is ...» нейросеть определила только глагол where и выдала перевод «wo ist der» (где это?), что в принципе, не лишено смысла. В общем, примерно также переводит на немецкий новичок в группе А1 ;)

    10 минут обучения

    «the weather is nice today» — «das haus ist bereit»
    «my name is tom» — «mein heiße heiße tom»
    «how old are you» — «wie alt sind sie»
    «where is the nearest shop» — «wo ist paris»

    Виден некий прогресс. Первая фраза совсем невпопад. Во второй фразе нейросеть «выучила» глагол heißen (называться), но «mein heiße heiße tom» все равно некорректно, хотя о смысле уже можно догадаться. Третья фраза уже правильная. В четвертой правильная первая часть «wo ist», но nearest shop почему-то было заменено на paris.

    30 минут обучения

    «the weather is nice today» — «das ist ist aus»
    «my name is tom» — "«tom» ist mein name"
    «how old are you» — «wie alt sind sie»
    «where is the nearest shop» — «wo ist der»

    Как можно видеть, вторая фраза стала правильной, хотя конструкция выглядит несколько непривычно. Третья фраза правильная, ну а 1я и 4я фразы пока так и не были «выучены». На этом я с целью экономии электроэнергии закончил процесс.

    Заключение


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

    Для желающих поэкспериментировать самостоятельно, исходный код под спойлером. Программа теоретически может использовать любую пару языков, не только английский и немецкий (файл должен быть в кодировке UTF-8). Вопрос качества перевода тоже остается открытым, здесь есть что потестировать.

    keras_translate.py
    import os
    # os.environ["CUDA_VISIBLE_DEVICES"] = "-1"  # Force CPU
    os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'  # 0 = all messages are logged, 3 - INFO, WARNING, and ERROR messages are not printed
    
    import string 
    import re
    import numpy as np
    import pandas as pd
    from keras.models import Sequential 
    from keras.layers import Dense, LSTM, Embedding, RepeatVector
    from keras.preprocessing.text import Tokenizer
    from keras.callbacks import ModelCheckpoint 
    from keras.preprocessing.sequence import pad_sequences
    from keras.models import load_model 
    from keras import optimizers 
    from sklearn.model_selection import train_test_split
    import matplotlib.pyplot as plt
    
    pd.set_option('display.max_colwidth', 200)
    
    # Read raw text file
    def read_text(filename):
        with open(filename, mode='rt', encoding='utf-8') as file:
            text = file.read()
            sents = text.strip().split('\n')
            return [i.split('\t') for i in sents]
    
    data = read_text("deutch.txt")
    deu_eng = np.array(data)
    
    deu_eng = deu_eng[:30000,:]
    print("Dictionary size:", deu_eng.shape)
    
    # Remove punctuation 
    deu_eng[:,0] = [s.translate(str.maketrans('', '', string.punctuation)) for s in deu_eng[:,0]] 
    deu_eng[:,1] = [s.translate(str.maketrans('', '', string.punctuation)) for s in deu_eng[:,1]] 
    
    # Convert text to lowercase 
    for i in range(len(deu_eng)): 
        deu_eng[i,0] = deu_eng[i,0].lower() 
        deu_eng[i,1] = deu_eng[i,1].lower()
        
    # Prepare English tokenizer
    eng_tokenizer = Tokenizer()
    eng_tokenizer.fit_on_texts(deu_eng[:, 0])
    eng_vocab_size = len(eng_tokenizer.word_index) + 1 
    eng_length = 8 
    
    # Prepare Deutch tokenizer 
    deu_tokenizer = Tokenizer()
    deu_tokenizer.fit_on_texts(deu_eng[:, 1])
    deu_vocab_size = len(deu_tokenizer.word_index) + 1 
    deu_length = 8 
    
    # Encode and pad sequences 
    def encode_sequences(tokenizer, length, lines):          
        # integer encode sequences
        seq = tokenizer.texts_to_sequences(lines)
        # pad sequences with 0 values
        seq = pad_sequences(seq, maxlen=length, padding='post')
        return seq
         
    # Split data into train and test set 
    train, test = train_test_split(deu_eng, test_size=0.2, random_state=12)
    
    # Prepare training data 
    trainX = encode_sequences(eng_tokenizer, eng_length, train[:, 0])
    trainY = encode_sequences(deu_tokenizer, deu_length, train[:, 1])
    
    # Prepare validation data 
    testX = encode_sequences(eng_tokenizer, eng_length, test[:, 0])
    testY = encode_sequences(deu_tokenizer, deu_length, test[:, 1])
    
    # Build NMT model 
    def make_model(in_vocab, out_vocab, in_timesteps, out_timesteps, n):
        model = Sequential()
        model.add(Embedding(in_vocab, n, input_length=in_timesteps, mask_zero=True))
        model.add(LSTM(n))
        model.add(Dropout(0.3))
        model.add(RepeatVector(out_timesteps))
        model.add(LSTM(n, return_sequences=True))
        model.add(Dropout(0.3))
        model.add(Dense(out_vocab, activation='softmax'))
        model.compile(optimizer=optimizers.RMSprop(lr=0.001), loss='sparse_categorical_crossentropy')
        return model
    
    print("deu_vocab_size:", deu_vocab_size, deu_length)
    print("eng_vocab_size:", eng_vocab_size, eng_length)
    
    # Model compilation (with 512 hidden units)
    model = make_model(eng_vocab_size, deu_vocab_size, eng_length, deu_length, 512)
    
    # Train model
    num_epochs = 250
    history = model.fit(trainX, trainY.reshape(trainY.shape[0], trainY.shape[1], 1), epochs=num_epochs, batch_size=512, validation_split=0.2, callbacks=None, verbose=1)
    # plt.plot(history.history['loss'])
    # plt.plot(history.history['val_loss'])
    # plt.legend(['train','validation'])
    # plt.show()
    model.save('en-de-model.h5')
    
    # Load model
    model = load_model('en-de-model.h5')
    
    def get_word(n, tokenizer):
        if n == 0:
            return ""
        for word, index in tokenizer.word_index.items():
            if index == n:
                return word
        return ""
    
    
    phrs_enc = encode_sequences(eng_tokenizer, eng_length, ["the weather is nice today", "my name is tom", "how old are you", "where is the nearest shop"])
    print("phrs_enc:", phrs_enc.shape)
    
    preds = model.predict_classes(phrs_enc)
    print("Preds:", preds.shape)
    print(preds[0])
    print(get_word(preds[0][0], deu_tokenizer), get_word(preds[0][1], deu_tokenizer), get_word(preds[0][2], deu_tokenizer), get_word(preds[0][3], deu_tokenizer))
    print(preds[1])
    print(get_word(preds[1][0], deu_tokenizer), get_word(preds[1][1], deu_tokenizer), get_word(preds[1][2], deu_tokenizer), get_word(preds[1][3], deu_tokenizer))
    print(preds[2])
    print(get_word(preds[2][0], deu_tokenizer), get_word(preds[2][1], deu_tokenizer), get_word(preds[2][2], deu_tokenizer), get_word(preds[2][3], deu_tokenizer))
    print(preds[3])
    print(get_word(preds[3][0], deu_tokenizer), get_word(preds[3][1], deu_tokenizer), get_word(preds[3][2], deu_tokenizer), get_word(preds[3][3], deu_tokenizer))
    print()
    


    Сам словарь слишком большой, чтобы приаттачить к статье, ссылка в комментариях.

    Как обычно, всем удачных экспериментов.
    Share post

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 21

      0
      В качестве исходного датасета был использован найденный в сети файл, содержащий английские и немецкие фразы, разделенные табуляцией

      Не нашел ссылки на этот файл в статье. Можете дать ее хотя бы в комментариях?

      0

      А откуда брали датасет? Вообще где такой вот можно найти на другие языки или как самому сгенерировать?

        0
          0

          Спасибо большое, очень интересная статья. Хотел бы такой же переводчик для своего языка сделать (малочисленный народ) соответственно такого датасета нет. Как их вообще делают? Не вручную наверное

            0
            Как их вообще делают? Не вручную наверное

            Я парсил словари/разговорники в электронном виде. Есть еще книги на двух языках. Большой вопрос только существует ли что-то подобное для вашего языка.

              0
              По идее, можно отсканировать и распознать разговорник или словарь, если они есть.
                0

                Лучше парсить словарь и разговорник в электронном виде, при сканировании (особенно с редкого языка) будет достаточно много ошибок, поэтому такой переводчик без обработки будет возвращать неправильный текст.


                Если словарь нужно именно сканировать (скажем, нет электронного), я бы делал примерно так:


                1. Отсканировал автоматически словарь, распарсил пусть и с ошибками,
                2. Обработал все тексты на этом редком языке, составил список слов с частотой их употребления,
                3. Подобрал бы для каждого слова из 1 подходящий популярный слово из 2 наиболее близкое, при необходимости провел бы анализ частично вручную, в этом случае получится словарь уже без ошибок,
                  0
                  Это да, после сканирования нужна вычитка вручную. Электронный вариант разумеется проще, т.к. кто-то уже сделал это раньше :)

                  Просто словарей в электронном виде может и не быть.
                0
                Вам не обязательно нужен такой датасет. Для редких языков специально придуман подход, когда вам достаточно произвольных текстов на вашем языке без какого-либо перевода.
            +3
            К сожалению, вы сделали неверные выводы и ваша нейросеть ничему не обучилась.
            Обратите внимание на поведение val_loss. Это значение ошибки на данных которые нейросеть не получала на вход. И он у вас в процессе обучения растет.
            Это говорит о том что ваша нейронка просто зазубривает комбинации вместо того чтоб находить какие-то закономерности «выучивать» глаголы и т.д.
            Ценность такой обученной модели крайне сомнительная, вы вводите в заблуждение и себя и читателей.
            При правильном подборе датасета, разделения обучающей и тестовой выборки вы столкнетесь с тем что задача перевода с помощью нескольких слоев LSTM — в принципе не решаема. Для ее решения придумана архитектура трансформера с multi-head attention.
              0
              Спасибо за поправку. Да, действительно, после примерно 50 эпох val_loss уже не снижается.
              График


              Ну, я и не рассчитывал сделать качественный переводчик в 50 строк кода.
                0
                И кстати да, я забыл добавить Dropout слои в модель, с ними результаты лучше.

                График

              0
              Интересно, есть ли перспектива в том, чтобы учить сразу две нейронки (англ->нем и нем->англ), и учитывать метрику схожести для произвольной фразы, пропущенной через обе нейронки? То есть, предположение, что чем лучше переводчик, тем меньше исказится фраза.
                0

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


                P.S. Кстати, тут еще есть проблема, что если мы пытаемся анализировать схожесть нескольких "тупых" нейронок (который "cat and dog rain" переводят как "дождь из кошек и собак") и одной умной (которая переводит как "сильный ливень"), то "тупые" будут забивать умную числом (или если анализировать вручную, метрика будет давать "белый шум" на практически любую неочевидную фразу).

                  0
                  Есть немного похожая архитектура, называемая VAE — Variational Autoencoder. Там действительно используются 2 сети, одна для кодирования, другая для декодирования. Вроде их как-то тоже применяют для перевода, лично не пробовал.

                  Краткое описание есть например здесь: ijdykeman.github.io/ml/2016/12/21/cvae.html
                  0
                  Мне кажется было бы логичней взять уже обученный эмбеддинг (BERT от Гугла вроде как щас топ) и плясать уже от него. И качество было-бы лучше и дофига языков поддерживается…
                    +1

                    Спасибо за статью, однако эксперимент в ней совершенно не показателен.
                    1) Данных для обучения с нуля (как вы делаете) очень мало. Решение простое — возьмите предобученные эмбеддинги или бОльший корпус.
                    2) В NMT уже давным давно используют BPE, а не токены (советую YouTokenToMe от команды VK). Это тоже очень влияет на сходимость и конечный результат.
                    3) Много вопросов к архитектуре сети. Советую почитать про attention и трансформеры.
                    Ну и собсвтенно как итог — ваша модель жутко оверфитится и выдает невнятный результат.

                      0

                      Интересно было бы посмотреть на сколько бы изменилось качество если перевести все слова перед обучением в векторный формат с помощью word-to-vec и обучить эту же модель на векторах.

                        0
                        Скорее всего не изменится. Слой Embedding как раз учит такие вектора, но сразу от общих градиентов сети. Другое дело, если вектора получены от ELMO или BERT, а не через word-to-vec — такие вектора гораздо содержательнее, т.к. зависят от смысла всего предложения.

                      Only users with full accounts can post comments. Log in, please.