Автоматизация повторяющихся задач с помощью циклов и функций

Многие пользователи R начинают программировать на R, имея опыт работы со статистикой, а не с опытом программирования/разработки программного обеспечения, поскольку ранее они использовали такие программы, как SPSS, Excel и т. д. Таким образом, они могут не иметь представления о некоторых методах программирования, которые можно использовать для улучшить код. Это может включать в себя создание более модульного кода, что, в свою очередь, упрощает поиск и устранение ошибок, но также может использоваться для автоматизации повторяющихся задач, таких как создание таблиц, графиков и т. д.

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

Начнем с простого примера. Допустим, у нас есть данные из нескольких разных групп. В данном случае 3 животных (тигры, лебеди и барсуки), и мы собрали некоторые данные об этом (оценка и какое-то значение).

Мы могли бы прочитать это в R как файл CSV или воссоздать его как фрейм данных следующим образом:

df <- data.frame(group = rep(c("tiger","swan","badger"), 6), 
 score = c(12,32,43,53,26,56,56,32,23,53,24,65,23,78,23,56,25,75), 
 val = c(24,67,32,21,21,56,54,21,35,67,34,23,32,36,74,24,24,74))

Мы можем представлять списки/столбцы значений с помощью вектора в R. Это делается путем помещения этих значений в список, разделенный запятыми, с помощью функции c, которая используется для объединения значений в вектор. Например, числа от 1 до 4:

my_list <- c(1,2,3,4)

Если мы выведем список, набрав имя списка:

my_list

Что выводит:

[1] 1 2 3 4

Мы используем это, чтобы предоставить значения во фрейме данных для столбцов score и val. Мы могли бы сделать то же самое для столбца group и добавить 6 лотов «тигр», «лебедь» и «барсук». Вместо этого мы используем функцию rep(replicate), чтобы повторить эти значения 6 раз.

rep(c("tiger","swan","badger"), 6)

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

Мы можем просматривать кадр данных в R несколькими способами. Мы могли бы ввести имя фрейма данных:

df

Что выводит:

  group score val
1 tiger    12  24
2 swan     32  67
3 badger   43  32
4 tiger    53  21
5 swan     26  21
6 badger   56  56
7 tiger    56  54
8 swan     32  21
9 badger   23  35
10 tiger   53  67
11 swan    24  34
12 badger  65  23
13 tiger   23  32
14 swan    78  36
15 badger  23  74
16 tiger   56  24
17 swan    25  24
18 badger  75  74

Если вы используете R Studio. Функция просмотра (с большой буквы V) откроет фрейм данных в новой вкладке и отобразит его в табличном формате:

View(df)

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

utils::View(df)

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

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

Сначала мы можем импортировать несколько полезных библиотек.

library(ggplot2)
library(tidyverse)

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

selected_group <- df %>% filter(group == "tiger")

Здесь мы передаем данные %>%из фрейма данных в переменную с именем selected_group, фильтруя имя группы, выбирая значения группы, соответствующие «тигру». Затем мы можем создать простую точечную диаграмму, например:

plt1 <- selected_group %>% ggplot(aes(x = val, y = score)) +
    geom_point(size=2) +
    labs(x = "Val", y = "Score") +
    ggtitle("Plot of score/val for tiger group")

Пакет ggplot работает, добавляя слои к графику с различной информацией, как показано на изображении ниже:

Здесь мы добавляем эстетику (aes), определяющую значения осей x и y.

ggplot(aes(x = val, y = score))

Затем мы добавляем точки (точки), установив для них размер 2.

geom_point(size=2)

Наконец, мы добавляем метки осей:

labs(x = "Val", y = "Score")

И название сюжета:

ggtitle("Plot of score/val for tiger group")

Затем мы можем вывести график:

plt1

Затем мы можем просто вырезать и вставить это и изменить для разных групп:

selected_group <- df %>% filter(group == "tiger")
plt1 <- selected_group %>% ggplot(aes(x = val, y = score)) +
    geom_point(size=2) +
    labs(x = "Val", y = "Score") +
    ggtitle("Plot of score/val for tiger group")
plt1
selected_group <- df %>% filter(group == "swan")
plt2 <- selected_group %>% ggplot(aes(x = val, y = score)) +
    geom_point(size=2) +
    labs(x = "Val", y = "Score") +
    ggtitle("Plot of score/val for swan group")
plt2
selected_group <- df %>% filter(group == "badger")
plt3 <- selected_group %>% ggplot(aes(x = val, y = score)) +
    geom_point(size=2) +
    labs(x = "Val", y = "Score") +
    ggtitle("Plot of score/val for badger group")
plt3

Хотя это работает, есть ненужное повторение кода. Также представьте, что у вас гораздо больше групп, 10, 100, больше? Этот подход не масштабируется, и любые изменения необходимо применять к каждому графику (например, изменение размера точек необходимо применять ко всем графикам). В идеале вы хотите повторно использовать как можно больше кода. Это сокращает объем обслуживания и масштабирует ваш код, чтобы он справился с любым количеством потенциальных групп.

Использование циклов

Один из способов улучшить это — использовать «петлю». Циклы — это структуры программирования, которые позволяют нам повторять блоки кода определенное количество раз или до тех пор, пока не будет выполнено определенное условие. Чтобы повторить код заданное количество раз, обычно используется цикл for.

Сначала мы создадим вектор, содержащий имена 3 групп:

groups <- c("tiger","swan","badger")

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

for(i in 1:3)
{
}

Каждая строка кода между фигурными скобками ({}) повторяется 3 раза. У нас также есть счетчик циклов под названием i. Это автоматически увеличивается (добавляется) каждый раз, когда выполняется содержимое цикла. В программировании принято называть счетчики циклов такими именами, как i, j, k, x, y и т. д., хотя вы можете называть свой счетчик циклов любым именем, которое вам нравится.

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

selected_group <- df %>% filter(group == groups[i])

Мы можем изменить выбранную группу для фильтрации по интересующей группе в нашем векторе groups. Счетчик циклов i можно использовать для указания на разные имена групп в векторе, начиная с 1 (тигр). Затем мы можем создать переменную для заголовка и обновить ее, чтобы отобразить имя соответствующей группы, используя функцию paste0 для конкатенации (объединения) строки заголовка текста:

group_title <- paste0("Plot of score/val for ", groups[i], "group")

Готовый цикл с сюжетом будет выглядеть так:

groups <- c("tiger","swan","badger")
for(i in 1:3)
{
    selected_group <- df %>% filter(group == groups[i])
    group_title <- paste0("Plot of score/val for ", groups[i], "group")
    plt <- selected_group %>% ggplot(aes(x = val, y = score)) +  
    geom_point(size=2) +
    labs(x = "Val", y = "Score") +
    ggtitle(group_title)
    print(plt)
}

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

Еще один способ сделать это еще более надежным — не «жестко кодировать» количество циклов, которое должен выполняться, поскольку список групп может быть расширен в будущем или, наоборот, элементы могут быть удалены. Вместо этого мы можем использовать функцию length, чтобы вернуть количество элементов в векторе groups. Таким образом, это всегда будет работать, если элементы добавляются или удаляются из вектора.

for(i in 1:length(groups))
{
    …
}

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

Это изменит это:

groups <- c("tiger","swan","badger")

В это:

groups <- unique(df$group)

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

Использование функций

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

Здесь мы создаем функцию с именем generateGroupScatterPlots и передаем кадр данных и интересующую группу, используя переменную current_group, обновляя код для использования группы, которую мы передаем.

generateGroupScatterPlots <- function(df, current_group)
{
    selected_group <- df %>% filter(group == current_group)
    group_title <- paste0("Plot of score/val for ", current_group, "group")
    plt <- selected_group %>% ggplot(aes(x = val, y = score)) +
        geom_point(size=2) +
        labs(x = "Val", y = "Score") +
        ggtitle(group_title)
    print(plt)
}

Функция не запустится, пока не будет вызвана. Чтобы выполнить функцию, нам нужно вызвать ее имя и передать любые аргументы, которые могут потребоваться. Мы вызываем функцию в цикле, проходящем в кадре данных и интересующей группе groups[i].

groups <- unique(df$group)
for(i in 1:length(groups))
{
    generateGroupScatterPlots(df, groups[i])
}

Еще одна проблема, с которой люди часто сталкиваются при попытке использовать циклы и функции, — это динамический доступ к столбцам в кадре данных. Например, предположим, что мы хотим суммировать столбцы, содержащие числовые данные, обычно вы получаете доступ к столбцу в R, указав фрейм данных, а затем имя столбца, разделенное символом доллара $:

df$score

Затем мы могли бы просмотреть этот столбец с помощью функции печати или одной из функций просмотра:

utils::View(df$score)

Но как нам изменить это, чтобы вместо этого использовать переменную? Другой способ сделать то же самое в R — использовать нотацию списка, состоящую из набора двойных квадратных скобок:

df[["score"]]

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

score_col <- "score"
utils::View(df[[score_col]])

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

print(sum(df$score))
print(sum(df$val))

Представьте, однако, что у вас есть гораздо больше столбцов и, возможно, вы также хотите вычислить другие показатели, такие как стандартное отклонение, среднее значение, сумма и т. д. Здесь проявляется мощь циклов. Сначала нам нужно сохранить имена столбцов интерес. Мы могли бы сделать:

col_names <- c("score", "val")

Другой способ — использовать функцию colnames и указать, какие имена столбцов мы хотим сохранить по номеру (т. е. номера 2 и 3, игнорируя первый столбец с именами групп).

col_names <- colnames(df)[2:3]

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

for(i in 1:length(col_names))
{
    col <- col_names[i]
    cat("\n\nColumn: ", col, "\n")
    print(sum(df[[col]]))
    print(mean(df[[col]]))
    print(sd(df[[col]]))
}

Что производит следующий вывод:

Column: score
[1] 755
[1] 41.94444
[1] 19.99551
Column: val
[1] 719
[1] 39.94444
[1] 19.65428

Обратите внимание, что мы берем имя столбца и сохраняем его в переменной с именем col для столбца. Затем мы используем нотацию списка [[ ]], добавляя имя переменной col, которое обновляется каждый раз, когда цикл запускается, чтобы указать на следующий столбец в векторе. Функция cat (объединение и печать) используется для отображения имен столбцов в выходных данных. \n указывает, что нам нужна новая строка (возврат), чтобы текст не находился на той же строке.

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

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

Давайте напишем код для вывода количества умерших людей в каждой категории. Мы начнем с функции для вычисления смертей в каждой категории:

deathsPerCategory <- function(df, col_name)
{
    cols_to_keep <- c("id", "death", col_name)
    selected_col <- df %>% select(cols_to_keep)
    filtered_col <- subset(selected_col, selected_col[[col_name]] == 1 & selected_col$death == 1)
    table <- filtered_col %>% group_by(death) %>% summarise(n=n(), .groups = ‘drop’)
    
    return(table$n)
}

Как и в предыдущем примере, мы передаем кадр данных и имя интересующего столбца. Затем мы создаем вектор, содержащий столбцы, которые мы хотим сохранить во фрейме данных. Это столбцы id и death, а также интересующий столбец. Затем мы используем функцию select, чтобы выбрать только эти столбцы из фрейма данных. Затем мы дополнительно подмножаем это, используя функцию subset для фильтрации данных таким образом, чтобы в столбце «смерть» и столбце интересующей категории заболевания должна быть «1». Затем мы создаем таблицу, группируя по death и суммируя количество смертей. Наконец, мы возвращаем это суммарное значение для вывода.

Затем мы можем использовать эту функцию в цикле для вывода сводок по 3 категориям заболеваний. Сначала нам нужно получить названия категорий заболеваний из названий столбцов:

col_names <- colnames(df)[2:4]

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

for (i in 1:length(col_names)) 
{
    cat("\n", col_names[i], "deaths = ", deathsPerCategory(df, col_names[i]), "\n")
}

Что дает следующий вывод, который мы можем подтвердить, посмотрев на таблицу:

cardiac deaths = 1
respiratory deaths = 1
metabolic deaths = 2

Полный код:

library(tidyverse)
deathsPerCategory <- function(df, col_name)
{
    cols_to_keep <- c("id", "death", col_name)
    selected_col <- df %>% select(cols_to_keep)
    filtered_col <- subset(selected_col, selected_col[[col_name]] == 1 & selected_col$death == 1)
    table <- filtered_col %>% group_by(death) %>% summarise(n=n(), .groups = "drop")
    return(table$n)
}
col_names <- colnames(df)[2:4]
for (i in 1:length(col_names)) 
{
    cat("\n", col_names[i], "deaths = ", deathsPerCategory(df, col_names[i]), "\n")
}

Несмотря на то, что в представленных здесь примерах используются небольшие наборы данных, мы надеемся, что вы сможете увидеть преимущества размышлений и встроенных возможностей расширения кода для работы с большими наборами данных и более сложными задачами. Это упрощает управление кодом, его поддержку и модификацию без ненужного повторения кода. Одним из навыков специалиста по данным является способность использовать методы программирования в своем анализе для обработки больших объемов данных способами, которые просто невозможны с использованием программного обеспечения, такого как Excel. Если вы переходите на R с другого, более визуального программного обеспечения в стиле электронных таблиц, то одним из основных преимуществ использования таких вещей, как R и Python, является возможность масштабировать ваш код таким образом. Я часто начинал, например, с написания кода для создания одного графика или вычисления, проверял, что я получаю правильный/ожидаемый вывод, а затем рефакторинг кода с использованием функций, циклов и т. д. для обработки оставшихся задач. Поначалу это может показаться более сложным, но со временем и с большими наборами данных это сэкономит вам больше времени в долгосрочной перспективе, поскольку одно изменение может быть применено ко всем вашим графикам, таблицам, вычислениям… вносить изменения.