Не спам-фильтр твоего отца

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

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

Содержание

  1. Работа с почтой
  2. Темное искусство анализа текста
  3. Классификация вещей
  4. Объединяем

Работа с почтой

Я в первую очередь пользователь Gmail, так что это единственная причина, по которой я сосредоточился на этом. Google предоставляет API для Gmail с приличной документацией. Я просто хотел собрать все непрочитанные сообщения в папке «Входящие» для обучения или классификации и пометить их как прочитанные.

Вам нужно будет перейти на их сайт API и получить учетную запись разработчика. Затем следуйте инструкциям по созданию приложения (именно так мы будем аутентифицировать наш код в API Gmail через OAuth). Как только вы это сделаете, этот код поможет вам начать авторизацию и извлекать все непрочитанные электронные письма.

def read_gmail():
  SCOPES = ['https://www.googleapis.com/auth/gmail.modify']
  unread_messages = []
  creds = None
  
  if os.path.exists('token.pickle'):
    with open('token.pickle', 'rb') as token:
    creds = pickle.load(token)
    if not creds or not creds.valid:
      if creds and creds.expired and creds.refresh_token:
        creds.refresh(Request())
      else:
        flow = InstalledAppFlow.from_client_secrets_file('credentials.json', SCOPES)
        creds = flow.run_local_server(port=0)
      with open('token.pickle', 'wb') as token:
        pickle.dump(creds, token)
service = build('gmail', 'v1', credentials=creds)
# Call the Gmail API
inbox_unreads = service.users().messages().list(userId='me',labelIds = ['INBOX', 'UNREAD']).execute()
if inbox_unreads['resultSizeEstimate'] != 0:
  for unread in inbox_unreads['messages']:
    unread_messages.append(service.users().messages().get(userId='me', id=unread['id']).execute())
    service.users().messages().modify(userId='me', id=unread['id'], body={"removeLabelIds": ["UNREAD"]}).execute()
  return unread_messages

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

Темное искусство анализа текста

TL:DR — Это сложно и основано на некоторых предположениях о том, как я читаю и оцениваю электронные письма.

Анализ текста по-прежнему остается одной из тех областей, в которых требуется немного больше академических исследований, чтобы все было правильно. Например, ваш анализатор настроений может иметь обзор продукта с 5 звездами, который говорит «Неплохо» или «Безумно прилично», и обзор с 1 звездой, который говорит «впечатляюще провалился» или «Удивительная трата денег». При анализе настроений вам нужно выяснить, какие слова соответствуют положительным или отрицательным настроениям.

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

  1. Электронные письма, содержащие определенные слова, могут постоянно считаться более важными, чем другие.
  2. Слова в строке темы больше влияют на определение важности электронного письма, чем слова в теле письма.
  3. Люди, содержащиеся в цепочке писем, также добавляют веса тому, насколько важно письмо (если ваш босс получил копию, оно должно быть важным, верно?).

Исходя из этих предположений, я решил определить «документ» (строку в таблице данных для анализа) как тему письма + тело письма + всех участников в полях «От» и «Копия».

Получив это определение, я применил несколько распространенных методов обработки текста и шумоподавления. Чтобы уменьшить шум, вносимый стоп-словами (такими как the a at и т.п.), я пропустил предмет и тело через сито общих стоп-слов, определенных в nltk библиотеке.

Наконец, поскольку мне действительно нужна только суть некоторых слов, я пропустил документы через алгоритм Портер Стеммер, который делает такие слова, как Connect Connected Connecting и Connector, все похожими на connect, поскольку ключевое понятие — соединить.

Теперь, когда текст проанализирован, как его организовать?

TL:DR — используйте TF-IDF для создания набора характеристик (количества слов) для каждого документа (сообщения).

tfidf_vect = TfidfVectorizer(ngram_range=(1, 2))
X_ngrams = tfidf_vect.fit_transform(df['text'])
train_messages, test_messages, train_labels, test_labels = train_test_split(X_ngrams, df['label'], test_size=0.3, random_state=42, stratify=df['label'])

Векторизатор TF-IDF в основном создает список слов для всех электронных писем и присваивает каждому слову значение в зависимости от того, как часто слово появляется в электронном письме и во всех электронных письмах. TF — Частота термина или количество раз, которое это слово появляется во всех электронных письмах. IDF - это обратная частота документа, которая немного более математическая, но вам нужно знать, что IDF измеряет, сколько информации предоставляет слово. Например, слово «важно» может не давать такого большего количества информации, так как слово «важный» может содержаться во всем, но слово «прекращено» дает много информации, поскольку в вашем мире только EC2 или ваша работа могут быть « прекращено».

Мы также используем 1 и 2 ngrams, что является способом учета слов, которые могут иметь смысл только вместе. Если мы используем только униграмму, каждое слово рассматривается отдельно. Таким образом, в предложении «Вы получили повышение и бонус в виде акций» каждое слово рассматривается без контекста, и вы видите promotion stock bonus, что звучит так, будто вы получили акции И бонус. Биграмма просматривает каждую пару, поэтому вы увидите promotion stock stock bonus, что звучит как ваша бонусная акция IS. Ngrams — это еще один способ придать контекстуальный цвет вашему анализу.

В конце у вас должна быть таблица данных, где каждый столбец представляет собой слово со связанным с ним значением TF-IDF, и столбец метки. При первом включении этой функции я проглотил 50 писем (выберите все в своем почтовом ящике и пометьте как непрочитанные, а затем проглотите) и случайным образом пометил их от 0 до 2, где 0 — «не читать», 1 — «прочитано, но только для вашего сведения» и 2 было «тебе нужно как-то отреагировать».

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

Классификация вещей

TL:DR — Используя Байес и случайный лес с 50 точками данных со случайными метками, я получил каждую модель с оценкой F1 около 0,35. Что не так уж ужасно и стало лучше, когда я действительно вернулся к данным и добавил к ним больше точек данных.

Для этого подхода я выбрал модель Multinomial Naïve Bayes и Random Forrest. Я хотел что-то, что было бы хорошо с категориальными или прерывистыми данными и поддерживало многопеременные (значительно больше, чем 2) выходные данные. Это не совсем спам-не-спам-фильтр, но, к счастью, здесь все еще работают старые резервные фильтры.

mnb = MultinomialNB()
%time mnb.fit(train_messages, train_labels)
mnbpred = mnb.predict(test_messages)
print('Multinomial Naive Bayes F1 Score :', metrics.f1_score(test_labels, mnbpred, average='weighted'))
# cross-validation using confusion matrix
pd.DataFrame(
  metrics.confusion_matrix(test_labels, mnbpred),
  index=[['actual', 'actual', 'actual'], list(action_map.keys())],
  columns=[['predicted', 'predicted', 'predicted'],
  list(action_map.keys())]
)

Это был типичный подход МНБ. Я не слишком углублялся в оценку модели, поэтому сжульничал и учел только оценку F1. Я также случайным образом навешивал ярлыки на свои данные во время их предварительной обработки, поэтому лучшее, что я смог сделать, это 0,35. Я уверен, что если бы я потратил время, чтобы собрать больше данных и на самом деле пометить их в соответствии со своим поведением, я бы добился большего успеха.

param_grid = {
  'n_estimators': [200, 500],
  'max_features': ['auto', 'sqrt', 'log2'],
  'max_depth' : [4,5,6,7,8],
  'criterion' :['gini', 'entropy']
}
grid = GridSearchCV(RandomForestClassifier(random_state=42), param_grid, refit = True, verbose = 3, n_jobs=6)
%time grid.fit(train_messages, train_labels)
pgpred = grid.predict(test_messages)
print('SVM F1 Score :', metrics.f1_score(test_labels, pgpred, average='weighted'))
print(grid.best_params_)
pd.DataFrame(
  metrics.confusion_matrix(test_labels, pgpred),
  index=[['actual', 'actual', 'actual'], list(action_map.keys())],
  columns=[['predicted', 'predicted', 'predicted'],
  list(action_map.keys())]
)

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

Объединяем

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

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

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

В конце концов, я многое узнал о разборе и анализе текста, а также о прогнозировании того, как я буду отвечать на определенные электронные письма. Я открыл исходный код Jupyter Notebook на своем Github, если вы хотите сделать свой собственный или пройти через него и оставить отзыв. Надеюсь, это даст вам отличное представление о мире обработки естественного языка.