import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import yfinance as yfMcKinney Chapter 5 - Getting Started with pandas
FINA 6333 for Spring 2025
%precision 4
pd.options.display.float_format = '{:.4f}'.format
# %config InlineBackend.figure_format = 'retina'Introduction
Chapter 5 of McKinney (2022) discusses the fundamentals of pandas, which will be our main tool for the rest of the semester. pandas is an abbreviation of panel data. Panel data contain observations on multiple entities (e.g., individuals, firms, or countries) over multiple time periods. Panel data combine cross-sectional data (i.e., data across entities at a single point in time) with time-series data (i.e., data over time for a single entity). Panel data are widely used in finance and economics to analyze trends, relationships, and behaviors over time and across entities. We will use pandas every day for the rest of the course!
pandas will be a major tool of interest throughout much of the rest of the book. It contains data structures and data manipulation tools designed to make data cleaning and analysis fast and easy in Python. pandas is often used in tandem with numerical computing tools like NumPy and SciPy, analytical libraries like statsmodels and scikit-learn, and data visualization libraries like matplotlib. pandas adopts significant parts of NumPy’s idiomatic style of array-based computing, especially array-based functions and a preference for data processing without for loops.
While pandas adopts many coding idioms from NumPy, the biggest difference is that pandas is designed for working with tabular or heterogeneous data. NumPy, by contrast, is best suited for working with homogeneous numerical array data.
Note: Indented block quotes are from McKinney (2022) unless otherwise indicated. The section numbers here differ from McKinney (2022) because we will only discuss some topics.
Introduction to pandas Data Structures
To get started with pandas, you will need to get comfortable with its two workhorse data structures: Series and DataFrame. While they are not a universal solution for every problem, they provide a solid, easy-to-use basis for most applications.
Series
A Series is a one-dimensional array-like object containing a sequence of values (of similar types to NumPy types) and an associated array of data labels, called its index. The simplest Series is formed from only an array of data.
The early examples use integer and string labels, but date-time labels are most useful.
obj = pd.Series([4, 7, -5, 3])
obj0 4
1 7
2 -5
3 3
dtype: int64
Contrast obj with its NumPy array equivalent:
np.array([4, 7, -5, 3])array([ 4, 7, -5, 3])
obj.valuesarray([ 4, 7, -5, 3])
obj.index # similar to range(4)RangeIndex(start=0, stop=4, step=1)
We did not explicitly create an index for obj, so obj has an integer index that starts at 0. We can explicitly create an index with the index= argument.
obj2 = pd.Series([4, 7, -5, 3], index=['d', 'b', 'a', 'c'])
obj2d 4
b 7
a -5
c 3
dtype: int64
obj2.indexIndex(['d', 'b', 'a', 'c'], dtype='object')
obj2['a']np.int64(-5)
obj2.loc['a']np.int64(-5)
obj2.iloc[2]np.int64(-5)
obj2['d'] = 6
obj2d 6
b 7
a -5
c 3
dtype: int64
obj2[['c', 'a', 'd']]c 3
a -5
d 6
dtype: int64
obj2.loc[['c', 'a', 'd']]c 3
a -5
d 6
dtype: int64
A pandas series is like a NumPy array, and we can use Boolean filters and perform vectorized mathematical operations.
obj2 > 0d True
b True
a False
c True
dtype: bool
obj2[obj2 > 0]d 6
b 7
c 3
dtype: int64
obj2.loc[obj2 > 0]d 6
b 7
c 3
dtype: int64
obj2 * 2d 12
b 14
a -10
c 6
dtype: int64
We can also create a pandas series from a dictionary. The dictionary keys become the series index.
sdata = {'Ohio': 35000, 'Texas': 71000, 'Oregon': 16000, 'Utah': 5000}
obj3 = pd.Series(sdata)
obj3Ohio 35000
Texas 71000
Oregon 16000
Utah 5000
dtype: int64
If we also specify an index with list states, pandas will:
- Respect the index order
- Keep California because it was in the index
- Drop Utah because it was not in the index
states = ['California', 'Ohio', 'Oregon', 'Texas']
obj4 = pd.Series(sdata, index=states)
obj4California NaN
Ohio 35000.0000
Oregon 16000.0000
Texas 71000.0000
dtype: float64
When we perform mathematical operations, pandas aligns series by their indexes. Here NaN is “not a number”, indicating missing values. NaN is a float, so the data type switches from int64 to float64.
obj3 + obj4California NaN
Ohio 70000.0000
Oregon 32000.0000
Texas 142000.0000
Utah NaN
dtype: float64
DataFrame
A pandas data frame is like a worksheet in an Excel workbook with row labels and column names that provide fast indexing.
A DataFrame represents a rectangular table of data and contains an ordered collection of columns, each of which can be a different value type (numeric, string, boolean, etc.). The DataFrame has both a row and column index; it can be thought of as a dict of Series all sharing the same index. Under the hood, the data is stored as one or more two-dimensional blocks rather than a list, dict, or some other collection of one-dimensional arrays. The exact details of DataFrame’s internals are outside the scope of this book.
There are many ways to construct a DataFrame, though one of the most common is from a dict of equal-length lists or NumPy arrays:
data = {
'state': ['Ohio', 'Ohio', 'Ohio', 'Nevada', 'Nevada', 'Nevada'],
'year': [2000, 2001, 2002, 2001, 2002, 2003],
'pop': [1.5, 1.7, 3.6, 2.4, 2.9, 3.2]
}
frame = pd.DataFrame(data)
frame| state | year | pop | |
|---|---|---|---|
| 0 | Ohio | 2000 | 1.5000 |
| 1 | Ohio | 2001 | 1.7000 |
| 2 | Ohio | 2002 | 3.6000 |
| 3 | Nevada | 2001 | 2.4000 |
| 4 | Nevada | 2002 | 2.9000 |
| 5 | Nevada | 2003 | 3.2000 |
We did not specify an index, so frame has the default index of integers starting at 0.
frame2 = pd.DataFrame(
data,
columns=['year', 'state', 'pop', 'debt'],
index=['one', 'two', 'three', 'four', 'five', 'six']
)
frame2| year | state | pop | debt | |
|---|---|---|---|---|
| one | 2000 | Ohio | 1.5000 | NaN |
| two | 2001 | Ohio | 1.7000 | NaN |
| three | 2002 | Ohio | 3.6000 | NaN |
| four | 2001 | Nevada | 2.4000 | NaN |
| five | 2002 | Nevada | 2.9000 | NaN |
| six | 2003 | Nevada | 3.2000 | NaN |
If we extract one column with df.column or df['column'], we get a series. We can use the df.colname or df['colname'] syntax to extract a column from a data frame as a series. However, we must use the df['colname'] syntax to add a column to a data frame. Also, we must use the df['colname'] syntax to extract or add a column whose name contains whitespace.
frame2['state']one Ohio
two Ohio
three Ohio
four Nevada
five Nevada
six Nevada
Name: state, dtype: object
frame2.stateone Ohio
two Ohio
three Ohio
four Nevada
five Nevada
six Nevada
Name: state, dtype: object
Data frames have two dimensions, so we must slice data frames more precisely than series.
- The
.loc[]method slices by row labels and column names - The
.iloc[]method slices by integer row and label indexes
frame2.loc['three']year 2002
state Ohio
pop 3.6000
debt NaN
Name: three, dtype: object
frame2.iloc[2]year 2002
state Ohio
pop 3.6000
debt NaN
Name: three, dtype: object
We can use NumPy’s [row, column] syntax within .loc[] and .iloc[].
frame2.loc['three', 'state'] # row, column'Ohio'
frame2.iloc[2, 1] # row, column'Ohio'
frame2.loc['three', ['state', 'pop']] # row, columnstate Ohio
pop 3.6000
Name: three, dtype: object
frame2.iloc[2, [1, 2]] # row, columnstate Ohio
pop 3.6000
Name: three, dtype: object
We can assign either scalars or arrays to data frame columns.
- Scalars will broadcast to every row in the data frame
- Arrays must have the same length as the column
frame2['debt'] = 16.5
frame2| year | state | pop | debt | |
|---|---|---|---|---|
| one | 2000 | Ohio | 1.5000 | 16.5000 |
| two | 2001 | Ohio | 1.7000 | 16.5000 |
| three | 2002 | Ohio | 3.6000 | 16.5000 |
| four | 2001 | Nevada | 2.4000 | 16.5000 |
| five | 2002 | Nevada | 2.9000 | 16.5000 |
| six | 2003 | Nevada | 3.2000 | 16.5000 |
frame2['debt'] = np.arange(6.)
frame2| year | state | pop | debt | |
|---|---|---|---|---|
| one | 2000 | Ohio | 1.5000 | 0.0000 |
| two | 2001 | Ohio | 1.7000 | 1.0000 |
| three | 2002 | Ohio | 3.6000 | 2.0000 |
| four | 2001 | Nevada | 2.4000 | 3.0000 |
| five | 2002 | Nevada | 2.9000 | 4.0000 |
| six | 2003 | Nevada | 3.2000 | 5.0000 |
If we assign a series to a data frame column, pandas will use the index to align it with the data frame. Data frame rows not in the series will be NaN.
val = pd.Series([-1.2, -1.5, -1.7], index=['two', 'four', 'five'])
valtwo -1.2000
four -1.5000
five -1.7000
dtype: float64
frame2['debt'] = val
frame2| year | state | pop | debt | |
|---|---|---|---|---|
| one | 2000 | Ohio | 1.5000 | NaN |
| two | 2001 | Ohio | 1.7000 | -1.2000 |
| three | 2002 | Ohio | 3.6000 | NaN |
| four | 2001 | Nevada | 2.4000 | -1.5000 |
| five | 2002 | Nevada | 2.9000 | -1.7000 |
| six | 2003 | Nevada | 3.2000 | NaN |
We can add columns to our data frame, then delete them with del.
frame2['eastern'] = (frame2.state == 'Ohio')
frame2| year | state | pop | debt | eastern | |
|---|---|---|---|---|---|
| one | 2000 | Ohio | 1.5000 | NaN | True |
| two | 2001 | Ohio | 1.7000 | -1.2000 | True |
| three | 2002 | Ohio | 3.6000 | NaN | True |
| four | 2001 | Nevada | 2.4000 | -1.5000 | False |
| five | 2002 | Nevada | 2.9000 | -1.7000 | False |
| six | 2003 | Nevada | 3.2000 | NaN | False |
del frame2['eastern']
frame2| year | state | pop | debt | |
|---|---|---|---|---|
| one | 2000 | Ohio | 1.5000 | NaN |
| two | 2001 | Ohio | 1.7000 | -1.2000 |
| three | 2002 | Ohio | 3.6000 | NaN |
| four | 2001 | Nevada | 2.4000 | -1.5000 |
| five | 2002 | Nevada | 2.9000 | -1.7000 |
| six | 2003 | Nevada | 3.2000 | NaN |
Index Objects
obj = pd.Series(range(3), index=['a', 'b', 'c'])
index = obj.index
indexIndex(['a', 'b', 'c'], dtype='object')
Index objects are immutable!
# # TypeError: Index does not support mutable operations
# index[1] = 'd'Indexes can contain duplicates, so an index does not guarantee that our data are duplicate-free.
dup_labels = pd.Index(['foo', 'foo', 'bar', 'bar'])
dup_labelsIndex(['foo', 'foo', 'bar', 'bar'], dtype='object')
Essential Functionality
This section provides the most common pandas operations. It is difficult to provide an exhaustive reference, but this section introduces the most common operations.
Indexing, Selection, and Filtering
Indexing, selecting, and filtering will be among our most-used pandas features.
obj = pd.Series(np.arange(4.), index=['a', 'b', 'c', 'd'])
obja 0.0000
b 1.0000
c 2.0000
d 3.0000
dtype: float64
obj['b']1.0000
obj.loc['b']1.0000
obj.iloc[1]1.0000
obj.iloc[1:3]b 1.0000
c 2.0000
dtype: float64
When we slice with labels, the left and right endpoints are inclusive.
obj.loc['b':'c']b 1.0000
c 2.0000
dtype: float64
obj.loc['b':'c'] = 5
obja 0.0000
b 5.0000
c 5.0000
d 3.0000
dtype: float64
data = pd.DataFrame(
data=np.arange(16).reshape((4, 4)),
index=['Ohio', 'Colorado', 'Utah', 'New York'],
columns=['one', 'two', 'three', 'four']
)
data| one | two | three | four | |
|---|---|---|---|---|
| Ohio | 0 | 1 | 2 | 3 |
| Colorado | 4 | 5 | 6 | 7 |
| Utah | 8 | 9 | 10 | 11 |
| New York | 12 | 13 | 14 | 15 |
Indexing one column returns a series.
data['two']Ohio 1
Colorado 5
Utah 9
New York 13
Name: two, dtype: int64
Indexing columns with a list returns a data frame.
data[['three']]| three | |
|---|---|
| Ohio | 2 |
| Colorado | 6 |
| Utah | 10 |
| New York | 14 |
data[['three', 'one']]| three | one | |
|---|---|---|
| Ohio | 2 | 0 |
| Colorado | 6 | 4 |
| Utah | 10 | 8 |
| New York | 14 | 12 |
Table 5-4 summarizes data frame indexing and slicing options:
df[val]: Select single column or sequence of columns from the DataFrame; special case conveniences: boolean array (filter rows), slice (slice rows), or boolean DataFrame (set values based on some criterion)df.loc[val]: Selects single row or subset of rows from the DataFrame by labeldf.loc[:, val]: Selects single column or subset of columns by labeldf.loc[val1, val2]: Select both rows and columns by labeldf.iloc[where]: Selects single row or subset of rows from the DataFrame by integer positiondf.iloc[:, where]: Selects single column or subset of columns by integer positiondf.iloc[where_i, where_j]: Select both rows and columns by integer positiondf.at[label_i, label_j]: Select a single scalar value by row and column labeldf.iat[i, j]: Select a single scalar value by row and column position (integers) reindex method Select either rows or columns by labelsget_value,set_valuemethods: Select single value by row and column label
pandas is powerful and these options can be overwhelming! We will typically use df[val] to select columns (here val is either a string or list of strings), df.loc[val] to select rows (here val is a row label), and df.loc[val1, val2] to select both rows and columns. The other options add flexibility, and we may occasionally use them. However, our data will be large enough that counting row and column number will be tedious, making .iloc[] impractical.
Arithmetic and Data Alignment
An important pandas feature for some applications is the behavior of arithmetic between objects with different indexes. When you are adding together objects, if any index pairs are not the same, the respective index in the result will be the union of the index pairs. For users with database experience, this is similar to an automatic outer join on the index labels.
s1 = pd.Series(
data=[7.3, -2.5, 3.4, 1.5],
index=['a', 'c', 'd', 'e']
)
s2 = pd.Series(
data=[-2.1, 3.6, -1.5, 4, 3.1],
index=['a', 'c', 'e', 'f', 'g']
)s1a 7.3000
c -2.5000
d 3.4000
e 1.5000
dtype: float64
s2a -2.1000
c 3.6000
e -1.5000
f 4.0000
g 3.1000
dtype: float64
s1 + s2a 5.2000
c 1.1000
d NaN
e 0.0000
f NaN
g NaN
dtype: float64
df1 = pd.DataFrame(
data=np.arange(9.).reshape((3, 3)),
columns=list('bcd'),
index=['Ohio', 'Texas', 'Colorado']
)
df2 = pd.DataFrame(
data=np.arange(12.).reshape((4, 3)),
columns=list('bde'),
index=['Utah', 'Ohio', 'Texas', 'Oregon']
)df1| b | c | d | |
|---|---|---|---|
| Ohio | 0.0000 | 1.0000 | 2.0000 |
| Texas | 3.0000 | 4.0000 | 5.0000 |
| Colorado | 6.0000 | 7.0000 | 8.0000 |
df2| b | d | e | |
|---|---|---|---|
| Utah | 0.0000 | 1.0000 | 2.0000 |
| Ohio | 3.0000 | 4.0000 | 5.0000 |
| Texas | 6.0000 | 7.0000 | 8.0000 |
| Oregon | 9.0000 | 10.0000 | 11.0000 |
df1 + df2| b | c | d | e | |
|---|---|---|---|---|
| Colorado | NaN | NaN | NaN | NaN |
| Ohio | 3.0000 | NaN | 6.0000 | NaN |
| Oregon | NaN | NaN | NaN | NaN |
| Texas | 9.0000 | NaN | 12.0000 | NaN |
| Utah | NaN | NaN | NaN | NaN |
Always check your output!
Arithmetic methods with fill values
df1 = pd.DataFrame(
data=np.arange(12.).reshape((3, 4)),
columns=list('abcd')
)
df2 = pd.DataFrame(
data=np.arange(20.).reshape((4, 5)),
columns=list('abcde')
)
df2.loc[1, 'b'] = np.nandf1| a | b | c | d | |
|---|---|---|---|---|
| 0 | 0.0000 | 1.0000 | 2.0000 | 3.0000 |
| 1 | 4.0000 | 5.0000 | 6.0000 | 7.0000 |
| 2 | 8.0000 | 9.0000 | 10.0000 | 11.0000 |
df2| a | b | c | d | e | |
|---|---|---|---|---|---|
| 0 | 0.0000 | 1.0000 | 2.0000 | 3.0000 | 4.0000 |
| 1 | 5.0000 | NaN | 7.0000 | 8.0000 | 9.0000 |
| 2 | 10.0000 | 11.0000 | 12.0000 | 13.0000 | 14.0000 |
| 3 | 15.0000 | 16.0000 | 17.0000 | 18.0000 | 19.0000 |
df1 + df2| a | b | c | d | e | |
|---|---|---|---|---|---|
| 0 | 0.0000 | 2.0000 | 4.0000 | 6.0000 | NaN |
| 1 | 9.0000 | NaN | 13.0000 | 15.0000 | NaN |
| 2 | 18.0000 | 20.0000 | 22.0000 | 24.0000 | NaN |
| 3 | NaN | NaN | NaN | NaN | NaN |
We can specify a fill value for NaN values. pandas fills would-be NaN values in each data frame before the arithmetic operation.
df1.add(df2, fill_value=0)| a | b | c | d | e | |
|---|---|---|---|---|---|
| 0 | 0.0000 | 2.0000 | 4.0000 | 6.0000 | 4.0000 |
| 1 | 9.0000 | 5.0000 | 13.0000 | 15.0000 | 9.0000 |
| 2 | 18.0000 | 20.0000 | 22.0000 | 24.0000 | 14.0000 |
| 3 | 15.0000 | 16.0000 | 17.0000 | 18.0000 | 19.0000 |
Operations between DataFrame and Series
arr = np.arange(12.).reshape((3, 4))
arrarray([[ 0., 1., 2., 3.],
[ 4., 5., 6., 7.],
[ 8., 9., 10., 11.]])
arr[0]array([0., 1., 2., 3.])
arr - arr[0]array([[0., 0., 0., 0.],
[4., 4., 4., 4.],
[8., 8., 8., 8.]])
Arithmetic operations between series and data frames behave the same as in the example above.
frame = pd.DataFrame(
data=np.arange(12.).reshape((4, 3)),
columns=list('bde'),
index=['Utah', 'Ohio', 'Texas', 'Oregon']
)
series = frame.iloc[0]frame| b | d | e | |
|---|---|---|---|
| Utah | 0.0000 | 1.0000 | 2.0000 |
| Ohio | 3.0000 | 4.0000 | 5.0000 |
| Texas | 6.0000 | 7.0000 | 8.0000 |
| Oregon | 9.0000 | 10.0000 | 11.0000 |
seriesb 0.0000
d 1.0000
e 2.0000
Name: Utah, dtype: float64
frame - series| b | d | e | |
|---|---|---|---|
| Utah | 0.0000 | 0.0000 | 0.0000 |
| Ohio | 3.0000 | 3.0000 | 3.0000 |
| Texas | 6.0000 | 6.0000 | 6.0000 |
| Oregon | 9.0000 | 9.0000 | 9.0000 |
series2 = pd.Series(data=range(3), index=['b', 'e', 'f'])frame| b | d | e | |
|---|---|---|---|
| Utah | 0.0000 | 1.0000 | 2.0000 |
| Ohio | 3.0000 | 4.0000 | 5.0000 |
| Texas | 6.0000 | 7.0000 | 8.0000 |
| Oregon | 9.0000 | 10.0000 | 11.0000 |
series2b 0
e 1
f 2
dtype: int64
frame + series2| b | d | e | f | |
|---|---|---|---|---|
| Utah | 0.0000 | NaN | 3.0000 | NaN |
| Ohio | 3.0000 | NaN | 6.0000 | NaN |
| Texas | 6.0000 | NaN | 9.0000 | NaN |
| Oregon | 9.0000 | NaN | 12.0000 | NaN |
pandas has a .sub() method that lets us chain operations, but we might need the axis argument to get the result we want!
series3 = frame['d']frame| b | d | e | |
|---|---|---|---|
| Utah | 0.0000 | 1.0000 | 2.0000 |
| Ohio | 3.0000 | 4.0000 | 5.0000 |
| Texas | 6.0000 | 7.0000 | 8.0000 |
| Oregon | 9.0000 | 10.0000 | 11.0000 |
series3Utah 1.0000
Ohio 4.0000
Texas 7.0000
Oregon 10.0000
Name: d, dtype: float64
frame - series| b | d | e | |
|---|---|---|---|
| Utah | 0.0000 | 0.0000 | 0.0000 |
| Ohio | 3.0000 | 3.0000 | 3.0000 |
| Texas | 6.0000 | 6.0000 | 6.0000 |
| Oregon | 9.0000 | 9.0000 | 9.0000 |
frame.sub(series3, axis=0)| b | d | e | |
|---|---|---|---|
| Utah | -1.0000 | 0.0000 | 1.0000 |
| Ohio | -1.0000 | 0.0000 | 1.0000 |
| Texas | -1.0000 | 0.0000 | 1.0000 |
| Oregon | -1.0000 | 0.0000 | 1.0000 |
Function Application and Mapping
np.random.seed(42)
frame = pd.DataFrame(
data=np.random.randn(4, 3),
columns=list('bde'),
index=['Utah', 'Ohio', 'Texas', 'Oregon']
)
frame| b | d | e | |
|---|---|---|---|
| Utah | 0.4967 | -0.1383 | 0.6477 |
| Ohio | 1.5230 | -0.2342 | -0.2341 |
| Texas | 1.5792 | 0.7674 | -0.4695 |
| Oregon | 0.5426 | -0.4634 | -0.4657 |
frame.abs()| b | d | e | |
|---|---|---|---|
| Utah | 0.4967 | 0.1383 | 0.6477 |
| Ohio | 1.5230 | 0.2342 | 0.2341 |
| Texas | 1.5792 | 0.7674 | 0.4695 |
| Oregon | 0.5426 | 0.4634 | 0.4657 |
Another frequent operation is applying a function on one-dimensional arrays to each column or row. DataFrame’s apply method does exactly this:
frame.apply(lambda x: x.max() - x.min()) # implied axis=0b 1.0825
d 1.2309
e 1.1172
dtype: float64
frame.apply(lambda x: x.max() - x.min(), axis=1) # explicit axis=1Utah 0.7860
Ohio 1.7572
Texas 2.0487
Oregon 1.0083
dtype: float64
However, under the hood, the .apply() method is a for loop and slower than built-in methods.
%timeit frame['e'].abs()44.9 μs ± 10.7 μs per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
%timeit frame['e'].apply(np.abs)96.9 μs ± 16.6 μs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
Summarizing and Computing Descriptive Statistics
df = pd.DataFrame(
[[1.4, np.nan], [7.1, -4.5], [np.nan, np.nan], [0.75, -1.3]],
index=['a', 'b', 'c', 'd'],
columns=['one', 'two']
)
df| one | two | |
|---|---|---|
| a | 1.4000 | NaN |
| b | 7.1000 | -4.5000 |
| c | NaN | NaN |
| d | 0.7500 | -1.3000 |
df.sum() # implied axis=0one 9.2500
two -5.8000
dtype: float64
df.sum(axis=1)a 1.4000
b 2.6000
c 0.0000
d -0.5500
dtype: float64
df.mean(axis=1, skipna=False)a NaN
b 1.3000
c NaN
d -0.2750
dtype: float64
The .idxmax() method returns the label for the maximum observation.
df| one | two | |
|---|---|---|
| a | 1.4000 | NaN |
| b | 7.1000 | -4.5000 |
| c | NaN | NaN |
| d | 0.7500 | -1.3000 |
df.idxmax()one b
two d
dtype: object
The .describe() returns summary statistics for each numerical column in a data frame.
df.describe()| one | two | |
|---|---|---|
| count | 3.0000 | 2.0000 |
| mean | 3.0833 | -2.9000 |
| std | 3.4937 | 2.2627 |
| min | 0.7500 | -4.5000 |
| 25% | 1.0750 | -3.7000 |
| 50% | 1.4000 | -2.9000 |
| 75% | 4.2500 | -2.1000 |
| max | 7.1000 | -1.3000 |
For non-numerical data, .describe() returns alternative summary statistics.
obj = pd.Series(['a', 'a', 'b', 'c'] * 4)
obj.describe()count 16
unique 3
top a
freq 8
dtype: object
Correlation and Covariance
Starting with version 0.2.51, the yfinance package changed the default behavior of the auto_adjust argument from False to True. By default, the yf.download() function now returns adjusted prices, without including the Adj Close column.
We prefer to work with raw data from Yahoo! Finance and explicitly calculate returns using the Adj Close column. Therefore, we will set auto_adjust=False in our yf.download() calls. See the yfinance changelog for release version 0.2.51.
Also, I will use the progress=False argument to improve the readability of the PDF and website I render from these notebooks.
data = yf.download(tickers='AAPL IBM MSFT GOOG', auto_adjust=False, progress=False)data['Adj Close'].tail()| Ticker | AAPL | GOOG | IBM | MSFT |
|---|---|---|---|---|
| Date | ||||
| 2025-02-24 | 247.1000 | 181.1900 | 261.8700 | 404.0000 |
| 2025-02-25 | 247.0400 | 177.3700 | 257.7500 | 397.9000 |
| 2025-02-26 | 240.3600 | 174.7000 | 255.8400 | 399.7300 |
| 2025-02-27 | 237.3000 | 170.2100 | 253.2300 | 392.5300 |
| 2025-02-28 | 241.8400 | 172.2200 | 252.4400 | 396.9900 |
Data frame data contains daily prices and volume for AAPL, IBM, MSFT, and GOOG. The Adj Close columns are reverse-engineered daily closing prices that account for dividends and stock splits (and reverse splits). As a result, the .pct_change() of Adj Close correctly considers dividends and price changes, so \(r_t = \frac{(P_t + D_t) - P_{t-1}}{P_{t-1}} = \frac{\text{Adj Close}_t - \text{Adj Close}_{t-1}}{\text{Adj Close}_{t-1}}.\)
returns = data['Adj Close'].pct_change().dropna()
returns| Ticker | AAPL | GOOG | IBM | MSFT |
|---|---|---|---|---|
| Date | ||||
| 2004-08-20 | 0.0029 | 0.0794 | 0.0042 | 0.0029 |
| 2004-08-23 | 0.0091 | 0.0101 | -0.0070 | 0.0044 |
| 2004-08-24 | 0.0280 | -0.0414 | 0.0007 | 0.0000 |
| 2004-08-25 | 0.0344 | 0.0108 | 0.0043 | 0.0114 |
| 2004-08-26 | 0.0487 | 0.0180 | -0.0045 | -0.0040 |
| ... | ... | ... | ... | ... |
| 2025-02-24 | 0.0063 | -0.0021 | 0.0015 | -0.0103 |
| 2025-02-25 | -0.0002 | -0.0211 | -0.0157 | -0.0151 |
| 2025-02-26 | -0.0270 | -0.0151 | -0.0074 | 0.0046 |
| 2025-02-27 | -0.0127 | -0.0257 | -0.0102 | -0.0180 |
| 2025-02-28 | 0.0191 | 0.0118 | -0.0031 | 0.0114 |
5165 rows × 4 columns
We multiply by 252 to annualize mean daily returns because means grow linearly with time and there are (about) 252 trading days per year.
returns.mean().mul(252)Ticker
AAPL 0.3579
GOOG 0.2533
IBM 0.1112
MSFT 0.1905
dtype: float64
We multiply by \(\sqrt{252}\) to annualize the volatility of daily returns because standard deviation is the square root of variance, variances grow linearly with time, and there are (about) 252 trading days per year. Ivo Welch explains this calculation at the bottom of Page 7 of Chapter 8 his free corporate finance textbook.
returns.std().mul(np.sqrt(252))Ticker
AAPL 0.3234
GOOG 0.3061
IBM 0.2281
MSFT 0.2693
dtype: float64
We can calculate pairwise correlations.
returns['MSFT'].corr(returns['IBM'])0.4733
We can also calculate correlation matrices.
returns.corr()| Ticker | AAPL | GOOG | IBM | MSFT |
|---|---|---|---|---|
| Ticker | ||||
| AAPL | 1.0000 | 0.5116 | 0.4121 | 0.5209 |
| GOOG | 0.5116 | 1.0000 | 0.3807 | 0.5603 |
| IBM | 0.4121 | 0.3807 | 1.0000 | 0.4733 |
| MSFT | 0.5209 | 0.5603 | 0.4733 | 1.0000 |
returns.corr().loc['MSFT', 'IBM']0.4733
np.allclose(
a=returns['MSFT'].corr(returns['IBM']),
b=returns.corr().loc['MSFT', 'IBM']
)True