Ввод и вывод данных

Чтение ввода пользователя

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

Чтение ввода пользователя осуществляется с помощью функции readline() (ниже скопирована последовательность ввода в консоли):

> user_input <- readline()
a b_c
> print(user_input)
[1] "a b_c"

Второй, более редкий вариант интерактивного ввода данных пользователем — функция menu(), в которой можно дать пользователю возможность выбрать вариант из списка. При использовании аргумента graphics = TRUE поднимается интерактивное окно. Простейший пример использования menu() (также скопирована копия последовательности ввода в консоли):

> menu(choices = c("List letters", "List LETTERS"))

1: List letters
2: List LETTERS

Selection: 1
[1] 1

Вывод сообщений

Вывод на печать

print()

Для вывода на печать обычно используют функцию print() и в некоторых случаях cat(). Функция print(), пожалуй, одна из самых часто используемых функций, даже простейшую отладку делают с её помощью. print() — это функция-дженерик, для очень большого числа классов определяется свой метод print(). Например, методы для линейных моделей и обобщенных линейных моделей:

methods("print")[grep('lm', methods("print"))]
## [1] "print.glm"         "print.lm"          "print.summary.glm"
## [4] "print.summary.lm"

Так как print() для каждого класса может иметь свои параметры, рассмотрим функцию print.default(), которая применяется для вывода на печать векторов. Как правило, для print() не используют дополнительные аргументы, в большом количестве случаев вообще даже саму функцию не пишут — и IDE, и консоль R интерпретируют вызов объекта в консоли как вывод на печать:

x <- 1:5
print(x)
## [1] 1 2 3 4 5
x
## [1] 1 2 3 4 5

Основные аргументы — quote и digits. При quote = TRUE во время вывода строки скрываются окружающие кавычки:

print('This is a string')
## [1] "This is a string"
print('This is a string', quote = FALSE)
## [1] This is a string

Аргумент digits менее очевиден: с его помощью задаётся количество значимых знаков, которое должно быть отображено (а не количество знаков после запятой, как в одноимённом аргументе функции round()). При этом по умолчанию считается, что значимых знаков семь. Также стоит учитывать, что если целая часть больше 22 знаков, то результат будет представлен в академическом (scientific) формате в виде числа с плавающей точкой (если не выставлено options(scipen = 999)):

# двузначное число с дробной частью, всего пять знаков
x <- 85.547
print(x)
## [1] 85.547
print(x, digits = 3)
## [1] 85.5
# число длиной в девять знаков
x <- 198789.547
print(x)
## [1] 198789.5
print(x, digits = 10)
## [1] 198789.547
# число длиной 30 знаков
x <- 38955015567677251979744654351
print(x)
## [1] 3.895502e+28

Такая логика характерна не только для базового print(), но и для некоторых других функций и классов (например, View() или некоторые функции в tidyverse). Вызвано это стремлением упростить пользователю жизнь и не перегружать лишней информацией: предполагается, что если дробная часть числа существенно меньше целой, то её наличие или отсутствие мало повлияет на решение пользователя, при этом всё число восприниматься будет проще. Важно понимать, что результат print() — это именно отображение данных пользователю, а не реальное округление, и используется только для числовых векторов:

x <- 38901556.7677251
print(x)
## [1] 38901557
## [1] "38901556.7677251"

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

x <- print(sample(5))
## [1] 5 2 3 1 4
## [1] 5 2 3 1 4

cat()

Функция cat() во многом повторяет функционал print.default(), в том числе и подход к отображению длинных чисел. Однако cat() намного удобнее использовать в ситуации, когда необходимо из нескольких переменных собрать одну текстовую строку и её вывести на печать (так как cat идет от concatenate). Это заменяет связку функций paste() + print().

x <- 5
cat(x, 'random numbers =', sample(x))
## 5 random numbers = 5 2 3 1 4

Так как cat() склеивает переданные объекты, то и вывод в консоли идет на одной строке, в том числе на этой строке оказывается и следующее выражение. Для переноса строки необходимо указать разделитель sep = '\n':

# без разделителя
cat(1:3)
## 1 2 3
x <- 'new object'

# с разделителем
cat(1:3, sep = '\n')
## 1
## 2
## 3
x <- 'new object'

Информационные сообщения: message, warning, stop

В функциях нередко используют информационные сообщения — message(), warning() и stop(). Первая функция используется, когда необходимо что-то сообщить об операциях во время выполнения функции. Например, если в функции необходимо сделать дополнительную конвертацию строкового значения в числовое:

# объявляем функцию, которая умножает значение на 5
my_fun <- function(x) {
  if (is.character(x)) {
    x <- as.numeric(x)
    message('Converted char x into numeric')
  }
  x * 5
}

# передаем в x строковое значение
my_fun('3')
## Converted char x into numeric
## [1] 15

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

# короткий вектор будет повторяться до вида 3, 2, 3, 2, 3
c(1, 2, 3, 4, 5) * c(3, 2)
## Warning in c(1, 2, 3, 4, 5) * c(3, 2): longer object length is not a multiple of
## shorter object length
## [1]  3  4  9  8 15

stop() используется в схожих случаях, как и warning(), однако не дожидается окончания выполнения функции, а завершает выполнение и извещает пользователя об ошибке. Например:

# объявляем функцию, которая умножает значение на 5
my_fun <- function(x, y) {
  if (length(x) != length(y))
    stop('Alarm! length(x) != length(y)')
  x * y
}

# передаём в x строковое значение
my_fun(x = 1:5, y = 3:2)

В целом в качестве функций информационных сообщений можно использовать и print() / cat(). Однако эти группы функций различаются тем, что вывод print() / cat() направляется в поток стандартного вывода (stdout), а вывод информационных функций — в поток ошибок (stderr). Использование потока stderr выделяет визуально эти сообщения, а также их вывод может использоваться в tryCatch() (подробнее см. про обработку ошибок). Ещё один нюанс, который стоит иногда принимать во внимание — некоторые авторы используют suppressMessages(), suppressWarnings(), которые скрывают вывод message() и warnings().

Чтение и запись текстовых файлов

В работе аналитики и исследователи чаще всего сталкиваются с данными, которые хранятся в простом текстовом формате (txt, разделители строк \n, \r или \n\r ) и основанных на них табличных форматах csv (с разделителями , или ;) или tsv (\t).

Построчное чтение

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

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

Прочитаем первые несколько строк текста песни:

txt_lines <- readLines('./data/oxxy_gorgorod.txt', n = 5)
txt_lines
## [1] "####"                                    
## [2] "Незаметно поправь её"                    
## [3] "Одеяло, за это себя предавая анафеме"    
## [4] "Она вышла из пены"                       
## [5] "Худой отпечаток плеча оставляя на кафеле"

Чтение текстовых файлов: read.table()

Для чтения текстовых файлов, которые содержат в табличном формате, в базовом R есть функция read.table() и функции-обертки, которые обращаются к ней, но с другими значениями аргументов по умолчанию (read.csv(), read.csv2, read.delim(), read.delim2()). Табличный формат предполагает наличие строк и колонок в файле, выделенных разделителями строк и полей соответственно, при этом формат файла может быть как txt, так и csv.

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

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

  • неожиданные разделители (например, экспорт из MS Excel в csv создаёт файл с разделителем ;);
  • лишние или пропущенные разделители строк или колонок (\t\t вместо \t), что создаёт разное количество колонок в таблице;
  • несоответствие файла расширению или вообще отсутствие расширения;
  • нестандартные кодировки, в том числе проблемы их импорта при работе в Windows;
  • наличие embedded nuls (\0) или метки порядка байтов (bite order marks, BOM);
  • наличие символов """", //// и прочих технических символов
  • метаданные (запись о дате и источнике данных) в первых строках файла.

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

Рассмотрим сложный пример, когда в txt-файле находятся табличные данные, при этом кодировка файла отличается от распространенных (UTF-16), в дополнение к этому в качестве десятичного разделителя используется ,. Вот так выглядит файл при печати в терминале:

cat ./data/txt_example.txt
## ��▒>4>4@        ▒>4><   08<5=>20=85     @>872>48B5;L    #?0:    5AOF
## 69500044        008560521       &▒  . 10        ! <1% - 2AB@8O  4       5
## 69500044        745621  &▒  . 10        ! <1% - 2AB@8O  4       5
## 69500300        69506432458     ▒! " 100 N20    (@5O 9D !05=A87 2B. 4   1       5
## 69500346        695089466518    &▒ ▒▒ " 25 N50 ▒!▒      =65@>-!C465=A:89 %$,    3       5
## 69500268        69504851543     $▒"! !  #!▒▒"+ !2 $/ 2 N20 ▒ ! !▒       ▒ ! !▒▒! -      1       5
## 69500009        69506584554      ▒ !.  ▒. /!▒'-▒ !"&+ ! ',▒ % ▒-#, @.6  "#! - -"        1       5
## 69500058        86600383        ".. N10 $ !" "  $ !" "  10      5
## 69500147        41200521        &▒  . 10        ! <1% - 2AB@8O  4       5
## 69500147        05860521        &▒  . 10        ! <1% - 2AB@8O  4       5
## 69500268        00000927        ▒"".. 20 N28    545>=  8EB5@    2       5

При желании можно распознать заголовки и разделители колонок (табуляция). Тем не менее, файл в таком виде не годится для работы. После некоторого перебора типов кодировок выяснилось, что файл представлен в кодировке UTF-16. Зададим все аргументы для корректного импорта:

path <- './data/txt_example.txt'
txt_data <- read.table(path, header = TRUE, fileEncoding = 'UTF-16', sep = '\t', dec = ',')

Посмотрим на файл и структуру данных. Как мы видим, весь файл импортирован как таблица. А до версии R 4.0.0 текстовые данные были бы ещё и преобразованы в факторы, так как в ранних версиях аргумент stringsAsFactors по умолчанию имел значение TRUE:

head(txt_data, n = 3)
##    КодПодр       КодНом            Наименование              Производитель Упак
## 1 69500044 ЛН-008560521       БАНЕОЦИН ПОР. 10Г      САНДОЗ ГмбХ - Австрия    4
## 2 69500044    ЛН-745621       БАНЕОЦИН ПОР. 10Г      САНДОЗ ГмбХ - Австрия    4
## 3 69500300  69506432458 НИМЕСАН ТАБЛ. 100МГ N20 Шрея Лайф Саенсиз Пвт. Лтд    1
##   Месяц
## 1     5
## 2     5
## 3     5
str(txt_data)
## 'data.frame':    10 obs. of  6 variables:
##  $ КодПодр      : int  69500044 69500044 69500300 69500346 69500268 69500009 69500058 69500147 69500147 69500268
##  $ КодНом       : chr  "ЛН-008560521" "ЛН-745621" "69506432458" "695089466518" ...
##  $ Наименование : chr  "БАНЕОЦИН ПОР. 10Г" "БАНЕОЦИН ПОР. 10Г" "НИМЕСАН ТАБЛ. 100МГ N20" "ЦИННАРИЗИН ТАБЛ. 25МГ N50 АВЕКСИМА" ...
##  $ Производитель: chr  "САНДОЗ ГмбХ - Австрия" "САНДОЗ ГмбХ - Австрия" "Шрея Лайф Саенсиз Пвт. Лтд" "Анжеро-Судженский ХФЗ,ООО" ...
##  $ Упак         : int  4 4 1 3 1 1 10 4 4 2
##  $ Месяц        : int  5 5 5 5 5 5 5 5 5 5

К наиболее трудным проблемам до недавнего времени можно было отнести наличие embedded nuls в тексте, так как это требовало предобработки в bash. Сейчас некоторые функции импорта (например, data.table::fread()) умеют обрабатывать такие ситуации.

Другая проблема — наличие сдвоенных разделителей полей (например, \t\t) в файлах с большим количеством пропусков. В таких файлах нельзя заменить сдвоенный разделитель на одинарный, так как неизвестно, чем является этот разделитель: ошибкой файла или же эти два разделителя ограничивают пустую ячейку, которая будет заполнена NA при импорте. К сожалению, эта проблема вряд ли может быть решена простым путем.

Из других не очень очевидных нюансов стоит отметить, что функция read.table() удобна, так как обладает большим набором аргументов, а также устойчива к некоторым ошибкам в данных. Однако импорт данных с её помощью весьма нетороплив, что ощутимо сказывается при импорте больших таблиц. Поэтому лучше использовать аналогичные функции других пакетов, в частности, data.table::fread().

Запись данных в текстовые файлы: cat, writeLines, write.table

Записывать данные в текстовые форматы можно как построчно, так и сразу всей таблицей. Для записи построчно обычно используется функция writeLines(), в редких случаях — cat() (при указании имени файла происходит перенаправление вывода с консоли в файл).

Запишем построчно прочитанные ранее строки и посмотрим в терминале, что получилось:

writeLines(txt_lines, con = './data/lines.txt')
cat ./data/lines.txt
## ####
## Незаметно поправь её
## Одеяло, за это себя предавая анафеме
## Она вышла из пены
## Худой отпечаток плеча оставляя на кафеле

Запись таблиц происходит аналогичным образом с помощью функции write.table() или её аналогом в других пакетах (в частности, более быстрый вариант — data.table::fwrite()). С помощью аргументов можно задать как типы разделителей (строк, полей и десятичные разделители), так и кодировку.

Ранее в таблицах использовались осмысленные названия строк, поэтому функции для записи по умолчанию сохраняли в файл отдельной колонкой и названия строк. Сейчас именование строк встречается реже, поэтому можно выставлять аргумент row.names = FALSE, чтобы в файл не были записаны отдельной колонкой бесполезные номера строк.

write.table(txt_data, './data/txt_example2.txt', row.names = FALSE)

Смотрим результат. Как мы видим, в отличие от первоначального формата, таблица уже в более читабельной кодировке:

cat ./data/txt_example2.txt
## "КодПодр" "КодНом" "Наименование" "Производитель" "Упак" "Месяц"
## 69500044 "ЛН-008560521" "БАНЕОЦИН ПОР. 10Г" "САНДОЗ ГмбХ - Австрия" 4 5
## 69500044 "ЛН-745621" "БАНЕОЦИН ПОР. 10Г" "САНДОЗ ГмбХ - Австрия" 4 5
## 69500300 "69506432458" "НИМЕСАН ТАБЛ. 100МГ N20" "Шрея Лайф Саенсиз Пвт. Лтд" 1 5
## 69500346 "695089466518" "ЦИННАРИЗИН ТАБЛ. 25МГ N50 АВЕКСИМА" "Анжеро-Судженский ХФЗ,ООО" 3 5
## 69500268 "69504851543" "ФИТОСЕДАН СБОР УСПОКОИТЕЛЬНЫЙ №2 Ф/П 2Г N20 КРАСНОГОРСК" "КРАСНОГОРСКЛЕКСР-ВА" 1 5
## 69500009 "69506584554" "БАНДАЖ КОМПРЕС. ПОДДЕРЖИВ. ПОЯСНИЧНО-КРЕСТЦОВЫЙ С ОВЕЧЬИМ МЕХОМ БКД-УНГА, р.6" "ТОНУС - ЭЛАСТ" 1 5
## 69500058 "ЛН-86600383" "АЛЛОХОЛ ТАБЛ.П.О. N10 ФАРМСТАНДАРТ" "ФАРМСТАНДАРТ" 10 5
## 69500147 "ЛН-41200521" "БАНЕОЦИН ПОР. 10Г" "САНДОЗ ГмбХ - Австрия" 4 5
## 69500147 "ЛН-05860521" "БАНЕОЦИН ПОР. 10Г" "САНДОЗ ГмбХ - Австрия" 4 5
## 69500268 "ЛН-00000927" "КВАМАТЕЛ ТАБЛ.П.О. 20МГ N28" "Гедеон Рихтер" 2 5

Чтение и запись файлов MS Excel

В отличие от простых форматов типа csv, файлы, созданные в MS Excel, импортировать не так просто. Ситуация ещё осложняется и тем, что форматы xls и xlsx сильно различаются по внутренней структуре. Часть пакетов, которые обеспечивают взаимодействие с Excel-файлами, требуют установки языка и компилятора Java (java development kit, jdk) в систему и соответствующих R-пакетов (rJava, в частности) — самые часто используемые пакеты тут XLconnect и xlsx. Тем не менее, в большинстве случаев необходимо только прочитать файл, и тут могут быть полезны пакеты openxlsx и readxl, не требующие установки jdk. Пакет openxlsx к тому же умеет ещё и записывать файлы в .xlsx, как XLconnect, в том числе и с условным форматированием ячеек.

Чтение файлов

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

Основные функции пакета: read_xls() для чтения файлов MS Excel, созданных в версиях до MS Excel 2007, read_xlsx() для чтения файлов, созданных в более современных версиях, также есть обобщающая функция read_excel(), которая по расширению файла определяет, какую из этих функций надо использовать. Пакет readxl принадлежит к экосистеме tideverse, поэтому импортированные таблицы имеют класс tibble и data.frame. Соответственно, если предполагается дальше работать с ними в data.table, то необходимо их отдельно явно преобразовать.

Вот так выглядит исходный файл в MS Excel:

Импортируем файл:

library(readxl)
# читаем файл
path <- './data/xls_example.xlsx'
xlsx_data <- read_xlsx(path)

str(xlsx_data)
## tibble [7 × 5] (S3: tbl_df/tbl/data.frame)
##  $ company  : chr [1:7] "xxx" "xxx" "xxx" "yyy" ...
##  $ item_code: num [1:7] 20000559 20000779 13117 40000289 40000274 ...
##  $ barcode  : num [1:7] 4.60e+12 4.60e+12 4.61e+12 3.57e+12 3.57e+12 ...
##  $ amount   : num [1:7] 3 4 2 6 2 1 9
##  $ price    : num [1:7] 480.3 26.9 353.8 207.6 174.8 ...
xlsx_data
## # A tibble: 7 × 5
##   company item_code       barcode amount price
##   <chr>       <dbl>         <dbl>  <dbl> <dbl>
## 1 xxx      20000559 4601026307584      3 480. 
## 2 xxx      20000779 4603182002259      4  27.0
## 3 xxx         13117 4607072020156      2 354. 
## 4 yyy      40000289 3574661287522      6 208. 
## 5 yyy      40000274 3574661287539      2 175. 
## 6 zzz      10000581 4602196002682      1 537. 
## 7 zzz      20000458 4601026308567      9 371.

При импорте можно указать тип колонок. Делается это с помощью аргумента col_types, в который необходимо передать вектор такой же длины, сколько колонок, состоящий из значений logical, numeric, date, text или list. К сожалению, в отличие от схожего аргумента colClasses в функциях импорта csv/xlsx-файлов (read.table(), read.csv(), data.table::fread(), openxlsx::read.xlsx и проч.), в readxl названия типов не соответствуют названиям атомарных типов данных в R.

Если для col_types указать NULL или guess, то тип будет выбран на основе анализа минимум 1000 строк (или всех строк, если датасет меньше 1000 строк). Также можно указать вектор типов длиной не по количеству колонок, а только из одного значения, тогда все колонки будут импортированы в этом типе. Значение skip указывает, что эту колонку не надо импортировать.

# читаем файл с указанием типа text для всех колонок
xlsx_data <- read_xlsx(path, col_types = 'text')
xlsx_data
## # A tibble: 7 × 5
##   company item_code barcode       amount price 
##   <chr>   <chr>     <chr>         <chr>  <chr> 
## 1 xxx     20000559  4601026307584 3      480.26
## 2 xxx     20000779  4603182002259 4      26.95 
## 3 xxx     13117     4607072020156 2      353.76
## 4 yyy     40000289  3574661287522 6      207.55
## 5 yyy     40000274  3574661287539 2      174.81
## 6 zzz     10000581  4602196002682 1      537.46
## 7 zzz     20000458  4601026308567 9      370.7

Как мы видим, readxl обладает рядом особенностей: во-первых, в колонке price, если её импортировать как числовую, показаны только целые значения, это сделано сознательно, так как дробные части существенно меньше целых (подробнее см. раздел про print()). Но это именно формат отображения таблички, а не реальное округление при импорте. А во-вторых, что важнее, в колонке item_code все значения в исходном файле начинаются с двух или трёх нулей. И на данный момент, несмотря на то, что эта проблема известна, readxl не умеет импортировать строковые значения, которые начинаются с одного или нескольких нулей. Например, 0020000559 импортируется как 20000559 даже при col_types = 'text'. Вообще, как оказалось, только функции пакета XLconnect способны импортировать данные в таком виде корректно, прочие часто используемые пакеты (readxl, openxlsx, xlsx) ошибаются.

В очень редких случаях случается так, что файл имеет формат xlsx, а расширение — xls. В таком случае функции импорта readxl выдадут ошибку, так как они при чтении файла делают проверку на его расширение. Тут может помочь проверка первых символов каждого файла (у xlsx-файлов первые четыре символа — это PK\003\004) и последующая смена расширения файла:

readChar(path, nchars = 4L, useBytes = TRUE)
## [1] "PK\003\004"

Частичное чтение файла

Большинство xlsx-файлов содержит несколько непустых листов. По умолчанию функции импорта читают только первый лист, остальные же игнорируются. Пакеты типа XLConnect могут читать весь файл в отдельный объект. Для чтения названий листов xlsx-файла можно воспользоваться функцией readxl::excel_sheets(), и полученный вектор потом циклом или через lapply() передать в функцию импорта:

# читаем список листов файла
sheets <- readxl::excel_sheets(path)
sheets
## [1] "Sheet1" "Sheet2"
# читаем все листы в список и смотрим структуру
file_str <- lapply(sheets, function(x) read_xlsx(path, sheet = x))
str(file_str)
## List of 2
##  $ : tibble [7 × 5] (S3: tbl_df/tbl/data.frame)
##   ..$ company  : chr [1:7] "xxx" "xxx" "xxx" "yyy" ...
##   ..$ item_code: num [1:7] 20000559 20000779 13117 40000289 40000274 ...
##   ..$ barcode  : num [1:7] 4.60e+12 4.60e+12 4.61e+12 3.57e+12 3.57e+12 ...
##   ..$ amount   : num [1:7] 3 4 2 6 2 1 9
##   ..$ price    : num [1:7] 480.3 26.9 353.8 207.6 174.8 ...
##  $ : tibble [3 × 2] (S3: tbl_df/tbl/data.frame)
##   ..$ company: chr [1:3] "xxx" "yyy" "zzz"
##   ..$ items  : num [1:3] 198 256 32

В некоторых случаях требуется импортировать только определённый диапазон ячеек со всего листа: например, когда на созданном вручную листе есть и таблица с данными, и графики, и дополнительные материалы. Для чтения определённой ячейки или диапазона ячеек используют аргумент range, в котором в строковом виде указывают диапазон ячеек в Excel-координатах (буквы для колонок и цифры для строк). К сожалению, на данный момент нельзя указать сразу несколько диапазонов. Во-вторых, в прочитанном диапазоне, если не указать обратное, первая строка будет по умолчанию интерпретироваться как строка заголовка. В результате приходится либо прямо задавать названия колонок, либо указывать, что заголовка нет:

read_xlsx(path, range = 'A5:B6', col_names = c('company', 'item_code'))
## # A tibble: 2 × 2
##   company item_code
##   <chr>       <dbl>
## 1 yyy      40000289
## 2 yyy      40000274

Сохранение Excel-файлов

С помощью пакета readxl нельзя сохранять файлы, для этого приходится обращаться к пакетам writexl, openxlsx или XLconnect. openxlsx, на мой взгляд, предпочтительнее, так как не требует установки Java, а так же имеет возможность сохранения Excel-файлов с условным форматированием таблиц.

При импорте файла с помощью пакета openxlsx можно импортировать все листы сразу, в один объект класса Workbook.

library(openxlsx)
xlsx_data <- loadWorkbook(path)
class(xlsx_data)
## [1] "Workbook"
## attr(,"package")
## [1] "openxlsx"

Функция conditionalFormatting задает правила, условия и стили условного форматирования ячеек в файле. В отличие от большинства других функций в R, она реализована так, что изменение происходит через изменение объекта на месте, нет необходимости перезаписывать импортированный Workbook. В аргументах функции указывается область, к которой должно быть применено условное форматирование (sheet, cols, rows), а также правила и цветовые схемы (type и style).

# задаём правило условного форматирования - градиент от 'lightskyblue2' до 'steelblue3' по значениям в колонке 'price' на первом листе.
conditionalFormatting(xlsx_data, sheet = 1, cols = 5, rows = 2:8, type = 'colorScale', style = c('lightskyblue2', 'steelblue3'))

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

# добавляем таблицу на 10 и следующие строки первого листа
writeDataTable(xlsx_data, sheet = 1, x = head(iris), startRow = 10, tableStyle = 'none', withFilter = FALSE)

# сохраняем
saveWorkbook(xlsx_data, file = './data/xls_example2.xlsx', overwrite = TRUE)
Вот так выглядит файл после сохранения:

Чтение и запись файлов SPSS

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

Чтение файлов SPSS

Для импорта файлов SPSS (.sav) обычно используют функции какого-либо из двух пакетов: foreign::read.spss(), который идет в базовом наборе пакетов R, а также haven::read_spss() авторства Хэдли Викхэма.

Исходный файл имеет такую структуру (скриншот из SPSS Output):

foreign::read.spss()

Читаем файл сразу с указанием необходимости перекодировки в кодировку локали (аргумент reencode) и выводим на печать.

library(foreign)
path <- './data/spss_example.sav'
spss_data <- read.spss(file = path, reencode = TRUE)
## re-encoding from UTF-8
## Warning in read.spss(file = path, reencode = TRUE): Undeclared level(s) 4, 9999
## added in variable: var3
## Warning in read.spss(file = path, reencode = TRUE): Duplicated levels in factor
## var3: отказ от ответа

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

В результате такого импорта мы получаем список с достаточно сложной структурой и большим количеством атрибутов. Если посмотреть на содержание, то видно, что для var3 метки значений импортированы как факторы. При этом вторая метка 999 = отказ от ответа импортирована по правилу paste0(label, duplicated.value.labels.infix, level), где duplicated.value.labels.infix = '_duplicated_' по умолчанию. В атрибутах хранятся именованные векторы для меток значений и метки переменных (label.table и variable.labels), а также тип указания пропущенных значений (одно, несколько, диапазон) и сама метка пропущенного значения (missings).

spss_data
## $var1
## [1] 3 4 3 3 4 3
## 
## $var2
## [1] 4 5 5 5 4 5
## 
## $var3
## [1] среднее                        9999                          
## [3] отказ от ответа                среднее                       
## [5] 4                              отказ от ответа_duplicated_999
## 6 Levels: среднее высшее 4 отказ от ответа ... 9999
## 
## attr(,"label.table")
## attr(,"label.table")$var1
## NULL
## 
## attr(,"label.table")$var2
## NULL
## 
## attr(,"label.table")$var3
## отказ от ответа отказ от ответа          высшее         среднее 
##           "999"            "99"             "2"             "1" 
## 
## attr(,"variable.labels")
##          var1          var2          var3 
##  "Практичный"  "Порядочный" "Образование" 
## attr(,"missings")
## attr(,"missings")$var1
## attr(,"missings")$var1$type
## [1] "none"
## 
## 
## attr(,"missings")$var2
## attr(,"missings")$var2$type
## [1] "none"
## 
## 
## attr(,"missings")$var3
## attr(,"missings")$var3$type
## [1] "one"
## 
## attr(,"missings")$var3$value
## [1] 9999
## 
## 
## attr(,"codepage")
## [1] 65001

Для того чтобы прочитать файл в привычный табличный вид и учесть выданные предупреждения, необходимо воспользоваться рядом дополнительных аргументов. Во-первых, аргумент to.data.frame = TRUE позволяет нам получить данные в виде data.frame без каких-либо дополнительных конверсий, к тому же при импорте будут применены правила для указания пользовательских пропусков (9999 для NA-значений, как и задано в файле). Второй полезный аргумент use.value.labels = FALSE указывает, что в результате импорта должны быть не метки значений, а сами значения. Таким образом, данные из колонки var3 будут импортированы как числа, соответственно, метка отказ от ответа для значений 99 и 999 не будет вызывать конфликта при формировании уровней фактора.

spss_data <- read.spss(
  file = path,
  to.data.frame = TRUE,
  use.value.labels = FALSE,
  reencode = TRUE
)
## re-encoding from UTF-8
spss_data
##   var1 var2 var3
## 1    3    4    1
## 2    4    5   NA
## 3    3    5   99
## 4    3    5    1
## 5    4    4    4
## 6    3    5  999

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

attributes(spss_data)
## $names
## [1] "var1" "var2" "var3"
## 
## $class
## [1] "data.frame"
## 
## $row.names
## [1] 1 2 3 4 5 6
## 
## $variable.labels
##          var1          var2          var3 
##  "Практичный"  "Порядочный" "Образование" 
## 
## $codepage
## [1] 65001

haven::read_sav()

Функции read_sav() и read_spss() пакета haven обладают схожим функционалом при меньшем количестве настроек. При этом они несколько быстрее foreign::read.spss(), а также не имеют проблем с длинными строками. В результате импорта получается tibble-таблица (так как haven принадлежит экосистеме tidyverse).

library(haven)
spss_data <- read_sav(path)
spss_data
## # A tibble: 6 × 3
##    var1  var2                  var3
##   <dbl> <dbl>             <dbl+lbl>
## 1     3     4   1 [среднее]        
## 2     4     5  NA                  
## 3     3     5  99 [отказ от ответа]
## 4     3     5   1 [среднее]        
## 5     4     4   4                  
## 6     3     5 999 [отказ от ответа]

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

str(spss_data)
## tibble [6 × 3] (S3: tbl_df/tbl/data.frame)
##  $ var1: num [1:6] 3 4 3 3 4 3
##   ..- attr(*, "label")= chr "Практичный"
##   ..- attr(*, "format.spss")= chr "F8.2"
##  $ var2: num [1:6] 4 5 5 5 4 5
##   ..- attr(*, "label")= chr "Порядочный"
##   ..- attr(*, "format.spss")= chr "F8.2"
##  $ var3: dbl+lbl [1:6]   1,  NA,  99,   1,   4, 999
##    ..@ label      : chr "Образование"
##    ..@ format.spss: chr "F8.2"
##    ..@ labels     : Named num [1:4] 1 2 99 999
##    .. ..- attr(*, "names")= chr [1:4] "среднее" "высшее" "отказ от ответа" "отказ от ответа"

Нередко возникает необходимость иметь не только значения, но и их метки, например, для var3 (уровень образования) из примера. В таких случаях помогает функция haven::as_factor():

as_factor(spss_data$var3)
## [1] среднее         <NA>            отказ от ответа среднее        
## [5] 4               отказ от ответа
## attr(,"label")
## [1] Образование
## Levels: среднее высшее 4 отказ от ответа

Запись файлов SPSS

Для сохранения данных в SPSS-формате используется функция write_sav() пакета haven. Помимо собственно значений, можно указать текстовую метку создаваемой переменной, а также формат хранения (количество знаков для целой и десятичной частей числа). Создадим две переменных, числовую и строковую:

# создаём числовую переменную
spss_data$var4 <- sample(nrow(spss_data))
attr(spss_data$var4, 'label') <- 'новая чисовая переменная'
attr(spss_data$var4, 'format.spss') <- 'F8.2'

# создаём строковую переменную
spss_data$var5 <- month.abb[1:nrow(spss_data)]
attr(spss_data$var5, 'label') <- 'новая строковая переменная'
attr(spss_data$var5, 'format.spss') <- 'A8'

Для создания переменной, в которой используются метки значений (в том числе и пользовательские метки для пропущенных значений), необходимо использовать функцию labelled_spss(). Создадим переменную из нулей и единиц с метками Нет и Да соответственно. Также укажем, что 9999 используется для обозначения пропущенных значений (за это отвечают аргументы na_values и na_range).

# создаём вектор
spss_data$var6 <- sample(c(0, 1), nrow(spss_data), replace = TRUE)

# задаём формат числа
attr(spss_data$var6, 'format.spss') <- 'F1.0'

# задаём метки с помощью именованного вектора в labels
spss_data$var6 <- labelled_spss(
  spss_data$var6,
  na_values = 9999,
  labels = c('Нет' = 0, 'Да' = 1),
  label = 'новая переменная с метками'
)

Из-за того, что при импорте колонки var3 пользовательская метка пропуска была заменена на NA, надо её тоже указать, заодно зададим новую метку для значения 4:

# корректируем метки значений в var3
var3_labels <- c(attr(spss_data$var3, 'labels'), 'новая метка' = 4)
var3_labels <- sort(var3_labels)

spss_data$var3 <- labelled_spss(
  spss_data$var3,
  na_values = 9999,
  labels = var3_labels,
  label = 'Образование'
)

# сохраняем всё в файл
write_sav(spss_data, './data/spss_example2.sav')

Структура обновленного файла:

Чтение и запись JSON

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

Для чтения обычно используют функцию fromJSON(), а в некоторых редких случаях — stream_in(), которая на вход принимает файлы, соединения или ссылки на файл в njson-формате. Вот так выглядит json-файл примера (для простоты чтения здесь он представлен в pretty-формате, в реальности это, конечно же, должна быть одна строка):

cat ./data/json_example.json
## [{
##   "name":"Thor",
##   "parents": {
##     "father":"Odin",
##     "mother":"Jord"
##   },
##   "weapon": "Mjölnir"
## },
## {
##   "name":"Loki",
##   "parents": {
##     "father":"Farbauti",
##     "mother":"Laufey"
##   }
## }]

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

library(jsonlite)
path <- './data/json_example.json'
json_parsed <- fromJSON(path)
json_parsed
##   name parents.father parents.mother  weapon
## 1 Thor           Odin           Jord Mjölnir
## 2 Loki       Farbauti         Laufey    <NA>

Запись объектов в json-формате осуществляется с помощью toJSON() или с помощью stream_out(). При этом надо учитывать, что при прямой записи с помощью toJSON() требуется указать значения большого количества аргументов, поэтому можно прибегнуть к простому трюку: сначала создать из таблицы json-объект с помощью toJSON(), а результат записать в файл построчно функцией writeLines():

json_parsed$spouse <- c('Sif', 'Sigyn')

writeLines(toJSON(json_parsed), con = './data/json_example2.json')

Смотрим результат, он должен быть одной строкой:

cat ./data/json_example2.json
## [{"name":"Thor","parents":{"father":"Odin","mother":"Jord"},"weapon":"Mjölnir","spouse":"Sif"},{"name":"Loki","parents":{"father":"Farbauti","mother":"Laufey"},"spouse":"Sigyn"}]

Чтение и запись XML/HTML

Для парсинга данных в XML формате обычно используют один из двух пакетов, XML и xml2, — они в целом схожи по функционалу, несмотря на то, что xml2 появился чуть ли не на 15 лет позже и во многом вдохновлен пакетом XML. Оба пакета регулярно обновляются и в целом взаимозаменяемы. Здесь я рассматриваю xml2, в немалой степени из-за того, что в пакете xml2 для навигации по нодам дерева разметки и для извлечения элементов и атрибутов используется язык XPathXML обращаться к нодам можно через оператор [[). Так как языки разметки XML и HTML схожи по организации, то логика парсинга xml-данных может быть легко перенесена на html-разметку и, соответственно, скрапинг web-страниц. Собственно, пакет rvest, который используется для скрапинга (авторства Хэдли Викхема, как и xml2) является оберткой над xml2.

Рассмотрим сложный кейс — xml-файл, в котором есть вложенные массивы и пропущенные значения. Вот так выглядит импортируемый файл:

cat ./data/xml_example.xml
## <?xml version="1.0" encoding="UTF-8"?>
## <edda>
##   <character>
##     <name>Thor</name>
##     <parents>
##       <father>
##         <name>Odin</name>
##         <weapon>Gungnir</weapon>
##       </father>
##       <mother>
##         <name>Jord</name>
##       </mother>
##     </parents>
##     <weapon>Mjölnir</weapon>
##   </character>
##   <character>
##     <name>Loki</name>
##     <parents>
##       <father>
##         <name>Farbauti</name>
##       </father>
##       <mother>
##         <name>Laufey</name>
##       </mother>
##     </parents>
##   </character>
## </edda>

Для обхода нод и выделения необходимых элементов можно воспользоваться функцией xml_find_all(), однако у неё есть большой минус: в случае пропусков в данных пропущенные значения будут заполнены последними не-NA значениями. Поэтому для того, чтобы учесть наличие вложенных элементов и пропусков, необходимо перебирать одинаковые по структуре блоки данных и указывать необходимые элементы с помощью XPath-путей. Для того чтобы в пути указать номер блока, я использовал функцию glue::glue(), более удобный аналог paste().

При таком процессе обработки функция xml_find_first() отдаёт первую встреченную ноду по указанному пути, при этом её дополнительно надо преобразовать в текст с помощью функции xml_text(). Для числовых значений, если бы они были, пришлось бы использовать xml_double() или xml_integer().

# подключаем библиотеку и импортируем файл
library(xml2)
library(glue)
path <- './data/xml_example.xml'
xml_data <- read_xml(path)

# считаем количество блоков по персонажам "Эдды"
n_chars <- xml_length(xml_data)

# итерируемся по блокам, номер блока указываем в XPath-пути
xml_data_parsed <- lapply(seq_len(n_chars), function(x) {
  data.frame(
    character = xml_text(xml_find_first(xml_data, glue('//character[{x}]/name'))),
    father = xml_text(xml_find_first(xml_data, glue('//character[{x}]/*/father/name'))),
    father_weapon = xml_text(xml_find_first(xml_data, glue('//character[{x}]/*/father/weapon'))),
    mother = xml_text(xml_find_first(xml_data, glue('//character[{x}]/*/mother/name'))),
    weapon = xml_text(xml_find_first(xml_data, glue('//character[{x}]/weapon')))
  )
})

# собираем список таблиц в одну общую таблицу
xml_data_parsed <- do.call(rbind, xml_data_parsed)
xml_data_parsed
##   character   father father_weapon mother  weapon
## 1      Thor     Odin       Gungnir   Jord Mjölnir
## 2      Loki Farbauti          <NA> Laufey    <NA>

Для сравнения, какой получается результат при прямолинейном использовании xml_find_all() или XML::xmlToDataFrame(): в первом случае некорректно обрабатываются пропуски, во втором — значения вложенных элементов оказываются слиты в одну строку и также не очень корректно обрабатываются пропуски:

# при использовании xml_find_all() вместо обхода по блокам и xml_find_first()
data.frame(
    character = xml_text(xml_find_all(xml_data, '//character/name')),
    father = xml_text(xml_find_all(xml_data, '//character/parents/father/name')),
    father_weapon = xml_text(xml_find_all(xml_data, '//character/parents/father/weapon')),
    mother = xml_text(xml_find_all(xml_data, '//character/parents/mother/name')),
    weapon = xml_text(xml_find_all(xml_data, '//character/weapon'))
)
##   character   father father_weapon mother  weapon
## 1      Thor     Odin       Gungnir   Jord Mjölnir
## 2      Loki Farbauti       Gungnir Laufey Mjölnir
# при использовании xmlToDataFrame() пакета xml
XML::xmlToDataFrame(XML::xmlParse(path))
##   name         parents  weapon
## 1 Thor OdinGungnirJord Mjölnir
## 2 Loki  FarbautiLaufey    <NA>

Запись и чтение R-объектов: RData, RDS

В том случае, когда необходимо сохранить на диск какой-нибудь R-объект, например, регрессионную модель, проще всего воспользоваться одним из нативных форматов, .Rds или .RData (.Rda). Оба формата создаются через обращение к функции сериализации serialize().

Для сохранения в формат .Rds нужна функция saveRDS(). Этот формат используется для сохранения единичных R-объектов, притом можно указать алгоритм сжатия (по умолчанию gzip, а также bzip2, xz):

# создаём объект и смотрим его размер в байтах
x <- rnorm(1e6)
object.size(x)
## 8000048 bytes
# сохраняем в rds
saveRDS(x, file = './data/rds_example.rds')

Смотрим размер результата в терминале:

wc -c ./data/rds_example.rds
## 7678250 ./data/rds_example.rds

Для сохранения нескольких объектов можно использовать функцию save() и сохранять объекты в файл с расширением .RData или .Rda. При сохранении данные также будут архивированы с помощью выбранного алгоритма. Функция save.image() — аналог функции save(), в которой в список объектов передан список объектов глобального окружения с их именами (ls(all.names = TRUE)). Основное отличие .RData от .Rds: с помощью первого можно сохранить несколько объектов, а с помощью .Rds — только один.

Создадим и проверим, что у нас в рабочем окружении два объекта. Сохраним:

# создаём x и y, смотрим объекты глобального окружения
x <- rnorm(100)
y <- runif(100)
ls()
## [1] "x" "y"
# сохраняем
save(list = c('x', 'y'), file = './data/rda_example.RData')

Импорт данных из форматов .Rds и .RData осуществляется двумя функциями — readRDS() и load() соответственно. Тем не менее, есть одно существенное различие: функция readRDS() по использованию похожа на все остальные функции импорта, т. е. результат её выполнения может быть записан как новый объект (связан с новым именем). Загрузка данных из .RData с помощью load() фактически восстанавливает сохранённые объекты в рабочем окружении (либо в специально указанном окружении). И если результат load() записывать в новый объект, то в этом объекте будут только названия объектов, сохранённых в .RData:

# очистим окружение для наглядности
rm(list = ls())

# прочитаем данные из .RData и .RDS
load('./data/rda_example.RData')
rda_data <- load('./data/rda_example.RData')
rds_data <- readRDS('./data/rds_example.rds')

# смотрим объекты окружения и их классы
ls()
## [1] "rda_data" "rds_data" "x"        "y"
sapply(ls(), function(x) class(get(x)))
##    rda_data    rds_data           x           y 
## "character"   "numeric" "character"   "numeric"
# отдельно смотрим структуру объекта rda_data
str(rda_data)
##  chr [1:2] "x" "y"