Привет. Я Игорь Буянов, старший разработчик группы разметки данных MTS AI. Я люблю датасеты и все методы, которые помогают их делать быстро и качественно. Недавно рассказывал о том, как делать иерархически датасет из Википедии. В этом посте хочу рассказать вам о Сноркеле - фреймворке для программирования данных (data programming). Познакомился я с ним случайно несколько лет назад, и меня поразил этот подход, который заключается в использовании разных эвристик и априорных знаний для автоматической разметки датасетов. Проект стартовал в Стэнфорде как инструмент для помощи в разметке датасетов для задачи information extraction, а сейчас разработчики делают платформу для пользования внешними заказчиками.

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

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

Основные понятия

Арсенал Сноркеля включает в себя три ключевых инструмента:

  • разметочные функции для создания датасета;

  • преобразующие функции для аугментации датасета;

  • срезающие (slicing) функции, выделяющие подмножества в датасете, которые критичны для производительности обучающихся моделей. 

В этом посте мы остановимся лишь на разметочных функциях. В разметочные функции (labeling functions) закодированы все возможные правила, по которым можно поставить какую-либо метку каждому примеру из набора данных. 

В качестве основы для таких функций используются:

  • внешние базы данных, такие как WordNet или WikiBase (distant supervision);

  • отдельные краудсорс-разметчики;

  • правила и паттерны, составленные экспертом;

  • правила и паттерны, полученные из анализа данных;

  • словари;

  • другие классификаторы.

Важно, что эти функции необязательно должны быть точными или ортогональными. Генеративная модель, являющаяся сердцем Сноркеля, попытается учесть недостатки отдельных функций. Для справки, подход, использующий для обучения источники с ошибками, т.е. с шумом, называется обучением со слабым контролем (weak supervision).

Общий план работы со Сноркелем

Основываясь на работе авторов и своем опыте, я пришел к следующему алгоритму действий:

  1. Разработка dev-датасета. 

    Небольшой датасет размечается людьми. Он нужен для того, чтобы тестировать гипотезы и разметочные функции. 

  2. Анализ данных. 

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

  3. Разработка разметочных функций.

    Здесь мы облачаем признаки и эвристики в функции, понятные Сноркелю.

  4. Оценка разметочных функций по dev-датасету. 

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

  5. Анализ ошибок.

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

  6. Возвращение к шагу 2 (опционально). 

    В результате анализа ошибок могут возникнуть новые гипотезы и идеи, которые обязательно нужно проверить.

  7. Обучение генеративной модели

    Применяя все разметочные функции к датасету мы получаем матрицу \Lambda, где строка - это объект датасета, а столбец - соответствующая разметочная функция. В ячейках же стоит вывод каждой разметочной функции. На ее основе, обучается генеративная модель со специальной шумоподавляющей функцией потерь, которая присваивает веса функциям в зависимости от их взаимодействия между собой.

  8. Получение вероятностных целевых меток.

    С помощью обученной модели получаются метки в формате вероятности принадлежности примера к классу.

  9. Обучение модели.

    Заключительный этап — обучение уже любой модели, которая вам нужна.

Для наглядности оставляю здесь иллюстрацию с последовательностью работы со Снокрелем для задачи information extraction из оригинальной статьи.

Краткое теоретическое описание работы Сноркеля

Остановимся подробнее на генеративной модели, упомянутой выше. Авторы оригинальной статьи представляют ее как факторный граф, или графическую вероятностную модель. Для упрощения в модели делается предположение о независимости ошибок разметочных функций, то есть каждая ошибка совершается независимо от других функций. Помимо этого, можно смоделировать статистические зависимости срабатывания функций, таких как, например, срабатывание двух функций одновременно. Эти зависимости определяются с помощью матрицы \Lambda.

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

\phi_{i,j}^{Lab}(\Lambda , Y) = \textbf{1}{\Lambda_{i,j}\neq\emptyset}\phi_{i,j}^{Acc}(\Lambda , Y) = \textbf{1}{\Lambda_{i,j}=y_i}\phi_{i,j,k}^{Corr}(\Lambda , Y) = \textbf{1}{\Lambda_{i,j}=\Lambda_{i,k}}, (j,k) \in C

Эти зависимости и являются факторами в графе генеративной модели. Тогда пусть для каждого примера x_i будет определен конкатенированный вектор этих факторов \phi_i(\Lambda, Y)  для всех разметочных функций j=1,...,n и потенциальных корреляций из множества C. Это множество содержит информацию о наличии взаимосвязи между парой функций. Пусть w \in R^{2n+|C|} - вектор параметров. Тогда модель определяется как

p_w(\Lambda,Y)=Z^{-1}\exp(\sum^m_{i=1}w^T\phi_i(\Lambda, y_i))

Чтобы обучить эту модель без доступа к истинным меткам, это нужно обучаться с помощью логарифмического негативного маргинализированного правдоподобия, зная матрицу \Lambda

\hat{w}=argmin_w-log\sum_Y p_w(\Lambda, Y)

Оптимизацию авторы проводили с помощью SGD с семплированием Гиббса. В конце концов, мы получаем \hat{Y}=p_w(Y|\Lambda) - вероятностные метки.

После этого идет обучение дискриминативной модели h_\theta(x) с помощью шумоустойчивой функции потерь, которая выглядит следующим образом:

\hat{\theta}=argmin_\theta\sum^m_{m=1}\mathop{\mathbb{E}}_{y\sim\hat{Y}}[l(h_\theta(x_i), y)]

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

Авторы также рассматривают две дилеммы — достижение точности модели и ее структуры. 

Первая формируется так. Если у нас мало разметочных функций, или они часто "пропускают", то они даже при взвешивании не будут сильно отличаться от голосования большинством. С другой стороны, работы показывают, что при наличии достаточной плотности меток голосование большинством достаточно эффективен. Где найти грань, когда мы можем не заморачиваться с генеративной моделью и использовать просто это голосование? Теоретические исследования авторов показывают, что наибольшей эффективностью их подход обладает, когда разметочных функций насчитывается около 15 штук.

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

В одной из своих статей авторы Сноркеля представили алгоритм быстрого выстраивания зависимостей C между факторами в графе. Этот алгоритм, однако, управляется параметром \epsilon, который контролирует количество учитываемых взаимосвязей. Можно также сказать, что этот параметр определяет сложность модели. Встает вопрос о том, как выбрать этот параметр автоматически. Эмпирически авторы установили, что существует "локтевая точка", после прохождения которой количество учитываемых взаимосвязей, а соответственно, сложность модели, начинает стремительно расти. Получается, что эта точка и есть то оптимальное значение, которое нужно найти и авторы приводят алгоритм, как они это делают. За более подробной информации, отсылаю к статье.

Применяем Сноркель на практике

В этом разделе я покажу, как можно использовать Сноркель. У меня было несколько часов свободного времени, и я решил продемонстрировать его в действии на датасете для анализа сантиментов, составленный из отзывов на AliExpress. Используя готовый датасет, мы сможем оценить качество разметки, полученной от Сноркеля.

Далее, я объясню по шагам, как работать со Снокрелем, опираясь на план, указанный выше. 

Для начала, экспортируем все библиотеки.

import warnings
warnings.simplefilter(action='ignore', category=FutureWarning)

import pandas as pd
from snorkel.labeling import labeling_function
from pymystem3 import Mystem

pd.options.mode.chained_assignment = None
import seaborn as sns
import nltk
import re
from dostoevsky.models import FastTextSocialNetworkModel
from dostoevsky.tokenization import RegexTokenizer

Несмотря на то, что традиционно задача анализа сентиментов имеет три метки, мы будем искать позитивные и негативные комментарии. Заведем соответствующие глобальные переменные. Переменная ABSTAIN нужна для случая, когда разметочная функция не может вынести решение о принадлежности к определенному классу.

ABSTAIN = -1
NEGATIVE = 0
POSITIVE = 1

Импортируем данные и оставляем колонки с текстом и исходными метками. Мы также можем видеть, что датасет идеально сбалансирован.

df = pd.read_csv("/media/astromis/Data/Datasets and corpuses/rureviews/women-clothing-accessories.3-class.balanced.csv", sep="\t")
df.columns = ["text", "label"]
df.label.value_counts()
   negative    30000
   neautral    30000
   positive    30000
   Name: label, dtype: int64
df = df[df.label.isin(["negative", "positive"])]
df.label = df.label.replace({"negative": 0, "positive": 1})

Перемешаем датасет и выделим три подвыборки:

  • dev - небольшая подвыборка, при помощи которой мы будем изучать данные и находить эвристики. В реальности, эту подвыборку нужно разметить вручную. Подобный процесс может занять от 2 до 4 часов.

  • train - основная часть данных, на которой мы будем тренировать модель Снокреля. Чем больше таких данных, тем лучше будет качество меток.

  • test - тестовая часть данных, на которой мы будем сравнивать работу разметки от Сноркеля с истинной разметкой.

df = df.sample(frac=1.0)

dev = df.iloc[:300]
train = df.iloc[300:10300]
test = df.iloc[3300:3800]

dev.label.value_counts()
   1    156
   0    144
   Name: label, dtype: int64

Анализ данных

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

dev["token_len"] = dev.text.apply(lambda x: len(x.split(" ")))
sns.displot(data=dev, x="token_len", hue="label")

Можно видеть, что есть небольшие различия в распределениях —  негативные комментарии содержат чуть больше токенов, чем позитивные. Давайте дальше посмотрим на распределение частей речи, раз уж мы заговорили про токены. Чтобы не усложнять, остановимся только на имени существительном, имени прилагательном и глаголе. Не забудем нормализовать полученное число на количество всех токенов в единице текста.

def get_postags(text):
   tokens = nltk.word_tokenize(text)
   return " ".join([x[1] for x in nltk.pos_tag(tokens, lang="rus")])
   
def pos_count_add(df_):
   df_['pos'] = df_.text.apply(lambda x: get_postags(x))
   df_["adjective_count"] = df_.pos.apply(lambda x: len(re.findall(r"A[ $]|A=[\w]+", x)))
   df_["noun_count"] = df_.pos.apply(lambda x: x.count("S"))
   df_["verb_count"] = df_.pos.apply(lambda x: len(re.findall(r"V[$ ]", x)))
   df_["adjective_count"] = df_["adjective_count"] / df_["token_len"]
   df_["noun_count"] = df_["noun_count"] / df_["token_len"]
   df_["verb_count"] = df_["verb_count"] / df_["token_len"]
   return df_
   
dev = pos_count_add(dev)

sns.displot(data=dev, x="noun_count", hue="label", kind="kde")

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

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

print(dev[dev.label == "positive"].text.to_list())
# text
print(dev[dev.label == "negative"].text.to_list())
#text
def compare(word, df):
   print("neg: ", ' '.join(df[df.label == 0].text.to_list()).count(word))
   print("pos: ",' '.join(df[df.label == 1].text.to_list()).count(word))
  
compare("рекоменд", dev)

#   neg:  8
#   pos:  13

compare("!", dev)

#    neg:  161
#    pos:  154

compare("не", dev)

#   neg:  267
#   pos:  172

compare("пасиб", dev)

#   neg:  5
#   pos:  34

Например, в позитивных часто присутствуют:

  • слово спасибо;

  • рекомендую;

  • смайлик-скобка ) 

в негативных же можно заметить:

  • частое использование частицы "не";

  • “грустный смайлик-скобку (

Подключаем внешние ресурсы

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

Выглядит он следующим образом:

emo_dict = pd.read_csv("kartaslov/dataset/emo_dict/kartaslovsent.csv", sep=";")
emo_dict.head()

term

tag

value

pstv

ngtv

neut

dunno

pstvNgtvDisagreementRatio

0

абажур

NEUT

0.08

0.185

0.037

0.580

0.198

1

аббатство

NEUT

0.10

0.192

0.038

0.578

0.192

2

аббревиатура

NEUT

0.08

0.196

0.000

0.630

0.174

3

абзац

NEUT

0.00

0.137

0.000

0.706

0.157

4

абиссинец

NEUT

0.28

0.151

0.113

0.245

0.491

Особенность этого словаря в том, что он содержит непрерывное значение для сентимента в диапазоне [-1;1]. Это может дать более точную оценку слову. Однако мы пойдем по упрощенному пути, обращая внимание на теги. Документация, кстати, доступна в гитхабе проекта.

Возьмем из таблицы слова и их теги, затем напишем функцию. 

Она будет работать следующим образом: pymystem3 произведет морфологический разбор и определит части речи. Она нам нужна для того, чтобы получить все существительные и прилагательные из предложения. Затем полярность предложения мы сформулируем по простому правилу — сложим полярности по всем найденным существительным и прилагательным.

Звучит немного мудрено, но я проверил — метод работает. Применим это на нашем датасете и посмотрим распределение. 

emo_dict = emo_dict[["term", "tag"]]
stemmer = Mystem()
def estimate_sentiment_by_vocab(word):
   analized_text = stemmer.analyze(word)
   filtered_sent = []
   for word in analized_text:
       if "analysis" not in word.keys():
           continue
       if len(word["analysis"]) == 0:
           continue
       word = word["analysis"][0]
       gr = word["gr"]
       if "PART=" in gr or len(re.findall(r"A[ $]|A=[\w]+", gr)) > 0 or "ADV" in gr:
           filtered_sent.append(word["lex"])
       else:
           continue
   emotion = 0
   for w in filtered_sent:
       if "NGTV" in emo_dict[emo_dict.term == w].tag.to_list():
           emotion -= 1
       elif "PSTV" in emo_dict[emo_dict.term == w].tag.to_list():
           emotion += 1
       else:
           continue
   return emotion

dev["dict_emo"] = dev.text.apply(lambda x: estimate_sentiment_by_vocab(x))
sns.displot(data=dev, x="dict_emo", hue="label", kind="kde")

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

Подключаем модели

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

В качестве такой модели мы возьмем модель анализа тональности из библиотеки “Достоевский”, которая изначально была обучена на текстах социальных сетей.

tokenizer = RegexTokenizer()
model = FastTextSocialNetworkModel(tokenizer=tokenizer)

def get_sentiments(text:list):
   for x in text:
       if not isinstance( x, str):
           print(x)
  
   results = model.predict(text, k=1)
   label = []
   score = []
   for x, y in list([list(x.items())[0] for x in results]):
       label.append(x)
       score.append(y)
   return label, score

sent_label, sent_score = get_sentiments(dev.text.tolist())
dev["sentiment"] = sent_label
dev["score"] = sent_score
dev["sentiment"] = dev["sentiment"].replace({"neutral": 2, "positive":1, "negative":0, "skip": 4, "speech": 5})

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

print(len(dev[dev.sentiment != 2]))
#    141
from sklearn.metrics import accuracy_score

accuracy_score(dev[dev.sentiment != 2].label, dev[dev.sentiment != 2].sentiment)
#    0.7021276595744681

Разработка и оценка разметочных функций

После того как мы провели анализ и подгрузили все ресурсы и модели, можем приступить к разработке разметочных функций. Сноркель предоставляет суперудобный способ записать все ваши эвристики в виде коротких и лаконичных функций. Напомню, что зависимости могут быть односторонним, т.е. проголосовать за какой-то класс или воздержаться (ABSTAIN).

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

from snorkel.labeling import labeling_function

@labeling_function()
def contains_not(x):
   return NEGATIVE if "не" in x.text.lower() else ABSTAIN

@labeling_function()
def check_emo_dict(x):
   if x.dict_emo > 0:
       return POSITIVE
   elif x.dict_emo < 0:
       return NEGATIVE
   else:
       return ABSTAIN

@labeling_function()
def sentiment_model(x):
   if x.sentiment == 1:
       return POSITIVE
   elif x.sentiment == 0:
       return NEGATIVE
   else:
       return ABSTAIN

@labeling_function()
def check_adjective_count(x):
   return POSITIVE if x.adjective_count > 0.2 else ABSTAIN

@labeling_function()
def contains_pasib(x):
   return POSITIVE if "пасиб" in x.text.lower() else ABSTAIN

@labeling_function()
def contains_recomend(x):
   return POSITIVE if "рекоменд" in x.text.lower() else ABSTAIN

@labeling_function()
def contains_r_paranth(x):
   return NEGATIVE if re.search(r"[\(]+", x.text) != None else ABSTAIN

@labeling_function()
def contains_l_paranth(x):
   return POSITIVE if re.search(r"[\)]+", x.text) != None else ABSTAIN

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

Помимо точности, вне зависимости от наличия разметки, мы видим:

  • покрытие - на каком проценте текстов сработало данное правило, т.е. дало метку отличную от ABSTAIN;

  • перекрытие - на сколько правило согласуется с другими;

  • конфликты - на сколько правило расходится с другими. 

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

from snorkel.labeling import PandasLFApplier

lfs = [contains_not,
      check_emo_dict,
      sentiment_model,
      check_adjective_count,
      contains_pasib,
      contains_r_paranth,
      contains_l_paranth,
      contains_recomend
     ]

applier = PandasLFApplier(lfs=lfs)
L_dev = applier.apply(df=dev)

from snorkel.labeling import LFAnalysis
LFAnalysis(L=L_dev, lfs=lfs).lf_summary(Y=dev.label.to_numpy())

Name

j

Polarity

Coverage

Overlaps

Conflicts

Correct

Incorrect

Emp. Acc.

contains_not

0

[0]

0.706667

0.463333

0.326667

123

89

0.580189

check_emo_dict

1

[0, 1]

0.320000

0.300000

0.193333

75

21

0.781250

sentiment_model

2

[0, 1]

0.396667

0.356667

0.200000

99

20

0.831933

check_adjective_count

3

[1]

0.120000

0.106667

0.046667

26

10

0.722222

contains_pasib

4

[1]

0.130000

0.116667

0.073333

34

5

0.871795

contains_r_paranth

5

[0]

0.096667

0.096667

0.070000

15

14

0.517241

contains_l_paranth

6

[1]

0.110000

0.103333

0.090000

27

6

0.818182

contains_recomend

7

[1]

0.083333

0.080000

0.066667

15

10

0.600000

Анализ ошибок

Я предлагаю кратко остановиться на анализе ошибок, чтобы поскорее перейти к самому интересному. Суть этого этапа заключается в том, что вы смотрите на результат таблицы выше и анализируете, какие конфликты правил возникли или почему какое-то правило не оправдало ваших ожиданий. Для этого в Сноркеле есть полезный метод. Допустим, мы хотим увидеть, где правило contains_not сработало, т.е. поставило негативный класс, и где модель из “Достоевского” поставила позитивный класс. Сделать это можно следующим образом

from snorkel.analysis import get_label_buckets

buckets = get_label_buckets(L_dev[:, 0], L_dev[:, 2])
dev.iloc[buckets[(NEGATIVE, POSITIVE)]].sample(10, random_state=1).text.to_list()
Вывод

 ['Комбинезон классный. маломерит на свой 44-46 заказала L в самый раз. за такие деньги очень хороший комбинезон. советую)))',

    'Шапка очень классная,пришла очень быстро,помпон пышный не искусственный,матерьял приятный-единственное тонковатая для Сибири, ну конечно кто как переносит холода.Помпон на клепке можно снять и постирать! Спасибо продавцу))) За такие деньги очень здорово!',

    'На картинке вещь смотрится очень не плохо. Реально выглядит так себе. Как безвкусный балахон. Такой фасон можно сшить из без выкройки. Очень огорчена. Первый раз так получилось. Материал 100% синтетика. К телу липнет очень не приятно.',

    'Сарафан без молнии на спине, что хорошо. Ткань приятная и сидит хорошо. Заужен книзу, похож на греческий хитоню',

    'мягкий,хорошо тянется. для дома и повседневной носки отлично.с учетом объема под грудью 88,заказала 90В,но думаю что надо было 85,он очень хорошо тянется,было бы лучше',

    'Это лучшее платье из всех заказанных ранее такого типа! Мягкое, безумно приятное к телу. Сидит идеально!',

    'Носки просто супер!!!бант очень качественные, я даже не ожидала, что на столько все будет здорово!!! Товар не отслеживался, но До Алтайского края шел всего месяц!!! Огромное спасибо продавцу! Пойду закажу ещё парочку таких носков)',

    'Очень довольна качеством, однако в плечах чуть узковато, но носить можно вполне комфортно ',

    'Очень странно.год назад брала себе такую шапку ,чёрную .очень мне нравится .сейчас заказала серую сыну ,пришла маленькая ,совсем другая .я не довольна ',

    'Кофточка тонкая . Короткая до пупка. S размер подошел на обхват  груди 86.  \r\nНе такая уж и классная вещь']

Сперва замечаем, что модель (или даже разметка) все-таки ошибается. С другой стороны, там где модель все-таки права, мы видим, что частица "не" стоит рядом с глаголом, что вместе характеризует хорошее качество товара. Как бы вы модернизировали это правило?

Обучение генеративной модели

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

# добавляем априорые знания к train датасету
train["token_len"] = train.text.apply(lambda x: len(x.split(" ")))
print("Adding pos tags")
train = pos_count_add(train)
print("Add dict emo")
train["dict_emo"] = train.text.apply(lambda x: estimate_sentiment_by_vocab(x))
print("Add sentiment")
sent_label, sent_score = get_sentiments(train.text.tolist())
train["sentiment"] = sent_label
train["score"] = sent_score
train["sentiment"] = train["sentiment"].replace({"neutral": 2, "positive":1, "negative":0, "skip": 4, "speech": 5})

# применяем разметочные функции
L_train = applier.apply(df=train)

# обучаем модель
from snorkel.labeling.model import LabelModel

label_model = LabelModel(cardinality=2, verbose=True)
label_model.fit(L_train=L_train, n_epochs=500, log_freq=100, seed=123)
probs_train = label_model.predict_proba(L_train)

# фильтруем
from snorkel.labeling import filter_unlabeled_dataframe

df_train_filtered, probs_train_filtered = filter_unlabeled_dataframe(
   X=train, y=probs_train, L=L_train

len(df_train_filtered)
#   9161

# превращаем вероятностные метки в полярные
from snorkel.utils import probs_to_preds

preds_train_filtered = probs_to_preds(probs=probs_train_filtered)

Для того, чтобы сравнить полученную разметку с истинной, обучим обычную логистическую регрессию на истинных метках тренировочного датасета и на метках, которые мы получили от Снокреля. Замеряться будем на тестовой выборке, которую мы подготовили в самом начале. В качестве векторизации используем обычный CountVectorizer.

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.linear_model import LogisticRegression

vectorizer = CountVectorizer()
X_train = vectorizer.fit_transform(df_train_filtered.text.tolist())
X_test = vectorizer.transform(test.text.tolist())

sklearn_model = LogisticRegression(C=1, solver="liblinear")
sklearn_model.fit(X=X_train, y=preds_train_filtered)

print(f"Test Accuracy: {sklearn_model.score(X=X_test, y=test.label) * 100:.1f}%")
#    Test Accuracy: 80.6%

sklearn_model = LogisticRegression(C=1, solver="liblinear")
sklearn_model.fit(X=X_train, y=train.loc[df_train_filtered.index].label)

print(f"Test Accuracy: {sklearn_model.score(X=X_test, y=test.label) * 100:.1f}%")
#    Test Accuracy: 98.4%

Итого, мы получили 80 процентов точности. Выглядит не особо впечатляюще по сравнению с точностью в 98 процентов. Однако я просто напомню, что разметка, на которой мы получили 80 процентов ( не фонтан, но достойно) собиралась за день работы максимум одного человека. Дальше ее можно использовать в качестве предразметки уже для аннотаторов, либо использовать алгоритмы удаления шума, которых я рассказывал в этой статье.

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

Как Сноркель использовали в MTS AI

Как я упомянул в начале, у нас в MTS AI Сноркель использовался командой, которая работала над медицинским чат-ботом. Я вспомнил об опыте, когда проект уже закрылся. Я взял на себя смелость найти сохранившиеся ноутбуки и связался с бывшей коллегой, чтобы спросить у нее об опыте использования и запечатлеть его.

Изначально задача состояла в том, чтобы в текстах находить сущности из 74 классов общее число которых достигало 10 тысяч, такой большой NER. Проблема в том, что разметки, конечно, для такого количества классов не существует. Разметить тексты - это не проблема, в MTS AI есть собственный отдел, другое дело, что как-то не хочется отдавать в разметку случайные тексты, в которых может ничего не быть. Поэтому, чтобы как-то исправить это, моя бывшая коллега, придумала следующую методологию. Настя Табалина, кстати, привет, если читаешь!

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

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

Неприятный текст

высыпания на ногах, чередования запоров и диареи.

на общую слабость, озноб, тошноту, диарею до 4 р в сутки.

на боли в животе, тошноту и рвоту, диарея, также общую слабость.

Далее выполняем следующие действия:

  •  для каждого слова выделим контекст слева и справа; 

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

  • векторизуем контекстное окно вместе со словом, 

  • усредняем его для каждого текста, а затем усредняем по всем текстам. 

Таким образом мы получили опорный вектор для класса "диарея". 

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

Вот на каких текстах сработала эта функция в нашем случае (орфография и пунктуация сохранены):

Неприятный текст

тяжесть в области правого подреберья, вздутие живота, повышенное газообразование, послабление стула до 2-х раз за сутки чаще в утреннеее время,\nголовные боли в височно-теменных областях тупого характер.\nпериодически подъемы АД до 160/120 мм рт.ст.

на появление головной боли в правой половине лба пульсирующего х-ра Тошноты , рвоты нет Т- норма , АД не повышается Ухудшение 3й день Накануне - прыжки на батуте Отмечает периодическое возникновение головных болей в одном и том же месте в течение последних 5 ти лет

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

Заключение

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

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

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