Визуализация пользовательских путей с помощью библиотеки Networkx

Опубликовано: Сентябрь 03, 2024

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

  1. просмотрели товар,
  2. добавили в корзину,
  3. перешли к чекауту,
  4. просмотрели апсел,
  5. обновили корзину,
  6. перешли к оплате,
  7. оплатили (совершили транзакцию)

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

Такие переходы пользователей при детальном рассмотрении будут представлять собой не линейную картину, а скорее сеточку – network. Соответственно и анализировать их возможно нужно, как сеть, с помощью соответствующих инструментов. В Python – это пакет Networkx, предлагающий ряд интересных функция для анализа сетей. У него есть ограничения в плане возможностей по визуализации, поэтому нельзя сказать, что задача решается легко, но поэкспериментировать достаточно интересно. Данная статья и предлагает описание такого экспериментирования.

Два базовых понятия Networkx - это node и edge, что в случае пользовательских переходов будет соответственно страницей и переходом между страницами. Чтобы анализировать данные с помощью Networkx, их естественно вначале нужно подготовить. Нам нужен Pandas dataframe данных пользовательских переходов со следующей структурой:

предыдущая страница страница количество переходов всего

"Количество переходов" — это количество всех внутрисессионых переходов, всех пользователей, к примеру за день, по данной траектории, т. е. от конкретной страницы (экрану) к другой конкретной странице (экрану). Повторную загрузку одной и той же страницы в данном примере мы не будем учитывать. Данную колонку мы назовем 'weight', поскольку это название применяется в Networkx для обозначения 'веса' грани (edge weight).

Вполне возможно, что изначально у вас будет таблица данных с такой структурой:

предыдущая страница текущая страница

А именно перечень всех отдельных переходов каждого пользователя и вам нужно будет предварительно обработать данные, например привести URL к упрощенному виду, убрав лишние параметры — нужно, чтобы все одинаковые страницы имели одинаковые URL, а затем уже посчитать вес, т. е. общее количество конкретного перехода.

Убрать или заменить ненужные параметры в URL в Pandas можно с помощью функции replace(). Подробнее на stackoverflow.

Сгруппировать одинаковые переходы, подсчитав их количество можно с помощью функции size(). Подробнее на stackoverflow.

Если вы хотите следовать шагам описанным далее, вы можете загрузить подготовленную мной таблицу пользовательских переходов в dataframe из данного файла csv с помощью команды

df = pd.read_csv('users_moves.csv')

Networkx

Импортируем необходимые библиотеки:

import numpy as np
import pandas as pd
import networkx as nx
import matplotlib as mpl
import matplotlib.pyplot as plt

Основные базовые понятия присутствующие в Networkx, которые мы будем использовать — это node, edge и weight. Нодой (node) в нашем случае будет страница (ее путь в URL), гранью (edge) — переход пользователя со страницы на другую страницу, а параметр weight будет отражать количество переходов всего.

В качестве типа графа мы выберем DiGrath — граф в котором можно задавать направление граней, т. е. в нашем случае указать с кокой ноды на какую был переход.

Граф в Networkx создается на основе данных о нодах и/или гранях, в нашем случае мы создадим граф на основе данных из нашего датафрейма, применив соответствующий метод доступный в Networkx. Наши данные отсортированы так, что вначале идут наиболее заметные переходы. Посмотрим вначале на первые 30 строчек.

Создадим граф:

G = nx.convert_matrix.from_pandas_edgelist(df.head(30), 'prevpath', 'path',
                                           edge_attr='weight', create_using=nx.DiGraph)

Визуализация в Networkx основана на Matplotlib. Выведем граф с использованием кругового расположения нодов. Смотрите комментарии в коде.

pos = nx.circular_layout(G)
fig, ax = plt.subplots(figsize=(8.5,5))
nodes = nx.draw_networkx(G, pos, node_size=150, node_color='0.8', edge_color = '0.5',
                               connectionstyle='arc3, rad=0.2', font_size = 7)
plt.margins(0.3,0) # чтобы пути страниц поместились в канвасе
plt.axis('off')
plt.savefig('net1.svg')
plt.show()

Как мы видим такой график не очень информативен и не удобен для восприятия.

Для большего удобства восприятия и информативности мы сделаем следующее:

  1. С помощью толщины линий отразим сравнительное количество переходов
  2. Раскрасим прямые и обратные переходы в разный цвет. Под прямыми в данном случае подразумеваются переходы по ходу воронки, под обратными - против хода воронки.
  3. Выведем названия нодов (пути страниц) отдельной командой, чтобы придать им удобное форматирование и расположение.
  4. Оформим кастомные стрелочки методами matplotlib.
  5. Добавим к нашему графику сопроводительный текст в центре, в котором будут цифры обратных переходов, т. е. индикация того, сколько раз пользователи по каким-то причинам были вынуждены вернуться на предыдущий шаг условной воронки.

В новом графике мы посмотрим уже на 50 первых строчек нашей таблицы данных, т. е. на 50 самых заметных переходов. Смотрите комментарии к блокам кода.

G = nx.convert_matrix.from_pandas_edgelist(df.head(50), 'prevpath', 'path',
                                           edge_attr='weight', create_using=nx.DiGraph)

Для того, чтобы привязать вес грани, т. е. количество переходов к толщине линии, нам нужно нормализовать этот набор данных. В данном случае я не стремлюсь к точным пропорциям, а скорее к более эстетическому отображению, поэтому применим что-то вроде "псевдонормализации":

edge_width = [G[u][v]['weight'] for u,v in G.edges()]
norm_edge_width = (edge_width / np.linalg.norm(edge_width))*10
width = list(map(lambda x:(x if x>0.7 else 0.7), norm_edge_width))

Подробнее о нормализации массива в NumPy на stackoverfow.

# Сделаем копию нашего графа
G1 = G.copy()
# Выберем переходы за исключением тех, что имеют обратные (обратные убираем)
for v,u in G1.edges():
    if G1.has_edge(u,v):
        G1.remove_edge(u, v)
# Выберем остальные переходы (т.е. обраные)
G2 = nx.difference(G, G1)
# и добавим им значение их веса
for v,u in G2.edges():
    G2[v][u]['weight'] = G[v][u]['weight']

Теперь визуально разделим прямые и обратные переходы. Прямые будут отображаться прямыми линиями, обратные — дугами. Раскрасим их в разные цвета.

connectionstyle = []
edge_colors=[]
for v,u in G.edges():
    if G1.has_edge(v,u):
        connectionstyle.append('arc3, rad=0')
        edge_colors.append('#008b8b')
    else:
        connectionstyle.append('arc3, rad=0.4')
        edge_colors.append('#dc143c')

Выводим основной график

Arrow = mpl.patches.ArrowStyle.CurveB(head_length=0.3, head_width=.4) #стиль стрелки
pos = nx.shell_layout(G) #расположение нодов
fig, ax = plt.subplots(figsize=(8,7))
edges = nx.draw_networkx_edges(G, pos, node_size=200, arrowsize=15, arrowstyle=Arrow, width=width)

# Выводим значение нодов с помощью отдельной функции plt.text для красоты отображения:
for p in pos:
    if pos[p][0] < 0:
        pos[p][0] -= 0.02
        plt.text(pos[p][0],pos[p][1],s=p, bbox=dict(facecolor='#87CEFA', edgecolor='None', alpha=0.5),
             horizontalalignment='right', fontsize=7)
    else:
        pos[p][0] += 0.02
        plt.text(pos[p][0],pos[p][1],s=p, bbox=dict(facecolor='#87CEFA', edgecolor='None', alpha=0.5),
             horizontalalignment='left', fontsize=7)
edge_labels = nx.get_edge_attributes(G2,'weight')

# Задаем параметры отображения линий, т.е. прямых и обратных переодов:
M = G.number_of_edges()
for i in range(M):
    edges[i].set_connectionstyle(connectionstyle[i])
    edges[i].set_color(edge_colors[i])
    edges[i].set_alpha(0.7)

# Выводим посередине графика текст с информацией:
xmin, xmax = ax.get_xlim()
ymin, ymax = ax.get_ylim()
s = ' \n'.join([v+'->'+u+'  '+str(w['weight']) for v,u,w in G2.edges.data()])
plt.text(0,-0.2, s='Обратные переходы пользователей \n\n'+s, 
         bbox=dict(facecolor='#dc143c', edgecolor='None', alpha=0.8),
         fontsize=8, horizontalalignment='center', color='white')

plt.tight_layout()
plt.axis('off')
plt.savefig('net2.svg')
plt.show()

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