В результате вернулось следующее количество сообщений:
- первая реклама Veet - 452 видео;
- Вжух 2.0 - 165 видео;
- реклама Клинского - 460 видео;
- новогодняя реклама Кока-Колы - 512 видео;
- клип “Блокеры” - 469 видео;
- клип “Мега-звезда” - 533 видео;
- дисс на Атеву - 421 видео;
- HypeCamp - 516 видео;
- Версус-баттл - 473 видео.
Далее для каждого канала в выборке видеозаписей (всего их оказалось 2680 из тех, которых еще не было в нашей базе) мы собрали данные для построения графа. Процесс описан в следующем подразделе.
2.3 Сбор данных о каналах
Частью нашей модели для выявления значимых факторов в распространении сообщений на YouTube являются структурные данные сети в русскоязычном сегменте платформы. Для их сбора нам нужно построить граф. Узлами в нем будут каналы. А за связь мы изначально предполагали взять наличие канала в избранных другого. Но, по какой-то причине, API YouTube не возвращает эти данные для всех каналов, поэтому мы решили ставить связь между каналами, если один из них лайкал видео другого. Зачастую блогеры лайкают не только видео, которые им интересны, но и видео своих друзей и те, в которых они принимали участие. А это как раз и означает близость между каналами, то есть увеличивает вероятность, что новость от одного перейдет к другому.
Для начала мы собрали базу русскоязычным каналов, которые не связаны напрямую с нашими сообщениями, чтобы не делать перевес в их сторону. Отправной точкой сбора данных стал лист трендовых видео в России на 31 марта 2018 года. Стандартно этот лист содержит 50 записей, которые меняются в зависимости от динамики набора популярности ролика. Для каждого из видео мы собрали также подходящие записи. YouTube определяет их или на основе общей темы, или схожести каналов, или предлагает другие ролики данного канала. И для каждого относящегося видео мы повторили ту же процедуру. Здесь мы использовали методы API videos().list() и search().list(). Код для Python выглядит так:
arr = videos_list_most_popular(client,
part='id,snippet,contentDetails,statistics',
chart='mostPopular',
regionCode='RU',
videoCategoryId='',
maxResults=50)
related = []
for i in range(0, 50):
ID = arr['items'][i]['id']
r = search_list_related_videos(client,
part='snippet',
relatedToVideoId=ID,
type='video',
maxResults=50)
related.append(r)
related1 = []
for i in range(0, 50):
for j in range(0, len(related[i]['items'])):
ID = related[i]['items'][j]['id']['videoId']
r = search_list_related_videos(client,
part='snippet',
relatedToVideoId=ID,
type='video',
maxResults=50)
related1.append(r)
По запросу система возвращает данные о видео, структура которых описана выше. Далее на основе полученных записей мы формируем словарь с каналами. Для каждого из трех полученных массивов мы применяли следующий цикл:
channels = {}
for i in range(0, len(arr['items'])):
key = arr['items'][i]['snippet']['channelId']
value = {'id': arr['items'][i]['snippet']['channelId'],
'name': arr['items'][i]['snippet']['channelTitle']}
channels[key] = value
Ключом в полученном словаре выступает ID канала, значением - другой словарь с атрибутами канала. Далее с помощью метода channels().search() мы собрали данные о статистике каждого канала. Также в структуре данных о канале есть ID плейлиста с его понравившимися видео. В том же цикле, используя данный ID мы формируем списки понравившихся видео. Код для Python выглядит так:
names = []
for j in channels:
c = channels_list_by_id(client,
part='snippet,contentDetails,statistics',
id=j)
if len(c['items']) != 0:
if c['items'][0]['snippet']['localized']['title'] in names:
pass
else:
names.append(c['items'][0]['snippet']['localized']['title'])
channels[j]['views'] = c['items'][0]['statistics']['viewCount']
channels[j]['videos'] = c['items'][0]['statistics']['videoCount']
channels[j]['subscribers'] = c['items'][0]['statistics']['subscriberCount']
try:
channels[j]['likes'] = ['items'][0]['contentDetails']['relatedPlaylists']['likes']
channels[j]['playlist'] = []
end = 0
nextPage = ''
while end == 0:
p = playlist_items_list_by_playlist_id(client,
part='snippet,contentDetails',
maxResults=50,
playlistId=channels[j]['likes'],
pageToken=nextPage)
for i in range(0, len(p['items'])):
v = p['items'][i]['contentDetails']['videoId']
channels[j]['playlist'].append(v)
try:
nextPage = p['nextPageToken']
except:
end = 1
nextPage = ''
except:
pass
else:
print len(c['items'])
Обратим внимание, что нам пришлось делать дополнительную проверку, чтобы избежать повтора каналов в словаре. Это связано с тем, что YouTube, по всей видимости присваивает ID не отдельному каналу, а его состоянию во времени. Поэтому во время скачивания мы сталкивались с проблемой, что если у канала изменилось число подписчиков или просмотров, то система определяла его как другой и присваивала новый ID. Чтобы этого избежать, мы проверяли каналы по листу с названиями тех, которые уже есть в базе. Это, на наш взгляд, уместная проверка, так как крупные и активные каналы имеют уникальное название.
В итоге для каждого канала у нас появился список понравившихся видео. Далее необходимо составить список каналов, которым эти видео принадлежат. Для этого мы использовали снова метод videos().search(), из результатов которого забирали название канала и его ID. Несмотря на то, что ID канала не совсем уникален для самого канала, нам необходима эта информация, т.к. только по ней возможно делать запросы к API. Код для Python:
for i in channels:
channels[i]['playlistAuthorsId'] = []
channels[i]['playlistAuthors'] = []
for j in channels[i]['playlist']:
v = videos_list_by_id(client,
part='snippet',
id=j)
if len(v['items']) > 0:
try:
if v['items'][0]['snippet']['channelTitle'] in channels[i]['playlistAuthors']:
pass
else: channels[i]['playlistAuthors'].append(v['items'][0]['snippet']['channelTitle'])
channels[i]['playlistAuthorsId'].append(v['items'][0]['snippet']['channelId'])
except:
pass
Таким образом у нас получается словарь каналов, в котором ключ - это ID канала, а значение - словарь с атрибутами:
- id - ID канала;
- name - название канала;
- views - количество просмотров на канале;
- videos - количество видео;
- subscribers - количество подписчиков;
- likes - ID плейлиста с понравившимися видео;
- playlist - лист c ID понравившихся видео;
- playlistAuthors - список названий каналов, чьи видео понравились данному каналу;
- playlistAuthorsId - список ID каналов, чьи видео понравились данному каналу.
Первоначально у нас получился 501 канал. Далее мы собрали информацию для тех каналов, которые попали в списки понравившихся, и в итоге у нас вышло 15404 канала, большинство из которых - русскоязычные. Также к ним добавились 2680 каналов, у которых выходили видео, связанные с нашими сообщениями. Итого в нашей базе находится 18084 канала, на основе которых мы будем строить граф и модель.
2.4 Построение и анализ графа
Как мы уже сказали, связи в нашей сети показывают, нравились ли когда-нибудь ролики одного канала другому. Для первичного анализа мы строим ненаправленный граф, т.к. такой граф кластеризуется большим числом алгоритмов. Создание итогового графа происходит через Python с помощью пакета networkx:
import networkx as nx
G = nx.Graph()
# add nodes from channels
for i in allChannels:
G.add_node(allChannels[i]['name'], label=allChannels[i]['name'],
title=allChannels[i]['name'],
subscribers=allChannels[i]['subscribers'],
videos=allChannels[i]['videos'],
views=allChannels[i]['views'])
# create list of edges
edges = []
for i in allChannels:
related = allChannels[i]['playlistAuthors']
for r in related:
pair = (allChannels[i]['name'], r)
edges.append(pair)
# add edges
G.add_edges_from(edges)
Здесь allChannels - это наш датасет со всем каналами. За ID узла мы берем название канала, а не его ID. Причина этого - возможность разных ID для одного и того же канала в базе.
Чтобы сделать граф динамическим, каждому узлу мы добавляем атрибут сообщения, который равен дате, когда узел отреагировал на одно из видео в нашей базе. Если узел не был активирован, то значение не присваивается. Код на Python для присвоения атрибута времени (на примере реакции на HypeCamp):
dates_dict = {}
for date in dates:
for c in dates[date]:
if c in dates_dict:
pass
else:
dates_dict[c] = date
nx.set_node_attributes(G, dates_dict, 'hypecampstart')
Для проведения кластерного анализа по нашей базе мы использовали R и пакет igraph. Данный пакет есть и для Python, но его документация для R написана подробнее и яснее. Для этого весь датасет с данными о каналах мы сохранили в .txt, а далее работали с ним в R.
Во-первых, нам снова пришлось создавать граф (на этот раз с помощью igraph):
library("igraph")
library("rjson")
db <- fromJSON(file = "data.txt")
edges <- c()
for (i in db) {
related <- i$playlistAuthors
for (r in related) {
if (i$name != r) {
name = i$name
edges <- append(edges, name)
edges <- append(edges, r)
}
}
}
g <- graph(edges=edges)
Далее мы проводили кластерный анализ. Для сравнения мы использовали три алгоритма.
- Через короткие случайные блуждания (функция cluster_walktrap в пакете): алгоритм ищет плотно связанные подграфы с помощью случайных блужданий. Идея состоит в том, что короткие случайные блуждания, как правило, остаются в одном сообществе.
- Основываясь на коэффициенте битвинности (функция cluster_edge_betweenness в пакете): идея состоит в том, что многие сети состоят из модулей, которые плотно связаны друг с другом, но редко связаны с другими модулями.
- Основываясь на ведущем собственном векторе (eigen vector) матрицы сообщества (функция cluster_leading_eigen в пакете): эта функция пытается найти плотно связанные подграфы, вычисляя главный неотрицательный собственный вектор матрицы модульности графа.
Все параметры в при запуске алгоритмов оставлены по умолчанию.
Для визуализации графа мы использовали Gephi. Для него созданный граф необходимо сохранить в подходящий формат, например, .gefx. Из-за того, что в названиях узлов присутствует кириллица, сохранить граф через R нам не удалось. Поэтому мы добавили получившиеся кластеры как переменные в датасет и сохранили его в формате .json:
new_db <- list()
for (i in db) {
i$wc <- tryCatch({as.character(membership(wc)[[i$name]])}, error=function(error_condition) {"0"})
i$eb <- tryCatch({as.character(membership(eb)[[i$name]])}, error=function(error_condition) {"0"})
i$le <- tryCatch({as.character(membership(le)[[i$name]])}, error=function(error_condition) {"0"})
new_db[[i$id]] <- i
}
# SAVE IN JSON
exportJson <- toJSON(new_db)
write(exportJson, "test.json")
Далее мы присоединили получившиеся переменные к основному датасету в Python:
import json
with open("test.json") as data_file:
data=json.load(data_file, encoding="windows-1251")
for i in data:
allChannels[i]['wc'] = data[i]['wc']
allChannels[i]['le'] = data[i]['le']
allChannels[i]['eb'] = data[i]['eb']
Снова создали граф (как показано выше) и сохранили его в формате .gexf:
nx.write_gexf(G, "clustered.gexf", encoding="utf-8")
2.4 Сбор «фич» и их анализ
Подходя к анализу данных и построению моделей, выделим гипотезы, которые мы хотим проверить:
1. Структура сети русскоязычных блогеров влияет на распространение информации;
2. Для ранних последователей факторы, связанные с структурой более значимы, чем для остальных пользователей.
Для проверки данных гипотез мы используем модели регрессии и классификации с переменными, которые мы перечислили в конце теоретического обоснования. В этом разделе мы опишем, как мы собирали переменные из данных.
Зависимая переменная в данной работу - это степень активации узла сообщением. По нашей шкале “0” говорит о том, что канал никак не отреагировал на сообщение. “100” - это автор сообщения. Числа от 0 до 100 - это степень остальных участников, где чем быстрее автор опубликовал ответное видео, тем ближе это число к сотне.
Чтобы посчитать данную величину, мы использовали цикл. Каждой видеозаписи из баз записей по запросам мы присваивали новый аргумент, равный отношению разницы даты данного видео с меньшей датой в базе к разнице максимальной даты к минимальной. Предварительно мы собирали массив всех дат, в которые публиковалась реакция на сообщение. Код для Python на примере базы видеозаписей по диссу Лиззки:
for i in lizzka:
s = lizzka[i]['snippet']['publishedAt'][0:10]
date = time.mktime(datetime.datetime.strptime(s, "%Y-%m-%d").timetuple())
minDate = min(dates)
maxDate = max(dates)
lizzka[i]['activatedStatus'] = (date-minDate)/(maxDate-minDate)
Для алгоритмов классификации мы также специально посчитали другой вариант зависимой переменной. Она равная классу степени активации узла от 0 до 11. “0” значит, что узел проигнорировал сообщение или оно не дошло до него. “11” говорит о том, что узел - автор сообщения. Классы мы просчитывали, когда добавляли атрибут степени активации к записи узла в базе каналов. Для этого сначала мы создали словарь, где ключ - это название канала, а значение - степень его активации. Далее в цикле мы перенесли эту информацию в базу каналов. Перенести напрямую в базу каналов не выходило, т.к. словарь каналов был создан с ключом в виде ID. Код для Python на примере versusbattle:
versusTitle = []
for i in versus:
name = versus[i]['snippet']['channelTitle']
status = versus[i]['activatedStatus']
if name in versusTitle.keys():
pass
else:
versusTitle[name] = status
for i in allChannels:
name = allChannels[i]['name']
if name in versusTitle:
allChannels[i]['versus'] = versusTitle[name]
allChannels[i]['versusCat'] = int(10*versusTitle[name])+1
else:
allChannels[i]['versus'] = 0
allChannels[i]['versusCat'] = 0
В среднем у нас в базе каждый узел был активирован примерно 0 раз, т.к. Доля активированных узлов (примерно 2600) мала среди почти 17000. Поэтому, возможно, придется рассматривать активированные узлы в модели отдельно. Максимально же один узел в нашей базе был активирован 6 разными сообщениями.
Далее мы извлекали факторы, связанные с сообщением. Для этого мы снова работали с базой видеороликов. На ее основе мы извлекали нужные данные и клали в новый словарь “meta”. В новом словаре ключ - это кодовое название ролика, а значение - словарь с искомыми фичами. Код для Python на примере клипа “Блокеры”:
meta = {}
meta['blockers'] = {}
## length of title and description