Как разбить текст на абзацы с помощью Python

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

Общий подход

Нам предстоит превратить текст в нечто понятное машине, т.е. в векторы. В практике обработки естественного языка векторное представление текста называется эмбеддингом (встраиванием). Есть два способа его проведения.

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

Второй вариант реализуется быстрее и позволяет достичь приемлемых результатов. Существует несколько предварительно обученных моделей эмбеддинга. Выберем одну из представленных тут.

Остановим свой выбор на модели эмбеддинга с наилучшим показателем общей производительности  —  all-mpnet-base-v2.

Начнем с того, что загрузим все необходимые пакеты, необходимые для работы.

# Сначала импортируем самые важные библиотеки
import pandas as pd
import numpy as np
# Библиотека для импорта предварительно обученной модели по эмбеддингу предложений
from sentence_transformers import SentenceTransformer
# Вычисление сходств между предложениями
from sklearn.metrics.pairwise import cosine_similarity
# Библиотека для визуализации
import seaborn as sns
import matplotlib.pyplot as plt
# Пакет для поиска локальных минимумов
from scipy.signal import argrelextrema

Шаг 1. Эмбеддинг

Предварительно обученная модель значительно облегчит задачу. Предположим, что у нас есть случайный текст.

Let me tell you a little story. When I was a little kid I really liked to play football. I wanted to be like Messi and play at Camp Nou. However, I was really bad at it and now I’m not training at Camp Nou. I’m writing a medium article on chunking text.

Позвольте мне рассказать небольшую историю. Когда я был маленьким, я очень любил играть в футбол. Я хотел быть похожим на Месси и играть на стадионе “Камп Ноу”. Однако у меня очень плохо получалось, и сейчас я не тренируюсь на “Камп Ноу”. Я пишу статью на Medium о разбивке текста на части.

Нам нужно превратить этот текст в векторное представление:

# Загрузка модели (не пытайтесь делать это в домашних условиях, процесс может занять некоторое время из-за размера в 420 мб)
model = SentenceTransformer('all-mpnet-base-v2')
# Разделение текста на предложения
sentences = text.split('. ')
# Эмбеддинг предложений
embeddings = model.encode(sentences)
print(embeddings.shape)
>> (5, 768)

Свершилось чудо: 5 предложений обычного текста превратились в 768-мерную среду! Что это дает? Теперь предложения стали векторами и можно проверить, насколько близки (то есть похожи) эти векторы в 768 измерениях. Для этого используем простейший способ  —  скалярное произведение.

Шаг 2. Скалярное произведение/мера подобия

Выражаясь простым языком, скалярное произведение показывает, насколько один вектор совпадает с другим. Если два вектора (предложения) направлены в одну сторону, считается, что они похожи. Проверим это на практике.

# Выбор строки (предложения) и всех столбцов
first_sentence = embeddings[0,:] 
second_sentence = embeddings[1,:]
third_sentence = embeddings[2,:] 
fourth_sentence = embeddings[3,:] 
fifth_sentence = embeddings[4,:] 

# Насколько похожи второе и третье предложения
print(f'Dot product of second and third sentence is {second_sentence @ third_sentence}')
print(f'Dot product of third and fourth sentence is {third_sentence @ fourth_sentence}')
print(f'Dot product of fourth and fith sentence is {fourth_sentence @ fifth_sentence}')

>> Скалярное произведение второго и третьего предложений составляет 0.4578239619731903
>> Скалярное произведение третьего и четвертого предложений составляет 0.4315364956855774
>> Скалярное произведение четвертого и пятого предложений составляет -0.07396048307418823

# Напоминание о тексте
2. Когда я был маленьким, я очень любил играть в футбол.
3. Я хотел быть похожим на Месси и играть на стадионе “Камп Ноу”.
4. Однако у меня очень плохо получалось, и сейчас я не тренируюсь на “Камп Ноу”.
5. Я пишу статью на Medium о разбивке текста на части.

Это впечатляющий результат, учитывая, что было использовано всего несколько строк кода. Как видите, 5-е предложение идет в другом направлении, чем 4-е (-0,07). Модель успешно отличила смысл предложения об эмбеддинге от предложений о футболе.

Однако есть гораздо лучший способ увидеть сходство предложений сразу  —  создать матрицу подобия. В Sklearn есть удобная функция для вычисления меры подобия  —  cosine_similarity. Почему не стоит использовать скалярное произведение? Хороший вопрос. Если векторы имеют одинаковую длину (величину), нет никакой разницы между скалярным произведением и мерой подобия. Я показал скалярное произведение только для того, чтобы объяснить, как это работает “под капотом”.

# Создание матрицы подобия
similarities = cosine_similarity(embeddings)
# Построение графика, отражающего полученный результат
sns.heatmap(similarities,annot=True).set_title('Cosine similarities matrix');

Здесь можно заметить интересную закономерность. Красный квадрат в середине  —  это часть текста, где сообщается о футболе. А как это будет выглядеть, если дважды поменять тему сообщения? Создадим новый текст и построим граф результатов.

Let me tell you a little story. When I was a little kid I really liked to play football. I wanted to be like Messi and play at Camp Nou. However, I was really bad at it and now I’m not training at Camp Nou. I’m writing a medium article on embeddings. In this article, I want to show how are we going to split a text into parts. We first embed sentences. Then we compute sentence similarities. After that, we detect the split point in the text. After finishing this process we will go play chess with friends.

Позвольте мне рассказать небольшую историю. Когда я был маленьким, я очень любил играть в футбол. Я хотел быть похожим на Месси и играть на стадионе “Камп Ноу”. Однако у меня очень плохо получалось, и сейчас я не тренируюсь на “Камп Ноу”. Я пишу статью на Medium о разбивке текста на части. В этой статье я хочу показать, как разделить текст на части. Сначала выполняется векторное представление предложений. Затем вычисляется мера подобия предложений. Потом определяются точки разделения в тексте. По завершении этого процесса мы пойдем играть в шахматы с друзьями.

Наверняка вы уже уловили закономерность, заметив две разные темы и их точки разделения.

Шаг 3. Определение точек разделения

То, что легко увидеть человеку, не всегда легко заметить компьютеру. Поэтому нужно создать некий паттерн, который поможет ему различать эти точки разделения.

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

Это более простое для понимания представление потока данных текста. И снова видим, что 4-е предложение с индексом 3 является точкой разделения. Теперь переходим к заключительной части.

6. Найдем относительные векторные минимумы.

“Графически относительные экстремумы  —  это вершины и впадины графа функции, причем вершины  —  это точки относительных максимумов, а впадины  —  точки относительных минимумов. Сочетание относительных максимумов и минимумов называется относительным экстремумом”. Более подробная информация  —  по ссылке.

Вот код для выполнения всех шагов:

def rev_sigmoid(x:float)->float:
    return (1 / (1 + math.exp(0.5*x)))
    
def activate_similarities(similarities:np.array, p_size=10)->np.array:
        """ Функция возвращает список взвешенных сумм активированных сходств предложений 

Аргументы:
            similarities (numpy array): это должна быть квадратная матрица, где каждое предложение соответствует другому согласно мере подобия. 
            p_size (int): количество предложений используется для расчета взвешенной суммы

Возврат:
            list: список взвешенных сумм
        """
        # Чтобы создать веса для сигмоидной функции, сначала нужно создать пространство. P_size определяет количество используемых предложений и размер вектора весов.
        x = np.linspace(-10,10,p_size)
        # Затем необходимо применить функцию активации к созданному пространству
        y = np.vectorize(rev_sigmoid) 
        # Поскольку мы применяем активацию только к количеству предложений p_size, мы должны добавить нули, чтобы пренебречь эффектом каждого дополнительного предложения, а для соответствия длине вектора мы умножим
        activation_weights = np.pad(y(x),(0,similarities.shape[0]-p_size))
        ### 1. Возьмите каждую диагональ справа от главной диагонали
        diagonals = [similarities.diagonal(each) for each in range(0,similarities.shape[0])]
        ### 2. Заполните каждую диагональ нулями в конце. Поскольку каждая диагональ имеет разную длину, мы должны проводить заполнение нулями в конце.
        diagonals = [np.pad(each, (0,similarities.shape[0]-len(each))) for each in diagonals]
        ### 3. Сложите эти диагонали в новую матрицу
        diagonals = np.stack(diagonals)
        ### 4. Примените веса активации к каждой строке. Умножьте сходства на активацию.
        diagonals = diagonals * activation_weights.reshape(-1,1)
        ### 5. Рассчитайте взвешенную сумму активированных сходств
        activated_similarities = np.sum(diagonals, axis=0)
        return activated_similarities
  
# Применим нашу функцию. Длинные предложения: рекомендую использовать 10 или более предложений
activated_similarities = activate_similarities(similarities, p_size=5)

# Создадим пустую фигуру для графика
fig, ax = plt.subplots()
### 6. Найдите относительные минимумы вектора. Все локальные минимумы следует сохранить в переменной с помощью функции argrelextrema
minmimas = argrelextrema(activated_similarities, np.less, order=2) # Параметр order управляет частотой разделений. Я бы не рекомендовал изменять его.
# Постройте график потока текста с активированными сходствами
sns.lineplot(y=activated_similarities, x=range(len(activated_similarities)), ax=ax).set_title('Relative minimas');
# Теперь проведем вертикальные линии, чтобы увидеть, где было создано разделение
plt.vlines(x=minmimas, ymin=min(activated_similarities), ymax=max(activated_similarities), colors='purple', ls='--', lw=1, label='vline_multiple - full height')

Алгоритм в действии

Теперь от небольшого текста перейдем к выполнению практической задачи  —  разбивке на части транскриптов длинных видео и подкастов. Во время последней презентации проекта один из наших преподавателей спросил, можем ли мы сделать реферирование для этого видео

Работа с длинными текстами

Я еще не упомянул одну важную деталь: при работе с длинными текстами вы столкнетесь с тем, что очень короткие предложения создают неожиданные точки смены. Чем короче предложение, тем меньше у него возможностей подобия. Чем короче текст, тем меньше информации он содержит -> меньше возможных вариантов подобия может быть найдено.

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

# Определение длины каждого предложения
sentece_length = [len(each) for each in sentences]
# Определение самого длинного выброса
long = np.mean(sentece_length) + np.std(sentece_length) *2
# Определение самого короткого выброса
short = np.mean(sentece_length) - np.std(sentece_length) *2
# Сокращение длинных предложений
text = ''
for each in sentences:
if len(each) > long:
# Заменим все запятые точками
comma_splitted = each.replace(',', '.')
else:
text+= f'{each}. '
sentences = text.split('. ')
# Теперь объединим короткие.
text = ''
for each in sentences:
if len(each) < short:
text+= f'{each} '
else:
text+= f'{each}. '

Теперь выполним следующие шаги.

1. Проведем эмбеддинг в отношении предложений и рассчитаем меру подобия.

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

2. Определим точки разделения.

Горизонтальная линия (ось x) — порядковые номера предложений. Вертикальная линия (ось y) — взвешенная сумма сходств

Увеличим масштаб некоторых частей, чтобы можно было реально увидеть картину.

Каждая вертикальная линия — это точка разделения. Горизонтальные линии — порядковые номера предложений. Вертикальная линия — взвешенная сумма подобий

Шаг 4. Создание текста, разбитого на абзацы

После получения точек разделения осталось самое простое, но самое важное  —  внедрить их в текст.

# Получение порядкового номера предложений, которые находятся в точках разделения
split_points = [each for each in minmimas[0]]
# Создание пустой строки
text = ''
for num,each in enumerate(sentences):
# Проверьте, является ли предложение минимумом (точкой разделения)
if num in split_points:
# Если да, то добавьте точку в конец предложения и абзац перед ним.
text+=f'\n\n {each}. '
else:
# Если это обычное предложение, просто поставьте точку в конце и продолжайте добавлять предложения.
text+=f'{each}. '

Теперь у нас есть разбитый на абзацы текст, состоящий из 1000 предложений.

Окончательный результат

Посмотрим на фрагменты сделанных разбиений, чтобы проверить, имеет ли это смысл.

В 1625 году итальянский дворянин по имени Пьетро де ла Валет отправился в путешествие по Ближнему Востоку … В то время путешествие по этому региону было очень опасным.

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

— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — 
— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — —

Они ушли в темноте ночи и бежали в безопасное место. Через пустыню …. Что означают символы на этих разбитых кусках глины. И если здесь когда-то стоял такой великий город, что с ним могло случиться.

Меня зовут Пол Купер, и вы слушаете подкаст “Падение цивилизации”. В каждой серии я рассматриваю одну из цивилизаций прошлого, которая поднялась к славе, а затем рухнула, обратившись в прах истории. Я хотел бы знать, что у них было общего. Что привело к их падению.

— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — 
— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — —

Каждый абзац отделяется новой строкой, каждое новое место в тексте отделяется знаком “ — — “, а содержание абзацев сокращается знаком “…”.

Как видите, разделение первых двух абзацев приемлемо, хотя они раскрывают одну и ту же мысль. Разделение вторых двух абзацев в месте, где автор представляется, совершенно точное. Так что в целом можно сказать, что работа проделана довольно хорошо.

Хотя она не всегда идеальна: иногда пропускается точка разделения на одно или два предложения, как здесь:

Затем я проснулся, как обескровленный человек, который бродит один в пустыне. Еще раз спасибо, что слушаете подкаст “Падение цивилизации”.

Я хотел бы поблагодарить моих актеров озвучки этой серии. Ре Бригнелла, Джейка Барретта Миллса, Шема Джейкобса, Ника Брэдли и Эмили Джонсон. Я люблю читать ваши комментарии в Twitter, поэтому прощу вас поделиться своим мнением. Вы можете подписаться на меня (Paul M).

Этот подкаст удается поддерживать только благодаря помощи наших щедрых подписчиков на Patreon. Вы помогаете мне работать, покрывать расходы и поддерживать подкаст без рекламы. … Если вам понравился этот подкаст, перейдите на Patreon.com.

Здесь, как видите, в первом абзаце первое предложение оказалось по ошибке, однако следующие два абзаца отлично разделены, поскольку второй  —  это благодарность актерам озвучки, а третий  —  благодарность сторонникам Patreon.

Код готового решения приводится на ноубуке Jupyter.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи N Polovinkin: How to chunk text into paragraphs using python

Предыдущая статьяСравниваем REST, GraphQL и gRPC
Следующая статьяСоздание готового к производству приложения React с помощью Next.js и Dokku