Skip to content

Bamboo Weekly #37: Consumer finances (solutions)

Get better at: Excel files, comprehensions, multiple files, stack-unstack, and plotting.

Bamboo Weekly #37: Consumer finances (solutions)

Consumer finances

This week, we explored a small part of the data that the Federal Reserve used in its Survey of Consumer Finances, a study of that they conduct every three years about American households’ financial state.

From DALL-E: A family of pandas at the table counting their money

The latest edition of the SCF was just released last week, with the report downloadable from /content/files/publications/files/scf23.pdf. Given that the report has nine (!) co-authors, it’s no surprise that there is a ton of data to review and analyze, and many questions that we could answer.

Data and seven questions

This is a weekly newsletter about Pandas, rather than a year-long, graduate-level seminar in economics — so I asked you to look at one Excel file that summarizes the data found in the research report. That file still contains many tabs, many of which describe a great deal of data, so we’re really looking at the tiniest tip of the iceberg here.

The Excel file we’re looking at is located at /content/files/econres/files/scf2022_tables_public_nominal_historical.xlsx. If you’re generally interested in the SCF, you can find out more at https://www.federalreserve.gov/econres/scfindex.htm.

This week, I gave you seven questions and tasks. The learning goals include working with Excel files (including rejiggering spreadsheets into useful data frames), multi-indexes, and plotting. A link to the Jupyter notebook that I used in solving these problems is below. Meanwhile, let’s look at how I solved things:

Create a data frame from the first two sheets (Table 1, 89-98 and Table 1, 01-22), where the index will be the descriptions (in column A) of rows 34-37, and the values will come from each year's values. The result will be a data frame with four rows and a multi-index columns, with the years on the outer level and "median" and "mean" in the inner level.

Before doing anything else, I loaded up Pandas — the library itself, and also aliases to Series and DataFrame, which often come in handy:

import pandas as pd
from pandas import Series, DataFrame

For this first task, I asked you to read data from the Excel file. That shouldn’t be too bad; Pandas has the “read_excel” method, and we can use it similarly to “read_csv” to get data from a file into a data frame.

However, this Excel file threw us a few curveballs. First, it has a bunch of sheets, each corresponding to a table of data in the research report. Second, data for Table 1 in the report is actually broken into two separate sheets that we have to join together. Third, these sheets have the equivalent of a multi-index (year → income → median/mean and year → percentage that saved). And finally, the sheets have many subsections, each of which describes a different breakdown of the data.

I was only interested in the subset that had to do with education, on rows 34-37. The goal is a data frame of four rows and with a multi-index on the columns, consisting of years (on the outer level) and median + mean (on the inner level).

How will we do all this? Slowly and carefully, for sure.

First, let’s talk strategy: I’m going to create a data frame from each of the first two sheets. I’ll create a Python list containing those two data frames, and then run “pd.concat” on them, to get a new, combined data frame back.

I love list comprehensions, and here I used one to create a 2-element list of data frames. (And yes, I could have theoretically asked for more than one sheet back from read_excel, but that didn’t seem to work for me, for reasons I didn’t have a chance to investigate.)

I thus created a list comprehension, iterating over the integers 0 and 1, corresponding to the indexes of the sheets I wanted to retrieve from the Excel file. For each of those sheets, I passed the following keyword arguments to read_excel:

Remember that while Excel numbers its rows starting with 1, Pandas indexes them starting with 0. It’s a great source for off-by-one errors.

Here’s how the code looks so far, given what I’ve described:

filename = 'scf2022_tables_public_nominal_historical.xlsx'

all_dfs = [pd.read_excel(filename,
                        sheet_name=sheet_number, 
                        header=[2,3,4],
                       index_col=0)
            for sheet_number in [0, 1]

The above is a good start, but definitely not enough. Each data frame still contains all of the rows from its sheet. And the multi-index is a bit too “multi” for our taste. And of course.

Fortunately, the two sheets are formatted identically, meaning that will want the same rows — 27 through 30. We could use “loc” to retrieve rows via the index, but given that we know which row numbers we want, it’ll be easier to just use “iloc” with a range. Remember that ranges, like many things in Python, are specified with the starting number and one past the ending number. We’ll thus ask for “iloc[27:31]” from each of the data frames we get from read_excel.

But then things get more interesting: How can we take the multi-index as we got it from Excel and turn it into a simplified version, containing only the year and median/mean?

The answer is with “xs”, the amazing method that lets us retrieve from a multi-index using a variety of different forms. In this case, we ask for only those columns whre “Income” is in the middle level (i.e., level 1). That gets rid of the “Percentage” portion of each multi-index, as well as the “Income” part.

all_dfs = [(pd.read_excel(filename,
                        sheet_name=sheet_number, 
                        header=[2,3,4],
                       index_col=0)
            .iloc[27:31]
            .xs('Income', level=1, axis='columns'))
           for sheet_number in [0, 1]]

The result is a 2-element list of data frames containing only the “Income” part of the multi-indexed columns, and the rows that are of interest to us from both sheets.

What’s left? We need to combine these two data frames together into a single one with pd.concat. (Remember that “concat” isn’t a method we run on a data frame, but rather a top-level Pandas function to which we pass a list of data frames.)

The only tricky thing here is that we want to join the data frames side-to-side, rather than top-to-bottom. We thus need to pass the “axis” keyword argument, with a value of “columns”:

df = pd.concat(all_dfs, axis='columns')

After doing all this, I got a data frame with 4 rows and 24 columns — 2 columns (mean and median) for each year of the survey.

Create a line plot with a separate line for each educational level, the x axis representing years, and the y axis showing median income in 2022 dollars. Which level of educational achievement appears to be improving its mean income level the most over the years?

Matplotlib is the best-known plotting library for Python, but whenever I can get away with it, I prefer to use the builtin Pandas plotting methods. They tend to be more limited, but they’re also (for me, at least) easier to work with, remember, and understand. (When things get more complex, or if I want the plot to look nicer, I use Seaborn instead.)

I wanted to see a line plot showing the median income for each educational level, with the x axis showing the progression of years. The idea is that we would be able to see whether, over the years, median income had improved or declined for each educational level.

We can always create a line plot from a data frame using the “plot.line” method. This gives us a separate line for each column, and uses the index for the x axis. While we could do this, it’ll give us a very weird, useless plot. Moreover, it’ll give us both the mean and the median, when we’re only interested in the median:

I removed the legend from the above plot, because it was covering most of the graph. But… this is totally useless.

What we need to do is pull out only the columns with a “Median” value in the multi-index. Once again, we’ll turn to “xs”:

df.xs('Median', level=1, axis='columns')

The above indicates that we want to select only those parts of the data frame for which level 1 (i.e., the second level) of the column multi-index has the value “Median”. The data frame that we get back lacks a mention of “Median”, just as the string you pass to “str.split” doesn’t appear in the list of strings that it returns. Here’s what I got back:

So we can just go ahead and plot now, right? No, not really — because we’ll again get a line for each column, and the x axis will be based on the index.

We need to transpose the data frame, swapping the two axes. We can do that with the “transpose” method, or its alias “T” (which doesn’t take parentheses). Then, with that in place, we can create the line plot:

df.xs('Median', level=1, axis='columns').T.plot.line()

Here’s what we get:

All educational levels have seen their median incomes rise over time. Not surprisingly, there is a strong correlation between the amount of education you got and your income. However, we see that in the last few years, the median income of college graduates has taken off, moving up and to the right very quickly, faster (it would seem from eyeballing it) than anyone else. People without a high-school education have seen their increases level off, becoming very close to flat.

Plot the mean vs. median income levels for each educational level. Again, the x axis should show the years, and the y axis should show the income. Show a separate plot for each educational level.

The above plot shows median income level. As you might know, mean and median both measure the “middle,” but they do it in different ways:

The problem with the mean is that if you have a few outliers, it can pull the values up or down, giving you a skewed picture of the truth. This is illustrated in an old joke: Bill Gates walks into a bar, and on average, everyone in the bar is now a multimillionaire.

I was thus curious to know if we could see any difference between the mean and median incomes for each educational group. A higher mean would show that there’s a small number of outliers earning a great deal, and thus pulling the mean up. A lower mean would show that there’s a small number of outliers earning very little, thus pulling the mean down.

We could, in theory, plot all of these lines together. But that’s hard to read, and I thought that it would be better and more useful to see them separately, with a two-line plot (mean vs. median) for each educational level.

But how to go about creating such a plot? To start, I grabbed the data for “No high school diploma”:

df.loc['No high school diploma']

When we ask for a single row from a data frame, we get a series back. The columns form the index of the series, which means that the columns of df (a multi-index with year + mean/median) are the index of the series:

Family characteristic        
1989                   Median    13.568
                       Mean      19.533
1992                   Median    12.298
                       Mean      17.430
1995                   Median    14.337
                       Mean      20.930
1998                   Median    15.203
                       Mean      21.694
2001                   Median    16.456
                       Mean      25.110
2004                   Median    19.507
                       Mean      25.901
2007                   Median    22.625
                       Mean      31.348
2010                   Median    23.373
                       Mean      33.686
2013                   Median    22.333
                       Mean      30.127
2016                   Median    26.336
                       Mean      38.852
2019                   Median    30.545
                       Mean      39.613
2022                   Median    32.427
                       Mean      42.232
Name: No high school diploma, dtype: float64

If I want to plot the mean vs. the median, then I’ll need to take turn our multi-indexed series into a data frame, one in which mean/median (i..e, the inner part of the multi-index) are the columns.

Fortunately, Pandas has a great way to do that, the “unstack” method:

df.loc['No high school diploma'].unstack()

After running that, I get the following:

I now have the data in the form that I want, with two columns (median + mean), and the years in the rows. I can create a line plot:

df.loc['No high school diploma'].unstack().plot.line()

And I get a great plot:

But wait: I want to get a separate plot for every one of the educational levels. How can I do that?

With a for loop:

for one_index in df.index:
    df.loc[one_index].unstack().plot.line(title=one_index)

Not only does the above code work, it also gives each plot a title based on the index, so that we can distinguish them from one another:

I won’t include all of the plots here, but we can see in all of them that the mean is always higher than the median. And we can see it pulling away, up and to the right, in the last few years. This would seem to indicate that while everyone’s income has been going up, people with more education are doing even better.

For example, here’s the graph for people with a college degree:

I don’t know for sure, but I’m guessing that people studying computer science and data science are the ones pulling the mean up.

Create a new data frame from the sheet named "Table 2," showing the amount of before-tax income for all families (column G). The columns of the data frame will be the survey years (as labeled in rows 7, 17, 27, etc.), and the index for the data frame will be the various income sources listed in column A, rows 8-13 ("Wages," "Interest or Dividends," etc., but not including "Total").

Next, I decided to look at Table 2, showing where people’s income comes from. This isn’t a dollar amount, but is rather a percentage — so by definition, the numbers will all add up to 100, and one always goes up at the expense of another.

The Excel spreadsheet that we have is great for looking at the data from each year. But I wanted to compare the “all families” information across different years. How can I create such a data frame?

I had a few different ideas, but in the end, I decided to use “read_excel” to get the spreadsheet’s first and seventh columns (A and G in Excel, or 0 and 6 in Pandas), skipping down to row 7 (i.e., 6 in Pandas) and not asking for any headers:

df = pd.read_excel(filename, 
              sheet_name='Table 2',
             header=None,
             usecols=[0, 6],
             skiprows=6,
             index_col=0)

The above instructs read_excel to:

The result is a data frame with a single column. We want to grab values from that column in order to build a new data frame, one in which the years would be the column names and the six “all families” values from that year’s survey.

I decided to create the new data frame using a dictionary, one in which the keys are the years and the values are a series. I can get the years from the survey name (in rows 7, 17, 27, etc.) and the values from rows 8-13, 18-23, 28-33, etc.

See a pattern here? I did, and I decided to take advantage of it and create a “for” loop.

all_years = {}

for start_index in range(0,111, 10):

    one_year = df.iloc[start_index:start_index+8][6]

    # get the year
    year = int(one_year.index[0].split()[0])

    # assign to our dict
    all_years[year] = one_year.iloc[1:-1]

I started with an empty dict. I then iterated over the range of 0 to 111, increasing by 10 with each iteration.

I grabbed the values that were of interest to me via iloc, starting at “start_index” and going up by 8. I only wanted the items from the column named 6. That all went into the “one_year” variable.

Then I grabbed the index, whose first word I knew to be the year in which the survey was taken. I grabbed that, then turned it into an integer.

So I had the year, and I had the values. I added them as a key-value pair to the dict, trimming the first and last rows from the values, since I didn’t want the survey title or the total to be included.

I then created a data frame from this dict:

df = DataFrame(all_years)

The result:

Create a line plot with a separate line for each income source; the x axis should be the years, and the y axis will be the percentages. Over the years of the survey, have the percentages from each income source remained constant? What has gone up, and what has gone down?

With this data frame in place, I was able to create such a plot without much trouble:

df.T.plot.line(grid=True)

Notice that I once again had to transpose the rows and columns, to make sure that the years would form the x axis and the different types of income formed the different lines.

I also, added grid lines, just because I thought that they would make the plot easier to read. The result:

We see that for families as a whole, the proportion of income they get from wages has dropped a bit over the years, whereas income from capital gains (i.e., selling investments) has gone up. We also see a rise in Social Security income; I didn’t look at it carefully, but I wouldn’t be surprised if that has something to do with the large number of people retiring, and the general aging of the US population.

Create a new data frame based on the 2022 survey data on income sources. The index should (again) be the income sources, but the columns this time should show the names from row 5, labeling the percentile of net worth. Set the symbol in B121 to be 0.

I decided to look at the data for just the 2022 survey, to look at the income sources for people across different percentiles of net worth. This basically meant reading from A117 through G123 in Excel, and turning them into a data frame. How did I do that?

First, I wanted to get the data, which turned out to be a little tricky. I did it with the following code:

df = pd.read_excel(filename, 
                   sheet_name='Table 2',
                   header=4,
                   skiprows=112,
                   nrows=6,
                   usecols=range(6),
                   index_col=0).replace({'†':0})

Here’s what I did:

That created a data frame without any trouble. But the column names were totally off. I decided to read the same sheet into Pandas again, but only in order to get the column names, which I immediately assigned to df.columns:

df.columns = pd.read_excel(filename, 
                           sheet_name='Table 2',
                           header=4,
                           usecols=range(6),
                           index_col=0,
                           nrows=1).columns

In other words, I created a 0-row data frame, grabbed its columns, and assigned them back. It worked:

For each percentile, what were the largest and smallest income sources?

Finally, given this data, I wanted to know each income percentile’s greatest and smallest sources of wealth.

Basically, I wanted to calculate the min and max for each column, and then get the index for each of those min and max values.

I can calculate more than one aggregate with “agg”. This method lets us pass a list of aggregation methods, and get a result for each. But the normal “min” and “max” methods won’t do what I want; I don’t want the value, but rather the index for each value. That’s where the “idxmin” and “idxmax” methods come in handy:

df.agg(['idxmin', 'idxmax'])

The result:

In other words: No matter your net worth, you’re always going to earn the greatest proportion of your income from wages. But the minimum depends very much on how much you earn; the lowest earners don’t get much from capital gains (duh!), while the highest earners don’t get money from transfers.

And there you have it!

My Jupyter notebook is located at: https://drive.google.com/file/d/1zEvdZC_Kbn5Q8h6_34L-h0b_iI_OwzKW/view?usp=sharing

Please share comments, thoughts, and corrections.

I’ll be back on Wednesday with another set of Pandas questions based on current events.

Reuven