- Lectures 1-5 covered the Python language. This lecture introduces NumPy for numerical arrays and pandas for tables of data.
What We'll Cover
Ch 11 (NumPy): arrays vs. Python lists, indexing and slicing, vectorized arithmetic and broadcasting, aggregations, random numbers, and matrix multiplication.
Ch 12 (Pandas): Series and DataFrames, reading and writing data, selecting data, missing data, group-by, combining DataFrames, and time series.
Our running data in the pandas part is the Fama-French 5 Factors from Ken French's data library.
Ch 11: NumPy
NumPy (Numerical Python) is the fundamental package for numerical computing in Python.
It provides an N-dimensional array object, along with functions for fast operations on these arrays.
Most data analysis libraries you'll use later (
pandas,scikit-learn,statsmodels) are built on top of NumPy.
Importing NumPy
- The conventional alias for NumPy is
np.
import numpy as npArrays vs Python Lists
A NumPy array stores elements of the same type in a contiguous block of memory.
Python lists store pointers to objects scattered in memory.
This makes NumPy arrays much faster and more memory-efficient for numerical work.
Speed comparison
- We compare the time to square 1 million numbers using a Python list vs a NumPy array.
# Using a Python list
py_list = list(range(1_000_000))
%timeit [x ** 2 for x in py_list]121 ms ± 35 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
# Using a NumPy array
np_arr = np.arange(1_000_000)
%timeit np_arr ** 22.07 ms ± 533 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
- NumPy is typically 10-100x faster than equivalent pure-Python code.
Creating Arrays
From a Python List
- Pass a list to
np.array()to create a one-dimensional array.
arr = np.array([1, 2, 3, 4, 5])
arrarray([1, 2, 3, 4, 5])
type(arr)numpy.ndarray
Multidimensional Arrays
- Pass a list of lists to create a 2D array (a matrix).
matrix = np.array([[1, 2, 3],
[4, 5, 6]])
matrixarray([[1, 2, 3],
[4, 5, 6]])Built-in Array Creation Functions
np.zeros(shape)creates an array filled with zeros.np.ones(shape)creates an array filled with ones.np.arange(start, stop, step)is likerange()but returns an array.np.linspace(start, stop, num)createsnumevenly-spaced values.
np.zeros(5)array([0., 0., 0., 0., 0.])
np.zeros((2, 3))array([[0., 0., 0.],
[0., 0., 0.]])np.ones((3, 3))array([[1., 1., 1.],
[1., 1., 1.],
[1., 1., 1.]])np.arange(0, 10, 2)array([0, 2, 4, 6, 8])
np.linspace(0, 1, 5)array([0. , 0.25, 0.5 , 0.75, 1. ])
np.eye(n)creates annxnidentity matrix.
np.eye(3)array([[1., 0., 0.],
[0., 1., 0.],
[0., 0., 1.]])Array Attributes
shape: a tuple giving the size in each dimension.ndim: the number of dimensions.size: the total number of elements.dtype: the data type of the elements.
matrix = np.array([[1, 2, 3],
[4, 5, 6]])
matrix.shape(2, 3)
matrix.ndim2
matrix.size6
matrix.dtypedtype('int64')Data Types (dtype)
NumPy arrays are homogeneous: all elements share one
dtype.Common dtypes include
int64,float64,bool.You can set the dtype when creating an array.
np.array([1, 2, 3], dtype='float64')array([1., 2., 3.])
- If you mix types, NumPy will upcast to the more general type.
# An int and a float together become floats
np.array([1, 2.5, 3])array([1. , 2.5, 3. ])
Reshape
reshape()returns an array with the same data but a new shape.The new shape must have the same total number of elements.
arr = np.arange(12)
arrarray([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
arr.reshape(3, 4)array([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])arr.reshape(2, 6)array([[ 0, 1, 2, 3, 4, 5],
[ 6, 7, 8, 9, 10, 11]])arrarray([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
- Using
-1tells NumPy to infer that dimension automatically.
arr.reshape(3, -1)array([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])Note: Incompatible shapes will raise an error.
# This will fail: 12 elements can't reshape into 5 x 3 = 15
arr.reshape(5, 3)
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
/tmp/ipykernel_1707/3981055620.py in <cell line: 0>()
1 # This will fail: 12 elements can't reshape into 5 x 3 = 15
----> 2 arr.reshape(5, 3)
ValueError: cannot reshape array of size 12 into shape (5,3)- You can also transpose an array with
.T.
mat = np.arange(6).reshape(2, 3)
matarray([[0, 1, 2],
[3, 4, 5]])mat.Tarray([[0, 3],
[1, 4],
[2, 5]])Indexing and Slicing
One-Dimensional Arrays
- Indexing and slicing work just like with Python lists.
arr = np.array([10, 20, 30, 40, 50])
arr[0]np.int64(10)
arr[-1]np.int64(50)
arr[1:4]array([20, 30, 40])
Two-Dimensional Arrays
Use
arr[row, col]syntax (more concise and computationally efficient thanarr[row][col]).Multidimensional slicing along a specific axis requires the
arr[row, col]syntax.
mat = np.array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])
mat[0, 1]np.int64(2)
- Use
:to select all elements along an axis.
# All rows, column 0
mat[:, 0]array([1, 4, 7])
- Chained indexing does not work for this use case.
mat[:][0]array([1, 2, 3])
mat[:]array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])# Row 1, all columns
mat[1, :]array([4, 5, 6])
# Top-left 2x2 sub-matrix
mat[:2, :2]array([[1, 2],
[4, 5]])Boolean Indexing
- You can index with a Boolean array to filter elements.
arr = np.array([1, -2, 3, -4, 5])
arr > 0array([ True, False, True, False, True])
arr[arr > 0]array([1, 3, 5])
Vectorized Arithmetic
Arithmetic operations on NumPy arrays apply element-wise without a
forloop.This is called vectorization.
arr = np.array([1, 2, 3, 4])
arr + 10array([11, 12, 13, 14])
arr * 2array([2, 4, 6, 8])
arr ** 2array([ 1, 4, 9, 16])
a = np.array([1, 2, 3])
b = np.array([10, 20, 30])
a + barray([11, 22, 33])
Broadcasting
Broadcasting allows NumPy to do arithmetic on arrays with different shapes.
NumPy triggers broadcasting automatically during element-wise arithmetic operations by evaluating the
.shapeattributes of the arrays involved.The smaller array is "stretched" across the larger one, without actually copying memory.
# A 2x3 matrix plus a 1D array of length 3
mat = np.array([[1, 2, 3],
[4, 5, 6]])
row = np.array([10, 20, 30])
mat + rowarray([[11, 22, 33],
[14, 25, 36]])- The 1D array
rowis broadcast across each row ofmat.
# Column-wise broadcasting using a 2x1 array
col = np.array([[100],
[200]])
mat + colarray([[101, 102, 103],
[204, 205, 206]])Aggregations
- NumPy provides fast functions to summarize arrays:
sum,mean,std,min,max,median, etc.
arr = np.array([1, 2, 3, 4, 5])
arr.sum()np.int64(15)
arr.mean()np.float64(3.0)
arr.std()np.float64(1.4142135623730951)
The axis Argument
For multi-dimensional arrays, the
axisargument controls which dimension to collapse.axis=0collapses rows (Vertical Sum: one value per column).axis=1collapses columns (Horizontal Sum: one value per row).
mat = np.array([[1, 2, 3],
[4, 5, 6]])
mat.sum()np.int64(21)
# Sum down each column
mat.sum(axis=0)array([5, 7, 9])
# Sum across each row
mat.sum(axis=1)array([ 6, 15])
Random Numbers (np.random)
Setting a Seed
For reproducible results, set a random seed before generating random numbers.
Why "42"?
Technically, any integer works as a seed (0, 1, 12345, etc.).
42 is the "Answer to the Ultimate Question of Life, the Universe, and Everything" in The Hitchhiker's Guide to the Galaxy.
np.random.seed(42)Generating Random Numbers
np.random.rand(d0, d1, ...): uniform on [0, 1).np.random.randn(d0, d1, ...): standard normal (mean 0, std 1).np.random.randint(low, high, size): random integers from [low, high).np.random.choice(a, size): sample from an array.
np.random.seed(42)
np.random.rand(3)array([0.37454012, 0.95071431, 0.73199394])
np.random.randn(2, 3)array([[-1.11188012, 0.31890218, 0.27904129],
[ 1.01051528, -0.58087813, -0.52516981]])np.random.randint(0, 100, size=5)array([20, 32, 75, 57, 21])
# Sample 4 items from a list
np.random.choice(['AAPL', 'GOOG', 'MSFT', 'AMZN', 'META'], size=4)array(['META', 'AMZN', 'AAPL', 'AAPL'], dtype='<U4')
Matrix Multiplication
For element-wise multiplication, use
*.For matrix multiplication (dot product), use the
@operator ornp.dot().
A = np.array([[1, 2],
[3, 4]])
B = np.array([[5, 6],
[7, 8]])
# Element-wise multiplication
A * Barray([[ 5, 12],
[21, 32]])# Matrix multiplication
A @ Barray([[19, 22],
[43, 50]])# Equivalent to:
np.dot(A, B)array([[19, 22],
[43, 50]])- This is essential for linear regression, portfolio math, and many other applications you'll see in econ.
In-Class Exercise
11-1. Array Creation:
- Create a 1D NumPy array containing the integers from 10 to 30 (inclusive) using
np.arange(). - Print its
shape,ndim, anddtype.
11-2. Reshape:
- Take your array from 11-1 and reshape it into a 3 x 7 matrix.
- Then transpose it and print the result.
11-3. Simulating Returns:
- Set the random seed to 0.
- Use
np.random.randn(252)to simulate 252 daily returns (one trading year) with mean 0 and std 1. - Compute and print the mean, standard deviation, and maximum of these simulated returns.
11-4. Boolean Filtering:
- Using your simulated returns from 11-3, count how many of the returns were negative using a Boolean filter.
- Hint: a Boolean array can be summed because
Truecounts as 1.
11-5. Matrix Multiplication:
- Create two 2 x 2 matrices:
X = [[1, 2], [3, 4]]andY = [[2, 0], [1, 2]]. - Compute their matrix product using
@. - Then compute their element-wise product using
*. Compare the two results.
Ch 12: Pandas
pandas is the main library for working with tabular data in Python.
It provides two main data structures: the 1D
Seriesand the 2DDataFrame.Think of a
DataFrameas a spreadsheet or SQL table you can manipulate with code.Pandas is built on NumPy, so most NumPy concepts (vectorization, broadcasting) carry over.
Importing Pandas
- The conventional alias for pandas is
pd.
import numpy as np
import pandas as pdSeries
A
Seriesis a one-dimensional labeled array, like a single column of a spreadsheet.Every value has a label (its index), and all values share a
dtype.
Creating a Series
- The simplest way to create a
Seriesis from a list.
prices = pd.Series([150.2, 2800.5, 305.7, 3300.1])
prices| 0 | |
|---|---|
| 0 | 150.2 |
| 1 | 2800.5 |
| 2 | 305.7 |
| 3 | 3300.1 |
- The numbers on the left are the index (auto-assigned 0, 1, 2, ... by default).
- The numbers on the right are the values.
Custom Index
- You can provide a custom index when you create the
Series.
prices = pd.Series([150.2, 2800.5, 305.7, 3300.1],
index=['AAPL', 'GOOG', 'MSFT', 'AMZN'])
prices| 0 | |
|---|---|
| AAPL | 150.2 |
| GOOG | 2800.5 |
| MSFT | 305.7 |
| AMZN | 3300.1 |
Creating a Series from a Dictionary
- A dictionary's keys become the index, and the values become the data.
mkt_caps = pd.Series({
'AAPL': 2_900_000,
'GOOG': 1_700_000,
'MSFT': 2_500_000,
'AMZN': 1_400_000,
})
mkt_caps| 0 | |
|---|---|
| AAPL | 2900000 |
| GOOG | 1700000 |
| MSFT | 2500000 |
| AMZN | 1400000 |
Accessing Values
- You can index a
Seriesby label or by position.
prices['AAPL']np.float64(150.2)
prices[['AAPL', 'MSFT']]| 0 | |
|---|---|
| AAPL | 150.2 |
| MSFT | 305.7 |
Series Attributes
values: the underlying NumPy array.index: the labels.dtype: the element data type.name: an optional name for the Series.
prices.valuesarray([ 150.2, 2800.5, 305.7, 3300.1])
prices.indexIndex(['AAPL', 'GOOG', 'MSFT', 'AMZN'], dtype='object')
prices.dtypedtype('float64')- You can give a
Seriesa name, which becomes its column header inside aDataFrame.
prices.name = 'price'
prices| price | |
|---|---|
| AAPL | 150.2 |
| GOOG | 2800.5 |
| MSFT | 305.7 |
| AMZN | 3300.1 |
DataFrame
A
DataFrameis a two-dimensional labeled table: rows and columns, each with an index.Each column is a
Series. Columns can have different dtypes.
Creating a DataFrame from a Dictionary of Lists
- Each key becomes a column name; each list becomes the column's values.
data = {
'ticker': ['AAPL', 'GOOG', 'MSFT', 'AMZN'],
'price': [150.2, 2800.5, 305.7, 3300.1],
'sector': ['Tech', 'Tech', 'Tech', 'Consumer'],
}
df = pd.DataFrame(data)
df| ticker | price | sector | |
|---|---|---|---|
| 0 | AAPL | 150.2 | Tech |
| 1 | GOOG | 2800.5 | Tech |
| 2 | MSFT | 305.7 | Tech |
| 3 | AMZN | 3300.1 | Consumer |
Creating a DataFrame from a List of Dictionaries
- Each dictionary becomes a row.
rows = [
{'ticker': 'AAPL', 'price': 150.2, 'sector': 'Tech'},
{'ticker': 'GOOG', 'price': 2800.5, 'sector': 'Tech'},
{'ticker': 'MSFT', 'price': 305.7, 'sector': 'Tech'},
]
pd.DataFrame(rows)| ticker | price | sector | |
|---|---|---|---|
| 0 | AAPL | 150.2 | Tech |
| 1 | GOOG | 2800.5 | Tech |
| 2 | MSFT | 305.7 | Tech |
Creating a DataFrame from a NumPy Array
- Useful for numerical data. Supply your own
columnsand (optionally)index.
np.random.seed(42)
returns = np.random.randn(5, 3) / 100 # daily returns ~1% scale
pd.DataFrame(returns,
columns=['AAPL', 'GOOG', 'MSFT'],
index=['Mon', 'Tue', 'Wed', 'Thu', 'Fri'])| AAPL | GOOG | MSFT | |
|---|---|---|---|
| Mon | 0.004967 | -0.001383 | 0.006477 |
| Tue | 0.015230 | -0.002342 | -0.002341 |
| Wed | 0.015792 | 0.007674 | -0.004695 |
| Thu | 0.005426 | -0.004634 | -0.004657 |
| Fri | 0.002420 | -0.019133 | -0.017249 |
Setting an Index Column
- Use
set_index()to promote an existing column to be the row index.
df = pd.DataFrame(data)
df = df.set_index('ticker')
df| price | sector | |
|---|---|---|
| ticker | ||
| AAPL | 150.2 | Tech |
| GOOG | 2800.5 | Tech |
| MSFT | 305.7 | Tech |
| AMZN | 3300.1 | Consumer |
- The DataFrame is now indexed by ticker symbol.
DataFrame Attributes
shape: (n_rows, n_cols)columns: column labelsindex: row labelsdtypes: dtype of each column
df.shape(4, 2)
df.columnsIndex(['price', 'sector'], dtype='object')
df.indexIndex(['AAPL', 'GOOG', 'MSFT', 'AMZN'], dtype='object', name='ticker')
df.dtypes| 0 | |
|---|---|
| price | float64 |
| sector | object |
Reading and Writing Data
Pandas can read and write a wide variety of file formats: CSV, Excel, JSON, Parquet, SQL, and more.
The most common is
read_csv()for comma-separated values files.
We'll try
pandas-datareaderfirst, which provides a clean Python API to Ken French's library.We include a fallback that downloads the CSV directly, in case package versions don't line up.
# Install pandas-datareader if not already available (Colab usually has it)
!pip install -q pandas-datareaderimport io, zipfile, urllib.request
import pandas as pd
# Try pandas-datareader first (the cleanest path)
try:
import pandas_datareader.data as web
ff = web.DataReader('F-F_Research_Data_5_Factors_2x3',
'famafrench',
start='1990-01-01',
end='2023-12-31')
ff5 = ff[0]
print(f"Loaded via pandas-datareader: {ff5.shape}")
except Exception as e:
# Fallback: download the CSV zip directly from Ken French's website
print(f"pandas-datareader failed ({type(e).__name__}); falling back to direct download.")
url = ("https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/"
"ftp/F-F_Research_Data_5_Factors_2x3_CSV.zip")
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req) as r:
zip_bytes = r.read()
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as z:
csv_name = z.namelist()[0]
with z.open(csv_name) as f:
raw = f.read().decode('latin1')
# The file has a text header; monthly data starts after a blank-line break.
# Find the first line that begins with a 6-digit YYYYMM and parse from there.
lines = raw.splitlines()
start = next(i for i, ln in enumerate(lines)
if len(ln) >= 6 and ln[:6].isdigit())
end = next((i for i in range(start, len(lines))
if not (len(lines[i]) >= 6 and lines[i][:6].isdigit())),
len(lines))
monthly_csv = "Date,Mkt-RF,SMB,HML,RMW,CMA,RF\n" + "\n".join(lines[start:end])
ff5 = pd.read_csv(io.StringIO(monthly_csv), index_col='Date')
ff5.index = pd.to_datetime(ff5.index, format='%Y%m').to_period('M')
print(f"Loaded via direct download: {ff5.shape}")Loaded via pandas-datareader: (408, 6)
- The download cell stores the monthly data in
ff5.
type(ff5)pandas.core.frame.DataFrame
def __init__(data=None, index: Axes | None=None, columns: Axes | None=None, dtype: Dtype | None=None, copy: bool | None=None) -> None
Two-dimensional, size-mutable, potentially heterogeneous tabular data. Data structure also contains labeled axes (rows and columns). Arithmetic operations align on both row and column labels. Can be thought of as a dict-like container for Series objects. The primary pandas data structure. Parameters ---------- data : ndarray (structured or homogeneous), Iterable, dict, or DataFrame Dict can contain Series, arrays, constants, dataclass or list-like objects. If data is a dict, column order follows insertion-order. If a dict contains Series which have an index defined, it is aligned by its index. This alignment also occurs if data is a Series or a DataFrame itself. Alignment is done on Series/DataFrame inputs. If data is a list of dicts, column order follows insertion-order. index : Index or array-like Index to use for resulting frame. Will default to RangeIndex if no indexing information part of input data and no index provided. columns : Index or array-like Column labels to use for resulting frame when data does not have them, defaulting to RangeIndex(0, 1, 2, ..., n). If data contains column labels, will perform column selection instead. dtype : dtype, default None Data type to force. Only a single dtype is allowed. If None, infer. copy : bool or None, default None Copy data from inputs. For dict data, the default of None behaves like ``copy=True``. For DataFrame or 2d ndarray input, the default of None behaves like ``copy=False``. If data is a dict containing one or more Series (possibly of different dtypes), ``copy=False`` will ensure that these inputs are not copied. .. versionchanged:: 1.3.0 See Also -------- DataFrame.from_records : Constructor from tuples, also record arrays. DataFrame.from_dict : From dicts of Series, arrays, or dicts. read_csv : Read a comma-separated values (csv) file into DataFrame. read_table : Read general delimited file into DataFrame. read_clipboard : Read text from clipboard into DataFrame. Notes ----- Please reference the :ref:`User Guide <basics.dataframe>` for more information. Examples -------- Constructing DataFrame from a dictionary. >>> d = {'col1': [1, 2], 'col2': [3, 4]} >>> df = pd.DataFrame(data=d) >>> df col1 col2 0 1 3 1 2 4 Notice that the inferred dtype is int64. >>> df.dtypes col1 int64 col2 int64 dtype: object To enforce a single dtype: >>> df = pd.DataFrame(data=d, dtype=np.int8) >>> df.dtypes col1 int8 col2 int8 dtype: object Constructing DataFrame from a dictionary including Series: >>> d = {'col1': [0, 1, 2, 3], 'col2': pd.Series([2, 3], index=[2, 3])} >>> pd.DataFrame(data=d, index=[0, 1, 2, 3]) col1 col2 0 0 NaN 1 1 NaN 2 2 2.0 3 3 3.0 Constructing DataFrame from numpy ndarray: >>> df2 = pd.DataFrame(np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]), ... columns=['a', 'b', 'c']) >>> df2 a b c 0 1 2 3 1 4 5 6 2 7 8 9 Constructing DataFrame from a numpy ndarray that has labeled columns: >>> data = np.array([(1, 2, 3), (4, 5, 6), (7, 8, 9)], ... dtype=[("a", "i4"), ("b", "i4"), ("c", "i4")]) >>> df3 = pd.DataFrame(data, columns=['c', 'a']) ... >>> df3 c a 0 3 1 1 6 4 2 9 7 Constructing DataFrame from dataclass: >>> from dataclasses import make_dataclass >>> Point = make_dataclass("Point", [("x", int), ("y", int)]) >>> pd.DataFrame([Point(0, 0), Point(0, 3), Point(2, 3)]) x y 0 0 0 1 0 3 2 2 3 Constructing DataFrame from Series/DataFrame: >>> ser = pd.Series([1, 2, 3], index=["a", "b", "c"]) >>> df = pd.DataFrame(data=ser, index=["a", "c"]) >>> df 0 a 1 c 3 >>> df1 = pd.DataFrame([1, 2, 3], index=["a", "b", "c"], columns=["x"]) >>> df2 = pd.DataFrame(data=df1, index=["a", "c"]) >>> df2 x a 1 c 3
ff5.head()| Mkt-RF | SMB | HML | RMW | CMA | RF | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 1990-01 | -7.80 | -1.21 | 0.83 | -1.12 | 1.40 | 0.57 |
| 1990-02 | 1.11 | 1.15 | 0.65 | -0.04 | -0.61 | 0.57 |
| 1990-03 | 1.83 | 1.59 | -2.90 | 1.99 | -0.94 | 0.64 |
| 1990-04 | -3.36 | -0.35 | -2.57 | 1.78 | -1.00 | 0.69 |
| 1990-05 | 8.44 | -2.41 | -3.89 | 1.68 | -1.63 | 0.68 |
- Values are in percent (so
1.5means a 1.5% return).
Writing to CSV
- Use
to_csv()to save a DataFrame as a CSV file.
ff5.to_csv('ff5_factors.csv')Reading from CSV
Use
read_csv()to read a CSV file back.The first column is treated as a regular column by default. Use
index_col=0to make it the index.
ff5 = pd.read_csv('ff5_factors.csv', index_col=0)
ff5.head()| Mkt-RF | SMB | HML | RMW | CMA | RF | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 1990-01 | -7.80 | -1.21 | 0.83 | -1.12 | 1.40 | 0.57 |
| 1990-02 | 1.11 | 1.15 | 0.65 | -0.04 | -0.61 | 0.57 |
| 1990-03 | 1.83 | 1.59 | -2.90 | 1.99 | -0.94 | 0.64 |
| 1990-04 | -3.36 | -0.35 | -2.57 | 1.78 | -1.00 | 0.69 |
| 1990-05 | 8.44 | -2.41 | -3.89 | 1.68 | -1.63 | 0.68 |
Other File Formats
pd.read_excel('file.xlsx')reads Excel files (requiresopenpyxl).pd.read_json('file.json')reads JSON files (recall Ch 10).pd.read_parquet('file.parquet')reads efficient Parquet files.Each has a matching
to_...()writer method.
Inspecting a DataFrame
- Always start by looking at your data with a few quick diagnostic methods.
head() and tail()
# First 5 rows by default
ff5.head()| Mkt-RF | SMB | HML | RMW | CMA | RF | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 1990-01 | -7.80 | -1.21 | 0.83 | -1.12 | 1.40 | 0.57 |
| 1990-02 | 1.11 | 1.15 | 0.65 | -0.04 | -0.61 | 0.57 |
| 1990-03 | 1.83 | 1.59 | -2.90 | 1.99 | -0.94 | 0.64 |
| 1990-04 | -3.36 | -0.35 | -2.57 | 1.78 | -1.00 | 0.69 |
| 1990-05 | 8.44 | -2.41 | -3.89 | 1.68 | -1.63 | 0.68 |
# Last 3 rows
ff5.tail(3)| Mkt-RF | SMB | HML | RMW | CMA | RF | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 2023-10 | -3.15 | -4.03 | 0.22 | 2.42 | -0.72 | 0.47 |
| 2023-11 | 8.88 | -0.18 | 1.66 | -3.72 | -1.22 | 0.44 |
| 2023-12 | 4.85 | 7.25 | 4.87 | -2.96 | 1.19 | 0.43 |
shape, columns, index, dtypes
ff5.shape(408, 6)
ff5.columnsIndex(['Mkt-RF', 'SMB', 'HML', 'RMW', 'CMA', 'RF'], dtype='object')
ff5.dtypes| 0 | |
|---|---|
| Mkt-RF | float64 |
| SMB | float64 |
| HML | float64 |
| RMW | float64 |
| CMA | float64 |
| RF | float64 |
info()
- A compact summary: row count, column count, dtypes, and non-null counts.
ff5.info()<class 'pandas.core.frame.DataFrame'> Index: 408 entries, 1990-01 to 2023-12 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Mkt-RF 408 non-null float64 1 SMB 408 non-null float64 2 HML 408 non-null float64 3 RMW 408 non-null float64 4 CMA 408 non-null float64 5 RF 408 non-null float64 dtypes: float64(6) memory usage: 22.3+ KB
describe()
- Summary statistics for each numeric column: count, mean, std, min, quartiles, max.
ff5.describe()| Mkt-RF | SMB | HML | RMW | CMA | RF | |
|---|---|---|---|---|---|---|
| count | 408.000000 | 408.000000 | 408.000000 | 408.000000 | 408.000000 | 408.000000 |
| mean | 0.707279 | 0.117034 | 0.151569 | 0.375270 | 0.197917 | 0.213725 |
| std | 4.448067 | 3.046912 | 3.248474 | 2.626859 | 2.176679 | 0.188014 |
| min | -17.200000 | -15.530000 | -13.830000 | -18.930000 | -7.060000 | 0.000000 |
| 25% | -1.960000 | -1.820000 | -1.670000 | -0.915000 | -1.070000 | 0.010000 |
| 50% | 1.185000 | 0.045000 | -0.070000 | 0.345000 | -0.020000 | 0.180000 |
| 75% | 3.430000 | 1.912500 | 1.670000 | 1.487500 | 1.410000 | 0.390000 |
| max | 13.580000 | 18.540000 | 12.860000 | 13.040000 | 9.000000 | 0.690000 |
value_counts()
- For a single column (Series), counts occurrences of each unique value.
# Add a 'decade' label column to demonstrate
ff5_demo = ff5.copy()
ff5_demo['decade'] = (ff5_demo.index.astype(str).str[:3] + '0s')
ff5_demo['decade'].value_counts()| count | |
|---|---|
| decade | |
| 1990s | 120 |
| 2000s | 120 |
| 2010s | 120 |
| 2020s | 48 |
Selecting Data
Selecting Columns
- Use bracket notation with the column name to get a single column (a
Series).
ff5['Mkt-RF'].head()| Mkt-RF | |
|---|---|
| Date | |
| 1990-01 | -7.80 |
| 1990-02 | 1.11 |
| 1990-03 | 1.83 |
| 1990-04 | -3.36 |
| 1990-05 | 8.44 |
- Pass a list of column names to get a DataFrame with multiple columns.
ff5[['Mkt-RF', 'SMB', 'HML']].head()| Mkt-RF | SMB | HML | |
|---|---|---|---|
| Date | |||
| 1990-01 | -7.80 | -1.21 | 0.83 |
| 1990-02 | 1.11 | 1.15 | 0.65 |
| 1990-03 | 1.83 | 1.59 | -2.90 |
| 1990-04 | -3.36 | -0.35 | -2.57 |
| 1990-05 | 8.44 | -2.41 | -3.89 |
Note: You can also use attribute access (ff5.SMB), but it only works for valid Python identifiers, so df.Mkt-RF would fail because of the hyphen.
Selecting Rows with .loc and .iloc
.loc[label]: select rows by label (index value)..iloc[position]: select rows by integer position.
# Select a specific row by its index label
ff5.loc['1990-01']| 1990-01 | |
|---|---|
| Mkt-RF | -7.80 |
| SMB | -1.21 |
| HML | 0.83 |
| RMW | -1.12 |
| CMA | 1.40 |
| RF | 0.57 |
# Select by position: the first row
ff5.iloc[0]| 1990-01 | |
|---|---|
| Mkt-RF | -7.80 |
| SMB | -1.21 |
| HML | 0.83 |
| RMW | -1.12 |
| CMA | 1.40 |
| RF | 0.57 |
Slicing with .loc and .iloc
# .loc slicing is INCLUSIVE of the endpoint
ff5.loc['2020-01':'2020-06']| Mkt-RF | SMB | HML | RMW | CMA | RF | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 2020-01 | -0.09 | -4.37 | -6.22 | -1.19 | -2.19 | 0.13 |
| 2020-02 | -8.15 | 0.08 | -3.82 | -1.43 | -2.53 | 0.12 |
| 2020-03 | -13.35 | -8.18 | -13.83 | -1.61 | 1.19 | 0.12 |
| 2020-04 | 13.58 | 2.57 | -1.34 | 2.76 | -1.02 | 0.00 |
| 2020-05 | 5.59 | 1.92 | -5.00 | 0.96 | -3.16 | 0.01 |
| 2020-06 | 2.44 | 1.95 | -2.17 | 0.14 | 0.55 | 0.01 |
# .iloc slicing is EXCLUSIVE of the endpoint (like Python lists)
ff5.iloc[0:5]| Mkt-RF | SMB | HML | RMW | CMA | RF | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 1990-01 | -7.80 | -1.21 | 0.83 | -1.12 | 1.40 | 0.57 |
| 1990-02 | 1.11 | 1.15 | 0.65 | -0.04 | -0.61 | 0.57 |
| 1990-03 | 1.83 | 1.59 | -2.90 | 1.99 | -0.94 | 0.64 |
| 1990-04 | -3.36 | -0.35 | -2.57 | 1.78 | -1.00 | 0.69 |
| 1990-05 | 8.44 | -2.41 | -3.89 | 1.68 | -1.63 | 0.68 |
Selecting Rows and Columns Together
# All rows in 2020, just the market and SMB factors
ff5.loc['2020-01':'2020-12', ['Mkt-RF', 'SMB']]| Mkt-RF | SMB | |
|---|---|---|
| Date | ||
| 2020-01 | -0.09 | -4.37 |
| 2020-02 | -8.15 | 0.08 |
| 2020-03 | -13.35 | -8.18 |
| 2020-04 | 13.58 | 2.57 |
| 2020-05 | 5.59 | 1.92 |
| 2020-06 | 2.44 | 1.95 |
| 2020-07 | 5.83 | -3.19 |
| 2020-08 | 7.62 | -0.89 |
| 2020-09 | -3.64 | -0.01 |
| 2020-10 | -2.08 | 4.67 |
| 2020-11 | 12.44 | 7.12 |
| 2020-12 | 4.63 | 4.93 |
# First 3 rows, first 2 columns
ff5.iloc[:3, :2]| Mkt-RF | SMB | |
|---|---|---|
| Date | ||
| 1990-01 | -7.80 | -1.21 |
| 1990-02 | 1.11 | 1.15 |
| 1990-03 | 1.83 | 1.59 |
Boolean Filtering
- A Boolean Series (one True/False per row) can be used to select rows.
# Months where the market beat the risk-free rate by more than 5%
mask = ff5['Mkt-RF'] > 5
mask.head()| Mkt-RF | |
|---|---|
| Date | |
| 1990-01 | False |
| 1990-02 | False |
| 1990-03 | False |
| 1990-04 | False |
| 1990-05 | True |
ff5[mask].head()| Mkt-RF | SMB | HML | RMW | CMA | RF | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 1990-05 | 8.44 | -2.41 | -3.89 | 1.68 | -1.63 | 0.68 |
| 1990-11 | 6.35 | 0.02 | -2.97 | 0.58 | -4.71 | 0.57 |
| 1991-02 | 7.21 | 4.02 | -0.62 | -0.06 | -0.34 | 0.48 |
| 1991-12 | 10.86 | -2.41 | -4.23 | 3.67 | -3.14 | 0.38 |
| 1996-09 | 5.01 | -1.41 | -2.75 | 1.31 | -2.12 | 0.44 |
- Combine conditions with
&(and),|(or),~(not). Wrap each condition in parentheses.
# Up market AND value premium positive
ff5[(ff5['Mkt-RF'] > 0) & (ff5['HML'] > 0)].head()| Mkt-RF | SMB | HML | RMW | CMA | RF | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 1990-02 | 1.11 | 1.15 | 0.65 | -0.04 | -0.61 | 0.57 |
| 1992-02 | 1.09 | 1.30 | 6.51 | 0.07 | 2.25 | 0.28 |
| 1992-04 | 1.09 | -5.75 | 4.36 | 1.65 | 2.40 | 0.32 |
| 1992-05 | 0.30 | 0.20 | 1.11 | -0.91 | 0.49 | 0.28 |
| 1992-12 | 1.53 | 1.65 | 2.64 | -0.52 | 0.83 | 0.28 |
Note: Forgetting the parentheses will raise an error because Python's and/or don't work element-wise on Series.
# This will fail
ff5[ff5['Mkt-RF'] > 0 and ff5['HML'] > 0]
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
/tmp/ipykernel_1707/2254064072.py in <cell line: 0>()
1 # This will fail
----> 2 ff5[ff5['Mkt-RF'] > 0 and ff5['HML'] > 0]
/usr/local/lib/python3.13/dist-packages/pandas/core/generic.py in __nonzero__(self)
1575 @final
1576 def __nonzero__(self) -> NoReturn:
-> 1577 raise ValueError(
1578 f"The truth value of a {type(self).__name__} is ambiguous. "
1579 "Use a.empty, a.bool(), a.item(), a.any() or a.all()."
ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().isin()
- Check whether each value is in a given list.
# Suppose we have a column with categories
ff5_demo = ff5.copy()
ff5_demo['decade'] = (ff5_demo.index.astype(str).str[:3] + '0s')
ff5_demo[ff5_demo['decade'].isin(['1990s', '2000s'])].head()| Mkt-RF | SMB | HML | RMW | CMA | RF | decade | |
|---|---|---|---|---|---|---|---|
| Date | |||||||
| 1990-01 | -7.80 | -1.21 | 0.83 | -1.12 | 1.40 | 0.57 | 1990s |
| 1990-02 | 1.11 | 1.15 | 0.65 | -0.04 | -0.61 | 0.57 | 1990s |
| 1990-03 | 1.83 | 1.59 | -2.90 | 1.99 | -0.94 | 0.64 | 1990s |
| 1990-04 | -3.36 | -0.35 | -2.57 | 1.78 | -1.00 | 0.69 | 1990s |
| 1990-05 | 8.44 | -2.41 | -3.89 | 1.68 | -1.63 | 0.68 | 1990s |
between()
- Select rows where a column's value falls in a range (inclusive on both ends).
# Months with moderate market returns (1% to 3%)
ff5[ff5['Mkt-RF'].between(1, 3)].head()| Mkt-RF | SMB | HML | RMW | CMA | RF | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 1990-02 | 1.11 | 1.15 | 0.65 | -0.04 | -0.61 | 0.57 |
| 1990-03 | 1.83 | 1.59 | -2.90 | 1.99 | -0.94 | 0.64 |
| 1990-12 | 2.48 | 0.60 | -1.57 | 2.66 | -1.98 | 0.60 |
| 1991-03 | 2.67 | 3.81 | -1.30 | -0.52 | -0.99 | 0.44 |
| 1991-08 | 2.32 | 1.42 | -0.79 | 0.85 | -0.32 | 0.46 |
query()
- A string-based filter that can be easier to read for complex conditions.
ff5.query('`Mkt-RF` > 5 and SMB > 0').head()- Note the backticks around
Mkt-RFbecause of the hyphen in the column name.
Column Operations
Adding a New Column
- Assigning to
df['new_col']creates a new column.
# Compute the total of the five factors for each month
ff5_demo = ff5.copy()
ff5_demo['Total'] = ff5_demo[['Mkt-RF', 'SMB', 'HML', 'RMW', 'CMA']].sum(axis=1)
ff5_demo.head()| Mkt-RF | SMB | HML | RMW | CMA | RF | Total | |
|---|---|---|---|---|---|---|---|
| Date | |||||||
| 1990-01 | -7.80 | -1.21 | 0.83 | -1.12 | 1.40 | 0.57 | -7.90 |
| 1990-02 | 1.11 | 1.15 | 0.65 | -0.04 | -0.61 | 0.57 | 2.26 |
| 1990-03 | 1.83 | 1.59 | -2.90 | 1.99 | -0.94 | 0.64 | 1.57 |
| 1990-04 | -3.36 | -0.35 | -2.57 | 1.78 | -1.00 | 0.69 | -5.50 |
| 1990-05 | 8.44 | -2.41 | -3.89 | 1.68 | -1.63 | 0.68 | 2.19 |
assign() for Chaining
assign()returns a new DataFrame with the added column, leaving the original untouched.Useful when you want to chain operations.
ff5.assign(Mkt_squared=lambda d: d['Mkt-RF'] ** 2).head()| Mkt-RF | SMB | HML | RMW | CMA | RF | Mkt_squared | |
|---|---|---|---|---|---|---|---|
| Date | |||||||
| 1990-01 | -7.80 | -1.21 | 0.83 | -1.12 | 1.40 | 0.57 | 60.8400 |
| 1990-02 | 1.11 | 1.15 | 0.65 | -0.04 | -0.61 | 0.57 | 1.2321 |
| 1990-03 | 1.83 | 1.59 | -2.90 | 1.99 | -0.94 | 0.64 | 3.3489 |
| 1990-04 | -3.36 | -0.35 | -2.57 | 1.78 | -1.00 | 0.69 | 11.2896 |
| 1990-05 | 8.44 | -2.41 | -3.89 | 1.68 | -1.63 | 0.68 | 71.2336 |
Dropping Columns
- Use
drop()withcolumns=...to remove one or more columns.
ff5_demo = ff5.copy()
ff5_demo = ff5_demo.drop(columns=['RF'])
ff5_demo.head()| Mkt-RF | SMB | HML | RMW | CMA | |
|---|---|---|---|---|---|
| Date | |||||
| 1990-01 | -7.80 | -1.21 | 0.83 | -1.12 | 1.40 |
| 1990-02 | 1.11 | 1.15 | 0.65 | -0.04 | -0.61 |
| 1990-03 | 1.83 | 1.59 | -2.90 | 1.99 | -0.94 |
| 1990-04 | -3.36 | -0.35 | -2.57 | 1.78 | -1.00 |
| 1990-05 | 8.44 | -2.41 | -3.89 | 1.68 | -1.63 |
- You can also drop rows:
df.drop(index=['1990-01', '1990-02']).
Renaming Columns
- Use
rename(columns={...})with a dictionary mapping old to new names.
ff5.rename(columns={'Mkt-RF': 'mkt_rf', 'RF': 'rf'}).head()| mkt_rf | SMB | HML | RMW | CMA | rf | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 1990-01 | -7.80 | -1.21 | 0.83 | -1.12 | 1.40 | 0.57 |
| 1990-02 | 1.11 | 1.15 | 0.65 | -0.04 | -0.61 | 0.57 |
| 1990-03 | 1.83 | 1.59 | -2.90 | 1.99 | -0.94 | 0.64 |
| 1990-04 | -3.36 | -0.35 | -2.57 | 1.78 | -1.00 | 0.69 |
| 1990-05 | 8.44 | -2.41 | -3.89 | 1.68 | -1.63 | 0.68 |
Missing Data
Real datasets almost always have missing values.
Pandas represents missing data as
NaN(Not a Number), borrowed from NumPy.
Creating a Toy Dataset with Missing Values
toy = pd.DataFrame({
'firm': ['A', 'B', 'C', 'D'],
'revenue': [100, np.nan, 150, 200],
'profit': [20, 15, np.nan, 40],
'industry': ['Tech', 'Tech', 'Auto', None],
})
toy| firm | revenue | profit | industry | |
|---|---|---|---|---|
| 0 | A | 100.0 | 20.0 | Tech |
| 1 | B | NaN | 15.0 | Tech |
| 2 | C | 150.0 | NaN | Auto |
| 3 | D | 200.0 | 40.0 | None |
Detecting Missing Values
isna()returns a Boolean DataFrame:Truewhere the value is missing.notna()is the opposite.
toy.isna()| firm | revenue | profit | industry | |
|---|---|---|---|---|
| 0 | False | False | False | False |
| 1 | False | True | False | False |
| 2 | False | False | True | False |
| 3 | False | False | False | True |
# Count missing values per column
toy.isna().sum()| 0 | |
|---|---|
| firm | 0 |
| revenue | 1 |
| profit | 1 |
| industry | 1 |
Dropping Missing Values
dropna()removes rows that contain any NaN by default.
toy.dropna()| firm | revenue | profit | industry | |
|---|---|---|---|---|
| 0 | A | 100.0 | 20.0 | Tech |
# Only drop rows where ALL values are missing
toy.dropna(how='all')| firm | revenue | profit | industry | |
|---|---|---|---|---|
| 0 | A | 100.0 | 20.0 | Tech |
| 1 | B | NaN | 15.0 | Tech |
| 2 | C | 150.0 | NaN | Auto |
| 3 | D | 200.0 | 40.0 | None |
# Drop rows where a specific column is missing
toy.dropna(subset=['revenue'])| firm | revenue | profit | industry | |
|---|---|---|---|---|
| 0 | A | 100.0 | 20.0 | Tech |
| 2 | C | 150.0 | NaN | Auto |
| 3 | D | 200.0 | 40.0 | None |
Filling Missing Values
fillna(value)replaces all NaN with a constant.
toy.fillna(0)| firm | revenue | profit | industry | |
|---|---|---|---|---|
| 0 | A | 100.0 | 20.0 | Tech |
| 1 | B | 0.0 | 15.0 | Tech |
| 2 | C | 150.0 | 0.0 | Auto |
| 3 | D | 200.0 | 40.0 | 0 |
- You can fill different columns with different values.
toy.fillna({'revenue': toy['revenue'].mean(),
'profit': 0,
'industry': 'Unknown'})| firm | revenue | profit | industry | |
|---|---|---|---|---|
| 0 | A | 100.0 | 20.0 | Tech |
| 1 | B | 150.0 | 15.0 | Tech |
| 2 | C | 150.0 | 0.0 | Auto |
| 3 | D | 200.0 | 40.0 | Unknown |
ffill()(forward-fill) propagates the last valid value forward.
bfill()(backward-fill) uses the next valid value.
- These are especially useful for time series data.
toy['revenue'].ffill()| revenue | |
|---|---|
| 0 | 100.0 |
| 1 | 100.0 |
| 2 | 150.0 |
| 3 | 200.0 |
toy['profit'].bfill()| profit | |
|---|---|
| 0 | 20.0 |
| 1 | 15.0 |
| 2 | 40.0 |
| 3 | 40.0 |
Sorting
sort_values()
- Sort rows by the values in one or more columns.
# Best market months
ff5.sort_values('Mkt-RF', ascending=False).head()| Mkt-RF | SMB | HML | RMW | CMA | RF | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 2020-04 | 13.58 | 2.57 | -1.34 | 2.76 | -1.02 | 0.00 |
| 2020-11 | 12.44 | 7.12 | 2.09 | -2.25 | 1.28 | 0.01 |
| 2011-10 | 11.34 | 3.25 | 0.06 | -2.25 | -0.72 | 0.00 |
| 1991-12 | 10.86 | -2.41 | -4.23 | 3.67 | -3.14 | 0.38 |
| 2009-04 | 10.17 | 7.08 | 5.37 | 1.32 | 0.33 | 0.01 |
# Sort by two columns
ff5.sort_values(['Mkt-RF', 'SMB'], ascending=[False, True]).head()| Mkt-RF | SMB | HML | RMW | CMA | RF | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 2020-04 | 13.58 | 2.57 | -1.34 | 2.76 | -1.02 | 0.00 |
| 2020-11 | 12.44 | 7.12 | 2.09 | -2.25 | 1.28 | 0.01 |
| 2011-10 | 11.34 | 3.25 | 0.06 | -2.25 | -0.72 | 0.00 |
| 1991-12 | 10.86 | -2.41 | -4.23 | 3.67 | -3.14 | 0.38 |
| 2009-04 | 10.17 | 7.08 | 5.37 | 1.32 | 0.33 | 0.01 |
sort_index()
- Sort by the index (useful after operations that shuffle rows).
ff5.sort_index().head()| Mkt-RF | SMB | HML | RMW | CMA | RF | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 1990-01 | -7.80 | -1.21 | 0.83 | -1.12 | 1.40 | 0.57 |
| 1990-02 | 1.11 | 1.15 | 0.65 | -0.04 | -0.61 | 0.57 |
| 1990-03 | 1.83 | 1.59 | -2.90 | 1.99 | -0.94 | 0.64 |
| 1990-04 | -3.36 | -0.35 | -2.57 | 1.78 | -1.00 | 0.69 |
| 1990-05 | 8.44 | -2.41 | -3.89 | 1.68 | -1.63 | 0.68 |
Transformations: apply, map, and .str
apply() on a Series
apply()runs a function on each element (or each row/column for a DataFrame).
# Convert factor returns from percent to decimal
ff5['Mkt-RF'].apply(lambda x: x / 100).head()| Mkt-RF | |
|---|---|
| Date | |
| 1990-01 | -0.0780 |
| 1990-02 | 0.0111 |
| 1990-03 | 0.0183 |
| 1990-04 | -0.0336 |
| 1990-05 | 0.0844 |
- For simple element-wise math, vectorized operations (
ff5['Mkt-RF'] / 100) are faster thanapply(). Useapply()for more complex logic.
apply() on a DataFrame
- By default
apply()operates column-wise (each column is passed in as a Series).
# Range (max - min) of each column
ff5.apply(lambda col: col.max() - col.min())| 0 | |
|---|---|
| Mkt-RF | 30.78 |
| SMB | 34.07 |
| HML | 26.69 |
| RMW | 31.97 |
| CMA | 16.06 |
| RF | 0.69 |
map() on a Series
map()substitutes each value using a dictionary or function. It is handy for recoding labels.
# Label whether each month was an 'up' or 'down' market
signs = ff5['Mkt-RF'].apply(lambda x: 'up' if x > 0 else 'down')
signs.head()| Mkt-RF | |
|---|---|
| Date | |
| 1990-01 | down |
| 1990-02 | up |
| 1990-03 | up |
| 1990-04 | down |
| 1990-05 | up |
# Map labels to numeric flags
signs.map({'up': 1, 'down': 0}).head()| Mkt-RF | |
|---|---|
| Date | |
| 1990-01 | 0 |
| 1990-02 | 1 |
| 1990-03 | 1 |
| 1990-04 | 0 |
| 1990-05 | 1 |
The .str Accessor
- The
.straccessor on a string Series gives you vectorized string methods.
tickers = pd.Series(['aapl', 'goog', 'msft', 'amzn'])
tickers.str.upper()| 0 | |
|---|---|
| 0 | AAPL |
| 1 | GOOG |
| 2 | MSFT |
| 3 | AMZN |
tickers.str.startswith('a')| 0 | |
|---|---|
| 0 | True |
| 1 | False |
| 2 | False |
| 3 | True |
tickers.str.len()| 0 | |
|---|---|
| 0 | 4 |
| 1 | 4 |
| 2 | 4 |
| 3 | 4 |
GroupBy: Split-Apply-Combine
groupby()is one of the most commonly used tools in pandas, especially for panel data.The pattern has three steps:
- Split the data into groups based on some key.
- Apply a function to each group.
- Combine the results into a new DataFrame.
A Simple Example
- We add a
decadecolumn to the FF data, then group by decade.
ff5_g = ff5.copy()
ff5_g['decade'] = (ff5_g.index.astype(str).str[:3] + '0s')
ff5_g.head()| Mkt-RF | SMB | HML | RMW | CMA | RF | decade | |
|---|---|---|---|---|---|---|---|
| Date | |||||||
| 1990-01 | -7.80 | -1.21 | 0.83 | -1.12 | 1.40 | 0.57 | 1990s |
| 1990-02 | 1.11 | 1.15 | 0.65 | -0.04 | -0.61 | 0.57 | 1990s |
| 1990-03 | 1.83 | 1.59 | -2.90 | 1.99 | -0.94 | 0.64 | 1990s |
| 1990-04 | -3.36 | -0.35 | -2.57 | 1.78 | -1.00 | 0.69 | 1990s |
| 1990-05 | 8.44 | -2.41 | -3.89 | 1.68 | -1.63 | 0.68 | 1990s |
# Group by decade
grouped = ff5_g.groupby('decade')
grouped<pandas.core.groupby.generic.DataFrameGroupBy object at 0x7fbb31155400>
groupbyreturns a lazy object. Nothing has been computed yet.
Aggregating Groups
- Apply an aggregation like
mean(),sum(),std(), orcount()to compute one value per group.
# Average factor returns in each decade
grouped.mean()| Mkt-RF | SMB | HML | RMW | CMA | RF | |
|---|---|---|---|---|---|---|
| decade | ||||||
| 1990s | 1.066917 | -0.171833 | 0.004333 | 0.192833 | 0.013333 | 0.401583 |
| 2000s | -0.142083 | 0.588167 | 0.662417 | 0.692417 | 0.553417 | 0.226667 |
| 2010s | 1.095167 | -0.029667 | -0.195667 | 0.112750 | 0.016250 | 0.042667 |
| 2020s | 0.961875 | 0.028125 | 0.110625 | 0.694792 | 0.224792 | 0.139375 |
# How many months in each decade?
grouped.size()| 0 | |
|---|---|
| decade | |
| 1990s | 120 |
| 2000s | 120 |
| 2010s | 120 |
| 2020s | 48 |
Selecting Columns in a GroupBy
# Just the market premium, by decade
ff5_g.groupby('decade')['Mkt-RF'].mean()| Mkt-RF | |
|---|---|
| decade | |
| 1990s | 1.066917 |
| 2000s | -0.142083 |
| 2010s | 1.095167 |
| 2020s | 0.961875 |
Multiple Aggregations with agg()
- Pass a list of functions to compute several statistics at once.
ff5_g.groupby('decade')['Mkt-RF'].agg(['mean', 'std', 'min', 'max'])| mean | std | min | max | |
|---|---|---|---|---|
| decade | ||||
| 1990s | 1.066917 | 3.985834 | -16.05 | 10.86 |
| 2000s | -0.142083 | 4.804752 | -17.20 | 10.17 |
| 2010s | 1.095167 | 3.739019 | -9.55 | 11.34 |
| 2020s | 0.961875 | 5.885397 | -13.35 | 13.58 |
Different Aggregations for Different Columns
ff5_g.groupby('decade').agg({
'Mkt-RF': 'mean',
'SMB': 'std',
'HML': ['mean', 'std'],
})| Mkt-RF | SMB | HML | ||
|---|---|---|---|---|
| mean | std | mean | std | |
| decade | ||||
| 1990s | 1.066917 | 2.888407 | 0.004333 | 2.605241 |
| 2000s | -0.142083 | 3.656525 | 0.662417 | 3.688049 |
| 2010s | 1.095167 | 2.278449 | -0.195667 | 2.278873 |
| 2020s | 0.961875 | 3.380858 | 0.110625 | 5.057430 |
Grouping by Multiple Columns
# Make a 'sign' column for market direction and group on (decade, sign)
ff5_g['mkt_sign'] = np.where(ff5_g['Mkt-RF'] > 0, 'up', 'down')
ff5_g.groupby(['decade', 'mkt_sign'])['Mkt-RF'].mean()| Mkt-RF | ||
|---|---|---|
| decade | mkt_sign | |
| 1990s | down | -3.318250 |
| up | 3.259500 | |
| 2000s | down | -4.380943 |
| up | 3.211045 | |
| 2010s | down | -3.289167 |
| up | 2.974167 | |
| 2020s | down | -4.636500 |
| up | 4.960714 |
transform(): Broadcast Group Aggregates Back
transform()returns a result with the same shape as the input: each row gets the value for its group.Typical uses include within-group demeaning (fixed effects) and standardizing by group.
# Compute each month's market return minus its decade's mean
decade_mean = ff5_g.groupby('decade')['Mkt-RF'].transform('mean')
ff5_g['Mkt-RF_demeaned'] = ff5_g['Mkt-RF'] - decade_mean
ff5_g[['Mkt-RF', 'decade', 'Mkt-RF_demeaned']].head()| Mkt-RF | decade | Mkt-RF_demeaned | |
|---|---|---|---|
| Date | |||
| 1990-01 | -7.80 | 1990s | -8.866917 |
| 1990-02 | 1.11 | 1990s | 0.043083 |
| 1990-03 | 1.83 | 1990s | 0.763083 |
| 1990-04 | -3.36 | 1990s | -4.426917 |
| 1990-05 | 8.44 | 1990s | 7.373083 |
- This is exactly the within-transformation used in panel-data fixed-effects estimation.
Combining DataFrames
concat()
pd.concat([df1, df2])stacks DataFrames along an axis.axis=0(default) stacks vertically (more rows).axis=1joins horizontally (more columns).
a = pd.DataFrame({'x': [1, 2], 'y': [3, 4]})
b = pd.DataFrame({'x': [5, 6], 'y': [7, 8]})
pd.concat([a, b])| x | y | |
|---|---|---|
| 0 | 1 | 3 |
| 1 | 2 | 4 |
| 0 | 5 | 7 |
| 1 | 6 | 8 |
# Reset the index to get clean row labels
pd.concat([a, b], ignore_index=True)| x | y | |
|---|---|---|
| 0 | 1 | 3 |
| 1 | 2 | 4 |
| 2 | 5 | 7 |
| 3 | 6 | 8 |
# Stacking horizontally
pd.concat([a, b], axis=1)| x | y | x | y | |
|---|---|---|---|---|
| 0 | 1 | 3 | 5 | 7 |
| 1 | 2 | 4 | 6 | 8 |
merge(): SQL-Style Joins
merge()combines two DataFrames using one or more key columns, like a SQL JOIN.
firms = pd.DataFrame({
'ticker': ['AAPL', 'GOOG', 'MSFT', 'TSLA'],
'sector': ['Tech', 'Tech', 'Tech', 'Auto'],
})
returns = pd.DataFrame({
'ticker': ['AAPL', 'GOOG', 'MSFT', 'AMZN'],
'ret': [0.05, 0.02, 0.03, 0.04],
})# Inner join: only tickers in BOTH frames
pd.merge(firms, returns, on='ticker', how='inner')| ticker | sector | ret | |
|---|---|---|---|
| 0 | AAPL | Tech | 0.05 |
| 1 | GOOG | Tech | 0.02 |
| 2 | MSFT | Tech | 0.03 |
# Left join: keep ALL rows from firms; fill missing with NaN
pd.merge(firms, returns, on='ticker', how='left')| ticker | sector | ret | |
|---|---|---|---|
| 0 | AAPL | Tech | 0.05 |
| 1 | GOOG | Tech | 0.02 |
| 2 | MSFT | Tech | 0.03 |
| 3 | TSLA | Auto | NaN |
# Right join: keep all rows from returns
pd.merge(firms, returns, on='ticker', how='right')| ticker | sector | ret | |
|---|---|---|---|
| 0 | AAPL | Tech | 0.05 |
| 1 | GOOG | Tech | 0.02 |
| 2 | MSFT | Tech | 0.03 |
| 3 | AMZN | NaN | 0.04 |
# Outer join: keep everything from both
pd.merge(firms, returns, on='ticker', how='outer')| ticker | sector | ret | |
|---|---|---|---|
| 0 | AAPL | Tech | 0.05 |
| 1 | AMZN | NaN | 0.04 |
| 2 | GOOG | Tech | 0.02 |
| 3 | MSFT | Tech | 0.03 |
| 4 | TSLA | Auto | NaN |
Merging on different column names:
- Use
left_onandright_onwhen the key columns have different names in the two frames.
# Suppose one frame calls it 'ticker' and another 'symbol'
returns_alt = returns.rename(columns={'ticker': 'symbol'})
pd.merge(firms, returns_alt, left_on='ticker', right_on='symbol', how='inner')| ticker | sector | symbol | ret | |
|---|---|---|---|---|
| 0 | AAPL | Tech | AAPL | 0.05 |
| 1 | GOOG | Tech | GOOG | 0.02 |
| 2 | MSFT | Tech | MSFT | 0.03 |
Time Series
Financial and economic data is typically indexed by date.
Pandas has rich tools for working with dates: parsing, resampling, rolling windows, and more.
Converting Strings to Dates
- The FF index is currently strings like
'1990-01'. Convert it to a realDatetimeIndex.
ff5.index = pd.to_datetime(ff5.index, format='%Y-%m')
ff5.head()| Mkt-RF | SMB | HML | RMW | CMA | RF | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 1990-01-01 | -7.80 | -1.21 | 0.83 | -1.12 | 1.40 | 0.57 |
| 1990-02-01 | 1.11 | 1.15 | 0.65 | -0.04 | -0.61 | 0.57 |
| 1990-03-01 | 1.83 | 1.59 | -2.90 | 1.99 | -0.94 | 0.64 |
| 1990-04-01 | -3.36 | -0.35 | -2.57 | 1.78 | -1.00 | 0.69 |
| 1990-05-01 | 8.44 | -2.41 | -3.89 | 1.68 | -1.63 | 0.68 |
ff5.indexDatetimeIndex(['1990-01-01', '1990-02-01', '1990-03-01', '1990-04-01',
'1990-05-01', '1990-06-01', '1990-07-01', '1990-08-01',
'1990-09-01', '1990-10-01',
...
'2023-03-01', '2023-04-01', '2023-05-01', '2023-06-01',
'2023-07-01', '2023-08-01', '2023-09-01', '2023-10-01',
'2023-11-01', '2023-12-01'],
dtype='datetime64[ns]', name='Date', length=408, freq=None)Selecting by Date
- With a
DatetimeIndex, you can slice with date strings.
# All of 2020
ff5.loc['2020'].head()| Mkt-RF | SMB | HML | RMW | CMA | RF | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 2020-01-01 | -0.09 | -4.37 | -6.22 | -1.19 | -2.19 | 0.13 |
| 2020-02-01 | -8.15 | 0.08 | -3.82 | -1.43 | -2.53 | 0.12 |
| 2020-03-01 | -13.35 | -8.18 | -13.83 | -1.61 | 1.19 | 0.12 |
| 2020-04-01 | 13.58 | 2.57 | -1.34 | 2.76 | -1.02 | 0.00 |
| 2020-05-01 | 5.59 | 1.92 | -5.00 | 0.96 | -3.16 | 0.01 |
# A specific date range
ff5.loc['2008-01':'2009-12']| Mkt-RF | SMB | HML | RMW | CMA | RF | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 2008-01-01 | -6.32 | -0.59 | 4.02 | 2.10 | 2.07 | 0.21 |
| 2008-02-01 | -3.09 | -0.62 | -0.71 | 1.07 | -0.98 | 0.13 |
| 2008-03-01 | -0.94 | 0.64 | 0.34 | 0.87 | 0.47 | 0.17 |
| 2008-04-01 | 4.61 | -1.20 | -0.99 | 1.84 | -2.57 | 0.18 |
| 2008-05-01 | 1.87 | 3.01 | -1.57 | 0.94 | 0.14 | 0.18 |
| 2008-06-01 | -8.43 | 1.16 | -2.49 | 4.97 | -0.69 | 0.17 |
| 2008-07-01 | -0.75 | 3.73 | 5.21 | -1.40 | 0.99 | 0.15 |
| 2008-08-01 | 1.53 | 3.27 | 1.51 | 1.82 | 0.67 | 0.13 |
| 2008-09-01 | -9.35 | 0.37 | 5.65 | 2.23 | 1.72 | 0.15 |
| 2008-10-01 | -17.20 | -3.04 | -1.92 | 2.73 | 1.46 | 0.08 |
| 2008-11-01 | -7.74 | -3.97 | -6.21 | 4.65 | 2.44 | 0.03 |
| 2008-12-01 | 1.77 | 3.30 | 0.02 | 0.11 | -1.56 | 0.00 |
| 2009-01-01 | -8.09 | -2.12 | -11.01 | 0.32 | -1.24 | 0.00 |
| 2009-02-01 | -10.14 | -1.17 | -6.59 | 1.05 | -0.60 | 0.01 |
| 2009-03-01 | 9.01 | 0.61 | 3.47 | -2.57 | -2.04 | 0.02 |
| 2009-04-01 | 10.17 | 7.08 | 5.37 | 1.32 | 0.33 | 0.01 |
| 2009-05-01 | 5.20 | -2.27 | 0.43 | -0.94 | -2.00 | 0.00 |
| 2009-06-01 | 0.42 | 2.28 | -2.78 | -1.39 | -0.36 | 0.01 |
| 2009-07-01 | 7.74 | 2.30 | 4.89 | -0.26 | 3.08 | 0.01 |
| 2009-08-01 | 3.33 | -0.10 | 7.60 | -2.85 | 3.28 | 0.01 |
| 2009-09-01 | 4.08 | 2.72 | 1.15 | 1.15 | 0.34 | 0.01 |
| 2009-10-01 | -2.55 | -4.85 | -4.17 | 4.22 | -1.48 | 0.00 |
| 2009-11-01 | 5.58 | -2.76 | -0.33 | 0.90 | 0.00 | 0.00 |
| 2009-12-01 | 2.74 | 6.14 | -0.11 | 0.71 | -0.06 | 0.01 |
resample(): Time-Based Aggregation
resample()is likegroupby()for time series: it groups rows by a time frequency.Common frequencies:
'D'daily,'W'weekly,'ME'month-end,'QE'quarter-end,'YE'year-end.(Older pandas versions used
'M','Q','Y'. The newer aliases ending inEare explicit and forward-compatible.)
# Aggregate monthly factors up to annual averages
ff5.resample('YE').mean().head()| Mkt-RF | SMB | HML | RMW | CMA | RF | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 1990-12-31 | -1.007500 | -1.252500 | -1.020833 | 0.763333 | -0.023333 | 0.630833 |
| 1991-12-31 | 2.160000 | 1.004167 | -0.925000 | 0.754167 | -0.969167 | 0.455000 |
| 1992-12-31 | 0.515000 | 0.695000 | 1.727500 | 0.513333 | 0.517500 | 0.287500 |
| 1993-12-31 | 0.664167 | 0.490833 | 1.239167 | -0.535000 | 0.877500 | 0.238333 |
| 1994-12-31 | -0.292500 | -0.186667 | -0.085833 | 0.498333 | 0.313333 | 0.320000 |
# Sum monthly returns within each year (approximate annual return)
ff5[['Mkt-RF', 'SMB', 'HML']].resample('YE').sum().head()| Mkt-RF | SMB | HML | |
|---|---|---|---|
| Date | |||
| 1990-12-31 | -12.09 | -15.03 | -12.25 |
| 1991-12-31 | 25.92 | 12.05 | -11.10 |
| 1992-12-31 | 6.18 | 8.34 | 20.73 |
| 1993-12-31 | 7.97 | 5.89 | 14.87 |
| 1994-12-31 | -3.51 | -2.24 | -1.03 |
Rolling Windows
rolling(window=k)creates a sliding window of sizekfor moving statistics.
# 12-month rolling mean of the market premium
ff5['Mkt-RF'].rolling(window=12).mean().head(20)| Mkt-RF | |
|---|---|
| Date | |
| 1990-01-01 | NaN |
| 1990-02-01 | NaN |
| 1990-03-01 | NaN |
| 1990-04-01 | NaN |
| 1990-05-01 | NaN |
| 1990-06-01 | NaN |
| 1990-07-01 | NaN |
| 1990-08-01 | NaN |
| 1990-09-01 | NaN |
| 1990-10-01 | NaN |
| 1990-11-01 | NaN |
| 1990-12-01 | -1.007500 |
| 1991-01-01 | 0.033333 |
| 1991-02-01 | 0.541667 |
| 1991-03-01 | 0.611667 |
| 1991-04-01 | 0.867500 |
| 1991-05-01 | 0.469167 |
| 1991-06-01 | 0.148333 |
| 1991-07-01 | 0.660000 |
| 1991-08-01 | 1.697500 |
# 12-month rolling volatility
ff5['Mkt-RF'].rolling(window=12).std().tail()| Mkt-RF | |
|---|---|
| Date | |
| 2023-08-01 | 5.350784 |
| 2023-09-01 | 4.726037 |
| 2023-10-01 | 4.396233 |
| 2023-11-01 | 4.905420 |
| 2023-12-01 | 4.475456 |
- The first
window-1rows areNaNbecause there aren't enough prior observations.
pct_change(): Computing Returns
pct_change()computes the percent change between consecutive rows.This is the standard way to turn a price series into a return series.
# Build a synthetic 'price' series by treating the risk-free rate as a deposit
# (Just for illustration of pct_change)
np.random.seed(42)
prices = pd.Series(100 * (1 + np.random.randn(10) / 100).cumprod())
prices| 0 | |
|---|---|
| 0 | 100.496714 |
| 1 | 100.357763 |
| 2 | 101.007769 |
| 3 | 102.546147 |
| 4 | 102.306032 |
| 5 | 102.066496 |
| 6 | 103.678343 |
| 7 | 104.474007 |
| 8 | 103.983528 |
| 9 | 104.547701 |
prices.pct_change()| 0 | |
|---|---|
| 0 | NaN |
| 1 | -0.001383 |
| 2 | 0.006477 |
| 3 | 0.015230 |
| 4 | -0.002342 |
| 5 | -0.002341 |
| 6 | 0.015792 |
| 7 | 0.007674 |
| 8 | -0.004695 |
| 9 | 0.005426 |
- The first value is
NaNbecause there's no prior price to compare to.
Reshape Preview: pivot and melt
Data can be in wide format (one row per entity, columns for each variable/period) or long format (one row per observation).
pivotandmeltconvert between the two.
A Long-Format Toy Dataset
long = pd.DataFrame({
'year': [2020, 2020, 2021, 2021, 2022, 2022],
'firm': ['A', 'B', 'A', 'B', 'A', 'B'],
'profit': [10, 15, 12, 18, 14, 20],
})
long| year | firm | profit | |
|---|---|---|---|
| 0 | 2020 | A | 10 |
| 1 | 2020 | B | 15 |
| 2 | 2021 | A | 12 |
| 3 | 2021 | B | 18 |
| 4 | 2022 | A | 14 |
| 5 | 2022 | B | 20 |
pivot(): Long → Wide
- Spread one column's values across new columns.
wide = long.pivot(index='year', columns='firm', values='profit')
wide| firm | A | B |
|---|---|---|
| year | ||
| 2020 | 10 | 15 |
| 2021 | 12 | 18 |
| 2022 | 14 | 20 |
melt(): Wide → Long
- Unstack columns back into rows. Useful for plotting and many statistical functions that expect long format.
wide.reset_index().melt(id_vars='year',
value_vars=['A', 'B'],
var_name='firm',
value_name='profit')| year | firm | profit | |
|---|---|---|---|
| 0 | 2020 | A | 10 |
| 1 | 2021 | A | 12 |
| 2 | 2022 | A | 14 |
| 3 | 2020 | B | 15 |
| 4 | 2021 | B | 18 |
| 5 | 2022 | B | 20 |
In-Class Exercise
12-1. Building a DataFrame:
- Create a DataFrame named
portfoliowith four columns:ticker:['AAPL', 'GOOG', 'MSFT', 'AMZN', 'TSLA']price:[180, 140, 330, 130, 250]shares:[100, 50, 30, 80, 20]sector:['Tech', 'Tech', 'Tech', 'Consumer', 'Auto']
- Print the DataFrame and use
info()to inspect dtypes and non-null counts.
12-2. New Columns and Filtering:
- Add a column
valueto yourportfolioequal toprice * shares. - Print just the rows where
value > 5000. - Sort the full DataFrame by
valuein descending order.
12-3. GroupBy:
- Using your
portfoliofrom 12-2, compute the total value invested in each sector withgroupby('sector')['value'].sum(). - Then use
agg(['sum', 'mean', 'count'])on the same group to compute three statistics at once.
12-4. Loading Fama-French Data:
- Re-read the file
ff5_factors.csvwe saved earlier withpd.read_csv(..., index_col=0). - Convert the index to datetime with
pd.to_datetime(..., format='%Y-%m'). - Report the start date, end date, and number of rows.
12-5. Filtering Returns:
- Find every month in which the market premium (
Mkt-RF) was greater than 10%. - How many such months are there? Print them.
12-6. Rolling Statistics:
- Compute the 24-month rolling mean and 24-month rolling standard deviation of
Mkt-RF. - Print the most recent five values of each.
12-7. Resample to Annual:
- Use
resample('YE').sum()to compute the approximate annual sum of each Fama-French factor. - Then sort this annual DataFrame by
Mkt-RFin descending order and print the top 5 best years for the market premium.
12-8. Merge and Group:
- Construct a DataFrame
sectorswith two columns:ticker:['AAPL', 'GOOG', 'MSFT', 'AMZN', 'TSLA', 'F', 'GM']industry:['Software', 'Software', 'Software', 'Retail', 'Auto', 'Auto', 'Auto']
- Merge it with your
portfolioDataFrame ontickerusing a left join. - Group the merged frame by
industryand report the totalvalueper industry.