Pull to refresh

Восстанавливаем предложения из эмбеддингов LaBSE

Reading time8 min
Views6.7K

На прошлой неделе меня дважды спрашивали, как восстановить текст предложения из его LaBSE эмбеддинга. Я дважды отвечал, что никак. Но на самом деле, конечно, можно обучить декодер генерировать текст по его эмбеддингу. Зачем? Например, чтобы:

  • переводить со 100 разных языков на русский;

  • суммаризовать много похожих предложений одним;

  • реалистично заменять фразы в составе предложений;

  • менять смысл или стиль предложений.

Модель для восстановления предложений из эмбеддингов опубликована как cointegrated/rut5-base-labse-decoder, а подробности – под катом.

Обзор предлагаемой системы. Рисунок автора.
Обзор предлагаемой системы. Рисунок автора.

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. В любом случае, лайкайте понравившиеся вам модели, и пишите в комментарии об интересных кейсах их применения. Подписывайтесь на мой канал, пользуйтесь солнцезащитным кремом и боритесь за мир!

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 12: ↑12 and ↓0+12
Comments31

Articles