python

ETL в задаче анализа данных для тех, кто не любит кофе и курилку

  • понедельник, 23 августа 2021 г. в 00:33:09
https://habr.com/ru/post/574110/
  • Python
  • *
  • Data Mining
  • *
  • R
  • *
  • Data Engineering
  • *


ETL в задаче анализа данных для тех, кто не любит кофе и курилку

Python*Data Mining*R*Data Engineering*


Кадр из фильма «Индиана Джонс: В поисках утраченного ковчега» (1981)


Наблюдаемая все чаще и чаще картина в задаче анализа данных вызывает удручающее впечатление. Intel, AMD и другие производители непрерывно наращивают вычислительную мощность. Гениальные математики-программисты пишут суперэффективные библиотеки и алгоритмы. И вся эта мощь гасится и распыляется рядовыми аналитиками и разработчиками. Причем начинается это все с нулевого этапа — этап подготовки и загрузки данных для анализа. Многочисленные вопросы и диалоги показывают, что в нынешних программах обучения зияют огромные дыры. Людям просто незнакомы многие концепции и инструменты, уже давно придуманные для этих задач. Для тех, кто хочет увеличить свою продуктивность, далее тезисно будут рассмотрены ряд таких подходов и инструментов в частичной привязке к реальным задачам.


В первую очередь, материал ориентирован на аналитиков, которые манипулируют разумными объемами данных, необходимых для решения практических задач. ETL из Бигдаты в котором перекачиваются сотни Тб ежесуточно живет своей отдельной жизнью.


Является продолжением серии предыдущих публикаций.


Разграничим задачи анализа данных на два больших класса:


  • периодический информационный обмен между ИТ системами и DS контуром;
  • эпизодический обмен или ad-hoc аналитика.

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


Ключевые ошибки в работе с такими грязными данным можно свести к следующим:


  • попытка автоматизировать неформализуемый хаос и смешать все в одной кастрюле;
  • попытка решать все одним инструментом на котором, к тому же, говорят «со словарем»;
  • использование циклов N-ой степени вложенности на языках высокого уровня.

Разделение задач


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


В зависимости от задач и объемов, преобразованные данные могут размещаться в локальные файлы, в БД, в облако — да куда угодно. Ключевое требование — минимальное время загрузки в RAM и возможность выборочной загрузки для больших объемов.


Важным эффектом от такого разделения — выполнение аналитики в совершенно чистом пространстве. Все проблемы, утечки памяти, резервы ресурсов и пр., случившиеся на этапе преобразования забыты после завершения этого процесса. У вас на руках только чистые входные данные — можно решать основную задачу.


Работать с текстовыми данными в R достаточно удобно. Однако, утечка памяти, на которую могут жаловаться в случае процессинга текстовых данных, скорее всего бывает связана со спецификой архитектуры R — наличие global string pool. Судя по анализу памяти, даже после удаления текстовой переменной и вызова gc(), сама строка остается в пуле — для внешнего наблюдателя «память течет». Это ни хорошо и ни плохо. Это архитектура среды, которую в задаче ETL можно обойти просто выполнения независимых блоков препроцессинга в отдельных процессах ОС, например, используя пакет callr. Терминируя процесс после выполнения возложенных задач, ОС получает всю использованную оперативную память обратно до последнего байта.


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


  • Apache Arrow — кроссплатформенное хранение, есть возможность частичной выборки и фильтрации данных еще на этапе загрузки;
  • fst — табличное хранение для ограниченного количества типов с возможностью частичной загрузки требуемых колонок;
  • qs — сериализованная выгрузка любых объектов R.

Ниже посмотрим, что можно сделать в отдельных случаях. Смотрим по умолчанию в контексте эпизодической ad-hoc аналитики, хотя большинство решений может быть применено и в потоковом продуктивном контуре.


Предварительный препроцессинг входных файлов


Бинарный Excel (.xslb)


Файлы в таком формате далеко не редкость. В чем причина его появления — сложно сказать. Объем не меньше, чем в xslx. Макросы и пр. для анализа не особо нужны. Формат формально проприетарный и закрытый.


Типовой сценарий — начинается поиск библиотек для работы с этим форматом. Даже если они и находятся, у этих библиотек возникает куча ограничение и сложностей.


Решение тривиальное. Ничто не работает с бинарным файлом лучше чем сам Excel. Так и преобразуйте на Windows машине с установленным офисом с помощью командной строки все xlsb в xlsx. Это можно делать руками или автоматически, как через COM на любом удобном языке, так и с помощью PowerShell. Примеры скрипта на PowerShell можно найти поиском, взять хотя бы этот.


Проблемы больше не существует.


PDF


PDF формат слабо пригоден для анализа. Формально — это контейнер, содержащий атомарные данные и инструкции для типографской машины по их размещению на листе. В нем сборище графических примитивов. Можно почитать спецификацию «Document management — Portable document format — Part 1: PDF 1.7». Однако PDF весьма популярен при публикации открытых данных различными компаниями, особенно гос.службами.


Какие прагматичные варианты есть? Не очень много.


  • поиск утилит по стыковке DS инструмента с pdf напрямую, например, rOpenSci: The pdftools package;
  • попытка ручной выгрузки из Adobe Reader содержания pdf файлов в текст с последующим парсингом этого текста;
  • распознавание через OCR, выгрузка в форматы Word/Excel с последующей перегрузкой в текстовые форматы (если потребуется).

Не исключено, что pdf файл стоит перед этим предварительно обрезать (crop страниц), разделить на блоки или выдрать только нужные страницы. Это решается как средствами командной строки, так и облачными сервисами — на вкус и цвет найдется любой инструмент. Исключительно для примера — кроссплатформенная утилита PDFsam Basic или облачный сервис PDFResizer.com. Мышкой клац-клац и файл подрезан.


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


Проблема может и осталась, но объезжена и почти не брыкается.


XML


Когда-то, в конце 90-х, этому формату прочили большое будущее. Даже БД на нем хотели строить. По факту все свалилось куда-то на обочину. Избыточно, крайне сложно, если все делать по-настоящему (c dtd). Но формат стал во многих областях де-факто средством кросс-обмена между программными компонентами или предоставления данных для публикации.


Штатный подход DS — загрузим все в память и распарсим дерево, а так делают «не задумываясь» большинство начинающих и некоторое количество слегка продолжающих, не приводит к успеху на данных чуть больше мизерных. Либо времени требует очень много, либо памяти не хватает. Ситуация усугубляется еще тем, что для анализа достаточно всего некоторого количества полей. Но даже если и удалось весь документ разобрать — обход многоуровневой вложенности циклами по распарсенному дереву на языке высокого уровня — типичный антипаттерн для ETL задач. Так делать совершенно не стоит.


Полагаете тема надумана? Для примера берем открытый «Единый реестр субъектов малого и среднего предпринимательства», ~6.5K XML файлов, ~46Гб сырых данных.


Какие есть альтернативы?


  • SAX парсер. Все круто, но писать обработчики событий для простого импорта данных — это как-то не совсем для аналитика данных задача.
  • регулярки. Да-да, опять регулярки. Если нужно выдрать некоторые поля, которые легко идентифицируются, то регулярки могут дать прекрасный результат, особенно на этапе предварительного препроцессинга.
  • XSL Transformation! Практика показывает, что почти никто из DS специалистов не знаком с этой технологией и даже о ней не слышал.

На самом деле XSLT / XPath из командной строки является единственным серьезным ответом в случае больших объемов и сложной разветвленной структуры. Все было в XML технологии продумано и структурировано до мелочей, а потом и стандартизировано. Но что-то где-то пошло не так.


За компактной сводкой по синтаксису и примерами кода можно обратиться, например, на
W3School.


Проблемы больше не существует, осталось только неудобство.


JSON


Json успешно заменяет формат xml, особенно в части обмена данными между отдельными модулями посредством REST API. В большинстве случаев эти структуры данных предназначены для разработчиков и их сложная нефиксированная древовидная структура доставляет много мучений аналитикам данных. Ведь для большинства алгоритмов и функций требуются либо матрицы либо прямоугольные таблицы (data.frame).


Типичный способ решения задачи преобразования иерархического json в data.frame — многоуровневые циклы. Такое решение плохо по множеству различных моментов:


  • очень много кода и непонятных манипуляций;
  • логика работы с данными перемешана с физическими манипуляциями;
  • понять логику и отследить отработку всех веток порой очень сложно;
  • включение проверок на возможные ошибки и некорректность входных данных кратно «раздувает» код и приводит почти к его полной нечитаемости;
  • работает медленно и потребляем много оперативной памяти.

Типовой пример из окружающего нас информационного мира. Аналитика помарочного учета.


Фрагмент исходного json (всего более десятка миллионов записей).
[
 {
 "up": {
 "sscc": "301802131116570999",
 "packing_date": "2018-02-13T07:00:00Z",
 "owner_id": "7a9eb56a-6d44-41b5-8a97-****bf9f89fa",
 "owner_organization_name": "ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ \"СЕРВИС\""
 },
 "down": {
 "sscc": "301802131116570999",
 "packing_date": "2018-02-13T07:00:00Z",
 "owner_id": "7a9eb56a-6d44-41b5-8a97-****bf9f89fa",
 "owner_organization_name": "ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ \"СЕРВИС\"",
 "childs": [
 {
 "sgtin": "30180213111657TESTtest00003",
 "sscc": "301802131116570999",
 "status": "arrived",
 "gtin": "04620032570099",
 "expiration_date": "2020-10-30T00:00:00Z",
 "batch": "01_139717"
 },
 {
 "sgtin": "30180213111657TESTtest00004",
 "sscc": "301802131116570999",
 "status": "arrived",
 "gtin": "04620032570099",
 "expiration_date": "2020-10-30T00:00:00Z",
 "batch": "01_139717"
 }
 ]
 }
 },
 {
 "up": {
 "sscc": "089011480010310999",
 "packing_date": "2018-09-10T23:59:59Z",
 "owner_id": "05a2a270-6449-440d-ba0e-****8b49b949",
 "owner_organization_name": "Общество с ограниченной ответственностью \"М'к Лабораторис\""
 },
 "down": {
 "sscc": "089011480010310999",
 "packing_date": "2018-09-10T23:59:59Z",
 "owner_id": "05a2a270-6449-440d-ba0e-****8b49b949",
 "owner_organization_name": "Общество с ограниченной ответственностью \"М'к Лабораторис\"",
 "childs": [
 {
 "sgtin": "18901148101199B2007ARABB0XV",
 "sscc": "089011480010310999",
 "status": "arrived",
 "gtin": "18901148101199",
 "expiration_date": "2021-03-31T00:00:00Z",
 "batch": "B800554"
 },
 {
 "sgtin": "18901148101199B20AB3YNJQRON",
 "sscc": "089011480010310999",
 "status": "arrived",
 "gtin": "18901148101199",
 "expiration_date": "2021-03-31T00:00:00Z",
 "batch": "B800554"
 }
 ]
 }
 }
]


Собрать данные ветки childs в таблицу штатными функциями парсинга не удастся. Тут и иерархические вложенные массивы и дублирование имен параметров. Ничего удивительного — это же протокол M2M обмена, там цели совсем другие ставились.


Что делаем? Правильно, циклы. На выходе получается примерно такой код.


Циклы-циклы-циклы
df = pd.json_normalize(test)
pallet = pd.DataFrame(columns = ["sscc", "packing_date", "owner_id", "owner_organization_name", "childs"])
pachka = pd.DataFrame(columns = ["sgtin", "sscc", "status", "gtin", "expiration_date", "batch", "pallet"])
for i in range(len(pd.json_normalize(test))):
    sscc = pd.json_normalize(test[i]["down"])
    pallet = pd.concat([pallet, sscc]).reset_index(drop = True)
for i in range(len(pallet)):
    df_sgtin = pd.json_normalize(test[i]["down"]["childs"],["childs"])
    df_sgtin["pallet"] = "Nan"
    for f in range(len(df_sgtin)):
        df_sgtin["pallet"][f] = pallet["sscc"][i]
    pachka = pd.concat([pachka, df_sgtin]).reset_index(drop = True)

Можно было бы успокоиться, но мы сделаем еще пару шагов.


Шаг 1. json parser


Альтернативное решение, которое ни на каких курсах не рассказывают.


Смотрим на структуру. Похоже, что down по структуре дублирует up, проверяем гипотезу. Все верно, поэтому достанем только ветку down. Достаем специализированный парсер jq о котором знают и который используют многие системные администраторы, но с которым аналитики данных практически не знакомы. jq можно использовать где угодно, включая командную строку. Про jqr писал ранее в публикации «Швейцарский нож для обработки json»


Смотрим на json, придумываем команду трансформации. Вот рабочая мантра для этого кейса: [.[].down | del(.childs, .sscc) + .childs[]]



И сам код трансформации в R


df <- here::here("data", "sample1.json") %>%
  readr::read_file() %>%
  jqr::jq('[.[].down | del(.childs, .sscc) + .childs[]]') %>%
  jsonlite::fromJSON()

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


  • jq Language Description
  • jqplay. Веб песочница для отработки схем трансформации в онлайне.
  • jqterm. Альтернативная веб песочница

Шаг 2. Смотрим по сторонам


Хорошо, что есть люди, которых посредственные показатели не устраивают. Совсем недавно появилась библиотека simdjson для парсинга JSON, основанная в т.ч. на применении SIMD инструкций процессоров. Эффект впечатляющий. Детально с принципами и результатами можно ознакомиться в работе Geoff Langdale, Daniel Lemire, «Parsing Gigabytes of JSON per Second, VLDB Journal 28 (6), 2019». https://arxiv.org/abs/1902.08318. Байндинги сделаны почти ко всем популярным языкам.


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


CSV


Ну тут то многие вздохнут и скажут, что с этим нет проблем. И могут легко ошибиться.


Возьмем, для примера, практическую задачку. Миллионы «IoT» устройств (электросчетчики, например) фиксируют по расписанию показания своих регистров. И вот эта все сводка приходит вам в виде кучи файлов в весьма странном формате. А что, разработчики счетчиков тоже люди. Они хотели сделать как лучше. Но работаем мы с тем что есть.


Итак, вот пример входного файла:


meter;meter_id;fmt_date;fmt_time;energy
A01;62711113623668;20180701;000000;81,20-
A01;62711113623668;20180701;003000;84,24-
A01;62711113623668;20180701;010000;8,56-
..........

Тут у нас есть все радости — и время представлено не в ISO формате и числа с разделителем десятичной части, отличающимся от системных настроек (обычно это точка). В довершение всего и знак стоит не там, где надо, почти как в SAP выгрузках. Штатные парсеры загрузчиков csv не помогут. Надо делать все самостоятельно. Итого, у стоит задача парсинга временнЫх и числовых показателей. Понятно, что это не проблема. Вопрос скорее в том, как сделать трансформацию со скоростями не меньше (или даже больше) штатной библиотеки импорта.


Используемые библиотеки
library(tidyverse)
library(data.table)
library(stringi)
library(tictoc)
library(anytime)
library(RcppFastFloat)

data.table::setDTthreads(0) # отдаем все ядра в распоряжение data.table
data.table::getDTthreads() # проверим доступное количество потоков

Построим генератор входных данных


Текст простейшего генератора
# Шаг 1. генерируем датасет --------------
# счетчики и точки снятия показаний
meter <- CJ(unlist(strsplit("ABCDE", "")), 
            str_pad(1:22, width = 2, side = "left", pad = "0")) %>%
  .[, paste0(V1, V2)]
timestamp <- seq(as.POSIXct("2018-07-01 00:00:00"), 
            as.POSIXct("2021-07-17 00:00:00"), by = "1 day")
ticks <- as.numeric(seq(0, by = 30 * 60, length.out = 48)) %>%
  hms::new_hms()

# делаем набивку и форматирование под задачу
dt <- CJ(meter, timestamp) %>%
  .[, meter_id := stri_rand_strings(1, 14, pattern = "[0-9]"), by = meter] %>%
  # набивка временами измерения
  .[, .(secs = ..ticks), by = names(.)] %>%
  # готовим представления под задачу
  .[, fmt_date := format(.BY[[1]], "%Y%m%d"), by = timestamp] %>%
  .[, fmt_time := gsub(":", "", format(.BY[[1]]), fixed = TRUE), by = secs] %>%
  # партиционируем по годам
  .[, partition := format(.BY[[1]], "%Y"), by = timestamp] %>%
  .[, timestamp := timestamp + secs] %>%
  # данные поступают из SAP в нечеловеческом формате
  # .[, energy := sprintf("%.3f-", runif(.N, 0, 100))] %>%
  .[, energy := paste0(format(runif(.N, 0.1, 100), digits = 2, nsmall = 1, 
                              scientific = FALSE, trim = TRUE), "-")] %>%
  .[, `:=`(secs = NULL)] %>%
  .[, .(meter, meter_id, timestamp, fmt_date, fmt_time, energy, partition)]

# 1.1. формируем набор отдельных файлов
tic("вариант vroom_write")
ff <- function(df, part){
  fname <- here::here("data", "temp", paste0(part, "_meter.csv"))
  vroom::vroom_write(df, fname, delim = ";")
}
dt %>%
  .[, .SD, .SDcols = setdiff(names(.), "timestamp")] %>%
  split(by = "partition", keep.by = FALSE) %>%
  purrr::iwalk(ff)
toc()

rm(dt)
gc()

Имеем ~5.8M записей — в целом, для теста сущие пустяки.


Типовой сценарий — воспроизвести логику загрузчика. Загрузить все как текст и потом построчно провести преобразование. Насколько эффективен этот способ? На это нельзя дать однозначный ответ. Сильно зависит от языка, навыков аналитики, используемых библиотек и особенности структуры данных и специфики предметной области.


Проводим загрузку данных, смотрим тайминги. Чтобы все везде было хорошо, именуйте файлы с использованием только цифр и латинского алфавита.


Читаем разными способами
# Шаг 2. тестируем различные способы загрузки ----------
files <- here::here("data", "temp") %>%
  fs::dir_ls(regexp = "2.+\\.csv")

tic("вариант vroom")
dt <- files %>%
  vroom::vroom(col_names = c("meter", "meterID", "date", "time", "energy"),
               skip = 1,
               col_types = "ccccc")
setDT(dt)
toc()

tic("вариант data.table")
dt <- files %>%
  purrr::map(fread,
             col.names = c("meter", "meterID", "date", "time", "energy"),
             skip = 1,
             colClasses = "character") %>%
  rbindlist()
toc()

ВременнАя метка. Немного трюков


Начнем с временной метки. Временные показатели исполнения, естественно, будут зависеть от мощности машины. Поэтому приводимые показатели важны для сопоставительного анализа.


Воспользуемся базовыми функциями R, на сгенерированном датасете получаем примерно 130 секунд. Большинство аналитиков (неважно, на каком языке пишут) скажут, что это вполне нормальная ситуация, нет смысла хотеть бОльшего.


tic("straightforward as.POSIXct")
dt[, timestamp := as.POSIXct(paste(date, time), 
                                  format = "%Y%m%d %H%M%S")]
toc()

Но не будем на этом останавливаться. Вдруг данных будет немного больше (это же IoT по постановке задачи). 50М или 500М? Поменяем библиотеку с пониманием того, как она устроена внутри и как устроен POSIXct.


Получаем 3.5 секунды на нашем датасете.


tic("straightforward lubridate")
dt[, timestamp := lubridate::ymd_hms(paste(date, time))]
toc()

Останавливаемся? А почему, собственно, да? Можно попробовать другие библиотеки. Но мы пойдем другим способом. Так уже получается, что у нас есть весь загруженный датасет в память. Давайте пользоваться спецификой предметной области и спецификой преобразуемых данных. Преобразование строки в дату — очень трудозатратная операция. Зачем прикидываться, что мы ничего не знаем о данных и делать эту операцию для каждой строки? Можно же сделать преобразование только для уникальных значений временных меток. Таковых оказывается на порядки меньше, сказывается специфика задачи.


> uniqueN(dt, by = c("date", "time")) # почти в 100 раз меньше!
[1] 53424
> uniqueN(dt, by = "date")
[1] 1113
> uniqueN(dt, by = "time")
[1] 48

Используем этот подход + меняем библиотеку.
Вариант через групповую обработку — получаем 2.9 сек.


tic("anytime")
# не забываем следить про таймзону
dt[, timestamp := anytime::anytime(stri_c(.BY[[1]], .BY[[2]], sep = " ")), 
   by = .(date, time)]
toc()

Для потоковых преобразований ограниченных подмножеств наилучшим подходом является предварительное создание словаря "входное значение — преобразованное значение". Тогда вся трансформация будет сводится выборке из этого словаря. Вариант через слияние по ссылкам со словарем дает чуть лучший результат 1.3 сек:


tic("inverse dictionary")
time_dict <- unique(dt[, .(date, time)]) %>%
  .[, timestamp := anytime::anytime(stri_c(date, time, sep = " "))]

dt[time_dict, on = .(date, time), timestamp := i.timestamp]
toc()

Это все? Потенциально, если расщепить словари даты и времени, то можно сократить объем преобразований еще примерно в 10 раз (1113 + 48 против 53K). Таймзоны в исходных данных не наблюдаются, городов тоже, значит все условно можно считать в UTC.


В таком варианте получаем примерно 0.7 сек.


tic("split + anytime")
dt[, secs := {
  tm <- as.integer(.BY[[1]]);
  (tm %% 100L) + ((tm %/% 100L) %% 100L) * 60L + (tm %/% 10000L) * 3600L}, 
  by = time] %>%
  .[, timestamp := anytime::anytime(.BY[[1]]) + secs, by = date]
toc()

Итого, путем краткого исследования исходной задачи и незначительных манипуляций получаем ускорение со 130 сек (которые многие аналитики сочли приемлемым) до 0.7 сек. (~ 180 раз) в базовом варианте, или с 3.5 сек до 0.7 сек (~ 5 раз) в варианте с lubridate. Можно, конечно, разбрасываться ресурсами, но если это делать на каждом шагу, то ресурсов и времени может легко не хватить.


Числовые показатели. Немного трюков


Формат, конечно, не очень удобный. Штатный парсер такую подачу не берет. Задача нормализации строки тривиальная, легко решается заменами или регулярками. Фактически, надо


  • знак - перенести вперед (или вообще исключить, по технической сути задачи все данные только на убыль);
  • привести десятичный разделить к виду, требуемому для работы функций преобразования.

Насчет десятичного разделителя, увы, нет четкого стандарта. Рекомендуемые символы — . или ,, встретить можно и то и другое. В R точка является штатным разделителем, поэтому приводим к нему. Пропускаем базовые методы, берем пакет stringr и преобразуем подобным образом:


dt[, energy_num := as.numeric(stri_replace_all_fixed(energy, c("-", ","), c("", "."), vectorize_all = FALSE))]

На этом можно было бы остановится и большинство делают именно так. Но попробуем сделать еще пару шагов.


Шаг 1. Использование форматного сканера.


Большинство функций форматного ввода данных в различных языках берут начало от C-шной функции printf(), либо просто являются интерфейсом к ее реализации. Однако, в C существует и обратная функция scanf() — форматный ввод. Т.е. можно не заниматься модификацией исходной строки, а модифицировать настройки парсера. В R есть различные функции парсинга, настройка decimal separator может различаться: настройка локали ОС, настройка опций R, передача параметром в функцию. Рассмотрим далее readr::parse_number.


Шаг 2. Смотрим по сторонам.


Казалось бы, задача парсинга текстового представления в числа, которой уже «50 лет» и используется везде и всюду, должна быть выверена до мелочей. Многие на это и уповают. Однако, как оказалось, это не так. Техника за это время сильно изменилась, а новые возможности открывают новые способы реализации. Удивительно, что взглянуть на старую задачу по новому решили только в 2020 году. SIMD + словарь преобразований. Daniel Lemire, «Number Parsing at a Gigabyte per Second, Software: Pratice and Experience 51 (8), 2021». https://arxiv.org/abs/2101.11408


Итого, подводим итоги. Оценим временной вклад, который дает предварительное преобразование строки на векторе в 100k значений.


fclean <- function(nvec){
  stri_replace_all_fixed(nvec, c("-", ","), c("", "."), vectorize_all = FALSE)
}
nn <- rep(c("32,342-", "82,5-", "70,28-", "32,342-", "12,8-"), 20000)
nn_clean <- fclean(nn)

bench::mark(
  replace = fclean(nn)
)

# A tibble: 1 x 13
  expression    min median `itr/sec` mem_alloc `gc/sec` n_itr  n_gc
  <bch:expr> <bch:> <bch:>     <dbl> <bch:byt>    <dbl> <int> <dbl>
1 replace    94.4ms  101ms      9.11     792KB        0     5     0

Теперь прогоним различные тесты с учетом базового сценария и шагов 1 и 2.


bench::mark(
  base = - as.numeric(fclean(nn)),
  base_clean = - as.numeric(nn_clean),
  readr = - parse_number(nn, locale = locale(decimal_mark = ",")),
  simd = - as.double2(fclean(nn)),
  simd_clean = - as.double2(nn_clean),
  min_iterations = 10
)

# A tibble: 5 x 13
  expression      min   median `itr/sec` mem_alloc `gc/sec` n_itr  n_gc
  <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl> <int> <dbl>
1 base        108.3ms 121.08ms      7.48    1.53MB     0       10     0
2 base_clean   23.7ms  28.89ms     34.6    781.3KB     0       18     0
3 readr          23ms  25.75ms     34.7     1.55MB     2.17    16     1
4 simd         88.2ms  97.39ms     10.1     2.29MB     0       10     0
5 simd_clean    3.4ms   4.56ms    195.      1.53MB     6.82    86     3

Видно, что подход настройки параметров парсера лидирует на таком объеме, если числа действительно приходят в кривоватом формате (нет менеджмента памяти!). Базовый же подход самый неудачный. Но на правильных данных, когда не будет накладных на преобразование строк, использование современной библиотеки SIMD парсинга может дать выигрыш, особенно на больших датасетах, на порядок и больше. Тут уже будет все зависеть от числа доступных ядер процессора, объема данных и т.д. Чем всего больше, тем существеннее разница.


Отметим, что отдельные группы разработки ПО оперативно «перескочили» на парсер fast_float, в частности в ПО Apache Arrow, Yandex ClickHouse and by Google Jsonnet, Microsoft LightGBM framework и получили значительное повышение скорости работы.


Вот так вот. Вы на какой стороне? Черной, белой или зеленой?


Утилиты командной строки


Как только мы договорились, что фаза предварительной подготовки данных и фаза аналитики разведены, можно ни в чем себе не отказывать. Большое количество задач по предварительному процессингу может быть выполнено утилитами командной строки. Зачастую это будет даже быстрее, чем поднимать DS инструменты. Тут и построчный анализ и работа с фиксированным буфером без пула операций менеджмента памяти и оптимизированные *nix библиотеки и устранение всяких байндингов и механизм передачи результатов посредством пайпов, без обращения к диску.


Отличная книга, описывающая подход: «Data Science at the Command Line, 2e by Jeroen
Janssens»


Там, конечно, тоже все нетривиально и есть масса подводных камней. Вот интересный список ссылок, которые высвечивают фонариком разные аспекты в формате попурри. И книги и «полудетективные истории».



Еще быстрее. GNU parallel


В качестве дополнительной вишенки на торте — параллелизация исполнения скриптов в shell. Все инструменты есть в ОС, просто пишите скрипт и бейте задачу. Все остальное — удел операционной системы. Можно начинать читать с разных мест, например с «Get more done at the Linux command line with GNU Parallel»


Заключение


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


  • перевод ранее «нерешаемых» задач ETL в класс «решаемых»;
  • ускорение пайпланов ETL на 2-3 порядка, в зависимости от качества исходного кода.

Есть повод попробовать что-либо.


Предыдущая публикация — «Уходим с Mercurial на Git».

Теги:data scienceetlанализ данных
Хабы: Python Data Mining R Data Engineering
Всего голосов 2: ↑2 и ↓0+2
Просмотры404