Сегодня, в преддверии старта нового потока курса Python для веб-разработки, делимся с вами полезным переводом статьи о небольшой интерактивной визуализации, для исследований данных о фильмах. Автор использует не только Flask и Bokeh, но и задействуя бесплатную облачную платформу баз данных easybase.io. Все подробности и демонстрации вы найдёте под катом.
Python имеет фантастическую поддержку полезных инструментов анализа: NumPy, SciPy, pandas, Dask, Scikit-Learn, OpenCV и многих других. Из библиотек визуализации данных для Python Bokeh преобладает как самая функциональная и мощная. Эта библиотека поддерживает несколько интерфейсов, охватывающих многие распространенные варианты применения.
Одна из замечательных особенностей Bokeh возможность экспортировать рисунок в виде сырых HTML и JavaScript. Она позволяет внедрять нарисованные программно рисунки в шаблоны приложения Flask. Когда пользователь открывает веб-приложение Flask, рисунки Bokeh создаются и встраиваются в HTML-код в реальном времени.
Для примера создадим программу интерактивного исследования данных о фильмах. В проекте будут виджеты пользовательского интерфейса (ползунки, меню), которые обновляют данные в ответ на действия пользователя. Вот, чему научит эта статья:
- Как в Bokeh создать интерактивную визуализацию с пятью точками данных.
- Как интегрировать в проект облачную базу данных с тремя тысячами точками данных (Easybase.io).
- Как вставить рисунок Bokeh в шаблон Flask.
- Как с помощью обратных вызовов 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)
сохраняет рисунок в этом месте и открывает его в вашем браузере.
Запустите файл и вот, что вы увидите:Пока всё не слишком увлекательно, но наведите курсор мыши на любую точку, чтобы получить информацию о фильме, которая указана в
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()
. Я пытался сделать код
расширяемым, поэтому рабочий процесс добавления нового элемента
управления выглядит так:- Добавляем новый виджет в словарь
controls
. - Внутри
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% к скидке указанной на баннере.
- Курс Python для веб-разработки
- Обучение профессии Data Science
- Обучение профессии Data Analyst
- Онлайн-буткемп по Data Analytics
- Курс по JavaScript
- Профессия Java-разработчик
- SQL для анализа данных
- C++ разработчик
- Курс по аналитике данных
- Курс по DevOps
- Профессия Веб-разработчик
- Профессия iOS-разработчик с нуля
- Профессия Android-разработчик с нуля
- Курс по Machine Learning
- Курс Математика и Machine Learning для Data Science
- Продвинутый курс Machine Learning Pro + Deep Learning