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

В этой статье я расскажу о том, как организовать рабочий процесс написания кода, дам несколько советов и покажу набор инструментов для работы. Цель состоит в том, чтобы облегчить переход от экспериментов к разработке инструментов.

Jupyter Notebook: цифровая лабораторная тетрадь

Для обеспечения воспроизводимости исследований необходимо регистрировать все, что вы делаете. Это достаточно обременительно, особенно если вы просто хотите просто поэкспериментировать и выполнить специальный анализ.

Отличный инструмент для экспериментов — Jupyter Notebook. Интерактивный интерфейс программирования позволяет мгновенно проверять действия, выполняемые кодом, благодаря чему можно создавать алгоритмы шаг за шагом. Более того, вы можете использовать ячейки Markdown для записи своих идей и выводов одновременно с кодом.

В интернете можно найти множество руководств по Jupyter. Рекомендую ознакомиться с горячими клавишами, поскольку они значительно ускоряют работу.

Эксперименты с циклом for

В цифровом виде выполнение цикла по экспериментальным переменным представляет собой действительно простой процесс. В конце концов, мощь компьютеров — это их способность повторять определенные действия.

Я структурирую формальные эксперименты следующим образом:

treatments = ['control', 't1', 't2']
subjects = ['A', 'B', 'C']

def experiment_with(subject, treatment):
    # код эксперимента размещается здесь
    return result

for t in treatments:
    for s in subjects:
        result = experiment_with(s, t)
        # другие детали, такие как создание визуализаций

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

Теперь необходимо определиться, что делать с выходными данными каждой итерации: создать из них визуализации, вычислить метрику или сохранить их? Выбор зависит от применения и желаемой формы отчетности.

Централизация и перекрестные ссылки

Файл Notebook должен быть создан для каждой важной части эксперимента. Я сохраняю эксперимент с соответствующим заголовком и номером версии каждый раз, когда повторяю его с использованием разных параметров или нового способа выполнения действий.

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

Пример организации экспериментов в одной папке

В результате получаем огромную кучу файлов, которую можно отсортировать по дате изменения. Благодаря централизации конкретные файлы можно с легкостью находить с помощью простого поиска.

Главным преимуществом такого объединения является возможность реализовать перекрестные ссылки внутри и между notebooks. Выполнить это можно с помощью HTML-ссылок в Markdown.

Чтобы создать ссылку на раздел в Notebook, добавьте следующий код в ячейку Markdown перед ссылочной частью:

<a id='label_of_your_choice'></a>
<!--ссылка в файле experiment00.ipynb-->

Теперь в том месте, где должна появиться ссылка на предыдущий источник, напишите один из следующих вариантов:

[description](#label_of_your_choice) <!--to make link in the same notebook-->

[description](experiment00.ipynb#label_of_your_choice) <!--делаем ссылку на другой файл-->

Например, [previous experiment]
(experiment00.ipynb#label_of_your_choice)
 выдает ссылку:

Пример перекрестной ссылки на предыдущий эксперимент

Теперь с помощью одного нажатия мыши можно перейти к указанному разделу в предыдущем эксперименте.

Отчеты должны содержать лучшие версии экспериментов и сопутствующие комментарии. Быстрый просмотр записей является ключом к повторяемости работы.

Работа с данными

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

Для лучшего понимания я веду журнал в электронной таблице, в котором перечислены входные и выходные данные всех экспериментов.

Функциональность пакетирования

Скорее всего, вы знакомы с написанием функций внутри сценария для улучшения чистоты и понятности кода, а также обеспечения выполнения принципа DRY (не повторяйтесь). По мере развития экспериментов, то же самое происходит и с функциями, которые содержат все больше логики и других функций.

Если вы следовали предыдущим советам, то начали разделять эксперименты на несколько файлов notebook: по одному для каждого эксперимента и его версии. Когда количество функций, выполняемых от одного эксперимента к другому, значительно увеличивается, приходит время задуматься о возможности повторного использования кода. Разве не было бы здорово, если бы была возможность просто импортировать функции, прошедшие проверку временем, в код, как это делается с любой другой библиотекой Python?

Начните с сохранения функций в файлах .py вместе с экспериментами. Вы можете редактировать эти файлы в любой IDE (я использую VS Code). Затем просто используйте оператор импорта с названием файла, чтобы вернуть эту функциональность в эксперименты:

# если сохранить функцию experiment_with() в
# файле experiment_functions.py в папке, в которой 
# находится эксперимент, ее можно импортировать в код

from experiment_functions import experiment_with

Значения по умолчанию, скрывающие сложность

По мере роста сложности и гибкости, функции нуждаются в большем количестве входных данных, которыми трудно управлять. К счастью, Python облегчает установку значений по умолчанию для аргументов функций. Наличие значений по умолчанию значительно ускоряет процесс экспериментов, скрывая ненужную сложность. Более того, новичок, приступающий к работе над функциями, сможет разобраться в них гораздо быстрее.

Иногда встречаются такие ситуации, когда значение по умолчанию должно быть определено как функция других параметров. В этом случае я устанавливаю значение по умолчанию на None и проверяю в теле функции, был ли аргумент при вызове определен. Если этого не произошло, то присваиваю ему соответствующее значение. Пример:

def do_some_magic(a=10, b=None):
    # если "b" не определен при вызове, ему присваивается значение по умолчанию, которое зависит от "a"
    if b is None:
        b = 10*a
        
    # какие-либо действия

Объектно-ориентированное программирование для повышения гибкости

Для больших функций, инкапсулирующих целые алгоритмы с большим количеством параметров, простого интерфейса функции может быть недостаточно. В этой ситуации я предпочитаю гибкость объектно-ориентированного подхода.

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

class MagicAlgorithm(object):
    
    # настройте параметры алгоритма
    # в методе класса init; установите значения по умолчанию
    # так же, как и для функций
    def __init__(self, a=10, b=None):
        if b is None:
            b = 10*a
    
    # получить доступ к параметрам в методах класса
    # можно через аргумент self
    def do_some_magic(self):
        a_local = self.a
        b_local = self.b
        # какие-либо действия
        
# вы можете создать объекты алгоритма с определенной конфигурацией
# и вносить изменения параметров на ходу в рамках экспериментов
b_values = [1, 2, 3]

magicAlg = MagicAlgorithm(a=20) # создание экземпляра алгоритма
for b in b_values:
    magicAlg.b = b # изменение лишь некоторых параметров
    magicAlg.do_some_magic() # использование функциональности класса

Классы также можно использовать для создания специфичных для приложения структур данных и инкапсулирования связанной с ними функциональности. Существует целая наука о шаблонах проектирования с использованием ООП.

Контроль версий с сохранением воспроизводимости результатов

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

Именно в этот момент воспроизводимость и возможность повторного использования вступают в противоречие. При воспроизведении двухлетнего эксперимента с помощью супер развитого алгоритма могут получиться абсолютно другие результаты. Допустим, вы хотите сохранить точную версию модулей, используемых в экспериментах, вместе с файлами notebook, в которых они были использованы (если вы не хотите скрыть какую-либо глупость, сделанную в прошлом). Сохранение пронумерованного файла для каждой внесенной модификации вместе с экспериментами для записи делает ситуацию еще более запутанной.

Введите контроль версий. Инженеры-программисты обеспечивают воспроизводимость с помощью управления версиями и использования сред для обеспечения совместимости. Этот подход можно применить и к экспериментам. Контролируйте версии функций и классов по мере их улучшения и в то же время сохраняйте ссылку на соответствующие версии в файлах эксперимента.

Я делаю это следующим образом: сортирую .py файлы в папки проектов с названием <project>_func, в которых устанавливаю репозитории, и начинаю управление версиями с помощью git.

При каждом создании нового файла эксперимента, я проверяю, зафиксирована ли используемая версия кода, и записываю следующие строки в верхнюю ячейку файла эксперимента:

%%bash 
cd <project>_func # заходим в директорию, в которой находится         
                  # необходимый репозиторий
git checkout <git commit id> # восстанавливаем версию кода,
                             # которая будет использоваться

В эксперименте я импортирую функции по мере необходимости. Допустим, я недавно переехал во Францию, и теперь пишу все отчеты на французском языке. Импорт и запуск последней версии этой функции:

from magic_func.magic import do_some_magic

do_some_magic()

Получаем:

Salut Le Monde!

Проблема заключается в необходимости перезапустить старый сценарий эксперимента, который генерировал отчет на английском языке. К счастью, я ссылался на id коммита в верхней части notebook эксперимента, как было показано ранее:

%%bash
cd magic_func
git checkout cff052f7c36bb09ccc101d9ce3652dce87f2acbc

Вывод кода, показанного выше, в этом контексте выглядит следующим образом:

Hello World!

Вуаля! Волшебство контроля версий и хорошо прописанных записей.

Использование пакетов Python для обмена

В конце концов, появится необходимость упаковать файлы .py вместе с соответствующими пакетами, чтобы поделиться ими с другими людьми и установить их в средах ядер Ipython, используемых в notebook экспериментов.

Следуйте официальному руководству по упаковыванию в Python, чтобы узнать, как упаковать код, и этому руководству, чтобы загрузить его на PyPi. Чтобы использовать определенную версию пакета в Jupyter Notebook, рекомендую установить его в среде conda. Чтобы запустить ядро Jupyter в этой среде, следуйте инструкциям по установке расширения nb_conda_kernels.

От экспериментов к модульному тестированию

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

Создавать тесты можно прямо в Notebooks и просто вставлять их код в формальную структуру модульных тестов. Для своих пакетов я использую фреймворк pytest.

Если вы дошли до этой точки, то вы уже не просто экспериментатор. Вы также являетесь разработчиком, который может не только делиться созданными инструментами, а также поддерживать и улучшать их. Приятного программирования!

Читайте также:


Перевод статьи Victor Serban: How to organize code in Python if you are a scientist

Предыдущая статьяПревращаем сценарии Python в инструменты МО
Следующая статьяЭффективное использование ESLint