Использование библиотеки обработки естественного языка 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 раз больше, чем о событиях или процентах.
Еще одна вещь, которая выделяется при взгляде на графики бара, - это то, насколько газеты похожи по своему выбору содержания. Поэтому, несмотря на различия в интересах людей, в конечном итоге мы больше похожи, чем различны.