В большинстве случаев в DS предпочитают работать с прямоугольными данными и на то есть масса причин и обоснований. Очень популярна тема парсинга и развертывания вложенных json. Часто дают тестовые задания на приведение к прямоугольному виду.
Но далеко не все задачи сводятся к data.frame
и не обязательно весь DS сводить к ML. Есть масса задач, оперирующих, например, с графами или их частным случаем — деревьями. Можно использовать библиотеку igraph
или аналоги и не думать о деталях. А можно попробовать чуть заглянуть внутрь алгоритмов.
На примере одной задачки посмотрим на работу с деревьями с альтернативной колокольни.
Постановка задачи
Задачка в предельно упрощенном виде (сама по себе полная исходная задача куда сложнее), идет из тематики «process mining» и звучит так:
- Есть инстансы процессов и набор шагов, выполняемых в этих процессах.
- Система является конечным автоматом, набор процессов и состояний (шагов) ограничен и мал.
- Глядя на весь набор исторических данных и текущее состояние исполняемого инстанса процесса требуется предсказать следующий шаг.
Можно было бы эту задачу переформулировать в более наглядных трактовках, как то: наблюдение за проплывающими крокодилами; катание на автобусах без номера маршрута; гадание на ромашках и т.д.
Но пусть она останется в более серьезном виде.
Лог событий тоже предельно сжат с сохранением правильной временнОй последовательности и содержит только два идентификатора case_id
и event
, необходимых в контексте текущей задачи. Исходный лог
Визуально граф переходов (DFG) выглядит примерно следующей мешаниной:
fpath <- here::here("data", "scan_and_go_seq2_reduced")
raw_dt <- fs::path_ext_set(fpath, "zip") %>%
vroom::vroom(delim = ",", locale = locale("ru", encoding = "UTF-8")) %>%
select(timestamp, activity_id = event, case_id) %>%
setDT() %>%
unique() %>%
setorder(timestamp)
План решения
Прежде чем хвататься за клавиатуру, прикинем на салфетке план решения задачи. Получается два пункта.
- Для каждого инстанса процесса перейдем от последовательности состояний к последовательности шагов по достижению состояния. Т.е. от состояний
A; B; C
перейдем кA; A-B; A-B-C
. Дляk
-го шага это называется префиксомk
процесса. - Построим дерево префиксов и определим вероятности перехода по тем или иным веткам, опираясь на наблюдаемые количественные показатели.
- Для каждого незавершенного процесса будем считать его максимальный префикс и соотносить его с префиксами завершенных процессов. В случае совпадения мы можем предсказать следующее состояние в соответствии с построенным деревом префиксов.
Итак, появляются деревья, они же графы. Можно не раздумывая долго сразу брать библиотеку для работы с графами.
Но это не всегда оказывается легко, да и работа с графами в общем случае зачастую удобнее в объектной модели — затруднительно применять векторизацию.
Но мы же сидим в пространстве DS инструментов со своими сильными и слабыми сторонами. Оставим ООП для C++ и Java, попробуем альтернативный вариант. Для этого воспользуемся:
- редуцированным представлением графа (дерева) в виде
data.frame
с двумя колонкамиfrom
&to
. Это таблица ребер, а в колонках будут записаны уникальные имена нод. - возможностями
data.table
в части работы по ссылкам (reference semantic).
Поскольку имена состояний достаточно длинные, префиксы будут несколько неудобны для промежуточного обозрения. Воспользуемся стандартным подходом — сочиним коротенький подстановочный словарик.
# для улучшение обозримости задачи построим словарик активностей
set.seed(1)
acdict_dt <- raw_dt[, .(activity_id = unique(activity_id))] %>%
.[, act_tag := head(unique(stri_rand_strings(.N * 5, 2, pattern = "[A-Z]")), .N)]
# и дополним исходный eventlog
dt <- merge(raw_dt, acdict_dt, by = "activity_id")
acdict_dt
Собираем сводку по префиксам
Займемся пошаговой сверткой путей с подсчетом количественных показателей. В классическом варианте здесь могла бы быть рекурсия или bfs/dfs
с последующей комбинаторной сборкой. Но пусть это рассказывается в учебных курсах, там это все очень хорошо разложено по полочкам.
Мы сделаем это другим образом, воспользуемся методом аналогий. Все многообразие трейсов даст на дерево (новогодняя гирлянда типа дождь/бахрома/занавес) у которого есть корень START (блок питания). Будем просто наматывать гирлянду на катушку, начиная от блока питания. По сути, отдаленный алгоритм bfs
, но объединяем маркировку сразу со складыванием и расчетами.
Может возникнуть резонный вопрос, почему одним махом не склеить все трейсы? Ответ лежит на поверхности — нам нужные количественные показатели для каждого префикса цепочки чтобы иметь вероятности ветвлений.
tic("Trace convolution")
accu_dt <- data.table(NULL)
# инициализируем цепочки, path -- уже пройденный путь
# для упрощения задачи отбросим сверхдлинные цепочки
step_dt <- dt[, N := .N, by = case_id] %>%
.[N <= quantile(.$N, probs = 0.8)] %>%
.[, .(from = "START", timestamp, to = act_tag, case_id)] %>%
setkey(case_id, timestamp)
while(nrow(step_dt) > 0){
tic("Another step")
# считаем статистику для текущих путей и вливаем в аккумулятор
accu_dt <- step_dt %>%
# поскольку все отсортировано по времени
# забираем только первую строчку для каждого кейса,
.[, head(.SD, 1), by = case_id] %>%
.[, .(N = .N, rel = .N /nrow(.)), by = .(from, to)] %>%
.[, to := stri_c(from, to, sep = "-")] %>%
{rbindlist(list(accu_dt, .))}
# втягиваем текущее ребро в путь и переходим к следующему
step_dt[, `:=`(from = stri_c(from, head(to, 1), sep = "-"),
to = shift(to, type = "lead")), by = case_id]
# подчищаем хвосты цепочек
step_dt %<>% na.omit()
toc()
}
toc()
Вариант реализации не сильно шустрый, но это вполне поправимо. Можно оптимизировать выборки и склейки, добавить преаллокацию памяти и сделать сдвиг однократным за счет буферных зон, но конкретно здесь и сейчас это некритично. Первичен простой и компактный код.
Итак, эту часть задачи удалось решить путем простых манипуляций с data.frame
без привлечения пакетов по работе с графами (но держа алгоримты в голове).
Визуализируем
Дерево получилось разветвленное, для просмотра исключим «шумовые» ветвления. Для начала воспользуемся штатными механизмами графовых библиотек. Сначала достанем igraph
.
accu_dt %>%
setorder(-N) %>%
.[, ratio := N / uniqueN(dt$case_id)]
ig <- accu_dt[ratio > 0.01, .(from, to)] %>%
graph_from_data_frame(directed = TRUE)
plot(ig, vertex.size = 3,
vertex.label.cex = .8,
vertex.label.dist = runif(length(V(ig)), -.7, .7),
edge.arrow.size = 0.5,
layout = layout.reingold.tilford)
Мда, применительно к нашему набору данных визуализация не очень. И это еще при условии, что названия событий заменены на словарные сокращения. Тут нужно более информативное размещение узлов, хотя бы без перекрытий.
В R есть весьма функциональный пакет data.tree
по работе именно с деревьями. Получаем вполне удобное и читаемое представление.
tree_dt <- accu_dt %>%
filter(ratio > 0.01) %>%
select(from, to)
print(FromDataFrameNetwork(tree_dt))
Но, хотелось бы отрисовать это в графике. Попробуем альтернативные варианты.
Можно использовать различные js виджеты, например, D3.
networkD3::simpleNetwork(tree_dt, fontSize = 12)
Но тут возникает сложность. Дерево — частный случай графа, но для визуализации именно дерева надо готовить входные данные в структуре parent-child. Просто подать на вход таблицу ребер не получится.
Опять дело пахнет рекурсией, объектами, обходами и пр. Однако, попробуем решить это другими способами. Опять же, data.table
и работа по ссылкам. Для визуализации дерева достанем еще один популярный виджен echarts
. Непосредственно для него необходимо будет готовить подобную структуру входных данных:
tibble(
name = "earth",
# 1st level
children = list(
tibble(
name = c("land", "ocean"),
# 2nd level
children = list(
tibble(name = c("forest", "river")),
# 3rd level
tibble(
name = c("fish", "kelp"),
children = list(
tibble(
name = c("shark", "tuna")
),
# 4th level
NULL # kelp
))))))
План свертки будет примерно аналогичен первой задаче, только сворачивать мы будем теперь не от блока питания, а от лампочек:
- расчитываем расстояния нод от корня;
- сворачиваем одним махом самые дальние ноды;
- движем вплоть до достижения корня.
Никаких рекурсий, никаких прогулок по ребрам. Определили конец и катим рулон. По своей сути, это обратная задача к мегапопулярному запросу «как мне развернуть вложенный json».
# глубина дерева может быть любой, сворачивать придется с конца
tree_dt <- copy(accu_dt) %>%
.[ratio > 0.01, .(from, to, children = list(NULL))] %>%
# дополним расстояниями от корня
.[from == "START", dist := 0L]
# посчитаем расстояния нод от корня
while(tree_dt[is.na(dist), .N] > 0){
max_dist <- tree_dt[, max(dist, na.rm = TRUE)]
nodes <- tree_dt[dist == max_dist, unique(to)]
tree_dt[from %chin% nodes, dist := max_dist + 1L]
}
for(ii in max(tree_dt$dist):0) {
nested_dt <- tree_dt[dist == ii, .(from, name = to, children)] %>%
tidyfst::nest_dt(from, .name = "children")
# теперь делаем возвратную точечную вставку сверток
tree_dt[nested_dt, children := i.children, on = c(to = "from")]
}
# в последнем цикле нам вставка уже не нужна
nested_dt %>%
setnames("from", "name")
Можно взглянуть на получившуюся структуру. Смотреть удобнее в интерактивных json виджетах.
jsonlite::toJSON(nested_dt) %>%
listviewer::jsonedit()
Теперь можем построить интерактивный виджет echarts tree. Удобно и читаемо.
nested_dt |>
e_charts() |>
e_tree(initialTreeDepth = 10, label = list(offset = c(0, -11)))
Заключение
Текущая публикация — просто очередная иллюстрация, как можно иначе взглянуть на решение «типовых задач». В DS инструментов чуть побольше, чем топор и пила.
- Компактно? Да.
- Быстро? Да.
- Понятно? Да.
Предыдущая публикация — «Data Science как макетная плата в enterprise задачах».