Время прочтения: 11 мин.

Хочу поделиться с вами решением задачи наглядного представления большого объёма данных с возможностью детального просмотра информации по интересующим объектам.

Описание задачи

Дисклеймер: вся информация по банкоматам сгенерирована случайным образом. Любое совпадение – чистая случайность. Все адреса взяты из объявлений о продаже квартир. Данные о снятиях наличности сгенерированы через команду randint модуля random. Общая сумма снятий посчитана как сумма случайно сгенерированных чисел.

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

Исходные данные следующие: уникальный номер устройства, координаты, адрес, тип и объём снятой наличности.

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

Выбор инструмента

Для решения этой задачи я выбрал платформу Plotly Dash от канадской компании Plotly Technologies.

Основными плюсами платформы являются:

1. Интерактивность: Dash позволяет создавать интерактивные веб-приложения.

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

3. Простота использования: Dash имеет простой и интуитивно понятный API, что позволяет быстро создавать и настраивать визуализации, а также подробную документацию.

4. Поддержка: библиотека Plotly Dash активно развивается и поддерживается сообществом разработчиков, что обеспечивает её стабильность и надёжность.

5. Интеграция: Dash может интегрироваться с другими инструментами Python, такими как Pandas и NumPy, что упрощает работу с данными.

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

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

Итак, начнем воплощать идею в код.

Реализация

Устанавливаю библиотеку.

Так как для работы используется Jupyter Notebook, то и версия устанавливается для него:

!pip install jupyter-dash

Импортирую библиотеки, которые понадобятся для работы и подгружаю данные:

import pandas as pd
import numpy as np
import os
from jupyter_dash import JupyterDash
from dash import html, Input, Output, dcc, dash_table
import plotly.express as px
import plotly.graph_objs as go
atms = pd.read_pickle(os.path.join('data', 'bankomat.pkl'))

Переношу данные на карту с помощью библиотеки Plotly.

fig = go.Figure(go.Scattermapbox(lat=atms.latitude, lon=atms.longitude))

Данный код создает объект Figure с использованием библиотеки Plotly, который использует тип графика – Scattermapbox для визуализации географических данных на карте. В качестве данных для графика используются координаты широты и долготы, которые передаются в виде массивов atms.latitude и atms.longitude соответственно.

Вывожу получившийся график:

fig.update_layout(mapbox_zoom=12,
    mapbox_style='carto-positron',
    margin=dict(t=5, b=5, l=5, r=5),
    mapbox_center={'lat': 59.935567, 'lon': 30.338619})
fig.show()
  • Код обновляет макет карты, устанавливая уровень приближения на 12 (параметр mapbox_zoom)
  • Параметром mapbox_style выбираю стиль карты
  • Параметром margin настраиваю отступы карты от границ фигуры
  • Параметром mapbox_center центрирую карту на заданных координатах широты и долготы
  • На параметре mapbox_style остановлюсь более подробно.

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

  • «open-street-map»
  • «carto-positron»
  • «carto-darkmatter»
  • «stamen-terrain»
  • «stamen-toner»
  • «stamen-watercolor»

Остальные виды карт, в том числе, созданные самостоятельно на сервисе Mapbox, требуют получения токена (подробнее на сайте https://docs.mapbox.com/help/getting-started/access-tokens/). Впрочем, это не является проблемой, т.к. публичный токен, на момент написания поста, можно получить бесплатно.

В итоге, получается карта следующего вида:

Задача вывода расположения объектов выполнена, приступаю к следующему этапу.

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

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

atms['size'] = atms['cash']/ 7000000

В итоге, получаю:

И приступаю к модернизации полученного графика:

fig = go.Figure(go.Scattermapbox(lat=atms.latitude, 
lon=atms.longitude,
customdata=atms["id"],
text='Банкомат по адресу: ' \
+ atms["address"] \
+ '.<br>' + 'Было снято ' \
+ atms["cash"].astype(str) \
+ ' руб.'\
+ '<br>' + 'Тип банкомата: ' \
+ atms["type"].astype(str),
marker=go.scattermapbox.Marker(
size=atms['size'],
color=atms['type'],
colorscale=['#228B22',
'#4682B4',
'#696969'])
)
)

fig.update_layout(mapbox_zoom=12,
    mapbox_style='carto-positron',
    margin=dict(t=5, b=5, l=5, r=5),
    mapbox_center={'lat': 59.935567, 'lon': 30.338619})
fig.show()

Теперь карта выглядит более наглядно:

Банкоматы представлены на карте в виде разноцветных маркеров. Размер маркера и цвет зависят от снятой суммы и типа банкомата.

При наведении на маркер отображается следующая информация о банкомате: адрес, сумма, которая была снята, и тип банкомата.

Остановлюсь на новых параметрах в коде.

  • text – отвечает за то, какой текст будет показан при наведении на маркер. Обратите внимание, что за перевод строки отвечает тег <br> html документа;
  • marker – отвечает за маркеры на карте и настраивается через объектgo.scattermapbox.Marker, в котором задается размер маркера, передается название колонки, по которой будет производиться группировка по цвету и список цветов в параметр colorscale  (можно указывать конкретные цвета, как в примере, указывать предопределенную цветовую схему Plotly или не указывать ничего);
  • customdata – отвечает за передачу дополнительной информации при взаимодействии с элементом. В него передан id банкомата.

Полученный график можно легко интегрировать в Dash–библиотеку. Для размещения элементов на html-странице используется язык html с синтаксисом библиотеки Dash. Более подробную информацию можно найти на официальном сайте (https://dash.plotly.com/dash-html-components).

Чтобы запустить сервер Dash необходимо добавить всего несколько строк кода:

app = JupyterDash(__name__)
app.layout = html.Div(
                      [
                        html.H2("Карта банкоматов", style={'text-align': 'center'}),
                        dcc.Graph(figure=fig, id='sub_area')
                      ]
                     )
app.run_server(debug=True)

Этот код создает веб-приложение, используя библиотеку JupyterDash. Внутри приложения есть html-разметка, которая содержит заголовок и интерактивную карту, созданную с помощью библиотеки plotly.graph_objs. Функция app.run_server запускает веб-сервер для отображения приложения в браузере. Параметр debug=True позволяет выводить отладочную информацию.

Для вывода интерактивного графика на веб-страницу применяется компонент dcc.Graph. Он использует библиотеку plotly.graph_objs для создания графиков и предоставляет множество настроек для управления внешним видом и поведением графика. Компонент dcc.Graph может быть использован для отображения данных различных типов, включая линейные графики, столбчатые диаграммы, круговые диаграммы и т.д.

В итоге, в браузере можно будет увидеть следующую страницу:

Получилось интересно, но то же самое можно было бы создать и в Jupyter Notebook, поэтому далее предлагаю добавить дополнительный функционал:

  • при нажатии на маркер банкомата будут выводиться данные о снятии наличных по дням недели в виде таблицы, а точка на карте будет менять цвет, «подсвечивая» выбранный банкомат;
  • при наведении курсора на другой банкомат будет выводиться сравнительная таблица с разницей в количестве снятой наличности между двумя банкоматами (выбранным и на который наведен курсор).

Указанные выше усовершенствования помогут произвести простой сравнительный анализ показателей банкоматов, отталкиваясь от их расположения на карте.

Данные о суммах снятия наличных по дням подгружаются из файла:

detalized_atms = pd.read_pickle(os.path.join('data', 'detalized_atms.pkl'))

Загруженные данные выглядят следующим образом:

Где «id» — это идентификатор банкомата, «date» — дата на которую посчитаны данные и «cash» — сумма снятий за день.

Данные загружены, теперь можно приступать к созданию визуализации.

Для начала, в уже созданный Div блок добавляю следующий код:

html.Div(
                 [
                  dash_table.DataTable(
                  id="table_click",
                  data=detalized_atms.to_dict("records"),
                  sort_action="native",
                  page_size=10)
                ], style={'width': '48%', 'display': 'inline-block', 'margin': '15px'}
             ),

    html.Div([dash_table.DataTable(
                    id="table_hover",
                    data=detalized_atms.to_dict("records"),
                    sort_action="native",
                    page_size=10,
                    style_data_conditional=[
                    {
                        'if': {
                            'filter_query': '{cash} < 0',
                            'column_id': 'cash'
                        },
                        'backgroundColor': 'red',
                        'color': 'white'
                    },
                    {
                        'if': {
                            'filter_query': '{cash} > 0',
                            'column_id': 'cash'
                        },
                        'backgroundColor': 'green',
                        'color': 'black'
                    },
                    {
                        'if': {
                            'filter_query': '{cash} = 0',
                            'column_id': 'cash'
                        },
                        'backgroundColor': 'white',
                        'color': 'black'
                    }]
                )], style={'width': '48%', 'display': 'inline-block', 'margin': '15px'})

В первом Div блоке создается таблица с использованием библиотеки Dash и компонента dash_table.DataTable, которая будет отображаться при нажатии на карту. Для того, чтобы таблица комфортно выглядела на экране, ограничивается количество строк до 10. Данные из датафрейма передаются словарём.

Во втором Div блоке создается таблица, которая будет отображаться при наведении. В таблице дополнительно используется параметр style_data_conditional, который позволяет задавать стили для строк таблицы в зависимости от условий. В данном случае строки окрашиваются в красный цвет, если значение в столбце «cash» меньше нуля, в зелёный – если больше нуля, и в белый – если значение равно нулю.

Компоненты таблиц размещены друг за другом с помощью параметра display: inline-block и имеют ширину 48% от ширины родительского блока. Также заданы отступы от краев блока с помощью параметра margin.

Чтобы всё заработало, необходимо определить функции, которые отвечают за взаимодействие.

@app.callback(
    Output('atm', 'figure'),
    Input('atm', 'clickData')
)
def update_map(clickData):
    """Функция обновления данных на карте"""
    if clickData:
        point_index = clickData['points'][0]['pointIndex']
        fig.update_traces(marker=dict(color=['red' if i == point_index
                                                   else c for i, c in enumerate(atms['type'])]))
        fig.update_layout(
            mapbox_center={'lat': float(atms.latitude[atms.id == int(clickData['points']\
                                                                     [0]['customdata'])]),
                           'lon': float(atms.longitude[atms.id == int(clickData['points']\
                                                                      [0]['customdata'])])})
    return fig

Этот код создаёт функцию update_map, которая обновляет карту с банкоматами при клике на определённый банкомат.

Функция принимает входной параметр clickData, который содержит информацию о клике на карте. Если клик по точке произведён, то извлекается индекс выбранной точки и обновляются маркеры на карте: точка, соответствующая выбранному банкомату, окрашивается в красный цвет.

Также функция обновляет центр карты, чтобы выбранный банкомат был в центре. Для этого извлекаются координаты выбранного банкомата из датафрейма atms и передаются в параметр mapbox_center метода update_layout.

Функция возвращает обновленный объект fig, который содержит карту с обновленными маркерами и центром.

Функция используется в качестве обратного вызова для компонента dcc.Graph с id «atm» и выходным параметром «figure». При клике на карту вызывается функция update_map, которая обновляет карту и возвращает новый объект fig.

Следующим шагом определяется логика заполнения таблицы, выводимой по клику:

@app.callback(
    Output('table_click', 'data'),
    Input('atm', 'clickData')
)
def update_table_click(clickData):
"""Функция обновления данных в таблице, выводимой по клику"""
    if clickData:
        data = detalized_atms[detalized_atms.id == int(clickData['points']\
        [0]['customdata'])]\
        .sort_values('date')
        return data.to_dict("records")

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

Функция возвращает обновленный объект данных таблицы, который содержит детализацию операций в выбранном банкомате.

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

@app.callback(
    Output('table_hover', 'data'),
    Input("atm", "hoverData"),
    Input('atm', 'clickData')
)
def update_table_hover(hoverData, clickData):
"""Функция обновления данных в таблице, выводимой по наведению"""
    if clickData:
        if hoverData:
            data_click = detalized_atms[detalized_atms.id == int(clickData['points']\
       [0]['customdata'])]\
                         						      .sort_values('date')\
                         						      .reset_index(drop=True)
            data_hover = detalized_atms[detalized_atms.id == int(hoverData['points']\
          [0]['customdata'])]\
                        						          .sort_values('date')\
                        						          .reset_index(drop=True)
            cash = data_hover.cash - data_click.cash
            data_hover.cash = cash
            return data_hover.to_dict("records")

В итоге, базовый вариант выглядит следующим образом:

Заключение

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

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

Стоит отметить, что библиотека Dash представляет широкие возможности по визуализации контента и способов работы с ним. Некоторые я представил при решении своей задачи, а ещё без особых проблем можно реализовать загрузку файла в Dash приложение и обработку его по заданным сценариям, но это уже отдельная тема, материалов по которой хватит ещё ни на одну публикацию. Dash поддерживает запуск на сервере. Информацию об этом можно посмотреть, в т.ч. на официальном сайте (https://dash.plotly.com/ ).

Спасибо за внимание, успехов в работе!