Русский
Русский
English
Статистика
Реклама

Перевод Развертывание интерактивных визуализаций данных в реальном времени на 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 ColumnDataSourcefrom bokeh.plotting import figure, output_file, showsource = 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 и создайте таблицу по крайней мере с такими столбцами (свободно добавляйте другие атрибуты, если хотите):



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



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



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

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



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

Часть третья


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

project templates    index.html app.py

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

from flask import Flask, render_templateapp = 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 такой код:

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <meta name="viewport" content="width=device-width, initial-scale=1.0">    <title>Document</title>    {{ js_resources|indent(4)|safe }}    {{ css_resources|indent(4)|safe }}    {{ plot_script|indent(4)|safe }}</head><body>    <h1 style="text-align: center; margin-bottom: 40px;">Flask + Bokeh + EasyBase.io</h2>    <div style="display: flex; justify-content: center;">        {{ plot_div|indent(4)|safe }}    </div></body></html>

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

from flask import Flask, render_templatefrom easybase import getfrom bokeh.models import ColumnDataSource, Div, Select, Slider, TextInputfrom bokeh.io import curdocfrom bokeh.resources import INLINEfrom bokeh.embed import componentsfrom 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_templatefrom easybase import getfrom bokeh.models import ColumnDataSource, Div, Select, Slider, TextInputfrom bokeh.io import curdocfrom bokeh.resources import INLINEfrom bokeh.embed import componentsfrom bokeh.plotting import figure, output_file, showapp = 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 должен выглядеть примерно так:



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


Итак, у нас есть отображаемая 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(), а не просто в рисунок. И запустим приложение:



Заключение


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

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

Простыня исходного кода
from flask import Flask, render_templatefrom easybase import getfrom bokeh.models import ColumnDataSource, Select, Sliderfrom bokeh.resources import INLINEfrom bokeh.embed import componentsfrom bokeh.plotting import figurefrom bokeh.layouts import column, rowfrom bokeh.models.callbacks import CustomJSapp = 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




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


Источник: habr.com
К списку статей
Опубликовано: 03.11.2020 20:15:07
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании skillfactory

Разработка веб-сайтов

Python

Визуализация данных

Flask

Skillfactory

Разработка

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru