В предыдущей части я рассматривал создание несложной распознавалки текста, основанной на нейронной сети. Сегодня мы применим аналогичный подход, и напишем автоматический переводчик текстов с английского на немецкий.
Для тех, кому интересно как это работает, подробности под катом.
Примечание: данный проект использования нейронной сети для перевода исключительно учебный, поэтому вопрос «зачем» не рассматривается. Просто для интереса. Я не ставлю целью доказать что тот или иной метод лучше или хуже, просто интересно было проверить, что получится. Метод, используемый ниже, разумеется, упрощенный, но надеюсь никто и не надеется, что мы напишем второй 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). Вопрос качества перевода тоже остается открытым, здесь есть что потестировать.
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)
Ammos
09.10.2019 07:00А откуда брали датасет? Вообще где такой вот можно найти на другие языки или как самому сгенерировать?
DmitrySpb79 Автор
09.10.2019 07:05Ammos
09.10.2019 09:50Спасибо большое, очень интересная статья. Хотел бы такой же переводчик для своего языка сделать (малочисленный народ) соответственно такого датасета нет. Как их вообще делают? Не вручную наверное
vedenin1980
09.10.2019 09:58Как их вообще делают? Не вручную наверное
Я парсил словари/разговорники в электронном виде. Есть еще книги на двух языках. Большой вопрос только существует ли что-то подобное для вашего языка.
DmitrySpb79 Автор
09.10.2019 10:03По идее, можно отсканировать и распознать разговорник или словарь, если они есть.
vedenin1980
09.10.2019 10:11Лучше парсить словарь и разговорник в электронном виде, при сканировании (особенно с редкого языка) будет достаточно много ошибок, поэтому такой переводчик без обработки будет возвращать неправильный текст.
Если словарь нужно именно сканировать (скажем, нет электронного), я бы делал примерно так:
- Отсканировал автоматически словарь, распарсил пусть и с ошибками,
- Обработал все тексты на этом редком языке, составил список слов с частотой их употребления,
- Подобрал бы для каждого слова из 1 подходящий популярный слово из 2 наиболее близкое, при необходимости провел бы анализ частично вручную, в этом случае получится словарь уже без ошибок,
DmitrySpb79 Автор
09.10.2019 10:27Это да, после сканирования нужна вычитка вручную. Электронный вариант разумеется проще, т.к. кто-то уже сделал это раньше :)
Просто словарей в электронном виде может и не быть.
DaniyarM
09.10.2019 14:34Вам не обязательно нужен такой датасет. Для редких языков специально придуман подход, когда вам достаточно произвольных текстов на вашем языке без какого-либо перевода.
Undiabler
09.10.2019 11:30К сожалению, вы сделали неверные выводы и ваша нейросеть ничему не обучилась.
Обратите внимание на поведение val_loss. Это значение ошибки на данных которые нейросеть не получала на вход. И он у вас в процессе обучения растет.
Это говорит о том что ваша нейронка просто зазубривает комбинации вместо того чтоб находить какие-то закономерности «выучивать» глаголы и т.д.
Ценность такой обученной модели крайне сомнительная, вы вводите в заблуждение и себя и читателей.
При правильном подборе датасета, разделения обучающей и тестовой выборки вы столкнетесь с тем что задача перевода с помощью нескольких слоев LSTM — в принципе не решаема. Для ее решения придумана архитектура трансформера с multi-head attention.DmitrySpb79 Автор
09.10.2019 11:59Спасибо за поправку. Да, действительно, после примерно 50 эпох val_loss уже не снижается.
ГрафикDmitrySpb79 Автор
09.10.2019 13:58И кстати да, я забыл добавить Dropout слои в модель, с ними результаты лучше.
График
algotrader2013
09.10.2019 13:02Интересно, есть ли перспектива в том, чтобы учить сразу две нейронки (англ->нем и нем->англ), и учитывать метрику схожести для произвольной фразы, пропущенной через обе нейронки? То есть, предположение, что чем лучше переводчик, тем меньше исказится фраза.
vedenin1980
09.10.2019 13:09Мне кажется тогда нейронки просто будут учится давать максимально одинаковые результаты (даже если они будут бредовыми). Типо если одна нейронка переведет фразу "я тебя люблю" как "иди на х", то вторая будет стараться перевести "иди на х" как "я тебя люблю", чтобы максимизировать метрику. Плюс проблема в том, что обучая обе нейронки на одинаковых датасетах и алгоритмах будет получать одинаковые результаты.
P.S. Кстати, тут еще есть проблема, что если мы пытаемся анализировать схожесть нескольких "тупых" нейронок (который "cat and dog rain" переводят как "дождь из кошек и собак") и одной умной (которая переводит как "сильный ливень"), то "тупые" будут забивать умную числом (или если анализировать вручную, метрика будет давать "белый шум" на практически любую неочевидную фразу).
DmitrySpb79 Автор
09.10.2019 13:14Есть немного похожая архитектура, называемая VAE — Variational Autoencoder. Там действительно используются 2 сети, одна для кодирования, другая для декодирования. Вроде их как-то тоже применяют для перевода, лично не пробовал.
Краткое описание есть например здесь: ijdykeman.github.io/ml/2016/12/21/cvae.html
Sklott
09.10.2019 14:33Мне кажется было бы логичней взять уже обученный эмбеддинг (BERT от Гугла вроде как щас топ) и плясать уже от него. И качество было-бы лучше и дофига языков поддерживается…
Crazy_as
10.10.2019 12:19Спасибо за статью, однако эксперимент в ней совершенно не показателен.
1) Данных для обучения с нуля (как вы делаете) очень мало. Решение простое — возьмите предобученные эмбеддинги или бОльший корпус.
2) В NMT уже давным давно используют BPE, а не токены (советую YouTokenToMe от команды VK). Это тоже очень влияет на сходимость и конечный результат.
3) Много вопросов к архитектуре сети. Советую почитать про attention и трансформеры.
Ну и собсвтенно как итог — ваша модель жутко оверфитится и выдает невнятный результат.
AgenSmith
11.10.2019 11:35Интересно было бы посмотреть на сколько бы изменилось качество если перевести все слова перед обучением в векторный формат с помощью word-to-vec и обучить эту же модель на векторах.
DaniyarM
11.10.2019 23:58Скорее всего не изменится. Слой Embedding как раз учит такие вектора, но сразу от общих градиентов сети. Другое дело, если вектора получены от ELMO или BERT, а не через word-to-vec — такие вектора гораздо содержательнее, т.к. зависят от смысла всего предложения.
vedenin1980
Не нашел ссылки на этот файл в статье. Можете дать ее хотя бы в комментариях?
Lazytech
Если я правильно понял, вот источник данных:
www.manythings.org/bilingual/deu
Сравните двуязычный фрагмент в статье со словами на этой странице:
www.manythings.org/bilingual/deu/1.html
P.S. Поправка! Вероятный первоисточник:
tatoeba.org
DmitrySpb79 Автор
Я использовал этот файл: cloud.mail.ru/public/5mzV/3eenc2eGE
13Мбайт к статье не приаттачить, файл слишком большой.