Как стать автором
Поиск
Написать публикацию
Обновить

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

Время на прочтение8 мин
Количество просмотров9K

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

Теги:
Хабы:
Всего голосов 12: ↑12 и ↓0+12
Комментарии31

Публикации

Ближайшие события