Skip to content

Commit eda1eb9

Browse files
author
yohplala
committed
Fix categorical data appending - ticket #949.
1 parent 95416ad commit eda1eb9

8 files changed

Lines changed: 203 additions & 28 deletions

File tree

.github/workflows/main.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ jobs:
2323
with:
2424
fetch-depth: 0
2525

26+
- name: Fetch upstream tags
27+
run: |
28+
git remote add upstream https://github.com/dask/fastparquet.git
29+
git fetch upstream --tags
30+
2631
- name: Setup conda
2732
uses: conda-incubator/setup-miniconda@v3
2833
with:
@@ -53,6 +58,11 @@ jobs:
5358
with:
5459
fetch-depth: 0
5560

61+
- name: Fetch upstream tags
62+
run: |
63+
git remote add upstream https://github.com/dask/fastparquet.git
64+
git fetch upstream --tags
65+
5666
- name: Setup conda
5767
uses: conda-incubator/setup-miniconda@v3
5868
with:
@@ -82,6 +92,11 @@ jobs:
8292
with:
8393
fetch-depth: 0
8494

95+
- name: Fetch upstream tags
96+
run: |
97+
git remote add upstream https://github.com/dask/fastparquet.git
98+
git fetch upstream --tags
99+
85100
- name: Setup conda
86101
uses: conda-incubator/setup-miniconda@v3
87102
with:
@@ -94,6 +109,7 @@ jobs:
94109
pip install hypothesis
95110
pip install pytest-localserver pytest-xdist pytest-asyncio
96111
pip install -e . --no-deps # Install fastparquet
112+
pip install versioneer # Needed for pandas build
97113
git clone https://github.com/pandas-dev/pandas
98114
cd pandas
99115
python setup.py build_ext -j 4
@@ -117,6 +133,11 @@ jobs:
117133
with:
118134
fetch-depth: 0
119135

136+
- name: Fetch upstream tags
137+
run: |
138+
git remote add upstream https://github.com/dask/fastparquet.git
139+
git fetch upstream --tags
140+
120141
- name: Setup conda
121142
uses: conda-incubator/setup-miniconda@v3
122143
with:

ci/environment-py310.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,5 @@ dependencies:
1818
- orjson
1919
- ujson
2020
- python-rapidjson
21-
- versioneer
2221
- meson-python
2322
- pyarrow

fastparquet/api.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ def __init__(self, fn, verify=False, open_with=default_open, root=False,
196196
"a filesystem compatible with fsspec") from e
197197
self.open = open_with
198198
self._statistics = None
199+
self.global_cats = {}
199200

200201
def _parse_header(self, f, verify=True):
201202
if self.fn and self.fn.endswith("_metadata"):
@@ -318,7 +319,8 @@ def __getitem__(self, item):
318319
new_pf.__setstate__(
319320
{"fn": self.fn, "open": self.open, "fmd": fmd,
320321
"pandas_nulls": self.pandas_nulls, "_base_dtype": self._base_dtype,
321-
"tz": self.tz, "_columns_dtype": self._columns_dtype}
322+
"tz": self.tz, "_columns_dtype": self._columns_dtype,
323+
"global_cats": {}} # fresh empty dict for the slice
322324
)
323325
new_pf._set_attrs()
324326
return new_pf
@@ -389,7 +391,7 @@ def read_row_group_file(self, rg, columns, categories, index=None,
389391
f, rg, columns, categories, self.schema, self.cats,
390392
selfmade=self.selfmade, index=index,
391393
assign=assign, scheme=self.file_scheme, partition_meta=partition_meta,
392-
row_filter=row_filter
394+
row_filter=row_filter, global_cats=self.global_cats
393395
)
394396
if ret:
395397
return df
@@ -1011,7 +1013,7 @@ def __getstate__(self):
10111013
self.fmd.row_groups = []
10121014
return {"fn": self.fn, "open": self.open, "fmd": self.fmd,
10131015
"pandas_nulls": self.pandas_nulls, "_base_dtype": self._base_dtype,
1014-
"tz": self.tz}
1016+
"tz": self.tz, "global_cats": self.global_cats}
10151017

10161018
def __setstate__(self, state):
10171019
self.__dict__.update(state)

fastparquet/core.py

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ def read_dictionary_page(file_obj, schema_helper, page_header, column_metadata,
200200

201201
def read_data_page_v2(infile, schema_helper, se, data_header2, cmd,
202202
dic, assign, num, use_cat, file_offset, ph, idx=None,
203-
selfmade=False, row_filter=None):
203+
selfmade=False, row_filter=None, remap_array=None):
204204
"""
205205
:param infile: open file
206206
:param schema_helper:
@@ -211,6 +211,7 @@ def read_data_page_v2(infile, schema_helper, se, data_header2, cmd,
211211
:param assign: output array (all of it)
212212
:param num: offset, rows so far
213213
:param use_cat: output is categorical?
214+
:param remap_array: array for remapping categorical indices
214215
:return: None
215216
216217
test data "/Users/mdurant/Downloads/datapage_v2.snappy.parquet"
@@ -338,6 +339,9 @@ def read_data_page_v2(infile, schema_helper, se, data_header2, cmd,
338339
if bit_width in [8, 16, 32] and selfmade:
339340
# special fastpath for cats
340341
outbytes = raw_bytes[pagefile.tell():]
342+
if remap_array is not None:
343+
# Apply remapping to outbytes.
344+
outbytes = remap_array[outbytes]
341345
if len(outbytes) == assign[num:num+data_header2.num_values].nbytes:
342346
assign[num:num+data_header2.num_values].view('uint8')[row_filter] = outbytes[row_filter]
343347
else:
@@ -358,6 +362,9 @@ def read_data_page_v2(infile, schema_helper, se, data_header2, cmd,
358362
encoding.NumpyIO(assign[num:num+data_header2.num_values].view('uint8')),
359363
itemsize=bit_width
360364
)
365+
if remap_array is not None:
366+
# Apply remapping after reading
367+
assign[num:num+data_header2.num_values] = remap_array[assign[num:num+data_header2.num_values]]
361368
else:
362369
temp = np.empty(data_header2.num_values, assign.dtype)
363370
encoding.read_rle_bit_packed_hybrid(
@@ -367,6 +374,8 @@ def read_data_page_v2(infile, schema_helper, se, data_header2, cmd,
367374
encoding.NumpyIO(temp.view('uint8')),
368375
itemsize=bit_width
369376
)
377+
if remap_array is not None:
378+
temp = remap_array[temp]
370379
if not nullable:
371380
assign[num:num+data_header2.num_values][nulls[row_filter]] = None
372381
assign[num:num+data_header2.num_values][~nulls[row_filter]] = temp[row_filter]
@@ -429,7 +438,7 @@ def read_data_page_v2(infile, schema_helper, se, data_header2, cmd,
429438

430439
def read_col(column, schema_helper, infile, use_cat=False,
431440
selfmade=False, assign=None, catdef=None,
432-
row_filter=None):
441+
row_filter=None, global_cats=None):
433442
"""Using the given metadata, read one column in one row-group.
434443
435444
Parameters
@@ -443,10 +452,20 @@ def read_col(column, schema_helper, infile, use_cat=False,
443452
use_cat: bool (False)
444453
If this column is encoded throughout with dict encoding, give back
445454
a pandas categorical column; otherwise, decode to values
455+
selfmade: bool (False)
456+
If data created by fastparquet
457+
assign: numpy array
458+
Where to store the result
459+
catdef: pandas.Categorical or CategoricalDtype
460+
If reading a categorical column, the categorical definition (categories and
461+
ordering).
446462
row_filter: bool array or None
447463
if given, selects which of the values read are to be written
448464
into the output. Effectively implies NULLs, even for a required
449465
column.
466+
global_cats: dict or None
467+
Optional dictionary for storing global categorical values across row groups.
468+
Format: {col_path: array}
450469
"""
451470
cmd = column.meta_data
452471
try:
@@ -480,6 +499,15 @@ def read_col(column, schema_helper, infile, use_cat=False,
480499
row_idx = [0] # map/list objects
481500
dic = None
482501
index_off = 0 # how far through row_filter we are
502+
503+
# Initialize tracking variables for categorical dictionaries
504+
# Only set up global dictionary tracking if using categorical and global_cats is provided
505+
remap_dict = {} # Dictionary for collecting mappings
506+
if use_cat and global_cats is not None:
507+
path_str = ".".join(cmd.path_in_schema)
508+
# Register this column in global_cats if not already present
509+
if path_str not in global_cats:
510+
global_cats[path_str] = None
483511

484512
while num < rows:
485513
off = infile.tell()
@@ -497,7 +525,44 @@ def read_col(column, schema_helper, infile, use_cat=False,
497525
ddt = [kv.value.decode() for kv in (cmd.key_value_metadata or [])
498526
if kv.key == b"label_dtype"]
499527
ddt = ddt[0] if ddt else None
500-
catdef._set_categories(pd.Index(dic, dtype=ddt), fastpath=True)
528+
529+
if global_cats is not None:
530+
# Check if categorical values are consistent with global dictionary.
531+
if global_cats[path_str] is None:
532+
# This is the first dictionary for this column, save it as global
533+
global_cats[path_str] = dic
534+
else:
535+
# Dictionary already defined for this column, check for inconsistency.
536+
global_dict = global_cats[path_str]
537+
new_values = []
538+
# Build remap_dict in a single comprehension,
539+
# appending new values to new_values at the same time:
540+
# - Use walrus operator (:=) to store found_idx from global_dict lookup.
541+
# - When found_idx is -1, append val to new_values and use its new position.
542+
# - Only include indices that need remapping (found_idx != i).
543+
remap_dict = {i: (len(global_dict) + len(new_values) - 1)
544+
if found_idx == -1 and not new_values.append(val) else found_idx
545+
for (i,), val in np.ndenumerate(dic)
546+
if (found_idx := next((j
547+
for (j,), gval in np.ndenumerate(global_dict)
548+
if val == gval), -1)) != i
549+
}
550+
if remap_dict:
551+
# If any remapping is needed, create a complete remap array.
552+
# Initialize with identity mapping (no change)
553+
remap_array = np.arange(len(dic), dtype=np.int32)
554+
# Update indices that need remapping
555+
remap_array[list(remap_dict)] = list(remap_dict.values())
556+
if new_values:
557+
# Add new values to global dictionary
558+
global_cats[path_str] = np.append(global_dict, new_values)
559+
# Update categories
560+
catdef._set_categories(pd.Index(global_cats[path_str], dtype=ddt), fastpath=True)
561+
562+
# Normal case - always set categories for this dictionary
563+
if global_cats is None or not remap_dict:
564+
catdef._set_categories(pd.Index(dic, dtype=ddt), fastpath=True)
565+
501566
if np.iinfo(assign.dtype).max < len(dic):
502567
raise RuntimeError('Assigned array dtype (%s) cannot accommodate '
503568
'number of category labels (%i)' %
@@ -509,7 +574,7 @@ def read_col(column, schema_helper, infile, use_cat=False,
509574
if ph.type == parquet_thrift.PageType.DATA_PAGE_V2:
510575
num += read_data_page_v2(infile, schema_helper, se, ph.data_page_header_v2, cmd,
511576
dic, assign, num, use_cat, off, ph, row_idx, selfmade=selfmade,
512-
row_filter=row_filter)
577+
row_filter=row_filter, remap_array=remap_array if remap_dict else None)
513578
continue
514579
if (selfmade and hasattr(cmd, 'statistics') and
515580
getattr(cmd.statistics, 'null_count', 1) == 0):
@@ -563,6 +628,9 @@ def read_col(column, schema_helper, infile, use_cat=False,
563628
part[defi == max_defi] = dic[val]
564629
elif not use_cat:
565630
part[defi == max_defi] = convert(val, se, dtype=assign.dtype)
631+
elif remap_dict:
632+
# Apply remapping of categorical codes
633+
part[defi == max_defi] = remap_array[val]
566634
else:
567635
part[defi == max_defi] = val
568636
else:
@@ -582,14 +650,18 @@ def read_col(column, schema_helper, infile, use_cat=False,
582650
piece[:] = dic[val]
583651
elif not use_cat:
584652
piece[:] = convert(val, se, dtype=assign.dtype)
653+
elif remap_dict:
654+
# Apply remapping of categorical codes
655+
piece[:] = remap_array[val]
585656
else:
586657
piece[:] = val
587658

588659
num += len(defi) if defi is not None else len(val)
589660

590661

591662
def read_row_group_arrays(file, rg, columns, categories, schema_helper, cats,
592-
selfmade=False, assign=None, row_filter=False):
663+
selfmade=False, assign=None, row_filter=False,
664+
global_cats=None):
593665
"""
594666
Read a row group and return as a dict of arrays
595667
@@ -615,7 +687,7 @@ def read_row_group_arrays(file, rg, columns, categories, schema_helper, cats,
615687
read_col(column, schema_helper, file, use_cat=name+'-catdef' in out,
616688
selfmade=selfmade, assign=out[name],
617689
catdef=out.get(name+'-catdef', None),
618-
row_filter=row_filter)
690+
row_filter=row_filter, global_cats=global_cats)
619691

620692
if _is_map_like(schema_helper, column):
621693
# TODO: could be done in fast loop in _assemble_objects?
@@ -634,15 +706,17 @@ def read_row_group_arrays(file, rg, columns, categories, schema_helper, cats,
634706

635707
def read_row_group(file, rg, columns, categories, schema_helper, cats,
636708
selfmade=False, index=None, assign=None,
637-
scheme='hive', partition_meta=None, row_filter=False):
709+
scheme='hive', partition_meta=None, row_filter=False,
710+
global_cats=None):
638711
"""
639712
Access row-group in a file and read some columns into a data-frame.
640713
"""
641714
partition_meta = partition_meta or {}
642715
if assign is None:
643716
raise RuntimeError('Going with pre-allocation!')
644717
read_row_group_arrays(file, rg, columns, categories, schema_helper,
645-
cats, selfmade, assign=assign, row_filter=row_filter)
718+
cats, selfmade, assign=assign, row_filter=row_filter,
719+
global_cats=global_cats)
646720

647721
for cat in cats:
648722
if cat not in assign:

fastparquet/test/test_output.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,3 +1214,91 @@ def test_attrs_roundtrip(tempdir):
12141214
df.to_parquet(path=fn, engine="fastparquet")
12151215
df2 = pd.read_parquet(fn, engine="fastparquet")
12161216
assert df2.attrs == attrs
1217+
1218+
1219+
def test_append_different_categorical_simple(tempdir):
1220+
"""Test for issue #949: wrong categories data when appending with categorical columns"""
1221+
fn = os.path.join(str(tempdir), 'test.parquet')
1222+
# First DataFrame with a categorical column
1223+
df1 = pd.DataFrame({
1224+
"col1": [1, 4, 7],
1225+
"col2": [2, 5, 8]
1226+
})
1227+
df1["col2"] = df1["col2"].astype("category")
1228+
write(fn, df1, write_index=False, file_scheme='simple')
1229+
# Second DataFrame to append
1230+
df2 = pd.DataFrame({
1231+
"col1": [4, 7, 10],
1232+
"col2": [5, 8, 11]
1233+
})
1234+
df2["col2"] = df2["col2"].astype("category")
1235+
write(fn, df2, append=True, write_index=False, file_scheme='simple')
1236+
# Read back again - this should maintain correct categorical values
1237+
df_combined = pd.read_parquet(fn, engine="fastparquet")
1238+
# Expected result when concatenating the two dataframes
1239+
expected = pd.concat([df1, df2], ignore_index=True)
1240+
expected["col2"] = expected["col2"].astype("category")
1241+
assert_frame_equal(df_combined, expected)
1242+
1243+
1244+
def test_append_different_categorical_multi(tempdir):
1245+
"""Test for issue #949: wrong categories data when appending with categorical columns"""
1246+
# Testing ParquetFile slicing as well.
1247+
# Set random seed for reproducibility
1248+
np.random.seed(42)
1249+
# Create initial DataFrame with categorical columns
1250+
def create_test_df(start_idx, rows, cats1, cats2):
1251+
cat1 = [cats1[i % len(cats1)] for i in range(rows)]
1252+
cat2 = [cats2[i % len(cats2)] for i in range(rows)]
1253+
df = pd.DataFrame({
1254+
'cat_col1': cat1,
1255+
'cat_col2': cat2,
1256+
'value': np.random.rand(rows)
1257+
})
1258+
df['cat_col1'] = df['cat_col1'].astype('category')
1259+
df['cat_col2'] = df['cat_col2'].astype('category')
1260+
return df
1261+
# Initial categories
1262+
cats1 = ['A', 'B', 'C']
1263+
cats2 = [10, 20, 30]
1264+
# First dataframe
1265+
fn = os.path.join(str(tempdir), 'test_parquet')
1266+
df1 = create_test_df(0, 5, cats1, cats2)
1267+
write(fn, df1, file_scheme='hive', write_index=False)
1268+
# New categories for second dataframe (overlapping + new values)
1269+
cats1_2 = ['B', 'C', 'D'] # B,C overlap with first df, D is new
1270+
cats2_2 = [30, 40, 50]
1271+
# Create second dataframe
1272+
df2 = create_test_df(len(df1), 6, cats1_2, cats2_2)
1273+
# Append second dataframe
1274+
write(fn, df2, file_scheme='hive', append=True, write_index=False)
1275+
# New categories for third dataframe (different ordering + new values)
1276+
cats1_3 = ['E', 'C', 'A'] # A,C from first, E is new
1277+
cats2_3 = [60, 70, 50] # Mixed order
1278+
# Create third dataframe
1279+
df3 = create_test_df(len(df1)+len(df2), 7, cats1_3, cats2_3)
1280+
# Append third dataframe
1281+
write(fn, df3, file_scheme='hive', append=True, write_index=False)
1282+
# Combine all original dataframes for comparison
1283+
expected_df = pd.concat([df1, df2, df3], axis=0, ignore_index=True)
1284+
expected_df['cat_col1'] = expected_df['cat_col1'].astype('category')
1285+
expected_df['cat_col2'] = expected_df['cat_col2'].astype('category')
1286+
pf = ParquetFile(fn)
1287+
actual_df = pf.to_pandas()
1288+
# Assert that the dataframes are equal
1289+
assert_frame_equal(expected_df, actual_df)
1290+
# Test slicing.
1291+
actual_df_subset = pf[1:].to_pandas()
1292+
expected_df_subset = pd.concat([df2, df3], axis=0, ignore_index=True)
1293+
expected_df_subset['cat_col1'] = expected_df_subset['cat_col1'].astype('category')
1294+
expected_df_subset['cat_col2'] = expected_df_subset['cat_col2'].astype('category')
1295+
try:
1296+
# Code to manage new categorical values in fastparquet does not reorder them.
1297+
# Code in pandas concat seems to do so.
1298+
actual_df_subset['cat_col1'] = actual_df_subset['cat_col1'].cat.reorder_categories(
1299+
expected_df_subset['cat_col1'].cat.categories
1300+
)
1301+
except ValueError:
1302+
raise AssertionError("failed to reorder categories")
1303+
assert_frame_equal(expected_df_subset, actual_df_subset)
1304+

0 commit comments

Comments
 (0)