Введение в архитектурный фреймворк для анализа данных Lena
Привет, Хабр! Я расскажу об архитектурном фреймворке, который я разрабатываю.
Архитектура определяет наиболее общее устройство программы и взаимодействие её компонентов. Lena как фреймворк реализует конкретную архитектуру для анализа данных (подробнее о ней ниже) и предоставляет пользователю классы и функции, которые могут быть при этом полезны (с учётом данной архитектуры).
Lena написана на популярном языке Python и работает с версиями Python 2, 3 и PyPy. Она опубликована под свободной лицензией Apache (версия 2) здесь. В данный момент она ещё разрабатывается, однако описываемое в данном руководстве уже используется, тестировано (общее покрытие всего фреймворка около 90%) и вряд ли будет изменено. Lena возникла при анализе данных экспериментов в физике нейтрино и названа в честь великой сибирской реки.
Вопросы архитектуры возникают, как правило, в больших и средних проектах. Если вы задумываетесь об использовании данного фреймворка, то вот краткий обзор его задач и преимуществ.
С точки зрения программирования:
- модульность, слабое зацепление. Алгоритмы могут быть легко добавлены, заменены или переиспользованы.
- производительность (с точки зрения использования памяти и скорости). Несколько видов анализа могут быть сделаны за одно чтение данных. Может использоваться PyPy с компиляцией «на лету».
- переиспользование кода. Отделение логики от презентации. Один шаблон может быть использован для нескольких графиков.
- быстрая разработка. Могут быть запущены только те элементы, которые уже работают. Во время разработки можно анализировать только малую часть данных. Результаты сложных вычислений легко сохранить.
- более понятный, структурированный и красивый код.
Уточню, что имеется в виду производительность в Python, потому что более низкоуровневые языки могут работать быстрее, хотя разработка в них может быть сложнее и дольше.
С точки зрения анализа данных:
- сравнение результатов анализа при произвольных изменениях (в том числе для разных входных данных или алгоритмов).
- переиспользование алгоритма для подмножества данных (например, увидеть, как алгоритм работает для разных позиций в детекторе).
- согласованность анализа. Когда мы запускаем несколько алгоритмов для одних данных или переиспользуем алгоритм, то мы уверены, что используем те же данные и тот же алгоритм.
- комбинация алгоритмов для более сложного анализа.
Это руководство (tutorial) — введение в архитектурный фреймворк Lena. Оно более подробное, чем обзор, потому что я сообщу основные сведения об элементах фреймворка, которые могут понадобиться при анализе данных, и постараюсь рассказать о причинах тех или иных решений. После прочтения этого введения вы сможете проводить настоящий анализ с использованием фреймворка. Впоследствии будут опубликованы следующие части руководства.
Три идеи Lena
Пример реального анализа
Элементы для разработки
При анализе данных мы часто сталкиваемся с изменением данных или алгоритмов. Например, мы хотим посмотреть, как наш анализ работает для другого набора данных или для определённой подвыборки исходных данных. Также мы можем захотеть применить разные алгоритмы и сравнить их результаты.
Чтобы легко это осуществить, нам должно быть просто изменять или расширять наш код в любой заданной точке. Идея Lena в том, чтобы разбить наш код на маленькие независимые блоки, которые затем составляются вместе. В этом руководстве показано, как это сделать, и какие последствия эта идея будет иметь для нашего кода.
Последовательности и элементы
Базовая идея Lena — объединить наши вычисления в последовательности. Последовательности состоят из элементов.
Простейшая программа Lena может быть следующей. Мы используем последовательность с одним элементом, анонимной функцией:
>>> from __future__ import print_function
>>> from lena.core import Sequence
>>> s = Sequence(
... lambda i: pow(-1, i) * (2 * i + 1),
... )
>>> results = s.run([0, 1, 2, 3])
>>> for res in results:
... print(res)
1 -3 5 -7
Поскольку Lena может работать с версиями Python 2 и 3, то в первой строке мы импортируем функцию print. Следующая строка импортирует класс фреймворка.
Sequence может быть инициализирована из нескольких элементов. Для выполнения полезной работы мы вызываем её метод run. Его аргумент должен быть итерируемым (в данном случае список из четырёх чисел).
Чтобы получить все результаты, мы пробегаем их в цикле for.
Перейдём к более сложному примеру. Часто удобно не передавать какие-либо данные в функцию, которая получает их сама где-то ещё. В этом случае мы используем последовательность Source:
from lena.core import Sequence, Source
from lena.flow import CountFrom, ISlice
s = Sequence(
lambda i: pow(-1, i) * (2 * i + 1),
)
spi = Source(
CountFrom(0),
s,
ISlice(10**6),
lambda x: 4./x,
Sum(),
)
results = list(spi())
# [3.1415916535897743]
Первый элемент Source должен иметь специальный метод __call__, который не принимает аргументы и сам генерирует значения. Эти значения передаются последовательностью: каждый последующий элемент получает на вход результаты работы предыдущего, а вызов последовательности даёт результаты последнего элемента.
CountFrom — это элемент, который производит бесконечный ряд чисел. Элементы должны быть функциями или объектами, но не классами¹. Мы передаём начальное число в CountFrom во время его инициализации (в данном случае нуль). Ключевые аргументы инициализации CountFrom — start (по умолчанию ноль) и step (по умолчанию 1).
Последующие элементы Source (если они есть) должны быть вызываемыми (callable) или объектами с методом с названием run. Они могут сами образовывать обычную Sequence.
Последовательности могут быть объединены вместе. В нашем примере мы используем ранее определённую последовательность s как второй элемент Source. Разницы бы не было, если бы мы использовали функцию из s вместо s.
Sequence может быть расположена перед, после и внутри другой Sequence. Sequence не может быть расположена перед Source, поскольку последняя не принимает никакой входящий поток (flow).
Примечание: если мы попытаемся создать экземпляр Sequence с Source в середине, инициализация сразу же откажет и выбросит LenaTypeError (подтип TypeError из Python).Все исключения Lena — подклассы LenaException. Они генерируются насколько можно раньше (не после того, как длительный анализ был осуществлён и отброшен).
Поскольку мы не можем использовать бесконечные ряды на практике, мы должны остановиться в какой-то точке. Мы берём первый миллион значений используя элемент ISlice. ISlice и CountFrom похожи на функции islice и count из модуля itertools стандартной библиотеки Python. ISlice может быть также инициализирован с ключевыми аргументами start, stop[, step], которые позволяют пропустить определённый начальный или конечный поднабор данных (определённый своим индексом) или взять каждое step значение (если step равен двум, то все чётные индексы начиная с нуля).
Мы применяем дальнейшие преобразования данных с помощью анонимной функции, и суммируем получившиеся значения.
В конце мы материализуем результаты в список и получаем грубое приближение числа пи.
Ленивые вычисления
Посмотрим на последний элемент предыдущей последовательности. Его класс обладает методом run, который принимает входящий поток flow:
class Sum():
def run(self, flow):
s = 0
for val in flow:
s += val
yield s
Заметим, что мы выдаём окончательное значение с помощью не return, а yield. Yield — это ключевое слово Python, которое превращает обычную функцию в генератор.
Генераторы — это реализация ленивых вычислений на Python. В самом первом примере мы использовали строку
>>> results = s.run([0, 1, 2, 3])
Метод Sequence run является генератором. Когда мы вызываем генератор, мы получаем результат, но никаких вычислений на самом деле не происходит, никакие инструкции из кода генератора не выполняются. Чтобы в действительности вычислить результаты, генератор должен быть материализован. Это может быть сделано с помощью контейнера (такого как список или кортеж) или в цикле:
>>> for res in results:
... print(res)
Преимущества ленивых вычислений:
- производительность. Чтение файлов данных может быть одним из самых длительных шагов в анализе данных. Поскольку ленивые вычисления используют только одно значение в данный момент, это значение может быть моментально использовано, не ожидая, когда закончится чтение всего набора данных. Это позволяет нам сделать полный анализ за практически то же время, которое бы потребовалось только для чтения входных данных.
- малое расходование памяти. Данные сразу используются и не хранятся где-либо. Это позволяет нам анализировать наборы данных большие, чем физическая память, и делает нашу программу масштабируемой.
Ленивые вычисления очень легко реализовать на Python с помощью ключевого слова yield. Генераторы нужно внимательно отличать от обычных функций в Lena. Если объект внутри последовательности имеет метод run, то он предполагается генератором. Иначе, если объект выполняем, то предполагается, что он функция, которая делает какое-то простое преобразование входящего значения.
Генераторы могут выдавать (yield) результаты ноль или неограниченное число раз. Используйте их чтобы изменить или сократить поток (flow) данных. Используйте функции или вызываемые объекты для вычислений, которые принимают и возвращают одно значение (value).
Контекст
Целью Lena является охватить процесс анализа от начала до конца. Конечные результаты анализа — таблицы и графики, которые могут быть использованы людьми.
Lena ничего не рисует сама, а полагается на другие программы. Она использует библиотеку Jinja для обработки текстовых шаблонов. В Lena нет предопределённых шаблонов или магических констант, и пользователи должны писать свои собственные. Пример одномерного графика в LaTeX:
% histogram_1d.tex
\documentclass{standalone}
\usepackage{tikz}
\usepackage{pgfplots}
\pgfplotsset{compat=1.15}
\begin{document}
\begin{tikzpicture}
\begin{axis}[]
\addplot [
const plot,
]
table [col sep=comma, header=false] {\VAR{ output.filepath }};
\end{axis}
\end{tikzpicture}
\end{document}
Это обычный TikZ шаблон, за исключением одной строки: \VAR{ output.filepath }. \VAR{ var } заменяется на текущее значение var во время рендеринга. Это позволяет использовать один шаблон для разных данных, вместо того чтобы создавать много идентичных файлов на каждый график. В данном примере переменная output.filepath передаётся в контексте рендеринга.
Более сложным примером может быть следующий:
\BLOCK{ set var = variable if variable else '' }
\begin{tikzpicture}
\begin{axis}[
\BLOCK{ if var.latex_name }
xlabel = { $\VAR{ var.latex_name }$
\BLOCK{ if var.unit }
[$\mathrm{\VAR{ var.unit }}$]
\BLOCK{ endif }
},
\BLOCK{ endif }
]
...
Если в контексте есть variable, назовём её var для краткости. Если у неё есть latex_name и unit (размерность), то эти значения могут быть использованы для метки оси x. Например, это может стать x [m] или E [keV] на картинке. Если имя или размерность не были переданы, график будет создан без метки, но также без ошибки или падения программы.
Jinja предоставляет очень богатые возможности для программирования. Шаблоны могут устанавливать переменные, использовать условные операторы и циклы. Обращайтесь к документации Jinja² за деталями.
Чтобы использовать Jinja с LaTeX, Lena немного изменила синтаксис по умолчанию³: блоки и переменные заключены в окружения \BLOCK и \VAR соответственно.
Контекст — это обычный словарь Python или его подтип. Flow в Lena состоит из кортежей пар (data, context). Обычно он не называется dataflow, потому что в нём также есть контекст. Как было показано ранее, контекст не обязателен для последовательностей Lena. Однако он значительно упрощает создание графиков и обеспечивает дополнительную к основным данным информацию. Чтобы добавить контекст в поток, просто передайте его вместе с данными как в следующем примере:
class ReadData():
"""Read data from CSV files."""
def run(self, flow):
"""Read filenames from flow and yield vectors.
If vector component could not be cast to float,
*ValueError* is raised.
"""
for filename in flow:
with open(filename, "r") as fil:
for line in fil:
vec = [float(coord)
for coord in line.split(',')]
# (data, context) pair
yield (vec, {"data": {"filename": filename}})
Мы читаем имена файлов из входящего flow и генерируем векторы координат. Мы добавляем имена файлов во вложенный словарь data (произвольно названный). На filename можно ссылаться в шаблоне как на data[«filename»] или просто data.filename.
Рендеринг шаблонов широко используется в хорошо разработанной области веб-программирования, и разница между рендерингом HTML страницы или LaTeX файла, как и любого другого текстового файла, мала. Несмотря на мощь шаблонов, хороший дизайн предполагает использование их полных возможностей лишь при необходимости. Главная задача шаблонов — создание графиков, в то время как какие-либо нетривиальные вычисления должны содержаться в самих данных (и передаваться через контекст).
Контекст делает возможным отделение данных от презентации в Lena. Это считается хорошей практикой в программировании, потому что заставляет части программы фокусироваться на своих основных задачах и предотвращает повторение кода.
Поскольку весь поток данных передаётся внутри последовательностей фреймворка, контекст также необходим, когда требуется передать дополнительные данные в последующие элементы. Разные элементы обновляют контекст из потока своим собственным контекстом, который сохраняется, если его не удалят или не изменят.
Сейчас мы готовы к настоящей обработке данных. Прочитаем данные из файла и создадим гистограмму из координат x.
Полный пример вместе с другими файлами из этого руководства можно найти в папке docs/examples/tutorial фреймворка или онлайн.
main.py
from __future__ import print_function
import os
from lena.core import Sequence, Source
from lena.math import mesh
from lena.output import HistToCSV, Writer, LaTeXToPDF, PDFToPNG
from lena.output import MakeFilename, RenderLaTeX
from lena.structures import Histogram
from read_data import ReadData
def main():
data_file = os.path.join("..", "data", "normal_3d.csv")
s = Sequence(
ReadData(),
lambda dt: (dt[0][0], dt[1]),
Histogram(mesh((-10, 10), 10)),
HistToCSV(),
MakeFilename("x"),
Writer("output"),
RenderLaTeX("histogram_1d.tex"),
Writer("output"),
LaTeXToPDF(),
PDFToPNG(),
)
results = s.run([data_file])
print(list(results))
if __name__ == "__main__":
main()
Если мы запустим этот скрипт, то итоговые графики и промежуточные файлы будут записаны в директорию output/, а вывод терминала будет близок к следующему:
$ python main.py
pdflatex -halt-on-error -interaction batchmode -output-directory output output/x.tex
pdftoppm output/x.pdf output/x -png -singlefile
[(‘output/x.png’, {‘output’: {‘filetype’: ‘png’}, ‘data’: {‘filename’: ‘../data/normal_3d.csv’}, ‘histogram’: {‘ranges’: [(-10, 10)], ‘dim’: 1, ‘nbins’: [10]}})]
Во время запуска элемент LaTeXToPDF вызывает pdflatex, и PDFToPNG вызывает программу pdftoppm. Команды выводятся со всеми аргументами, так что если во время рендеринга в LaTeX возникла ошибка, вы можете запустить эту команду вручную пока обрабатываемый файл output/x.tex не будет исправлен (и затем исправить шаблон).
Последняя строка вывода — это данные и контекст, результаты запуска (run) последовательности. Элементы, которые создают файлы, обычно генерируют пары (путь к файлу, контекст). В данном случае есть одно итоговое значение, в качестве части данных (первая часть пары) имеющее строку output/x.png.
Вернёмся к скрипту и посмотрим на последовательность более детально. Последовательность s пробегает один файл данных (в списке легко могли быть ещё). Поскольку наш ReadData генерирует пару (data, context), последующая lambda оставляет контекстную часть неизменной, и получает нулевой индекс каждого входящего вектора (нулевой части пары (data, context)).
Эта функция lambda не очень читаема, и мы увидим лучший и более общий подход в следующей части руководства. Но она показывает, как поток может быть перехвачен и преобразован в любой точке внутри последовательности.
Получающиеся x компоненты заполняют гистограмму Histogram, которая инициализируется с границами бинов (edges), определяемыми сеткой (mesh) от -10 до 10 с десятью бинами.
Эта гистограмма, после того как ей передали весь поток, преобразуется в текст формата CSV (значения, разделённые запятыми). Чтобы внешние программы (такие как pdflatex) могли использовать получившуюся таблицу, она должна быть записана в файл.
MakeFilename добавляет имя файла в словарь context[«output»]. Context.output.filename — это имя файла без пути и расширения (последнее будет установлено другими элементами в зависимости от формата данных: сначала это таблица csv, затем она может стать рисунком pdf и т.д.). Поскольку ожидается только один файл, мы можем просто назвать его x.
Элемент Writer записывает текстовые данные в файловую систему. Он инициализируется с именем директории для записи выходных данных. Чтобы значение было записано, его контекст должен иметь подсловарь «output».
После того как мы создали таблицу csv, мы можем обработать наш шаблон LaTeX histogram_1d.tex с этой таблицей и контекстом, и преобразовать график в pdf и png. Как и ранее, RenderLaTeX создаёт текст, который должен быть записан в файловую систему перед использованием.
Поздравляем: теперь вы можете делать полный анализ используя фреймворк, от начала до окончательных графиков. В конце этой части руководства мы покажем несколько элементов Lena, которые могут быть полезны при разработке.
Давайте используем структуру предыдущего анализа и добавим ещё несколько элементов в последовательность:
from lena.context import Context
from lena.flow import Cache, End, Print
s = Sequence(
Print(),
ReadData(),
# Print(),
ISlice(1000),
lambda val: val[0][0], # data.x
Histogram(mesh((-10, 10), 10)),
Context(),
Cache("x_hist.pkl"),
# End(),
HistToCSV(),
# ...
)
Print выводит значения, которые проходят через него в потоке. Если мы подозреваем ошибку или хотим увидеть, что в точности происходит в заданной точке, мы можем поместить любое число элементов Print куда захотим. Нам не нужно искать другие файлы и добавлять в них инструкции print для печати входящих и выходных значений.
ISlice, который мы встретили ранее, когда приближали число пи, ограничивает поток до заданного числа значений. Если мы не уверены, что наш анализ уже корректен, мы можем выбрать лишь небольшой объём данных, чтобы это проверить.
Context — это элемент, являющийся подклассом словаря, и он может быть использован в качестве контекста, когда нужен форматированный вывод. Если объект класса Context находится внутри последовательности, он преобразует контекстную часть потока в этот класс, который выводится с отступами (не в одну строчку, как обычный словарь). Это может помочь во время анализа человеком многих вложенных контекстов.
Cache сохраняет входящий поток или загружает его из файла. Его аргумент инициализации — имя файла, в котором будет сохранён поток. Если файл отсутствует, то Cache создаёт его, запускает предыдущие элементы, сохраняет значения из потока в файл и передаёт их дальше. Во время последующих запусков он загружает поток из файла, и никакие предыдущие элементы не выполняются. Cache использует pickle, который позволяет сериализацию и десериализацию большинства объектов Python (кроме кода функций). Если у вас есть длительное вычисление и вы хотите сохранить результаты (например, чтобы улучшить графики, находящиеся дальше в последовательности), можете использовать Cache. Если вы изменили алгоритм до Cache, просто удалите файл, чтобы он перезаполнился новым потоком.
End запускает все предыдущие элементы и останавливает анализ на этом месте. Если бы мы включили его в этом примере, Cache был бы заполнен или прочитан (как и без элемента End), но ничто не передалось бы в HistToCSV и далее. End можно использовать если точно известно, что последующий анализ не полон и потерпит неудачу.
Резюме
Lena поощряет разбиение анализа на небольшие независимые элементы, которые объединяются в последовательности. Это позволяет заменять, добавлять или удалять любой элемент или преобразовывать поток в любом месте, что может быть очень полезно при разработке. Последовательности могут быть элементами других последовательностей, что позволяет их переиспользование.
Элементы могут быть вызываемыми (callable) или генераторами. Простые вызываемые могут быть легко добавлены для преобразования каждого значения из потока, в то время как генераторы могут преобразовывать поток, добавляя в него значения или сокращая его. Генераторы позволяют делать ленивые вычисления, что снижает использование памяти и обобщает алгоритмы для использования потенциально многих значений вместо одного.
Полная информация об анализе передаётся через контекст. Ответственность пользователя — добавлять необходимый контекст и писать шаблоны графиков. Пользователь также должен предоставить начальный контекст для именования файлов и графиков, но помимо этого фреймворк передаёт и обновляет контекст самостоятельно.
Мы ввели две базовых последовательности. Sequence может помещаться до, после или внутри другой последовательности. Source похожа на Sequence, но никакая другая последовательность не может ей предшествовать.
В этой части руководства мы узнали, как сделать простой анализ данных, читаемых из файла, и как создать несколько графиков используя только один шаблон. В следующей части мы узнаем о новых типах элементов и последовательностей и как сделать несколько видов анализа за одно чтение файла данных.
Упражнения
Иван хочет лучше узнать генераторы и реализует элемент End. Он создаёт такой класс:
class End(object): """Stop sequence here.""" def run(self, flow): """Exhaust all preceding flow and stop iteration.""" for val in flow: pass raise StopIteration()
и добавляет этот элемент в пример main.py выше. Когда он запускает программу, он получает
Traceback (most recent call last): File "main.py”, line 46, in
main() File "main.py”, line 42, in main results = s.run([data_file]) File "lena/core/sequence.py”, line 70, in run flow = elem.run(flow) File "main.py”, line 24, in run raise StopIteration() StopIteration Похоже, что никакие последующие элементы, действительно, не были исполнены. Однако Иван вспоминает, что StopIteration внутри генератора должно вести к нормальному завершению и не должно быть ошибкой. Что было сделано не так?
Светлана хочет убедиться, что никакая инструкция действительно не исполняется во время вызова генератора. Напишите простой генератор, чтобы это проверить.
Count считает значения, проходящие через него. Для того чтобы он не менял поток данных, он должен добавлять результаты в контекст. Какие ещё решения по дизайну нужно рассмотреть? Напишите простую реализацию и проверьте, что она работает как элемент последовательности.
Льву не нравится, как организован вывод в предыдущих примерах.
«В наши объектно-ориентированные дни я мог воспользоваться только одним объектом чтобы сделать весь анализ»,- говорит он. «Гистограмма в CSV, Записать, Сверстать, опять Записать, … Если наша система вывода остаётся той же, и нам надо повторять то же самое в каждом скрипте, то это code bloat (раздувание кода).»
Как сделать только один элемент для всего процесса вывода? Какие преимущества и недостатки этих двух подходов?
** Вспомним реализацию Sum ранее. Предположим, вам нужно разделить поток на два и провести два анализа, так чтобы вам не нужно было считывать поток несколько раз или хранить его целиком в памяти.
Позволит ли Sum сделать это, почему? Как она должна быть изменена? Эти вопросы будут отвечены в следующей части руководства.
Ответы на упражнения даны в конце руководства.
Сноски
1. Эта возможность может быть добавлена в будущем.
2. Документация Jinja
3. Использование Jinja для вёрстки LaTeX было предложено здесь и здесь, синтаксис шаблонов был взят из оригинальной статьи.
Альтернативы
Ruffus — вычислительный конвейер (computational pipeline) для Python, используемый в науке и биоинформатике. Он соединяет компоненты программы через запись и чтение файлов.