This week, we looked at some of the financial reports published by the Federal Election Commission (FEC) in the United States. These reports are particularly interesting right now, less than one month before American voters elect a new House of Representatives and one-third of the Senate.
With elections come election spending, and US elections are particularly expensive. This election will be the most expensive on record, according to the Washington Post (https://www.washingtonpost.com/politics/2026/10/07/extraordinary-amount-money-pouring-into-midterm-elections/).
This week, we'll be looking at some of this FEC data, and what it says about campaign committees and money.
Data and five questions
This week's data comes from the FEC site. The main page for downloads is at https://www.fec.gov/data/browse-data/?tab=bulk-data . From that page, we want:
- Candidate master (2025-2026 data, plus headers)
- Committee master (2025-2026 data, plus headers)
- Candidate-committee linkages (2025-2026 data, plus headers)
- All candidates (2025-2026 data)
Note that many committees are not connected to a candidate — such as the political donation arms of companies, lobbies, or political-action committees (PACs). When a committee is associated with a candidate, it might be their campaign. But it also might be a separate account associated with the same candidate, or even a committee jointly run by multiple candidates.
Paid subscribers, both to Bamboo Weekly and to my LernerPython+data membership program (https://LernerPython.com) get all of the questions and answers, as well as downloadable data files, downloadable versions of my notebooks, one-click access to my notebooks, and invitations to monthly office hours.
Learning goals for this week include: Joining, grouping, data cleaning, and plotting with Plotly.
Here are the solutions to this week's five questions:
Read the first three files (candidate master, committee master, and candidate-committee linkages) into data frames, using the separate header files to name the columns in each resulting data frame. Combine the three data frames into a single one, such that we can get information about each committee, its possible linkage to a candidate, and (if there is a candidate) information about that candidate.
As usual, the first thing I did was load Pandas and Plotly Express:
import pandas as pd
from plotly import express as pxI started with the candidate data file, cn26.zip. Note that while this is a zipfile, inside it contains a (compressed) CSV file. The read_csv method knows how to handle such a compressed file without any issues, assuming that there is only a single CSV file in it.
One thing to note about this file is that it uses | characters as separators. That's easy to handle, using the sep keyword argument.
A more surprising, and frustrating, thing about the file is that it doesn't have any column headers. Those are in a separate file, cn_header_file.csv, which is actually comma separated.
I decided to tackle this in two parts:
First, I read the entirety of the header file into Python, used str.split to break it apart, and used a list comprehension to run str.strip on each of the returned values, to avoid leading and trailing whitespace (which were definitely in there). Then, when I used read_csv to read the zipped CSV file, I passed header=None and gave the list of headers to the names keyword argument:
cn26_headers = [one_column.strip()
for one_column in open('data/bw-191-cn_header_file.csv').read().split(',')]
cn26_filename = 'data/bw-191-cn26.zip'
cn26_df = pd.read_csv(cn26_filename, sep='|',
header=None,
names=cn26_headers)The result of all this was to create a data frame, cn26_df, with 8,640 rows and 15 columns.
I repeated this basic technique with the two additional data sets, first for committees:
cm26_headers = [one_column.strip()
for one_column in open('data/bw-191-cm_header_file.csv').read().split(',')]
cm26_filename = 'data/bw-191-cm26.zip'
cm26_df = pd.read_csv(cm26_filename, sep='|',
header=None,
names=cm26_headers)This had 20,823 rows and 15 columns.
I repeated it once more for the join table, linking the candidates with the committees:
ccl26_headers = [one_column.strip()
for one_column in open('data/bw-191-ccl_header_file.csv').read().split(',')]
ccl26_filename = 'data/bw-191-ccl26.zip'
ccl26_df = pd.read_csv(ccl26_filename, sep='|',
header=None,
names=ccl26_headers)This data frame had 8,171 rows and 7 columns.
I then asked you to combine them. We could use join, but that would require that there be a common index. We could do that with the set_index method, but it seems a bit unnecessary here. Instead, we can use merge, which lets us combine data frames with any columns.
I decided to do a "left" join. This means that when we merge, the data frame on the left (i.e., on which we're invoking the method) determines what rows will stick around. If we were to use the default "inner" join, then the merge would return a much smaller data frame, one whose index was the intersection of the two indexes.
I started my left join with the committees, then the join table, and then the committees, to ensure that we would get all committees, with NaN wherever there was no owner or candidate in charge:
df = (
cm26_df
.merge(ccl26_df[['CMTE_ID', 'CAND_ID']], on='CMTE_ID', how='left', suffixes=('_CM', '_CCL'))
.merge(cn26_df, left_on='CAND_ID_CM', right_on='CAND_ID', how='left')
)Notice that I invoke merge twice here. First, I merge cm26_df and ccl26_df. That returns a new data frame, on which I then invoke merge.
You can also see that I'm using a number of keyword arguments:
onindicates which column, named the same thing in both the left and right data frames, should be usedleft_onandright_onare used when the two data frames give different names to the columns we want to join on- Because columns must have unique names, we pass
suffixesto indicate what suffix should be added to column names that are on both the left and the right. - Finally,
howindicates what kind of merge we want to do.
The final result is a data frame with 21,212 rows and 31 columns, allowing us to trace through all committees and any candidates who run them.
How many committees are associated with a candidate? How many are not? How many candidates have more than one committee? How many committees are jointly run by more than one candidate?
To find out how many committees are associated with a candidate, I basically want all of the rows in which the candidate ID is NaN. I can get that with [] to get just that column, and then invoking isna, which returns True or False:
(
df
['CAND_ID']
.isna()
)Now that I have a boolean series, how can I use it to find out how many committees do have a candidate (i.e., isna is False), and how many do not have a candidate (i.e., isna is True)?
I'll run value_counts, and then use set_axis to modify the labels:
(
df
['CAND_ID']
.isna()
.value_counts()
.set_axis(['candidate', 'no candidate'], axis='index')
)The result:
count
candidate 13381
no candidate 7831
Many more committees are associated with candidates than not. But there's still a very large minority that are not.
How many candidates have more than one committee? To find this out, we'll keep only the rows where there is actually a candidate. It's tempting to run dropna, but doing so without any qualifiers removes any row with even one NaN value. Instead, we'll pass the subset keyword argument, which indicates which column(s) Pandas should look at when determining whether there are NaN values.
Then we run a groupby. Here, I grouped by three columns – CAN_ID (the candidate ID), CAND_NAME (the candidate name) and CAND_OFFICE_ST (the state in which the person is running). I could have just used the candidate ID, but that wouldn't have made it easy to know the candidate's name. And I guess I could have just used the ID and the name, but then I decided it might be interesting to see what states they're in, too.
So I ran a groupby using those three columns, calculating on CMTE_ID, and invoking nunique to get the number of distinct committee values for that candidate.
The thing is, groupby normally returns a series. Even if you provide more than one categorical column, it'll still give you a series, albeit one with a multi-index. I wanted, after the grouping, to then use assign. But assign only works on a data frame, and we have a series.I thus invoked to_frame , returning a one-column data frame from a series:
(
df
.dropna(subset=['CAND_ID'])
.groupby(['CAND_ID', 'CAND_NAME', 'CAND_OFFICE_ST'])['CMTE_ID'].nunique().to_frame()
)With my data frame in place, I then used assign to add a new boolean column, more_than_one, indicating whether the candidate had more than one committee. I then did the same thing as before, using a combination of [], value_counts, and set_axis to find the number with one, vs. more than one, committee:
(
df
.dropna(subset=['CAND_ID'])
.groupby(['CAND_ID', 'CAND_NAME', 'CAND_OFFICE_ST'])['CMTE_ID'].nunique().to_frame()
.assign(more_than_one = pd.col('CMTE_ID') > 1)
['more_than_one']
.value_counts()
.set_axis(['just one', 'more than one'], axis='index')
)The result:
count
just one 6775
more than one 310
So yes, about 5 percent of candidates have more than one committee. It's not overwhelming, but it isn't nothing, either.
Finally, I wanted to know how many committees are managed by more than one candidate. I basically ran the mirror image of the previous query, grouping by committee (rather than candidate) and calculating on candidate (rather than committee). I used count , because I wanted the number of candidates supporting the committee:
(
df
.dropna(subset=['CAND_ID'])
.groupby(['CMTE_ID', 'CMTE_NM'])['CAND_ID'].count().to_frame()
.assign(more_than_one = pd.col('CAND_ID') > 1)
['more_than_one']
.value_counts()
.set_axis(['just one', 'more than one'], axis='index')
)The result:
count
just one 7126
more than one 318
Again, we see that the overwhelming majority of committees are owned by a single candidate. However, there are several hundred that are shared.