Skip to content

Commit 09360d8

Browse files
committed
PERF: faster unstacking
closes #15503 Author: Jeff Reback <[email protected]> Closes #15510 from jreback/reshape3 and squashes the following commits: ec29226 [Jeff Reback] PERF: faster unstacking
1 parent 5067708 commit 09360d8

File tree

7 files changed

+196
-15
lines changed

7 files changed

+196
-15
lines changed

asv_bench/benchmarks/reshape.py

+21
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,27 @@ def time_reshape_unstack_simple(self):
5959
self.df.unstack(1)
6060

6161

62+
class reshape_unstack_large_single_dtype(object):
63+
goal_time = 0.2
64+
65+
def setup(self):
66+
m = 100
67+
n = 1000
68+
69+
levels = np.arange(m)
70+
index = pd.MultiIndex.from_product([levels]*2)
71+
columns = np.arange(n)
72+
values = np.arange(m*m*n).reshape(m*m, n)
73+
self.df = pd.DataFrame(values, index, columns)
74+
self.df2 = self.df.iloc[:-1]
75+
76+
def time_unstack_full_product(self):
77+
self.df.unstack()
78+
79+
def time_unstack_with_mask(self):
80+
self.df2.unstack()
81+
82+
6283
class unstack_sparse_keyspace(object):
6384
goal_time = 0.2
6485

doc/source/whatsnew/v0.20.0.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -640,7 +640,7 @@ Performance Improvements
640640
- Improved performance and reduced memory when indexing with a ``MultiIndex`` (:issue:`15245`)
641641
- When reading buffer object in ``read_sas()`` method without specified format, filepath string is inferred rather than buffer object. (:issue:`14947`)
642642
- Improved performance of `rank()` for categorical data (:issue:`15498`)
643-
643+
- Improved performance when using ``.unstack()`` (:issue:`15503`)
644644

645645

646646
.. _whatsnew_0200.bug_fixes:

pandas/core/reshape.py

+47-9
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77

88
import numpy as np
99

10-
from pandas.types.common import _ensure_platform_int, is_list_like
10+
from pandas.types.common import (_ensure_platform_int,
11+
is_list_like, is_bool_dtype,
12+
needs_i8_conversion)
1113
from pandas.types.cast import _maybe_promote
1214
from pandas.types.missing import notnull
1315
import pandas.types.concat as _concat
@@ -25,6 +27,7 @@
2527

2628
import pandas.core.algorithms as algos
2729
import pandas.algos as _algos
30+
import pandas._reshape as _reshape
2831

2932
from pandas.core.index import MultiIndex, _get_na_value
3033

@@ -182,9 +185,21 @@ def get_new_values(self):
182185
stride = values.shape[1]
183186
result_width = width * stride
184187
result_shape = (length, result_width)
188+
mask = self.mask
189+
mask_all = mask.all()
190+
191+
# we can simply reshape if we don't have a mask
192+
if mask_all and len(values):
193+
new_values = (self.sorted_values
194+
.reshape(length, width, stride)
195+
.swapaxes(1, 2)
196+
.reshape(result_shape)
197+
)
198+
new_mask = np.ones(result_shape, dtype=bool)
199+
return new_values, new_mask
185200

186201
# if our mask is all True, then we can use our existing dtype
187-
if self.mask.all():
202+
if mask_all:
188203
dtype = values.dtype
189204
new_values = np.empty(result_shape, dtype=dtype)
190205
else:
@@ -194,13 +209,36 @@ def get_new_values(self):
194209

195210
new_mask = np.zeros(result_shape, dtype=bool)
196211

197-
# is there a simpler / faster way of doing this?
198-
for i in range(values.shape[1]):
199-
chunk = new_values[:, i * width:(i + 1) * width]
200-
mask_chunk = new_mask[:, i * width:(i + 1) * width]
201-
202-
chunk.flat[self.mask] = self.sorted_values[:, i]
203-
mask_chunk.flat[self.mask] = True
212+
name = np.dtype(dtype).name
213+
sorted_values = self.sorted_values
214+
215+
# we need to convert to a basic dtype
216+
# and possibly coerce an input to our output dtype
217+
# e.g. ints -> floats
218+
if needs_i8_conversion(values):
219+
sorted_values = sorted_values.view('i8')
220+
new_values = new_values.view('i8')
221+
name = 'int64'
222+
elif is_bool_dtype(values):
223+
sorted_values = sorted_values.astype('object')
224+
new_values = new_values.astype('object')
225+
name = 'object'
226+
else:
227+
sorted_values = sorted_values.astype(name, copy=False)
228+
229+
# fill in our values & mask
230+
f = getattr(_reshape, "unstack_{}".format(name))
231+
f(sorted_values,
232+
mask.view('u1'),
233+
stride,
234+
length,
235+
width,
236+
new_values,
237+
new_mask.view('u1'))
238+
239+
# reconstruct dtype if needed
240+
if needs_i8_conversion(values):
241+
new_values = new_values.view(values.dtype)
204242

205243
return new_values, new_mask
206244

pandas/src/reshape.pyx

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# cython: profile=False
2+
3+
from numpy cimport *
4+
cimport numpy as np
5+
import numpy as np
6+
7+
cimport cython
8+
9+
import_array()
10+
11+
cimport util
12+
13+
from numpy cimport NPY_INT8 as NPY_int8
14+
from numpy cimport NPY_INT16 as NPY_int16
15+
from numpy cimport NPY_INT32 as NPY_int32
16+
from numpy cimport NPY_INT64 as NPY_int64
17+
from numpy cimport NPY_FLOAT16 as NPY_float16
18+
from numpy cimport NPY_FLOAT32 as NPY_float32
19+
from numpy cimport NPY_FLOAT64 as NPY_float64
20+
21+
from numpy cimport (int8_t, int16_t, int32_t, int64_t, uint8_t, uint16_t,
22+
uint32_t, uint64_t, float16_t, float32_t, float64_t)
23+
24+
int8 = np.dtype(np.int8)
25+
int16 = np.dtype(np.int16)
26+
int32 = np.dtype(np.int32)
27+
int64 = np.dtype(np.int64)
28+
float16 = np.dtype(np.float16)
29+
float32 = np.dtype(np.float32)
30+
float64 = np.dtype(np.float64)
31+
32+
cdef double NaN = <double> np.NaN
33+
cdef double nan = NaN
34+
35+
include "reshape_helper.pxi"

pandas/src/reshape_helper.pxi.in

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
Template for each `dtype` helper function for take
3+
4+
WARNING: DO NOT edit .pxi FILE directly, .pxi is generated from .pxi.in
5+
"""
6+
7+
# ----------------------------------------------------------------------
8+
# reshape
9+
# ----------------------------------------------------------------------
10+
11+
{{py:
12+
13+
# name, c_type
14+
dtypes = [('uint8', 'uint8_t'),
15+
('uint16', 'uint16_t'),
16+
('uint32', 'uint32_t'),
17+
('uint64', 'uint64_t'),
18+
('int8', 'int8_t'),
19+
('int16', 'int16_t'),
20+
('int32', 'int32_t'),
21+
('int64', 'int64_t'),
22+
('float32', 'float32_t'),
23+
('float64', 'float64_t'),
24+
('object', 'object')]
25+
}}
26+
27+
{{for dtype, c_type in dtypes}}
28+
29+
30+
@cython.wraparound(False)
31+
@cython.boundscheck(False)
32+
def unstack_{{dtype}}(ndarray[{{c_type}}, ndim=2] values,
33+
ndarray[uint8_t, ndim=1] mask,
34+
Py_ssize_t stride,
35+
Py_ssize_t length,
36+
Py_ssize_t width,
37+
ndarray[{{c_type}}, ndim=2] new_values,
38+
ndarray[uint8_t, ndim=2] new_mask):
39+
"""
40+
transform long sorted_values to wide new_values
41+
42+
Parameters
43+
----------
44+
values : typed ndarray
45+
mask : boolean ndarray
46+
stride : int
47+
length : int
48+
width : int
49+
new_values : typed ndarray
50+
result array
51+
new_mask : boolean ndarray
52+
result mask
53+
54+
"""
55+
56+
cdef:
57+
Py_ssize_t i, j, w, nulls, s, offset
58+
59+
{{if dtype == 'object'}}
60+
if True:
61+
{{else}}
62+
with nogil:
63+
{{endif}}
64+
65+
for i in range(stride):
66+
67+
nulls = 0
68+
for j in range(length):
69+
70+
for w in range(width):
71+
72+
offset = j * width + w
73+
74+
if mask[offset]:
75+
s = i * width + w
76+
new_values[j, s] = values[offset - nulls, i]
77+
new_mask[j, s] = 1
78+
else:
79+
nulls += 1
80+
81+
{{endfor}}

pandas/tests/frame/test_reshape.py

+8-5
Original file line numberDiff line numberDiff line change
@@ -121,19 +121,22 @@ def test_pivot_index_none(self):
121121
assert_frame_equal(result, expected)
122122

123123
def test_stack_unstack(self):
124-
stacked = self.frame.stack()
124+
f = self.frame.copy()
125+
f[:] = np.arange(np.prod(f.shape)).reshape(f.shape)
126+
127+
stacked = f.stack()
125128
stacked_df = DataFrame({'foo': stacked, 'bar': stacked})
126129

127130
unstacked = stacked.unstack()
128131
unstacked_df = stacked_df.unstack()
129132

130-
assert_frame_equal(unstacked, self.frame)
131-
assert_frame_equal(unstacked_df['bar'], self.frame)
133+
assert_frame_equal(unstacked, f)
134+
assert_frame_equal(unstacked_df['bar'], f)
132135

133136
unstacked_cols = stacked.unstack(0)
134137
unstacked_cols_df = stacked_df.unstack(0)
135-
assert_frame_equal(unstacked_cols.T, self.frame)
136-
assert_frame_equal(unstacked_cols_df['bar'].T, self.frame)
138+
assert_frame_equal(unstacked_cols.T, f)
139+
assert_frame_equal(unstacked_cols_df['bar'].T, f)
137140

138141
def test_unstack_fill(self):
139142

setup.py

+3
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ def is_platform_mac():
113113
_pxi_dep_template = {
114114
'algos': ['algos_common_helper.pxi.in', 'algos_groupby_helper.pxi.in',
115115
'algos_take_helper.pxi.in', 'algos_rank_helper.pxi.in'],
116+
'_reshape': ['reshape_helper.pxi.in'],
116117
'_join': ['join_helper.pxi.in', 'joins_func_helper.pxi.in'],
117118
'hashtable': ['hashtable_class_helper.pxi.in',
118119
'hashtable_func_helper.pxi.in'],
@@ -496,6 +497,8 @@ def pxd(name):
496497
algos={'pyxfile': 'algos',
497498
'pxdfiles': ['src/util', 'hashtable'],
498499
'depends': _pxi_dep['algos']},
500+
_reshape={'pyxfile': 'src/reshape',
501+
'depends': _pxi_dep['_reshape']},
499502
_join={'pyxfile': 'src/join',
500503
'pxdfiles': ['src/util', 'hashtable'],
501504
'depends': _pxi_dep['_join']},

0 commit comments

Comments
 (0)