Подтвердить что ты не робот

Обеспечение воспроизводимости в среде R

Я работаю в лаборатории вычислительной биологии, где у нас есть несколько человек, работающих над несколькими проектами, в основном в R (что меня волнует этот пост). Раньше люди просто разрабатывали свой код для каждого проекта, что может включать или не включать шаблонный код, скопированный с предыдущих проектов. Одной вещью, которую я подталкивал на протяжении многих лет, было то, чтобы принести некоторую централизованную структуру этому беспорядку, и люди идентифицируют общие шаблоны, чтобы мы могли превратить эти повторяющиеся/общие блоки кода в пакеты по всем многим причинам, которые можно было бы подумать, что хорошая вещь. Итак, теперь наши люди используют сочетание централизованных пакетов/подпрограмм в своих сценариях, специфичных для проекта.

Там здесь есть. У нас есть мандат от полномочий, которые будут заключаться в том, что каждый script для каждого проекта должен быть на 100% воспроизводимым с течением времени, насколько это возможно (и это включает в себя 100% всего кода, к которому у нас есть прямой доступ, включая наши пакеты), То есть, если я вызываю функцию foo в панели пакетов с параметром A, чтобы получить результат X сегодня, через 4 года я должен получить тот же результат. (ошибочный вывод из-за ошибок исключается здесь)

Тема воспроизводимости возникала сейчас и в R в разных кругах, но, как правило, она, как представляется, обсуждалась с точки зрения воспроизводимости процесса (например, виньетки). Это не то же самое - я могу запустить виньетку сегодня, а затем запустить тот же код через 6 месяцев с помощью обновленных пакетов и получить совершенно разные результаты.

Решение, которое было согласовано (что я не являюсь поклонником), заключается в том, что если функция или пакет необходимо изменить в непереходном совместимом изменении, оно просто получает новое имя. Таким образом, если нам нужно радикально изменить функцию foo(), ее можно было бы назвать foo2(), и если для этого требуется радикальное изменение, она называется foo3(). Это гарантирует, что любой script, который называется foo(), всегда будет получать исходный результат, позволяя вещам продвигаться вперед в репозитории пакетов. Это работает, но мне это очень не нравится - кажется, это эстетически чрезвычайно захламлено, и я волнуюсь, что это приведет к массовому запутанности с течением времени, имеющему пакеты bar, bar2, bar3, bar4... функции foo1, foo2, foo3 и т.д.

Проблема в том, что я не придумал альтернативное решение, которое действительно лучше. Одна из возможностей - отметить номера версий пакетов, R и т.д. И убедиться, что они загружены, но у них много проблем - не в последнюю очередь из-за того, что он полагается на правильную дисциплину управления версиями и подвержен ошибкам. Кроме того, эта альтернатива уже была отвергнута;) Идеально, что у нас было бы, есть какое-то понятие развития и выпуска, поскольку большинство из этих изменений, как правило, происходят раньше, а затем выравниваются с изменениями, происходящими гораздо реже. OTOH, что на самом деле означает devel, - это "пока еще не пакет" (что мы делаем), но может быть трудно точно определить, в какой момент это право, чтобы транспортировать материал. Неизменно в тот момент, когда вы считаете себя в безопасности, когда осознаете, что нет.

Таким образом, имея в виду все это, мне любопытно, если кто-то еще там справился с подобными ситуациями и как они могли бы решить что-то.

edit: просто для того, чтобы быть ясным, без обратной совместимости, я говорю не только об API и т.д., но и о выходах для заданного набора входов.

4b9b3361

Ответ 1

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

  • Управление версиями (svn, git, bzr, cvs и т.д.)
  • Тестирование устройств

Моя первая реакция заключается в том, что вам нужно институционализировать какую-то систему управления кодом. Это упростит, потому что старая версия foo() по-прежнему доступна, если вы действительно этого хотите. Из того, что вы сказали, это звучит так, будто вам нужно упаковать свои общие функции и создать какой-то график выпуска. Скрипты, требующие обратной совместимости, должны включать имя пакета и информацию о выпуске. Таким образом, ВСЕГДА можно получить foo() точно так же, как это было при написании script. Вы также должны убедиться, что люди используют только официальные версии релизов в своей работе, потому что в противном случае это может стать настоящей болью.

Я согласен, имея коллекцию foo: foo99 обречен на провал. Но по крайней мере это будет славно запутанный провал. Эстетика в сторону, это приведет вас всех бонкеров. Если foo2() является улучшением (более точным, быстрым и т.д.) Foo(), то его следует называть foo() и освобождать для использования в соответствии с расписанием рассылки вашей компании. Если он делает что-то другое, это уже не foo(). Это могут быть fooo() или superFoo() или fooMe(), но это не foo().

Наконец, вам нужно начать тестирование своих функций. (Unit Tests). Для каждой функции, которая публикуется и доступна для других, у вас должен быть четко определенный набор тестов. Если кто-то не исправляет ошибку в foo(), результаты должны оставаться неизменными. Если кто-то исправляет ошибку, то результаты должны быть более точными и, вероятно, более желательными в большинстве случаев. Если вам нужно воспроизвести старые, неправильные результаты, вы можете выкопать старую версию foo() из вашей системы управления версиями. Установив строгие модульные тесты, вы узнаете, изменились ли результаты foo. Эти знания должны помочь свести к минимуму количество функций foo(), которые вам нужны. Вместо того, чтобы создавать версию каждый раз, когда кто-то что-то подстраивает, вы можете протестировать новую версию, чтобы увидеть, соответствуют ли результаты ожиданиям. Но это сложно, потому что вы должны убедиться, что ваши тесты охватывают все, что функция может когда-либо увидеть, включая причудливые краевые случаи. В исследовательских условиях я бы предположил, что это может стать проблемой.

Ответ 2

Я не уверен в интеграции с R, но Sumatra, возможно, стоит изучить. Это позволяет вам отслеживать код и результаты. Поэтому, если вам нужно вернуться к повторному запуску этого симулятора 4 года назад, код должен быть там.

Ответ 3

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

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

R можно легко сделать переносимым, включая все установленные пакеты. Храните переносимую версию R вместе с использованными пакетами, кодом и данными на компакт-диске для каждого анализа, и вы уверены, что можете воспроизводить все, что захотите. Хорошо, вы пропустите ОС, но не можете их всех. В любом случае, если ОС имеет значение, достаточно важное для того, чтобы назвать анализ невоспроизводимым, проблема, скорее всего, будет вашим анализом. Вы не хотите никому рассказывать, что ваш результат зависит от версии Windows, которую вы используете, не так ли?

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

x <- read.table("sometable")
y <- ColSums(x)/4.3

и настройки значений или ввода

myfun <- function(i,j){
  x <- read.table(i)
  y <- ColSums(x)/j
}

Сэкономит вам и многим другим множество проблем с копировальной пастой. (Как так, объект не найден? Какой объект?)

Ответ 4

Всякий раз, когда вы хотите заморозить свой код так, чтобы его можно было воспроизводить "навсегда", например, когда опубликован ваш документ, самый безопасный способ сделать это - создать виртуальную машину, содержащую весь ваш код и данные, и программное обеспечение, необходимое для его запуска (включая операционную систему). Здесь приведен пример здесь на сайте Университета Вашингтона.

Ответ 5

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

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

Единственный способ сделать это - сохранить копию всех ваших пакетов и версии R с помощью вашего кода. Там нет центральной корпорации, которая бы следила за совместимостью с ошибками, которая позаботится об этом для вас.

Ответ 6

Что делать, если изменение в результате связано с изменением вашей операционной системы? Возможно, Microsoft исправит ошибку в Windows XP для Windows 7, а затем при обновлении - все ваши выходы разные.

Если вы хотите справиться с этим, я думаю, что лучший способ работы - хранить снимки виртуальных машин при закрытии анализа и сохранять образы виртуальных машин для последующего использования. Конечно, через пять лет у вас не будет лицензии на запуск Windows XP, так что другая проблема - одна решена с использованием операционной системы с открытым исходным кодом, такой как Linux.

Ответ 7

Я бы пошел с изображениями докеров.
Это довольно удобный способ воспроизведения ОС и всех зависимостей.
Вы создаете изображение, а затем можете его развернуть в докере, он будет полностью настроен.
Вы можете найти несколько R-докеров, доступных, чтобы вы могли легко создать свой имидж. Имея уже построенный образ, вы можете использовать его для развертывания в тестовой среде, а затем в Production.

Ответ 8

Это может быть поздний ответ, но я нашел полезным создать общую оболочку, такую ​​как следующее, особенно при быстрой итерации при разработке новой функции:

myFunction <- function(..., version = "latest"){
  if((version == "latest") || (version == 6)){
    return(myFunction06(...))
  } ...
  if((version == 1)){
    return(myFunction01(...))
  }
 }

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

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

Ответ 9

Решением может быть использование методов S4 и разрешение R-диспетчера выполнить эту работу для вас (см. пример ниже). Таким образом, вы несколько "пуленепробиваемы" в отношении возможности систематического обновления кода, не рискуя что-то сломать.

Основные преимущества

Главное, что S4-методы поддерживают множественную отправку.

Таким образом, ваша функция всегда будет foo (в отличие от необходимости отслеживать foo1, foo2 и т.д.), в то время как новые функции могут быть легко реализованы (путем добавления соответствующих методов), не касаясь "старых" методы (на которые могут положиться другие люди/пакеты).

Ключевые функции, которые вам понадобятся:

  • setGeneric
  • setMethod
  • setRefClass (справочные классы S4, личная рекомендация) или setClass (класс S4, я бы не использовал их по причине, описанной в "Дополнительные замечания" в самом конце)

"Недостатки"

  • Вам нужно переключиться с S3 на логику S4

  • Это означает, что вам нужно написать немного больше кода, чем вы могли бы использовать (общие определения методов, определения методов и, возможно, собственные вызовы класса (см. пример ниже). Но это "покупает" себя и своих кода гораздо больше структуры и делает ее более надежной.

  • Это может также означать, что вы в конечном итоге все глубже и глубже погрузитесь в мир объектно-ориентированное программирование или Объектно-ориентированный дизайн. Хотя я лично считаю, что это хорошая вещь (мое личное эмпирическое правило: чем сложнее/распространено ваше приложение, тем лучше вы отключите использование ООП), некоторые считают, что эти подходы являются R-нетипичными (я категорически не согласен с тем, R имеет превосходные OO-функции, которые поддерживаются командой Core Team) или "неподходящими" для R (это может быть правдой в зависимости от того, насколько вы полагаетесь на "ООП" /код). Если вы захотите пойти таким путем, вы можете ознакомиться с принципами объектной ориентации SOLID. Вы также можете просмотреть следующие книги: Чистый кодер и Прагматический программист.

  • Если вычислительная эффективность (например, при оценке статистических моделей) действительно важна, использование методов S4 и ссылочных классов S4 может немного замедлить вас. В конце концов, там больше кода по сравнению с S3. Но я бы рекомендовал проверить влияние этого от случая к случаю через system.time() и/или microbenchmark::microbenchmark() вместо выбора "идеологических" сторон (S3 против S4).


Пример

Начальная функция

Предположим, что вы находитесь в отделе A, а кто-то из вашей команды начал с создания функции под названием foo()

foo <- function(x, y) {
    x + y
}
foo(x=10, y=20)

Первый запрос на изменение

Вы хотели бы расширить его, не нарушая "старый" код, который полагается на foo().

Теперь, я думаю, мы все согласны с тем, что это может быть довольно сложно сделать.

Вам либо нужно явно изменить исходный код foo() (каждый раз, когда вы рискуете сломать что-то, что уже использовалось для работы, это нарушает "O" в SOLID: Open Closed-Principle), или вам нужно использовать альтернативные имена, такие как foo1, foo2 и т.д. (очень сложно отслеживать, какая функция делает что).

foo <- function(x, y, type=c("old", "new")) {
    type <- match.arg(type, choices=c("old", "new")) 
    if (type == "old") {
        x + y
    } else if (type == "new") {
        x * y    
    }
}
foo(x=10, y=20)
[1] 30
foo(x=10, y=20, type="new")
[1] 200

foo1 <- function(x, y) {
    x * y
}
foo1(x=10, y=20)
[1] 200

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

Общий метод

Вам нужно начать, превратив foo() в общий метод.

setGeneric(
    name="foo",
    signature=c("x", "y", ".ctx", ".ns"),
    def=function(x, y, ..., .ctx, .ns) {
        standardGeneric("foo")
    }
)

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

Аргументы подписи

Степень гибкости по отношению к исходной проблеме напрямую связана с количеством аргументов подписи, которые вы объявляете (signature=c("x", "y", ".ctx", ".ns")): чем больше аргументов подписи, тем больше гибкости, чем у вас, но чем сложнее ваш код, тем более хорошо (в отношении того, сколько кода вам нужно написать).

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

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

Обратите внимание, что мы сказали, что наши "старые" аргументы x и y теперь должны быть сигнатурами-аргументами, а также есть два новых аргумента: .ctx и .ns. Я займусь этим через минуту. Это те аргументы, которые предоставят нам гибкость, которой мы руководствуемся.

Определение начального метода

Теперь мы определяем "вариант" (метод) общего метода для следующего "сценария подписи":

  • x - numeric
  • y numeric
  • .ctx просто не будет предоставляться при вызове метода и, таким образом, missing
  • .ns просто не будет предоставляться при вызове метода и, таким образом, missing

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

setMethod(
    f="foo", 
    signature=signature(x="numeric", y="numeric", .ctx="missing", .ns="missing"), 
    definition=function(x, y, ..., .ctx, .ns) {
        x + y
    }
)

Когда мы вызываем foo с этим "сигнатурным сценарием" (запрашиваем метод, который мы зарегистрировали для этого сценария), диспетчер методов точно знает, какой фактический метод ему нужно выбраться из хранилища:

foo(x=10, y=20)
[1] 30

Первое обновление

Теперь кто-то из отдела B приходит, смотрит на foo(), ему нравится, но решает, что foo() нуждается в обновлении (x * y вместо x + y), если он будет использоваться в его отделе.

Это, когда .ctx (short for context) вступает в игру: это аргумент, по которому мы можем различать контексты приложений.

Определение класса, представляющего новый контекст приложения

setRefClass("ApplicationContextDepartmentB")

При вызове foo() мы предоставим ему экземпляр этого класса (.ctx=new("ApplicationContextDepartmentB"))

Определение нового метода для нового контекста приложения

Обратите внимание, как мы регистрируем аргумент подписи .ctx нашему новому классу ApplicationContextDepartmentB:

setMethod(
    f="foo", 
    signature=signature(x="numeric", y="numeric", 
        .ctx="ApplicationContextDepartmentB", .ns="missing"), 
    definition=function(x, y, ..., .ctx, .ns) {
        out <- x * y
        attributes(out)$description <- "I'm different from the original foo()"
        return(out)
    }
)

Таким образом, диспетчер методов точно знает, что он должен возвращать "новый" метод вместо "старого" метода, когда мы вызываем foo() следующим образом:

foo(x=1, y=10, .ctx=new("ApplicationContextDepartmentB"))
[1] 10
attr(,"description")
[1] "I'm different from the original foo()"

"Старый" метод вообще не изменяется:

foo(x=1, y=10)
[1] 30

Второе обновление

Предположим, что кто-то из отдела C приходит и предлагает еще одну "конфигурацию" или версию для foo(). Вы можете легко обеспечить, что с нарушением всего, что вы реализовали для отделов A и B до сих пор, следуя той же процедуре, что и для отдела B.

Но мы даже остановимся здесь еще на одном шаге: мы определим еще два дополнительных класса, которые позволят нам различать разные "пространства имен" (что там, где .ns входит в игру).

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

Определение классов

setRefClass("ApplicationContextDepartmentC")
setRefClass("TestNamespace")
setRefClass("ProductionNamespace")

Определение нового метода для нового контекста приложения и сценария "test"

Обратите внимание, как мы регистрируем аргументы подписи .ctx нашему новому классу ApplicationContextDepartmentC и .ns нашему новому классу TestNamespace:

setMethod(
    f="foo", 
    signature=signature(x="character", y="numeric", 
        .ctx="ApplicationContextDepartmentC", .ns="TestNamespace"), 
    definition=function(x, y, ..., .ctx, .ns) {
        data.frame(x, y, test.ok=rep(TRUE, length(x)))
    }
)

Опять же, диспетчер методов будет искать правильный метод при вызове foo() следующим образом:

foo(x=letters[1:5], y=11:15, .ctx=new("ApplicationContextDepartmentC"), 
    .ns=new("TestNamespace"))
  x  y test.ok
1 a 11    TRUE
2 b 12    TRUE
3 c 13    TRUE
4 d 14    TRUE
5 e 15    TRUE

Определение нового метода для нового контекста приложения и "продуктивного" сценария

setMethod(
    f="foo", 
    signature=signature(x="character", y="numeric", 
        .ctx="ApplicationContextDepartmentC", .ns="ProductionNamespace"), 
    definition=function(x, y, ..., .ctx, .ns) {
        data.frame(x, y)
    }
)

Мы сообщаем диспетчеру метода, что теперь мы хотим, чтобы метод, зарегистрированный для этого сценария или пространства имен, выглядел следующим образом:

foo(x=letters[1:5], y=11:15, .ctx=new("ApplicationContextDepartmentC"), 
    .ns=new("ProductionNamespace"))

  x  y
1 a 11
2 b 12
3 c 13
4 d 14
5 e 15

Обратите внимание, что вы можете свободно использовать классы TestNamespace и ProductionNamespace в любом месте. Эти классы никак не привязаны к ApplicationContextDepartmentC, поэтому вы можете, например, также использовать все ваши другие сценарии приложений.

Дополнительные замечания для определения методов

Что-то, что часто довольно полезно начинать с метода, который принимает классы ANY для своих аргументов подписи и определяет более ограничительные методы по мере развития вашего программного обеспечения:

setMethod(
    f="foo", 
    signature=signature(x="ANY", y="ANY", .ctx="missing", .ns="missing"), 
    definition=function(x, y, ..., .ctx, .ns) {
        message("Value of x:")
        print(x)
        message("Value of y:")
        print(y)
    }
)
foo(x="Hello World!", y=rep(TRUE, 3))
Value of x:
[1] "Hello World!"
Value of y:
[1] TRUE TRUE TRUE

Дополнительные замечания для определений классов

Я предпочитаю ссылочные классы S4 над классами S4 из-за возможностей саморегуляции ссылочных классов S4:

setRefClass(
    Class="A", 
    fields=list(
        x1="numeric",
        x2="logical"
    ),
    methods=list(
        getX1=function() {
            .self$x1
        },
        getX2=function() {
            .self$x2
        },
        setX1=function(x) {
            .self$x1 <- x
        },
        setX2=function(x) {
            .self$field("x2", x)
        },
        addX1AndX2=function() {
            .self$getX1() + .self$getX2()
        }
    )
)
x <- new("A", x1=10, x2=TRUE)
x$getX1()
[1] 10
x$getX2()
[1] TRUE
x$addX1AndX2()
[1] 11

В классах S4 нет этой функции.

Последующие изменения значений полей:

x$setX1(100)
x$addX1AndX2()
[1] 101
x$x1 <- 1000
x$addX1AndX2()
[1] 1001

Дополнительные замечания для документирования методов и классов

Я настоятельно рекомендую использовать пакеты roxygen2 и devtools, чтобы документировать ваши методы и классы. Возможно, вам также захочется посмотреть в пакет roxygen3.

Документирование общих методов с помощью roxygen2:

#' Foo
#'
#' This method takes \code{x} and \code{y} and adds them.
#' 
#' Some details here
#' 
#' @param x \strong{Signature argument}.
#' @param y \strong{Signature argument}.
#' @param ... Further arguments to be passed to subsequent functions.
#' @param .ctx \strong{Signature argument}.
#'      Application context.
#' @param .ns \strong{Signature argument}.
#'      Application namespace. Usually used to distinguish different context 
#'      versions or configurations.
#' @author Janko Thyson \email{[email protected]@something.com}
#' @references \url{http://www.something.com/}
#' @example inst/examples/foo.R
#' @docType methods
#' @rdname foo-methods
#' @export

setGeneric(
    name="foo",
    signature=c("x", "y", ".ctx", ".ns"),
    def=function(x, y, ..., .ctx, .ns) {
        standardGeneric("foo")
    }
)

Документирование методов с помощью roxygen2:

#' @param x \code{\link{character}}. Character vector.
#' @param y \code{\link{numeric}}. Numerical vector.  
#' @param .ctx \code{\link{ApplicationContextDepartmentC}}. 
#' @param .ns \code{\link{ProductionNamespace}}.  
#' @return \code{\link{data.frame}}. Some data frame.
#' @rdname foo-methods
#' @aliases foo,character,numeric,missing,missing-method
#' @export

setMethod(
    f="foo", 
    signature=signature(x="character", y="numeric", 
        .ctx="ApplicationContextDepartmentC", .ns="ProductionNamespace"), 
    definition=function(x, y, ..., .ctx, .ns) {
        data.frame(x, y)
    }
)