polypress 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- polypress/__init__.py +13 -0
- polypress/caccel.py +164 -0
- polypress/cli.py +255 -0
- polypress/codec.py +343 -0
- polypress/dtz.py +978 -0
- polypress/fast.py +1397 -0
- polypress/stream.py +438 -0
- polypress/tcz.c +207 -0
- polypress/turbo.py +1515 -0
- polypress-0.2.0.dist-info/METADATA +121 -0
- polypress-0.2.0.dist-info/RECORD +15 -0
- polypress-0.2.0.dist-info/WHEEL +5 -0
- polypress-0.2.0.dist-info/entry_points.txt +2 -0
- polypress-0.2.0.dist-info/licenses/LICENSE +21 -0
- polypress-0.2.0.dist-info/top_level.txt +1 -0
polypress/fast.py
ADDED
|
@@ -0,0 +1,1397 @@
|
|
|
1
|
+
"""Vectorised table codec -- same ideas as smart.py, none of the Python loops.
|
|
2
|
+
|
|
3
|
+
Three changes made this 15-50x faster AND slightly smaller:
|
|
4
|
+
|
|
5
|
+
1. all prediction is numpy. Polynomial extrapolation of order k is just the
|
|
6
|
+
k-th finite difference, so the "local function builder" is np.diff. The
|
|
7
|
+
2D version is np.diff twice, once along each axis.
|
|
8
|
+
|
|
9
|
+
2. residuals are packed as one-byte varints with an escape, vectorised --
|
|
10
|
+
no per-value Python loop.
|
|
11
|
+
|
|
12
|
+
3. the entropy stage is xz/zstd (C) instead of our Python range coder. The
|
|
13
|
+
range coder was the bottleneck and it was not paying for itself.
|
|
14
|
+
|
|
15
|
+
4. CROSS-COLUMN STRUCTURE IS EXPLOITED BY REORDERING, NOT CONTEXT MODELLING.
|
|
16
|
+
To code City given Postal Code, sort the rows by Postal Code: equal
|
|
17
|
+
postal codes become adjacent, so City collapses into long runs that xz
|
|
18
|
+
eats. The permutation costs nothing to store because the decoder has
|
|
19
|
+
already reconstructed the parent column and can recompute the same
|
|
20
|
+
stable argsort. Measured on 40k EV rows this beat the conditioned range
|
|
21
|
+
coder 2,328 B vs 6,901 B.
|
|
22
|
+
|
|
23
|
+
Fidelity: the logical table round-trips exactly, cell for cell.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import bz2
|
|
29
|
+
import json
|
|
30
|
+
import lzma
|
|
31
|
+
import math
|
|
32
|
+
from collections import Counter, defaultdict
|
|
33
|
+
from typing import Dict, List, Optional, Tuple
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
|
|
37
|
+
from . import caccel
|
|
38
|
+
from . import codec
|
|
39
|
+
from . import dtz
|
|
40
|
+
|
|
41
|
+
DICT_MAX = 1 << 16
|
|
42
|
+
MI_SAMPLE = 40000 # ceiling on rows sampled for column correlation
|
|
43
|
+
MI_MIN_SAMPLE = 1500 # floor, so a very wide table still measures something
|
|
44
|
+
MI_BUDGET = 150_000_000 # cap on (column pairs x sampled rows)
|
|
45
|
+
MIN_2D_GROUP = 3
|
|
46
|
+
INT_LIMIT = 1 << 62
|
|
47
|
+
XZ = dict(format=lzma.FORMAT_RAW,
|
|
48
|
+
filters=[{"id": lzma.FILTER_LZMA2, "preset": 9 | lzma.PRESET_EXTREME}])
|
|
49
|
+
ESCAPE = 255
|
|
50
|
+
MAGIC = b"PPZ1" # Polypress container
|
|
51
|
+
MAGIC_V0 = b"FAST" # pre-rename archives still open
|
|
52
|
+
# Fallback magics are 4 bytes with the codec baked in rather than 4 + a method
|
|
53
|
+
# byte. That one byte matters: the fallback exists to tie a general compressor
|
|
54
|
+
# that we would otherwise lose to, and the tie is measured against its bare
|
|
55
|
+
# output stream. Every byte of container is a byte of deficit.
|
|
56
|
+
MAGIC_RAW_XZ = b"PPZX"
|
|
57
|
+
MAGIC_RAW_BZ = b"PPZB"
|
|
58
|
+
|
|
59
|
+
# Fallback codecs. stdlib only, deliberately. An adversarial suite found four
|
|
60
|
+
# tables where the modelling lost to a plain general-purpose compressor -- by
|
|
61
|
+
# 0.8% to 11% -- because this codec always finishes with xz and xz is not
|
|
62
|
+
# always the best finisher. Carrying xz and bzip2 candidates makes it
|
|
63
|
+
# impossible to lose to either. brotli won two of those four by under 1% and
|
|
64
|
+
# is NOT carried: it is not in the standard library and would mean linking
|
|
65
|
+
# libbrotli into the C port, which is a poor trade for <1% on data that is
|
|
66
|
+
# incompressible anyway.
|
|
67
|
+
# Sorting cannot create useful runs in a handful of rows, and the parent
|
|
68
|
+
# search is O(columns^2). Below this row count, skip it entirely.
|
|
69
|
+
MIN_ROWS_FOR_PARENTS = 8
|
|
70
|
+
# The planar predictor differences down the rows, so it needs rows to work
|
|
71
|
+
# with. On a 1-row table a "group" is pure bookkeeping: it restructures the
|
|
72
|
+
# payload, reduces nothing, and -- because it counted as a trick that fired --
|
|
73
|
+
# used to suppress the fallback on exactly the table that needed it most.
|
|
74
|
+
MIN_ROWS_FOR_2D = 3
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------ packing
|
|
78
|
+
|
|
79
|
+
def zigzag(a: np.ndarray) -> np.ndarray:
|
|
80
|
+
"""Signed -> unsigned, using the bit trick rather than arithmetic.
|
|
81
|
+
|
|
82
|
+
The obvious `(-a << 1) - 1` overflows int64: differences between values
|
|
83
|
+
near the +/-2^62 limit legitimately reach 2^63, and negating or doubling
|
|
84
|
+
those in signed arithmetic wraps to nonsense. `(a << 1) ^ (a >> 63)` is
|
|
85
|
+
exact because it relies on the wraparound instead of fighting it."""
|
|
86
|
+
a = np.ascontiguousarray(a, dtype=np.int64)
|
|
87
|
+
with np.errstate(over="ignore"):
|
|
88
|
+
u = (a << np.int64(1)) ^ (a >> np.int64(63))
|
|
89
|
+
return u.view(np.uint64)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def unzigzag(u: np.ndarray) -> np.ndarray:
|
|
93
|
+
u = np.ascontiguousarray(u, dtype=np.uint64)
|
|
94
|
+
return ((u >> np.uint64(1)).astype(np.int64)
|
|
95
|
+
^ -(u & np.uint64(1)).astype(np.int64))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def pack_ints(res: np.ndarray) -> bytes:
|
|
99
|
+
"""One byte per small value, escape + 4 bytes for the rest."""
|
|
100
|
+
if caccel.HAVE_C:
|
|
101
|
+
return caccel.pack(res)
|
|
102
|
+
u = zigzag(res)
|
|
103
|
+
small = u < ESCAPE
|
|
104
|
+
head = np.full(u.size, ESCAPE, dtype=np.uint8)
|
|
105
|
+
head[small] = u[small].astype(np.uint8)
|
|
106
|
+
tail = u[~small]
|
|
107
|
+
if tail.size and int(tail.max()) >= (1 << 32):
|
|
108
|
+
return b"\x08" + head.tobytes() + tail.astype("<u8").tobytes()
|
|
109
|
+
return b"\x04" + head.tobytes() + tail.astype("<u4").tobytes()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def unpack_ints(buf: bytes, n: int) -> np.ndarray:
|
|
113
|
+
if caccel.HAVE_C:
|
|
114
|
+
return caccel.unpack(buf, n)
|
|
115
|
+
width = buf[0]
|
|
116
|
+
head = np.frombuffer(buf[1:1 + n], dtype=np.uint8).astype(np.uint64)
|
|
117
|
+
big = head == ESCAPE
|
|
118
|
+
nbig = int(big.sum())
|
|
119
|
+
if nbig:
|
|
120
|
+
at = 1 + n
|
|
121
|
+
if width == 8:
|
|
122
|
+
tail = np.frombuffer(buf[at:at + 8 * nbig], dtype="<u8")
|
|
123
|
+
else:
|
|
124
|
+
tail = np.frombuffer(buf[at:at + 4 * nbig],
|
|
125
|
+
dtype="<u4").astype(np.uint64)
|
|
126
|
+
head = head.copy()
|
|
127
|
+
head[big] = tail
|
|
128
|
+
return unzigzag(head)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def ints_to_cells(a: np.ndarray, dec: int) -> List[str]:
|
|
132
|
+
"""Fixed-point integers back to their printed text, vectorised.
|
|
133
|
+
|
|
134
|
+
Equivalent to codec.int_to_cell per element, but the digit formatting
|
|
135
|
+
happens inside numpy instead of a Python loop -- this was over half of
|
|
136
|
+
decode time."""
|
|
137
|
+
if a.size == 0:
|
|
138
|
+
return []
|
|
139
|
+
if caccel.HAVE_C:
|
|
140
|
+
return caccel.cells_from_ints(a, dec)
|
|
141
|
+
if dec == 0:
|
|
142
|
+
return list(map(str, a.tolist()))
|
|
143
|
+
scale = 10 ** dec
|
|
144
|
+
pos = "%d.%0" + str(dec) + "d"
|
|
145
|
+
neg = "-" + pos
|
|
146
|
+
out = []
|
|
147
|
+
ap = out.append
|
|
148
|
+
for x in a.tolist():
|
|
149
|
+
if x < 0:
|
|
150
|
+
x = -x
|
|
151
|
+
ap(neg % (x // scale, x % scale))
|
|
152
|
+
else:
|
|
153
|
+
ap(pos % (x // scale, x % scale))
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def packed_len(res: np.ndarray) -> int:
|
|
158
|
+
u = zigzag(res)
|
|
159
|
+
esc = u[u >= ESCAPE]
|
|
160
|
+
width = 8 if esc.size and int(esc.max()) >= (1 << 32) else 4
|
|
161
|
+
return 1 + res.size + width * int(esc.size)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ----------------------------------------------------------------- analysis
|
|
165
|
+
|
|
166
|
+
def _numeric(cells):
|
|
167
|
+
"""Scaled-integer view of a column, or None. C first, numpy fallback."""
|
|
168
|
+
if not cells:
|
|
169
|
+
return None
|
|
170
|
+
if caccel.HAVE_C:
|
|
171
|
+
return caccel.parse_column(cells)
|
|
172
|
+
num = codec.as_numeric_column(cells)
|
|
173
|
+
if num is None or not num[0]:
|
|
174
|
+
return None
|
|
175
|
+
if max(abs(min(num[0])), abs(max(num[0]))) >= INT_LIMIT:
|
|
176
|
+
return None
|
|
177
|
+
return np.array(num[0], dtype=np.int64), num[1]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# At most this fraction of a column may be exception cells. It is a screen,
|
|
181
|
+
# not the decision -- the decision is the whole-file guard in encode(). The
|
|
182
|
+
# point of the screen is to refuse the pathological shapes cheaply: a column
|
|
183
|
+
# of ragged-decimal floats has exceptions everywhere and is exactly the case
|
|
184
|
+
# that was measured 9-23% worse and reverted.
|
|
185
|
+
EX_MAX_FRACTION = 0.05
|
|
186
|
+
# The C port's POW10 table stops here, so both implementations refuse beyond it.
|
|
187
|
+
EX_MAX_DEC = 18
|
|
188
|
+
|
|
189
|
+
_NUM_RE = codec._NUMERIC
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _exact_int(cell: str, dec: int):
|
|
193
|
+
"""The cell as a scaled integer, or None if it cannot be reproduced.
|
|
194
|
+
|
|
195
|
+
Exactly codec.as_numeric_column's per-cell test: parse, print back, and
|
|
196
|
+
require the original string. That is what rejects "007", "1.50" at three
|
|
197
|
+
decimals, and -- the case that started this -- "-0.0", which parses to 0
|
|
198
|
+
and prints as "0.0".
|
|
199
|
+
"""
|
|
200
|
+
if not _NUM_RE.match(cell):
|
|
201
|
+
return None
|
|
202
|
+
v = codec.cell_to_int(cell, dec)
|
|
203
|
+
# The C parser refuses anything past the 2^62 acceptance limit while it is
|
|
204
|
+
# still accumulating digits, so a value beyond it is an exception there.
|
|
205
|
+
# Python's ints are arbitrary precision and would happily take it, which
|
|
206
|
+
# would put the two implementations on different plans for the same file.
|
|
207
|
+
if v >= INT_LIMIT or v <= -INT_LIMIT:
|
|
208
|
+
return None
|
|
209
|
+
return v if codec.int_to_cell(v, dec) == cell else None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _numeric_lenient(cells):
|
|
213
|
+
"""A column that is numeric apart from a few cells that are not.
|
|
214
|
+
|
|
215
|
+
The numeric test is all or nothing, and that turns out to be expensive.
|
|
216
|
+
The Treasury yield curve -- the matrix table the planar predictor exists
|
|
217
|
+
for -- contains four blank cells in 72,048, and those four drop all eight
|
|
218
|
+
rate columns to the dictionary path. With no numeric columns there is no
|
|
219
|
+
group, and the archive is 59,309 B instead of 34,856 B. Four cells, 41% of
|
|
220
|
+
the file. Separately, one "-0.0" in 26,304 cells disqualifies a whole
|
|
221
|
+
temperature column and splits a 21-column matrix into 18 and 3.
|
|
222
|
+
|
|
223
|
+
The exceptions are recorded by position and stored as text, and their
|
|
224
|
+
slots in the integer array are FORWARD-FILLED from the previous good
|
|
225
|
+
value. Filling rather than removing is what keeps every column the same
|
|
226
|
+
length, which is what keeps the planar predictor able to stack them --
|
|
227
|
+
worth another 18% on the yield curve on top of the 28% for being numeric
|
|
228
|
+
at all. The fill values are never seen: the decoder overwrites those
|
|
229
|
+
positions with the stored strings.
|
|
230
|
+
|
|
231
|
+
The decimal count is the most common one rather than the maximum, with
|
|
232
|
+
ties going to the smaller count so the choice cannot depend on dict order.
|
|
233
|
+
Anything that does not reproduce exactly at that count becomes an
|
|
234
|
+
exception, which folds blanks, "-0.0", leading zeros and stray decimal
|
|
235
|
+
counts into one mechanism.
|
|
236
|
+
"""
|
|
237
|
+
n = len(cells)
|
|
238
|
+
if n == 0:
|
|
239
|
+
return None
|
|
240
|
+
limit = int(n * EX_MAX_FRACTION)
|
|
241
|
+
|
|
242
|
+
counts: Dict[int, int] = {}
|
|
243
|
+
bad = 0
|
|
244
|
+
for c in cells:
|
|
245
|
+
if not _NUM_RE.match(c):
|
|
246
|
+
bad += 1
|
|
247
|
+
if bad > limit:
|
|
248
|
+
return None
|
|
249
|
+
continue
|
|
250
|
+
i = c.find(".")
|
|
251
|
+
d = 0 if i < 0 else len(c) - i - 1
|
|
252
|
+
if d > EX_MAX_DEC:
|
|
253
|
+
# counted as an exception candidate rather than as a decimal
|
|
254
|
+
# count, so the histogram stays a fixed 0..18 array in the C port
|
|
255
|
+
bad += 1
|
|
256
|
+
if bad > limit:
|
|
257
|
+
return None
|
|
258
|
+
continue
|
|
259
|
+
counts[d] = counts.get(d, 0) + 1
|
|
260
|
+
if not counts:
|
|
261
|
+
return None
|
|
262
|
+
dec = min(counts, key=lambda d: (-counts[d], d))
|
|
263
|
+
|
|
264
|
+
vals: List[Optional[int]] = [None] * n
|
|
265
|
+
expos: List[int] = []
|
|
266
|
+
exvals: List[str] = []
|
|
267
|
+
for i, c in enumerate(cells):
|
|
268
|
+
v = _exact_int(c, dec)
|
|
269
|
+
if v is None:
|
|
270
|
+
expos.append(i)
|
|
271
|
+
exvals.append(c)
|
|
272
|
+
if len(expos) > limit:
|
|
273
|
+
return None
|
|
274
|
+
else:
|
|
275
|
+
vals[i] = v
|
|
276
|
+
if not expos:
|
|
277
|
+
return None # plain _numeric already handles this
|
|
278
|
+
|
|
279
|
+
# forward fill, then patch any leading run with the first good value
|
|
280
|
+
fill = None
|
|
281
|
+
first_good = None
|
|
282
|
+
for i in range(n):
|
|
283
|
+
if vals[i] is None:
|
|
284
|
+
vals[i] = fill
|
|
285
|
+
else:
|
|
286
|
+
fill = vals[i]
|
|
287
|
+
if first_good is None:
|
|
288
|
+
first_good = vals[i]
|
|
289
|
+
if first_good is None:
|
|
290
|
+
return None
|
|
291
|
+
for i in range(n):
|
|
292
|
+
if vals[i] is None:
|
|
293
|
+
vals[i] = first_good
|
|
294
|
+
else:
|
|
295
|
+
break
|
|
296
|
+
|
|
297
|
+
a = np.array(vals, dtype=np.int64)
|
|
298
|
+
if a.size and int(np.abs(a).max()) >= INT_LIMIT:
|
|
299
|
+
return None
|
|
300
|
+
return a, dec, np.array(expos, dtype=np.int64), exvals
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _lenient_promising(cells, a, expos, nrows) -> bool:
|
|
304
|
+
"""Could storing this column as numbers possibly beat leaving it alone?
|
|
305
|
+
|
|
306
|
+
The never-worse guard costs a whole second encode, and profiling says that
|
|
307
|
+
second encode IS the cost -- the lenient scan itself is 0.04-0.09s where
|
|
308
|
+
the extra encode is 0.9-1.4s. On four large datasets the guard runs, finds
|
|
309
|
+
nothing, and doubles encode time for zero bytes.
|
|
310
|
+
|
|
311
|
+
This is a one-sided screen, and the direction matters. The alternative cost
|
|
312
|
+
is deliberately OVER-estimated: it includes the dictionary's alphabet, and
|
|
313
|
+
it ignores the reorder parent that would make the column cheaper still. So
|
|
314
|
+
the real alternative is never more expensive than this estimate, and
|
|
315
|
+
`numeric >= estimate` therefore implies `numeric >= real`. Skipping on that
|
|
316
|
+
basis cannot discard a win on this column.
|
|
317
|
+
|
|
318
|
+
It is only a nominator. Whenever any column looks promising the full
|
|
319
|
+
end-to-end guard still runs and still decides.
|
|
320
|
+
"""
|
|
321
|
+
k = diff_order(a)
|
|
322
|
+
num = _probe_bytes(pack_ints(np.diff(a, n=k) if k else a))
|
|
323
|
+
num += _probe_bytes(pack_ints(np.diff(expos, prepend=np.int64(0))))
|
|
324
|
+
uniq = sorted(set(cells))
|
|
325
|
+
if len(uniq) <= DICT_MAX and len(uniq) * 2 <= max(nrows, 2):
|
|
326
|
+
idx = {v: i for i, v in enumerate(uniq)}
|
|
327
|
+
ids = np.array([idx[v] for v in cells], dtype=np.int64)
|
|
328
|
+
alt = _probe_bytes(ids.astype(_width(len(uniq))).tobytes())
|
|
329
|
+
alt += _probe_len(uniq)
|
|
330
|
+
else:
|
|
331
|
+
alt = _probe_len(cells)
|
|
332
|
+
return num < alt
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def classify(table, lenient: bool = True) -> List[dict]:
|
|
336
|
+
nrows = len(table.rows)
|
|
337
|
+
plan = []
|
|
338
|
+
for j in range(len(table.columns)):
|
|
339
|
+
cells = table.column(j)
|
|
340
|
+
num = _numeric(cells)
|
|
341
|
+
if num is not None:
|
|
342
|
+
plan.append({"kind": "num", "ints": num[0], "dec": num[1],
|
|
343
|
+
"j": j, "ex": None})
|
|
344
|
+
continue
|
|
345
|
+
lax = _numeric_lenient(cells) if lenient else None
|
|
346
|
+
if lax is not None:
|
|
347
|
+
plan.append({"kind": "num", "ints": lax[0], "dec": lax[1],
|
|
348
|
+
"j": j, "ex": (lax[2], lax[3]),
|
|
349
|
+
"exp": _lenient_promising(cells, lax[0], lax[2],
|
|
350
|
+
nrows)})
|
|
351
|
+
continue
|
|
352
|
+
uniq = sorted(set(cells))
|
|
353
|
+
if len(uniq) <= DICT_MAX and len(uniq) * 2 <= max(nrows, 2):
|
|
354
|
+
idx = {v: i for i, v in enumerate(uniq)}
|
|
355
|
+
plan.append({"kind": "dict", "alpha": uniq,
|
|
356
|
+
"ids": np.array([idx[v] for v in cells],
|
|
357
|
+
dtype=np.int64), "j": j})
|
|
358
|
+
continue
|
|
359
|
+
plan.append({"kind": "text", "cells": cells, "j": j})
|
|
360
|
+
return plan
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def find_2d_groups(plan, nrows: int = 1 << 30) -> List[List[int]]:
|
|
364
|
+
if nrows < MIN_ROWS_FOR_2D:
|
|
365
|
+
return []
|
|
366
|
+
groups, run = [], []
|
|
367
|
+
for pos, col in enumerate(plan):
|
|
368
|
+
if col["kind"] != "num" or col["ints"].size == 0:
|
|
369
|
+
if len(run) >= MIN_2D_GROUP:
|
|
370
|
+
groups.append(run)
|
|
371
|
+
run = []
|
|
372
|
+
continue
|
|
373
|
+
if not run:
|
|
374
|
+
run = [pos]
|
|
375
|
+
continue
|
|
376
|
+
prev = plan[run[-1]]
|
|
377
|
+
hi_a = int(np.abs(prev["ints"]).max()) or 1
|
|
378
|
+
hi_b = int(np.abs(col["ints"]).max()) or 1
|
|
379
|
+
if prev["dec"] == col["dec"] and hi_a <= 8 * hi_b and hi_b <= 8 * hi_a:
|
|
380
|
+
run.append(pos)
|
|
381
|
+
else:
|
|
382
|
+
if len(run) >= MIN_2D_GROUP:
|
|
383
|
+
groups.append(run)
|
|
384
|
+
run = [pos]
|
|
385
|
+
if len(run) >= MIN_2D_GROUP:
|
|
386
|
+
groups.append(run)
|
|
387
|
+
return groups
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def diff_order(a: np.ndarray) -> int:
|
|
391
|
+
"""Polynomial degree whose residuals pack smallest. Order k residuals are
|
|
392
|
+
the k-th finite difference, so this is just np.diff k times."""
|
|
393
|
+
best, best_cost = 0, packed_len(a)
|
|
394
|
+
for k in range(1, min(4, a.size - 1) + 1):
|
|
395
|
+
c = packed_len(np.diff(a, n=k)) + 8 * k
|
|
396
|
+
if c < best_cost:
|
|
397
|
+
best, best_cost = k, c
|
|
398
|
+
return best
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ------------------------------------------------- cross-column conditioning
|
|
402
|
+
|
|
403
|
+
def _counts_entropy(counts: np.ndarray, n: int) -> float:
|
|
404
|
+
p = counts / n
|
|
405
|
+
return float(-np.sum(p * np.log2(p)))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# Entropy scores are COMPARED as integers, never as floats.
|
|
409
|
+
#
|
|
410
|
+
# The parent search ranks columns by entropy differences, and for a long time
|
|
411
|
+
# both implementations tried to agree on those differences bit for bit. That is
|
|
412
|
+
# not achievable and the attempt was hiding a real defect. `np.sum` is pairwise
|
|
413
|
+
# for small arrays -- which `csrc/ppz_encode.c:pairwise_sum` reproduces exactly,
|
|
414
|
+
# and for a 23-bin marginal the two agree to the last bit -- but on a 13,147-bin
|
|
415
|
+
# joint histogram numpy takes a SIMD reduction whose grouping depends on the
|
|
416
|
+
# CPU's vector width. Measured on this machine: `np.sum` differs from numpy's
|
|
417
|
+
# own documented scalar algorithm at that size. So no portable C can match it,
|
|
418
|
+
# and two numpy builds on different hardware need not match each other either.
|
|
419
|
+
#
|
|
420
|
+
# The last bit was never meaningful. A gain of 0.4912703838362144 and one of
|
|
421
|
+
# 0.4912703838362162 say the same thing about a column, and letting the
|
|
422
|
+
# difference between them choose a parent -- and therefore every byte of the
|
|
423
|
+
# archive after it -- is the bug. Scores are quantised to a grid about 1e-6
|
|
424
|
+
# wide and compared as int64. Anything closer than that is a tie, and ties fall
|
|
425
|
+
# to the lower column index in both implementations, which is a rule they can
|
|
426
|
+
# actually both keep.
|
|
427
|
+
#
|
|
428
|
+
# `floor(x * SCALE + 0.5)` rather than round(): Python's round() is
|
|
429
|
+
# banker's rounding and C's llround() is half-away-from-zero, and every score
|
|
430
|
+
# here is non-negative.
|
|
431
|
+
_SCORE_SCALE = 1 << 20
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _score(x: float) -> int:
|
|
435
|
+
return int(math.floor(x * _SCORE_SCALE + 0.5))
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# The nomination floor, 0.05 bits, on the same integer grid.
|
|
439
|
+
_MIN_GAIN_SCORE = _score(0.05)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# Above this many joint bins, counting by bincount would allocate more than
|
|
443
|
+
# it saves and np.unique's sort is the better trade. Real survey columns have
|
|
444
|
+
# cardinalities in the tens, so the product is tiny and this never trips.
|
|
445
|
+
JOINT_BINCOUNT_MAX = 1 << 20
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _joint_counts(xs: np.ndarray, ys: np.ndarray, ny: int) -> np.ndarray:
|
|
449
|
+
"""Counts of each distinct (x, y) pair, ascending by combined key.
|
|
450
|
+
|
|
451
|
+
np.unique sorts, which is O(n log n) and was the single largest cost in
|
|
452
|
+
encoding a wide table. When the combined key space is small -- which is
|
|
453
|
+
the normal case for categorical columns -- bincount does it in one pass.
|
|
454
|
+
|
|
455
|
+
Both branches return counts in ascending key order, which matters for more
|
|
456
|
+
than tidiness: the caller sums them, np.sum is pairwise, and a different
|
|
457
|
+
order would give a different last bit, a different parent, and a different
|
|
458
|
+
archive. The two paths are interchangeable only because the order matches.
|
|
459
|
+
"""
|
|
460
|
+
key = xs * ny + ys
|
|
461
|
+
if key.size:
|
|
462
|
+
hi = int(key.max())
|
|
463
|
+
if hi < JOINT_BINCOUNT_MAX:
|
|
464
|
+
c = np.bincount(key)
|
|
465
|
+
return c[c > 0]
|
|
466
|
+
_, jc = np.unique(key, return_counts=True)
|
|
467
|
+
return jc
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _cond_entropy_corrected(xs: np.ndarray, ys: np.ndarray, ny: int,
|
|
471
|
+
hx: float, mx_size: int) -> float:
|
|
472
|
+
"""H(Y|X) with a Miller-Madow correction.
|
|
473
|
+
|
|
474
|
+
Computed as H(X,Y) - H(X) from one combined key, so it is two numpy
|
|
475
|
+
passes instead of two Python Counters. That mattered: on a 209-column
|
|
476
|
+
survey table this function was 94% of encode time, called once per
|
|
477
|
+
ordered column pair -- 38,220 times.
|
|
478
|
+
|
|
479
|
+
`hx` and `mx_size` describe X alone. They used to be recomputed inside
|
|
480
|
+
here, once for every Y -- 420 times per column on a 421-column table, for
|
|
481
|
+
a value that never changed. They are now hoisted to the caller. The
|
|
482
|
+
arithmetic is written in the same shape as before so the result is the
|
|
483
|
+
same double, not merely the same number.
|
|
484
|
+
|
|
485
|
+
The correction is not optional. A parent with many distinct values scores
|
|
486
|
+
near-zero conditional entropy purely because each of its values is seen a
|
|
487
|
+
handful of times, which is how an earlier version picked nonsense parents.
|
|
488
|
+
"""
|
|
489
|
+
n = xs.size
|
|
490
|
+
if n == 0:
|
|
491
|
+
return 0.0
|
|
492
|
+
jc = _joint_counts(xs, ys, ny)
|
|
493
|
+
h = _counts_entropy(jc, n) - hx
|
|
494
|
+
return h + (jc.size - mx_size) / (2.0 * n * math.log(2))
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _entropy_and_distinct(ys: np.ndarray) -> Tuple[float, int]:
|
|
498
|
+
"""H(Y) and the number of distinct values, from one bincount.
|
|
499
|
+
|
|
500
|
+
Both are needed per column and both come from the same counts, so taking
|
|
501
|
+
them together halves the work and guarantees they agree.
|
|
502
|
+
"""
|
|
503
|
+
n = ys.size
|
|
504
|
+
if n == 0:
|
|
505
|
+
return 0.0, 0
|
|
506
|
+
counts = np.bincount(ys)
|
|
507
|
+
counts = counts[counts > 0]
|
|
508
|
+
return _counts_entropy(counts, n), int(counts.size)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _entropy(ys: np.ndarray) -> float:
|
|
512
|
+
return _entropy_and_distinct(ys)[0]
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def pick_parents(plan, nrows) -> Tuple[Dict[int, Optional[int]], List[int]]:
|
|
516
|
+
dict_pos = [p for p, c in enumerate(plan) if c["kind"] == "dict"]
|
|
517
|
+
if nrows < MIN_ROWS_FOR_PARENTS or len(dict_pos) < 2:
|
|
518
|
+
return {p: None for p in dict_pos}, list(dict_pos)
|
|
519
|
+
|
|
520
|
+
# Parent search is O(columns^2) pairs. A wide table has a lot of them --
|
|
521
|
+
# 209 columns is 38,220 -- so trade sample depth against pair count and
|
|
522
|
+
# keep the total work bounded. Narrow tables are unaffected: they hit the
|
|
523
|
+
# MI_SAMPLE ceiling instead.
|
|
524
|
+
npairs = max(1, len(dict_pos) * (len(dict_pos) - 1))
|
|
525
|
+
rows = min(MI_SAMPLE, max(MI_MIN_SAMPLE, MI_BUDGET // npairs))
|
|
526
|
+
step = max(1, nrows // rows)
|
|
527
|
+
sample = {p: np.ascontiguousarray(plan[p]["ids"][::step]) for p in dict_pos}
|
|
528
|
+
sizes = {p: len(plan[p]["alpha"]) for p in dict_pos}
|
|
529
|
+
base, distinct = {}, {}
|
|
530
|
+
for p in dict_pos:
|
|
531
|
+
base[p], distinct[p] = _entropy_and_distinct(sample[p])
|
|
532
|
+
|
|
533
|
+
gain = defaultdict(dict)
|
|
534
|
+
for a in dict_pos:
|
|
535
|
+
# H(X) and X's distinct count are the same for every b, so they are
|
|
536
|
+
# computed once per a rather than once per pair
|
|
537
|
+
ha, ma = base[a], distinct[a]
|
|
538
|
+
for b in dict_pos:
|
|
539
|
+
if a == b:
|
|
540
|
+
continue
|
|
541
|
+
g = _score(base[b] - _cond_entropy_corrected(
|
|
542
|
+
sample[a], sample[b], sizes[b], ha, ma))
|
|
543
|
+
if g > _MIN_GAIN_SCORE:
|
|
544
|
+
gain[b][a] = g
|
|
545
|
+
|
|
546
|
+
root = min(dict_pos, key=lambda p: _score(base[p]))
|
|
547
|
+
placed, order = {root}, [root]
|
|
548
|
+
parent: Dict[int, Optional[int]] = {root: None}
|
|
549
|
+
|
|
550
|
+
# `remaining` is a list in column order, not a set. Two candidate pairs can
|
|
551
|
+
# have exactly equal gain -- duplicated or near-duplicated columns are
|
|
552
|
+
# common in survey extracts -- and the winner is whichever the loop reaches
|
|
553
|
+
# first, because the comparison is strictly `>`. With a set, "first" is an
|
|
554
|
+
# artefact of CPython's hash table, so the bytes of the archive depended on
|
|
555
|
+
# it. A list makes the tie-break explicit and reproducible: lowest column
|
|
556
|
+
# index wins. That matters for the C port, which has to reproduce this
|
|
557
|
+
# exactly, and it is worth having regardless -- an encoder whose output can
|
|
558
|
+
# shift with an interpreter's internals is not one to build a format on.
|
|
559
|
+
remaining = [p for p in dict_pos if p != root]
|
|
560
|
+
while remaining:
|
|
561
|
+
best = None
|
|
562
|
+
for b in remaining:
|
|
563
|
+
for a, g in gain[b].items():
|
|
564
|
+
if a in placed and (best is None or g > best[2]):
|
|
565
|
+
best = (b, a, g)
|
|
566
|
+
if best is None:
|
|
567
|
+
b = min(remaining, key=lambda p: _score(base[p]))
|
|
568
|
+
parent[b] = None
|
|
569
|
+
else:
|
|
570
|
+
b, a, _ = best
|
|
571
|
+
parent[b] = a
|
|
572
|
+
placed.add(b)
|
|
573
|
+
order.append(b)
|
|
574
|
+
remaining.remove(b)
|
|
575
|
+
|
|
576
|
+
# Never-worse, the same guard text columns already had.
|
|
577
|
+
#
|
|
578
|
+
# This asymmetry was doing real damage. Text parents are chosen by
|
|
579
|
+
# measurement and can decline; dictionary parents were chosen by
|
|
580
|
+
# conditional entropy and taken on trust. So every experiment that moved a
|
|
581
|
+
# column out of `text` -- ragged-decimal numerics, raising the dictionary
|
|
582
|
+
# threshold -- traded a measured decision for an unmeasured one and lost,
|
|
583
|
+
# and the loss landed on a different column than the one being changed,
|
|
584
|
+
# which is why per-column probes never saw it coming.
|
|
585
|
+
#
|
|
586
|
+
# Entropy stays as the nominator; it is good at that and cheap. What is
|
|
587
|
+
# added is the check that the nomination actually pays. A parent is kept
|
|
588
|
+
# only if permuting by it beats leaving the column alone.
|
|
589
|
+
for b in order:
|
|
590
|
+
a = parent.get(b)
|
|
591
|
+
if a is None:
|
|
592
|
+
continue
|
|
593
|
+
ids = plan[b]["ids"]
|
|
594
|
+
perm = np.argsort(plan[a]["ids"], kind="stable")
|
|
595
|
+
w = _width(len(plan[b]["alpha"]))
|
|
596
|
+
if _probe_bytes(ids[perm].astype(w).tobytes()) >= \
|
|
597
|
+
_probe_bytes(ids.astype(w).tobytes()):
|
|
598
|
+
parent[b] = None
|
|
599
|
+
return parent, order
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
# How many entropy-nominated parents actually get compressed and compared.
|
|
603
|
+
# Each candidate costs one cheap pass over the column, and the score is a good
|
|
604
|
+
# enough nominator that the winner is usually in the first few -- but "usually"
|
|
605
|
+
# was doing more work than it should. Measured end to end over 18 tables:
|
|
606
|
+
#
|
|
607
|
+
# candidates 1 3 5 10 all
|
|
608
|
+
# total 8,839,893 8,833,390 8,800,253 8,796,082 8,790,196
|
|
609
|
+
# vs 3 +0.07% 0.00% -0.38% -0.42% -0.49%
|
|
610
|
+
# encode s 9.6 10.4 11.2 12.9 17.4
|
|
611
|
+
#
|
|
612
|
+
# 5 takes the bulk of the available gain for 8% more encode time; going to
|
|
613
|
+
# "all" costs 67% more time for a further 0.11%. The win is concentrated where
|
|
614
|
+
# it is most wanted -- chicago_permits, the worst dataset in the corpus, drops
|
|
615
|
+
# 3.5% at this setting.
|
|
616
|
+
#
|
|
617
|
+
# One caveat worth keeping: widening this is NOT monotonic per file. Each
|
|
618
|
+
# column's choice is probed alone, but every text column ends up in one shared
|
|
619
|
+
# blob that is compressed together, so a locally better ordering can be
|
|
620
|
+
# globally worse -- noaa_gsoy_ord is 6,559 B at 3 and 6,588 B at 10. That is
|
|
621
|
+
# the same local-versus-global trap as the 2D groups, and it is the reason
|
|
622
|
+
# this constant is set by measurement rather than raised to "all".
|
|
623
|
+
TEXT_PARENT_CANDIDATES = 5
|
|
624
|
+
|
|
625
|
+
# A fast stand-in for the real entropy stage, used only to choose between
|
|
626
|
+
# orderings. Preset 1 ranks the candidates the same way preset 9 does at a
|
|
627
|
+
# fraction of the cost, and nothing it produces is ever stored.
|
|
628
|
+
_PROBE = dict(format=lzma.FORMAT_RAW,
|
|
629
|
+
filters=[{"id": lzma.FILTER_LZMA2, "preset": 1}])
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _probe_len(cells: List[str]) -> int:
|
|
633
|
+
return len(lzma.compress("\n".join(cells).encode("utf-8"), **_PROBE))
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _probe_bytes(b: bytes) -> int:
|
|
637
|
+
return len(lzma.compress(b, **_PROBE))
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def pick_text_parents(plan, nrows, parent, order) -> Dict[int, Optional[int]]:
|
|
641
|
+
"""Choose a reorder parent for each text column.
|
|
642
|
+
|
|
643
|
+
Text columns were the one place the reordering idea was never applied, and
|
|
644
|
+
they are exactly where it was most needed: on the datasets this codec does
|
|
645
|
+
worst on, the text blob is 59-71% of the output and receives no modelling
|
|
646
|
+
at all. Measured on real data, sorting a text column by the right
|
|
647
|
+
dictionary column takes 21-45% off it -- for free, by the same argument
|
|
648
|
+
that makes it free for dictionary columns.
|
|
649
|
+
|
|
650
|
+
Text columns are leaves: a text column may HAVE a parent but never BE one.
|
|
651
|
+
That is not a modelling decision, it is a decoding one -- the permutation
|
|
652
|
+
is recomputed from a parent the decoder has already rebuilt, and text
|
|
653
|
+
columns are rebuilt after every dictionary column, so a text parent could
|
|
654
|
+
not be guaranteed available in time. Keeping them leaves also means no
|
|
655
|
+
cycle is possible and the existing decode order still holds.
|
|
656
|
+
|
|
657
|
+
Scoring reuses the dictionary machinery by factorising the text column
|
|
658
|
+
into ids. That is only ever used to score -- the column is still stored as
|
|
659
|
+
text.
|
|
660
|
+
"""
|
|
661
|
+
text_pos = [p for p, c in enumerate(plan) if c["kind"] == "text"]
|
|
662
|
+
out: Dict[int, Optional[int]] = {p: None for p in text_pos}
|
|
663
|
+
dict_pos = [p for p, c in enumerate(plan) if c["kind"] == "dict"]
|
|
664
|
+
if nrows < MIN_ROWS_FOR_PARENTS or not dict_pos or not text_pos:
|
|
665
|
+
return out
|
|
666
|
+
|
|
667
|
+
npairs = max(1, len(text_pos) * len(dict_pos))
|
|
668
|
+
rows = min(MI_SAMPLE, max(MI_MIN_SAMPLE, MI_BUDGET // npairs))
|
|
669
|
+
step = max(1, nrows // rows)
|
|
670
|
+
|
|
671
|
+
dsample = {p: np.ascontiguousarray(plan[p]["ids"][::step]) for p in dict_pos}
|
|
672
|
+
dbase = {}
|
|
673
|
+
for p in dict_pos:
|
|
674
|
+
dbase[p] = _entropy_and_distinct(dsample[p])
|
|
675
|
+
|
|
676
|
+
for tp in text_pos:
|
|
677
|
+
cells = plan[tp]["cells"][::step]
|
|
678
|
+
uniq = sorted(set(cells))
|
|
679
|
+
idx = {v: i for i, v in enumerate(uniq)}
|
|
680
|
+
ids = np.array([idx[v] for v in cells], dtype=np.int64)
|
|
681
|
+
base_t = _entropy(ids)
|
|
682
|
+
ranked = []
|
|
683
|
+
for dp in dict_pos:
|
|
684
|
+
ha, ma = dbase[dp]
|
|
685
|
+
g = _score(base_t - _cond_entropy_corrected(
|
|
686
|
+
dsample[dp], ids, len(uniq), ha, ma))
|
|
687
|
+
if g > _MIN_GAIN_SCORE:
|
|
688
|
+
ranked.append((g, dp))
|
|
689
|
+
if not ranked:
|
|
690
|
+
continue
|
|
691
|
+
ranked.sort(key=lambda r: (-r[0], r[1]))
|
|
692
|
+
|
|
693
|
+
# Conditional entropy is the right criterion for dictionary columns and
|
|
694
|
+
# the wrong one here. It measures how often the parent pins down the
|
|
695
|
+
# exact value; what actually shrinks a text column is having *similar*
|
|
696
|
+
# strings adjacent, which is not the same thing. Worse, reordering
|
|
697
|
+
# destroys whatever useful order the file already had -- a table
|
|
698
|
+
# written in time order often has address locality for free. Trusting
|
|
699
|
+
# the score alone made one real dataset 66% LARGER.
|
|
700
|
+
#
|
|
701
|
+
# So the score is used only to nominate candidates, and the decision is
|
|
702
|
+
# measured. A cheap preset picks between them; the real entropy stage
|
|
703
|
+
# runs later on whichever won. Never-worse is the same rule the
|
|
704
|
+
# fallback container follows, and for the same reason.
|
|
705
|
+
full = plan[tp]["cells"]
|
|
706
|
+
keep_none = _probe_len(full)
|
|
707
|
+
best, best_cost = None, keep_none
|
|
708
|
+
for _g, dp in ranked[:TEXT_PARENT_CANDIDATES]:
|
|
709
|
+
perm = np.argsort(plan[dp]["ids"], kind="stable")
|
|
710
|
+
cost = _probe_len([full[i] for i in perm])
|
|
711
|
+
if cost < best_cost:
|
|
712
|
+
best, best_cost = dp, cost
|
|
713
|
+
out[tp] = best
|
|
714
|
+
return out
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _emit_exceptions(col, spec, bins, sgroups) -> None:
|
|
718
|
+
"""Store the cells a numeric column could not represent.
|
|
719
|
+
|
|
720
|
+
Positions are delta-coded before packing -- exceptions are sorted and
|
|
721
|
+
usually sparse, so the gaps are far smaller than the indices. The strings
|
|
722
|
+
join the text blob, where a run of empty cells or of "-0.0" costs
|
|
723
|
+
essentially nothing.
|
|
724
|
+
|
|
725
|
+
Emitted immediately after the column's own payload, in both the binary and
|
|
726
|
+
the string stream, so the decoder recovers them at the same point in its
|
|
727
|
+
own walk without needing an index.
|
|
728
|
+
"""
|
|
729
|
+
ex = col.get("ex")
|
|
730
|
+
if not ex:
|
|
731
|
+
return
|
|
732
|
+
expos, exvals = ex
|
|
733
|
+
spec["nex"] = int(expos.size)
|
|
734
|
+
bins.append(pack_ints(np.diff(expos, prepend=np.int64(0))))
|
|
735
|
+
sgroups.append(exvals)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _apply_exceptions(cells, sp, cuts, texts, bi, ti):
|
|
739
|
+
"""Inverse of _emit_exceptions. Returns (cells, bi, ti)."""
|
|
740
|
+
nex = sp.get("nex", 0)
|
|
741
|
+
if not nex:
|
|
742
|
+
return cells, bi, ti
|
|
743
|
+
expos = np.cumsum(unpack_ints(cuts[bi], nex))
|
|
744
|
+
bi += 1
|
|
745
|
+
exvals = texts[ti]
|
|
746
|
+
ti += 1
|
|
747
|
+
for i, p in enumerate(expos.tolist()):
|
|
748
|
+
cells[p] = exvals[i]
|
|
749
|
+
return cells, bi, ti
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _width(n: int) -> str:
|
|
753
|
+
return "<u1" if n <= 256 else ("<u2" if n <= 65536 else "<u4")
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
# Strings are stored as one concatenated UTF-8 blob plus an array of byte
|
|
757
|
+
# lengths. Joining on a separator -- "\n", "\x00", anything -- is wrong,
|
|
758
|
+
# because a cell is allowed to contain that byte. This costs nothing: the
|
|
759
|
+
# lengths compress to almost nothing and the blob compresses exactly as well
|
|
760
|
+
# as the joined form did.
|
|
761
|
+
|
|
762
|
+
def _front_code(words: List[str]) -> bytes:
|
|
763
|
+
"""Sorted words as (shared prefix length, remainder).
|
|
764
|
+
|
|
765
|
+
A dictionary column's alphabet is stored sorted, so neighbours share long
|
|
766
|
+
prefixes -- "CHICAGO AVE", "CHICAGO BLVD". Front-coding replaces each word
|
|
767
|
+
with how much it shares with the previous one plus the rest.
|
|
768
|
+
|
|
769
|
+
Worth being sceptical of, because RLE over the same blob was tried here and
|
|
770
|
+
bought +0.3%: xz already finds adjacent repeats. This is a different shape
|
|
771
|
+
of redundancy, and measured end to end it is worth -1.19% overall and
|
|
772
|
+
-7.39% on seattle_fire911 -- but it LOSES on four of thirteen datasets, so
|
|
773
|
+
it is chosen by measurement rather than applied on principle.
|
|
774
|
+
|
|
775
|
+
The prefix length is one byte, capped at 255. Only ever applied to groups
|
|
776
|
+
with no embedded newline, so no remainder can contain one either and the
|
|
777
|
+
newline join stays unambiguous.
|
|
778
|
+
|
|
779
|
+
The shared prefix is counted in BYTES, not characters. That is not a
|
|
780
|
+
detail: the C port compares bytes, and if this counted characters the two
|
|
781
|
+
would produce different archives for any word with a non-ASCII prefix.
|
|
782
|
+
Cutting a multi-byte character in half is harmless here because the halves
|
|
783
|
+
are only ever concatenated back together before decoding.
|
|
784
|
+
"""
|
|
785
|
+
lens = bytearray()
|
|
786
|
+
rest = []
|
|
787
|
+
prev = b""
|
|
788
|
+
for w in words:
|
|
789
|
+
b = w.encode("utf-8")
|
|
790
|
+
n = 0
|
|
791
|
+
m = min(len(prev), len(b), 255)
|
|
792
|
+
while n < m and prev[n] == b[n]:
|
|
793
|
+
n += 1
|
|
794
|
+
lens.append(n)
|
|
795
|
+
rest.append(b[n:])
|
|
796
|
+
prev = b
|
|
797
|
+
return bytes(lens) + b"\n".join(rest)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _un_front_code(chunk: bytes, n: int) -> List[str]:
|
|
801
|
+
lens = chunk[:n]
|
|
802
|
+
rest = chunk[n:].split(b"\n")
|
|
803
|
+
out: List[str] = []
|
|
804
|
+
prev = b""
|
|
805
|
+
for i in range(n):
|
|
806
|
+
w = prev[:lens[i]] + rest[i]
|
|
807
|
+
out.append(w.decode("utf-8"))
|
|
808
|
+
prev = w
|
|
809
|
+
return out
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
# How many groups get a real end-to-end trial. Each one costs a compression of
|
|
813
|
+
# the whole text pile, so this is what bounds the price -- and the price is the
|
|
814
|
+
# whole argument for the value being 1. Measured across 21 tables:
|
|
815
|
+
#
|
|
816
|
+
# K encode total bytes vs K=0
|
|
817
|
+
# 0 28.5s 9,646,428 +0 (1.00x)
|
|
818
|
+
# 1 32.9s 9,633,980 -12,448 (1.16x)
|
|
819
|
+
# 2 40.0s 9,633,419 -13,009 (1.41x)
|
|
820
|
+
# 3 43.1s 9,633,419 -13,009 (1.52x)
|
|
821
|
+
#
|
|
822
|
+
# One trial takes 96% of everything available for a sixth of the time penalty.
|
|
823
|
+
# The stake-ranked first candidate is the right one nearly every time, which is
|
|
824
|
+
# what a good nominator is for.
|
|
825
|
+
TEXT_FC_CANDIDATES = 1
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
# Below this estimated saving a candidate is not worth a trial, and below this
|
|
829
|
+
# share of its own bytes it is not worth one either. Both are floors on the
|
|
830
|
+
# NOMINATION only -- every survivor is still decided by measuring the whole
|
|
831
|
+
# pile. Their job is to stop the trials being spent on nothing, which is what
|
|
832
|
+
# takes encode time from 21s to 57s across the test set.
|
|
833
|
+
FC_MIN_BYTES = 1024
|
|
834
|
+
FC_MIN_NUM, FC_MIN_DEN = 1, 4 # shared/total must exceed 1/4
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _prefix_stats(words: List[str], cap: int = 4000) -> Tuple[int, int]:
|
|
838
|
+
"""(bytes shared with the previous word, total bytes), both integers.
|
|
839
|
+
|
|
840
|
+
This measures the one property that makes front-coding pay -- how much
|
|
841
|
+
consecutive values have in common. It is deliberately NOT a compression
|
|
842
|
+
probe: a probe run on the group alone was measured +2.50% worse end to end,
|
|
843
|
+
because it cannot see what happens once the group is concatenated with
|
|
844
|
+
everything else.
|
|
845
|
+
|
|
846
|
+
Returned as two integers rather than a ratio so the C port can rank
|
|
847
|
+
candidates by cross-multiplying and never has to agree about a float.
|
|
848
|
+
"""
|
|
849
|
+
n = min(len(words), cap)
|
|
850
|
+
if n < 2:
|
|
851
|
+
return 0, 0
|
|
852
|
+
shared = 0
|
|
853
|
+
prev = words[0].encode("utf-8")
|
|
854
|
+
total = len(prev)
|
|
855
|
+
for i in range(1, n):
|
|
856
|
+
b = words[i].encode("utf-8")
|
|
857
|
+
m = min(len(prev), len(b), 255)
|
|
858
|
+
k = 0
|
|
859
|
+
while k < m and prev[k] == b[k]:
|
|
860
|
+
k += 1
|
|
861
|
+
shared += k
|
|
862
|
+
total += len(b)
|
|
863
|
+
prev = b
|
|
864
|
+
return shared, total
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _choose_front(groups: List[List[str]], ndict: int) -> frozenset:
|
|
868
|
+
"""Which string groups to front-code, decided on the WHOLE text pile.
|
|
869
|
+
|
|
870
|
+
Three designs that decided per group were measured and all lost: a cheap
|
|
871
|
+
per-group compression probe (+2.50%) and a prefix-ratio threshold at 0.3,
|
|
872
|
+
0.5 and 0.7 (+3.29%, +2.45%, +0.57%). The reason is worth remembering --
|
|
873
|
+
a long shared prefix IS a long LZ77 match, and xz already codes it
|
|
874
|
+
cheaply, so front-coding can remove the very anchor xz was matching on.
|
|
875
|
+
High prefix sharing is necessary and nowhere near sufficient.
|
|
876
|
+
|
|
877
|
+
So the ratio only NOMINATES, capped at TEXT_FC_CANDIDATES, and every
|
|
878
|
+
decision is a measurement of the entire pile. Measured that way: -0.39%
|
|
879
|
+
overall and never worse on any table, with weather_hourly's time column
|
|
880
|
+
going 9,438 -> 434 bytes and treasury_yields' date column -19.5%.
|
|
881
|
+
"""
|
|
882
|
+
def build(front):
|
|
883
|
+
packed = _pack_strings(groups, front)
|
|
884
|
+
return packed, lzma.compress(packed[0], **XZ)
|
|
885
|
+
|
|
886
|
+
best = frozenset()
|
|
887
|
+
best_packed, best_zb = build(best)
|
|
888
|
+
best_z = len(best_zb)
|
|
889
|
+
|
|
890
|
+
# the alphabets as one block, which is what shipped before this
|
|
891
|
+
if ndict:
|
|
892
|
+
alpha = frozenset(range(ndict))
|
|
893
|
+
packed, zb = build(alpha)
|
|
894
|
+
if len(zb) < best_z:
|
|
895
|
+
best, best_packed, best_zb, best_z = alpha, packed, zb, len(zb)
|
|
896
|
+
|
|
897
|
+
# Then the most promising remaining groups, one at a time, each confirmed.
|
|
898
|
+
#
|
|
899
|
+
# Ranked by ESTIMATED BYTES AT STAKE, not by ratio. Ranking by ratio was
|
|
900
|
+
# wrong in a way worth recording: a numeric column's exception group is a
|
|
901
|
+
# run of identical values -- 47 copies of "-0.0" -- which shares 98% of its
|
|
902
|
+
# bytes and so outranked everything, and with three trial slots those tiny
|
|
903
|
+
# groups crowded out the real prize. weather_hourly's timestamp column has
|
|
904
|
+
# a ratio of only 0.74 but about a megabyte at stake against that group's
|
|
905
|
+
# 184 bytes, and it was being skipped entirely.
|
|
906
|
+
cands = []
|
|
907
|
+
for i in range(ndict, len(groups)):
|
|
908
|
+
g = groups[i]
|
|
909
|
+
if not g or any("\n" in s for s in g):
|
|
910
|
+
continue
|
|
911
|
+
sh, tot = _prefix_stats(g)
|
|
912
|
+
if sh <= 0 or tot <= 0:
|
|
913
|
+
continue
|
|
914
|
+
if sh * FC_MIN_DEN <= tot * FC_MIN_NUM:
|
|
915
|
+
continue
|
|
916
|
+
sampled = min(len(g), 4000)
|
|
917
|
+
stake = sh * len(g) // sampled
|
|
918
|
+
if stake < FC_MIN_BYTES:
|
|
919
|
+
continue
|
|
920
|
+
cands.append((stake, i))
|
|
921
|
+
# descending stake, ties by group index so both implementations agree
|
|
922
|
+
cands.sort(key=lambda c: (-c[0], c[1]))
|
|
923
|
+
for _stake, i in cands[:TEXT_FC_CANDIDATES]:
|
|
924
|
+
trial = frozenset(best | {i})
|
|
925
|
+
packed, zb = build(trial)
|
|
926
|
+
if len(zb) < best_z:
|
|
927
|
+
best, best_packed, best_zb, best_z = trial, packed, zb, len(zb)
|
|
928
|
+
# the winner's pile and its compressed bytes are returned so the caller
|
|
929
|
+
# never compresses the same pile a second time
|
|
930
|
+
return best, best_packed, best_zb
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _pack_strings(groups: List[List[str]], front=frozenset()):
|
|
934
|
+
"""Concatenate string groups, paying for length prefixes only where a
|
|
935
|
+
value actually contains a newline.
|
|
936
|
+
|
|
937
|
+
Newline-joining is what xz likes -- the separator sits in the same stream
|
|
938
|
+
as the content and models well. But a cell may legally contain a newline,
|
|
939
|
+
which silently split one value into two. So each group records whether it
|
|
940
|
+
is newline-joined; the rare group that is not carries an explicit length
|
|
941
|
+
array. On real tables no group needs one, and the joined layout is kept."""
|
|
942
|
+
parts, metas, length_arrays = [], [], []
|
|
943
|
+
for i, g in enumerate(groups):
|
|
944
|
+
if any("\n" in s for s in g):
|
|
945
|
+
blobs = [s.encode("utf-8") for s in g]
|
|
946
|
+
data = b"".join(blobs)
|
|
947
|
+
length_arrays.append(np.fromiter((len(b) for b in blobs),
|
|
948
|
+
dtype=np.int64, count=len(blobs)))
|
|
949
|
+
metas.append({"n": len(g), "b": len(data), "nl": False})
|
|
950
|
+
elif i in front:
|
|
951
|
+
data = _front_code(g)
|
|
952
|
+
metas.append({"n": len(g), "b": len(data), "nl": True, "fc": 1})
|
|
953
|
+
else:
|
|
954
|
+
data = "\n".join(g).encode("utf-8")
|
|
955
|
+
metas.append({"n": len(g), "b": len(data), "nl": True})
|
|
956
|
+
parts.append(data)
|
|
957
|
+
return b"".join(parts), metas, length_arrays
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _unpack_strings(data: bytes, metas, length_bins,
|
|
961
|
+
legacy_nfront: int = 0) -> List[List[str]]:
|
|
962
|
+
"""`legacy_nfront` covers archives written with the archive-level "fc"
|
|
963
|
+
flag, which meant "the first N groups are front-coded". Those are only
|
|
964
|
+
hours old but they exist on disk, and reading one under the new per-group
|
|
965
|
+
rule would return wrong strings rather than an error -- the worst outcome
|
|
966
|
+
for a decoder. Both spellings are honoured."""
|
|
967
|
+
out, at, li = [], 0, 0
|
|
968
|
+
for i, m in enumerate(metas):
|
|
969
|
+
chunk = data[at:at + m["b"]]
|
|
970
|
+
at += m["b"]
|
|
971
|
+
if m["n"] == 0:
|
|
972
|
+
out.append([])
|
|
973
|
+
continue
|
|
974
|
+
if m["nl"]:
|
|
975
|
+
if m.get("fc") or i < legacy_nfront:
|
|
976
|
+
out.append(_un_front_code(chunk, m["n"]))
|
|
977
|
+
else:
|
|
978
|
+
out.append(chunk.decode("utf-8").split("\n"))
|
|
979
|
+
else:
|
|
980
|
+
lengths = unpack_ints(length_bins[li], m["n"])
|
|
981
|
+
li += 1
|
|
982
|
+
ends = np.cumsum(lengths)
|
|
983
|
+
starts = ends - lengths
|
|
984
|
+
out.append([chunk[int(starts[i]):int(ends[i])].decode("utf-8")
|
|
985
|
+
for i in range(m["n"])])
|
|
986
|
+
return out
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
# --------------------------------------------------------------- fallbacks
|
|
990
|
+
|
|
991
|
+
def _canonical_bytes(table) -> bytes:
|
|
992
|
+
"""The table as canonical CSV -- the form a general compressor would see.
|
|
993
|
+
|
|
994
|
+
Deliberately the same shape as the input file rather than some private
|
|
995
|
+
layout, because the fallback only earns its place if it matches what
|
|
996
|
+
`xz file.csv` would have produced. A private layout that compresses worse
|
|
997
|
+
than the user's own CSV would be a fallback that does not fall back.
|
|
998
|
+
"""
|
|
999
|
+
import csv
|
|
1000
|
+
import io
|
|
1001
|
+
buf = io.StringIO()
|
|
1002
|
+
w = csv.writer(buf, lineterminator="\n")
|
|
1003
|
+
w.writerow(table.columns)
|
|
1004
|
+
w.writerows(table.rows)
|
|
1005
|
+
return buf.getvalue().encode("utf-8")
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _from_canonical(data: bytes):
|
|
1009
|
+
import csv
|
|
1010
|
+
import io
|
|
1011
|
+
rows = list(csv.reader(io.StringIO(data.decode("utf-8"), newline="")))
|
|
1012
|
+
if not rows:
|
|
1013
|
+
return dtz.Table([], [])
|
|
1014
|
+
return dtz.Table(rows[0], rows[1:])
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
# Input block size for the capped compressors below. Only affects how often
|
|
1018
|
+
# the output is checked against the cap, never the bytes produced: LZMA2 and
|
|
1019
|
+
# bzip2 are streams, and feeding one in pieces gives the identical output to a
|
|
1020
|
+
# single call. Verified for both.
|
|
1021
|
+
_CAP_CHUNK = 1 << 20
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def _compress_capped(data: bytes, cap: int, kind: str) -> Optional[bytes]:
|
|
1025
|
+
"""`data` compressed, or None as soon as the result cannot fit in `cap`.
|
|
1026
|
+
|
|
1027
|
+
The point is to make "always check the fallback" affordable. Compressed
|
|
1028
|
+
output only grows, so once it has passed the cap the candidate has lost and
|
|
1029
|
+
the rest of the input is wasted work. On a table the modelling wins
|
|
1030
|
+
handsomely -- which is most of them -- that abort happens early, because
|
|
1031
|
+
the modelled archive is a small fraction of what the plain CSV compresses
|
|
1032
|
+
to.
|
|
1033
|
+
|
|
1034
|
+
Aborting cannot change the answer, only the time taken: the bytes returned
|
|
1035
|
+
when it does NOT abort are exactly `lzma.compress`/`bz2.compress` would
|
|
1036
|
+
have produced.
|
|
1037
|
+
"""
|
|
1038
|
+
comp = (lzma.LZMACompressor(**XZ) if kind == "xz"
|
|
1039
|
+
else bz2.BZ2Compressor(9))
|
|
1040
|
+
out, total = [], 0
|
|
1041
|
+
for i in range(0, len(data), _CAP_CHUNK):
|
|
1042
|
+
piece = comp.compress(data[i:i + _CAP_CHUNK])
|
|
1043
|
+
total += len(piece)
|
|
1044
|
+
if total >= cap:
|
|
1045
|
+
return None
|
|
1046
|
+
out.append(piece)
|
|
1047
|
+
piece = comp.flush()
|
|
1048
|
+
total += len(piece)
|
|
1049
|
+
if total >= cap:
|
|
1050
|
+
return None
|
|
1051
|
+
out.append(piece)
|
|
1052
|
+
return b"".join(out)
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _raw_candidates(table, limit: int) -> Optional[bytes]:
|
|
1056
|
+
"""Smallest standard-codec encoding of the whole table, or None.
|
|
1057
|
+
|
|
1058
|
+
Returns None unless it beats `limit`, and also unless it round-trips --
|
|
1059
|
+
CSV quoting is not lossless for every conceivable cell, so the candidate
|
|
1060
|
+
is parsed back and compared before it is allowed to win. A fallback that
|
|
1061
|
+
corrupts data is worse than losing by 11%.
|
|
1062
|
+
|
|
1063
|
+
Called on every table now, not only when no trick fired. The compressors
|
|
1064
|
+
are capped at `limit` so a hopeless candidate is abandoned part-way rather
|
|
1065
|
+
than finished and then thrown away.
|
|
1066
|
+
"""
|
|
1067
|
+
canon = _canonical_bytes(table)
|
|
1068
|
+
try:
|
|
1069
|
+
# Parsed ONCE. This used to call _from_canonical twice, once for the
|
|
1070
|
+
# rows and once for the columns, which was affordable while the
|
|
1071
|
+
# fallback ran only on tables where no trick fired and is not now that
|
|
1072
|
+
# it runs on every table.
|
|
1073
|
+
back = _from_canonical(canon)
|
|
1074
|
+
if back.rows != table.rows or back.columns != table.columns:
|
|
1075
|
+
return None
|
|
1076
|
+
except Exception:
|
|
1077
|
+
return None
|
|
1078
|
+
|
|
1079
|
+
best = None
|
|
1080
|
+
for magic, kind in ((MAGIC_RAW_XZ, "xz"), (MAGIC_RAW_BZ, "bz2")):
|
|
1081
|
+
# 4 bytes of magic ride in front, so the compressor's own budget is
|
|
1082
|
+
# that much smaller than the archive it has to beat.
|
|
1083
|
+
cap = (len(best) if best is not None else limit) - len(magic)
|
|
1084
|
+
if cap <= 0:
|
|
1085
|
+
continue
|
|
1086
|
+
blob = _compress_capped(canon, cap, kind)
|
|
1087
|
+
if blob is not None:
|
|
1088
|
+
best = magic + blob
|
|
1089
|
+
return best
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
# ------------------------------------------------------------------- codec
|
|
1093
|
+
|
|
1094
|
+
def encode(table) -> bytes:
|
|
1095
|
+
"""Smallest of the modelled encoding and the plain fallbacks.
|
|
1096
|
+
|
|
1097
|
+
The fallbacks are not run unconditionally -- they roughly double encode
|
|
1098
|
+
time, since the entropy stage dominates. They are run only when none of
|
|
1099
|
+
the three modelling tricks fired, which is precisely the case where this
|
|
1100
|
+
codec has degenerated into "split into columns, then xz" and a different
|
|
1101
|
+
finisher may well beat it. When any trick fired, the modelled output wins
|
|
1102
|
+
by a margin no general compressor closes, and the extra work is skipped.
|
|
1103
|
+
"""
|
|
1104
|
+
# Numeric-with-exceptions, measured. Recovering a column that is numeric
|
|
1105
|
+
# apart from a few cells is worth a great deal where it applies -- 41% of
|
|
1106
|
+
# the Treasury yield curve -- but it also moves a column out of the
|
|
1107
|
+
# dictionary path, and that is precisely the trade that made the reverted
|
|
1108
|
+
# ragged-decimal experiment 9-23% worse. So the old behaviour is encoded
|
|
1109
|
+
# too and kept if it is smaller: never-worse per file, not on average.
|
|
1110
|
+
#
|
|
1111
|
+
# The guard costs a whole second encode, so it is nominated first. The
|
|
1112
|
+
# plan is built once and reused, and the second encode only happens when
|
|
1113
|
+
# some column's numbers actually look cheaper than leaving it alone. That
|
|
1114
|
+
# screen is one-sided -- it over-estimates the alternative -- so it can
|
|
1115
|
+
# only decline cases that could not have won. Without it, four large
|
|
1116
|
+
# datasets encoded twice and kept the first result every time.
|
|
1117
|
+
plan = classify(table)
|
|
1118
|
+
any_lax = any(c.get("ex") for c in plan)
|
|
1119
|
+
promising = any(c.get("exp") for c in plan)
|
|
1120
|
+
|
|
1121
|
+
lenient = True
|
|
1122
|
+
if not any_lax:
|
|
1123
|
+
# the two plans are identical, so there is nothing to choose between
|
|
1124
|
+
blob, fired, ngroups, _ = _encode_plan(table, plan=plan)
|
|
1125
|
+
elif not promising:
|
|
1126
|
+
del plan
|
|
1127
|
+
lenient = False
|
|
1128
|
+
blob, fired, ngroups, _ = _encode_plan(table, use_lenient=False)
|
|
1129
|
+
else:
|
|
1130
|
+
blob, fired, ngroups, _ = _encode_plan(table, plan=plan)
|
|
1131
|
+
del plan
|
|
1132
|
+
alt, alt_fired, alt_groups, _ = _encode_plan(table, use_lenient=False)
|
|
1133
|
+
if len(alt) < len(blob):
|
|
1134
|
+
blob, fired, ngroups, lenient = alt, alt_fired, alt_groups, False
|
|
1135
|
+
|
|
1136
|
+
# The planar predictor was the last decision in this codec taken on trust,
|
|
1137
|
+
# and measuring it showed the trust was misplaced: of the three tables in
|
|
1138
|
+
# the corpus where a group forms at all, two came out LARGER for it --
|
|
1139
|
+
# nyc_collisions by 2,377 B and wide_random by 15,733 B -- against one real
|
|
1140
|
+
# win of 5.98% on random_floats. Grouped columns lose their own measured
|
|
1141
|
+
# differencing order and are forced into one fixed scheme, so a group
|
|
1142
|
+
# trades several measured decisions for a single unmeasured one, which is
|
|
1143
|
+
# exactly the asymmetry commit 20dd96e removed from the parent search.
|
|
1144
|
+
#
|
|
1145
|
+
# The guard has to be END TO END, not per group. Two cheaper checks were
|
|
1146
|
+
# tried and both give the wrong answer: raw packed length shows -0.0% on
|
|
1147
|
+
# nyc_collisions where the real effect is +28.1%, and a per-group probe
|
|
1148
|
+
# says wide_random gains 1.4% where the file actually loses 0.69% -- the
|
|
1149
|
+
# group's bytes are concatenated with every other payload and compressed
|
|
1150
|
+
# together, so nothing short of the whole container can see the result.
|
|
1151
|
+
#
|
|
1152
|
+
# Encoding twice is affordable because groups are rare: they formed on 2 of
|
|
1153
|
+
# 18 tables here and 3 of 24 in the full sweep. Tables without a group pay
|
|
1154
|
+
# nothing at all.
|
|
1155
|
+
if ngroups:
|
|
1156
|
+
alt, alt_fired, _, _ = _encode_plan(table, use_2d=False,
|
|
1157
|
+
use_lenient=lenient)
|
|
1158
|
+
if len(alt) < len(blob):
|
|
1159
|
+
blob, fired = alt, alt_fired
|
|
1160
|
+
|
|
1161
|
+
# The fallbacks are ALWAYS considered. They used to be skipped whenever any
|
|
1162
|
+
# trick fired, on the theory that a table where one fired cannot lose to
|
|
1163
|
+
# plain xz. That theory was measured against 18 tables and held; measured
|
|
1164
|
+
# against 100 unselected ones it fails on 3, once by 28.1% -- the shipped
|
|
1165
|
+
# archive was 2,830,752 bytes where the xz fallback nobody ran was
|
|
1166
|
+
# 2,210,086. Invariant 2 says "never worse, MEASURED, not assumed", and a
|
|
1167
|
+
# gate that decides which encodings are even built is exactly an assumption.
|
|
1168
|
+
#
|
|
1169
|
+
# It is affordable because `_raw_candidates` stops compressing the moment
|
|
1170
|
+
# the candidate cannot win; see there.
|
|
1171
|
+
alt = _raw_candidates(table, len(blob))
|
|
1172
|
+
return alt if alt is not None else blob
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def _encode_plan(table, use_2d: bool = True, use_lenient: bool = True,
|
|
1176
|
+
plan=None) -> Tuple[bytes, int, int, int]:
|
|
1177
|
+
# `plan` lets the caller hand in a classification it has already paid for.
|
|
1178
|
+
# Nothing here writes to it, so it is safe to share between encodes that
|
|
1179
|
+
# agree about `use_lenient`.
|
|
1180
|
+
if plan is None:
|
|
1181
|
+
plan = classify(table, lenient=use_lenient)
|
|
1182
|
+
nrows = len(table.rows)
|
|
1183
|
+
groups = find_2d_groups(plan, nrows) if use_2d else []
|
|
1184
|
+
in_group = {pos: gi for gi, g in enumerate(groups) for pos in g}
|
|
1185
|
+
parent, order = pick_parents(plan, nrows)
|
|
1186
|
+
|
|
1187
|
+
bins: List[bytes] = [] # binary payloads
|
|
1188
|
+
sgroups: List[List[str]] = [] # string payloads, length-prefixed
|
|
1189
|
+
specs: List[Optional[dict]] = [None] * len(plan)
|
|
1190
|
+
|
|
1191
|
+
for pos in order:
|
|
1192
|
+
col = plan[pos]
|
|
1193
|
+
ids = col["ids"]
|
|
1194
|
+
par = parent.get(pos)
|
|
1195
|
+
if par is not None:
|
|
1196
|
+
perm = np.argsort(plan[par]["ids"], kind="stable")
|
|
1197
|
+
ids = ids[perm]
|
|
1198
|
+
w = _width(len(col["alpha"]))
|
|
1199
|
+
bins.append(ids.astype(w).tobytes())
|
|
1200
|
+
sgroups.append(col["alpha"])
|
|
1201
|
+
specs[pos] = {"kind": "dict", "n": len(col["alpha"]), "w": w,
|
|
1202
|
+
"parent": par}
|
|
1203
|
+
|
|
1204
|
+
tparent = pick_text_parents(plan, nrows, parent, order)
|
|
1205
|
+
|
|
1206
|
+
for pos, col in enumerate(plan):
|
|
1207
|
+
if col["kind"] == "text":
|
|
1208
|
+
tp = tparent.get(pos)
|
|
1209
|
+
cells = col["cells"]
|
|
1210
|
+
if tp is not None:
|
|
1211
|
+
# exactly the dictionary-column trick, and free for exactly
|
|
1212
|
+
# the same reason: the decoder has already rebuilt the parent
|
|
1213
|
+
# and recomputes the same stable argsort
|
|
1214
|
+
perm = np.argsort(plan[tp]["ids"], kind="stable")
|
|
1215
|
+
cells = [cells[i] for i in perm]
|
|
1216
|
+
sgroups.append(cells)
|
|
1217
|
+
specs[pos] = {"kind": "text"} if tp is None else \
|
|
1218
|
+
{"kind": "text", "parent": tp}
|
|
1219
|
+
elif col["kind"] == "num" and pos in in_group:
|
|
1220
|
+
specs[pos] = {"kind": "grp", "dec": col["dec"], "g": in_group[pos]}
|
|
1221
|
+
_emit_exceptions(col, specs[pos], bins, sgroups)
|
|
1222
|
+
elif col["kind"] == "num":
|
|
1223
|
+
a = col["ints"]
|
|
1224
|
+
k = diff_order(a)
|
|
1225
|
+
bins.append(pack_ints(np.diff(a, n=k) if k else a))
|
|
1226
|
+
specs[pos] = {"kind": "num", "dec": col["dec"], "k": k,
|
|
1227
|
+
"warm": a[:k].tolist()}
|
|
1228
|
+
_emit_exceptions(col, specs[pos], bins, sgroups)
|
|
1229
|
+
|
|
1230
|
+
for g in groups:
|
|
1231
|
+
M = np.stack([plan[pos]["ints"] for pos in g], axis=1) # rows x cols
|
|
1232
|
+
D = np.diff(np.diff(M, axis=0), axis=1)
|
|
1233
|
+
side = np.concatenate([M[0], np.diff(M, axis=0)[:, 0]])
|
|
1234
|
+
bins.append(pack_ints(np.concatenate([side, D.ravel()])))
|
|
1235
|
+
|
|
1236
|
+
# Front-coding the dictionary alphabets, measured. The alphabets are the
|
|
1237
|
+
# first len(order) string groups by construction, and they are sorted, so
|
|
1238
|
+
# neighbours share long prefixes. Worth -1.19% overall and -7.39% on
|
|
1239
|
+
# seattle_fire911 -- but it LOSES on four of thirteen datasets, so the
|
|
1240
|
+
# blob is built both ways and the smaller kept.
|
|
1241
|
+
#
|
|
1242
|
+
# This guard is cheap in a way the others are not: it re-compresses only
|
|
1243
|
+
# the text pile, not the whole container. Compare the exceptions guard,
|
|
1244
|
+
# which costs a second full encode.
|
|
1245
|
+
front, (txt_data, smeta, length_arrays), txt_b = \
|
|
1246
|
+
_choose_front(sgroups, len(order))
|
|
1247
|
+
# "front == exactly the alphabets" is the common case, and the older
|
|
1248
|
+
# archive-level flag spells it in 7 bytes instead of 7 per group. Same
|
|
1249
|
+
# information, smaller metadata, same payload; the decoder reads both.
|
|
1250
|
+
compact = bool(order) and front == frozenset(range(len(order)))
|
|
1251
|
+
if compact:
|
|
1252
|
+
for m in smeta:
|
|
1253
|
+
m.pop("fc", None)
|
|
1254
|
+
|
|
1255
|
+
n_before = len(bins)
|
|
1256
|
+
bins.extend(pack_ints(a) for a in length_arrays) # always last
|
|
1257
|
+
meta = {"columns": table.columns, "nrows": nrows, "cols": specs,
|
|
1258
|
+
"groups": groups, "order": order,
|
|
1259
|
+
"bins": [len(b) for b in bins],
|
|
1260
|
+
"smeta": smeta, "nlenbins": len(bins) - n_before}
|
|
1261
|
+
if compact:
|
|
1262
|
+
meta["fc"] = 1
|
|
1263
|
+
meta_b = lzma.compress(json.dumps(meta, separators=(",", ":")).encode(),
|
|
1264
|
+
**XZ)
|
|
1265
|
+
bin_b = lzma.compress(b"".join(bins), **XZ)
|
|
1266
|
+
blob = (MAGIC + len(meta_b).to_bytes(4, "big")
|
|
1267
|
+
+ len(bin_b).to_bytes(4, "big") + len(txt_b).to_bytes(4, "big")
|
|
1268
|
+
+ meta_b + bin_b + txt_b)
|
|
1269
|
+
|
|
1270
|
+
# Did any of the three ideas actually do something? A parent-sorted
|
|
1271
|
+
# column, a column whose differences packed smaller than its values, or a
|
|
1272
|
+
# 2D group. If none did, this run was just "columns, then xz".
|
|
1273
|
+
fired = (sum(1 for s in specs if s and s["kind"] == "dict"
|
|
1274
|
+
and s["parent"] is not None)
|
|
1275
|
+
+ sum(1 for s in specs if s and s["kind"] == "num" and s["k"])
|
|
1276
|
+
+ len(groups))
|
|
1277
|
+
nlax = sum(1 for c in plan if c.get("ex"))
|
|
1278
|
+
return blob, fired, len(groups), nlax
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def decode(blob: bytes):
|
|
1282
|
+
if blob[:4] == MAGIC_RAW_XZ:
|
|
1283
|
+
return _from_canonical(lzma.decompress(blob[4:], **XZ))
|
|
1284
|
+
if blob[:4] == MAGIC_RAW_BZ:
|
|
1285
|
+
return _from_canonical(bz2.decompress(blob[4:]))
|
|
1286
|
+
if blob[:4] not in (MAGIC, MAGIC_V0):
|
|
1287
|
+
raise ValueError("not a Polypress archive")
|
|
1288
|
+
ml = int.from_bytes(blob[4:8], "big")
|
|
1289
|
+
bl = int.from_bytes(blob[8:12], "big")
|
|
1290
|
+
o = 16
|
|
1291
|
+
meta = json.loads(lzma.decompress(blob[o:o + ml], **XZ))
|
|
1292
|
+
o += ml
|
|
1293
|
+
raw = lzma.decompress(blob[o:o + bl], **XZ)
|
|
1294
|
+
o += bl
|
|
1295
|
+
txt_data = lzma.decompress(blob[o:], **XZ)
|
|
1296
|
+
|
|
1297
|
+
nrows, specs = meta["nrows"], meta["cols"]
|
|
1298
|
+
cuts, at = [], 0
|
|
1299
|
+
for size in meta["bins"]:
|
|
1300
|
+
cuts.append(raw[at:at + size])
|
|
1301
|
+
at += size
|
|
1302
|
+
|
|
1303
|
+
nlen = meta["nlenbins"]
|
|
1304
|
+
length_bins = cuts[len(cuts) - nlen:] if nlen else []
|
|
1305
|
+
# Which groups are front-coded is recorded per group in smeta. The
|
|
1306
|
+
# archive-level "fc" is the older spelling, kept readable.
|
|
1307
|
+
legacy = len(meta["order"]) if meta.get("fc") else 0
|
|
1308
|
+
texts = _unpack_strings(txt_data, meta["smeta"], length_bins, legacy)
|
|
1309
|
+
|
|
1310
|
+
cols: List[Optional[List[str]]] = [None] * len(specs)
|
|
1311
|
+
ids_by_pos: Dict[int, np.ndarray] = {}
|
|
1312
|
+
bi = ti = 0
|
|
1313
|
+
|
|
1314
|
+
for pos in meta["order"]:
|
|
1315
|
+
sp = specs[pos]
|
|
1316
|
+
alpha = texts[ti]
|
|
1317
|
+
ti += 1
|
|
1318
|
+
ids = np.frombuffer(cuts[bi], dtype=sp["w"]).astype(np.int64)
|
|
1319
|
+
bi += 1
|
|
1320
|
+
par = sp["parent"]
|
|
1321
|
+
if par is not None:
|
|
1322
|
+
perm = np.argsort(ids_by_pos[par], kind="stable")
|
|
1323
|
+
out = np.empty_like(ids)
|
|
1324
|
+
out[perm] = ids
|
|
1325
|
+
ids = out
|
|
1326
|
+
ids_by_pos[pos] = ids
|
|
1327
|
+
cols[pos] = (np.array(alpha, dtype=object)[ids].tolist()
|
|
1328
|
+
if alpha else [])
|
|
1329
|
+
|
|
1330
|
+
# Exceptions belonging to grouped columns are read here, in the same walk
|
|
1331
|
+
# the encoder wrote them, but cannot be applied until the group has been
|
|
1332
|
+
# reconstructed further down.
|
|
1333
|
+
grp_ex: Dict[int, Tuple[np.ndarray, List[str]]] = {}
|
|
1334
|
+
|
|
1335
|
+
for pos, sp in enumerate(specs):
|
|
1336
|
+
if sp["kind"] == "text":
|
|
1337
|
+
cells = list(texts[ti])
|
|
1338
|
+
ti += 1
|
|
1339
|
+
tp = sp.get("parent")
|
|
1340
|
+
if tp is not None:
|
|
1341
|
+
perm = np.argsort(ids_by_pos[tp], kind="stable")
|
|
1342
|
+
restored = [None] * len(cells)
|
|
1343
|
+
for k, src in enumerate(perm):
|
|
1344
|
+
restored[src] = cells[k]
|
|
1345
|
+
cells = restored
|
|
1346
|
+
cols[pos] = cells
|
|
1347
|
+
elif sp["kind"] == "grp":
|
|
1348
|
+
nex = sp.get("nex", 0)
|
|
1349
|
+
if nex:
|
|
1350
|
+
grp_ex[pos] = (np.cumsum(unpack_ints(cuts[bi], nex)),
|
|
1351
|
+
texts[ti])
|
|
1352
|
+
bi += 1
|
|
1353
|
+
ti += 1
|
|
1354
|
+
elif sp["kind"] == "num":
|
|
1355
|
+
k = sp["k"]
|
|
1356
|
+
d = unpack_ints(cuts[bi], nrows - k)
|
|
1357
|
+
bi += 1
|
|
1358
|
+
a = _undiff(d, np.array(sp["warm"], dtype=np.int64), k) if k else d
|
|
1359
|
+
cells = ints_to_cells(a, sp["dec"])
|
|
1360
|
+
cells, bi, ti = _apply_exceptions(cells, sp, cuts, texts, bi, ti)
|
|
1361
|
+
cols[pos] = cells
|
|
1362
|
+
|
|
1363
|
+
for gi, g in enumerate(meta["groups"]):
|
|
1364
|
+
w = len(g)
|
|
1365
|
+
n_side = w + (nrows - 1)
|
|
1366
|
+
flat = unpack_ints(cuts[bi], n_side + (nrows - 1) * (w - 1))
|
|
1367
|
+
bi += 1
|
|
1368
|
+
row0 = flat[:w]
|
|
1369
|
+
col0 = flat[w:n_side]
|
|
1370
|
+
D = flat[n_side:].reshape(nrows - 1, w - 1)
|
|
1371
|
+
D1 = np.concatenate([col0[:, None], D], axis=1).cumsum(axis=1)
|
|
1372
|
+
M = np.concatenate([row0[None, :], D1], axis=0).cumsum(axis=0)
|
|
1373
|
+
for c, pos in enumerate(g):
|
|
1374
|
+
cells = ints_to_cells(M[:, c], specs[pos]["dec"])
|
|
1375
|
+
ex = grp_ex.get(pos)
|
|
1376
|
+
if ex is not None:
|
|
1377
|
+
expos, exvals = ex
|
|
1378
|
+
for i, p in enumerate(expos.tolist()):
|
|
1379
|
+
cells[p] = exvals[i]
|
|
1380
|
+
cols[pos] = cells
|
|
1381
|
+
|
|
1382
|
+
# zip transposes at C speed; the nested comprehension did not
|
|
1383
|
+
rows = [list(r) for r in zip(*cols)]
|
|
1384
|
+
return dtz.Table(list(meta["columns"]), rows)
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
def _undiff(d: np.ndarray, warm: np.ndarray, k: int) -> np.ndarray:
|
|
1388
|
+
"""Invert k-fold differencing.
|
|
1389
|
+
|
|
1390
|
+
`warm` holds the first k ORIGINAL values, not the differences, so each
|
|
1391
|
+
level's leading term has to be re-derived: the first element of the j-th
|
|
1392
|
+
difference is np.diff(warm, n=j)[0]."""
|
|
1393
|
+
a = d
|
|
1394
|
+
for j in range(k - 1, -1, -1):
|
|
1395
|
+
first = warm[0] if j == 0 else np.diff(warm, n=j)[0]
|
|
1396
|
+
a = np.concatenate([[first], a]).cumsum()
|
|
1397
|
+
return a
|