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

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



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

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

Сбор данных


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

            Hi.		Hallo!
            Hi.		Gru? Gott!
            Run!	Lauf!
            Wow!	Potzdonner!
            Wow!	Donnerwetter!
            Fire!	Feuer!
            Help!	Hilfe!
            Help!	Zu Hulf!
            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 fur 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()


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

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

Комментарии (21)


  1. vedenin1980
    09.10.2019 01:09

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

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


    1. Lazytech
      09.10.2019 06:39

      Если я правильно понял, вот источник данных:
      www.manythings.org/bilingual/deu

      Сравните двуязычный фрагмент в статье со словами на этой странице:
      www.manythings.org/bilingual/deu/1.html

      P.S. Поправка! Вероятный первоисточник:
      tatoeba.org


    1. DmitrySpb79 Автор
      09.10.2019 07:03

      Я использовал этот файл: cloud.mail.ru/public/5mzV/3eenc2eGE

      13Мбайт к статье не приаттачить, файл слишком большой.


  1. Ammos
    09.10.2019 07:00

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


    1. DmitrySpb79 Автор
      09.10.2019 07:05

      1. Ammos
        09.10.2019 09:50

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


        1. vedenin1980
          09.10.2019 09:58

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

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


        1. DmitrySpb79 Автор
          09.10.2019 10:03

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


          1. vedenin1980
            09.10.2019 10:11

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


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


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


            1. DmitrySpb79 Автор
              09.10.2019 10:27

              Это да, после сканирования нужна вычитка вручную. Электронный вариант разумеется проще, т.к. кто-то уже сделал это раньше :)

              Просто словарей в электронном виде может и не быть.


        1. DaniyarM
          09.10.2019 14:34

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


  1. Undiabler
    09.10.2019 11:30

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


    1. DmitrySpb79 Автор
      09.10.2019 11:59

      Спасибо за поправку. Да, действительно, после примерно 50 эпох val_loss уже не снижается.

      График


      1. DmitrySpb79 Автор
        09.10.2019 13:58

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

        График


  1. algotrader2013
    09.10.2019 13:02

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


    1. vedenin1980
      09.10.2019 13:09

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


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


    1. DmitrySpb79 Автор
      09.10.2019 13:14

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

      Краткое описание есть например здесь: ijdykeman.github.io/ml/2016/12/21/cvae.html


  1. Sklott
    09.10.2019 14:33

    Мне кажется было бы логичней взять уже обученный эмбеддинг (BERT от Гугла вроде как щас топ) и плясать уже от него. И качество было-бы лучше и дофига языков поддерживается…


  1. Crazy_as
    10.10.2019 12:19

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


  1. AgenSmith
    11.10.2019 11:35

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


    1. DaniyarM
      11.10.2019 23:58

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