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

На практике такая задача интересна, прежде всего, в области финансов:

  • Выявление недобросовестных контрагентов. Информационный фон может использоваться как фактор при принятии решения о приостановке платежей недобросовестных контрагентов;

  • Кредитный риск. Предположим, что мы анализируем кредитный риск своих контрагентов и хотим знать о наступлении неблагоприятного события, которое сможет повлиять на платежеспособность по обязательствам (тогда мы можем приостановить рассмотрение заявки на выдачу кредита или отменить очередной намеченный транш и т.д.);

  • Ценовой индикатор. Анализ мнений аналитиков по поводу роста цен на акции/ товары, что в дальнейшем можно использовать для понимания трендов, принятия решения о покупке/продаже активов.

Рассмотрим пример новости, подлежащей анализу:
Компания X расторгла контракт с Y, выбрав Z в качестве эксклюзивного поставщика сепулек для своих сепулькаров. Лишившись такого крупного заказчика, Y, и раньше испытывавшая серьезные проблемы из-за повышения налогов, может оказаться на грани банкротства, сообщают аналитики сепулька-информ.

Для Z это позитивная новость, для Y тональность новости будет негативной. Относительно сепулька-информ ничего не происходит и, наконец, X не выиграл и не проиграл от этой сделки, по крайне мере текст на это явно не указывает – оценка нейтральная. Нет никакого смысла оценивать тональность такой новости целиком, тональность текста будет меняться относительно каждой компании. 

Эксперименты

Изначально мы пытались решить задачу с помощью готовых датасетов. Кроме русскоязычного датасета Sentiment Analysis in Russian найти размеченные новости (твиты и отзывы не подходят для такой задачи) не удалось. С англоязычными источниками возникла аналогичная проблема: в открытом доступе имеется только набор заголовков новостей SENTIMENT ANALYSIS FOR FINANCIAL NEWS, но такие тексты слишком короткие для применения обученной на них модели.

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

1. Дообучение классификатора на эмбеддингах BERT.

Первой была предпринята попытка обойтись без своего датасета. Мы брали сантимент всей новости, автоматически выделяли ключевую (или главную) сущность в тексте и считали, что оценка относится к этой сущности.

Базовый подход: брали от статьи 512 токенов, связанных с интересующей нас сущностью и пропускали через BERT, на полученных эмбеддингах обучали классификатор. Подробнее с этим подходом можно ознакомиться тут.

2. Дообучение LSTM на эмбеддингах BERT.

Каждую статью мы разбивали на последовательности из 512 токенов, которые пропускали через BERT. Затем от полученных эмбеддингов брали только первые, соответствующие метке, классификации и собирали их обратно в «единую статью». Чтобы все статьи получались одинакового размера, применяли padding из пустых векторов и, наконец, подавали на вход LSTM. Если обычный BERT может принимать на вход не более 512 токенов, то такой трюк с разбиением статьи на последовательности позволяет ничего не выбрасывать из новости. Такой подход оказался лучше первого.

Хотя обе модели показывали приемлемые результаты на известных датасетах, как только мы начинали брать длинные новости и заменять общие новости финансовыми, все сразу ломалось. Особенные трудности возникали, когда текст был посвящён одновременно нескольким сущностям.

Стало очевидно, что главным недостатком открытых датасетов по sentiment analysis является симметричность: то есть оценка всей новости без выделения сущностей внутри неё.

Главным выводом для нас стало, что несимметричность не просто важна, а является ключевой частью решения задачи. Следовательно, модель должна уметь выделять сущности и дообучаться этому.

3. Попытка применения zero-short classification.

В процессе исследования мы наткнулись на такую тему, как Natural Language Inference (NLI). Тем более, что как раз появилась первая открытая русскоязычная модель. Идея была проста: вдруг ничего не придется дообучать и модель сама поймет, чего мы от неё хотим.

В качестве лейблов мы передавали модели варианты типа «позитивное событие для сущности», «нейтральное событие для сущности», «негативное событие для сущности». Результат модели оказался неудовлетворительным. Несмотря на неудачную попытку, направление NLI кажется очень перспективным. В будущем мы сделаем свою собственную модель zero-short classification, также обученную на финансовых новостях.

4. Дообучение BERT на новых данных.

Для обучения BERT мы подготовили свою асимметричную разметку, придумали как выделять интересующие нас сущности и ужимать текст до 512 токенов. Добавили к BERT слой классификации, при этом обучая не только его, а весь BERT.

Важно понимать, что разметка данных достаточно субъективный процесс. Оценки могут зависеть от смысла задачи, а разметчики должны иметь необходимые знания чтобы правильно сделать разметку. Изначально мы отобрали 2,5 тысячи новостей финансового характера и попытались выполнить разметку с привлечением стажеров, но после валидации этой разметки пришлось её выкинуть. У наших случайных разметчиков не было достаточного экономического понимания для правильного восприятия ситуации в новости. Так, например, большинство из них давали нейтральную оценку введению временной администрации в банке, не понимая, что это действие сопряжено с серьёзными нарушениями банка или процедурой банкротства.

В рамках MVP мы разметили 480 новостей силами одного исследователя. Этого набора данных оказалось достаточно для демонстрации работоспособности модели.

Как объяснить BERT асимметричность и указать на ключевые сущности?

Унификация сущности

Мы размечали одну и ту же новость относительно каждой встреченной сущности в ней. То есть если в тексте упоминаются три компании, то мы получим три примера текста для оценки, где ключевая сущность, к которой относится оценка, выделена специальным образом. Мы можем унифицировать ключевую сущность заменой специального вида или добавить имя сущности в начало текста, например, ‘ООО Ромашка | ’ + текст новости.
На практике хорошо себя показала простая замена ключевой сущности на специальный символ «X».

В качестве разметки использовалась семибалльная шкала тональности: от «-3» до «+3», где «-3» – это крайне негативное событие, «+3» – крайне позитивное, «0» – нейтральное и т.д.  

Рассмотрим для наглядности упрощенный пример разметки.
Исходный текст:
«ЦБ отозвал лицензию у банка БВТ, сообщает Финам»

Разметка:
«X отозвал лицензию у банка БВТ, сообщает Финам» – тональность «0»
«ЦБ отозвал лицензию у X, сообщает Финам» – тональность «-3»
«ЦБ отозвал лицензию у банка БВТ, сообщает X» – тональность «0» 

Препроцессинг текста новости

Поскольку модели типа BERT имеют ограничения, нам надо сокращать размер текста, при этом не потеряв ничего полезного. В этой части задачи имеется большой размах для творчества. Приведем несколько возможных подходов:

  • При помощи синтаксического дерева:

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

    • начиная с найденного узла, содержащего сущность, запускаем на дереве «алгоритм закраски»: сначала помечаем узлы дерева на m уровней выше найденного, затем узлы на n уровней ниже уже помеченных (m и n – настраиваемые параметры);

    • все помеченные узлы объединяем в текст, если между узлами имеются пропуски, ставим троеточие.

  • Более простой вариант: брать все предложения, в которых упоминается интересующая нас сущность. В рамках этого подхода мы обучали рассматриваемую далее модель.

В русскоязычных новостях мы активно использовали набор библиотек Natasha в качестве вспомогательного инструмента:

  • для извлечения именованных сущностей и их нормализации;

  • разбиения статей на предложения;

  • извлечение информации из синтаксического дерева для автоматического выделения ключевых именованных сущностей и сокращенного варианта текста.

Стоит отметить, что в тексте часто одна и та же сущность может встречаться в виде сокращений, псевдонимов или аббревиатур. Это известная проблема разного написания названий организаций в зависимости от издательства, настроения автора, погоды, дня недели и т.д.  Кажется, авторы не подозревают, что нам хочется обучать нейронки на их новостях.
Для контрагента, записанного в базе как АО «Тинькофф банк», получится найти в статьях множество вариантов написания: «Тинькофф», «банк Тинькофф», «Тинькофф-банк», Tinkoff, TCSG, Банк Олега Тинькова и т.д.

Для приведения сущностей к единой форме мы используем словарь возможных написаний для каждой сущности и нечеткий поиск с помощью библиотеки FuzzyWuzzy. Это также важно для задачи матчинга новости и базы контрагентов, по которым мы хотим проводить анализ.

Workflow подготовки данных и обучения модели:

Для каждой новости:

  1. Выделяем сущности и делаем оценку тональности новости относительно каждой;

  2. Приводим разные варианты употребления одной сущности к единому виду;

  3. Делаем замену ключевой сущности на X;

  4. Ужимаем текст исходной новости до 512 токенов относительно X.

В итоге мы получаем N* примеров и обучаем BERT с дополнительным выходным слоем классификации.

Workflow подготовки данных и обучения модели
Workflow подготовки данных и обучения модели

Результаты

На нашей разметке на тестовой выборке модель продемонстрировала следующие результаты:

  • Accuracy: 0.7301

  • F1 score: 0.5210

    Матрица ошибок
    Матрица ошибок

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

На данный момент модель склонна путаться, когда в одном тексте попадаются несколько сущностей с противоположным смыслом:
Банк «А» получил прибыль, а банк «Б» убыток.

Также модель ошибается, когда встречается двойной смысл. Например, негативное действие с нейтральными или позитивными последствиями:
Создатели российского бренда уличной одежды Don't Care сожгли последний билет на концерт американской рок-группы Nirvana и выпустили одежду с принтом из его пепла. Об этом сообщается в пресс-релизе, поступившем в редакцию «Ленты.ру».

Для «Ленты» будет нейтральная тональность (оценка «0»). Для Don't Care получится негативная (оценка «-1») – такой специфичный пример модель обработала неправильно, скорее всего ориентируясь на «сожгла последний». Уверенность модели для «Ленты» в ответе «0» была с вероятностью 99%, а для Don't Care модель начала сомневаться: вероятность для класса «-1» равна 0.84, а для класса «1» – 0.126.

Workflow использования модели:

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

Берем новость и извлекаем ключевые сущности.
Для каждой ключевой сущности:

  1. Приводим разные варианты употребления одной сущности к единому виду;

  2. Делаем замену ключевой сущности на X;

  3. Ужимаем текст примера до 512 токенов относительно X;

  4. Подаем в обученную модель;

  5. Получаем вероятности классов шкалы тональностей, выбираем наиболее вероятный класс.

Workflow использования модели
Workflow использования модели

Комплексная задача оценки тональности деловых новостей.

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

  1. Дедубликация новостей;

  2. Определение важности новости;

  3. Извлечение именованных сущностей;

  4. Матчинг извлеченных сущностей с базой сущностей-контрагентов;

  5. Определение релевантности новости и сущности;

  6. Анализ тональности;

  7. Визуализация результатов;

  8. Инструмент разметки ошибок для дообучения модели.

В этой статье мы вскользь затронули задачи 3-5, основное же внимание было уделено анализу тональности.

Модель асимметричного анализа тональности выложена в открытый доступ на huggingface. Не стоит забывать, что при использовании модели крайне важна подготовка текста (workflow использования модели).

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

Комментарии (3)


  1. ipostny
    19.11.2021 13:32

    Добрый день!
    Отличная статья, интересный подход. Могли бы Вы, пожалуйста, показать пример препроцессинга новости?


    1. neoflex Автор
      22.11.2021 11:55

      Спасибо за обратную связь!

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


    1. neoflex Автор
      03.12.2021 17:57

      Старались максимально упростить пример, но все равно он вышел достаточно объемным. Пример сделан исключительно для работы с Natasha и в нём не учитываются словари аббревиатур/сокращений и другая дополнительная обработка. Также для простоты приводим пример с сокращением текста на основе выбора предложений, в которых упоминается интересующая нас сущность.

      from natasha import (
          Segmenter,
          MorphVocab,
          NewsEmbedding,
          NewsMorphTagger,
          NewsSyntaxParser,
          NewsNERTagger,
          PER,
          NamesExtractor,
          Doc
      )
      segmenter = Segmenter()
      emb = NewsEmbedding()
      morph_tagger = NewsMorphTagger(emb)
      syntax_parser = NewsSyntaxParser(emb)
      morph_vocab = MorphVocab()
      
      
      # ----------------------------- key sentences block -----------------------------
      
      
      def find_synax_tokens_with_order(doc, start, tokens, text_arr, full_str):
          ''' Находит все синтаксические токены, соответствующие заданному набору простых токенов (найденные
              для определенной NER другими функциями).
              Возвращает словарь найденных синтаксических токенов (ключ - идентификатор токена, состоящий
              из номера предложения и номера токена внутри предложения).
              Начинает поиск с указанной позиции в списке синтаксических токенов, дополнительно возвращает
              позицию остановки, с которой нужно продолжить поиск следующей NER.
          '''
          found = []
          in_str = False
          str_candidate = ''
          str_counter = 0
          if len(text_arr) == 0:
              return [], start
          for i in range(start, len(doc.syntax.tokens)):
              t = doc.syntax.tokens[i]
              if in_str:
                  str_counter += 1
                  if str_counter < len(text_arr) and t.text == text_arr[str_counter]:
                      str_candidate += t.text
                      found.append(t)
                      if str_candidate == full_str:
                          return found, i+1
                  else:
                      in_str = False
                      str_candidate = ''
                      str_counter = 0
                      found = []
      
              if t.text == text_arr[0]:
                  found.append(t)
                  str_candidate = t.text
                  if str_candidate == full_str:
                      return found, i+1
                  in_str = True
          return [], len(doc.syntax.tokens)
      
      
      def find_tokens_in_diap_with_order(doc, start_token, diap):
          ''' Находит все простые токены (без синтаксической информации), которые попадают в
              указанный диапазон. Эти диапазоны мы получаем из разметки NER.
              Возвращает набор найденных токенов и в виде массива токенов, и в виде массива строчек.
              Начинает поиск с указанной позиции в строке и дополнительно возвращает позицию остановки.
          '''
          found_tokens = []
          found_text = []
          full_str = ''
          next_i = 0
          for i in range(start_token, len(doc.tokens)):
              t = doc.tokens[i]
              if t.start > diap[-1]:
                  next_i = i
                  break
              if t.start in diap:
                  found_tokens.append(t)
                  found_text.append(t.text)
                  full_str += t.text
          return found_tokens, found_text, full_str, next_i
      
      
      def add_found_arr_to_dict(found, dict_dest):
          for synt in found:
              dict_dest.update({synt.id: synt})
          return dict_dest
      
      
      def make_all_syntax_dict(doc):
          all_syntax = {}
          for synt in doc.syntax.tokens:
              all_syntax.update({synt.id: synt})
          return all_syntax
      
      
      def is_consiquent(id_1, id_2):
          ''' Проверяет идут ли токены друг за другом без промежутка по ключам. '''
          id_1_list = id_1.split('_')
          id_2_list = id_2.split('_')
          if id_1_list[0] != id_2_list[0]:
              return False
          return int(id_1_list[1]) + 1 == int(id_2_list[1])
      
      
      def replace_found_to(found, x_str):
          ''' Заменяет последовательность токенов NER на «заглушку». '''
          prev_id = '0_0'
          for synt in found:
              if is_consiquent(prev_id, synt.id):
                  synt.text = ''
              else:
                  synt.text = x_str
              prev_id = synt.id
      
      
      def analyze_doc(text):
          ''' Запускает Natasha для анализа документа. '''
          doc = Doc(text)
          doc.segment(segmenter)
          doc.tag_morph(morph_tagger)
          doc.parse_syntax(syntax_parser)
          ner_tagger = NewsNERTagger(emb)
          doc.tag_ner(ner_tagger)
          return doc
      
      
      def find_non_sym_syntax_short(entity_name, doc, add_X=False, x_str='X'):
          ''' Отыскивает заданную сущность в тексте, среди всех NER (возможно, в другой грамматической форме).
      
              entity_name - сущность, которую ищем;
              doc - документ, в котором сделан препроцессинг Natasha;
              add_X - сделать ли замену сущности на «заглушку»;
              x_str - текст замены.
      
              Возвращает:
              all_found_syntax - словарь всех подходящих токенов образующих искомые сущности, в котором
              в случае надобности произведена замена NER на «заглушку»;
              all_syntax - словарь всех токенов.
          '''
          all_found_syntax = {}
          current_synt_number = 0
          current_tok_number = 0
      
          # идем по всем найденным NER
          for span in doc.spans:
              span.normalize(morph_vocab)
              if span.type != 'ORG':
                  continue
              diap = range(span.start, span.stop)
              # создаем словарь всех синтаксических элементов (ключ -- id из номера предложения и номера внутри предложения)
              all_syntax = make_all_syntax_dict(doc)
              # находим все простые токены внутри NER
              found_tokens, found_text, full_str, current_tok_number = find_tokens_in_diap_with_order(doc, current_tok_number,
                                                                                                      diap)
              # по найденным простым токенам находим все синтаксические токены внутри данного NER
              found, current_synt_number = find_synax_tokens_with_order(doc, current_synt_number, found_tokens, found_text,
                                                                        full_str)
              # если текст NER совпадает с указанной сущностью, то делаем замену
              if entity_name.find(span.normal) >= 0 or span.normal.find(entity_name) >= 0:
                  if add_X:
                      replace_found_to(found, x_str)
                  all_found_syntax = add_found_arr_to_dict(found, all_found_syntax)
          return all_found_syntax, all_syntax
      
      
      def key_sentences(all_found_syntax):
          ''' Находит номера предложений с искомой NER. '''
          key_sent_numb = {}
          for synt in all_found_syntax.keys():
              key_sent_numb.update({synt.split('_')[0]: 1})
          return key_sent_numb
      
      
      def openinig_punct(x):
          opennings = ['«', '(']
          return x in opennings
      
      
      def key_sentences_str(entitiy_name, doc, add_X=False, x_str='X', return_all=True):
          ''' Составляет окончательный текст, в котором есть только предложения, где есть ключевая сущность,
              эта сущность, если указано, заменяется на «заглушку».
          '''
          all_found_syntax, all_syntax = find_non_sym_syntax_short(entitiy_name, doc, add_X, x_str)
          key_sent_numb = key_sentences(all_found_syntax)
          str_ret = ''
      
          for s in all_syntax.keys():
              if (s.split('_')[0] in key_sent_numb.keys()) or (return_all):
                  to_add = all_syntax[s]
      
                  if s in all_found_syntax.keys():
                      to_add = all_found_syntax[s]
                  else:
                      if to_add.rel == 'punct' and not openinig_punct(to_add.text):
                          str_ret = str_ret.rstrip()
      
                  str_ret += to_add.text
                  if (not openinig_punct(to_add.text)) and (to_add.text != ''):
                      str_ret += ' '
      
          return str_ret
      
      
      # ----------------------------- key entities block -----------------------------
      
      
      def find_synt(doc, synt_id):
          for synt in doc.syntax.tokens:
              if synt.id == synt_id:
                  return synt
          return None
      
      
      def is_subj(doc, synt, recursion_list=[]):
          ''' Сообщает является ли слово подлежащим или частью сложного подлежащего. '''
          if synt.rel == 'nsubj':
              return True
          if synt.rel == 'appos':
              found_head = find_synt(doc, synt.head_id)
              if found_head.id in recursion_list:
                  return False
              return is_subj(doc, found_head, recursion_list + [synt.id])
          return False
      
      
      def find_subjects_in_syntax(doc):
          ''' Выдает словарик, в котором для каждой NER написано, является ли он
              подлежащим в предложении.
              Выдает стартовую позицию NER и было ли оно подлежащим (или appos)
          '''
          found_subjects = {}
          current_synt_number = 0
          current_tok_number = 0
      
          for span in doc.spans:
              span.normalize(morph_vocab)
              if span.type != 'ORG':
                  continue
      
              found_subjects.update({span.start: 0})
              diap = range(span.start, span.stop)
      
              found_tokens, found_text, full_str, current_tok_number = find_tokens_in_diap_with_order(doc,
                                                                                                      current_tok_number,
                                                                                                      diap)
      
              found, current_synt_number = find_synax_tokens_with_order(doc, current_synt_number, found_tokens,
                                                                        found_text, full_str)
      
              found_subjects.update({span.start: 0})
              for synt in found:
                  if is_subj(doc, synt):
                      found_subjects.update({span.start: 1})
          return found_subjects
      
      
      def entity_weight(lst, c=1):
          return c*lst[0]+lst[1]
      
      
      def determine_subject(found_subjects, doc, new_agency_list, return_best=True, threshold=0.75):
          ''' Определяет ключевую NER и список самых важных NER, основываясь на том, сколько
              раз каждая из них встречается в текста вообще и сколько раз в роли подлежащего '''
          objects_arr = []
          objects_arr_ners = []
          should_continue = False
          for span in doc.spans:
              should_continue = False
              span.normalize(morph_vocab)
              if span.type != 'ORG':
                  continue
              if span.normal in new_agency_list:
                  continue
              for i in range(len(objects_arr)):
                  t, lst = objects_arr[i]
      
                  if t.find(span.normal) >= 0:
                      lst[0] += 1
                      lst[1] += found_subjects[span.start]
                      should_continue = True
                      break
      
                  if span.normal.find(t) >= 0:
                      objects_arr[i] = (span.normal, [lst[0]+1, lst[1]+found_subjects[span.start]])
                      should_continue = True
                      break
      
              if should_continue:
                  continue
              objects_arr.append((span.normal, [1, found_subjects[span.start]]))
              objects_arr_ners.append(span.normal)
      
          max_weight = 0
          opt_ent = 0
          for obj in objects_arr:
              t, lst = obj
              w = entity_weight(lst)
              if max_weight < w:
                  max_weight = w
                  opt_ent = t
      
          if not return_best:
              return opt_ent, objects_arr_ners
      
          bests = []
          for obj in objects_arr:
              t, lst = obj
              w = entity_weight(lst)
              if max_weight*threshold < w:
                  bests.append(t)
      
          return opt_ent, bests
      
      
      text = '''В офисах Сбера начали тестировать технологию помощи посетителям в экстренных ситуациях. «Зеленая кнопка» будет
       в зонах круглосуточного обслуживания офисов банка в Воронеже, Санкт-Петербурге, Подольске, Пскове, Орле и Ярославле.
       В них находятся стенды с сенсорными кнопками, обеспечивающие связь с операторами центра мониторинга службы безопасности
       банка. Получив сигнал о помощи, оператор центра может подключиться к объекту по голосовой связи. С помощью камер
       видеонаблюдения он оценит обстановку и при необходимости вызовет полицию или скорую помощь. «Зеленой кнопкой» можно
       воспользоваться в нерабочее для отделения время, если возникла угроза жизни или здоровью. В остальных случаях помочь
       клиентам готовы сотрудники отделения банка. «Одно из направлений нашей работы в области ESG и устойчивого развития
       — это забота об обществе. И здоровье людей как высшая ценность является его основой. Поэтому задача банка в области
       безопасности гораздо масштабнее, чем обеспечение только финансовой безопасности клиентов. Этот пилотный проект
       приурочен к 180-летию Сбербанка: мы хотим, чтобы, приходя в банк, клиент чувствовал, что его жизнь и безопасность
       — наша ценность», — отметил заместитель председателя правления Сбербанка Станислав Кузнецов.'''
      
      doc = analyze_doc(text)
      key_entity = determine_subject(find_subjects_in_syntax(doc), doc, [])[0]
      res = key_sentences_str(key_entity, doc, add_X=True, x_str='X', return_all=False)