2017-06-25

О matplotlib

Погрузился я в пучины Питона. И сделал для себя несколько открытий.
Про Flask как-нибудь в другой раз, если будет повод. А пока про другое.
Matplotlib logo
Графики. В математическом смысле. Нарисовать, показать, визуализировать. Помнится, впервые я потыкал matplotlib несколько лет назад. Когда нужно было нарисовать график по простыне чисел из логов. А вот сейчас пришлось с нею (библиотекой) познакомиться поближе.
В простейшем случае использование matplotlib выглядит очень просто.
from matplotlib import pyplot as plt

plt.figure()

x = [1, 2, 3]
y = [0.5, 1.8, 0.7]

plt.plot(x, y)

plt.show()
Этот код нарисует график по трём точкам.
figure
plt.show() попытается запустить графическое окошко, где будет показан график, и откуда можно сохранить это дело в png файл.
Можно сразу сохранить в файл.
plt.savefig('fig1.png')
И это вполне работает даже на серверах без X. Правда, для без X может понадобиться явно задать бэкенд, не использующий графику.
import matplotlib as mpl
mpl.use('Agg')
Заметьте, что всякое рисование, это вызовы функций (вроде plot()) модуля pyplot. Именно такие вызовы вы увидите во всех примерах в документации matplotlib. И во всех ответах на StackOverflow. Рисование происходит на текущем рисунке. А новый текущий рисунок создаётся вызовом plt.figure(). Вполне академичный и питоновый подход.
Но любой более-менее опытный программист отметит, что этот подход потоконебезопасен. Пока мы рисуем в одном потоке, другой поток может создать новый текущий рисунок. В академической среде, где matplotlib используется для визуализации данных, это никого не волнует. В серьёзном продакшине это тоже не создаёт особенных проблем, потому что потоками в Питоне никто не любит пользоваться, все пользуются процессами. Даже для серьёзной параллельности рекомендуют заменить threading на multiprocessing.
Но на самом деле всё рисование делается через объект Axes (не топоры, а оси), который есть у каждого рисунка (а часто и не один). А всякие манипуляции с рисунком в целом, в том числе и сохранение в файл, делается через объект Figure (не фига, а рисунок). Так что эту потенциальную потоконебезопасность можно обойти.
figure = plt.figure()
axes = figure.gca()

x = [1, 2, 3]
y = [0.5, 1.8, 0.7]

axes.plot(x, y)

figure.savefig('fig2.png')
Вернёмся к рисованию.
Если не нравится, как отмасштабировался график по умолчанию, можно задать диапазоны по осям.
axes.set_xbound(0, 4)
axes.set_ybound(0, 2)
figure
Конечно же существуют и get-методы для получения текущих значений.
Можно всяко-разно-хитро форматировать метки на осях, расставлять подписи и заголовки. Какую-то магию, вроде задания форматтеров, нужно делать до рисования. Другую магию, вроде красивого оформления меток на осях, нужно выполнять после рисования.
from datetime import date
import matplotlib.dates as mdates
import matplotlib.ticker as mtick

x = [date(2017, 6, 24), date(2017, 6, 25), date(2017, 6, 26)]   # даты
y = [50, 100, 40]                                               # проценты

figure = plt.figure()
axes = figure.gca()

axes.set_title('Tick format example')   # заголовок графика
axes.set_xlabel('Dates')                # заголовок оси X
axes.set_ylabel('Percents')             # заголовок оси Y

axes.xaxis.set_major_locator(
    mdates.DayLocator())                # местоположение меток по оси X, на каждый день
axes.xaxis.set_major_formatter(
    mdates.DateFormatter('%Y-%m-%d'))   # формат меток по оси X, в формате strftime
axes.yaxis.set_major_formatter(
    mtick.FormatStrFormatter('%.0f%%')) # формат меток по оси Y, в формате оператора %

axes.plot(x, y)

figure.autofmt_xdate()     # красиво расположить метки по оси X
figure
Можно рисовать столбчатые диаграммы. По оси X тут будут равномерно распределённые попугаи, которые обычно генерируют библиотекой numpy, которая тоже постоянно используется для матана на Python и является обязательной зависимостью matplotlib.
import numpy as np

values = [40, 60, 50]               # значения
tick_labels = ['A', 'B', 'C']       # подписи к столбцам

positions = np.arange(len(values))  # положения столбцов по оси X
width = 0.35                        # ширина столбцов

figure = plt.figure()
axes = figure.gca()

axes.bar(positions, values, width, tick_label=tick_labels,
         align='center')            # по центру — красивее
figure
Можно рисовать круговые (пироговые, pie chart) диаграммы. И добавлять легенду.
values = [40, 60, 50]       # значения
labels = ['A', 'B', 'C']    # подписи

figure = plt.figure()
axes = figure.gca()

axes.pie(values, labels=labels,
        autopct='%.0f%%')   # расставить проценты
axes.legend()

axes.axis('equal')          # чтобы круг был круглым
figure
Есть ещё довольно много типов диаграмм из коробки: графики с метками ошибок, диаграммы рассеивания, графики в логарифмическом масштабе, графики с заливкой цветом и другие.
Но вот если хочется нарисовать что-то, что не рисуется стандартными диаграммами, приходится опускаться на один уровень абстракции ниже.
Например, чтобы добавить надпись над столбцами столбчатой диаграммы, приходится вручную высчитывать координаты текста.
values = [40, 60, 50]               # значения
labels = ['one', 'two', 'three']    # надписи над столбцами
tick_labels = ['A', 'B', 'C']       # подписи под столбцами

positions = np.arange(len(values))
width = 0.35

figure = plt.figure()
axes = figure.gca()

axes.bar(positions, values, width, tick_label=tick_labels,
         align='center')

y0, y1 = axes.get_ybound()      # размер графика по оси Y
y_shift = 0.1 * (y1 - y0)       # дополнительное место под надписи

for i, rect in enumerate(axes.patches):     # по всем нарисованным прямоугольникам
    height = rect.get_height()
    label = labels[i]
    x = rect.get_x() + rect.get_width() / 2 # посередине прямоугольника
    y = y0 + height + y_shift / 2           # над прямоугольником в середине доп. места
    axes.text(x, y, label, ha='center', va='center')    # выводим текст

axes.set_ybound(y0, y1 + y_shift)   # меняем размеры графика, чтобы надписи поместились
figure
А чтобы сделать бубликовую диаграмму (или пончиковую, donut chart), придётся нарисовать круговую, а в центре нарисовать кружок. Либо вручную высчитывать координаты и рисовать сектора.
from matplotlib.patches import Circle

values = [40, 60, 50]       # значения
labels = ['A', 'B', 'C']    # подписи

figure = plt.figure()
axes = figure.gca()

axes.pie(values, labels=labels,
        autopct='%.0f%%',   # расставить проценты
        pctdistance=0.7)    # проценты чуть подальше от центра
axes.add_patch(Circle((0, 0),           # круг посередине
                      radius=0.5,       # радиусом 0.5
                      facecolor='w',    # с белой заливкой
                      edgecolor='b'))   # и чёрной окантовкой

axes.axis('equal')          # чтобы круг был круглым
figure
Таких низкоуровневых примитивов весьма много. Большинство из них — наследники класса Patch. Тут есть прямоугольники, эллипсы, многоугольники, сектора и дуги, стрелки, прямые и даже кривые Безье. Текст, оси и прочее — тоже примитивы, отдельно. В общем-то, весьма полноценная векторная графика получается.
Всё это рисуется не в пиксельных координатах, а в координатах данных. Та же круговая диаграмма — это сектора с центром в точке (0, 0) и радиусом в единицу. С одной стороны, удобно, всякие метки и прочий текст можно прицепить именно что к конкретной точке данных. С другой стороны, несколько непривычно. В конце концов, даже полярные координаты поддерживаются.
Кстати, текст можно писать в TeX.
А ещё matplotlib очень круто работает с цветами. Помимо возможности задавать цвета в виде RGB, RGBA (да, есть прозрачность), HSV, в нотации HTML, в виде кучи предопределённых имен, есть ещё и карты цветов (colormap). Когда вы рисуете кучу графиков вместе, вам же хочется каждую линию покрасить в свой цвет, и чтобы всё вместе смотрелось красиво. Вот matplotlib и имеет кучу предопределённых палитр на любой вкус. Конечно же, можно создать и свою.
Можно делать рисунки, содержащие много графиков. При этом в рамках одной Figure создаётся именно множество Axes.
import matplotlib.gridspec as gridspec

figure = plt.figure()
grid = gridspec.GridSpec(3, 3)   # сетка 3х3

figure.suptitle('Many graphs')   # общий заголовок

axes0 = figure.add_subplot(grid[0, 0])      # ячейка в 0 строке и 0 столбце
axes0.set_title('cell')

axes1 = figure.add_subplot(grid[0, 1:])     # ячейка в 0 строке с 1 столбца и до конца
axes1.set_title('colspan')

axes2 = figure.add_subplot(grid[1:, 0])     # ячейка c 1 строки и до конца в 0 столбце
axes2.set_title('rowspan')

axes3 = figure.add_subplot(grid[1:, 1:])    # большая ячейка
axes3.set_title('big cell')

figure.tight_layout()   # чтобы не налезали друг на друга
figure
matplotlib либо показывает графики в примитивном GUI, либо генерирует картинки. Но у нас ведь веб (2.0). А в вебе есть потрясная библиотека d3(.js). И неудивительно, что захотелось скрестить ужа с ежом. Создавать диаграммы в matplotlib, а рисовать их в браузере через d3. Этим занимается библиотечка mpld3.
Принцип работы интересный. Из рисунка (Figure) из matplotlib создаётся некий JSON, питоновой библиотекой. Этот JSON скармливается JS библиотеке, которая использует (и зависит от) d3, чтобы нарисовать что нужно. Питоновая часть mpld3 может сама сгенерировать весь HTML, достаточный для запуска в браузере.
from matplotlib import pyplot as plt
import mpld3

figure = plt.figure()
axes = figure.gca()

x = [1, 2, 3]
y = [0.5, 1.8, 0.7]

axes.plot(x, y)

mpld3.show(figure)
show() запускает сервер и открывает браузер. Но у меня это не заработало, потому что этот сервер отдаёт минифицированную версию d3.js c не-ASCII символами, не указывая кодировки.
Надо, конечно же, не использовать этот примитивный сервер. Можно, к примеру, взять тот самый JSON, который генерирует mpld3, и самостоятельно передать его в веб страницу.
mpld3_json = mpld3.fig_to_dict(figure)
figure.clear()      # освободить ресурсы matplotlib
return jsonify(mpld3_json)
А на странице, где уже подключены d3.js и mpld3.js, выполнить небольшой яваскрипт.
!function(mpld3){
    mpld3.draw_figure('{{ div_id }}', {{ mpld3_json | tojson }});
}(mpld3);
Этот скрипт нарисует график в div с указанным id. Соответственно, можно на одной странице показывать несколько графиков.
Всё прекрасно и хорошо. mpld3шные графики даже как-то интерактивны, зумятся, можно добавить всплывающие подсказки. Но mpld3 не поддерживает заметную кучу возможностей matplotlib.
В mpld3 нельзя задать форматирование подписей и цвет отображения осей. Это очень портит дело, когда у вас не числа, а, например, даты, которые очень нужно форматировать. Да что там, в mpld3 нельзя скрыть оси, что актуально, например, для круговых диаграм. Нафиг там не нужны чёрные оси слева и внизу. Максимум, что можно, хоть убрать числа с осей.
axes.set_axis_off()  # not supported by mpld3
axes.get_xaxis().set_ticks([])
axes.get_yaxis().set_ticks([])
В mpld3 нельзя задавать общий заголовок для рисунков с множеством графиков. Плюс ещё куча мелких проблем со шрифтами, штриховыми линиями и прочим. 3D графики mpld3 тоже не умеет.
Вообще хочется, раз уж мы пошли графики в браузере делать, выкинуть все эти питоновые прослойки, и рисовать напрямую в d3. Правда, придётся кодить на JavaScript, а не на Python. А ещё говорят, что где-то есть мифические аналитики, которые привыкли matplotlib. Так что...
Как же налаживать и отлаживать все эти графики? В Jupyter Notebook конечно же. Также почему-то когда-то известном как IPython.
Ставим и запускаем где-нибудь в папочке, где будем ноутбуки складировать. Актуальная версия только для Python 3, но можно доставить ядрышки, и запускать код во втором Питоне.
$ sudo pip3 install jupyter
$ cd ~/notebooks
$ jupyter-notebook
А дальше начинается интерактивная магия. Можно писать куски кода на Питоне, тут же выполнять их, тут же видеть результат. Энтузиасты машинкового обучения только в этих блокнотах и живут.
Можно и подробнейшие комментарии в Markdown вставлять, в отдельные ячейки. С поддержкой формул в TeX, конечно же.
Чтобы рисунки matplotlib сразу отображались в блокноте, нужно добавить директиву. Тогда никаких plt.show() не понадобится. Любое наличие Figure в ячейке приведёт к выводу картинки.
from matplotlib import pyplot as plt
%matplotlib inline
Чтобы вместо изображений matplotlib отображались SVGшки mpd3, можно явно вызвать метод display().
import mpld3

# ...

mpld3.display()
Либо же перманентно включить рендеринг любого matplotlib через mpld3.
import mpld3
mpld3.enable_notebook()
Понятно, что и matplotlib, и mpld3 должны быть доступны для движка блокнота.
Физически блокнот представляет собой файл с расширением .ipynb. В формате JSON, на самом деле. Все изменения периодически сами туда сохраняются. В файле хранится не только код, но и все результаты, даже картинки matplotlib. Так что для просмотра блокнота вовсе не обязательно в нём что-то запускать. И эти файлы вполне можно коммитить, GitHub их даже показывать умеет.
Вот, к примеру, ноутбук с примерами из этого поста.