Типизированный вывод и Parquet
Пригодится, когда файл идёт в анализ — pandas, DuckDB, Spark, хранилище данных — и
нужно, чтобы он нёс настоящие типы колонок и настоящий NULL, а не просто текст,
о смысле которого читателю приходится догадываться. Дайте колонкам
<data> имена, назовите файл с расширением
.parquet — и TDC запишет двоичный типизированный файл, без внешних библиотек и без
лишних флагов.
Весь наш вывод до сих пор был текстом: CSV, JSON, SQL. Для человека и для всего, что читает символы, это отлично. А для анализа данных у текста две проблемы, которые Parquet решает.
- Типов нет. В CSV всё — строка. Дата-сайентист грузит файл и заново гадает, где
число, где дата, где просто текст — и угадывает неправильно:
007превращается в7, и номер документа тихо испорчен. - NULL не выразить. Пустое место между двумя запятыми — это «пустой текст» или
«значения не было»?
missingвыдаёт пустую строку, и разница теряется.
Parquet — это колоночный двоичный формат, на который опираются все аналитические
инструменты. У каждой колонки есть настоящий тип и настоящий NULL, а файл
открывается одной строкой — pd.read_parquet("data.parquet") — и чинить в нём ничего не
надо.
Вывод в примерах ниже — иллюстративный: точные значения зависят от версии ядра и сида.
Важна форма: строка схемы на каждую колонку и то, где появляется настоящий null.
- Arow group: срез строк, который собирается, пишется и отпускается
- Bпервая колонка этого среза, лежащая отдельно
- Cвторая колонка
- Dтретья — читатель, которому нужна одна колонка, трогает только её куски
Как включить
Нужно две вещи: пометить, какие теги <data> —
колонки, и назвать файл .parquet.
Колонка — это <data> с атрибутом name. Без name тег остаётся обычным текстом
оформления и в файл не попадает. Тип задаётся атрибутом type.
<block>
<line>
<data name="id" type="int64">${{Id}}</data>
<data name="reading" type="int64">${{Reading}}</data>
<data name="is_outlier" type="bool">${{IsOutlier}}</data>
<data name="city" type="string">${{City}}</data>
<data name="amount" type="int64|null">${{Amount}}</data>
</line>
</block>
Формат выбирается по расширению файла — никаких новых флагов:
tdcv2 data.tdc -o data.parquet # двоичный типизированный файл
tdcv2 data.tdc -o data.csv # текст, ровно как раньше
Вот что увидит тот, кто откроет файл — схема (тип на колонку), а затем строки:
id INT64 REQUIRED
reading INT64 REQUIRED
is_outlier BOOLEAN REQUIRED
city BYTE_ARRAY REQUIRED {"type":"STRING"}
amount INT64 OPTIONAL
{"id":1,"reading":45, "is_outlier":false,"city":"Moscow", "amount":2143}
{"id":2,"reading":54, "is_outlier":false,"city":"Moscow", "amount":2328}
{"id":3,"reading":42, "is_outlier":false,"city":"Kazan", "amount":5275}
{"id":4,"reading":42, "is_outlier":false,"city":"Samara", "amount":null}
{"id":5,"reading":540,"is_outlier":true, "city":"Samara", "amount":5787}
{"id":6,"reading":53, "is_outlier":false,"city":"Kazan", "amount":3308}Две детали, на которых стоит остановиться. amount в четвёртой строке — настоящий
null (колонка OPTIONAL), а не пустая строка: это наш
missing, который наконец приехал как положено. А
is_outlier — настоящий BOOLEAN: колонка-метка
anomaly_flag, то есть готовый размеченный датасет для
проверки детектора аномалий.
Какие типы можно писать
type= | Что это | Из какого текста читается |
|---|---|---|
bool | истина / ложь | true/false, 1/0 |
int32 | целое 32 бита | -42 |
int64 | целое 64 бита | 9007199254740993 — точно |
double | дробное 8 байт | 3.14, 1e3 |
string | текст UTF-8 | как есть |
date | календарная дата | 2020-05-14 |
timestamp | момент времени | ISO-8601 |
decimal(p,s) | точное десятичное (деньги) | 123.45 — без округления |
uuid | UUID как 16 байт | канонический вид |
json | JSON | как есть |
float | дробное 4 байта | 3.14 — вдвое меньше double |
float16 | дробное 2 байта | 3.14 — точность ~3 знака |
enum | текст-перечисление | RED — строка, но с ярлыком |
uint8/16/32/64 | целое без знака | 255 — отрицательное отвергнет |
Добавьте \|null после типа, чтобы сделать колонку допускающей NULL:
type="int64\|null". Без этого пустое значение — ошибка, и это бесплатная проверка
качества: если колонка не должна быть пустой, TDC об этом скажет.
decimal никогда не округляет молчаdecimal(18,2) со значением 123.456 — это ошибка, а не потерянная копейка.
float и float16 намеренно отказываются от точности — в этом их смысл, они
занимают меньше места. 0.1 в float станет 0.100000001490116, а в float16 —
0.0999755859375. Значение в файле ровно такое, каким его увидит читатель (TDC округляет
сразу, чтобы статистика не описывала числа, которых в файле нет). Выход за диапазон
(1e40 для float, 100000 для float16) — ошибка, а не тихая бесконечность.
uint64 вмещает числа до 18 446 744 073 709 551 615 — больше, чем int64. Отрицательное
значение в такой колонке — ошибка.
Тип можно не писать — TDC выведет его сам
Движок знает, какой генератор породил колонку, поэтому в большинстве случаев type=
не нужен. Вот конфиг, где нет ни одного type=:
<sequence name="Id"><gen type="increment" value="1"/></sequence>
<sequence name="Price"><gen type="number" value="1..999" decimals="2"/></sequence>
<sequence name="Qty"><gen type="number" value="1..99" missing="0.4"/></sequence>
<sequence name="Born"><gen type="date" range="1990-01-01..2000-12-31" format="YYYY-MM-DD"/></sequence>
<sequence name="Key"><gen type="template" value="common.id.uuid"/></sequence>
<sequence name="R"><gen type="number" value="10..20" anomaly="0.4" anomaly_flag="Flag"/></sequence>
...
<data name="id">${{Id}}</data>
<data name="price">${{Price}}</data>
<data name="qty">${{Qty}}</data>
<data name="born">${{Born}}</data>
<data name="key">${{Key}}</data>
<data name="flag">${{Flag}}</data>
Что TDC вычислил сам:
id INT64 REQUIRED
price DOUBLE REQUIRED
qty INT64 OPTIONAL
born INT32 REQUIRED {"type":"DATE"}
key FIXED_LEN_BYTE_ARRAY REQUIRED {"type":"UUID"}
flag BOOLEAN REQUIRED
{"id":1,"price":230,"qty":63, "born":"1996-05-25","key":"e96b21bc-...","flag":true}
{"id":2,"price":589,"qty":null,"born":"2000-05-01","key":"85caccad-...","flag":false}Правила простые: number без decimals → целое, с decimals
→ дробное; счётчик increment → целое; common.id.uuid →
UUID; колонка-метка anomaly_flag → булево. И, что удобно,
missing сам делает колонку nullable (qty стала
OPTIONAL).
Порядок такой: явный type= → вывод из генератора → текст. TDC никогда не
угадывает тип по самим значениям — именно это и портит данные в CSV (007 → 7). Если
TDC не уверен, колонка остаётся строкой: строка ничего не ломает.
Два случая, когда вывод сознательно пропускается
dateбезformat="YYYY-MM-DD". По умолчанию дата печатается как05/25/1996, а это не ISO — объявлять такое датой было бы нечестно.maskилиcaseна генераторе. Они переписывают текст, и число уже не число.
В обоих случаях поставьте type= руками, если знаете, что делаете.
Когда значение не подходит под тип
TDC никогда не пишет испорченный файл — он останавливается и говорит, где именно:
tdc: column "n", row 1: "abc" is not an integer (int64)
Опечатку в самом имени типа валидатор поймает ещё до начала генерации (код
TDC194).
Большие объёмы — row-group'ы
Файл пишется row-group'ами (по 50 000 строк): группа собирается, пишется и освобождается, поэтому память не растёт с числом строк. 120 000 строк — это три группы, и читатель может пропускать целые группы, не разбирая файл от начала до конца. Это то же потоковое поведение, что держит большие выгрузки на ровной памяти.
Эти же группы дают многопоточность. Байты группы не зависят от того, где она лежит в файле — размеры записаны в заголовках страниц, а все смещения собраны в оглавлении, — поэтому потоки собирают группы независимо, а в конце координатор складывает их подряд и дописывает одно оглавление с поправленными смещениями.
Работа делится по группам, а не по строкам — разрежь группу пополам, и получились бы группы, каких однопоточный запуск не делает. Поэтому вывод совпадает байт в байт при любом числе потоков. На миллионе строк:
--jobs 1 6.51 s --jobs 4 2.51 s --jobs 8 2.18 s <- файлы всех трёх запусков идентичны
Одно условие: нужен настоящий файл (-o). В стандартный вывод
Parquet пишется в один поток — координатору надо знать, куда легла каждая группа. Число
потоков задаётся флагом --jobs, если хотите; байты в любом случае
одни и те же.
Списки
Колонка может держать список значений, а не одно: type="[]int64" либо просто
repeat на генераторе — тогда тип выведется сам. Пустые
списки и null внутри списка записываются честно.
<data name="scores" type="[]int64">${{Scores}}</data>
scores INT64 OPTIONAL (repeated)
{"scores":[45,52,61]}
{"scores":[]}
{"scores":[70,null,55]}Быстрые запросы — статистика по колонкам
Для каждой колонки TDC записывает минимум, максимум и количество NULL. Это позволяет
читателю пропускать целые блоки: на запрос вроде amount > 500 он смотрит на максимум
блока и, если тот меньше, не разбирает блок вовсе.
Сравнение идёт по правилам формата, а не JavaScript: строки сравниваются по своим
байтам UTF-8. В ASCII заглавная буква стоит раньше строчной, поэтому
"Apple" < "apple" < "zebra", а любой не-ASCII текст сортируется после всего ASCII — это
устойчивый переносимый порядок, с которым согласен любой читатель Parquet.
Повторяющиеся значения хранятся один раз
Если в колонке мало разных значений — города, статусы, категории — TDC записывает их словарём: сам список один раз, а каждая строка — маленький номер, указывающий в него. Решение принимается само, по данным. На 50 000 строк:
| колонка | разных значений | словарь | размер |
|---|---|---|---|
city | 5 | да | 18 КБ |
status | 3 | да | 12 КБ |
uuid | 50 000 | нет — не окупился | 781 КБ |
Колонке с уникальными значениями словарь только навредил бы, поэтому TDC его там не применяет. Правило простое: словарь берётся, когда разных значений вдвое меньше числа строк или ещё меньше.
Сжатие
Страницы сжимаются алгоритмом snappy — стандартом для Parquet, который понимает любой читатель. На реальном наборе из 50 000 строк и 14 колонок:
no compression, no dictionary: 5.70 MB now: 1.99 MB <- почти втрое меньше
Сжатие выбирается по колонке и только когда оно выигрывает. У snappy есть служебные байты, и на совсем маленькой странице они стоят больше, чем экономят — такую колонку TDC оставляет несжатой. Файл никогда не растёт от попытки его уменьшить.
Реализовано собственным кодом TDC, без сторонних библиотек — и не только ради зависимостей: две реализации snappy могут выдать разные (одинаково верные) байты на одних данных, и именно общий кодировщик позволяет всем пяти реализациям давать побайтово одинаковые файлы на одной версии.
Чтение обратно в pandas
Вся выгода — на стороне читателя. Одна строка, и датафрейм уже с правильными dtype и
настоящим NaN там, где в файле был null — чистить нечего:
import pandas as pd
df = pd.read_parquet("data.parquet")
print(df.dtypes)
id int64 reading int64 is_outlier bool city object amount float64 dtype: object
id и reading — целые, is_outlier — настоящий булев тип, city — текст, а amount
(nullable-колонка) возвращается как float64, чтобы держать NaN в пропущенных строках
(попросите pandas включить nullable-бэкенд через
pd.read_parquet(..., dtype_backend="numpy_nullable"), чтобы сохранить nullable Int64).
Сам датафрейм:
id reading is_outlier city amount 0 1 45 False Moscow 2143.0 1 2 54 False Moscow 2328.0 2 3 42 False Kazan 5275.0 3 4 42 False Samara NaN 4 5 540 True Samara 5787.0
amount в четвёртой строке — NaN, а не пустая строка: null пережил обход туда-обратно.
Полное описание библиотечного API каждого языка см. в
Языковых привязках.
Чего пока нет
- Сжатие zstd / brotli — snappy есть, этих пока нет.
- Словарь для чисел с плавающей точкой работает, но выигрыш обычно меньше — повторов среди дробных немного.
MAPи вложенные структуры внутри колонки — списки уже есть (см.repeat), а вотMAPи списки списков не поддержаны.- Геометрические типы — добавляются по одному, каждый лишь ярлык поверх тех же байтов.
float, float16 и enum раньше числились здесь как нереализованные — теперь они
работают и дают правильные логические типы (FLOAT, FLOAT16, ENUM).
Смотрите также
- Форматы вывода (CSV, JSON, SQL…) — текстовая сторона того же блока вывода и где кусается синтаксис каждого формата.
- Вывод и оформление —
<block>,<line>и<data>целиком. - Большие выгрузки и потоковый вывод — row-group'ы,
--jobsи ровная память при любом размере. - CLI —
-o,--jobs,--engine. - Маски и регистр —
mask/case, которые выключают вывод типа. - Языковые привязки — чтение и запись из всех пяти: TypeScript, Python, Java, C# и Rust.