Статьи

Python NLTK / Neo4j: анализ стенограмм того, как я встретил вашу маму

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

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

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

import requests
from bs4 import BeautifulSoup
from soupselect import select
 
episodes = {}
for i in range(1,3):
    page = open("data/transcripts/page-" + str(i) + ".html", 'r')
    soup = BeautifulSoup(page.read())
 
    for row in select(soup, "td.topic-titles a"):
        parts = row.text.split(" - ")
        episodes[parts[0]] = {"title": parts[1], "link": row.get("href")}
 
for key, value in episodes.iteritems():
    parts = key.split("x")
    season = int(parts[0])
    episode = int(parts[1])
    filename = "data/transcripts/S%d-Ep%d" %(season, episode)
    print filename
 
    with open(filename, 'wb') as handle:
        headers = {'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36'}
        response = requests.get("transcript website" + value["link"], headers = headers)
        if response.ok:
            for block in response.iter_content(1024):
                if not block:
                    break
 
                handle.write(block)

{Примечание редактора: по запросу владельца стенограммы сайта мы удалили конкретные ссылки на этот сайт.}

файлы, содержащие списки эпизодов, называются «page-1» и «page-2»

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

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

В конце я решил начать с простого и извлечь все слова из стенограмм и посчитать, сколько раз слово встречалось в данном эпизоде.

Я закончил со следующим сценарием, который создал словарь (эпизод -> слова + вхождения):

import csv
import nltk
import re
 
from bs4 import BeautifulSoup
from soupselect import select
from nltk.corpus import stopwords
from collections import Counter
from nltk.tokenize import word_tokenize
 
def count_words(words):
    tally=Counter()
    for elem in words:
        tally[elem] += 1
    return tally
 
episodes_dict = {}
with open('data/import/episodes.csv', 'r') as episodes:
    reader = csv.reader(episodes, delimiter=',')
    reader.next()
 
    for row in reader:
        print row
        transcript = open("data/transcripts/S%s-Ep%s" %(row[3], row[1])).read()
        soup = BeautifulSoup(transcript)
        rows = select(soup, "table.tablebg tr td.post-body div.postbody")
 
        raw_text = rows[0]
        [ad.extract() for ad in select(raw_text, "div.ads-topic")]
        [ad.extract() for ad in select(raw_text, "div.t-foot-links")]
 
        text = re.sub("[^a-zA-Z]", " ", raw_text.text.strip())
        words = [w for w in nltk.word_tokenize(text) if not w.lower() in stopwords.words("english")]
 
        episodes_dict[row[0]] = count_words(words)

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

с / где-то / в Neo4j

Язык запросов Neo4j, Cypher, имеет действительно хороший инструмент ETL-esque, называемый «LOAD CSV» для загрузки в файлы CSV (как следует из названия!), Поэтому я добавил некоторый код для сохранения своих слов на диск:

with open("data/import/words.csv", "w") as words:
    writer = csv.writer(words, delimiter=",")
    writer.writerow(["EpisodeId", "Word", "Occurrences"])
    for episode_id, words in episodes_dict.iteritems():
        for word in words:
            writer.writerow([episode_id, word, words[word]])

Вот как выглядит содержимое файла CSV:

$ head -n 10 data/import/words.csv
EpisodeId,Word,Occurrences
165,secondly,1
165,focus,1
165,baby,1
165,spiders,1
165,go,4
165,apartment,1
165,buddy,1
165,Exactly,1
165,young,1

Теперь нам нужно написать Cypher, чтобы получить данные в Neo4j:

// words
LOAD CSV WITH HEADERS FROM "file:/Users/markneedham/projects/neo4j-himym/data/import/words.csv" AS row
MERGE (word:Word {value: row.Word})
// episodes
LOAD CSV WITH HEADERS FROM "file:/Users/markneedham/projects/neo4j-himym/data/import/words.csv" AS row
MERGE (episode:Episode {id: TOINT(row.EpisodeId)})
// words to episodes
LOAD CSV WITH HEADERS FROM "file:/Users/markneedham/projects/neo4j-himym/data/import/words.csv" AS row
MATCH (word:Word {value: row.Word})
MATCH (episode:Episode {id: TOINT(row.EpisodeId)})
MERGE (word)-[:USED_IN_EPISODE {times: TOINT(row.Occurrences) }]->(episode);

Сделав это, мы можем написать несколько простых запросов, чтобы изучить слова, использованные в книге «Как я встретил вашу маму»:

MATCH (word:Word)-[r:USED_IN_EPISODE]->(episode) 
RETURN word.value, COUNT(episode) AS episodes, SUM(r.times) AS occurrences
ORDER BY occurrences DESC
LIMIT 10
 
==> +-------------------------------------+
==> | word.value | episodes | occurrences |
==> +-------------------------------------+
==> | "Ted"      | 207      | 11437       |
==> | "Barney"   | 208      | 8052        |
==> | "Marshall" | 208      | 7236        |
==> | "Robin"    | 205      | 6626        |
==> | "Lily"     | 207      | 6330        |
==> | "m"        | 208      | 4777        |
==> | "re"       | 208      | 4097        |
==> | "know"     | 208      | 3489        |
==> | "Oh"       | 197      | 3448        |
==> | "like"     | 208      | 2498        |
==> +-------------------------------------+
==> 10 rows

Основные 5 персонажей занимают 5 верхних позиций, что, вероятно, и следовало ожидать. Я не уверен, почему ‘m’ и ‘re’ находятся в следующих двух позициях — я ожидаю, что это может быть ошибкой!

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

WITH ["Ted", "Barney", "Robin", "Lily", "Marshall"] as mainCharacters
MATCH (word:Word) WHERE word.value IN mainCharacters
MATCH (episode:Episode)<-[r:USED_IN_EPISODE]-(word)
WITH episode, word, r
ORDER BY episode.id, r.times DESC
WITH episode, COLLECT({word: word.value, times: r.times})[0] AS topWord
RETURN episode.id, topWord.word AS word, topWord.times AS occurrences
LIMIT 10
 
==> +---------------------------------------+
==> | episode.id | word       | occurrences |
==> +---------------------------------------+
==> | 72         | "Barney"   | 75          |
==> | 143        | "Ted"      | 16          |
==> | 43         | "Lily"     | 74          |
==> | 156        | "Ted"      | 12          |
==> | 206        | "Barney"   | 23          |
==> | 50         | "Marshall" | 51          |
==> | 113        | "Ted"      | 76          |
==> | 178        | "Barney"   | 21          |
==> | 182        | "Barney"   | 22          |
==> | 67         | "Ted"      | 84          |
==> +---------------------------------------+
==> 10 rows

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

WITH ["Ted", "Barney", "Robin", "Lily", "Marshall"] as mainCharacters
MATCH (word:Word) WHERE word.value IN mainCharacters
MATCH (episode:Episode)<-[r:USED_IN_EPISODE]-(word)
WITH episode, word, r
ORDER BY episode.id, r.times DESC
WITH episode, COLLECT({word: word.value, times: r.times})[0] AS topWord
RETURN MIN(topWord.times), MAX(topWord.times), AVG(topWord.times), STDEV(topWord.times)
 
==> +-------------------------------------------------------------------------------------+
==> | MIN(topWord.times) | MAX(topWord.times) | AVG(topWord.times) | STDEV(topWord.times) |
==> +-------------------------------------------------------------------------------------+
==> | 3                  | 259                | 63.90865384615385  | 42.36255207691068    |
==> +-------------------------------------------------------------------------------------+
==> 1 row

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

  • Обнаружение общих фраз / мемов / фраз, используемых в шоу (например, желтый зонтик) — это должно быть возможно путем создания n-грамм различной длины и последующего поиска этих фраз по всему корпусу.
  • Вытащите сцены — некоторые из транскриптов используют ключевое слово «сцена», чтобы обозначить это, хотя некоторые из них не делают. В зависимости от того, сколько транскриптов содержат разметки сцен, возможно, мы могли бы обучить классификатор, чтобы определить, где должны быть сцены в транскриптах, у которых нет сцен.
  • Проанализируйте, кто разговаривает друг с другом или кто чаще всего говорит друг о друге
  • Создайте график бесед, о которых мои коллеги Макс и Майкл ранее писали в блоге.

Будьте общительны, делитесь!