Skip to content

Commit 11397df

Browse files
jbrockmendelclaude
andcommitted
PERF: Optimize CSV categorical parsing when categories are known
When read_csv receives a CategoricalDtype with pre-specified categories, map parsed values directly to category codes in a single pass using a pre-built hash table, avoiding the factorize-then-recode steps. For non-string category types (datetime, float edge cases, bool), the optimization is attempted first and falls back to the existing path if str() representations don't match the raw CSV tokens. closes pandas-dev#17743 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent be070de commit 11397df

3 files changed

Lines changed: 104 additions & 2 deletions

File tree

asv_bench/benchmarks/io/csv.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from pandas import (
1111
Categorical,
12+
CategoricalDtype,
1213
DataFrame,
1314
Index,
1415
concat,
@@ -448,6 +449,12 @@ def time_convert_post(self, engine):
448449
def time_convert_direct(self, engine):
449450
read_csv(self.fname, engine=engine, dtype="category")
450451

452+
def time_convert_known_categories(self, engine):
453+
# GH#17743
454+
cats = ["aaaaaaaa", "bbbbbbb", "cccccccc", "dddddddd", "eeeeeeee"]
455+
dtype = {col: CategoricalDtype(cats) for col in ["a", "b", "c"]}
456+
read_csv(self.fname, engine=engine, dtype=dtype)
457+
451458

452459
class ReadCSVParseDates(StringIORewind):
453460
params = ["c", "python"]

doc/source/whatsnew/v3.1.0.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ Performance improvements
132132
- Performance improvement in :func:`merge` with ``how="cross"`` (:issue:`38082`)
133133
- Performance improvement in :func:`merge` with ``how="left"`` (:issue:`64370`)
134134
- Performance improvement in :func:`merge` with ``sort=False`` for single-key ``how="left"``/``how="right"`` joins when the opposite join key is sorted, unique, and range-like (:issue:`64146`)
135+
- Performance improvement in :func:`read_csv` with ``engine="c"`` when parsing columns as :class:`CategoricalDtype` with known categories, by mapping parsed values directly to category codes in a single pass instead of factorizing and then recoding (:issue:`17743`)
135136
- Performance improvement in :func:`read_csv` with ``engine="c"`` when reading from binary file-like objects (e.g. PyArrow S3 file handles) by avoiding unnecessary ``TextIOWrapper`` wrapping (:issue:`46823`)
136137
- Performance improvement in :func:`read_html` and the Python CSV parser when ``thousands`` is set, fixing catastrophic regex backtracking on cells with many comma-separated digit groups followed by non-numeric text (:issue:`52619`)
137138
- Performance improvement in :func:`read_sas` by reading page header fields directly in Cython instead of falling back to Python (:issue:`47339`)

pandas/_libs/parsers.pyx

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ from pandas._libs.khash cimport (
9595

9696
from pandas.errors import (
9797
EmptyDataError,
98+
Pandas4Warning,
9899
ParserError,
99100
ParserWarning,
100101
)
@@ -1113,8 +1114,36 @@ cdef class TextReader:
11131114
kh_str_starts_t *na_hashset,
11141115
set na_fset, bint raise_on_invalid):
11151116
if isinstance(dtype, CategoricalDtype):
1116-
# TODO: I suspect that _categorical_convert could be
1117-
# optimized when dtype is an instance of CategoricalDtype
1117+
if dtype.categories is not None:
1118+
# GH#17743: try optimized path for known categories.
1119+
# Convert categories to str and match against raw CSV tokens.
1120+
# For string/integer categories str() matches CSV text exactly.
1121+
# For other types (datetime, float, bool) it may not match,
1122+
# in which case we fall through to the general path.
1123+
codes, na_count, not_found_count = _categorical_convert_known(
1124+
self.parser, i, start, end, na_filter, na_hashset,
1125+
dtype.categories)
1126+
if not_found_count == 0 or (
1127+
dtype.categories.dtype == object
1128+
):
1129+
# Either all values matched, or categories are strings
1130+
# (so unmatched values are genuinely unexpected).
1131+
if not_found_count > 0:
1132+
warnings.warn(
1133+
"Constructing a Categorical with a dtype and "
1134+
"values containing non-null entries not in that "
1135+
"dtype's categories is deprecated and will raise "
1136+
"in a future version.",
1137+
Pandas4Warning,
1138+
stacklevel=find_stack_level(),
1139+
)
1140+
array_type = dtype.construct_array_type()
1141+
cat = array_type._simple_new(codes, dtype=dtype)
1142+
return cat, na_count
1143+
1144+
# Fall through: non-string type where str() didn't match
1145+
# all CSV tokens (e.g. datetime, float edge cases).
1146+
11181147
codes, cats, na_count = _categorical_convert(
11191148
self.parser, i, start, end, na_filter, na_hashset)
11201149

@@ -1574,6 +1603,71 @@ cdef _categorical_convert(parser_t *parser, int64_t col,
15741603
return np.asarray(codes), result, na_count
15751604

15761605

1606+
@cython.wraparound(False)
1607+
@cython.boundscheck(False)
1608+
cdef _categorical_convert_known(parser_t *parser, int64_t col,
1609+
int64_t line_start, int64_t line_end,
1610+
bint na_filter,
1611+
kh_str_starts_t *na_hashset,
1612+
categories):
1613+
"""Convert column data into codes using pre-specified categories.
1614+
1615+
When categories are known ahead of time, we build the hash table from
1616+
the categories directly and map parsed values to category codes in a
1617+
single pass, avoiding the factorize-then-recode steps.
1618+
"""
1619+
cdef:
1620+
int na_count = 0
1621+
int not_found_count = 0
1622+
Py_ssize_t i, num_cats, lines
1623+
coliter_t it
1624+
const char *word = NULL
1625+
1626+
int64_t NA = -1
1627+
int64_t[::1] codes
1628+
1629+
int ret = 0
1630+
kh_str_t *table
1631+
khiter_t k
1632+
1633+
lines = line_end - line_start
1634+
codes = np.empty(lines, dtype=np.int64)
1635+
1636+
num_cats = len(categories)
1637+
# Keep references to encoded category strings to prevent GC
1638+
# while the hash table holds pointers to their buffers.
1639+
cat_bytes_list = [str(cat).encode("utf-8") for cat in categories]
1640+
1641+
# Build hash table: encoded category string -> category index
1642+
table = kh_init_str()
1643+
for i in range(num_cats):
1644+
k = kh_put_str(table, PyBytes_AsString(cat_bytes_list[i]), &ret)
1645+
table.vals[k] = <int64_t>i
1646+
1647+
with nogil:
1648+
coliter_setup(&it, parser, col, line_start)
1649+
1650+
for i in range(lines):
1651+
COLITER_NEXT(it, word)
1652+
1653+
if na_filter:
1654+
if kh_get_str_starts_item(na_hashset, word):
1655+
na_count += 1
1656+
codes[i] = NA
1657+
continue
1658+
1659+
k = kh_get_str(table, word)
1660+
if k != table.n_buckets:
1661+
codes[i] = table.vals[k]
1662+
else:
1663+
# Value not in known categories
1664+
not_found_count += 1
1665+
codes[i] = NA
1666+
1667+
kh_destroy_str(table)
1668+
return np.asarray(codes), na_count, not_found_count
1669+
1670+
15771671
# -> ndarray[f'|S{width}']
15781672
cdef _to_fw_string(parser_t *parser, int64_t col, int64_t line_start,
15791673
int64_t line_end, int64_t width):

0 commit comments

Comments
 (0)