На прошлой неделе меня дважды спрашивали, как восстановить текст предложения из его LaBSE эмбеддинга. Я дважды отвечал, что никак. Но на самом деле, конечно, можно обучить декодер генерировать текст по его эмбеддингу. Зачем? Например, чтобы:
переводить со 100 разных языков на русский;
суммаризовать много похожих предложений одним;
реалистично заменять фразы в составе предложений;
менять смысл или стиль предложений.
Модель для восстановления предложений из эмбеддингов опубликована как cointegrated/rut5-base-labse-decoder, а подробности – под катом.
![Обзор предлагаемой системы. Рисунок автора. Обзор предлагаемой системы. Рисунок автора.](https://habrastorage.org/getpro/habr/upload_files/f9f/8ad/cc8/f9f8adcc86da8255d93cb807fa3d02d1.png)
LaBSE и другие энкодеры предложений
Энкодер предложений (sentence encoder) – это модель (обычно нейросеть), которая получает на вход текст предложения, а на выходе отдаёт многомерный вектор (например, 768-мерный), примерно описывающий смысл этого предложения. То есть такой, что у предложений, похожих друг на друга по смыслу, векторы похожи друг на друга геометрически. Энкодеры предложений можно использовать для классификации текстов и массы других полезных задач; подробнее читайте в моих постах про маленький BERT и про рейтинг энкодеров предложений.
LaBSE (language-agnostic BERT sentence embeddings) – это модель, предложенная в статье 2020 года от исследователей из Google. По архитектуре это BERT, а обучался он на выборке текстов на 100+ языков в многозадачном режиме. Основная задача – сближать друг с другом эмбеддинги предложений с одинаковым смыслом на разных языках, и с этой задачей модель справляется очень хорошо. Благодаря этой способности можно, например, обучать модель классифицировать английские тексты, а потом применять на русских, или находить в большом корпусе пары предложений на разных языках, являющиеся переводами друг друга.
А вот чего LaBSE не умеет делать совсем, так это генерировать тексты. Единожды превратив текст в вектор, мы уже не сможем получить из него обратно текст. Для этого нужна отдельная модель. И то не факт, что она с этим справится: как говаривал профессор Raymond J. Mooney, You can’t cram the meaning of a single $&!#* sentence into a single $!#&* vector! Но мы всё-таки попробуем.
Обучение декодера
Декодер в NLP – это как раз модель, которая из векторов генерирует тексты, т.е. решает задачу, обратную задаче энкодера. Для русского языка есть несколько декодеров, из которых я выбрал некогда обученную мною модель T5, т.к. это требовало минимальных изменений в коде. Как альтернатива, я мог бы попробовать дообучить русскую GPT; если попробуете – расскажите, пожалуйста!
Кодирование текстов в векторы происходит абсолютно стандартно: извлекаем эмбеддинг CLS-токена из LaBSE и нормализуем его.
bert_name = 'sentence-transformers/LaBSE'
enc_tokenizer = AutoTokenizer.from_pretrained(bert_name)
encoder = AutoModel.from_pretrained(bert_name)
def encode(texts, do_norm=True):
encoded_input = enc_tokenizer(texts, padding=True, truncation=True, max_length=512, return_tensors='pt')
with torch.no_grad():
model_output = encoder(**encoded_input.to(encoder.device))
embeddings = model_output.pooler_output
if do_norm:
embeddings = torch.nn.functional.normalize(embeddings)
return embeddings
Декодирование выглядит так же просто. Это стандартная генерация текстов с помощью T5 (или любого другого seq2seq трансформера), только на вход мы подаём эмбеддинги из LaBSE, которые "прикидываются" эмбеддингами от энкодера T5 (благо размерность и у тех, и у других оказалась 768, так что мне даже не пришлось модифицировать слои cross-attention в T5).
t5_name = 'cointegrated/rut5-base-labse-decoder'
dec_tokenizer = AutoTokenizer.from_pretrained(t5_name)
decoder = AutoModelForSeq2SeqLM.from_pretrained(t5_name)
def decode(embeddings, max_length=256, repetition_penalty=3.0, num_beams=3, **kwargs):
out = decoder.generate(
encoder_outputs=BaseModelOutput(last_hidden_state=embeddings.unsqueeze(1)),
max_length=max_length,
num_beams=num_beams,
repetition_penalty=repetition_penalty,
)
return [dec_tokenizer.decode(tokens, skip_special_tokens=True) for tokens in out]
Естественно, без файнтюнинга T5 предложения, сгенерированные таким образом, будут бессмысленными, ведь T5 обучался смотреть на эмбеддинги из другого пространства, причём не на один, а на целую последовательность эмбеддингов (для каждого токена).
Для дообучения я взял 2 миллиона коротких текстов: opus100, Leipzig collection, и комментарии из Одноклассников. В качестве аугментации добавил ещё 400К отдельно взятых слов. И на всём этом стандартным образом (teacher-forced cross-entropy) обучил T5 генерировать из эмбеддинга исходный текст. Обучал с батчом 8 в течение примерно миллиона шагов; это заняло 2.5 дня на Google Colab. Блокнот – туть.
После дообучения T5 справляется с новой задачей вполне сносно. Можно, например, закодировать такие тексты:
embeddings = encode([
"4 декабря 2000 года",
"Давно такого не читала, очень хорошо пишешь!",
"Я тогда не понимала, что происходит, не понимаю и сейчас.",
"London is the capital of Great Britain.",
])
print(embeddings.shape)
# torch.Size([4, 768])
После декодирования тексты меняются, но смысл их модель примерно воспроизводит:
for text in decode(embeddings):
print(text)
# После 4 декабря 2000 года
# Не так давно, это многое читала!
# Я не понимала того, что происходит сейчас, тогда же.
# Британская столица Англии.
Примеры применения
Окей, нейросеть обучена, и что теперь? В общем-то ничего, ведь этот эксперимент я проделал в первую очередь просто для развлечения. Но если хочется развлекаться дальше, в этом блокноте собрано несколько примеров применения такого декодера. Самый очевидный – это перефразирование, но возможны и более креативные применения.
Перевод
LaBSE умеет "переводить" тексты с разных языков в общее векторное пространство, а наш декодер умеет переводить из этого пространства на русский. Значит, вместе эта парочка моделей можем переводить на русский с любого из 109 языков, известных LaBSE!
Ниже пример десятка языков. Модель не совсем понимает разницу между словами "господа" и "господи", а в остальном вполне справляется с задачей.
Исходный текст | Перевод |
Господа, я не ел 6 дней! | Господи, я не ел 6 дней! |
Панове, я не їв 6 днів! | Господи, я не ел 6 дней! |
Gentlemen, I haven't eaten for 6 days! | Господи, я 6 дней не кормила! |
Messieurs, je n'ai pas mangé depuis 6 jours! | Господи, я не съела 6 дней! |
Meine Herren, ich habe seit sechs Tagen nichts gegessen! | Господи, у меня шесть дней не ели ничего! |
Худовандо, ман 6 рӯз боз чизе нахӯрдаам! | Боже, у меня еще 6 дней не было! |
Tanrım, 6 gündür yemek yemedim! | Господи, я не ел 6 дней! |
אלוהים, לא אכלתי 6 ימים | Боже, у меня не было 6 дней! |
主啊,我已经6天没吃东西了 | Господи, я уже 6 дней не ела. |
हे प्रभु, मैंने 6 दिनों से कुछ नहीं खाया है! | Господи, я не съела ничего из этого дня! |
Суммаризация
Иногда бывает нужно по множеству предложений понять, в чём их основная общая идея. Например, не читать 50 отзывов на товар, а прочитать один "усреднённый отзыв". Наша модель вообще-то совсем не предназначалась для суммаризации – но вдруг у неё получится?
Для примера я взял данные отзывов на товары из хакатона М.Видео. Собираю в один текст все отзывы на товар, разбиваю предложения, вычисляю эмбеддинг каждого предложения, усредняю их все в один вектор, нормирую его, и декодирую этот вектор своей моделью. Вот примеры того, что получилось:
# соковыжималка Braun J500
# примеры отдельных предложений
Дизайн строгий, но это понятно - фирма-то немецая.
Из 5 кг яблок выходит меньше литра сока, а пены больше чем сам сок!!!
Жмых сухой, сеточка мелкая и не пропускает куски.
...
# декодированное усреднённое предложение
Устройство очень хорошее, потому что выбрасывать яблоки неплохо.
# Планшет Lenovo Tab 3 Plus
# примеры отдельных предложений
Минусов никаких не заметно пока что.
Экран, Lte, gps, ГЛОНАСС, 2 сим, быстрый, шустрый, размер, тонкий, сборка.
Замечательный планшет!
...
# декодированное усреднённое предложение
Всё очень красиво, у нас на экране есть сенсорная картинка.
# Планшет Prestigio MultiPad
# примеры отдельных предложений
Не очень понравилось что динамик только 1, стерео нет(
На расстоянии пары сантиметров видны пиксели, батарейка заряжается конечно долго.
Зарядное устройство стандартвое без изъян.
...
# декодированное усреднённое предложение
Встроенный экран очень хороший, даже несмотря на то, что у меня есть сенсорная камера.
Как видим, усреднённые предложения не очень информативные, но в целом неплохо отражают настроение отзывов и некоторые аспекты описываемых товаров.
Сложение и вычитание предложений
Мы помним и любим word2vec за поддержку прикольных алгебраических операций над векторами слов, в духе "king + woman - man = queen". Оказывается, LaBSE так тоже умеет!
embeddings = encode(['король', 'женщина', 'мужчина'])
print(decode(embeddings[[0]] + embeddings[[1]] - embeddings[[2]]))
# ['королева']
embeddings = encode(['Лондон', 'Франция', 'Англия'])
print(decode(embeddings[[0]] + embeddings[[1]] - embeddings[[2]]))
# ['Париж']
Более того, LaBSE может складывать и вычитать не только слова, но и небольшие фразы. С длинными текстами у него получается хуже, но зато декодер иногда прикольно додумывает детали:
embeddings = encode([
'Это произошло во время правления Петра Первого.',
'Иван Грозный',
'Пётр Первый',
])
print(decode(embeddings[[0]] + embeddings[[1]] - embeddings[[2]]))
# ['Это произошло в режиме правления Ивана Грозного.']
embeddings = encode([
'Кошка обучает своих котят охотиться за мышами.',
'белый медведь',
'кот',
])
print(decode(embeddings[[0]] + embeddings[[1]] - embeddings[[2]]))
# ['Белый Медведь обучает медведям следить за охотой на оленей.']
embeddings = encode([
'Я не хочу делать прививку, потому что не доверяю врачам.',
'Я верю в народную медицину.',
'Я не доверяю никаким врачам.',
])
print(decode(embeddings[[0]] + embeddings[[1]] - embeddings[[2]]))
# ['Я верю в вакцинацию, потому что я хочу лечиться.']
Перенос стиля текстов
Как мы видели в примере с отзывами, векторы LaBSE сохраняют информацию о стиле и настрое текстов. Получается, если мы возьмём несколько пар текстов с похожим смыслом, но в разном стиле, то средняя разница между их векторами может отражать разницу между стилями. Может быть, её можно использовать для изменения смысла других текстов, как в статьях про TextSETTR или DIFFUR?
Для примера возьмём отсюда примеры сдержанных и эмоциональных текстов. Примеры на английском, но LaBSE на это плевать.
texts_reserved = [
"That is a very pretty painting.",
"I’m excited to see the show.",
"I’m surprised they rescheduled the meeting.",
"This specimen is an example of the baroque style.",
"After the performance, we ate a meal.",
]
texts_emotive = [
"OMG, that’s such a beautiful painting!",
"I’m sooo excited to see the show, it’s going to be stellar!!",
"I absolutely can not believe that they rescheduled the meeting!",
"This wonderful specimen is a truly spectacular example of the baroque style.",
"After the superb performance, we ate a delicious meal.",
]
delta = encode(texts_emotive).mean(0) - encode(texts_reserved).mean(0)
print(decode(encode('Этот фильм произвёл на меня хорошее впечатление.') + delta * 1))
# ['Этот фильм мне очень понравился хорошим впечатлением!']
print(decode(encode('Внешний долг США достиг рекордной величины.') + delta * 1.5))
# ['Увеличенная США долговая задолженность в целом достигла рекордных размеров!']
Видим, что перефразированные тексты действительно стали более эмоциональными и экспрессивными.
Другой пример – превращение формальных текстов в неформальные:
texts_formal = [
"This was a remarkably thought-provoking read.",
"It is certainly amongst my favorites.",
"We humbly request your presence at our gala on the 12th.",
]
texts_informal = [
"reading this rly makes u think",
"Its def one of my favs",
"come swing by our bbq next week if ya can make it",
]
delta = encode(texts_formal).mean(0) - encode(texts_informal).mean(0)
print(decode(encode('Убедительно просим вас покинуть помещение учреждения.') - delta * 0.5))
# ['пожалуйста, уходите из помещения']
print(decode(encode('Был рад нашей с Вами встрече!') - delta * 0.5))
# ['Хорошо встретился с тобой!']
Как видим, оно тоже приблизительно работает.
Ещё я пробовал применить этот подход для детоксификации текстов, но оказалось, что LaBSE понимает смысл грубых текстов на русском языке не очень хорошо – видимо, в его обучающей выборке таких было немного.
Заключение
Энкодерами предложений в последнее время занимаются довольно много, и генераторами текста (такими, как GPT) – тоже. Но к таким декодерам, которые бы инвертировали работу энкодера, интерес в последнее время угас (хотя когда-то автоэнкодеры были модной штучкой). Возможно, зря: как видим, для инвертированного энкодера в 2022 вполне можно найти любопытные применения.
Мой декодер (cointegrated/rut5-base-labse-decoder) выложен на HF; вы можете использовать его в паре с облегчённым русско-английским энкодером cointegrated/LaBSE-en-ru или с полноценной моделью на 100+ языков sentence-transformers/LaBSE. В любом случае, лайкайте понравившиеся вам модели, и пишите в комментарии об интересных кейсах их применения. Подписывайтесь на мой канал, пользуйтесь солнцезащитным кремом и боритесь за мир!