Workshop 3, Algorithms and Data Analysis

Data management for panel data with pandas

Author

Alberto Dorantes, Ph.D.

Published

October 5, 2026

Abstract
This is an INDIVIDUAL workshop. We learn the data-management skills that come before any financial model: importing, exploring, merging, filtering, transforming and summarizing a panel dataset with the historical financial statements of all US public firms. We calculate financial variables and ratios correctly within each firm, build industry-year summary tables, reshape data between long and wide formats, and treat extreme values with winsorization.

1 General directions

Work in a new Google Colab notebook named W3-Algorithms-YourFirstName-YourLastName, and share it with cdorante@tec.mx (Edit privileges). Same rules as before: re-type and run the code, write your notes, predict before you run, and verify your results. If you use an LLM, write your algorithm first and paste your prompt.

Grading: notebook (60%) + Canvas Quiz W3 (40%), due on the same date as the workshop.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

2 Data structures in Finance

In Finance, datasets usually have one of the following structures:

  1. Time series: many periods for one subject (a company, an index, a currency). With several subjects, each subject is usually a column. This is the wide format we used in Workshop 2 with the monthly prices:
ts = pd.DataFrame({"p_stock1": [10, 11], "r_stock1": [0.02, 0.10],
                   "p_stock2": [20, 21], "r_stock2": [0.01, 0.05]},
                  index=["2014m1", "2014m2"])
ts
p_stock1 r_stock1 p_stock2 r_stock2
2014m1 10 0.02 20 0.01
2014m2 11 0.10 21 0.05
  1. Cross-section: many subjects for one period. For example, some financial ratios of Mexican firms in the last quarter of 2016:
cs = pd.DataFrame({"Ticker": ["ALFAA.MX", "AMXB.MX"], "ROA": [0.023, 0.015],
                   "ATO": [0.90, 1.1], "ProfitMargin": [0.15, 0.10]})
cs
Ticker ROA ATO ProfitMargin
0 ALFAA.MX 0.023 0.9 0.15
1 AMXB.MX 0.015 1.1 0.10
  1. Panel data: many subjects, and several periods for each subject. Each row is one subject-period (for example, one firm-year). This is the long format:
panel = pd.DataFrame({"Ticker": ["ALFAA.MX", "ALFAA.MX", "BIMBOA.MX", "BIMBOA.MX"],
                      "quarter": ["2014q1", "2014q2", "2014q1", "2014q2"],
                      "ROA": [0.20, 0.21, 0.15, 0.20]})
panel
Ticker quarter ROA
0 ALFAA.MX 2014q1 0.20
1 ALFAA.MX 2014q2 0.21
2 BIMBOA.MX 2014q1 0.15
3 BIMBOA.MX 2014q2 0.20

Most of the data used in corporate finance and in quantitative investing is panel data. Panel data requires special care: when we calculate changes or lags, we must never mix the data of one firm with the data of another firm. This is the most important lesson of this workshop.

3 Importing the datasets

We will work with 2 datasets:

  • usdata.csv: a panel dataset with the annual financial statements of all US public firms (firms that issue shares in the stock market).
  • usfirms.csv: a catalog (cross-section) with general information of each firm: name, status (active or cancelled) and industry.
def import_csv(file, base_url="https://www.apradie.com/datos/"):
    """Downloads a file from base_url and import it into a data frame ."""

    import os
    import io
    import requests
    import pandas

    headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"}
    try: 
        response = requests.get(base_url + file, headers= headers)
        response.raise_for_status() # Error if the response is 4xx or 5xx
        #Reading the content un a dataframe:
        df = pd.read_csv(io.StringIO(response.text))
        print(f"File {file} was downloaded and imported as a data frame")
    except requests.exceptions.HTTPError as e:
        print(f"HTTP Error occured: {e}")
        raise
    except requests.exceptions.RequestException as e:
        print(f"An error ocurred dufing the request: {e}")
        raise
    return df 
  
usdata = import_csv("usdata.csv")
usfirms = import_csv("usfirms.csv")
usdata.head()
File usdata.csv was downloaded and imported as a data frame
File usfirms.csv was downloaded and imported as a data frame
firm year revenue cogs sgae depreciation otherincome extraordinaryitems finexp incometax ... totalliabilities currentliabilities accountspayable shortdebt longdebt retainedearnings adjprice originalprice sharesoutstanding fixedassets
0 A 2000 10773000.0 5522000.0 3703000.0 495000.0 111000.0 0.0 0.0 407000.0 ... 3160000.0 2758000.0 857000.0 830000.0 0.0 757000.0 33.102282 54.75 456366.381 1741000.0
1 A 2001 8396000.0 5166000.0 3274000.0 734000.0 301000.0 580000.0 0.0 -71000.0 ... 2327000.0 2002000.0 386000.0 761000.0 0.0 931000.0 17.237371 28.51 463695.160 1848000.0
2 A 2002 6010000.0 3694000.0 3188000.0 735000.0 60000.0 -10000.0 0.0 -525000.0 ... 3576000.0 2181000.0 305000.0 574000.0 1150000.0 -101000.0 10.858758 17.96 467024.421 1579000.0
3 A 2003 6056000.0 3762000.0 2657000.0 362000.0 35000.0 -268000.0 0.0 1100000.0 ... 3473000.0 1906000.0 441000.0 311000.0 1150000.0 -2159000.0 17.678735 29.24 476149.083 1447000.0
4 A 2004 7181000.0 4058000.0 2445000.0 292000.0 54000.0 0.0 0.0 91000.0 ... 3487000.0 1871000.0 441000.0 261000.0 1150000.0 -1810000.0 14.571050 24.10 486841.087 1258000.0

5 rows × 28 columns

We wrote a small function to avoid repeating the same download code twice. Whenever you copy and paste a block of code to change only one value, consider writing a function.

3.1 Data dictionaries

All $ amounts are in thousands of US dollars.

Data dictionary of usdata (historical annual financial data)
Variable Description
firm Firm ticker: the unique code of the company
year Fiscal year
revenue Total sales of the firm in the fiscal year
cogs Cost of goods sold in the fiscal year: variable costs of the products sold
sgae Sales, general and administrative expenses in the fiscal year
depreciation Depreciation and amortization in the fiscal year
otherincome Other operating income/expenses that are not directly from the core operations of the firm
extraordinaryitems Extraordinary income/expenses not related to regular operations
finexp Financial expenses: interest expenses paid in the year (generated by loans)
incometax Income tax paid in the year
cashflowoper Cash flow generated by operations during the year
cashflowfin Cash flow from financing activities during the year
cashflowinv Cash flow from investing activities during the year
cashinbanks Cash balance in banks at the end of the year
totalassets Total assets of the firm at the end of the year
currentassets Current assets of the firm at the end of the year
accountsreceivables Accounts receivable at the end of the year
inventory Inventory at the end of the year
totalliabilities Total liabilities of the firm at the end of the year
currentliabilities Current liabilities of the firm at the end of the year
accountspayable Accounts payable at the end of the year
shortdebt Short-term financial debt at the end of the year
longdebt Long-term financial debt at the end of the year (loans to be paid in more than 1 year)
retainedearnings Retained earnings at the end of the year
adjprice Stock price at the end of the year, adjusted for stock splits and dividends; used to calculate stock returns
originalprice Historical stock price (not adjusted); used to calculate the historical market value
sharesoutstanding Historical number of shares outstanding
fixedassets Fixed assets at the end of the year
Data dictionary of usfirms (catalog of firms)
Variable Description
empresa Firm ticker: the unique code of the company
Nombre Name of the firm
status Status of the firm: active or cancelled
partind Percentage participation in the S&P 500 index
naics1 North American Industry Classification System: level 1 (the broadest industry)
naics2 North American Industry Classification System: level 2
SectorEconomatica Economatica industry classification

For income-statement variables (revenue, cogs, etc.), the value is the amount generated during the fiscal year. For balance-sheet variables (totalassets, longdebt, etc.), the value is the balance at the end of the fiscal year. This difference matters when we build ratios: a flow of the year (like earnings) should be compared with the balance at the beginning of the year, which is the balance at the end of the previous year.

4 Exploring a dataset

Before any analysis, answer these questions: How many rows and columns? What are the types of the columns? How many missing values? How many subjects and periods?

print(usdata.shape)
usdata.info()
(62453, 28)
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 62453 entries, 0 to 62452
Data columns (total 28 columns):
 #   Column               Non-Null Count  Dtype  
---  ------               --------------  -----  
 0   firm                 62453 non-null  object 
 1   year                 62453 non-null  int64  
 2   revenue              62393 non-null  float64
 3   cogs                 62393 non-null  float64
 4   sgae                 62393 non-null  float64
 5   depreciation         62453 non-null  float64
 6   otherincome          62393 non-null  float64
 7   extraordinaryitems   62392 non-null  float64
 8   finexp               62398 non-null  float64
 9   incometax            62451 non-null  float64
 10  cashflowoper         62366 non-null  float64
 11  cashflowfin          62366 non-null  float64
 12  cashflowinv          62366 non-null  float64
 13  cashinbanks          51416 non-null  float64
 14  totalassets          62452 non-null  float64
 15  currentassets        60658 non-null  float64
 16  accountsreceivables  51416 non-null  float64
 17  inventory            51416 non-null  float64
 18  totalliabilities     62451 non-null  float64
 19  currentliabilities   51412 non-null  float64
 20  accountspayable      51393 non-null  float64
 21  shortdebt            51393 non-null  float64
 22  longdebt             57723 non-null  float64
 23  retainedearnings     61901 non-null  float64
 24  adjprice             59594 non-null  float64
 25  originalprice        59626 non-null  float64
 26  sharesoutstanding    61717 non-null  float64
 27  fixedassets          62393 non-null  float64
dtypes: float64(26), int64(1), object(1)
memory usage: 13.3+ MB
print("Number of firms:", usdata["firm"].nunique())
print("Years:", usdata["year"].min(), "to", usdata["year"].max())
print("Missing values in revenue:", usdata["revenue"].isna().sum())
Number of firms: 5609
Years: 2000 to 2023
Missing values in revenue: 60

nunique() counts the distinct values of a column, and isna().sum() counts the missing values (NaN): isna() returns True/False for each row, and the sum counts the True values. The dataset has one row for each firm and each year, even for the years before the firm went public; those rows are empty (NaN). Then, the first year with non-empty data is the year when the firm started to trade in the stock market.

describe() shows descriptive statistics of the numeric columns. We transpose it (.T) so that each variable is a row:

usdata[["revenue", "totalassets", "originalprice"]].describe().T
count mean std min 25% 50% 75% max
revenue 62393.0 4.989409e+06 1.936746e+07 -4.214926e+06 134790.00 769432.00 2.974489e+06 6.481250e+08
totalassets 62452.0 1.559356e+07 1.114561e+08 0.000000e+00 364028.75 1640936.50 6.017116e+06 4.325437e+09
originalprice 59626.0 4.274569e+01 1.140603e+02 1.500000e-03 10.13 24.32 4.656188e+01 7.000450e+03

5 Merging datasets

The panel usdata does not have the name, the status or the industry of each firm. That information is in usfirms. Both datasets have a common column: the firm ticker, but it has different names: firm in usdata and empresa in usfirms.

We do a left join: we keep all the rows of the left dataset (usdata), and we pull the matching columns from the right dataset (usfirms). This is exactly what VLOOKUP does in Excel.

merged = pd.merge(usdata,
                  usfirms[["empresa", "Nombre", "status", "naics1"]],
                  left_on="firm", right_on="empresa", how="left")
merged = merged.drop(columns="empresa")      # drop the redundant ticker column
merged.head()
firm year revenue cogs sgae depreciation otherincome extraordinaryitems finexp incometax ... shortdebt longdebt retainedearnings adjprice originalprice sharesoutstanding fixedassets Nombre status naics1
0 A 2000 10773000.0 5522000.0 3703000.0 495000.0 111000.0 0.0 0.0 407000.0 ... 830000.0 0.0 757000.0 33.102282 54.75 456366.381 1741000.0 Agilent Technologies, Inc active Manufacturing
1 A 2001 8396000.0 5166000.0 3274000.0 734000.0 301000.0 580000.0 0.0 -71000.0 ... 761000.0 0.0 931000.0 17.237371 28.51 463695.160 1848000.0 Agilent Technologies, Inc active Manufacturing
2 A 2002 6010000.0 3694000.0 3188000.0 735000.0 60000.0 -10000.0 0.0 -525000.0 ... 574000.0 1150000.0 -101000.0 10.858758 17.96 467024.421 1579000.0 Agilent Technologies, Inc active Manufacturing
3 A 2003 6056000.0 3762000.0 2657000.0 362000.0 35000.0 -268000.0 0.0 1100000.0 ... 311000.0 1150000.0 -2159000.0 17.678735 29.24 476149.083 1447000.0 Agilent Technologies, Inc active Manufacturing
4 A 2004 7181000.0 4058000.0 2445000.0 292000.0 54000.0 0.0 0.0 91000.0 ... 261000.0 1150000.0 -1810000.0 14.571050 24.10 486841.087 1258000.0 Agilent Technologies, Inc active Manufacturing

5 rows × 31 columns

5.1 Always check a merge

A left join must return exactly the same number of rows as the left dataset. If the right dataset had a repeated ticker, every matching row of the left dataset would be duplicated, silently. We check both things with assert:

assert usfirms["empresa"].is_unique, "usfirms has repeated tickers!"
assert len(merged) == len(usdata), "The merge changed the number of rows!"
print("Rows usdata:", usdata.shape, " rows merged:", merged.shape)
print("Firms without a match in usfirms:", merged["Nombre"].isna().sum(), "rows")
Rows usdata: (62453, 28)  rows merged: (62453, 31)
Firms without a match in usfirms: 0 rows
WarningA silent error: merges that duplicate rows

If a key is repeated in the right table, merge repeats the rows of the left table without any warning. All your later sums and averages would be wrong. Always check is_unique on the key of the right table, and compare the number of rows before and after the merge.

6 Selecting, filtering and creating columns

We select columns with a list of names, and we filter rows with boolean conditions (as in Workshop 2):

active = merged[merged["status"] == "active"]
big_2023 = merged[(merged["year"] == 2023) & (merged["totalassets"] > 100_000_000)]
print(len(active), len(big_2023))
big_2023[["firm", "Nombre", "totalassets"]].head()
43073 93
firm Nombre totalassets
186 AAPL Apple Inc 352583000.0
230 ABBV Abbvie Inc 134711000.0
583 ACF_old General Motors Financial Company, Inc 132011000.0
1499 AFL Aflac Inc 126724000.0
1842 AIG American Intl Group, Inc 539306000.0

To combine conditions in pandas, we use & (and), | (or) and ~ (not), and each condition must be in parentheses. The Python words and/or do not work with data frame columns.

New columns are calculated with vectorized operations, row by row:

merged["grossprofit"] = merged["revenue"] - merged["cogs"]
merged[["firm", "year", "revenue", "cogs", "grossprofit"]].dropna().head(3)
firm year revenue cogs grossprofit
0 A 2000 10773000.0 5522000.0 5251000.0
1 A 2001 8396000.0 5166000.0 3230000.0
2 A 2002 6010000.0 3694000.0 2316000.0

7 Grouping: summary tables

groupby splits the data into groups, applies a function to each group, and combines the results. It is the pandas version of a pivot table in Excel.

# Total revenue of all firms in each year (in billions of dollars):
rev_by_year = merged.groupby("year")["revenue"].sum() / 1_000_000
rev_by_year.tail()
year
2019    16378.886831
2020    15662.475472
2021    18375.103165
2022    20776.048144
2023    21254.103133
Name: revenue, dtype: float64

With agg we can calculate several statistics at the same time, and give names to the results:

summary = merged.groupby("naics1").agg(
    n_obs=("revenue", "count"),
    median_revenue=("revenue", "median"),
    total_assets=("totalassets", "sum"))
summary.sort_values("n_obs", ascending=False).head()
n_obs median_revenue total_assets
naics1
Manufacturing 24301 580222.0 1.626045e+11
Finance and Insurance 13076 434319.5 6.186014e+11
Information 4516 720605.0 4.869501e+10
Retail Trade 3175 3086608.0 2.520594e+10
Mining, Quarrying, and Oil and Gas Extraction 2659 809974.0 1.553227e+10

Each line inside agg reads: new_column=("existing_column", "function"). We can also group by two columns, for example industry and year:

by_ind_year = merged.groupby(["naics1", "year"])["revenue"].median()
by_ind_year.head()
naics1  year
-       2016     28804.0
        2017    159879.0
        2018    388858.0
        2019    718019.0
        2020    803287.0
Name: revenue, dtype: float64

8 Lags and changes in panel data: groupby + shift

To calculate a growth rate, a return or a ratio with a beginning-of-year balance, we need the value of the previous year of the same firm. shift(1) moves a column one row down. But in a panel, the previous row of the first year of a firm belongs to another firm! Look at this small example:

demo = pd.DataFrame({"firm": ["A", "A", "A", "B", "B", "B"],
                     "year": [2021, 2022, 2023, 2021, 2022, 2023],
                     "assets": [100, 110, 121, 50, 55, 60]})
demo["assets_lag_WRONG"] = demo["assets"].shift(1)
demo["assets_lag"] = demo.groupby("firm")["assets"].shift(1)
demo
firm year assets assets_lag_WRONG assets_lag
0 A 2021 100 NaN NaN
1 A 2022 110 100.0 100.0
2 A 2023 121 110.0 110.0
3 B 2021 50 121.0 NaN
4 B 2022 55 50.0 50.0
5 B 2023 60 55.0 55.0

The WRONG column assigns the assets of firm A in 2023 (121) as the previous assets of firm B in 2021. Any growth or return calculated with it would be garbage. With groupby("firm"), the shift is done within each firm, and the first year of each firm correctly gets NaN.

ImportantThe golden rule of panel data
  1. Sort the data by firm and year: df = df.sort_values(["firm", "year"]).
  2. Always calculate lags, changes and returns within each firm: df.groupby("firm")["x"].shift(1).

9 Reshaping: long and wide formats

Sometimes we need a panel in wide format: one row per year and one column per firm (as the price file of Workshop 2). pivot converts long into wide, and melt converts wide into long:

wide = demo.pivot(index="year", columns="firm", values="assets")
print(wide)

long_again = wide.reset_index().melt(id_vars="year", var_name="firm", value_name="assets")
print(long_again)
firm    A   B
year         
2021  100  50
2022  110  55
2023  121  60
   year firm  assets
0  2021    A     100
1  2022    A     110
2  2023    A     121
3  2021    B      50
4  2022    B      55
5  2023    B      60

reset_index() converts the index (the years) back into a regular column, which melt needs.

10 CHALLENGE 1: Apple’s financial performance

Using the merged dataset:

  1. Create a data frame apple with the rows of Apple (ticker AAPL) and non-missing revenue, sorted by year.
  2. Calculate grossprofit =revenue-cogs, ebit =grossprofit-sgae-depreciation, and marketvalue =originalprice\times sharesoutstanding. We use the original (not adjusted) price because the number of shares is the historical number.
  3. Calculate the annual growth rate of revenue, grossprofit and ebit (\frac{X_t}{X_{t-1}}-1). Only one firm is in this data frame, so a simple shift(1) is fine here. If the current or the previous value is negative, set the growth to NaN (a “growth” from a negative number has no meaning).
  4. Create a growth index for revenue, grossprofit, ebit and marketvalue: the value of each variable divided by its value in the first year.
  5. Show a plot of the growth indexes over the years, and a table with the annual growth rates.
  6. Provide an insight: what can you say about Apple’s performance in terms of revenue, gross profit, ebit and market value? Did the market value grow faster than the operating results? What could explain it?

11 CHALLENGE 2: The whole market over time

Using the merged dataset with all firms:

  1. Create the columns grossprofit, ebit and marketvalue for all firms (as in Challenge 1).
  2. Create a summary table by year with the sum of revenue, grossprofit, ebit and marketvalue of all firms (in billions of dollars), and the number of firms with revenue data each year.
  3. Plot the growth index of each variable (first year = 1).
  4. Provide an insight: what can you say about the performance of the whole US market? Which years show a drop in ebit or market value, and what happened in those years?
  5. Critical thinking: the number of firms changes every year. Why could this be a problem when we compare sums over time? Suggest an alternative measure.

12 CHALLENGE 3: Financial variables and ratios for all firms

Using the merged dataset sorted by firm and year, calculate the following for all firm-years. Remember the golden rule of panel data.

1. Financial variables

  • grossprofit, ebit and marketvalue (already done in Challenge 2)
  • Net income: netincome =ebit+otherincome+extraordinaryitems-finexp-incometax
  • Book value of equity: bookvalue =totalassets-totalliabilities
  • Annual stock return: stockreturn, the continuously compounded return of the adjusted price: \ln(adjprice_t/adjprice_{t-1}), within each firm.

2. Financial ratios

  • Operating return on assets: roabit=\frac{ebit_{t}}{totalassets_{t-1}}
  • Return on assets: roa=\frac{netincome_{t}}{totalassets_{t-1}}
  • Operating earnings per share: oeps=\frac{ebit_t}{sharesoutstanding_t}
  • Operating earnings per share deflated by price: oepsp=\frac{oeps_t}{originalprice_t}
  • Book-to-market ratio: bmr=\frac{bookvalue_t}{marketvalue_t}

We divide flows (ebit, net income) by the total assets at the beginning of the year (t-1), because the assets that generated the earnings of the year are the ones the firm had at the beginning of the year.

  1. Do your own research and briefly explain with your own words: (a) what is the earnings per share deflated by price and how it is related to the P/E ratio, and (b) what the book-to-market ratio measures and what a high value means.
  2. Show the descriptive statistics (describe()) of the 5 ratios. Do you see strange values? Where could they come from?
  3. Verify: check with assert that the stockreturn of the first year with data of every firm is NaN.

13 CHALLENGE 4: Industry-year summary tables

(OPTIONAL)

Using the data frame of Challenge 3:

  1. Create a table with the median roabit by industry (naics1) and year. Why is the median a better choice than the mean for financial ratios?
  2. Reshape the table into wide format: one row per year and one column per industry. Plot the median roabit of the 3 industries with more firms over time.
  3. Which industry had the highest median roabit in the last year of the data? Which one had the lowest?
  4. Add to your merged data frame a column ind_median_roabit with the median roabit of the industry of each firm in each year, and a column above_ind equal to 1 if the firm’s roabit is higher than its industry median, and 0 otherwise. Hint: use groupby(["naics1", "year"])["roabit"].transform("median"). transform returns one value per row (the median of the group of that row), instead of one value per group.

14 CHALLENGE 5: Bug hunt

Each piece of code has a bug that does not produce an error message: the code runs, but the result is wrong. Find the bug, explain it, and fix it.

5.1 Annual growth of total assets for all firms:

df = df.sort_values(["firm", "year"])
df["asset_growth"] = df["totalassets"] / df["totalassets"].shift(1) - 1

5.2 A student merges a table of industry names with the panel. The catalog industries has the columns naics1 and industry_name, but some naics1 codes appear twice in it (with two different spellings of the name):

panel2 = pd.merge(df, industries, on="naics1", how="left")
print(panel2["revenue"].sum())

5.3 Number of active firms:

n_active = merged[merged["status"] == "active"].shape[0]

15 DataCamp online course

You MUST TAKE Chapter 1: Getting to know a dataset from the course Exploratory Data Analysis in Python.

16 W3 submission

  1. Submit the .ipynb file of your Google Colab notebook in Canvas

  2. Answer the Canvas Quiz W3 before the deadline, with your notebook open.