Pandas objects can be thought of as enhanced versions of NumPy structured arrays in which the rows and columns are identified with labels rather than simple integer indices.
Let's introduce these three fundamental Pandas data structures: the Series, DataFrame, and Index.
import numpy as np
import pandas as pd
A Pandas Series is a one-dimensional array of indexed data:
data = pd.Series([0.25, 0.5, 0.75, 1.0])
data
0 0.25 1 0.50 2 0.75 3 1.00 dtype: float64
Series objects wrap both a sequence of values and a sequence of indices, which we can access with the values and index attributes.
The values are simply a familiar NumPy array:
data.values
array([0.25, 0.5 , 0.75, 1. ])
type(_)
numpy.ndarray
The index is an array-like object of type pd.Index:
data.index
RangeIndex(start=0, stop=4, step=1)
print(pd.RangeIndex.__doc__)
Immutable Index implementing a monotonic integer range.
RangeIndex is a memory-saving special case of Int64Index limited to
representing monotonic ranges. Using RangeIndex may in some instances
improve computing speed.
This is the default index type used
by DataFrame and Series when no explicit index is provided by the user.
Parameters
----------
start : int (default: 0), or other RangeIndex instance
If int and "stop" is not given, interpreted as "stop" instead.
stop : int (default: 0)
step : int (default: 1)
dtype : np.int64
Unused, accepted for homogeneity with other index types.
copy : bool, default False
Unused, accepted for homogeneity with other index types.
name : object, optional
Name to be stored in the index.
Attributes
----------
start
stop
step
Methods
-------
from_range
See Also
--------
Index : The base pandas Index type.
Int64Index : Index of int64 data.
Like with a NumPy array, data can be accessed by the associated index via the familiar Python square-bracket notation:
data[1]
0.5
data[1:3]
1 0.50 2 0.75 dtype: float64
type(_)
pandas.core.series.Series
Series as generalized NumPy array¶It may look like the Series object is basically interchangeable with a one-dimensional NumPy array.
The essential difference is the presence of the index: while the Numpy Array has an implicitly defined integer index used to access the values, the Pandas Series has an explicitly defined index associated with the values.
data = pd.Series([0.25, 0.5, 0.75, 1.0], index=['a', 'b', 'c', 'd'])
data
a 0.25 b 0.50 c 0.75 d 1.00 dtype: float64
data['b'] # item access works as expected
0.5
We can even use non-contiguous or non-sequential indices:
data = pd.Series([0.25, 0.5, 0.75, 1.0], index=[2, 5, 3, 7])
data
2 0.25 5 0.50 3 0.75 7 1.00 dtype: float64
data[5]
0.5
You can think of a Pandas Series a bit like a specialization of a Python dictionary.
A dictionary is a structure that maps arbitrary keys to a set of arbitrary values, and a Series is a structure which maps typed keys to a set of typed values:
population_dict = {'California': 38332521,
'Texas': 26448193,
'New York': 19651127,
'Florida': 19552860,
'Illinois': 12882135}
population = pd.Series(population_dict)
population
California 38332521 Texas 26448193 New York 19651127 Florida 19552860 Illinois 12882135 dtype: int64
type(_)
pandas.core.series.Series
population.index
Index(['California', 'Texas', 'New York', 'Florida', 'Illinois'], dtype='object')
population['California'] # typical dictionary-style item access
38332521
population['California':'Illinois'] # array-like slicing
California 38332521 Texas 26448193 New York 19651127 Florida 19552860 Illinois 12882135 dtype: int64
The next fundamental structure in Pandas is the DataFrame which can be thought of either as a generalization of a NumPy array, or as a specialization of a Python dictionary.
If a Series is an analog of a one-dimensional array with flexible indices, a DataFrame is an analog of a two-dimensional array with both flexible row indices and flexible column names.
You can think of a DataFrame as a sequence of aligned Series objects.
Here, by aligned we mean that they share the same index:
area_dict = {'California': 423967, 'Texas': 695662, 'New York': 141297,
'Florida': 170312, 'Illinois': 149995}
area = pd.Series(area_dict)
area
California 423967 Texas 695662 New York 141297 Florida 170312 Illinois 149995 dtype: int64
states = pd.DataFrame({'population': population, 'area': area})
states
| population | area | |
|---|---|---|
| California | 38332521 | 423967 |
| Texas | 26448193 | 695662 |
| New York | 19651127 | 141297 |
| Florida | 19552860 | 170312 |
| Illinois | 12882135 | 149995 |
type(_)
pandas.core.frame.DataFrame
states.index
Index(['California', 'Texas', 'New York', 'Florida', 'Illinois'], dtype='object')
Additionally, the DataFrame has a columns attribute, which is an Index object holding the column labels:
states.columns
Index(['population', 'area'], dtype='object')
type(_)
pandas.core.indexes.base.Index
Thus the DataFrame can be thought of as a generalization of a two-dimensional NumPy array, where both the rows and columns have a generalized index for accessing the data.
Similarly, we can also think of a DataFrame as a specialization of a dictionary.
Where a dictionary maps a key to a value, a DataFrame maps a column name to a Series of column data:
states['area'] # "feature"
California 423967 Texas 695662 New York 141297 Florida 170312 Illinois 149995 Name: area, dtype: int64
A DataFrame is a collection of Series objects, and a single-column DataFrame can be constructed from a single Series:
pd.DataFrame(population, columns=['population'])
| population | |
|---|---|
| California | 38332521 |
| Texas | 26448193 |
| New York | 19651127 |
| Florida | 19552860 |
| Illinois | 12882135 |
data = [{'a': i, 'b': 2 * i} for i in range(3)]
data
[{'a': 0, 'b': 0}, {'a': 1, 'b': 2}, {'a': 2, 'b': 4}]
pd.DataFrame(data)
| a | b | |
|---|---|---|
| 0 | 0 | 0 |
| 1 | 1 | 2 |
| 2 | 2 | 4 |
pd.DataFrame([{'a': 1, 'b': 2}, {'b': 3, 'c': 4}]) # Pandas will fill missing keys with ``NaN``
| a | b | c | |
|---|---|---|---|
| 0 | 1.0 | 2 | NaN |
| 1 | NaN | 3 | 4.0 |
Given a two-dimensional array of data, we can create a DataFrame with any specified column and index names:
np.random.rand(3, 2)
array([[0.30282887, 0.48376433],
[0.53588853, 0.97428136],
[0.94756199, 0.46766408]])
pd.DataFrame(np.random.rand(3, 2),
columns=['foo', 'bar'],
index=['a', 'b', 'c'])
| foo | bar | |
|---|---|---|
| a | 0.759907 | 0.458958 |
| b | 0.776779 | 0.767430 |
| c | 0.131552 | 0.740137 |
A = np.zeros(3, dtype=[('A', 'i8'), ('B', 'f8')])
A
array([(0, 0.), (0, 0.), (0, 0.)], dtype=[('A', '<i8'), ('B', '<f8')])
pd.DataFrame(A)
| A | B | |
|---|---|---|
| 0 | 0 | 0.0 |
| 1 | 0 | 0.0 |
| 2 | 0 | 0.0 |
This Index object is an interesting structure in itself, and it can be thought of either as an immutable array or as an ordered set (technically a multi-set, as Index objects may contain repeated values).
ind = pd.Index([2, 3, 5, 7, 11])
ind
Int64Index([2, 3, 5, 7, 11], dtype='int64')
The Index in many ways operates like an array.
ind[1]
3
ind[::2]
Int64Index([2, 5, 11], dtype='int64')
Index objects also have many of the attributes familiar from NumPy arrays:
ind.size, ind.shape, ind.ndim, ind.dtype,
(5, (5,), 1, dtype('int64'))
One difference is that indices are immutable–that is, they cannot be modified via the normal means:
ind[1] = 0
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) <ipython-input-60-906a9fa1424c> in <module> ----> 1 ind[1] = 0 ~/Developer/py-venvs/sphinx-venv/lib/python3.9/site-packages/pandas/core/indexes/base.py in __setitem__(self, key, value) 4275 @final 4276 def __setitem__(self, key, value): -> 4277 raise TypeError("Index does not support mutable operations") 4278 4279 def __getitem__(self, key): TypeError: Index does not support mutable operations
The Index object follows many of the conventions used by Python's built-in set data structure, so that unions, intersections, differences, and other combinations can be computed in a familiar way:
indA = pd.Index([1, 3, 5, 7, 9])
indB = pd.Index([2, 3, 5, 7, 11])
indA.intersection(indB) # intersection
Int64Index([3, 5, 7], dtype='int64')
indA.union(indB) # union
Int64Index([1, 2, 3, 5, 7, 9, 11], dtype='int64')
indA.symmetric_difference(indB) # symmetric difference
Int64Index([1, 2, 9, 11], dtype='int64')
To modify values in NumPy arrays we use indexing (e.g., arr[2, 1]), slicing (e.g., arr[:, 1:5]), masking (e.g., arr[arr > 0]), fancy indexing (e.g., arr[0, [1, 5]]), and combinations thereof (e.g., arr[:, [1, 5]]).
Here we'll look at similar means of accessing and modifying values in Pandas Series and DataFrame objects.
If you have used the NumPy patterns, the corresponding patterns in Pandas will feel very familiar, though there are a few quirks to be aware of.
A Series object acts in many ways like a one-dimensional NumPy array, and in many ways like a standard Python dictionary.
Like a dictionary, the Series object provides a mapping from a collection of keys to a collection of values:
data = pd.Series([0.25, 0.5, 0.75, 1.0], index=['a', 'b', 'c', 'd'])
data
a 0.25 b 0.50 c 0.75 d 1.00 dtype: float64
data['b'] # mnemonic indexing
0.5
'a' in data # dictionary-like Python expressions...
True
data.keys() # ...and methods.
Index(['a', 'b', 'c', 'd'], dtype='object')
list(data.items())
[('a', 0.25), ('b', 0.5), ('c', 0.75), ('d', 1.0)]
Series objects can even be modified with a dictionary-like syntax:
data['e'] = 1.25
data
a 0.25 b 0.50 c 0.75 d 1.00 e 1.25 dtype: float64
This easy mutability of the objects is a convenient feature: under the hood, Pandas is making decisions about memory layout and data copying that might need to take place.
A Series builds on this dictionary-like interface and provides array-style item selection via the same basic mechanisms as NumPy arrays – that is, slices, masking, and fancy indexing:
data['a':'c'] # slicing by explicit index
a 0.25 b 0.50 c 0.75 dtype: float64
data[0:2] # slicing by implicit integer index
a 0.25 b 0.50 dtype: float64
data[(data > 0.3) & (data < 0.8)] # masking
b 0.50 c 0.75 dtype: float64
because
(data > 0.3) & (data < 0.8)
a False b True c True d False e False dtype: bool
type(_)
pandas.core.series.Series
data[['a', 'e']] # fancy indexing
a 0.25 e 1.25 dtype: float64
Notice that when slicing with an explicit index (i.e., data['a':'c']), the final index is included in the slice, while when slicing with an implicit index (i.e., data[0:2]), the final index is excluded from the slice.
If your Series has an explicit integer index, an indexing operation such as data[1] will use the explicit indices, while a slicing operation like data[1:3] will use the implicit Python-style index.
data = pd.Series(['a', 'b', 'c'], index=[1, 3, 5])
data
1 a 3 b 5 c dtype: object
data[1] # explicit index when indexing
'a'
data[1:3] # implicit index when slicing
3 b 5 c dtype: object
Because of this potential confusion in the case of integer indexes, Pandas provides some special indexer attributes that explicitly expose certain indexing schemes.
These are not functional methods, but attributes that expose a particular slicing interface to the data in the Series.
First, the loc attribute allows indexing and slicing that always references the explicit index:
data.loc[1]
'a'
data.loc[1:3]
1 a 3 b dtype: object
The iloc attribute allows indexing and slicing that always references the implicit Python-style index:
data.iloc[1:3]
3 b 5 c dtype: object
A third indexing attribute, ix, is a hybrid of the two, and for Series objects is equivalent to standard []-based indexing.
The purpose of the ix indexer will become more apparent in the context of DataFrame objects.
Recall that a DataFrame acts in many ways like a two-dimensional or structured array, and in other ways like a dictionary of Series structures sharing the same index.
area = pd.Series({'California': 423967, 'Texas': 695662,
'New York': 141297, 'Florida': 170312,
'Illinois': 149995})
pop = pd.Series({'California': 38332521, 'Texas': 26448193,
'New York': 19651127, 'Florida': 19552860,
'Illinois': 12882135})
data = pd.DataFrame({'area':area, 'pop':pop})
data
| area | pop | |
|---|---|---|
| California | 423967 | 38332521 |
| Texas | 695662 | 26448193 |
| New York | 141297 | 19651127 |
| Florida | 170312 | 19552860 |
| Illinois | 149995 | 12882135 |
data['area'] # columns can be accessed via dict-style indexing
California 423967 Texas 695662 New York 141297 Florida 170312 Illinois 149995 Name: area, dtype: int64
data.area # alternatively, use attribute-style access with column names
California 423967 Texas 695662 New York 141297 Florida 170312 Illinois 149995 Name: area, dtype: int64
this dictionary-style syntax can also be used to modify the object, in this case adding a new column:
data['density'] = data['pop'] / data['area']
data
| area | pop | density | |
|---|---|---|---|
| California | 423967 | 38332521 | 90.413926 |
| Texas | 695662 | 26448193 | 38.018740 |
| New York | 141297 | 19651127 | 139.076746 |
| Florida | 170312 | 19552860 | 114.806121 |
| Illinois | 149995 | 12882135 | 85.883763 |
DataFrame can also be viewed as an enhanced two-dimensional array:
data.values # examine the raw underlying data array
array([[4.23967000e+05, 3.83325210e+07, 9.04139261e+01],
[6.95662000e+05, 2.64481930e+07, 3.80187404e+01],
[1.41297000e+05, 1.96511270e+07, 1.39076746e+02],
[1.70312000e+05, 1.95528600e+07, 1.14806121e+02],
[1.49995000e+05, 1.28821350e+07, 8.58837628e+01]])
data.values.T
array([[4.23967000e+05, 6.95662000e+05, 1.41297000e+05, 1.70312000e+05,
1.49995000e+05],
[3.83325210e+07, 2.64481930e+07, 1.96511270e+07, 1.95528600e+07,
1.28821350e+07],
[9.04139261e+01, 3.80187404e+01, 1.39076746e+02, 1.14806121e+02,
8.58837628e+01]])
type(_)
numpy.ndarray
data.T # transpose the full DataFrame object
| California | Texas | New York | Florida | Illinois | |
|---|---|---|---|---|---|
| area | 4.239670e+05 | 6.956620e+05 | 1.412970e+05 | 1.703120e+05 | 1.499950e+05 |
| pop | 3.833252e+07 | 2.644819e+07 | 1.965113e+07 | 1.955286e+07 | 1.288214e+07 |
| density | 9.041393e+01 | 3.801874e+01 | 1.390767e+02 | 1.148061e+02 | 8.588376e+01 |
type(_)
pandas.core.frame.DataFrame
data.values[0] # passing a single index to an array accesses a row
array([4.23967000e+05, 3.83325210e+07, 9.04139261e+01])
data['area'] # assing a single "index" to access a column
California 423967 Texas 695662 New York 141297 Florida 170312 Illinois 149995 Name: area, dtype: int64
Using the iloc indexer, we can index the underlying array as if it is a simple NumPy array (using the implicit Python-style index)
data.iloc[:3, :2]
| area | pop | |
|---|---|---|
| California | 423967 | 38332521 |
| Texas | 695662 | 26448193 |
| New York | 141297 | 19651127 |
Similarly, using the loc indexer we can index the underlying data in an array-like style but using the explicit index and column names:
data.loc[:'Illinois', :'pop']
| area | pop | |
|---|---|---|
| California | 423967 | 38332521 |
| Texas | 695662 | 26448193 |
| New York | 141297 | 19651127 |
| Florida | 170312 | 19552860 |
| Illinois | 149995 | 12882135 |
Any of the familiar NumPy-style data access patterns can be used within these indexers.
data.loc[data.density > 100, ['pop', 'density']]
| pop | density | |
|---|---|---|
| New York | 19651127 | 139.076746 |
| Florida | 19552860 | 114.806121 |
Any of these indexing conventions may also be used to set or modify values; this is done in the standard way that you might be accustomed to from working with NumPy:
data.iloc[0, 2] = 90
data
| area | pop | density | |
|---|---|---|---|
| California | 423967 | 38332521 | 90.000000 |
| Texas | 695662 | 26448193 | 38.018740 |
| New York | 141297 | 19651127 | 139.076746 |
| Florida | 170312 | 19552860 | 114.806121 |
| Illinois | 149995 | 12882135 | 85.883763 |
data['Florida':'Illinois'] # *slicing* refers to rows
| area | pop | density | |
|---|---|---|---|
| Florida | 170312 | 19552860 | 114.806121 |
| Illinois | 149995 | 12882135 | 85.883763 |
data[data.density > 100] # direct masking operations are also interpreted row-wise
| area | pop | density | |
|---|---|---|---|
| New York | 141297 | 19651127 | 139.076746 |
| Florida | 170312 | 19552860 | 114.806121 |
One of the essential pieces of NumPy is the ability to perform quick element-wise operations, both with basic arithmetic (addition, subtraction, multiplication, etc.) and with more sophisticated operations (trigonometric functions, exponential and logarithmic functions, etc.).
Pandas inherits much of this functionality from NumPy.
Pandas includes a couple useful twists, however: for unary operations like negation and trigonometric functions, these ufuncs will preserve index and column labels in the output, and for binary operations such as addition and multiplication, Pandas will automatically align indices when passing the objects to the ufunc.
Because Pandas is designed to work with NumPy, any NumPy ufunc will work on Pandas Series and DataFrame objects:
rng = np.random.RandomState(42)
ser = pd.Series(rng.randint(0, 10, 4))
ser
0 6 1 3 2 7 3 4 dtype: int64
rng.randint(0, 10, (3, 4))
array([[1, 7, 5, 1],
[4, 0, 9, 5],
[8, 0, 9, 2]])
df = pd.DataFrame(rng.randint(0, 10, (3, 4)), columns=['A', 'B', 'C', 'D'])
df
| A | B | C | D | |
|---|---|---|---|---|
| 0 | 6 | 3 | 8 | 2 |
| 1 | 4 | 2 | 6 | 4 |
| 2 | 8 | 6 | 1 | 3 |
If we apply a NumPy ufunc on either of these objects, the result will be another Pandas object with the indices preserved:
np.exp(ser)
0 403.428793 1 20.085537 2 1096.633158 3 54.598150 dtype: float64
type(_)
pandas.core.series.Series
np.sin(df * np.pi / 4) # a slightly more complex calculation
| A | B | C | D | |
|---|---|---|---|---|
| 0 | -1.000000e+00 | 0.707107 | -2.449294e-16 | 1.000000e+00 |
| 1 | 1.224647e-16 | 1.000000 | -1.000000e+00 | 1.224647e-16 |
| 2 | -2.449294e-16 | -1.000000 | 7.071068e-01 | 7.071068e-01 |
type(_)
pandas.core.frame.DataFrame
For binary operations on two Series or DataFrame objects, Pandas will align indices in the process of performing the operation.
Suppose we are combining two different data sources, and find only the top three US states by area and the top three US states by population:
area = pd.Series({'Alaska': 1723337, 'Texas': 695662, 'California': 423967}, name='area')
population = pd.Series({'California': 38332521, 'Texas': 26448193, 'New York': 19651127}, name='population')
population / area
Alaska NaN California 90.413926 New York NaN Texas 38.018740 dtype: float64
The resulting array contains the union of indices of the two input arrays, which could be determined using standard Python set arithmetic on these indices:
area.index.union(population.index) # this does create a new index and doesn't modify in place.
Index(['Alaska', 'California', 'New York', 'Texas'], dtype='object')
area.index
Index(['Alaska', 'Texas', 'California'], dtype='object')
Any item for which one or the other does not have an entry is marked with NaN, or "Not a Number," which is how Pandas marks missing data
.
This index matching is implemented this way for any of Python's built-in arithmetic expressions; any missing values are filled in with NaN by default:
A = pd.Series([2, 4, 6], index=[0, 1, 2])
B = pd.Series([1, 3, 5], index=[1, 2, 3])
A + B
0 NaN 1 5.0 2 9.0 3 NaN dtype: float64
If using NaN values is not the desired behavior, the fill value can be modified using appropriate object methods in place of the operators:
A.add(B, fill_value=0)
0 2.0 1 5.0 2 9.0 3 5.0 dtype: float64
A similar type of alignment takes place for both columns and indices when performing operations on DataFrames:
A = pd.DataFrame(rng.randint(0, 20, (2, 2)), columns=list('AB'))
A
| A | B | |
|---|---|---|
| 0 | 13 | 17 |
| 1 | 8 | 1 |
B = pd.DataFrame(rng.randint(0, 10, (3, 3)), columns=list('BAC'))
B
| B | A | C | |
|---|---|---|---|
| 0 | 1 | 5 | 5 |
| 1 | 9 | 3 | 5 |
| 2 | 1 | 9 | 1 |
A + B
| A | B | C | |
|---|---|---|---|
| 0 | 18.0 | 18.0 | NaN |
| 1 | 11.0 | 10.0 | NaN |
| 2 | NaN | NaN | NaN |
fill = A.stack().mean()
fill
9.75
A.add(B, fill_value=fill)
| A | B | C | |
|---|---|---|---|
| 0 | 18.00 | 18.00 | 14.75 |
| 1 | 11.00 | 10.00 | 14.75 |
| 2 | 18.75 | 10.75 | 10.75 |
The following table lists Python operators and their equivalent Pandas object methods:
| Python Operator | Pandas Method(s) |
|---|---|
+ |
add() |
- |
sub(), subtract() |
* |
mul(), multiply() |
/ |
truediv(), div(), divide() |
// |
floordiv() |
% |
mod() |
** |
pow() |
When performing operations between a DataFrame and a Series, the index and column alignment is similarly maintained.
Operations between a DataFrame and a Series are similar to operations between a two-dimensional and one-dimensional NumPy array.
A = rng.randint(10, size=(3, 4))
A
array([[3, 8, 2, 4],
[2, 6, 4, 8],
[6, 1, 3, 8]])
type(A)
numpy.ndarray
A - A[0]
array([[ 0, 0, 0, 0],
[-1, -2, 2, 4],
[ 3, -7, 1, 4]])
According to NumPy's broadcasting rules , subtraction between a two-dimensional array and one of its rows is applied row-wise.
In Pandas, the convention similarly operates row-wise by default:
df = pd.DataFrame(A, columns=list('QRST'))
df - df.iloc[0]
| Q | R | S | T | |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 |
| 1 | -1 | -2 | 2 | 4 |
| 2 | 3 | -7 | 1 | 4 |
If you would instead like to operate column-wise you have to specify the axis keyword:
df.subtract(df['R'], axis=0)
| Q | R | S | T | |
|---|---|---|---|---|
| 0 | -5 | 0 | -6 | -4 |
| 1 | -4 | 0 | -2 | 2 |
| 2 | 5 | 0 | 2 | 7 |
The difference between data found in many tutorials and data in the real world is that real-world data is rarely clean and homogeneous. In particular, many interesting datasets will have some amount of data missing.
To make matters even more complicated, different data sources may indicate missing data in different ways.
To indicate the presence of missing data in a table or DataFrame we can use two strategies: using a mask that globally indicates missing values, or choosing a sentinel value that indicates a missing entry.
In the masking approach, the mask might be an entirely separate Boolean array, or it may involve appropriation of one bit in the data representation to locally indicate the null status of a value.
In the sentinel approach, the sentinel value could be some data-specific convention, such as indicating a missing integer value with -9999 or some rare bit pattern, or it could be a more global convention, such as indicating a missing floating-point value with NaN (Not a Number).
None of these approaches is without trade-offs: use of a separate mask array requires allocation of an additional Boolean array. A sentinel value reduces the range of valid values that can be represented, and may require extra (often non-optimized) logic in CPU and GPU arithmetic.
The way in which Pandas handles missing values is constrained by its reliance on the NumPy package, which does not have a built-in notion of NA values for non-floating-point data types.
NumPy does have support for masked arrays – that is, arrays that have a separate Boolean mask array attached for marking data as "good" or "bad." Pandas could have derived from this, but the overhead in both storage, computation, and code maintenance makes that an unattractive choice.
With these constraints in mind, Pandas chose to use sentinels for missing data, and further chose to use two already-existing Python null values: the special floating-point NaN value, and the Python None object.
None: Pythonic missing data¶The first sentinel value used by Pandas is None, a Python singleton object that is often used for missing data in Python code.
Because it is a Python object, None cannot be used in any arbitrary NumPy/Pandas array, but only in arrays with data type 'object' (i.e., arrays of Python objects):
vals1 = np.array([1, None, 3, 4])
vals1
array([1, None, 3, 4], dtype=object)
Any operations on the data will be done at the Python level, with much more overhead than the typically fast operations seen for arrays with native types:
for dtype in ['object', 'int']:
print("dtype =", dtype)
%timeit np.arange(1E6, dtype=dtype).sum()
print()
dtype = object 81.8 ms ± 125 µs per loop (mean ± std. dev. of 7 runs, 10 loops each) dtype = int 1.87 ms ± 34.1 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
The use of Python objects in an array also means that if you perform aggregations like sum() or min() across an array with a None value, you will generally get an error:
vals1.sum()
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) <ipython-input-94-30a3fc8c6726> in <module> ----> 1 vals1.sum() ~/Developer/venvs/py-ml/lib/python3.8/site-packages/numpy/core/_methods.py in _sum(a, axis, dtype, out, keepdims, initial, where) 45 def _sum(a, axis=None, dtype=None, out=None, keepdims=False, 46 initial=_NoValue, where=True): ---> 47 return umr_sum(a, axis, dtype, out, keepdims, initial, where) 48 49 def _prod(a, axis=None, dtype=None, out=None, keepdims=False, TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'
NaN: Missing numerical data¶The other missing data representation, NaN (acronym for Not a Number), is different; it is a special floating-point value recognized by all systems that use the standard IEEE floating-point representation:
vals2 = np.array([1, np.nan, 3, 4])
vals2.dtype
dtype('float64')
1 + np.nan, 0 * np.nan
(nan, nan)
vals2.sum(), vals2.min(), vals2.max()
(nan, nan, nan)
NumPy does provide some special aggregations that will ignore these missing values:
np.nansum(vals2), np.nanmin(vals2), np.nanmax(vals2)
(8.0, 1.0, 4.0)
NaN and None both have their place, and Pandas is built to handle the two of them nearly interchangeably, converting between them where appropriate:
pd.Series([1, np.nan, 2, None])
0 1.0 1 NaN 2 2.0 3 NaN dtype: float64
The following table lists the upcasting conventions in Pandas when NA values are introduced:
| Typeclass | Conversion When Storing NAs | NA Sentinel Value |
|---|---|---|
floating |
No change | np.nan |
object |
No change | None or np.nan |
integer |
Cast to float64 |
np.nan |
boolean |
Cast to object |
None or np.nan |
Keep in mind that in Pandas, string data is always stored with an object dtype.
As we have seen, Pandas treats None and NaN as essentially interchangeable for indicating missing or null values.
To facilitate this convention, there are several useful methods for detecting, removing, and replacing null values in Pandas data structures.
They are:
isnull(): Generate a boolean mask indicating missing valuesnotnull(): Opposite of isnull()dropna(): Return a filtered version of the datafillna(): Return a copy of the data with missing values filled or imputedPandas data structures have two useful methods for detecting null data: isnull() and notnull().
Either one will return a Boolean mask over the data:
data = pd.Series([1, np.nan, 'hello', None])
data.isnull()
0 False 1 True 2 False 3 True dtype: bool
In addition to the masking used before, there are the convenience methods, dropna()
(which removes NA values) and fillna() (which fills in NA values):
data.dropna()
0 1 2 hello dtype: object
For a DataFrame, there are more options:
df = pd.DataFrame([[1, np.nan, 2],
[2, 3, 5],
[np.nan, 4, 6]])
df
| 0 | 1 | 2 | |
|---|---|---|---|
| 0 | 1.0 | NaN | 2 |
| 1 | 2.0 | 3.0 | 5 |
| 2 | NaN | 4.0 | 6 |
df.dropna() # drop all rows in which *any* null value is present
| 0 | 1 | 2 | |
|---|---|---|---|
| 1 | 2.0 | 3.0 | 5 |
df.dropna(axis='columns') # drop NA values from all columns containing a null value
| 2 | |
|---|---|
| 0 | 2 |
| 1 | 5 |
| 2 | 6 |
The default is how='any', such that any row or column (depending on the axis keyword) containing a null value will be dropped.
df[3] = np.nan
df
| 0 | 1 | 2 | 3 | |
|---|---|---|---|---|
| 0 | 1.0 | NaN | 2 | NaN |
| 1 | 2.0 | 3.0 | 5 | NaN |
| 2 | NaN | 4.0 | 6 | NaN |
You can also specify how='all', which will only drop rows/columns that are all null values:
df.dropna(axis='columns', how='all')
| 0 | 1 | 2 | |
|---|---|---|---|
| 0 | 1.0 | NaN | 2 |
| 1 | 2.0 | 3.0 | 5 |
| 2 | NaN | 4.0 | 6 |
The thresh parameter lets you specify a minimum number of non-null values for the row/column to be kept:
df.dropna(axis='rows', thresh=3)
| 0 | 1 | 2 | 3 | |
|---|---|---|---|---|
| 1 | 2.0 | 3.0 | 5 | NaN |
Sometimes rather than dropping NA values, you'd rather replace them with a valid value.
This value might be a single number like zero, or it might be some sort of imputation or interpolation from the good values.
You could do this in-place using the isnull() method as a mask, but because it is such a common operation Pandas provides the fillna() method, which returns a copy of the array with the null values replaced.
data = pd.Series([1, np.nan, 2, None, 3], index=list('abcde'))
data
a 1.0 b NaN c 2.0 d NaN e 3.0 dtype: float64
data.fillna(0) # fill NA entries with a single value
a 1.0 b 0.0 c 2.0 d 0.0 e 3.0 dtype: float64
data.fillna(method='ffill') # specify a forward-fill to propagate the previous value forward
a 1.0 b 1.0 c 2.0 d 2.0 e 3.0 dtype: float64
data.fillna(method='bfill') # specify a back-fill to propagate the next values backward
a 1.0 b 2.0 c 2.0 d 3.0 e 3.0 dtype: float64
For DataFrames, the options are similar, but we can also specify an axis along which the fills take place:
df
| 0 | 1 | 2 | 3 | |
|---|---|---|---|---|
| 0 | 1.0 | NaN | 2 | NaN |
| 1 | 2.0 | 3.0 | 5 | NaN |
| 2 | NaN | 4.0 | 6 | NaN |
df.fillna(method='ffill', axis=1)
| 0 | 1 | 2 | 3 | |
|---|---|---|---|---|
| 0 | 1.0 | 1.0 | 2.0 | 2.0 |
| 1 | 2.0 | 3.0 | 5.0 | 5.0 |
| 2 | NaN | 4.0 | 6.0 | 6.0 |
Up to this point we've been focused primarily on one-dimensional and two-dimensional data, stored in Pandas Series and DataFrame objects, respectively.
Often it is useful to go beyond this and store higher-dimensional data–that is, data indexed by more than one or two keys.
A far more common pattern in practice is to make use of hierarchical indexing (also known as multi-indexing) to incorporate multiple index levels within a single index.
In this way, higher-dimensional data can be compactly represented within the familiar one-dimensional Series and two-dimensional DataFrame objects.
Let's start by considering how we might represent two-dimensional data within a one-dimensional Series.
Suppose you would like to track data about states from two different years. Using the Pandas tools we've already covered, you might be tempted to simply use Python tuples as keys:
index = [('California', 2000), ('California', 2010),
('New York', 2000), ('New York', 2010),
('Texas', 2000), ('Texas', 2010)]
populations = [33871648, 37253956,
18976457, 19378102,
20851820, 25145561]
pop = pd.Series(populations, index=index)
pop
(California, 2000) 33871648 (California, 2010) 37253956 (New York, 2000) 18976457 (New York, 2010) 19378102 (Texas, 2000) 20851820 (Texas, 2010) 25145561 dtype: int64
If you need to select all values from 2010, you'll need to do some messy (and potentially slow) munging to make it happen:
pop[[i for i in pop.index if i[1] == 2010]]
(California, 2010) 37253956 (New York, 2010) 19378102 (Texas, 2010) 25145561 dtype: int64
Our tuple-based indexing is essentially a rudimentary multi-index, and the Pandas MultiIndex type gives us the type of operations we wish to have:
index = pd.MultiIndex.from_tuples(index)
index
MultiIndex([('California', 2000),
('California', 2010),
( 'New York', 2000),
( 'New York', 2010),
( 'Texas', 2000),
( 'Texas', 2010)],
)
type(_)
pandas.core.indexes.multi.MultiIndex
A MultiIndex contains multiple levels of indexing–in this case, the state names and the years, as well as multiple labels for each data point which encode these levels.
If we re-index our series with this MultiIndex, we see the hierarchical representation of the data:
pop = pop.reindex(index)
pop
California 2000 33871648
2010 37253956
New York 2000 18976457
2010 19378102
Texas 2000 20851820
2010 25145561
dtype: int64
Here the first two columns of the Series representation show the multiple index values, while the third column shows the data.
Notice that some entries are missing in the first column: in this multi-index representation, any blank entry indicates the same value as the line above it.
Now to access all data for which the second index is 2010, we can simply use the Pandas slicing notation:
pop[:, 2010]
California 37253956 New York 19378102 Texas 25145561 dtype: int64
The result is a singly indexed array with just the keys we're interested in. This syntax is much more convenient (and the operation is much more efficient!) than the home-spun tuple-based multi-indexing solution that we started with.
We could have stored the same data using a simple DataFrame with index and column labels; in fact, Pandas is built with this equivalence in mind.
The unstack() method will quickly convert a multiply indexed Series into a conventionally indexed DataFrame:
pop_df = pop.unstack()
pop_df
| 2000 | 2010 | |
|---|---|---|
| California | 33871648 | 37253956 |
| New York | 18976457 | 19378102 |
| Texas | 20851820 | 25145561 |
type(pop_df)
pandas.core.frame.DataFrame
Naturally, the stack() method provides the opposite operation:
pop_df.stack()
California 2000 33871648
2010 37253956
New York 2000 18976457
2010 19378102
Texas 2000 20851820
2010 25145561
dtype: int64
Seeing this, you might wonder why would we would bother with hierarchical indexing at all.
The reason is simple: just as we were able to use multi-indexing to represent two-dimensional data within a one-dimensional Series, we can also use it to represent data of three or more dimensions in a Series or DataFrame.
Each extra level in a multi-index represents an extra dimension of data; taking advantage of this property gives us much more flexibility in the types of data we can represent.
Concretely, we might want to add another column of demographic data for each state at each year (say, population under 18) ; with a MultiIndex this is as easy as adding another column to the DataFrame:
pop_df = pd.DataFrame({'total': pop,
'under18': [9267089, 9284094,
4687374, 4318033,
5906301, 6879014]})
pop_df
| total | under18 | ||
|---|---|---|---|
| California | 2000 | 33871648 | 9267089 |
| 2010 | 37253956 | 9284094 | |
| New York | 2000 | 18976457 | 4687374 |
| 2010 | 19378102 | 4318033 | |
| Texas | 2000 | 20851820 | 5906301 |
| 2010 | 25145561 | 6879014 |
In addition, all the ufuncs work with hierarchical indices as well:
f_u18 = pop_df['under18'] / pop_df['total']
f_u18.unstack()
| 2000 | 2010 | |
|---|---|---|
| California | 0.273594 | 0.249211 |
| New York | 0.247010 | 0.222831 |
| Texas | 0.283251 | 0.273568 |
The most straightforward way to construct a multiply indexed Series or DataFrame is to simply pass a list of two or more index arrays to the constructor:
df = pd.DataFrame(np.random.rand(4, 2),
index=[['a', 'a', 'b', 'b'], [1, 2, 1, 2]],
columns=['data1', 'data2'])
df
| data1 | data2 | ||
|---|---|---|---|
| a | 1 | 0.482545 | 0.352967 |
| 2 | 0.574280 | 0.063582 | |
| b | 1 | 0.102271 | 0.569372 |
| 2 | 0.753026 | 0.194597 |
Similarly, if you pass a dictionary with appropriate tuples as keys, Pandas will automatically recognize this and use a MultiIndex by default:
data = {('California', 2000): 33871648,
('California', 2010): 37253956,
('Texas', 2000): 20851820,
('Texas', 2010): 25145561,
('New York', 2000): 18976457,
('New York', 2010): 19378102}
pd.Series(data)
California 2000 33871648
2010 37253956
Texas 2000 20851820
2010 25145561
New York 2000 18976457
2010 19378102
dtype: int64
For more flexibility in how the index is constructed, you can instead use the class method constructors available in the pd.MultiIndex.
You can construct the MultiIndex from a simple list of arrays giving the index values within each level:
pd.MultiIndex.from_arrays([['a', 'a', 'b', 'b'], [1, 2, 1, 2]])
MultiIndex([('a', 1),
('a', 2),
('b', 1),
('b', 2)],
)
You can even construct it from a Cartesian product of single indices:
pd.MultiIndex.from_product([['a', 'b'], [1, 2]])
MultiIndex([('a', 1),
('a', 2),
('b', 1),
('b', 2)],
)
Sometimes it is convenient to name the levels of the MultiIndex.
This can be accomplished by passing the names argument to any of the above MultiIndex constructors, or by setting the names attribute of the index after the fact:
pop.index.names = ['state', 'year']
pop
state year
California 2000 33871648
2010 37253956
New York 2000 18976457
2010 19378102
Texas 2000 20851820
2010 25145561
dtype: int64
In a DataFrame, the rows and columns are completely symmetric, and just as the rows can have multiple levels of indices, the columns can have multiple levels as well:
index = pd.MultiIndex.from_product([[2013, 2014], [1, 2]], names=['year', 'visit'])
columns = pd.MultiIndex.from_product([['Bob', 'Guido', 'Sue'], ['HR', 'Temp']], names=['subject', 'type'])
data = np.round(np.random.randn(4, 6), 1) # mock some data
data[:, ::2] *= 10
data += 37
health_data = pd.DataFrame(data, index=index, columns=columns)
health_data # create the DataFrame
| subject | Bob | Guido | Sue | ||||
|---|---|---|---|---|---|---|---|
| type | HR | Temp | HR | Temp | HR | Temp | |
| year | visit | ||||||
| 2013 | 1 | 48.0 | 38.1 | 19.0 | 38.4 | 52.0 | 38.8 |
| 2 | 34.0 | 38.0 | 37.0 | 36.9 | 31.0 | 37.6 | |
| 2014 | 1 | 41.0 | 37.0 | 52.0 | 38.9 | 38.0 | 37.4 |
| 2 | 47.0 | 36.9 | 46.0 | 36.4 | 42.0 | 36.6 | |
This is fundamentally four-dimensional data, where the dimensions are the subject, the measurement type, the year, and the visit number; we can index the top-level column by the person's name and get a full DataFrame containing just that person's information:
health_data['Guido']
| type | HR | Temp | |
|---|---|---|---|
| year | visit | ||
| 2013 | 1 | 19.0 | 38.4 |
| 2 | 37.0 | 36.9 | |
| 2014 | 1 | 52.0 | 38.9 |
| 2 | 46.0 | 36.4 |
pop
state year
California 2000 33871648
2010 37253956
New York 2000 18976457
2010 19378102
Texas 2000 20851820
2010 25145561
dtype: int64
pop['California', 2000] # access single elements by indexing with multiple terms
33871648
The MultiIndex also supports partial indexing, or indexing just one of the levels in the index.
The result is another Series, with the lower-level indices maintained:
pop['California']
year 2000 33871648 2010 37253956 dtype: int64
Other types of indexing and selection could be based either on Boolean masks:
pop[pop > 22000000]
state year
California 2000 33871648
2010 37253956
Texas 2010 25145561
dtype: int64
or on fancy indexing:
pop[['California', 'Texas']]
state year
California 2000 33871648
2010 37253956
Texas 2000 20851820
2010 25145561
dtype: int64
A multiply indexed DataFrame behaves in a similar manner:
health_data
| subject | Bob | Guido | Sue | ||||
|---|---|---|---|---|---|---|---|
| type | HR | Temp | HR | Temp | HR | Temp | |
| year | visit | ||||||
| 2013 | 1 | 48.0 | 38.1 | 19.0 | 38.4 | 52.0 | 38.8 |
| 2 | 34.0 | 38.0 | 37.0 | 36.9 | 31.0 | 37.6 | |
| 2014 | 1 | 41.0 | 37.0 | 52.0 | 38.9 | 38.0 | 37.4 |
| 2 | 47.0 | 36.9 | 46.0 | 36.4 | 42.0 | 36.6 | |
Remember that columns are primary in a DataFrame, and the syntax used for multiply indexed Series applies to the columns.
We can recover Guido's heart rate data with a simple operation:
health_data['Guido', 'HR']
year visit
2013 1 19.0
2 37.0
2014 1 52.0
2 46.0
Name: (Guido, HR), dtype: float64
Also, as with the single-index case, we can use the loc, iloc, and ix indexers:
health_data.iloc[:2, :2]
| subject | Bob | ||
|---|---|---|---|
| type | HR | Temp | |
| year | visit | ||
| 2013 | 1 | 48.0 | 38.1 |
| 2 | 34.0 | 38.0 | |
These indexers provide an array-like view of the underlying two-dimensional data, but each individual index in loc or iloc can be passed a tuple of multiple indices:
health_data.loc[:, ('Bob', 'HR')]
year visit
2013 1 48.0
2 34.0
2014 1 41.0
2 47.0
Name: (Bob, HR), dtype: float64
One of the keys to working with multiply indexed data is knowing how to effectively transform the data.
There are a number of operations that will preserve all the information in the dataset, but rearrange it for the purposes of various computations.
We saw a brief example of this in the stack() and unstack() methods, but there are many more ways to finely control the rearrangement of data between hierarchical indices and columns.
Earlier, we briefly mentioned a caveat, but we should emphasize it more here.
Many of the MultiIndex slicing operations will fail if the index is not sorted.
We'll start by creating some simple multiply indexed data where the indices are not lexographically sorted:
index = pd.MultiIndex.from_product([['a', 'c', 'b'], [1, 2]])
data = pd.Series(np.random.rand(6), index=index)
data.index.names = ['char', 'int']
data
char int
a 1 0.002105
2 0.280923
c 1 0.008604
2 0.631968
b 1 0.072270
2 0.273800
dtype: float64
try:
data['a':'b'] # try to take a partial slice of this index
except KeyError as e:
print(type(e))
print(e)
<class 'pandas.errors.UnsortedIndexError'> 'Key length (1) was greater than MultiIndex lexsort depth (0)'
This is the result of the MultiIndex not being sorted; in general, partial slices and other similar operations require the levels in the MultiIndex to be in sorted (i.e., lexographical) order.
Pandas provides a number of convenience routines to perform this type of sorting; examples are the sort_index() and sortlevel() methods of the DataFrame.
data = data.sort_index()
data
char int
a 1 0.002105
2 0.280923
b 1 0.072270
2 0.273800
c 1 0.008604
2 0.631968
dtype: float64
With the index sorted in this way, partial slicing will work as expected:
data['a':'b']
char int
a 1 0.002105
2 0.280923
b 1 0.072270
2 0.273800
dtype: float64
As we saw briefly before, it is possible to convert a dataset from a stacked multi-index to a simple two-dimensional representation, optionally specifying the level to use:
pop.unstack(level=0)
| state | California | New York | Texas |
|---|---|---|---|
| year | |||
| 2000 | 33871648 | 18976457 | 20851820 |
| 2010 | 37253956 | 19378102 | 25145561 |
pop.unstack(level=1)
| year | 2000 | 2010 |
|---|---|---|
| state | ||
| California | 33871648 | 37253956 |
| New York | 18976457 | 19378102 |
| Texas | 20851820 | 25145561 |
The opposite of unstack() is stack(), which here can be used to recover the original series:
pop.unstack().stack()
state year
California 2000 33871648
2010 37253956
New York 2000 18976457
2010 19378102
Texas 2000 20851820
2010 25145561
dtype: int64
Another way to rearrange hierarchical data is to turn the index labels into columns; this can be accomplished with the reset_index method.
Calling this on the population dictionary will result in a DataFrame with a state and year column holding the information that was formerly in the index.
pop_flat = pop.reset_index(name='population') # specify the name of the data for the column
pop_flat
| state | year | population | |
|---|---|---|---|
| 0 | California | 2000 | 33871648 |
| 1 | California | 2010 | 37253956 |
| 2 | New York | 2000 | 18976457 |
| 3 | New York | 2010 | 19378102 |
| 4 | Texas | 2000 | 20851820 |
| 5 | Texas | 2010 | 25145561 |
Often when working with data in the real world, the raw input data looks like this and it's useful to build a MultiIndex from the column values.
This can be done with the set_index method of the DataFrame, which returns a multiply indexed DataFrame:
pop_flat.set_index(['state', 'year'])
| population | ||
|---|---|---|
| state | year | |
| California | 2000 | 33871648 |
| 2010 | 37253956 | |
| New York | 2000 | 18976457 |
| 2010 | 19378102 | |
| Texas | 2000 | 20851820 |
| 2010 | 25145561 |
We've previously seen that Pandas has built-in data aggregation methods, such as mean(), sum(), and max().
For hierarchically indexed data, these can be passed a level parameter that controls which subset of the data the aggregate is computed on.
health_data
| subject | Bob | Guido | Sue | ||||
|---|---|---|---|---|---|---|---|
| type | HR | Temp | HR | Temp | HR | Temp | |
| year | visit | ||||||
| 2013 | 1 | 48.0 | 38.1 | 19.0 | 38.4 | 52.0 | 38.8 |
| 2 | 34.0 | 38.0 | 37.0 | 36.9 | 31.0 | 37.6 | |
| 2014 | 1 | 41.0 | 37.0 | 52.0 | 38.9 | 38.0 | 37.4 |
| 2 | 47.0 | 36.9 | 46.0 | 36.4 | 42.0 | 36.6 | |
Perhaps we'd like to average-out the measurements in the two visits each year. We can do this by naming the index level we'd like to explore, in this case the year:
data_mean = health_data.mean(level='year')
data_mean
| subject | Bob | Guido | Sue | |||
|---|---|---|---|---|---|---|
| type | HR | Temp | HR | Temp | HR | Temp |
| year | ||||||
| 2013 | 41.0 | 38.05 | 28.0 | 37.65 | 41.5 | 38.2 |
| 2014 | 44.0 | 36.95 | 49.0 | 37.65 | 40.0 | 37.0 |
By further making use of the axis keyword, we can take the mean among levels on the columns as well:
data_mean.mean(axis=1, level='type')
| type | HR | Temp |
|---|---|---|
| year | ||
| 2013 | 36.833333 | 37.966667 |
| 2014 | 44.333333 | 37.200000 |
Some of the most interesting studies of data come from combining different data sources.
These operations can involve anything from very straightforward concatenation of two different datasets, to more complicated database-style joins and merges that correctly handle any overlaps between the datasets.
Series and DataFrames are built with this type of operation in mind, and Pandas includes functions and methods that make this sort of data wrangling fast and straightforward.
Here we'll take a look at simple concatenation of Series and DataFrames with the pd.concat function; later we'll dive into more sophisticated in-memory merges and joins implemented in Pandas.
For convenience, we'll define this function which creates a DataFrame of a particular form that will be useful below:
def make_df(cols, ind):
"""Quickly make a DataFrame"""
data = {c: [str(c) + str(i) for i in ind]
for c in cols}
return pd.DataFrame(data, ind)
# example DataFrame
make_df('ABC', range(3))
| A | B | C | |
|---|---|---|---|
| 0 | A0 | B0 | C0 |
| 1 | A1 | B1 | C1 |
| 2 | A2 | B2 | C2 |
In addition, we'll create a quick class that allows us to display multiple DataFrames side by side. The code makes use of the special _repr_html_ method, which IPython uses to implement its rich object display:
class display(object):
"""Display HTML representation of multiple objects"""
template = """<div style="float: left; padding: 10px;">
<p style='font-family:"Courier New", Courier, monospace'>{0}</p>{1}
</div>"""
def __init__(self, *args):
self.args = args
def _repr_html_(self):
return '\n'.join(self.template.format(a, eval(a)._repr_html_())
for a in self.args)
def __repr__(self):
return '\n\n'.join(a + '\n' + repr(eval(a))
for a in self.args)
pd.concat¶Pandas has a function, pd.concat(), which has a similar syntax to np.concatenate but contains a number of options that we'll discuss momentarily:
# Signature in Pandas v0.18
pd.concat(objs, axis=0, join='outer', join_axes=None, ignore_index=False,
keys=None, levels=None, names=None, verify_integrity=False,
copy=True)
pd.concat() can be used for a simple concatenation of Series or DataFrame objects, just as np.concatenate() can be used for simple concatenations of arrays:
ser1 = pd.Series(['A', 'B', 'C'], index=[1, 2, 3])
ser2 = pd.Series(['D', 'E', 'F'], index=[4, 5, 6])
pd.concat([ser1, ser2])
1 A 2 B 3 C 4 D 5 E 6 F dtype: object
df1 = make_df('AB', [1, 2])
df2 = make_df('AB', [3, 4])
display('df1', 'df2', 'pd.concat([df1, df2])')
df1
| A | B | |
|---|---|---|
| 1 | A1 | B1 |
| 2 | A2 | B2 |
df2
| A | B | |
|---|---|---|
| 3 | A3 | B3 |
| 4 | A4 | B4 |
pd.concat([df1, df2])
| A | B | |
|---|---|---|
| 1 | A1 | B1 |
| 2 | A2 | B2 |
| 3 | A3 | B3 |
| 4 | A4 | B4 |
By default, the concatenation takes place row-wise within the DataFrame (i.e., axis=0).
Like np.concatenate, pd.concat allows specification of an axis along which concatenation will take place:
df3 = make_df('AB', [0, 1])
df4 = make_df('CD', [0, 1])
display('df3', 'df4', "pd.concat([df3, df4], axis=1)")
df3
| A | B | |
|---|---|---|
| 0 | A0 | B0 |
| 1 | A1 | B1 |
df4
| C | D | |
|---|---|---|
| 0 | C0 | D0 |
| 1 | C1 | D1 |
pd.concat([df3, df4], axis=1)
| A | B | C | D | |
|---|---|---|---|---|
| 0 | A0 | B0 | C0 | D0 |
| 1 | A1 | B1 | C1 | D1 |
One important difference between np.concatenate and pd.concat is that Pandas concatenation preserves indices, even if the result will have duplicate indices:
x = make_df('AB', [0, 1])
y = make_df('AB', [2, 3])
y.index = x.index # make duplicate indices!
display('x', 'y', 'pd.concat([x, y])')
x
| A | B | |
|---|---|---|
| 0 | A0 | B0 |
| 1 | A1 | B1 |
y
| A | B | |
|---|---|---|
| 0 | A2 | B2 |
| 1 | A3 | B3 |
pd.concat([x, y])
| A | B | |
|---|---|---|
| 0 | A0 | B0 |
| 1 | A1 | B1 |
| 0 | A2 | B2 |
| 1 | A3 | B3 |
Notice the repeated indices in the result.
While this is valid within DataFrames, the outcome is often undesirable.
pd.concat() gives us a few ways to handle it.
try:
pd.concat([x, y], verify_integrity=True)
except ValueError as e:
print("ValueError:", e)
ValueError: Indexes have overlapping values: Int64Index([0, 1], dtype='int64')
Sometimes the index itself does not matter, and you would prefer it to simply be ignored.
This option can be specified using the ignore_index flag.
With this set to true, the concatenation will create a new integer index for the resulting Series:
display('x', 'y', 'pd.concat([x, y], ignore_index=True)')
x
| A | B | |
|---|---|---|
| 0 | A0 | B0 |
| 1 | A1 | B1 |
y
| A | B | |
|---|---|---|
| 0 | A2 | B2 |
| 1 | A3 | B3 |
pd.concat([x, y], ignore_index=True)
| A | B | |
|---|---|---|
| 0 | A0 | B0 |
| 1 | A1 | B1 |
| 2 | A2 | B2 |
| 3 | A3 | B3 |
Another option is to use the keys option to specify a label for the data sources; the result will be a hierarchically indexed series containing the data:
display('x', 'y', "pd.concat([x, y], keys=['x', 'y'])")
x
| A | B | |
|---|---|---|
| 0 | A0 | B0 |
| 1 | A1 | B1 |
y
| A | B | |
|---|---|---|
| 0 | A2 | B2 |
| 1 | A3 | B3 |
pd.concat([x, y], keys=['x', 'y'])
| A | B | ||
|---|---|---|---|
| x | 0 | A0 | B0 |
| 1 | A1 | B1 | |
| y | 0 | A2 | B2 |
| 1 | A3 | B3 |
In practice, data from different sources might have different sets of column names, and pd.concat offers several options in this case.
Consider the concatenation of the following two DataFrames, which have some (but not all!) columns in common:
df5 = make_df('ABC', [1, 2])
df6 = make_df('BCD', [3, 4])
display('df5', 'df6', 'pd.concat([df5, df6])')
df5
| A | B | C | |
|---|---|---|---|
| 1 | A1 | B1 | C1 |
| 2 | A2 | B2 | C2 |
df6
| B | C | D | |
|---|---|---|---|
| 3 | B3 | C3 | D3 |
| 4 | B4 | C4 | D4 |
pd.concat([df5, df6])
| A | B | C | D | |
|---|---|---|---|---|
| 1 | A1 | B1 | C1 | NaN |
| 2 | A2 | B2 | C2 | NaN |
| 3 | NaN | B3 | C3 | D3 |
| 4 | NaN | B4 | C4 | D4 |
By default, the join is a union of the input column|s (join='outer'), but we can change this to an intersection of the columns using join='inner':
Another option is to directly specify the index of the remaininig colums using the join_axes argument, which takes a list of index objects.
display('df5', 'df6', "pd.concat([df5, df6])")
df5
| A | B | C | |
|---|---|---|---|
| 1 | A1 | B1 | C1 |
| 2 | A2 | B2 | C2 |
df6
| B | C | D | |
|---|---|---|---|
| 3 | B3 | C3 | D3 |
| 4 | B4 | C4 | D4 |
pd.concat([df5, df6])
| A | B | C | D | |
|---|---|---|---|---|
| 1 | A1 | B1 | C1 | NaN |
| 2 | A2 | B2 | C2 | NaN |
| 3 | NaN | B3 | C3 | D3 |
| 4 | NaN | B4 | C4 | D4 |
append() method¶Because direct array concatenation is so common, Series and DataFrame objects have an append method that can accomplish the same thing in fewer keystrokes.
For example, rather than calling pd.concat([df1, df2]), you can simply call df1.append(df2):
display('df1', 'df2', 'df1.append(df2)')
df1
| A | B | |
|---|---|---|
| 1 | A1 | B1 |
| 2 | A2 | B2 |
df2
| A | B | |
|---|---|---|
| 3 | A3 | B3 |
| 4 | A4 | B4 |
df1.append(df2)
| A | B | |
|---|---|---|
| 1 | A1 | B1 |
| 2 | A2 | B2 |
| 3 | A3 | B3 |
| 4 | A4 | B4 |
Keep in mind that unlike the append() and extend() methods of Python lists, the append() method in Pandas does not modify the original object–instead it creates a new object with the combined data.
It also is not a very efficient method, because it involves creation of a new index and data buffer.
Thus, if you plan to do multiple append operations, it is generally better to build a list of DataFrames and pass them all at once to the concat() function.
One essential feature offered by Pandas is its high-performance, in-memory join and merge operations.
If you have ever worked with databases, you should be familiar with this type of data interaction.
The main interface for this is the pd.merge function, and we'll see few examples of how this can work in practice.
For convenience, we will start by redefining the display() functionality:
class display(object):
"""Display HTML representation of multiple objects"""
template = """<div style="float: left; padding: 10px;">
<p style='font-family:"Courier New", Courier, monospace'>{0}</p>{1}
</div>"""
def __init__(self, *args):
self.args = args
def _repr_html_(self):
return '\n'.join(self.template.format(a, eval(a)._repr_html_())
for a in self.args)
def __repr__(self):
return '\n\n'.join(a + '\n' + repr(eval(a))
for a in self.args)
The behavior implemented in pd.merge() is a subset of what is known as relational algebra, which is a formal set of rules for manipulating relational data, and forms the conceptual foundation of operations available in most databases.
The strength of the relational algebra approach is that it proposes several primitive operations, which become the building blocks of more complicated operations on any dataset.
With this lexicon of fundamental operations implemented efficiently in a database or other program, a wide range of fairly complicated composite operations can be performed.
Pandas implements several of these fundamental building-blocks in the pd.merge() function and the related join() method of Series and Dataframes.
The pd.merge() function implements a number of types of joins: the one-to-one, many-to-one, and many-to-many joins.
All three types of joins are accessed via an identical call to the pd.merge() interface; the type of join performed depends on the form of the input data.
Perhaps the simplest type of merge expresion is the one-to-one join, which is in many ways very similar to the column-wise concatenation that we have already seen.
As a concrete example, consider the following two DataFrames which contain information on several employees in a company:
df1 = pd.DataFrame({'employee': ['Bob', 'Jake', 'Lisa', 'Sue'],
'group': ['Accounting', 'Engineering', 'Engineering', 'HR']})
df2 = pd.DataFrame({'employee': ['Lisa', 'Bob', 'Jake', 'Sue'],
'hire_date': [2004, 2008, 2012, 2014]})
display('df1', 'df2')
df1
| employee | group | |
|---|---|---|
| 0 | Bob | Accounting |
| 1 | Jake | Engineering |
| 2 | Lisa | Engineering |
| 3 | Sue | HR |
df2
| employee | hire_date | |
|---|---|---|
| 0 | Lisa | 2004 |
| 1 | Bob | 2008 |
| 2 | Jake | 2012 |
| 3 | Sue | 2014 |
To combine this information into a single DataFrame, we can use the pd.merge() function:
df3 = pd.merge(df1, df2)
df3
| employee | group | hire_date | |
|---|---|---|---|
| 0 | Bob | Accounting | 2008 |
| 1 | Jake | Engineering | 2012 |
| 2 | Lisa | Engineering | 2004 |
| 3 | Sue | HR | 2014 |
The pd.merge() function recognizes that each DataFrame has an "employee" column, and automatically joins using this column as a key.
The result of the merge is a new DataFrame that combines the information from the two inputs.
Notice that the order of entries in each column is not necessarily maintained: in this case, the order of the "employee" column differs between df1 and df2, and the pd.merge() function correctly accounts for this.
Additionally, keep in mind that the merge in general discards the index, except in the special case of merges by index (see the left_index and right_index keywords, discussed momentarily).
Many-to-one joins are joins in which one of the two key columns contains duplicate entries.
For the many-to-one case, the resulting DataFrame will preserve those duplicate entries as appropriate:
df4 = pd.DataFrame({'group': ['Accounting', 'Engineering', 'HR'],
'supervisor': ['Carly', 'Guido', 'Steve']})
display('df3', 'df4', 'pd.merge(df3, df4)')
df3
| employee | group | hire_date | |
|---|---|---|---|
| 0 | Bob | Accounting | 2008 |
| 1 | Jake | Engineering | 2012 |
| 2 | Lisa | Engineering | 2004 |
| 3 | Sue | HR | 2014 |
df4
| group | supervisor | |
|---|---|---|
| 0 | Accounting | Carly |
| 1 | Engineering | Guido |
| 2 | HR | Steve |
pd.merge(df3, df4)
| employee | group | hire_date | supervisor | |
|---|---|---|---|---|
| 0 | Bob | Accounting | 2008 | Carly |
| 1 | Jake | Engineering | 2012 | Guido |
| 2 | Lisa | Engineering | 2004 | Guido |
| 3 | Sue | HR | 2014 | Steve |
Many-to-many joins are a bit confusing conceptually, but are nevertheless well defined.
If the key column in both the left and right array contains duplicates, then the result is a many-to-many merge.
Consider the following, where we have a DataFrame showing one or more skills associated with a particular group. By performing a many-to-many join, we can recover the skills associated with any individual person:
df5 = pd.DataFrame({'group': ['Accounting', 'Accounting',
'Engineering', 'Engineering', 'HR', 'HR'],
'skills': ['math', 'spreadsheets', 'coding', 'linux',
'spreadsheets', 'organization']})
display('df1', 'df5', "pd.merge(df1, df5)")
df1
| employee | group | |
|---|---|---|
| 0 | Bob | Accounting |
| 1 | Jake | Engineering |
| 2 | Lisa | Engineering |
| 3 | Sue | HR |
df5
| group | skills | |
|---|---|---|
| 0 | Accounting | math |
| 1 | Accounting | spreadsheets |
| 2 | Engineering | coding |
| 3 | Engineering | linux |
| 4 | HR | spreadsheets |
| 5 | HR | organization |
pd.merge(df1, df5)
| employee | group | skills | |
|---|---|---|---|
| 0 | Bob | Accounting | math |
| 1 | Bob | Accounting | spreadsheets |
| 2 | Jake | Engineering | coding |
| 3 | Jake | Engineering | linux |
| 4 | Lisa | Engineering | coding |
| 5 | Lisa | Engineering | linux |
| 6 | Sue | HR | spreadsheets |
| 7 | Sue | HR | organization |
We've already seen the default behavior of pd.merge(): it looks for one or more matching column names between the two inputs, and uses this as the key.
However, often the column names will not match so nicely, and pd.merge() provides a variety of options for handling this.
on keyword¶Most simply, you can explicitly specify the name of the key column using the on keyword, which takes a column name or a list of column names:
display('df1', 'df2', "pd.merge(df1, df2, on='employee')")
df1
| employee | group | |
|---|---|---|
| 0 | Bob | Accounting |
| 1 | Jake | Engineering |
| 2 | Lisa | Engineering |
| 3 | Sue | HR |
df2
| employee | hire_date | |
|---|---|---|
| 0 | Lisa | 2004 |
| 1 | Bob | 2008 |
| 2 | Jake | 2012 |
| 3 | Sue | 2014 |
pd.merge(df1, df2, on='employee')
| employee | group | hire_date | |
|---|---|---|---|
| 0 | Bob | Accounting | 2008 |
| 1 | Jake | Engineering | 2012 |
| 2 | Lisa | Engineering | 2004 |
| 3 | Sue | HR | 2014 |
left_on and right_on keywords¶At times you may wish to merge two datasets with different column names; for example, we may have a dataset in which the employee name is labeled as "name" rather than "employee".
In this case, we can use the left_on and right_on keywords to specify the two column names:
df3 = pd.DataFrame({'name': ['Bob', 'Jake', 'Lisa', 'Sue'],
'salary': [70000, 80000, 120000, 90000]})
display('df1', 'df3', 'pd.merge(df1, df3, left_on="employee", right_on="name")')
df1
| employee | group | |
|---|---|---|
| 0 | Bob | Accounting |
| 1 | Jake | Engineering |
| 2 | Lisa | Engineering |
| 3 | Sue | HR |
df3
| name | salary | |
|---|---|---|
| 0 | Bob | 70000 |
| 1 | Jake | 80000 |
| 2 | Lisa | 120000 |
| 3 | Sue | 90000 |
pd.merge(df1, df3, left_on="employee", right_on="name")
| employee | group | name | salary | |
|---|---|---|---|---|
| 0 | Bob | Accounting | Bob | 70000 |
| 1 | Jake | Engineering | Jake | 80000 |
| 2 | Lisa | Engineering | Lisa | 120000 |
| 3 | Sue | HR | Sue | 90000 |
The result has a redundant column that we can drop if desired–for example, by using the drop() method of DataFrames:
pd.merge(df1, df3, left_on="employee", right_on="name").drop('name', axis=1)
| employee | group | salary | |
|---|---|---|---|
| 0 | Bob | Accounting | 70000 |
| 1 | Jake | Engineering | 80000 |
| 2 | Lisa | Engineering | 120000 |
| 3 | Sue | HR | 90000 |
left_index and right_index keywords¶Sometimes, rather than merging on a column, you would instead like to merge on an index. For example, your data might look like this:
df1.set_index?
df1a = df1.set_index([ 'group', 'employee'])
df1a
| group | employee |
|---|---|
| Accounting | Bob |
| Engineering | Jake |
| Lisa | |
| HR | Sue |
df1a = df1.set_index(['employee', 'group'])
df2a = df2.set_index('employee')
display('df1a', 'df2a')
df1a
| group | |
|---|---|
| employee | |
| Bob | Accounting |
| Jake | Engineering |
| Lisa | Engineering |
| Sue | HR |
df2a
| hire_date | |
|---|---|
| employee | |
| Lisa | 2004 |
| Bob | 2008 |
| Jake | 2012 |
| Sue | 2014 |
You can use the index as the key for merging by specifying the left_index and/or right_index flags in pd.merge():
display('df1a', 'df2a', "pd.merge(df1a, df2a, left_index=True, right_index=True)")
df1a
| group | |
|---|---|
| employee | |
| Bob | Accounting |
| Jake | Engineering |
| Lisa | Engineering |
| Sue | HR |
df2a
| hire_date | |
|---|---|
| employee | |
| Lisa | 2004 |
| Bob | 2008 |
| Jake | 2012 |
| Sue | 2014 |
pd.merge(df1a, df2a, left_index=True, right_index=True)
| group | hire_date | |
|---|---|---|
| employee | ||
| Bob | Accounting | 2008 |
| Jake | Engineering | 2012 |
| Lisa | Engineering | 2004 |
| Sue | HR | 2014 |
df1a, df2a, pd.merge(df1a, df2a, left_index=True, right_index=True)
( group
employee
Bob Accounting
Jake Engineering
Lisa Engineering
Sue HR,
hire_date
employee
Lisa 2004
Bob 2008
Jake 2012
Sue 2014,
group hire_date
employee
Bob Accounting 2008
Jake Engineering 2012
Lisa Engineering 2004
Sue HR 2014)
For convenience, DataFrames implement the join() method, which performs a merge that defaults to joining on indices:
display('df1a', 'df2a', 'df1a.join(df2a)')
df1a
| group | |
|---|---|
| employee | |
| Bob | Accounting |
| Jake | Engineering |
| Lisa | Engineering |
| Sue | HR |
df2a
| hire_date | |
|---|---|
| employee | |
| Lisa | 2004 |
| Bob | 2008 |
| Jake | 2012 |
| Sue | 2014 |
df1a.join(df2a)
| group | hire_date | |
|---|---|---|
| employee | ||
| Bob | Accounting | 2008 |
| Jake | Engineering | 2012 |
| Lisa | Engineering | 2004 |
| Sue | HR | 2014 |
If you'd like to mix indices and columns, you can combine left_index with right_on or left_on with right_index to get the desired behavior:
display('df1a', 'df3', "pd.merge(df1a, df3, left_index=True, right_on='name')")
df1a
| group | |
|---|---|
| employee | |
| Bob | Accounting |
| Jake | Engineering |
| Lisa | Engineering |
| Sue | HR |
df3
| name | salary | |
|---|---|---|
| 0 | Bob | 70000 |
| 1 | Jake | 80000 |
| 2 | Lisa | 120000 |
| 3 | Sue | 90000 |
pd.merge(df1a, df3, left_index=True, right_on='name')
| group | name | salary | |
|---|---|---|---|
| 0 | Accounting | Bob | 70000 |
| 1 | Engineering | Jake | 80000 |
| 2 | Engineering | Lisa | 120000 |
| 3 | HR | Sue | 90000 |
We have glossed over one important consideration in performing a join: the type of set arithmetic used in the join. This comes up when a value appears in one key column but not the other:
df6 = pd.DataFrame({'name': ['Peter', 'Paul', 'Mary'], 'food': ['fish', 'beans', 'bread']},
columns=['name', 'food'])
df7 = pd.DataFrame({'name': ['Mary', 'Joseph'], 'drink': ['wine', 'beer']},
columns=['name', 'drink'])
display('df6', 'df7', 'pd.merge(df6, df7)')
df6
| name | food | |
|---|---|---|
| 0 | Peter | fish |
| 1 | Paul | beans |
| 2 | Mary | bread |
df7
| name | drink | |
|---|---|---|
| 0 | Mary | wine |
| 1 | Joseph | beer |
pd.merge(df6, df7)
| name | food | drink | |
|---|---|---|---|
| 0 | Mary | bread | wine |
Here we have merged two datasets that have only a single "name" entry in common: Mary.
By default, the result contains the intersection of the two sets of inputs; this is what is known as an inner join.
We can specify this explicitly using the how keyword, which defaults to "inner":
pd.merge(df6, df7, how='inner')
| name | food | drink | |
|---|---|---|---|
| 0 | Mary | bread | wine |
Other options for the how keyword are 'outer', 'left', and 'right'.
An outer join returns a join over the union of the input columns, and fills in all missing values with NAs:
display('df6', 'df7', "pd.merge(df6, df7, how='outer')")
df6
| name | food | |
|---|---|---|
| 0 | Peter | fish |
| 1 | Paul | beans |
| 2 | Mary | bread |
df7
| name | drink | |
|---|---|---|
| 0 | Mary | wine |
| 1 | Joseph | beer |
pd.merge(df6, df7, how='outer')
| name | food | drink | |
|---|---|---|---|
| 0 | Peter | fish | NaN |
| 1 | Paul | beans | NaN |
| 2 | Mary | bread | wine |
| 3 | Joseph | NaN | beer |
The left join and right join return joins over the left entries and right entries, respectively:
display('df6', 'df7', "pd.merge(df6, df7, how='left')")
df6
| name | food | |
|---|---|---|
| 0 | Peter | fish |
| 1 | Paul | beans |
| 2 | Mary | bread |
df7
| name | drink | |
|---|---|---|
| 0 | Mary | wine |
| 1 | Joseph | beer |
pd.merge(df6, df7, how='left')
| name | food | drink | |
|---|---|---|---|
| 0 | Peter | fish | NaN |
| 1 | Paul | beans | NaN |
| 2 | Mary | bread | wine |
display('df6', 'df7', "pd.merge(df6, df7, how='right')")
df6
| name | food | |
|---|---|---|
| 0 | Peter | fish |
| 1 | Paul | beans |
| 2 | Mary | bread |
df7
| name | drink | |
|---|---|---|
| 0 | Mary | wine |
| 1 | Joseph | beer |
pd.merge(df6, df7, how='right')
| name | food | drink | |
|---|---|---|---|
| 0 | Mary | bread | wine |
| 1 | Joseph | NaN | beer |
pd.merge?
suffixes Keyword¶Finally, you may end up in a case where your two input DataFrames have conflicting column names:
df8 = pd.DataFrame({'name': ['Bob', 'Jake', 'Lisa', 'Sue'],
'rank': [1, 2, 3, 4]})
df9 = pd.DataFrame({'name': ['Bob', 'Jake', 'Lisa', 'Sue'],
'rank': [3, 1, 4, 2]})
display('df8', 'df9', 'pd.merge(df8, df9, on="name")')
df8
| name | rank | |
|---|---|---|
| 0 | Bob | 1 |
| 1 | Jake | 2 |
| 2 | Lisa | 3 |
| 3 | Sue | 4 |
df9
| name | rank | |
|---|---|---|
| 0 | Bob | 3 |
| 1 | Jake | 1 |
| 2 | Lisa | 4 |
| 3 | Sue | 2 |
pd.merge(df8, df9, on="name")
| name | rank_x | rank_y | |
|---|---|---|---|
| 0 | Bob | 1 | 3 |
| 1 | Jake | 2 | 1 |
| 2 | Lisa | 3 | 4 |
| 3 | Sue | 4 | 2 |
Because the output would have two conflicting column names, the merge function automatically appends a suffix _x or _y to make the output columns unique.
If these defaults are inappropriate, it is possible to specify a custom suffix using the suffixes keyword:
display('df8', 'df9', 'pd.merge(df8, df9, on="name", suffixes=["_L", "_R"])')
df8
| name | rank | |
|---|---|---|
| 0 | Bob | 1 |
| 1 | Jake | 2 |
| 2 | Lisa | 3 |
| 3 | Sue | 4 |
df9
| name | rank | |
|---|---|---|
| 0 | Bob | 3 |
| 1 | Jake | 1 |
| 2 | Lisa | 4 |
| 3 | Sue | 2 |
pd.merge(df8, df9, on="name", suffixes=["_L", "_R"])
| name | rank_L | rank_R | |
|---|---|---|---|
| 0 | Bob | 1 | 3 |
| 1 | Jake | 2 | 1 |
| 2 | Lisa | 3 | 4 |
| 3 | Sue | 4 | 2 |
Merge and join operations come up most often when combining data from different sources. Here we will consider an example of some data about US states and their populations. The data files can be found at http://github.com/jakevdp/data-USstates/:
pop = pd.read_csv('data/state-population.csv')
areas = pd.read_csv('data/state-areas.csv')
abbrevs = pd.read_csv('data/state-abbrevs.csv')
display('pop.head()', 'areas.head()', 'abbrevs.head()')
pop.head()
| state/region | ages | year | population | |
|---|---|---|---|---|
| 0 | AL | under18 | 2012 | 1117489.0 |
| 1 | AL | total | 2012 | 4817528.0 |
| 2 | AL | under18 | 2010 | 1130966.0 |
| 3 | AL | total | 2010 | 4785570.0 |
| 4 | AL | under18 | 2011 | 1125763.0 |
areas.head()
| state | area (sq. mi) | |
|---|---|---|
| 0 | Alabama | 52423 |
| 1 | Alaska | 656425 |
| 2 | Arizona | 114006 |
| 3 | Arkansas | 53182 |
| 4 | California | 163707 |
abbrevs.head()
| state | abbreviation | |
|---|---|---|
| 0 | Alabama | AL |
| 1 | Alaska | AK |
| 2 | Arizona | AZ |
| 3 | Arkansas | AR |
| 4 | California | CA |
Given this information, say we want to compute a relatively straightforward result: rank US states and territories by their 2010 population density. We clearly have the data here to find this result, but we'll have to combine the datasets to find the result.
We'll start with a many-to-one merge that will give us the full state name within the population DataFrame.
We want to merge based on the state/region column of pop, and the abbreviation column of abbrevs.
We'll use how='outer' to make sure no data is thrown away due to mismatched labels.
merged = pd.merge(pop, abbrevs, how='outer',
left_on='state/region', right_on='abbreviation')
merged = merged.drop('abbreviation', 1) # drop duplicate info
merged.head()
| state/region | ages | year | population | state | |
|---|---|---|---|---|---|
| 0 | AL | under18 | 2012 | 1117489.0 | Alabama |
| 1 | AL | total | 2012 | 4817528.0 | Alabama |
| 2 | AL | under18 | 2010 | 1130966.0 | Alabama |
| 3 | AL | total | 2010 | 4785570.0 | Alabama |
| 4 | AL | under18 | 2011 | 1125763.0 | Alabama |
Let's double-check whether there were any mismatches here, which we can do by looking for rows with nulls:
merged.isnull().any()
state/region False ages False year False population True state True dtype: bool
Some of the population info is null:
merged[merged['population'].isnull()].head()
| state/region | ages | year | population | state | |
|---|---|---|---|---|---|
| 2448 | PR | under18 | 1990 | NaN | NaN |
| 2449 | PR | total | 1990 | NaN | NaN |
| 2450 | PR | total | 1991 | NaN | NaN |
| 2451 | PR | under18 | 1991 | NaN | NaN |
| 2452 | PR | total | 1993 | NaN | NaN |
It appears that all the null population values are from Puerto Rico prior to the year 2000; this is likely due to this data not being available from the original source.
More importantly, we see also that some of the new state entries are also null, which means that there was no corresponding entry in the abbrevs key!
Let's figure out which regions lack this match:
merged.loc[merged['state'].isnull(), 'state/region'].unique()
array(['PR', 'USA'], dtype=object)
We can quickly infer the issue: our population data includes entries for Puerto Rico (PR) and the United States as a whole (USA), while these entries do not appear in the state abbreviation key. We can fix these quickly by filling in appropriate entries:
merged.loc[merged['state/region'] == 'PR', 'state'] = 'Puerto Rico'
merged.loc[merged['state/region'] == 'USA', 'state'] = 'United States'
merged.isnull().any()
state/region False ages False year False population True state False dtype: bool
No more nulls in the state column: we're all set!
Now we can merge the result with the area data using a similar procedure.
Examining our results, we will want to join on the state column in both:
final = pd.merge(merged, areas, on='state', how='left')
final.head()
| state/region | ages | year | population | state | area (sq. mi) | |
|---|---|---|---|---|---|---|
| 0 | AL | under18 | 2012 | 1117489.0 | Alabama | 52423.0 |
| 1 | AL | total | 2012 | 4817528.0 | Alabama | 52423.0 |
| 2 | AL | under18 | 2010 | 1130966.0 | Alabama | 52423.0 |
| 3 | AL | total | 2010 | 4785570.0 | Alabama | 52423.0 |
| 4 | AL | under18 | 2011 | 1125763.0 | Alabama | 52423.0 |
Again, let's check for nulls to see if there were any mismatches:
final.isnull().any()
state/region False ages False year False population True state False area (sq. mi) True dtype: bool
There are nulls in the area column; we can take a look to see which regions were ignored here:
final['state'][final['area (sq. mi)'].isnull()].unique()
array(['United States'], dtype=object)
We see that our areas DataFrame does not contain the area of the United States as a whole.
We could insert the appropriate value (using the sum of all state areas, for instance), but in this case we'll just drop the null values because the population density of the entire United States is not relevant to our current discussion:
final.dropna(inplace=True)
final.head()
| state/region | ages | year | population | state | area (sq. mi) | |
|---|---|---|---|---|---|---|
| 0 | AL | under18 | 2012 | 1117489.0 | Alabama | 52423.0 |
| 1 | AL | total | 2012 | 4817528.0 | Alabama | 52423.0 |
| 2 | AL | under18 | 2010 | 1130966.0 | Alabama | 52423.0 |
| 3 | AL | total | 2010 | 4785570.0 | Alabama | 52423.0 |
| 4 | AL | under18 | 2011 | 1125763.0 | Alabama | 52423.0 |
Now we have all the data we need. To answer the question of interest, let's first select the portion of the data corresponding with the year 2000, and the total population.
We'll use the query() function to do this quickly:
data2010 = final.query("year == 2010 & ages == 'total'")
data2010.head()
| state/region | ages | year | population | state | area (sq. mi) | |
|---|---|---|---|---|---|---|
| 3 | AL | total | 2010 | 4785570.0 | Alabama | 52423.0 |
| 91 | AK | total | 2010 | 713868.0 | Alaska | 656425.0 |
| 101 | AZ | total | 2010 | 6408790.0 | Arizona | 114006.0 |
| 189 | AR | total | 2010 | 2922280.0 | Arkansas | 53182.0 |
| 197 | CA | total | 2010 | 37333601.0 | California | 163707.0 |
Now let's compute the population density and display it in order. We'll start by re-indexing our data on the state, and then compute the result:
data2010.set_index('state', inplace=True)
density = data2010['population'] / data2010['area (sq. mi)']
density.sort_values(ascending=False, inplace=True)
density.head()
state District of Columbia 8898.897059 Puerto Rico 1058.665149 New Jersey 1009.253268 Rhode Island 681.339159 Connecticut 645.600649 dtype: float64
The result is a ranking of US states plus Washington, DC, and Puerto Rico in order of their 2010 population density, in residents per square mile. We can see that by far the densest region in this dataset is Washington, DC (i.e., the District of Columbia); among states, the densest is New Jersey.
We can also check the end of the list:
density.tail()
state South Dakota 10.583512 North Dakota 9.537565 Montana 6.736171 Wyoming 5.768079 Alaska 1.087509 dtype: float64
We see that the least dense state, by far, is Alaska, averaging slightly over one resident per square mile.
This type of messy data merging is a common task when trying to answer questions using real-world data sources.
An essential piece of analysis of large data is efficient summarization: computing aggregations like sum(), mean(), median(), min(), and max(), in which a single number gives insight into the nature of a potentially large dataset.
we'll use the same display magic function as usual:
class display(object):
"""Display HTML representation of multiple objects"""
template = """<div style="float: left; padding: 10px;">
<p style='font-family:"Courier New", Courier, monospace'>{0}</p>{1}
</div>"""
def __init__(self, *args):
self.args = args
def _repr_html_(self):
return '\n'.join(self.template.format(a, eval(a)._repr_html_())
for a in self.args)
def __repr__(self):
return '\n\n'.join(a + '\n' + repr(eval(a))
for a in self.args)
Here we will use the Planets dataset, available via the Seaborn package. It gives information on planets that astronomers have discovered around other stars (known as extrasolar planets or exoplanets for short). It can be downloaded with a simple Seaborn command:
import seaborn as sns
planets = sns.load_dataset('planets')
planets.shape # 1,000+ extrasolar planets discovered up to 2014.
--------------------------------------------------------------------------- SSLCertVerificationError Traceback (most recent call last) /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/urllib/request.py in do_open(self, http_class, req, **http_conn_args) 1341 try: -> 1342 h.request(req.get_method(), req.selector, req.data, headers, 1343 encode_chunked=req.has_header('Transfer-encoding')) /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/http/client.py in request(self, method, url, body, headers, encode_chunked) 1254 """Send a complete request to the server.""" -> 1255 self._send_request(method, url, body, headers, encode_chunked) 1256 /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/http/client.py in _send_request(self, method, url, body, headers, encode_chunked) 1300 body = _encode(body, 'body') -> 1301 self.endheaders(body, encode_chunked=encode_chunked) 1302 /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/http/client.py in endheaders(self, message_body, encode_chunked) 1249 raise CannotSendHeader() -> 1250 self._send_output(message_body, encode_chunked=encode_chunked) 1251 /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/http/client.py in _send_output(self, message_body, encode_chunked) 1009 del self._buffer[:] -> 1010 self.send(msg) 1011 /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/http/client.py in send(self, data) 949 if self.auto_open: --> 950 self.connect() 951 else: /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/http/client.py in connect(self) 1423 -> 1424 self.sock = self._context.wrap_socket(self.sock, 1425 server_hostname=server_hostname) /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/ssl.py in wrap_socket(self, sock, server_side, do_handshake_on_connect, suppress_ragged_eofs, server_hostname, session) 499 # ctx._wrap_socket() --> 500 return self.sslsocket_class._create( 501 sock=sock, /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/ssl.py in _create(cls, sock, server_side, do_handshake_on_connect, suppress_ragged_eofs, server_hostname, context, session) 1039 raise ValueError("do_handshake_on_connect should not be specified for non-blocking sockets") -> 1040 self.do_handshake() 1041 except (OSError, ValueError): /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/ssl.py in do_handshake(self, block) 1308 self.settimeout(None) -> 1309 self._sslobj.do_handshake() 1310 finally: SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1122) During handling of the above exception, another exception occurred: URLError Traceback (most recent call last) <ipython-input-155-c51edcc88343> in <module> 1 import seaborn as sns ----> 2 planets = sns.load_dataset('planets') 3 planets.shape # 1,000+ extrasolar planets discovered up to 2014. ~/Developer/py-venvs/sphinx-venv/lib/python3.9/site-packages/seaborn/utils.py in load_dataset(name, cache, data_home, **kws) 483 os.path.basename(full_path)) 484 if not os.path.exists(cache_path): --> 485 if name not in get_dataset_names(): 486 raise ValueError(f"'{name}' is not one of the example datasets.") 487 urlretrieve(full_path, cache_path) ~/Developer/py-venvs/sphinx-venv/lib/python3.9/site-packages/seaborn/utils.py in get_dataset_names() 417 """ 418 url = "https://github.com/mwaskom/seaborn-data" --> 419 with urlopen(url) as resp: 420 html = resp.read() 421 /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/urllib/request.py in urlopen(url, data, timeout, cafile, capath, cadefault, context) 212 else: 213 opener = _opener --> 214 return opener.open(url, data, timeout) 215 216 def install_opener(opener): /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/urllib/request.py in open(self, fullurl, data, timeout) 515 516 sys.audit('urllib.Request', req.full_url, req.data, req.headers, req.get_method()) --> 517 response = self._open(req, data) 518 519 # post-process response /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/urllib/request.py in _open(self, req, data) 532 533 protocol = req.type --> 534 result = self._call_chain(self.handle_open, protocol, protocol + 535 '_open', req) 536 if result: /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/urllib/request.py in _call_chain(self, chain, kind, meth_name, *args) 492 for handler in handlers: 493 func = getattr(handler, meth_name) --> 494 result = func(*args) 495 if result is not None: 496 return result /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/urllib/request.py in https_open(self, req) 1383 1384 def https_open(self, req): -> 1385 return self.do_open(http.client.HTTPSConnection, req, 1386 context=self._context, check_hostname=self._check_hostname) 1387 /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/urllib/request.py in do_open(self, http_class, req, **http_conn_args) 1343 encode_chunked=req.has_header('Transfer-encoding')) 1344 except OSError as err: # timeout error -> 1345 raise URLError(err) 1346 r = h.getresponse() 1347 except: URLError: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1122)>
planets.head()
rng = np.random.RandomState(42)
ser = pd.Series(rng.rand(5))
ser
0 0.374540 1 0.950714 2 0.731994 3 0.598658 4 0.156019 dtype: float64
ser.sum(), ser.mean()
(2.811925491708157, 0.5623850983416314)
For a DataFrame, by default the aggregates return results within each column:
df = pd.DataFrame({'A': rng.rand(5), 'B': rng.rand(5)})
df
| A | B | |
|---|---|---|
| 0 | 0.183405 | 0.611853 |
| 1 | 0.304242 | 0.139494 |
| 2 | 0.524756 | 0.292145 |
| 3 | 0.431945 | 0.366362 |
| 4 | 0.291229 | 0.456070 |
df.mean()
A 0.347115 B 0.373185 dtype: float64
By specifying the axis argument, you can instead aggregate within each row:
df.mean(axis='columns')
0 0.397629 1 0.221868 2 0.408451 3 0.399153 4 0.373650 dtype: float64
Pandas Series and DataFrames provide a convenience method describe() that computes several common aggregates for each column and returns the result:
planets.dropna().describe() # dropping rows with missing values
| number | orbital_period | mass | distance | year | |
|---|---|---|---|---|---|
| count | 498.00000 | 498.000000 | 498.000000 | 498.000000 | 498.000000 |
| mean | 1.73494 | 835.778671 | 2.509320 | 52.068213 | 2007.377510 |
| std | 1.17572 | 1469.128259 | 3.636274 | 46.596041 | 4.167284 |
| min | 1.00000 | 1.328300 | 0.003600 | 1.350000 | 1989.000000 |
| 25% | 1.00000 | 38.272250 | 0.212500 | 24.497500 | 2005.000000 |
| 50% | 1.00000 | 357.000000 | 1.245000 | 39.940000 | 2009.000000 |
| 75% | 2.00000 | 999.600000 | 2.867500 | 59.332500 | 2011.000000 |
| max | 6.00000 | 17337.500000 | 25.000000 | 354.000000 | 2014.000000 |
This can be a useful way to begin understanding the overall properties of a dataset.
For example, we see in the year column that although exoplanets were discovered as far back as 1989, half of all known expolanets were not discovered until 2010 or after.
This is largely thanks to the Kepler mission, which is a space-based telescope specifically designed for finding eclipsing planets around other stars.
The following table summarizes some other built-in Pandas aggregations:
| Aggregation | Description |
|---|---|
count() |
Total number of items |
first(), last() |
First and last item |
mean(), median() |
Mean and median |
min(), max() |
Minimum and maximum |
std(), var() |
Standard deviation and variance |
mad() |
Mean absolute deviation |
prod() |
Product of all items |
sum() |
Sum of all items |
These are all methods of DataFrame and Series objects.
Simple aggregations can give you a flavor of your dataset, but often we would prefer to aggregate conditionally on some label or index: this is implemented in the so-called groupby operation.
The name "group by" comes from a command in the SQL database language, but it is perhaps more illuminative to think of it in the terms first coined by Hadley Wickham of Rstats fame: split, apply, combine.
A canonical example of this split-apply-combine operation, where the "apply" is a summation aggregation, is illustrated in this figure:

This makes clear what the groupby accomplishes:
DataFrame depending on the value of the specified key.While this could certainly be done manually using some combination of the masking, aggregation, and merging commands covered earlier, an important realization is that the intermediate splits do not need to be explicitly instantiated. Rather, the GroupBy can (often) do this in a single pass over the data, updating the sum, mean, count, min, or other aggregate for each group along the way.
The power of the GroupBy is that it abstracts away these steps: the user need not think about how the computation is done under the hood, but rather thinks about the operation as a whole.
As a concrete example, let's take a look at using Pandas for the computation shown in this diagram:
df = pd.DataFrame({'key': ['A', 'B', 'C', 'A', 'B', 'C'],
'data': range(6)}, columns=['key', 'data'])
df
| key | data | |
|---|---|---|
| 0 | A | 0 |
| 1 | B | 1 |
| 2 | C | 2 |
| 3 | A | 3 |
| 4 | B | 4 |
| 5 | C | 5 |
The most basic split-apply-combine operation can be computed with the groupby() method of DataFrames, passing the name of the desired key column:
df.groupby('key')
<pandas.core.groupby.generic.DataFrameGroupBy object at 0x7fbb19adca30>
Notice that what is returned is not a set of DataFrames, but a DataFrameGroupBy object.
This object is where the magic is: you can think of it as a special view of the DataFrame, which is poised to dig into the groups but does no actual computation until the aggregation is applied.
This lazy evaluation approach means that common aggregates can be implemented very efficiently in a way that is almost transparent to the user.
To produce a result, we can apply an aggregate to this DataFrameGroupBy object, which will perform the appropriate apply/combine steps to produce the desired result:
df.groupby('key').sum()
| data | |
|---|---|
| key | |
| A | 3 |
| B | 5 |
| C | 7 |
type(_)
pandas.core.frame.DataFrame
The sum() method is just one possibility here; you can apply virtually any common Pandas or NumPy aggregation function, as well as virtually any valid DataFrame operation.
The GroupBy object is a very flexible abstraction.
In many ways, you can simply treat it as if it's a collection of DataFrames, and it does the difficult things under the hood. Let's see some examples using the Planets data.
Perhaps the most important operations made available by a GroupBy are aggregate, filter, transform, and apply but before that let's introduce some of the other functionality that can be used with the basic GroupBy operation.
The GroupBy object supports column indexing in the same way as the DataFrame, and returns a modified GroupBy object:
planets.groupby('method')
<pandas.core.groupby.generic.DataFrameGroupBy object at 0x7f864d921470>
planets.groupby('method')['orbital_period']
<pandas.core.groupby.generic.SeriesGroupBy object at 0x7f864d921898>
Here we've selected a particular Series group from the original DataFrame group by reference to its column name. As with the GroupBy object, no computation is done until we call some aggregate on the object:
planets.groupby('method')['orbital_period'].median()
method Astrometry 631.180000 Eclipse Timing Variations 4343.500000 Imaging 27500.000000 Microlensing 3300.000000 Orbital Brightness Modulation 0.342887 Pulsar Timing 66.541900 Pulsation Timing Variations 1170.000000 Radial Velocity 360.200000 Transit 5.714932 Transit Timing Variations 57.011000 Name: orbital_period, dtype: float64
The GroupBy object supports direct iteration over the groups, returning each group as a Series or DataFrame:
for (method, group) in planets.groupby('method'):
print("{0:30s} shape={1}".format(method, group.shape))
Astrometry shape=(2, 6) Eclipse Timing Variations shape=(9, 6) Imaging shape=(38, 6) Microlensing shape=(23, 6) Orbital Brightness Modulation shape=(3, 6) Pulsar Timing shape=(5, 6) Pulsation Timing Variations shape=(1, 6) Radial Velocity shape=(553, 6) Transit shape=(397, 6) Transit Timing Variations shape=(4, 6)
Through some Python class magic, any method not explicitly implemented by the GroupBy object will be passed through and called on the groups, whether they are DataFrame or Series objects.
For example, you can use the describe() method of DataFrames to perform a set of aggregations that describe each group in the data:
planets.groupby('method')['year'].describe()
| count | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|
| method | ||||||||
| Astrometry | 2.0 | 2011.500000 | 2.121320 | 2010.0 | 2010.75 | 2011.5 | 2012.25 | 2013.0 |
| Eclipse Timing Variations | 9.0 | 2010.000000 | 1.414214 | 2008.0 | 2009.00 | 2010.0 | 2011.00 | 2012.0 |
| Imaging | 38.0 | 2009.131579 | 2.781901 | 2004.0 | 2008.00 | 2009.0 | 2011.00 | 2013.0 |
| Microlensing | 23.0 | 2009.782609 | 2.859697 | 2004.0 | 2008.00 | 2010.0 | 2012.00 | 2013.0 |
| Orbital Brightness Modulation | 3.0 | 2011.666667 | 1.154701 | 2011.0 | 2011.00 | 2011.0 | 2012.00 | 2013.0 |
| Pulsar Timing | 5.0 | 1998.400000 | 8.384510 | 1992.0 | 1992.00 | 1994.0 | 2003.00 | 2011.0 |
| Pulsation Timing Variations | 1.0 | 2007.000000 | NaN | 2007.0 | 2007.00 | 2007.0 | 2007.00 | 2007.0 |
| Radial Velocity | 553.0 | 2007.518987 | 4.249052 | 1989.0 | 2005.00 | 2009.0 | 2011.00 | 2014.0 |
| Transit | 397.0 | 2011.236776 | 2.077867 | 2002.0 | 2010.00 | 2012.0 | 2013.00 | 2014.0 |
| Transit Timing Variations | 4.0 | 2012.500000 | 1.290994 | 2011.0 | 2011.75 | 2012.5 | 2013.25 | 2014.0 |
This is just one example of the utility of dispatch methods.
Notice that they are applied to each individual group, and the results are then combined within GroupBy and returned.
Again, any valid DataFrame/Series method can be used on the corresponding GroupBy object, which allows for some very flexible and powerful operations!
GroupBy objects have aggregate(), filter(), transform(), and apply() methods that efficiently implement a variety of useful operations before combining the grouped data.
rng = np.random.RandomState(0)
df = pd.DataFrame({'key': ['A', 'B', 'C', 'A', 'B', 'C'],
'data1': range(6),
'data2': rng.randint(0, 10, 6)},
columns = ['key', 'data1', 'data2'])
df
| key | data1 | data2 | |
|---|---|---|---|
| 0 | A | 0 | 5 |
| 1 | B | 1 | 0 |
| 2 | C | 2 | 3 |
| 3 | A | 3 | 3 |
| 4 | B | 4 | 7 |
| 5 | C | 5 | 9 |
We're now familiar with GroupBy aggregations with sum(), median(), and the like, but the aggregate() method allows for even more flexibility.
It can take a string, a function, or a list thereof, and compute all the aggregates at once.
df.groupby('key').aggregate([min, np.median, max])
| data1 | data2 | |||||
|---|---|---|---|---|---|---|
| min | median | max | min | median | max | |
| key | ||||||
| A | 0 | 1.5 | 3 | 3 | 4.0 | 5 |
| B | 1 | 2.5 | 4 | 0 | 3.5 | 7 |
| C | 2 | 3.5 | 5 | 3 | 6.0 | 9 |
A filtering operation allows you to drop data based on the group properties. For example, we might want to keep all groups in which the standard deviation is larger than some critical value:
def filter_func(x):
return x['data2'].std() > 4
display('df', "df.groupby('key').std()", "df.groupby('key').filter(filter_func)")
df
| key | data1 | data2 | |
|---|---|---|---|
| 0 | A | 0 | 5 |
| 1 | B | 1 | 0 |
| 2 | C | 2 | 3 |
| 3 | A | 3 | 3 |
| 4 | B | 4 | 7 |
| 5 | C | 5 | 9 |
df.groupby('key').std()
| data1 | data2 | |
|---|---|---|
| key | ||
| A | 2.12132 | 1.414214 |
| B | 2.12132 | 4.949747 |
| C | 2.12132 | 4.242641 |
df.groupby('key').filter(filter_func)
| key | data1 | data2 | |
|---|---|---|---|
| 1 | B | 1 | 0 |
| 2 | C | 2 | 3 |
| 4 | B | 4 | 7 |
| 5 | C | 5 | 9 |
While aggregation must return a reduced version of the data, transformation can return some transformed version of the full data to recombine. For such a transformation, the output is the same shape as the input.
df.groupby('key').transform(lambda x: x - x.mean())
| data1 | data2 | |
|---|---|---|
| 0 | -1.5 | 1.0 |
| 1 | -1.5 | -3.5 |
| 2 | -1.5 | -3.0 |
| 3 | 1.5 | -1.0 |
| 4 | 1.5 | 3.5 |
| 5 | 1.5 | 3.0 |
The apply() method lets you apply an arbitrary function to the group results.
The function should take a DataFrame, and return either a Pandas object (e.g., DataFrame, Series) or a scalar; the combine operation will be tailored to the type of output returned.
def norm_by_data2(x):
# x is a DataFrame of group values
x['data1'] /= x['data2'].sum()
return x
display('df', "df.groupby('key').apply(norm_by_data2)")
df
| key | data1 | data2 | |
|---|---|---|---|
| 0 | A | 0 | 5 |
| 1 | B | 1 | 0 |
| 2 | C | 2 | 3 |
| 3 | A | 3 | 3 |
| 4 | B | 4 | 7 |
| 5 | C | 5 | 9 |
df.groupby('key').apply(norm_by_data2)
| key | data1 | data2 | |
|---|---|---|---|
| 0 | A | 0.000000 | 5 |
| 1 | B | 0.142857 | 0 |
| 2 | C | 0.166667 | 3 |
| 3 | A | 0.375000 | 3 |
| 4 | B | 0.571429 | 7 |
| 5 | C | 0.416667 | 9 |
In the simple examples presented before, we split the DataFrame on a single column name.
This is just one of many options by which the groups can be defined, and we'll go through some other options for group specification here.
The key can be any series or list with a length matching that of the DataFrame:
L = [0, 1, 0, 1, 2, 0]
display('df', 'df.groupby(L).sum()')
df
| key | data1 | data2 | |
|---|---|---|---|
| 0 | A | 0 | 5 |
| 1 | B | 1 | 0 |
| 2 | C | 2 | 3 |
| 3 | A | 3 | 3 |
| 4 | B | 4 | 7 |
| 5 | C | 5 | 9 |
df.groupby(L).sum()
| data1 | data2 | |
|---|---|---|
| 0 | 7 | 17 |
| 1 | 4 | 3 |
| 2 | 4 | 7 |
Of course, this means there's another, more verbose way of accomplishing the df.groupby('key') from before:
display('df', "df.groupby(df['key']).sum()")
df
| key | data1 | data2 | |
|---|---|---|---|
| 0 | A | 0 | 5 |
| 1 | B | 1 | 0 |
| 2 | C | 2 | 3 |
| 3 | A | 3 | 3 |
| 4 | B | 4 | 7 |
| 5 | C | 5 | 9 |
df.groupby(df['key']).sum()
| data1 | data2 | |
|---|---|---|
| key | ||
| A | 3 | 8 |
| B | 5 | 7 |
| C | 7 | 12 |
Another method is to provide a dictionary that maps index values to the group keys:
df2 = df.set_index('key')
mapping = {'A': 'vowel', 'B': 'consonant', 'C': 'consonant'}
display('df2', 'df2.groupby(mapping).sum()')
df2
| data1 | data2 | |
|---|---|---|
| key | ||
| A | 0 | 5 |
| B | 1 | 0 |
| C | 2 | 3 |
| A | 3 | 3 |
| B | 4 | 7 |
| C | 5 | 9 |
df2.groupby(mapping).sum()
| data1 | data2 | |
|---|---|---|
| consonant | 12 | 19 |
| vowel | 3 | 8 |
Similar to mapping, you can pass any Python function that will input the index value and output the group:
display('df2', 'df2.groupby(str.lower).mean()')
df2
| data1 | data2 | |
|---|---|---|
| key | ||
| A | 0 | 5 |
| B | 1 | 0 |
| C | 2 | 3 |
| A | 3 | 3 |
| B | 4 | 7 |
| C | 5 | 9 |
df2.groupby(str.lower).mean()
| data1 | data2 | |
|---|---|---|
| a | 1.5 | 4.0 |
| b | 2.5 | 3.5 |
| c | 3.5 | 6.0 |
Further, any of the preceding key choices can be combined to group on a multi-index:
df2.groupby([str.lower, mapping]).mean()
| data1 | data2 | ||
|---|---|---|---|
| a | vowel | 1.5 | 4.0 |
| b | consonant | 2.5 | 3.5 |
| c | consonant | 3.5 | 6.0 |
As an example of this, in a couple lines of Python code we can put all these together and count discovered planets by method and by decade:
decade = 10 * (planets['year'] // 10)
decade = decade.astype(str) + 's'
decade.name = 'decade'
planets.groupby(['method', decade])['number'].sum().unstack().fillna(0)
| decade | 1980s | 1990s | 2000s | 2010s |
|---|---|---|---|---|
| method | ||||
| Astrometry | 0.0 | 0.0 | 0.0 | 2.0 |
| Eclipse Timing Variations | 0.0 | 0.0 | 5.0 | 10.0 |
| Imaging | 0.0 | 0.0 | 29.0 | 21.0 |
| Microlensing | 0.0 | 0.0 | 12.0 | 15.0 |
| Orbital Brightness Modulation | 0.0 | 0.0 | 0.0 | 5.0 |
| Pulsar Timing | 0.0 | 9.0 | 1.0 | 1.0 |
| Pulsation Timing Variations | 0.0 | 0.0 | 1.0 | 0.0 |
| Radial Velocity | 1.0 | 52.0 | 475.0 | 424.0 |
| Transit | 0.0 | 0.0 | 64.0 | 712.0 |
| Transit Timing Variations | 0.0 | 0.0 | 0.0 | 9.0 |
This shows the power of combining many of the operations we've discussed up to this point when looking at realistic datasets. We immediately gain a coarse understanding of when and how planets have been discovered over the past several decades!
We have seen how the GroupBy abstraction lets us explore relationships within a dataset.
A pivot table is a similar operation that is commonly seen in spreadsheets and other programs that operate on tabular data:
GroupBy can sometimes cause confusion; it helps me to think of pivot tables as essentially a multidimensional version of GroupBy aggregation.We'll use the database of passengers on the Titanic, available through the Seaborn library:
import seaborn as sns
titanic = sns.load_dataset('titanic')
titanic.head()
| survived | pclass | sex | age | sibsp | parch | fare | embarked | class | who | adult_male | deck | embark_town | alive | alone | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 3 | male | 22.0 | 1 | 0 | 7.2500 | S | Third | man | True | NaN | Southampton | no | False |
| 1 | 1 | 1 | female | 38.0 | 1 | 0 | 71.2833 | C | First | woman | False | C | Cherbourg | yes | False |
| 2 | 1 | 3 | female | 26.0 | 0 | 0 | 7.9250 | S | Third | woman | False | NaN | Southampton | yes | True |
| 3 | 1 | 1 | female | 35.0 | 1 | 0 | 53.1000 | S | First | woman | False | C | Southampton | yes | False |
| 4 | 0 | 3 | male | 35.0 | 0 | 0 | 8.0500 | S | Third | man | True | NaN | Southampton | no | True |
To start learning more about this data, we might begin by grouping according to gender, survival status, or some combination thereof.
If you have read the previous section, you might be tempted to apply a GroupBy operation–for example, let's look at survival rate by gender:
titanic.groupby('sex')[['survived']].mean()
| survived | |
|---|---|
| sex | |
| female | 0.742038 |
| male | 0.188908 |
This immediately gives us some insight: overall, three of every four females on board survived, while only one in five males survived.
This is useful, but we might like to go one step deeper and look at survival by both sex and, say, class.
Using the vocabulary of GroupBy, we might proceed using something like this:
we group by class and gender, select survival, apply a mean aggregate, combine the resulting groups, and then unstack the hierarchical index to reveal the hidden multidimensionality. In code:
titanic.groupby(['sex', 'class'])['survived'].aggregate('mean').unstack()
| class | First | Second | Third |
|---|---|---|---|
| sex | |||
| female | 0.968085 | 0.921053 | 0.500000 |
| male | 0.368852 | 0.157407 | 0.135447 |
This gives us a better idea of how both gender and class affected survival, but the code is starting to look a bit garbled.
While each step of this pipeline makes sense in light of the tools we've previously discussed, the long string of code is not particularly easy to read or use.
This two-dimensional GroupBy is common enough that Pandas includes a convenience routine, pivot_table, which succinctly handles this type of multi-dimensional aggregation.
Here is the equivalent to the preceding operation using the pivot_table method of DataFrames:
titanic.pivot_table('survived', index='sex', columns='class')
| class | First | Second | Third |
|---|---|---|---|
| sex | |||
| female | 0.968085 | 0.921053 | 0.500000 |
| male | 0.368852 | 0.157407 | 0.135447 |
This is eminently more readable than the groupby approach, and produces the same result.
As you might expect of an early 20th-century transatlantic cruise, the survival gradient favors both women and higher classes.
First-class women survived with near certainty (hi, Rose!), while only one in ten third-class men survived (sorry, Jack!).
Just as in the GroupBy, the grouping in pivot tables can be specified with multiple levels, and via a number of options.
For example, we might be interested in looking at age as a third dimension.
We'll bin the age using the pd.cut function:
age = pd.cut(titanic['age'], [0, 18, 80])
titanic.pivot_table('survived', ['sex', age], 'class')
| class | First | Second | Third | |
|---|---|---|---|---|
| sex | age | |||
| female | (0, 18] | 0.909091 | 1.000000 | 0.511628 |
| (18, 80] | 0.972973 | 0.900000 | 0.423729 | |
| male | (0, 18] | 0.800000 | 0.600000 | 0.215686 |
| (18, 80] | 0.375000 | 0.071429 | 0.133663 |
We can apply the same strategy when working with the columns as well; let's add info on the fare paid using pd.qcut to automatically compute quantiles:
fare = pd.qcut(titanic['fare'], 2)
titanic.pivot_table('survived', ['sex', age], [fare, 'class'])
| fare | (-0.001, 14.454] | (14.454, 512.329] | |||||
|---|---|---|---|---|---|---|---|
| class | First | Second | Third | First | Second | Third | |
| sex | age | ||||||
| female | (0, 18] | NaN | 1.000000 | 0.714286 | 0.909091 | 1.000000 | 0.318182 |
| (18, 80] | NaN | 0.880000 | 0.444444 | 0.972973 | 0.914286 | 0.391304 | |
| male | (0, 18] | NaN | 0.000000 | 0.260870 | 0.800000 | 0.818182 | 0.178571 |
| (18, 80] | 0.0 | 0.098039 | 0.125000 | 0.391304 | 0.030303 | 0.192308 | |
The result is a four-dimensional aggregation with hierarchical indices, shown in a grid demonstrating the relationship between the values.
The full call signature of the pivot_table method of DataFrames is as follows:
# call signature as of Pandas 0.18
DataFrame.pivot_table(data, values=None, index=None, columns=None,
aggfunc='mean', fill_value=None, margins=False,
dropna=True, margins_name='All')
We've already seen examples of the first three arguments; here we'll take a quick look at the remaining ones.
Two of the options, fill_value and dropna, have to do with missing data and are fairly straightforward; we will not show examples of them here.
The aggfunc keyword controls what type of aggregation is applied, which is a mean by default.
As in the GroupBy, the aggregation specification can be a string representing one of several common choices (e.g., 'sum', 'mean', 'count', 'min', 'max', etc.) or a function that implements an aggregation (e.g., np.sum(), min(), sum(), etc.).
Additionally, it can be specified as a dictionary mapping a column to any of the above desired options:
titanic.pivot_table(index='sex', columns='class',
aggfunc={'survived':sum, 'fare':'mean'})
| fare | survived | |||||
|---|---|---|---|---|---|---|
| class | First | Second | Third | First | Second | Third |
| sex | ||||||
| female | 106.125798 | 21.970121 | 16.118810 | 91 | 70 | 72 |
| male | 67.226127 | 19.741782 | 12.661633 | 45 | 17 | 47 |
Notice also here that we've omitted the values keyword; when specifying a mapping for aggfunc, this is determined automatically.
At times it's useful to compute totals along each grouping.
This can be done via the margins keyword:
titanic.pivot_table('survived', index='sex', columns='class', margins=True)
| class | First | Second | Third | All |
|---|---|---|---|---|
| sex | ||||
| female | 0.968085 | 0.921053 | 0.500000 | 0.742038 |
| male | 0.368852 | 0.157407 | 0.135447 | 0.188908 |
| All | 0.629630 | 0.472826 | 0.242363 | 0.383838 |
Here this automatically gives us information about the class-agnostic survival rate by gender, the gender-agnostic survival rate by class, and the overall survival rate of 38%.
The margin label can be specified with the margins_name keyword, which defaults to "All".
As a more interesting example, let's take a look at the freely available data on births in the United States, provided by the Centers for Disease Control (CDC). This data can be found at https://raw.githubusercontent.com/jakevdp/data-CDCbirths/master/births.csv:
births = pd.read_csv('data/births.csv')
births.describe()
| year | month | day | births | |
|---|---|---|---|---|
| count | 15547.000000 | 15547.000000 | 15067.000000 | 15547.000000 |
| mean | 1979.037435 | 6.515919 | 17.769894 | 9762.293561 |
| std | 6.728340 | 3.449632 | 15.284034 | 28552.465810 |
| min | 1969.000000 | 1.000000 | 1.000000 | 1.000000 |
| 25% | 1974.000000 | 4.000000 | 8.000000 | 4358.000000 |
| 50% | 1979.000000 | 7.000000 | 16.000000 | 4814.000000 |
| 75% | 1984.000000 | 10.000000 | 24.000000 | 5289.500000 |
| max | 2008.000000 | 12.000000 | 99.000000 | 199622.000000 |
Taking a look at the data, we see that it's relatively simple–it contains the number of births grouped by date and gender:
births.head()
| year | month | day | gender | births | |
|---|---|---|---|---|---|
| 0 | 1969 | 1 | 1.0 | F | 4046 |
| 1 | 1969 | 1 | 1.0 | M | 4440 |
| 2 | 1969 | 1 | 2.0 | F | 4454 |
| 3 | 1969 | 1 | 2.0 | M | 4548 |
| 4 | 1969 | 1 | 3.0 | F | 4548 |
We can start to understand this data a bit more by using a pivot table. Let's add a decade column, and take a look at male and female births as a function of decade:
births['decade'] = 10 * (births['year'] // 10)
births.pivot_table('births', index='decade', columns='gender', aggfunc='sum')
| gender | F | M |
|---|---|---|
| decade | ||
| 1960 | 1753634 | 1846572 |
| 1970 | 16263075 | 17121550 |
| 1980 | 18310351 | 19243452 |
| 1990 | 19479454 | 20420553 |
| 2000 | 18229309 | 19106428 |
We immediately see that male births outnumber female births in every decade. To see this trend a bit more clearly, we can use the built-in plotting tools in Pandas to visualize the total number of births by year:
%matplotlib inline
import matplotlib.pyplot as plt
sns.set() # use Seaborn styles
births.pivot_table('births', index='year', columns='gender', aggfunc='sum').plot()
plt.ylabel('total births per year');
With a simple pivot table and plot() method, we can immediately see the annual trend in births by gender. By eye, it appears that over the past 50 years male births have outnumbered female births by around 5%.
Though this doesn't necessarily relate to the pivot table, there are a few more interesting features we can pull out of this dataset using the Pandas tools covered up to this point. We must start by cleaning the data a bit, removing outliers caused by mistyped dates (e.g., June 31st) or missing values (e.g., June 99th). One easy way to remove these all at once is to cut outliers; we'll do this via a robust sigma-clipping operation:
quartiles = np.percentile(births['births'], [25, 50, 75])
mu = quartiles[1]
sig = 0.74 * (quartiles[2] - quartiles[0])
This final line is a robust estimate of the sample mean, where the 0.74 comes from the interquartile range of a Gaussian distribution.
With this we can use the query() method to filter-out rows with births outside these values:
births = births.query('(births > @mu - 5 * @sig) & (births < @mu + 5 * @sig)')
Next we set the day column to integers; previously it had been a string because some columns in the dataset contained the value 'null':
# set 'day' column to integer; it originally was a string due to nulls
births['day'] = births['day'].astype(int)
Finally, we can combine the day, month, and year to create a Date index. This allows us to quickly compute the weekday corresponding to each row:
# create a datetime index from the year, month, day
births.index = pd.to_datetime(10000 * births.year +
100 * births.month +
births.day, format='%Y%m%d')
births['dayofweek'] = births.index.dayofweek
import matplotlib.pyplot as plt
import matplotlib as mpl
births.pivot_table('births', index='dayofweek',
columns='decade', aggfunc='mean').plot()
plt.gca().set_xticklabels(['Mon', 'Tues', 'Wed', 'Thurs', 'Fri', 'Sat', 'Sun'])
plt.ylabel('mean births by day');
Apparently births are slightly less common on weekends than on weekdays! Note that the 1990s and 2000s are missing because the CDC data contains only the month of birth starting in 1989.
Another intersting view is to plot the mean number of births by the day of the year. Let's first group the data by month and day separately:
births_by_date = births.pivot_table('births', [births.index.month, births.index.day])
births_by_date.head()
| births | ||
|---|---|---|
| 1 | 1 | 4009.225 |
| 2 | 4247.400 | |
| 3 | 4500.900 | |
| 4 | 4571.350 | |
| 5 | 4603.625 |
The result is a multi-index over months and days.
To make this easily plottable, let's turn these months and days into a date by associating them with a dummy year variable (making sure to choose a leap year so February 29th is correctly handled!)
from datetime import datetime
births_by_date.index = [datetime(2012, month, day)
for (month, day) in births_by_date.index]
births_by_date.head()
| births | |
|---|---|
| 2012-01-01 | 4009.225 |
| 2012-01-02 | 4247.400 |
| 2012-01-03 | 4500.900 |
| 2012-01-04 | 4571.350 |
| 2012-01-05 | 4603.625 |
Focusing on the month and day only, we now have a time series reflecting the average number of births by date of the year.
From this, we can use the plot method to plot the data. It reveals some interesting trends:
fig, ax = plt.subplots(figsize=(12, 4))
births_by_date.plot(ax=ax);
In particular, the striking feature of this graph is the dip in birthrate on US holidays (e.g., Independence Day, Labor Day, Thanksgiving, Christmas, New Year's Day) although this likely reflects trends in scheduled/induced births rather than some deep psychosomatic effect on natural births.
Looking at this short example, you can see that many of the Python and Pandas tools we've seen to this point can be combined and used to gain insight from a variety of datasets.
Pandas was developed in the context of financial modeling, so as you might expect, it contains a fairly extensive set of tools for working with dates, times, and time-indexed data. Date and time data comes in a few flavors:
The Python world has a number of available representations of dates, times, deltas, and timespans. While the time series tools provided by Pandas tend to be the most useful for data science applications, it is helpful to see their relationship to other packages used in Python.
datetime and dateutil¶Python's basic objects for working with dates and times reside in the built-in datetime module.
Along with the third-party dateutil module, you can use it to quickly perform a host of useful functionalities on dates and times:
from datetime import datetime
datetime(year=2015, month=7, day=4)
datetime.datetime(2015, 7, 4, 0, 0)
Or, using the dateutil module, you can parse dates from a variety of string formats:
from dateutil import parser
date = parser.parse("4th of July, 2015")
date
datetime.datetime(2015, 7, 4, 0, 0)
Once you have a datetime object, you can do things like printing the day of the week:
date.strftime('%A')
'Saturday'
A related package to be aware of is pytz, which contains tools for working with the most migrane-inducing piece of time series data: time zones.
The power of datetime and dateutil lie in their flexibility and easy syntax: you can use these objects and their built-in methods to easily perform nearly any operation you might be interested in.
Where they break down is when you wish to work with large arrays of dates and times:
just as lists of Python numerical variables are suboptimal compared to NumPy-style typed numerical arrays, lists of Python datetime objects are suboptimal compared to typed arrays of encoded dates.
datetime64¶The weaknesses of Python's datetime format inspired the NumPy team to add a set of native time series data type to NumPy.
The datetime64 dtype encodes dates as 64-bit integers, and thus allows arrays of dates to be represented very compactly.
date = np.array('2015-07-04', dtype=np.datetime64)
date
array('2015-07-04', dtype='datetime64[D]')
Once we have this date formatted, however, we can quickly do vectorized operations on it:
date + np.arange(12)
array(['2015-07-04', '2015-07-05', '2015-07-06', '2015-07-07',
'2015-07-08', '2015-07-09', '2015-07-10', '2015-07-11',
'2015-07-12', '2015-07-13', '2015-07-14', '2015-07-15'],
dtype='datetime64[D]')
Because of the uniform type in NumPy datetime64 arrays, this type of operation can be accomplished much more quickly than if we were working directly with Python's datetime objects, especially as arrays get large.
One detail of the datetime64 and timedelta64 objects is that they are built on a fundamental time unit.
Because the datetime64 object is limited to 64-bit precision, the range of encodable times is $2^{64}$ times this fundamental unit. In other words, datetime64 imposes a trade-off between time resolution and maximum time span.
If you want a time resolution of one nanosecond, you only have enough information to encode a range of $2^{64}$ nanoseconds, or just under 600 years:
np.datetime64('2015-07-04') # a day-based datetime
numpy.datetime64('2015-07-04')
np.datetime64('2015-07-04 12:00') # a minute-based datetime
numpy.datetime64('2015-07-04T12:00')
np.datetime64('2015-07-04 12:59:59.50', 'ns') # a nanosecond-based datetiem
numpy.datetime64('2015-07-04T12:59:59.500000000')
The following table lists the available format codes along with the relative and absolute timespans that they can encode:
| Code | Meaning | Time span (relative) | Time span (absolute) |
|---|---|---|---|
Y |
Year | ± 9.2e18 years | [9.2e18 BC, 9.2e18 AD] |
M |
Month | ± 7.6e17 years | [7.6e17 BC, 7.6e17 AD] |
W |
Week | ± 1.7e17 years | [1.7e17 BC, 1.7e17 AD] |
D |
Day | ± 2.5e16 years | [2.5e16 BC, 2.5e16 AD] |
h |
Hour | ± 1.0e15 years | [1.0e15 BC, 1.0e15 AD] |
m |
Minute | ± 1.7e13 years | [1.7e13 BC, 1.7e13 AD] |
s |
Second | ± 2.9e12 years | [ 2.9e9 BC, 2.9e9 AD] |
ms |
Millisecond | ± 2.9e9 years | [ 2.9e6 BC, 2.9e6 AD] |
us |
Microsecond | ± 2.9e6 years | [290301 BC, 294241 AD] |
ns |
Nanosecond | ± 292 years | [ 1678 AD, 2262 AD] |
ps |
Picosecond | ± 106 days | [ 1969 AD, 1970 AD] |
fs |
Femtosecond | ± 2.6 hours | [ 1969 AD, 1970 AD] |
as |
Attosecond | ± 9.2 seconds | [ 1969 AD, 1970 AD] |
Pandas builds upon all the tools just discussed to provide a Timestamp object, which combines the ease-of-use of datetime and dateutil with the efficient storage and vectorized interface of numpy.datetime64.
From a group of these Timestamp objects, Pandas can construct a DatetimeIndex that can be used to index data in a Series or DataFrame:
date = pd.to_datetime("4th of July, 2015")
date
Timestamp('2015-07-04 00:00:00')
date.strftime('%A')
'Saturday'
date + pd.to_timedelta(np.arange(12), 'D') # do NumPy-style vectorized operations directly on this same object
DatetimeIndex(['2015-07-04', '2015-07-05', '2015-07-06', '2015-07-07',
'2015-07-08', '2015-07-09', '2015-07-10', '2015-07-11',
'2015-07-12', '2015-07-13', '2015-07-14', '2015-07-15'],
dtype='datetime64[ns]', freq=None)
Where the Pandas time series tools really become useful is when you begin to index data by timestamps:
index = pd.DatetimeIndex(['2014-07-04', '2014-08-04', '2015-07-04', '2015-08-04'])
data = pd.Series([0, 1, 2, 3], index=index)
data
2014-07-04 0 2014-08-04 1 2015-07-04 2 2015-08-04 3 dtype: int64
Now that we have this data in a Series, we can make use of any of the Series indexing patterns passing values that can be coerced into dates:
data['2014-07-04':'2015-07-04']
2014-07-04 0 2014-08-04 1 2015-07-04 2 dtype: int64
data['2015'] # additional special date-only indexing operations
2015-07-04 2 2015-08-04 3 dtype: int64
The fundamental Pandas data structures for working with time series data:
Timestamp type. As mentioned before, it is essentially a replacement for Python's native datetime, but is based on the more efficient numpy.datetime64 data type. The associated Index structure is DatetimeIndex.Period type. This encodes a fixed-frequency interval based on numpy.datetime64. The associated index structure is PeriodIndex.Timedelta type. Timedelta is a more efficient replacement for Python's native datetime.timedelta type, and is based on numpy.timedelta64. The associated index structure is TimedeltaIndex.The most fundamental of these date/time objects are the Timestamp and DatetimeIndex objects.
While these class objects can be invoked directly, it is more common to use the pd.to_datetime() function, which can parse a wide variety of formats.
Passing a single date to pd.to_datetime() yields a Timestamp; passing a series of dates by default yields a DatetimeIndex:
dates = pd.to_datetime([datetime(2015, 7, 3), '4th of July, 2015',
'2015-Jul-6', '07-07-2015', '20150708'])
dates
DatetimeIndex(['2015-07-03', '2015-07-04', '2015-07-06', '2015-07-07',
'2015-07-08'],
dtype='datetime64[ns]', freq=None)
Any DatetimeIndex can be converted to a PeriodIndex with the to_period() function with the addition of a frequency code; here we'll use 'D' to indicate daily frequency:
dates.to_period('D')
PeriodIndex(['2015-07-03', '2015-07-04', '2015-07-06', '2015-07-07',
'2015-07-08'],
dtype='period[D]', freq='D')
dates - dates[0] # A ``TimedeltaIndex`` is createdwhen subtracting
TimedeltaIndex(['0 days', '1 days', '3 days', '4 days', '5 days'], dtype='timedelta64[ns]', freq=None)
pd.date_range()¶To make the creation of regular date sequences more convenient, Pandas offers a few functions for this purpose: pd.date_range() for timestamps, pd.period_range() for periods, and pd.timedelta_range() for time deltas:
pd.date_range('2015-07-03', '2015-07-10') # by default, the frequency is one day
DatetimeIndex(['2015-07-03', '2015-07-04', '2015-07-05', '2015-07-06',
'2015-07-07', '2015-07-08', '2015-07-09', '2015-07-10'],
dtype='datetime64[ns]', freq='D')
pd.date_range('2015-07-03', periods=8)
DatetimeIndex(['2015-07-03', '2015-07-04', '2015-07-05', '2015-07-06',
'2015-07-07', '2015-07-08', '2015-07-09', '2015-07-10'],
dtype='datetime64[ns]', freq='D')
pd.date_range('2015-07-03', periods=8, freq='H')
DatetimeIndex(['2015-07-03 00:00:00', '2015-07-03 01:00:00',
'2015-07-03 02:00:00', '2015-07-03 03:00:00',
'2015-07-03 04:00:00', '2015-07-03 05:00:00',
'2015-07-03 06:00:00', '2015-07-03 07:00:00'],
dtype='datetime64[ns]', freq='H')
pd.period_range('2015-07', periods=8, freq='M')
PeriodIndex(['2015-07', '2015-08', '2015-09', '2015-10', '2015-11', '2015-12',
'2016-01', '2016-02'],
dtype='period[M]', freq='M')
pd.timedelta_range(0, periods=10, freq='H')
TimedeltaIndex(['00:00:00', '01:00:00', '02:00:00', '03:00:00', '04:00:00',
'05:00:00', '06:00:00', '07:00:00', '08:00:00', '09:00:00'],
dtype='timedelta64[ns]', freq='H')
Fundamental to these Pandas time series tools is the concept of a frequency or date offset.
Just as we saw the D (day) and H (hour) codes above, we can use such codes to specify any desired frequency spacing:
| Code | Description | Code | Description |
|---|---|---|---|
D |
Calendar day | B |
Business day |
W |
Weekly | ||
M |
Month end | BM |
Business month end |
Q |
Quarter end | BQ |
Business quarter end |
A |
Year end | BA |
Business year end |
H |
Hours | BH |
Business hours |
T |
Minutes | ||
S |
Seconds | ||
L |
Milliseonds | ||
U |
Microseconds | ||
N |
nanoseconds |
The monthly, quarterly, and annual frequencies are all marked at the end of the specified period.
By adding an S suffix to any of these, they instead will be marked at the beginning:
| Code | Description | Code | Description | |
|---|---|---|---|---|
MS |
Month start | BMS |
Business month start | |
QS |
Quarter start | BQS |
Business quarter start | |
AS |
Year start | BAS |
Business year start |
Additionally, you can change the month used to mark any quarterly or annual code by adding a three-letter month code as a suffix:
Q-JAN, BQ-FEB, QS-MAR, BQS-APR, etc.A-JAN, BA-FEB, AS-MAR, BAS-APR, etc.In the same way, the split-point of the weekly frequency can be modified by adding a three-letter weekday code:
W-SUN, W-MON, W-TUE, W-WED, etc.On top of this, codes can be combined with numbers to specify other frequencies.
For example, for a frequency of 2 hours 30 minutes, we can combine the hour (H) and minute (T) codes as follows:
pd.timedelta_range(0, periods=9, freq="2H30T")
TimedeltaIndex(['00:00:00', '02:30:00', '05:00:00', '07:30:00', '10:00:00',
'12:30:00', '15:00:00', '17:30:00', '20:00:00'],
dtype='timedelta64[ns]', freq='150T')
All of these short codes refer to specific instances of Pandas time series offsets, which can be found in the pd.tseries.offsets module.
For example, we can create a business day offset directly as follows:
from pandas.tseries.offsets import BDay
pd.date_range('2015-07-01', periods=5, freq=BDay())
DatetimeIndex(['2015-07-01', '2015-07-02', '2015-07-03', '2015-07-06',
'2015-07-07'],
dtype='datetime64[ns]', freq='B')
The ability to use dates and times as indices to intuitively organize and access data is an important piece of the Pandas time series tools. The benefits of indexed data in general (automatic alignment during operations, intuitive data slicing and access, etc.) still apply, and Pandas provides several additional time series-specific operations.
We will take a look at a few of those here, using some stock price data as an example. Because Pandas was developed largely in a finance context, it includes some very specific tools for financial data.
For example, the accompanying pandas-datareader package (installable via pip install pandas-datareader), knows how to import financial data from a number of available sources, including Yahoo finance, Google Finance, and others.
Download some data (ADD A DESCRIPTION FOR IT)
from pandas_datareader import data
goog = data.DataReader('VIXCLS', start='2004', end='2016', data_source='fred')
goog.head()
| VIXCLS | |
|---|---|
| DATE | |
| 2004-01-01 | NaN |
| 2004-01-02 | 18.22 |
| 2004-01-05 | 17.49 |
| 2004-01-06 | 16.73 |
| 2004-01-07 | 15.50 |
goog = goog['VIXCLS'] # for simplicity consider its sole column
%matplotlib inline
import matplotlib.pyplot as plt
import seaborn; seaborn.set()
goog.plot();
One common need for time series data is resampling at a higher or lower frequency.
This can be done using the resample() method, or the much simpler asfreq() method.
The primary difference between the two is that resample() is fundamentally a data aggregation, while asfreq() is fundamentally a data selection.
Let's compare what the two return when we down-sample the data; we will resample the data at the end of business year:
goog.plot(alpha=0.5, style='-')
goog.resample('BA').mean().plot(style=':')
goog.asfreq('BA').plot(style='--');
plt.legend(['input', 'resample', 'asfreq'], loc='upper left');
Notice the difference: at each point, resample reports the average of the previous year, while asfreq reports the value at the end of the year.
For up-sampling, resample() and asfreq() are largely equivalent, though resample has many more options available.
In this case, the default for both methods is to leave the up-sampled points empty, that is, filled with NA values.
Just as with the pd.fillna() function discussed previously, asfreq() accepts a method argument to specify how values are imputed.
We resample the business day data at a daily frequency (i.e., including weekends):
fig, ax = plt.subplots(2, sharex=True)
data = goog.iloc[:10]
data.asfreq('D').plot(ax=ax[0], marker='o')
data.asfreq('D', method='bfill').plot(ax=ax[1], style='-o')
data.asfreq('D', method='ffill').plot(ax=ax[1], style='--o')
ax[1].legend(["back-fill", "forward-fill"]);
The top panel is the default: non-business days are left as NA values and do not appear on the plot. The bottom panel shows the differences between two strategies for filling the gaps: forward-filling and backward-filling.
Another common time series-specific operation is shifting of data in time.
Pandas has two closely related methods for computing this: shift() and tshift()
In short, the difference between them is that shift() shifts the data, while tshift() shifts the index.
In both cases, the shift is specified in multiples of the frequency.
Here we will both shift() and tshift() by 900 days;
fig, ax = plt.subplots(3, sharey=True)
# apply a frequency to the data
goog = goog.asfreq('D', method='pad')
goog.plot(ax=ax[0])
goog.shift(900).plot(ax=ax[1])
goog.tshift(900).plot(ax=ax[2])
# legends and annotations
local_max = pd.to_datetime('2007-11-05')
offset = pd.Timedelta(900, 'D')
ax[0].legend(['input'], loc=2)
ax[0].get_xticklabels()[2].set(weight='heavy', color='red')
ax[0].axvline(local_max, alpha=0.3, color='red')
ax[1].legend(['shift(900)'], loc=2)
ax[1].get_xticklabels()[2].set(weight='heavy', color='red')
ax[1].axvline(local_max + offset, alpha=0.3, color='red')
ax[2].legend(['tshift(900)'], loc=2)
ax[2].get_xticklabels()[1].set(weight='heavy', color='red')
ax[2].axvline(local_max + offset, alpha=0.3, color='red');
We see here that shift(900) shifts the data by 900 days, pushing some of it off the end of the graph (and leaving NA values at the other end), while tshift(900) shifts the index values by 900 days.
A common context for this type of shift is in computing differences over time. For example, we use shifted values to compute the one-year return on investment for Google stock over the course of the dataset:
ROI = 100 * (goog.tshift(-365) / goog - 1)
ROI.plot()
plt.ylabel('% Return on Investment');
This helps us to see the overall trend in Google stock: thus far, the most profitable times to invest in Google have been (unsurprisingly, in retrospect) shortly after its IPO, and in the middle of the 2009 recession.
Rolling statistics are a third type of time series-specific operation implemented by Pandas.
These can be accomplished via the rolling() attribute of Series and DataFrame objects, which returns a view similar to what we saw with the groupby operation (see Aggregation and Grouping).
This rolling view makes available a number of aggregation operations by default.
For example, here is the one-year centered rolling mean and standard deviation of the Google stock prices:
rolling = goog.rolling(40, center=True)
data = pd.DataFrame({'input': goog,
'one-year rolling_mean': rolling.mean(),
'one-year rolling_std': rolling.std()})
ax = data.plot(style=['-', '--', ':'])
ax.lines[0].set_alpha(0.3)
As with group-by operations, the aggregate() and apply() methods can be used for custom rolling computations.
As a more involved example of working with some time series data, let's take a look at bicycle counts on Seattle's Fremont Bridge. This data comes from an automated bicycle counter, installed in late 2012, which has inductive sensors on the east and west sidewalks of the bridge. The hourly bicycle counts can be downloaded from http://data.seattle.gov/; here is the direct link to the dataset.
As of summer 2016, the CSV can be downloaded as follows:
!curl -o FremontBridge.csv https://data.seattle.gov/api/views/65db-xm6k/rows.csv?accessType=DOWNLOAD
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 2024k 0 2024k 0 0 331k 0 --:--:-- 0:00:06 --:--:-- 462k
Once this dataset is downloaded, we can use Pandas to read the CSV output into a DataFrame.
We will specify that we want the Date as an index, and we want these dates to be automatically parsed:
data = pd.read_csv('data/FremontBridge.csv', index_col='Date', parse_dates=True)
data.head()
| Fremont Bridge Total | Fremont Bridge East Sidewalk | Fremont Bridge West Sidewalk | |
|---|---|---|---|
| Date | |||
| 2012-10-03 00:00:00 | 13.0 | 4.0 | 9.0 |
| 2012-10-03 01:00:00 | 10.0 | 4.0 | 6.0 |
| 2012-10-03 02:00:00 | 2.0 | 1.0 | 1.0 |
| 2012-10-03 03:00:00 | 5.0 | 2.0 | 3.0 |
| 2012-10-03 04:00:00 | 7.0 | 6.0 | 1.0 |
We'll further process this dataset by shortening the column names and adding a "Total" column:
data['Total'] = data["Fremont Bridge Total"]
data.dropna().describe() # have a look at summary statistics
| Fremont Bridge Total | Fremont Bridge East Sidewalk | Fremont Bridge West Sidewalk | Total | |
|---|---|---|---|---|
| count | 64934.000000 | 64934.000000 | 64934.000000 | 64934.000000 |
| mean | 113.805033 | 51.976191 | 61.828842 | 113.805033 |
| std | 145.235402 | 67.013247 | 90.605138 | 145.235402 |
| min | 0.000000 | 0.000000 | 0.000000 | 0.000000 |
| 25% | 14.000000 | 6.000000 | 7.000000 | 14.000000 |
| 50% | 61.000000 | 28.000000 | 30.000000 | 61.000000 |
| 75% | 148.000000 | 70.000000 | 74.000000 | 148.000000 |
| max | 1097.000000 | 698.000000 | 850.000000 | 1097.000000 |
We can gain some insight into the dataset by visualizing it:
%matplotlib inline
import seaborn; seaborn.set()
data.plot()
plt.ylabel('Hourly Bicycle Count');
The ~25,000 hourly samples are far too dense for us to make much sense of. We can gain more insight by resampling the data to a coarser grid:
weekly = data.resample('W').sum()
weekly.plot(style=[':', '--', '-'])
plt.ylabel('Weekly bicycle count');
This shows us some interesting seasonal trends: as you might expect, people bicycle more in the summer than in the winter, and even within a particular season the bicycle use varies from week to week (likely dependent on weather.
Another way that comes in handy for aggregating the data is to use a rolling mean, utilizing the pd.rolling_mean() function.
Here we'll do a 30 day rolling mean of our data, making sure to center the window:
daily = data.resample('D').sum()
daily.rolling(30, center=True).sum().plot(style=[':', '--', '-'])
plt.ylabel('mean hourly count');
The jaggedness of the result is due to the hard cutoff of the window. We can get a smoother version of a rolling mean using a window function–for example, a Gaussian window.
The following code specifies both the width of the window (we chose 50 days) and the width of the Gaussian within the window (we chose 10 days):
daily.rolling(50, center=True,
win_type='gaussian').sum(std=10).plot(style=[':', '--', '-']);
While these smoothed data views are useful to get an idea of the general trend in the data, they hide much of the interesting structure. For example, we might want to look at the average traffic as a function of the time of day; we do this by grouping:
by_time = data.groupby(data.index.time).mean()
hourly_ticks = 4 * 60 * 60 * np.arange(6)
by_time.plot(xticks=hourly_ticks, style=[':', '--', '-']);
The hourly traffic is a strongly bimodal distribution, with peaks around 8:00 in the morning and 5:00 in the evening. This is likely evidence of a strong component of commuter traffic crossing the bridge. This is further evidenced by the differences between the western sidewalk (generally used going toward downtown Seattle), which peaks more strongly in the morning, and the eastern sidewalk (generally used going away from downtown Seattle), which peaks more strongly in the evening.
We also might be curious about how things change based on the day of the week. Again, we can do this with a simple groupby:
by_weekday = data.groupby(data.index.dayofweek).mean()
by_weekday.index = ['Mon', 'Tues', 'Wed', 'Thurs', 'Fri', 'Sat', 'Sun']
by_weekday.plot(style=[':', '--', '-']);
This shows a strong distinction between weekday and weekend totals, with around twice as many average riders crossing the bridge on Monday through Friday than on Saturday and Sunday.
With this in mind, let's do a compound GroupBy and look at the hourly trend on weekdays versus weekends. We'll start by grouping by both a flag marking the weekend, and the time of day:
weekend = np.where(data.index.weekday < 5, 'Weekday', 'Weekend')
by_time = data.groupby([weekend, data.index.time]).mean()
import matplotlib.pyplot as plt
fig, ax = plt.subplots(1, 2, figsize=(14, 5))
by_time.loc['Weekday'].plot(ax=ax[0], title='Weekdays',
xticks=hourly_ticks, style=[':', '--', '-'])
by_time.loc['Weekend'].plot(ax=ax[1], title='Weekends',
xticks=hourly_ticks, style=[':', '--', '-']);
The result is very interesting: we see a bimodal commute pattern during the work week, and a unimodal recreational pattern during the weekends.