Взаимодействие с другими языками программирования

Нередко возникает необходимость использовать скрипты и алгоритмы, написанные на других, часто низкоуровневых языках. Причин этому может быть множество: как ожидаемый прирост скорости (как в случае с C++), так и необходимость работать с уже готовыми библиотеками - например, некоторыми алгоритмами, написанными на Fortran или Python. Точно так же в работе многих исследователей и аналитиков жизненно необходимы инструменты для работы с базами данных — возможность подключиться к базе данных и отправить SQL-запрос, импортировать/экспортировать данные. Ещё одна важная область, в которой используются другие языки программирования, — это презентация результатов исследований и, соответственно, связь с языками разметки и возможностью конвертировать отчёты в разные форматы, инструменты для воспроизводимых исследований, использование HTML и JavaScript для интерактивных графиков и таблиц, а также дашбордов.

Низкоуровневые языки

C и Fortran

R в своей основе написан на C, точно так же многие статистические алгоритмы используют скрипты на Fortran. Тем не менее, можно функции и рутины на этих языках использовать и в пользовательских скриптах на R. Схема импорта скриптов на C и Fortran идентична — необходимо скомпилировать код и потом подключить динамическую библиотеку (dll, совместно используемый объект).

Например, у нас есть функция, написанная на C (для Fortran это будут подпрограммы, subroutines). В функции первый аргумент — длина массива, второй аргумент — сам массив, в теле функции происходит возведение в квадрат элементов массива (точнее, столько первых элементов, сколько было передано в первом аргументе).

cat ./src/c_example.c
## void my_c_f(int *nin, double *x)
## {
##  int n = nin[0];
## 
##  int i;
## 
##  for (i=0; i<n; i++)
##      x[i] = x[i] * x[i];
## }

Скомпилировать этот скрипт можно с помощью консольной команды R (для этого потребуется компилятор С или Fortran, оба есть в gcc или в RTools для Windows):

R CMD SHLIB ./src/c_example.c
## make: Nothing to be done for 'all'.

Далее следует подключить скомпилированный код (расширение файла может зависеть от компилятора и операционной системы):

dyn.load("./src/c_example.so")

Следующим этапом будет обращение к этому коду, для C и Fortran есть соответствующие функции в базовом R, функции .C() и .Fortran():

.C('my_c_f', as.integer(5), as.double(1:5))
## [[1]]
## [1] 5
## 
## [[2]]
## [1]  1  4  9 16 25

Следует иметь в виду одну особенность: при выполнении кода будут возвращены все объекты, которые были созданы во время его выполнения. Соответственно, для красоты и большей понятности кода лучше писать функции-обертки (врапперы), которые разбирают результат скрипта на C/Fortran и возвращают только необходимое значение. Также во враппер можно добавить сообщения об ошибках и скрыть некоторые дополнительные вычисления, которые необходимы для C/Fortran:

c_wrapper <- function(x) {
  if (!is.numeric(x))
    stop('argument x must be numeric')
  dyn.load("./src/c_example.so")
  out <- .C('my_c_f',
            n = as.integer(length(x)),
            x = as.double(x))
  return(out$x)
}
c_wrapper(1:5)
## [1]  1  4  9 16 25

C++

В настоящее время для низкоуровневой оптимизации чаще всего используются вставки кода на C++. Основной пакет для работы с C++ в R — Rcpp и связанные с ним пакеты, дающие возможность работать с разными библиотеками C++. Влияние Rcpp растёт постоянно, в настоящее время более нескольких тысяч R пакетов используют Rcpp и реализации алгоритмов на C++. Для работы в Windows дополнительно должен быть установлен набор инструментов Rtools (XCode для MacOS).

Включить код на C++ в свой рабочий код на R можно двумя путями (плюс чанки в R Markdown). Первый и более элегантный — создать внешний файл, в котором будет использован заголовочный файл Rcpp.h, соответствующее пространство имён using namespace Rcpp и указан префикс // [[Rcpp::export]]. Напишем простую функцию, которая умножает на два переданное значение:

#include <Rcpp.h>
using namespace Rcpp;

// [[Rcpp::export]]

NumericVector timesTwo(NumericVector x) {
    return x * 2;
}

/*** R
timesTwo(42)
*/

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

В конце .cpp-файла можно сделать вставку на R, в виде /* R <код на R> */. Тогда при компиляции и импорте скрипта в R этот код будет выполнен уже интерпретатором R. Это полезный инструмент для тестирования написанной на C++ функции.

library(Rcpp)

# импортируем функцию на C++ 
sourceCpp('./src/cpp_example.cpp')
## 
## > timesTwo(42)
## [1] 84
# применяем импортированную функцию
timesTwo(9)
## [1] 18

Второй способ — написанный код на C++ передать в виде строки в функцию cppFunction(). Так как это работа уже явно в R-окружении, не надо указывать заголовочные файлы и т. д.

# создаём объект с кодом на C++ в виде строки
cpp_code <- '
  NumericVector my_cpp_f(NumericVector x) {
    return x * 2;
  }
'

# создаем R-функцию
cppFunction(cpp_code)

# смотрим, как работает
my_cpp_f(9)
## [1] 18

Оба варианта, sourceCpp() и cppFunction(), компилируют код на C++ и на его основе создают совместно используемый объект, который подгружается в R-окружение и потом вызывается с помощью .Call().

Java

Обращения к Java из R — весьма редкое событие в практике большинства пользователей. Обычно пользователи сталкиваются с Java при работе с пакетами, которые работают с программами MS Office, в частности, MS Excel.

Работать с Java из R можно несколькими способами: через обращение к командной строке с помощью system() или с помощью соответствующих пакетов (например, rJava). Сам язык также надо установить, обычно это делается с помощью java developer kit (JDK или OpenJDK).

Простейший класс, при инстанцировании которого выводится на печать сообщение Hello, World. либо, если был использован дополнительный аргумент, текст этого аргумента:

cat ./src/HelloWorld.java
## public class HelloWorld {
## 
##   public String sayHello() {
##     String result = new String("Hello Java World!");
##     return result;
##   }
## 
##   public static void main(String[] args) {
##     String stringArg = "Hello, World.";
## 
##     if (args.length > 0) {
##       stringArg = args[0];
##     }
## 
##     System.out.println(stringArg);
##   }
## }

Наиболее примитивный способ — компиляция класса и создание экземпляра класса в командной строке. Для этого необходимо использовать функцию system() и команды javac (для компиляции класса, это создаёт одноимённый файл) и java (для создания экземпляра класса). Так как у нас в main есть аргументы и System.out.println(), то при создании экземпляра класса на печать выводится соответствующее сообщение.

# компилируем класс
system('javac ./src/HelloWorld.java')

# переходим в директорию с файлом класса и создаём экземпляр
system('cd ./src && java HelloWorld')

# создаём экземпляр класса и указываем дополнительный аргумент
system('cd ./src && java HelloWorld "new string"')

Более сложный, но более гибкий способ работать с Java — это использовать пакет rJava, который помогает из R запускать виртуальную машину (.jinit() вернёт 0, если машина инициализирована) и использовать скомпилированные классы.

library(rJava)

# инициализация виртуальной машины
.jinit()

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

# создаём архив из файла класса
system('jar cvf ./src/HelloWorld.jar ./src/HelloWorld.class')

# указываем путь к папке с архивом
.jaddClassPath(paste0(getwd(), '/src'))
.jclassPath()
## [1] "/home/konhis/R/x86_64-pc-linux-gnu-library/4.1/rJava/java"
## [2] "/home/konhis/Documents/Rprojects/r_textbook/src"
# создаём Java-объект в R
obj <- .jnew('HelloWorld')
str(obj)
## Formal class 'jobjRef' [package "rJava"] with 2 slots
##   ..@ jobj  :<externalptr> 
##   ..@ jclass: chr "HelloWorld"

Методы и поля класса можно посмотреть с помощью функций .jfields() и .jmethods().:

## NULL
##  [1] "public static void HelloWorld.main(java.lang.String[])"                                    
##  [2] "public java.lang.String HelloWorld.sayHello()"                                             
##  [3] "public final void java.lang.Object.wait(long,int) throws java.lang.InterruptedException"   
##  [4] "public final void java.lang.Object.wait() throws java.lang.InterruptedException"           
##  [5] "public final native void java.lang.Object.wait(long) throws java.lang.InterruptedException"
##  [6] "public boolean java.lang.Object.equals(java.lang.Object)"                                  
##  [7] "public java.lang.String java.lang.Object.toString()"                                       
##  [8] "public native int java.lang.Object.hashCode()"                                             
##  [9] "public final native java.lang.Class java.lang.Object.getClass()"                           
## [10] "public final native void java.lang.Object.notify()"                                        
## [11] "public final native void java.lang.Object.notifyAll()"

Вызов методов можно сделать тремя путями. Первый и наиболее быстрый — с помощью функции .jcall(), однако необходимо прямо указывать, какого типа будет результат, притом тип в JNI-нотации. Более простой метод — с помощью J(), в котором необходимо только указать Java-объект и название метода. Третий более привычен для пользователей R и напоминает вызов методов R6-классов, так как использует оператор $. Функция .DollarNames() вернёт список методов класса в том виде, который необходим для их вызова с помощью $:

# вызываем метод sayHello класса HelloWorld
.jcall(obj, returnSig = 'S', method = 'sayHello')
## [1] "Hello Java World!"
J(obj, method = 'sayHello')
## [1] "Hello Java World!"
obj$sayHello()
## [1] "Hello Java World!"

Python

reticulate — относительно недавний пакет, который прямо предназначен для работы с Python из R. Пакет разработан компанией RStudio, которая активно старается расширить количество поддерживаемых языков хотя бы на уровне markdown-чанков и скриптов. Его авторы даже с энтузиазмом говорят, что пакет reticulate позволяет писать пакеты, гармонично использующие оба языка.

Работать с Python с помощью пакета reticulate можно в нескольких ситуациях:

  • выполнение скриптов на Python и загрузка функций и объектов в рабочее окружение R (по аналогии с source());

  • подгрузка модулей и виртуальных окружений Python в рабочее окружение R;

  • реализация интерактивной консоли Python (REPL) в консоли R;

  • чанки в R Markdown по аналогии с другими языками чанков (r, bash, stan и т. д.).

Версия Python

При начале работы стоит с помощью базовой функции Sys.which() уточнить, какая версия Python прописана в системных путях (PATH) и есть ли вообще такая запись (т. е. установлен ли язык). Для более подробной информации о всех версиях языка можно воспользоваться py_config() или py_discover_config().

library(reticulate)

# смотрим, какая версия есть
Sys.which('python')
## python 
##     ""
Sys.which('python3')
##            python3 
## "/usr/bin/python3"
# смотрим более подробную информацию
py_config()
## python:         /usr/bin/python3
## libpython:      /usr/lib/python3.8/config-3.8-x86_64-linux-gnu/libpython3.8.so
## pythonhome:     //usr://usr
## version:        3.8.10 (default, Nov 26 2021, 20:14:08)  [GCC 9.3.0]
## numpy:          /home/konhis/.local/lib/python3.8/site-packages/numpy
## numpy_version:  1.19.4

В том случае, когда есть несколько версий, можно указать какую-то определенную. Это особенно актуально, когда предполагается использовать модули, актуальные для версий 2.7, а обычно используются версии 3.*. Для этого есть функция use_python(). Пакет reticulate также позволяет работать с виртуальными окружениями (use_virtualenv()), а также с окружением Conda (use_condaenv()). Впрочем, если для работы с Python напрямую (не через вызовы из R) не используется Anaconda, то лучше не усложнять систему и ограничиться просто pip и модулями или же отдельным виртуальным окружением.

Импорт модулей и скриптов

Один из самых важных функционалов пакета reticulate — импорт модулей Python в рабочее окружение R и работа с ними как с объектами R. Для импорта модулей используется функция import(). Получить доступ к функциям и объектам модуля можно с помощью оператора $, как при работе со списками:

# импорт пакета platform
py_platform <- import('platform')

# вызываем функцию platform модуля platform
py_platform$platform()
## [1] "Linux-5.4.0-96-generic-x86_64-with-glibc2.29"

Аналогично можно выполнять Python-скрипты и импортировать результат их работы в R. Допустим, у нас есть скрипт python_example.py, в котором мы объявляем функцию возведения объекта в четвёртую степень.

cat ./src/python_example.py
## def my_f_python(x):
##   return x ** 4

Пример выполнения этого скрипта и доступа к полученным объектам, для чистоты запишем их в новое окружение:

# создаём новое окружение и выполняем python-скрипт
my_env <- new.env()
source_python('./src/python_example.py', envir = my_env)

# смотрим результат выполнения скрипта
ls(my_env)
## [1] "my_f_python" "r"
# применяем функцию
my_env$my_f_python(5)
## [1] 625

Преобразование типов

При импорте модулей или объектов происходит преобразование типов Python-объектов в R-объекты. При вызове R-объектов из Python происходит аналогичное преобразование. Конечно, конвертируются только самые основные классы объектов — функции, словари, списки, массивы и таблицы. Обычно соответствия типов и классов иллюстрируют следующей таблицей:

Объект Python Примеры на R
Вектор из одного элемента Scalar 1, 1L, TRUE, “foo”
Вектор List c(1.0, 2.0, 3.0), c(1L, 2L, 3L)
Список Tuple list(1L, TRUE, “foo”)
Именованный список Dict list(a = 1L, b = 2.0)
Матрица/массив NumPy ndarray matrix(c(1,2,3,4), nrow = 2, ncol = 2)
Таблица Pandas DataFrame data.frame(x = c(1,2,3), y = c(“a”, “b”, “c”))
Функция Python function function(x) x + 1
Логические значения True, False TRUE, FALSE
NULL None NULL

Интерактивная консоль

Переключение в интерактивную консоль Python в консоли R можно сделать с помощью функции repl_python(), будет использоваться та версия Python, которая стоит в системе по умолчанию. Выход из консоли — с помощью команды exit.

Примеры создания и использования функции даны в виде копий консоли:

> repl_python()
Python 3.7.5 (/usr/bin/python3)
Reticulate 1.16 REPL -- A Python interpreter in R.
>>> def my_f_python(x):
...     return x ** 3
... 
>>> my_f_python(5)
125
>>> exit
>

После того, как будет завершена Python-сессия (после exit и возвращения в консоль R), созданные объекты можно вызвать как элементы объекта py в глобальном окружении:

> py$my_f_python(5)
[1] 125

Чанки в R Markdown

Переключение на Python в чанках традиционное, как и с другими языками (language engines), указанием языка в заголовке чанка (python). Если в скрипте предполагается использовать одновременно и R, и Python, то в начале скрипта необходимо подключить пакет reticulate.

def my_f_python(x):
  return x ** 4

my_f_python(5)
## 625

Если в скрипте был подключен пакет reticulate, то созданные в Python-чанках объекты будут доступны и в R-чанках, так же как и в объекте py при работе с интерактивной консолью:

res_from_py <- py$my_f_python(5)
res_from_py
## [1] 625

Аналогично в Python-чанках можно работать с R-объектами (если reticulate был явно подключен). Все R-объекты хранятся в объекте r:

r.res_from_py
## 625.0

SQL

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

Сочетание SQL и R весьма простое. Основой служит пакет DBI, который является интерфейсом к базе данных. С помощью пакета создаётся коннектор к базе, и потом уже с использованием этого коннектора происходит сбор данных, загрузка таблиц и т. д. Второй компонент — это собственно драйвер для подключения. Как правило, для разных баз данных существуют свои пакеты (RPostgreSQL, RPresto и т. д.). Также есть общий пакет odbc, который разрабатывается и поддерживается командой RStudio, в ледствие чего интегрирован в IDE, — при создании подключения есть возможность просматривать таблицы базы данных, а также писать и выполнять запросы, как если бы это был отдельный клиент для работы с БД.

Иногда встречаются рекомендации использовать пакет dbplyr, который включает в себя бэкенды нескольких наиболее популярных баз данных и позволяет обращаться к таблицам с помощью tidyverse-синтаксиса (переводит глагольные выражения на R в SQL-запрос). На мой взгляд, это удобно только в том случае, если запрос осуществляется к небольшой таблице или сам по себе простой. А для каких-то более сложных вещей, таких как CTE, сложных джойнов с условиями, использования представлений, оконных функции и т. д., лучше писать и оптимизировать запрос самостоятельно.

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

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

# подключаем бэкенд
library(RSQLite)

# создаём коннектор/открываем соединение
con <- dbConnect(RSQLite::SQLite(), ":memory:")

# записываем таблицу в базу данных
dbWriteTable(conn = con, name = 'mtcars_db', value = mtcars)

Запись таблицы в базу данных осуществляется с помощью функции dbWriteTable(), в аргументы которой мы передаём коннектор, указываем название новой таблицы и какая таблица из рабочего окружения в R должна быть записана. Стоит учитывать, что при записи в базу данных теряются названия строк, если они были в таблице (впрочем, это больше характерно для data.frame-синтаксиса, который встречается всё реже). Проверить, что таблица была создана, можно с помощью dbListTables() или полноценного запроса:

dbListTables(con)
## [1] "mtcars_db"

Запросы к базе данных выполняются схожим образом — функциями dbGetQuery() или dbSendQuery(), в аргументы которых передаются коннектор и строка запроса. Первая функция используется в ситуациях, когда нужно именно импортировать данные в рабочее окружение R, вторая — когда необходимо выполнить что-то на сервере. Строка запроса выглядит как стандартный SQL-запрос на соответствующем диалекте. В тех случаях, когда необходимо в запросе использовать какие-то изменяемые параметры (например, даты), полезно формировать запросы с помощью paste() или glue::glue(). В примере ниже я импортирую три строки из таблицы mtcars_db, в которых в поле cyl значения равны значению переменной my_cyl:

# пользовательская переменная
my_cyl <- 6

# пишем запрос с использованием переменной
query <- paste('select * from mtcars_db where cyl =', my_cyl, 'limit 3')

# делаем запрос к БД и импортируем результат
dbGetQuery(conn = con, statement = query)
##    mpg cyl disp  hp drat    wt  qsec vs am gear carb
## 1 21.0   6  160 110 3.90 2.620 16.46  0  1    4    4
## 2 21.0   6  160 110 3.90 2.875 17.02  0  1    4    4
## 3 21.4   6  258 110 3.08 3.215 19.44  1  0    3    1

После завершения работы с базой данных необходимо закрывать соединение с помощью функции dbDisconnect(). В целом проще и правильнее всего будет создать собственную функцию для импорта данных, в которой выполняются все этапы, включая открытие соединения, импорт и первичный процессинг данных (например, преобразование в data.table) и разрыв соединения. В данном случае, так как база данных у нас существует только когда открыто соединение (потому что создаётся виртуально), добавим строчку записи данных в виртуальную БД. Естественно, в работе с существующими базами данных это лишний шаг.

# объявляем функцию
my_connector <- function(q) {
  con <- dbConnect(RSQLite::SQLite(), ":memory:")
  # только для того, чтобы работал пример
  dbWriteTable(conn = con, name = 'mtcars_db', value = mtcars)
  res <- dbGetQuery(con, q)
  on.exit(dbDisconnect(conn = con))
  return(res)
}

# делаем запрос к БД
mtcars_6c <- my_connector(query)
mtcars_6c
##    mpg cyl disp  hp drat    wt  qsec vs am gear carb
## 1 21.0   6  160 110 3.90 2.620 16.46  0  1    4    4
## 2 21.0   6  160 110 3.90 2.875 17.02  0  1    4    4
## 3 21.4   6  258 110 3.08 3.215 19.44  1  0    3    1

Markdown

Одна из неотъемлемых частей современного R — поддержка языка разметки markdown. В отличие от многих других языков, в которых реализовано взаимодействие с markdown, в R это превратилось в целую систему взаимосвязанных пакетов и практик.

В R markdown используется в виде .Rmd-документов, в которых совмещены текст в markdown-разметке и блоки (чанки) кода. Основной язык чанков — R, однако также могут быть чанки на bash, C++, Python, SQL, stan и некоторых других языках. При рендере на основе этих .Rmd-документов могут быть получены файлы в форматах md, pdf, tex, HTML-страницы и документы MS Office. Всё это превращает R и R Markdown в мощный инструмент для составления отчётов, BI-аналитики и прочих направлений, где требуется презентация результатов анализа данных.

В основе всего функционала R Markdown лежит несколько технологий. Сами Rmd-документы состоят из текста в markdown разметке, а также из чанков кода. При рендере документа с помощью пакета knitr происходит выполнение кода и встраивание результатов кода в текст, в результате получается уже классический markdown-документ (md). Далее этот документ конвертируется в требуемый формат с помощью pandoc, консольной утилиты для форматирования текстов и конвертирования их в разные форматы. При конверсии в pdf необходимо, чтобы в системе также был установлен какой-нибудь дистрибутив TeX.

Чаще всего Rmd конвертируются в HTML-страницы, так как это позволяет использовать их в самых разных местах (от презентаций до сайтов). Вторая важная причина — за счёт использования htmlwidgets в таких HTML-страницах сохраняются интерактивные графики (plotly, higcharts и т. д.) и таблицы.

Основные формы использования R Markdown:

  • Ноутбуки (по аналогии с Jupyter notebook). Ячейки формируются как чанки, обратными апострофами. В отличие от Jupyter, это именно текст с вставками кода, а не разные чанки для текста и кода.

  • Презентации. Rmd-документы можно преобразовать в html-презентации (ioslides, slidy, xaringan), в pdf-презентации (beamer) и даже в презентации в формате MS PowerPoint.

  • Дашборды. Пакет flexdashboard позволяет формировать как статичные (обновляющиеся по расписанию), так и интерактивные дашборды (при сочетании с Shiny). В подобных дашбордах могут быть как графики, так и таблицы, тексты и прочие форматы представления данных.

  • Сайты. Пакет blogdown позволяет сочетать генераторы статических сайтов (Hugo, Jekyll), Rmd-документы и пользовательские темы (чистый css или css-препроцессоры). В результате можно создать собственный блог, посты которого будут исходно в виде R Markdown, личную страницу и т. д.

  • Книги. Немалая часть онлайн-учебников по R или по отдельным пакетам написана и опубликована с помощью пакета bookdown, который позволяет собирать множество Rmd-документов в один большой документ (со всей сопутствующей навигацией, системой ссылок на литературу и перекрестных ссылок и т. д.). Формат книг также может быть разным — как HTML, так и EPUB, PDF и Kindle.

  • Документация. Примерами будут сайты с документацией по пакетам (пакет pkgdown), виньетки к пакетам или страницы для документации к пакету на GitHub.

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