Skip to content

Commit 4c67377

Browse files
committed
BUG/API: .merge() and .join() on category dtype columns will now
preserve the category dtype when possible closes pandas-dev#10409
1 parent 8d57450 commit 4c67377

File tree

7 files changed

+158
-1
lines changed

7 files changed

+158
-1
lines changed

asv_bench/benchmarks/join_merge.py

+24
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,30 @@ def time_i8merge(self):
257257
merge(self.left, self.right, how='outer')
258258

259259

260+
class MergeCategoricals(object):
261+
goal_time = 0.2
262+
263+
def setup(self):
264+
self.left_object = pd.DataFrame(
265+
{'X': np.random.choice(range(0, 10), size=(10000,)),
266+
'Y': np.random.choice(['one', 'two', 'three'], size=(10000,))})
267+
268+
self.right_object = pd.DataFrame(
269+
{'X': np.random.choice(range(0, 10), size=(10000,)),
270+
'Z': np.random.choice(['jjj', 'kkk', 'sss'], size=(10000,))})
271+
272+
self.left_cat = self.left_object.assign(
273+
Y=self.left_object['Y'].astype('category'))
274+
self.right_cat = self.right_object.assign(
275+
Z=self.right_object['Z'].astype('category'))
276+
277+
def time_merge_object(self):
278+
merge(self.left_object, self.right_object, on='X')
279+
280+
def time_merge_cat(self):
281+
merge(self.left_cat, self.right_cat, on='X')
282+
283+
260284
#----------------------------------------------------------------------
261285
# Ordered merge
262286

doc/source/whatsnew/v0.20.0.txt

+5-1
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,10 @@ Other API Changes
368368
- ``pd.read_csv()`` will now raise a ``ValueError`` for the C engine if the quote character is larger than than one byte (:issue:`11592`)
369369
- ``inplace`` arguments now require a boolean value, else a ``ValueError`` is thrown (:issue:`14189`)
370370
- ``pandas.api.types.is_datetime64_ns_dtype`` will now report ``True`` on a tz-aware dtype, similar to ``pandas.api.types.is_datetime64_any_dtype``
371-
- ``DataFrame.asof()`` will return a null filled ``Series`` instead the scalar ``NaN`` if a match is not found (:issue:`15118`)
371+
- ``DataFrame.asof()`` will return a null filled ``Series`` instead the scalar ``NaN`` if a match is not found (:issue:`15118`)
372+
- ``.merge()`` and ``.join()`` on ``category`` dtype columns will now preserve the category dtype when possible (:issue:`10409`)
373+
374+
372375
.. _whatsnew_0200.deprecations:
373376

374377
Deprecations
@@ -409,6 +412,7 @@ Performance Improvements
409412
- Improved performance of timeseries plotting with an irregular DatetimeIndex
410413
(or with ``compat_x=True``) (:issue:`15073`).
411414
- Improved performance of ``groupby().cummin()`` and ``groupby().cummax()`` (:issue:`15048`, :issue:`15109`)
415+
- Improved performance of merge/join on ``category`` columns (:issue:`10409`)
412416

413417
- When reading buffer object in ``read_sas()`` method without specified format, filepath string is inferred rather than buffer object.
414418

pandas/core/internals.py

+2
Original file line numberDiff line numberDiff line change
@@ -5223,6 +5223,8 @@ def get_reindexed_values(self, empty_dtype, upcasted_na):
52235223
# External code requested filling/upcasting, bool values must
52245224
# be upcasted to object to avoid being upcasted to numeric.
52255225
values = self.block.astype(np.object_).values
5226+
elif self.block.is_categorical:
5227+
values = self.block.values
52265228
else:
52275229
# No dtype upcasting is done here, it will be performed during
52285230
# concatenation itself.

pandas/tests/test_categorical.py

+2
Original file line numberDiff line numberDiff line change
@@ -4098,12 +4098,14 @@ def test_merge(self):
40984098
cright = right.copy()
40994099
cright['d'] = cright['d'].astype('category')
41004100
result = pd.merge(left, cright, how='left', left_on='b', right_on='c')
4101+
expected['d'] = expected['d'].astype('category', categories=['null'])
41014102
tm.assert_frame_equal(result, expected)
41024103

41034104
# cat-object
41044105
cleft = left.copy()
41054106
cleft['b'] = cleft['b'].astype('category')
41064107
result = pd.merge(cleft, cright, how='left', left_on='b', right_on='c')
4108+
expected['b'] = expected['b'].astype('category')
41074109
tm.assert_frame_equal(result, expected)
41084110

41094111
# cat-cat

pandas/tools/merge.py

+8
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
is_datetime64_dtype,
2222
needs_i8_conversion,
2323
is_int64_dtype,
24+
is_categorical_dtype,
2425
is_integer_dtype,
2526
is_float_dtype,
2627
is_integer,
@@ -1339,6 +1340,13 @@ def _factorize_keys(lk, rk, sort=True):
13391340
if is_datetime64tz_dtype(lk) and is_datetime64tz_dtype(rk):
13401341
lk = lk.values
13411342
rk = rk.values
1343+
1344+
# if we exactly match in categories, allow us to use codes
1345+
if (is_categorical_dtype(lk) and
1346+
is_categorical_dtype(rk) and
1347+
lk.is_dtype_equal(rk)):
1348+
return lk.codes, rk.codes, len(lk.categories)
1349+
13421350
if is_int_or_datetime_dtype(lk) and is_int_or_datetime_dtype(rk):
13431351
klass = _hash.Int64Factorizer
13441352
lk = _ensure_int64(com._values_from_object(lk))

pandas/tools/tests/test_merge.py

+116
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from pandas.util.testing import (assert_frame_equal,
1313
assert_series_equal,
1414
slow)
15+
from pandas.types.dtypes import CategoricalDtype
1516
from pandas import DataFrame, Index, MultiIndex, Series, Categorical
1617
import pandas.util.testing as tm
1718

@@ -1368,3 +1369,118 @@ def f():
13681369
def f():
13691370
household.join(log_return, how='outer')
13701371
self.assertRaises(NotImplementedError, f)
1372+
1373+
1374+
class TestMergeCategorical(tm.TestCase):
1375+
_multiprocess_can_split_ = True
1376+
1377+
def setUp(self):
1378+
np.random.seed(1234)
1379+
self.left = DataFrame(
1380+
{'X': np.random.choice(['foo', 'bar'], size=(10,)),
1381+
'Y': np.random.choice(['one', 'two', 'three'], size=(10,))})
1382+
1383+
self.right = pd.DataFrame(
1384+
{'X': np.random.choice(['foo', 'bar'], size=(10,)),
1385+
'Z': np.random.choice(['jjj', 'kkk', 'sss'], size=(10,))})
1386+
1387+
def test_identical(self):
1388+
# GH 10409
1389+
left = self.left.assign(X=self.left.X.astype('category'))
1390+
1391+
merged = pd.merge(left, left, on='X')
1392+
result = merged.dtypes.sort_index()
1393+
expected = Series([CategoricalDtype(),
1394+
np.dtype('O'),
1395+
np.dtype('O')],
1396+
index=['X', 'Y_x', 'Y_y'])
1397+
assert_series_equal(result, expected)
1398+
1399+
def test_other_columns(self):
1400+
# non-merge columns should preserver if possible
1401+
x = self.left.X.astype('category')
1402+
left = DataFrame({'X': x, 'Y': x})
1403+
1404+
merged = pd.merge(left, left, on='X')
1405+
result = merged.dtypes.sort_index()
1406+
expected = Series([CategoricalDtype(),
1407+
CategoricalDtype(),
1408+
CategoricalDtype()],
1409+
index=['X', 'Y_x', 'Y_y'])
1410+
assert_series_equal(result, expected)
1411+
1412+
# different categories
1413+
x = self.left.X.astype('category')
1414+
left = DataFrame(
1415+
{'X': x,
1416+
'Y': x.cat.set_categories(['bar', 'foo', 'bah'])})
1417+
right = self.right.drop_duplicates(['X'])
1418+
right = right.assign(
1419+
Y=pd.Series(['foo', 'foo']).astype(
1420+
'category', categories=['foo', 'bar', 'baz']))
1421+
1422+
merged = pd.merge(left, right, on='X')
1423+
result = merged.dtypes.sort_index()
1424+
expected = Series([CategoricalDtype(),
1425+
CategoricalDtype(),
1426+
CategoricalDtype(),
1427+
np.dtype('O')],
1428+
index=['X', 'Y_x', 'Y_y', 'Z'])
1429+
assert_series_equal(result, expected)
1430+
1431+
def test_categories_same(self):
1432+
# GH 10409
1433+
left = self.left.assign(X=self.left.X.astype('category'))
1434+
right = self.right.assign(X=self.right.X.astype('category'))
1435+
1436+
merged = pd.merge(left, right, on='X')
1437+
result = merged.dtypes.sort_index()
1438+
expected = Series([CategoricalDtype(),
1439+
np.dtype('O'),
1440+
np.dtype('O')],
1441+
index=['X', 'Y', 'Z'])
1442+
assert_series_equal(result, expected)
1443+
1444+
def test_categories_different(self):
1445+
1446+
r = self.right.drop_duplicates(['X'])
1447+
1448+
# from above with original categories
1449+
left = self.left.assign(X=self.left.X.astype('category'))
1450+
1451+
right = r.assign(X=r.X.astype('category'))
1452+
merged = pd.merge(left, right, on='X')
1453+
1454+
# swap the categories
1455+
# but should still work (end return categorical)
1456+
left = self.left.assign(X=self.left.X.astype('category'))
1457+
right = r.assign(X=r.X.astype('category', categories=['foo', 'bar']))
1458+
result = pd.merge(left, right, on='X')
1459+
tm.assert_index_equal(result.X.cat.categories,
1460+
pd.Index(['bar', 'foo']))
1461+
1462+
assert_frame_equal(result, merged)
1463+
1464+
result = result.dtypes.sort_index()
1465+
expected = Series([CategoricalDtype(),
1466+
np.dtype('O'),
1467+
np.dtype('O')],
1468+
index=['X', 'Y', 'Z'])
1469+
assert_series_equal(result, expected)
1470+
1471+
# swap the categories and ordered on one
1472+
# but should still work (end return categorical)
1473+
right = r.assign(X=r.X.astype('category', categories=['foo', 'bar'],
1474+
ordered=True))
1475+
result = pd.merge(left, right, on='X')
1476+
tm.assert_index_equal(result.X.cat.categories,
1477+
pd.Index(['bar', 'foo']))
1478+
1479+
assert_frame_equal(result, merged)
1480+
1481+
result = result.dtypes.sort_index()
1482+
expected = Series([CategoricalDtype(),
1483+
np.dtype('O'),
1484+
np.dtype('O')],
1485+
index=['X', 'Y', 'Z'])
1486+
assert_series_equal(result, expected)

pandas/tools/tests/test_merge_asof.py

+1
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ def test_basic_categorical(self):
148148
trades.ticker = trades.ticker.astype('category')
149149
quotes = self.quotes.copy()
150150
quotes.ticker = quotes.ticker.astype('category')
151+
expected.ticker = expected.ticker.astype('category')
151152

152153
result = merge_asof(trades, quotes,
153154
on='time',

0 commit comments

Comments
 (0)