McKinney Chapter 5 - Getting Started with pandas

FINA 6333 for Spring 2025

Author

Richard Herron

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import yfinance as yf
%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])
obj
0    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.values
array([ 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'])
obj2
d    4
b    7
a   -5
c    3
dtype: int64
obj2.index
Index(['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
obj2
d    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 > 0
d     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 * 2
d    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)
obj3
Ohio      35000
Texas     71000
Oregon    16000
Utah       5000
dtype: int64

If we also specify an index with list states, pandas will:

  1. Respect the index order
  2. Keep California because it was in the index
  3. Drop Utah because it was not in the index
states = ['California', 'Ohio', 'Oregon', 'Texas']
obj4 = pd.Series(sdata, index=states)
obj4
California          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 + obj4
California           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.state
one        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.

  1. The .loc[] method slices by row labels and column names
  2. 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, column
state     Ohio
pop     3.6000
Name: three, dtype: object
frame2.iloc[2, [1, 2]] # row, column
state     Ohio
pop     3.6000
Name: three, dtype: object

We can assign either scalars or arrays to data frame columns.

  1. Scalars will broadcast to every row in the data frame
  2. 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'])
val
two    -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
index
Index(['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_labels
Index(['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'])
obj
a   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
obj
a   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 label
  • df.loc[:, val]: Selects single column or subset of columns by label
  • df.loc[val1, val2]: Select both rows and columns by label
  • df.iloc[where]: Selects single row or subset of rows from the DataFrame by integer position
  • df.iloc[:, where]: Selects single column or subset of columns by integer position
  • df.iloc[where_i, where_j]: Select both rows and columns by integer position
  • df.at[label_i, label_j]: Select a single scalar value by row and column label
  • df.iat[i, j]: Select a single scalar value by row and column position (integers) reindex method Select either rows or columns by labels
  • get_value, set_value methods: 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']
)
s1
a    7.3000
c   -2.5000
d    3.4000
e    1.5000
dtype: float64
s2
a   -2.1000
c    3.6000
e   -1.5000
f    4.0000
g    3.1000
dtype: float64
s1 + s2
a   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.nan
df1
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))
arr
array([[ 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
series
b   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
series2
b    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
series3
Utah      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=0
b   1.0825
d   1.2309
e   1.1172
dtype: float64
frame.apply(lambda x: x.max() - x.min(), axis=1) # explicit axis=1
Utah     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=0
one    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

Note

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

References

McKinney, Wes. 2022. Python for Data Analysis. 3rd ed. https://wesmckinney.com/book/; O’Reilly Media, Inc.