Треки
/
R
R
/
Вправи
/
Сирний клуб
Сирний клуб

Сирний клуб

Навчальна вправа

Вступ

Що таке функціональне програмування?

Для наших цілей ось кілька ключових особливостей:

  • Чисті функції, які послідовно відображають вхідні дані у вихідні й не мають побічних ефектів.
  • Незмінні дані.
  • Функції першого класу, з якими можна поводитися як зі значеннями будь-якого іншого типу.
  • Функції вищого порядку, які приймають інші функції як аргументи або як повернене значення.

Однак R не є чисто функціональною мовою (на відміну, наприклад, від Haskell).

Розгляньмо R як прагматичну мову, яка (дедалі частіше) надає функціональні можливості там, де вони є чистим і простим способом виконати роботу. Загалом мета - аналіз даних, а програмування - лише засіб для її досягнення.

Пайпи

Використання пайпів |> для зʼєднання функцій стає дедалі поширенішим прийомом у багатьох мовах.

Коротко:

  • Результат ліворуч від пайпа стає першим аргументом функції праворуч (причому «праворуч» не враховує необовʼязкові розриви рядків).
  • Будь-які інші аргументи можна передати у виклику функції.
  • У R пайп потрібно спрямовувати на виклик функції, а не просто на назву, тож дужки потрібні завжди (це відрізняється від деяких інших мов).

Деякі програмісти, читаючи код, подумки промовляють пайп як «тоді»: зробити крок 1, ТОДІ крок 2...

Наведений нижче приклад ілюструє синтаксис.

library(stringr)

"Monday-25-February" |> 
  str_split("-") |>     
  unlist() |>               # [1] "Monday"   "25"       "February"
  str_sub(1, 3) |>          # [1] "Mon" "25"  "Feb"
  str_to_upper() |>         # [1] "MON" "25"  "FEB"
  str_flatten(collapse = ", ")
#> [1] "MON, 25, FEB"

Цей різновид пайпа додали до Base R у версії 4.1.0, і його можна зустріти під назвою «рідний пайп».

Редактор RStudio (та деякі інші) використовує Ctrl-Shift-M як гарячу клавішу, щоб додати пайп із потрібними пробілами.

Функції apply

Раніше ми бачили, що багато функцій у R є «векторизованими»: вони працюють з цілими векторами, тож писати явні цикли не потрібно.

Функції, які використовують лише векторизовані функції, і самі, найімовірніше, будуть автоматично векторизованими. Простий приклад:

triple <- function(x) return(3 * x)
triple(1:10)
 [1]  3  6  9 12 15 18 21 24 27 30

Проте складніші функції можуть не приймати вектор на вході, наприклад, якщо містять цикли чи блоки if-else.

Нам потрібен загальніший спосіб застосовувати функції до векторів (подібно до map() у деяких інших мовах).

Оскільки в базовій мові R є багато різноманітних структур даних, для роботи з ними існує ціла родина функцій *apply(). Технічніше кажучи, програмісти часто називають їх «функціями вищого порядку», а математики «функціоналами», бо вони приймають функції як аргументи.

Поки що розгляньмо lapply() і sapply().

lapply()

«List-apply» призначена працювати зі списками на вході, але за потреби вектори непомітно перетворюються на списки.

Вихідні дані - список, тож, можливо, їх доведеться розпакувати, щоб отримати вектор:

h <- function(size) {
    switch(size,
           "small" = 6,
           "medium" = 8,
           "large" = 10
    )
}

v <- c("small", "large", "medium")

h(v) # bad use of an unvectorized function
#> Error in switch(size, small = 6, medium = 8, large = 10) : 
#>   EXPR must be a length 1 vector

lapply(v, h)  # output is a list
#> [[1]]
#> [1] 6

#> [[2]]
#> [1] 10

#> [[3]]
#> [1] 8

lapply(v, h) |> unlist() # output is a vector
#> [1]  6 10  8

Ті, хто вже знайомий із концепцією Switch, можуть помітити, що ми могли б натомість замінити скалярний switch на векторизовану функцію recode_values() із бібліотеки dplyr. Векторизація - це добре, а сучасний R дає кілька способів її досягти.

library(dplyr)

h <- function(size) {
  recode_values(size,
         "small" ~ 6,
         "medium" ~ 8,
         "large" ~ 10
  )
}

v <- c("small", "large", "medium")
h(v)
#> [1]  6 10  8

sapply()

«Simplified-apply» робить щось подібне до lapply, але ще й намагається спростити повернене значення: наприклад, іменований вектор замість списку. Залежно від контексту може знадобитися застосувати unname():

sapply(v, h)
 small  large medium 
     6     10      8 

sapply(v, h) |> unname()
#> [1]  6 10  8

Бібліотека purrr

lapply і sapply прості, знайомі більшості користувачів R і досі досить популярні для векторів і простих списків.

У Base R є також багато інших функцій apply. Ключова мета Tidyverse - запропонувати простішу й послідовнішу заміну для цієї плутанини.

Тепер є пакет purrr, який пропонує кілька додаткових варіантів, як-от map(), і водночас робить дизайн послідовнішим, а повідомлення про помилки зрозумілішими.

Цей пакет, менш обмежений зворотною сумісністю, ніж Base R, значно наближає R до сучасного функціонального програмування, як у інших мовах.

У purrr багато найрізноманітніших функцій, які годі описати коротко. Ось кілька ресурсів, які допоможуть орієнтуватися в бібліотеці:

Численні функції map

Функція map() застосовує функцію до кожного елемента вхідних даних і завжди повертає список.

Поки що це звучить як lapply(), лише з іншою назвою і, можливо, з кращими повідомленнями про помилки.

Проте map() - лише один представник великої родини.

Кілька запитань, які варто обміркувати, добираючи функцію:

  • Що потрібно повернути? map() завжди повертає список, але якщо потрібен атомарний вектор певного типу, для рядків тексту (англ. string) використовують map_chr(), для цілих чисел map_int() тощо.
  • Чи потрібен кожен позиційний індекс із вхідних даних? Тоді назви функцій отримують префікс i, як у imap().
  • Чи потрібно обробити кілька входів паралельно?
    • Замініть map на map2 у назві, як у map2_dbl() для двох входів (наприклад, значень і вагових коефіцієнтів, щоб обчислити зважене середнє)
    • Замініть на pmap для списку паралельних входів, як у pmap_chr(). І так, паралелізм на рівні обладнання можливий.
  • Чи потрібно обробити всі елементи вхідних даних? Якщо ні, зверніть увагу на map_if(), щоб застосувати предикатний (булевий, англ. Boolean) фільтр, map_at(), щоб задати критерії за позиційним індексом, і head_while(), щоб обробляти вхідні дані, доки якийсь елемент не перестане задовольняти предикат.
  • Чи вхідні дані глибоко вкладені? Для такої ситуації є функції.
  • Чи має повернене значення завжди бути того самого типу, що й вхідні дані? Тоді замість map використовують функції modify() або modify_tree() для рекурсивного застосування.

Ці функції, коли це можливо, приймають вхідні дані першим аргументом, щоб добре працювати з пайпами.

Крім того, purrr (навмисно) прискіпливіший за Base R. Якщо попросити щось конкретне, функції повернуть саме це або зупиняться з повідомленням про помилку. Суворо, але дуже дієво для зменшення кількості bug, які важко знайти.

Функції reduce

Функції map зазвичай мають повернене значення тієї самої довжини, що й вхідні дані.

Деякі інші функції зменшують розмірність даних, наприклад sum().

sum(1:100)
#> [1] 5050

У наведеному прикладі ми перетворюємо вектор довжини 100 на ціле число довжини 1. У майбутній концепції про матриці та масиви ми побачимо, що ідея зменшення розмірності загальніша.

Функція sum() вбудована (як і багато інших статистичних функцій). Однак нам потрібен спосіб застосовувати довільні функції зменшення розмірності до всієї структури даних за допомогою якоїсь функції вищого порядку, аналогічної до map().

У багатьох інших мовах використовують такі назви функцій, як fold, foldl і foldr. У R відповідна функція - reduce(), що відповідає добре відомій (принаймні ентузіастам алгоритмів) моделі MapReduce.

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

# surround an infix operator with backticks, to use as a function
reduce(1:100, `+`)
#> [1] 5050  # same as sum(1:100)

# anonymous function, alternately add and subtract
reduce(1:10, \(accum, nextval) ifelse(nextval %% 2 == 0, accum + nextval, accum - nextval))
#> [1] 7

Чи має значення, застосовуємо ми функцію від початку чи від кінця вхідних даних?

Не має, коли оператори асоціативні, як-от + або *. Очевидно, (1 + 2) + 3 == 1 + (2 + 3).

На противагу цьому, - і / неасоціативні:

(1 - 2) - 3
#> [1] -4
1 - (2 - 3)
#> [1] 2

Для функцій, де напрямок має значення, є необовʼязковий аргумент .dir.

# default is .dir = "forward"
reduce(1:10, `-`)
#> [1] -53
reduce(1:10, `-`, .dir = "backward")
#> [1] -5

Як і з map() та map2(), є також функція reduce2(), щоб паралельно працювати з двома вхідними векторами.

У R ми любимо працювати з векторами. Уявімо, що замість одного підсумкового значення потрібно побачити всі проміжні кроки.

Для цього є відповідні функції - accumulate() та accumulate2(). Синтаксис подібний до reduce(), з кількома додатковими необовʼязковими аргументами для задання типу вихідних даних.

accumulate(1:10, `+`)
#> [1]  1  3  6 10 15 21 28 36 45 55

Деякі поширені різновиди accumulate мають власні функції. У концепції векторних функцій ішлося про cumsum() і cumprod() у Base R, а в бібліотеці dplyr є cummean() для числових середніх, а також cumall() і cumany() для логічних векторів.

Функції filter

Часто потрібно включити (або виключити) ті елементи вхідних даних, які відповідають якомусь предикату.

У концепції фільтрування векторів ми бачили один ідіоматичний спосіб це зробити.

v <- 1:3
v[v >= 2]
#> [1] 2 3

Це широко використовують у традиційному R, але воно погано вписується в сучасні функціональні конвеєри.

Тому purrr надає набір предикатних функцій, які дотримуються звичного порядку аргументів f(input, func, ...), щоб працювати з пайпами.

Назви дещо відрізняються від більшості інших мов (такі назви, як filter і drop, у R вже означають щось зовсім інше).

Відповідні функції purrr включають keep() і discard(), а також кілька інших.

Крім того, є every(), some() і none(), які замінюють all() та any(), повертаючи одне логічне значення.

Вказівки

Ми відкриваємо сирний клуб, який використовуватиме машинне навчання (ML), щоб добирати нові сири для наших любителів сиру на основі їхньої історії та смаків.

Нові учасники мають заповнити початкове опитування, щоб ми могли зібрати трохи базових даних для початку. Завдяки цьому зʼясувалося, що є частина категоричних клієнтів, чиїм відгукам бракує нюансів. Оскільки це може безповоротно викривити алгоритм, чутливіший до нюансів, для їхніх потреб налаштовано окремий алгоритм. Потрібно написати кілька допоміжних функцій, щоб упорядкувати їхні дані.

Note

Хоча наведені нижче завдання можна розвʼязати різними способами, кожне з них розвʼязується не більше ніж двома функціями вищого порядку.

1. Класифікувати клієнтів

Система оцінювання має пʼятизіркову шкалу, яка складається з цілих чисел 1:5. Категоричні клієнти ставлять лише оцінки 1 або 5, і ми хочемо знати, чи клієнт поводиться саме так.

Реалізуйте all_15(), яка приймає вектор оцінок і повертає true, якщо всі оцінки дорівнюють 1 або 5, і false в іншому разі.

all_15(c(2, 3, 4, 4, 1))
#> [1] FALSE

all_15(c(1, 5, 5, 1, 5))
#> [1] TRUE

2. Дати імена клієнтам

Потрібно зіставити імʼя клієнта (або ID) з його оцінками.

Реалізуйте name_customers(), яка приймає вектор імен і список оцінок та «зшиває» їх у список списків.

Елементи внутрішнього списку повинні мати імена name і rating.

names <- c("c1", "c2")
ratings <- list(c(2, 3, 5), c(1, 1, 5))
name_customers(names, ratings)
#> [[1]]
#> [[1]]$name
#> [1] "c1"

#> [[1]]$rating
#> [1] 2 3 5

#> [[2]]
#> [[2]]$name
#> [1] "c2"

#> [[2]]$rating
#> [1] 1 1 5

3. Відокремити категоричних клієнтів

Потрібно відокремити категоричніших клієнтів від решти.

Реалізуйте emphatics(), яка приймає клієнтів та оцінки. Повертає список, до якого потрапляють лише ті, хто ставить оцінки 1 або 5.

# names and ratings as in the task 2 example

emphatics(names, ratings)
#> [[1]]
#> [[1]]$name
#> [1] "c2"

#> [[1]]$rating
#> [1] 1 1 5

4. Змінити оцінки на двійкові

Оскільки категоричні клієнти ставлять лише оцінки 1 і 5, обчислювально зручніше замінити їх на 0 і 1.

Реалізуйте to_binary(), яка приймає вектор категоричних оцінок. Повертає двійкові оцінки, де 1 змінено на 0, а 5 - на 1.

to_binary(c(1, 1, 5, 5, 1))
#> [1] 0 0 1 1 0

5. Обчислити середні оцінки з плином часу

Ми хочемо постійно покращувати рекомендації для кожного окремого клієнта (не лише для категоричних) з часом, у міру того як краще розуміємо їхні смаки.

Реалізуйте satisfactions(), яка приймає вектор оцінок. Повертає вектор кумулятивних середніх оцінок на цей момент. Округліть результати до 2 знаків після коми.

Сподіваємося, що ці числа й надалі будуть зростати, на радість щасливим клієнтам!

satisfactions(c(1, 2, 2, 3))
#> [1] 1.00 1.50 1.67 2.00
Редагувати через GitHub Посилання відкривається в новому вікні або вкладці
R Exercism

Час розпочати Сирний клуб?

Зареєструйтеся на Exercism, щоб вивчати й опановувати R, а також 21 концепція111 вправ та справжнє наставництво від людей, і все це безкоштовно.