Feb 27: data.table pt.3

Запись занятия

Запись занятия 27 февраля:

Все записи организованы в плейлист


Разбор домашней работы

level 2 (HNTR)

library(data.table)
# таблица привлечённых пользователей и их стоимости
users <- fread('https://gitlab.com/upravitelev/mar201s/raw/master/data/users.csv')
str(users)
## Classes 'data.table' and 'data.frame':   100 obs. of  3 variables:
##  $ user_id     : chr  "user_1" "user_2" "user_3" "user_4" ...
##  $ media_source: chr  "FbAds" "FbAds" "AdWords" "Ironsource" ...
##  $ cost        : num  2.61 1.51 2.95 0.97 1.44 1.07 1.88 2.22 1.7 2.94 ...
##  - attr(*, ".internal.selfref")=<externalptr>
# таблица платежей привлечённых пользователей
payments <- fread('https://gitlab.com/upravitelev/mar201s/raw/master/data/payments.csv')
str(payments)
## Classes 'data.table' and 'data.frame':   221 obs. of  2 variables:
##  $ user_id: chr  "user_28" "user_28" "user_28" "user_28" ...
##  $ gross  : num  0.755 0.37 0.38 0.33 0.286 ...
##  - attr(*, ".internal.selfref")=<externalptr>

Задание - в таблице payments разметьте пользователей, у которых больше 5 платежей и сумма за пять платежей превышает 20 - их обозначьте как whales, прочих как other.

Решение Здесь необходимо разбить задачу на этапы. Первый этап — посчитать, сколько платежей сделали пользователи и сколько суммарно заплатили. Условие сумма за пять платежей усложняет задачу, его рассмотрим отдельно (смотрите комментарии в коде).

# считаем количество платежей и сумму платежей по пользователям
payments_stat <- payments[, list(
  n_purchases = .N, 
  gross = sum(gross),
  # сумма платежей за первые 5 платежей
  gross5 = sum(gross[1:5])
), by = user_id]
payments_stat[1:5]
##    user_id n_purchases     gross    gross5
## 1: user_28          10  4.374968  2.121308
## 2: user_80          11 30.122404  8.625715
## 3: user_22           2  6.905017        NA
## 4:  user_9           5 10.433389 10.433389
## 5:  user_5          15 21.905107 12.024310

Второй шаг — выделить пользователей по условию (больше пяти платежей и сумма платежей больше 20).

# выделяем пользователей. так как user_id не обернуто в list()
# то в результате мы получаем вектор, а не таблицу (подробнее смотрите в разделе `Выбор колонки в data.table`)
tg_users <- payments_stat[n_purchases > 5 & gross > 20, user_id]
tg_users[1:5]
## [1] "user_80" "user_5"  "user_78" "user_21" "user_40"

Третий этап — разметить пользователей в таблице платежей, whale или нет.

# с помощью ifelse() размечаем пользователей в таблице платежей
payments[, is_whale := ifelse(user_id %in% tg_users, 'whales', 'other')]
payments_stat[1:5]
##    user_id n_purchases     gross    gross5
## 1: user_28          10  4.374968  2.121308
## 2: user_80          11 30.122404  8.625715
## 3: user_22           2  6.905017        NA
## 4:  user_9           5 10.433389 10.433389
## 5:  user_5          15 21.905107 12.024310

Задание - Сделайте сводные статистики (количество пользователей, средний и максимальный платежи) в каждой из этих двух групп.

Решение Решение типовое, обычные агрегатные статистики по группе:

payments[, list(
  n_users = uniqueN(user_id), 
  gross_mn = mean(gross), 
  gross_max = max(gross)
), by = is_whale]
##    is_whale n_users gross_mn gross_max
## 1:    other      23 1.669462  18.52535
## 2:   whales       7 2.061005  20.98412

level 3 (HMP)

Задание - замените в payments все значения gross, которые выше 90%, на NA.

Решение 90% — это обозначение девяностого перцентиля распределения (то есть, если упорядочить данные по возрастанию, то 90% обозначает значение, выше которого 10% наблюдений с самыми большими значениями). Медиана - это 50% перцентиль (делит ряд упорядоченных значений на две равные по количеству наблюдений части). Для вычисления перцентилей (а также децилей, квантилей и любых других долей) используется функция quantile() с аргументом probs.

Так как дальше нам потребуется статистика по gross с незамещёнными значениями, то проще создать вторую колонку и в неё записать gross, если значения меньше 90%, и NA, если больше.

# высчитаем отдельно 90%, это операция над колонкой
gross90 <- payments[, quantile(gross, .90)]
gross90  
##      90% 
## 4.101129
# создаем колонку, аналогичную gross, с заменёнными значениями
payments[, gross2 := ifelse(gross > gross90, NA, gross)]

Задание - Замените все полученные NA на медиану по ряду. Создайте отдельно колонку is_imputed, в которой будет промаркировано, замещено пропущенное значение гросса медианой или нет.

Решение Можно сначала заместить значения, а потом создать метку (по равенству с gross90), но проще сделать в обратном порядке:

# создаем колонку с меткой, есть ли в этой строке в gross2 пропуск или нет
payments[, is_imputed := ifelse(is.na(gross2), 'imputed', 'not-imputed')]

# заменяем пропущенные значения gross2 медианой по всему ряду
payments[, gross2 := ifelse(is.na(gross2), median(gross2, na.rm = TRUE), gross2)]

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

Решение Первым делом надо присоединить метку канала пользователя. Делаем это с помощью merge():

payments <- merge(payments, users[, list(user_id, media_source)], by = 'user_id', all.x = TRUE)
payments[1:5]
##    user_id     gross is_whale    gross2  is_imputed media_source
## 1: user_14 2.7557520    other 2.7557520 not-imputed        FbAds
## 2: user_16 5.5066916    other 0.9492291     imputed        FbAds
## 3: user_21 0.6769709   whales 0.6769709 not-imputed   Ironsource
## 4: user_21 0.4285491   whales 0.4285491 not-imputed   Ironsource
## 5: user_21 0.7705587   whales 0.7705587 not-imputed   Ironsource

Считаем статистики по каждому каналу:

payments_imp <- payments[, list(
    payers = uniqueN(user_id), 
    transactions = .N,
    gross_mn = mean(gross),
    imputed = sum(is_imputed == 'imputed'),
    gross2 = mean(gross2),
    delta = mean(gross) - mean(gross2)
  ),
  by = media_source]

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

payments_imp[, gross_mn := round(gross_mn, 2)]
payments_imp[, gross2 := round(gross2, 2)]
payments_imp[, delta := round(delta, 2)]
payments_imp
##    media_source payers transactions gross_mn imputed gross2 delta
## 1:        FbAds      9           51     1.92       5   1.32  0.60
## 2:   Ironsource     10           88     1.73      10   1.08  0.65
## 3:      AdWords     11           82     1.88       7   1.12  0.76

merge()

Одна из самых, наверное, важных операций при работе с таблицами — построчное слияние двух или нескольких таблиц. При использовании функции merge() каждому значению в ключевой колонке первой таблицы сопоставляется строка параметров наблюдения другой таблицы, с таким же значением в ключевой колонке, как и в первой таблице. В других языках программирования, в SQL, в частности, аналогичная функция может называться join. Несмотря на сложность формулировки, выглядит это достаточно просто:

# создаем датасет 1, в синтасисе data.table
dt1 <- data.table(key_col = c('r1', 'r2', 'r3'),
                  col_num = seq_len(3))

# создаем датасет 2, в синтасисе data.table
dt2 <- data.table(key_col = c('r3', 'r1', 'r2'),
                  col_char = c('c', 'a', 'b'))

# сливаем построчно по значениям в колонке key_col
merge(x = dt1, y = dt2, by = 'key_col')
##    key_col col_num col_char
## 1:      r1       1        a
## 2:      r2       2        b
## 3:      r3       3        c

Здесь первая таблица задается аргументом x, вторая таблица - аргументом y, а колонка (или колонки), по значениям которой происходит объединение таблиц, задается аргументом by. Если аргумент by не указан, то слияние происходит по тем колонкам, которые имеют одинаковое название в объединяемых таблицах. Притом, таблицы можно объединять по значениям колонок разными именами, тогда надо отдельно указать, по значениям каких колонок в первой и второй таблице происходит объединение, и для этого вместо общего аргумента by используют аргументы by.x и by.y для первой и второй таблицы соответственно.

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

cbind(dt1, dt2)
##    key_col col_num key_col col_char
## 1:      r1       1      r3        c
## 2:      r2       2      r1        a
## 3:      r3       3      r2        b
merge(x = dt1, y = dt2, by = 'key_col')
##    key_col col_num col_char
## 1:      r1       1        a
## 2:      r2       2        b
## 3:      r3       3        c

Второе существенное отличие от cbind() — обработка ситуаций, когда в таблицах разное количество наблюдений. Например, в первой таблице данные по первой волне опросов, а во второй — данные по тем, кто из принявших участие в первой волне, принял участие и во второй волне, а так же какие-то новые опрошенные респонденты. Разное количество наблюдений в объединяемых таблицах порождает четыре варианта слияния, все они задаются аргументом all с постфиксами.

Создаем данные:

# создаём данные первой волны
wave1 <- data.table(id = paste0('id_', seq_len(5)),
                    col1 = sample(10, 5))
wave1
##      id col1
## 1: id_1    1
## 2: id_2   10
## 3: id_3    3
## 4: id_4    6
## 5: id_5    5
# создаём данные второй волны
wave2 <- data.table(id = paste0('id_', c(1, 3, 5, 6, 7, 8)),
                    col2 = sample(letters, 6))
wave2
##      id col2
## 1: id_1    d
## 2: id_3    f
## 3: id_5    i
## 4: id_6    n
## 5: id_7    y
## 6: id_8    v

Варианты направлений объединения (мерджа) таблиц:

  • all = FALSE. Значение аргумента по умолчанию, в результате объединения будет таблица с наблюдениями, которые есть и в первой, и во второй таблице. То есть, наблюдения из первой таблицы, которым нет сопоставления из второй таблицы, отбрасываются. В примере с волнами это будет таблица только по тем, кто принял участи и в первой, и во второй волнах опросов:
# сливаем так, чтобы оставить только тех, кто был в обеих волнах, это зачение по умолчанию
merge(x = wave1, y = wave2, by = 'id', all = FALSE)
##      id col1 col2
## 1: id_1    1    d
## 2: id_3    3    f
## 3: id_5    5    i
  • all.x = TRUE. Всем наблюдениям из первой таблицы сопоставляются значения из второй. Если во второй таблице нет соответствующих наблюдений, то пропуски заполняются NA-значениями (в нашем примере в колонке col2):
# сливаем так, чтобы оставить тех, кто был в первой волне
merge(x = wave1, y = wave2, by = 'id', all.x = TRUE)
##      id col1 col2
## 1: id_1    1    d
## 2: id_2   10 <NA>
## 3: id_3    3    f
## 4: id_4    6 <NA>
## 5: id_5    5    i
  • all.y = TRUE. Обратная ситуация, когда всем наблюдениям из второй таблицы сопоставляются значения из первой, и пропущенные значения заполняются NA-значениями (в нашем примере в колонке co12):
# сливаем так, чтобы оставить тех, кто был во второй волне
merge(x = wave1, y = wave2, by = 'id', all.y = TRUE)
##      id col1 col2
## 1: id_1    1    d
## 2: id_3    3    f
## 3: id_5    5    i
## 4: id_6   NA    n
## 5: id_7   NA    y
## 6: id_8   NA    v
  • all = TRUE. Объединение предыдущих двух вариантов — создается таблица по всему набору уникальных значений из ключевых таблиц, по которым происходит слияние. И если в какой-то из таблиц нет соответствующих наблюдений, то пропуски также заполняются NA-значениями:
# сливаем так, чтобы оставить тех, кто был в какой-то из обеих волн
merge(x = wave1, y = wave2, by = 'id', all = TRUE)
##      id col1 col2
## 1: id_1    1    d
## 2: id_2   10 <NA>
## 3: id_3    3    f
## 4: id_4    6 <NA>
## 5: id_5    5    i
## 6: id_6   NA    n
## 7: id_7   NA    y
## 8: id_8   NA    v

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

Alarm! Необходимо помнить, что в ситуациях, когда одному значению ключа в первой таблице соответствует одна строка, а во второй таблице — несколько строк, то в результате объединения таблиц значения из первой таблицы размножатся по количеству строк во второй таблице:

# таблица, где на одно значение колонки ключа есть одна строка
dt1 <- data.table(
  key_col = c('a', 'b', 'c'),
  col1 = 1:3
)
dt1
##    key_col col1
## 1:       a    1
## 2:       b    2
## 3:       c    3
# на одно значение ключа (key_col = b)есть три строки
dt2 <- data.table(
  key_col = c('a', 'b', 'b', 'b', 'c'),
  col2 = rnorm(5)
) 
dt2
##    key_col        col2
## 1:       a  0.44019780
## 2:       b  1.12687272
## 3:       b  2.21855221
## 4:       b  1.38706310
## 5:       c -0.09708646

Объединяем и получаем размножение значений из первой таблицы для ключа key_col = b, значение 2 из колонки col1 теперь встречается три раза:

merge(dt1, dt2, by = 'key_col', all.x = TRUE)
##    key_col col1        col2
## 1:       a    1  0.44019780
## 2:       b    2  1.12687272
## 3:       b    2  2.21855221
## 4:       b    2  1.38706310
## 5:       c    3 -0.09708646

Домашнее задание

Level 1: I’m too young to die

  • внимательно прочитайте материалы занятия и разберитесь с примерами.

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


Level 2: Hey, not too rough

  • выполните следующие выражения. Посмотрите класс объектов, при необходимости преобразуйте в data.table:
library(data.table)
# таблица привлеченных пользователей и их стоимости
users <- fread('https://gitlab.com/upravitelev/mar201s/raw/master/data/users.csv')
str(users)
## Classes 'data.table' and 'data.frame':   100 obs. of  3 variables:
##  $ user_id     : chr  "user_1" "user_2" "user_3" "user_4" ...
##  $ media_source: chr  "FbAds" "FbAds" "AdWords" "Ironsource" ...
##  $ cost        : num  2.61 1.51 2.95 0.97 1.44 1.07 1.88 2.22 1.7 2.94 ...
##  - attr(*, ".internal.selfref")=<externalptr>
# таблица платежей привлеченных пользователей
payments <- fread('https://gitlab.com/upravitelev/mar201s/raw/master/data/payments.csv')
str(payments)
## Classes 'data.table' and 'data.frame':   221 obs. of  2 variables:
##  $ user_id: chr  "user_28" "user_28" "user_28" "user_28" ...
##  $ gross  : num  0.755 0.37 0.38 0.33 0.286 ...
##  - attr(*, ".internal.selfref")=<externalptr>
  • Посчитайте среднюю стоимость (cost) привлечения пользователей для каждого рекламного канала (media_source). В результате у вас должна получиться такая таблица:
##    media_source cost_mn
## 1:        FbAds    1.66
## 2:      AdWords    1.60
## 3:   Ironsource    1.56
  • Посчитайте сколько в среднем платят пользователи (gross) каждого канала привлечения пользователей. В результате у вас должна получиться такая таблица:
##    media_source gross_mn
## 1:        FbAds    97.79
## 2:   Ironsource   151.98
## 3:      AdWords   154.42

Level 3: Hurt me plenty

  • Повторите основные задания и добавьте к средней стоимости / платежам еще и общее количество пользователей, и количество пользователей, сделавших платеж. Для подсчета количества пользователей используйте связку функций length() + unique() или uniqueN()

  • Добавьте строчку Total — все статистики, но без разбивки по источником привлечения пользователей. В результате у вас должна получиться таблица:

##    media_source total_users cost_mn payers gross_mn
## 1:      AdWords          29    1.60     11   154.42
## 2:        FbAds          40    1.66      9    97.79
## 3:   Ironsource          31    1.56     10   151.98
## 4:        Total         100    1.61     30   404.19

Level 4: Ultra-violence

NB! задания не связаны друг с другом, просто отработка разных алгоритмов.

  • Отсортируйте датасет по пользователям. Создайте колонку кумулятивных платежей gross_cum по каждому пользователю (кумулята в рамках пользователя). Создайте колонку, в которой будет доля этого значения кумуляты от общей суммы. Вам поможет идея, что делать операции над колонками в группах можно не только при создании новой таблицы, но и при создании новых колонок.
##    user_id     gross gross_cum share
## 1: user_14 2.7557520 2.7557520  1.00
## 2: user_16 5.5066916 5.5066916  1.00
## 3: user_21 0.6769709 0.6769709  0.03
## 4: user_21 0.4285491 1.1055199  0.04
## 5: user_21 0.7705587 1.8760787  0.07
  • Создайте переменную-маркер gross_quant, в котором будет маркировка пользователей, в каком квартиле находятся суммарные платежи пользователя. Подумайте, погуглите (напрямую задача вряд ли решается), как сделать это максимально лаконично (достаточно читаемо это можно сделать в два выражения, например).
##    user_id     gross gross_cum share gross_quant
## 1: user_14 2.7557520 2.7557520  1.00          Q1
## 2: user_16 5.5066916 5.5066916  1.00          Q1
## 3: user_21 0.6769709 0.6769709  0.03          Q4
## 4: user_21 0.4285491 1.1055199  0.04          Q4
## 5: user_21 0.7705587 1.8760787  0.07          Q4


Level 5: Nightmare

  • Перезагрузите payments.

  • Для каждого платящего пользователя сгенерируйте метрику, на какой день от инсталла пользователь сделал платеж (округляйте до целых). Для генерации используйте rlnorm() c meanlog = 1, sdlog = 0.5.

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

  • Сделайте все это только с одним мерджем - присоединением канала пользователя. В принципе, четырех выражений вполне хватит. Если воспользоваться некоторыми трюками и пренебречь эстетикой - то и двух.

##    media_source delta_mn
## 1:        FbAds 1.428571
## 2:   Ironsource 0.500000
## 3:      AdWords 1.125000