[Перевод] Развертывание интерактивных визуализаций данных в реальном времени на Flask и Bokeh

image

Сегодня, в преддверии старта нового потока курса «Python для веб-разработки», делимся с вами полезным переводом статьи о небольшой интерактивной визуализации, для исследований данных о фильмах. Автор использует не только Flask и Bokeh, но и задействуя бесплатную облачную платформу баз данных easybase.io. Все подробности и демонстрации вы найдёте под катом.


Python имеет фантастическую поддержку полезных инструментов анализа: NumPy, SciPy, pandas, Dask, Scikit-Learn, OpenCV и многих других. Из библиотек визуализации данных для Python Bokeh преобладает как самая функциональная и мощная. Эта библиотека поддерживает несколько интерфейсов, охватывающих многие распространенные варианты применения.

Одна из замечательных особенностей Bokeh — возможность экспортировать рисунок в виде сырых HTML и JavaScript. Она позволяет внедрять нарисованные программно рисунки в шаблоны приложения Flask. Когда пользователь открывает веб-приложение Flask, рисунки Bokeh создаются и встраиваются в HTML-код в реальном времени.

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

  1. Как в Bokeh создать интерактивную визуализацию с пятью точками данных.
  2. Как интегрировать в проект облачную базу данных с тремя тысячами точками данных (Easybase.io).
  3. Как вставить рисунок Bokeh в шаблон Flask.
  4. Как с помощью обратных вызовов JavaScript (CustomJS) добавить виджеты Bokeh, чтобы запрашивать данные.


Часть первая


Первые шаги — установка:

pip install bokeh 
pip install Flask

Создайте файл с именем app.py и начните с такого кода:

from bokeh.models import ColumnDataSource
from bokeh.plotting import figure, output_file, show

source = ColumnDataSource()

fig = figure(plot_height=600, plot_width=720, tooltips=[("Title", "@title"), ("Released", "@released")])
fig.circle(x="x", y="y", source=source, size=8, color="color", line_color=None)
fig.xaxis.axis_label = "IMDB Rating"
fig.yaxis.axis_label = "Rotten Tomatoes Rating"

Переменная source используется для представления данных стандартным для элементов Bokeh способом. Данные передаются в объект, скармливаемые рисунку Bokeh. Этот объект — сопоставление ключей с массивом значений. Позже мы увидим, как получить доступ и манипулировать этим объектом напрямую с помощью CustomJS.

Заметим, что fig представляет визуальный компонент Bokeh. Параметр tooltips задает надпись, отображаемую при наведении курсора мыши на точку в визуализации. Кортежи этого массива структурированы так: ("NAME TO DISPLAY", "@COLUMN_NAME_IN_SOURCE"). Чтобы изменить размер отдельных точек на графике, можно изменить параметр size в fig.circle(). Оставьте параметры x, y и color одинаковыми: позже будет показано, как изменить их согласно какому-то условию.

Переменная axis_label контролирует метки осей X и Y. В нашем случае ось X измеряет рейтинг фильма в IMDB, ось Y — рейтинг Rotten Tomatoes. Позже мы увидим, что (очевидно) между ними есть положительная корреляция. Теперь можно написать такой код для передачи каких-то данных в рисунок и его отображения в нашем браузере:

currMovies = [
    {'imdbid': 'tt0099878', 'title': 'Jetsons: The Movie', 'genre': 'Animation, Comedy, Family', 'released': '07/06/1990', 'imdbrating': 5.4, 'imdbvotes': 2731, 'country': 'USA', 'numericrating': 4.3, 'usermeter': 46},
    {'imdbid': 'tt0099892', 'title': 'Joe Versus the Volcano', 'genre': 'Comedy, Romance', 'released': '03/09/1990', 'imdbrating': 5.6, 'imdbvotes': 23680, 'country': 'USA', 'numericrating': 5.2, 'usermeter': 54},
    {'imdbid': 'tt0099938', 'title': 'Kindergarten Cop', 'genre': 'Action, Comedy, Crime', 'released': '12/21/1990', 'imdbrating': 5.9, 'imdbvotes': 83461, 'country': 'USA', 'numericrating': 5.1, 'usermeter': 51},
    {'imdbid': 'tt0099939', 'title': 'King of New York', 'genre': 'Crime, Thriller', 'released': '09/28/1990', 'imdbrating': 7, 'imdbvotes': 19031, 'country': 'Italy, USA, UK', 'numericrating': 6.1, 'usermeter': 79},
    {'imdbid': 'tt0099951', 'title': 'The Krays', 'genre': 'Biography, Crime, Drama', 'released': '11/09/1990', 'imdbrating': 6.7, 'imdbvotes': 4247, 'country': 'UK', 'numericrating': 6.4, 'usermeter': 82}
]

source.data = dict(
    x = [d['imdbrating'] for d in currMovies],
    y = [d['numericrating'] for d in currMovies],
    color = ["#FF9900" for d in currMovies],
    title = [d['title'] for d in currMovies],
    released = [d['released'] for d in currMovies],
    imdbvotes = [d['imdbvotes'] for d in currMovies],
    genre = [d['genre'] for d in currMovies]
)

output_file("graph.html")
show(fig)


До второй части мы будем использовать массив из пяти примеров словарей, содержащих связанные с фильмами свойства. В конечном счете мы получим 3000 записей из EasyBase в режиме реального времени. Свойства в source.data — это ссылка Bokeh на то, где на графике должен отображаться элемент и то, как он должен выглядеть. Как уже было сказано, структура этой переменной представляет собой словарь, отображающий все наши атрибуты в соответствующий массив. По этой причине используется синтаксис построения встроенного массива для захвата и извлечения каждого свойства из наших данных в его массив.

Здесь видно, где именно эти элементы расположены на рисунке Bokeh: x, y и color передаются в метод circle(), а title и genre передаются во всплывающую подсказку (позже воспользуемся released, imdbvotes и genre). Свободно изменяйте значения в массивах. Например, если вы хотите, чтобы цвет точек соотносился с жанром, вот код: color = ["#FF9900" for d in currMovies], можно сделать и так: color = ["#008800", if d['genre'] == "drama" else "#FF9900" for d in curMovies]. Наконец, output_file указывает на место, где вы хотите сохранить свои более поздние рисунки. show(fig) сохраняет рисунок в этом месте и открывает его в вашем браузере. Запустите файл — и вот, что вы увидите:

chart plotting IMDB and Rotten Tomatoes ratings of movie titles

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

Часть вторая


Вот ссылка на CSV-файл с тремя тысячами записей фильмов с теми же атрибутами, что в нашем примере.

Давайте поместим записи в базу данных, чтобы обращаться к ним асинхронно и управлять ими из подходящего источника. Я воспользуюсь easybase.io потому, что это бесплатно и не нужно ничего скачивать, подробнее об этом здесь. Кроме того, легко заполнить коллекцию содержимым файла CSV или JSON. Войдите в EasyBase и создайте таблицу по крайней мере с такими столбцами (свободно добавляйте другие атрибуты, если хотите):

_ohcghlt_11ffq_xl5jnd0efgim.png

Как только эта таблица откроется, нажмите кнопку + и перейдите к экрану «upload data».

-tvaysgrelqwwsd8ld9gd1wkfig.gif

Перетащите файл CSV в это диалоговое окно. Полученная коллекция будет выглядеть примерно так:

mjwkx3sryftt8lhyvppsfsjtkm0.png

Помните, что всегда можно загрузить данные в формате CSV или JSON из EasyBase, выбрав всё (в разделе +) и перейдя к разделу «share».

Перейдите в Integrate → REST → GET. Откройте свою новую интеграцию в Get, добавьте все столбцы. Сохранитесь, а затем откройте всплывающее окно интеграции. Мое окно выглядит так:

jgswdpvx5_4nvqs0p9dsyxsm7ou.gif

Обратите внимание на ваш идентификатор интеграции — именно через него мы будем извлекать данные из приложения.

Часть третья


Теперь давайте превратим приложение в проект Flask. Перейдите в каталог с файлом app.py. Создайте папку с именем template, добавьте файл с именем index.html.

project
├── templates
│   └── index.html
└── app.py


В файле app.py посмотрим очень простую реализацию приложения Flask:


from flask import Flask, render_template

app = Flask(__name__)

@app.route('/')
def index():
    return render_template('index.html')

if __name__ == "__main__":
    app.run(debug=True)


Запустите программу командой export FLASK_APP=app.py && flask run на Mac или set FLASK_APP=app.py && flask run на Windows, будет создан веб-сервер. Перейдите по адресу localhost:5000, приложение отобразит templates/index.html. Напишем в файле index.html такой код:




    
    
    Document
    {{ js_resources|indent(4)|safe }}
    {{ css_resources|indent(4)|safe }}
    {{ plot_script|indent(4)|safe }}


    

Flask + Bokeh + EasyBase.io

{{ plot_div|indent(4)|safe }}


Это очень простая веб-страница с четырьмя текучими [прим. перев. — конечно, нет никаких «текучих атрибутов», вероятно, речь идет об этом видео] атрибутами: js_resources, css_resources, plot_script и plot_div. Bokeh даст нам все передаваемые в эти атрибуты переменные. Во-первых, мы намерены совместить код из первой части с app.py. Начнем с импортирования модулей:

from flask import Flask, render_template
from easybase import get
from bokeh.models import ColumnDataSource, Div, Select, Slider, TextInput
from bokeh.io import curdoc
from bokeh.resources import INLINE
from bokeh.embed import components
from bokeh.plotting import figure, output_file, show


Добавьте код из первой части в метод index() перед вызовом return render_template("index.html"). Я заменю захардкоженный массив фильмов вызовом get () в EasyBase. Если у вас не установлена библиотека EasyBase, установите ее так: pip easybase-python. Я заменяю массив из первой части вот таким методом:

def selectedMovies():
    res = get("Dt-p-a0jVTBSVQji", 0, 3000, "password")
    return res
  
# ...

currMovies = selectedMovies()


Первый параметр get() — это идентификатор интеграции из предыдущей версии, после идут offset, length и authentication.

Как и в первой части, этот метод возвращает массив словарей. У этих словарей атрибуты те же, что и раньше.

В методе index() заменим return render_template("index.html") вот этим кодом:

script, div = components(fig)
return render_template(
    'index.html',
    plot_script=script,
    plot_div=div,
    js_resources=INLINE.render_js(),
    css_resources=INLINE.render_css(),
).encode(encoding='UTF-8')


Ниже перечисление вводимых в шаблон переменных.

  • plot_script: JavaScript рисунка
  • plot_div: HTML рисунка внутри тега div
  • js_resources: основной и требуемый Boken JavaScript
  • css_resources: Основной и требуемый Bokeh CSS


Теперь app.py будет выглядеть примерно так:

from flask import Flask, render_template
from easybase import get
from bokeh.models import ColumnDataSource, Div, Select, Slider, TextInput
from bokeh.io import curdoc
from bokeh.resources import INLINE
from bokeh.embed import components
from bokeh.plotting import figure, output_file, show

app = Flask(__name__)

@app.route('/')
def index():
    def selectedMovies():
        res = get("Dt-p-a0jVTBSVQji", 0, 3000, "password")
        return res
    
    source = ColumnDataSource()

    fig = figure(plot_height=600, plot_width=720, tooltips=[("Title", "@title"), ("Released", "@released")])
    fig.circle(x="x", y="y", source=source, size=5, color="color", line_color=None)
    fig.xaxis.axis_label = "IMDB Rating"
    fig.yaxis.axis_label = "Rotten Tomatoes Rating"

    currMovies = selectedMovies()

    source.data = dict(
        x = [d['imdbrating'] for d in currMovies],
        y = [d['numericrating'] for d in currMovies],
        color = ["#FF9900" for d in currMovies],
        title = [d['title'] for d in currMovies],
        released = [d['released'] for d in currMovies],
        imdbvotes = [d['imdbvotes'] for d in currMovies],
        genre = [d['genre'] for d in currMovies]
    )

    script, div = components(fig)
    return render_template(
        'index.html',
        plot_script=script,
        plot_div=div,
        js_resources=INLINE.render_js(),
        css_resources=INLINE.render_css(),
    ).encode(encoding='UTF-8')

if __name__ == "__main__":
    app.run(debug=True)


Выполните программу командой export FLASK_APP=app.py && flask run на Mac или set FLASK_APP=app.py && flask run на Windows. Ваш сайт на localhost:5000 должен выглядеть примерно так:

gxgwce3kixdtthd5xprcyxqds70.png

Часть четвертая


Итак, у нас есть отображаемая Flask модель Bokeh. Конечная цель — добавить виджеты пользовательского интерфейса, с помощью которых пользователи смогут манипулировать данными. Все изменения будут касаться метода index(). Я очень старался сделать реализацию легко расширяемой. Давайте сначала создадим словарь элементов управления:

genre_list = ['All', 'Comedy', 'Sci-Fi', 'Action', 'Drama', 'War', 'Crime', 'Romance', 'Thriller', 'Music', 'Adventure', 'History', 'Fantasy', 'Documentary', 'Horror', 'Mystery', 'Family', 'Animation', 'Biography', 'Sport', 'Western', 'Short', 'Musical']

controls = {
    "reviews": Slider(title="Min # of reviews", value=10, start=10, end=200000, step=10),
    "min_year": Slider(title="Start Year", start=1970, end=2021, value=1970, step=1),
    "max_year": Slider(title="End Year", start=1970, end=2021, value=2021, step=1),
    "genre": Select(title="Genre", value="All", options=genre_list)
}

controls_array = controls.values()


Мы видим три ползунка и выпадающее меню. Свободно редактируйте или добавляйте любые пользовательские виджеты в этот словарь. А нам нужно реализовать обратный вызов, выполняемый при изменении любого элемента управления. Это делается с помощью модуля Bokeh CustomJS. Четвертая часть статьи может быть сложной, но я постарался разобрать все как можно понятнее. Начнем с переменной обратного вызова:

callback = CustomJS(args=dict(source=source, controls=controls), code="""
    if (!window.full_data_save) {
        window.full_data_save = JSON.parse(JSON.stringify(source.data));
    }
    var full_data = window.full_data_save;
    var full_data_length = full_data.x.length;
    var new_data = { x: [], y: [], color: [], title: [], released: [], imdbvotes: [] }
    for (var i = 0; i < full_data_length; i++) {
        if (full_data.imdbvotes[i] === null || full_data.released[i] === null || full_data.genre[i] === null)
            continue;
        if (
            full_data.imdbvotes[i] > controls.reviews.value &&
            Number(full_data.released[i].slice(-4)) >= controls.min_year.value &&
            Number(full_data.released[i].slice(-4)) <= controls.max_year.value &&
            (controls.genre.value === 'All' || full_data.genre[i].split(",").some(ele => ele.trim() === controls.genre.value))
        ) {
            Object.keys(new_data).forEach(key => new_data[key].push(full_data[key][i]));
        }
    }
    source.data = new_data;
    source.change.emit();
""")


Функция code вызывается при любом изменении входных данных. И вызывается она с доступными аргументами: source (исходные данные) и controls (словарь controls). Первая часть code проверяет, существует ли глобальная переменная JavaScript с именем full_data_save. Поскольку эта переменная не существует при первом запуске этой функции, функция создаст глубокую копию необработанных данных и сохранит их в этой глобальной переменной.

Теперь full_data_save не будет изменяться, поэтому у нас всегда будет ссылка на исходные данные. Затем создается новый объект с именем new_data, который принимает тот же формат, что у исходных данных. После выполняется цикл по всем исходным данным с проверкой того, удовлетворяют ли данные значению элемента управления. Видно, что доступ к значению элементов управления осуществляется через controls.*control_name*.value, аналогично тому, как исходные данные мы получили через аргументы CustomJS. Поскольку атрибут released имеет формат MM-DD-YYYY, чтобы сравнить его с min_year и max_year (строки 17–18), я воспользуюсь только последними четырьмя символами. Если отдельный элемент удовлетворяет всем запросам пользователя, он перемещается в new_data с помощью приведенной ниже строки:


Object.keys(new_data).forEach(key => new_data[key].push(full_data[key][i]));


Код отправляет все атрибуты full_data по индексу i в соответствующий массив new_data. После просмотра всех данных в цикле исходными данными становятся новые данные. Наконец, изменения отправляются с помощью функции source.change.emit(). Я пытался сделать код расширяемым, поэтому рабочий процесс добавления нового элемента управления выглядит так:

  1. Добавляем новый виджет в словарь controls.
  2. Внутри CustomJS, добавляем написанное нами условное выражение в строку 15.


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

for single_control in controls_array:
    single_control.js_on_change('value', callback)


И последнее — добавим элементы управления в layout. Заменим предыдущую строку script, div = components(fig) вот так:

    
    inputs_column = column(*controls_array, width=320, height=1000)
    layout_row = row([ inputs_column, fig ])
    
    script, div = components(layout_row)


Код создает колонку элементов управления под названием input_controls, за которым следует строка input_controls и рисунок. Теперь передаем эту строку в метод components(), а не просто в рисунок. И запустим приложение:

l8s45qmp0wgf5kn8marnsf5f5j8.gif

Заключение


Наше приложение успешно интегрировало интерактивный Bokeh рисунок с пользовательскими обратными вызовами на JavaScript. Обратные вызовы выполняются при редактировании любого из наших элементов управления и позволяют выполнять интерактивные запросы из нескольких источников. Python извлекает тысячи записей из Easybase.io с помощью пакета easybase-python, и все эти технологии успешно работают на локальном экземпляре Flask.

Теперь добавим маршрут для пользователей, чтобы они могли добавлять данные в нашу базу данных из приложения Flask. Визуализации Bokeh будут обновляться в режиме реального времени. Спасибо, что прочитали! Не стесняйтесь оставлять комментарии с любыми вопросами. Ниже я добавил весь исходный код app.py:

Простыня исходного кода
from flask import Flask, render_template
from easybase import get
from bokeh.models import ColumnDataSource, Select, Slider
from bokeh.resources import INLINE
from bokeh.embed import components
from bokeh.plotting import figure
from bokeh.layouts import column, row
from bokeh.models.callbacks import CustomJS

app = Flask(__name__)

@app.route('/')
def index():
    
    genre_list = ['All', 'Comedy', 'Sci-Fi', 'Action', 'Drama', 'War', 'Crime', 'Romance', 'Thriller', 'Music', 'Adventure', 'History', 'Fantasy', 'Documentary', 'Horror', 'Mystery', 'Family', 'Animation', 'Biography', 'Sport', 'Western', 'Short', 'Musical']

    controls = {
        "reviews": Slider(title="Min # of reviews", value=10, start=10, end=200000, step=10),
        "min_year": Slider(title="Start Year", start=1970, end=2021, value=1970, step=1),
        "max_year": Slider(title="End Year", start=1970, end=2021, value=2021, step=1),
        "genre": Select(title="Genre", value="All", options=genre_list)
    }

    controls_array = controls.values()

    def selectedMovies():
        res = get("Dt-p-a0jVTBSVQji", 0, 2000, "password")
        return res

    source = ColumnDataSource()

    callback = CustomJS(args=dict(source=source, controls=controls), code="""
        if (!window.full_data_save) {
            window.full_data_save = JSON.parse(JSON.stringify(source.data));
        }
        var full_data = window.full_data_save;
        var full_data_length = full_data.x.length;
        var new_data = { x: [], y: [], color: [], title: [], released: [], imdbvotes: [] }
        for (var i = 0; i < full_data_length; i++) {
            if (full_data.imdbvotes[i] === null || full_data.released[i] === null || full_data.genre[i] === null)
                continue;
            if (
                full_data.imdbvotes[i] > controls.reviews.value &&
                Number(full_data.released[i].slice(-4)) >= controls.min_year.value &&
                Number(full_data.released[i].slice(-4)) <= controls.max_year.value &&
                (controls.genre.value === 'All' || full_data.genre[i].split(",").some(ele => ele.trim() === controls.genre.value))
            ) {
                Object.keys(new_data).forEach(key => new_data[key].push(full_data[key][i]));
            }
        }
        
        source.data = new_data;
        source.change.emit();
    """)

    fig = figure(plot_height=600, plot_width=720, tooltips=[("Title", "@title"), ("Released", "@released")])
    fig.circle(x="x", y="y", source=source, size=5, color="color", line_color=None)
    fig.xaxis.axis_label = "IMDB Rating"
    fig.yaxis.axis_label = "Rotten Tomatoes Rating"

    currMovies = selectedMovies()

    source.data = dict(
        x = [d['imdbrating'] for d in currMovies],
        y = [d['numericrating'] for d in currMovies],
        color = ["#FF9900" for d in currMovies],
        title = [d['title'] for d in currMovies],
        released = [d['released'] for d in currMovies],
        imdbvotes = [d['imdbvotes'] for d in currMovies],
        genre = [d['genre'] for d in currMovies]
    )

    for single_control in controls_array:
        single_control.js_on_change('value', callback)

    inputs_column = column(*controls_array, width=320, height=1000)
    layout_row = row([ inputs_column, fig ])

    script, div = components(layout_row)
    return render_template(
        'index.html',
        plot_script=script,
        plot_div=div,
        js_resources=INLINE.render_js(),
        css_resources=INLINE.render_css(),
    )

if __name__ == "__main__":
    app.run(debug=True)


Если ты построишь его — они придут. [прим. перев. Отсыл на фразу из фильма Кевина Костнера «Поле чудес»: «Если ты построишь его — он придет»].

На тот случай если вы задумали сменить сферу или повысить свою квалификацию — промокод HABR даст вам дополнительные 10% к скидке указанной на баннере.

image


Eще курсы


Рекомендуемые статьи


© Habrahabr.ru