Говорят, хорошая визуализация данных лучше тысячи слов о них, и с этим трудно спорить.

Промпт: интерактивная визуализация сети транзакций, абстракция на белом фоне
Промпт: интерактивная визуализация сети транзакций, абстракция на белом фоне

Эта статья посвящена написанию приложения на Python для интерактивной визуализации графов. В первой части представлен краткий обзор использованных средств и библиотек, а также свойства приложения. Во второй половине — технические детали, касающиеся использования NetworkX, Plotly и Dash, и собственно код.

1. Свойства приложения для визуализации графов

Правильно построенный граф сети транзакций позволяет обнаружить закономерности и аномалии в системе. Такие приложения имеют большой потенциал применимости в финансовой сфере, примером может служить обнаружение фрода или отмывания денег. Для данного проекта был создан фиктивный датасет из транзакций, и на нем будет показана работа написанного приложения (а именно отображение интерактивного графа сети, построенного по имеющимся данным о транзакциях).

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

Кроме того, это интерактивное приложение. Когда пользователь наведёт мышь на вершину или ребро графа, будет показана дополнительная информация об элементе. Также пользователь сможет выбрать аккаунт и временной промежуток, по которым будут отображаться данные.

2. Введение в используемые средства

2.1 Теория графов и NetworkX

Граф, описывающий сеть транзакций, состоит из вершин (которым соответствуют аккаунты-участники транзакций) и рёбер (которым соответствуют сами транзакции). У вершин есть такие свойства, как имя пользователя и тип аккаунта, а у ребер — время совершения и сумма операции. Таким образом, сеть транзакций является ориентированным графом, где каждое ребро направлено от отправителя к получателю.

NetworkX — это Python-библиотека для создания, изменения и изучения структуры графов. Она позволяет построить и визуализировать граф всего за несколько строк кода:

import networkx as nx
import matplotlib.pyplot as plt
G = nx.Graph()
G.add_edge(1,2)
G.add_edge(1,3)
nx.draw(G, with_labels=True)
plt.show()

Помимо построения простых графов с данными, заданными прямо в коде, NetworkX также поддерживает импорт из баз данных и .csv-файлов (что и будет использовано далее).

2.2 Интерактивная визуализация и Plotly

Для Python создано немало полезных библиотек для визуализации данных. Но, в отличие от статичных Matplotlib и Seaborn, Plotly создает интерактивные графики. Этот пакет поддерживает множество распространенных типов графов и диаграмм. При использовании ipywidgets, Plotly также позволяет отображать интерактивные графики прямо в Jupyter Notebook.

2.3 Создание веб-приложения и Dash

Jupyter Notebook — популярный инструмент у аналитиков и data scientist'ов, но в некоторых случаях визуализация данных должна быть доступна менеджерам, заказчикам и другим заинтересованным лицам, которые не обязательно имеют опыт в работе с кодом и необходимое окружение на компьютере. В таком случае хорошим выбором оказывается написание веб-приложения, которое будет доступно прямо в браузере любому пользователю.

Это легко сделать при помощи опенсорс-фреймворка Dash, созданного для быстрого написания реактивных веб-приложений. Dash обеспечивает интеграцию кода для анализа данных с фронтенд-частью на HTML, CSS и JavaScript с минимальными усилиями.

Так как Dash построен на популярных фреймворках Flask для бэкенда и React.js для фронтенда, у него большое комьюнити в Интернете. Отдельно стоит отметить, что Dash полностью совместим с Plotly, поэтому интерактивный график сети транзакций легко станет компонентом Dash-приложения, интерфейс которого будет дополнен другими компонентами для взаимодействия пользователя с кодом для анализа данных.

3. Написание кода

Перейдем же к программированию!

3.1 Инициализация веб-приложения

Так как в основе Dash лежит Flask, совсем не удивительно, что синтаксис для запуска приложения очень похож.

import dash
import dash_core_components as dcc
import dash_html_components as html
import networkx as nx
import plotly.graph_objs as go
import pandas as pd
from colour import Color
from datetime import datetime
from textwrap import dedent as d
import json

external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css']
app = dash.Dash(__name__, external_stylesheets=external_stylesheets)
app.title = "Transaction Network"

if __name__ == '__main__':
    app.run_server(debug=True)

3.2 Написание пользовательского интерфейса приложения

HTML-разметка и интерактивные компоненты для веб-приложения легко интегрируются с Python-кодом для анализа данных с помощью интерфейсов dash_html_components и dash_core_components. В данном веб-приложении для дизайна используется Bootstrap grid system, а из возможных интерактивных элементов задействованы следующие:

  • RangeSlider для задания временного промежутка, за который рассматриваются данные;

  • Input для того, чтобы пользователь мог задать название аккаунта для поиска;

  • собственно Plotly graph для отображения поля с графом сети транзакций;

  • Hover box и Click box для отображения детальной информации при наведении курсора/клике соответственно на элемент графа. На самом деле, как видно из кода ниже, это не отдельные библиотечные компоненты, а два поля типа Markdown, в которые отправляются соответствующие данные.

Пользовательский интерфейс нашего приложения и его элементы
Пользовательский интерфейс нашего приложения и его элементы
app.layout = html.Div([
    html.Div([html.H1("Transaction Network Graph")],
             className="row",
             style={'textAlign': "center"}),
    
    html.Div(
        className="row",
        children=[
            html.Div(
                className="two columns",
                children=[
                    dcc.Markdown(d("""
                            **Time Range To Visualize**
                            Slide the bar to define year range.
                            """)),
                    html.Div(
                        className="twelve columns",
                        children=[
                            dcc.RangeSlider(
                                id='my-range-slider',
                                min=2010,
                                max=2019,
                                step=1,
                                value=[2010, 2019],
                                marks={
                                    2010: {'label': '2010'},
                                    2011: {'label': '2011'},
                                    2012: {'label': '2012'},
                                    2013: {'label': '2013'},
                                    2014: {'label': '2014'},
                                    2015: {'label': '2015'},
                                    2016: {'label': '2016'},
                                    2017: {'label': '2017'},
                                    2018: {'label': '2018'},
                                    2019: {'label': '2019'}
                                }
                            ),
                            html.Br(),
                            html.Div(id='output-container-range-slider')
                        ],
                        style={'height': '300px'}
                    ),
                    html.Div(
                        className="twelve columns",
                        children=[
                            dcc.Markdown(d("""
                            **Account To Search**
                            Input the account to visualize.
                            """)),
                            dcc.Input(id="input1", type="text", placeholder="Account"),
                            html.Div(id="output")
                        ],
                        style={'height': '300px'}
                    )
                ]
            ),
            html.Div(
                className="eight columns",
                children=[dcc.Graph(id="my-graph",
                                    figure=network_graph(YEAR, ACCOUNT))],
            ),
            html.Div(
                className="two columns",
                children=[
                    html.Div(
                        className='twelve columns',
                        children=[
                            dcc.Markdown(d("""
                            **Hover Data**
                            Mouse over values in the graph.
                            """)),
                            html.Pre(id='hover-data', style=styles['pre'])
                        ],
                        style={'height': '400px'}),
                    html.Div(
                        className='twelve columns',
                        children=[
                            dcc.Markdown(d("""
                            **Click Data**
                            Click on points in the graph.
                            """)),
                            html.Pre(id='click-data', style=styles['pre'])
                        ],
                        style={'height': '400px'})
                ]
            )
        ]
    )
])

3.3 Привязка к коду для обработки данных

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

Когда пользователь наводит курсор или кликает на вершину или ребро на графе, HoverBox/ClickBox отображает детальную информацию по выбранному аккаунту или транзакции. Для этого используются обратные вызовы.

@app.callback(
    dash.dependencies.Output('my-graph', 'figure'),
    [dash.dependencies.Input('my-range-slider', 'value'), dash.dependencies.Input('input1', 'value')])
def update_output(value,input1):
    YEAR = value
    ACCOUNT = input1
    return network_graph(value, input1)
    
@app.callback(
    dash.dependencies.Output('hover-data', 'children'),
    [dash.dependencies.Input('my-graph', 'hoverData')])
def display_hover_data(hoverData):
    return json.dumps(hoverData, indent=2)
    
@app.callback(
    dash.dependencies.Output('click-data', 'children'),
    [dash.dependencies.Input('my-graph', 'clickData')])
def display_click_data(clickData):
    return json.dumps(clickData, indent=2)

3.4 Отображение графа на поле с NetworkX и Plotly

Теперь перейдем к части кода, которая отвечает за построение графа сети транзакций. Начнем с импорта данных и трансформации строк с датами в питоновский тип Datetime.

edge1 = pd.read_csv('edge1.csv')
node1 = pd.read_csv('node1.csv')
edge1['Datetime'] = "" 
accountSet=set() 
for index in range(0,len(edge1)):
    edge1['Datetime'][index] = datetime.strptime(edge1['Date'][index], '%d/%m/%Y')
    if edge1['Datetime'][index].year<yearRange[0] or edge1['Datetime'][index].year>yearRange[1]:
        edge1.drop(axis=0, index=index, inplace=True)
        continue
    accountSet.add(edge1['Source'][index])
    accountSet.add(edge1['Target'][index])

Строим граф сети с помощью NetworkX:

G = nx.from_pandas_edgelist(edge1, 'Source', 'Target', ['Source', 'Target', 'TransactionAmt', 'Date'], create_using=nx.MultiDiGraph())
nx.set_node_attributes(G, node1.set_index('Account')['CustomerName'].to_dict(), 'CustomerName')
nx.set_node_attributes(G, node1.set_index('Account')['Type'].to_dict(), 'Type')
pos = nx.layout.shell_layout(G, shells)
for node in G.nodes:
    G.nodes[node]['pos'] = list(pos[node])

Код для описания вершин:

traceRecode = []
node_trace = go.Scatter(x=[], y=[], hovertext=[], text=[], mode='markers+text', textposition="bottom center",
                        hoverinfo="text", marker={'size': 50, 'color': 'LightSkyBlue'})

index = 0
for node in G.nodes():
    x, y = G.node[node]['pos']
    hovertext = "CustomerName: " + str(G.nodes[node]['CustomerName']) + "<br>" + "AccountType: " + str(
        G.nodes[node]['Type'])
    text = node1['Account'][index]
    node_trace['x'] += tuple([x])
    node_trace['y'] += tuple([y])
    node_trace['hovertext'] += tuple([hovertext])
    node_trace['text'] += tuple([text])
    index = index + 1
    
traceRecode.append(node_trace)

Теперь определим ребра при помощи Plotly. Это чуть сложнее, чем было с вершинами, поскольку цвет ребра должен отображать время транзакции (чем раньше, тем светлее ребро), а ширина ребра соответствовать сумме транзакции (чем больше, тем шире ребро).

colors = list(Color('lightcoral').range_to(Color('darkred'), len(G.edges())))
colors = ['rgb' + str(x.rgb) for x in colors]

index = 0
for edge in G.edges:
    x0, y0 = G.node[edge[0]]['pos']
    x1, y1 = G.node[edge[1]]['pos']
    weight = float(G.edges[edge]['TransactionAmt']) / max(edge1['TransactionAmt']) * 10
    trace = go.Scatter(x=tuple([x0, x1, None]), y=tuple([y0, y1, None]),
                       mode='lines',
                       line={'width': weight},
                       marker=dict(color=colors[index]),
                       line_shape='spline',
                       opacity=1)
    traceRecode.append(trace)
    index = index + 1

Также добавим невидимую точку в середине каждого ребра, чтобы работало наведение курсора мыши на ребро для отображения детальной информации (на данный момент Dash позволяет обрабатывать событие наведения курсора только на точки, указанные в наборе данных).

middle_hover_trace = go.Scatter(x=[], y=[], hovertext=[], mode='markers', hoverinfo="text",marker={'size': 20, 'color': 'LightSkyBlue'},opacity=0)
index = 0
for edge in G.edges:
    x0, y0 = G.node[edge[0]]['pos']
    x1, y1 = G.node[edge[1]]['pos']
    hovertext = "From: " + str(G.edges[edge]['Source']) + "<br>" + "To: " + str(
        G.edges[edge]['Target']) + "<br>" + "TransactionAmt: " + str(
        G.edges[edge]['TransactionAmt']) + "<br>" + "TransactionDate: " + str(G.edges[edge]['Date'])
    middle_hover_trace['x'] += tuple([(x0 + x1) / 2])
    middle_hover_trace['y'] += tuple([(y0 + y1) / 2])
    middle_hover_trace['hovertext'] += tuple([hovertext])
    index = index + 1
traceRecode.append(middle_hover_trace)

Наконец, опишем вид самого поля с графом:

figure = {
    "data": traceRecode,
    "layout": go.Layout(title='Interactive Transaction Visualization', showlegend=False, hovermode='closest',
                        margin={'b': 40, 'l': 40, 'r': 40, 't': 40},
                        xaxis={'showgrid': False, 'zeroline': False, 'showticklabels': False},
                        yaxis={'showgrid': False, 'zeroline': False, 'showticklabels': False},
                        height=600,
                        clickmode='event+select',
                        annotations=[
                            dict(
                                ax=(G.node[edge[0]]['pos'][0] + G.node[edge[1]]['pos'][0]) / 2,
                                ay=(G.node[edge[0]]['pos'][1] + G.node[edge[1]]['pos'][1]) / 2, axref='x', ayref='y',
                                x=(G.node[edge[1]]['pos'][0] * 3 + G.node[edge[0]]['pos'][0]) / 4,
                                y=(G.node[edge[1]]['pos'][1] * 3 + G.node[edge[0]]['pos'][1]) / 4, xref='x', yref='y',
                                showarrow=True,
                                arrowhead=3,
                                arrowsize=4,
                                arrowwidth=1,
                                opacity=1
                            ) for edge in G.edges]
                        )}

Демонстрация работы получившегося веб-приложения:

Полный код приложения опубликован автором в ее репозитории на GitHub и занимает 303 строки. Далее можно заняться развёртыванием приложения, например, при помощи Heroku.

Комментарии (4)


  1. leshabirukov
    11.04.2023 15:59
    +1

    Я так понял, Plotly в основном про графики, не про графы, самый ходовой визуализатор графов скорее graphviz, еще pyvis прикольный. Для описания, -networkx да, выбор по-умолчанию, но рекомендую посмотреть в сторону неявных графов, nographs. Оно куда беднее функционально и менее популярно, зато не надо явно создавать образ описываемой структуры, всегда приятно избежать дублирования, синхронизации и т.п.


    1. NechkaP Автор
      11.04.2023 15:59

      Спасибо за комментарий, соглашусь, что Plotly не самый очевидный выбор для отрисовки именно графов.
      Мотивацию автора оригинала я могу только угадывать, но в моем случае (собственно, почему я изначально нашла этот туториал, и он мне помог понять, с чего начать) задача стояла так: быстро сделать веб-приложение, которое будет интерактивно отображать поле с графом (и позволит юзеру его редактировать, в общем, чуть больше элементов интерфейса и обработки событий, чем в этом посте), а еще сможет быть легко развернуто на сервере.

      Чистый Flask в сочетании с более классическими библиотеками для графов вроде graphviz - это уже штука с более высоким порогом входа, если специализируешься скорее на анализе данных в Jupyter, нежели на полноценной веб-разработке с бэкендом и фронтендом. Но тоже, конечно, стоит изучения (а вот про nographs слышу впервые, посмотрю, спасибо).

      Ну и в конце концов, все это можно рассматривать как пример чуть менее банального юзкейса Plotly/Dash, чем классические графики на главной страничке документации :)


  1. twistfire92
    11.04.2023 15:59
    +1

    Полтора года назад работал с Dash, как раз строил графы. И конкретно для графов использовал Dash-cytoscape. Советую обратить внимание. Гораздо проще использовать, плюс гораздо больше интерактивности.


    1. NechkaP Автор
      11.04.2023 15:59

      Спасибо за дополнение, ознакомлюсь)