Использование библиотеки обработки естественного языка spaCy для получения информации из новостных статей

Элеонора Рузвельт якобы сказала:

Великие умы обсуждают идеи; средние умы обсуждают события; маленькие умы обсуждают людей.

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

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

Чтобы найти информацию в газетных статьях, я решил использовать технику обработки естественного языка под названием Named Entity Recognition (NER), которая используется для идентификации того, что называется именованными объектами в предложении. Именованные объекты - это такие вещи, как продукты, страны, компании, числа. Для этого я буду использовать библиотеку обработки естественного языка spaCy. Вот пример из их документации того, как может выглядеть NER-тегирование:

spaCy распознает следующие объекты:

PERSON:      People, including fictional.
NORP:        Nationalities or religious or political groups.
FAC:         Buildings, airports, highways, bridges, etc.
ORG:         Companies, agencies, institutions, etc.
GPE:         Countries, cities, states.
LOC:         Non-GPE locations, mountain ranges, bodies of water.
PRODUCT:     Objects, vehicles, foods, etc. (Not services.)
EVENT:       Named hurricanes, battles, wars, sports events, etc.
WORK_OF_ART: Titles of books, songs, etc.
LAW:         Named documents made into laws.
LANGUAGE:    Any named language.
DATE:        Absolute or relative dates or periods.
TIME:        Times smaller than a day.
PERCENT:     Percentage, including ”%“.
MONEY:       Monetary values, including unit.
QUANTITY:    Measurements, as of weight or distance.
ORDINAL:     “first”, “second”, etc.
CARDINAL:    Numerals that do not fall under another type.

Как видно, у нас есть ЛИЧНОСТЬ и СОБЫТИЕ, но ИДЕИ катастрофически не хватает. Чтобы исправить это, нам нужно будет выбрать одного из других, который будет выступать в роли посредника для идей. Для этого я выбрал ПРОЦЕНТ. Причина этого в том, что процент обычно - это способ описания абстрактных идей, его используют, когда говорят, например, о человечестве в целом, а не о том или ином человеке. Это не идеальная карта, основанная на идеях, но мы должны работать с тем, что у нас есть.

Что касается «размера ума» читателей, я буду использовать индекс читабельности Коулмана-Лиау. Это способ количественно определить, на каком уровне образования должен быть читатель, чтобы понимать текст, и рассчитывается по формуле:

Coleman_Liau = 0.0588*L–0.296*S-15.8
L = Average number of letters per 100 characters
S = Average number of sentences per 100 characters

Опять же, аналогия не идеальна, но, надеюсь, достаточно хороша.

Похоже, у нас все настроено, давайте приступим к работе с данными!

Сбор и очистка данных

Мы собираемся использовать новостную ленту, предоставляемую по бесплатной подписке на newsapi.org. Это означает, что мы получим заголовок, описание (резюме статьи) и первые 260 символов содержания статьи. . Я решил выбрать несколько популярных англоязычных газет, в основном из США и Англии:

sources = ['abc-news', 'cnn', 'fox-news', 'cbs-news', 'the-new-york-times', 'reuters', 'the-wall-street-journal', 'the-washington-post', 'bloomberg', 'buzzfeed', 'bbc-news', 'daily-mail']

После получения данных (13 368 статей за июнь 2019 г.) я изучил их и обнаружил, что есть несколько статей на китайском и арабском языках, которые вызовут проблемы для spaCy. Я очистил его с помощью функции, которую нашел в StackOverflow:

latin_letters= {}
def is_latin(uchr):
    try: 
        return latin_letters[uchr]
    except KeyError:
        try:
             return latin_letters.setdefault(
             uchr, 'LATIN' in ud.name(uchr))
        except:
            print(uchr)
            raise Exception()
def only_roman_chars(unistr):
    return all(is_latin(uchr) for uchr in unistr if uchr.isalpha())

После очистки у нас осталось 11458 сообщений, распределенных по разным источникам:

df.groupby('source').count()['title']
abc-news                   1563
bbc-news                   1076
bloomberg                    56
buzzfeed                    295
cbs-news                    780
cnn                         809
daily-mail                 1306
fox-news                   1366
reuters                     916
the-new-york-times         1467
the-wall-street-journal     590
the-washington-post        1234

Я решил использовать Описание в качестве основы для тегов NER, поскольку мы хотим пометить статью в зависимости от того, о чем она, и «Описание» мне показалось, что лучше всего для этого подходит.

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

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

ners = ['PERSON','NORP','FAC','ORG','GPE','LOC','PRODUCT','EVENT','WORK_OF_ART','LAW','LANGUAGE','DATE','TIME','PERCENT','MONEY','QUANTITY','ORDINAL','CARDINAL']
# The ners we are most interested in
ners_small = ['PERSON', 'EVENT', 'PERCENT']
nlp = spacy.load("en_core_web_sm")
df['ner'] = df['Description'].apply(lambda desc: dict(Counter([ent.label_ for ent in nlp(desc).ents])))
for ner in ners:
    df[ner] = df['ner'].apply(lambda n: n[ner] if ner in n else 0)

Сгруппируем их по источникам и нормализуем:

df_grouped_mean = df.groupby('source').mean()
# Normalize 
df_grouped = df_grouped_mean[ners].div(
    df_grouped_mean[ners].sum(axis=1), axis=0)
df_grouped['coleman_content'] = df_grouped_mean['coleman_content']
# Do the same for the smaller ners-set
df_grouped_small = df_grouped_mean[ners_small].div(
   df_grouped_mean[ners_small].sum(axis=1), axis=0)
df_grouped_small['coleman_content'] = 
   df_grouped_mean['coleman_content']

Глядя на результат

fig, axes = plt.subplots(nrows=3, ncols=1)
df_grouped[ners].iloc[:4].plot(kind='bar', figsize=(20,14), rot=10, ax=axes[0], legend=False);
df_grouped[ners].iloc[4:8].plot(kind='bar', figsize=(20,14), rot=10, ax=axes[1]);
df_grouped[ners].iloc[8:].plot(kind='bar', figsize=(20,14), rot=10, ax=axes[2], legend=False);

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

focus = []
for source in df_grouped[ners].values:
    focus.append(sorted([(ners[i],x) for i,x in enumerate(source)], key=lambda x: x[1], reverse=True)[:3])
        
df_grouped['focus'] = [' '.join([y[0] for y in x]) for x in focus]
df_grouped['focus']
abc-news                          ORG GPE DATE
bbc-news                        GPE PERSON ORG
bloomberg                       ORG GPE PERSON
buzzfeed                   ORG PERSON CARDINAL
cbs-news                        PERSON ORG GPE
cnn                             PERSON ORG GPE
daily-mail                     PERSON DATE GPE
fox-news                        ORG PERSON GPE
reuters                           DATE GPE ORG
the-new-york-times              PERSON GPE ORG
the-wall-street-journal         ORG GPE PERSON
the-washington-post             GPE ORG PERSON

А также, давайте перечислим источники новостей, которые имеют наибольшую долю по определенной теме:

largest_in_topic = {}
for n in ners:
    largest_in_topic[n] = list(df_grouped.sort_values(n,ascending=False).index[:3])
largest_in_topic
{'PERSON': ['cnn', 'daily-mail', 'the-new-york-times'],
 'NORP': ['the-washington-post', 'the-new-york-times', 'fox-news'],
 'FAC': ['the-new-york-times', 'abc-news', 'fox-news'],
 'ORG': ['buzzfeed', 'the-wall-street-journal', 'bloomberg'],
 'GPE': ['abc-news', 'the-washington-post', 'bbc-news'],
 'LOC': ['bloomberg', 'abc-news', 'the-washington-post'],
 'PRODUCT': ['the-wall-street-journal', 'daily-mail', 'buzzfeed'],
 'EVENT': ['bbc-news', 'bloomberg', 'reuters'],
 'WORK_OF_ART': ['cbs-news', 'fox-news', 'the-new-york-times'],
 'LAW': ['bloomberg', 'the-wall-street-journal', 'cnn'],
 'LANGUAGE': ['bbc-news', 'fox-news', 'the-new-york-times'],
 'DATE': ['reuters', 'daily-mail', 'cbs-news'],
 'TIME': ['bbc-news', 'daily-mail', 'cbs-news'],
 'PERCENT': ['bloomberg', 'buzzfeed', 'cbs-news'],
 'MONEY': ['bloomberg', 'the-wall-street-journal', 'cbs-news'],
 'QUANTITY': ['buzzfeed', 'bbc-news', 'cnn'],
 'ORDINAL': ['bbc-news', 'cbs-news', 'reuters'],
 'CARDINAL': ['bloomberg', 'cbs-news', 'abc-news']}

Здесь есть несколько интересных моментов:

  • Почти все любят говорить о странах, компаниях и людях.
  • Wall Street Journal и Bloomberg, как и ожидалось, любят деньги и организации.
  • Рейтер любит уточнять даты.

Если мы посмотрим только на меньший NER-набор, мы получим:

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

import re
alphabets= "([A-Za-z])"
prefixes = "(Mr|St|Mrs|Ms|Dr)[.]"
suffixes = "(Inc|Ltd|Jr|Sr|Co)"
starters = "(Mr|Mrs|Ms|Dr|He\s|She\s|It\s|They\s|Their\s|Our\s|We\s|But\s|However\s|That\s|This\s|Wherever)"
acronyms = "([A-Z][.][A-Z][.](?:[A-Z][.])?)"
websites = "[.](com|net|org|io|gov)"

def split_into_sentences(text):
    text = " " + text + "  "
    text = text.replace("\n"," ")
    text = re.sub(prefixes,"\\1<prd>",text)
    text = re.sub(websites,"<prd>\\1",text)
    if "Ph.D" in text: text = text.replace("Ph.D.","Ph<prd>D<prd>")
    text = re.sub("\s" + alphabets + "[.] "," \\1<prd> ",text)
    text = re.sub(acronyms+" "+starters,"\\1<stop> \\2",text)
    text = re.sub(alphabets + "[.]" + alphabets + "[.]" + alphabets + "[.]","\\1<prd>\\2<prd>\\3<prd>",text)
    text = re.sub(alphabets + "[.]" + alphabets + "[.]","\\1<prd>\\2<prd>",text)
    text = re.sub(" "+suffixes+"[.] "+starters," \\1<stop> \\2",text)
    text = re.sub(" "+suffixes+"[.]"," \\1<prd>",text)
    text = re.sub(" " + alphabets + "[.]"," \\1<prd>",text)
    if "”" in text: text = text.replace(".”","”.")
    if "\"" in text: text = text.replace(".\"","\".")
    if "!" in text: text = text.replace("!\"","\"!")
    if "?" in text: text = text.replace("?\"","\"?")
    text = text.replace(".",".<stop>")
    text = text.replace("?","?<stop>")
    text = text.replace("!","!<stop>")
    text = text.replace("<prd>",".")
    sentences = text.split("<stop>")
    sentences = sentences[:-1]
    sentences = [s.strip() for s in sentences]
    return sentences

Сделайте расчет:

def calculate_coleman(letter_count, word_count, sentence_count):
    return 0.0588 * letter_count*100/word_count - 0.296 *   
           sentence_count*100/word_count - 15.8
df['coleman'] = df['split_content'].apply(lambda x: calculate_coleman(
    len(' '.join(x).replace(' ', '').replace('.', '')),
    len([y for y in ' '.join(x).replace('’', '').split() if not y.isnumeric()]),
    len(x)))
df_grouped['coleman'].sort_values(ascending=False)
bloomberg                  14.606977
reuters                    13.641115
bbc-news                   13.453002
fox-news                   13.167492
abc-news                   13.076667
the-washington-post        13.025180
the-wall-street-journal    12.762103
cbs-news                   12.753429
daily-mail                 12.030524
cnn                        11.988568
the-new-york-times         11.682979
buzzfeed                   10.184662

Это немного удивительно; Я ожидал, например, что New York Times будет выше, но, с другой стороны, это могло быть правдой. Вероятно, было бы более точно, если бы у меня было более 260 символов контента, но следующий уровень newsapi стоит 449 долларов в месяц. Чтобы быть уверенным, я перепроверю его по внешнему источнику на предмет удобочитаемости позже.

Ищу корреляцию

Давайте сопоставим читаемость с людьми, событием и процентом:

Интересно, что на самом деле кажется, что есть некоторая корреляция, по крайней мере, на ЛИЦО и СОБЫТИЕ. Давайте посчитаем показатель корреляции:

df_grouped_small.corr()

Глядя на столбец coleman_content, можно сказать, что в цитате Элеоноры Рузвельт что-то есть! По крайней мере, постольку, поскольку существует отрицательная корреляция между Coleman-Liau и PERSON и положительная корреляция между Coleman-Liau и EVENT.

Поскольку СОБЫТИЕ предназначено для «средних» умов, мы ожидаем, что диаграмма разброса переместится к середине для высоких значений СОБЫТИЯ, например:

Это не совсем то, что мы видим, но отрицательная / положительная корреляция между ЛИЦОМ / СОБЫТИЕМ по-прежнему придает цитате некоторое доверие.

Конечно, это следует принимать с ведром соли. Помимо всех уступок, на которые мы до сих пор пошли, у нас недостаточно образцов для достижения статистической значимости. Фактически, давайте посмотрим на значение p (функция из StackOverflow):

from scipy.stats import pearsonr
def calculate_pvalues(df):
    df = df.dropna()._get_numeric_data()
    dfcols = pd.DataFrame(columns=df.columns)
    pvalues = dfcols.transpose().join(dfcols, how='outer')
    for r in df.columns:
        for c in df.columns:
            pvalues[r][c] = round(pearsonr(df[r], df[c])[1], 4)
    return pvalues
calculate_pvalues(df_grouped_small)

Как и ожидалось, p-значения низкие, за исключением PERCENT.

Поскольку рассчитанные уровни Коулмана-Лиау казались немного неправильными, я решил протестировать со следующими уровнями читабельности, взятыми из http://www.adamsherk.com/publishing/news-sites-google-reading-level/

reading_level = {'abc-news': (41,57,1),'cnn': (27,69,2),
   'fox-news': (23,73,2),'cbs-news': (28,70,0),
   'the-new-york-times': (7,85,7),'reuters': (6,85,7),
   'the-wall-street-journal': (9,88,2),
   'the-washington-post': (24,72,2),'bloomberg': (6,81,11)}

Они дают 3 значения (Basic, Intermediate, Advanced), которым я дал разные веса (-1,0,1) для расчета единственного значения.

df_grouped_small['external_reading_level'] = df_grouped_small.index.map(
    lambda x: reading_level[x][2]-reading_level[x][0] if x in reading_level else 0)

Глядя на корреляцию

df_grouped_small[df_grouped_small['external_reading_level'] != 0][ners_small + ['external_reading_level']].corr()

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

Заключение

Наши результаты показывают, что в цитате действительно может быть доля правды, но статистическая значимость настолько мала, что необходимы дальнейшие исследования. Кроме того, оказывается, что независимо от размера ума люди любят много говорить о других людях. Даже самые умные новости (Bloomberg с кричащим уровнем Коулмана-Лиау 14,6) говорят о людях в 7 раз больше, чем о событиях или процентах.

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